{"release_id":"01a1c502-4ad1-0000-ec62-adc54d3c69aa","chunk_id":"01a1c502-4c10-7c00-ab72-1c0bf57b380d","file":"CtMw8XEA.mjs","mappings":"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","names":["startStoryIndex","IMAGE_DURATION_MS","IMAGE_LOAD_TIMEOUT_MS","ERROR_HOLD_MS","groupIndex","$.state","$.proxy","$$props","storyIndex","progress","paused","videoEl","mediaState","imageLoadTimer","errorTimer","errorTracked","rafId","lastTick","clearImageLoadTimer","clearErrorTimer","clearMediaTimers","onMediaReady","$.get","$.set","onMediaError","reason","trackVideoLoadError","scheduleErrorAdvance","advance","onVideoState","state","currentGroup","currentStory","preloadUrls","$.derived","urls","g","candidates","s","fmtRel","iso","ms","m","t","h","tick","now","dt","back","handleTap","e","rect","x","handleKey","recordView","storyId","$.user_effect","story","onMount","onDestroy","div","root_1","div_1","$.child","node_1","img","root_2","classes","$.event","$.replay_events","$.append","$$anchor","video","root_3","$$value","$$node","$$action_arg","videoLoadGuard","$.set_attribute","$$render","consequent","alternate","div_2","root_4","div_3","$0","div_4","root_5","p","p_1","consequent_1","consequent_2","$.reset","div_5","$.sibling","$.each","_","div_6","root_6","div_7","$.template_effect","styles","$.set_style","div_8","div_9","getAvatarUrl","DuelAvatar","node_3","a","text_2","span","button","div_10","root_7","$.set_text","text_4","consequent_3","div_11","node_4","u","img_1","root_8","$2","$.delegated","$$args","consequent_4"],"sources":["../../../../../../src/lib/components/post/StoryViewer.svelte"],"sourcesContent":["<script lang=\"ts\">\n\timport { t } from '$lib/i18n';\n\timport { onMount, onDestroy } from 'svelte';\n\timport DuelAvatar from '$lib/components/duel/DuelAvatar.svelte';\n\timport { getAvatarUrl } from '$lib/avatar';\n\timport { videoLoadGuard, type VideoLoadState } from '$lib/actions/video-load-guard';\n\timport { trackVideoLoadError } from '$lib/analytics';\n\timport type { StoryGroupView } from '$lib/types/story';\n\n\tinterface Props {\n\t\tgroups: StoryGroupView[];\n\t\tstartGroupIndex: number;\n\t\t/** Story-within-group offset for the opening group. Batch 20 added\n\t\t *  this so the rail can deep-link into the first unviewed frame\n\t\t *  (Instagram-style) instead of always starting at index 0. */\n\t\tstartStoryIndex?: number;\n\t\tviewerLoggedIn: boolean;\n\t\tonclose: () => void;\n\t\t/** Fired every time a new story becomes the active frame so the parent\n\t\t *  rail can persist viewed-state (e.g. localStorage). */\n\t\tonview?: (storyId: string) => void;\n\t}\n\n\tlet {\n\t\tgroups,\n\t\tstartGroupIndex,\n\t\tstartStoryIndex = 0,\n\t\tviewerLoggedIn,\n\t\tonclose,\n\t\tonview\n\t}: Props = $props();\n\n\tconst IMAGE_DURATION_MS = 5000;\n\t// Backstop for the <img> path only — an image that never fires load OR error.\n\t// Videos are covered by the shared `videoLoadGuard` action instead; this\n\t// matches that action's own default so the two paths can't silently diverge.\n\tconst IMAGE_LOAD_TIMEOUT_MS = 15000;\n\t// On a load failure, hold the \"couldn't load\" message just long enough to read,\n\t// then auto-advance so a single dead frame doesn't dead-end the whole run.\n\tconst ERROR_HOLD_MS = 1400;\n\n\tlet groupIndex = $state(startGroupIndex);\n\tlet storyIndex = $state(startStoryIndex);\n\tlet progress = $state(0); // 0..1\n\tlet paused = $state(false);\n\tlet videoEl: HTMLVideoElement | null = $state(null);\n\n\t// Per-frame media loading state. Images auto-advance on the clock even when\n\t// unloaded, but videos only advanced on `onended` — so a stalled/dead video\n\t// hung on black indefinitely. This drives a loader + error fallback for both.\n\tlet mediaState = $state<'loading' | 'ready' | 'error'>('loading');\n\tlet imageLoadTimer: ReturnType<typeof setTimeout> | null = null;\n\tlet errorTimer: ReturnType<typeof setTimeout> | null = null;\n\t// One `video_load_error` per frame (same guard MediaLightbox uses per open) —\n\t// the guard can re-arm on stalled/suspend/waiting, and we don't want a flaky\n\t// clip to emit a burst of events.\n\tlet errorTracked = false;\n\n\tlet rafId: number | null = null;\n\tlet lastTick = 0;\n\n\tfunction clearImageLoadTimer() {\n\t\tif (imageLoadTimer !== null) {\n\t\t\tclearTimeout(imageLoadTimer);\n\t\t\timageLoadTimer = null;\n\t\t}\n\t}\n\n\tfunction clearErrorTimer() {\n\t\tif (errorTimer !== null) {\n\t\t\tclearTimeout(errorTimer);\n\t\t\terrorTimer = null;\n\t\t}\n\t}\n\n\tfunction clearMediaTimers() {\n\t\tclearImageLoadTimer();\n\t\tclearErrorTimer();\n\t}\n\n\tfunction onMediaReady() {\n\t\t// Ignore a late load that resolves after we already gave up and moved on.\n\t\tif (mediaState === 'error') return;\n\t\tmediaState = 'ready';\n\t\tclearImageLoadTimer();\n\t}\n\n\tfunction onMediaError(reason: 'error' | 'timeout' = 'error') {\n\t\tclearImageLoadTimer();\n\t\tif (mediaState !== 'error') {\n\t\t\tmediaState = 'error';\n\t\t\tif (!errorTracked) {\n\t\t\t\terrorTracked = true;\n\t\t\t\ttrackVideoLoadError('story', reason, 'inline_video');\n\t\t\t}\n\t\t}\n\t\tscheduleErrorAdvance();\n\t}\n\n\t// Auto-advance past a dead frame — but NEVER while the viewer is holding it\n\t// paused with a centre tap. `tick()` respects `paused`; this used to be a bare\n\t// setTimeout, so a held frame still got flipped to error and skipped out from\n\t// under the viewer's finger. The watchdog effect re-arms this on release.\n\tfunction scheduleErrorAdvance() {\n\t\tif (errorTimer !== null || paused) return;\n\t\terrorTimer = setTimeout(() => {\n\t\t\terrorTimer = null;\n\t\t\tif (mediaState === 'error' && !paused) advance();\n\t\t}, ERROR_HOLD_MS);\n\t}\n\n\t// Videos go through the shared `videoLoadGuard` action rather than a local\n\t// timeout: it also carries the iOS Safari Low Power Mode escape hatch\n\t// (`readyState >= 1` ⇒ ready), which matters here because the effect below\n\t// sets `muted = false` before `play()`, so iOS rejects the first autoplay and\n\t// the real load only starts after the muted retry. A hand-rolled 8s budget\n\t// would have skipped perfectly good clips on that path.\n\tfunction onVideoState(state: VideoLoadState, reason?: 'error' | 'timeout') {\n\t\tif (state === 'error') onMediaError(reason ?? 'error');\n\t\telse if (state === 'ready') onMediaReady();\n\t}\n\n\tconst currentGroup = $derived(groups[groupIndex]);\n\tconst currentStory = $derived(currentGroup?.stories[storyIndex] ?? null);\n\n\t// Preload the neighbouring frames (next + prev in group, first of next\n\t// group). Without this the single <img> keeps painting the previous frame\n\t// — often the previous creator's photo — until the new src downloads, which\n\t// read as \"tapped next, saw someone else's image\" + a slow, janky advance.\n\tconst preloadUrls = $derived.by(() => {\n\t\t// De-duped: two candidates can resolve to the same URL (e.g. a single-story\n\t\t// group, or a repeated crosspost), and a keyed `{#each … (u)}` throws an\n\t\t// unhandled `each_key_duplicate` on a repeated key — which aborted the whole\n\t\t// viewer render on /f (RAT crash). A Set guarantees unique keys.\n\t\tconst urls = new Set<string>();\n\t\tconst g = groups[groupIndex];\n\t\tif (!g) return [...urls];\n\t\tconst candidates = [\n\t\t\tg.stories[storyIndex + 1],\n\t\t\tg.stories[storyIndex - 1],\n\t\t\tgroups[groupIndex + 1]?.stories[0]\n\t\t];\n\t\tfor (const s of candidates) {\n\t\t\tif (s && s.kind === 'image' && s.mediaUrl) urls.add(s.mediaUrl);\n\t\t}\n\t\treturn [...urls];\n\t});\n\n\tfunction fmtRel(iso: string) {\n\t\tconst ms = Date.now() - new Date(iso).getTime();\n\t\tconst m = Math.floor(ms / 60000);\n\t\tif (m < 1) return t('now');\n\t\tif (m < 60) return t('{n}m', { n: m });\n\t\tconst h = Math.floor(m / 60);\n\t\treturn t('{n}h', { n: h });\n\t}\n\n\tfunction tick(now: number) {\n\t\tif (paused || !currentStory) {\n\t\t\tlastTick = now;\n\t\t\trafId = requestAnimationFrame(tick);\n\t\t\treturn;\n\t\t}\n\t\tconst dt = now - lastTick;\n\t\tlastTick = now;\n\t\tif (currentStory.kind === 'video' && videoEl) {\n\t\t\t// Driven by the video element instead of clock — keeps the bar in sync\n\t\t\t// with playback. Advancing is handled by `onended` only; advancing\n\t\t\t// here too would double-fire (clock hits 1 AND onended) and skip a\n\t\t\t// frame — sometimes straight past into the next creator's story.\n\t\t\tprogress = videoEl.duration > 0 ? videoEl.currentTime / videoEl.duration : 0;\n\t\t} else if (mediaState === 'ready') {\n\t\t\t// Only burn the 5s once the image is actually on screen — otherwise a\n\t\t\t// slow signed URL spends its whole window as a black frame, then jumps.\n\t\t\tprogress = Math.min(1, progress + dt / IMAGE_DURATION_MS);\n\t\t\tif (progress >= 1) advance();\n\t\t}\n\t\trafId = requestAnimationFrame(tick);\n\t}\n\n\tfunction advance() {\n\t\tconst g = groups[groupIndex];\n\t\tif (!g) {\n\t\t\tonclose();\n\t\t\treturn;\n\t\t}\n\t\tif (storyIndex < g.stories.length - 1) {\n\t\t\tstoryIndex++;\n\t\t\tprogress = 0;\n\t\t} else if (groupIndex < groups.length - 1) {\n\t\t\tgroupIndex++;\n\t\t\tstoryIndex = 0;\n\t\t\tprogress = 0;\n\t\t} else {\n\t\t\tonclose();\n\t\t}\n\t}\n\n\tfunction back() {\n\t\tif (storyIndex > 0) {\n\t\t\tstoryIndex--;\n\t\t\tprogress = 0;\n\t\t} else if (groupIndex > 0) {\n\t\t\tgroupIndex--;\n\t\t\tstoryIndex = groups[groupIndex].stories.length - 1;\n\t\t\tprogress = 0;\n\t\t}\n\t}\n\n\tfunction handleTap(e: MouseEvent) {\n\t\tif (!(e.currentTarget instanceof HTMLElement)) return;\n\t\tconst rect = e.currentTarget.getBoundingClientRect();\n\t\tconst x = e.clientX - rect.left;\n\t\tif (x < rect.width / 3) back();\n\t\telse if (x > (2 * rect.width) / 3) advance();\n\t\telse paused = !paused;\n\t}\n\n\tfunction handleKey(e: KeyboardEvent) {\n\t\tif (e.key === 'Escape') onclose();\n\t\telse if (e.key === 'ArrowLeft') back();\n\t\telse if (e.key === 'ArrowRight') advance();\n\t\telse if (e.key === ' ') {\n\t\t\te.preventDefault();\n\t\t\tpaused = !paused;\n\t\t}\n\t}\n\n\tasync function recordView(storyId: string) {\n\t\tif (!viewerLoggedIn) return;\n\t\ttry {\n\t\t\tawait fetch(`/api/story/${storyId}`, { method: 'POST' });\n\t\t} catch {\n\t\t\t/* fire and forget */\n\t\t}\n\t}\n\n\t$effect(() => {\n\t\tconst story = currentStory;\n\t\tif (!story) return;\n\t\tprogress = 0;\n\t\t// New frame → back to loading. The watchdog effect below owns arming the\n\t\t// timers, so they stay in one place and stay paused-aware.\n\t\tmediaState = 'loading';\n\t\terrorTracked = false;\n\t\tclearMediaTimers();\n\t\trecordView(story.id);\n\t\tonview?.(story.id);\n\t\treturn () => clearMediaTimers();\n\t});\n\n\t// The two watchdogs are wall-clock timers, but the viewer can hold any frame\n\t// with a centre tap. Freeze them while paused and re-arm on release, so a slow\n\t// frame someone is deliberately holding is never flipped to error and skipped.\n\t$effect(() => {\n\t\tif (paused) {\n\t\t\tclearMediaTimers();\n\t\t\treturn;\n\t\t}\n\t\tif (mediaState === 'loading' && currentStory?.kind === 'image') {\n\t\t\t// <img> fires neither load nor error if the request just hangs; videos\n\t\t\t// don't need this, `videoLoadGuard` has its own (re-arming) timeout.\n\t\t\tclearImageLoadTimer();\n\t\t\timageLoadTimer = setTimeout(() => {\n\t\t\t\timageLoadTimer = null;\n\t\t\t\tif (mediaState === 'loading') onMediaError('timeout');\n\t\t\t}, IMAGE_LOAD_TIMEOUT_MS);\n\t\t} else if (mediaState === 'error') {\n\t\t\tscheduleErrorAdvance();\n\t\t}\n\t});\n\n\t$effect(() => {\n\t\tif (videoEl) {\n\t\t\tvideoEl.muted = false;\n\t\t\tvideoEl.play().catch(() => {\n\t\t\t\t// Autoplay blocked — fall back to muted (browsers always allow muted autoplay).\n\t\t\t\tif (videoEl) {\n\t\t\t\t\tvideoEl.muted = true;\n\t\t\t\t\tvideoEl.play().catch(() => {});\n\t\t\t\t}\n\t\t\t});\n\t\t}\n\t});\n\n\tonMount(() => {\n\t\tdocument.body.style.overflow = 'hidden';\n\t\tlastTick = performance.now();\n\t\trafId = requestAnimationFrame(tick);\n\t\twindow.addEventListener('keydown', handleKey);\n\t});\n\n\tonDestroy(() => {\n\t\tdocument.body.style.overflow = '';\n\t\tif (rafId !== null) cancelAnimationFrame(rafId);\n\t\tclearMediaTimers();\n\t\twindow.removeEventListener('keydown', handleKey);\n\t});\n</script>\n\n{#if currentGroup && currentStory}\n\t<div class=\"fixed inset-0 z-[100] flex items-center justify-center bg-black\">\n\t\t<!-- The tap-to-nav layer covers the whole screen except the close button. -->\n\t\t<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->\n\t\t<div\n\t\t\tonclick={handleTap}\n\t\t\tclass=\"absolute inset-0 flex items-center justify-center\"\n\t\t>\n\t\t\t{#if currentStory.kind === 'image'}\n\t\t\t\t<img\n\t\t\t\t\tsrc={currentStory.mediaUrl}\n\t\t\t\t\talt=\"\"\n\t\t\t\t\tonload={onMediaReady}\n\t\t\t\t\tonerror={() => onMediaError('error')}\n\t\t\t\t\tclass=\"max-h-full max-w-full object-contain transition-opacity duration-150\"\n\t\t\t\t\tclass:opacity-0={mediaState !== 'ready'}\n\t\t\t\t/>\n\t\t\t{:else}\n\t\t\t\t<!-- svelte-ignore a11y_media_has_caption -->\n\t\t\t\t<video\n\t\t\t\t\tbind:this={videoEl}\n\t\t\t\t\tuse:videoLoadGuard={{ onState: onVideoState }}\n\t\t\t\t\tsrc={currentStory.mediaUrl}\n\t\t\t\t\tposter={currentStory.mediaPosterUrl ?? undefined}\n\t\t\t\t\tplaysinline\n\t\t\t\t\tautoplay\n\t\t\t\t\tonended={advance}\n\t\t\t\t\tclass=\"max-h-full max-w-full\"\n\t\t\t\t></video>\n\t\t\t{/if}\n\n\t\t\t<!-- Loading / error feedback so a slow or dead signed URL never leaves the\n\t\t\t\t viewer on a bare black screen or the browser's native <video> spinner. -->\n\t\t\t{#if mediaState === 'loading'}\n\t\t\t\t<div class=\"pointer-events-none absolute inset-0 flex items-center justify-center\">\n\t\t\t\t\t<div class=\"story-spinner\" aria-label={t('loading')}></div>\n\t\t\t\t</div>\n\t\t\t{:else if mediaState === 'error'}\n\t\t\t\t<div class=\"pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-1 px-6 text-center\">\n\t\t\t\t\t<p class=\"text-sm font-semibold text-white\">{t(\"couldn't load this story\")}</p>\n\t\t\t\t\t<p class=\"text-xs text-white/60\">{t('skipping…')}</p>\n\t\t\t\t</div>\n\t\t\t{/if}\n\t\t</div>\n\n\t\t<!-- Progress bars: one segment per story in the active group -->\n\t\t<div class=\"pointer-events-none absolute top-3 left-3 right-3 z-10 flex gap-1\">\n\t\t\t{#each currentGroup.stories as _, i (i)}\n\t\t\t\t<div class=\"h-0.5 flex-1 overflow-hidden rounded-full bg-white/30\">\n\t\t\t\t\t<div\n\t\t\t\t\t\tclass=\"h-full bg-white transition-[width] duration-75\"\n\t\t\t\t\t\tstyle:width={i < storyIndex\n\t\t\t\t\t\t\t? '100%'\n\t\t\t\t\t\t\t: i === storyIndex\n\t\t\t\t\t\t\t\t? `${progress * 100}%`\n\t\t\t\t\t\t\t\t: '0%'}\n\t\t\t\t\t></div>\n\t\t\t\t</div>\n\t\t\t{/each}\n\t\t</div>\n\n\t\t<!-- Header: avatar + nickname + time + close -->\n\t\t<div class=\"pointer-events-none absolute top-6 left-3 right-3 z-10 mt-2 flex items-center justify-between gap-3\">\n\t\t\t<div class=\"pointer-events-auto flex items-center gap-2\">\n\t\t\t\t<DuelAvatar\n\t\t\t\t\tnickname={currentGroup.creator.nickname}\n\t\t\t\t\tavatarUrl={getAvatarUrl(currentGroup.creator.avatarPath)}\n\t\t\t\t\tsize={36}\n\t\t\t\t\thref={currentGroup.creator.nickname ? `/u/${currentGroup.creator.nickname}` : ''}\n\t\t\t\t\tlink={false}\n\t\t\t\t/>\n\t\t\t\t<a\n\t\t\t\t\thref={currentGroup.creator.nickname ? `/u/${currentGroup.creator.nickname}` : '#'}\n\t\t\t\t\tclass=\"text-sm font-semibold text-white drop-shadow\"\n\t\t\t\t>\n\t\t\t\t\t@{currentGroup.creator.nickname ?? t('unknown')}\n\t\t\t\t</a>\n\t\t\t\t<span class=\"text-xs text-white/70 drop-shadow\">{fmtRel(currentStory.createdAt)}</span>\n\t\t\t</div>\n\t\t\t<button\n\t\t\t\ttype=\"button\"\n\t\t\t\tonclick={onclose}\n\t\t\t\tclass=\"pointer-events-auto rounded-full bg-black/30 p-2 text-white hover:bg-black/60\"\n\t\t\t\taria-label={t('close')}\n\t\t\t>\n\t\t\t\t<svg viewBox=\"0 0 24 24\" class=\"h-5 w-5\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\" stroke-linecap=\"round\">\n\t\t\t\t\t<line x1=\"18\" y1=\"6\" x2=\"6\" y2=\"18\" />\n\t\t\t\t\t<line x1=\"6\" y1=\"6\" x2=\"18\" y2=\"18\" />\n\t\t\t\t</svg>\n\t\t\t</button>\n\t\t</div>\n\n\t\t{#if paused}\n\t\t\t<div class=\"pointer-events-none absolute bottom-10 left-1/2 -translate-x-1/2 rounded-full bg-black/60 px-3 py-1 text-[11px] text-white\">\n\t\t\t\t{t('paused — tap to resume')}\n\t\t\t</div>\n\t\t{/if}\n\n\t\t<!-- Hidden adjacent-frame preloader — see preloadUrls. Keeps the next /\n\t\t\t previous advance instant and stops the prior frame from lingering. -->\n\t\t<div aria-hidden=\"true\" class=\"pointer-events-none absolute h-0 w-0 overflow-hidden opacity-0\">\n\t\t\t{#each preloadUrls as u (u)}\n\t\t\t\t<img src={u} alt=\"\" />\n\t\t\t{/each}\n\t\t</div>\n\t</div>\n{/if}\n\n<style>\n\t/* Neutral loading spinner — plain translucent-white ring, no colored glow. */\n\t.story-spinner {\n\t\twidth: 34px;\n\t\theight: 34px;\n\t\tborder-radius: 9999px;\n\t\tborder: 3px solid rgba(255, 255, 255, 0.25);\n\t\tborder-top-color: rgba(255, 255, 255, 0.9);\n\t\tanimation: story-spin 0.7s linear infinite;\n\t}\n\n\t@keyframes story-spin {\n\t\tto {\n\t\t\ttransform: rotate(360deg);\n\t\t}\n\t}\n\n\t@media (prefers-reduced-motion: reduce) {\n\t\t.story-spinner {\n\t\t\tanimation-duration: 1.6s;\n\t\t}\n\t}\n</style>\n"],"version":3}