From 07ea0a5e891d04417fa065c94e345818e1028cc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?John=20Molakvo=C3=A6?= <14975046+skjnldsv@users.noreply.github.com> Date: Fri, 9 Oct 2026 11:05:45 +0200 Subject: [PATCH] feat: add viewer settings with the slideshow delay and shortcuts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A cog button at the bottom left opens the settings, as does the ? key. They hold the slideshow delay and list the keyboard shortcuts. The delay, and the volume of videos and audio and whether they are muted, are kept on the server through the viewer app's config endpoint. Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: John Molakvoæ <14975046+skjnldsv@users.noreply.github.com> --- __tests__/component/Viewer.spec.ts | 42 ++++++++++ __tests__/component/ViewerSettings.spec.ts | 68 ++++++++++++++++ __tests__/component/mountViewer.ts | 2 + __tests__/mediaVolume.spec.ts | 89 ++++++++++++++++++++ __tests__/viewerSettings.spec.ts | 95 ++++++++++++++++++++++ e2e/settings.spec.ts | 44 ++++++++++ lib/components/ViewerSettings.vue | 95 ++++++++++++++++++++++ lib/composables/useMediaPlayer.ts | 4 +- lib/composables/useMediaVolume.ts | 58 +++++++++++++ lib/composables/useViewerSettings.ts | 69 ++++++++++++++++ lib/views/Viewer.vue | 40 ++++++++- playground/style.css | 8 ++ 12 files changed, 612 insertions(+), 2 deletions(-) create mode 100644 __tests__/component/ViewerSettings.spec.ts create mode 100644 __tests__/mediaVolume.spec.ts create mode 100644 __tests__/viewerSettings.spec.ts create mode 100644 e2e/settings.spec.ts create mode 100644 lib/components/ViewerSettings.vue create mode 100644 lib/composables/useMediaVolume.ts create mode 100644 lib/composables/useViewerSettings.ts diff --git a/__tests__/component/Viewer.spec.ts b/__tests__/component/Viewer.spec.ts index 7da3d03..c872924 100644 --- a/__tests__/component/Viewer.spec.ts +++ b/__tests__/component/Viewer.spec.ts @@ -33,6 +33,7 @@ vi.mock('@nextcloud/image-editor', async () => { import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus' import { Permission, registerFileAction } from '@nextcloud/files' +import { useViewerSettings } from '../../lib/composables/useViewerSettings.ts' import { restoreTitle } from '../../lib/utils/documentTitle.ts' import { getViewer } from '../../lib/viewer.ts' import { makeFile, makeHandler } from '../factories.ts' @@ -522,6 +523,47 @@ describe('comparing two files', () => { }) }) +describe('the viewer settings', () => { + it('set how long the slideshow shows each file', async () => { + const { vm, wrapper, modalProps } = mountViewer([imageHandler()]) + const file = makeFile({ mime: 'image/jpeg' }) + await vm.open([file], file) + await wrapper.vm.$nextTick() + expect(modalProps().slideshowDelay).toBe(5000) + + await useViewerSettings().setSlideshowDelay(10) + await wrapper.vm.$nextTick() + expect(modalProps().slideshowDelay).toBe(10000) + await useViewerSettings().setSlideshowDelay(5) + }) + + // Escape reaches the viewer too until the dialog has taken the focus + it('keep the viewer open while they are', async () => { + const { vm, wrapper, modalProps } = mountViewer([imageHandler()]) + const file = makeFile({ mime: 'image/jpeg' }) + await vm.open([file], file) + await wrapper.vm.$nextTick() + expect(modalProps().noClose).toBe(false) + + await wrapper.find('.viewer__settings').trigger('click') + expect(modalProps().noClose).toBe(true) + }) + + it.each([ + ['the button at the bottom', (wrapper: VueWrapper) => wrapper.find('.viewer__settings').trigger('click')], + ['?', () => document.body.dispatchEvent(new KeyboardEvent('keydown', { key: '?', bubbles: true, cancelable: true }))], + ])('open on %s', async (_, open) => { + const { vm, wrapper } = mountViewer([imageHandler()]) + const file = makeFile({ mime: 'image/jpeg' }) + await vm.open([file], file) + await wrapper.vm.$nextTick() + expect(wrapper.findComponent({ name: 'ViewerSettings' }).exists()).toBe(false) + + await open(wrapper) + await vi.waitFor(() => expect(wrapper.findComponent({ name: 'ViewerSettings' }).exists()).toBe(true)) + }) +}) + describe('Viewer sidebar', () => { const sidebarButton = (wrapper: ReturnType['wrapper']) => wrapper.findAll('.nc-action-button-stub').find((button) => button.text().includes('Open sidebar')) diff --git a/__tests__/component/ViewerSettings.spec.ts b/__tests__/component/ViewerSettings.spec.ts new file mode 100644 index 0000000..882f23b --- /dev/null +++ b/__tests__/component/ViewerSettings.spec.ts @@ -0,0 +1,68 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { mount } from '@vue/test-utils' +import { afterEach, describe, expect, it } from 'vitest' +import { defineComponent } from 'vue' +import ViewerSettings from '../../lib/components/ViewerSettings.vue' +import { useViewerSettings } from '../../lib/composables/useViewerSettings.ts' + +const NcSelectStub = defineComponent({ + name: 'NcSelect', + props: { options: { type: Array, default: () => [] }, modelValue: { type: Object, default: null } }, + emits: ['update:modelValue'], + template: '
', +}) + +/** The settings dialog, with only the delay dropdown rendered */ +function mountSettings() { + return mount(ViewerSettings, { + props: { open: true }, + global: { + stubs: { + NcAppSettingsDialog: { template: '
' }, + NcAppSettingsSection: { template: '
' }, + NcAppSettingsShortcutsSection: true, + NcSelect: NcSelectStub, + }, + }, + }) +} + +afterEach(async () => { + await useViewerSettings().setSlideshowDelay(5) +}) + +describe('the slideshow delay dropdown', () => { + it('offers the delays, with the current one picked', () => { + const select = mountSettings().findComponent(NcSelectStub) + + expect(select.props('options')).toEqual([ + { id: 3, label: '3 seconds' }, + { id: 5, label: '5 seconds' }, + { id: 10, label: '10 seconds' }, + { id: 30, label: '30 seconds' }, + ]) + expect(select.props('modelValue')).toEqual({ id: 5, label: '5 seconds' }) + }) + + // The server takes any delay from 1 to 60 seconds + it('lists the current delay too when it is none of the offered ones', async () => { + await useViewerSettings().setSlideshowDelay(7) + const select = mountSettings().findComponent(NcSelectStub) + + expect((select.props('options') as { id: number }[]).map((option) => option.id)).toEqual([3, 5, 7, 10, 30]) + expect(select.props('modelValue')).toEqual({ id: 7, label: '7 seconds' }) + }) + + it('saves the picked delay', async () => { + const select = mountSettings().findComponent(NcSelectStub) + + select.vm.$emit('update:modelValue', { id: 30, label: '30 seconds' }) + await select.vm.$nextTick() + + expect(useViewerSettings().slideshowDelay.value).toBe(30) + expect(select.props('modelValue')).toEqual({ id: 30, label: '30 seconds' }) + }) +}) diff --git a/__tests__/component/mountViewer.ts b/__tests__/component/mountViewer.ts index 840f17f..74dcb72 100644 --- a/__tests__/component/mountViewer.ts +++ b/__tests__/component/mountViewer.ts @@ -36,6 +36,8 @@ export const NcModalStub = defineComponent({ disableSwipe: { type: Boolean, default: false }, slideshowPaused: { type: Boolean, default: false }, slideshowRunning: { type: Boolean, default: false }, + slideshowDelay: { type: Number, default: 5000 }, + noClose: { type: Boolean, default: false }, lightBackdrop: { type: Boolean, default: false }, inlineActions: { type: Number, default: 0 }, additionalTrapElements: { type: Array, default: () => [] }, diff --git a/__tests__/mediaVolume.spec.ts b/__tests__/mediaVolume.spec.ts new file mode 100644 index 0000000..2046980 --- /dev/null +++ b/__tests__/mediaVolume.spec.ts @@ -0,0 +1,89 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { mount } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { defineComponent, h, nextTick, useTemplateRef } from 'vue' + +const put = vi.hoisted(() => vi.fn()) +vi.mock('@nextcloud/initial-state', () => ({ loadState: () => ({ slideshow_delay: 5, volume: 40, muted: true }) })) +vi.mock('@nextcloud/axios', () => ({ default: { put } })) +vi.mock('@nextcloud/dialogs', () => ({ showError: vi.fn() })) + +/** A player playing at the volume the user keeps, once it has its element */ +async function mountPlayer() { + // Fresh settings each time: they are kept for the whole page + vi.resetModules() + const { useMediaVolume } = await import('../lib/composables/useMediaVolume.ts') + const wrapper = mount(defineComponent({ + setup() { + useMediaVolume(useTemplateRef('media')) + return () => h('audio', { ref: 'media' }) + }, + })) + await nextTick() + return { wrapper, media: wrapper.element as HTMLAudioElement } +} + +/** + * Change the volume the way the player's controls do + * + * @param media - The element + * @param volume - From 0 to 1 + * @param muted - Whether it is muted + */ +function change(media: HTMLMediaElement, volume: number, muted = media.muted) { + media.volume = volume + media.muted = muted + media.dispatchEvent(new Event('volumechange')) +} + +beforeEach(() => { + vi.useFakeTimers() + put.mockReset() + put.mockResolvedValue({}) +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe('the volume of a player', () => { + it('starts where the user left it', async () => { + const { media } = await mountPlayer() + + expect(media.volume).toBe(0.4) + expect(media.muted).toBe(true) + }) + + it('is kept once the slider stops, not on every step', async () => { + const { media } = await mountPlayer() + + change(media, 0.5) + change(media, 0.6) + change(media, 0.7) + expect(put).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(500) + expect(put).toHaveBeenCalledExactlyOnceWith(expect.stringContaining('/config/volume'), { value: 70 }) + }) + + it('keeps muting apart from the volume', async () => { + const { media } = await mountPlayer() + + change(media, 0.4, false) + await vi.advanceTimersByTimeAsync(500) + + expect(put).toHaveBeenCalledExactlyOnceWith(expect.stringContaining('/config/muted'), { value: false }) + }) + + it('keeps the last change when the viewer closes before it is saved', async () => { + const { wrapper, media } = await mountPlayer() + + change(media, 0.2) + wrapper.unmount() + + expect(put).toHaveBeenCalledExactlyOnceWith(expect.stringContaining('/config/volume'), { value: 20 }) + }) +}) diff --git a/__tests__/viewerSettings.spec.ts b/__tests__/viewerSettings.spec.ts new file mode 100644 index 0000000..02b6acc --- /dev/null +++ b/__tests__/viewerSettings.spec.ts @@ -0,0 +1,95 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const stored = vi.hoisted(() => ({ value: null as { slideshow_delay: number, volume: number, muted: boolean } | null })) +const put = vi.hoisted(() => vi.fn()) +const showError = vi.hoisted(() => vi.fn()) +vi.mock('@nextcloud/initial-state', () => ({ loadState: (app: string, key: string, fallback: unknown) => (app === 'viewer' && key === 'config' ? stored.value : null) ?? fallback })) +vi.mock('@nextcloud/axios', () => ({ default: { put } })) +vi.mock('@nextcloud/dialogs', () => ({ showError })) + +/** + * The settings as a page with these stored settings sees them + * + * @param settings - What the server handed the page, nothing for a guest + */ +async function settingsWith(settings: { slideshow_delay: number, volume: number, muted: boolean } | null) { + stored.value = settings + vi.resetModules() + const { useViewerSettings } = await import('../lib/composables/useViewerSettings.ts') + return useViewerSettings() +} + +/** What the server hands a user who never changed anything */ +const STORED = { slideshow_delay: 5, volume: 100, muted: false } + +beforeEach(() => { + put.mockReset() + put.mockResolvedValue({}) + showError.mockReset() +}) + +afterEach(() => { + stored.value = null +}) + +describe('the slideshow delay', () => { + it('is 5 seconds unless the user chose otherwise', async () => { + expect((await settingsWith(null)).slideshowDelay.value).toBe(5) + expect((await settingsWith({ ...STORED, slideshow_delay: 10 })).slideshowDelay.value).toBe(10) + }) + + it('is kept on the server', async () => { + const settings = await settingsWith(STORED) + + await settings.setSlideshowDelay(20) + + expect(settings.slideshowDelay.value).toBe(20) + expect(put).toHaveBeenCalledWith(expect.stringContaining('/ocs/v2.php/apps/viewer/api/v1/config/slideshow_delay'), { value: 20 }) + }) + + // No settings handed over: a guest, or a server that keeps none + it('is only kept on the page where there is nowhere to save it', async () => { + const settings = await settingsWith(null) + + await settings.setSlideshowDelay(20) + + expect(settings.slideshowDelay.value).toBe(20) + expect(put).not.toHaveBeenCalled() + }) + + it('goes back to what it was when it cannot be saved, and says so', async () => { + put.mockRejectedValue(new Error('offline')) + const settings = await settingsWith({ ...STORED, slideshow_delay: 10 }) + + await settings.setSlideshowDelay(20) + + expect(settings.slideshowDelay.value).toBe(10) + expect(showError).toHaveBeenCalledWith('Could not save your viewer settings') + }) +}) + +describe('the volume', () => { + it('is full and not muted unless the user chose otherwise', async () => { + const settings = await settingsWith(null) + expect(settings.volume.value).toBe(100) + expect(settings.muted.value).toBe(false) + + const chosen = await settingsWith({ ...STORED, volume: 40, muted: true }) + expect(chosen.volume.value).toBe(40) + expect(chosen.muted.value).toBe(true) + }) + + it('is kept on the server, muted or not', async () => { + const settings = await settingsWith(STORED) + + await settings.setVolume(40) + await settings.setMuted(true) + + expect(put).toHaveBeenCalledWith(expect.stringContaining('/ocs/v2.php/apps/viewer/api/v1/config/volume'), { value: 40 }) + expect(put).toHaveBeenCalledWith(expect.stringContaining('/ocs/v2.php/apps/viewer/api/v1/config/muted'), { value: true }) + }) +}) diff --git a/e2e/settings.spec.ts b/e2e/settings.spec.ts new file mode 100644 index 0000000..8e13247 --- /dev/null +++ b/e2e/settings.spec.ts @@ -0,0 +1,44 @@ +/*! + * 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('The viewer settings', () => { + test('open from the button at the bottom, and list the shortcuts', async ({ page }) => { + const viewer = new ViewerPage(page) + await viewer.open('photo.jpg') + await viewer.waitForOpen() + + await viewer.container.getByRole('button', { name: 'Viewer settings' }).click() + const settings = page.locator('.app-settings') + await expect(settings).toBeVisible() + await expect(settings.getByText('Download')).toBeVisible() + await expect(settings.locator('.vs__selected')).toHaveText('5 seconds') + + // Not before the dialog has taken the focus, which would close it again + await expect(settings.locator(':focus')).toHaveCount(1) + await settings.getByRole('combobox').click() + // By its text: NcSelect splits a label in two to ellipsise its middle + await page.getByRole('option').filter({ hasText: '10 seconds' }).click() + await expect(settings.locator('.vs__selected')).toHaveText('10 seconds') + }) + + // The dialog sits over the viewer: Escape closes it, and only it + test('open on ?, and close on Escape without closing the viewer', async ({ page }) => { + const viewer = new ViewerPage(page) + await viewer.open('photo.jpg') + await viewer.waitForOpen() + + await page.keyboard.press('?') + const settings = page.locator('.app-settings') + await expect(settings).toBeVisible() + // Escape goes to the dialog once it holds the focus + await expect(settings.locator(':focus')).toHaveCount(1) + + await page.keyboard.press('Escape') + await expect(settings).toHaveCount(0) + await expect(viewer.container).toBeVisible() + }) +}) diff --git a/lib/components/ViewerSettings.vue b/lib/components/ViewerSettings.vue new file mode 100644 index 0000000..5818e6f --- /dev/null +++ b/lib/components/ViewerSettings.vue @@ -0,0 +1,95 @@ + + + + + diff --git a/lib/composables/useMediaPlayer.ts b/lib/composables/useMediaPlayer.ts index efc368a..89aebf8 100644 --- a/lib/composables/useMediaPlayer.ts +++ b/lib/composables/useMediaPlayer.ts @@ -6,11 +6,12 @@ import type { EmitFn } from 'vue' import type { ViewerEmits, ViewerProps } from '../viewer.ts' -import { onBeforeUnmount, onUpdated, ref, useTemplateRef, watch } from 'vue' +import { computed, onBeforeUnmount, onUpdated, ref, useTemplateRef, watch } from 'vue' import blankVideo from '../img/blank.mp4' import { logger } from '../services/logger.ts' import { preloadMedia } from '../services/mediaPreloader.ts' import { t } from '../utils/l10n.ts' +import { useMediaVolume } from './useMediaVolume.ts' import { useViewerProps } from './useViewerProps.ts' /** Marks the page furniture the viewer hides around a full screen player */ @@ -42,6 +43,7 @@ export function useMediaPlayer(forAudio: boolean, props: ViewerProps, emit: Emit const video = useTemplateRef('video') const audio = useTemplateRef('audio') + useMediaVolume(computed(() => video.value ?? audio.value)) const fallback = ref(false) diff --git a/lib/composables/useMediaVolume.ts b/lib/composables/useMediaVolume.ts new file mode 100644 index 0000000..5e59b77 --- /dev/null +++ b/lib/composables/useMediaVolume.ts @@ -0,0 +1,58 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Ref } from 'vue' + +import debounce from 'debounce' +import { onBeforeUnmount, watch } from 'vue' +import { useViewerSettings } from './useViewerSettings.ts' + +/** How long the volume has to stay put before it is saved, in milliseconds */ +const SAVE_DELAY = 500 + +/** + * Play a media element at the volume the user last chose, muted or not, and + * keep what they change for the next one. + * + * @param media - The video or audio element, once there is one + */ +export function useMediaVolume(media: Readonly>) { + const { volume, muted, setVolume, setMuted } = useViewerSettings() + + // Once the slider stops: dragging it changes the volume many times + const save = debounce((element: HTMLMediaElement) => { + const percent = Math.round(element.volume * 100) + // Setting the volume below fires the event too, with what is kept already + if (percent !== volume.value) { + setVolume(percent) + } + if (element.muted !== muted.value) { + setMuted(element.muted) + } + }, SAVE_DELAY) + + /** + * @param event - The change of volume + */ + function onVolumeChange(event: Event) { + save(event.target as HTMLMediaElement) + } + + watch(media, (element, previous) => { + previous?.removeEventListener('volumechange', onVolumeChange) + if (!element) { + return + } + element.volume = volume.value / 100 + element.muted = muted.value + element.addEventListener('volumechange', onVolumeChange) + }, { immediate: true }) + + onBeforeUnmount(() => { + // What was changed last is kept even when the viewer closes right away + save.flush() + media.value?.removeEventListener('volumechange', onVolumeChange) + }) +} diff --git a/lib/composables/useViewerSettings.ts b/lib/composables/useViewerSettings.ts new file mode 100644 index 0000000..a67f11f --- /dev/null +++ b/lib/composables/useViewerSettings.ts @@ -0,0 +1,69 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import axios from '@nextcloud/axios' +import { showError } from '@nextcloud/dialogs' +import { loadState } from '@nextcloud/initial-state' +import { generateOcsUrl } from '@nextcloud/router' +import { reactive, readonly, toRef } from 'vue' +import { logger } from '../services/logger.ts' +import { t } from '../utils/l10n.ts' + +/** What the server hands the page, for a logged-in user only */ +interface StoredConfig { + /** Seconds the slideshow shows each file for */ + slideshow_delay: number + /** Volume of videos and audio, in percent */ + volume: number + /** Whether videos and audio start muted */ + muted: boolean +} + +/** The settings of a user who never changed them, as the server has them */ +export const DEFAULT_CONFIG: Readonly = { + slideshow_delay: 5, + volume: 100, + muted: false, +} + +// No settings for a guest, or from a server that keeps none: they are then +// only kept until the page is left +const stored = loadState('viewer', 'config', null) +const config = reactive({ ...DEFAULT_CONFIG, ...stored }) + +/** + * Change a setting, and keep it on the server the way the Files app keeps + * its own. It goes back to what it was if it cannot be saved. + * + * @param key - The setting + * @param value - Its new value + */ +async function setConfig(key: K, value: StoredConfig[K]): Promise { + const previous = config[key] + config[key] = value + if (stored === null) { + return + } + try { + await axios.put(generateOcsUrl('apps/viewer/api/v1/config/{key}', { key }), { value }) + } catch (error) { + logger.error(`Could not save the viewer setting ${key}`, { error }) + config[key] = previous + showError(t('Could not save your viewer settings')) + } +} + +/** + * The user's viewer settings, the same for every viewer on the page. + */ +export function useViewerSettings() { + return { + slideshowDelay: toRef(readonly(config), 'slideshow_delay'), + volume: toRef(readonly(config), 'volume'), + muted: toRef(readonly(config), 'muted'), + setSlideshowDelay: (seconds: number) => setConfig('slideshow_delay', seconds), + setVolume: (percent: number) => setConfig('volume', percent), + setMuted: (muted: boolean) => setConfig('muted', muted), + } +} diff --git a/lib/views/Viewer.vue b/lib/views/Viewer.vue index 911fe86..4810191 100644 --- a/lib/views/Viewer.vue +++ b/lib/views/Viewer.vue @@ -22,6 +22,8 @@ :lightBackdrop="lightBackdrop" :name="modalName" :show="!!currentFile || !!errorString" + :noClose="settingsOpen /* Escape belongs to the settings over it */" + :slideshowDelay="slideshowDelay * 1000" :slideshowPaused="slideshowPaused" :spreadNavigation="true" :style="{ width: isSidebarShown ? `${sidebarPosition}px` : null }" @@ -249,6 +251,19 @@ @loaded="onLoad" @errored="onError" /> + + + + + +