forked from Keelrleek/SoulStream
Baseline: full tree snapshot after git repository loss
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.
This commit is contained in:
commit
a667b4b28e
161 files changed
+53170
No files matched your search
@@ -0,0 +1,255 @@
|
||||
<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>
|
||||
Reference in new issue
Block a user