Skip to content

Commit 2d17af6

Browse files
feat: Add audio description functionality to video editor (#95)
* feat: Add audio description functionality to video editor - Implemented audio description upload and delete features in the video editor. - Created messages for internationalization in the AudioDescriptionWidget component. - Updated VideoSettingsModal to conditionally render the AudioDescriptionWidget based on context. - Enhanced hooks to manage audio description errors and state. - Added Redux actions and reducers for handling audio description requests. - Developed API methods for uploading and deleting audio descriptions. - Introduced file validation utilities for audio description uploads. - Wrote tests for new functionality, including reducers, selectors, and API methods. * fix: Add EditorContextProvider to VideoSelectorPage modal - Wrap VideoSelector with EditorContextProvider to provide context for video editor components - Fixes "This component needs to be wrapped in <EditorContextProvider>" error when accessing video gallery modal - Ensures VideoSettingsModal and other editor components have access to EditorContext hooks - Aligns VideoSelectorPage context structure with EditorPage for consistency
1 parent c46bdea commit 2d17af6

32 files changed

Lines changed: 1412 additions & 58 deletions

src/data/api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ export const waffleFlagDefaults = {
5353
useNewGroupConfigurationsPage: true,
5454
useReactMarkdownEditor: true,
5555
useVideoGalleryFlow: false,
56+
enableAudioDescription: false,
5657
} as const;
5758

5859
export type WaffleFlagName = keyof typeof waffleFlagDefaults;

src/editors/EditorContext.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ export interface EditorContext {
1515
learningContextId: string;
1616
/** Is the so-called "Markdown" problem editor available in this learning context? */
1717
isMarkdownEditorEnabledForContext: boolean;
18+
/** Is the audio description upload widget enabled for the current learning context? */
19+
isAudioDescriptionEnabled: boolean;
1820
}
1921

2022
export type EditorContextInit = {
@@ -38,15 +40,19 @@ export const EditorContextProvider: React.FC<{ children: React.ReactNode; } & Ed
3840
learningContextId,
3941
}) => {
4042
const courseIdIfCourse = isCourseKey(learningContextId) ? learningContextId : undefined;
41-
const isMarkdownEditorEnabledForContext = useWaffleFlags(courseIdIfCourse).useReactMarkdownEditor;
43+
const waffleFlags = useWaffleFlags(courseIdIfCourse);
44+
const isMarkdownEditorEnabledForContext = waffleFlags.useReactMarkdownEditor;
45+
const isAudioDescriptionEnabled = waffleFlags.enableAudioDescription;
4246

4347
const ctx: EditorContext = React.useMemo(() => ({
4448
learningContextId,
4549
isMarkdownEditorEnabledForContext,
50+
isAudioDescriptionEnabled,
4651
}), [
4752
// Dependencies - make sure we update the context object if any of these values change:
4853
learningContextId,
4954
isMarkdownEditorEnabledForContext,
55+
isAudioDescriptionEnabled,
5056
]);
5157
return <context.Provider value={ctx}>{children}</context.Provider>;
5258
};

src/editors/VideoSelectorPage.jsx

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import PropTypes from 'prop-types';
44
import ErrorBoundary from './sharedComponents/ErrorBoundary';
55
import VideoSelector from './VideoSelector';
66
import store from './data/store';
7+
import { EditorContextProvider } from './EditorContext';
78

89
const VideoSelectorPage = ({
910
blockId,
@@ -20,16 +21,18 @@ const VideoSelectorPage = ({
2021
studioEndpointUrl,
2122
}}
2223
>
23-
<VideoSelector
24-
{...{
25-
blockId,
26-
learningContextId: courseId,
27-
lmsEndpointUrl,
28-
studioEndpointUrl,
29-
returnFunction,
30-
onCancel,
31-
}}
32-
/>
24+
<EditorContextProvider learningContextId={courseId}>
25+
<VideoSelector
26+
{...{
27+
blockId,
28+
learningContextId: courseId,
29+
lmsEndpointUrl,
30+
studioEndpointUrl,
31+
returnFunction,
32+
onCancel,
33+
}}
34+
/>
35+
</EditorContextProvider>
3336
</ErrorBoundary>
3437
</Provider>
3538
);

src/editors/containers/VideoEditor/components/VideoEditorModal.test.tsx

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import { IntlProvider } from '@edx/frontend-platform/i18n';
66
import { initializeMockApp } from '@edx/frontend-platform';
77
import VideoEditorModal from './VideoEditorModal';
88
import { thunkActions } from '../../../data/redux';
9+
import { EditorContextProvider } from '../../../EditorContext';
10+
import { mockWaffleFlags } from '../../../../data/apiHooks.mock';
911

1012
jest.mock('../../../data/redux', () => ({
1113
...jest.requireActual('../../../data/redux'),
@@ -18,6 +20,8 @@ jest.mock('../../../data/redux', () => ({
1820
},
1921
}));
2022

23+
mockWaffleFlags();
24+
2125
describe('VideoUploader', () => {
2226
let store;
2327

@@ -36,6 +40,8 @@ describe('VideoUploader', () => {
3640
uploadTranscript: { status: 'inactive' },
3741
deleteTranscript: { status: 'inactive' },
3842
fetchVideos: { status: 'inactive' },
43+
uploadAudioDescription: { status: 'inactive' },
44+
deleteAudioDescription: { status: 'inactive' },
3945
},
4046
video: {
4147
videoSource: '',
@@ -53,6 +59,7 @@ describe('VideoUploader', () => {
5359
stopTime: '00:00:00',
5460
total: '00:00:00',
5561
},
62+
audioDescriptionUrl: '',
5663
},
5764
},
5865
});
@@ -69,13 +76,15 @@ describe('VideoUploader', () => {
6976
const renderComponent = async () => render(
7077
<AppProvider store={store} wrapWithRouter={false}>
7178
<IntlProvider locale="en">
72-
<MemoryRouter
73-
initialEntries={[
74-
'/some/path?selectedVideoId=id_1&selectedVideoUrl=https://video.com',
75-
]}
76-
>
77-
<VideoEditorModal isLibrary={false} />
78-
</MemoryRouter>
79+
<EditorContextProvider learningContextId="course-v1:test+test+test">
80+
<MemoryRouter
81+
initialEntries={[
82+
'/some/path?selectedVideoId=id_1&selectedVideoUrl=https://video.com',
83+
]}
84+
>
85+
<VideoEditorModal isLibrary={false} />
86+
</MemoryRouter>
87+
</EditorContextProvider>
7988
</IntlProvider>
8089
</AppProvider>,
8190
);
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { useDispatch, useSelector } from 'react-redux';
2+
import { useState, useCallback, useMemo } from 'react';
3+
import { thunkActions, selectors } from '../../../../../../data/redux';
4+
import { fileInput as sharedFileInput } from '../../../../../../sharedComponents/FileInput';
5+
import {
6+
checkValidFileSize,
7+
checkValidFileType,
8+
} from '../../../../../../sharedComponents/FileInput/fileValidation';
9+
10+
const ALLOWED_AUDIO_TYPES = ['audio/mpeg', 'audio/ogg', 'audio/mp4', 'audio/wav', 'audio/x-wav', 'audio/aac', 'audio/x-m4a'];
11+
const ALLOWED_EXTENSIONS = ['.mp3', '.ogg', '.m4a', '.wav', '.aac'];
12+
const DEFAULT_WARNING = { show: false, adDuration: 0, videoDuration: 0 };
13+
14+
const checkAudioDuration = (file, callback) => {
15+
const url = URL.createObjectURL(file);
16+
const audio = new Audio();
17+
audio.preload = 'metadata';
18+
audio.onloadedmetadata = () => {
19+
callback(Math.round(audio.duration));
20+
URL.revokeObjectURL(url);
21+
};
22+
audio.onerror = () => {
23+
URL.revokeObjectURL(url);
24+
};
25+
audio.src = url;
26+
};
27+
28+
const parseTimeToSeconds = (timeValue) => {
29+
if (!timeValue) { return 0; }
30+
if (typeof timeValue === 'number') { return timeValue; }
31+
const parts = String(timeValue).split(':').map(Number);
32+
if (parts.length === 3) { return parts[0] * 3600 + parts[1] * 60 + parts[2]; }
33+
if (parts.length === 2) { return parts[0] * 60 + parts[1]; }
34+
return Number(timeValue) || 0;
35+
};
36+
37+
/**
38+
* Creates the file input handler with audio-specific validation (size + type).
39+
* Delegates to shared fileInput hook, wrapping with validation before dispatching upload.
40+
*/
41+
export const useFileInput = ({ fileSizeError, fileTypeError, onDurationChecked }) => {
42+
const dispatch = useDispatch();
43+
44+
return sharedFileInput({
45+
onAddFile: (file) => {
46+
if (!checkValidFileSize({ file, onSizeFail: fileSizeError.set })) { return; }
47+
if (!checkValidFileType({
48+
file,
49+
allowedTypes: ALLOWED_AUDIO_TYPES,
50+
allowedExtensions: ALLOWED_EXTENSIONS,
51+
onTypeFail: fileTypeError.set,
52+
})) { return; }
53+
if (onDurationChecked) { checkAudioDuration(file, onDurationChecked); }
54+
dispatch(thunkActions.video.uploadAudioDescription({ file }));
55+
},
56+
});
57+
};
58+
59+
/**
60+
* Hook that manages the duration mismatch warning state.
61+
* Compares the AD file duration against the video duration from Redux state.
62+
*/
63+
export const useDurationWarning = () => {
64+
const duration = useSelector(selectors.video.duration);
65+
const [warning, setWarning] = useState(DEFAULT_WARNING);
66+
const videoDurationSec = parseTimeToSeconds(duration?.total);
67+
68+
const onDurationChecked = useCallback((adDurationSec) => {
69+
if (videoDurationSec > 0 && Math.abs(adDurationSec - videoDurationSec) > 1) {
70+
setWarning({ show: true, adDuration: adDurationSec, videoDuration: videoDurationSec });
71+
} else {
72+
setWarning(DEFAULT_WARNING);
73+
}
74+
}, [videoDurationSec]);
75+
76+
const dismiss = useCallback(() => setWarning(DEFAULT_WARNING), []);
77+
78+
const durationWarning = useMemo(
79+
() => ({ ...warning, onDurationChecked, dismiss }),
80+
[warning, onDurationChecked, dismiss],
81+
);
82+
83+
return { durationWarning };
84+
};

0 commit comments

Comments
 (0)