diff --git a/immichFrame.Web/src/lib/components/elements/asset-component.svelte b/immichFrame.Web/src/lib/components/elements/asset-component.svelte index 7f02e36b..66e8f92a 100644 --- a/immichFrame.Web/src/lib/components/elements/asset-component.svelte +++ b/immichFrame.Web/src/lib/components/elements/asset-component.svelte @@ -32,6 +32,7 @@ playAudio?: boolean; onVideoWaiting?: () => void; onVideoPlaying?: () => void; + onAssetError?: () => void; } let { @@ -53,7 +54,8 @@ showInfo = $bindable(false), playAudio = false, onVideoWaiting = () => {}, - onVideoPlaying = () => {} + onVideoPlaying = () => {}, + onAssetError = () => {} }: Props = $props(); let instantTransition = slideshowStore.instantTransition; let transitionDuration = $derived( @@ -119,6 +121,7 @@ {playAudio} {onVideoWaiting} {onVideoPlaying} + {onAssetError} bind:this={primaryAssetComponent} bind:showInfo /> @@ -140,6 +143,7 @@ {playAudio} {onVideoWaiting} {onVideoPlaying} + {onAssetError} bind:this={secondaryAssetComponent} bind:showInfo /> @@ -163,6 +167,7 @@ {playAudio} {onVideoWaiting} {onVideoPlaying} + {onAssetError} bind:this={primaryAssetComponent} bind:showInfo /> diff --git a/immichFrame.Web/src/lib/components/elements/asset.svelte b/immichFrame.Web/src/lib/components/elements/asset.svelte index 14ee6465..fad28e55 100644 --- a/immichFrame.Web/src/lib/components/elements/asset.svelte +++ b/immichFrame.Web/src/lib/components/elements/asset.svelte @@ -1,4 +1,5 @@ {#if !hidden} @@ -86,6 +79,6 @@ id="progressbar" class="fixed left-0 h-[3px] bg-primary z-[1000] {location == ProgressBarLocation.Top ? 'top-0' : 'bottom-0'}" - style:width={`${$progress * 100}%`} + style:width={`${progress.current * 100}%`} > {/if} diff --git a/immichFrame.Web/src/lib/components/home-page/home-page.svelte b/immichFrame.Web/src/lib/components/home-page/home-page.svelte index fa183550..79c9b68b 100644 --- a/immichFrame.Web/src/lib/components/home-page/home-page.svelte +++ b/immichFrame.Web/src/lib/components/home-page/home-page.svelte @@ -26,11 +26,14 @@ api.init(); - // TODO: make this configurable? const PRELOAD_ASSETS = 5; + const TRANSITION_WATCHDOG_MS = 10000; + const VIDEO_STALL_MS = 15000; + const CURSOR_HIDE_MS = 2000; + const RELOAD_ON_ERROR_MS = 30000; - let assetHistory: api.AssetResponseDto[] = []; - let assetBacklog: api.AssetResponseDto[] = []; + let assetHistory: api.AssetResponseDto[] = $state([]); + let assetBacklog: api.AssetResponseDto[] = $state([]); let displayingAssets: api.AssetResponseDto[] = $state([]); @@ -40,6 +43,11 @@ let progressBar: ProgressBar = $state() as ProgressBar; let assetComponent: AssetComponentInstance = $state() as AssetComponentInstance; let currentDuration: number = $state($configStore.interval ?? 20); + + let watchdogTimer: number | undefined; + let videoStallTimeout: number | undefined; + let timeoutId: number | undefined; + let userPaused: boolean = $state(false); let error: boolean = $state(false); @@ -63,7 +71,6 @@ let refreshInterval: number; let cursorVisible = $state(true); - let timeoutId: number; const clientIdentifier = page.url.searchParams.get('client'); const authsecret = page.url.searchParams.get('authsecret'); @@ -93,7 +100,7 @@ const showCursor = () => { cursorVisible = true; clearTimeout(timeoutId); - timeoutId = setTimeout(hideCursor, 2000); + timeoutId = window.setTimeout(hideCursor, CURSOR_HIDE_MS); }; async function updateAssetPromises() { @@ -116,16 +123,14 @@ !displayingAssets.find((item) => item.id === key) && !assetBacklog.find((item) => item.id === key) ); - for (const key of keysToRemove) { - try { - const [url] = await assetPromisesDict[key]; - revokeObjectUrl(url); - } catch (err) { - console.warn('Failed to resolve asset during cleanup:', err); - } finally { - delete assetPromisesDict[key]; - } - } + + keysToRemove.forEach((key) => { + const promise = assetPromisesDict[key]; + delete assetPromisesDict[key]; + promise + .then(([url]) => revokeObjectUrl(url)) + .catch((err) => console.warn('Failed to resolve asset during cleanup:', err)); + }); } async function loadAssets() { @@ -149,23 +154,58 @@ } } - let isHandlingAssetTransition = false; + let isHandlingAssetTransition = $state(false); + let transitionEpoch = 0; + let pendingTransition: { previous: boolean; instant: boolean } | null = $state(null); + const handleDone = async (previous: boolean = false, instant: boolean = false) => { if (isHandlingAssetTransition) { + pendingTransition = { previous, instant }; return; } + + const currentEpoch = ++transitionEpoch; isHandlingAssetTransition = true; + + clearTimeout(watchdogTimer); + clearTimeout(videoStallTimeout); + // Watchdog: If the transition (fetching/loading assets) hangs, force-release the lock. + watchdogTimer = window.setTimeout(() => { + if (currentEpoch === transitionEpoch && isHandlingAssetTransition) { + console.error('Transition watchdog triggered: Force-resetting lock due to hang'); + isHandlingAssetTransition = false; + + if (pendingTransition) { + const next = pendingTransition; + pendingTransition = null; + handleDone(next.previous, next.instant); + } + } + }, TRANSITION_WATCHDOG_MS); + try { userPaused = false; progressBar.restart(false); $instantTransition = instant; if (previous) await getPreviousAssets(); else await getNextAssets(); - await tick(); + await tick(); + + if (currentEpoch !== transitionEpoch) return; + await assetComponent?.play?.(); progressBar.play(); } finally { - isHandlingAssetTransition = false; + if (currentEpoch === transitionEpoch) { + isHandlingAssetTransition = false; + clearTimeout(watchdogTimer); + + if (pendingTransition) { + const next = pendingTransition; + pendingTransition = null; + handleDone(next.previous, next.instant); + } + } } }; @@ -182,7 +222,6 @@ const useSplit = shouldUseSplitView(assetBacklog); const next = assetBacklog.splice(0, useSplit ? 2 : 1); - assetBacklog = [...assetBacklog]; if (displayingAssets.length) { assetHistory.push(...displayingAssets); @@ -204,7 +243,6 @@ const useSplit = shouldUseSplitView(assetHistory.slice(-2)); const next = assetHistory.splice(useSplit ? -2 : -1); - assetHistory = [...assetHistory]; if (displayingAssets.length) { assetBacklog.unshift(...displayingAssets); @@ -392,7 +430,7 @@ // 30 second reload on error refreshInterval = window.setInterval(() => { if (error) window.location.reload(); - }, 30000); + }, RELOAD_ON_ERROR_MS); if ($configStore.primaryColor) { document.documentElement.style.setProperty('--primary-color', $configStore.primaryColor); @@ -426,6 +464,9 @@ window.removeEventListener('mousemove', showCursor); window.removeEventListener('click', showCursor); window.clearInterval(refreshInterval); + window.clearTimeout(timeoutId); + window.clearTimeout(videoStallTimeout); + window.clearTimeout(watchdogTimer); }; }); @@ -473,12 +514,25 @@ playAudio={$configStore.playAudio} onVideoWaiting={async () => { await progressBar.pause(); + clearTimeout(videoStallTimeout); + if (userPaused) return; + + videoStallTimeout = window.setTimeout(() => { + if (!userPaused) { + console.warn('Video stalled, skipping...'); + handleDone(false, true); + } + }, Math.max(5000, Math.min(VIDEO_STALL_MS, currentDuration * 1000))); }} onVideoPlaying={async () => { + clearTimeout(videoStallTimeout); if (!userPaused) { await progressBar.play(); } }} + onAssetError={async () => { + await handleDone(false, true); + }} />