Skip to content
Open
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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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`

Expand All @@ -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)` |
Expand Down
45 changes: 44 additions & 1 deletion __tests__/component/handlerContract.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions __tests__/component/media.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
11 changes: 10 additions & 1 deletion __tests__/component/mountViewer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: `
<div
v-show="show"
Expand Down Expand Up @@ -129,6 +130,8 @@ export interface MountViewerResult {
vm: any
/** Emit an NcModal event (next|previous|close) to drive navigation. */
emitModal: (event: 'next' | 'previous' | 'close') => Promise<void>
/** What the modal reports when its play / pause button is used. */
reportSlideshow: (running: boolean) => Promise<void>
/** Read the modal `data-handler` attribute. */
modalHandlerId: () => string | undefined
/** Read the modal name (basename / comparison title). */
Expand Down Expand Up @@ -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!)
Expand All @@ -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'),
Expand Down
60 changes: 60 additions & 0 deletions __tests__/component/viewerApi.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })])
Expand Down
31 changes: 31 additions & 0 deletions e2e/slideshow.spec.ts
Original file line number Diff line number Diff line change
@@ -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()

Check failure on line 25 in e2e/slideshow.spec.ts

View workflow job for this annotation

GitHub Actions / merge-reports

[firefox] › e2e/slideshow.spec.ts:20:2 › Viewer slideshow › is running when opened with startSlideshow

2) [firefox] › e2e/slideshow.spec.ts:20:2 › Viewer slideshow › is running when opened with startSlideshow Retry #2 ─────────────────────────────────────────────────────────────────────────────────────── Error: expect(locator).toBeVisible() failed Locator: locator('.viewer__modal').getByRole('button', { name: 'Pause slideshow' }) Expected: visible Timeout: 5000ms Error: element(s) not found Call log: - Expect "toBeVisible" locator('.viewer__modal').getByRole('button', { name: 'Pause slideshow' }) with timeout 5000ms - waiting for locator('.viewer__modal').getByRole('button', { name: 'Pause slideshow' }) 23 | await viewer.waitForOpen() 24 | > 25 | await expect(viewer.pauseSlideshowButton).toBeVisible() | ^ 26 | 27 | // The button still works the other way round 28 | await viewer.pauseSlideshowButton.click() at /home/runner/work/nextcloud-viewer/nextcloud-viewer/e2e/slideshow.spec.ts:25:45

Check failure on line 25 in e2e/slideshow.spec.ts

View workflow job for this annotation

GitHub Actions / merge-reports

[firefox] › e2e/slideshow.spec.ts:20:2 › Viewer slideshow › is running when opened with startSlideshow

2) [firefox] › e2e/slideshow.spec.ts:20:2 › Viewer slideshow › is running when opened with startSlideshow Retry #1 ─────────────────────────────────────────────────────────────────────────────────────── Error: expect(locator).toBeVisible() failed Locator: locator('.viewer__modal').getByRole('button', { name: 'Pause slideshow' }) Expected: visible Timeout: 5000ms Error: element(s) not found Call log: - Expect "toBeVisible" locator('.viewer__modal').getByRole('button', { name: 'Pause slideshow' }) with timeout 5000ms - waiting for locator('.viewer__modal').getByRole('button', { name: 'Pause slideshow' }) 23 | await viewer.waitForOpen() 24 | > 25 | await expect(viewer.pauseSlideshowButton).toBeVisible() | ^ 26 | 27 | // The button still works the other way round 28 | await viewer.pauseSlideshowButton.click() at /home/runner/work/nextcloud-viewer/nextcloud-viewer/e2e/slideshow.spec.ts:25:45

Check failure on line 25 in e2e/slideshow.spec.ts

View workflow job for this annotation

GitHub Actions / merge-reports

[firefox] › e2e/slideshow.spec.ts:20:2 › Viewer slideshow › is running when opened with startSlideshow

2) [firefox] › e2e/slideshow.spec.ts:20:2 › Viewer slideshow › is running when opened with startSlideshow Error: expect(locator).toBeVisible() failed Locator: locator('.viewer__modal').getByRole('button', { name: 'Pause slideshow' }) Expected: visible Timeout: 5000ms Error: element(s) not found Call log: - Expect "toBeVisible" locator('.viewer__modal').getByRole('button', { name: 'Pause slideshow' }) with timeout 5000ms - waiting for locator('.viewer__modal').getByRole('button', { name: 'Pause slideshow' }) 23 | await viewer.waitForOpen() 24 | > 25 | await expect(viewer.pauseSlideshowButton).toBeVisible() | ^ 26 | 27 | // The button still works the other way round 28 | await viewer.pauseSlideshowButton.click() at /home/runner/work/nextcloud-viewer/nextcloud-viewer/e2e/slideshow.spec.ts:25:45

Check failure on line 25 in e2e/slideshow.spec.ts

View workflow job for this annotation

GitHub Actions / merge-reports

[chromium] › e2e/slideshow.spec.ts:20:2 › Viewer slideshow › is running when opened with startSlideshow

1) [chromium] › e2e/slideshow.spec.ts:20:2 › Viewer slideshow › is running when opened with startSlideshow Retry #2 ─────────────────────────────────────────────────────────────────────────────────────── Error: expect(locator).toBeVisible() failed Locator: locator('.viewer__modal').getByRole('button', { name: 'Pause slideshow' }) Expected: visible Timeout: 5000ms Error: element(s) not found Call log: - Expect "toBeVisible" locator('.viewer__modal').getByRole('button', { name: 'Pause slideshow' }) with timeout 5000ms - waiting for locator('.viewer__modal').getByRole('button', { name: 'Pause slideshow' }) 23 | await viewer.waitForOpen() 24 | > 25 | await expect(viewer.pauseSlideshowButton).toBeVisible() | ^ 26 | 27 | // The button still works the other way round 28 | await viewer.pauseSlideshowButton.click() at /home/runner/work/nextcloud-viewer/nextcloud-viewer/e2e/slideshow.spec.ts:25:45

Check failure on line 25 in e2e/slideshow.spec.ts

View workflow job for this annotation

GitHub Actions / merge-reports

[chromium] › e2e/slideshow.spec.ts:20:2 › Viewer slideshow › is running when opened with startSlideshow

1) [chromium] › e2e/slideshow.spec.ts:20:2 › Viewer slideshow › is running when opened with startSlideshow Retry #1 ─────────────────────────────────────────────────────────────────────────────────────── Error: expect(locator).toBeVisible() failed Locator: locator('.viewer__modal').getByRole('button', { name: 'Pause slideshow' }) Expected: visible Timeout: 5000ms Error: element(s) not found Call log: - Expect "toBeVisible" locator('.viewer__modal').getByRole('button', { name: 'Pause slideshow' }) with timeout 5000ms - waiting for locator('.viewer__modal').getByRole('button', { name: 'Pause slideshow' }) 23 | await viewer.waitForOpen() 24 | > 25 | await expect(viewer.pauseSlideshowButton).toBeVisible() | ^ 26 | 27 | // The button still works the other way round 28 | await viewer.pauseSlideshowButton.click() at /home/runner/work/nextcloud-viewer/nextcloud-viewer/e2e/slideshow.spec.ts:25:45

Check failure on line 25 in e2e/slideshow.spec.ts

View workflow job for this annotation

GitHub Actions / merge-reports

[chromium] › e2e/slideshow.spec.ts:20:2 › Viewer slideshow › is running when opened with startSlideshow

1) [chromium] › e2e/slideshow.spec.ts:20:2 › Viewer slideshow › is running when opened with startSlideshow Error: expect(locator).toBeVisible() failed Locator: locator('.viewer__modal').getByRole('button', { name: 'Pause slideshow' }) Expected: visible Timeout: 5000ms Error: element(s) not found Call log: - Expect "toBeVisible" locator('.viewer__modal').getByRole('button', { name: 'Pause slideshow' }) with timeout 5000ms - waiting for locator('.viewer__modal').getByRole('button', { name: 'Pause slideshow' }) 23 | await viewer.waitForOpen() 24 | > 25 | await expect(viewer.pauseSlideshowButton).toBeVisible() | ^ 26 | 27 | // The button still works the other way round 28 | await viewer.pauseSlideshowButton.click() at /home/runner/work/nextcloud-viewer/nextcloud-viewer/e2e/slideshow.spec.ts:25:45

// The button still works the other way round
await viewer.pauseSlideshowButton.click()
await expect(viewer.startSlideshowButton).toBeVisible()
})
})
7 changes: 6 additions & 1 deletion e2e/support/viewer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<void> {
await this.page.goto(query ? `/?${query}` : '/')
Expand Down
4 changes: 4 additions & 0 deletions lib/components/Audios.vue
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
preload="metadata"
@error.capture.prevent.stop.once="onFail"
@ended="donePlaying"
@pause="onPause"
@play="onPlay"
@canplay="doneLoading">

<!-- Omitting `type` on purpose because most of the
Expand Down Expand Up @@ -51,6 +53,8 @@ const {
onFail,
donePlaying,
doneLoading,
onPause,
onPlay,
options,
} = usePlyrPlayer(true, props, emit)

Expand Down
4 changes: 4 additions & 0 deletions lib/components/Videos.vue
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@
preload="metadata"
@error.capture.prevent.stop.once="onFail"
@ended="donePlaying"
@pause="onPause"
@play="onPlay"
@canplay="doneLoading"
@loadedmetadata="onLoadedMetadata">

Expand Down Expand Up @@ -67,6 +69,8 @@ const {
onFail,
donePlaying,
doneLoading,
onPause,
onPlay,
options,
} = usePlyrPlayer(false, props, emit)

Expand Down
6 changes: 6 additions & 0 deletions lib/composables/usePlyrPlayer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,10 @@ export function usePlyrPlayer(forAudio: boolean, props: ViewerProps, emit: EmitF
const disableSwipe = () => emit('update:canSwipe', false)
const enableSwipe = () => emit('update:canSwipe', true)

// So the viewer's slideshow waits for the media instead of moving on mid-play
const onPlay = () => emit('update:playing', true)
const onPause = () => emit('update:playing', false)

/**
* Get the current plyr control items, or an empty array if not ready.
*/
Expand Down Expand Up @@ -220,6 +224,8 @@ export function usePlyrPlayer(forAudio: boolean, props: ViewerProps, emit: EmitF
doneLoading,
donePlaying,
onFail,
onPause,
onPlay,
options,
video,
}
Expand Down
14 changes: 14 additions & 0 deletions lib/viewer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,14 @@ export interface ViewerEmits {
* @param editing Whether the viewer is now in editing mode
*/
'update:editing': [boolean]

/**
* Emit this event when your component starts or stops playing media. The
* slideshow waits while media plays, rather than moving on in the middle of it.
*
* @param playing Whether media is playing
*/
'update:playing': [boolean]
}

/**
Expand Down Expand Up @@ -136,6 +144,12 @@ export type ViewerOptions = {
*/
canLoop?: boolean

/**
* Whether to start the slideshow as soon as the viewer opens. Ignored for
* a single file, as there is nothing to move on to.
*/
startSlideshow?: boolean

/**
* Whether to offer the Files sidebar for the open file. Defaults to true.
* Turn it off for a file the sidebar cannot resolve, such as an old
Expand Down
Loading
Loading