├── src ├── util │ ├── webvtt-parser.d.ts │ ├── appkit.ts │ ├── client.js │ ├── waveform.js │ └── utilityFunctions.ts ├── img │ ├── placeholder-waveform.png │ ├── subtitle.svg │ ├── trash-restore.svg │ └── opencast-editor-narrow.svg ├── i18n │ ├── locales.json │ ├── lngs-generated.ts │ ├── i18next.d.ts │ ├── config.tsx │ └── LazyLoadingPlugin.ts ├── App.tsx ├── redux │ ├── createAsyncThunkWithTypes.ts │ ├── mainMenuSlice.ts │ ├── endSlice.ts │ ├── finishSlice.ts │ ├── store.ts │ ├── errorSlice.ts │ ├── workflowPostSlice.ts │ └── metadataSlice.ts ├── index.css ├── main │ ├── Subtitle.tsx │ ├── Landing.tsx │ ├── Body.tsx │ ├── Error.tsx │ ├── Tooltip.tsx │ ├── Discard.tsx │ ├── WorkflowConfiguration.tsx │ ├── TheEnd.tsx │ ├── CuttingActionsContextMenu.tsx │ ├── FinishMenu.tsx │ ├── Lock.tsx │ ├── ContextMenu.tsx │ ├── Finish.tsx │ ├── KeyboardControls.tsx │ ├── Cutting.tsx │ ├── MainContent.tsx │ ├── MainMenu.tsx │ ├── Save.tsx │ ├── WorkflowSelection.tsx │ ├── Chapter.tsx │ ├── Header.tsx │ └── SubtitleVideoArea.tsx ├── index.tsx ├── types.ts └── globalKeys.ts ├── .dockerignore ├── public ├── favicon.ico ├── robots.txt ├── manifest.json ├── editor-settings.toml └── opencast-editor.svg ├── .github ├── mock │ └── editor │ │ ├── test.mp4 │ │ └── ID-dual-stream-demo │ │ └── edit.json ├── dependabot.yml ├── workflows │ ├── pr-test-build.yml │ ├── check-merge-conflict.yml │ ├── crowdin-upload-keys.yml │ ├── release-cut-tag.yml │ ├── check-label.yml │ ├── crowdin-download-translations.yml │ ├── pr-test-playwright.yml │ ├── check-icla.yml │ ├── pr-remove-test-branch.yml │ ├── release-build.yml │ ├── deploy-main-branches.yml │ └── pr-deploy-test-branch.yml ├── release.yml ├── get-release-server.sh ├── generate-lngs.sh └── assets │ └── index.css ├── .crowdin.yaml ├── vite-env.d.ts ├── eslint.config.js ├── .gitignore ├── tests ├── navigation.test.ts └── metadata.test.ts ├── tsconfig.json ├── playwright.config.ts ├── vite.config.ts ├── Dockerfile ├── index.html ├── package.json ├── editor-settings.toml └── README.md /src/util/webvtt-parser.d.ts: -------------------------------------------------------------------------------- 1 | // webvtt-parser.d.ts 2 | declare module "webvtt-parser"; 3 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .dockerignore 2 | .git/ 3 | .github/ 4 | build/ 5 | Dockerfile 6 | node_modules/ 7 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/opencast/opencast-editor/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /.github/mock/editor/test.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/opencast/opencast-editor/HEAD/.github/mock/editor/test.mp4 -------------------------------------------------------------------------------- /src/img/placeholder-waveform.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/opencast/opencast-editor/HEAD/src/img/placeholder-waveform.png -------------------------------------------------------------------------------- /src/util/appkit.ts: -------------------------------------------------------------------------------- 1 | import { DEFAULT_CONFIG as APPKIT_CONFIG } from "@opencast/appkit"; 2 | 3 | export const COLORS = APPKIT_CONFIG.colors; 4 | -------------------------------------------------------------------------------- /src/i18n/locales.json: -------------------------------------------------------------------------------- 1 | [ 2 | "de-DE.json", 3 | "en-US.json", 4 | "es-ES.json", 5 | "fr-FR.json", 6 | "nl-NL.json", 7 | "zh-CN.json", 8 | "zh-TW.json" 9 | ] 10 | -------------------------------------------------------------------------------- /src/i18n/lngs-generated.ts: -------------------------------------------------------------------------------- 1 | export const languages = new Map([ 2 | ["de-DE", "de-DE"], 3 | ["el", "el-GR"], 4 | ["en", "en-US"], 5 | ["es", "es-ES"], 6 | ["fr", "fr-FR"], 7 | ]); 8 | -------------------------------------------------------------------------------- /.crowdin.yaml: -------------------------------------------------------------------------------- 1 | project_id: 458068 2 | api_token_env: CROWDIN_TOKEN 3 | base_path: . 4 | preserve_hierarchy: true 5 | 6 | files: 7 | - source: /src/i18n/locales/en-US.json 8 | translation: /src/i18n/locales/%locale%.json 9 | -------------------------------------------------------------------------------- /vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Types for our user defined variables 3 | */ 4 | interface ImportMetaEnv { 5 | readonly VITE_APP_SETTINGS_PATH: string 6 | } 7 | 8 | interface ImportMeta { 9 | readonly env: ImportMetaEnv 10 | } 11 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import Body from "./main/Body"; 2 | import Header from "./main/Header"; 3 | import { GlobalStyle } from "./cssStyles"; 4 | 5 | function App() { 6 | return ( 7 |
8 | 9 |
10 | 11 |
12 | ); 13 | } 14 | 15 | export default App; 16 | -------------------------------------------------------------------------------- /src/i18n/i18next.d.ts: -------------------------------------------------------------------------------- 1 | // import the original type declarations 2 | import "i18next"; 3 | 4 | // import all namespaces (for the default language, only) 5 | import translation from "./locales/en-US.json"; 6 | 7 | declare module "i18next" { 8 | interface CustomTypeOptions { 9 | resources: { 10 | translation: typeof translation; 11 | }; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Opencast Editor", 3 | "name": "Web-based cutting tool for Opencast", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "minimal-ui", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import opencastConfig from "@opencast/eslint-config-ts-react"; 2 | 3 | export default [ 4 | ...opencastConfig, 5 | 6 | // Fully ignore some files 7 | { 8 | ignores: ["build/", "**/*.js", "*.ts", "tests/**"], 9 | }, 10 | 11 | { 12 | rules: { 13 | // // TODO: We want to turn these on eventually 14 | "@typescript-eslint/no-floating-promises": "off", 15 | }, 16 | }, 17 | ]; 18 | -------------------------------------------------------------------------------- /src/img/subtitle.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/img/trash-restore.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | /playwright-report 11 | 12 | # production 13 | /build 14 | 15 | # editor files 16 | *.swp 17 | .vscode/ 18 | 19 | # misc 20 | .DS_Store 21 | .env.local 22 | .env.development.local 23 | .env.test.local 24 | .env.production.local 25 | 26 | npm-debug.log* 27 | yarn-debug.log* 28 | yarn-error.log* 29 | 30 | *.eslintcache 31 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | 4 | # GHA 5 | - package-ecosystem: github-actions 6 | directory: / 7 | schedule: 8 | interval: monthly 9 | 10 | # Javascript 11 | - package-ecosystem: npm 12 | directory: / 13 | schedule: 14 | interval: monthly 15 | time: "04:00" 16 | open-pull-requests-limit: 15 17 | labels: 18 | - type:dependencies 19 | groups: 20 | minor-and-patch: 21 | update-types: 22 | - minor 23 | - patch 24 | -------------------------------------------------------------------------------- /src/redux/createAsyncThunkWithTypes.ts: -------------------------------------------------------------------------------- 1 | import { createAsyncThunk } from "@reduxjs/toolkit"; 2 | import { AppDispatch, RootState } from "./store"; 3 | 4 | /** 5 | * Use instead of createAsyncThunk to provide basic typing to all async thunks 6 | * 7 | * Thematically this belongs in `store.ts`. However, this causes 8 | * "Cannot access 'createAsyncThunk' before initialization", 9 | * so this has to be moved into it's own file. 10 | */ 11 | export const createAppAsyncThunk = createAsyncThunk.withTypes<{ 12 | state: RootState 13 | dispatch: AppDispatch 14 | }>(); 15 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body, textarea, input { 2 | margin: 0; 3 | font-family: "Roboto Flex Variable", "Roboto", -apple-system, BlinkMacSystemFont, "Segoe UI", "Oxygen", 4 | "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", 12 | monospace; 13 | } 14 | 15 | /* Make the Create React App fill the whole height */ 16 | html, body, #root, .App { 17 | height: 100%; 18 | } 19 | -------------------------------------------------------------------------------- /.github/workflows/pr-test-build.yml: -------------------------------------------------------------------------------- 1 | name: Build » Vitest tests 2 | 3 | on: 4 | push: 5 | branches-ignore: 6 | - 'dependabot/**' 7 | pull_request: 8 | branches: 9 | - develop 10 | - r/* 11 | 12 | jobs: 13 | check-npm-test: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - name: Checkout sources 18 | uses: actions/checkout@v4 19 | 20 | - name: Get Node.js 21 | uses: actions/setup-node@v4 22 | with: 23 | node-version: 20 24 | 25 | - name: Run npm ci 26 | run: npm ci 27 | 28 | - name: Execute Vitest tests 29 | run: npm run test 30 | -------------------------------------------------------------------------------- /tests/navigation.test.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test'; 2 | 3 | test('Test: Navigation', async ({ page, baseURL }) => { 4 | 5 | await page.goto(baseURL); 6 | expect(page.url()).toBe(baseURL); 7 | await expect(page).toHaveTitle("Opencast Editor"); 8 | 9 | // checks if Navbar on left has 4 elements 10 | const length = await page.locator('#root > div > div > nav > button').count(); 11 | expect(length >= 2).toBeTruthy(); 12 | 13 | // Navigation to Finish 14 | await page.click('button[role="menuitem"]:has-text("Finish")'); 15 | 16 | // Navigation to Cutting 17 | await page.click('button[role="menuitem"]:has-text("Cutting")'); 18 | }); 19 | -------------------------------------------------------------------------------- /.github/workflows/check-merge-conflict.yml: -------------------------------------------------------------------------------- 1 | name: Check » Merge conflicts 2 | on: 3 | push: 4 | pull_request_target: 5 | types: 6 | - opened 7 | - synchronize 8 | 9 | jobs: 10 | check-merge-conflicts: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Check for dirty pull requests 14 | uses: eps1lon/actions-label-merge-conflict@releases/2.x 15 | with: 16 | dirtyLabel: "status:conflicts" 17 | repoToken: "${{ secrets.GITHUB_TOKEN }}" 18 | commentOnDirty: | 19 | This pull request has conflicts ☹ 20 | Please resolve those so we can review the pull request. 21 | Thanks. 22 | -------------------------------------------------------------------------------- /src/main/Subtitle.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import SubtitleEditor from "./SubtitleEditor"; 3 | import SubtitleSelect from "./SubtitleSelect"; 4 | import { useAppSelector } from "../redux/store"; 5 | import { selectIsDisplayEditView } from "../redux/subtitleSlice"; 6 | 7 | /** 8 | * A container for the various subtitle views 9 | */ 10 | const Subtitle: React.FC = () => { 11 | 12 | const displayEditView = useAppSelector(selectIsDisplayEditView); 13 | 14 | const render = () => { 15 | return displayEditView ? : ; 16 | }; 17 | 18 | return ( 19 | <> 20 | {render()} 21 | 22 | ); 23 | }; 24 | 25 | export default Subtitle; 26 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | 3 | exclude: 4 | authors: 5 | - dependabot 6 | - dependabot[bot] 7 | 8 | categories: 9 | - title: Security Fixes 10 | labels: 11 | - type:security 12 | 13 | - title: New Features 14 | labels: 15 | - type:feature 16 | - type:enhancement 17 | 18 | - title: Usability and Accessibility 19 | labels: 20 | - type:usability 21 | - type:accessibility 22 | - type:visual-clarity 23 | 24 | - title: Bug Fixes 25 | labels: 26 | - type:bug 27 | 28 | - title: Documentation 29 | labels: 30 | - type:documentation 31 | 32 | - title: Other Changes 33 | labels: 34 | - "*" 35 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "jsx": "react-jsx", 22 | "jsxImportSource": "@emotion/react", 23 | "types": ["vite/client", "vite-plugin-svgr/client", "vitest/globals"] 24 | }, 25 | "include": [ 26 | "src" 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /.github/workflows/crowdin-upload-keys.yml: -------------------------------------------------------------------------------- 1 | name: Crowdin » Upload keys 2 | 3 | on: 4 | push: 5 | branches: 6 | - develop 7 | - r/* 8 | 9 | concurrency: 10 | group: crowdin-${{ github.ref }} 11 | cancel-in-progress: true 12 | 13 | jobs: 14 | upload-translation-keys: 15 | if: github.repository_owner == 'opencast' 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v5 19 | 20 | - name: Prepare crowdin client 21 | run: | 22 | wget --quiet https://artifacts.crowdin.com/repo/deb/crowdin3.deb 23 | sudo dpkg -i crowdin3.deb 24 | 25 | - name: Upload translation source 26 | env: 27 | CROWDIN_TOKEN: ${{ secrets.CROWDIN_TOKEN }} 28 | run: | 29 | crowdin upload sources --config .crowdin.yaml -b "${GITHUB_REF##*/}" 30 | -------------------------------------------------------------------------------- /src/redux/mainMenuSlice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, PayloadAction } from "@reduxjs/toolkit"; 2 | 3 | import { MainMenuStateNames } from "../types"; 4 | 5 | export interface mainMenu { 6 | value: MainMenuStateNames, 7 | } 8 | 9 | const initialState: mainMenu = { 10 | value: MainMenuStateNames.cutting, 11 | }; 12 | 13 | /** 14 | * Slice for the main menu state 15 | */ 16 | export const mainMenuSlice = createSlice({ 17 | name: "mainMenuState", 18 | initialState, 19 | reducers: { 20 | setState: (state, action: PayloadAction) => { 21 | state.value = action.payload; 22 | }, 23 | }, 24 | selectors: { 25 | selectMainMenuState: state => state.value, 26 | }, 27 | }); 28 | 29 | export const { setState } = mainMenuSlice.actions; 30 | 31 | export const { selectMainMenuState } = mainMenuSlice.selectors; 32 | 33 | export default mainMenuSlice.reducer; 34 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { PlaywrightTestConfig, devices } from '@playwright/test'; 2 | 3 | const config: PlaywrightTestConfig = { 4 | 5 | reporter: [ 6 | [process.env.CI ? 'github' : 'list'], 7 | ['html', { outputFolder: 'playwright-report' }], 8 | ], 9 | testIgnore: '**/redux/**', 10 | retries: 1, 11 | timeout: 60 * 1000, 12 | 13 | use: { 14 | baseURL: 'http://localhost:5173/', 15 | headless: true, 16 | screenshot: 'only-on-failure', 17 | }, 18 | 19 | webServer: { 20 | command: 'npm run start', 21 | port: 5173, 22 | timeout: 120 * 1000, 23 | reuseExistingServer: !process.env.CI, 24 | }, 25 | 26 | projects: [ 27 | { 28 | name: 'chromium', 29 | use: { ...devices['Desktop Chrome'] }, 30 | }, 31 | { 32 | name: 'firefox', 33 | use: { ...devices['Desktop Firefox'] }, 34 | }, 35 | ], 36 | 37 | 38 | }; 39 | 40 | export default config; 41 | -------------------------------------------------------------------------------- /src/redux/endSlice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, PayloadAction } from "@reduxjs/toolkit"; 2 | 3 | interface end { 4 | end: boolean, 5 | value: "success" | "discarded", 6 | } 7 | 8 | const initialState: end = { 9 | end: false, 10 | value: "success", 11 | }; 12 | 13 | /** 14 | * Slice for the main menu state 15 | */ 16 | export const endSlice = createSlice({ 17 | name: "endState", 18 | initialState, 19 | reducers: { 20 | setEnd: (state, action: PayloadAction<{ hasEnded: end["end"], value: end["value"]; }>) => { 21 | state.end = action.payload.hasEnded; 22 | state.value = action.payload.value; 23 | }, 24 | }, 25 | selectors: { 26 | selectIsEnd: state => state.end, 27 | selectEndState: state => state.value, 28 | }, 29 | }); 30 | 31 | export const { setEnd } = endSlice.actions; 32 | 33 | export const { selectIsEnd, selectEndState } = endSlice.selectors; 34 | 35 | export default endSlice.reducer; 36 | -------------------------------------------------------------------------------- /src/redux/finishSlice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, PayloadAction } from "@reduxjs/toolkit"; 2 | 3 | export interface finish { 4 | value: "Save changes" | "Start processing" | "Discard changes" | undefined, 5 | pageNumber: number, 6 | } 7 | 8 | const initialState: finish = { 9 | value: "Start processing", 10 | pageNumber: 0, 11 | }; 12 | 13 | /** 14 | * Slice for the main menu state 15 | */ 16 | export const finishSlice = createSlice({ 17 | name: "finishState", 18 | initialState, 19 | reducers: { 20 | setState: (state, action: PayloadAction) => { 21 | state.value = action.payload; 22 | }, 23 | setPageNumber: (state, action: PayloadAction) => { 24 | state.pageNumber = action.payload; 25 | }, 26 | }, 27 | selectors: { 28 | selectFinishState: state => state.value, 29 | selectPageNumber: state => state.pageNumber, 30 | }, 31 | }); 32 | 33 | // Export Actions 34 | export const { setState, setPageNumber } = finishSlice.actions; 35 | 36 | export const { selectFinishState, selectPageNumber } = finishSlice.selectors; 37 | 38 | export default finishSlice.reducer; 39 | -------------------------------------------------------------------------------- /public/editor-settings.toml: -------------------------------------------------------------------------------- 1 | #### 2 | # Opencast Stand-alone Video Editor 3 | ## 4 | 5 | # This file contains configuration meant for development and demonstration. 6 | # New features should be enabled in here to make testing easier. 7 | 8 | # ⚠️ Please do not use this for production deployments. 9 | 10 | 11 | # Pick a default event identifier which should be on develop.opencast.org 12 | id = 'ID-dual-stream-demo' 13 | 14 | # Callback to develop.opencast.org 15 | allowedCallbackPrefixes = ["https://develop.opencast.org"] 16 | callbackUrl = "https://develop.opencast.org" 17 | callbackSystem = "OPENCAST" 18 | 19 | [opencast] 20 | # Connect to develop.opencast.org and use the default demo user 21 | url = 'https://develop.opencast.org' 22 | name = "admin" 23 | password = "opencast" 24 | 25 | 26 | [metadata] 27 | show = true 28 | 29 | [trackSelection] 30 | show = true 31 | atLeastOneVideo = true 32 | atMostTwoVideos = true 33 | 34 | [subtitles] 35 | show = true 36 | mainFlavor = "captions" 37 | 38 | [subtitles.languages] 39 | german = { lang = "de-DE" } 40 | english = { lang = "en-US", type = "closed-caption" } 41 | spanish = { lang = "es" } 42 | 43 | 44 | [subtitles.icons] 45 | "de-DE" = "DE" 46 | "en-US" = "EN" 47 | "es" = "ES" 48 | 49 | [thumbnail] 50 | show = true 51 | -------------------------------------------------------------------------------- /src/i18n/config.tsx: -------------------------------------------------------------------------------- 1 | import i18next from "i18next"; 2 | import { initReactI18next } from "react-i18next"; 3 | import LanguageDetector from "i18next-browser-languagedetector"; 4 | import ChainedBackend, { ChainedBackendOptions } from "i18next-chained-backend"; 5 | import resourcesToBackend from "i18next-resources-to-backend"; 6 | 7 | import { languages } from "./lngs-generated"; 8 | import LazyLoadingPlugin from "./LazyLoadingPlugin"; 9 | 10 | 11 | const debug = Boolean(new URLSearchParams(window.location.search).get("debug")); 12 | 13 | const bundledResources = { 14 | en: { 15 | translation: import("./locales/en-US.json"), 16 | }, 17 | }; 18 | 19 | i18next 20 | .use(ChainedBackend) 21 | .use(initReactI18next) 22 | .use(LanguageDetector) 23 | .init({ 24 | supportedLngs: Array.from(languages.keys()), 25 | fallbackLng: ["en", "en-US"], 26 | nonExplicitSupportedLngs: false, 27 | debug: debug, 28 | backend: { 29 | backends: [ 30 | LazyLoadingPlugin, 31 | resourcesToBackend(bundledResources), 32 | ], 33 | }, 34 | }); 35 | 36 | if (debug) { 37 | console.debug("language", i18next.language); 38 | console.debug("languages", i18next.languages); 39 | } 40 | 41 | export default i18next; 42 | -------------------------------------------------------------------------------- /.github/workflows/release-cut-tag.yml: -------------------------------------------------------------------------------- 1 | name: Release » Create release tag 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | create-release-tag: 8 | name: Create release tag 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout sources 12 | uses: actions/checkout@v5 13 | with: 14 | fetch-depth: 0 15 | 16 | - name: Prepare git 17 | run: | 18 | git config --global user.email 'cloud@opencast.org' 19 | git config --global user.name 'Release Bot' 20 | 21 | - name: Tag and push 22 | env: 23 | GH_TOKEN: ${{ github.token }} 24 | run: | 25 | #Translate 'develop' to 18.x or whatever is appropriate 26 | if [ "develop" = "${{ github.ref_name }}" ]; then 27 | #NB normally we only clone just the head ref, but fetch-depth: 0 above gets *all* the history 28 | export TEMP="$((`git branch -a | grep r/ | cut -f 4 -d '/' | sort | tail -n 1 | cut -f 1 -d '.'` + 1)).x" 29 | else 30 | export TEMP=${{ github.ref_name }} 31 | fi 32 | export TAG=${TEMP#r\/}-`date +%Y-%m-%d` 33 | git tag $TAG 34 | git push origin $TAG 35 | sleep 2 36 | gh workflow run release-build.yml -r $TAG 37 | -------------------------------------------------------------------------------- /.github/get-release-server.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if [ $# -ne 1 ]; then 4 | echo "Usage: $0 OC_VERSION" 5 | echo " eg: $0 r/16.x -> Returns the current correct server for r/16.x" 6 | exit 1 7 | fi 8 | 9 | git clone https://github.com/opencast/opencast.git ~/opencast 10 | cd ~/opencast 11 | 12 | #Get the list of *all* branches in the format remotes/origin/r/N.m 13 | #grep for r/N.x 14 | #then use cut to remove remotes/origin 15 | #then use sort, with a field delimiter of '/', sorting on the *second* key in 'n'umeric 'r'evers order 16 | #then only consider the first 3 entries 17 | ary=( develop `git branch -a | grep origin | grep 'r/[0-9]*.x' | cut -f 3- -d '/' | sort -t '/' -k 2nr | head -n 3` ) 18 | 19 | #Iterate through the array above. 20 | #If the script input matches the first item, spit out develop 21 | #If the script iput matches hte second item... etc 22 | #If it doesn't match anything, then don't say anything 23 | for i in "${!ary[@]}" 24 | do 25 | if [[ "${ary[i]}" = "$1" ]]; then 26 | if [[ $i -eq 0 ]]; then 27 | echo "develop.opencast.org" 28 | exit 0 29 | elif [[ $i -eq 1 ]]; then 30 | echo "stable.opencast.org" 31 | exit 0 32 | elif [[ $i -eq 2 ]]; then 33 | echo "legacy.opencast.org" 34 | exit 0 35 | fi 36 | fi 37 | done 38 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import react from "@vitejs/plugin-react"; 3 | import svgr from "vite-plugin-svgr"; 4 | import child from "child_process"; 5 | import { configDefaults } from 'vitest/config' 6 | 7 | const commitHash = child.execSync("git rev-parse HEAD").toString().trim(); 8 | 9 | // https://vitejs.dev/config/ 10 | export default defineConfig(() => { 11 | return { 12 | base: process.env.PUBLIC_URL || "", 13 | server: { 14 | open: true, 15 | }, 16 | build: { 17 | outDir: "build", 18 | }, 19 | plugins: [ 20 | react({ 21 | jsxImportSource: "@emotion/react", 22 | babel: { 23 | plugins: ["@emotion/babel-plugin"], 24 | }, 25 | }), 26 | // svgr options: https://react-svgr.com/docs/options/ 27 | svgr({ svgrOptions: { } }), 28 | ], 29 | // Workaround, see https://github.com/vitejs/vite/discussions/5912#discussioncomment-6115736 30 | define: { 31 | global: "globalThis", 32 | 'import.meta.env.VITE_GIT_COMMIT_HASH': JSON.stringify(commitHash), 33 | 'import.meta.env.VITE_APP_BUILD_DATE': JSON.stringify(new Date().toISOString()), 34 | }, 35 | test: { 36 | globals: true, 37 | environment: 'jsdom', 38 | exclude: [ 39 | ...configDefaults.exclude, 40 | './tests', 41 | ], 42 | }, 43 | }; 44 | }); 45 | -------------------------------------------------------------------------------- /.github/workflows/check-label.yml: -------------------------------------------------------------------------------- 1 | # This workflow runs on any change made to a pull-request and aims to verify 2 | # that the correct label is present. 3 | 4 | name: Check » Proper label usage 5 | 6 | on: 7 | pull_request_target: 8 | types: [opened, labeled, unlabeled, synchronize] 9 | 10 | jobs: 11 | check-labels: 12 | runs-on: ubuntu-latest 13 | steps: 14 | # Ensure that one of the required labels is present and none of the undesired is absent 15 | # See https://github.com/jesusvasquez333/verify-pr-label-action 16 | - name: Verify PR label action 17 | uses: jesusvasquez333/verify-pr-label-action@v1.4.0 18 | with: 19 | github-token: "${{ secrets.GITHUB_TOKEN }}" 20 | valid-labels: > 21 | type:tests, 22 | type:infrastructure, 23 | type:enhancement, 24 | type:feature, 25 | type:dependencies, 26 | type:documentation, 27 | type:accessibility, 28 | type:security, 29 | type:bug, 30 | type:usability, 31 | type:visual-clarity, 32 | type:code-quality 33 | invalid-labels: > 34 | status:duplicate, 35 | status:conflicts, 36 | status:wontfix 37 | pull-request-number: "${{ github.event.pull_request.number }}" 38 | disable-reviews: true 39 | -------------------------------------------------------------------------------- /.github/generate-lngs.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | declare -A country_language_map 4 | declare -a order 5 | 6 | # Loop through each file in the directory 7 | for file in ??-??.json; do 8 | if [ -f "$file" ]; then 9 | # Extract country name and language code from the filename 10 | country=$(basename "$file" .json | cut -d '-' -f 1) 11 | language_code=$(basename "$file" .json) 12 | 13 | if [ ! "${country_language_map[$country]}" ]; then 14 | order+=("$country") 15 | fi 16 | 17 | # Check if the country already exists in the map 18 | if [ -n "${country_language_map[$country]}" ]; then 19 | country_language_map["$country"]+=" $language_code" 20 | else 21 | country_language_map["$country"]="$language_code" 22 | fi 23 | fi 24 | done 25 | 26 | echo "export const languages = new Map([" 27 | # Print the country-language mappings 28 | for i in "${!order[@]}"; do 29 | country=${order[$i]} 30 | languages=() 31 | for language in ${country_language_map[$country]}; do 32 | languages+=("$language") 33 | done 34 | if [ ${#languages[@]} -eq 1 ]; then 35 | echo " [\"$country\", \"${languages[0]}\"]," 36 | else 37 | for language in "${languages[@]}"; do 38 | echo " [\"$language\", \"$language\"]," 39 | done 40 | fi 41 | done 42 | echo "]);" 43 | -------------------------------------------------------------------------------- /.github/workflows/crowdin-download-translations.yml: -------------------------------------------------------------------------------- 1 | name: Crowdin » Download translations 2 | 3 | on: 4 | schedule: 5 | - cron: "0 5 * * 1" 6 | workflow_dispatch: 7 | 8 | jobs: 9 | download-translations: 10 | if: github.repository_owner == 'opencast' 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v5 14 | 15 | - name: Prepare git 16 | run: | 17 | git config --global user.email 'crowdin-bot@opencast.org' 18 | git config --global user.name 'Crowdin Bot' 19 | 20 | - name: Prepare crowdin client 21 | run: | 22 | wget --quiet https://artifacts.crowdin.com/repo/deb/crowdin3.deb -O crowdin.deb 23 | sudo dpkg -i crowdin.deb 24 | 25 | - name: Download translations 26 | env: 27 | CROWDIN_TOKEN: ${{ secrets.CROWDIN_TOKEN }} 28 | run: | 29 | crowdin download --config .crowdin.yaml -b main 30 | # TODO: Find a way to generate only languages that are above a certain threshold 31 | # - name: Update language list 32 | # working-directory: src/i18n/locales 33 | # run: $GITHUB_WORKSPACE/.github/generate-lngs.sh > ../lngs-generated.ts 34 | 35 | - name: Add new translations 36 | run: | 37 | git add src/i18n/ 38 | 39 | - name: Upload translations 40 | run: | 41 | if git commit -m "Automatically update translation keys"; then 42 | git push 43 | fi 44 | -------------------------------------------------------------------------------- /src/main/Landing.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { css } from "@emotion/react"; 4 | 5 | import { useTranslation } from "react-i18next"; 6 | 7 | /** 8 | * This page is to be displayed when the application has run into a critical error 9 | * from which it cannot recover. 10 | */ 11 | const Landing: React.FC = () => { 12 | 13 | const { t } = useTranslation(); 14 | 15 | const landingStyle = css({ 16 | height: "100%", 17 | display: "flex", 18 | flexDirection: "column", 19 | justifyContent: "center", 20 | alignItems: "center", 21 | 22 | a: { 23 | color: "#007bff", 24 | textDecoration: "none", 25 | }, 26 | li: { 27 | margin: "5px", 28 | }, 29 | code: { 30 | userSelect: "all", 31 | color: "#e83e8c", 32 | }, 33 | }); 34 | 35 | return ( 36 |
37 |

{t("landing.main-heading")}

38 |
39 |
  • 40 | {t("landing.contact-admin")} 41 |
  • 42 |
  • 43 | {t("landing.start-editing-1")} 44 | ?id=[media-package-id] 45 | {t("landing.start-editing-2")} 46 |
  • 47 |
  • 48 | {t("landing.link-to-documentation")} 49 | 50 | docs.opencast.org 51 | 52 |
  • 53 |
    54 |
    55 | ); 56 | }; 57 | 58 | export default Landing; 59 | -------------------------------------------------------------------------------- /src/i18n/LazyLoadingPlugin.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BackendModule, 3 | InitOptions, 4 | MultiReadCallback, 5 | ReadCallback, 6 | ResourceKey, 7 | ResourceLanguage, 8 | Services, 9 | } from "i18next"; 10 | import { languages } from "./lngs-generated"; 11 | 12 | export default class LazyLoadingPlugin implements BackendModule { 13 | 14 | type: "backend"; 15 | 16 | constructor(_services: Services, _backendOptions: object, _i18nextOptions: InitOptions) { 17 | this.type = "backend"; 18 | } 19 | 20 | init(_services: Services, _backendOptions: object, _i18nextOptions: InitOptions): void { 21 | // no init needed 22 | } 23 | 24 | read(language: string, _namespace: string, callback: ReadCallback): void { 25 | const lng = languages.get(language); 26 | type JsonModule = { default: ResourceKey }; 27 | import(`./locales/${lng}.json`).then( 28 | (obj: JsonModule) => { 29 | callback(null, obj); 30 | }, 31 | ); 32 | } 33 | 34 | create?(_languages: readonly string[], _namespace: string, _key: string, _fallbackValue: string): void { 35 | throw new Error("Method not implemented."); 36 | } 37 | 38 | readMulti?(_languages: readonly string[], _namespaces: readonly string[], _callback: MultiReadCallback): void { 39 | throw new Error("Method not implemented."); 40 | } 41 | 42 | save?(_language: string, _namespace: string, _data: ResourceLanguage): void { 43 | throw new Error("Method not implemented."); 44 | } 45 | 46 | } 47 | -------------------------------------------------------------------------------- /.github/mock/editor/ID-dual-stream-demo/edit.json: -------------------------------------------------------------------------------- 1 | { 2 | "segments": [], 3 | "workflows": [ 4 | { 5 | "id": "publish", 6 | "name": "Publish", 7 | "displayOrder": 1000, 8 | "description": "" 9 | } 10 | ], 11 | "tracks": [ 12 | { 13 | "audio_stream": { 14 | "available": true, 15 | "thumbnail_uri": null, 16 | "enabled": true 17 | }, 18 | "video_stream": { 19 | "available": true, 20 | "thumbnail_uri": null, 21 | "enabled": true 22 | }, 23 | "flavor": { 24 | "type": "presentation", 25 | "subtype": "preview" 26 | }, 27 | "uri": "http://localhost:3000/editor/test.mp4", 28 | "id": "f7bb98b5-e02d-4738-8b0b-7c6f6ec24c4f" 29 | }, 30 | { 31 | "audio_stream": { 32 | "available": true, 33 | "thumbnail_uri": null, 34 | "enabled": true 35 | }, 36 | "video_stream": { 37 | "available": true, 38 | "thumbnail_uri": null, 39 | "enabled": true 40 | }, 41 | "flavor": { 42 | "type": "presenter", 43 | "subtype": "preview" 44 | }, 45 | "uri": "http://localhost:3000/editor/test.mp4", 46 | "id": "38cbcf48-bc16-4ccf-8346-8bd921d159fe" 47 | } 48 | ], 49 | "title": "Dual-Stream Demo", 50 | "date": "2022-02-03T01:02:00Z", 51 | "duration": 64715, 52 | "series": { 53 | "id": null, 54 | "title": null 55 | }, 56 | "workflow_active": false, 57 | "locking_active": false 58 | } 59 | -------------------------------------------------------------------------------- /.github/workflows/pr-test-playwright.yml: -------------------------------------------------------------------------------- 1 | name: Build » Playwright UI-Test 2 | 3 | on: 4 | push: 5 | branches-ignore: 6 | - 'dependabot/**' 7 | pull_request: 8 | branches: 9 | - develop 10 | - r/* 11 | jobs: 12 | test: 13 | 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - name: Check out source code 18 | uses: actions/checkout@v5 19 | 20 | - name: Install Node Dependency 21 | uses: actions/setup-node@v5 22 | with: 23 | node-version: 20 24 | 25 | - name: Install dependencies 26 | run: npm ci 27 | 28 | - name: Install supported browsers 29 | run: npx playwright install 30 | 31 | - name: Prepare configuration 32 | run: > 33 | sed -i 34 | 's_https://develop.opencast.org_http://localhost:5173_' 35 | public/editor-settings.toml 36 | 37 | - name: Prepare mock files 38 | run: cp -r .github/mock/editor public/ 39 | 40 | - name: Build editor 41 | run: | 42 | npm run build 43 | cp public/editor-settings.toml build/ 44 | 45 | - name: Use production build for testing 46 | run: sed -i 's/npm run start/python -m http.server --bind 127.0.0.1 --directory build 5173/' playwright.config.ts 47 | 48 | - name: Run playwright-test 49 | run: npx playwright test --config=playwright.config.ts 50 | 51 | - name: Upload tests results 52 | if: success() || failure() 53 | uses: actions/upload-artifact@v4 54 | with: 55 | name: test-results 56 | path: test-results/ 57 | -------------------------------------------------------------------------------- /src/redux/store.ts: -------------------------------------------------------------------------------- 1 | import { configureStore } from "@reduxjs/toolkit"; 2 | import mainMenuStateReducer from "./mainMenuSlice"; 3 | import finishStateReducer from "./finishSlice"; 4 | import videoReducer from "./videoSlice"; 5 | import workflowPostReducer from "./workflowPostSlice"; 6 | import endReducer from "./endSlice"; 7 | import metadataReducer from "./metadataSlice"; 8 | import subtitleReducer from "./subtitleSlice"; 9 | import chapterReducer from "./chapterSlice"; 10 | import errorReducer from "./errorSlice"; 11 | import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux"; 12 | 13 | export const store = configureStore({ 14 | reducer: { 15 | mainMenuState: mainMenuStateReducer, 16 | finishState: finishStateReducer, 17 | videoState: videoReducer, 18 | workflowPostState: workflowPostReducer, 19 | endState: endReducer, 20 | metadataState: metadataReducer, 21 | subtitleState: subtitleReducer, 22 | chapterState: chapterReducer, 23 | errorState: errorReducer, 24 | }, 25 | }); 26 | 27 | export type AppDispatch = typeof store.dispatch; 28 | 29 | // Infer the `RootState` and `AppDispatch` types from the store itself 30 | export type RootState = ReturnType; 31 | 32 | export type ThunkApiConfig = { state: RootState; dispatch: AppDispatch; rejectValue?: string }; 33 | 34 | // Use instead of plain `useDispatch` and `useSelector` 35 | export const useAppDispatch: () => AppDispatch = useDispatch; 36 | export const useAppSelector: TypedUseSelectorHook = useSelector; 37 | 38 | export default store; 39 | -------------------------------------------------------------------------------- /src/main/Body.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import MainMenu from "./MainMenu"; 4 | import MainContent from "./MainContent"; 5 | import TheEnd from "./TheEnd"; 6 | import Error from "./Error"; 7 | import Landing from "./Landing"; 8 | import Lock from "./Lock"; 9 | 10 | import { css } from "@emotion/react"; 11 | 12 | import { useAppSelector } from "../redux/store"; 13 | import { selectIsEnd } from "../redux/endSlice"; 14 | import { selectIsError } from "../redux/errorSlice"; 15 | import { settings } from "../config"; 16 | 17 | const Body: React.FC = () => { 18 | 19 | const isEnd = useAppSelector(selectIsEnd); 20 | const isError = useAppSelector(selectIsError); 21 | 22 | // If we"re in a special state, display a special page 23 | // Otherwise display the normal page 24 | const main = () => { 25 | if (!settings.id) { 26 | return ( 27 | 28 | ); 29 | } else if (isEnd) { 30 | return ( 31 |
    32 | 33 | 34 |
    35 | ); 36 | } else if (isError) { 37 | return ( 38 | 39 | ); 40 | } else { 41 | return ( 42 |
    43 | 44 | 45 | 46 |
    47 | ); 48 | } 49 | }; 50 | 51 | const bodyStyle = css({ 52 | display: "flex", 53 | flexDirection: "row", 54 | height: "calc(100% - 64px)", 55 | }); 56 | 57 | return ( 58 | 59 | {main()} 60 | 61 | ); 62 | }; 63 | 64 | export default Body; 65 | -------------------------------------------------------------------------------- /.github/workflows/check-icla.yml: -------------------------------------------------------------------------------- 1 | name: Check » ICLA 2 | on: 3 | pull_request_target: 4 | types: 5 | - opened 6 | 7 | jobs: 8 | check-icla: 9 | if: github.event.pull_request.user.login != 'dependabot[bot]' 10 | runs-on: ubuntu-24.04 11 | steps: 12 | - name: Install Python module 13 | run: pip install --break-system-packages apereocla 14 | 15 | - name: Check Apereo ICLA for GitHub user 16 | run: apereocla -g "${{ github.event.pull_request.user.login }}" 17 | 18 | - name: Comment if no CLA has been filed 19 | if: ${{ failure() }} 20 | uses: thollander/actions-comment-pull-request@main 21 | with: 22 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 23 | message: > 24 | Hi @${{ github.event.pull_request.user.login }} 25 | 26 | Thank you for contributing to the Opencast Editor. 27 | 28 | We noticed that you have not yet filed an [Individual Contributor License Agreement](https://www.apereo.org/licensing/agreements/icla). 29 | Doing that (once) helps us to ensure that Opencast stays free for all. 30 | If you make your contribution on behalf of an institution, you might also want to file a 31 | [Corporate Contributor License Agreement](https://www.apereo.org/licensing/agreements/ccla) 32 | (giving you as individual contributor a bit more security as well). It can take a while for 33 | this bot to find out about new filings, so if you just filed one or both of the above do not 34 | worry about this message! 35 | 36 | Please let us know if you have any questions regarding the CLA. 37 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOMClient from "react-dom/client"; 3 | import "./index.css"; 4 | import App from "./App"; 5 | import { Provider } from "react-redux"; 6 | import store from "./redux/store"; 7 | 8 | import { init } from "./config"; 9 | import { sleep } from "./util/utilityFunctions"; 10 | 11 | import "@fontsource-variable/roboto-flex"; 12 | 13 | import "./i18n/config"; 14 | 15 | import "@opencast/appkit/dist/colors.css"; 16 | import { ColorSchemeProvider } from "@opencast/appkit"; 17 | 18 | const container = document.getElementById("root"); 19 | if (!container) { 20 | throw new Error("Failed to find the root element"); 21 | } 22 | const root = ReactDOMClient.createRoot(container); 23 | 24 | 25 | // Load config here 26 | // Load the rest of the application and try to fetch the settings file from the 27 | // server. 28 | const initialize = Promise.race([ 29 | init(), 30 | sleep(600), 31 | ]); 32 | 33 | initialize.then( 34 | 35 | () => { 36 | root.render( 37 | 38 | 39 | 40 | 41 | 42 | 43 | , 44 | ); 45 | }, 46 | 47 | // This error case is very unlikely to occur. 48 | (e: Error) => root.render(

    49 | {`Fatal error while loading app: ${e.message}`} 50 |
    51 | This might be caused by a incorrect configuration by the system administrator. 52 |

    ), 53 | ); 54 | 55 | // If you want to start measuring performance in your app, pass a function 56 | // to log results (for example: reportWebVitals(console.log)) 57 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 58 | 59 | -------------------------------------------------------------------------------- /src/main/Error.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { css } from "@emotion/react"; 4 | 5 | import { LuFrown } from "react-icons/lu"; 6 | 7 | import { useAppSelector } from "../redux/store"; 8 | import { selectErrorDetails, selectErrorIcon, selectErrorMessage, selectErrorTitle } from "../redux/errorSlice"; 9 | 10 | import { useTranslation } from "react-i18next"; 11 | 12 | /** 13 | * This page is to be displayed when the application has run into a critical error 14 | * from which it cannot recover. 15 | */ 16 | const Error: React.FC = () => { 17 | 18 | const { t } = useTranslation(); 19 | 20 | // Init redux variables 21 | const errorTitle = useAppSelector(selectErrorTitle); 22 | const errorMessage = useAppSelector(selectErrorMessage); 23 | const errorDetails = useAppSelector(selectErrorDetails); 24 | const ErrorIcon = useAppSelector(selectErrorIcon); 25 | 26 | const detailsStyle = css({ 27 | display: "flex", 28 | flexDirection: "column", 29 | alignItems: "center", 30 | }); 31 | 32 | const theEndStyle = css({ 33 | height: "100%", 34 | display: "flex", 35 | flexDirection: "column", 36 | justifyContent: "center", 37 | alignItems: "center", 38 | gap: "10px", 39 | }); 40 | 41 | return ( 42 |
    43 |
    {errorTitle ? errorTitle : t("error.generic-message")}
    44 | {ErrorIcon ? : } 45 | {errorMessage}
    46 | {errorDetails && 47 |
    48 | {t("error.details")}
    49 | {errorDetails} 50 |
    51 | } 52 |
    53 | ); 54 | }; 55 | 56 | export default Error; 57 | -------------------------------------------------------------------------------- /src/redux/errorSlice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, PayloadAction } from "@reduxjs/toolkit"; 2 | import { IconType } from "react-icons"; 3 | 4 | interface error { 5 | error: boolean, 6 | errorTitle?: string, 7 | errorMessage: string, 8 | errorDetails?: string, 9 | errorIcon?: IconType, 10 | } 11 | 12 | const initialState: error = { 13 | error: false, 14 | errorTitle: "", 15 | errorMessage: "Unknown error", 16 | errorDetails: "", 17 | errorIcon: undefined, 18 | }; 19 | 20 | /** 21 | * Slice for the error page state 22 | */ 23 | export const errorSlice = createSlice({ 24 | name: "errorState", 25 | initialState, 26 | reducers: { 27 | setError: (state, action: PayloadAction<{ 28 | error: error["error"], 29 | errorTitle?: error["errorTitle"], 30 | errorMessage: error["errorMessage"], 31 | errorDetails?: error["errorDetails"], 32 | errorIcon?: error["errorIcon"]; 33 | }>) => { 34 | state.error = action.payload.error; 35 | state.errorTitle = action.payload.errorTitle; 36 | state.errorMessage = action.payload.errorMessage; 37 | state.errorDetails = action.payload.errorDetails; 38 | state.errorIcon = action.payload.errorIcon; 39 | }, 40 | }, 41 | selectors: { 42 | selectIsError: state => state.error, 43 | selectErrorTitle: state => state.errorTitle, 44 | selectErrorMessage: state => state.errorMessage, 45 | selectErrorDetails: state => state.errorDetails, 46 | selectErrorIcon: state => state.errorIcon, 47 | }, 48 | }); 49 | 50 | export const { setError } = errorSlice.actions; 51 | 52 | export const { 53 | selectIsError, 54 | selectErrorTitle, 55 | selectErrorMessage, 56 | selectErrorDetails, 57 | selectErrorIcon, 58 | } = errorSlice.selectors; 59 | 60 | export default errorSlice.reducer; 61 | -------------------------------------------------------------------------------- /src/main/Tooltip.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useTheme } from "../themes"; 3 | import Tooltip, { TooltipProps } from "@mui/material/Tooltip"; 4 | 5 | export const ThemedTooltip = ({ className, ...props }: TooltipProps) => { 6 | 7 | const theme = useTheme(); 8 | 9 | const positionRef = React.useRef<{ x: number; y: number; }>({ x: 0, y: 0 }); 10 | const areaRef = React.useRef(null); 11 | 12 | return ( 13 | positionRef.current = { x: -9999, y: -9999 }} 25 | onMouseMove={event => positionRef.current = { x: event.clientX, y: event.clientY }} 26 | 27 | PopperProps={{ 28 | anchorEl: { 29 | getBoundingClientRect: () => { 30 | return new DOMRect( 31 | positionRef.current.x, 32 | areaRef.current?.getBoundingClientRect().y, 33 | 0, 34 | positionRef.current.y, 35 | ); 36 | }, 37 | }, 38 | }} 39 | 40 | componentsProps={{ 41 | tooltip: { 42 | sx: { 43 | backgroundColor: `${theme.tooltip}`, 44 | outline: "2px solid transparent", 45 | color: `${theme.tooltip_text}`, 46 | fontSize: "16px", 47 | lineHeight: "normal", 48 | fontFamily: "Roboto Flex Variable", 49 | }, 50 | }, 51 | arrow: { 52 | sx: { 53 | color: `${theme.tooltip}`, 54 | }, 55 | }, 56 | }} 57 | 58 | /> 59 | ); 60 | }; 61 | -------------------------------------------------------------------------------- /src/main/Discard.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { css } from "@emotion/react"; 4 | import { basicButtonStyle, backOrContinueStyle, navigationButtonStyle } from "../cssStyles"; 5 | 6 | import { LuChevronLeft, LuCircleX } from "react-icons/lu"; 7 | 8 | import { useAppDispatch } from "../redux/store"; 9 | import { setEnd } from "../redux/endSlice"; 10 | 11 | import { PageButton } from "./Finish"; 12 | 13 | import { useTranslation } from "react-i18next"; 14 | import { useTheme } from "../themes"; 15 | import { ProtoButton } from "@opencast/appkit"; 16 | 17 | /** 18 | * Shown if the user wishes to abort. 19 | * Informs the user about aborting and displays abort button. 20 | */ 21 | const Discard: React.FC = () => { 22 | 23 | const { t } = useTranslation(); 24 | 25 | const cancelStyle = css({ 26 | display: "flex", 27 | flexDirection: "column" as const, 28 | alignItems: "center", 29 | gap: "30px", 30 | }); 31 | 32 | return ( 33 |
    34 |

    {t("discard.headline-text")}

    35 | 36 | {t("discard.info-text")} 37 | 38 |
    39 | 40 | 41 |
    42 |
    43 | ); 44 | }; 45 | 46 | /** 47 | * Button that sets the app into an aborted state 48 | */ 49 | const DiscardButton: React.FC = () => { 50 | 51 | const { t } = useTranslation(); 52 | 53 | // Initialize redux variables 54 | const dispatch = useAppDispatch(); 55 | const theme = useTheme(); 56 | 57 | const discard = () => { 58 | dispatch(setEnd({ hasEnded: true, value: "discarded" })); 59 | }; 60 | 61 | return ( 62 | 66 | 67 | {t("discard.confirm-button")} 68 | 69 | ); 70 | }; 71 | 72 | export default Discard; 73 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1.4 2 | 3 | ARG NODE_VERSION=16 4 | FROM node:${NODE_VERSION}-alpine as build 5 | 6 | RUN apk add --no-cache git 7 | WORKDIR /src 8 | COPY package.json . 9 | COPY package-lock.json . 10 | RUN npm i 11 | COPY / . 12 | 13 | ARG PUBLIC_URL=/ 14 | ARG REACT_APP_SETTINGS_PATH=/editor-settings.toml 15 | RUN npm run build 16 | 17 | 18 | ARG NODE_VERSION=16 19 | FROM node:${NODE_VERSION}-alpine as caddy 20 | 21 | RUN apk add --no-cache curl 22 | 23 | ARG CADDY_VERSION=2.5.1 24 | 25 | RUN curl -sSL "https://github.com/caddyserver/caddy/releases/download/v${CADDY_VERSION}/caddy_${CADDY_VERSION}_linux_amd64.tar.gz" | tar xzf - caddy \ 26 | && chown 0:0 caddy \ 27 | && chmod +x caddy 28 | RUN mkdir -p /rootfs/config /rootfs/data \ 29 | && chown 1000:1000 /rootfs/config /rootfs/data 30 | 31 | 32 | FROM scratch 33 | 34 | ENV XDG_CONFIG_HOME /config 35 | ENV XDG_DATA_HOME /data 36 | 37 | COPY < { 21 | 22 | const { t } = useTranslation(); 23 | 24 | const postAndProcessWorkflowStatus = useAppSelector(selectStatus); 25 | const postAndProcessError = useAppSelector(selectError); 26 | 27 | const workflowConfigurationStyle = css({ 28 | display: "flex", 29 | flexDirection: "column" as const, 30 | alignItems: "center", 31 | padding: "20px", 32 | gap: "30px", 33 | }); 34 | 35 | return ( 36 |
    37 |

    {t("workflowConfig.headline-text")}

    38 | 39 | Placeholder 40 |
    {t("workflowConfig.satisfied-text")}
    41 |
    42 | 43 | 47 |
    48 | {postAndProcessWorkflowStatus === "failed" && 49 | 50 | 51 | {t("various.error-text") + "\n"} 52 | {postAndProcessError ? 53 | t("various.error-details-text", { errorMessage: postAndProcessError }) : undefined 54 | } 55 | 56 | 57 | } 58 |
    59 | ); 60 | }; 61 | 62 | export default WorkflowConfiguration; 63 | -------------------------------------------------------------------------------- /.github/workflows/pr-remove-test-branch.yml: -------------------------------------------------------------------------------- 1 | name: PRs » Remove Pull Request Page 2 | 3 | on: 4 | pull_request_target: 5 | types: 6 | - closed 7 | 8 | concurrency: 9 | group: pull-request-page 10 | cancel-in-progress: false 11 | 12 | env: 13 | PR_NUMBER: ${{ github.event.pull_request.number }} 14 | 15 | jobs: 16 | delete-pr-directory: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Prepare git 20 | run: | 21 | git config --global user.name "Editor Deployment Bot" 22 | git config --global user.email "cloud@opencast.org" 23 | 24 | - name: Prepare GitHub SSH key from org level secret 25 | env: 26 | DEPLOY_KEY: ${{ secrets.DEPLOY_KEY_TEST }} 27 | run: | 28 | install -dm 700 ~/.ssh/ 29 | echo "${DEPLOY_KEY}" > ~/.ssh/id_ed25519 30 | chmod 600 ~/.ssh/id_ed25519 31 | ssh-keyscan github.com >> ~/.ssh/known_hosts 32 | 33 | - name: Clone test repository 34 | run: | 35 | git clone -b gh-pages "git@github.com:opencast/opencast-editor-test.git" editor-test 36 | 37 | - name: Delete build if present 38 | working-directory: editor-test 39 | run: | 40 | if [ -d "${PR_NUMBER}" ]; then 41 | rm -rf "${PR_NUMBER}" 42 | echo "Directory ${PR_NUMBER} deleted successfully." 43 | else 44 | echo "Directory ${PR_NUMBER} does not exist. Skipping deletion." 45 | fi 46 | 47 | - name: Clean index.html 48 | working-directory: editor-test 49 | run: | 50 | echo '' >> index.html 55 | 56 | - name: Commit new version 57 | working-directory: editor-test 58 | run: | 59 | git add . 60 | # Commit and push, or just say true since the commit fails if nothing has changed 61 | (git commit -m "Remove deployment ${PR_NUMBER} due to PR closure" && git push origin gh-pages --force) || true 62 | -------------------------------------------------------------------------------- /src/util/client.js: -------------------------------------------------------------------------------- 1 | // A tiny wrapper around fetch(), borrowed from 2 | // https://kentcdodds.com/blog/replace-axios-with-a-simple-custom-fetch-wrapper 3 | 4 | import { settings } from "../config"; 5 | 6 | /** 7 | * Client I stole this from a react tutorial 8 | */ 9 | export async function client(endpoint, { body, ...customConfig } = {}) { 10 | const headers = { "Content-Type": "application/json" }; 11 | 12 | // Attempt Http basic auth if we got credentials 13 | let authHeaders = {}; 14 | if (settings.opencast.name && settings.opencast.password) { 15 | const encoded = btoa(unescape(encodeURIComponent( 16 | settings.opencast.name + ":" + settings.opencast.password, 17 | ))); 18 | authHeaders = { "Authorization": `Basic ${encoded}` }; 19 | } 20 | 21 | const config = { 22 | method: body ? "POST" : "GET", 23 | ...customConfig, 24 | headers: { 25 | ...headers, 26 | ...customConfig.headers, 27 | ...authHeaders, 28 | }, 29 | }; 30 | 31 | if (body) { 32 | if (config.headers["Content-Type"].includes("urlencoded")) { 33 | config.body = body; 34 | } else { 35 | config.body = JSON.stringify(body); 36 | } 37 | } 38 | 39 | let data; 40 | let text; 41 | let response; 42 | try { 43 | response = await window.fetch(endpoint, config); 44 | text = await response.text(); 45 | 46 | if (response.url.includes("login.html")) { 47 | throw new Error("Got redirected to login page, authentification failed."); 48 | } 49 | 50 | if (response.ok) { 51 | data = text.length ? text : ""; 52 | return data; 53 | } 54 | throw new Error(response.statusText); 55 | } catch (err) { 56 | return Promise.reject(response.status ? 57 | "Status " + response.status + ": " + text : 58 | err.message, 59 | ); 60 | } 61 | } 62 | 63 | client.get = function (endpoint, customConfig = {}) { 64 | return client(endpoint, { ...customConfig, method: "GET" }); 65 | }; 66 | 67 | client.post = function (endpoint, body, customConfig = {}) { 68 | return client(endpoint, { ...customConfig, body }); 69 | }; 70 | 71 | client.delete = function (endpoint, customConfig = {}) { 72 | return client(endpoint, { ...customConfig, method: "DELETE" }); 73 | }; 74 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 12 | 16 | 17 | Opencast Editor 18 | 52 | 53 | 54 | 55 | 56 |
    57 | 58 |
    59 | 60 | 61 | 62 |
    63 |
    64 | 65 | 66 | -------------------------------------------------------------------------------- /src/main/TheEnd.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { css } from "@emotion/react"; 4 | 5 | import { LuCircleCheck, LuCircleX } from "react-icons/lu"; 6 | 7 | import { useAppSelector } from "../redux/store"; 8 | import { selectEndState } from "../redux/endSlice"; 9 | import { basicButtonStyle, navigationButtonStyle } from "../cssStyles"; 10 | 11 | import { useTranslation } from "react-i18next"; 12 | import { useTheme } from "../themes"; 13 | import { ThemedTooltip } from "./Tooltip"; 14 | import { CallbackButton } from "./Finish"; 15 | import { ProtoButton } from "@opencast/appkit"; 16 | 17 | /** 18 | * This page is to be displayed when the user is "done" with the editor 19 | * and should not be able to perfom any actions anymore 20 | */ 21 | const TheEnd: React.FC = () => { 22 | 23 | const { t } = useTranslation(); 24 | 25 | // Init redux variables 26 | const endState = useAppSelector(selectEndState); 27 | 28 | const text = () => { 29 | if (endState === "discarded") { 30 | return t("theEnd.discarded-text"); 31 | } else if (endState === "success") { 32 | return t("theEnd.info-text"); 33 | } 34 | }; 35 | 36 | const theEndStyle = css({ 37 | width: "100%", 38 | height: "calc(100vh - 64px)", 39 | display: "flex", 40 | flexDirection: "column", 41 | justifyContent: "center", 42 | alignItems: "center", 43 | gap: "20px", 44 | }); 45 | 46 | const restartOrBackStyle = css({ 47 | display: "flex", 48 | flexDirection: "row", 49 | gap: "20px", 50 | }); 51 | 52 | return ( 53 |
    54 | {endState === "discarded" ? : } 55 |
    {text()}
    56 |
    57 | 58 | {(endState === "discarded") && } 59 |
    60 |
    61 | ); 62 | }; 63 | 64 | 65 | const StartOverButton: React.FC = () => { 66 | 67 | const { t } = useTranslation(); 68 | const theme = useTheme(); 69 | 70 | const reloadPage = () => { 71 | window.location.reload(); 72 | }; 73 | 74 | return ( 75 | 76 | 80 | {t("theEnd.startOver-button")} 81 | 82 | 83 | ); 84 | }; 85 | 86 | export default TheEnd; 87 | -------------------------------------------------------------------------------- /src/main/CuttingActionsContextMenu.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useTranslation } from "react-i18next"; 3 | 4 | import { LuChevronLeft, LuChevronRight, LuScissors, LuTrash } from "react-icons/lu"; 5 | import TrashRestore from "../img/trash-restore.svg?react"; 6 | 7 | import { ContextMenuItem, ThemedContextMenu } from "./ContextMenu"; 8 | import { KEYMAP, rewriteKeys } from "../globalKeys"; 9 | import { useAppDispatch, useAppSelector } from "../redux/store"; 10 | import { cut, markAsDeletedOrAlive, mergeLeft, mergeRight, selectIsCurrentSegmentAlive } from "../redux/videoSlice"; 11 | 12 | const CuttingActionsContextMenu: React.FC<{ 13 | children: React.ReactNode, 14 | }> = ({ 15 | children, 16 | }) => { 17 | 18 | const { t } = useTranslation(); 19 | 20 | // Init redux variables 21 | const dispatch = useAppDispatch(); 22 | const isCurrentSegmentAlive = useAppSelector(selectIsCurrentSegmentAlive); 23 | 24 | const cuttingContextMenuItems: ContextMenuItem[] = [ 25 | { 26 | name: t("cuttingActions.cut-button"), 27 | action: () => dispatch(cut()), 28 | icon: LuScissors, 29 | hotKey: KEYMAP.cutting.cut.key, 30 | ariaLabel: t("cuttingActions.cut-tooltip-aria", { 31 | hotkeyName: rewriteKeys(KEYMAP.cutting.cut.key), 32 | }), 33 | }, 34 | { 35 | name: isCurrentSegmentAlive ? t("cuttingActions.delete-button") : t("cuttingActions.restore-button"), 36 | action: () => dispatch(markAsDeletedOrAlive()), 37 | icon: isCurrentSegmentAlive ? LuTrash : TrashRestore, 38 | hotKey: KEYMAP.cutting.delete.key, 39 | ariaLabel: t("cuttingActions.delete-restore-tooltip-aria", { 40 | hotkeyName: rewriteKeys(KEYMAP.cutting.delete.key), 41 | }), 42 | }, 43 | { 44 | name: t("cuttingActions.mergeLeft-button"), 45 | action: () => dispatch(mergeLeft()), 46 | icon: LuChevronLeft, 47 | hotKey: KEYMAP.cutting.mergeLeft.key, 48 | ariaLabel: t("cuttingActions.mergeLeft-tooltip-aria", { 49 | hotkeyName: rewriteKeys(KEYMAP.cutting.mergeLeft.key), 50 | }), 51 | }, 52 | { 53 | name: t("cuttingActions.mergeRight-button"), 54 | action: () => dispatch(mergeRight()), 55 | icon: LuChevronRight, 56 | hotKey: KEYMAP.cutting.mergeRight.key, 57 | ariaLabel: t("cuttingActions.mergeRight-tooltip-aria", { 58 | hotkeyName: rewriteKeys(KEYMAP.cutting.mergeRight.key), 59 | }), 60 | }, 61 | ]; 62 | 63 | return ( 64 | 67 | {children} 68 | 69 | ); 70 | }; 71 | 72 | export default CuttingActionsContextMenu; 73 | -------------------------------------------------------------------------------- /src/main/FinishMenu.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { css } from "@emotion/react"; 4 | import { basicButtonStyle, tileButtonStyle } from "../cssStyles"; 5 | 6 | import { IconType } from "react-icons"; 7 | import { LuSave, LuDatabase, LuCircleX } from "react-icons/lu"; 8 | 9 | import { useAppDispatch } from "../redux/store"; 10 | import { setState, setPageNumber, finish } from "../redux/finishSlice"; 11 | 12 | import { useTranslation } from "react-i18next"; 13 | import { useTheme } from "../themes"; 14 | import { ProtoButton } from "@opencast/appkit"; 15 | 16 | /** 17 | * Displays a menu for selecting what should be done with the current changes 18 | */ 19 | const FinishMenu: React.FC = () => { 20 | 21 | const finishMenuStyle = css({ 22 | display: "flex", 23 | flexDirection: "row", 24 | justifyContent: "center", 25 | flexWrap: "wrap", 26 | gap: "30px", 27 | }); 28 | 29 | return ( 30 |
    31 | 32 | 33 | 34 |
    35 | ); 36 | }; 37 | 38 | /** 39 | * Buttons for the finish menu 40 | */ 41 | const FinishMenuButton: React.FC<{ Icon: IconType, stateName: finish["value"]; }> = ({ Icon, stateName }) => { 42 | 43 | const { t } = useTranslation(); 44 | const theme = useTheme(); 45 | const dispatch = useAppDispatch(); 46 | 47 | const finish = () => { 48 | dispatch(setState(stateName)); 49 | dispatch(setPageNumber(1)); 50 | }; 51 | 52 | let buttonString; 53 | switch (stateName) { 54 | case "Save changes": 55 | buttonString = t("finishMenu.save-button"); 56 | break; 57 | case "Start processing": 58 | buttonString = t("finishMenu.start-button"); 59 | break; 60 | case "Discard changes": 61 | buttonString = t("finishMenu.discard-button"); 62 | break; 63 | default: 64 | buttonString = "Could not load String value"; 65 | break; 66 | } 67 | 68 | const iconStyle = css({ 69 | display: "flex", 70 | justifyContent: "center", 71 | alignItems: "center", 72 | 73 | background: `${theme.background_finish_menu_icon}`, 74 | color: `${theme.text}`, 75 | borderRadius: "50%", 76 | width: "90px", 77 | height: "90px", 78 | }); 79 | 80 | const labelStyle = css({ 81 | padding: "0px 20px", 82 | }); 83 | 84 | return ( 85 | 89 |
    90 | 91 |
    92 |
    {buttonString}
    93 |
    94 | ); 95 | }; 96 | 97 | export default FinishMenu; 98 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { Catalog } from "./redux/metadataSlice"; 2 | 3 | export interface Segment { 4 | id: string, 5 | start: number, 6 | end: number, 7 | deleted: boolean, 8 | text?: string, // For chapters 9 | } 10 | 11 | export interface Track { 12 | id: string, 13 | uri: string, 14 | flavor: Flavor, 15 | audio_stream: {available: boolean, enabled: boolean, thumbnail_uri: string}, 16 | video_stream: {available: boolean, enabled: boolean, thumbnail_uri: string}, 17 | thumbnailUri: string | undefined, 18 | thumbnailPriority: number, 19 | } 20 | 21 | export interface Flavor { 22 | type: string, 23 | subtype: string, 24 | } 25 | 26 | export interface Workflow { 27 | id: string, 28 | name: string, 29 | description: string, 30 | displayOrder: number, 31 | } 32 | 33 | export interface TimelineState { 34 | segments: Segment[] 35 | scrubberPos: number 36 | } 37 | 38 | export interface SubtitlesFromOpencast { 39 | id: string, 40 | subtitle: string, 41 | tags: string[], 42 | } 43 | 44 | export interface SubtitlesInEditor { 45 | cues: SubtitleCue[], 46 | tags: string[], 47 | deleted: boolean, 48 | } 49 | 50 | export interface SubtitleCue { 51 | id?: string, // Actually not useful as an identifier, as it is not guaranteed to exist 52 | idInternal: string, // Identifier for internal use. Has nothing to do with the webvtt parser. 53 | text: string, 54 | startTime: number, 55 | endTime: number, 56 | // Odditiy of the webvtt parser. Changes to text also need to be applied to tree.children[0].value 57 | tree: {children: [{type: string, value: string}]} 58 | // And many more 59 | } 60 | 61 | export interface ExtendedSubtitleCue extends SubtitleCue { 62 | alignment : string 63 | direction : string 64 | lineAlign : string 65 | linePosition : string 66 | positionAlign : string 67 | size : number 68 | textPosition : string 69 | } 70 | 71 | export interface PostEditArgument { 72 | segments: Segment[] 73 | tracks: Track[] 74 | customizedTrackSelection: boolean 75 | subtitles: SubtitlesFromOpencast[] 76 | chapters: SubtitlesFromOpencast[] 77 | workflow?: [{id: string}] 78 | metadata: Catalog[] 79 | } 80 | 81 | // Use respective i18n keys as values 82 | export enum MainMenuStateNames { 83 | cutting = "mainMenu.cutting-button", 84 | metadata = "mainMenu.metadata-button", 85 | trackSelection = "mainMenu.select-tracks-button", 86 | subtitles = "mainMenu.subtitles-button", 87 | chapters = "mainMenu.chapters-button", 88 | thumbnail = "mainMenu.thumbnail-button", 89 | finish = "mainMenu.finish-button", 90 | keyboardControls = "mainMenu.keyboard-controls-button", 91 | } 92 | 93 | export interface httpRequestState { 94 | status: "idle" | "loading" | "success" | "failed", 95 | error: string | undefined, 96 | errorReason: "unknown" | "workflowActive" 97 | } 98 | -------------------------------------------------------------------------------- /.github/assets/index.css: -------------------------------------------------------------------------------- 1 | /*This contains Css for the generated index.html*/ 2 | @media (prefers-color-scheme: light) { 3 | :root { 4 | --header-color: #575757; 5 | --bg-color: #F3F3F3; 6 | --text-color: #0F0F0F; 7 | --container-color: #fefefe; 8 | --boxShadow-color: #0000004d; 9 | --item-color: #fefefe; 10 | --item-border: #d6cbc9; 11 | --item-hover: #f3f3f3; 12 | --item-boxShadow-color: #96969626; 13 | } 14 | } 15 | 16 | @media (prefers-color-scheme: dark) { 17 | :root { 18 | --header-color: #2E2E2E; 19 | --bg-color: #1E1E1E; 20 | --text-color: #FFF; 21 | --container-color: #171717; 22 | --boxShadow-color: #0000004d; 23 | --item-color: #171717; 24 | --item-border: #151515; 25 | --item-hover: #2e2e2e; 26 | --item-boxShadow-color: #0000004d; 27 | } 28 | } 29 | 30 | body { 31 | margin: 0px; 32 | background: var(--bg-color, #F3F3F3); 33 | font-family: Roboto Flex Variable, Roboto, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; 34 | } 35 | 36 | /* Text Container */ 37 | .text-container { 38 | /* Layout */ 39 | margin: 3% auto; 40 | max-width: 75%; 41 | padding: 10px 15px 10px 15px; 42 | 43 | /* Styling */ 44 | border-radius: 5px; 45 | background: var(--container-color, #fefefe); 46 | box-shadow: 0 1px 3px 1px var(--boxShadow-color, #0000004d); 47 | } 48 | .text-container > p { 49 | color: var(--text-color, #0F0F0F); 50 | text-align: center; 51 | font-size: 20px; 52 | font-weight: 400; 53 | } 54 | 55 | /* Header */ 56 | .head-container { 57 | width: 100%; 58 | height: 45px; 59 | background: var(--header-color, #575757); 60 | } 61 | .head-container > img { 62 | /*Image Style*/ 63 | width: 180px; 64 | height: 40px; 65 | padding: 4px 10px 4px; 66 | } 67 | 68 | /* Version-List */ 69 | ul { 70 | list-style-type: none; 71 | display: flex; 72 | justify-content: center; 73 | align-items: center; 74 | flex-wrap: wrap; 75 | column-gap: 20px; 76 | row-gap: 10px; 77 | } 78 | 79 | li { 80 | width: 160px; 81 | text-align: center; 82 | padding: 15px 10px; 83 | border-radius: 4px; 84 | border: 1px solid var(--item-border, #d6cbc9); 85 | background: var(--item-color, #fefefe); 86 | box-shadow: 0px 5px 10px 0px var(--item-boxShadow-color, #96969626); 87 | } 88 | 89 | li:hover { 90 | background-color: var(--item-hover, #f3f3f3); 91 | } 92 | 93 | li > a { 94 | color: var(--text-color, #0F0F0F); 95 | font-size: 18px; 96 | font-weight: 400; 97 | text-decoration: none; 98 | padding:15px 50px; 99 | } 100 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "opencast-editor", 3 | "version": "0.1.0", 4 | "type": "module", 5 | "private": true, 6 | "dependencies": { 7 | "@emotion/react": "^11.14.0", 8 | "@emotion/styled": "^11.14.1", 9 | "@fontsource-variable/roboto-flex": "^5.2.6", 10 | "@iarna/toml": "^2.2.5", 11 | "@mui/material": "^6.4.1", 12 | "@opencast/appkit": "^0.4.0", 13 | "@reduxjs/toolkit": "^2.8.2", 14 | "@types/react": "^18.3.12", 15 | "@types/react-dom": "^18.3.1", 16 | "deepmerge": "^4.3.1", 17 | "emotion-normalize": "^11.0.1", 18 | "final-form": "^4.20.10", 19 | "i18next": "^25.3.2", 20 | "i18next-browser-languagedetector": "^8.2.0", 21 | "i18next-chained-backend": "^4.6.2", 22 | "i18next-resources-to-backend": "^1.2.1", 23 | "lodash": "^4.17.21", 24 | "luxon": "^3.6.1", 25 | "mui-rff": "^8.0.4", 26 | "react": "^18.2.0", 27 | "react-beforeunload": "^2.6.0", 28 | "react-device-detect": "^2.2.3", 29 | "react-dom": "^18.3.1", 30 | "react-draggable": "^4.5.0", 31 | "react-final-form": "^6.5.9", 32 | "react-hotkeys-hook": "^5.2.1", 33 | "react-i18next": "^15.4.0", 34 | "react-icons": "^5.5.0", 35 | "react-indiana-drag-scroll": "^2.2.1", 36 | "react-player": "git+https://arnei@github.com/Arnei/react-player.git#b441d7aafe9b98745318103d9c93872a3ffc5da9", 37 | "react-redux": "^9.2.0", 38 | "react-resizable": "^3.0.5", 39 | "react-select": "^5.10.1", 40 | "react-virtualized-auto-sizer": "^1.0.26", 41 | "react-window": "^1.8.11", 42 | "redux": "^5.0.1", 43 | "smol-toml": "^1.4.0", 44 | "standardized-audio-context": "^25.3.77", 45 | "typescript": "^5.8.3", 46 | "uuid": "^11.0.5", 47 | "webvtt-parser": "^2.2.0" 48 | }, 49 | "scripts": { 50 | "start": "vite", 51 | "build": "tsc && eslint . --max-warnings=0 && vite build", 52 | "serve": "vite preview", 53 | "test": "vitest", 54 | "playtest": "npx playwright test --config=playwright.config.ts" 55 | }, 56 | "browserslist": { 57 | "production": [ 58 | ">0.2%", 59 | "not dead", 60 | "not op_mini all" 61 | ], 62 | "development": [ 63 | "last 1 chrome version", 64 | "last 1 firefox version", 65 | "last 1 safari version" 66 | ] 67 | }, 68 | "devDependencies": { 69 | "@opencast/eslint-config-ts-react": "^0.3.0", 70 | "@playwright/test": "^1.53.2", 71 | "@types/lodash": "^4.17.19", 72 | "@types/luxon": "^3.6.2", 73 | "@types/react-beforeunload": "^2.1.5", 74 | "@types/react-resizable": "^3.0.8", 75 | "@types/react-virtualized-auto-sizer": "^1.0.4", 76 | "@types/react-window": "^1.8.8", 77 | "@types/uuid": "^10.0.0", 78 | "@vitejs/plugin-react": "^4.6.0", 79 | "eslint": "^9.30.0", 80 | "jsdom": "^26.1.0", 81 | "typescript-eslint": "^8.35.1", 82 | "use-resize-observer": "^9.1.0", 83 | "vite": "^7.0.6", 84 | "vite-plugin-svgr": "^4.3.0", 85 | "vitest": "^3.2.4" 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/main/Lock.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | import { LuLock } from "react-icons/lu"; 3 | 4 | import { useAppDispatch, useAppSelector } from "../redux/store"; 5 | import { settings } from "../config"; 6 | import { setLock, video } from "../redux/videoSlice"; 7 | import { selectIsEnd } from "../redux/endSlice"; 8 | import { setError } from "../redux/errorSlice"; 9 | import { client } from "../util/client"; 10 | import { useInterval } from "../util/utilityFunctions"; 11 | import { useBeforeunload } from "react-beforeunload"; 12 | 13 | const Lock: React.FC = () => { 14 | const endpoint = `${settings.opencast.url}/editor/${settings.id}/lock`; 15 | 16 | const dispatch = useAppDispatch(); 17 | const lockingActive = useAppSelector((state: { videoState: { lockingActive: video["lockingActive"]; }; }) => 18 | state.videoState.lockingActive); 19 | const lockRefresh = useAppSelector((state: { videoState: { lockRefresh: video["lockRefresh"]; }; }) => 20 | state.videoState.lockRefresh); 21 | const lockState = useAppSelector((state: { videoState: { lockState: video["lockState"]; }; }) => 22 | state.videoState.lockState); 23 | const lock = useAppSelector((state: { videoState: { lock: video["lock"]; }; }) => state.videoState.lock); 24 | const isEnd = useAppSelector(selectIsEnd); 25 | 26 | function requestLock() { 27 | const form = `user=${lock.user}&uuid=${lock.uuid}`; 28 | client.post(endpoint, form, { 29 | headers: { 30 | "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", 31 | }, 32 | }) 33 | .then(() => dispatch(setLock(true))) 34 | .catch((error: string) => { 35 | dispatch(setLock(false)); 36 | dispatch(setError({ 37 | error: true, 38 | errorDetails: error, 39 | errorIcon: LuLock, 40 | errorTitle: "Video editing locked", 41 | errorMessage: "This video is currently being edited by another user", 42 | })); 43 | }); 44 | } 45 | 46 | function releaseLock() { 47 | if (lockingActive && lockState) { 48 | client.delete(endpoint + "/" + lock.uuid) 49 | .then(() => { 50 | console.info("Lock released"); 51 | dispatch(setLock(false)); 52 | }); 53 | } 54 | } 55 | 56 | // Request lock 57 | useEffect(() => { 58 | if (lockingActive) { 59 | requestLock(); 60 | } 61 | // eslint-disable-next-line react-hooks/exhaustive-deps 62 | }, [lockingActive]); 63 | 64 | // Refresh lock 65 | useInterval(() => { 66 | requestLock(); 67 | }, lockingActive ? lockRefresh : null); 68 | 69 | // Release lock on leaving page 70 | useBeforeunload((_event: { preventDefault: () => void; }) => { 71 | releaseLock(); 72 | }); 73 | 74 | // Release lock on discard 75 | useEffect(() => { 76 | if (isEnd) { 77 | releaseLock(); 78 | } 79 | // eslint-disable-next-line react-hooks/exhaustive-deps 80 | }, [isEnd]); 81 | 82 | return (<>); 83 | }; 84 | 85 | export default Lock; 86 | -------------------------------------------------------------------------------- /tests/metadata.test.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test'; 2 | 3 | test.beforeEach(async ({ page, baseURL }) => { 4 | await page.goto(baseURL); 5 | await page.click('button[role="menuitem"]:has-text("Metadata")'); 6 | }); 7 | 8 | test.describe('Test Metadata-Page', () => { 9 | 10 | // checks if fields contains their dummy-data 11 | test('Check Inputfields', async ({ page }) => { 12 | 13 | for (let i = 0; i < input.length; i++) { 14 | const locator = page.locator('input[name="' + input[i] + '"]'); 15 | await expect(locator).toHaveValue(iValue[i]); 16 | await page.click('input[name="' + input[i] + '"]'); 17 | await page.fill('input[name="' + input[i] + '"]', iFill[i]); 18 | } 19 | for (let i = 0; i < dropdown.length; i++) { 20 | const locator = page.locator('input[name="' + dropdown[i] + '"]'); 21 | await expect(locator).toHaveValue(dValue[i]); 22 | } 23 | }); 24 | 25 | test('Check other fields', async ({ page }) => { 26 | 27 | // different syntax and or not editable 28 | const description = page.locator('textarea[name="description"]'); 29 | await expect(description).toHaveValue(''); 30 | await page.click('textarea[name="description"]'); 31 | await page.fill('textarea[name="description"]', 'Test-Description'); 32 | 33 | const startDate = page.locator('input[name="startDate"]'); 34 | await expect(startDate).toHaveValue(/[0-9]/); 35 | 36 | const created = page.locator('input[name="created"]'); 37 | await expect(created).toHaveValue(/[0-9]/); 38 | 39 | const publisher = page.locator('input[name="publisher"]'); 40 | await expect(publisher).toHaveValue(''); 41 | 42 | const identifier = page.locator('input[name="identifier"]'); 43 | await expect(identifier).toHaveValue('ID-dual-stream-demo'); 44 | 45 | }); 46 | 47 | test('Check: Change Dropdown Value', async ({ page, browserName }) => { 48 | 49 | // Language 50 | await page.click('[data-testid="language"] [class*="-control"]'); 51 | await page.click('div[id*="option-22"]'); 52 | 53 | // License 54 | await page.click('[data-testid="license"] [class*="-control"]'); 55 | await page.click('div[id*="option-8"]'); 56 | 57 | // Series / isPartOf 58 | await page.click('[data-testid="isPartOf"] [class*="-control"]'); 59 | await page.click('div[id*="option-4"]'); 60 | 61 | // Creator 62 | await page.click('[data-testid="creator"] [class*="-control"]'); 63 | await page.click('div[id*="option-15"]'); 64 | await page.click('[aria-label="Remove Lars Kiesow"]'); 65 | 66 | // Contributor 67 | await page.click('[data-testid="contributor"] [class*="-control"]'); 68 | await page.click('div[id*="option-15"]'); 69 | }); 70 | 71 | }); 72 | 73 | const input = ['title', 'subject', 'rightsHolder', 'duration', 'location', 'source']; 74 | const iValue = ['Dual-Stream Demo', '', '', '00:01:04', '', '']; 75 | const iFill = ['Test-Title', 'Test-Subject', 'Test-Rights', '00:02:45', 'Test-Location', 'Test-Source']; 76 | 77 | const dropdown = ['language', 'license', 'isPartOf', 'creator', 'contributor']; 78 | const dValue = ['', 'CC-BY-SA', '', 'Lars Kiesow', '']; 79 | 80 | -------------------------------------------------------------------------------- /.github/workflows/release-build.yml: -------------------------------------------------------------------------------- 1 | name: Release » Process release tag 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | tags: 7 | # CF: 17.x-YYYY-MM-DD 8 | - '*.x-*-*-*' 9 | 10 | jobs: 11 | build-release-tarballs: 12 | name: Create release from tag 13 | if: github.repository_owner == 'Arnei' 14 | runs-on: ubuntu-latest 15 | outputs: 16 | checksum: ${{ steps.tarball.outputs.checksum }} 17 | tag: ${{ steps.tarball.outputs.tag }} 18 | branch: ${{ steps.tarball.outputs.branch }} 19 | permissions: 20 | contents: write #for the release 21 | pull-requests: write #For the PR in the upstream repo 22 | 23 | steps: 24 | - name: Checkout sources 25 | uses: actions/checkout@v5 26 | 27 | - name: Get Node.js 28 | uses: actions/setup-node@v5 29 | with: 30 | node-version: 20 31 | 32 | - name: Run npm ci 33 | run: npm ci 34 | 35 | - name: Build the app 36 | env: 37 | PUBLIC_URL: /editor-ui 38 | VITE_APP_SETTINGS_PATH: /ui/config/editor/editor-settings.toml 39 | run: npm run build 40 | 41 | - name: Create release tarball 42 | id: tarball 43 | working-directory: build 44 | env: 45 | GH_TOKEN: ${{ github.token }} 46 | run: | 47 | tar -czf "../oc-editor-$(git describe --tags).tar.gz" * 48 | echo checksum=`sha256sum ../oc-editor-$(git describe --tags).tar.gz | cut -f 1 -d " "` >> $GITHUB_OUTPUT 49 | echo tag=$(git describe --tags) >> $GITHUB_OUTPUT 50 | while read branchname 51 | do 52 | LAST_BRANCH="$branchname" 53 | #branchname looks like 'r/17.x', the describe + cut looks like '17.x', so we prefix with r/ 54 | # NB: branchname == develop is not handled here, it's handled below 55 | if [ "$branchname" != "r/`git describe --tags | cut -f 1 -d -`" ]; then 56 | continue 57 | fi 58 | echo "Base branch is $branchname" 59 | BASE_BRANCH="$branchname" 60 | echo "branch=$BASE_BRANCH" >> $GITHUB_OUTPUT 61 | break 62 | done <<< `gh api \ 63 | -H "Accept: application/vnd.github+json" \ 64 | -H "X-GitHub-Api-Version: 2022-11-28" \ 65 | /repos/${{ github.repository_owner }}/opencast/branches?per_page=100 | \ 66 | jq -r '. | map(select(.name | match("r/[0-9]*.x"))) | .[].name'` 67 | #Figure out what develop branch's version should be 68 | # Bash is, without a doubt, the worst possible way to do this, but here we are... 69 | export DEVELOP_VERSION=$(($(echo $LAST_BRANCH | cut -f 2 -d '/' | cut -f 1 -d '.') + 1)).x 70 | #If we didn't find a match above, *and* the develop branch matches the tag 71 | if [ -z "${BASE_BRANCH}" -a "$DEVELOP_VERSION" == "`git describe --tags | cut -f 1 -d -`" ]; then 72 | echo "Base branch is develop, version $DEVELOP_VERSION" 73 | # Develop is by definition (LAST_BRANCH + 1).x 74 | echo "branch=develop" >> $GITHUB_OUTPUT 75 | fi 76 | 77 | - name: Create new release in github 78 | uses: softprops/action-gh-release@v2 79 | with: 80 | files: oc-editor-*.tar.gz 81 | fail_on_unmatched_files: true 82 | generate_release_notes: true 83 | -------------------------------------------------------------------------------- /src/main/ContextMenu.tsx: -------------------------------------------------------------------------------- 1 | import { Menu, MenuItem } from "@mui/material"; 2 | import React, { MouseEventHandler } from "react"; 3 | import { IconType } from "react-icons"; 4 | 5 | import { useTheme } from "../themes"; 6 | import { customIconStyle } from "../cssStyles"; 7 | 8 | export interface ContextMenuItem { 9 | name: string, 10 | action: MouseEventHandler, 11 | ariaLabel: string, 12 | icon?: IconType | React.FunctionComponent, 13 | hotKey?: string, 14 | } 15 | 16 | /** 17 | * Context menu component 18 | * 19 | * @param menuItems Menu items 20 | * @param children Content between the opening and the closing tag where the context menu should be triggered 21 | */ 22 | export const ThemedContextMenu: React.FC<{ 23 | menuItems: ContextMenuItem[], 24 | children: React.ReactNode, 25 | }> = ({ 26 | menuItems, 27 | children, 28 | }) => { 29 | 30 | const theme = useTheme(); 31 | 32 | // Init state variables 33 | const [contextMenuPosition, setContextMenuPosition] = React.useState<{ 34 | left: number, 35 | top: number, 36 | } | null>(null); 37 | 38 | const handleContextMenu = (e: React.MouseEvent) => { 39 | e.preventDefault(); 40 | 41 | setContextMenuPosition(contextMenuPosition === null 42 | ? { left: e.clientX + 5, top: e.clientY } 43 | : null, // Prevent relocation of context menu outside the element 44 | ); 45 | }; 46 | 47 | const handleClose = () => { 48 | setContextMenuPosition(null); 49 | }; 50 | 51 | /** 52 | * Handles the click on a menu item 53 | * 54 | * @param e mouse event 55 | * @param action menu item action 56 | */ 57 | const handleAction = (e: React.MouseEvent, action: MouseEventHandler) => { 58 | action(e); 59 | 60 | // Immediately close menu after action 61 | handleClose(); 62 | }; 63 | 64 | const renderMenuItems = () => { 65 | return menuItems.map((menuItem, i) => ( 66 | handleAction(e, menuItem.action)} 69 | sx={{ 70 | fontFamily: "inherit", 71 | gap: "15px", 72 | }} 73 | aria-label={menuItem.ariaLabel} 74 | > 75 | {menuItem.icon && 76 | 77 | } 78 |
    79 | {menuItem.name} 80 |
    81 | {menuItem.hotKey && 82 |
    86 | {menuItem.hotKey} 87 |
    88 | } 89 |
    90 | )); 91 | }; 92 | 93 | return ( 94 |
    97 | {children} 98 | 99 | 114 | { renderMenuItems() } 115 | 116 |
    117 | ); 118 | }; 119 | -------------------------------------------------------------------------------- /src/main/Finish.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import FinishMenu from "./FinishMenu"; 4 | import Save from "./Save"; 5 | import Discard from "./Discard"; 6 | import WorkflowSelection from "./WorkflowSelection"; 7 | import WorkflowConfiguration from "./WorkflowConfiguration"; 8 | 9 | import { LuDoorOpen } from "react-icons/lu"; 10 | 11 | import { css } from "@emotion/react"; 12 | import { basicButtonStyle, navigationButtonStyle } from "../cssStyles"; 13 | 14 | import { IconType } from "react-icons"; 15 | 16 | import { useAppDispatch, useAppSelector } from "../redux/store"; 17 | import { selectFinishState, selectPageNumber, setPageNumber } from "../redux/finishSlice"; 18 | import { useTheme } from "../themes"; 19 | import { settings } from "../config"; 20 | import { useTranslation } from "react-i18next"; 21 | import { ProtoButton } from "@opencast/appkit"; 22 | 23 | /** 24 | * Displays a menu for selecting what should be done with the current changes 25 | */ 26 | const Finish: React.FC = () => { 27 | 28 | const pageNumber = useAppSelector(selectPageNumber); 29 | const finishState = useAppSelector(selectFinishState); 30 | 31 | const render = () => { 32 | if (pageNumber === 0) { 33 | return ( 34 | 35 | ); 36 | } else if (pageNumber === 1) { 37 | if (finishState === "Save changes") { 38 | return ( 39 | 40 | ); 41 | } else if (finishState === "Start processing") { 42 | return ( 43 | 44 | ); 45 | } else if (finishState === "Discard changes") { 46 | return ( 47 | 48 | ); 49 | } 50 | } else if (pageNumber === 2) { 51 | return ( 52 | 53 | ); 54 | } 55 | }; 56 | 57 | return ( 58 | <>{render()} 59 | ); 60 | }; 61 | 62 | /** 63 | * Takes you to a different page 64 | */ 65 | export const PageButton: React.FC<{ 66 | pageNumber: number, 67 | label: string, 68 | Icon: IconType; 69 | }> = ({ 70 | pageNumber, 71 | label, 72 | Icon, 73 | }) => { 74 | 75 | const theme = useTheme(); 76 | 77 | // Initialize redux variables 78 | const dispatch = useAppDispatch(); 79 | 80 | const onPageChange = () => { 81 | dispatch(setPageNumber(pageNumber)); 82 | }; 83 | 84 | const pageButtonStyle = css({ 85 | minWidth: "100px", 86 | padding: "16px", 87 | justifyContent: "center", 88 | boxShadow: `${theme.boxShadow}`, 89 | background: `${theme.element_bg}`, 90 | }); 91 | 92 | return ( 93 | 97 | 98 | {label} 99 | 100 | ); 101 | }; 102 | 103 | /** 104 | * Takes you back to the callback url resource 105 | */ 106 | export const CallbackButton: React.FC = () => { 107 | 108 | const { t } = useTranslation(); 109 | 110 | const theme = useTheme(); 111 | 112 | const openCallbackUrl = () => { 113 | window.open(settings.callbackUrl, "_self"); 114 | }; 115 | 116 | return ( 117 | <> 118 | {settings.callbackUrl !== undefined && 119 | 123 | 124 | 125 | {settings.callbackSystem ? 126 | t("various.callback-button-system", { system: settings.callbackSystem }) : 127 | t("various.callback-button-generic") 128 | } 129 | 130 | 131 | } 132 | 133 | ); 134 | }; 135 | 136 | 137 | export default Finish; 138 | -------------------------------------------------------------------------------- /.github/workflows/deploy-main-branches.yml: -------------------------------------------------------------------------------- 1 | name: Build » Deploy main branches 2 | 3 | on: 4 | push: 5 | branches: 6 | - develop 7 | - r/* 8 | 9 | concurrency: 10 | group: ${{ github.workflow }} 11 | cancel-in-progress: false 12 | 13 | jobs: 14 | detect-repo-owner: 15 | if: github.repository_owner == 'opencast' 16 | runs-on: ubuntu-latest 17 | outputs: 18 | server: ${{ steps.test-server.outputs.server }} 19 | branch: ${{ steps.branch-name.outputs.branch }} 20 | steps: 21 | - name: Checkout sources 22 | uses: actions/checkout@v5 23 | 24 | - name: Determine the correct test server 25 | id: test-server 26 | run: echo "server=`./.github/get-release-server.sh ${{ github.ref_name }}`" >> $GITHUB_OUTPUT 27 | 28 | - name: Determine branch name 29 | id: branch-name 30 | run: | 31 | #Temp becomes something like r/17.x 32 | export TEMP=${{ github.ref_name }} 33 | #Strip the r/ prefix, giving us just 17.x. If this is main/develop this does nothing 34 | echo "branch=${TEMP#r\/}" >> $GITHUB_OUTPUT 35 | 36 | deploy-main-branches: 37 | runs-on: ubuntu-latest 38 | needs: detect-repo-owner 39 | steps: 40 | - name: Checkout sources 41 | uses: actions/checkout@v5 42 | 43 | - name: Get Node.js 44 | uses: actions/setup-node@v5 45 | with: 46 | node-version: 20 47 | 48 | - name: Run npm ci 49 | run: npm ci 50 | 51 | - name: Build the app 52 | run: | 53 | # This set the editor's datasource to the relevant test server 54 | sed -i "s#develop.opencast.org#$SERVER#g" public/editor-settings.toml 55 | npm run build 56 | env: 57 | SERVER: ${{needs.detect-repo-owner.outputs.server}} 58 | PUBLIC_URL: ${{needs.detect-repo-owner.outputs.branch}} 59 | VITE_APP_SETTINGS_PATH: editor-settings.toml 60 | 61 | # tests are currently failing 62 | #- run: npm test 63 | # env: 64 | # CI: true 65 | 66 | - name: Prepare git 67 | run: | 68 | git config --global user.name "Editor Deployment Bot" 69 | git config --global user.email "cloud@opencast.org" 70 | 71 | - name: Commit new version 72 | run: | 73 | git checkout -- public/editor-settings.toml 74 | git fetch --unshallow origin gh-pages 75 | git checkout gh-pages 76 | # Update gh-pages 77 | rm -rf $BRANCH 78 | mv build $BRANCH 79 | #Generate an index, in case anyone lands at the root of the test domain 80 | echo $'

    Deployment for the latest development versions of the Opencast editor.The branches listed here correspond to Opencast\'s own branches.
    Please select a version.

    ' >> index.html 86 | git add $BRANCH index.html 87 | git diff --staged --quiet || git commit --amend -m "Build $(date)" 88 | env: 89 | BRANCH: ${{needs.detect-repo-owner.outputs.branch}} 90 | 91 | - name: update CSS and other assets 92 | if: github.ref == 'refs/heads/develop' 93 | run: | 94 | rm -rf assets 95 | mv assets_temp assets 96 | git add assets 97 | git diff --staged --quiet || git commit --amend -m "Build $(date)" 98 | 99 | 100 | - name: Push updates 101 | run: git push origin gh-pages --force 102 | -------------------------------------------------------------------------------- /src/redux/workflowPostSlice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice } from "@reduxjs/toolkit"; 2 | import { client } from "../util/client"; 3 | import { Segment, PostEditArgument, httpRequestState } from "../types"; 4 | import { settings } from "../config"; 5 | import { createAppAsyncThunk } from "./createAsyncThunkWithTypes"; 6 | import { selectCatalogById, selectCatalogIds, selectFieldById } from "./metadataSlice"; 7 | 8 | const initialState: httpRequestState = { 9 | status: "idle", 10 | error: undefined, 11 | errorReason: "unknown", 12 | }; 13 | 14 | export const postVideoInformation = 15 | createAppAsyncThunk("video/postVideoInformation", async (argument: PostEditArgument, { getState }) => { 16 | if (!settings.id) { 17 | throw new Error("Missing media package id"); 18 | } 19 | 20 | // Transform 21 | const state = getState(); 22 | const catalogsJson = selectCatalogIds(state).map(catId => { 23 | const cat = selectCatalogById(state, catId); 24 | 25 | const fieldsJson = cat.fieldIds.map(fieldId => { 26 | const field = selectFieldById(state, fieldId); 27 | 28 | // Remove internal keys (`id`, `catalogId`) & restore original field `id` 29 | const { catalogId, id: compositeId, ...rest } = field; 30 | const originalId = compositeId.split(":")[1]; // after `${catalogId}:` 31 | 32 | return { ...rest, id: originalId, collection: undefined }; 33 | }); 34 | 35 | return { 36 | flavor: cat.flavor, 37 | title: cat.title, 38 | fields: fieldsJson, 39 | }; 40 | }); 41 | 42 | const response = await client.post(`${settings.opencast.url}/editor/${settings.id}/edit.json`, 43 | { 44 | segments: convertSegments(argument.segments), 45 | tracks: argument.tracks, 46 | customizedTrackSelection: argument.customizedTrackSelection, 47 | subtitles: argument.subtitles, 48 | chapters: argument.chapters, 49 | workflows: argument.workflow, 50 | metadataJSON: JSON.stringify(catalogsJson), 51 | }, 52 | ); 53 | return response; 54 | }); 55 | 56 | /** 57 | * Slice for managing a post request for saving current changes 58 | * TODO: Create a wrapper for this and workflowPostAndProcessSlice 59 | */ 60 | const workflowPostSlice = createSlice({ 61 | name: "workflowPostState", 62 | initialState, 63 | reducers: { 64 | resetPostRequestState: state => { 65 | state.status = "idle"; 66 | }, 67 | }, 68 | extraReducers: builder => { 69 | builder.addCase( 70 | postVideoInformation.pending, (state, _action) => { 71 | state.status = "loading"; 72 | }); 73 | builder.addCase( 74 | postVideoInformation.fulfilled, (state, _action) => { 75 | state.status = "success"; 76 | }); 77 | builder.addCase( 78 | postVideoInformation.rejected, (state, action) => { 79 | state.status = "failed"; 80 | state.error = action.error.message; 81 | }); 82 | }, 83 | selectors: { 84 | selectStatus: state => state.status, 85 | selectError: state => state.error, 86 | }, 87 | }); 88 | 89 | interface segmentAPI { 90 | start: number, 91 | end: number, 92 | deleted: boolean, 93 | selected: boolean, 94 | } 95 | 96 | // Convert a segment from how it is stored in redux into 97 | // a segment that can be send to Opencast 98 | export const convertSegments = (segments: Segment[]) => { 99 | const newSegments: segmentAPI[] = []; 100 | 101 | segments.forEach(segment => { 102 | newSegments.push({ 103 | start: segment.start, 104 | end: segment.end, 105 | deleted: segment.deleted, 106 | selected: false, 107 | }); 108 | }); 109 | 110 | return newSegments; 111 | }; 112 | 113 | export const { resetPostRequestState } = workflowPostSlice.actions; 114 | 115 | export const { selectStatus, selectError } = workflowPostSlice.selectors; 116 | 117 | export default workflowPostSlice.reducer; 118 | -------------------------------------------------------------------------------- /src/img/opencast-editor-narrow.svg: -------------------------------------------------------------------------------- 1 | 2 | 55 | 56 | -------------------------------------------------------------------------------- /src/globalKeys.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Contains mappings for special keyboard controls, beyond what is usually expected of a webpage 3 | * Learn more about keymaps at https://github.com/greena13/react-hotkeys#defining-key-maps (12.03.2021) 4 | * 5 | * Additional global configuration settins are placed in "./config.ts" 6 | * (They are not placed here, because that somehow makes the name fields of keymaps undefined for some reason) 7 | * 8 | * If you add a new keyMap, be sure to add it to the getAllHotkeys function 9 | */ 10 | import { match } from "@opencast/appkit"; 11 | import { ParseKeys } from "i18next"; 12 | import { isMacOs } from "react-device-detect"; 13 | 14 | // Groups for displaying hotkeys in the overview page 15 | const groupVideoPlayer = "keyboardControls.groupVideoPlayer"; 16 | const groupCuttingView = "keyboardControls.groupCuttingView"; 17 | const groupCuttingViewScrubber = "keyboardControls.groupCuttingViewScrubber"; 18 | const groupSubtitleList = "keyboardControls.groupSubtitleList"; 19 | 20 | /** 21 | * Helper function that rewrites keys based on the OS 22 | */ 23 | export const rewriteKeys = (key: string | IKey) => { 24 | const newKey = typeof key === "string" ? 25 | key : key.splitKey ? 26 | key.key.replaceAll(key.splitKey, "+") : key.key; 27 | 28 | return isMacOs ? newKey.replace("Alt", "Option") : newKey; 29 | }; 30 | 31 | export const getGroupName = (groupName: string): ParseKeys => { 32 | return match(groupName, { 33 | videoPlayer: () => groupVideoPlayer, 34 | cutting: () => groupCuttingView, 35 | timeline: () => groupCuttingViewScrubber, 36 | subtitleList: () => groupSubtitleList, 37 | }) ?? "keyboardControls.defaultGroupName"; 38 | }; 39 | 40 | export interface IKeyMap { 41 | [property: string]: IKeyGroup; 42 | } 43 | 44 | export interface IKeyGroup { 45 | [property: string]: IKey; 46 | } 47 | 48 | export interface IKey { 49 | name: string; 50 | key: string; 51 | splitKey?: string; 52 | } 53 | 54 | export const KEYMAP: IKeyMap = { 55 | videoPlayer: { 56 | play: { 57 | name: "keyboardControls.videoPlayButton", 58 | key: "Shift+Alt+Space, Space", 59 | }, 60 | previous: { 61 | name: "video.previousButton", 62 | key: "Shift+Alt+Left", 63 | }, 64 | next: { 65 | name: "video.nextButton", 66 | key: "Shift+Alt+Right", 67 | }, 68 | preview: { 69 | name: "video.previewButton", 70 | key: "Shift+Alt+p", 71 | }, 72 | }, 73 | cutting: { 74 | cut: { 75 | name: "cuttingActions.cut-button", 76 | key: "Shift+Alt+c", 77 | }, 78 | delete: { 79 | name: "cuttingActions.delete-button", 80 | key: "Shift+Alt+d", 81 | }, 82 | mergeLeft: { 83 | name: "cuttingActions.mergeLeft-button", 84 | key: "Shift+Alt+n", 85 | }, 86 | mergeRight: { 87 | name: "cuttingActions.mergeRight-button", 88 | key: "Shift+Alt+m", 89 | }, 90 | zoomIn: { 91 | name: "cuttingActions.zoomIn", 92 | key: "Shift;Alt;r, +", 93 | splitKey: ";", 94 | }, 95 | zoomOut: { 96 | name: "cuttingActions.zoomOut", 97 | key: "Shift+Alt+e, -", 98 | }, 99 | }, 100 | timeline: { 101 | left: { 102 | name: "keyboardControls.scrubberLeft", 103 | key: "Shift+Alt+j , Left", 104 | }, 105 | right: { 106 | name: "keyboardControls.scrubberRight", 107 | key: "Shift+Alt+l, Right", 108 | }, 109 | increase: { 110 | name: "keyboardControls.scrubberIncrease", 111 | key: "Shift+Alt+i, Up", 112 | }, 113 | decrease: { 114 | name: "keyboardControls.scrubberDecrease", 115 | key: "Shift+Alt+k, Down", 116 | }, 117 | }, 118 | subtitleList: { 119 | addAbove: { 120 | name: "subtitleList.addSegmentAbove", 121 | key: "Shift+Alt+q", 122 | }, 123 | addBelow: { 124 | name: "subtitleList.addSegmentBelow", 125 | key: "Shift+Alt+a", 126 | }, 127 | jumpAbove: { 128 | name: "subtitleList.jumpToSegmentAbove", 129 | key: "Shift+Alt+w", 130 | }, 131 | jumpBelow: { 132 | name: "subtitleList.jumpToSegmentBelow", 133 | key: "Shift+Alt+s", 134 | }, 135 | delete: { 136 | name: "subtitleList.deleteSegment", 137 | key: "Shift+Alt+d", 138 | }, 139 | addCue: { 140 | name: "subtitleList.addCue", 141 | key: "Shift+Alt+e", 142 | }, 143 | }, 144 | }; 145 | -------------------------------------------------------------------------------- /src/main/KeyboardControls.tsx: -------------------------------------------------------------------------------- 1 | import { css } from "@emotion/react"; 2 | import { ParseKeys } from "i18next"; 3 | 4 | import React from "react"; 5 | 6 | import { useTranslation, Trans } from "react-i18next"; 7 | import { getGroupName, KEYMAP, rewriteKeys } from "../globalKeys"; 8 | import { useTheme } from "../themes"; 9 | import { titleStyle, titleStyleBold } from "../cssStyles"; 10 | 11 | const Group: React.FC<{ name: ParseKeys, entries: { [key: string]: string[][]; }; }> = ({ name, entries }) => { 12 | 13 | const { t } = useTranslation(); 14 | const theme = useTheme(); 15 | 16 | const groupStyle = css({ 17 | display: "flex", 18 | flexDirection: "column" as const, 19 | width: "460px", 20 | maxWidth: "50vw", 21 | 22 | background: `${theme.menu_background}`, 23 | borderRadius: "5px", 24 | boxShadow: `${theme.boxShadow_tiles}`, 25 | boxSizing: "border-box", 26 | padding: "0px 20px 20px 20px", 27 | }); 28 | 29 | const headingStyle = css({ 30 | color: `${theme.text}`, 31 | }); 32 | 33 | return ( 34 |
    35 |

    {t(name)}

    36 | {Object.entries(entries).map(([key, value], index) => 37 | , 38 | )} 39 |
    40 | ); 41 | }; 42 | 43 | const Entry: React.FC<{ name: string, sequences: string[][]; }> = ({ name, sequences }) => { 44 | 45 | const { t } = useTranslation(); 46 | const theme = useTheme(); 47 | 48 | const entryStyle = css({ 49 | display: "flex", 50 | flexFlow: "column nowrap", 51 | justifyContent: "left", 52 | width: "100%", 53 | padding: "10px 0px", 54 | gap: "10px", 55 | }); 56 | 57 | const labelStyle = css({ 58 | fontWeight: "bold", 59 | overflow: "hidden", 60 | textOverflow: "ellipsis", 61 | wordWrap: "break-word", 62 | color: `${theme.text}`, 63 | }); 64 | 65 | const sequenceStyle = css({ 66 | display: "flex", 67 | flexDirection: "row", 68 | gap: "10px", 69 | }); 70 | 71 | const singleKeyStyle = css({ 72 | borderRadius: "4px", 73 | borderWidth: "2px", 74 | borderStyle: "solid", 75 | borderColor: `${theme.singleKey_border}`, 76 | background: `${theme.singleKey_bg}`, 77 | boxShadow: `${theme.singleKey_boxShadow}`, 78 | padding: "10px", 79 | color: `${theme.text}`, 80 | }); 81 | 82 | const orStyle = css({ 83 | alignSelf: "center", 84 | fontSize: "20px", 85 | fontWeight: "bold", 86 | }); 87 | 88 | return ( 89 |
    90 |
    {name || t("keyboardControls.missingLabel")}
    91 | {sequences.map((sequence, index, arr) => ( 92 |
    93 | {sequence.map((singleKey, index) => ( 94 |
    95 |
    96 | {singleKey} 97 |
    98 | {sequence.length - 1 !== index && 99 |
    +
    100 | } 101 |
    102 | ))} 103 |
    104 | {arr.length - 1 !== index && t("keyboardControls.sequenceSeparator")} 105 |
    106 |
    107 | ))} 108 |
    109 | ); 110 | }; 111 | 112 | 113 | const KeyboardControls: React.FC = () => { 114 | 115 | const { t } = useTranslation(); 116 | const theme = useTheme(); 117 | 118 | const groupsStyle = css({ 119 | display: "flex", 120 | flexDirection: "row" as const, 121 | flexWrap: "wrap", 122 | justifyContent: "center", 123 | gap: "30px", 124 | }); 125 | 126 | const render = () => { 127 | if (KEYMAP && Object.keys(KEYMAP).length > 0) { 128 | 129 | const groups: JSX.Element[] = []; 130 | Object.entries(KEYMAP).forEach(([groupName, group], index) => { 131 | const entries: { [groupName: string]: string[][]; } = {}; 132 | Object.entries(group).forEach(([, action]) => { 133 | const sequences = action.key.split(",").map(item => item.trim()); 134 | const sequenceSplitKey = action.splitKey ? action.splitKey : "+"; 135 | entries[action.name] = Object.entries(sequences).map(([, sequence]) => { 136 | return sequence.split(sequenceSplitKey).map(item => rewriteKeys(item.trim())); 137 | }); 138 | }); 139 | groups.push(); 140 | }); 141 | 142 | return ( 143 |
    144 | {groups} 145 |
    146 | ); 147 | } 148 | 149 | // No groups fallback 150 | return
    {t("keyboardControls.genericError")}
    ; 151 | }; 152 | 153 | const keyboardControlsStyle = css({ 154 | display: "flex", 155 | flexDirection: "column" as const, 156 | alignItems: "center", 157 | width: "100%", 158 | }); 159 | 160 | return ( 161 |
    162 |
    163 | {t("keyboardControls.header")} 164 |
    165 | 166 | {render()} 167 |
    168 | ); 169 | }; 170 | 171 | export default KeyboardControls; 172 | -------------------------------------------------------------------------------- /src/main/Cutting.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | import CuttingActions from "./CuttingActions"; 3 | import Timeline from "./Timeline"; 4 | import { 5 | fetchVideoInformation, 6 | selectCurrentlyAt, 7 | selectDuration, 8 | selectIsPlaying, 9 | selectIsMuted, 10 | selectVolume, 11 | selectIsPlayPreview, 12 | selectTitle, 13 | setClickTriggered, 14 | setCurrentlyAt, 15 | setIsPlaying, 16 | setIsMuted, 17 | setVolume, 18 | setIsPlayPreview, 19 | jumpToPreviousSegment, 20 | jumpToNextSegment, 21 | selectVideos, 22 | cut, 23 | mergeAll, 24 | mergeLeft, 25 | mergeRight, 26 | } from "../redux/videoSlice"; 27 | import { useTranslation } from "react-i18next"; 28 | import { useAppDispatch, useAppSelector } from "../redux/store"; 29 | import { httpRequestState } from "../types"; 30 | import { useTheme } from "../themes"; 31 | import { setError } from "../redux/errorSlice"; 32 | import { selectTitleFromEpisodeDc } from "../redux/metadataSlice"; 33 | import { titleStyle, titleStyleBold, videosStyle } from "../cssStyles"; 34 | import { LuHourglass } from "react-icons/lu"; 35 | import { css } from "@emotion/react"; 36 | import VideoPlayers from "./VideoPlayers"; 37 | import VideoControls from "./VideoControls"; 38 | import { fetchMetadata, selectGetStatus as selectMetadataGetStatus } from "../redux/metadataSlice"; 39 | 40 | const Cutting: React.FC = () => { 41 | 42 | const { t } = useTranslation(); 43 | 44 | // Init redux variables 45 | const dispatch = useAppDispatch(); 46 | const videoURLStatus = useAppSelector((state: { videoState: { status: httpRequestState["status"]; }; }) => 47 | state.videoState.status); 48 | const error = useAppSelector((state: { videoState: { error: httpRequestState["error"]; }; }) => 49 | state.videoState.error); 50 | const videos = useAppSelector(selectVideos); 51 | const duration = useAppSelector(selectDuration); 52 | const theme = useTheme(); 53 | const errorReason = useAppSelector((state: { videoState: { errorReason: httpRequestState["errorReason"]; }; }) => 54 | state.videoState.errorReason); 55 | const metadataGetStatus = useAppSelector(selectMetadataGetStatus); 56 | 57 | // Try to fetch URL from external API 58 | useEffect(() => { 59 | if (videoURLStatus === "idle") { 60 | dispatch(fetchVideoInformation()); 61 | } else if (videoURLStatus === "failed") { 62 | if (errorReason === "workflowActive") { 63 | dispatch(setError({ 64 | error: true, 65 | errorTitle: t("error.workflowActive-errorTitle"), 66 | errorMessage: t("error.workflowActive-errorMessage"), 67 | errorIcon: LuHourglass, 68 | })); 69 | } else { 70 | dispatch(setError({ 71 | error: true, 72 | errorMessage: t("video.comError-text"), 73 | errorDetails: error, 74 | })); 75 | } 76 | } else if (videoURLStatus === "success") { 77 | // Editor can not handle events with no videos/audio-only atm 78 | if (videos === null || videos.length === 0) { 79 | dispatch(setError({ 80 | error: true, 81 | errorMessage: t("video.noVideoError-text"), 82 | errorDetails: error, 83 | })); 84 | } 85 | if (duration === null) { 86 | dispatch(setError({ 87 | error: true, 88 | errorMessage: t("video.durationError-text"), 89 | errorDetails: error, 90 | })); 91 | } 92 | } 93 | }, [videoURLStatus, dispatch, error, t, errorReason, duration, videos]); 94 | 95 | // Already try fetching Metadata to reduce wait time 96 | useEffect(() => { 97 | if (metadataGetStatus === "idle") { 98 | dispatch(fetchMetadata()); 99 | } 100 | }, [metadataGetStatus, dispatch]); 101 | 102 | // Style 103 | const cuttingStyle = css({ 104 | display: "flex", 105 | width: "auto", 106 | flexDirection: "column", 107 | justifyContent: "center", 108 | alignItems: "center", 109 | }); 110 | 111 | 112 | return ( 113 |
    114 | 115 | 116 |
    117 | 125 | 131 | 144 |
    145 |
    146 | ); 147 | }; 148 | 149 | 150 | const CuttingHeader: React.FC = () => { 151 | 152 | const title = useAppSelector(selectTitle); 153 | const metadataTitle = useAppSelector(selectTitleFromEpisodeDc); 154 | const theme = useTheme(); 155 | 156 | return ( 157 |
    158 | {metadataTitle ? metadataTitle : title} 159 |
    160 | ); 161 | }; 162 | 163 | export default Cutting; 164 | -------------------------------------------------------------------------------- /src/redux/metadataSlice.ts: -------------------------------------------------------------------------------- 1 | import { createEntityAdapter, createSlice, EntityState, nanoid, PayloadAction } from "@reduxjs/toolkit"; 2 | import { client } from "../util/client"; 3 | 4 | import { httpRequestState } from "../types"; 5 | import { settings } from "../config"; 6 | import { createAppAsyncThunk } from "./createAsyncThunkWithTypes"; 7 | import { RootState } from "./store"; 8 | 9 | export interface Catalog { 10 | id: string // generated 11 | fieldIds: string[]; // references into `fields` adapter 12 | 13 | flavor: string, // "dublincore/episode" 14 | title: string, // name identifier 15 | } 16 | 17 | interface BackendCatalog { 18 | fields: MetadataField[], 19 | flavor: string, // "dublincore/episode" 20 | title: string, // name identifier 21 | } 22 | 23 | export interface MetadataField { 24 | id: string; // `${catalogId}:${fieldName}` 25 | catalogId: string; 26 | name: string; // original `id` 27 | 28 | readOnly: boolean, 29 | label: string; 30 | type: string; 31 | value: string, 32 | required: boolean, 33 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 34 | collection: { [key: string]: any } | undefined, 35 | } 36 | 37 | interface metadata { 38 | catalogs: EntityState, 39 | fields: EntityState, 40 | hasChanges: boolean; // Did user make changes to metadata view since last save 41 | } 42 | 43 | const catalogsAdapter = createEntityAdapter(); 44 | const fieldsAdapter = createEntityAdapter(); 45 | 46 | const initialState: metadata & httpRequestState = { 47 | catalogs: catalogsAdapter.getInitialState(), 48 | fields: fieldsAdapter.getInitialState(), 49 | hasChanges: false, 50 | status: "idle", 51 | error: undefined, 52 | errorReason: "unknown", 53 | }; 54 | 55 | export const fetchMetadata = createAppAsyncThunk("metadata/fetchMetadata", async () => { 56 | if (!settings.id) { 57 | throw new Error("Missing media package identifier"); 58 | } 59 | 60 | const response = await client.get(`${settings.opencast.url}/editor/${settings.id}/metadata.json`); 61 | return JSON.parse(response) as BackendCatalog[]; 62 | }); 63 | 64 | /** 65 | * Slice for managing a post request for saving current changes and starting a workflow 66 | */ 67 | const metadataSlice = createSlice({ 68 | name: "metadataState", 69 | initialState, 70 | reducers: { 71 | setFieldValue: (state, action: PayloadAction<{ id: string; value: string }>) => { 72 | fieldsAdapter.updateOne(state.fields, { 73 | id: action.payload.id, 74 | changes: { value: action.payload.value }, 75 | }); 76 | state.hasChanges = true; 77 | }, 78 | setHasChanges: (state, action: PayloadAction) => { 79 | state.hasChanges = action.payload; 80 | }, 81 | }, 82 | extraReducers: builder => { 83 | builder.addCase( 84 | fetchMetadata.pending, (state, _action) => { 85 | state.status = "loading"; 86 | }); 87 | builder.addCase(fetchMetadata.fulfilled, (state, action) => { 88 | // Entity Adapter preparations 89 | const catalogEntities: Catalog[] = []; 90 | const fieldEntities: MetadataField[] = []; 91 | 92 | action.payload.forEach(rawCatalog => { 93 | const catalogId = nanoid(); // new stable id 94 | const fieldIds: string[] = []; 95 | 96 | rawCatalog.fields.forEach(field => { 97 | const fieldId = `${catalogId}:${field.id}`; // unique per catalog 98 | fieldIds.push(fieldId); 99 | 100 | fieldEntities.push({ 101 | ...field, 102 | id: fieldId, 103 | name: field.id, 104 | catalogId, // back‑reference for convenience 105 | }); 106 | }); 107 | 108 | catalogEntities.push({ 109 | ...rawCatalog, 110 | id: catalogId, 111 | fieldIds, 112 | }); 113 | }); 114 | 115 | // Replace state with the fetched entities 116 | catalogsAdapter.setAll(state.catalogs, catalogEntities); 117 | fieldsAdapter.setAll(state.fields, fieldEntities); 118 | state.hasChanges = false; 119 | }); 120 | builder.addCase( 121 | fetchMetadata.rejected, (state, action) => { 122 | state.status = "failed"; 123 | state.error = action.error.message; 124 | }); 125 | }, 126 | selectors: { 127 | selectHasChanges: state => state.hasChanges, 128 | selectGetStatus: state => state.status, 129 | selectGetError: state => state.error, 130 | selectTitleFromEpisodeDc: state => { 131 | for (const catalogId of state.catalogs.ids) { 132 | const catalog = state.catalogs.entities[catalogId]; 133 | if (!catalog) { continue; } 134 | 135 | if (catalog.flavor === "dublincore/episode") { 136 | for (const fieldId of state.fields.ids) { 137 | const field = state.fields.entities[fieldId]; 138 | if (field.catalogId === catalogId && field.name === "title") { 139 | return field.value; 140 | } 141 | } 142 | } 143 | } 144 | 145 | return undefined; 146 | }, 147 | }, 148 | }); 149 | 150 | export const { setFieldValue, setHasChanges } = metadataSlice.actions; 151 | 152 | export const { 153 | selectAll: selectAllCatalogs, 154 | selectIds: selectCatalogIds, 155 | selectById: selectCatalogById, 156 | } = catalogsAdapter.getSelectors(s => s.metadataState.catalogs); 157 | 158 | export const { 159 | selectById: selectFieldById, 160 | } = fieldsAdapter.getSelectors(s => s.metadataState.fields); 161 | 162 | export const { 163 | selectHasChanges, 164 | selectGetStatus, 165 | selectGetError, 166 | selectTitleFromEpisodeDc, 167 | } = metadataSlice.selectors; 168 | 169 | export default metadataSlice.reducer; 170 | -------------------------------------------------------------------------------- /src/main/MainContent.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef } from "react"; 2 | 3 | import Metadata from "./Metadata"; 4 | import TrackSelection from "./TrackSelection"; 5 | import Subtitle from "./Subtitle"; 6 | import Finish from "./Finish"; 7 | import KeyboardControls from "./KeyboardControls"; 8 | 9 | import { LuWrench } from "react-icons/lu"; 10 | 11 | import { css } from "@emotion/react"; 12 | 13 | import { useAppSelector } from "../redux/store"; 14 | import { selectMainMenuState } from "../redux/mainMenuSlice"; 15 | 16 | import { MainMenuStateNames } from "../types"; 17 | 18 | import { useBeforeunload } from "react-beforeunload"; 19 | import { selectHasChanges as videoSelectHasChanges } from "../redux/videoSlice"; 20 | import { selectHasChanges as metadataSelectHasChanges } from "../redux/metadataSlice"; 21 | import { selectHasChanges as selectSubtitleHasChanges } from "../redux/subtitleSlice"; 22 | import { useTheme } from "../themes"; 23 | import Thumbnail from "./Thumbnail"; 24 | import Cutting from "./Cutting"; 25 | import Chapter from "./Chapter"; 26 | 27 | /** 28 | * A container for the main functionality 29 | * Shows different components depending on the state off the app 30 | */ 31 | const MainContent: React.FC = () => { 32 | 33 | const mainMenuState = useAppSelector(selectMainMenuState); 34 | const videoChanged = useAppSelector(videoSelectHasChanges); 35 | const metadataChanged = useAppSelector(metadataSelectHasChanges); 36 | const subtitleChanged = useAppSelector(selectSubtitleHasChanges); 37 | const theme = useTheme(); 38 | 39 | // Display warning when leaving the page if there are unsaved changes 40 | useBeforeunload((event: { preventDefault: () => void; }) => { 41 | if (videoChanged || metadataChanged || subtitleChanged) { 42 | event.preventDefault(); 43 | } 44 | }); 45 | 46 | const mainContentStyle = css({ 47 | display: "flex", 48 | width: "100%", 49 | paddingRight: "20px", 50 | paddingLeft: "20px", 51 | gap: "20px", 52 | background: `${theme.background}`, 53 | overflow: "auto", 54 | }); 55 | 56 | const cuttingStyle = css({ 57 | flexDirection: "column", 58 | }); 59 | 60 | const metadataStyle = css({ 61 | }); 62 | 63 | const trackSelectStyle = css({ 64 | flexDirection: "column", 65 | alignContent: "space-around", 66 | }); 67 | 68 | const subtitleSelectStyle = css({ 69 | flexDirection: "column", 70 | justifyContent: "space-around", 71 | }); 72 | 73 | const thumbnailSelectStyle = css({ 74 | flexDirection: "column", 75 | alignContent: "space-around", 76 | }); 77 | 78 | const finishStyle = css({ 79 | flexDirection: "column", 80 | justifyContent: "space-around", 81 | }); 82 | 83 | const keyboardControlsStyle = css({ 84 | flexDirection: "column", 85 | }); 86 | 87 | const defaultStyle = css({ 88 | flexDirection: "column", 89 | alignItems: "center", 90 | padding: "20px", 91 | }); 92 | 93 | // Apply main focus to the current view for keyboard shortcuts. 94 | const mainRef = useRef(null); 95 | useEffect(() => { 96 | // Auto-focus main content when route changes 97 | mainRef.current?.focus(); 98 | }, [mainMenuState]); 99 | 100 | const render = () => { 101 | if (mainMenuState === MainMenuStateNames.cutting) { 102 | return ( 103 |
    106 | 107 |
    108 | ); 109 | } else if (mainMenuState === MainMenuStateNames.metadata) { 110 | return ( 111 |
    114 | 115 |
    116 | ); 117 | } else if (mainMenuState === MainMenuStateNames.trackSelection) { 118 | return ( 119 |
    122 | 123 |
    124 | ); 125 | } else if (mainMenuState === MainMenuStateNames.subtitles) { 126 | return ( 127 |
    130 | 131 |
    132 | ); 133 | } else if (mainMenuState === MainMenuStateNames.chapters) { 134 | return ( 135 |
    136 | 137 |
    138 | ); 139 | } else if (mainMenuState === MainMenuStateNames.thumbnail) { 140 | return ( 141 |
    144 | 145 |
    146 | ); 147 | } else if (mainMenuState === MainMenuStateNames.finish) { 148 | return ( 149 |
    152 | 153 |
    154 | ); 155 | } else if (mainMenuState === MainMenuStateNames.keyboardControls) { 156 | return ( 157 |
    160 | 161 |
    162 | ); 163 | } else { 164 |
    167 | 168 | Placeholder 169 |
    ; 170 | } 171 | }; 172 | 173 | return ( 174 | <>{render()} 175 | ); 176 | }; 177 | 178 | export default MainContent; 179 | -------------------------------------------------------------------------------- /src/main/MainMenu.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { css, SerializedStyles } from "@emotion/react"; 4 | 5 | import { IconType } from "react-icons"; 6 | import { LuScissors, LuFilm, LuFileText, LuSquareCheckBig, LuBookOpenText } from "react-icons/lu"; 7 | import { LuImage } from "react-icons/lu"; 8 | import SubtitleIcon from "../img/subtitle.svg?react"; 9 | 10 | import { useAppDispatch, useAppSelector } from "../redux/store"; 11 | import { setState, selectMainMenuState, mainMenu } from "../redux/mainMenuSlice"; 12 | import { setPageNumber } from "../redux/finishSlice"; 13 | 14 | import { MainMenuStateNames } from "../types"; 15 | import { settings } from "../config"; 16 | import { basicButtonStyle, BREAKPOINTS } from "../cssStyles"; 17 | import { setIsPlaying } from "../redux/videoSlice"; 18 | 19 | import { useTranslation } from "react-i18next"; 20 | import { resetPostRequestState } from "../redux/workflowPostSlice"; 21 | import { setIsDisplayEditView } from "../redux/subtitleSlice"; 22 | 23 | import { useTheme } from "../themes"; 24 | import { ProtoButton } from "@opencast/appkit"; 25 | import { screenWidthAtMost } from "@opencast/appkit"; 26 | 27 | /** 28 | * A container for selecting the functionality shown in the main part of the app 29 | */ 30 | const MainMenu: React.FC = () => { 31 | 32 | const { t } = useTranslation(); 33 | const theme = useTheme(); 34 | 35 | const mainMenuStyle = css({ 36 | borderRight: `${theme.menuBorder}`, 37 | minWidth: "120px", 38 | maxWidth: "140px", 39 | display: "flex", 40 | flexDirection: "column", 41 | alignItems: "center", 42 | padding: "20px", 43 | overflowX: "hidden", 44 | overflowY: "auto", 45 | background: `${theme.menu_background}`, 46 | gap: "30px", 47 | [screenWidthAtMost(BREAKPOINTS.large)]: { 48 | minWidth: "60px", 49 | padding: "20px 10px", 50 | }, 51 | }); 52 | 53 | return ( 54 | 98 | ); 99 | }; 100 | 101 | interface mainMenuButtonInterface { 102 | Icon: IconType | React.FunctionComponent, 103 | stateName: mainMenu["value"], 104 | bottomText: string, 105 | ariaLabelText: string; 106 | customCSS?: SerializedStyles, 107 | iconCustomCSS?: SerializedStyles, 108 | } 109 | 110 | /** 111 | * A button to set the state of the app 112 | * @param param0 113 | */ 114 | export const MainMenuButton: React.FC = ({ 115 | Icon, 116 | stateName, 117 | bottomText, 118 | ariaLabelText, 119 | customCSS, 120 | iconCustomCSS, 121 | }) => { 122 | 123 | const dispatch = useAppDispatch(); 124 | const activeState = useAppSelector(selectMainMenuState); 125 | const theme = useTheme(); 126 | 127 | const onMenuItemClicked = () => { 128 | dispatch(setState(stateName)); 129 | // Reset multi-page content to their first page 130 | if (stateName === MainMenuStateNames.finish) { 131 | dispatch(setPageNumber(0)); 132 | } 133 | if (stateName === MainMenuStateNames.subtitles) { 134 | dispatch(setIsDisplayEditView(false)); 135 | } 136 | // Halt ongoing events 137 | dispatch(setIsPlaying(false)); 138 | // Reset states 139 | dispatch(resetPostRequestState()); 140 | }; 141 | 142 | const mainMenuButtonStyle = css({ 143 | width: "100%", 144 | height: "100px", 145 | outline: `${theme.menuButton_outline}`, 146 | ...(activeState === stateName) && { 147 | backgroundColor: `${theme.button_color}`, 148 | color: `${theme.inverted_text}`, 149 | boxShadow: `${theme.boxShadow}`, 150 | }, 151 | "&:hover": { 152 | backgroundColor: `${theme.button_color}`, 153 | color: `${theme.inverted_text}`, 154 | boxShadow: `${theme.boxShadow}`, 155 | }, 156 | flexDirection: "column", 157 | [screenWidthAtMost(BREAKPOINTS.large)]: { 158 | height: "60px", 159 | minHeight: "40px", 160 | }, 161 | }); 162 | 163 | return ( 164 | 170 | 175 | {bottomText && 176 |
    181 | {bottomText} 182 |
    } 183 |
    184 | ); 185 | }; 186 | 187 | export default MainMenu; 188 | -------------------------------------------------------------------------------- /src/util/waveform.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Big thanks to Duncan "slampunk" Smith for writing this code and allowing it 3 | * to be used for this application. 4 | * duncan83@gmail.com 5 | */ 6 | 7 | /* eslint-disable space-before-function-paren */ 8 | 9 | import { AudioContext } from "standardized-audio-context"; 10 | 11 | export function Waveform(opts) { 12 | this.audioContext = new AudioContext(); 13 | this.oCanvas = document.createElement("canvas"); 14 | this.buffer = {}; 15 | this.WIDTH = 0; 16 | this.HEIGHT = 0; 17 | this.channelData = []; 18 | this.waveformImage = ""; 19 | this.audioBuffer = null; 20 | 21 | this.aveRMS = 0; 22 | this.peakRMS = 0; 23 | 24 | this.numberSamples = 100000; 25 | this.waveformType = "img"; 26 | this.drawWaveform = this.drawCanvasWaveform; 27 | 28 | if (opts.width && opts.height) { 29 | this.setDimensions(opts.width, opts.height); 30 | } 31 | if (opts.samples) { 32 | this.numberSamples = opts.samples; 33 | } 34 | if (opts.type && opts.type === "svg") { 35 | this.waveformType = "svg"; 36 | this.drawWaveform = this.delegateToWorker; 37 | this.worker = null; 38 | } 39 | if (opts.media) { 40 | this.generateWaveform(opts.media) 41 | .then(() => { 42 | this.getAudioData(); 43 | this.drawWaveform(); 44 | if (this.waveformType !== "svg") { 45 | _completeFuncs.forEach(fn => { 46 | fn(this.waveformImage || this.svgPath, this.waveformType); 47 | }); 48 | } 49 | }) 50 | .catch(e => { 51 | console.log("Waveform Worker: " + e); 52 | this._error = e.toString(); 53 | this.onerror.forEach(fn => fn(e.toString())); 54 | }); 55 | } 56 | 57 | var _completeFuncs = []; 58 | Object.defineProperty(this, "oncomplete", { 59 | get: function() { 60 | return _completeFuncs; 61 | }, 62 | set: function(fn, _opt) { 63 | if (typeof fn == "function") { 64 | if (this.waveformImage || this.svgPath) { 65 | fn(this.waveformImage || this.svgPath, this.svgLength); 66 | return; 67 | } 68 | 69 | _completeFuncs.push(fn); 70 | } 71 | }, 72 | }); 73 | 74 | var _error = ""; 75 | var _errorFuncs = []; 76 | Object.defineProperty(this, "onerror", { 77 | get: function() { 78 | return _errorFuncs; 79 | }, 80 | set: function(fn, _opt) { 81 | if (typeof fn == "function") { 82 | if (this._error && this._error !== "") { 83 | fn(_error); 84 | return; 85 | } 86 | } 87 | 88 | _errorFuncs.push(fn); 89 | }, 90 | }); 91 | } 92 | 93 | Waveform.prototype = { 94 | constructor: Waveform, 95 | setDimensions: function(width, height) { 96 | this.oCanvas.width = width; 97 | this.WIDTH = width; 98 | this.oCanvas.height = height; 99 | this.HEIGHT = height; 100 | this.ocCtx = this.oCanvas.getContext("2d"); 101 | }, 102 | decodeAudioData: function(arraybuffer) { 103 | return new Promise((resolve, reject) => { 104 | new Promise((res, _rej) => { 105 | if (arraybuffer instanceof ArrayBuffer) { 106 | res(arraybuffer); 107 | } else if (arraybuffer instanceof Blob) { 108 | let reader = new FileReader(); 109 | reader.onload = function() { 110 | res(reader.result); 111 | }; 112 | reader.readAsArrayBuffer(arraybuffer); 113 | } 114 | }) 115 | .then(buffer => { 116 | this.audioContext.decodeAudioData(buffer) 117 | .then(audiobuffer => { 118 | this.buffer = audiobuffer; 119 | resolve(); 120 | }) 121 | .catch(e => { 122 | reject(e); 123 | }); 124 | }) 125 | .catch(e => { 126 | reject(e); 127 | }); 128 | }); 129 | }, 130 | getAudioData: function(buffer) { 131 | buffer = buffer || this.buffer; 132 | this.channelData = this.dropSamples(buffer.getChannelData(0), this.numberSamples); 133 | }, 134 | drawCanvasWaveform: function(amp) { 135 | amp = amp || 1; 136 | this.ocCtx.fillStyle = "#FFFFFF00"; // "#b7d8f9"; 137 | this.ocCtx.fillRect(0, 0, this.WIDTH, this.HEIGHT); 138 | this.ocCtx.lineWidth = 1; 139 | this.ocCtx.strokeStyle = "black"; // "#38597a"; 140 | let sliceWidth = this.WIDTH * 1.0 / this.channelData.length; 141 | let x = 0; 142 | 143 | this.ocCtx.beginPath(); 144 | this.ocCtx.moveTo(x, this.channelData[0] * this.HEIGHT / 128.0 / 2); 145 | 146 | this.channelData.forEach(sample => { 147 | let v = sample * amp; 148 | let y = this.HEIGHT * (1 + v) / 2; 149 | this.ocCtx.lineTo(x, y); 150 | this.aveRMS += sample * sample; 151 | this.peakRMS = Math.max(sample * sample, this.peakRMS); 152 | x += sliceWidth; 153 | }); 154 | this.ocCtx.lineTo(this.WIDTH, this.HEIGHT / 2); 155 | this.ocCtx.stroke(); 156 | this.aveRMS = Math.sqrt(this.aveRMS / this.channelData.length); 157 | this.aveDBs = 20 * Math.log(this.aveRMS) / Math.log(10); 158 | this.waveformImage = this.oCanvas.toDataURL(); 159 | }, 160 | dropSamples: function(data, requestedLength) { 161 | let divider = Math.max(parseInt(data.length / requestedLength), 1); 162 | return data.filter((_sample, i) => i % divider === 0); 163 | }, 164 | generateWaveform: function(arraybuffer) { 165 | return this.decodeAudioData(arraybuffer); 166 | }, 167 | delegateToWorker: function() { 168 | if (!this.worker) { 169 | this.worker = new Worker("../util/svgworker.js"); 170 | this.worker.addEventListener("message", this.workerCommunication.bind(this), false); 171 | this.worker.postMessage(this.channelData); 172 | } 173 | }, 174 | workerCommunication: function(e) { 175 | switch (e.data.type) { 176 | case "path": 177 | this.setSVGpath(e.data.path, e.data.length); 178 | this.worker.removeEventListener("message", this.workerCommunication.bind(this), false); 179 | this.worker.terminate(); 180 | this.worker = null; 181 | break; 182 | default: 183 | break; 184 | } 185 | }, 186 | setSVGpath: function(path, len) { 187 | this.svgPath = document.createElementNS("http://www.w3.org/2000/svg", "path"); 188 | this.svgLength = len; 189 | 190 | this.svgPath.setAttribute("d", path); 191 | this.svgPath.setAttribute("vector-effect", "non-scaling-stroke"); 192 | this.svgPath.setAttribute("stroke-width", "0.5px"); 193 | 194 | this.oncomplete.forEach(fn => fn(this.svgPath, this.svgLength)); 195 | }, 196 | }; 197 | -------------------------------------------------------------------------------- /src/main/Save.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | 3 | import { css } from "@emotion/react"; 4 | import { 5 | basicButtonStyle, backOrContinueStyle, ariaLive, 6 | navigationButtonStyle, 7 | } from "../cssStyles"; 8 | 9 | import { LuCircleCheck, LuCircleAlert, LuChevronLeft, LuSave, LuCheck } from "react-icons/lu"; 10 | 11 | import { useAppDispatch, useAppSelector } from "../redux/store"; 12 | import { 13 | selectCustomizedTrackSelection, 14 | selectHasChanges, 15 | selectSegments, 16 | selectSelectedWorkflowId, 17 | selectTracks, 18 | setHasChanges as videoSetHasChanges, 19 | } from "../redux/videoSlice"; 20 | import { postVideoInformation, selectStatus, selectError } from "../redux/workflowPostSlice"; 21 | 22 | import { CallbackButton, PageButton } from "./Finish"; 23 | 24 | import { useTranslation } from "react-i18next"; 25 | import { 26 | setHasChanges as metadataSetHasChanges, 27 | selectHasChanges as metadataSelectHasChanges, 28 | selectAllCatalogs, 29 | } from "../redux/metadataSlice"; 30 | import { 31 | selectSubtitles, selectHasChanges as selectSubtitleHasChanges, 32 | setHasChanges as subtitleSetHasChanges, 33 | } from "../redux/subtitleSlice"; 34 | import { serializeSubtitle } from "../util/utilityFunctions"; 35 | import { useTheme } from "../themes"; 36 | import { ThemedTooltip } from "./Tooltip"; 37 | import { ErrorBox } from "@opencast/appkit"; 38 | import { Spinner } from "@opencast/appkit"; 39 | import { ProtoButton } from "@opencast/appkit"; 40 | import { setEnd } from "../redux/endSlice"; 41 | import { selectSubtitles as selectChapters } from "../redux/chapterSlice"; 42 | import { SubtitlesInEditor } from "../types"; 43 | 44 | /** 45 | * Shown if the user wishes to save. 46 | * Informs the user about saving and displays a save button 47 | */ 48 | const Save: React.FC = () => { 49 | 50 | const { t } = useTranslation(); 51 | 52 | const postWorkflowStatus = useAppSelector(selectStatus); 53 | const postError = useAppSelector(selectError); 54 | const metadataHasChanges = useAppSelector(metadataSelectHasChanges); 55 | const hasChanges = useAppSelector(selectHasChanges); 56 | const subtitleHasChanges = useAppSelector(selectSubtitleHasChanges); 57 | 58 | const saveStyle = css({ 59 | display: "flex", 60 | flexDirection: "column", 61 | justifyContent: "center", 62 | alignItems: "center", 63 | gap: "30px", 64 | }); 65 | 66 | const render = () => { 67 | // Post (successful) save 68 | if (postWorkflowStatus === "success" 69 | && !hasChanges && !metadataHasChanges && !subtitleHasChanges) { 70 | return ( 71 | <> 72 | 73 |
    {t("save.success-text")}
    74 | 75 | 76 | ); 77 | // Pre save 78 | } else { 79 | return ( 80 | <> 81 | 82 | {t("save.info-text")} 83 | 84 |
    85 | 86 | 87 |
    88 | 89 | ); 90 | } 91 | }; 92 | 93 | return ( 94 |
    95 |

    {t("save.headline-text")}

    96 | {render()} 97 | {postWorkflowStatus === "failed" && 98 | 99 | 100 | {t("various.error-text") + "\n"} 101 | {postError ? 102 | t("various.error-details-text", { errorMessage: postError }) : undefined 103 | } 104 | 105 | 106 | } 107 |
    108 | ); 109 | }; 110 | 111 | /** 112 | * Button that sends a post request to save current changes 113 | */ 114 | export const SaveButton: React.FC<{ 115 | text?: string 116 | isTransitionToEnd?: boolean 117 | startWorkflow?: boolean 118 | }> = ({ 119 | text, 120 | isTransitionToEnd = false, 121 | startWorkflow = false, 122 | }) => { 123 | const { t } = useTranslation(); 124 | 125 | // Initialize redux variables 126 | const dispatch = useAppDispatch(); 127 | 128 | const segments = useAppSelector(selectSegments); 129 | const tracks = useAppSelector(selectTracks); 130 | const customizedTrackSelection = useAppSelector(selectCustomizedTrackSelection); 131 | const subtitles = useAppSelector(selectSubtitles); 132 | const chapters = useAppSelector(selectChapters); 133 | const metadata = useAppSelector(selectAllCatalogs); 134 | const selectedWorkflowId = useAppSelector(selectSelectedWorkflowId); 135 | const workflowStatus = useAppSelector(selectStatus); 136 | const theme = useTheme(); 137 | 138 | // Update based on current fetching status 139 | let tooltip = null; 140 | const Icon = () => { 141 | if (workflowStatus === "failed") { 142 | tooltip = t("save.confirmButton-failed-tooltip"); 143 | return ; 144 | } else if (workflowStatus === "success") { 145 | tooltip = t("save.confirmButton-success-tooltip"); 146 | return ; 147 | } else if (workflowStatus === "loading") { 148 | tooltip = t("save.confirmButton-attempting-tooltip"); 149 | return ; 150 | } 151 | ; 152 | }; 153 | 154 | const ariaSaveUpdate = () => { 155 | if (workflowStatus === "success") { 156 | return t("save.success-tooltip-aria"); 157 | } 158 | }; 159 | 160 | const prepareSubtitles = (subs: { [identifier: string]: SubtitlesInEditor; }) => 161 | Object.entries(subs).map(([id, { deleted, cues, tags }]) => ({ 162 | id, 163 | subtitle: deleted ? "" : serializeSubtitle(cues), 164 | tags: deleted ? [] : tags, 165 | deleted, 166 | })); 167 | 168 | const save = () => { 169 | dispatch(postVideoInformation({ 170 | segments: segments, 171 | tracks: tracks, 172 | customizedTrackSelection, 173 | subtitles: prepareSubtitles(subtitles), 174 | chapters: prepareSubtitles(chapters), 175 | metadata: metadata, 176 | workflow: startWorkflow && selectedWorkflowId ? [{ id: selectedWorkflowId }] : undefined, 177 | })); 178 | }; 179 | 180 | // Let users leave the page without warning after a successful save 181 | useEffect(() => { 182 | if (workflowStatus === "success") { 183 | if (isTransitionToEnd) { 184 | dispatch(setEnd({ hasEnded: true, value: "success" })); 185 | } 186 | dispatch(videoSetHasChanges(false)); 187 | dispatch(metadataSetHasChanges(false)); 188 | dispatch(subtitleSetHasChanges(false)); 189 | } 190 | // eslint-disable-next-line react-hooks/exhaustive-deps 191 | }, [dispatch, workflowStatus]); 192 | 193 | return ( 194 | 195 | 199 | {Icon()} 200 | {text ?? t("save.confirm-button")} 201 |
    {ariaSaveUpdate()}
    202 |
    203 |
    204 | ); 205 | }; 206 | 207 | export default Save; 208 | -------------------------------------------------------------------------------- /editor-settings.toml: -------------------------------------------------------------------------------- 1 | #### 2 | # Opencast Stand-alone Video Editor 3 | ## 4 | 5 | # This file contains the editord default configuration and our recommendation for production use. 6 | # All values in here aer set to their defaults. 7 | 8 | # ⚠️ When deployed, this file is publicly accessibly! 9 | 10 | 11 | #### 12 | # General Settings 13 | ## 14 | 15 | # Allowed prefixes in callback urls to prevent malicious urls 16 | # If empty, no callback url is allowed 17 | # Type: string[] 18 | # Default: [] 19 | #allowedCallbackPrefixes = [] 20 | 21 | # Url to go back after finishing editing 22 | # If undefined, no return link will be shown on the end pages 23 | # Type: string | undefined 24 | # Default: undefined 25 | #callbackUrl = 26 | 27 | # Name of system to go back to 28 | # If undefined, a generic system name is used instead of a speficic name 29 | # Type: string | undefined 30 | # Default: undefined 31 | #callbackSystem = 32 | 33 | 34 | #### 35 | # Metadata 36 | ## 37 | 38 | [metadata] 39 | # If the metadata editor appears in the main menu 40 | # Type: boolean 41 | # Default: true 42 | #show = true 43 | 44 | ## Metadata display configuration 45 | ## Override various settings for how metadata catalogs and fields will be 46 | ## displayed in the editor. Configuration happens for each catalog separately. 47 | ## 48 | ## Configuration options for fields: 49 | ## 50 | ## show (boolean): Show or hide fields 51 | ## readonly (boolean): Mark fields as readonly 52 | ## 53 | ## Default behavior: 54 | ## 55 | ## - The default settings are based on Opencast's admin interface configuration 56 | ## - If catalogs are not specified, all of its fields will be displayed 57 | ## - If a catalog is specified but empty, it will not be displayed 58 | ## 59 | ## Example: 60 | ## 61 | # # This is the default catalog 62 | # [metadata.configureFields."EVENTS.EVENTS.DETAILS.CATALOG.EPISODE"] 63 | # title = {show = true, readonly = false} 64 | # subject = {show = false} 65 | # description = {readonly = true} 66 | # 67 | # # This catalog is specified but empty, and as such will not be displayed 68 | # [metadata.configureFields."NameOfAnExtendedMetadataCatalog"] 69 | 70 | 71 | #### 72 | # Track Selection 73 | ## 74 | 75 | [trackSelection] 76 | 77 | # If the track selection appears in the main menu. 78 | # Type: boolean 79 | # Default: true 80 | #show = true 81 | 82 | # Ensure that at least one video stream remains selected 83 | # Typically, the track selection ensures that at least one video stream 84 | # remains selected. If you would like your users to be able to create selections 85 | # with only audio streams, set this to false. 86 | # Default: true 87 | #atLeastOneVideo = true 88 | 89 | # Disables track selection for events with more than two videos 90 | # If your Opencast can handle track selection for more than two videos, set this 91 | # to false. 92 | # Default: true 93 | #atMostTwoVideos = true 94 | 95 | #### 96 | # Subtitles 97 | ## 98 | 99 | [subtitles] 100 | 101 | # If the subtitle editor appears in the main menu 102 | # Before you enable the subtitle editor, you should define some languages 103 | # under "subtitles.languages" 104 | # Type: boolean 105 | # Default: false 106 | #show = false 107 | 108 | # The main flavor of the subtitle tracks in Opencast 109 | # No other tracks should have the same main flavor as subtitle tracks 110 | # Type: string 111 | # Default: "captions" 112 | #mainFlavor = "captions" 113 | 114 | [subtitles.languages] 115 | ## A list of languages for which new subtitles can be created 116 | # For each language, various tags can be specified 117 | # A list of officially recommended tags can be found at 118 | # https://docs.opencast.org/develop/admin/#configuration/subtitles/#tags 119 | # At least the "lang" tag MUST be specified 120 | german = { lang = "de-DE" } 121 | english = { lang = "en-US", type = "closed-caption" } 122 | spanish = { lang = "es" } 123 | 124 | [subtitles.icons] 125 | # A list of icons to be displayed for languages defined above. 126 | # Values are strings but should preferably be Unicode icons. 127 | # These are optional and you can also choose to have no icons. 128 | "de-DE" = "DE" 129 | "en-US" = "EN" 130 | "es" = "ES" 131 | 132 | [subtitles.defaultVideoFlavor] 133 | # Specify the default video in the subtitle video player by flavor 134 | # If not specified, the editor will decide on a default by itself 135 | # "type" = "presentation" 136 | # "subtype" = "preview" 137 | 138 | ### 139 | # Chapters 140 | ## 141 | 142 | [chapters] 143 | 144 | # If the chapter editor appears in the main menu 145 | # Type: boolean 146 | # Default: false 147 | #show = false 148 | 149 | # The main flavor of the chapters track in Opencast 150 | # No other track should have the same main flavor as chapters track 151 | # Type: string 152 | # Default: "chapters" 153 | #mainFlavor = "chapters" 154 | 155 | 156 | #### 157 | # Thumbnail Selection 158 | ## 159 | 160 | [thumbnail] 161 | 162 | # If the thumbnail editor appears in the main menu 163 | # Type: boolean 164 | # Default: false 165 | #show = false 166 | 167 | # Whether to use "simple" or "professional" mode. 168 | # Professional mode allows users to edit all thumbnails that fit the subflavor 169 | # specified in the Opencast configuration file 170 | # `etc/org.opencastproject.editor.EditorServiceImpl.cfg`. It is useful 171 | # when working with multiple thumbnails. 172 | # Simple mode only allows users to edit the "primary" thumbnail, as specified 173 | # in the Opencast configuration file 174 | # `etc/org.opencastproject.editor.EditorServiceImpl.cfg`. It is useful 175 | # when there is only a single thumbnail to worry about and you want hide 176 | # potential fallbacks from the user. If a primary thumbnail cannot be 177 | # determined, this falls back to professional mode. 178 | # Type: boolean 179 | # Default: false 180 | #simpleMode = false 181 | 182 | 183 | 184 | ############################################################ 185 | # Settings for demo deployment 186 | ############################################################ 187 | 188 | # All settings from here on are ment for demo deployments and rarely useful for production. 189 | # In general, these should be completely left out from deployments in Opencast. 190 | 191 | 192 | # Id of the event that the editor should open by default. 193 | # This is very useful as demo, but has no purpose otherwise. 194 | # Type: string | undefined 195 | # Default: undefined 196 | #id = 197 | 198 | 199 | [opencast] 200 | 201 | # URL of the opencast server to connect to. 202 | # The default will work just fine if integrated in Opencast. 203 | # Type: URL 204 | # Default: Current server 205 | #url = 'https://develop.opencast.org' 206 | 207 | # Username for HTTP basic authentication against Opencast. 208 | # Not defining this will work just fine if integrated in Opencast. 209 | # Type: string | undefined 210 | # Default: undefined 211 | #name = 212 | 213 | # Password for HTTP basic authentication against Opencast. 214 | # Not defining this will work just fine if integrated in Opencast. 215 | # Type: string | undefined 216 | # Default: undefined 217 | #password = 218 | 219 | # Replace media package URLs with local URLs if possible. 220 | # This is done only if: 221 | # - Opencast indicates that the files are available locally 222 | # - Opencast URL has not been overwritten 223 | # Type: boolean 224 | # Default: true 225 | #local = 226 | -------------------------------------------------------------------------------- /src/main/WorkflowSelection.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | 3 | import { css } from "@emotion/react"; 4 | import { backOrContinueStyle } from "../cssStyles"; 5 | 6 | import { useAppDispatch, useAppSelector } from "../redux/store"; 7 | import { selectWorkflows, setSelectedWorkflowIndex } from "../redux/videoSlice"; 8 | 9 | import { PageButton } from "./Finish"; 10 | import { LuChevronLeft } from "react-icons/lu"; 11 | import { selectStatus as saveSelectStatus, selectError as saveSelectError } from "../redux/workflowPostSlice"; 12 | import { httpRequestState, Workflow } from "../types"; 13 | import { SaveButton } from "./Save"; 14 | import { EmotionJSX } from "@emotion/react/dist/declarations/src/jsx-namespace"; 15 | 16 | import { useTranslation } from "react-i18next"; 17 | import { Trans } from "react-i18next"; 18 | import { FormControlLabel, Radio, RadioGroup } from "@mui/material"; 19 | import { useTheme } from "../themes"; 20 | import { ErrorBox } from "@opencast/appkit"; 21 | 22 | /** 23 | * Allows the user to select a workflow 24 | */ 25 | const WorkflowSelection: React.FC = () => { 26 | 27 | const { t } = useTranslation(); 28 | 29 | const dispatch = useAppDispatch(); 30 | 31 | // Initialite redux states 32 | let workflows = useAppSelector(selectWorkflows); 33 | // Need to make copy to handle undefined displayOrder values 34 | workflows = [...workflows].sort((a, b) => { 35 | return (b.displayOrder - a.displayOrder); 36 | }); 37 | 38 | const saveStatus = useAppSelector(saveSelectStatus); 39 | const saveError = useAppSelector(saveSelectError); 40 | 41 | const workflowSelectionStyle = css({ 42 | padding: "20px", 43 | display: "flex", 44 | flexDirection: "column", 45 | justifyContent: "center", 46 | alignItems: "center", 47 | gap: "30px", 48 | }); 49 | 50 | const workflowSelectionSelectionStyle = css({ 51 | display: "flex", 52 | flexDirection: "column", 53 | alignItems: "left", 54 | gap: "20px", 55 | flexWrap: "wrap", 56 | maxHeight: "50vh", 57 | }); 58 | 59 | useEffect(() => { 60 | if (workflows.length >= 1) { 61 | dispatch(setSelectedWorkflowIndex(workflows[0].id)); 62 | } 63 | }, [dispatch, workflows]); 64 | 65 | const handleWorkflowSelectChange = (event: { target: { value: string; }; }) => { 66 | dispatch(setSelectedWorkflowIndex(event.target.value)); 67 | }; 68 | 69 | // Layout template 70 | const render = (topTitle: string, topText: JSX.Element, hasWorkflowButtons: boolean, 71 | nextButton: EmotionJSX.Element, errorStatus: httpRequestState["status"], 72 | errorMessage: httpRequestState["error"]) => { 73 | return ( 74 |
    75 |

    {topTitle}

    76 | {topText} 77 | {hasWorkflowButtons && 78 | 84 | {workflows.map((workflow: Workflow, _index: number) => ( 85 | 91 | ))} 92 | 93 | } 94 |
    95 | 96 | {/* */} 97 | {nextButton} 98 |
    99 | {errorStatus === "failed" && 100 | 101 | {t("various.error-text")}
    102 | {errorMessage ? 103 | t("various.error-details-text", { errorMessage: saveError }) : 104 | t("various.error-text")}
    105 |
    106 | } 107 |
    108 | ); 109 | }; 110 | 111 | // Fills the layout template with values based on how many workflows are available 112 | const renderSelection = () => { 113 | if (workflows.length <= 0) { 114 | return ( 115 | render( 116 | t("workflowSelection.saveAndProcess-text"), 117 | 118 | There are no workflows to process your changes with.
    119 | Please save your changes and contact an administrator. 120 |
    , 121 | false, 122 | , 123 | saveStatus, 124 | saveError, 125 | ) 126 | ); 127 | } else if (workflows.length === 1) { 128 | return ( 129 | render( 130 | t("workflowSelection.saveAndProcess-text"), 131 | 132 | The changes will be saved and the video will be cut and processed with 133 | the workflow {{ workflow: workflows[0].name }}.
    134 | This will take some time. 135 |
    , 136 | false, 137 | , 142 | saveStatus, 143 | saveError, 144 | ) 145 | ); 146 | } else { 147 | return ( 148 | render( 149 | t("workflowSelection.selectWF-text"), 150 |
    151 | {t("workflowSelection.manyWorkflows-text")} 152 |
    , 153 | true, 154 | , 159 | saveStatus, 160 | saveError, 161 | ) 162 | ); 163 | } 164 | }; 165 | 166 | return ( 167 | renderSelection() 168 | ); 169 | }; 170 | 171 | const WorkflowButton: React.FC<{ 172 | stateName: string, 173 | workflowId: string, 174 | workflowDescription: string; 175 | }> = ({ 176 | stateName, 177 | workflowId, 178 | workflowDescription, 179 | }) => { 180 | 181 | // Note: Styling the Radio Button is done in WorkflowSelectRadio 182 | 183 | const labelStyle = css({ 184 | display: "flex", 185 | flexDirection: "column", 186 | maxWidth: "500px", 187 | paddingTop: "2px", 188 | }); 189 | 190 | const headerStyle = css({ 191 | width: "100%", 192 | padding: "5px 0px", 193 | fontSize: "larger", 194 | }); 195 | 196 | return ( 197 | } 198 | label={ 199 |
    200 |
    {stateName}
    201 |
    {workflowDescription}
    202 |
    203 | } 204 | /> 205 | ); 206 | }; 207 | 208 | const WorkflowSelectRadio: React.FC = props => { 209 | 210 | const theme = useTheme(); 211 | 212 | const style = css({ 213 | alignSelf: "start", 214 | color: `${theme.text}`, 215 | "&$checked": { 216 | color: `${theme.text}`, 217 | }, 218 | }); 219 | 220 | return ( 221 | 226 | ); 227 | }; 228 | 229 | 230 | export default WorkflowSelection; 231 | -------------------------------------------------------------------------------- /public/opencast-editor.svg: -------------------------------------------------------------------------------- 1 | 2 | 55 | 56 | -------------------------------------------------------------------------------- /src/util/utilityFunctions.ts: -------------------------------------------------------------------------------- 1 | import { nanoid } from "@reduxjs/toolkit"; 2 | import { WebVTTParser, WebVTTSerializer } from "webvtt-parser"; 3 | import { ExtendedSubtitleCue, SubtitleCue } from "../types"; 4 | import { useEffect, useState, useRef } from "react"; 5 | import i18next from "i18next"; 6 | 7 | export const roundToDecimalPlace = (num: number, decimalPlace: number) => { 8 | const decimalFactor = Math.pow(10, decimalPlace); 9 | return Math.round((num + Number.EPSILON) * decimalFactor) / decimalFactor; 10 | }; 11 | 12 | 13 | // Returns a promise that resolves after `ms` milliseconds. 14 | export const sleep = (ms: number) => new Promise((resolve, _reject) => setTimeout(resolve, ms)); 15 | 16 | 17 | // Get an understandable time string for ARIA 18 | export const convertMsToReadableString = (ms: number): string => { 19 | const hours = new Date((ms ? ms : 0)).toISOString().substr(11, 2); 20 | const minutes = new Date((ms ? ms : 0)).toISOString().substr(14, 2); 21 | const seconds = new Date((ms ? ms : 0)).toISOString().substr(17, 2); 22 | 23 | const result = []; 24 | if (parseInt(hours) > 0) { result.push(hours + " hours, "); } 25 | if (parseInt(minutes) > 0 || parseInt(hours) > 0) { result.push(minutes + " minutes, "); } 26 | result.push(seconds + " seconds"); 27 | 28 | return result.join(""); 29 | }; 30 | 31 | // eslint-disable-next-line max-len 32 | /* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-return */ 33 | /** 34 | * Converts a working subtitle representation into a string 35 | */ 36 | export function serializeSubtitle(subtitle: SubtitleCue[]): string { 37 | const seri = new WebVTTSerializer(); 38 | 39 | // Fix cues to work with serialize 40 | let cueIndex = 0; 41 | const cues = [...subtitle]; 42 | for (let cue of subtitle) { 43 | cue = { ...cue }; 44 | cue.startTime = cue.startTime / 1000; 45 | cue.endTime = cue.endTime / 1000; 46 | 47 | const extendedCue: ExtendedSubtitleCue = { 48 | id: cue.id ? cue.id : undefined, 49 | idInternal: cue.idInternal, 50 | text: cue.text, 51 | startTime: cue.startTime, 52 | endTime: cue.endTime, 53 | tree: cue.tree, 54 | 55 | // The serializer has a bug where some of the attributes like alignment are written to the VTT file 56 | // as `alignment: undefined` if they are not set. This then causes illegal parsing exceptions with the 57 | // parser. That"s why we set some acceptable defaults here. 58 | alignment: "center", 59 | direction: "horizontal", 60 | lineAlign: "start", 61 | linePosition: "auto", 62 | positionAlign: "auto", 63 | size: 100, 64 | textPosition: "auto", 65 | }; 66 | cue = extendedCue; 67 | 68 | cues[cueIndex] = cue; 69 | 70 | cueIndex++; 71 | } 72 | return seri.serialize(cues); 73 | } 74 | 75 | export function parseSubtitle(subtitle: string): SubtitleCue[] { 76 | // Used parsing library: https://www.npmjs.com/package/webvtt-parser 77 | // - Unmaintained and does have bugs, so we will need to switch eventually 78 | // Other interesting vtt parsing libraries: 79 | // https://github.com/osk/node-webvtt 80 | // - Pros: Parses styles and meta information 81 | // - Cons: Parses timestamps in seconds, Maybe not maintained anymore 82 | // https://github.com/gsantiago/subtitle.js 83 | // - Pros: Parses styles, can also parse SRT, actively maintained 84 | // - Cons: Uses node streaming library, can"t polyfill without ejecting CreateReactApp 85 | // TODO: Parse caption 86 | if (subtitle === "") { 87 | throw new Error("File is empty"); 88 | } 89 | 90 | const parser = new WebVTTParser(); 91 | const tree = parser.parse(subtitle, "metadata"); 92 | if (tree.errors.length !== 0) { 93 | 94 | // state.status = "failed" 95 | const errors = []; 96 | for (const er of tree.errors) { 97 | errors.push("On line: " + er.line + " col: " + er.col + " error occured: " + er.message); 98 | } 99 | throw new Error(errors.join("\n")); 100 | // setError(state, action.payload.identifier, errors.join("\n")) 101 | } 102 | 103 | // Attach a unique id to each segment/cue 104 | // This is used by React to keep track of cues between changes (e.g. addition, deletion) 105 | let index = 0; 106 | for (const cue of tree.cues) { 107 | if (!cue.id) { 108 | cue.idInternal = nanoid(); 109 | tree.cues[index] = cue; 110 | } 111 | 112 | // Turn times into milliseconds 113 | cue.startTime = cue.startTime * 1000; 114 | cue.endTime = cue.endTime * 1000; 115 | tree.cues[index] = cue; 116 | 117 | index++; 118 | } 119 | 120 | return tree.cues; 121 | } 122 | // eslint-disable-next-line max-len 123 | /* eslint-enable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-return */ 124 | 125 | /** 126 | * Parse language code to language name 127 | * Returns language name in the language set by the user 128 | * Returns undefined if the input was undefined or the language code could not 129 | * be parsed 130 | */ 131 | export function languageCodeToName(lang: string | undefined): string | undefined { 132 | if (!lang) { 133 | return undefined; 134 | } 135 | const currentLang = i18next.resolvedLanguage; 136 | const languageNames = new Intl.DisplayNames(currentLang, { type: "language" }); 137 | try { 138 | return languageNames.of(lang.trim()); 139 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 140 | } catch (e) { 141 | return undefined; 142 | } 143 | } 144 | 145 | /** 146 | * @returns the current window width and height 147 | */ 148 | function getWindowDimensions() { 149 | const { innerWidth: width, innerHeight: height } = window; 150 | return { 151 | width, 152 | height, 153 | }; 154 | } 155 | 156 | /** 157 | * A hook for window dimensions 158 | */ 159 | export default function useWindowDimensions() { 160 | const [windowDimensions, setWindowDimensions] = useState(getWindowDimensions()); 161 | 162 | useEffect(() => { 163 | function handleResize() { 164 | setWindowDimensions(getWindowDimensions()); 165 | } 166 | 167 | window.addEventListener("resize", handleResize); 168 | return () => window.removeEventListener("resize", handleResize); 169 | }, []); 170 | 171 | return windowDimensions; 172 | 173 | } 174 | 175 | // Runs a callback every delay milliseconds 176 | // Pass delay = null to stop 177 | // Based off: https://overreacted.io/making-setinterval-declarative-with-react-hooks/ 178 | type IntervalFunction = () => (unknown); 179 | export function useInterval(callback: IntervalFunction, delay: number | null) { 180 | 181 | const savedCallback = useRef(null); 182 | 183 | useEffect(() => { 184 | savedCallback.current = callback; 185 | }); 186 | 187 | useEffect(() => { 188 | function tick() { 189 | if (savedCallback.current !== null) { 190 | savedCallback.current(); 191 | } 192 | } 193 | if (delay !== null) { 194 | const id = setInterval(tick, delay); 195 | return () => { clearInterval(id); }; 196 | } 197 | }, [callback, delay]); 198 | } 199 | 200 | // Returns true if the given index is out of bounds on the given array 201 | export function outOfBounds(array: unknown[], index: number) { 202 | if (index >= array.length) { 203 | return true; 204 | } 205 | return false; 206 | } 207 | 208 | // Typeguard for Dates 209 | export function isValidDate(value: unknown): value is Date { 210 | return value instanceof Date && !isNaN(value.getTime()); 211 | } 212 | 213 | -------------------------------------------------------------------------------- /.github/workflows/pr-deploy-test-branch.yml: -------------------------------------------------------------------------------- 1 | name: PRs » Publish Pull Request Page 2 | 3 | on: 4 | pull_request_target: 5 | types: 6 | - opened 7 | - synchronize 8 | 9 | concurrency: 10 | group: pull-request-page 11 | cancel-in-progress: false 12 | 13 | jobs: 14 | detect-repo-owner: 15 | if: github.repository_owner == 'opencast' 16 | runs-on: ubuntu-latest 17 | outputs: 18 | server: ${{ steps.test-server.outputs.server }} 19 | branch: ${{ steps.branch-name.outputs.branch }} 20 | steps: 21 | - name: Checkout sources 22 | uses: actions/checkout@v5 23 | with: 24 | ref: ${{github.event.pull_request.head.ref}} 25 | repository: ${{github.event.pull_request.head.repo.full_name}} 26 | 27 | - name: Determine the correct test server 28 | id: test-server 29 | run: echo "server=`./.github/get-release-server.sh ${{ github.ref_name }}`" >> $GITHUB_OUTPUT 30 | 31 | - name: Determine branch name 32 | id: branch-name 33 | run: | 34 | #Temp becomes something like r/17.x 35 | export TEMP=${{ github.ref_name }} 36 | #Strip the r/ prefix, giving us just 17.x. If this is main/develop this does nothing 37 | echo "branch=${TEMP#r\/}" >> $GITHUB_OUTPUT 38 | 39 | deploy-pr: 40 | runs-on: ubuntu-latest 41 | needs: detect-repo-owner 42 | steps: 43 | - name: Generate build path 44 | run: echo "build=${{github.event.number}}/$(date +%Y-%m-%d_%H-%M-%S)/" >> $GITHUB_OUTPUT 45 | id: build-path 46 | 47 | - name: Checkout sources 48 | uses: actions/checkout@v5 49 | with: 50 | ref: ${{github.event.pull_request.head.ref}} 51 | repository: ${{github.event.pull_request.head.repo.full_name}} 52 | 53 | - name: Get Node.js 54 | uses: actions/setup-node@v5 55 | with: 56 | node-version: 20 57 | 58 | - name: Run npm ci 59 | run: npm ci 60 | 61 | - name: Build the app 62 | run: | 63 | # This set the editor's datasource to the relevant test server 64 | sed -i "s#develop.opencast.org#$SERVER#g" public/editor-settings.toml 65 | npm run build 66 | env: 67 | SERVER: ${{needs.detect-repo-owner.outputs.server}} 68 | PUBLIC_URL: ${{ steps.build-path.outputs.build }} 69 | 70 | - name: Prepare git 71 | run: | 72 | git config --global user.name "Editor Deployment Bot" 73 | git config --global user.email "cloud@opencast.org" 74 | 75 | - name: Prepare GitHub SSH key from org level secret 76 | env: 77 | DEPLOY_KEY: ${{ secrets.DEPLOY_KEY_TEST }} 78 | run: | 79 | install -dm 700 ~/.ssh/ 80 | echo "${DEPLOY_KEY}" > ~/.ssh/id_ed25519 81 | chmod 600 ~/.ssh/id_ed25519 82 | ssh-keyscan github.com >> ~/.ssh/known_hosts 83 | 84 | - name: Wait for previous workflows to finish 85 | uses: softprops/turnstyle@v2 86 | with: 87 | same-branch-only: false 88 | env: 89 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 90 | 91 | - name: Clone repository 92 | run: | 93 | git clone -b gh-pages "git@github.com:${{ github.repository_owner }}/opencast-editor-test.git" editor-test 94 | 95 | - name: Store build in the clone 96 | env: 97 | DEPLOY_PATH: editor-test/${{ steps.build-path.outputs.build }} 98 | run: | 99 | mkdir -p ${DEPLOY_PATH} 100 | cp -rv build/* ${DEPLOY_PATH} 101 | 102 | - name: Cleanup test repository 103 | working-directory: editor-test 104 | env: 105 | GH_TOKEN: ${{ github.token }} 106 | run: | 107 | wget https://raw.githubusercontent.com/${{ github.repository_owner }}/opencast-editor-test/main/.github/scripts/cleanup-deployments.sh 108 | bash cleanup-deployments.sh ${{ github.repository_owner }}/opencast-editor 109 | rm -f cleanup-deployments.sh 110 | git add . 111 | 112 | - name: Generate index.html 113 | working-directory: editor-test 114 | run: | 115 | echo '
    ' >> index.html 120 | 121 | - name: Commit new version 122 | working-directory: editor-test 123 | run: | 124 | git add . 125 | git commit --amend -m "Build ${{ steps.build-path.outputs.build }}" 126 | 127 | - name: Push updates 128 | working-directory: editor-test 129 | run: | 130 | git push origin gh-pages --force 131 | 132 | - name: Add comment with deployment location 133 | uses: thollander/actions-comment-pull-request@v3 134 | with: 135 | comment-tag: static-test-deployment 136 | message: > 137 | This pull request is deployed at 138 | [test.editor.opencast.org/${{ steps.build-path.outputs.build }} 139 | ](https://test.editor.opencast.org/${{ steps.build-path.outputs.build }}). 140 | 141 | It might take a few minutes for it to become available. 142 | 143 | 144 | #This is currently defunct, but kept around in case we run into issues in the future 145 | # where this check might be handy, cf spammers filing PRs or something. 146 | # Previously we checked is_team_member prior to doing anything else above 147 | check-member: 148 | name: Check organization membership 149 | if: github.repository_owner == 'opencast' 150 | runs-on: ubuntu-latest 151 | 152 | # Map a step output to a job output 153 | outputs: 154 | is_team_member: ${{ steps.is_developer.outputs.permitted == 'true' || steps.is_committer.outputs.permitted == 'true' }} 155 | 156 | steps: 157 | - name: Check if user is Opencast developer 158 | id: is_developer 159 | if: ${{ github.event.pull_request.head.repo.full_name != 'opencast/opencast-editor' }} 160 | uses: TheModdingInquisition/actions-team-membership@v1.0 161 | with: 162 | team: developers 163 | # Personal Access Token with the `read:org` permission 164 | token: ${{ secrets.ORGANIZATION_MEMBER_SECRET }} 165 | exit: false 166 | 167 | - name: Check if user is Opencast committer 168 | id: is_committer 169 | if: ${{ github.event.pull_request.head.repo.full_name != 'opencast/opencast-editor' }} 170 | uses: TheModdingInquisition/actions-team-membership@v1.0 171 | with: 172 | team: committers 173 | # Personal Access Token with the `read:org` permission 174 | token: ${{ secrets.ORGANIZATION_MEMBER_SECRET }} 175 | exit: false 176 | 177 | 178 | check-no-modified-translations: 179 | name: Translations only via Crowdin 180 | if: github.repository_owner == 'opencast' 181 | runs-on: ubuntu-latest 182 | 183 | steps: 184 | - name: Checkout Sources 185 | uses: actions/checkout@v5 186 | 187 | - name: Get changed locale files 188 | uses: dorny/paths-filter@v3 189 | id: filter_locales 190 | with: 191 | filters: | # !(pattern) matches anything but pattern 192 | locales: 193 | - 'src/i18n/locales/!(en-US)*.json' 194 | 195 | - name: Check for changes in translations 196 | if: steps.filter_locales.outputs.locales == true 197 | uses: actions/github-script@v8 198 | with: 199 | script: | 200 | core.setFailed('You should not alter translations outside of Crowdin.') 201 | -------------------------------------------------------------------------------- /src/main/Chapter.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from "react-i18next"; 2 | import { useAppDispatch, useAppSelector } from "../redux/store"; 3 | import SubtitleListEditor from "./SubtitleListEditor"; 4 | import { useEffect, useState } from "react"; 5 | import SubtitleVideoArea from "./SubtitleVideoArea"; 6 | import Timeline from "./Timeline"; 7 | import { 8 | addCueAtIndex, 9 | cut, 10 | mergeAll, 11 | removeCue, 12 | selectAspectRatio, 13 | selectClickTriggered, 14 | selectCurrentlyAt, 15 | selectCurrentlyAtInSeconds, 16 | selectFocusSegmentId, 17 | selectFocusSegmentTriggered, 18 | selectFocusSegmentTriggered2, 19 | selectIsPlaying, 20 | selectIsPlayPreview, 21 | selectPreviewTriggered, 22 | selectSelectedSubtitleById, 23 | selectSelectedSubtitleId, 24 | setAspectRatio, 25 | setClickTriggered, 26 | setCueAtIndex, 27 | setCurrentlyAt, 28 | setCurrentlyAtAndTriggerPreview, 29 | setFocusSegmentTriggered, 30 | setFocusSegmentTriggered2, 31 | setFocusToSegmentAboveId, 32 | setFocusToSegmentBelowId, 33 | setIsPlaying, 34 | setIsPlayPreview, 35 | setPreviewTriggered, 36 | setSubtitle, 37 | initializeSubtitle, 38 | setSelectedSubtitleId, 39 | deleteByMerge, 40 | } from "../redux/chapterSlice"; 41 | import { css } from "@emotion/react"; 42 | import { useTheme } from "../themes"; 43 | import { titleStyle, titleStyleBold } from "../cssStyles"; 44 | import { 45 | selectChaptersFromOpencast, 46 | selectDuration, 47 | } from "../redux/videoSlice"; 48 | import { parseSubtitle } from "../util/utilityFunctions"; 49 | import { v4 as uuidv4 } from "uuid"; 50 | import CuttingActions from "./CuttingActions"; 51 | 52 | /** 53 | * Displays an editor view for a selected subtitle file 54 | */ 55 | const Chapter: React.FC = () => { 56 | const { t } = useTranslation(); 57 | const dispatch = useAppDispatch(); 58 | const theme = useTheme(); 59 | 60 | const [getError, setGetError] = useState(undefined); 61 | 62 | const subtitle = useAppSelector(selectSelectedSubtitleById); 63 | const selectedId = useAppSelector(selectSelectedSubtitleId); 64 | // Assume only one chapter track 65 | const captionTracks = useAppSelector(state => selectChaptersFromOpencast(state)); 66 | const captionTrack = captionTracks[0] ?? undefined; 67 | const duration = useAppSelector(selectDuration); 68 | 69 | // Prepare subtitle in redux 70 | useEffect(() => { 71 | // Parse subtitle data from Opencast 72 | if (subtitle?.cues === undefined && captionTrack !== undefined && captionTrack.subtitle !== undefined 73 | && !selectedId) { 74 | try { 75 | dispatch(setSelectedSubtitleId(captionTrack.id)); 76 | dispatch(setSubtitle({ 77 | identifier: captionTrack.id, 78 | subtitles: { cues: parseSubtitle(captionTrack.subtitle), tags: captionTrack.tags, deleted: false }, 79 | })); 80 | } catch (error) { 81 | if (error instanceof Error) { 82 | setGetError(error.message); 83 | } else { 84 | setGetError(String(error)); 85 | } 86 | } 87 | 88 | // Or create a new subtitle instead 89 | } else if (subtitle?.cues === undefined && captionTrack === undefined && !selectedId) { 90 | // Create an empty subtitle 91 | // const newId = uuidv4(); 92 | dispatch(initializeSubtitle({ identifier: uuidv4(), subtitles: { cues: [{ 93 | id: undefined, 94 | idInternal: uuidv4(), 95 | text: "", 96 | startTime: 0, 97 | endTime: duration, 98 | tree: { children: [{ type: "text", value: "" }] }, 99 | }], tags: [], deleted: false } })); 100 | } 101 | }, [dispatch, captionTrack, subtitle, selectedId, duration]); 102 | 103 | const subtitleEditorStyle = css({ 104 | display: "flex", 105 | flexDirection: "column", 106 | paddingRight: "20px", 107 | paddingLeft: "20px", 108 | gap: "20px", 109 | height: "100%", 110 | }); 111 | 112 | const headerRowStyle = css({ 113 | display: "flex", 114 | flexDirection: "row", 115 | justifyContent: "center", 116 | alignItems: "center", 117 | width: "100%", 118 | gap: "10px", 119 | padding: "15px", 120 | }); 121 | 122 | const subAreaStyle = css({ 123 | display: "flex", 124 | flexDirection: "row", 125 | flexGrow: 1, // No fixed height, fill available space 126 | justifyContent: "space-between", 127 | alignItems: "top", 128 | width: "100%", 129 | paddingTop: "10px", 130 | paddingBottom: "10px", 131 | gap: "30px", 132 | borderBottom: `${theme.menuBorder}`, 133 | }); 134 | 135 | const render = () => { 136 | if (getError !== undefined) { 137 | return ( 138 | {"Subtitle Parsing Error(s): " + getError} 139 | ); 140 | } else { 141 | return ( 142 | <> 143 |
    144 |
    145 | {t("chapters.editTitle")} 146 |
    147 |
    148 |
    149 | 169 | 185 |
    186 | 194 | 200 | 201 | ); 202 | } 203 | }; 204 | 205 | return ( 206 |
    207 | {render()} 208 |
    209 | ); 210 | }; 211 | 212 | export default Chapter; 213 | -------------------------------------------------------------------------------- /src/main/Header.tsx: -------------------------------------------------------------------------------- 1 | 2 | import React from "react"; 3 | import { useAppSelector } from "../redux/store"; 4 | import { useTheme } from "../themes"; 5 | 6 | import { css } from "@emotion/react"; 7 | import { useTranslation } from "react-i18next"; 8 | import { MainMenuButton } from "./MainMenu"; 9 | import { LuMoon, LuSun } from "react-icons/lu"; 10 | import { HiOutlineTranslate } from "react-icons/hi"; 11 | import { LuKeyboard } from "react-icons/lu"; 12 | import { MainMenuStateNames } from "../types"; 13 | import { basicButtonStyle, BREAKPOINTS, undisplay } from "../cssStyles"; 14 | 15 | import { selectIsEnd } from "../redux/endSlice"; 16 | import { 17 | checkboxMenuItem, 18 | HeaderMenuItemDef, 19 | ProtoButton, 20 | screenWidthAtMost, 21 | useColorScheme, 22 | WithHeaderMenu, 23 | } from "@opencast/appkit"; 24 | import { IconType } from "react-icons"; 25 | import i18next from "i18next"; 26 | import { languages as lngs } from "../i18n/lngs-generated"; 27 | 28 | function Header() { 29 | const theme = useTheme(); 30 | const { scheme } = useColorScheme(); 31 | const { t } = useTranslation(); 32 | 33 | const isEnd = useAppSelector(selectIsEnd); 34 | 35 | const headerStyle = css({ 36 | display: "flex", 37 | alignItems: "center", 38 | justifyContent: "space-between", 39 | backgroundColor: `${theme.header_bg}`, 40 | }); 41 | 42 | const headerStyleThemed = scheme.includes("high-contrast-") 43 | ? css({ 44 | height: "62px", 45 | borderBottom: "2px solid white", 46 | }) 47 | : css({ 48 | height: "64px", 49 | }); 50 | 51 | const rightSideButtonsStyle = css({ 52 | display: "flex", 53 | flexDirection: "row", 54 | height: "100%", 55 | alignItems: "center", 56 | paddingRight: "24px", 57 | gap: "16px", 58 | }); 59 | 60 | const settingsButtonCSS = css({ 61 | display: "flex", 62 | flexDirection: "row", 63 | alignItems: "center", 64 | gap: "8px", 65 | 66 | fontSize: 16, 67 | fontFamily: "inherit", 68 | fontWeight: 500, 69 | color: `${theme.header_text}`, 70 | outline: `${theme.menuButton_outline}`, 71 | padding: "6px 8px", 72 | 73 | ":hover, :active": { 74 | outline: `2px solid ${theme.metadata_highlight}`, 75 | backgroundColor: theme.header_button_hover_bg, 76 | color: `${theme.header_text}`, 77 | }, 78 | 79 | [screenWidthAtMost(BREAKPOINTS.medium)]: { 80 | fontSize: 0, 81 | }, 82 | }); 83 | 84 | return ( 85 |
    86 | 87 |
    88 | 89 | 90 | {!isEnd && 91 | 99 | } 100 |
    101 |
    102 | ); 103 | } 104 | 105 | const LogoPicture: React.FC = () => { 106 | const imgUrl = new URL("/public/opencast-editor.svg", import.meta.url).href; 107 | return ( 108 |
    109 | *": { 112 | height: "calc(100% - 0.5px)", 113 | }, 114 | }}> 115 | 116 | Opencast Editor Logo 117 | 118 |
    119 | ); 120 | }; 121 | 122 | const Logo: React.FC = () => { 123 | 124 | const { t } = useTranslation(); 125 | const { scheme } = useColorScheme(); 126 | 127 | const logo = css({ 128 | paddingLeft: "8px", 129 | opacity: scheme === "dark" ? "0.8" : "1", 130 | display: "flex", 131 | height: "100%", 132 | "> *": { 133 | height: "calc(100% - 12px)", 134 | }, 135 | alignItems: "center", 136 | 137 | // Unset a bunch of CSS to keep the logo clean 138 | outline: "unset", 139 | "&:hover": { 140 | backgroundColor: "unset", 141 | }, 142 | "&:focus": { 143 | backgroundColor: "unset", 144 | }, 145 | }); 146 | 147 | return ( 148 | 156 | ); 157 | }; 158 | 159 | const LanguageButton: React.FC = () => { 160 | const { t } = useTranslation(); 161 | 162 | const isCurrentLanguage = (language: string) => language === i18next.resolvedLanguage; 163 | 164 | const changeLanguage = (lng: string | undefined) => { 165 | i18next.changeLanguage(lng); 166 | }; 167 | 168 | const languageNames = (language: string) => { 169 | return new Intl.DisplayNames([language], { 170 | type: "language", 171 | }).of(language); 172 | }; 173 | 174 | const languages = Array.from(lngs, ([key, value]) => { 175 | return { value: value, label: languageNames(key) }; 176 | }); 177 | 178 | // menuItems can"t deal with languages being undefined, so we return early 179 | // until we reach a rerender with actual information 180 | if (languages === undefined) { 181 | return (<>); 182 | } 183 | 184 | const menuItems = Object.values(languages).map(lng => checkboxMenuItem({ 185 | checked: isCurrentLanguage(lng.value), 186 | children: <>{lng.label}, 187 | onClick: () => { 188 | changeLanguage(lng?.value); 189 | }, 190 | })); 191 | 192 | const label = t("language.language"); 193 | return ( 194 | 201 | 202 | 203 | ); 204 | }; 205 | 206 | const ThemeButton: React.FC = () => { 207 | 208 | const { t } = useTranslation(); 209 | 210 | const { scheme, isAuto, update } = useColorScheme(); 211 | const currentPref = isAuto ? "auto" : scheme; 212 | const choices = ["auto", "light", "dark", "light-high-contrast", "dark-high-contrast"] as const; 213 | const menuItems: HeaderMenuItemDef[] = choices.map(choice => checkboxMenuItem({ 214 | checked: currentPref === choice, 215 | children: <>{t(`theme.${choice}`)}, 216 | onClick: () => update(choice), 217 | })); 218 | 219 | return ( 220 | 226 | 230 | 231 | ); 232 | }; 233 | 234 | type HeaderButtonProps = JSX.IntrinsicElements["button"] & { 235 | Icon: IconType; 236 | label: string; 237 | }; 238 | 239 | const HeaderButton = React.forwardRef( 240 | ({ Icon, label, ...rest }, ref) => { 241 | const theme = useTheme(); 242 | 243 | const themeSelectorButtonStyle = css({ 244 | display: "flex", 245 | alignItems: "center", 246 | gap: "8px", 247 | 248 | fontSize: 16, 249 | fontFamily: "inherit", 250 | fontWeight: 500, 251 | color: `${theme.header_text}`, 252 | outline: `${theme.menuButton_outline}`, 253 | padding: "6px 8px", 254 | 255 | ":hover, :active": { 256 | outline: `2px solid ${theme.metadata_highlight}`, 257 | backgroundColor: theme.header_button_hover_bg, 258 | color: `${theme.header_text}`, 259 | }, 260 | ":focus": { 261 | backgroundColor: "inherit", 262 | color: `${theme.header_text}`, 263 | }, 264 | ":focus:hover": { 265 | backgroundColor: theme.header_button_hover_bg, 266 | color: `${theme.header_text}`, 267 | }, 268 | }); 269 | 270 | const iconStyle = css({ 271 | display: "flex", 272 | alignItems: "center", 273 | 274 | fontSize: 22, 275 | }); 276 | 277 | return ( 278 | 283 | 284 | {label} 285 | 286 | ); 287 | }); 288 | 289 | export default Header; 290 | -------------------------------------------------------------------------------- /src/main/SubtitleVideoArea.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import { css } from "@emotion/react"; 3 | import { RootState, ThunkApiConfig, useAppSelector } from "../redux/store"; 4 | import { 5 | selectIsMuted, 6 | selectVideos, 7 | selectVolume, 8 | selectJumpTriggered, 9 | setIsMuted, 10 | setVolume, 11 | setJumpTriggered, 12 | } from "../redux/videoSlice"; 13 | import { Flavor, SubtitlesInEditor } from "../types"; 14 | import { settings } from "../config"; 15 | import { useTranslation } from "react-i18next"; 16 | import { serializeSubtitle } from "../util/utilityFunctions"; 17 | import { useTheme } from "../themes"; 18 | import { VideoPlayer } from "./VideoPlayers"; 19 | import VideoControls from "./VideoControls"; 20 | import Select from "react-select"; 21 | import { selectFieldStyle } from "../cssStyles"; 22 | import { ActionCreatorWithPayload, AsyncThunk } from "@reduxjs/toolkit"; 23 | 24 | /** 25 | * A part of the subtitle editor that displays a video and related controls 26 | * 27 | * A bug in the react-player module prevents hotloading subtitle files: 28 | * https://github.com/cookpete/react-player/issues/1162 29 | * We have "fixed" this in a fork https://github.com/Arnei/react-player, because 30 | * coming up with a proper fix appears to be rather difficult 31 | * TODO: Come up with a proper fix and create a PR 32 | */ 33 | const SubtitleVideoArea: React.FC<{ 34 | selectIsPlaying: (state: RootState) => boolean, 35 | selectCurrentlyAt: (state: RootState) => number, 36 | selectCurrentlyAtInSeconds: (state: RootState) => number, 37 | selectClickTriggered: (state: RootState) => boolean, 38 | selectPreviewTriggered: (state: RootState) => boolean, 39 | selectAspectRatio: (state: RootState) => number, 40 | selectIsPlayPreview: (state: RootState) => boolean, 41 | selectSelectedSubtitleById: (state: RootState) => SubtitlesInEditor, 42 | setIsPlaying: ActionCreatorWithPayload, 43 | setPreviewTriggered: ActionCreatorWithPayload, 44 | setAspectRatio: ActionCreatorWithPayload<{ dataKey: number; } & { width: number, height: number; }, string>, 45 | setIsPlayPreview: ActionCreatorWithPayload, 46 | setClickTriggered: ActionCreatorWithPayload, 47 | setCurrentlyAtAndTriggerPreview: AsyncThunk, 48 | }> = ({ 49 | selectIsPlaying, 50 | selectCurrentlyAt, 51 | selectCurrentlyAtInSeconds, 52 | selectClickTriggered, 53 | selectPreviewTriggered, 54 | selectAspectRatio, 55 | selectIsPlayPreview, 56 | selectSelectedSubtitleById, 57 | setIsPlaying, 58 | setPreviewTriggered, 59 | setAspectRatio, 60 | setIsPlayPreview, 61 | setClickTriggered, 62 | setCurrentlyAtAndTriggerPreview, 63 | }) => { 64 | 65 | const tracks = useAppSelector(selectVideos); 66 | const subtitle = useAppSelector(selectSelectedSubtitleById); 67 | const [selectedFlavor, setSelectedFlavor] = useState(); 68 | const [subtitleUrl, setSubtitleUrl] = useState(""); 69 | 70 | // Decide on initial flavor on mount 71 | useEffect(() => { 72 | // Get default from settings 73 | if (settings.subtitles.defaultVideoFlavor !== undefined) { 74 | setSelectedFlavor(settings.subtitles.defaultVideoFlavor); 75 | return; 76 | } 77 | // If there is no default, just pick any 78 | if (tracks.length > 0) { 79 | setSelectedFlavor(tracks[0].flavor); 80 | return; 81 | } 82 | // eslint-disable-next-line react-hooks/exhaustive-deps 83 | }, []); 84 | 85 | // Get the uri of a track the currently selected flavor 86 | const getTrackURIBySelectedFlavor = () => { 87 | for (const track of tracks) { 88 | if (track.flavor.type === selectedFlavor?.type && track.flavor.subtype === selectedFlavor?.subtype) { 89 | return track.uri; 90 | } 91 | } 92 | }; 93 | 94 | // Get a track URI by any means necessary 95 | const getTrackURI = () => { 96 | const trackURIByFlavor = getTrackURIBySelectedFlavor(); 97 | if (trackURIByFlavor) { 98 | return trackURIByFlavor; 99 | } 100 | if (tracks.length > 0) { 101 | return tracks[0].uri; 102 | } 103 | }; 104 | 105 | // Parse subtitles to something the video player understands 106 | useEffect(() => { 107 | if (subtitle?.cues) { 108 | const serializedSubtitle = serializeSubtitle(subtitle?.cues); 109 | setSubtitleUrl(window.URL.createObjectURL(new Blob([serializedSubtitle], { type: "text/vtt" }))); 110 | } 111 | }, [subtitle?.cues]); 112 | 113 | const areaWrapper = css({ 114 | display: "block", 115 | height: "100%", 116 | width: "40%", 117 | }); 118 | 119 | const videoPlayerAreaStyle = css({ 120 | display: "flex", 121 | flexDirection: "column", 122 | justifyContent: "center", 123 | alignItems: "center", 124 | height: "100%", 125 | gap: "10px", 126 | }); 127 | 128 | const render = () => { 129 | return ( 130 |
    131 |
    132 | {selectedFlavor && (a.push(o.flavor), a), [])} 134 | changeFlavorcallback={setSelectedFlavor} 135 | defaultFlavor={selectedFlavor} 136 | />} 137 | {/* TODO: Make preview mode work or remove it */} 138 | 160 | 171 |
    172 |
    173 | ); 174 | }; 175 | 176 | return ( 177 | <> 178 | {render()} 179 | 180 | ); 181 | }; 182 | 183 | /** 184 | * Changes the selectedFlavor in SubtitleVideoArea 185 | */ 186 | const VideoSelectDropdown: React.FC<{ 187 | flavors: Flavor[], 188 | changeFlavorcallback: React.Dispatch>, 189 | defaultFlavor: Flavor; 190 | }> = ({ 191 | flavors, 192 | changeFlavorcallback, 193 | defaultFlavor, 194 | }) => { 195 | 196 | const { t } = useTranslation(); 197 | const theme = useTheme(); 198 | 199 | const dropdownName = "flavors"; 200 | 201 | // Turn flavor into string 202 | const stringifyFlavor = (flavor: Flavor) => { 203 | return flavor.type + "/" + flavor.subtype; 204 | }; 205 | 206 | const getFlavorLabel = (flavor: Flavor) => { 207 | // Omit subtype if all flavour subtypes are equal 208 | if (flavors.every(f => f.subtype === flavors[0].subtype)) { 209 | return flavor.type; 210 | } 211 | 212 | return stringifyFlavor(flavor); 213 | }; 214 | 215 | // Data to populate the dropdown with 216 | const data = flavors.map(flavor => ({ 217 | label: getFlavorLabel(flavor), 218 | value: stringifyFlavor(flavor), 219 | })); 220 | 221 | const subtitleAddFormStyle = css({ 222 | width: "100%", 223 | }); 224 | 225 | return ( 226 | <> 227 |
    {t("subtitleVideoArea.selectVideoLabel")}
    228 |