Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions __tests__/component/media.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
5 changes: 4 additions & 1 deletion e2e/audio.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions e2e/navigation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
58 changes: 58 additions & 0 deletions e2e/poster.spec.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
22 changes: 15 additions & 7 deletions lib/composables/usePlyrPlayer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

/**
Expand Down
3 changes: 3 additions & 0 deletions playground/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
Loading