From 14d811a7af58efee5e7bdb4534f65ba1dcc2b4d4 Mon Sep 17 00:00:00 2001 From: Richard Tibbles Date: Sat, 18 Jul 2026 14:05:23 -0700 Subject: [PATCH 1/4] Add media file-extractor extension derivation and shared strings Derive source extensions from the MIME type, then the URL suffix, falling back to the container default, so extracted sources never carry a null type. Read src via getAttribute so extraction works on detached nodes. Add VIDEO_PRESETS to classify content as video vs audio, and a mediaStrings module for the extracted components. --- .../utils/__tests__/fileExtractors.spec.js | 213 ++++++++++++++++++ .../frontend/utils/fileExtractors.js | 51 ++++- .../frontend/utils/mediaStrings.js | 122 ++++++++++ 3 files changed, 380 insertions(+), 6 deletions(-) create mode 100644 kolibri/plugins/media_player/frontend/utils/__tests__/fileExtractors.spec.js create mode 100644 kolibri/plugins/media_player/frontend/utils/mediaStrings.js diff --git a/kolibri/plugins/media_player/frontend/utils/__tests__/fileExtractors.spec.js b/kolibri/plugins/media_player/frontend/utils/__tests__/fileExtractors.spec.js new file mode 100644 index 00000000000..4a1a066dac8 --- /dev/null +++ b/kolibri/plugins/media_player/frontend/utils/__tests__/fileExtractors.spec.js @@ -0,0 +1,213 @@ +import extractors from '../fileExtractors'; + +/** + * Helper to create a mock DOM element + * @param {string} tagName - Tag name to create + * @param {object} attributes - Attributes to set on the element + * @param {Element[]} children - Child elements to append + * @returns {Element} + */ +function createElement(tagName, attributes = {}, children = []) { + const element = document.createElement(tagName); + for (const [key, value] of Object.entries(attributes)) { + element.setAttribute(key, value); + } + for (const child of children) { + element.appendChild(child); + } + return element; +} + +describe('fileExtractors', () => { + describe('video extractor', () => { + it('extracts file from video element with src attribute', () => { + const video = createElement('video', { src: 'http://example.com/video.mp4' }); + const files = extractors.video(video); + + expect(files).toHaveLength(1); + expect(files[0]).toMatchObject({ + storage_url: 'http://example.com/video.mp4', + preset: 'high_res_video', + available: true, + supplementary: false, + thumbnail: false, + extension: 'mp4', + }); + }); + + it('extracts files from video with source children', () => { + const source1 = createElement('source', { src: 'video.mp4', type: 'video/mp4' }); + const source2 = createElement('source', { src: 'video.webm', type: 'video/webm' }); + const video = createElement('video', {}, [source1, source2]); + + const files = extractors.video(video); + + expect(files).toHaveLength(2); + // getAttribute('src') returns the raw attribute, so the URL is the value set + expect(files[0].storage_url).toMatch(/video\.mp4$/); + expect(files[0].extension).toBe('mp4'); + expect(files[1].storage_url).toMatch(/video\.webm$/); + expect(files[1].extension).toBe('webm'); + }); + + it('extracts subtitle tracks from video', () => { + const source = createElement('source', { src: 'video.mp4', type: 'video/mp4' }); + const track = createElement('track', { + src: 'subtitles.vtt', + kind: 'subtitles', + srclang: 'en', + label: 'English', + }); + const video = createElement('video', {}, [source, track]); + + const files = extractors.video(video); + + // Should have 2 files: 1 video source + 1 subtitle + expect(files).toHaveLength(2); + + const subtitleFile = files.find(f => f.preset === 'video_subtitle'); + expect(subtitleFile).toBeDefined(); + expect(subtitleFile.storage_url).toMatch(/subtitles\.vtt$/); + expect(subtitleFile.supplementary).toBe(true); + expect(subtitleFile.lang).toBe('en'); + }); + + it('ignores metadata tracks', () => { + const source = createElement('source', { src: 'video.mp4', type: 'video/mp4' }); + const metadataTrack = createElement('track', { + src: 'chapters.vtt', + kind: 'metadata', + }); + const video = createElement('video', {}, [source, metadataTrack]); + + const files = extractors.video(video); + + // Should only have 1 file (the video source) + expect(files).toHaveLength(1); + expect(files[0].preset).toBe('high_res_video'); + }); + + it('ignores chapters tracks', () => { + const source = createElement('source', { src: 'video.mp4', type: 'video/mp4' }); + const chaptersTrack = createElement('track', { + src: 'chapters.vtt', + kind: 'chapters', + }); + const video = createElement('video', {}, [source, chaptersTrack]); + + const files = extractors.video(video); + + expect(files).toHaveLength(1); + expect(files[0].preset).toBe('high_res_video'); + }); + + it('combines src attribute and source children', () => { + const source = createElement('source', { src: 'video-alt.webm', type: 'video/webm' }); + const video = createElement('video', { src: 'video.mp4' }, [source]); + + const files = extractors.video(video); + + expect(files).toHaveLength(2); + expect(files[0].storage_url).toMatch(/video\.mp4$/); + expect(files[1].storage_url).toMatch(/video-alt\.webm$/); + }); + + it('derives extension from the URL suffix for a direct src', () => { + const video = createElement('video', { src: 'http://example.com/clip.webm' }); + const files = extractors.video(video); + + expect(files[0].extension).toBe('webm'); + }); + + it('falls back to the URL suffix when the source type is not in the map', () => { + const source = createElement('source', { src: 'clip.ogv', type: 'video/ogg' }); + const video = createElement('video', {}, [source]); + + const files = extractors.video(video); + + expect(files[0].extension).toBe('ogv'); + }); + + it('derives extension from the URL suffix when source has no type', () => { + const source = createElement('source', { src: 'clip.webm' }); + const video = createElement('video', {}, [source]); + + const files = extractors.video(video); + + expect(files[0].extension).toBe('webm'); + }); + + it('falls back to the container default when source has no type and no URL suffix', () => { + const source = createElement('source', { src: 'stream' }); + const video = createElement('video', {}, [source]); + + const files = extractors.video(video); + + expect(files[0].extension).toBe('mp4'); + }); + + it('assigns correct priority to multiple sources', () => { + const source1 = createElement('source', { src: 'video1.mp4', type: 'video/mp4' }); + const source2 = createElement('source', { src: 'video2.webm', type: 'video/webm' }); + const video = createElement('video', {}, [source1, source2]); + + const files = extractors.video(video); + + expect(files[0].priority).toBe(1); + expect(files[1].priority).toBe(2); + }); + }); + + describe('audio extractor', () => { + it('extracts file from audio element with src attribute', () => { + const audio = createElement('audio', { src: 'http://example.com/audio.mp3' }); + const files = extractors.audio(audio); + + expect(files).toHaveLength(1); + expect(files[0]).toMatchObject({ + storage_url: 'http://example.com/audio.mp3', + preset: 'audio', + available: true, + supplementary: false, + thumbnail: false, + extension: 'mp3', + }); + }); + + it('extracts files from audio with source children', () => { + const source = createElement('source', { src: 'audio.mp3', type: 'audio/mpeg' }); + const audio = createElement('audio', {}, [source]); + + const files = extractors.audio(audio); + + expect(files).toHaveLength(1); + expect(files[0].storage_url).toMatch(/audio\.mp3$/); + expect(files[0].preset).toBe('audio'); + }); + + it('returns empty array for audio without sources', () => { + const audio = createElement('audio', {}); + const files = extractors.audio(audio); + + expect(files).toHaveLength(0); + }); + + it('falls back to the container default when source has no type and no URL suffix', () => { + const source = createElement('source', { src: 'stream' }); + const audio = createElement('audio', {}, [source]); + + const files = extractors.audio(audio); + + expect(files[0].extension).toBe('mp3'); + }); + }); + + describe('extractor exports', () => { + it('exports video and audio extractors', () => { + expect(extractors).toHaveProperty('video'); + expect(extractors).toHaveProperty('audio'); + expect(typeof extractors.video).toBe('function'); + expect(typeof extractors.audio).toBe('function'); + }); + }); +}); diff --git a/kolibri/plugins/media_player/frontend/utils/fileExtractors.js b/kolibri/plugins/media_player/frontend/utils/fileExtractors.js index 55d6609659c..e18efa32f25 100644 --- a/kolibri/plugins/media_player/frontend/utils/fileExtractors.js +++ b/kolibri/plugins/media_player/frontend/utils/fileExtractors.js @@ -2,6 +2,9 @@ * File extractor functions for media elements (video/audio) */ +// Content presets that route to the video player (vs. the audio player). +export const VIDEO_PRESETS = new Set(['high_res_video', 'low_res_video']); + /** * Generate file data object for media files * @param {string} url - The file's storage URL @@ -21,6 +24,27 @@ function _generateFileData(url, preset, options = {}) { }; } +const mimeTypeToExtensionMap = { + 'video/mp4': 'mp4', + 'video/webm': 'webm', + 'audio/mpeg': 'mp3', +}; + +/** + * Derive a file extension from a URL's path suffix, ignoring query/hash. + * @param {string} url - The file's URL (absolute or relative) + * @returns {string|null} lowercased extension, or null if none is present + */ +function _extensionFromUrl(url) { + const path = url.split(/[?#]/)[0]; + const lastSegment = path.split('/').pop(); + const dotIndex = lastSegment.lastIndexOf('.'); + if (dotIndex <= 0 || dotIndex === lastSegment.length - 1) { + return null; + } + return lastSegment.slice(dotIndex + 1).toLowerCase(); +} + /** * Extract files from media elements (video/audio) * @param {HTMLMediaElement} element - Source for file extraction @@ -28,12 +52,20 @@ function _generateFileData(url, preset, options = {}) { */ function extractMediaFiles(element) { const files = []; - const preset = element.tagName.toLowerCase() === 'video' ? 'high_res_video' : 'audio'; + const isVideo = element.tagName.toLowerCase() === 'video'; + const preset = isVideo ? 'high_res_video' : 'audio'; + // The container's typical extension, used when neither the type nor the URL + // suffix yields one. + const defaultExtension = isVideo ? 'mp4' : 'mp3'; let sourceCount = 0; - // Direct src attribute - if (element.src) { - files.push(_generateFileData(element.src, preset, { priority: 1 })); + // Use getAttribute to read raw attribute values, since .src resolves + // against the document base URL which is unavailable on detached nodes. + const src = element.getAttribute('src'); + if (src) { + // Prefer the URL suffix; fall back to the container's typical extension. + const extension = _extensionFromUrl(src) || defaultExtension; + files.push(_generateFileData(src, preset, { extension, priority: 1 })); sourceCount++; } @@ -42,8 +74,15 @@ function extractMediaFiles(element) { const childTag = child.tagName.toLowerCase(); const childSrc = child.getAttribute('src'); - if (childTag === 'source' && child.src) { - files.push(_generateFileData(child.src, preset, { priority: sourceCount + 1 })); + if (childTag === 'source' && childSrc) { + // Map the MIME type first; fall back to the URL suffix for types the map + // doesn't cover (e.g. video/ogg) or when type is absent, then to the + // container default so the source type never becomes video/null. + const extension = + (child.type && mimeTypeToExtensionMap[child.type]) || + _extensionFromUrl(childSrc) || + defaultExtension; + files.push(_generateFileData(childSrc, preset, { extension, priority: sourceCount + 1 })); sourceCount++; } else if (childTag === 'track' && childSrc && !['metadata', 'chapters'].includes(child.kind)) { files.push( diff --git a/kolibri/plugins/media_player/frontend/utils/mediaStrings.js b/kolibri/plugins/media_player/frontend/utils/mediaStrings.js new file mode 100644 index 00000000000..ef32b3dadd3 --- /dev/null +++ b/kolibri/plugins/media_player/frontend/utils/mediaStrings.js @@ -0,0 +1,122 @@ +import { createTranslator } from 'kolibri/utils/i18n'; + +export default createTranslator('MediaPlayerStrings', { + // Transport controls (shared across audio and video players) + play: { + message: 'Play', + context: 'Button to start media playback.', + }, + pause: { + message: 'Pause', + context: 'Button to pause media playback.', + }, + replay: { + message: 'Go back 10 seconds', + context: 'Button to rewind media by 10 seconds.', + }, + forward: { + message: 'Go forward 10 seconds', + context: 'Button to advance media by 10 seconds.', + }, + mute: { + message: 'Mute', + context: 'Button to mute media.', + }, + unmute: { + message: 'Unmute', + context: 'Button to unmute media.', + }, + playbackRate: { + message: 'Playback rate', + context: 'Button to change media playback speed.', + }, + playbackRateWithValue: { + message: 'Playback rate: {rate, number}×', + context: + 'Accessible label for the button that changes media playback speed, including the current speed, e.g. "Playback rate: 1.5×".', + }, + playbackRateLabel: { + message: '{rate, number}×', + context: 'Label showing the current playback speed multiplier, e.g. "1.5×".', + }, + progressBar: { + message: 'Progress bar', + context: 'Seek bar for navigating through the media.', + }, + currentTime: { + message: 'Current time', + context: 'Indicates the current playback position in the media.', + }, + durationTime: { + message: 'Duration time', + context: 'Indicates the total length of the media.', + }, + loaded: { + message: 'Loaded', + context: 'Indicates the media has loaded into the player.', + }, + fullscreen: { + message: 'Enter fullscreen', + context: 'Button to open the media player in fullscreen view.', + }, + nonFullscreen: { + message: 'Exit fullscreen', + context: 'Button to exit the media player from fullscreen view.', + }, + volumeLevel: { + message: 'Volume level', + context: 'Indicates the volume control.', + }, + + // Caption/transcript controls + captions: { + message: 'Captions', + context: 'Button to access subtitle/caption options in the media player.', + }, + captionsOff: { + message: 'Captions off', + context: 'Option to turn off subtitles (captions) in the media player.', + }, + transcriptOff: { + message: 'Transcript off', + context: 'Option to turn off the transcript.', + }, + languages: { + message: 'Languages', + context: 'Button to access language options for media captions.', + }, + + // Transcript toggle (AudioPlayer) — display casing is applied via CSS text-transform + showTranscript: { + message: 'Show transcript', + context: 'Button to display the transcript panel.', + }, + hideTranscript: { + message: 'Hide transcript', + context: 'Button to hide the transcript panel.', + }, + + // Error messages (used in video.js config) + networkError: { + message: 'A network error caused the media download to fail part-way', + context: 'Error message displayed when a media download fails due to a network error.', + }, + formatError: { + message: + 'The media could not be loaded, either because the server or network failed or because the format is not supported', + context: 'Error message displayed when a media file cannot be loaded.', + }, + corruptionOrSupportError: { + message: + 'The media playback was aborted due to a corruption problem or because the media used features your browser did not support', + context: 'Error message displayed when media playback is aborted.', + }, + sourceError: { + message: 'No compatible source was found for this media', + context: 'Error message displayed when no compatible media source is found.', + }, + encryptionError: { + message: 'The media is encrypted and we do not have the keys to decrypt it', + context: 'Error message displayed when encrypted media cannot be decrypted.', + }, +}); From 2150bd4266cefe298ccea21b68408b53da52d735 Mon Sep 17 00:00:00 2001 From: Richard Tibbles Date: Sat, 18 Jul 2026 14:05:37 -0700 Subject: [PATCH 2/4] Add media player composables Replace the Vuex store module with instance-scoped composables: useMediaPlayer owns player lifecycle and state, composing useCaptions, useMediaProgress, and useScrollContainer; useSeekBar derives scrub-bar state. useMediaProgress isolates the shared progress-tracking logic so video and audio report progress identically. Add a videojs mock and a useMediaPlayer manual mock for testing consumers. --- .../composables/__tests__/useCaptions.spec.js | 135 +++++ .../__tests__/useMediaProgress.spec.js | 214 +++++++ .../frontend/composables/useCaptions.js | 290 ++++++++++ .../frontend/composables/useMediaPlayer.js | 532 ++++++++++++++++++ .../frontend/composables/useMediaProgress.js | 115 ++++ .../frontend/composables/useSeekBar.js | 156 +++++ .../media_player/frontend/test/videojsMock.js | 268 +++++++++ .../components/NumericKeypad/index.vue | 6 +- .../__tests__/useScrollContainer.spec.js | 29 +- .../composables/useScrollContainer.js | 101 ++-- 10 files changed, 1783 insertions(+), 63 deletions(-) create mode 100644 kolibri/plugins/media_player/frontend/composables/__tests__/useCaptions.spec.js create mode 100644 kolibri/plugins/media_player/frontend/composables/__tests__/useMediaProgress.spec.js create mode 100644 kolibri/plugins/media_player/frontend/composables/useCaptions.js create mode 100644 kolibri/plugins/media_player/frontend/composables/useMediaPlayer.js create mode 100644 kolibri/plugins/media_player/frontend/composables/useMediaProgress.js create mode 100644 kolibri/plugins/media_player/frontend/composables/useSeekBar.js create mode 100644 kolibri/plugins/media_player/frontend/test/videojsMock.js diff --git a/kolibri/plugins/media_player/frontend/composables/__tests__/useCaptions.spec.js b/kolibri/plugins/media_player/frontend/composables/__tests__/useCaptions.spec.js new file mode 100644 index 00000000000..767dd5dc52a --- /dev/null +++ b/kolibri/plugins/media_player/frontend/composables/__tests__/useCaptions.spec.js @@ -0,0 +1,135 @@ +import { ref } from 'vue'; + +jest.mock('video.js', () => require('../../test/videojsMock').videojsModuleMock()); + +/* eslint-disable import-x/first */ +import useCaptions from '../useCaptions'; +import Settings from '../../utils/settings'; +import { createTrack, createFakePlayer } from '../../test/videojsMock'; +/* eslint-enable import-x/first */ + +function createCaptions() { + return useCaptions(ref(createFakePlayer())); +} + +describe('useCaptions', () => { + beforeEach(() => { + // Settings persists via Lockr → localStorage; isolate each test. + window.localStorage.clear(); + }); + + it('loads default caption settings (subtitles on, transcript off)', () => { + const captions = createCaptions(); + expect(captions.subtitles.value).toBe(true); + expect(captions.transcript.value).toBe(false); + }); + + it('enables the track matching the active language and exposes its cues', () => { + const captions = createCaptions(); + captions.setLanguage('en'); + + const enCues = [{}, {}]; + const en = createTrack({ language: 'en', cues: enCues, activeCues: [enCues[0]] }); + const es = createTrack({ language: 'es', cues: [{}] }); + captions.setTrackList([en, es]); + + expect(en.mode).toBe('showing'); + expect(es.mode).toBe('disabled'); + expect(captions.cues.value).toHaveLength(2); + expect(captions.activeCueIds.value).toEqual(['en-cue-0']); + }); + + it('persists caption preferences when toggled', () => { + const captions = createCaptions(); + captions.toggleSubtitles(); + + expect(captions.subtitles.value).toBe(false); + expect(new Settings().captionSubtitles).toBe(false); + }); + + it('toggleTranscript flips transcript state', () => { + const captions = createCaptions(); + captions.toggleTranscript(); + expect(captions.transcript.value).toBe(true); + }); + + it('setLanguage switches the active caption track', () => { + const captions = createCaptions(); + const en = createTrack({ language: 'en', cues: [{}, {}] }); + const es = createTrack({ language: 'es', cues: [{}] }); + captions.setLanguage('en'); + captions.setTrackList([en, es]); + + captions.setLanguage('es'); + + expect(captions.language.value).toBe('es'); + expect(es.mode).toBe('showing'); + expect(en.mode).toBe('disabled'); + expect(captions.cues.value).toHaveLength(1); + }); + + it('picks up cues that load after a language switch', () => { + const captions = createCaptions(); + const en = createTrack({ language: 'en', cues: [{}, {}] }); + // Tracks are fetched lazily, so a track only enabled later starts empty. + const es = createTrack({ language: 'es' }); + captions.setLanguage('en'); + captions.setTrackList([en, es]); + + captions.setLanguage('es'); + expect(captions.cues.value).toEqual([]); + + // The VTT finishes parsing after the switch: addCue is the only signal. + es.cues = [{}]; + es.addCue({}); + + expect(captions.cues.value).toHaveLength(1); + }); + + it('initCaptionState disables captions when no track matches the language', () => { + const captions = createCaptions(); + captions.setLanguage('fr'); // no track for fr + captions.setTrackList([createTrack({ language: 'en', cues: [{}] })]); + + captions.initCaptionState(); + + expect(captions.subtitles.value).toBe(false); + expect(captions.transcript.value).toBe(false); + }); + + it('isDefaultTrack compares by short language code', () => { + const captions = createCaptions(); + captions.setLanguage('en'); + expect(captions.isDefaultTrack('en')).toBe(true); + expect(captions.isDefaultTrack('es')).toBe(false); + }); + + it('resetState clears cue state', () => { + const captions = createCaptions(); + captions.setLanguage('en'); + captions.setTrackList([createTrack({ language: 'en', cues: [{}, {}] })]); + expect(captions.cues.value.length).toBeGreaterThan(0); + + captions.resetState(); + + expect(captions.cues.value).toEqual([]); + expect(captions.activeCueIds.value).toEqual([]); + }); + + it('resetState detaches cuechange listeners so later track events are ignored', () => { + const captions = createCaptions(); + captions.setLanguage('en'); + const cues = [{}, {}]; + const en = createTrack({ language: 'en', cues, activeCues: [cues[0]] }); + captions.setTrackList([en]); + expect(captions.activeCueIds.value).toEqual(['en-cue-0']); + + captions.resetState(); + expect(captions.activeCueIds.value).toEqual([]); + + // A cuechange fired after teardown must not repopulate active cues. + en.activeCues = [cues[1]]; + en.trigger('cuechange'); + expect(captions.activeCueIds.value).toEqual([]); + }); +}); diff --git a/kolibri/plugins/media_player/frontend/composables/__tests__/useMediaProgress.spec.js b/kolibri/plugins/media_player/frontend/composables/__tests__/useMediaProgress.spec.js new file mode 100644 index 00000000000..aefe7bb0bb5 --- /dev/null +++ b/kolibri/plugins/media_player/frontend/composables/__tests__/useMediaProgress.spec.js @@ -0,0 +1,214 @@ +import { ref, computed } from 'vue'; +import useMediaProgress from '../useMediaProgress'; +import { createFakePlayer } from '../../test/videojsMock'; + +function createMockPlayer({ duration = 100, ...state } = {}) { + return ref(createFakePlayer({ duration, ...state })); +} + +function setup(overrides = {}) { + const emit = jest.fn(); + const player = overrides.player || createMockPlayer(overrides.playerState); + const extraFields = overrides.extraFields || ref(null); + const savedLocation = + overrides.savedLocation || + computed(() => { + if (extraFields.value && extraFields.value.contentState) { + return extraFields.value.contentState.savedLocation; + } + return 0; + }); + + const result = useMediaProgress({ + player, + emit, + forceDurationBasedProgress: overrides.forceDurationBasedProgress || ref(false), + durationBasedProgress: overrides.durationBasedProgress || ref(0), + extraFields, + savedLocation, + }); + + return { emit, player, ...result }; +} + +describe('useMediaProgress', () => { + describe('recordProgress', () => { + it('reports the absolute durationBasedProgress and ignores elapsed time when forced', () => { + const timeSpent = 30; + const contentDuration = 300; + const player = createMockPlayer({ duration: 100, currentTime: 0 }); + const { emit, updateTime } = setup({ + player, + forceDurationBasedProgress: ref(true), + durationBasedProgress: ref(timeSpent / contentDuration), + }); + + player.value.currentTime(50); + updateTime(); + + expect(emit).toHaveBeenCalledWith('updateProgress', timeSpent / contentDuration); + expect(emit).not.toHaveBeenCalledWith('addProgress', expect.anything()); + }); + + it('emits addProgress as the fraction of duration newly elapsed since the last record', () => { + const player = createMockPlayer({ duration: 100, currentTime: 0 }); + const { emit, updateTime } = setup({ player }); + + player.value.currentTime(30); + updateTime(); + player.value.currentTime(50); + updateTime(); + + expect(emit.mock.calls).toEqual([ + ['addProgress', 0.3], + ['addProgress', 0.2], + ]); + }); + }); + + describe('updateTime', () => { + it('updates dummyTime from player currentTime', () => { + const player = createMockPlayer({ currentTime: 3 }); + const { emit, updateTime } = setup({ player }); + + updateTime(); + + // 3 seconds elapsed, but less than 5s threshold — no progress emitted + expect(emit).not.toHaveBeenCalled(); + }); + + it('records progress once playback crosses the 5 second threshold', () => { + const player = createMockPlayer({ duration: 100, currentTime: 6 }); + const { emit, updateTime } = setup({ player }); + + updateTime(); + + expect(emit).toHaveBeenCalledWith('addProgress', 0.06); + }); + + it('does not re-record until another 5 seconds have elapsed', () => { + const player = createMockPlayer({ duration: 100, currentTime: 6 }); + const { emit, updateTime } = setup({ player }); + + updateTime(); + emit.mockClear(); + + player.value.currentTime(9); + updateTime(); + expect(emit).not.toHaveBeenCalled(); + + player.value.currentTime(11); + updateTime(); + expect(emit).toHaveBeenCalledWith('addProgress', 0.05); + }); + + it('skips update while seeking', () => { + const player = createMockPlayer({ currentTime: 50, seeking: true }); + const { emit, updateTime } = setup({ player }); + + updateTime(); + + expect(emit).not.toHaveBeenCalled(); + }); + }); + + describe('handleSeek', () => { + it('flushes sub-threshold progress before a seek and rebaselines after it', () => { + const player = createMockPlayer({ duration: 100, currentTime: 0 }); + const { emit, updateTime, handleSeek } = setup({ player }); + + player.value.currentTime(3); + updateTime(); + expect(emit).not.toHaveBeenCalled(); + + player.value.currentTime(80); + handleSeek(); + expect(emit).toHaveBeenCalledWith('addProgress', 0.03); + + emit.mockClear(); + player.value.currentTime(85); + updateTime(); + expect(emit).toHaveBeenCalledWith('addProgress', 0.05); + }); + }); + + describe('setPlayState', () => { + it('emits startTracking when state is true', () => { + const player = createMockPlayer(); + const { emit, setPlayState } = setup({ player }); + + setPlayState(true); + + expect(emit).toHaveBeenCalledWith('startTracking'); + }); + + it('emits stopTracking when state is false', () => { + const player = createMockPlayer(); + const { emit, setPlayState } = setup({ player }); + + setPlayState(false); + + expect(emit).toHaveBeenCalledWith('stopTracking'); + }); + + it('records progress before changing state', () => { + const player = createMockPlayer({ duration: 100 }); + const { emit, setPlayState } = setup({ + player, + forceDurationBasedProgress: ref(true), + durationBasedProgress: ref(0.5), + }); + + setPlayState(true); + + // recordProgress should be called before startTracking + const calls = emit.mock.calls.map(c => c[0]); + expect(calls.indexOf('updateProgress')).toBeLessThan(calls.indexOf('startTracking')); + }); + + it('skips recording progress while seeking', () => { + const player = createMockPlayer({ seeking: true }); + const { emit, setPlayState } = setup({ player }); + + setPlayState(true); + + // Only startTracking, no progress recording + expect(emit).toHaveBeenCalledTimes(1); + expect(emit).toHaveBeenCalledWith('startTracking'); + }); + }); + + describe('updateContentState', () => { + it('emits updateContentState with saved location', () => { + const player = createMockPlayer({ currentTime: 42 }); + const { emit, updateContentState } = setup({ player }); + + updateContentState(); + + expect(emit).toHaveBeenCalledWith('updateContentState', { savedLocation: 42 }); + }); + + it('preserves existing contentState fields', () => { + const player = createMockPlayer({ currentTime: 42 }); + const { emit, updateContentState } = setup({ + player, + extraFields: ref({ contentState: { someField: 'value', savedLocation: 10 } }), + }); + + updateContentState(); + + expect(emit).toHaveBeenCalledWith('updateContentState', { + someField: 'value', + savedLocation: 42, + }); + }); + + it('does nothing when player is null', () => { + const { emit, updateContentState } = setup({ player: ref(null) }); + + updateContentState(); + + expect(emit).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/kolibri/plugins/media_player/frontend/composables/useCaptions.js b/kolibri/plugins/media_player/frontend/composables/useCaptions.js new file mode 100644 index 00000000000..a3ae2d7a87c --- /dev/null +++ b/kolibri/plugins/media_player/frontend/composables/useCaptions.js @@ -0,0 +1,290 @@ +import vue, { ref, computed } from 'vue'; +import videojs from 'video.js'; +import { languageIdToCode } from 'kolibri/utils/i18n'; +import trackUtils from '../utils/track'; +import Settings from '../utils/settings'; + +const { handleSelectedLanguageChange } = videojs.getComponent('TextTrackMenuItem').prototype; + +/** + * Default caption settings. + * @returns {{captionLanguage: string, captionSubtitles: boolean, captionTranscript: boolean}} + */ +const defaultSettings = () => ({ + captionLanguage: vue.locale, + captionSubtitles: true, + captionTranscript: false, +}); + +/** + * Composable for caption / text-track state and behavior: the active language, + * subtitles vs. transcript, cue tracking, and synchronization with the video.js + * text-track API. Caption preferences persist to localStorage. + * + * The player-driven lifecycle methods (setTrackList, updateTrackList, + * initCaptionState, resetState) are called by useMediaPlayer as the player + * emits its track events. + * @param {import('vue').Ref} player - Ref to the video.js player instance + * @returns {object} Caption state, actions, and lifecycle hooks: + * - language {Ref} - Current caption language + * - subtitles {Ref} - Whether subtitles are enabled + * - transcript {Ref} - Whether transcript is enabled + * - cues {Ref} - Caption cues for the current language + * - activeCueIds {Ref} - Currently active cue IDs + * - captionTracks {ComputedRef} - Available caption tracks + * - setLanguage {Function} - Set the caption language + * - toggleSubtitles {Function} - Toggle subtitles on/off + * - toggleTranscript {Function} - Toggle transcript on/off + * - isDefaultTrack {Function} - Check if a language is the default track + * - setTrackList {Function} - Bind a new text-track list + * - updateTrackList {Function} - Reconcile a changed text-track list + * - initCaptionState {Function} - Disable captions with no matching track + * - resetState {Function} - Clear cue state + */ +export default function useCaptions(player) { + const language = ref(null); + const subtitles = ref(true); + const transcript = ref(false); + const trackList = ref(null); + const cues = ref([]); + const activeCueIds = ref([]); + // Plain array (not a ref): used only imperatively, never exposed reactively. + let trackListeners = []; + + // Caption settings (persisted to localStorage) + const captionSettings = new Settings(defaultSettings()); + language.value = captionSettings.captionLanguage; + subtitles.value = captionSettings.captionSubtitles; + transcript.value = captionSettings.captionTranscript; + + const captionTracks = computed(() => tracks()); + + function tracks() { + return trackUtils.listToArray(trackList.value || []); + } + + function activeTrack() { + return tracks().find(track => trackUtils.isEnabled(track)); + } + + function languageTrack() { + return tracks().find(track => language.value === track.language); + } + + function setCuesFromTrack(track) { + const newCues = trackUtils.listToArray(track.cues || []); + // Ensure cues have ids + newCues.forEach((cue, i) => { + cue.id = track.id + '-cue-' + i; + }); + + cues.value = newCues; + } + + function setActiveCuesFromTrack(track) { + // In case we get triggered to set active cues but haven't added cues yet, do that now + // This helps an issue in Safari where we don't get an updated cue list + if (track.cues && track.cues.length !== cues.value.length) { + setCuesFromTrack(track); + } + + activeCueIds.value = trackUtils + .listToArray(track.activeCues || []) + .map(cue => cue.id) + .filter(Boolean); + } + + function synchronizeTrackList() { + captionSettings.save({ + captionSubtitles: subtitles.value, + captionTranscript: transcript.value, + }); + + tracks().forEach(track => { + if (track.language === language.value) { + trackUtils.setMode(track, subtitles.value || transcript.value, !subtitles.value); + } else { + trackUtils.setMode(track, false); + } + + if (trackUtils.isEnabled(track)) { + setCuesFromTrack(track); + setActiveCuesFromTrack(track); + setLanguage(track.language); + } + }); + } + + function checkLanguageTrack() { + let newLanguage; + + // When we enable either subtitles or transcript, ensure we have appropriate language track + if ((subtitles.value || transcript.value) && !languageTrack()) { + if (activeTrack()) { + newLanguage = activeTrack().language; + } else { + // Just use language of first track as fallback + const tl = tracks(); + if (tl.length > 0) { + newLanguage = tl[0].language; + } + } + } + + if (newLanguage && newLanguage !== language.value) { + language.value = newLanguage; + } + } + + function initCaptionState() { + // If no track for saved language, disable subtitles and transcript + if (!languageTrack()) { + subtitles.value = false; + transcript.value = false; + } + } + + // Detach the cuechange listeners recorded in setTrackList. Idempotent and a + // no-op when none are registered, so it is safe to call from both a track-list + // swap and resetState. + function removeTrackListeners() { + const ts = tracks(); + trackListeners.forEach(({ trackId, event, listener }) => { + const track = ts.find(t => t.id === trackId); + if (track) { + track.removeEventListener(event, listener); + } + }); + trackListeners = []; + } + + function setTrackList(newTrackList) { + removeTrackListeners(); + + trackList.value = newTrackList; + synchronizeTrackList(); + + tracks().forEach(track => { + const changeListener = () => { + if (trackUtils.isEnabled(track)) { + setActiveCuesFromTrack(track); + } + }; + track.addEventListener('cuechange', changeListener); + trackListeners.push({ + trackId: track.id, + event: 'cuechange', + listener: changeListener, + }); + + if (track.addCue.overridden) { + return; + } + + // Override `addCue` method to hook into the addition of cues + const addCue = track.addCue.bind(track); + track.addCue = (...args) => { + const result = addCue(...args); + + if (track.language == language.value) { + setCuesFromTrack(track); + setActiveCuesFromTrack(track); + } + + return result; + }; + track.addCue.overridden = true; + }); + } + + function updateTrackList(newTrackList) { + if (trackList.value.length !== newTrackList.length) { + return setTrackList(newTrackList); + } + + trackList.value = newTrackList; + synchronizeTrackList(); + } + + // Public action: Set caption language + function setLanguage(newLanguage) { + if (language.value === newLanguage) { + return; + } + + language.value = newLanguage; + + captionSettings.captionLanguage = newLanguage; + synchronizeTrackList(); + + // When changing language, and there is no format enabled, enable subtitles + if (!subtitles.value && !transcript.value) { + toggleSubtitles(); + } + + const track = activeTrack(); + if (!track) { + return; + } + + // Retain video.js behavior on language change, + // see TextTrackMenuItem.handleSelectedLanguageChange + if (player.value) { + handleSelectedLanguageChange.call({ + track, + player_: player.value, + }); + } + } + + // Public action: Toggle subtitles + function toggleSubtitles() { + subtitles.value = !subtitles.value; + checkLanguageTrack(); + synchronizeTrackList(); + } + + // Public action: Toggle transcript + function toggleTranscript() { + transcript.value = !transcript.value; + checkLanguageTrack(); + synchronizeTrackList(); + } + + function isDefaultTrack(langCode) { + if (!language.value) { + return false; + } + const shortLangCode = languageIdToCode(langCode); + const shortGlobalLangCode = languageIdToCode(language.value); + return shortLangCode === shortGlobalLangCode; + } + + function resetState() { + removeTrackListeners(); + cues.value = []; + activeCueIds.value = []; + } + + return { + // State + language, + subtitles, + transcript, + cues, + activeCueIds, + captionTracks, + + // Actions + setLanguage, + toggleSubtitles, + toggleTranscript, + isDefaultTrack, + + // Player-driven lifecycle + setTrackList, + updateTrackList, + initCaptionState, + resetState, + }; +} diff --git a/kolibri/plugins/media_player/frontend/composables/useMediaPlayer.js b/kolibri/plugins/media_player/frontend/composables/useMediaPlayer.js new file mode 100644 index 00000000000..c38941504e0 --- /dev/null +++ b/kolibri/plugins/media_player/frontend/composables/useMediaPlayer.js @@ -0,0 +1,532 @@ +import { + ref, + computed, + provide, + inject, + readonly, + markRaw, + onBeforeUnmount, + onMounted, + watch, + nextTick, +} from 'vue'; +import videojs from 'video.js'; +import mapValues from 'lodash/mapValues'; +import { useIntervalFn, useThrottleFn } from '@vueuse/core'; +import useContentViewer from 'kolibri/composables/useContentViewer'; +import useScrollContainer from 'kolibri-common/composables/useScrollContainer'; +import Settings from '../utils/settings'; +import customExtractors from '../utils/fileExtractors'; +import useMediaProgress from './useMediaProgress'; +import useCaptions from './useCaptions'; + +const MEDIA_PLAYER_CONTEXT_KEY = 'mediaPlayerContext'; + +/** + * Available playback rate options for audio/video players + */ +export const PLAYBACK_RATES = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0]; + +/** + * Composable for managing media player state, progress tracking, and content viewer integration. + * Creates instance-specific state that can be safely used with multiple players. + * + * This composable integrates useContentViewer internally, so components using it + * do not need to call useContentViewer separately. + * @param {object} context - Vue component context ({ emit }) + * @param {object} options - Configuration options + * @param {import('vue').Ref} [options.rootEl] - Root element ref for scroll container detection + * @param {import('vue').Ref} options.wrapperRef - Wrapper element ref for first-interaction play + * @param {import('vue').Ref} options.playerRef - The