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">