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): ''.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" > -
- - - - {{ fullscreenText }} - -
+
@@ -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 @@ + + + + + + + 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 @@ + + + + + + + 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