From a7903d08bb7564dee68391e835dea325994077b7 Mon Sep 17 00:00:00 2001 From: Arnei Date: Thu, 28 May 2026 15:37:07 +0200 Subject: [PATCH 1/2] Make editor audio-only proof Make sure the editor deals reasonably well with audio-only tracks. Includes: - Displaying a little image for the player if there is no video stream. - Make sure the track selection makes sense (don't display a video stream for an audio only track, don't allow users to deselect all tracks) - Disable thumbnail generation for audio-only tracks, but keep thumbnail generation. --- src/i18n/locales/en-US.json | 3 +-- src/img/video-off.png | Bin 0 -> 818 bytes src/main/Cutting.tsx | 12 +----------- src/main/SubtitleVideoArea.tsx | 13 +++++++++++-- src/main/ThumbnailSelect.tsx | 15 ++++++++------- src/main/Timeline.tsx | 4 ++-- src/main/TrackSelection.tsx | 20 +++++++++++++++----- src/main/VideoPlayers.tsx | 14 ++++++++++---- src/redux/__tests__/videoSlice.test.ts | 10 +++++----- src/redux/videoSlice.ts | 24 +++++++++++++----------- 10 files changed, 66 insertions(+), 49 deletions(-) create mode 100644 src/img/video-off.png diff --git a/src/i18n/locales/en-US.json b/src/i18n/locales/en-US.json index d0db1949f..0b8c8e62e 100644 --- a/src/i18n/locales/en-US.json +++ b/src/i18n/locales/en-US.json @@ -233,8 +233,7 @@ "unauthorizedError-text": "You are not allowed to edit this video", "comError-text": "A problem occurred during communication with Opencast.", "loadError-text": "An error has occurred loading this video.", - "durationError-text": "Opencast failed to provide the video duration.", - "noVideoError-text": "The editor does not support audio files yet!" + "durationError-text": "Opencast failed to provide the video duration." }, "landing": { diff --git a/src/img/video-off.png b/src/img/video-off.png new file mode 100644 index 0000000000000000000000000000000000000000..238e761a57af8fa0579c4210f5846414c84309e9 GIT binary patch literal 818 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA1|-9oezr3(F#Ys&aSW-LbN9M!*<~MzwujGi z_p7C`FZ+EeWQ(D;!kUI~?S@HRkEevBuy=?igf(nt3JPiyRR}YFMQLASv=(n zBYUdH?1lflwp@6}HRaYDA^nTnw=Q|UVncY_mh%xS>RG++mUb#Kzq&SQUQ&4airt6S z^W?7k@r@s7?+qU9If))Gi%X(4_O08QbiJaB%V^8mtv}`K4+VXc-?H=3^D&3rS)TlA3Ey!s z?e6ck3X@cQo)e}yF0H~_YMZs|^qz2efb6MGOj!3%S*+{+msiueVV0tt?@jLNXHxYpP&WG$sd6~`nN&WEt^7nP9nOAgRRd=~v&w<9pP%Z#@y)s{ z%Hh`A*Sl?JZ4$3@UiYViY3Vj`u(hpUqJJmfdGY-VoA`^c1tI&seEoZXN1FNCtv@rX zTe)MB&-i`h<9@0g@TzXz?(%onWu0fges}2pI){~Vl9#UCsuBO^693VDm#D^8W#?Nw z+J9CGhFj$Cao^6gRPMxt)1q@9u_TApGsP_qx$^8*a!aDuPPRS+AKmK>f^C}5URfS8 zxEf>YCb4Xew*4c^uo-_EtginR6qS3Z!FFY*nfspuJnqH|-pMRY`a1vmqGYKFZ(p$g aVR7EK)lld5EMQh(VDNPHb6Mw<&;$VL6@Cf; literal 0 HcmV?d00001 diff --git a/src/main/Cutting.tsx b/src/main/Cutting.tsx index c298802f8..8f71f1b1b 100644 --- a/src/main/Cutting.tsx +++ b/src/main/Cutting.tsx @@ -18,7 +18,6 @@ import { setIsPlayPreview, jumpToPreviousSegment, jumpToNextSegment, - selectVideos, cut, mergeAll, mergeLeft, @@ -48,7 +47,6 @@ const Cutting: React.FC = () => { state.videoState.status); const error = useAppSelector((state: { videoState: { error: httpRequestState["error"]; }; }) => state.videoState.error); - const videos = useAppSelector(selectVideos); const duration = useAppSelector(selectDuration); const theme = useTheme(); const errorReason = useAppSelector((state: { videoState: { errorReason: httpRequestState["errorReason"]; }; }) => @@ -75,14 +73,6 @@ const Cutting: React.FC = () => { })); } } else if (videoURLStatus === "success") { - // Editor can not handle events with no videos/audio-only atm - if (videos === null || videos.length === 0) { - dispatch(setError({ - error: true, - errorMessage: t("error.noVideoError-text"), - errorDetails: error, - })); - } if (duration === null) { dispatch(setError({ error: true, @@ -91,7 +81,7 @@ const Cutting: React.FC = () => { })); } } - }, [videoURLStatus, dispatch, error, t, errorReason, duration, videos]); + }, [videoURLStatus, dispatch, error, t, errorReason, duration]); // Already try fetching Metadata to reduce wait time useEffect(() => { diff --git a/src/main/SubtitleVideoArea.tsx b/src/main/SubtitleVideoArea.tsx index 86d05af59..8ea566b8f 100644 --- a/src/main/SubtitleVideoArea.tsx +++ b/src/main/SubtitleVideoArea.tsx @@ -3,7 +3,7 @@ import { css } from "@emotion/react"; import { RootState, ThunkApiConfig, useAppSelector } from "../redux/store"; import { selectIsMuted, - selectVideos, + selectTracks, selectVolume, selectJumpTriggered, setIsMuted, @@ -63,7 +63,7 @@ const SubtitleVideoArea: React.FC<{ setCurrentlyAtAndTriggerPreview, }) => { - const tracks = useAppSelector(selectVideos); + const tracks = useAppSelector(selectTracks); const subtitle = useAppSelector(selectSelectedSubtitleById); const [selectedFlavor, setSelectedFlavor] = useState(); const [subtitleUrl, setSubtitleUrl] = useState(""); @@ -103,6 +103,14 @@ const SubtitleVideoArea: React.FC<{ } }; + const isAudioOnly = () => { + for (const track of tracks) { + if (track.flavor.type === selectedFlavor?.type && track.flavor.subtype === selectedFlavor?.subtype) { + return !track.video_stream.available; + } + } + }; + // Parse subtitles to something the video player understands useEffect(() => { if (subtitle?.cues) { @@ -143,6 +151,7 @@ const SubtitleVideoArea: React.FC<{ subtitleUrl={subtitleUrl} first={true} last={true} + audioOnly={isAudioOnly()} selectIsPlaying={selectIsPlaying} selectIsMuted={selectIsMuted} selectCurrentlyAtInSeconds={selectCurrentlyAtInSeconds} diff --git a/src/main/ThumbnailSelect.tsx b/src/main/ThumbnailSelect.tsx index 857a927fd..ac3fe376f 100644 --- a/src/main/ThumbnailSelect.tsx +++ b/src/main/ThumbnailSelect.tsx @@ -11,7 +11,6 @@ import { import { Theme, useTheme } from "../themes"; import { selectOriginalThumbnails, - selectVideos, selectTracks, setHasChanges, setThumbnail, @@ -27,7 +26,7 @@ import { setIndex, setIsDisplayEditView } from "../redux/thumbnailSlice"; */ const ThumbnailSelect: React.FC = () => { - const videoTracks = useAppSelector(selectVideos); + const tracks = useAppSelector(selectTracks); const thumbnailSelectStyle = css({ display: "flex", @@ -42,7 +41,7 @@ const ThumbnailSelect: React.FC = () => { return (
- {videoTracks.map((track: Track, index: number) => ( + {tracks.map((track: Track, index: number) => ( - + {track.video_stream.available && + + } const { t } = useTranslation(); const dispatch = useAppDispatch(); - const videoURLs = useAppSelector(selectVideoURL); + const videoURLs = useAppSelector(selectTrackURLs); const videoURLStatus = useAppSelector((state: { videoState: { status: httpRequestState["status"]; }; }) => state.videoState.status); const theme = useTheme(); diff --git a/src/main/TrackSelection.tsx b/src/main/TrackSelection.tsx index 871ad1cd0..e96f251ed 100644 --- a/src/main/TrackSelection.tsx +++ b/src/main/TrackSelection.tsx @@ -6,7 +6,9 @@ import ReactPlayer from "react-player"; import { Track } from "../types"; import { + selectAudiosOnly, selectCustomizedTrackSelection, + selectTracks, selectVideos, selectWaveformImages, setAudioEnabled, @@ -37,27 +39,35 @@ const TrackSelection: React.FC = () => { const dispatch = useAppDispatch(); // Generate list of tracks - const tracks = useAppSelector(selectVideos); + const tracks = useAppSelector(selectTracks); + const videos = useAppSelector(selectVideos); + const audioOnlys = useAppSelector(selectAudiosOnly); + let enabledCount = 0; if (settings.trackSelection.atLeastOneVideo) { // Only care about at least one video stream being enabled - enabledCount = tracks.reduce( + enabledCount = videos.reduce( (memo: number, track: Track) => memo + (track.video_stream.enabled ? 1 : 0), 0, ); } else { // Make sure that at least one track remains enabled - enabledCount = tracks.reduce( + enabledCount += videos.reduce( (memo: number, track: Track) => memo + (track.video_stream.enabled ? 1 : 0) + (track.audio_stream.enabled ? 1 : 0), 0, ); + enabledCount += audioOnlys.reduce( + (memo: number, track: Track) => + memo + (track.audio_stream.enabled ? 1 : 0), + 0, + ); } const images = useAppSelector(selectWaveformImages); const customizedTrackSelection = !!useAppSelector(selectCustomizedTrackSelection); - const videoTrackItems = tracks.map( + const videoTrackItems = videos.map( (track: Track) => ( { />), ); - const audioTrackItems = tracks.map( + const audioTrackItems = [...videos, ...audioOnlys].map( (track: Track, index: number) => ( , @@ -50,10 +51,10 @@ const VideoPlayers: React.FC<{ maxHeightInPixel = 300, }) => { - const videos = useAppSelector(selectVideos); + const videos = useAppSelector(selectTracks); let primaryIndex = videos.findIndex(e => e.audio_stream.available === true); primaryIndex = primaryIndex < 0 ? 0 : primaryIndex; - const videoCount = useAppSelector(selectVideoCount); + const videoCount = useAppSelector(selectTrackCount); const [videoPlayers, setVideoPlayers] = useState([]); @@ -81,6 +82,7 @@ const VideoPlayers: React.FC<{ subtitleUrl={""} first={i === 0} last={i === videoCount - 1} + audioOnly={!videos[i].video_stream.available} selectIsPlaying={selectIsPlaying} selectIsMuted={selectIsMuted} selectVolume={selectVolume} @@ -126,6 +128,7 @@ interface VideoPlayerProps { first: boolean, last: boolean, overwritePlayerCSS?: SerializedStyles, + audioOnly?: boolean, selectIsPlaying: (state: RootState) => boolean, selectIsMuted: (state: RootState) => boolean, selectVolume: (state: RootState) => number, @@ -169,6 +172,7 @@ export const VideoPlayer = React.forwardRef { // Arrange const resultStatus: httpRequestState = { status: "success", error: undefined, errorReason: "unknown" }; const segments = [{ start: 0, end: 42, deleted: false }]; - const videoURLs: video["videoURLs"] = ["video/url"]; + const trackURLs: video["trackURLs"] = ["video/url"]; const dur: video["duration"] = 42; const title: video["title"] = "Video Title"; // const presenters: video["presenters"] = [ "Otto Opencast" ] // Currently missing from the API const tracks: video["tracks"] = [{ - id: "id", uri: videoURLs[0], flavor: { subtype: "prepared", type: "presenter" }, + id: "id", uri: trackURLs[0], flavor: { subtype: "prepared", type: "presenter" }, /* eslint-disable camelcase */ video_stream: { available: true, enabled: true, thumbnail_uri: "thumb/url" }, audio_stream: { available: true, enabled: true, thumbnail_uri: "thumb/url" }, @@ -333,7 +333,7 @@ describe("Video reducer", () => { expect(rootState.videoState).toMatchObject(resultStatus); expect(selectSegments(rootState)).toMatchObject(segments); - expect(selectVideoURL(rootState)).toMatchObject(videoURLs); + expect(selectTrackURLs(rootState)).toMatchObject(trackURLs); expect(selectDuration(rootState)).toEqual(dur); expect(selectTitle(rootState)).toEqual(title); expect(selectTracks(rootState)).toMatchObject(tracks); diff --git a/src/redux/videoSlice.ts b/src/redux/videoSlice.ts index 787b1a4ef..e2ff6d9e5 100644 --- a/src/redux/videoSlice.ts +++ b/src/redux/videoSlice.ts @@ -29,8 +29,8 @@ export interface video { waveformImages: string[]; originalThumbnails: { id: Track["id"], uri: Track["thumbnailUri"]; }[]; - videoURLs: string[], // Links to each video - videoCount: number, // Total number of videos + trackURLs: string[], // Links to each track + trackCount: number, // Total number of tracks duration: number, // Video duration in milliseconds. Can be null due to Opencast internal error title: string, presenters: string[], @@ -69,8 +69,8 @@ export const initialState: video & httpRequestState = { waveformImages: [], originalThumbnails: [], - videoURLs: [], - videoCount: 0, + trackURLs: [], + trackCount: 0, duration: 0, title: "", presenters: [], @@ -358,9 +358,8 @@ const videoSlice = createSlice({ } return track; }); - const videos = state.tracks.filter((track: Track) => track.video_stream.available === true); - state.videoURLs = videos.reduce((a: string[], o: { uri: string; }) => (a.push(o.uri), a), []); - state.videoCount = state.videoURLs.length; + state.trackURLs = state.tracks.reduce((a: string[], o: { uri: string; }) => (a.push(o.uri), a), []); + state.trackCount = state.trackURLs.length; state.subtitlesFromOpencast = payload.subtitles ? state.subtitlesFromOpencast = payload.subtitles : []; state.chaptersFromOpencast = payload.chapters ? @@ -413,8 +412,10 @@ const videoSlice = createSlice({ selectOriginalThumbnails: state => state.originalThumbnails, // Selectors mainly pertaining to the information fetched from Opencast selectVideos: state => state.tracks.filter((track: Track) => track.video_stream.available === true), - selectVideoURL: state => state.videoURLs, - selectVideoCount: state => state.videoCount, + selectTrackURLs: state => state.trackURLs, + selectTrackCount: state => state.trackCount, + selectAudiosOnly: state => state.tracks.filter((track: Track) => + !track.video_stream.available === true && track.audio_stream.available), selectDuration: state => state.duration, selectDurationInSeconds: state => state.duration / 1000, selectTitle: state => state.title, @@ -628,8 +629,8 @@ export const { selectTimelineZoom, selectWaveformImages, selectOriginalThumbnails, - selectVideoURL, - selectVideoCount, + selectTrackURLs, + selectTrackCount, selectDuration, selectDurationInSeconds, selectTitle, @@ -641,6 +642,7 @@ export const { selectChaptersFromOpencast, selectChaptersFromOpencastById, selectVideos, + selectAudiosOnly, selectDisplayDuration, selectPrimaryThumbnailTrack, } = videoSlice.selectors; From 06d90adc28a8150529b31521b544cec7b4b4303a Mon Sep 17 00:00:00 2001 From: Arnei Date: Fri, 19 Jun 2026 10:37:28 +0200 Subject: [PATCH 2/2] Make sure audio only icon is visible With the way we do it now, we can't change the color of the audio only icon, as it is effictively an image. But we can change the background for the icon instead. Also fixes an issue where themed css was not properly applied to the react player. --- src/main/VideoPlayers.tsx | 39 +++++++++++++++++++++------------------ src/themes.ts | 5 +++++ 2 files changed, 26 insertions(+), 18 deletions(-) diff --git a/src/main/VideoPlayers.tsx b/src/main/VideoPlayers.tsx index 06a20302c..473075770 100644 --- a/src/main/VideoPlayers.tsx +++ b/src/main/VideoPlayers.tsx @@ -410,6 +410,7 @@ export const VideoPlayer = React.forwardRef - + {/* Make sure themeing is applied properly with this wrapper*/} +
+ +
); } else { diff --git a/src/themes.ts b/src/themes.ts index 697242dfd..5163d058b 100644 --- a/src/themes.ts +++ b/src/themes.ts @@ -68,6 +68,7 @@ export interface Theme { clock_focus: string; digit_selected: string; text_shadow: string; + audio_only_bg: string; } export const lightMode: Theme = { @@ -130,6 +131,7 @@ export const lightMode: Theme = { `0 2px ${COLORS.neutral15}, 0 -2px ${COLORS.neutral15},` + `1px 1px ${COLORS.neutral15}, -1px -1px ${COLORS.neutral15},` + `1px -1px ${COLORS.neutral15}, -1px 1px ${COLORS.neutral15}`, + audio_only_bg: COLORS.neutral05, }; export const darkMode: Theme = { @@ -190,6 +192,7 @@ export const darkMode: Theme = { `0 2px ${COLORS.neutral15}, 0 -2px ${COLORS.neutral15},` + `1px 1px ${COLORS.neutral15}, -1px -1px ${COLORS.neutral15},` + `1px -1px ${COLORS.neutral15}, -1px 1px ${COLORS.neutral15}`, + audio_only_bg: COLORS.neutral90, }; export const highContrastDarkMode: Theme = { @@ -248,6 +251,7 @@ export const highContrastDarkMode: Theme = { digit_selected: "#000", text_shadow: "2px 0 #000, -2px 0 #000, 0 2px #000, 0 -2px #000," + " 1px 1px #000, -1px -1px #000, 1px -1px #000, -1px 1px #000", + audio_only_bg: "#fff", }; export const highContrastLightMode: Theme = { @@ -306,4 +310,5 @@ export const highContrastLightMode: Theme = { digit_selected: "#fff", text_shadow: "2px 0 snow, -2px 0 snow, 0 2px snow, 0 -2px snow," + " 1px 1px snow, -1px -1px snow, 1px -1px snow, -1px 1px snow", + audio_only_bg: "#fff", };