diff --git a/README.md b/README.md index 6dafdf2..85e9bfc 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,7 @@ const src = computed(() => props.file.encodedSource) | `errored` | `[Error]` | Notify the viewer an error occurred (custom message shown) | | `update:canSwipe` | `[boolean]` | Enable/disable the swipe gesture (e.g. for custom controls) | | `update:editing` | `[boolean]` | Notify the viewer the editing mode changed | +| `update:playing` | `[boolean]` | Notify the viewer media plays, so the slideshow waits for it | #### 2. Define the custom element and register the handler @@ -264,6 +265,7 @@ neither does a request that fails: both fall back to names ascending. | `onNext` | `() => void` | Called when navigating to the next item | | `onClose` | `() => void` | Called when the viewer is closed | | `canLoop` | `boolean` | Whether navigation loops from last to first item and vice versa | +| `startSlideshow` | `boolean` | Whether to start the slideshow on open, given more than one file | ### 🧭 Migrating from `OCA.Viewer` @@ -278,6 +280,7 @@ instead, and the viewer works with `@nextcloud/files` nodes rather than the | `OCA.Viewer.open({ path, list })` | `getViewer().open(nodes, file)` | | `OCA.Viewer.open({ fileInfo, list })` | `getViewer().open(nodes, file)` | | `OCA.Viewer.openWith(id, { … })` | `getViewer().open(nodes, file, options, id)` | +| `OCA.Viewer.open({ …, startSlideshow: true })` | `getViewer().open(nodes, file, { startSlideshow: true })` | | `OCA.Viewer.compare(fileInfo1, fileInfo2)` | `getViewer().compare(node1, node2)` | | `OCA.Viewer.close()` | `getViewer().close()` | | `OCA.Viewer.mimetypes.includes(node.mime)` | `canView(node)` | diff --git a/__tests__/component/handlerContract.spec.ts b/__tests__/component/handlerContract.spec.ts index 28f7364..595ddd5 100644 --- a/__tests__/component/handlerContract.spec.ts +++ b/__tests__/component/handlerContract.spec.ts @@ -44,7 +44,7 @@ const Probe = defineComponent({ isSidebarShown: { type: Boolean, default: false }, localSource: { type: String, default: undefined }, }, - emits: ['loaded', 'errored', 'update:canSwipe', 'update:editing'], + emits: ['loaded', 'errored', 'update:canSwipe', 'update:editing', 'update:playing'], setup(props, { emit }) { emitFromProbe = (event, payload) => emit(event as 'loaded', payload as never) return () => { @@ -355,6 +355,49 @@ describe('a handler that misbehaves', () => { }) }) +describe('a handler playing media', () => { + it('holds the slideshow until the media stops', async () => { + renders.length = 0 + const f1 = makeFile({ basename: 'a.mp4', mime: 'video/mp4' }) + const f2 = makeFile({ basename: 'b.mp4', mime: 'video/mp4' }) + const { vm, wrapper, modalProps } = mountViewer([probeHandler()]) + + await vm.open([f1, f2], f1, { startSlideshow: true }) + await wrapper.vm.$nextTick() + await flushPromises() + expect(modalProps().slideshowPaused).toBe(false) + + // What Videos.vue emits as the video plays and ends + emitFromProbe!('update:playing', true) + await wrapper.vm.$nextTick() + expect(modalProps().slideshowPaused).toBe(true) + + emitFromProbe!('update:playing', false) + await wrapper.vm.$nextTick() + expect(modalProps().slideshowPaused).toBe(false) + }) + + it('lets go of the slideshow when the file changes', async () => { + renders.length = 0 + const f1 = makeFile({ basename: 'a.mp4', mime: 'video/mp4' }) + const f2 = makeFile({ basename: 'b.jpg', mime: 'image/jpeg' }) + const { vm, wrapper, modalProps, emitModal } = mountViewer([probeHandler()]) + + await vm.open([f1, f2], f1) + await wrapper.vm.$nextTick() + await flushPromises() + + emitFromProbe!('update:playing', true) + await wrapper.vm.$nextTick() + expect(modalProps().slideshowPaused).toBe(true) + + // The video left with its handler, and nothing on the image plays + await emitModal('next') + await flushPromises() + expect(modalProps().slideshowPaused).toBe(false) + }) +}) + describe('swiping away from a handler', () => { it('stops while the handler is being interacted with', async () => { renders.length = 0 diff --git a/__tests__/component/media.spec.ts b/__tests__/component/media.spec.ts index d37069f..7fbb1e1 100644 --- a/__tests__/component/media.spec.ts +++ b/__tests__/component/media.spec.ts @@ -443,6 +443,23 @@ describe('Videos.vue (smoke)', () => { }) }) +describe('media reporting that it plays', () => { + it.each([ + ['Videos', Videos, 'video', 'clip.mp4', 'video/mp4'], + ['Audios', Audios, 'audio', 'song.mp3', 'audio/mpeg'], + ])('%s tells the viewer when it plays and pauses', async (_name, component, tag, basename, mime) => { + const file = makeFile({ basename, mime }) + const wrapper = mount(component, { props: makeProps({ file, files: [file] }) }) + await flushPromises() + + await wrapper.find(tag).trigger('play') + expect(wrapper.emitted('update:playing')).toEqual([[true]]) + + await wrapper.find(tag).trigger('pause') + expect(wrapper.emitted('update:playing')).toEqual([[true], [false]]) + }) +}) + describe('the page around a full screen player', () => { /** * Mount Videos with the page furniture the server renders around it. diff --git a/__tests__/component/mountViewer.ts b/__tests__/component/mountViewer.ts index 92ef782..0a97bb6 100644 --- a/__tests__/component/mountViewer.ts +++ b/__tests__/component/mountViewer.ts @@ -35,9 +35,10 @@ export const NcModalStub = defineComponent({ enableSlideshow: { type: Boolean, default: false }, disableSwipe: { type: Boolean, default: false }, slideshowPaused: { type: Boolean, default: false }, + slideshowRunning: { type: Boolean, default: false }, lightBackdrop: { type: Boolean, default: false }, }, - emits: ['next', 'previous', 'close'], + emits: ['next', 'previous', 'close', 'update:slideshowRunning'], template: `
Promise + /** What the modal reports when its play / pause button is used. */ + reportSlideshow: (running: boolean) => Promise /** Read the modal `data-handler` attribute. */ modalHandlerId: () => string | undefined /** Read the modal name (basename / comparison title). */ @@ -187,6 +190,11 @@ export function mountViewer(handlers: IHandler[] = []): MountViewerResult { await wrapper.vm.$nextTick() } + const reportSlideshow = async (running: boolean) => { + findModal().vm.$emit('update:slideshowRunning', running) + await wrapper.vm.$nextTick() + } + const renderedTags = () => { const html = wrapper.html() return [...html.matchAll(/<(oca-viewer-[a-z0-9-]+)/g)].map(([, tag]) => tag!) @@ -197,6 +205,7 @@ export function mountViewer(handlers: IHandler[] = []): MountViewerResult { wrapper, vm: wrapper.vm as any, emitModal, + reportSlideshow, modalStyle: () => findModal().attributes('style'), modalHandlerId: () => findModal().attributes('data-handler'), modalName: () => findModal().attributes('data-name'), diff --git a/__tests__/component/viewerApi.spec.ts b/__tests__/component/viewerApi.spec.ts index 0a53db6..f9f1d2d 100644 --- a/__tests__/component/viewerApi.spec.ts +++ b/__tests__/component/viewerApi.spec.ts @@ -330,6 +330,66 @@ describe('compare() with bad input', () => { }) }) +describe('the startSlideshow option', () => { + it('starts the slideshow on open', async () => { + const { vm, wrapper, modalProps } = mountViewer([imageHandler()]) + const files = [makeFile(), makeFile()] + + await vm.open(files, files[0], { startSlideshow: true }) + await wrapper.vm.$nextTick() + + expect(modalProps().slideshowRunning).toBe(true) + }) + + it('does not start it unasked', async () => { + const { vm, wrapper, modalProps } = mountViewer([imageHandler()]) + const files = [makeFile(), makeFile()] + + await vm.open(files, files[0]) + await wrapper.vm.$nextTick() + + expect(modalProps().slideshowRunning).toBe(false) + }) + + it('is ignored for a single file', async () => { + const { vm, wrapper, modalProps } = mountViewer([imageHandler()]) + const file = makeFile() + + await vm.open([file], file, { startSlideshow: true }) + await wrapper.vm.$nextTick() + + expect(modalProps().slideshowRunning).toBe(false) + }) + + it('follows the play / pause button', async () => { + const { vm, wrapper, modalProps, reportSlideshow } = mountViewer([imageHandler()]) + const files = [makeFile(), makeFile()] + + await vm.open(files, files[0], { startSlideshow: true }) + await wrapper.vm.$nextTick() + + await reportSlideshow(false) + expect(modalProps().slideshowRunning).toBe(false) + + await reportSlideshow(true) + expect(modalProps().slideshowRunning).toBe(true) + }) + + it('does not carry over to the next open', async () => { + const { vm, wrapper, modalProps, emitModal } = mountViewer([imageHandler()]) + const files = [makeFile(), makeFile()] + + await vm.open(files, files[0], { startSlideshow: true }) + await wrapper.vm.$nextTick() + await emitModal('close') + + await vm.open(files, files[0]) + await wrapper.vm.$nextTick() + + expect(modalProps().slideshowRunning).toBe(false) + }) +}) + describe('the editing option', () => { it('opens straight into editing for a handler that can edit a writable file', async () => { const { vm, modalProps } = mountViewer([imageHandler({ canEdit: true })]) diff --git a/e2e/slideshow.spec.ts b/e2e/slideshow.spec.ts new file mode 100644 index 0000000..b6902cd --- /dev/null +++ b/e2e/slideshow.spec.ts @@ -0,0 +1,31 @@ +/*! + * 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' + +test.describe('Viewer slideshow', () => { + test('waits to be started', async ({ page }) => { + const viewer = new ViewerPage(page) + await viewer.open('photo.jpg') + await viewer.waitForOpen() + + await expect(viewer.startSlideshowButton).toBeVisible() + await expect(viewer.pauseSlideshowButton).toHaveCount(0) + }) + + // The real modal has to take the state from the viewer for this, so it + // is what stands between the option and a button that says otherwise + test('is running when opened with startSlideshow', async ({ page }) => { + const viewer = new ViewerPage(page) + await viewer.open('photo.jpg', 'slideshow') + await viewer.waitForOpen() + + await expect(viewer.pauseSlideshowButton).toBeVisible() + + // The button still works the other way round + await viewer.pauseSlideshowButton.click() + await expect(viewer.startSlideshowButton).toBeVisible() + }) +}) diff --git a/e2e/support/viewer.ts b/e2e/support/viewer.ts index 05c03de..df12db2 100644 --- a/e2e/support/viewer.ts +++ b/e2e/support/viewer.ts @@ -19,6 +19,9 @@ export class ViewerPage { public readonly nextButton: Locator public readonly previousButton: Locator public readonly closeButton: Locator + /** The slideshow button, named after what it does next */ + public readonly pauseSlideshowButton: Locator + public readonly startSlideshowButton: Locator constructor(public readonly page: Page) { // NcModal teleports to the body, so match it by class rather than @@ -29,13 +32,15 @@ export class ViewerPage { this.nextButton = this.container.getByRole('button', { name: 'Next' }) this.previousButton = this.container.getByRole('button', { name: 'Previous' }) this.closeButton = this.container.getByRole('button', { name: 'Close' }) + this.pauseSlideshowButton = this.container.getByRole('button', { name: 'Pause slideshow' }) + this.startSlideshowButton = this.container.getByRole('button', { name: 'Start slideshow' }) } /** * Open the playground and click one of its files. * * @param name the file to open - * @param query optional playground flags, e.g. `previews` + * @param query optional playground flags, e.g. `previews` or `slideshow` */ async open(name: string, query = ''): Promise { await this.page.goto(query ? `/?${query}` : '/') diff --git a/lib/components/Audios.vue b/lib/components/Audios.vue index 5fd894f..32a828d 100644 --- a/lib/components/Audios.vue +++ b/lib/components/Audios.vue @@ -18,6 +18,8 @@ preload="metadata" @error.capture.prevent.stop.once="onFail" @ended="donePlaying" + @pause="onPause" + @play="onPlay" @canplay="doneLoading">