forked from Keelrleek/SoulStream
The previous .git directory was found gutted (only info/ remained), so history is unrecoverable locally. This commit restores version control with the tree exactly as it stands: the working app, the preview-tracks launcher on the artist hero, the scrollable-track-previews design bundle (docs + approved mockups), and the Phase 0 validation artifacts.
256 lines
8.4 KiB
Svelte
256 lines
8.4 KiB
Svelte
<script>
|
|
import { onDestroy, onMount } from "svelte";
|
|
import { getCatalogHeroGpuSource } from "../lib/state.svelte.js";
|
|
import { createHeroGpuRenderer } from "../lib/heroGpu.js";
|
|
|
|
let {
|
|
source = "",
|
|
scope = "",
|
|
plan = null,
|
|
active = true,
|
|
onloading = () => {},
|
|
onready = () => {},
|
|
onerror = () => {},
|
|
} = $props();
|
|
|
|
let canvas;
|
|
let bloomCanvas;
|
|
let previousCanvas;
|
|
let previousBloomCanvas;
|
|
let renderer = null;
|
|
let rendererError = null;
|
|
let rendererReady = $state(false);
|
|
let painted = $state(false);
|
|
let previousVisible = $state(false);
|
|
let previousFading = $state(false);
|
|
let generation = 0;
|
|
let transitionTimer;
|
|
let resizeObserver;
|
|
let renderedScope = null;
|
|
|
|
function reportFailure(error, failedSource = source) {
|
|
if (!failedSource) return;
|
|
onerror(failedSource, error instanceof Error ? error : new Error(String(error)));
|
|
}
|
|
|
|
onMount(() => {
|
|
try {
|
|
renderer = createHeroGpuRenderer(
|
|
canvas,
|
|
bloomCanvas,
|
|
previousCanvas,
|
|
previousBloomCanvas,
|
|
(error) => reportFailure(error),
|
|
);
|
|
resizeObserver = new ResizeObserver(() => renderer?.resize());
|
|
resizeObserver.observe(canvas);
|
|
rendererReady = true;
|
|
} catch (error) {
|
|
// Initialization often overlaps the art search, before `source`
|
|
// exists. Mark it settled so the first real source can trigger the
|
|
// normal fallback path instead of remaining on an empty canvas.
|
|
rendererError = error instanceof Error ? error : new Error(String(error));
|
|
rendererReady = true;
|
|
}
|
|
});
|
|
|
|
$effect(() => {
|
|
const selected = source;
|
|
const selectedScope = scope;
|
|
const cropPlan = plan;
|
|
const visible = active;
|
|
const ready = rendererReady;
|
|
// Start payload preparation even while WebGL programs are compiling.
|
|
// The keyed state cache makes the renderer-ready rerun share this work.
|
|
const payloadReady = selected && visible && (!ready || renderer)
|
|
? getCatalogHeroGpuSource(selected, cropPlan)
|
|
: null;
|
|
if (!selected || !visible) {
|
|
generation += 1;
|
|
clearTimeout(transitionTimer);
|
|
painted = false;
|
|
previousVisible = false;
|
|
previousFading = false;
|
|
if (ready && renderer) renderer.clear();
|
|
return;
|
|
}
|
|
if (!ready) return;
|
|
if (!renderer) {
|
|
reportFailure(rendererError ?? new Error("live GPU renderer unavailable"), selected);
|
|
return;
|
|
}
|
|
|
|
// Preserve carousel crossfades within one artist, but never hold the
|
|
// previous artist's finished frame while the next artist is processing.
|
|
if (renderedScope !== selectedScope) {
|
|
renderedScope = selectedScope;
|
|
generation += 1;
|
|
clearTimeout(transitionTimer);
|
|
painted = false;
|
|
previousVisible = false;
|
|
previousFading = false;
|
|
renderer.clear();
|
|
}
|
|
|
|
const request = ++generation;
|
|
let cancelled = false;
|
|
onloading(selected);
|
|
(async () => {
|
|
try {
|
|
const payload = await payloadReady;
|
|
if (!payload) throw new Error("live hero payload unavailable");
|
|
if (cancelled || request !== generation || source !== selected) return;
|
|
const result = renderer.render(payload);
|
|
if (cancelled || request !== generation || source !== selected) return;
|
|
|
|
canvas.dataset.heroGpu = "ready";
|
|
canvas.dataset.heroSource = selected;
|
|
canvas.dataset.heroRenderMs = result.renderMs.toFixed(2);
|
|
canvas.dataset.heroRenderer = result.renderer;
|
|
clearTimeout(transitionTimer);
|
|
if (result.hadPrevious) {
|
|
previousVisible = true;
|
|
previousFading = false;
|
|
requestAnimationFrame(() => {
|
|
if (request !== generation) return;
|
|
// Commit a style pass at opacity 1 before starting the
|
|
// fade: rAF callbacks run before style recalc, so
|
|
// without this forced reflow the visible+fading class
|
|
// changes collapse into one recalc (opacity 0 -> 0)
|
|
// and the 1.4s transition never fires.
|
|
void previousCanvas.offsetWidth;
|
|
previousFading = true;
|
|
});
|
|
transitionTimer = setTimeout(() => {
|
|
previousVisible = false;
|
|
previousFading = false;
|
|
}, 1_400);
|
|
}
|
|
painted = true;
|
|
onready(selected, result);
|
|
} catch (error) {
|
|
if (!cancelled && request === generation && source === selected) {
|
|
canvas.dataset.heroGpu = "failed";
|
|
reportFailure(error, selected);
|
|
}
|
|
}
|
|
})();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
});
|
|
|
|
onDestroy(() => {
|
|
generation += 1;
|
|
clearTimeout(transitionTimer);
|
|
resizeObserver?.disconnect();
|
|
renderer?.destroy();
|
|
});
|
|
</script>
|
|
|
|
<!-- Each `previous` canvas must come AFTER its `current` sibling: they share
|
|
a z-index, so DOM order decides stacking, and the outgoing frame has to
|
|
overlay the already-updated current canvas while it fades out. -->
|
|
<div class="gpu-hero" class:active={Boolean(source)} class:painted aria-hidden="true">
|
|
<canvas bind:this={bloomCanvas} class="bloom current"></canvas>
|
|
<canvas bind:this={previousBloomCanvas} class="bloom previous" class:fading={previousFading} class:visible={previousVisible}></canvas>
|
|
<canvas bind:this={canvas} class="banner current"></canvas>
|
|
<canvas bind:this={previousCanvas} class="banner previous" class:fading={previousFading} class:visible={previousVisible}></canvas>
|
|
</div>
|
|
|
|
<style>
|
|
.gpu-hero {
|
|
position: relative;
|
|
display: block;
|
|
z-index: 1;
|
|
/* The 70px header strip must be PADDING, not a margin on the canvas:
|
|
this box has no border, so a child margin-top would collapse through
|
|
it and drag the whole wrapper — including the top-0 bloom strip —
|
|
70px down. That left only the static seed gradient behind the
|
|
frosted results-head, and offset the previous canvas 70px below the
|
|
current one during crossfades. */
|
|
padding-top: 70px;
|
|
opacity: 0;
|
|
transition: opacity 180ms ease;
|
|
}
|
|
|
|
/* Keep the WebGL context/shaders warm while metadata is resolving without
|
|
contributing a second 16:9 box beside a provisional <img>. */
|
|
.gpu-hero:not(.active) {
|
|
position: absolute;
|
|
inset: 0;
|
|
opacity: 0;
|
|
}
|
|
|
|
.gpu-hero.painted {
|
|
opacity: 1;
|
|
}
|
|
|
|
.gpu-hero.painted:not(.active) {
|
|
opacity: 0;
|
|
}
|
|
|
|
.banner {
|
|
position: relative;
|
|
display: block;
|
|
width: 100%;
|
|
height: auto;
|
|
aspect-ratio: 16 / 9;
|
|
max-height: 78vh;
|
|
z-index: 1;
|
|
}
|
|
|
|
.banner.previous {
|
|
position: absolute;
|
|
/* Matches the wrapper's 70px padding-top, so the outgoing frame sits
|
|
exactly over the in-flow current canvas. */
|
|
top: 70px;
|
|
left: 0;
|
|
opacity: 0;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.banner.previous.visible {
|
|
opacity: 1;
|
|
}
|
|
|
|
.banner.previous.fading {
|
|
opacity: 0;
|
|
transition: opacity 1.4s ease;
|
|
}
|
|
|
|
.bloom {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 106px;
|
|
filter: blur(34px) saturate(1.6);
|
|
transform: scale(1.15);
|
|
opacity: 0.95;
|
|
z-index: 0;
|
|
}
|
|
|
|
.bloom.previous {
|
|
opacity: 0;
|
|
}
|
|
|
|
.bloom.previous.visible {
|
|
opacity: 0.95;
|
|
}
|
|
|
|
.bloom.previous.fading {
|
|
opacity: 0;
|
|
transition: opacity 1.4s ease;
|
|
}
|
|
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.gpu-hero,
|
|
.banner.previous.fading,
|
|
.bloom.previous.fading {
|
|
transition-duration: 1ms;
|
|
}
|
|
}
|
|
</style>
|