diff --git a/__tests__/component/media.spec.ts b/__tests__/component/media.spec.ts index c040b55..3c8ea3c 100644 --- a/__tests__/component/media.spec.ts +++ b/__tests__/component/media.spec.ts @@ -513,6 +513,70 @@ describe('Videos.vue (smoke)', () => { }) }) +describe('a video that has played to the end', () => { + // What a user puts beside a film to show before and after it plays: + // a picture of the same name in the same folder + async function mountWithPoster() { + const movie = makeFile({ basename: 'trailer.webm', mime: 'video/webm' }) + const poster = makeFile({ basename: 'trailer.jpg', mime: 'image/jpeg' }) + const wrapper = mount(Videos, { props: makeProps({ file: movie, files: [movie, poster] }) }) + await flushPromises() + const video = wrapper.find('video').element as HTMLVideoElement + video.load = vi.fn() + const player = wrapper.findComponent({ name: 'VuePlyrStub' }).vm.player as { stop: Mock } + return { wrapper, video, player, poster } + } + + it('shows the picture of the same name beside it as its poster', async () => { + const { wrapper, poster } = await mountWithPoster() + + expect(wrapper.find('video').attributes('poster')).toBe(poster.encodedSource) + }) + + it('goes back to its poster without downloading the video again', async () => { + const { wrapper, video, player, poster } = await mountWithPoster() + + await wrapper.find('video').trigger('ended') + + // Stopped at the start is what puts plyr's poster back over it, and + // the bytes already buffered stay for the next play + expect(player.stop).toHaveBeenCalledOnce() + expect(video.load).not.toHaveBeenCalled() + expect(wrapper.find('video').attributes('poster')).toBe(poster.encodedSource) + }) + + it('says so, rather than throw, when it has neither a player nor a media element', () => { + const error = vi.spyOn(logger, 'error').mockImplementation(() => {}) + let donePlaying!: () => void + const Host = defineComponent({ + setup() { + const file = makeFile({ basename: 'clip.mp4', mime: 'video/mp4' }) + // No template refs, so neither plyr nor the element ever arrive + donePlaying = usePlyrPlayer(false, makeProps({ file, files: [file] }), (() => {}) as never).donePlaying + return () => h('div') + }, + }) + mount(Host) + + expect(() => donePlaying()).not.toThrow() + expect(error).toHaveBeenCalledWith('Media element not found in donePlaying') + error.mockRestore() + }) + + it('rewinds by itself when there is no player yet', async () => { + const { wrapper, video } = await mountWithPoster() + wrapper.findComponent({ name: 'VuePlyrStub' }).vm.player = undefined + video.pause = vi.fn() + video.currentTime = 12 + + await wrapper.find('video').trigger('ended') + + expect(video.pause).toHaveBeenCalledOnce() + expect(video.currentTime).toBe(0) + expect(video.load).not.toHaveBeenCalled() + }) +}) + describe('media reporting that it plays', () => { it.each([ ['Videos', Videos, 'video', 'clip.mp4', 'video/mp4'], diff --git a/e2e/audio.spec.ts b/e2e/audio.spec.ts index 1267bf4..2b09684 100644 --- a/e2e/audio.spec.ts +++ b/e2e/audio.spec.ts @@ -40,7 +40,10 @@ test.describe('Audio', () => { const state = await audio.evaluate((element: HTMLAudioElement) => ({ readyState: element.readyState, duration: element.duration, - error: element.error?.code ?? null, + // Firefox on a machine with no audio device, as on CI, fails + // to play any sound it has loaded, and says so: that is the + // machine, not the file, and the viewer leaves it be + error: element.error?.message.includes('OnMediaSinkAudioError') ? null : (element.error?.code ?? null), })) expect(state.error).toBeNull() expect(state.readyState).toBeGreaterThan(0) diff --git a/e2e/navigation.spec.ts b/e2e/navigation.spec.ts index d8f90f4..918153f 100644 --- a/e2e/navigation.spec.ts +++ b/e2e/navigation.spec.ts @@ -33,6 +33,8 @@ const MEDIA = [ 'sound.m4a', 'sound.aac', 'clip.webm', + 'trailer.webm', + 'trailer.jpg', ] // No group, so the sheet music only pages among itself diff --git a/e2e/poster.spec.ts b/e2e/poster.spec.ts new file mode 100644 index 0000000..ab72411 --- /dev/null +++ b/e2e/poster.spec.ts @@ -0,0 +1,58 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { expect, test } from '@playwright/test' +import { ViewerPage } from './support/viewer.ts' + +/** + * A video with a picture of the same name beside it, which people keep + * together to show the picture before the film plays and again once it + * has: `trailer.webm` and `trailer.jpg` in the playground. + */ +test.describe('A video with a poster beside it', () => { + test('shows the poster again once it has played, without downloading the video again', async ({ page }) => { + // Served uncacheable, so that a reload of the element has to fetch + // the file again, as Firefox did on every replay (nextcloud/viewer#2585). + // A rewind plays on from what is already buffered + const requests: string[] = [] + await page.route('**/trailer.webm', async (route) => { + requests.push(route.request().url()) + const response = await route.fetch() + await route.fulfill({ response, headers: { ...response.headers(), 'cache-control': 'no-store' } }) + }) + + const viewer = new ViewerPage(page) + await viewer.open('trailer.webm') + await viewer.waitForOpen() + + const player = viewer.container.locator('.plyr') + const poster = player.locator('.plyr__poster') + await expect(player).toHaveClass(/plyr__poster-enabled/) + await expect(poster).toHaveAttribute('style', /trailer\.jpg/) + + // Played through to the end, muted so no autoplay policy stands in the way + const video = viewer.container.locator('video').first() + await video.evaluate(async (element: HTMLVideoElement) => { + element.muted = true + element.currentTime = 0 + const ended = new Promise((resolve) => element.addEventListener('ended', resolve, { once: true })) + await element.play() + await ended + }) + + // Back at the start and paused, which is what puts plyr's poster on top + await expect(player).toHaveClass(/plyr--stopped/) + await expect(poster).toHaveCSS('opacity', '1') + const downloads = requests.length + + // Playing it again comes from what was already buffered + await video.evaluate(async (element: HTMLVideoElement) => { + const ended = new Promise((resolve) => element.addEventListener('ended', resolve, { once: true })) + await element.play() + await ended + }) + await expect(poster).toHaveCSS('opacity', '1') + expect(requests).toHaveLength(downloads) + }) +}) diff --git a/lib/composables/usePlyrPlayer.ts b/lib/composables/usePlyrPlayer.ts index 5652b45..532fe45 100644 --- a/lib/composables/usePlyrPlayer.ts +++ b/lib/composables/usePlyrPlayer.ts @@ -84,19 +84,27 @@ export function usePlyrPlayer(forAudio: boolean, props: ViewerProps, emit: EmitF } /** - * Reset video after playing to show poster again + * Go back to the start once the media has played, showing its poster again. + * + * Rewound and paused rather than reloaded: plyr shows the poster over a + * player stopped at the start, and what the element already buffered is + * kept. Reloading it brought the poster back too, but every replay then + * downloaded the whole file again (nextcloud/viewer#2585). */ function donePlaying() { - const media = forAudio ? audio : video + if (player.value) { + player.value.stop() + return + } + + const media = (forAudio ? audio : video).value // Should not happen™ - if (!media.value) { + if (!media) { logger.error('Media element not found in donePlaying') return } - - // reset and show poster after play - media.value.autoplay = false - media.value.load() + media.pause() + media.currentTime = 0 } /** diff --git a/playground/App.vue b/playground/App.vue index 78811a6..c3ecf3b 100644 --- a/playground/App.vue +++ b/playground/App.vue @@ -67,6 +67,9 @@ const fixtures: Fixture[] = [ { name: 'sound.m4a', mime: 'audio/mp4' }, { name: 'sound.aac', mime: 'audio/aac' }, { name: 'clip.webm', mime: 'video/webm' }, + // A video with a picture of the same name beside it, its poster + { name: 'trailer.webm', mime: 'video/webm' }, + { name: 'trailer.jpg', mime: 'image/jpeg' }, ] /** Where the fixtures are served from, shaped like a WebDAV path */ diff --git a/playground/public/remote.php/dav/files/playground/trailer.jpg b/playground/public/remote.php/dav/files/playground/trailer.jpg new file mode 100644 index 0000000..9f3ee59 Binary files /dev/null and b/playground/public/remote.php/dav/files/playground/trailer.jpg differ diff --git a/playground/public/remote.php/dav/files/playground/trailer.webm b/playground/public/remote.php/dav/files/playground/trailer.webm new file mode 100644 index 0000000..4cafdaf Binary files /dev/null and b/playground/public/remote.php/dav/files/playground/trailer.webm differ