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