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.
213 lines
6.6 KiB
Svelte
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>
|