diff --git a/package-lock.json b/package-lock.json index 775a41a2c..c8df55d1c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4350,6 +4350,14 @@ "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz", "integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==" }, + "@types/multipipe": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/multipipe/-/multipipe-3.0.0.tgz", + "integrity": "sha512-CbhyiQkqlGTacMjyw64y1/jIFBJr0TKPefLyUyXmIhabNv5rA8X1+60ss3TjlLoM3JsK288HVyPwTnO0nHawJA==", + "requires": { + "@types/node": "*" + } + }, "@types/node": { "version": "18.8.4", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.8.4.tgz", @@ -4412,6 +4420,15 @@ "@types/react": "*" } }, + "@types/react-final-form-listeners": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@types/react-final-form-listeners/-/react-final-form-listeners-1.0.0.tgz", + "integrity": "sha512-DovKaDfBqBzPnThFV8mGXL4iAbMCObEFIPUkH7wbD/EN3jZEjpDuPibrqY+w4W1dzicO109YP1LT9KsDDxdYnw==", + "dev": true, + "requires": { + "@types/react": "*" + } + }, "@types/react-is": { "version": "17.0.3", "resolved": "https://registry.npmjs.org/@types/react-is/-/react-is-17.0.3.tgz", @@ -4431,6 +4448,15 @@ "redux": "^4.0.0" } }, + "@types/react-resizable": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@types/react-resizable/-/react-resizable-1.7.4.tgz", + "integrity": "sha512-+xsGkd+Gvb9+8mLR1EyhNN8kBRJcsT1uJF4WpkFpFPIoApX2S89BmJA2RVtMdkhwe6YxV4RbHfaJ3bIdcgHc7g==", + "dev": true, + "requires": { + "@types/react": "*" + } + }, "@types/react-transition-group": { "version": "4.4.4", "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.4.tgz", @@ -4439,6 +4465,24 @@ "@types/react": "*" } }, + "@types/react-virtualized-auto-sizer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@types/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.1.tgz", + "integrity": "sha512-GH8sAnBEM5GV9LTeiz56r4ZhMOUSrP43tAQNSRVxNexDjcNKLCEtnxusAItg1owFUFE6k0NslV26gqVClVvong==", + "dev": true, + "requires": { + "@types/react": "*" + } + }, + "@types/react-window": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/@types/react-window/-/react-window-1.8.5.tgz", + "integrity": "sha512-V9q3CvhC9Jk9bWBOysPGaWy/Z0lxYcTXLtLipkt2cnRj1JOSFNF7wqGpkScSXMgBwC+fnVRg/7shwgddBG5ICw==", + "dev": true, + "requires": { + "@types/react": "*" + } + }, "@types/resolve": { "version": "1.17.1", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz", @@ -5416,6 +5460,11 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, + "base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" + }, "batch": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/batch/-/batch-0.6.1.tgz", @@ -5544,6 +5593,15 @@ "node-int64": "^0.4.0" } }, + "buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "requires": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, "buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -5685,6 +5743,11 @@ "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.2.2.tgz", "integrity": "sha512-cOU9usZw8/dXIXKtwa8pM0OTJQuJkxMN6w30csNRUerHfeQ5R6U3kkU/FtJeIf3M202OHfY2U8ccInBG7/xogA==" }, + "classnames": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.1.tgz", + "integrity": "sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA==" + }, "clean-css": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.0.tgz", @@ -6133,7 +6196,7 @@ "css.escape": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", - "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==" + "integrity": "sha1-QuJ9T6BK4y+TGktNQZH6nN3ul8s=" }, "cssdb": { "version": "6.6.1", @@ -6283,6 +6346,11 @@ "integrity": "sha512-0VNbwmWJDS/G3ySwFSJA3ayhbURMTJLtwM2DTxf9CWondCnh6DTNlO9JgRSq6ibf4eD0lfMJNBxUdEAHHix+bA==", "optional": true }, + "debounce": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz", + "integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==" + }, "debug": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.3.tgz", @@ -6553,6 +6621,43 @@ "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==" }, + "duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha1-ixLauHjA1p4+eJEFFmKjL8a93ME=", + "requires": { + "readable-stream": "^2.0.2" + }, + "dependencies": { + "readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "requires": { + "safe-buffer": "~5.1.0" + } + } + } + }, + "easy-bem": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/easy-bem/-/easy-bem-1.1.1.tgz", + "integrity": "sha512-GJRqdiy2h+EXy6a8E6R+ubmqUM08BK0FWNq41k24fup6045biQ8NXxoXimiwegMQvFFV3t1emADdGNL1TlS61A==" + }, "ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -6571,6 +6676,11 @@ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.68.tgz", "integrity": "sha512-cId+QwWrV8R1UawO6b9BR1hnkJ4EJPCPAr4h315vliHUtVUJDk39Sg1PMNnaWKfj5x+93ssjeJ9LKL6r8LaMiA==" }, + "emitter-component": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/emitter-component/-/emitter-component-1.1.1.tgz", + "integrity": "sha1-Bl4tvtaVm/RwZ57avq95gdEAOrY=" + }, "emittery": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.8.1.tgz", @@ -8083,6 +8193,14 @@ "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } + }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "requires": { + "safe-buffer": "~5.1.0" + } } } }, @@ -8301,6 +8419,11 @@ "harmony-reflect": "^1.4.6" } }, + "ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==" + }, "ignore": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.0.tgz", @@ -10494,7 +10617,7 @@ "load-script": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/load-script/-/load-script-1.0.0.tgz", - "integrity": "sha512-kPEjMFtZvwL9TaZo0uZ2ml+Ye9HUMmPwbYRJ324qF9tqMejwykJ5ggTyvzmrbBeapCAbk98BSbTeovHEEP1uCA==" + "integrity": "sha1-BJGTngvuVkPuSUp+PaPSuscMbKQ=" }, "loader-runner": { "version": "4.3.0", @@ -10527,8 +10650,7 @@ "lodash-es": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", - "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", - "optional": true + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==" }, "lodash.clonedeep": { "version": "4.5.0", @@ -10802,6 +10924,15 @@ "thunky": "^1.0.2" } }, + "multipipe": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/multipipe/-/multipipe-4.0.0.tgz", + "integrity": "sha512-jzcEAzFXoWwWwUbvHCNPwBlTz3WCWe/jPcXSmTfbo/VjRwRTfvLZ/bdvtiTdqCe8d4otCSsPCbhGYcX+eggpKQ==", + "requires": { + "duplexer2": "^0.1.2", + "object-assign": "^4.1.0" + } + }, "nano-css": { "version": "5.3.5", "resolved": "https://registry.npmjs.org/nano-css/-/nano-css-5.3.5.tgz", @@ -10820,8 +10951,7 @@ "nanoclone": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/nanoclone/-/nanoclone-0.2.1.tgz", - "integrity": "sha512-wynEP02LmIbLpcYw8uBKpcfF6dmg2vcpKqxeH5UcoKEYdExslsdUA4ugFauuaeYdTB76ez6gJW8XAZ6CgkXYxA==", - "optional": true + "integrity": "sha512-wynEP02LmIbLpcYw8uBKpcfF6dmg2vcpKqxeH5UcoKEYdExslsdUA4ugFauuaeYdTB76ez6gJW8XAZ6CgkXYxA==" }, "nanoid": { "version": "3.3.4", @@ -12058,8 +12188,7 @@ "property-expr": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.5.tgz", - "integrity": "sha512-IJUkICM5dP5znhCckHSv30Q4b5/JA5enCtkRHYaOVOAocnH/1BQEYTC5NMfT3AVl/iXKdr3aqQbQn9DxyWknwA==", - "optional": true + "integrity": "sha512-IJUkICM5dP5znhCckHSv30Q4b5/JA5enCtkRHYaOVOAocnH/1BQEYTC5NMfT3AVl/iXKdr3aqQbQn9DxyWknwA==" }, "proxy-addr": { "version": "2.0.7", @@ -12346,15 +12475,24 @@ "html-parse-stringify": "^3.0.1" } }, + "react-indiana-drag-scroll": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/react-indiana-drag-scroll/-/react-indiana-drag-scroll-2.1.0.tgz", + "integrity": "sha512-Tj94Dv9PkmoKqc9nxK/dzwhtE8pP7NTxmeHqkD8KN0zad1NNE+/JsvRcK4EdqE6CdA2nMESzqPMvv1AzRXdBew==", + "requires": { + "classnames": "^2.2.6", + "debounce": "^1.2.0", + "easy-bem": "^1.1.1" + } + }, "react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" }, "react-player": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/react-player/-/react-player-2.10.1.tgz", - "integrity": "sha512-ova0jY1Y1lqLYxOehkzbNEju4rFXYVkr5rdGD71nsiG4UKPzRXQPTd3xjoDssheoMNjZ51mjT5ysTrdQ2tEvsg==", + "version": "git+https://arnei@github.com/Arnei/react-player.git#20fe6c061cf7d71d33d764b4a51c9b9bbb614bf6", + "from": "git+https://arnei@github.com/Arnei/react-player.git#20fe6c061cf7d71d33d764b4a51c9b9bbb614bf6", "requires": { "deepmerge": "^4.0.0", "load-script": "^1.0.0", @@ -12381,6 +12519,15 @@ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz", "integrity": "sha512-F27qZr8uUqwhWZboondsPx8tnC3Ct3SxZA3V5WyEvujRyyNv0VYPhoBg1gZ8/MV5tubQp76Trw8lTv9hzRBa+A==" }, + "react-resizable": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/react-resizable/-/react-resizable-3.0.4.tgz", + "integrity": "sha512-StnwmiESiamNzdRHbSSvA65b0ZQJ7eVQpPusrSmcpyGKzC0gojhtO62xxH6YOBmepk9dQTBi9yxidL3W4s3EBA==", + "requires": { + "prop-types": "15.x", + "react-draggable": "^4.0.3" + } + }, "react-scripts": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/react-scripts/-/react-scripts-5.0.1.tgz", @@ -12497,6 +12644,20 @@ "tslib": "^2.1.0" } }, + "react-virtualized-auto-sizer": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.6.tgz", + "integrity": "sha512-7tQ0BmZqfVF6YYEWcIGuoR3OdYe8I/ZFbNclFlGOC3pMqunkYF/oL30NCjSGl9sMEb17AnzixDz98Kqc3N76HQ==" + }, + "react-window": { + "version": "1.8.7", + "resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.7.tgz", + "integrity": "sha512-JHEZbPXBpKMmoNO1bNhoXOOLg/ujhL/BU4IqVU9r8eQPcy5KQnGHIHDRkJ0ns9IM5+Aq5LNwt3j8t3tIrePQzA==", + "requires": { + "@babel/runtime": "^7.0.0", + "memoize-one": ">=3.1.1 <6" + } + }, "readable-stream": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", @@ -13213,6 +13374,14 @@ "wbuf": "^1.7.3" } }, + "split2": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/split2/-/split2-3.2.2.tgz", + "integrity": "sha512-9NThjpgZnifTkJpzTZ7Eue85S49QwpNhZTq6GRJwObb6jnLFNGB7Qm73V5HewTROPyxD0C29xqmaI68bQtV+hg==", + "requires": { + "readable-stream": "^3.0.0" + } + }, "sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -13319,6 +13488,14 @@ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==" }, + "stream": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stream/-/stream-0.0.2.tgz", + "integrity": "sha1-f1Nj8Ff2WSxVlfALyAon9c7B8O8=", + "requires": { + "emitter-component": "^1.1.1" + } + }, "string-length": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", @@ -13415,11 +13592,18 @@ } }, "string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", "requires": { - "safe-buffer": "~5.1.0" + "safe-buffer": "~5.2.0" + }, + "dependencies": { + "safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" + } } }, "stringify-object": { @@ -13487,6 +13671,24 @@ "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.0.13.tgz", "integrity": "sha512-xGPXiFVl4YED9Jh7Euv2V220mriG9u4B2TA6Ybjc1catrstKD2PpIdU3U0RKpkVBC2EhmL/F0sPCr9vrFTNRag==" }, + "subtitle": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/subtitle/-/subtitle-4.1.2.tgz", + "integrity": "sha512-GbzhdUAwCGNllWiGjlBE4Bg0bCENfV3dnGEUejC+PA1My9uAD7wC/rWnr4/eIMRtB/Yz6OI8WXMisPQpteQIcA==", + "requires": { + "@types/multipipe": "^3.0.0", + "multipipe": "^4.0.0", + "split2": "^3.2.2", + "strip-bom": "^4.0.0" + }, + "dependencies": { + "strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==" + } + } + }, "supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -13779,8 +13981,7 @@ "toposort": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz", - "integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==", - "optional": true + "integrity": "sha1-riF2gXXRVZ1IvvNUILL0li8JwzA=" }, "tough-cookie": { "version": "4.0.0", @@ -14051,7 +14252,7 @@ "void-elements": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", - "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==" + "integrity": "sha1-YU9/v42AHwu18GYfWy9XhXUOTwk=" }, "w3c-hr-time": { "version": "1.0.2", @@ -14317,6 +14518,11 @@ "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==" }, + "webvtt-parser": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/webvtt-parser/-/webvtt-parser-2.2.0.tgz", + "integrity": "sha512-FzmaED+jZyt8SCJPTKbSsimrrnQU8ELlViE1wuF3x1pgiQUM8Llj5XWj2j/s6Tlk71ucPfGSMFqZWBtKn/0uEA==" + }, "whatwg-encoding": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-1.0.5.tgz", diff --git a/package.json b/package.json index 83d9ffc15..36676a24e 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "@types/react-dom": "^18.0.3", "@types/react-redux": "^7.1.24", "@types/yup": "^0.32.0", + "buffer": "^6.0.3", "customize-cra": "^1.0.0", "deepmerge": "^4.2.2", "emotion": "^11.0.0", @@ -48,14 +49,21 @@ "react-final-form-listeners": "^1.0.3", "react-hotkeys": "^2.0.0", "react-i18next": "^11.18.3", - "react-player": "^2.10.1", + "react-indiana-drag-scroll": "^2.1.0", + "react-player": "git+https://arnei@github.com/Arnei/react-player.git#20fe6c061cf7d71d33d764b4a51c9b9bbb614bf6", "react-redux": "^7.2.8", + "react-resizable": "^3.0.4", "react-scripts": "5.0.1", "react-select": "^5.4.0", "react-use": "^17.4.0", + "react-virtualized-auto-sizer": "^1.0.6", + "react-window": "^1.8.7", "redux": "^4.2.0", "standardized-audio-context": "^25.3.32", - "typescript": "^4.8.4" + "stream": "0.0.2", + "subtitle": "^4.1.2", + "typescript": "^4.8.4", + "webvtt-parser": "^2.2.0" }, "scripts": { "start": "REACT_APP_GIT_SHA=`git rev-parse HEAD` REACT_APP_BUILD_DATE=\"`date`\" react-app-rewired start", @@ -85,10 +93,14 @@ }, "devDependencies": { "@playwright/test": "^1.26.1", + "@redux-devtools/core": "^3.13.1", "@types/lodash": "^4.14.186", "@types/luxon": "^3.0.1", "@types/react-beforeunload": "^2.1.1", - "@redux-devtools/core": "^3.13.1", + "@types/react-final-form-listeners": "^1.0.0", + "@types/react-resizable": "^1.7.4", + "@types/react-virtualized-auto-sizer": "^1.0.1", + "@types/react-window": "^1.8.5", "use-resize-observer": "^9.0.2" } } diff --git a/public/editor-settings.toml b/public/editor-settings.toml index da61c399d..bc9d71c3e 100644 --- a/public/editor-settings.toml +++ b/public/editor-settings.toml @@ -27,6 +27,8 @@ id = 'ID-dual-stream-demo' # Default: Current server url = 'https://develop.opencast.org' + + # Username, used for HTTP basic authentication against Opencast. # Not defining this will work just fine if integrated in Opencast. # Type: string | undefined @@ -88,6 +90,37 @@ password = "opencast" # Default: true #show = true +#### +# Subtitles +## + +[subtitles] + +# If the subtitle editor appears in the main menu +# Before you enable the subtitle editor, you should define some languages +# under "subtitles.languages" +# Type: boolean +# Default: false +#show = false + +# The main flavor of the subtitle tracks in Opencast +# No other tracks should have the same main flavor as subtitle tracks +# Type: string +# Default: "captions" +#mainFlavor = "captions" + +## A list of languages for which subtitles can be created +# +# Example: +[subtitles.languages] +# "captions/source+de" = "Deutsch" +# "captions/source+en" = "English" + +# Specify the default video in the subtitle video player by flavor +# If not specified, the editor will decide on a default by itself +#[subtitles.defaultVideoFlavor] +# "type" = "presentation" +# "subtype" = "preview" #### # Thumbnail Selection diff --git a/src/config.ts b/src/config.ts index 01806c67c..6303cd79c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -4,9 +4,13 @@ * - GET parameters * and exports them. * Code was largely adapted from https://github.com/elan-ev/opencast-studio/blob/master/src/settings.js (January 11th, 2021) + * + * Also does some global hotkey configuration */ import parseToml from '@iarna/toml/parse-string'; import deepmerge from 'deepmerge'; +import { configure } from 'react-hotkeys'; +import { Flavor } from './types'; /** * Local constants @@ -45,6 +49,12 @@ interface iSettings { thumbnail: { show: boolean, simpleMode: boolean, + }, + subtitles: { + show: boolean, + mainFlavor: string, + languages: { [key: string]: string } | undefined, + defaultVideoFlavor: Flavor | undefined, } } @@ -72,6 +82,12 @@ var defaultSettings: iSettings = { thumbnail: { show: false, simpleMode: false, + }, + subtitles: { + show: false, + mainFlavor: "captions", + languages: undefined, + defaultVideoFlavor: undefined, } } var configFileSettings: iSettings @@ -122,6 +138,25 @@ export const init = async () => { // Combine results settings = merge.all([defaultSettings, configFileSettings, urlParameterSettings]) as iSettings; + + // Configure hotkeys + configure({ + ignoreTags: [], // Do not ignore hotkeys when focused on a textarea, input, select + ignoreEventsCondition: (e: any) => { + // Ignore hotkeys when focused on a textarea, input, select IF that hotkey is expected to perform + // a certain function in that element that is more important than any hotkey function + // (e.g. you need "Space" in a textarea to create whitespaces, not play/pause videos) + if (e.target && e.target.tagName) { + const tagname = e.target.tagName.toLowerCase() + if ((tagname === "textarea" || tagname === "input" || tagname === "select") + && (!e.altKey && !e.ctrlKey) + && (e.code === "Space" || e.code === "ArrowLeft" || e.code === "ArrowRight" || e.code === "ArrowUp" || e.code === "ArrowDown")) { + return true + } + } + return false + }, + }) }; /** @@ -262,6 +297,16 @@ const types = { throw new Error("is not a boolean"); } }, + 'map': (v: any, allowParse: any) => { + for (let key in v) { + if (typeof key !== 'string') { + throw new Error("is not a string, but should be"); + } + if (typeof v[key] !== 'string') { + throw new Error("is not a string, but should be"); + } + } + }, 'objectsWithinObjects': (v: any, allowParse: any) => { for (let catalogName in v) { if (typeof catalogName !== 'string') { @@ -311,6 +356,12 @@ const SCHEMA = { trackSelection: { show : types.boolean, }, + subtitles: { + show: types.boolean, + mainFlavor: types.string, + languages: types.map, + defaultVideoFlavor: types.map, + }, thumbnail: { show : types.boolean, simpleMode: types.boolean, diff --git a/src/cssStyles.tsx b/src/cssStyles.tsx index 436b58065..846a10b38 100644 --- a/src/cssStyles.tsx +++ b/src/cssStyles.tsx @@ -131,6 +131,37 @@ export const backOrContinueStyle = css(({ ...(flexGapReplacementStyle(20, false)), })) +/** + * CSS for big buttons in a dynamic grid + */ + export const tileButtonStyle = (theme: Theme) => css({ + width: '250px', + height: '220px', + display: 'flex', + flexDirection: 'column' as const, + fontSize: "x-large", + ...(flexGapReplacementStyle(30, false)), + boxShadow: `${theme.boxShadow}`, + background: `${theme.element_bg}`, + alignItems: 'unset', // overwrite from basicButtonStyle to allow for textOverflow to work + placeSelf: 'center', +}); + +/** + * CSS for disabling the animation of the basicButtonStyle + */ +export const disableButtonAnimation = css({ + "&:hover": { + transform: 'none', + }, + "&:focus": { + transform: 'none', + }, + "&:active": { + transform: 'none', + }, +}) + /** * CSS for a title */ diff --git a/src/globalKeys.ts b/src/globalKeys.ts index c7d8db6da..d2ee8da18 100644 --- a/src/globalKeys.ts +++ b/src/globalKeys.ts @@ -1,13 +1,21 @@ +import { ApplicationKeyMap, ExtendedKeyMapOptions, KeyMapOptions, MouseTrapKeySequence } from 'react-hotkeys'; /** * Contains mappings for special keyboard controls, beyond what is usually expected of a webpage * Learn more about keymaps at https://github.com/greena13/react-hotkeys#defining-key-maps (12.03.2021) + * + * Additional global configuration settins are placed in './config.ts' + * (They are not placed here, because that somehow makes the name fields of keymaps undefined for some reason) + * + * If you add a new keyMap, be sure to add it to the getAllHotkeys function */ import { KeyMap } from "react-hotkeys"; import { isMacOs } from 'react-device-detect'; // Groups for displaying hotkeys in the overview page +const groupVideoPlayer = "keyboardControls.groupVideoPlayer" const groupCuttingView = 'keyboardControls.groupCuttingView' const groupCuttingViewScrubber = 'keyboardControls.groupCuttingViewScrubber' +const groupSubtitleList = "keyboardControls.groupSubtitleList" /** * Helper function that rewrites keys based on the OS @@ -21,6 +29,24 @@ const rewriteKeys = (key: string) => { return newKey } +/** + * (Semi-) global map for video player controls + */ +export const videoPlayerKeyMap: KeyMap = { + preview: { + name: "video.previewButton", + sequence: rewriteKeys("Control+Alt+p"), + action: "keydown", + group: groupVideoPlayer, + }, + play: { + name: "keyboardControls.videoPlayButton", + sequence: rewriteKeys("Space"), + action: "keydown", + group: groupVideoPlayer, + }, +} + /** * (Semi-) global map for the buttons in the cutting view */ @@ -49,18 +75,6 @@ export const cuttingKeyMap: KeyMap = { action: "keydown", group: groupCuttingView, }, - preview: { - name: "video.previewButton", - sequence: rewriteKeys("Control+Alt+p"), - action: "keydown", - group: groupCuttingView, - }, - play: { - name: "keyboardControls.videoPlayButton", - sequence: rewriteKeys("Space"), - action: "keydown", - group: groupCuttingView, - }, } /** @@ -100,3 +114,69 @@ export const scrubberKeyMap: KeyMap = { group: groupCuttingViewScrubber, }, } + +export const subtitleListKeyMap: KeyMap = { + addAbove: { + name: "subtitleList.addSegmentAbove", + sequence: rewriteKeys("Control+Alt+q"), + action: "keydown", + group: groupSubtitleList, + }, + addBelow: { + name: "subtitleList.addSegmentBelow", + sequence: rewriteKeys("Control+Alt+a"), + action: "keydown", + group: groupSubtitleList, + }, + jumpAbove: { + name: "subtitleList.jumpToSegmentAbove", + sequence: rewriteKeys("Control+Alt+w"), + action: "keydown", + group: groupSubtitleList, + }, + jumpBelow: { + name: "subtitleList.jumpToSegmentBelow", + sequence: rewriteKeys("Control+Alt+s"), + action: "keydown", + group: groupSubtitleList, + }, + delete : { + name: "subtitleList.deleteSegment", + sequence: rewriteKeys("Control+Alt+d"), + action: "keydown", + group: groupSubtitleList, + } +} + +/** + * Combines all keyMaps into a single list of keys for KeyboardControls to display + * Placing this under the keyMaps is important, else the translation hooks won't happen + */ + export const getAllHotkeys = () => { + const allKeyMaps = [videoPlayerKeyMap, cuttingKeyMap, scrubberKeyMap, subtitleListKeyMap] + const allKeys : ApplicationKeyMap = {} + + for (const keyMap of allKeyMaps) { + for (const [key, value] of Object.entries(keyMap)) { + + // Parse sequences + let sequences : KeyMapOptions[] = [] + if ((value as ExtendedKeyMapOptions).sequences !== undefined) { + for (const sequence of (value as ExtendedKeyMapOptions).sequences) { + sequences.push({sequence: sequence as MouseTrapKeySequence, action: (value as ExtendedKeyMapOptions).action}) + } + } else { + sequences = [ {sequence: (value as ExtendedKeyMapOptions).sequence, action: (value as ExtendedKeyMapOptions).action } ] + } + + // Create new key + allKeys[key] = { + name: (value as ExtendedKeyMapOptions).name, + group: (value as ExtendedKeyMapOptions).group, + sequences: sequences, + } + } + } + + return allKeys +} diff --git a/src/i18n/locales/en-US.json b/src/i18n/locales/en-US.json index 7b38d7a4f..c2f1d8552 100644 --- a/src/i18n/locales/en-US.json +++ b/src/i18n/locales/en-US.json @@ -3,6 +3,7 @@ "cutting-button": "Cutting", "finish-button": "Finish", "select-tracks-button": "Tracks", + "subtitles-button": "Subtitles", "thumbnail-button": "Thumbnail", "metadata-button": "Metadata", "keyboard-controls-button": "Keyboard Controls", @@ -226,12 +227,44 @@ "cannotDeleteTrackTooltip": "Cannot remove this track from publication." }, + "subtitles": { + "selectSubtitleButton-tooltip": "Edit subtitles for {{title}}", + "selectSubtitleButton-tooltip-aria": "Select {{title}} for subtitle editing", + "createSubtitleButton-tooltip": "Opens a dialog for creating new subtitles", + "createSubtitleButton-clicked-tooltip-aria": "Contains a dialog for creating new subtitles", + "createSubtitleButton-createButton": "Create", + "createSubtitleButton-createButton-tooltip": "Start a new subtitle file with the chosen title.", + "createSubtitleDropdown-label": "Pick a language", + "backButton": "Back", + "backButton-tooltip": "Return to subtitle selection", + "editTitle": "Subtitle Editor - {{title}}", + "editTitle-loading": "Loading" + }, + + "subtitleList": { + "startTime-tooltip": "Beginning of the segment", + "startTime-tooltip-aria": "Beginning at", + "endTime-tooltip": "End of the segment", + "endTime-tooltip-aria": "Ending at", + "addSegmentAbove": "Add segment above", + "addSegmentBelow": "Add segment below", + "jumpToSegmentAbove": "Jump to segment above", + "jumpToSegmentBelow": "Jump to segment below", + "deleteSegment": "Delete segment" + }, + + "subtitleVideoArea": { + "selectVideoLabel": "Video Flavors" + }, + "keyboardControls": { "header": "Keyboard Controls", "defaultGroupName": "General", "missingLabel": "Unknown", - "groupCuttingView": "Cutting View", - "groupCuttingViewScrubber": "Cutting View - Scrubber", + "groupVideoPlayer": "Video Player", + "groupCuttingView": "Cutting", + "groupCuttingViewScrubber": "Scrubbing", + "groupSubtitleList": "Subtitles", "sequenceSeperator": "or", "genericError": "Failed to load overview", "videoPlayButton": "Play/Pause Video", diff --git a/src/main/CuttingActions.tsx b/src/main/CuttingActions.tsx index 20f9a3183..06c0c5605 100644 --- a/src/main/CuttingActions.tsx +++ b/src/main/CuttingActions.tsx @@ -19,8 +19,6 @@ import { cut, markAsDeletedOrAlive, selectIsCurrentSegmentAlive, mergeLeft, mergeRight } from '../redux/videoSlice' import { GlobalHotKeys, KeySequence, KeyMapOptions } from "react-hotkeys"; -import { selectMainMenuState } from "../redux/mainMenuSlice"; -import { MainMenuStateNames } from "../types"; import { cuttingKeyMap } from "../globalKeys"; import { ActionCreatorWithoutPayload } from "@reduxjs/toolkit"; @@ -37,7 +35,6 @@ const CuttingActions: React.FC<{}> = () => { // Init redux variables const dispatch = useDispatch(); - const mainMenuState = useSelector(selectMainMenuState) /** * General action callback for cutting actions @@ -78,7 +75,7 @@ const CuttingActions: React.FC<{}> = () => { }) return ( - +
= () => { const FinishMenuButton: React.FC<{iconName: IconDefinition, stateName: finish["value"]}> = ({iconName, stateName}) => { const { t } = useTranslation(); - + const theme = useSelector(selectTheme) const dispatch = useDispatch(); - const theme = useSelector(selectTheme); const finish = () => { dispatch(setState(stateName)); dispatch(setPageNumber(1)) } - const finishMenuButtonStyle = css({ - width: '250px', - height: '220px', - flexDirection: 'column' as const, - fontSize: "x-large", - ...(flexGapReplacementStyle(30, false)), - boxShadow: `${theme.boxShadow}`, - background: `${theme.element_bg}`, - }); - var buttonString; switch(stateName) { case "Save changes": @@ -79,7 +68,7 @@ const FinishMenuButton: React.FC<{iconName: IconDefinition, stateName: finish["v } return ( -
) => { if (event.key === " " || event.key === "Enter") { @@ -91,8 +80,4 @@ const FinishMenuButton: React.FC<{iconName: IconDefinition, stateName: finish["v ); }; - - - - export default FinishMenu; diff --git a/src/main/KeyboardControls.tsx b/src/main/KeyboardControls.tsx index e0fb7df36..d3ae3dc1e 100644 --- a/src/main/KeyboardControls.tsx +++ b/src/main/KeyboardControls.tsx @@ -1,10 +1,11 @@ import { css } from "@emotion/react"; import React from "react"; -import { getApplicationKeyMap, KeyMapDisplayOptions } from 'react-hotkeys'; +import { KeyMapDisplayOptions } from 'react-hotkeys'; import { useTranslation, Trans} from "react-i18next"; import { useSelector } from "react-redux"; import { flexGapReplacementStyle } from "../cssStyles"; +import { getAllHotkeys } from "../globalKeys"; import { selectTheme } from "../redux/themeSlice"; import i18next from "./../i18n/config"; @@ -103,7 +104,7 @@ const KeyboardControls: React.FC<{}> = () => { const { t } = useTranslation(); - const keyMap = getApplicationKeyMap(); + const keyMap = getAllHotkeys() const groupsStyle = css({ display: 'flex', diff --git a/src/main/MainContent.tsx b/src/main/MainContent.tsx index 52112b463..8b8562fbd 100644 --- a/src/main/MainContent.tsx +++ b/src/main/MainContent.tsx @@ -5,6 +5,7 @@ import Timeline from './Timeline'; import CuttingActions from './CuttingActions'; import Metadata from './Metadata'; import TrackSelection from './TrackSelection'; +import Subtitle from "./Subtitle"; import Finish from "./Finish" import KeyboardControls from "./KeyboardControls"; @@ -22,6 +23,10 @@ import { flexGapReplacementStyle } from "../cssStyles"; import { useBeforeunload } from 'react-beforeunload'; import { selectHasChanges as videoSelectHasChanges } from "../redux/videoSlice"; import { selectHasChanges as metadataSelectHasChanges} from "../redux/metadataSlice"; +import { + selectIsPlaying, selectCurrentlyAt, + setIsPlaying, setCurrentlyAt, setClickTriggered, +} from '../redux/videoSlice' import { selectTheme } from "../redux/themeSlice"; import ThemeSwitcher from "./ThemeSwitcher"; import Thumbnail from "./Thumbnail"; @@ -44,19 +49,8 @@ const MainContent: React.FC<{}> = () => { } }); - // Return display 'flex' if state is currently active - // also keep track if any state was activated - var stateActive = false; - let displayState = (state: MainMenuStateNames): object => { - if (mainMenuState === state) { - stateActive = true; - return { display: "flex" }; - } - return { display: 'none' }; - } - const cuttingStyle = css({ - ...displayState(MainMenuStateNames.cutting), + display: 'flex', flexDirection: 'column' as const, justifyContent: 'space-around', ...(flexGapReplacementStyle(20, false)), @@ -66,7 +60,9 @@ const MainContent: React.FC<{}> = () => { }) const metadataStyle = css({ - ...displayState(MainMenuStateNames.metadata), + display: 'flex', + // flexDirection: 'column' as const, + // justifyContent: 'space-around', ...(flexGapReplacementStyle(20, false)), paddingRight: '20px', paddingLeft: '161px', @@ -74,7 +70,7 @@ const MainContent: React.FC<{}> = () => { }) const trackSelectStyle = css({ - ...displayState(MainMenuStateNames.trackSelection), + display: 'flex', flexDirection: 'column' as const, alignContent: 'space-around', ...(flexGapReplacementStyle(20, false)), @@ -83,8 +79,17 @@ const MainContent: React.FC<{}> = () => { background: `${theme.background}`, }) + const subtitleSelectStyle = css({ + display: 'flex', + flexDirection: 'column' as const, + justifyContent: 'space-around', + paddingRight: '20px', + paddingLeft: '161px', + height: '100%', + }) + const thumbnailSelectStyle = css({ - ...displayState(MainMenuStateNames.thumbnail), + display: 'flex', flexDirection: 'column' as const, alignContent: 'space-around', ...(flexGapReplacementStyle(20, false)), @@ -94,7 +99,7 @@ const MainContent: React.FC<{}> = () => { }) const finishStyle = css({ - ...displayState(MainMenuStateNames.finish), + display: 'flex', flexDirection: 'column' as const, justifyContent: 'space-around', ...(flexGapReplacementStyle(20, false)), @@ -105,8 +110,9 @@ const MainContent: React.FC<{}> = () => { }) const keyboardControlsStyle = css({ - flexDirection: 'column' as const, - ...displayState(MainMenuStateNames.keyboardControls), + display: 'flex', + // flexDirection: 'column' as const, + // justifyContent: 'space-around', ...(flexGapReplacementStyle(20, false)), paddingRight: '20px', paddingLeft: '161px', @@ -114,42 +120,84 @@ const MainContent: React.FC<{}> = () => { }) const defaultStyle = css({ - display: stateActive ? 'none' : 'flex', + display: 'flex', flexDirection: 'column' as const, alignItems: 'center', padding: '20px', ...(flexGapReplacementStyle(20, false)), }) - return ( -
-
+ const render = () => { + if (mainMenuState === MainMenuStateNames.cutting) { + return ( +
-
+ +
+ ) + } else if (mainMenuState === MainMenuStateNames.metadata) { + return ( +
-
-
+
+ ) + } else if (mainMenuState === MainMenuStateNames.trackSelection) { + return ( +
-
-
+
+ ) + } else if (mainMenuState === MainMenuStateNames.subtitles) { + return ( +
+ +
+ ) + } else if (mainMenuState === MainMenuStateNames.thumbnail) { + return ( +
-
-
- -
-
- - -
+
+ ) + } else if (mainMenuState === MainMenuStateNames.finish) { + return ( +
+ +
+ ) + } else if (mainMenuState === MainMenuStateNames.keyboardControls) { + return ( +
+ + +
+ ) + } else {
Placeholder
+ } + } + + return ( +
+ {render()}
); }; +const CuttingTimeline : React.FC<{}> = () => { + return ( + + ); +} + export default MainContent; diff --git a/src/main/MainMenu.tsx b/src/main/MainMenu.tsx index 0234b5b6f..b580fd4f0 100644 --- a/src/main/MainMenu.tsx +++ b/src/main/MainMenu.tsx @@ -3,7 +3,8 @@ import React from "react"; import { css } from '@emotion/react' import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faCut, faFilm, faListUl, faPhotoVideo, faSignOutAlt, faGear, IconDefinition } from "@fortawesome/free-solid-svg-icons"; +import { faCut, faFilm, faListUl, faPhotoVideo, faSignOutAlt, faGear } from "@fortawesome/free-solid-svg-icons"; +import { faClosedCaptioning } from "@fortawesome/free-regular-svg-icons"; import { useDispatch, useSelector } from 'react-redux' import { setState, selectMainMenuState, mainMenu } from '../redux/mainMenuSlice' @@ -18,6 +19,7 @@ import './../i18n/config'; import { useTranslation } from 'react-i18next'; import { resetPostRequestState as metadataResetPostRequestState } from "../redux/metadataSlice"; import { resetPostRequestState } from "../redux/workflowPostSlice"; +import { setIsDisplayEditView } from "../redux/subtitleSlice"; import { selectTheme } from "../redux/themeSlice"; @@ -58,16 +60,22 @@ const MainMenu: React.FC<{}> = () => { ariaLabelText={t(MainMenuStateNames.metadata)} />} {settings.trackSelection.show && } + {settings.subtitles.show && } {settings.thumbnail.show && } = () => { }; interface mainMenuButtonInterface { - iconName: IconDefinition, + iconName: any, // Unfortunately, icons from different packages don't share the same IconDefinition type. Works anyway. stateName: mainMenu["value"], bottomText: string, ariaLabelText: string; @@ -113,6 +121,9 @@ const MainMenuButton: React.FC = ({iconName, stateName, if (stateName === MainMenuStateNames.finish) { dispatch(setPageNumber(0)) } + if (stateName === MainMenuStateNames.subtitles) { + dispatch(setIsDisplayEditView(false)) + } // Halt ongoing events dispatch(setIsPlaying(false)) // Reset states diff --git a/src/main/Save.tsx b/src/main/Save.tsx index 87911537e..e6d128703 100644 --- a/src/main/Save.tsx +++ b/src/main/Save.tsx @@ -20,6 +20,9 @@ import './../i18n/config'; import { useTranslation } from 'react-i18next'; import { postMetadata, selectPostError, selectPostStatus, setHasChanges as metadataSetHasChanges, selectHasChanges as metadataSelectHasChanges } from "../redux/metadataSlice"; +import { selectSubtitles } from "../redux/subtitleSlice"; +import { serializeSubtitle } from "../util/utilityFunctions"; +import { Flavor } from "../types"; import { selectTheme } from "../redux/themeSlice"; /** @@ -102,6 +105,7 @@ export const SaveButton: React.FC<{}> = () => { const segments = useSelector(selectSegments) const tracks = useSelector(selectTracks) + const subtitles = useSelector(selectSubtitles) const workflowStatus = useSelector(selectStatus); const metadataStatus = useSelector(selectPostStatus); const theme = useSelector(selectTheme); @@ -131,6 +135,17 @@ export const SaveButton: React.FC<{}> = () => { } } + const prepareSubtitles = () => { + const subtitlesForPosting = [] + + for (const identifier in subtitles) { + let flavor: Flavor = {type: identifier.split("/")[0], subtype: identifier.split("/")[1]} + subtitlesForPosting.push({flavor: flavor, subtitle: serializeSubtitle(subtitles[identifier])}) + + } + return subtitlesForPosting + } + // Dispatches first save request // Subsequent save requests should be wrapped in useEffect hooks, // so they are only sent after the previous one has finished @@ -146,6 +161,7 @@ export const SaveButton: React.FC<{}> = () => { dispatch(postVideoInformation({ segments: segments, tracks: tracks, + subtitles: prepareSubtitles() })) } diff --git a/src/main/Subtitle.tsx b/src/main/Subtitle.tsx new file mode 100644 index 000000000..53746659b --- /dev/null +++ b/src/main/Subtitle.tsx @@ -0,0 +1,33 @@ +import React from "react"; +import SubtitleEditor from "./SubtitleEditor"; +import SubtitleSelect from "./SubtitleSelect"; +import { useSelector } from "react-redux"; +import { selectIsDisplayEditView } from "../redux/subtitleSlice"; + +/** + * A container for the various subtitle views + */ +const Subtitle : React.FC<{}> = () => { + + const displayEditView = useSelector(selectIsDisplayEditView) + + const render = () => { + if (!displayEditView) { + return ( + + ) + } else { + return ( + + ) + } + } + + return ( + <> + {render()} + + ); +} + +export default Subtitle; diff --git a/src/main/SubtitleEditor.tsx b/src/main/SubtitleEditor.tsx new file mode 100644 index 000000000..0e3c259a9 --- /dev/null +++ b/src/main/SubtitleEditor.tsx @@ -0,0 +1,176 @@ +import React, { useEffect, useState } from "react"; +import { css } from "@emotion/react"; +import { basicButtonStyle, flexGapReplacementStyle } from "../cssStyles"; +import { faChevronLeft } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { + selectCaptionTrackByFlavor, +} from '../redux/videoSlice' +import { useDispatch, useSelector } from "react-redux"; +import { SubtitleCue } from "../types"; +import SubtitleListEditor from "./SubtitleListEditor"; +import { + setIsDisplayEditView, + selectSelectedSubtitleByFlavor, + selectSelectedSubtitleFlavor, + setSubtitle +} from '../redux/subtitleSlice' +import { settings } from "../config"; +import SubtitleVideoArea from "./SubtitleVideoArea"; +import SubtitleTimeline from "./SubtitleTimeline"; +import { useTranslation } from "react-i18next"; +import { selectTheme } from "../redux/themeSlice"; +import { parseSubtitle } from "../util/utilityFunctions"; + +/** + * Displays an editor view for a selected subtitle file + */ + const SubtitleEditor : React.FC<{}> = () => { + + const { t } = useTranslation(); + + const dispatch = useDispatch() + const [getError, setGetError] = useState(undefined) + const subtitle : SubtitleCue[] = useSelector(selectSelectedSubtitleByFlavor) + const selectedFlavor = useSelector(selectSelectedSubtitleFlavor) + const captionTrack = useSelector(selectCaptionTrackByFlavor(selectedFlavor)) + + // Prepare subtitle in redux + useEffect(() => { + // Parse subtitle data from Opencast + if (subtitle === undefined && captionTrack !== undefined && captionTrack.subtitle !== undefined && selectedFlavor) { + try { + dispatch(setSubtitle({identifier: selectedFlavor, subtitles: parseSubtitle(captionTrack.subtitle)})) + } catch (error) { + if (error instanceof Error) { + setGetError(error.message) + } else { + setGetError(String(error)) + } + } + + // Or create a new subtitle instead + } else if (subtitle === undefined && captionTrack === undefined && selectedFlavor) { + // Create an empty subtitle + dispatch(setSubtitle({identifier: selectedFlavor, subtitles: []})) + } + }, [dispatch, captionTrack, subtitle, selectedFlavor]) + + const getTitle = () => { + return (settings.subtitles.languages !== undefined && subtitle && selectedFlavor) ? + settings.subtitles.languages[selectedFlavor] : t("subtitles.editTitle-loading") + } + + const subtitleEditorStyle = css({ + display: 'flex', + flexDirection: 'column', + paddingRight: '20px', + paddingLeft: '20px', + gap: '20px', + height: '100%', + }) + + const headerRowStyle = css({ + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + width: '100%', + }) + + const subAreaStyle = css({ + display: 'flex', + flexDirection: 'row', + flexGrow: 1, // No fixed height, fill available space + justifyContent: 'space-between', + alignItems: 'top', + width: '100%', + paddingTop: '10px', + paddingBottom: '10px', + ...(flexGapReplacementStyle(30, true)), + borderBottom: '1px solid #BBB', + }) + + // Taken from VideoHeader. Maybe generalize this to cssStyles.tsx + const titleStyle = css({ + display: 'inline-block', + padding: '15px', + overflow: 'hidden', + whiteSpace: "nowrap", + textOverflow: 'ellipsis', + maxWidth: '500px', + }) + + const titleStyleBold = css({ + fontWeight: 'bold', + fontSize: '24px', + verticalAlign: '-2.5px', + }) + + const render = () => { + if (getError !== undefined) { + return ( + {"Subtitle Parsing Error(s): " + getError} + ) + } else { + return( + <> +
+ +
+ {t("subtitles.editTitle", {title: getTitle()})} +
+
+
+
+ + +
+ + + ) + } + } + + return ( +
+ {render()} +
+ ); +} + + +/** + * Takes you to a different page + */ + export const BackButton : React.FC<{}> = () => { + + const { t } = useTranslation(); + const theme = useSelector(selectTheme) + const dispatch = useDispatch(); + + const backButtonStyle = css({ + width: '50px', + height: '10px', + padding: '16px', + boxShadow: `${theme.boxShadow}`, + background: `${theme.element_bg}`, + justifyContent: 'space-around' + }) + + return ( +
dispatch(setIsDisplayEditView(false)) } + onKeyDown={(event: React.KeyboardEvent) => { if (event.key === " " || event.key === "Enter") { + dispatch(setIsDisplayEditView(false)) + }}}> + + {t("subtitles.backButton")} +
+ ); +} + +export default SubtitleEditor diff --git a/src/main/SubtitleListEditor.tsx b/src/main/SubtitleListEditor.tsx new file mode 100644 index 000000000..2dc5107ee --- /dev/null +++ b/src/main/SubtitleListEditor.tsx @@ -0,0 +1,592 @@ +import { css, SerializedStyles } from "@emotion/react" +import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons" +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" +import { memoize } from "lodash" +import React, { useRef } from "react" +import { useEffect, useState } from "react" +import { HotKeys } from "react-hotkeys" +import { useTranslation } from "react-i18next" +import { shallowEqual, useDispatch, useSelector } from "react-redux" +import { basicButtonStyle, flexGapReplacementStyle } from "../cssStyles" +import { subtitleListKeyMap } from "../globalKeys" +import { addCueAtIndex, + removeCue, + selectFocusSegmentId, + selectFocusSegmentTriggered, + selectFocusSegmentTriggered2, + selectSelectedSubtitleByFlavor, + selectSelectedSubtitleFlavor, + setCueAtIndex, + setCurrentlyAt, + setFocusSegmentTriggered, + setFocusSegmentTriggered2, + setFocusToSegmentAboveId, + setFocusToSegmentBelowId +} from "../redux/subtitleSlice" +import { SubtitleCue } from "../types" +import { convertMsToReadableString } from "../util/utilityFunctions" +import { VariableSizeList } from "react-window" +import { CSSProperties } from "react" +import AutoSizer from "react-virtualized-auto-sizer" +import { selectTheme } from "../redux/themeSlice" + +/** + * Displays everything needed to edit subtitles + */ +const SubtitleListEditor : React.FC<{}> = () => { + + const dispatch = useDispatch() + + const subtitle = useSelector(selectSelectedSubtitleByFlavor) + const subtitleFlavor = useSelector(selectSelectedSubtitleFlavor, shallowEqual) + const focusTriggered = useSelector(selectFocusSegmentTriggered, shallowEqual) + const focusId = useSelector(selectFocusSegmentId, shallowEqual) + const defaultSegmentLength = 5000 + const segmentHeight = 100 + + const itemsRef = useRef([]); + const listRef = useRef(null); + + // Update ref array size + useEffect(() => { + if (subtitle) { + itemsRef.current = itemsRef.current.slice(0, subtitle.length); + } + }, [subtitle]); + + // Scroll to segment when triggered by reduxState + useEffect(() => { + if (focusTriggered) { + if (itemsRef && itemsRef.current && subtitle) { + const itemIndex = subtitle.findIndex(item => item.id === focusId) + if (listRef && listRef.current) { + listRef.current.scrollToItem(itemIndex, "center"); + + } + } + dispatch(setFocusSegmentTriggered(false)) + } + }, [dispatch, focusId, focusTriggered, itemsRef, subtitle]) + + // Automatically create a segment if there are no segments + useEffect(() => { + if (subtitle && subtitle.length === 0) { + dispatch(addCueAtIndex({ + identifier: subtitleFlavor, + cueIndex: 0, + text: "", + startTime: 0, + endTime: defaultSegmentLength + })) + } + }, [dispatch, subtitle, subtitleFlavor]) + + const listStyle = css({ + display: 'flex', + flexDirection: 'column', + height: '100%', + width: '60%', + ...(flexGapReplacementStyle(20, false)), + }) + + // Old CSS for not yet implemented buttons + // const headerStyle = css({ + // display: 'flex', + // flexDirection: 'row', + // justifyContent: 'flex-end', + // flexWrap: 'wrap', + // ...(flexGapReplacementStyle(20, false)), + // paddingRight: '20px', + // }) + + // const cuttingActionButtonStyle = { + // padding: '16px', + // boxShadow: '0 0 10px rgba(0, 0, 0, 0.3)', + // }; + + const calcEstimatedSize = React.useCallback(() => { + return segmentHeight + }, []) + + const itemData = createItemData(subtitle, subtitleFlavor, defaultSegmentLength) + + return ( +
+ + {({ height, width }) => ( + segmentHeight} + itemKey={(index, data) => data.items[index].id} + width={width} + overscanCount={4} + estimatedItemSize={calcEstimatedSize()} + innerElementType={innerElementType} + ref={listRef} + > + {SubtitleListSegment} + + )} + +
+ ); +} + +/** + * Helper function for reducing rerender calls caused by react-window + */ +const createItemData = memoize((items, identifier, defaultSegmentLength) => ({ + items, + identifier, + defaultSegmentLength, +})); + +/** + * Global variable to synchronize padding for react-window elements + */ +const PADDING_SIZE = 20; + +// Used for padding in the VariableSizeList +const innerElementType = React.forwardRef(({ style, ...rest }, ref) => ( +
+)); + +/** + * Type definition for SubtitleListSegment + */ +type subtitleListSegmentProps = { + index: number, + data: {items: SubtitleCue[], identifier: string, defaultSegmentLength: number}, + style: CSSProperties +}; + +/** + * A single subtitle segment + */ +const SubtitleListSegment = React.memo((props: subtitleListSegmentProps) => { + + // Parse props + const { items, identifier, defaultSegmentLength } = props.data + const cue = items[props.index] + + const { t } = useTranslation(); + const theme = useSelector(selectTheme) + const dispatch = useDispatch() + + // Unfortunately, the focus selectors will cause every element to rerender, + // even if they are not the ones that are focused + // However, since the number of list segments rendered is severly limited + // by react-window, so it should not be an issue + const focusTriggered2 = useSelector(selectFocusSegmentTriggered2, shallowEqual) + const focusId2 = useSelector(selectFocusSegmentId, shallowEqual) + const textAreaRef = useRef(null); + + // Set focus to textarea + useEffect(() => { + if (focusTriggered2 && focusId2 === cue.id) { + if (textAreaRef && textAreaRef.current) { + textAreaRef.current.focus() + } + dispatch(setFocusSegmentTriggered2(false)) + } + }, [cue.id, dispatch, focusId2, focusTriggered2]) + + const updateCueText = (event: { target: { value: any } }) => { + dispatch(setCueAtIndex({ + identifier: identifier, + cueIndex: props.index, + newCue: { + id: cue.id, + text: event.target.value, + startTime: cue.startTime, + endTime: cue.endTime, + tree: cue.tree + } + })) + }; + + const updateCueStart = (event: { target: { value: any } }) => { + dispatch(setCueAtIndex({ + identifier: identifier, + cueIndex: props.index, + newCue: { + id: cue.id, + text: cue.text, + startTime: event.target.value, + endTime: cue.endTime, + tree: cue.tree + } + })) + }; + + const updateCueEnd = (event: { target: { value: any } }) => { + dispatch(setCueAtIndex({ + identifier: identifier, + cueIndex: props.index, + newCue: { + id: cue.id, + text: cue.text, + startTime: cue.startTime, + endTime: event.target.value, + tree: cue.tree + } + })) + }; + + const addCueAbove = () => { + dispatch(addCueAtIndex({identifier: identifier, + cueIndex: props.index, + text: "", + startTime: cue.startTime - defaultSegmentLength, + endTime: cue.startTime + })) + } + + const addCueBelow = () => { + dispatch(addCueAtIndex({ + identifier: identifier, + cueIndex: props.index + 1, + text: "", + startTime: cue.endTime, + endTime: cue.endTime + defaultSegmentLength + })) + } + + const deleteCue = () => { + dispatch(removeCue({ + identifier: identifier, + cue: cue + })) + } + + // Maps functions to hotkeys + const handlers = { + addAbove: () => addCueAbove(), + addBelow: () => addCueBelow(), + jumpAbove: () => { + dispatch(setFocusSegmentTriggered(true)) + dispatch(setFocusToSegmentAboveId({identifier: identifier, segmentId: cue.id})) + }, + jumpBelow: () => { + dispatch(setFocusSegmentTriggered(true)) + dispatch(setFocusToSegmentBelowId({identifier: identifier, segmentId: cue.id})) + }, + delete: () => { + dispatch(setFocusSegmentTriggered(true)) + dispatch(setFocusToSegmentAboveId({identifier: identifier, segmentId: cue.id})) + deleteCue() + }, + } + + const setTimeToSegmentStart = () => { + dispatch(setCurrentlyAt(cue.startTime)) + } + + const segmentStyle = css({ + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-around', + alignItems: 'center', + ...(flexGapReplacementStyle(20, false)), + // Make function buttons visible when hovered or focused + "&:hover": { + "& .functionButtonAreaStyle": { + visibility: "visible", + } + }, + "&:focus-within": { + "& .functionButtonAreaStyle": { + visibility: "visible", + } + }, + }) + + const timeAreaStyle = css({ + display: 'flex', + flexDirection: 'column', + justifyContent: 'space-between', + height: '100%', + }) + + const functionButtonAreaStyle = css({ + display: 'flex', + flexDirection: 'column', + justifyContent: 'space-between', + alignItems: 'center', + ...(flexGapReplacementStyle(10, false)), + flexGrow: '0.5', + minWidth: '20px', + height: '132px', // Hackily moves buttons beyond the segment border. Specific value causes buttons from neighboring segments to overlay + visibility: 'hidden', + }) + + const fieldStyle = css({ + fontSize: '1em', + marginLeft: '15px', + borderRadius: '5px', + borderWidth: '1px', + padding: '10px 10px', + background: `${theme.element_bg}`, + border: '1px solid #ccc', + color: `${theme.text}` + }) + + const textFieldStyle = css({ + flexGrow: '7', + height: '80%', + minWidth: '100px', + // TODO: Find a way to allow resizing without breaking the UI + // Manual or automatic resizing can cause neighboring textareas to overlap + // Can use TextareaAutosize from mui, but that does not fix the overlap problem + resize: 'none', + }) + + const addSegmentButtonStyle = css({ + width: '32px', + height: '32px', + boxShadow: `${theme.boxShadow}`, + background: `${theme.element_bg}`, + zIndex: '1000', + }) + + return ( + +
+ +