Files
SoulStream/src/components/ArtworkViewer.svelte
T
KeelrLeek a667b4b28e 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.
2026-07-22 17:57:00 -05:00

213 lines
6.6 KiB
Svelte

<script>
// Full-window artwork viewer — a lightbox for the playing album's cover.
// Opened from the corner expand button on the Now Playing panel's art.
// No player chrome: the artwork itself, as large as the window allows,
// at the full resolution of the canonical cover (3000px-class for
// library albums). Click anywhere, Escape, or the corner button closes.
import { fade } from "svelte/transition";
import { player, ui, getArt, getCatalogArt, playerArtSource } from "../lib/state.svelte.js";
import Icon from "./Icon.svelte";
const currentItem = $derived(player.queue[player.queueIndex] ?? null);
// Album art (local canonical file or catalog CDN) → data URL. The original
// stays untouched for the foreground; the ambient backdrop is baked once
// at 256px so opening the viewer does not create a viewport-scale blur
// surface in the compositor.
const AMBIENT_SIZE = 256;
const AMBIENT_BLUR = 18;
let artUrl = $state(null);
let ambientUrl = $state(null);
async function makeAmbient(src, isActive) {
const img = new Image();
img.decoding = "async";
img.src = src;
await img.decode();
if (!isActive()) return null;
const canvas = document.createElement("canvas");
canvas.width = AMBIENT_SIZE;
canvas.height = AMBIENT_SIZE;
const ctx = canvas.getContext("2d");
if (!ctx || !img.naturalWidth || !img.naturalHeight) return null;
// Match object-fit: cover for non-square artwork. The viewer's existing
// 112% overscan/scale crops away the small baked-blur edge just as it did
// for the old live CSS filter.
const scale = Math.max(
AMBIENT_SIZE / img.naturalWidth,
AMBIENT_SIZE / img.naturalHeight,
);
const width = img.naturalWidth * scale;
const height = img.naturalHeight * scale;
ctx.filter = `blur(${AMBIENT_BLUR}px) saturate(1.4) brightness(0.5)`;
ctx.drawImage(
img,
(AMBIENT_SIZE - width) / 2,
(AMBIENT_SIZE - height) / 2,
width,
height,
);
if (!isActive()) return null;
return canvas.toDataURL("image/jpeg", 0.82);
}
$effect(() => {
const open = ui.fullscreenArt;
const item = currentItem;
artUrl = null;
ambientUrl = null;
if (!open || !item) return;
let active = true;
const pending = item.art
? getArt(item.art)
: item.artUrl
? getCatalogArt(playerArtSource(item))
: Promise.resolve(null);
pending
.then(async (url) => {
if (!active || !url) return;
artUrl = url;
try {
const ambient = await makeAmbient(url, () => active);
if (active && ambient) ambientUrl = ambient;
} catch {
// The foreground remains fully usable if ambient baking
// fails for an unusual image payload.
}
})
.catch(() => {});
return () => (active = false);
});
// Nothing playing → nothing to view.
$effect(() => {
if (ui.fullscreenArt && !currentItem) ui.fullscreenArt = false;
});
function onKey(e) {
if (!ui.fullscreenArt) return;
if (e.key === "Escape") {
e.preventDefault();
ui.fullscreenArt = false;
}
}
</script>
<svelte:window onkeydown={onKey} />
{#if ui.fullscreenArt && currentItem}
<!-- The whole surface is one dismiss target, lightbox-style. -->
<div
class="viewer"
role="button"
tabindex="-1"
aria-label="Close artwork viewer"
transition:fade={{ duration: 160 }}
onclick={() => (ui.fullscreenArt = false)}
onkeydown={(e) => {
if (e.key === "Enter" || e.key === " ") ui.fullscreenArt = false;
}}
>
{#if ambientUrl}
<img class="bg" src={ambientUrl} alt="" aria-hidden="true" draggable="false" />
{/if}
<div class="shade" aria-hidden="true"></div>
<button
class="close"
title="Close (Esc)"
onclick={(e) => {
e.stopPropagation();
ui.fullscreenArt = false;
}}
>
<Icon name="shrink" size={17} />
</button>
{#if artUrl}
<img class="art" src={artUrl} alt="Album artwork" draggable="false" />
{:else}
<div class="art placeholder"><Icon name="disc" size={72} /></div>
{/if}
</div>
{/if}
<style>
.viewer {
position: fixed;
inset: 0;
z-index: 100;
overflow: hidden;
background: #0b0b0e;
display: grid;
place-items: center;
cursor: zoom-out;
}
.bg {
position: absolute;
inset: -6%;
width: 112%;
height: 112%;
object-fit: cover;
transform: scale(1.06);
}
.shade {
position: absolute;
inset: 0;
background: radial-gradient(
120% 90% at 50% 45%,
transparent 40%,
rgba(6, 6, 9, 0.5) 100%
);
}
.close {
position: absolute;
top: 18px;
right: 18px;
z-index: 3;
width: 38px;
height: 38px;
border-radius: 50%;
display: grid;
place-items: center;
color: #fff;
background: rgba(10, 10, 14, 0.55);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
cursor: pointer;
transition: background 0.16s ease, transform 0.16s ease;
}
.close:hover { background: rgba(10, 10, 14, 0.85); transform: scale(1.06); }
/* The artwork owns the window: as large as the viewport allows with a
slim breathing margin, full native resolution behind it. */
.art {
position: relative;
z-index: 2;
width: min(94vh, 94vw);
aspect-ratio: 1;
object-fit: contain;
border-radius: 10px;
box-shadow: 0 48px 110px -34px rgba(0, 0, 0, 0.9);
background: transparent;
animation: settle 0.24s cubic-bezier(0.2, 0.7, 0.3, 1);
cursor: zoom-out;
}
.art.placeholder {
display: grid;
place-items: center;
color: var(--text-muted);
background: var(--bg-highlight);
}
@keyframes settle {
from { opacity: 0; transform: scale(0.965); }
to { opacity: 1; transform: none; }
}
@media (prefers-reduced-motion: reduce) {
.art { animation: none; }
}
</style>