diff --git a/docs/frontend_architecture/single_page_apps.rst b/docs/frontend_architecture/single_page_apps.rst
index 8bdcdfe172f..b38ece7b541 100644
--- a/docs/frontend_architecture/single_page_apps.rst
+++ b/docs/frontend_architecture/single_page_apps.rst
@@ -92,13 +92,13 @@ A special kind of Kolibri Module is dedicated to rendering particular content ty
:members:
:noindex:
-The ``ContentViewer`` class has one required property ``viewerComponent`` which should return a Vue component that wraps the content rendering code. This component will be passed ``files``, ``file``, ``itemData``, ``preset``, ``itemId``, ``answerState``, ``allowHints``, ``extraFields``, ``interactive``, ``lang``, ``showCorrectAnswer``, ``defaultItemPreset``, ``availableFiles``, ``defaultFile``, ``supplementaryFiles``, ``thumbnailFiles``, ``contentDirection``, and ``contentIsRtl`` props, defining the files associated with the piece of content, and other required data for rendering.
+The ``ContentViewer`` class has one required property ``viewerComponent`` which should return a Vue component that wraps the content rendering code. The ``ContentViewer`` wrapper accepts a ``contentNode`` prop containing the content metadata (including ``files``, ``lang``, ``options``, and ``duration``), and provides all necessary data to descendant viewer components via the ``useContentViewer`` composable.
-The component should use the `useContentViewer` composable and the `contentViewerProps`, importable as follows:
+The component should use the `useContentViewer` composable, importable as follows:
.. code-block:: javascript
- import useContentViewer, { contentViewerProps } from 'kolibri/composables/useContentViewer';
+ import useContentViewer from 'kolibri/composables/useContentViewer';
In order to log data about users viewing content, the component should emit ``startTracking``, ``updateProgress``, and ``stopTracking`` events, using the Vue ``$emit`` method. ``startTracking`` and ``stopTracking`` are emitted without any arguments, whereas ``updateProgress`` should be emitted with a single value between 0 and 1 representing the current proportion of progress on the content.
diff --git a/kolibri/core/content/hooks.py b/kolibri/core/content/hooks.py
index 2d72566933a..d9fd2b6e1a5 100644
--- a/kolibri/core/content/hooks.py
+++ b/kolibri/core/content/hooks.py
@@ -9,6 +9,8 @@
from django.core.serializers.json import DjangoJSONEncoder
from django.utils.safestring import mark_safe
+from le_utils.constants import file_formats
+from le_utils.constants import format_presets
from kolibri.core.webpack.hooks import WebpackBundleHook
from kolibri.core.webpack.hooks import WebpackInclusionMixin
@@ -29,6 +31,30 @@ class ContentRendererHook(WebpackBundleHook, WebpackInclusionMixin):
def presets(self):
pass
+ #: Optional tuple of CSS selectors that this content renderer can handle
+ css_selectors = ()
+
+ #: Whether to allow object tag handling (defaults to False for sandboxing compatibility)
+ allow_object_tag = False
+
+ @classmethod
+ def all_css_selectors(cls):
+ """Get all CSS selectors (auto-generated from presets + custom), cached."""
+ if not hasattr(cls, "_cached_css_selectors"):
+ selectors = list(cls.css_selectors)
+
+ if cls.allow_object_tag:
+ for preset in cls.presets:
+ preset_obj = next(
+ x for x in format_presets.PRESETLIST if x.id == preset
+ )
+ for fmt in preset_obj.allowed_formats:
+ fmt_obj = file_formats.getformat(fmt)
+ selectors.append(f'object[type="{fmt_obj.mimetype}"]')
+
+ cls._cached_css_selectors = tuple(sorted(set(selectors)))
+ return cls._cached_css_selectors
+
@classmethod
def html(cls):
tags = []
@@ -53,7 +79,11 @@ def template_html(self):
'{data} '.format(
bundle=self.unique_id,
data=json.dumps(
- {"urls": urls, "presets": self.presets},
+ {
+ "urls": urls,
+ "presets": self.presets,
+ "css_selectors": self.all_css_selectors(),
+ },
separators=(",", ":"),
ensure_ascii=False,
cls=DjangoJSONEncoder,
diff --git a/kolibri/core/content/zip_wsgi.py b/kolibri/core/content/zip_wsgi.py
index 144d3339184..67e2f2ce4c3 100644
--- a/kolibri/core/content/zip_wsgi.py
+++ b/kolibri/core/content/zip_wsgi.py
@@ -24,6 +24,7 @@
from le_utils.constants.file_formats import BLOOMPUB
from le_utils.constants.file_formats import H5P
from le_utils.constants.file_formats import HTML5
+from le_utils.constants.file_formats import HTML5_ARTICLE
from le_utils.constants.file_formats import PERSEUS
from whitenoise.responders import StaticFile
@@ -270,7 +271,7 @@ def get_embedded_file(
return response
-archive_file_types = (HTML5, H5P, BLOOMPUB, BLOOMD, PERSEUS)
+archive_file_types = (HTML5, H5P, BLOOMPUB, BLOOMD, PERSEUS, HTML5_ARTICLE)
archive_file_extension_match = "|".join(archive_file_types)
# Allows a base url to be passed in the main
diff --git a/kolibri/plugins/bloompub_viewer/frontend/views/BloomPubRendererIndex.vue b/kolibri/plugins/bloompub_viewer/frontend/views/BloomPubRendererIndex.vue
index 4e5c7cf9927..ac218fd1d9e 100644
--- a/kolibri/plugins/bloompub_viewer/frontend/views/BloomPubRendererIndex.vue
+++ b/kolibri/plugins/bloompub_viewer/frontend/views/BloomPubRendererIndex.vue
@@ -6,28 +6,11 @@
:style="{ width: iframeWidth }"
@changeFullscreen="isInFullscreen = $event"
>
-
+
@@ -182,18 +168,7 @@
diff --git a/kolibri/plugins/media_player/frontend/views/AudioPlayerControls.vue b/kolibri/plugins/media_player/frontend/views/AudioPlayerControls.vue
new file mode 100644
index 00000000000..885e258d9d6
--- /dev/null
+++ b/kolibri/plugins/media_player/frontend/views/AudioPlayerControls.vue
@@ -0,0 +1,427 @@
+
+
+
+
+
+
+
+ {{ formattedCurrentTime }}
+ {{ formattedDuration }}
+
+
+
+
+
+
+
+
+
+
+ {{ playbackRateLabel }}
+
+
+
+
+
+
+
+
+
+
diff --git a/kolibri/plugins/media_player/frontend/views/AudioStickyPlayer.vue b/kolibri/plugins/media_player/frontend/views/AudioStickyPlayer.vue
new file mode 100644
index 00000000000..533a1fcd8ec
--- /dev/null
+++ b/kolibri/plugins/media_player/frontend/views/AudioStickyPlayer.vue
@@ -0,0 +1,83 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/kolibri/plugins/media_player/frontend/views/MediaPlayerCaptions/SubtitlesMenuItem.vue b/kolibri/plugins/media_player/frontend/views/MediaPlayerCaptions/SubtitlesMenuItem.vue
index 01efa0d20b8..fe5a5cabf82 100644
--- a/kolibri/plugins/media_player/frontend/views/MediaPlayerCaptions/SubtitlesMenuItem.vue
+++ b/kolibri/plugins/media_player/frontend/views/MediaPlayerCaptions/SubtitlesMenuItem.vue
@@ -17,17 +17,19 @@
-
-
-
diff --git a/kolibri/plugins/media_player/frontend/views/MediaPlayerLanguages/LanguagesMenuItem.vue b/kolibri/plugins/media_player/frontend/views/MediaPlayerLanguages/LanguagesMenuItem.vue
index d5330bdef9e..9b3e5fd8824 100644
--- a/kolibri/plugins/media_player/frontend/views/MediaPlayerLanguages/LanguagesMenuItem.vue
+++ b/kolibri/plugins/media_player/frontend/views/MediaPlayerLanguages/LanguagesMenuItem.vue
@@ -17,10 +17,21 @@
+
+
+
diff --git a/kolibri/plugins/media_player/frontend/views/__tests__/AudioPlayer.spec.js b/kolibri/plugins/media_player/frontend/views/__tests__/AudioPlayer.spec.js
new file mode 100644
index 00000000000..04b78eea8fc
--- /dev/null
+++ b/kolibri/plugins/media_player/frontend/views/__tests__/AudioPlayer.spec.js
@@ -0,0 +1,182 @@
+import { render, screen, fireEvent } from '@testing-library/vue';
+import { nextTick, ref } from 'vue';
+import AudioPlayer from '../AudioPlayer';
+import mediaStrings from '../../utils/mediaStrings';
+/* eslint-disable import-x/named */
+import {
+ mockThumbnailFiles,
+ mockEmbedded,
+ mockCaptionTracks,
+ mockTranscript,
+ mockLoading,
+ mockIsPlaying,
+ mockInitPlayer,
+ mockToggleTranscript,
+ resetMocks,
+} from '../../composables/useMediaPlayer';
+/* eslint-enable import-x/named */
+
+const { play$, showTranscript$, hideTranscript$ } = mediaStrings;
+
+jest.mock('../../composables/useMediaPlayer');
+
+const mockWindowIsSmall = ref(false);
+jest.mock('kolibri-design-system/lib/composables/useKResponsiveWindow', () => () => ({
+ windowIsSmall: mockWindowIsSmall,
+}));
+
+jest.mock('video.js', () => ({
+ formatTime: seconds => {
+ const mins = Math.floor(seconds / 60);
+ const secs = Math.floor(seconds % 60);
+ return `${mins}:${secs < 10 ? '0' : ''}${secs}`;
+ },
+}));
+
+describe('AudioPlayer', () => {
+ let intersectionCallback;
+ const mockObserve = jest.fn();
+ const mockDisconnect = jest.fn();
+
+ beforeAll(() => {
+ global.IntersectionObserver = jest.fn(callback => {
+ intersectionCallback = callback;
+ return { observe: mockObserve, disconnect: mockDisconnect };
+ });
+ });
+
+ beforeEach(() => {
+ resetMocks();
+ mockObserve.mockClear();
+ mockDisconnect.mockClear();
+ });
+
+ describe('loading state', () => {
+ it('shows loader when loading', () => {
+ mockLoading.value = true;
+ render(AudioPlayer);
+ expect(screen.getByRole('progressbar')).toBeInTheDocument();
+ });
+
+ it('hides controls when loading', () => {
+ mockLoading.value = true;
+ render(AudioPlayer);
+ expect(screen.queryByLabelText(play$())).not.toBeInTheDocument();
+ });
+ });
+
+ describe('audio sources', () => {
+ it('renders source elements for audio files', () => {
+ const { container } = render(AudioPlayer);
+ const sources = container.querySelectorAll('audio source');
+ expect(sources).toHaveLength(2);
+ });
+
+ it('sets correct MIME type on source elements', () => {
+ const { container } = render(AudioPlayer);
+ const sources = container.querySelectorAll('audio source');
+ expect(sources[0]).toHaveAttribute('type', 'audio/mpeg');
+ expect(sources[1]).toHaveAttribute('type', 'audio/ogg');
+ });
+ });
+
+ describe('poster image', () => {
+ it('shows poster when thumbnail files exist', () => {
+ mockThumbnailFiles.value = [{ storage_url: '/poster.jpg' }];
+ render(AudioPlayer);
+ const poster = screen.getByRole('img');
+ expect(poster).toBeInTheDocument();
+ expect(poster).toHaveAttribute('src', '/poster.jpg');
+ });
+
+ it('does not render poster when no thumbnails', () => {
+ render(AudioPlayer);
+ expect(screen.queryByRole('img')).not.toBeInTheDocument();
+ });
+ });
+
+ describe('transcript', () => {
+ it('shows transcript toggle button when caption tracks exist', () => {
+ mockCaptionTracks.value = [{ id: 'en', lang: 'English' }];
+ render(AudioPlayer);
+ expect(screen.getByRole('button', { name: showTranscript$() })).toBeInTheDocument();
+ });
+
+ it('hides transcript toggle when no caption tracks', () => {
+ render(AudioPlayer);
+ expect(screen.queryByRole('button', { name: showTranscript$() })).not.toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: hideTranscript$() })).not.toBeInTheDocument();
+ });
+
+ it('calls toggleTranscript when toggle button is clicked', async () => {
+ mockCaptionTracks.value = [{ id: 'en', lang: 'English' }];
+ render(AudioPlayer);
+ await fireEvent.click(screen.getByRole('button', { name: showTranscript$() }));
+ expect(mockToggleTranscript).toHaveBeenCalledTimes(1);
+ });
+
+ it('shows "HIDE TRANSCRIPT" button when transcript is enabled', () => {
+ mockCaptionTracks.value = [{ id: 'en', lang: 'English' }];
+ mockTranscript.value = true;
+ render(AudioPlayer);
+ expect(screen.getByRole('button', { name: hideTranscript$() })).toBeInTheDocument();
+ });
+ });
+
+ describe('sticky player', () => {
+ it('does not show sticky player when paused', () => {
+ const { container } = render(AudioPlayer);
+ expect(container.querySelector('.sticky-top, .sticky-bottom')).toBeNull();
+ });
+
+ it('shows sticky player when playing and scrolled out of view', async () => {
+ mockIsPlaying.value = true;
+ const { container } = render(AudioPlayer);
+ intersectionCallback([{ isIntersecting: false }]);
+ await nextTick();
+ expect(
+ container.querySelector('.sticky-top') || container.querySelector('.sticky-bottom'),
+ ).toBeTruthy();
+ });
+
+ it('hides sticky player when scrolled back into view', async () => {
+ mockIsPlaying.value = true;
+ const { container } = render(AudioPlayer);
+
+ intersectionCallback([{ isIntersecting: false }]);
+ await nextTick();
+ expect(
+ container.querySelector('.sticky-top') || container.querySelector('.sticky-bottom'),
+ ).toBeTruthy();
+
+ intersectionCallback([{ isIntersecting: true }]);
+ await nextTick();
+ expect(container.querySelector('.sticky-top, .sticky-bottom')).toBeNull();
+ });
+ });
+
+ describe('player initialization', () => {
+ it('calls initPlayer on mount', () => {
+ render(AudioPlayer);
+ expect(mockInitPlayer).toHaveBeenCalledTimes(1);
+ });
+
+ it('sets up IntersectionObserver on ready', () => {
+ render(AudioPlayer);
+ expect(mockObserve).toHaveBeenCalled();
+ });
+ });
+
+ describe('standalone vs embedded', () => {
+ it('applies standalone-wrapper class when not embedded', () => {
+ const { container } = render(AudioPlayer);
+ expect(container.querySelector('.standalone-wrapper')).toBeTruthy();
+ });
+
+ it('does not apply standalone-wrapper when embedded', () => {
+ mockEmbedded.value = true;
+ const { container } = render(AudioPlayer);
+ expect(container.querySelector('.standalone-wrapper')).toBeNull();
+ });
+ });
+});
diff --git a/kolibri/plugins/media_player/frontend/views/__tests__/AudioPlayerControls.spec.js b/kolibri/plugins/media_player/frontend/views/__tests__/AudioPlayerControls.spec.js
new file mode 100644
index 00000000000..e0ea353ff2f
--- /dev/null
+++ b/kolibri/plugins/media_player/frontend/views/__tests__/AudioPlayerControls.spec.js
@@ -0,0 +1,166 @@
+import { render, screen, fireEvent } from '@testing-library/vue';
+import videojs from 'video.js';
+import AudioPlayerControls from '../AudioPlayerControls';
+import mediaStrings from '../../utils/mediaStrings';
+/* eslint-disable import-x/named */
+import {
+ mockCurrentTime,
+ mockDuration,
+ mockIsPlaying,
+ mockMuted,
+ mockPlaybackRate,
+ mockTogglePlay,
+ mockSeek,
+ mockRewind,
+ mockForward,
+ mockToggleMute,
+ mockSetPlaybackRate,
+ resetMocks,
+} from '../../composables/useMediaPlayer';
+/* eslint-enable import-x/named */
+
+jest.mock('../../composables/useMediaPlayer');
+
+jest.mock('video.js', () => ({
+ formatTime: seconds => {
+ const mins = Math.floor(seconds / 60);
+ const secs = Math.floor(seconds % 60);
+ return `${mins}:${secs < 10 ? '0' : ''}${secs}`;
+ },
+}));
+
+const { play$, pause$, replay$, forward$, mute$, unmute$, playbackRate$, progressBar$ } =
+ mediaStrings;
+
+describe('AudioPlayerControls', () => {
+ beforeEach(() => {
+ resetMocks();
+ mockCurrentTime.value = 30;
+ });
+
+ describe('transport controls', () => {
+ it('renders play button when paused', () => {
+ render(AudioPlayerControls);
+ expect(screen.getByLabelText(play$())).toBeInTheDocument();
+ });
+
+ it('renders pause button when playing', () => {
+ mockIsPlaying.value = true;
+ render(AudioPlayerControls);
+ expect(screen.getByLabelText(pause$())).toBeInTheDocument();
+ });
+
+ it('calls togglePlay when play button is clicked', async () => {
+ render(AudioPlayerControls);
+ await fireEvent.click(screen.getByLabelText(play$()));
+ expect(mockTogglePlay).toHaveBeenCalledTimes(1);
+ });
+
+ it('renders rewind and forward buttons', () => {
+ render(AudioPlayerControls);
+ expect(screen.getByLabelText(replay$())).toBeInTheDocument();
+ expect(screen.getByLabelText(forward$())).toBeInTheDocument();
+ });
+
+ it('calls rewind when rewind button is clicked', async () => {
+ render(AudioPlayerControls);
+ await fireEvent.click(screen.getByLabelText(replay$()));
+ expect(mockRewind).toHaveBeenCalledTimes(1);
+ });
+
+ it('calls forward when forward button is clicked', async () => {
+ render(AudioPlayerControls);
+ await fireEvent.click(screen.getByLabelText(forward$()));
+ expect(mockForward).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('progress display', () => {
+ it('displays formatted current time', () => {
+ render(AudioPlayerControls);
+ expect(screen.getByText(videojs.formatTime(mockCurrentTime.value))).toBeInTheDocument();
+ });
+
+ it('displays formatted duration', () => {
+ render(AudioPlayerControls);
+ expect(screen.getByText(videojs.formatTime(mockDuration.value))).toBeInTheDocument();
+ });
+
+ it('renders a progress slider with correct ARIA attributes', () => {
+ render(AudioPlayerControls);
+ const slider = screen.getByRole('slider', { name: progressBar$() });
+ expect(slider).toBeInTheDocument();
+ expect(slider).toHaveAttribute('aria-valuemin', '0');
+ expect(slider).toHaveAttribute('aria-valuemax', String(mockDuration.value));
+ expect(slider).toHaveAttribute('aria-valuenow', String(mockCurrentTime.value));
+ });
+ });
+
+ describe('secondary controls', () => {
+ it('renders mute button when not muted', () => {
+ render(AudioPlayerControls);
+ expect(screen.getByLabelText(mute$())).toBeInTheDocument();
+ });
+
+ it('renders unmute button when muted', () => {
+ mockMuted.value = true;
+ render(AudioPlayerControls);
+ expect(screen.getByLabelText(unmute$())).toBeInTheDocument();
+ });
+
+ it('calls toggleMute when mute button is clicked', async () => {
+ render(AudioPlayerControls);
+ await fireEvent.click(screen.getByLabelText(mute$()));
+ expect(mockToggleMute).toHaveBeenCalledTimes(1);
+ });
+
+ it('displays playback rate label', () => {
+ render(AudioPlayerControls);
+ const rateLabel = `${mockPlaybackRate.value}X`;
+ expect(screen.getByText(rateLabel)).toBeInTheDocument();
+ });
+
+ it('cycles playback rate when rate button is clicked', async () => {
+ render(AudioPlayerControls);
+ await fireEvent.click(screen.getByLabelText(playbackRate$()));
+ expect(mockSetPlaybackRate).toHaveBeenCalledWith(1.25);
+ });
+
+ it('wraps back to 0.5X after 2X', async () => {
+ mockPlaybackRate.value = 2.0;
+ render(AudioPlayerControls);
+ await fireEvent.click(screen.getByLabelText(playbackRate$()));
+ expect(mockSetPlaybackRate).toHaveBeenCalledWith(0.5);
+ });
+ });
+
+ describe('keyboard navigation', () => {
+ it('seeks forward on ArrowRight key', async () => {
+ render(AudioPlayerControls);
+ const slider = screen.getByRole('slider', { name: progressBar$() });
+ await fireEvent.keyDown(slider, { key: 'ArrowRight' });
+ expect(mockForward).toHaveBeenCalledWith(5);
+ });
+
+ it('seeks backward on ArrowLeft key', async () => {
+ render(AudioPlayerControls);
+ const slider = screen.getByRole('slider', { name: progressBar$() });
+ await fireEvent.keyDown(slider, { key: 'ArrowLeft' });
+ expect(mockRewind).toHaveBeenCalledWith(5);
+ });
+
+ it('seeks to start on Home key', async () => {
+ render(AudioPlayerControls);
+ const slider = screen.getByRole('slider', { name: progressBar$() });
+ await fireEvent.keyDown(slider, { key: 'Home' });
+ expect(mockSeek).toHaveBeenCalledWith(0);
+ });
+
+ it('seeks to end on End key', async () => {
+ render(AudioPlayerControls);
+ const slider = screen.getByRole('slider', { name: progressBar$() });
+ await fireEvent.keyDown(slider, { key: 'End' });
+ expect(mockSeek).toHaveBeenCalledWith(mockDuration.value);
+ });
+ });
+});
diff --git a/kolibri/plugins/media_player/frontend/views/__tests__/AudioStickyPlayer.spec.js b/kolibri/plugins/media_player/frontend/views/__tests__/AudioStickyPlayer.spec.js
new file mode 100644
index 00000000000..c5e166d5391
--- /dev/null
+++ b/kolibri/plugins/media_player/frontend/views/__tests__/AudioStickyPlayer.spec.js
@@ -0,0 +1,155 @@
+import { render, screen, fireEvent } from '@testing-library/vue';
+import { ref } from 'vue';
+import videojs from 'video.js';
+import AudioStickyPlayer from '../AudioStickyPlayer';
+import mediaStrings from '../../utils/mediaStrings';
+/* eslint-disable import-x/named */
+import {
+ mockCurrentTime,
+ mockDuration,
+ mockIsPlaying,
+ mockTogglePlay,
+ mockRewind,
+ mockForward,
+ mockToggleMute,
+ mockSetPlaybackRate,
+ resetMocks,
+} from '../../composables/useMediaPlayer';
+/* eslint-enable import-x/named */
+
+jest.mock('../../composables/useMediaPlayer');
+
+const mockWindowIsSmall = ref(false);
+jest.mock('kolibri-design-system/lib/composables/useKResponsiveWindow', () => () => ({
+ windowIsSmall: mockWindowIsSmall,
+}));
+
+jest.mock('video.js', () => ({
+ formatTime: seconds => {
+ const mins = Math.floor(seconds / 60);
+ const secs = Math.floor(seconds % 60);
+ return `${mins}:${secs < 10 ? '0' : ''}${secs}`;
+ },
+}));
+
+const { pause$, replay$, forward$, mute$, playbackRate$, progressBar$ } = mediaStrings;
+
+describe('AudioStickyPlayer', () => {
+ beforeEach(() => {
+ resetMocks();
+ mockCurrentTime.value = 45;
+ mockDuration.value = 300;
+ mockIsPlaying.value = true;
+ mockWindowIsSmall.value = false;
+ });
+
+ describe('desktop layout', () => {
+ it('renders all transport buttons', () => {
+ render(AudioStickyPlayer);
+ expect(screen.getByLabelText(pause$())).toBeInTheDocument();
+ expect(screen.getByLabelText(replay$())).toBeInTheDocument();
+ expect(screen.getByLabelText(forward$())).toBeInTheDocument();
+ });
+
+ it('displays current time and duration', () => {
+ render(AudioStickyPlayer);
+ expect(screen.getByText(videojs.formatTime(mockCurrentTime.value))).toBeInTheDocument();
+ expect(screen.getByText(videojs.formatTime(mockDuration.value))).toBeInTheDocument();
+ });
+
+ it('renders progress slider with ARIA attributes', () => {
+ render(AudioStickyPlayer);
+ const slider = screen.getByRole('slider', { name: progressBar$() });
+ expect(slider).toBeInTheDocument();
+ expect(slider).toHaveAttribute('aria-valuenow', String(mockCurrentTime.value));
+ expect(slider).toHaveAttribute('aria-valuemax', String(mockDuration.value));
+ });
+
+ it('renders playback rate and volume buttons', () => {
+ render(AudioStickyPlayer);
+ expect(screen.getByLabelText(playbackRate$())).toBeInTheDocument();
+ expect(screen.getByLabelText(mute$())).toBeInTheDocument();
+ });
+
+ it('applies sticky-top class on desktop', () => {
+ const { container } = render(AudioStickyPlayer);
+ expect(container.querySelector('.sticky-top')).toBeTruthy();
+ expect(container.querySelector('.sticky-bottom')).toBeNull();
+ });
+ });
+
+ describe('mobile layout', () => {
+ beforeEach(() => {
+ mockWindowIsSmall.value = true;
+ });
+
+ it('applies sticky-bottom class on mobile', () => {
+ const { container } = render(AudioStickyPlayer);
+ expect(container.querySelector('.sticky-bottom')).toBeTruthy();
+ expect(container.querySelector('.sticky-top')).toBeNull();
+ });
+
+ it('renders two-row layout on mobile', () => {
+ const { container } = render(AudioStickyPlayer);
+ expect(container.querySelector('.rows-2')).toBeTruthy();
+ expect(container.querySelector('.rows-1')).toBeNull();
+ });
+
+ it('renders all controls in mobile layout', () => {
+ render(AudioStickyPlayer);
+ expect(screen.getByLabelText(pause$())).toBeInTheDocument();
+ expect(screen.getByLabelText(replay$())).toBeInTheDocument();
+ expect(screen.getByLabelText(forward$())).toBeInTheDocument();
+ expect(screen.getByLabelText(playbackRate$())).toBeInTheDocument();
+ expect(screen.getByLabelText(mute$())).toBeInTheDocument();
+ });
+ });
+
+ describe('interactions', () => {
+ it('calls togglePlay when pause button is clicked', async () => {
+ render(AudioStickyPlayer);
+ await fireEvent.click(screen.getByLabelText(pause$()));
+ expect(mockTogglePlay).toHaveBeenCalledTimes(1);
+ });
+
+ it('calls rewind when rewind button is clicked', async () => {
+ render(AudioStickyPlayer);
+ await fireEvent.click(screen.getByLabelText(replay$()));
+ expect(mockRewind).toHaveBeenCalledTimes(1);
+ });
+
+ it('calls forward when forward button is clicked', async () => {
+ render(AudioStickyPlayer);
+ await fireEvent.click(screen.getByLabelText(forward$()));
+ expect(mockForward).toHaveBeenCalledTimes(1);
+ });
+
+ it('cycles playback rate on click', async () => {
+ render(AudioStickyPlayer);
+ await fireEvent.click(screen.getByLabelText(playbackRate$()));
+ expect(mockSetPlaybackRate).toHaveBeenCalledWith(1.25);
+ });
+
+ it('calls toggleMute when volume button is clicked', async () => {
+ render(AudioStickyPlayer);
+ await fireEvent.click(screen.getByLabelText(mute$()));
+ expect(mockToggleMute).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('keyboard navigation on progress bar', () => {
+ it('seeks forward on ArrowRight key', async () => {
+ render(AudioStickyPlayer);
+ const slider = screen.getByRole('slider', { name: progressBar$() });
+ await fireEvent.keyDown(slider, { key: 'ArrowRight' });
+ expect(mockForward).toHaveBeenCalledWith(5);
+ });
+
+ it('seeks backward on ArrowLeft key', async () => {
+ render(AudioStickyPlayer);
+ const slider = screen.getByRole('slider', { name: progressBar$() });
+ await fireEvent.keyDown(slider, { key: 'ArrowLeft' });
+ expect(mockRewind).toHaveBeenCalledWith(5);
+ });
+ });
+});
diff --git a/kolibri/plugins/media_player/frontend/views/__tests__/MediaPlayerIndex.spec.js b/kolibri/plugins/media_player/frontend/views/__tests__/MediaPlayerIndex.spec.js
deleted file mode 100644
index a2b24b34a0a..00000000000
--- a/kolibri/plugins/media_player/frontend/views/__tests__/MediaPlayerIndex.spec.js
+++ /dev/null
@@ -1,23 +0,0 @@
-import MediaPlayerIndex from '../MediaPlayerIndex';
-
-const { methods } = MediaPlayerIndex;
-
-describe('updateProgress', () => {
- let context = {};
-
- beforeEach(() => {
- context = {
- forceDurationBasedProgress: null,
- $emit: jest.fn(),
- durationBasedProgress: 0.1,
- };
- });
-
- it('should be able to calculate progress using time-based tracking', () => {
- context.forceDurationBasedProgress = true;
- methods.recordProgress.call(context);
-
- expect(context.$emit.mock.calls[0][0]).toBe('updateProgress');
- expect(context.$emit.mock.calls[0][1]).toBe(0.1);
- });
-});
diff --git a/kolibri/plugins/media_player/frontend/views/customButtons.js b/kolibri/plugins/media_player/frontend/views/customButtons.js
index bead6b81813..ab4e2f5b896 100644
--- a/kolibri/plugins/media_player/frontend/views/customButtons.js
+++ b/kolibri/plugins/media_player/frontend/views/customButtons.js
@@ -25,3 +25,14 @@ export class ForwardButton extends videojsButton {
}
ForwardButton.prototype.controlText_ = 'Forward';
+
+const PlayToggle = videojs.getComponent('PlayToggle');
+
+// Centered overlay play/pause toggle — extends PlayToggle so it inherits the
+// play/pause icon switching tied to player state, but adds a distinct class
+// so it can be styled separately from the small toggle in the control bar.
+export class BigPlayToggle extends PlayToggle {
+ buildCSSClass() {
+ return `vjs-big-play-toggle ${super.buildCSSClass()}`;
+ }
+}
diff --git a/kolibri/plugins/media_player/kolibri_plugin.py b/kolibri/plugins/media_player/kolibri_plugin.py
index 25fef8593bb..754534ada8d 100644
--- a/kolibri/plugins/media_player/kolibri_plugin.py
+++ b/kolibri/plugins/media_player/kolibri_plugin.py
@@ -17,3 +17,5 @@ class MediaPlayerAsset(content_hooks.ContentRendererHook):
format_presets.VIDEO_HIGH_RES,
format_presets.VIDEO_LOW_RES,
)
+ css_selectors = ("video", "audio")
+ allow_object_tag = True # Media player can handle object tags for audio/video
diff --git a/kolibri/plugins/media_player/package.json b/kolibri/plugins/media_player/package.json
index b497dd26dc5..10d4a788f9d 100644
--- a/kolibri/plugins/media_player/package.json
+++ b/kolibri/plugins/media_player/package.json
@@ -5,6 +5,7 @@
"version": "0.0.1",
"dependencies": {
"@babel/runtime": "^7.29.2",
+ "@testing-library/vue": "catalog:",
"@vueuse/core": "catalog:",
"frame-throttle": "^3.0.0",
"global": "^4.4.0",
diff --git a/kolibri/plugins/pdf_viewer/frontend/views/PdfRendererIndex.vue b/kolibri/plugins/pdf_viewer/frontend/views/PdfRendererIndex.vue
index 94db28b279f..c93b0c5fa95 100644
--- a/kolibri/plugins/pdf_viewer/frontend/views/PdfRendererIndex.vue
+++ b/kolibri/plugins/pdf_viewer/frontend/views/PdfRendererIndex.vue
@@ -1,120 +1,121 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -131,7 +132,10 @@
import commonCoreStrings from 'kolibri/uiText/commonCoreStrings';
import useKResponsiveWindow from 'kolibri-design-system/lib/composables/useKResponsiveWindow';
import CoreFullscreen from 'kolibri-common/components/CoreFullscreen';
- import useContentViewer, { contentViewerProps } from 'kolibri/composables/useContentViewer';
+ import EmbeddedReadCard from 'kolibri-common/components/EmbeddedReadCard';
+
+ import ViewerToolbar from 'kolibri-common/components/ViewerToolbar';
+ import useContentViewer from 'kolibri/composables/useContentViewer';
import { ref, computed } from 'vue';
import '../utils/domPolyfills';
import { EventBus } from '../utils/event_utils';
@@ -151,7 +155,10 @@
SideBar,
PdfPage,
CoreFullscreen,
+ EmbeddedReadCard,
+
RecyclableScroller,
+ ViewerToolbar,
},
mixins: [commonCoreStrings],
setup(props, context) {
@@ -160,7 +167,14 @@
const defaultDuration = computed(() => {
return totalPages.value ? totalPages.value * 30 : null;
});
- const { defaultFile, reportLoadingError } = useContentViewer(props, context, {
+ const {
+ defaultFile,
+ reportLoadingError,
+ embedded,
+ extraFields,
+ forceDurationBasedProgress,
+ durationBasedProgress,
+ } = useContentViewer(context, {
defaultDuration,
});
return {
@@ -169,9 +183,12 @@
totalPages,
defaultFile,
reportLoadingError,
+ embedded,
+ extraFields,
+ forceDurationBasedProgress,
+ durationBasedProgress,
};
},
- props: contentViewerProps,
data: () => ({
loadingProgress: null,
scale: null,
@@ -190,6 +207,22 @@
outline: null,
}),
computed: {
+ mobileEmbedded() {
+ return this.embedded && this.windowIsSmall && !this.isInFullscreen;
+ },
+ pdfViewerStyle() {
+ const style = {
+ backgroundColor: this.embedded ? this.$themeTokens.surface : this.$themeTokens.text,
+ };
+ if (this.embedded && !this.mobileEmbedded) {
+ // The mobile-embedded preview gets its card border from EmbeddedReadCard.
+ style.border = `1px solid ${this.$themeTokens.fineLine}`;
+ if (!this.windowIsSmall) {
+ style.minHeight = '400px';
+ }
+ }
+ return style;
+ },
// Returns whether or not the current device is iOS.
// Probably not perfect, but worked in testing.
iOS() {
@@ -210,10 +243,7 @@
return this.firstPageHeight * this.scale + MARGIN;
},
savedLocation() {
- if (this.extraFields && this.extraFields.contentState) {
- return this.extraFields.contentState.savedLocation;
- }
- return 0;
+ return this.extraFields?.contentState?.savedLocation ?? 0;
},
savedVisitedPages: {
get() {
@@ -226,9 +256,6 @@
this.visitedPages = value;
},
},
- fullscreenText() {
- return this.isInFullscreen ? this.$tr('exitFullscreen') : this.$tr('enterFullscreen');
- },
debouncedShowVisiblePages() {
// So as not to share debounced functions between instances of the same component
// and also to allow access to the cancel method of the debounced function
@@ -237,6 +264,10 @@
return debounce(this.showVisiblePages, renderDebounceTime);
},
screenSizeMultiplier() {
+ if (this.mobileEmbedded) {
+ // Shrink the rendered pages so the preview fits within the card.
+ return 1.2;
+ }
if (this.windowIsLarge) {
return 1.25;
}
@@ -247,6 +278,14 @@
},
},
watch: {
+ mobileEmbedded() {
+ // screenSizeMultiplier depends on mobileEmbedded; re-derive scale so
+ // pages re-render at the appropriate size when entering/leaving the
+ // preview state.
+ if (this.firstPageWidth) {
+ this.scale = this.$el.clientWidth / (this.firstPageWidth * this.screenSizeMultiplier);
+ }
+ },
recycleListIsMounted(newVal) {
// On iOS pinch zooming always targets the document no matter what.
// meta viewport attrs for `user-scalable` are ignored in iOS because
@@ -331,7 +370,7 @@
const outline = await pdfDocument.getOutline();
this.outline = outline;
- this.showSideBar = outline && outline.length > 0 && this.windowIsLarge; // Remove if other tabs are already implemented
+ this.showSideBar = outline && outline.length > 0 && this.windowIsLarge && !this.embedded; // Remove if other tabs are already implemented
// Reduce the scale slightly if we are showing the sidebar
// at first load.
this.scale = this.showSideBar ? 0.75 * this.scale : this.scale;
@@ -653,18 +692,6 @@
});
},
},
- $trs: {
- exitFullscreen: {
- message: 'Exit fullscreen',
- context:
- "Learners can use the Esc key or the 'exit fullscreen' button to close the fullscreen view on the PDF Viewer.",
- },
- enterFullscreen: {
- message: 'Enter fullscreen',
- context:
- 'Learners can use the full screen button in the upper right corner to open a PDF in fullscreen view.',
- },
- },
};
@@ -673,7 +700,7 @@
diff --git a/packages/kolibri-common/components/EmbeddedReadCard.vue b/packages/kolibri-common/components/EmbeddedReadCard.vue
new file mode 100644
index 00000000000..20623612bc6
--- /dev/null
+++ b/packages/kolibri-common/components/EmbeddedReadCard.vue
@@ -0,0 +1,119 @@
+
+
+
+
+
+
+
+ {{ coreString('read') }}
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/kolibri-common/components/SafeHTML/SafeHtmlImage.vue b/packages/kolibri-common/components/SafeHTML/SafeHtmlImage.vue
index e12cbe5592f..8c1286d33da 100644
--- a/packages/kolibri-common/components/SafeHTML/SafeHtmlImage.vue
+++ b/packages/kolibri-common/components/SafeHTML/SafeHtmlImage.vue
@@ -6,10 +6,10 @@
>
@@ -19,8 +19,6 @@
export default {
name: 'SafeHtmlTable',
- inheritAttrs: false,
-
props: {
node: {
required: true,
diff --git a/packages/kolibri-common/components/SafeHTML/__tests__/SafeHTML.spec.js b/packages/kolibri-common/components/SafeHTML/__tests__/SafeHTML.spec.js
new file mode 100644
index 00000000000..70319050b34
--- /dev/null
+++ b/packages/kolibri-common/components/SafeHTML/__tests__/SafeHTML.spec.js
@@ -0,0 +1,271 @@
+import { render, screen } from '@testing-library/vue';
+
+import kolibri from 'kolibri';
+import { createSafeHTML } from '../index';
+
+jest.mock('kolibri', () => ({
+ canHandleElement: jest.fn(),
+}));
+
+const SafeHTML = createSafeHTML();
+
+const HELLO_WORLD_TEXT = 'Hello World';
+const CONTENT_TEXT = 'Content';
+
+describe('SafeHTML', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ // Default mock: no element can be handled
+ kolibri.canHandleElement = jest.fn().mockReturnValue(false);
+ });
+
+ describe('basic HTML sanitization', () => {
+ it('renders basic HTML content', () => {
+ render(SafeHTML, {
+ props: {
+ html: `${HELLO_WORLD_TEXT}
`,
+ },
+ });
+ expect(screen.getByText(HELLO_WORLD_TEXT)).toBeInTheDocument();
+ });
+
+ it('adds safe-html class to elements', () => {
+ render(SafeHTML, {
+ props: {
+ html: `${CONTENT_TEXT}
`,
+ },
+ });
+ const div = screen.getByText(CONTENT_TEXT);
+ expect(div).toHaveClass('safe-html');
+ });
+
+ it('strips forbidden tags like style', () => {
+ const { container } = render(SafeHTML, {
+ props: {
+ html: 'Content
',
+ },
+ });
+ expect(container.querySelector('style')).not.toBeInTheDocument();
+ });
+
+ it('strips forbidden attributes like style', () => {
+ render(SafeHTML, {
+ props: {
+ html: `${CONTENT_TEXT}
`,
+ },
+ });
+ const div = screen.getByText(CONTENT_TEXT);
+ expect(div).not.toHaveAttribute('style');
+ });
+ });
+
+ describe('object tag handling', () => {
+ it('allows object tags in the output', () => {
+ const { container } = render(SafeHTML, {
+ props: {
+ html: 'PDF ',
+ },
+ });
+ expect(container.querySelector('object')).toBeInTheDocument();
+ });
+
+ it('preserves data attribute on object tags', () => {
+ const { container } = render(SafeHTML, {
+ props: {
+ html: 'PDF ',
+ },
+ });
+ const objectEl = container.querySelector('object');
+ expect(objectEl).toHaveAttribute('data', 'test.pdf');
+ });
+
+ it('preserves type attribute on object tags', () => {
+ const { container } = render(SafeHTML, {
+ props: {
+ html: 'Video ',
+ },
+ });
+ const objectEl = container.querySelector('object');
+ expect(objectEl).toHaveAttribute('type', 'video/mp4');
+ });
+ });
+
+ describe('data attribute sanitization', () => {
+ it('allows valid file URLs in data attribute', () => {
+ const { container } = render(SafeHTML, {
+ props: {
+ html: 'PDF ',
+ },
+ });
+ const objectEl = container.querySelector('object');
+ expect(objectEl).toHaveAttribute('data', '/path/to/file.pdf');
+ });
+
+ it('allows blob URLs in data attribute', () => {
+ const { container } = render(SafeHTML, {
+ props: {
+ html: 'PDF ',
+ },
+ });
+ const objectEl = container.querySelector('object');
+ expect(objectEl).toHaveAttribute('data', 'blob:https://example.com/12345');
+ });
+
+ it('sanitizes javascript URLs in data attribute', () => {
+ const { container } = render(SafeHTML, {
+ props: {
+ html: 'PDF ',
+ },
+ });
+ const objectEl = container.querySelector('object');
+ // DOMPurify should remove or sanitize the dangerous URL
+ expect(objectEl?.getAttribute('data')).not.toBe('javascript:alert(1)');
+ });
+ });
+
+ describe('ContentViewer integration', () => {
+ it('calls canHandleElement for object tags', () => {
+ render(SafeHTML, {
+ props: {
+ html: 'Video ',
+ },
+ });
+ expect(kolibri.canHandleElement).toHaveBeenCalled();
+ });
+
+ it('renders ContentViewer when element can be handled', () => {
+ kolibri.canHandleElement = jest.fn().mockReturnValue(true);
+
+ const { container } = render(SafeHTML, {
+ props: {
+ html: ' ',
+ },
+ });
+
+ // Original should be replaced by the ContentViewer stub.
+ expect(container.querySelector('video')).not.toBeInTheDocument();
+ });
+
+ it('renders original element when canHandleElement returns false', () => {
+ kolibri.canHandleElement = jest.fn().mockReturnValue(false);
+
+ const { container } = render(SafeHTML, {
+ props: {
+ html: ' ',
+ },
+ });
+
+ expect(container.querySelector('video')).toBeInTheDocument();
+ });
+
+ it('checks video elements for ContentViewer handling', () => {
+ render(SafeHTML, {
+ props: {
+ html: ' ',
+ },
+ });
+
+ expect(kolibri.canHandleElement).toHaveBeenCalled();
+ // Verify it was called with a video element
+ const call = kolibri.canHandleElement.mock.calls.find(
+ call => call[0].tagName?.toLowerCase() === 'video',
+ );
+ expect(call).toBeTruthy();
+ });
+
+ it('checks audio elements for ContentViewer handling', () => {
+ render(SafeHTML, {
+ props: {
+ html: ' ',
+ },
+ });
+
+ expect(kolibri.canHandleElement).toHaveBeenCalled();
+ const call = kolibri.canHandleElement.mock.calls.find(
+ call => call[0].tagName?.toLowerCase() === 'audio',
+ );
+ expect(call).toBeTruthy();
+ });
+ });
+
+ describe('allowed origins', () => {
+ it('strips src attributes with absolute URLs by default', () => {
+ const { container } = render(SafeHTML, {
+ props: {
+ html: ' ',
+ },
+ });
+ const audio = container.querySelector('audio');
+ expect(audio).toBeInTheDocument();
+ expect(audio).not.toHaveAttribute('src');
+ });
+
+ it('allows src attributes matching an allowed origin', () => {
+ const SafeHTMLWithOrigins = createSafeHTML({}, { allowedOrigins: ['http://localhost:8000'] });
+ const { container } = render(SafeHTMLWithOrigins, {
+ props: {
+ html: ' ',
+ },
+ });
+ const audio = container.querySelector('audio');
+ expect(audio).toHaveAttribute('src', 'http://localhost:8000/zipcontent/abc123.mp3');
+ });
+
+ it('strips src attributes not matching any allowed origin', () => {
+ const SafeHTMLWithOrigins = createSafeHTML({}, { allowedOrigins: ['http://localhost:8000'] });
+ const { container } = render(SafeHTMLWithOrigins, {
+ props: {
+ html: ' ',
+ },
+ });
+ const audio = container.querySelector('audio');
+ expect(audio).not.toHaveAttribute('src');
+ });
+
+ it('allows data attributes on object tags matching an allowed origin', () => {
+ const SafeHTMLWithOrigins = createSafeHTML({}, { allowedOrigins: ['http://localhost:8000'] });
+ const { container } = render(SafeHTMLWithOrigins, {
+ props: {
+ html: ' ',
+ },
+ });
+ const obj = container.querySelector('object');
+ expect(obj).toHaveAttribute('data', 'http://localhost:8000/zipcontent/doc.pdf');
+ });
+
+ it('still allows blob and data URIs when origins are specified', () => {
+ const SafeHTMLWithOrigins = createSafeHTML({}, { allowedOrigins: ['http://localhost:8000'] });
+ const { container } = render(SafeHTMLWithOrigins, {
+ props: {
+ html: ' ',
+ },
+ });
+ const obj = container.querySelector('object');
+ expect(obj).toHaveAttribute('data', 'blob:https://example.com/12345');
+ });
+
+ it('still allows relative URIs when origins are specified', () => {
+ const SafeHTMLWithOrigins = createSafeHTML({}, { allowedOrigins: ['http://localhost:8000'] });
+ const { container } = render(SafeHTMLWithOrigins, {
+ props: {
+ html: ' ',
+ },
+ });
+ const obj = container.querySelector('object');
+ expect(obj).toHaveAttribute('data', './file.pdf');
+ });
+ });
+
+ describe('semantics tag handling', () => {
+ it('allows semantics tag for MathML content', () => {
+ const { container } = render(SafeHTML, {
+ props: {
+ html: 'x ',
+ },
+ });
+ // Check that semantics tag is in the rendered output
+ expect(container.innerHTML).toContain('semantics');
+ expect(container.innerHTML).toContain(' o.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
+ // Allow the specified origins (with trailing slash/path) in addition to the defaults
+ const origins = escaped.join('|');
+ return new RegExp(
+ `^(?:(?:${origins})/|(?:blob:https?|data):|[^a-z]|[a-z+.-]+(?:[^a-z+.\\-:]|$))`,
+ 'i',
+ );
+}
// Factory function to create SafeHTML with custom component support
-export function createSafeHTML(customComponents = {}) {
+export function createSafeHTML(customComponents = {}, { allowedOrigins } = {}) {
const validProps = Object.keys(customComponents).reduce((acc, tagName) => {
for (const prop of Object.keys(customComponents[tagName].props || {})) {
acc[kebabCase(prop)] = true;
}
return acc;
}, {});
+ const ALLOWED_URI_REGEXP = buildAllowedUriRegexp(allowedOrigins);
return {
name: 'SafeHTML',
functional: true,
@@ -27,6 +47,7 @@ export function createSafeHTML(customComponents = {}) {
},
render(h, context) {
const docFragment = DOMPurify.sanitize(context.props.html, {
+ ADD_ATTR,
ADD_TAGS,
FORBID_TAGS,
ALLOWED_URI_REGEXP,
@@ -44,56 +65,51 @@ export function createSafeHTML(customComponents = {}) {
if (node.nodeType === Node.ELEMENT_NODE) {
const tagName = node.tagName.toLowerCase();
- // Check if this is a custom element
- const CustomComponent = customComponents[tagName];
-
- if (CustomComponent) {
- // Extract attributes and convert to props
- const attrs = {};
- const props = {};
-
- for (const attr of node.attributes) {
- attrs[attr.name] = attr.value;
- const propName = attr.name.replace(/-([a-z])/g, g => g[1].toUpperCase());
- props[propName] = attr.value;
- }
-
- return h(
- CustomComponent,
- {
- props,
- attrs,
- on: context.listeners,
- },
- mapChildren(node.childNodes),
- );
- }
- // Handle regular HTML elements
+ // Extract attributes and convert to props
const attrs = {};
+ const props = {
+ node,
+ };
+
for (const attr of node.attributes) {
attrs[attr.name] = attr.value;
+ const propName = attr.name.replace(/-([a-z])/g, g => g[1].toUpperCase());
+ props[propName] = attr.value;
}
attrs.class = attrs.class ? `${attrs.class} safe-html` : 'safe-html';
- if (tagName === 'table') {
- return h(
- SafeHtmlTable,
+
+ // Check if this is a custom element
+ const component =
+ customComponents[tagName] ||
+ HTMLComponents[tagName] ||
+ (kolibri.canHandleElement(node) ? 'ContentViewer' : null);
+
+ if (component) {
+ const childProps = { ...props };
+ // Mark ContentViewers rendered within SafeHTML as embedded
+ if (component === 'ContentViewer') {
+ childProps.embedded = true;
+ }
+ // Extract class from attrs so Vue merges it with the component's
+ // template class instead of overriding it. In Vue 2, class
+ // inside attrs overrides a component's root element class.
+ const { class: className, ...componentAttrs } = attrs;
+ const childVNode = h(
+ component,
{
- props: { node },
- attrs,
+ class: className,
+ props: childProps,
+ attrs: componentAttrs,
+ on: context.listeners,
},
mapChildren(node.childNodes),
);
- }
-
- if (tagName === 'img') {
- return h(SafeHtmlImage, {
- attrs,
- props: {
- src: attrs.src,
- alt: attrs.alt,
- },
- });
+ // Wrap embedded ContentViewers in a layout container
+ if (component === 'ContentViewer') {
+ return h('div', { class: 'embedded-content-viewer' }, [childVNode]);
+ }
+ return childVNode;
}
return h(tagName, { attrs }, mapChildren(node.childNodes));
diff --git a/packages/kolibri-common/components/SafeHTML/style.scss b/packages/kolibri-common/components/SafeHTML/style.scss
index 5c0b04ba676..d21729a795a 100644
--- a/packages/kolibri-common/components/SafeHTML/style.scss
+++ b/packages/kolibri-common/components/SafeHTML/style.scss
@@ -88,9 +88,7 @@ b.safe-html {
}
.table-container {
- width: calc(100% + 32px);
- padding: 0 16px;
- margin-left: -16px;
+ width: 100%;
overflow-x: auto;
}
@@ -172,6 +170,12 @@ img.safe-html {
opacity: 1;
}
+.embedded-content-viewer {
+ max-width: 960px;
+ margin: 40px auto;
+ overflow: hidden;
+}
+
math.safe-html {
@include text-style(normal, 32px, 130%);
diff --git a/packages/kolibri-common/components/ViewerToolbar.vue b/packages/kolibri-common/components/ViewerToolbar.vue
new file mode 100644
index 00000000000..e079d567a88
--- /dev/null
+++ b/packages/kolibri-common/components/ViewerToolbar.vue
@@ -0,0 +1,97 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/kolibri-common/components/__tests__/ViewerToolbar.spec.js b/packages/kolibri-common/components/__tests__/ViewerToolbar.spec.js
new file mode 100644
index 00000000000..4b9a4ed8f5e
--- /dev/null
+++ b/packages/kolibri-common/components/__tests__/ViewerToolbar.spec.js
@@ -0,0 +1,62 @@
+import { render, screen } from '@testing-library/vue';
+import userEvent from '@testing-library/user-event';
+import { createTranslator } from 'kolibri/utils/i18n';
+import ViewerToolbar from '../ViewerToolbar';
+
+const { enterFullscreen$, exitFullscreen$ } = createTranslator(
+ ViewerToolbar.name,
+ ViewerToolbar.$trs,
+);
+
+function renderToolbar(props = {}, slots = {}) {
+ return render(ViewerToolbar, {
+ props: {
+ isInFullscreen: false,
+ ...props,
+ },
+ slots,
+ });
+}
+
+describe('ViewerToolbar', () => {
+ it('should mount', () => {
+ const { container } = renderToolbar();
+ expect(container.firstChild).toBeTruthy();
+ });
+
+ it('shows enter fullscreen button when not in fullscreen', () => {
+ renderToolbar({ isInFullscreen: false });
+ expect(screen.getByText(enterFullscreen$())).toBeInTheDocument();
+ });
+
+ it('shows exit fullscreen button when in fullscreen', () => {
+ renderToolbar({ isInFullscreen: true });
+ expect(screen.getByText(exitFullscreen$())).toBeInTheDocument();
+ });
+
+ it('shows primary fullscreen button when embedded', () => {
+ renderToolbar({ embedded: true });
+ expect(screen.getByText(enterFullscreen$())).toBeInTheDocument();
+ });
+
+ it('emits toggleFullscreen when fullscreen button is clicked', async () => {
+ const { emitted } = renderToolbar();
+ await userEvent.click(screen.getByText(enterFullscreen$()));
+ expect(emitted().toggleFullscreen).toBeTruthy();
+ });
+
+ it('renders left slot content', () => {
+ renderToolbar({}, { left: 'Left ' });
+ expect(screen.getByTestId('left-content')).toBeInTheDocument();
+ });
+
+ it('renders center slot content', () => {
+ renderToolbar({}, { center: 'Center ' });
+ expect(screen.getByTestId('center-content')).toBeInTheDocument();
+ });
+
+ it('renders right slot content', () => {
+ renderToolbar({}, { right: 'Right ' });
+ expect(screen.getByTestId('right-content')).toBeInTheDocument();
+ });
+});
diff --git a/packages/kolibri-common/components/quizzes/QuizReport/index.vue b/packages/kolibri-common/components/quizzes/QuizReport/index.vue
index 7b5ea850c4a..445a6b4aeae 100644
--- a/packages/kolibri-common/components/quizzes/QuizReport/index.vue
+++ b/packages/kolibri-common/components/quizzes/QuizReport/index.vue
@@ -164,12 +164,10 @@
/>
diff --git a/packages/kolibri-zip/src/fileUtils.js b/packages/kolibri-zip/src/fileUtils.js
index c20cc4e5e94..497f1f4e655 100644
--- a/packages/kolibri-zip/src/fileUtils.js
+++ b/packages/kolibri-zip/src/fileUtils.js
@@ -211,7 +211,7 @@ const domParser = new DOMParser();
const domSerializer = new XMLSerializer();
-const urlAttributes = ['src', 'href'];
+const urlAttributes = ['src', 'href', 'data', 'poster'];
const queryParamRegex = /([^?)]+)?(\?.*)/g;
diff --git a/packages/kolibri/components/__tests__/DownloadButton.spec.js b/packages/kolibri/components/__tests__/DownloadButton.spec.js
index 670ba120359..94672f71188 100644
--- a/packages/kolibri/components/__tests__/DownloadButton.spec.js
+++ b/packages/kolibri/components/__tests__/DownloadButton.spec.js
@@ -1,16 +1,19 @@
import Vue from 'vue';
import { render, screen } from '@testing-library/vue';
import useUser, { useUserMock } from 'kolibri/composables/useUser'; // eslint-disable-line
-import { VIEWER_SUFFIX } from 'kolibri/constants';
+import kolibri from 'kolibri';
import DownloadButton from '../DownloadButton';
jest.mock('kolibri/composables/useUser');
+jest.mock('kolibri');
const getDownloadableFile = (isExercise = false) => {
const PRESET = isExercise ? 'exercise' : 'thumbnail';
- // Register a component with the preset name so that the file is considered renderable
- Vue.component(PRESET + VIEWER_SUFFIX, { template: '
' });
+ // Mock the preset viewer component so that the file is considered renderable
+ kolibri.presetViewerComponent = jest
+ .fn()
+ .mockImplementation(preset => (preset === PRESET ? { template: '
' } : null));
return {
preset: PRESET,
diff --git a/packages/kolibri/components/internal/ContentViewer/ContentViewerError.vue b/packages/kolibri/components/internal/ContentViewer/ContentViewerError.vue
index e3ad5cc292b..85ce8bc269b 100644
--- a/packages/kolibri/components/internal/ContentViewer/ContentViewerError.vue
+++ b/packages/kolibri/components/internal/ContentViewer/ContentViewerError.vue
@@ -45,7 +45,7 @@
},
props: {
error: {
- type: Object,
+ type: [Object, Error],
default: null,
},
files: {
diff --git a/packages/kolibri/components/internal/ContentViewer/__tests__/contentViewer.spec.js b/packages/kolibri/components/internal/ContentViewer/__tests__/contentViewer.spec.js
index 39ce14bee54..dfdd6770c5c 100644
--- a/packages/kolibri/components/internal/ContentViewer/__tests__/contentViewer.spec.js
+++ b/packages/kolibri/components/internal/ContentViewer/__tests__/contentViewer.spec.js
@@ -1,28 +1,24 @@
-import Vue from 'vue';
-import { VIEWER_SUFFIX } from 'kolibri/constants';
-import { canRenderContent, getRenderableFiles, getDefaultFile, getFilePreset } from '../utils';
+// Mock kolibri before importing utils that depend on it
+import kolibri from 'kolibri';
+import { getRenderableFiles, getDefaultFile, getFilePreset } from '../utils';
-// Add a component to the Vue instance that can be used to test the utility functions
+jest.mock('kolibri', () => ({
+ default: { presetViewerComponent: jest.fn() },
+ __esModule: true,
+}));
+
+// Mock the preset viewer components so they can be used to test the utility functions
const addRegisterableComponents = (...presets) => {
- presets.forEach(preset => {
- Vue.component(preset + VIEWER_SUFFIX, { template: '
' });
- });
+ kolibri.presetViewerComponent.mockImplementation(preset =>
+ presets.includes(preset) ? { template: '
' } : null,
+ );
};
describe('Utility Functions', () => {
beforeEach(() => {
- Vue.options.components = {};
- });
-
- describe('canRenderContent', () => {
- it('returns true if preset viewer component is registered', () => {
- addRegisterableComponents('preset1');
- expect(canRenderContent('preset1')).toBe(true);
- });
-
- it('returns false if preset viewer component is not registered', () => {
- expect(canRenderContent('preset2')).toBe(false);
- });
+ jest.clearAllMocks();
+ // Reset mock to return null by default (no viewer registered)
+ kolibri.presetViewerComponent.mockReturnValue(null);
});
describe('getRenderableFiles', () => {
diff --git a/packages/kolibri/components/internal/ContentViewer/index.js b/packages/kolibri/components/internal/ContentViewer/index.js
index 5d680b85551..7c8a2a3c49f 100644
--- a/packages/kolibri/components/internal/ContentViewer/index.js
+++ b/packages/kolibri/components/internal/ContentViewer/index.js
@@ -1,8 +1,65 @@
-import { VIEWER_SUFFIX } from 'kolibri/constants';
+import { h, ref, onErrorCaptured, provide, computed } from 'vue';
import heartbeat from 'kolibri/heartbeat';
-import { canRenderContent, getRenderableFiles, getDefaultFile, getFilePreset } from './utils';
+import kolibri from 'kolibri';
+import { defaultLanguage } from 'kolibri/utils/i18n';
+import { inferPresetFromMimetype } from 'kolibri/utils/mimetypes';
+import { validateObject } from 'kolibri/utils/objectSpecs';
+import { getRenderableFiles, getDefaultFile, getFilePreset } from './utils';
import ContentViewerError from './ContentViewerError';
+export const CONTENT_VIEWER_CONTEXT_KEY = Symbol('contentViewerContext');
+
+// Module-level counter for unique viewer IDs
+let viewerIdCounter = 0;
+
+const langSpec = {
+ id: { type: String, required: true },
+ lang_name: { type: String, required: true },
+ lang_direction: { type: String, required: true },
+};
+
+const fileSpec = {
+ id: { type: String, required: true },
+ storage_url: { type: String, required: true },
+ extension: { type: String, required: true },
+ available: { type: Boolean, required: true },
+ file_size: { type: Number, required: true },
+ checksum: { type: String, required: true },
+ preset: { type: String, required: true },
+ lang: {
+ type: Object,
+ default: null,
+ spec: langSpec,
+ },
+ supplementary: { type: Boolean, required: true },
+ thumbnail: { type: Boolean, required: true },
+};
+
+const contentNodeSpec = {
+ files: {
+ type: Array,
+ required: true,
+ spec: {
+ type: Object,
+ required: true,
+ spec: fileSpec,
+ },
+ },
+ lang: {
+ type: Object,
+ default: null,
+ spec: langSpec,
+ },
+ options: {
+ type: Object,
+ default: () => ({}),
+ },
+ duration: {
+ type: Number,
+ default: null,
+ },
+};
+
const interactionEvents = [
'answerGiven',
'hintTaken',
@@ -16,55 +73,248 @@ const interactionEvents = [
'finished',
];
-function combineEventListeners(existing, newListener) {
- if (!existing) {
- return newListener;
+/**
+ * Combines event listeners and appends a viewerId to all event arguments.
+ * This allows parent components to track which ContentViewer emitted the event.
+ *
+ * @param {Function|Array|undefined} existing - Existing listener(s) from context
+ * @param {Function} heartbeatListener - The heartbeat.setUserActive listener
+ * @param {string} viewerId - Unique identifier for this ContentViewer instance
+ * @returns {Function} Combined listener that calls heartbeat and existing listeners with viewerId
+ */
+function combineEventListenersWithViewerId(existing, heartbeatListener, viewerId) {
+ return (...args) => {
+ // Always call heartbeat
+ heartbeatListener();
+
+ // Call existing listeners with viewerId appended to args
+ if (existing) {
+ if (Array.isArray(existing)) {
+ existing.forEach(fn => fn(...args, viewerId));
+ } else {
+ existing(...args, viewerId);
+ }
+ }
+ };
+}
+
+function getComponent(node, defaultItemPreset) {
+ // Check if we have a DOM node prop
+ const domComponent = kolibri.elementViewerComponent(node);
+ if (domComponent) {
+ return domComponent;
}
- if (Array.isArray(existing)) {
- return [...existing, newListener];
+ if (defaultItemPreset) {
+ return kolibri.presetViewerComponent(defaultItemPreset);
}
- return [existing, newListener];
+ return null;
}
export default {
- functional: true,
- render: function (createElement, context) {
- const defaultItemPreset = getFilePreset(
- getDefaultFile(getRenderableFiles(context.props.files || [])),
- context.props.preset,
+ name: 'ContentViewer',
+ props: {
+ // -- Content structure --
+ // What content to render and how to resolve the viewer component.
+ contentNode: {
+ type: Object,
+ default: null,
+ validator: contentNode => validateObject(contentNode, contentNodeSpec),
+ },
+ node: {
+ type: HTMLElement,
+ default: null,
+ },
+ itemData: {
+ default: null,
+ },
+ preset: {
+ default: null,
+ type: String,
+ },
+
+ // -- Session state --
+ // Progress tracking state, relayed from the caller's useProgressTracking composable.
+ progress: {
+ type: Number,
+ default: 0,
+ },
+ timeSpent: {
+ type: Number,
+ default: 0,
+ },
+ extraFields: {
+ type: Object,
+ default: () => ({}),
+ },
+ answerState: {
+ type: Object,
+ default: () => ({}),
+ },
+
+ // -- User data --
+ // Identifies the user interacting with or being reviewed for this content.
+ // Not necessarily the logged-in user (e.g. coach reviewing a learner's work).
+ userId: {
+ type: String,
+ default: '',
+ },
+ userFullName: {
+ type: String,
+ default: '',
+ },
+
+ // -- Rendering context --
+ // Per-call-site configuration that controls how the viewer behaves.
+ itemId: {
+ type: String,
+ },
+ interactive: {
+ type: Boolean,
+ default: true,
+ },
+ showCorrectAnswer: {
+ type: Boolean,
+ default: false,
+ },
+ allowHints: {
+ type: Boolean,
+ default: true,
+ },
+ embedded: {
+ type: Boolean,
+ default: false,
+ },
+ },
+ setup(props, context) {
+ const error = ref(null);
+
+ // Generate unique viewer ID for this instance
+ const viewerId = `content-viewer-${++viewerIdCounter}`;
+
+ const _customExtractors = ref(null);
+
+ // Computed values that combine multiple prop sources
+ const files = computed(() => {
+ // If node prop is provided and can be handled, extract files from it
+ if (props.node && kolibri.canHandleElement(props.node)) {
+ // Check for custom extractors first (keyed by CSS selector)
+ for (const [selector, extractor] of Object.entries(_customExtractors.value || {})) {
+ if (props.node.matches(selector)) {
+ const extractedFiles = extractor(props.node);
+ if (extractedFiles && extractedFiles.length > 0) {
+ return extractedFiles;
+ }
+ }
+ }
+
+ // Default handling for elements
+ if (props.node.matches('object[data][type]')) {
+ const mimeType = props.node.getAttribute('type');
+ return [
+ {
+ storage_url: props.node.getAttribute('data'),
+ preset: inferPresetFromMimetype(mimeType),
+ available: true,
+ supplementary: false,
+ thumbnail: false,
+ priority: 1,
+ },
+ ];
+ }
+ }
+ // Otherwise use the content node files
+ return props.contentNode?.files || [];
+ });
+
+ const defaultFile = computed(() => {
+ return getDefaultFile(getRenderableFiles(files.value));
+ });
+
+ const defaultItemPreset = computed(() =>
+ getFilePreset(getDefaultFile(getRenderableFiles(files.value)), props.preset),
);
- if (canRenderContent(defaultItemPreset)) {
+
+ const options = computed(() => {
+ return props.contentNode?.options || {};
+ });
+
+ const lang = computed(() => {
+ return props.contentNode?.lang || defaultLanguage;
+ });
+
+ const duration = computed(() => {
+ return props.contentNode?.duration || null;
+ });
+
+ function setCustomExtractors(extractors) {
+ _customExtractors.value = extractors;
+ }
+
+ // Provide props context to descendant components via useContentViewer
+ const contentViewerContext = {
+ contentNode: computed(() => props.contentNode),
+ node: computed(() => props.node),
+ files,
+ defaultFile,
+ itemData: computed(() => props.itemData),
+ itemId: computed(() => props.itemId),
+ answerState: computed(() => props.answerState),
+ showCorrectAnswer: computed(() => props.showCorrectAnswer),
+ interactive: computed(() => props.interactive),
+ lang,
+ options,
+ extraFields: computed(() => props.extraFields),
+ userId: computed(() => props.userId),
+ allowHints: computed(() => props.allowHints),
+ timeSpent: computed(() => props.timeSpent),
+ duration,
+ userFullName: computed(() => props.userFullName),
+ progress: computed(() => props.progress),
+ embedded: computed(() => props.embedded),
+ setCustomExtractors,
+ };
+
+ provide(CONTENT_VIEWER_CONTEXT_KEY, contentViewerContext);
+
+ onErrorCaptured(err => {
+ // Error boundary - catches uncaught errors from child renderer components
+ error.value = err;
+ context.emit('error', err);
+ });
+
+ return () => {
+ const component = getComponent(props.node, defaultItemPreset.value);
+ // If we caught an error, show error component
+ if (error.value || !component) {
+ return h(ContentViewerError, {
+ props: {
+ error: error.value
+ ? error.value
+ : new Error('No compatible viewer found for this content.'),
+ files: files.value,
+ },
+ });
+ }
+
const combinedListeners = {
...context.listeners,
- ...context.data.on,
};
for (const event of interactionEvents) {
- combinedListeners[event] = combineEventListeners(
+ combinedListeners[event] = combineEventListenersWithViewerId(
combinedListeners[event],
heartbeat.setUserActive,
+ viewerId,
);
}
-
- const safeAttrs = {};
- for (const [key, value] of Object.entries(context.data.attrs || {})) {
- if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
- safeAttrs[key] = value;
- }
- }
-
- return createElement(
- defaultItemPreset + VIEWER_SUFFIX,
+ return h(
+ component,
{
- ...context.data,
- attrs: safeAttrs,
- props: context.props,
on: combinedListeners,
},
- context.children,
+ context.slots.default,
);
- }
- return createElement(ContentViewerError);
+ };
},
};
diff --git a/packages/kolibri/components/internal/ContentViewer/utils.js b/packages/kolibri/components/internal/ContentViewer/utils.js
index 6609e1ef1e2..d79165addb8 100644
--- a/packages/kolibri/components/internal/ContentViewer/utils.js
+++ b/packages/kolibri/components/internal/ContentViewer/utils.js
@@ -1,15 +1,18 @@
-import Vue from 'vue';
-import { VIEWER_SUFFIX } from 'kolibri/constants';
-
-export const canRenderContent = preset => Boolean(Vue.options.components[preset + VIEWER_SUFFIX]);
+// Deferred import to avoid circular dependency during module loading:
+// ContentViewer/utils → kolibri → pluginMediator → ContentViewerError
+// → DownloadButton → ContentViewer/utils
+const getKolibri = () => require('kolibri').default;
export const getRenderableFiles = files =>
files.filter(
file =>
- !file.thumbnail && !file.supplementary && file.available && canRenderContent(file.preset),
+ !file.thumbnail &&
+ !file.supplementary &&
+ file.available &&
+ getKolibri().presetViewerComponent(file.preset),
);
export const getDefaultFile = files => (files && files.length ? files[0] : undefined);
export const getFilePreset = (file, preset) =>
- file ? file.preset : canRenderContent(preset) ? preset : null;
+ file ? file.preset : getKolibri().presetViewerComponent(preset) ? preset : null;
diff --git a/packages/kolibri/composables/__mocks__/useContentViewer.js b/packages/kolibri/composables/__mocks__/useContentViewer.js
new file mode 100644
index 00000000000..58c0e534039
--- /dev/null
+++ b/packages/kolibri/composables/__mocks__/useContentViewer.js
@@ -0,0 +1,85 @@
+/**
+ * `useContentViewer` composable function mock.
+ *
+ * If default values are sufficient for tests,
+ * you only need call `jest.mock('')`
+ * at the top of a test file.
+ *
+ * If you need to override some default values from some tests,
+ * you can import a helper function `useContentViewerMock` that accepts
+ * an object with values to be overriden and use it together
+ * with `mockImplementation` as follows:
+ *
+ * ```
+ * // eslint-disable-next-line import-x/named
+ * import useContentViewer, { useContentViewerMock } from '';
+ *
+ * jest.mock('')
+ *
+ * it('test', () => {
+ * useContentViewer.mockImplementation(
+ * () => useContentViewerMock({ defaultFile: { storage_url: 'foo' } })
+ * );
+ * })
+ * ```
+ *
+ * You can reset your mock implementation back to default values
+ * for other tests by calling the following in `beforeEach`:
+ *
+ * ```
+ * useContentViewer.mockImplementation(() => useContentViewerMock())
+ * ```
+ */
+import { ref } from 'vue';
+
+const MOCK_DEFAULTS = {
+ files: [],
+ options: {},
+ lang: null,
+ duration: null,
+ forceDurationBasedProgress: false,
+ durationBasedProgress: null,
+ defaultFile: null,
+ supplementaryFiles: [],
+ thumbnailFiles: [],
+ contentDirection: 'ltr',
+ contentIsRtl: false,
+ availableHints: 0,
+ totalHints: 0,
+ itemData: null,
+ itemId: null,
+ answerState: null,
+ allowHints: false,
+ extraFields: { contentState: {} },
+ interactive: false,
+ showCorrectAnswer: false,
+ timeSpent: 0,
+ userId: null,
+ userFullName: '',
+ progress: 0,
+ embedded: false,
+};
+
+const MOCK_METHODS = {
+ checkAnswer: jest.fn(() => null),
+ takeHint: jest.fn(() => null),
+ reportError: jest.fn(),
+ reportLoadingError: jest.fn(),
+};
+
+export function useContentViewerMock(overrides = {}) {
+ const mocks = {
+ ...MOCK_DEFAULTS,
+ ...overrides,
+ };
+ const refs = {};
+ for (const key in mocks) {
+ refs[key] = ref(mocks[key]);
+ }
+ return {
+ ...refs,
+ ...MOCK_METHODS,
+ };
+}
+
+export default jest.fn(() => useContentViewerMock());
diff --git a/packages/kolibri/composables/useContentViewer.js b/packages/kolibri/composables/useContentViewer.js
index e6909bdd198..5e762ca6786 100644
--- a/packages/kolibri/composables/useContentViewer.js
+++ b/packages/kolibri/composables/useContentViewer.js
@@ -1,184 +1,225 @@
-import Vue, { ref, computed, getCurrentInstance } from 'vue';
+import { computed, inject } from 'vue';
import { get } from '@vueuse/core';
import logger from 'kolibri-logging';
import { ContentErrorConstants } from 'kolibri/constants';
-import {
- defaultLanguage,
- languageValidator,
- getContentLangDir,
- languageDirections,
-} from 'kolibri/utils/i18n';
+import { getContentLangDir, languageDirections } from 'kolibri/utils/i18n';
import { getRenderableFiles, getDefaultFile } from '../components/internal/ContentViewer/utils';
-import ContentViewerError from '../components/internal/ContentViewer/ContentViewerError';
+import { CONTENT_VIEWER_CONTEXT_KEY } from '../components/internal/ContentViewer';
const logging = logger.getLogger(__filename);
-const ContentViewerErrorComponent = Vue.extend(ContentViewerError);
-
-const fileFieldMap = {
- storage_url: {
- type: String,
- },
- id: {
- type: String,
- },
- priority: {
- type: Number,
- },
- available: {
- type: Boolean,
- },
- file_size: {
- type: Number,
- },
- checksum: {
- type: String,
- },
- extension: {
- type: String,
- },
- preset: {
- type: String,
- },
- lang: {
- type: Object,
- validator: lang => lang === null || languageValidator(lang),
- },
- supplementary: {
- type: Boolean,
- },
- thumbnail: {
- type: Boolean,
- },
-};
-
-function fileValidator(file) {
- let result = true;
- for (const key in fileFieldMap) {
- const val =
- typeof file[key] !== 'undefined' &&
- typeof file[key] === typeof fileFieldMap[key].type() &&
- (fileFieldMap[key].validator ? fileFieldMap[key].validator(file[key]) : true);
- if (!val) {
- logging.error(`Validation failed for '${key}' in `, file);
- result = false;
- }
+/**
+ * Composable for content viewer components.
+ *
+ * This composable provides access to content viewer state and utilities for
+ * viewer components that are rendered within a ContentViewer wrapper.
+ *
+ * The ContentViewer wrapper component is responsible for:
+ * - Resolving the appropriate viewer component based on content type
+ * - Extracting files from either contentNode props or DOM elements
+ * - Providing a unified context to all descendant viewer components
+ *
+ * @param {Object} context - The Vue component context object
+ * @param {Function} context.emit - The component's emit function for emitting events
+ *
+ * @param {Object} [options={}] - Configuration options
+ *
+ * @param {import('vue').Ref|number|null} [options.defaultDuration=null] - Default duration for
+ * duration-based progress calculation. Can be a Vue ref or a static number. Used when the
+ * content doesn't have an explicit duration set.
+ *
+ * @param {Object.} [options.customExtractors={}] - Custom file extractors
+ * for DOM-based viewing. These extractors are called when the ContentViewer receives
+ * a DOM node prop instead of files.
+ *
+ * The object keys are CSS selectors, and the values are extractor functions.
+ * When the ContentViewer has a node prop that matches a selector, the
+ * corresponding extractor function is called to extract file objects from the element.
+ *
+ * @example
+ * // Example: Custom extractors for video and audio elements
+ * const customExtractors = {
+ * 'video': (element) => {
+ * const files = [];
+ * if (element.src) {
+ * files.push({
+ * storage_url: element.src,
+ * preset: 'high_res_video',
+ * available: true,
+ * supplementary: false,
+ * thumbnail: false,
+ * });
+ * }
+ * // Extract from children
+ * for (const source of element.querySelectorAll('source')) {
+ * files.push({
+ * storage_url: source.src,
+ * preset: 'high_res_video',
+ * available: true,
+ * });
+ * }
+ * return files;
+ * },
+ * 'audio': (element) => {
+ * // Similar extraction for audio elements
+ * return [{ storage_url: element.src, preset: 'audio', available: true }];
+ * },
+ * };
+ *
+ * // Usage in a viewer component's setup function
+ * setup(props, context) {
+ * const { files, defaultFile } = useContentViewer(context, {
+ * customExtractors,
+ * defaultDuration: 300, // 5 minutes
+ * });
+ * // ...
+ * }
+ *
+ * @returns {Object} Content viewer state and utilities
+ *
+ * @returns {import('vue').ComputedRef} returns.files - All available files for this content
+ *
+ * @returns {import('vue').ComputedRef} returns.defaultFile - The primary file
+ * for viewing
+ *
+ * @returns {import('vue').ComputedRef} returns.supplementaryFiles - Supplementary files
+ * (e.g., subtitles, transcripts)
+ *
+ * @returns {import('vue').ComputedRef} returns.thumbnailFiles - Thumbnail/poster image files
+ *
+ * @returns {import('vue').ComputedRef} returns.options - Content-specific options
+ *
+ * @returns {import('vue').ComputedRef} returns.lang - Language information for the content
+ *
+ * @returns {import('vue').ComputedRef} returns.duration - Content duration in seconds
+ *
+ * @returns {import('vue').ComputedRef} returns.forceDurationBasedProgress - Whether to
+ * force duration-based progress calculation
+ *
+ * @returns {import('vue').ComputedRef} returns.durationBasedProgress - Progress
+ * calculated from timeSpent / duration
+ *
+ * @returns {import('vue').ComputedRef} returns.contentDirection - Text direction
+ * ('ltr' or 'rtl')
+ *
+ * @returns {import('vue').ComputedRef} returns.contentIsRtl - Whether content is
+ * right-to-left
+ *
+ * @returns {Function} returns.reportError - Report an error to the parent ContentViewer
+ *
+ * @returns {Function} returns.reportLoadingError - Report a loading-specific error
+ *
+ * @returns {import('vue').ComputedRef} returns.itemData - Raw item data (for exercises/assessments)
+ *
+ * @returns {import('vue').ComputedRef} returns.itemId - Item identifier
+ *
+ * @returns {import('vue').ComputedRef} returns.answerState - Current answer state
+ *
+ * @returns {import('vue').ComputedRef} returns.interactive - Whether interaction
+ * is allowed
+ *
+ * @returns {import('vue').ComputedRef} returns.showCorrectAnswer - Whether to show
+ * correct answers
+ *
+ * @returns {import('vue').ComputedRef} returns.allowHints - Whether hints are allowed
+ *
+ * @returns {import('vue').ComputedRef} returns.extraFields - Additional metadata fields
+ *
+ * @returns {import('vue').ComputedRef} returns.userId - Current user ID
+ *
+ * @returns {import('vue').ComputedRef} returns.userFullName - Current user's full name
+ *
+ * @returns {import('vue').ComputedRef} returns.timeSpent - Time spent on this content
+ * (seconds)
+ *
+ * @returns {import('vue').ComputedRef} returns.progress - Current progress (0-1)
+ *
+ * @throws {Error} If called outside of a ContentViewer component hierarchy
+ *
+ * @example
+ * // Basic usage in a viewer component
+ * import useContentViewer from 'kolibri/composables/useContentViewer';
+ *
+ * export default {
+ * name: 'MyViewer',
+ * setup(props, context) {
+ * const {
+ * defaultFile,
+ * files,
+ * reportLoadingError,
+ * contentDirection,
+ * } = useContentViewer(context);
+ *
+ * return {
+ * defaultFile,
+ * files,
+ * reportLoadingError,
+ * contentDirection,
+ * };
+ * },
+ * };
+ */
+export default function useContentViewer(
+ { emit },
+ { defaultDuration = null, customExtractors = {} } = {},
+) {
+ // Inject props from ContentViewer
+ const injectedContext = inject(CONTENT_VIEWER_CONTEXT_KEY);
+
+ if (!injectedContext) {
+ throw new Error(
+ 'useContentViewer must be called within a component that is a descendant of ContentViewer',
+ );
}
- return result;
-}
-function multipleFileValidator(files) {
- return files.reduce((acc, file) => acc && fileValidator(file), true);
-}
+ const {
+ files,
+ itemData,
+ itemId,
+ answerState,
+ showCorrectAnswer,
+ interactive,
+ lang,
+ options,
+ extraFields,
+ userId,
+ allowHints,
+ timeSpent,
+ duration,
+ userFullName,
+ progress,
+ embedded,
+ setCustomExtractors,
+ } = injectedContext;
+
+ setCustomExtractors(customExtractors);
-export const contentViewerProps = {
- files: {
- type: Array,
- default: () => [],
- validator: multipleFileValidator,
- },
- // As an alternative to passing a file object to set the state of the
- // content viewer, can also pass raw itemData (which will be parsed by
- // the viewer if there are no files or file object).
- // The type could depend on the viewer, so we enforce nothing here
- // except a null default.
- itemData: {
- default: null,
- },
- // If just itemData is passed, we have no mechanism for knowing the preset
- // of the data, and hence which viewer to choose. If itemData is utilized
- // the preset must be explicitly set.
- preset: {
- default: null,
- type: String,
- },
- itemId: {
- type: String,
- },
- answerState: {
- type: Object,
- default: () => ({}),
- },
- allowHints: {
- type: Boolean,
- default: true,
- },
- extraFields: {
- type: Object,
- default: () => ({}),
- },
- options: {
- type: Object,
- default: () => ({}),
- },
- // Allow content viewers to display in a static mode
- // where user interaction is not allowed
- interactive: {
- type: Boolean,
- default: true,
- },
- lang: {
- type: Object,
- default: () => defaultLanguage,
- validator: languageValidator,
- },
- showCorrectAnswer: {
- type: Boolean,
- default: false,
- },
- timeSpent: {
- type: Number,
- default: 0,
- },
- duration: {
- type: Number,
- default: null,
- },
- userId: {
- type: String,
- default: '',
- },
- userFullName: {
- type: String,
- default: '',
- },
- progress: {
- type: Number,
- default: 0,
- },
-};
-
-export default function useContentViewer(props, { emit }, { defaultDuration = null } = {}) {
- const instance = getCurrentInstance();
- const _resourceError = ref(null);
-
- // Computed properties
const forceDurationBasedProgress = computed(() => {
- return props.options.force_duration_based_progress || false;
+ return options.value.force_duration_based_progress || false;
});
const durationBasedProgress = computed(() => {
- const duration = props.duration || get(defaultDuration);
- if (!duration) {
+ const dur = duration.value || get(defaultDuration);
+ if (!dur) {
return null;
}
- return props.timeSpent / duration;
+ return timeSpent.value / dur;
});
const defaultFile = computed(() => {
- return getDefaultFile(getRenderableFiles(props.files));
+ return getDefaultFile(getRenderableFiles(files.value));
});
const supplementaryFiles = computed(() => {
- return props.files.filter(file => file.supplementary && file.available);
+ return files.value.filter(file => file.supplementary && file.available);
});
const thumbnailFiles = computed(() => {
- return props.files.filter(file => file.thumbnail && file.available);
+ return files.value.filter(file => file.thumbnail && file.available);
});
const contentDirection = computed(() => {
- return getContentLangDir(props.lang);
+ return getContentLangDir(lang.value);
});
const contentIsRtl = computed(() => {
@@ -204,20 +245,8 @@ export default function useContentViewer(props, { emit }, { defaultDuration = nu
return null;
};
- let _errorComponent;
-
const reportError = error => {
emit('error', error);
- _resourceError.value = error;
- if (!_errorComponent) {
- const domNode = document.createElement('div');
- instance.$el.prepend(domNode);
- _errorComponent = new ContentViewerErrorComponent({
- el: domNode,
- parent: instance,
- propsData: { error: _resourceError, files: props.files },
- });
- }
};
const reportLoadingError = error => {
@@ -228,7 +257,10 @@ export default function useContentViewer(props, { emit }, { defaultDuration = nu
};
return {
- _resourceError,
+ files,
+ options,
+ lang,
+ duration,
forceDurationBasedProgress,
durationBasedProgress,
defaultFile,
@@ -242,5 +274,17 @@ export default function useContentViewer(props, { emit }, { defaultDuration = nu
takeHint,
reportLoadingError,
reportError,
+ itemData,
+ itemId,
+ answerState,
+ allowHints,
+ extraFields,
+ interactive,
+ showCorrectAnswer,
+ timeSpent,
+ userId,
+ userFullName,
+ progress,
+ embedded,
};
}
diff --git a/packages/kolibri/constants.js b/packages/kolibri/constants.js
index d5085a3e64f..8783e26e872 100644
--- a/packages/kolibri/constants.js
+++ b/packages/kolibri/constants.js
@@ -214,8 +214,6 @@ export const MAX_QUESTIONS_PER_QUIZ_SECTION = 25;
export const DisconnectionErrorCodes = [0, 502, 504, 511];
-export const VIEWER_SUFFIX = '_viewer';
-
// enum identifying the types of setup for Lod devices
export const LodTypePresets = Object.freeze({
JOIN: 'JOIN',
diff --git a/packages/kolibri/internal/apiSpec.js b/packages/kolibri/internal/apiSpec.js
index a522cc510e4..89ff6bbe366 100644
--- a/packages/kolibri/internal/apiSpec.js
+++ b/packages/kolibri/internal/apiSpec.js
@@ -59,6 +59,7 @@ export default {
'kolibri/utils/baseClient': require('kolibri/utils/baseClient'),
'kolibri/utils/browserInfo': require('kolibri/utils/browserInfo'),
'kolibri/utils/i18n': require('kolibri/utils/i18n'),
+ 'kolibri/utils/mimetypes': require('kolibri/utils/mimetypes'),
'kolibri/utils/objectSpecs': require('kolibri/utils/objectSpecs'),
'kolibri/utils/onboardingSteps': require('kolibri/utils/onboardingSteps'),
'kolibri/utils/redirectBrowser': require('kolibri/utils/redirectBrowser'),
diff --git a/packages/kolibri/internal/pluginMediator.js b/packages/kolibri/internal/pluginMediator.js
index 361d1479a37..e32aebd2332 100644
--- a/packages/kolibri/internal/pluginMediator.js
+++ b/packages/kolibri/internal/pluginMediator.js
@@ -1,12 +1,14 @@
import Vue from 'vue';
import logging from 'kolibri-logging';
import scriptLoader from 'kolibri/utils/scriptLoader';
-import { VIEWER_SUFFIX } from 'kolibri/constants';
import { languageDirections, currentLanguage } from 'kolibri/utils/i18n';
import { rtlManager } from 'kolibri/rtlcss';
import ContentViewerLoading from '../components/internal/ContentViewer/ContentViewerLoading';
import ContentViewerError from '../components/internal/ContentViewer/ContentViewerError';
+const VIEWER_SUFFIX = '_viewer';
+const DOM_VIEWER_SUFFIX = '_dom_viewer';
+
const logger = logging.getLogger(__filename);
/**
@@ -20,6 +22,9 @@ const publicMethods = [
'registerContentViewer',
'loadDirectionalCSS',
'ready',
+ 'presetViewerComponent',
+ 'elementViewerComponent',
+ 'canHandleElement',
];
const domParser = new DOMParser();
@@ -160,6 +165,27 @@ export default function pluginMediatorFactory(facade) {
}
delete this._languageAssetRegistry;
},
+ _registerViewerType(kolibriModuleName, items, suffix) {
+ for (const item of items) {
+ const registryKey = item + suffix;
+ if (!this._contentViewerRegistry[suffix]) {
+ this._contentViewerRegistry[suffix] = {};
+ }
+ const registry = this._contentViewerRegistry[suffix];
+ if (registry[item]) {
+ logger.warn(`Kolibri Modules: Two content viewers are registering for ${registryKey}`);
+ continue;
+ }
+ registry[item] = () => ({
+ component: this.retrieveContentViewer(kolibriModuleName),
+ loading: ContentViewerLoading,
+ error: ContentViewerError,
+ delay: 0,
+ timeout: 30000,
+ });
+ }
+ },
+
/**
* A method for registering content viewers for asynchronous loading and track
* which file types we have registered viewers for.
@@ -167,31 +193,13 @@ export default function pluginMediatorFactory(facade) {
* @param {String[]} kolibriModuleUrls the URLs of the Javascript
* files that constitute the kolibriModule
* @param {String[]} contentPresets the names of presets this content viewer can render
+ * @param {String[]} domTags the names of DOM tags this content viewer can handle
*/
- registerContentViewer(kolibriModuleName, kolibriModuleUrls, contentPresets) {
+ registerContentViewer(kolibriModuleName, kolibriModuleUrls, contentPresets = [], domTags = []) {
this._contentViewerUrls[kolibriModuleName] = kolibriModuleUrls;
- contentPresets.forEach(preset => {
- if (this._contentViewerRegistry[preset]) {
- logger.warn(`Kolibri Modules: Two content viewers are registering for ${preset}`);
- } else {
- this._contentViewerRegistry[preset] = kolibriModuleName;
- Vue.component(preset + VIEWER_SUFFIX, () => ({
- /* Check the Kolibri core app for a content viewer module that is able to
- * handle the rendering of the current content node.
- */
- component: this.retrieveContentViewer(preset),
- // A component to use while the async component is loading
- loading: ContentViewerLoading,
- // A component to use if the load fails
- error: ContentViewerError,
- // Delay before showing the loading component.
- delay: 0,
- // The error component will be displayed if a timeout is
- // provided and exceeded.
- timeout: 30000,
- }));
- }
- });
+
+ this._registerViewerType(kolibriModuleName, contentPresets, VIEWER_SUFFIX);
+ this._registerViewerType(kolibriModuleName, domTags, DOM_VIEWER_SUFFIX);
},
/**
@@ -206,9 +214,7 @@ export default function pluginMediatorFactory(facade) {
const moduleName = element.getAttribute('data-viewer');
try {
const data = JSON.parse(decodeMarkedSafeText(element.innerHTML.trim()));
- const presets = data.presets;
- const urls = data.urls;
- this.registerContentViewer(moduleName, urls, presets);
+ this.registerContentViewer(moduleName, data.urls, data.presets, data.css_selectors);
} catch (e) {
logger.error(`Error parsing content viewer for ${moduleName}`);
}
@@ -217,24 +223,22 @@ export default function pluginMediatorFactory(facade) {
/**
* A method to retrieve a content viewer component.
- * @param {String} preset content preset
- * @return {Promise} Promise that resolves with loaded content viewer Vue component
+ * @param {String} kolibriModuleName name of the module.
+ * @return {Promise} Promise that resolves with loaded viewer component
*/
- retrieveContentViewer(preset) {
+ retrieveContentViewer(kolibriModuleName) {
return new Promise((resolve, reject) => {
- const kolibriModuleName = this._contentViewerRegistry[preset];
function resolveComponent(module) {
if (module.viewerComponent) {
resolve(module.viewerComponent);
} else {
reject(
- `Content viewer registered for ${preset} but no viewerComponent found in module ${kolibriModuleName}`,
+ `Content viewer registered but no viewerComponent found in module ${kolibriModuleName}`,
);
}
}
if (!kolibriModuleName) {
- // Our content viewer registry does not have a viewer for this content preset.
- reject(`No registered content viewer available for preset: ${preset}`);
+ reject(`No registered content viewer available for: kolibriModuleName`);
} else if (this._kolibriModuleRegistry[kolibriModuleName]) {
// There is a named viewer for this preset, and it is already loaded.
resolveComponent(this._kolibriModuleRegistry[kolibriModuleName]);
@@ -269,6 +273,58 @@ export default function pluginMediatorFactory(facade) {
}
});
},
+ /**
+ * Get a viewer component for a preset
+ * @param {string} preset - Content preset name
+ * @returns {Function|null} Component resolver function or null
+ */
+ presetViewerComponent(preset) {
+ const viewerRegistry = this._contentViewerRegistry[VIEWER_SUFFIX];
+ return viewerRegistry?.[preset] || null;
+ },
+
+ /**
+ * Get a viewer component for a DOM element
+ * @param {HTMLElement} element - DOM element
+ * @returns {Function|null} Component resolver function or null
+ */
+ elementViewerComponent(element) {
+ if (!element) {
+ return null;
+ }
+ const domViewerRegistry = this._contentViewerRegistry[DOM_VIEWER_SUFFIX];
+ if (!domViewerRegistry) {
+ return null;
+ }
+
+ // Check each registered selector to find a match
+ for (const [selector, component] of Object.entries(domViewerRegistry)) {
+ if (element.matches(selector)) {
+ return component;
+ }
+ }
+
+ return null;
+ },
+
+ /**
+ * Check if an element can be handled by any registered content viewer
+ * @param {HTMLElement} element - DOM element to check
+ * @returns {boolean} True if element can be handled
+ */
+ canHandleElement(element) {
+ if (!element) {
+ return false;
+ }
+ const domViewerRegistry = this._contentViewerRegistry[DOM_VIEWER_SUFFIX];
+ if (!domViewerRegistry) {
+ return false;
+ }
+
+ const allSelectors = Object.keys(domViewerRegistry).join(',');
+ return allSelectors && element.matches(allSelectors);
+ },
+
/*
* Method to load the direction specific CSS for a particular content viewer
* @param {ContentViewerModule} contentViewerModule The content viewer module to load the
diff --git a/packages/kolibri/package.json b/packages/kolibri/package.json
index dddff5a1605..23a01b56468 100644
--- a/packages/kolibri/package.json
+++ b/packages/kolibri/package.json
@@ -65,6 +65,7 @@
"./utils/baseClient": "./utils/baseClient",
"./utils/browserInfo": "./utils/browserInfo",
"./utils/i18n": "./utils/i18n",
+ "./utils/mimetypes": "./utils/mimetypes",
"./utils/objectSpecs": "./utils/objectSpecs",
"./utils/onboardingSteps": "./utils/onboardingSteps",
"./utils/redirectBrowser": "./utils/redirectBrowser",
diff --git a/packages/kolibri/styles/internal/initializeTheme.js b/packages/kolibri/styles/internal/initializeTheme.js
index 446087f83d3..feb5e4641ab 100644
--- a/packages/kolibri/styles/internal/initializeTheme.js
+++ b/packages/kolibri/styles/internal/initializeTheme.js
@@ -15,9 +15,13 @@ export function setThemeConfig(theme) {
});
}
+// Default matching ThemeHook.get_theme() in kolibri/core/theme_hook.py
+const DEFAULT_THEME = { signIn: {}, tokenMapping: {}, sideNav: {}, appBar: {} };
+
export default function initializeTheme() {
- validateObject(plugin_data.kolibriTheme, themeSpec);
- const theme = objectWithDefaults(plugin_data.kolibriTheme, themeSpec);
+ const themeData = plugin_data.kolibriTheme || DEFAULT_THEME;
+ validateObject(themeData, themeSpec);
+ const theme = objectWithDefaults(themeData, themeSpec);
if (theme.brandColors) {
setBrandColors(theme.brandColors);
}
diff --git a/packages/kolibri/urls.js b/packages/kolibri/urls.js
index 3a59fd8ccf0..2e1d72aeae5 100644
--- a/packages/kolibri/urls.js
+++ b/packages/kolibri/urls.js
@@ -170,6 +170,14 @@ class UrlResolver {
port: this.__zipContentPort,
});
}
+ zipContentOrigin() {
+ return new URL(
+ generateUrl(this.__zipContentUrl || '', {
+ origin: this.__zipContentOrigin,
+ port: this.__zipContentPort,
+ }),
+ ).origin;
+ }
static(url) {
if (!this.__staticUrl) {
throw new ReferenceError('Static Url is not defined');
@@ -216,7 +224,11 @@ export const createUrlResolver = () => {
if (prop in target) {
return target[prop];
}
- return target._getUrlFunction(prop);
+ // Guard against Symbol props (e.g. Symbol.toPrimitive) which are
+ // passed by the JS runtime during type coercion and by Jest during mocking.
+ if (typeof prop === 'string') {
+ return target._getUrlFunction(prop);
+ }
},
});
}
diff --git a/packages/kolibri/utils/__tests__/mimetypes.spec.js b/packages/kolibri/utils/__tests__/mimetypes.spec.js
new file mode 100644
index 00000000000..df8e1ed7812
--- /dev/null
+++ b/packages/kolibri/utils/__tests__/mimetypes.spec.js
@@ -0,0 +1,96 @@
+import {
+ inferPresetFromMimetype,
+ hasPresetForMimetype,
+ getRegisteredMimetypes,
+} from '../mimetypes';
+
+describe('mimetypes utility', () => {
+ describe('inferPresetFromMimetype', () => {
+ it('returns correct preset for video/mp4', () => {
+ expect(inferPresetFromMimetype('video/mp4')).toBe('high_res_video');
+ });
+
+ it('returns correct preset for video/webm', () => {
+ expect(inferPresetFromMimetype('video/webm')).toBe('high_res_video');
+ });
+
+ it('returns correct preset for audio/mp3', () => {
+ expect(inferPresetFromMimetype('audio/mp3')).toBe('audio');
+ });
+
+ it('returns correct preset for audio/mpeg', () => {
+ expect(inferPresetFromMimetype('audio/mpeg')).toBe('audio');
+ });
+
+ it('returns correct preset for application/pdf', () => {
+ expect(inferPresetFromMimetype('application/pdf')).toBe('document');
+ });
+
+ it('returns correct preset for application/epub+zip', () => {
+ expect(inferPresetFromMimetype('application/epub+zip')).toBe('epub');
+ });
+
+ it('returns correct preset for application/h5p+zip', () => {
+ expect(inferPresetFromMimetype('application/h5p+zip')).toBe('h5p');
+ });
+
+ it('returns correct preset for application/zip', () => {
+ expect(inferPresetFromMimetype('application/zip')).toBe('html5_zip');
+ });
+
+ it('returns "document" as default for unknown mimetypes', () => {
+ expect(inferPresetFromMimetype('application/unknown')).toBe('document');
+ });
+
+ it('returns "document" for null mimetype', () => {
+ expect(inferPresetFromMimetype(null)).toBe('document');
+ });
+
+ it('returns "document" for undefined mimetype', () => {
+ expect(inferPresetFromMimetype(undefined)).toBe('document');
+ });
+ });
+
+ describe('hasPresetForMimetype', () => {
+ it('returns true for known mimetypes', () => {
+ expect(hasPresetForMimetype('video/mp4')).toBe(true);
+ expect(hasPresetForMimetype('audio/mpeg')).toBe(true);
+ expect(hasPresetForMimetype('application/pdf')).toBe(true);
+ });
+
+ it('returns false for unknown mimetypes', () => {
+ expect(hasPresetForMimetype('application/unknown')).toBe(false);
+ expect(hasPresetForMimetype('text/html')).toBe(false);
+ });
+
+ it('returns false for null/undefined', () => {
+ expect(hasPresetForMimetype(null)).toBe(false);
+ expect(hasPresetForMimetype(undefined)).toBe(false);
+ });
+ });
+
+ describe('getRegisteredMimetypes', () => {
+ it('returns an array of mimetypes', () => {
+ const mimetypes = getRegisteredMimetypes();
+ expect(Array.isArray(mimetypes)).toBe(true);
+ expect(mimetypes.length).toBeGreaterThan(0);
+ });
+
+ it('includes expected video mimetypes', () => {
+ const mimetypes = getRegisteredMimetypes();
+ expect(mimetypes).toContain('video/mp4');
+ expect(mimetypes).toContain('video/webm');
+ });
+
+ it('includes expected audio mimetypes', () => {
+ const mimetypes = getRegisteredMimetypes();
+ expect(mimetypes).toContain('audio/mp3');
+ expect(mimetypes).toContain('audio/mpeg');
+ });
+
+ it('includes expected document mimetypes', () => {
+ const mimetypes = getRegisteredMimetypes();
+ expect(mimetypes).toContain('application/pdf');
+ });
+ });
+});
diff --git a/packages/kolibri/utils/mimetypes.js b/packages/kolibri/utils/mimetypes.js
new file mode 100644
index 00000000000..941b6dfb65b
--- /dev/null
+++ b/packages/kolibri/utils/mimetypes.js
@@ -0,0 +1,91 @@
+/**
+ * Utility functions for mapping MIME types to content presets.
+ *
+ * These mappings are used when handling DOM elements (like tags)
+ * that specify content by MIME type rather than Kolibri's preset system.
+ *
+ * The mappings should stay in sync with the presets defined in le_utils
+ * and the file formats registered in the content viewer hooks.
+ */
+
+/**
+ * Map of MIME types to their corresponding Kolibri content presets.
+ * This is used when rendering embedded content specified via object tags
+ * or other DOM elements that use MIME types.
+ */
+const MIMETYPE_TO_PRESET_MAP = {
+ // Video formats
+ 'video/mp4': 'high_res_video',
+ 'video/webm': 'high_res_video',
+ 'video/ogg': 'high_res_video',
+
+ // Audio formats
+ 'audio/mp3': 'audio',
+ 'audio/mpeg': 'audio',
+ 'audio/ogg': 'audio',
+
+ // Document formats
+ 'application/pdf': 'document',
+
+ // HTML5/Zip content
+ 'application/epub+zip': 'epub',
+ 'application/bloompub+zip': 'bloompub',
+ 'application/kpub+zip': 'kpub',
+ 'application/perseus+zip': 'exercise',
+ 'application/zip': 'html5_zip',
+ 'application/x-zip-compressed': 'html5_zip',
+ 'application/vnd.h5p': 'h5p',
+ 'application/h5p+zip': 'h5p',
+};
+
+/**
+ * Default preset to use when a MIME type is not recognized.
+ */
+const DEFAULT_PRESET = 'document';
+
+/**
+ * Infer content preset from a MIME type.
+ *
+ * This is primarily used when handling embedded content specified via
+ * tags or similar DOM elements where content is
+ * identified by MIME type rather than Kolibri's preset system.
+ *
+ * @param {string} mimetype - The MIME type to look up
+ * @returns {string} The corresponding content preset, or 'document' as fallback
+ *
+ * @example
+ * // Returns 'high_res_video'
+ * inferPresetFromMimetype('video/mp4');
+ *
+ * @example
+ * // Returns 'document' (default fallback)
+ * inferPresetFromMimetype('application/unknown');
+ */
+export function inferPresetFromMimetype(mimetype) {
+ return MIMETYPE_TO_PRESET_MAP[mimetype] || DEFAULT_PRESET;
+}
+
+/**
+ * Check if a MIME type has a known preset mapping.
+ *
+ * @param {string} mimetype - The MIME type to check
+ * @returns {boolean} True if the MIME type has a known preset
+ */
+export function hasPresetForMimetype(mimetype) {
+ return mimetype in MIMETYPE_TO_PRESET_MAP;
+}
+
+/**
+ * Get all registered MIME types.
+ *
+ * @returns {string[]} Array of all registered MIME types
+ */
+export function getRegisteredMimetypes() {
+ return Object.keys(MIMETYPE_TO_PRESET_MAP);
+}
+
+export default {
+ inferPresetFromMimetype,
+ hasPresetForMimetype,
+ getRegisteredMimetypes,
+};
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index e5539f77334..6a828112b5e 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -540,6 +540,9 @@ importers:
'@babel/runtime':
specifier: ^7.29.2
version: 7.29.2
+ '@testing-library/vue':
+ specifier: 'catalog:'
+ version: 5.9.0(vue-template-compiler@2.7.16)(vue@2.7.16)
'@vueuse/core':
specifier: 'catalog:'
version: 11.3.0(vue@2.7.16)
@@ -1826,10 +1829,6 @@ packages:
'@babel/code-frame@7.12.11':
resolution: {integrity: sha512-Zt1yodBx1UcyiePMSkWnU4hPqhwq7hGi2nFL1LeA3EUl+q2LQx16MISgJ0+z7dnmgvP9QtIleuETGOiOH1RcIw==}
- '@babel/code-frame@7.28.6':
- resolution: {integrity: sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==}
- engines: {node: '>=6.9.0'}
-
'@babel/code-frame@7.29.0':
resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
engines: {node: '>=6.9.0'}
@@ -9659,12 +9658,6 @@ snapshots:
dependencies:
'@babel/highlight': 7.25.9
- '@babel/code-frame@7.28.6':
- dependencies:
- '@babel/helper-validator-identifier': 7.28.5
- js-tokens: 4.0.0
- picocolors: 1.1.1
-
'@babel/code-frame@7.29.0':
dependencies:
'@babel/helper-validator-identifier': 7.28.5
@@ -11306,7 +11299,7 @@ snapshots:
'@babel/traverse@7.28.6':
dependencies:
- '@babel/code-frame': 7.28.6
+ '@babel/code-frame': 7.29.0
'@babel/generator': 7.28.6
'@babel/helper-globals': 7.28.0
'@babel/parser': 7.28.6
@@ -15023,7 +15016,7 @@ snapshots:
json-stable-stringify-without-jsonify: 1.0.1
levn: 0.4.1
lodash.merge: 4.6.2
- minimatch: 3.1.2
+ minimatch: 3.1.5
natural-compare: 1.4.0
optionator: 0.9.4
progress: 2.0.3
@@ -16289,7 +16282,7 @@ snapshots:
jest-message-util@30.2.0:
dependencies:
- '@babel/code-frame': 7.28.6
+ '@babel/code-frame': 7.29.0
'@jest/types': 30.2.0
'@types/stack-utils': 2.0.3
chalk: 4.1.2
@@ -17388,7 +17381,7 @@ snapshots:
parse-json@5.2.0:
dependencies:
- '@babel/code-frame': 7.28.6
+ '@babel/code-frame': 7.29.0
error-ex: 1.3.4
json-parse-even-better-errors: 2.3.1
lines-and-columns: 1.2.4