From 06a00077c38e01637f9df6f76c63a900806aa5b9 Mon Sep 17 00:00:00 2001 From: skjnldsv Date: Sat, 12 Sep 2026 09:21:03 +0200 Subject: [PATCH 1/2] feat(viewer): start the slideshow on open `open(nodes, file, { startSlideshow: true })` opens straight into the slideshow, which Photos does from its Memories and Timeline views. The option is ignored for a single file. The slideshow state is bound to NcModal's `slideshowRunning` model, so the play / pause button and the last-slide stop are followed, and the state resets on close. Handlers get an `update:playing` emit. The slideshow waits while it is true, and the video and audio players emit it from the media element's play and pause events, so a slideshow does not move on in the middle of a video. Needs @nextcloud/vue with the `slideshowRunning` model on NcModal. Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: skjnldsv --- README.md | 3 ++ __tests__/component/handlerContract.spec.ts | 45 +++++++++++++++- __tests__/component/media.spec.ts | 17 ++++++ __tests__/component/mountViewer.ts | 11 +++- __tests__/component/viewerApi.spec.ts | 60 +++++++++++++++++++++ lib/components/Audios.vue | 4 ++ lib/components/Videos.vue | 4 ++ lib/composables/usePlyrPlayer.ts | 6 +++ lib/viewer.ts | 14 +++++ lib/views/Viewer.vue | 27 +++++++++- 10 files changed, 187 insertions(+), 4 deletions(-) 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/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">