Skip to content
Merged
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
42 changes: 40 additions & 2 deletions packages/web-app-preview/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
v-else-if="activeMediaFile.isVideo"
:file="activeMediaFile"
:is-auto-play-enabled="isAutoPlayEnabled"
@reload-url="reloadMediaFileUrl(activeMediaFile)"
/>
<media-audio
v-else-if="activeMediaFile.isAudio"
Expand Down Expand Up @@ -187,6 +188,7 @@ const preview = useTemplateRef<HTMLElement>('preview')
const motionPlayer = useTemplateRef<{ isPlaying: boolean; toggle: () => void }>('motionPlayer')
const keyBindings: string[] = []
let loadPreviewImageController: AbortController = null
let reloadUrlController: AbortController = null

const space = computed(() => {
if (!unref(activeMediaFile)) {
Expand Down Expand Up @@ -324,6 +326,41 @@ const loadPreviewImage = async (mediaFile: MediaFile) => {
}
}

/** Signed URLs expire, so fetch a fresh one on demand. */
async function reloadMediaFileUrl(mediaFile: MediaFile) {
reloadUrlController?.abort()
reloadUrlController = new AbortController()
const { signal } = reloadUrlController

try {
const url = await getUrlForResource(
getMatchingSpace(mediaFile.resource),
// don't pass the cached download URL to force a new signature
{ ...mediaFile.resource, downloadURL: undefined },
{ signal }
)

if (signal.aborted) {
revokeUrl(url)
return
}

revokeUrl(mediaFile.url)
mediaFile.url = url
} catch (e) {
if (e.name === 'CanceledError') {
return
}

console.error(e)
mediaFile.isError = true
} finally {
if (reloadUrlController?.signal === signal) {
reloadUrlController = null
}
}
}

const goToNext = () => {
if (unref(activeIndex) + 1 >= unref(mediaFiles).length) {
activeIndex.value = 0
Expand Down Expand Up @@ -476,10 +513,11 @@ onBeforeUnmount(() => {
removeKeyAction(keyBindingId)
})

loadPreviewImageController?.abort()
reloadUrlController?.abort()

Object.values(unref(mediaFiles)).forEach((cachedFile) => {
revokeUrl(unref(cachedFile.url))
})

loadPreviewImageController?.abort()
})
</script>
72 changes: 70 additions & 2 deletions packages/web-app-preview/src/components/Sources/MediaVideo.vue
Comment thread
kulmann marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,25 @@
controls
preload="preload"
:autoplay="isAutoPlayEnabled"
@error="onError"
@canplay="onCanPlay"
>
<source :src="file.url" :type="sourceType" />
<source :src="file.url" :type="sourceType" @error="onError" />
</video>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, useTemplateRef } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, unref, useTemplateRef, watch } from 'vue'
import { MediaFile } from '../../helpers/types'

const { file, isAutoPlayEnabled = true } = defineProps<{
file: MediaFile
isAutoPlayEnabled?: boolean
}>()

const emit = defineEmits<{
(e: 'reload-url'): void
}>()

const video = useTemplateRef('video')
const resizeVideoDimensions = () => {
const stageMedia: HTMLElement = document.querySelector('.stage_media')
Expand All @@ -35,6 +41,68 @@ const sourceType = computed(() => {
return file.mimeType
})

// blob URLs (e.g. vault files) don't expire, so a retry can't fix them
const isSignedUrl = computed(() => !!file.url && !file.url.startsWith('blob:'))

// retry a video on error after updating the source with a new signed URL
let isRetryInProgress = false
let resumeTime = 0
let resumePlaying = false

function onCanPlay() {
isRetryInProgress = false
}

function onError() {
Comment thread
JammingBen marked this conversation as resolved.
if (isRetryInProgress || !unref(isSignedUrl)) {
return
}

isRetryInProgress = true
resumeTime = video.value?.currentTime || 0
resumePlaying = !!video.value && !video.value.paused
emit('reload-url')
}

watch(
() => file.id,
() => {
isRetryInProgress = false
resumeTime = 0
resumePlaying = false
}
)

watch(
() => file.url,
async (url) => {
if (!isRetryInProgress || !url) {
return
}

await nextTick()

const element = video.value
if (!element) {
return
}

element.addEventListener(
'loadedmetadata',
() => {
element.currentTime = resumeTime
if (resumePlaying) {
element.play().catch(() => {
console.error('Failed to resume video playback after retry.')
})
}
},
{ once: true }
)
element.load()
}
)

onMounted(() => {
resizeVideoDimensions()
window.addEventListener('resize', resizeVideoDimensions)
Expand Down
90 changes: 88 additions & 2 deletions packages/web-app-preview/tests/unit/app.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,90 @@ describe('Preview app', () => {
})
})

describe('Method "reloadMediaFileUrl"', () => {
it('fetches a fresh url without the cached download url and swaps it in', async () => {
const { wrapper, getUrlForResource, revokeUrl } = createShallowMountWrapper()
await nextTick()
getUrlForResource.mockClear()
revokeUrl.mockClear()
getUrlForResource.mockResolvedValue('new-url')

const resource = { downloadURL: 'expired-url' } as Resource
const mediaFile = { url: 'old-url', resource }
await (wrapper.vm as any).reloadMediaFileUrl(mediaFile)

expect(getUrlForResource).toHaveBeenCalledWith(
// no matching space in the test store
undefined,
expect.objectContaining({ downloadURL: undefined }),
expect.objectContaining({ signal: expect.anything() })
)
// the store resource must stay untouched
expect(resource.downloadURL).toBe('expired-url')
expect(revokeUrl).toHaveBeenCalledWith('old-url')
expect(mediaFile.url).toBe('new-url')
})

it('aborts a previous reload and discards its late url', async () => {
const { wrapper, getUrlForResource, revokeUrl } = createShallowMountWrapper()
await nextTick()
getUrlForResource.mockClear()
revokeUrl.mockClear()

let resolveFirst: (url: string) => void
getUrlForResource
.mockImplementationOnce(() => new Promise((resolve) => (resolveFirst = resolve)))
.mockResolvedValueOnce('second-url')

const mediaFile = { url: 'old-url', resource: {} as Resource }
const firstReload = (wrapper.vm as any).reloadMediaFileUrl(mediaFile)
await (wrapper.vm as any).reloadMediaFileUrl(mediaFile)
resolveFirst('first-url')
await firstReload

expect(getUrlForResource.mock.calls[0][2].signal.aborted).toBe(true)
expect(revokeUrl).toHaveBeenCalledWith('first-url')
expect(mediaFile.url).toBe('second-url')
})

it('discards the url when the app unmounts while the reload is in flight', async () => {
const { wrapper, getUrlForResource, revokeUrl } = createShallowMountWrapper()
await nextTick()
getUrlForResource.mockClear()
revokeUrl.mockClear()

let resolveUrl: (url: string) => void
getUrlForResource.mockImplementationOnce(
() => new Promise((resolve) => (resolveUrl = resolve))
)

const mediaFile = { url: 'old-url', resource: {} as Resource }
const reload = (wrapper.vm as any).reloadMediaFileUrl(mediaFile)
wrapper.unmount()
resolveUrl('late-url')
await reload

expect(revokeUrl).toHaveBeenCalledWith('late-url')
expect(mediaFile.url).toBe('old-url')
})

it('keeps the current url when the request fails', async () => {
const { wrapper, getUrlForResource, revokeUrl } = createShallowMountWrapper()
await nextTick()
getUrlForResource.mockClear()
revokeUrl.mockClear()
getUrlForResource.mockRejectedValue(new Error('failed'))
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined)

const mediaFile = { url: 'old-url', resource: {} as Resource }
await (wrapper.vm as any).reloadMediaFileUrl(mediaFile)

expect(mediaFile.url).toBe('old-url')
expect(revokeUrl).not.toHaveBeenCalled()
consoleError.mockRestore()
})
})

describe('Generated "mediaFiles"', () => {
it('should hide hidden shares if the share visibility query is not set to "hidden"', () => {
const { wrapper } = createShallowMountWrapper()
Expand All @@ -197,6 +281,7 @@ function createShallowMountWrapper({
vi.mocked(queryItemAsString).mockImplementationOnce(() => '1')

const getUrlForResource = vi.fn()
const revokeUrl = vi.fn()

return {
wrapper: shallowMount(App, {
Expand All @@ -207,7 +292,7 @@ function createShallowMountWrapper({
}),
activeFiles,
isFolderLoading: true,
revokeUrl: vi.fn(),
revokeUrl,
getUrlForResource,
loadFolderForFileContext: vi.fn()
},
Expand All @@ -218,6 +303,7 @@ function createShallowMountWrapper({
}
}),
mocks,
getUrlForResource
getUrlForResource,
revokeUrl
}
}