diff --git a/packages/web-app-preview/src/App.vue b/packages/web-app-preview/src/App.vue index cd3cf17e26e..a1891690b7d 100644 --- a/packages/web-app-preview/src/App.vue +++ b/packages/web-app-preview/src/App.vue @@ -113,13 +113,11 @@ import { isLocationSharesActive, Key, Modifier, - ProcessorType, queryItemAsString, sortHelper, useAppNavigation, useGetMatchingSpace, useKeyboardActions, - usePreviewService, useRoute, useRouteQuery, useRouter @@ -135,7 +133,7 @@ import { useFileTypes, useFullScreenMode, useImageControls, - usePreviewDimensions + useMediaFileLoader } from './composables' import { mimeTypes } from './mimeTypes' import { RouteLocationRaw } from 'vue-router' @@ -163,8 +161,6 @@ const contextRouteQuery = useRouteQuery('contextRouteQuery') as unknown as Ref< > const { isFileTypeAudio, isFileTypeImage, isFileTypeVideo } = useFileTypes() -const previewService = usePreviewService() -const { dimensions } = usePreviewDimensions() const { getMatchingSpace } = useGetMatchingSpace() const { closeApp } = useAppNavigation({ router, currentFileContext }) const { bindKeyAction, removeKeyAction } = useKeyboardActions() @@ -187,9 +183,14 @@ const photoRollEnabled = ref(true) const preview = useTemplateRef('preview') const motionPlayer = useTemplateRef<{ isPlaying: boolean; toggle: () => void }>('motionPlayer') const keyBindings: string[] = [] -let loadPreviewImageController: AbortController = null let reloadUrlController: AbortController = null +const { loadPreviewImage, cancelStaleLoads, preloadNeighbors } = useMediaFileLoader({ + mediaFiles, + activeIndex, + getUrlForResource +}) + const space = computed(() => { if (!unref(activeMediaFile)) { return null @@ -271,61 +272,6 @@ const loading = computed(() => { return unref(file.isLoading) }) -const loadPreviewImage = async (mediaFile: MediaFile) => { - if (mediaFile.url) { - return - } - - if (loadPreviewImageController) { - loadPreviewImageController.abort() - } - - loadPreviewImageController = new AbortController() - - try { - // Vault images can't be thumbnailed server-side (the server only holds - // the ciphertext blob), so skip the preview service and fetch the full - // image instead - `getUrlForResource` is vault-aware and returns a blob - // URL with cleartext bytes. Gate strictly on the vault flag: every other - // image keeps the normal preview-service path, including the legacy - // no-thumbnail behaviour (do NOT key this off hasPreview(), or plain - // images the server has no thumbnail for would download the full - // original on every open). - const useFullImage = - mediaFile.isImage && (mediaFile.resource.isInVault || mediaFile.mimeType === 'image/svg+xml') - - if (mediaFile.isImage && !useFullImage) { - mediaFile.url = await previewService.loadPreview( - { - space: unref(space), - resource: mediaFile.resource, - dimensions: unref(dimensions), - processor: ProcessorType.enum.fit - }, - false, - false, - loadPreviewImageController.signal - ) - } else { - mediaFile.url = await getUrlForResource(unref(space), mediaFile.resource, { - signal: loadPreviewImageController.signal - }) - } - - mediaFile.isLoading = false - } catch (e) { - if (e.name === 'CanceledError') { - return - } - - console.error(e) - mediaFile.isError = true - mediaFile.isLoading = false - } finally { - loadPreviewImageController = null - } -} - /** Signed URLs expire, so fetch a fresh one on demand. */ async function reloadMediaFileUrl(mediaFile: MediaFile) { reloadUrlController?.abort() @@ -465,12 +411,14 @@ watch( { immediate: true } ) -watch(activeMediaFile, (newValue, oldValue) => { - if (!unref(activeMediaFile)) { +watch(activeMediaFile, async (newValue, oldValue) => { + if (!newValue) { return } - loadPreviewImage(unref(activeMediaFile)) + // drop loads that are neither the active file nor one of its neighbors + cancelStaleLoads() + const load = loadPreviewImage(newValue) currentImageRotation.value = 0 if (oldValue !== null) { @@ -481,7 +429,16 @@ watch(activeMediaFile, (newValue, oldValue) => { } } - emit('update:resource', unref(activeMediaFile).resource) + emit('update:resource', newValue.resource) + + // preload the neighbors only once this file is in, and only if the user + // hasn't navigated on in the meantime + await load + if (newValue !== unref(activeMediaFile)) { + return + } + + preloadNeighbors() }) watch( @@ -513,7 +470,6 @@ onBeforeUnmount(() => { removeKeyAction(keyBindingId) }) - loadPreviewImageController?.abort() reloadUrlController?.abort() Object.values(unref(mediaFiles)).forEach((cachedFile) => { diff --git a/packages/web-app-preview/src/composables/index.ts b/packages/web-app-preview/src/composables/index.ts index 646d8011d5f..837c05eae8d 100644 --- a/packages/web-app-preview/src/composables/index.ts +++ b/packages/web-app-preview/src/composables/index.ts @@ -1,4 +1,5 @@ export * from './useFileTypes' export * from './useFullScreenMode' export * from './useImageControls' +export * from './useMediaFileLoader' export * from './usePreviewDimensions' diff --git a/packages/web-app-preview/src/composables/useMediaFileLoader.ts b/packages/web-app-preview/src/composables/useMediaFileLoader.ts new file mode 100644 index 00000000000..8a1a1316e16 --- /dev/null +++ b/packages/web-app-preview/src/composables/useMediaFileLoader.ts @@ -0,0 +1,278 @@ +import { onBeforeUnmount, Ref, unref } from 'vue' +import { + FolderViewerSlotProps, + ProcessorType, + useGetMatchingSpace, + usePreviewService +} from '@opencloud-eu/web-pkg' +import { MediaFile } from '../helpers/types' +import { usePreviewDimensions } from './usePreviewDimensions' + +type UseMediaFileLoaderOptions = { + mediaFiles: Ref + activeIndex: Ref + getUrlForResource: FolderViewerSlotProps['getUrlForResource'] +} + +/** How often a resolved url is re-resolved when warming it up reveals a stale url. */ +const maxLoadAttempts = 2 + +export const useMediaFileLoader = ({ + mediaFiles, + activeIndex, + getUrlForResource +}: UseMediaFileLoaderOptions) => { + const previewService = usePreviewService() + const { dimensions } = usePreviewDimensions() + const { getMatchingSpace } = useGetMatchingSpace() + + // keyed by MediaFile instance and not by resource id: `buildMediaFiles` creates new + // instances, an id-keyed registry would hand a fresh instance the request of a + // discarded one and leave it loading forever + const controllers = new Map() + const requests = new Map>() + // warm-ups must stay referenced until they settled, otherwise the browser is + // free to drop them mid-flight + const warmUps = new Map void>() + let isDisposed = false + + /** + * Only the preview-service path yields bytes worth preloading: videos and audio must + * not be downloaded in the background, vault images have no server-side thumbnail + * (loading one means downloading and decrypting the full original) and SVGs are + * rendered as a document via `vue-inline-svg` instead of an image. + */ + const isPreloadable = (mediaFile: MediaFile) => { + return ( + mediaFile.isImage && + !mediaFile.resource.isInVault && + mediaFile.mimeType?.toLowerCase() !== 'image/svg+xml' + ) + } + + /** + * Pull a remote image into the browser cache so displaying it later is instant. Blob + * urls already hold the bytes, there's nothing to warm up for them. + * + * Resolves to `false` if the url turned out to be stale (signed urls expire while the + * viewer sits on a file) or the load was aborted on the way. The caller re-resolves + * stale urls and drops aborted loads. + */ + const warmImageCache = (mediaFile: MediaFile, signal: AbortSignal): Promise => { + // url resolution may settle after the signal was aborted (not every resolver is + // signal-aware, e.g. the `downloadURL` shortcut in `getFileUrl`), an abandoned + // load must not start downloading anything + if (signal.aborted) { + return Promise.resolve(false) + } + + const url = mediaFile.url + + // only the preview-service path yields bytes worth preloading: videos and audio + // stream through their own element, svg files render via a document fetch and an + // Image can't decode any of their urls - the failed decode would be misread as + // a stale url and trigger a pointless re-resolution + if (!isPreloadable(mediaFile) || !url || url.startsWith('blob:')) { + return Promise.resolve(true) + } + + return new Promise((resolve) => { + const image = new Image() + const settle = (isUsable: boolean) => { + image.onload = null + image.onerror = null + warmUps.delete(image) + resolve(isUsable) + } + + // on public links `image.src = url` is what downloads the preview, not the + // url-resolving requests the signal already covers: aborting must be able + // to stop this download too + signal.addEventListener( + 'abort', + () => { + settle(false) + // reassigning src is the only way to cancel an in-flight image fetch + image.src = '' + }, + { once: true } + ) + + warmUps.set(image, settle) + image.onload = () => settle(true) + image.onerror = () => settle(false) + image.src = url + }) + } + + const resolveUrl = (mediaFile: MediaFile, signal: AbortSignal): Promise => { + const space = getMatchingSpace(mediaFile.resource) + + // Vault images can't be thumbnailed server-side (the server only holds + // the ciphertext blob), so skip the preview service and fetch the full + // image instead - `getUrlForResource` is vault-aware and returns a blob + // URL with cleartext bytes. Gate strictly on the vault flag: every other + // image keeps the normal preview-service path, including the legacy + // no-thumbnail behaviour (do NOT key this off hasPreview(), or plain + // images the server has no thumbnail for would download the full + // original on every open). + const useFullImage = + mediaFile.isImage && (mediaFile.resource.isInVault || mediaFile.mimeType === 'image/svg+xml') + + if (mediaFile.isImage && !useFullImage) { + return previewService.loadPreview( + { + space, + resource: mediaFile.resource, + dimensions: unref(dimensions), + processor: ProcessorType.enum.fit + }, + false, + false, + signal + ) + } + + return getUrlForResource(space, mediaFile.resource, { signal }) + } + + const performLoad = async (mediaFile: MediaFile, controller: AbortController) => { + try { + for (let attempt = 1; attempt <= maxLoadAttempts; attempt++) { + mediaFile.url = await resolveUrl(mediaFile, controller.signal) + mediaFile.isLoading = false + // a file that recovered from an error must not keep rendering the error state + mediaFile.isError = false + + const isUsable = await warmImageCache(mediaFile, controller.signal) + // the warm-up is an optimization, the resolved url still renders the file + if (isUsable || isDisposed || attempt === maxLoadAttempts) { + return + } + + // the warm-up couldn't use the url: either the load was aborted on the way - + // then the url is dead weight and the file reloads once it becomes relevant + // again - or the url went stale and is resolved again on the next attempt + mediaFile.url = undefined + mediaFile.isLoading = true + + if (controller.signal.aborted) { + return + } + } + } catch (e) { + if (e.name === 'CanceledError') { + return + } + + console.error(e) + mediaFile.isError = true + mediaFile.isLoading = false + } finally { + controllers.delete(mediaFile) + requests.delete(mediaFile) + } + } + + const loadPreviewImage = (mediaFile: MediaFile): Promise => { + if (isDisposed || mediaFile.url) { + return Promise.resolve() + } + + const pendingRequest = requests.get(mediaFile) + if (pendingRequest) { + return pendingRequest + } + + const controller = new AbortController() + controllers.set(mediaFile, controller) + + const request = performLoad(mediaFile, controller) + requests.set(mediaFile, request) + + return request + } + + /** Abort in-flight loads that are neither the active file nor one of its neighbors. */ + const cancelStaleLoads = () => { + const files = unref(mediaFiles) + const index = unref(activeIndex) + const keep = new Set() + + if (files.length && index !== null && index !== undefined) { + ;[-1, 0, 1].forEach((offset) => { + const file = files[(index + offset + files.length) % files.length] + if (file) { + keep.add(file) + } + }) + } + + controllers.forEach((controller, mediaFile) => { + if (!keep.has(mediaFile)) { + controller.abort() + } + }) + } + + /** + * Load the files next to the active one so navigating to them is instant. The backward + * neighbor follows the forward one, it shouldn't compete with the file the user is most + * likely heading for. + */ + const preloadNeighbors = () => { + const files = unref(mediaFiles) + const index = unref(activeIndex) + if (files.length < 2 || index === null || index === undefined) { + return + } + + // both neighbors are resolved up front so they belong to the position we preload for + const forward = files[(index + 1) % files.length] + const backward = files[(index - 1 + files.length) % files.length] + const seen = new Set([files[index]]) + + const preload = (mediaFile: MediaFile) => { + if (!mediaFile || seen.has(mediaFile) || !isPreloadable(mediaFile)) { + return undefined + } + + seen.add(mediaFile) + return loadPreviewImage(mediaFile) + } + + const forwardLoad = preload(forward) + void Promise.resolve(forwardLoad).finally(() => { + // the user may have moved on while the forward neighbor was loading: the + // backward neighbor of the position we started from is irrelevant then + if (unref(activeIndex) !== index || unref(mediaFiles) !== files) { + return + } + + preload(backward) + }) + } + + const cancelAllLoads = () => { + isDisposed = true + + controllers.forEach((controller) => controller.abort()) + controllers.clear() + + // settle pending warm-ups, their loads bail out on `isDisposed` + warmUps.forEach((settle, image) => { + image.onload = null + image.onerror = null + settle(false) + }) + warmUps.clear() + } + + onBeforeUnmount(cancelAllLoads) + + return { + cancelStaleLoads, + loadPreviewImage, + preloadNeighbors + } +} diff --git a/packages/web-app-preview/tests/unit/app.spec.ts b/packages/web-app-preview/tests/unit/app.spec.ts index 5d3952de3a4..bbe8bf0fb68 100644 --- a/packages/web-app-preview/tests/unit/app.spec.ts +++ b/packages/web-app-preview/tests/unit/app.spec.ts @@ -1,5 +1,6 @@ import App from '../../src/App.vue' import { nextTick, ref } from 'vue' +import { flushPromises, VueWrapper } from '@vue/test-utils' import { defaultComponentMocks, defaultPlugins, shallowMount } from '@opencloud-eu/web-test-helpers' import { FileContext, queryItemAsString } from '@opencloud-eu/web-pkg' import { Resource } from '@opencloud-eu/web-client' @@ -97,77 +98,63 @@ const activeFiles = [ } ] -describe('Preview app', () => { - describe('Method "loadPreviewImage"', () => { - it('should load the preview image if active file changes', async () => { - const { wrapper, mocks } = createShallowMountWrapper() - await nextTick() - ;(wrapper.vm as any).goToNext() - await nextTick() +// visible files: bear.png, elephant.png, lonely_sloth_very_sad.gif, happy_hippo.gif, +// sleeping_dog.gif, cat_murr_murr.gif, labrador.gif +const wrappers: VueWrapper[] = [] - expect(mocks.$previewService.loadPreview).toHaveBeenCalledWith( - expect.objectContaining({ - resource: expect.objectContaining({ - name: 'cat_murr_murr.gif' - }) - }), - expect.anything(), - expect.anything(), - expect.anything() - ) - }) +describe('Preview app', () => { + beforeEach(() => { + vi.mocked(queryItemAsString).mockReturnValue('1') + }) - it('uses the preview service for a non-vault image even when the server reports no thumbnail', async () => { - const { wrapper, mocks, getUrlForResource } = createShallowMountWrapper() - await nextTick() - // ignore the implicit load for the active file on mount - mocks.$previewService.loadPreview.mockClear() - getUrlForResource.mockClear() + afterEach(() => { + while (wrappers.length) { + wrappers.pop().unmount() + } + }) - const mediaFile = { - isImage: true, - mimeType: 'image/png', - resource: mock({ isInVault: false, hasPreview: () => false }) - } - await (wrapper.vm as any).loadPreviewImage(mediaFile) + describe('Preloading', () => { + // files are sorted by name: bear.png, cat_murr_murr.gif, elephant.png, happy_hippo.gif, + // labrador.gif, lonely_sloth_very_sad.gif, sleeping_dog.gif + it('loads the active file and then its forward and backward neighbors', async () => { + const { mocks } = createShallowMountWrapper() + await flushPromises() - // must NOT download the full original just because there's no thumbnail - expect(mocks.$previewService.loadPreview).toHaveBeenCalled() - expect(getUrlForResource).not.toHaveBeenCalled() + expect(requestedNames(mocks)).toEqual(['bear.png', 'cat_murr_murr.gif', 'sleeping_dog.gif']) }) - it('fetches the full (decrypted) image via getUrlForResource for a vault image', async () => { - const { wrapper, mocks, getUrlForResource } = createShallowMountWrapper() - await nextTick() - mocks.$previewService.loadPreview.mockClear() - getUrlForResource.mockClear() - - const mediaFile = { - isImage: true, - mimeType: 'image/png', - resource: mock({ isInVault: true, hasPreview: () => false }) - } - await (wrapper.vm as any).loadPreviewImage(mediaFile) + it('does not load a preloaded file again when navigating to it', async () => { + const { wrapper, mocks } = createShallowMountWrapper() + await flushPromises() - expect(getUrlForResource).toHaveBeenCalled() - expect(mocks.$previewService.loadPreview).not.toHaveBeenCalled() + ;(wrapper.vm as any).goToNext() + await flushPromises() + + // cat_murr_murr.gif was preloaded, only its new forward neighbor is left to load + expect(requestedNames(mocks)).toEqual([ + 'bear.png', + 'cat_murr_murr.gif', + 'sleeping_dog.gif', + 'elephant.png' + ]) }) - it('fetches SVG files via getUrlForResource instead of the preview service', async () => { - const { wrapper, mocks, getUrlForResource } = createShallowMountWrapper() - await nextTick() - mocks.$previewService.loadPreview.mockClear() - getUrlForResource.mockClear() - - const mediaFile = { - isImage: true, - mimeType: 'image/svg+xml', - resource: mock({ isInVault: false, hasPreview: () => true }) - } - await (wrapper.vm as any).loadPreviewImage(mediaFile) + it('does not load anything again while walking back and forth', async () => { + const { wrapper, mocks } = createShallowMountWrapper() + await flushPromises() - expect(getUrlForResource).toHaveBeenCalled() - expect(mocks.$previewService.loadPreview).not.toHaveBeenCalled() + ;(wrapper.vm as any).goToNext() + await flushPromises() + ;(wrapper.vm as any).goToPrev() + await flushPromises() + + // bear.png is back in the focus and so are its preloaded neighbors + expect(requestedNames(mocks)).toEqual([ + 'bear.png', + 'cat_murr_murr.gif', + 'sleeping_dog.gif', + 'elephant.png' + ]) }) }) @@ -271,37 +258,46 @@ describe('Preview app', () => { }) }) +const requestedNames = (mocks: ReturnType) => + mocks.$previewService.loadPreview.mock.calls.map(([{ resource }]) => resource.name) + function createShallowMountWrapper({ currentFileContext }: { currentFileContext?: Partial } = {}) { const mocks = defaultComponentMocks() - mocks.$previewService.loadPreview.mockResolvedValue('') - vi.mocked(queryItemAsString).mockImplementationOnce(() => '1') + // blob urls mirror what the preview service returns for private spaces and keep the + // browser cache warm-up out of the way, it has its own tests + mocks.$previewService.loadPreview.mockImplementation(({ resource }) => + Promise.resolve(`blob:preview-${resource.name}`) + ) const getUrlForResource = vi.fn() const revokeUrl = vi.fn() + const wrapper = shallowMount(App, { + props: { + currentFileContext: mock({ + path: 'personal/admin/bear.png', + ...currentFileContext + }), + activeFiles, + isFolderLoading: true, + revokeUrl, + getUrlForResource, + loadFolderForFileContext: vi.fn() + }, + global: { + plugins: [...defaultPlugins()], + mocks, + provide: mocks + } + }) + wrappers.push(wrapper) + return { - wrapper: shallowMount(App, { - props: { - currentFileContext: mock({ - path: 'personal/admin/bear.png', - ...currentFileContext - }), - activeFiles, - isFolderLoading: true, - revokeUrl, - getUrlForResource, - loadFolderForFileContext: vi.fn() - }, - global: { - plugins: [...defaultPlugins()], - mocks, - provide: mocks - } - }), + wrapper, mocks, getUrlForResource, revokeUrl diff --git a/packages/web-app-preview/tests/unit/composables/useMediaFileLoader.spec.ts b/packages/web-app-preview/tests/unit/composables/useMediaFileLoader.spec.ts new file mode 100644 index 00000000000..f98837b30ce --- /dev/null +++ b/packages/web-app-preview/tests/unit/composables/useMediaFileLoader.spec.ts @@ -0,0 +1,691 @@ +import { ref, unref } from 'vue' +import { flushPromises } from '@vue/test-utils' +import { + defaultComponentMocks, + getComposableWrapper, + useGetMatchingSpaceMock +} from '@opencloud-eu/web-test-helpers' +import { useGetMatchingSpace } from '@opencloud-eu/web-pkg' +import { Resource, SpaceResource } from '@opencloud-eu/web-client' +import { mock } from 'vitest-mock-extended' +import { useMediaFileLoader } from '../../../src/composables' +import { MediaFile } from '../../../src/helpers/types' + +vi.mock('@opencloud-eu/web-pkg', async (importOriginal) => ({ + ...(await importOriginal()), + useGetMatchingSpace: vi.fn() +})) + +/** + * Records every instance so warm-ups can be settled by hand. `autoLoad` mirrors a + * browser that fetches and decodes an image without further interaction. + */ +class MockImage { + static instances: MockImage[] = [] + static autoLoad = true + + onload: (() => void) | null = null + onerror: (() => void) | null = null + private currentSrc = '' + + constructor() { + MockImage.instances.push(this) + } + + get src() { + return this.currentSrc + } + + set src(value: string) { + this.currentSrc = value + if (MockImage.autoLoad) { + queueMicrotask(() => this.onload?.()) + } + } + + triggerLoad = () => this.onload?.() + triggerError = () => this.onerror?.() +} + +const space = mock() + +const createFile = (overrides: Partial = {}): MediaFile => { + const name = overrides.name ?? 'bear.png' + return { + id: name, + name, + ext: 'png', + mimeType: 'image/png', + isVideo: false, + isImage: true, + isAudio: false, + isMotionPhoto: false, + isLoading: true, + isError: false, + resource: mock({ id: name, name, etag: `etag-${name}`, isInVault: false }), + ...overrides + } as MediaFile +} + +const createFiles = (files: Partial[]) => files.map((file) => createFile(file)) + +type CreateWrapperOptions = { + files?: Partial[] + activeIndex?: number +} + +function createWrapper({ files = [createFile()], activeIndex = 0 }: CreateWrapperOptions = {}) { + const mocks = defaultComponentMocks() + mocks.$previewService.loadPreview.mockImplementation(({ resource }) => + Promise.resolve(`preview-url-${resource.name}`) + ) + + const mediaFiles = ref(files.map((file) => createFile(file))) + const index = ref(activeIndex) + const getUrlForResource = vi.fn().mockResolvedValue('full-url') + + const wrapper = getComposableWrapper( + () => ({ loader: useMediaFileLoader({ mediaFiles, activeIndex: index, getUrlForResource }) }), + { mocks, provide: mocks } + ) + + return { + wrapper, + mediaFiles, + activeIndex: index, + getUrlForResource, + mocks, + loader: (wrapper.vm as unknown as { loader: ReturnType }).loader + } +} + +/** MediaFiles are reactive proxies once held in a ref, always address them via the ref. */ +const requestedNames = (mocks: ReturnType) => + mocks.$previewService.loadPreview.mock.calls.map(([{ resource }]) => resource.name) + +const signalsOf = (mocks: ReturnType) => + mocks.$previewService.loadPreview.mock.calls.map((call) => call[3] as AbortSignal) + +describe('useMediaFileLoader', () => { + beforeEach(() => { + MockImage.instances = [] + MockImage.autoLoad = true + vi.stubGlobal('Image', MockImage) + vi.mocked(useGetMatchingSpace).mockReturnValue( + useGetMatchingSpaceMock({ getMatchingSpace: () => space }) + ) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + describe('method "loadPreviewImage"', () => { + it('does not load a file that already has a url', async () => { + const { wrapper, loader, mocks } = createWrapper() + + await loader.loadPreviewImage(createFile({ url: 'existing-url' })) + + expect(mocks.$previewService.loadPreview).not.toHaveBeenCalled() + wrapper.unmount() + }) + + it('resolves the url and clears the loading state', async () => { + const { wrapper, loader, mediaFiles } = createWrapper() + const mediaFile = unref(mediaFiles)[0] + + await loader.loadPreviewImage(mediaFile) + + expect(mediaFile.url).toBe('preview-url-bear.png') + expect(mediaFile.isLoading).toBe(false) + expect(mediaFile.isError).toBe(false) + wrapper.unmount() + }) + + it('joins an in-flight request instead of issuing a second one', async () => { + const { wrapper, loader, mocks, mediaFiles } = createWrapper() + const mediaFile = unref(mediaFiles)[0] + let resolvePreview: (url: string) => void + mocks.$previewService.loadPreview.mockImplementationOnce( + () => new Promise((resolve) => (resolvePreview = resolve)) + ) + + const first = loader.loadPreviewImage(mediaFile) + const second = loader.loadPreviewImage(mediaFile) + resolvePreview('preview-url') + await Promise.all([first, second]) + + expect(mocks.$previewService.loadPreview).toHaveBeenCalledTimes(1) + expect(mediaFile.url).toBe('preview-url') + wrapper.unmount() + }) + + it('marks the file as errored when the request fails', async () => { + const { wrapper, loader, mocks, mediaFiles } = createWrapper() + const mediaFile = unref(mediaFiles)[0] + mocks.$previewService.loadPreview.mockRejectedValueOnce(new Error('failed')) + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined) + + await loader.loadPreviewImage(mediaFile) + + expect(mediaFile.isError).toBe(true) + expect(mediaFile.isLoading).toBe(false) + consoleError.mockRestore() + wrapper.unmount() + }) + + it('leaves an aborted request loading so it retries when navigated to', async () => { + const { wrapper, loader, mocks, mediaFiles } = createWrapper() + const mediaFile = unref(mediaFiles)[0] + mocks.$previewService.loadPreview.mockImplementation(() => new Promise(() => undefined)) + + loader.loadPreviewImage(mediaFile) + await flushPromises() + wrapper.unmount() + await flushPromises() + + expect(mediaFile.isLoading).toBe(true) + expect(mediaFile.isError).toBe(false) + expect(mediaFile.url).toBeUndefined() + }) + + it('clears a stale error state once the file loads successfully', async () => { + const { wrapper, loader } = createWrapper() + const mediaFile = createFile({ isError: true }) + + await loader.loadPreviewImage(mediaFile) + + expect(mediaFile.isError).toBe(false) + expect(mediaFile.url).toBe('preview-url-bear.png') + wrapper.unmount() + }) + + it('resolves the space of the file being loaded', async () => { + const { wrapper, loader, mocks } = createWrapper() + + await loader.loadPreviewImage(createFile()) + + expect(mocks.$previewService.loadPreview).toHaveBeenCalledWith( + expect.objectContaining({ space }), + expect.anything(), + expect.anything(), + expect.anything() + ) + wrapper.unmount() + }) + + it('uses getUrlForResource for vault images and svg files', async () => { + const { wrapper, loader, mocks, getUrlForResource } = createWrapper() + + await loader.loadPreviewImage(createFile({ resource: mock({ isInVault: true }) })) + await loader.loadPreviewImage(createFile({ name: 'logo.svg', mimeType: 'image/svg+xml' })) + + expect(getUrlForResource).toHaveBeenCalledTimes(2) + expect(mocks.$previewService.loadPreview).not.toHaveBeenCalled() + wrapper.unmount() + }) + }) + + describe('method "preloadNeighbors"', () => { + it('loads the forward neighbor first and the backward one once it settled', async () => { + const { wrapper, loader, mocks } = createWrapper({ + files: createFiles([ + { name: 'a.png' }, + { name: 'b.png' }, + { name: 'c.png' }, + { name: 'd.png' } + ]), + activeIndex: 1 + }) + let resolvePreview: (url: string) => void + mocks.$previewService.loadPreview.mockImplementationOnce( + () => new Promise((resolve) => (resolvePreview = resolve)) + ) + + loader.preloadNeighbors() + await flushPromises() + expect(requestedNames(mocks)).toEqual(['c.png']) + + resolvePreview('preview-url-c.png') + await flushPromises() + expect(requestedNames(mocks)).toEqual(['c.png', 'a.png']) + wrapper.unmount() + }) + + it('loads the backward neighbor even if the forward one fails', async () => { + const { wrapper, loader, mocks } = createWrapper({ + files: createFiles([{ name: 'a.png' }, { name: 'b.png' }, { name: 'c.png' }]), + activeIndex: 1 + }) + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined) + mocks.$previewService.loadPreview.mockRejectedValueOnce(new Error('failed')) + + loader.preloadNeighbors() + await flushPromises() + + expect(requestedNames(mocks)).toEqual(['c.png', 'a.png']) + consoleError.mockRestore() + wrapper.unmount() + }) + + it('wraps around at both ends of the list', async () => { + const files = createFiles([{ name: 'a.png' }, { name: 'b.png' }, { name: 'c.png' }]) + + const first = createWrapper({ files, activeIndex: 0 }) + first.loader.preloadNeighbors() + await flushPromises() + expect(requestedNames(first.mocks)).toEqual(['b.png', 'c.png']) + first.wrapper.unmount() + + const last = createWrapper({ files, activeIndex: 2 }) + last.loader.preloadNeighbors() + await flushPromises() + expect(requestedNames(last.mocks)).toEqual(['a.png', 'b.png']) + last.wrapper.unmount() + }) + + it('requests a single neighbor for a two file list', async () => { + const { wrapper, loader, mocks } = createWrapper({ + files: createFiles([{ name: 'a.png' }, { name: 'b.png' }]), + activeIndex: 0 + }) + + loader.preloadNeighbors() + await flushPromises() + + expect(requestedNames(mocks)).toEqual(['b.png']) + wrapper.unmount() + }) + + it('does not preload anything for a single file list', async () => { + const { wrapper, loader, mocks } = createWrapper() + + loader.preloadNeighbors() + await flushPromises() + + expect(mocks.$previewService.loadPreview).not.toHaveBeenCalled() + wrapper.unmount() + }) + + it('does not preload videos, audio, vault images or svg files', async () => { + const { wrapper, loader, mocks, getUrlForResource } = createWrapper({ + files: createFiles([ + { name: 'a.png' }, + { name: 'clip.mp4', mimeType: 'video/mp4', isVideo: true, isImage: false }, + { name: 'song.mp3', mimeType: 'audio/mpeg', isAudio: true, isImage: false }, + { name: 'secret.png', resource: mock({ isInVault: true }) }, + { name: 'logo.svg', mimeType: 'image/svg+xml' } + ]), + activeIndex: 0 + }) + + loader.preloadNeighbors() + await flushPromises() + + expect(requestedNames(mocks)).toEqual([]) + expect(getUrlForResource).not.toHaveBeenCalled() + wrapper.unmount() + }) + + it('does not preload the active file itself', async () => { + const { wrapper, loader, mocks } = createWrapper({ + files: createFiles([{ name: 'a.png' }, { name: 'b.png' }]), + activeIndex: 0 + }) + + loader.preloadNeighbors() + await flushPromises() + + expect(requestedNames(mocks)).not.toContain('a.png') + wrapper.unmount() + }) + + it('does not refetch a neighbor that was preloaded already', async () => { + const { wrapper, loader, mocks } = createWrapper({ + files: createFiles([{ name: 'a.png' }, { name: 'b.png' }]), + activeIndex: 0 + }) + + loader.preloadNeighbors() + await flushPromises() + expect(mocks.$previewService.loadPreview).toHaveBeenCalledTimes(1) + + loader.preloadNeighbors() + await flushPromises() + + expect(mocks.$previewService.loadPreview).toHaveBeenCalledTimes(1) + wrapper.unmount() + }) + + it('does not preload the backward neighbor after the active index changed', async () => { + const { wrapper, loader, mocks, activeIndex } = createWrapper({ + files: createFiles([ + { name: 'a.png' }, + { name: 'b.png' }, + { name: 'c.png' }, + { name: 'd.png' } + ]), + activeIndex: 1 + }) + let resolvePreview: (url: string) => void + mocks.$previewService.loadPreview.mockImplementationOnce( + () => new Promise((resolve) => (resolvePreview = resolve)) + ) + + loader.preloadNeighbors() + await flushPromises() + expect(requestedNames(mocks)).toEqual(['c.png']) + + // the user jumped elsewhere while the forward neighbor was still loading + activeIndex.value = 3 + resolvePreview('preview-url-c.png') + await flushPromises() + + expect(requestedNames(mocks)).toEqual(['c.png']) + wrapper.unmount() + }) + + it('does not preload the backward neighbor after the file list was rebuilt', async () => { + const { wrapper, loader, mocks, mediaFiles } = createWrapper({ + files: createFiles([ + { name: 'a.png' }, + { name: 'b.png' }, + { name: 'c.png' }, + { name: 'd.png' } + ]), + activeIndex: 1 + }) + let resolvePreview: (url: string) => void + mocks.$previewService.loadPreview.mockImplementationOnce( + () => new Promise((resolve) => (resolvePreview = resolve)) + ) + + loader.preloadNeighbors() + await flushPromises() + expect(requestedNames(mocks)).toEqual(['c.png']) + + // a rebuilt list means the captured neighbors are orphans, even at the same index + mediaFiles.value = createFiles([ + { name: 'a.png' }, + { name: 'b.png' }, + { name: 'c.png' }, + { name: 'd.png' } + ]) + resolvePreview('preview-url-c.png') + await flushPromises() + + expect(requestedNames(mocks)).toEqual(['c.png']) + wrapper.unmount() + }) + }) + + describe('method "cancelStaleLoads"', () => { + it('aborts loads outside the active position and its neighbors', async () => { + const { wrapper, loader, mocks, mediaFiles, activeIndex } = createWrapper({ + files: createFiles([ + { name: 'a.png' }, + { name: 'b.png' }, + { name: 'c.png' }, + { name: 'd.png' } + ]), + activeIndex: 1 + }) + mocks.$previewService.loadPreview.mockImplementation(() => new Promise(() => undefined)) + + unref(mediaFiles).forEach((file) => loader.loadPreviewImage(file)) + await flushPromises() + + activeIndex.value = 3 + loader.cancelStaleLoads() + + // a.png is d.png's backward neighbor thanks to the wrap, b.png is the only + // request that is neither d.png nor one of its neighbors + expect(signalsOf(mocks).map((signal) => signal.aborted)).toEqual([false, true, false, false]) + wrapper.unmount() + }) + + it('keeps loads for the active file and both neighbors', async () => { + const { wrapper, loader, mocks, mediaFiles } = createWrapper({ + files: createFiles([{ name: 'a.png' }, { name: 'b.png' }, { name: 'c.png' }]), + activeIndex: 1 + }) + mocks.$previewService.loadPreview.mockImplementation(() => new Promise(() => undefined)) + + unref(mediaFiles).forEach((file) => loader.loadPreviewImage(file)) + await flushPromises() + loader.cancelStaleLoads() + + expect(signalsOf(mocks).every((signal) => !signal.aborted)).toBe(true) + wrapper.unmount() + }) + + it('settles and stops a warm-up download when its load becomes stale', async () => { + MockImage.autoLoad = false + const { wrapper, loader, mocks, mediaFiles } = createWrapper({ + files: createFiles([ + { name: 'a.png' }, + { name: 'b.png' }, + { name: 'c.png' }, + { name: 'd.png' } + ]), + activeIndex: 1 + }) + // public-link style: loadPreview resolves a remote url, the warm-up downloads + mocks.$previewService.loadPreview.mockResolvedValue('https://example.org/d.jpg') + + // index 3 is no neighbor of the active index 1, its load is stale by definition + const staleFile = unref(mediaFiles)[3] + const load = loader.loadPreviewImage(staleFile) + await flushPromises() + expect(MockImage.instances).toHaveLength(1) + + loader.cancelStaleLoads() + + // the abort listener clears src synchronously: the download is stopped + expect(MockImage.instances[0].src).toBe('') + await load + + expect(staleFile.url).toBeUndefined() + expect(staleFile.isLoading).toBe(true) + // the abort must not be misread as a stale url: no second resolution + expect(mocks.$previewService.loadPreview).toHaveBeenCalledTimes(1) + wrapper.unmount() + }) + + it('does not start a warm-up when the url resolved after the load was aborted', async () => { + const { wrapper, loader, mocks, mediaFiles } = createWrapper({ + files: createFiles([ + { name: 'a.png' }, + { name: 'b.png' }, + { name: 'c.png' }, + { name: 'd.png' } + ]), + activeIndex: 1 + }) + let resolvePreview: (url: string) => void + mocks.$previewService.loadPreview.mockImplementationOnce( + () => new Promise((resolve) => (resolvePreview = resolve)) + ) + + const staleFile = unref(mediaFiles)[3] + const load = loader.loadPreviewImage(staleFile) + await flushPromises() + + // not every url resolver is signal-aware: a resolution may arrive after abort + loader.cancelStaleLoads() + resolvePreview('https://example.org/d.jpg') + await load + + expect(MockImage.instances).toHaveLength(0) + expect(staleFile.url).toBeUndefined() + expect(staleFile.isLoading).toBe(true) + wrapper.unmount() + }) + }) + + describe('unmount', () => { + it('aborts all in-flight loads', async () => { + const { wrapper, loader, mocks, mediaFiles } = createWrapper({ + files: createFiles([{ name: 'a.png' }, { name: 'b.png' }]), + activeIndex: 0 + }) + mocks.$previewService.loadPreview.mockImplementation(() => new Promise(() => undefined)) + + unref(mediaFiles).forEach((file) => loader.loadPreviewImage(file)) + await flushPromises() + wrapper.unmount() + + expect(signalsOf(mocks).every((signal) => signal.aborted)).toBe(true) + }) + + it('does not start new requests afterwards', async () => { + const { wrapper, loader, mocks, mediaFiles } = createWrapper({ + files: createFiles([{ name: 'a.png' }, { name: 'b.png' }]), + activeIndex: 0 + }) + + wrapper.unmount() + mocks.$previewService.loadPreview.mockClear() + await loader.loadPreviewImage(unref(mediaFiles)[1]) + + expect(mocks.$previewService.loadPreview).not.toHaveBeenCalled() + }) + + it('settles a pending warm-up without resolving a new url', async () => { + MockImage.autoLoad = false + const { wrapper, loader, mocks, mediaFiles } = createWrapper({ + files: createFiles([{ name: 'a.png' }, { name: 'b.png' }]), + activeIndex: 0 + }) + + const load = loader.loadPreviewImage(unref(mediaFiles)[0]) + await flushPromises() + expect(MockImage.instances).toHaveLength(1) + + wrapper.unmount() + await load + + expect(mocks.$previewService.loadPreview).toHaveBeenCalledTimes(1) + // unmount must stop the download too, not just settle the promise + expect(MockImage.instances[0].src).toBe('') + }) + }) + + describe('browser cache warm-up', () => { + it('does not create an image for blob urls, they carry the bytes already', async () => { + const { wrapper, loader, mocks } = createWrapper() + mocks.$previewService.loadPreview.mockResolvedValue('blob:preview-url') + + await loader.loadPreviewImage(createFile()) + + expect(MockImage.instances).toHaveLength(0) + wrapper.unmount() + }) + + it('warms the cache for remote urls', async () => { + MockImage.autoLoad = false + const { wrapper, loader, mocks } = createWrapper() + mocks.$previewService.loadPreview.mockResolvedValue('https://example.org/preview.jpg') + const mediaFile = createFile() + + const load = loader.loadPreviewImage(mediaFile) + await flushPromises() + + expect(MockImage.instances.map((image) => image.src)).toEqual([ + 'https://example.org/preview.jpg' + ]) + + MockImage.instances[0].triggerLoad() + await load + + expect(mediaFile.url).toBe('https://example.org/preview.jpg') + expect(mediaFile.isLoading).toBe(false) + wrapper.unmount() + }) + + it('resolves a fresh url once when the warm-up hits a stale one', async () => { + MockImage.autoLoad = false + const { wrapper, loader, mocks } = createWrapper() + mocks.$previewService.loadPreview + .mockResolvedValueOnce('https://example.org/expired.jpg') + .mockResolvedValueOnce('https://example.org/fresh.jpg') + const mediaFile = createFile() + + const load = loader.loadPreviewImage(mediaFile) + await flushPromises() + + MockImage.instances[0].triggerError() + await flushPromises() + + expect(mocks.$previewService.loadPreview).toHaveBeenCalledTimes(2) + expect(MockImage.instances.map((image) => image.src)).toEqual([ + 'https://example.org/expired.jpg', + 'https://example.org/fresh.jpg' + ]) + + MockImage.instances[1].triggerLoad() + await load + + expect(mediaFile.url).toBe('https://example.org/fresh.jpg') + expect(mediaFile.isError).toBe(false) + expect(mediaFile.isLoading).toBe(false) + wrapper.unmount() + }) + + it('keeps the resolved url when the warm-up keeps failing', async () => { + MockImage.autoLoad = false + const { wrapper, loader, mocks } = createWrapper() + mocks.$previewService.loadPreview + .mockResolvedValueOnce('https://example.org/first.jpg') + .mockResolvedValueOnce('https://example.org/second.jpg') + const mediaFile = createFile() + + const load = loader.loadPreviewImage(mediaFile) + await flushPromises() + MockImage.instances[0].triggerError() + await flushPromises() + MockImage.instances[1].triggerError() + await load + + // the warm-up is an optimization, the file still renders + expect(mocks.$previewService.loadPreview).toHaveBeenCalledTimes(2) + expect(mediaFile.url).toBe('https://example.org/second.jpg') + expect(mediaFile.isError).toBe(false) + expect(mediaFile.isLoading).toBe(false) + wrapper.unmount() + }) + + it('does not warm the cache for videos, audio or svg files', async () => { + const { wrapper, loader, mocks, getUrlForResource } = createWrapper() + + const video = createFile({ + name: 'clip.mp4', + mimeType: 'video/mp4', + isVideo: true, + isImage: false + }) + const audio = createFile({ + name: 'song.mp3', + mimeType: 'audio/mpeg', + isAudio: true, + isImage: false + }) + const svg = createFile({ name: 'logo.svg', mimeType: 'image/svg+xml' }) + + await loader.loadPreviewImage(video) + await loader.loadPreviewImage(audio) + await loader.loadPreviewImage(svg) + + expect(MockImage.instances).toHaveLength(0) + // one resolution each, no decode-failure retry loop + expect(getUrlForResource).toHaveBeenCalledTimes(3) + // skipping the warm-up must not break the load itself + expect(mocks.$previewService.loadPreview).not.toHaveBeenCalled() + for (const file of [video, audio, svg]) { + expect(file.url).toBe('full-url') + expect(file.isLoading).toBe(false) + } + wrapper.unmount() + }) + }) +})