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,212 @@
|
||||
<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>
|
||||
Reference in new issue
Block a user