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,813 @@
|
||||
<script>
|
||||
import {
|
||||
player,
|
||||
theme,
|
||||
ui,
|
||||
nextTrack,
|
||||
prevTrack,
|
||||
toggleShuffle,
|
||||
cycleRepeat,
|
||||
toggleNowPlaying,
|
||||
openCurrentArtist,
|
||||
openCurrentAlbum,
|
||||
openCurrentSong,
|
||||
getArt,
|
||||
getCatalogArt,
|
||||
playerArtSource,
|
||||
} from "../lib/state.svelte.js";
|
||||
import * as api from "../lib/api.js";
|
||||
import Icon from "./Icon.svelte";
|
||||
import SaveMenu from "./SaveMenu.svelte";
|
||||
|
||||
const pct = $derived(
|
||||
player.duration ? Math.min(100, (player.position / player.duration) * 100) : 0,
|
||||
);
|
||||
const bufferPct = $derived(
|
||||
player.streamTotal > 0
|
||||
? Math.min(100, (player.streamReceived / player.streamTotal) * 100)
|
||||
: 0,
|
||||
);
|
||||
const hasWave = $derived(player.waveform.length > 0);
|
||||
const idle = $derived(player.status === "idle");
|
||||
const currentItem = $derived(player.queue[player.queueIndex] ?? null);
|
||||
// The native scratch engine needs a complete local file. A network stream
|
||||
// keeps the reliable seek-on-drag behavior until it has been saved/cached.
|
||||
const canScratch = $derived(!currentItem?.stream);
|
||||
// Split "Artist — Album" for the separate, clickable meta lines.
|
||||
const metaParts = $derived.by(() => {
|
||||
const sub = player.track?.sub ?? "";
|
||||
return sub.includes(" — ")
|
||||
? sub.split(" — ").map((s) => s.trim())
|
||||
: [sub.trim(), ""];
|
||||
});
|
||||
const volumeIcon = $derived(
|
||||
player.muted || player.volume === 0
|
||||
? "volume-mute"
|
||||
: player.volume < 0.5
|
||||
? "volume-low"
|
||||
: "volume",
|
||||
);
|
||||
|
||||
let artUrl = $state(null);
|
||||
let volTrack = $state(null);
|
||||
let draggingVolume = $state(false);
|
||||
let draggingSeek = $state(false);
|
||||
let lastAudibleVolume = 0.7;
|
||||
let lastScrubSeekAt = 0;
|
||||
let scrubStartX = 0;
|
||||
let scrubLastX = 0;
|
||||
let scrubLastAt = 0;
|
||||
let scratchActive = false;
|
||||
|
||||
$effect(() => {
|
||||
const item = currentItem;
|
||||
artUrl = null;
|
||||
if (!item) return;
|
||||
let active = true;
|
||||
if (item.art) {
|
||||
getArt(item.art).then((url) => {
|
||||
if (active) artUrl = url;
|
||||
});
|
||||
} else if (item.artUrl) {
|
||||
// Streamed catalog track: cover comes from the catalog CDN, upgraded
|
||||
// to the verified high-res source (Apple/CAA/Deezer-max) when ready.
|
||||
getCatalogArt(playerArtSource(item)).then((url) => {
|
||||
if (active) artUrl = url;
|
||||
});
|
||||
}
|
||||
return () => (active = false);
|
||||
});
|
||||
|
||||
// Waveform canvas + hover state.
|
||||
let waveWrap = $state(null);
|
||||
let waveCanvas = $state(null);
|
||||
let hoverX = $state(-1);
|
||||
const hoverTime = $derived(
|
||||
waveWrap && player.duration ? (hoverX / waveWrap.clientWidth) * player.duration : 0,
|
||||
);
|
||||
|
||||
// Canvas colors; played/hover follow the adaptive accent theme.
|
||||
const UNPLAYED = "rgba(255,255,255,0.24)";
|
||||
const PREVIEW = "rgba(255,255,255,0.66)";
|
||||
const PLAYHEAD = "#ffffff";
|
||||
|
||||
function fmt(secs) {
|
||||
if (secs == null || !isFinite(secs)) return "–:––";
|
||||
const s = Math.max(0, Math.floor(secs));
|
||||
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
if (player.status === "playing") api.playerControl("pause");
|
||||
else if (player.status === "paused") api.playerControl("resume");
|
||||
}
|
||||
|
||||
function seekTarget(e, el) {
|
||||
if (!player.duration || idle) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const f = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
|
||||
return f * player.duration;
|
||||
}
|
||||
|
||||
function previewScrub(e, el) {
|
||||
const target = seekTarget(e, el);
|
||||
if (target == null) return;
|
||||
player.position = target; // instant visual feedback while dragging
|
||||
return target;
|
||||
}
|
||||
|
||||
function regularScrub(target, force = false) {
|
||||
const now = performance.now();
|
||||
if (force || now - lastScrubSeekAt >= 75) {
|
||||
lastScrubSeekAt = now;
|
||||
api.playerControl("seek", target);
|
||||
}
|
||||
}
|
||||
|
||||
function startScrub(e, el) {
|
||||
if (!player.duration || idle) return;
|
||||
draggingSeek = true;
|
||||
player.scrubbing = true;
|
||||
el.setPointerCapture(e.pointerId);
|
||||
scrubStartX = scrubLastX = e.clientX;
|
||||
scrubLastAt = performance.now();
|
||||
scratchActive = false;
|
||||
previewScrub(e, el);
|
||||
}
|
||||
|
||||
function moveScrub(e, el) {
|
||||
if (!draggingSeek) return;
|
||||
const target = previewScrub(e, el);
|
||||
if (target == null) return;
|
||||
const now = performance.now();
|
||||
if (canScratch && Math.abs(e.clientX - scrubStartX) > 3) {
|
||||
const activatingScratch = !scratchActive;
|
||||
scratchActive = true;
|
||||
const elapsed = Math.max(1, now - scrubLastAt);
|
||||
// Map hand velocity (pixels/ms) to a musical 0.25×–4× rate.
|
||||
const rate = Math.min(4, Math.max(0.25, Math.abs(e.clientX - scrubLastX) / elapsed * 1.6));
|
||||
if (activatingScratch || now - lastScrubSeekAt >= 50) {
|
||||
lastScrubSeekAt = now;
|
||||
api.playerControl("scratch", target, e.clientX < scrubLastX ? -rate : rate);
|
||||
}
|
||||
} else if (!canScratch) {
|
||||
regularScrub(target);
|
||||
}
|
||||
scrubLastX = e.clientX;
|
||||
scrubLastAt = now;
|
||||
}
|
||||
|
||||
function endScrub(e, el) {
|
||||
if (!draggingSeek) return;
|
||||
const target = previewScrub(e, el);
|
||||
if (target != null) {
|
||||
if (scratchActive) api.playerControl("end-scratch", target);
|
||||
else regularScrub(target, true); // a simple click remains exact seek
|
||||
}
|
||||
draggingSeek = false;
|
||||
player.scrubbing = false;
|
||||
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId);
|
||||
}
|
||||
|
||||
function onWaveHover(e) {
|
||||
const rect = waveWrap.getBoundingClientRect();
|
||||
hoverX = Math.max(0, Math.min(rect.width, e.clientX - rect.left));
|
||||
}
|
||||
|
||||
function setVolume(e) {
|
||||
const rect = volTrack.getBoundingClientRect();
|
||||
const frac = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
|
||||
player.volume = frac;
|
||||
player.muted = frac === 0;
|
||||
if (frac > 0) lastAudibleVolume = frac;
|
||||
api.playerControl("volume", frac);
|
||||
}
|
||||
|
||||
function startVolumeDrag(e) {
|
||||
draggingVolume = true;
|
||||
volTrack.setPointerCapture(e.pointerId);
|
||||
setVolume(e);
|
||||
}
|
||||
|
||||
function endVolumeDrag(e) {
|
||||
draggingVolume = false;
|
||||
if (volTrack.hasPointerCapture(e.pointerId)) volTrack.releasePointerCapture(e.pointerId);
|
||||
}
|
||||
|
||||
function volumeKeydown(e) {
|
||||
const step = e.shiftKey ? 0.1 : 0.05;
|
||||
if (e.key === "ArrowLeft" || e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setVolume({ clientX: volTrack.getBoundingClientRect().left + Math.max(0, player.volume - step) * volTrack.clientWidth });
|
||||
} else if (e.key === "ArrowRight" || e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setVolume({ clientX: volTrack.getBoundingClientRect().left + Math.min(1, player.volume + step) * volTrack.clientWidth });
|
||||
} else if (e.key === "Home" || e.key === "End") {
|
||||
e.preventDefault();
|
||||
setVolume({ clientX: volTrack.getBoundingClientRect().left + (e.key === "Home" ? 0 : volTrack.clientWidth) });
|
||||
}
|
||||
}
|
||||
|
||||
function toggleMute() {
|
||||
if (player.muted || player.volume === 0) {
|
||||
const volume = lastAudibleVolume || 0.7;
|
||||
player.volume = volume;
|
||||
player.muted = false;
|
||||
api.playerControl("volume", volume);
|
||||
} else {
|
||||
lastAudibleVolume = player.volume;
|
||||
player.muted = true;
|
||||
api.playerControl("volume", 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Wave geometry is invariant while a track and player-bar size stay the
|
||||
// same. Cache it instead of rebuilding ~1,600 line segments and resetting
|
||||
// the canvas backing store on every 60 ms position event.
|
||||
let wavePath = null;
|
||||
let wavePathPeaks = null;
|
||||
let wavePathWidth = 0;
|
||||
let wavePathHeight = 0;
|
||||
let lastPlayX = Number.NEGATIVE_INFINITY;
|
||||
let lastHoverDrawn = Number.NEGATIVE_INFINITY;
|
||||
let lastAccent = "";
|
||||
let lastAccentHover = "";
|
||||
let lastDuration = null;
|
||||
const windowActive = $derived(ui.windowActive);
|
||||
|
||||
// Draw one solid mirrored waveform, colored by played / hover / unplayed,
|
||||
// with a white playhead line and a green hover line.
|
||||
function drawWave(force = false) {
|
||||
// Position events still update playback/history while the window is
|
||||
// minimized, but there is no visible canvas to repaint. Visibility
|
||||
// return below forces one exact catch-up frame.
|
||||
if (!windowActive) return;
|
||||
const canvas = waveCanvas;
|
||||
const wrap = waveWrap;
|
||||
const peaks = player.waveform;
|
||||
if (!canvas || !wrap || !peaks || peaks.length === 0) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const W = wrap.clientWidth;
|
||||
const H = 26;
|
||||
if (W <= 0) return;
|
||||
const pixelWidth = Math.max(1, Math.round(W * dpr));
|
||||
const pixelHeight = Math.max(1, Math.round(H * dpr));
|
||||
const resized = canvas.width !== pixelWidth || canvas.height !== pixelHeight;
|
||||
if (resized) {
|
||||
canvas.width = pixelWidth;
|
||||
canvas.height = pixelHeight;
|
||||
}
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
|
||||
const N = peaks.length;
|
||||
const mid = H / 2;
|
||||
// Thin waveform floating in the middle: cap the amplitude well short
|
||||
// of the edges so loud sections don't clip flat, and keep a small
|
||||
// baseline so quiet sections still read as a hairline.
|
||||
const maxAmp = 7;
|
||||
const amp = (v) => Math.max(0.7, v * maxAmp);
|
||||
const geometryChanged =
|
||||
resized ||
|
||||
wavePathPeaks !== peaks ||
|
||||
wavePathWidth !== W ||
|
||||
wavePathHeight !== H;
|
||||
if (geometryChanged) {
|
||||
const path = new Path2D();
|
||||
path.moveTo(0, mid - amp(peaks[0]));
|
||||
const divisor = Math.max(1, N - 1);
|
||||
for (let i = 0; i < N; i++) {
|
||||
path.lineTo((i / divisor) * W, mid - amp(peaks[i]));
|
||||
}
|
||||
for (let j = N - 1; j >= 0; j--) {
|
||||
path.lineTo((j / divisor) * W, mid + amp(peaks[j]));
|
||||
}
|
||||
path.closePath();
|
||||
wavePath = path;
|
||||
wavePathPeaks = peaks;
|
||||
wavePathWidth = W;
|
||||
wavePathHeight = H;
|
||||
}
|
||||
|
||||
const playX = player.duration
|
||||
? Math.max(0, Math.min(W, (player.position / player.duration) * W))
|
||||
: 0;
|
||||
const visualsChanged =
|
||||
geometryChanged ||
|
||||
hoverX !== lastHoverDrawn ||
|
||||
theme.accent !== lastAccent ||
|
||||
theme.accentHover !== lastAccentHover ||
|
||||
player.duration !== lastDuration;
|
||||
// A sub-device-pixel playhead move cannot materially change the image.
|
||||
// Keep exact redraws for hover, resize, theme, seeks, and short tracks.
|
||||
if (!force && !visualsChanged && Math.abs(playX - lastPlayX) < 0.75 / dpr) return;
|
||||
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
const fillClip = (x0, x1, color) => {
|
||||
if (x1 <= x0) return;
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.rect(x0, 0, x1 - x0, H);
|
||||
ctx.clip();
|
||||
ctx.fillStyle = color;
|
||||
ctx.fill(wavePath);
|
||||
ctx.restore();
|
||||
};
|
||||
|
||||
fillClip(0, W, UNPLAYED);
|
||||
if (hoverX > playX) fillClip(playX, hoverX, PREVIEW);
|
||||
// Match the primary Play button and the sidebar's completed bars.
|
||||
// `accentHover` remains reserved for hover previews and controls.
|
||||
fillClip(0, playX, theme.accent);
|
||||
|
||||
// Playhead / hover markers: fixed height, padded off the edges,
|
||||
// so they read as small ticks rather than full-height rules.
|
||||
const LINE_H = 18;
|
||||
const lineY = (H - LINE_H) / 2;
|
||||
ctx.fillStyle = PLAYHEAD;
|
||||
ctx.fillRect(playX - 0.75, lineY, 1.5, LINE_H);
|
||||
if (hoverX >= 0) {
|
||||
ctx.fillStyle = theme.accentHover;
|
||||
ctx.fillRect(hoverX - 0.5, lineY, 1, LINE_H);
|
||||
}
|
||||
|
||||
lastPlayX = playX;
|
||||
lastHoverDrawn = hoverX;
|
||||
lastAccent = theme.accent;
|
||||
lastAccentHover = theme.accentHover;
|
||||
lastDuration = player.duration;
|
||||
}
|
||||
|
||||
// Redraw whenever the waveform, position, hover, or accent changes
|
||||
// (the accent lerps every frame during a crossfade, animating the fill).
|
||||
$effect(() => {
|
||||
player.waveform;
|
||||
player.position;
|
||||
player.duration;
|
||||
hoverX;
|
||||
theme.accent;
|
||||
theme.accentHover;
|
||||
drawWave();
|
||||
});
|
||||
|
||||
// Redraw on player-bar resize.
|
||||
$effect(() => {
|
||||
if (!waveWrap) return;
|
||||
const ro = new ResizeObserver(() => drawWave(true));
|
||||
ro.observe(waveWrap);
|
||||
return () => ro.disconnect();
|
||||
});
|
||||
</script>
|
||||
|
||||
<footer class="player">
|
||||
<div class="now-playing">
|
||||
{#if artUrl}
|
||||
<img class="art cover" src={artUrl} alt="" draggable="false" />
|
||||
{:else}
|
||||
<div class="art placeholder" class:pulsing={player.status === "buffering"}>
|
||||
{#if player.track}<Icon name="music" size={22} />{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="meta">
|
||||
{#if player.error}
|
||||
<span class="title err">⚠ {player.error}</span>
|
||||
{:else if player.track}
|
||||
<button class="title link" onclick={openCurrentSong} title={player.track.title}>
|
||||
{player.track.title}
|
||||
</button>
|
||||
{:else}
|
||||
<span class="title muted">Nothing playing</span>
|
||||
{/if}
|
||||
|
||||
{#if player.status === "buffering"}
|
||||
<span class="sub">{metaParts[0] || "buffering…"}</span>
|
||||
{:else if player.track}
|
||||
{#if metaParts[0]}
|
||||
<button class="sub link" onclick={openCurrentArtist} title={metaParts[0]}>
|
||||
{metaParts[0]}
|
||||
</button>
|
||||
{/if}
|
||||
{#if metaParts[1]}
|
||||
<button class="sub link album" onclick={openCurrentAlbum} title={metaParts[1]}>
|
||||
{metaParts[1]}
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<div class="buttons">
|
||||
<button
|
||||
class="ctl small"
|
||||
class:on={player.shuffle}
|
||||
title="Shuffle"
|
||||
onclick={toggleShuffle}
|
||||
><Icon name="shuffle" size={16} /></button>
|
||||
<button class="ctl" title="Previous" onclick={prevTrack} disabled={idle}>
|
||||
<Icon name="skip-back" size={18} />
|
||||
</button>
|
||||
<button
|
||||
class="play"
|
||||
title="Play/Pause"
|
||||
onclick={toggle}
|
||||
disabled={idle || player.status === "buffering"}
|
||||
>
|
||||
<Icon name={player.status === "playing" ? "pause" : "play"} size={16} />
|
||||
</button>
|
||||
<button class="ctl" title="Next" onclick={() => nextTrack(false)} disabled={idle}>
|
||||
<Icon name="skip-forward" size={18} />
|
||||
</button>
|
||||
<button
|
||||
class="ctl small"
|
||||
class:on={player.repeat !== "off"}
|
||||
title={player.repeat === "one" ? "Repeat one" : player.repeat === "all" ? "Repeat all" : "Repeat"}
|
||||
onclick={cycleRepeat}
|
||||
><Icon name={player.repeat === "one" ? "repeat-one" : "repeat"} size={16} /></button>
|
||||
</div>
|
||||
<div class="scrubber">
|
||||
<span class="time">{fmt(player.position)}</span>
|
||||
{#if hasWave}
|
||||
<div
|
||||
class="wave-wrap"
|
||||
bind:this={waveWrap}
|
||||
role="slider"
|
||||
aria-label="Seek"
|
||||
aria-valuenow={Math.round(pct)}
|
||||
tabindex="0"
|
||||
onmousemove={onWaveHover}
|
||||
onmouseleave={() => (hoverX = -1)}
|
||||
class:dragging={draggingSeek}
|
||||
onpointerdown={(e) => startScrub(e, waveWrap)}
|
||||
onpointermove={(e) => moveScrub(e, waveWrap)}
|
||||
onpointerup={(e) => endScrub(e, waveWrap)}
|
||||
onpointercancel={(e) => endScrub(e, waveWrap)}
|
||||
>
|
||||
<canvas bind:this={waveCanvas} class="wave-canvas"></canvas>
|
||||
{#if hoverX >= 0}
|
||||
<div class="wave-tip" style="left: {hoverX}px">{fmt(hoverTime)}</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="track"
|
||||
class:dragging={draggingSeek}
|
||||
onpointerdown={(e) => startScrub(e, e.currentTarget)}
|
||||
onpointermove={(e) => moveScrub(e, e.currentTarget)}
|
||||
onpointerup={(e) => endScrub(e, e.currentTarget)}
|
||||
onpointercancel={(e) => endScrub(e, e.currentTarget)}
|
||||
role="slider"
|
||||
aria-label="Seek"
|
||||
aria-valuenow={Math.round(pct)}
|
||||
tabindex="0"
|
||||
>
|
||||
{#if bufferPct > 0 && player.track?.source === "stream"}
|
||||
<div class="buffer" style="width: {bufferPct}%"></div>
|
||||
{/if}
|
||||
<div class="fill" style="width: {pct}%"></div>
|
||||
</div>
|
||||
{/if}
|
||||
<span class="time">{fmt(player.duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="extras">
|
||||
<SaveMenu align="up" />
|
||||
<button
|
||||
class="vol-icon"
|
||||
class:on={ui.nowPlaying}
|
||||
title="Now playing"
|
||||
onclick={toggleNowPlaying}
|
||||
>
|
||||
<Icon name="panel-right" size={17} />
|
||||
</button>
|
||||
<button class="vol-icon" title={player.muted ? "Unmute" : "Mute"} onclick={toggleMute}>
|
||||
<Icon name={volumeIcon} size={18} />
|
||||
</button>
|
||||
<div
|
||||
class="track vol"
|
||||
bind:this={volTrack}
|
||||
class:dragging={draggingVolume}
|
||||
onpointerdown={startVolumeDrag}
|
||||
onpointermove={(e) => draggingVolume && setVolume(e)}
|
||||
onpointerup={endVolumeDrag}
|
||||
onpointercancel={endVolumeDrag}
|
||||
onkeydown={volumeKeydown}
|
||||
role="slider"
|
||||
aria-label="Volume"
|
||||
aria-valuenow={Math.round((player.muted ? 0 : player.volume) * 100)}
|
||||
tabindex="0"
|
||||
>
|
||||
<div class="fill" style="width: {(player.muted ? 0 : player.volume) * 100}%"></div>
|
||||
<div class="vol-thumb" style="left: {(player.muted ? 0 : player.volume) * 100}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<style>
|
||||
.player {
|
||||
grid-area: player;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
padding: 0 16px;
|
||||
background: var(--bg-player);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.now-playing {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.art {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.art.placeholder {
|
||||
background: var(--bg-highlight);
|
||||
}
|
||||
|
||||
.art.cover {
|
||||
object-fit: cover;
|
||||
background: var(--bg-highlight);
|
||||
}
|
||||
|
||||
.art.pulsing {
|
||||
animation: pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Separate, clickable meta lines. Buttons are width-constrained so the
|
||||
hover underline only spans the text, and ellipsis-truncate. */
|
||||
.title,
|
||||
.sub {
|
||||
max-width: 100%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-align: left;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 600;
|
||||
}
|
||||
.title.muted { color: var(--text-secondary); }
|
||||
.title.err { color: #f15e6c; }
|
||||
.title.link:hover { text-decoration: underline; }
|
||||
|
||||
.sub {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.35;
|
||||
}
|
||||
.sub.link:hover {
|
||||
color: var(--text-primary);
|
||||
text-decoration: underline;
|
||||
}
|
||||
.sub.album {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.sub.album.link:hover { color: var(--text-primary); }
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: min(45vw, 560px);
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.ctl {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.ctl.small {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.ctl:disabled {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.ctl:hover:not(:disabled) {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* shuffle / repeat active state */
|
||||
.ctl.on {
|
||||
color: var(--accent-hover);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ctl.on::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: -6px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.play {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-primary);
|
||||
color: #000;
|
||||
font-size: 14px;
|
||||
transition: transform 0.1s;
|
||||
}
|
||||
|
||||
.play:disabled {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.play:hover:not(:disabled) {
|
||||
transform: scale(1.06);
|
||||
}
|
||||
|
||||
/* Fixed height so swapping between the thin bar and the waveform
|
||||
canvas never reflows the buttons above. */
|
||||
.scrubber {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
.time {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
min-width: 32px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.track {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: hsla(0, 0%, 100%, 0.2);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.track:hover {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.track.dragging {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.buffer {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
background: hsla(0, 0%, 100%, 0.25);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.fill {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
background: var(--text-primary);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.track:hover .fill {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
/* waveform scrubber */
|
||||
.wave-wrap {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
height: 26px;
|
||||
cursor: pointer;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.wave-canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.wave-tip {
|
||||
position: absolute;
|
||||
top: -22px;
|
||||
transform: translateX(-50%);
|
||||
background: #000;
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
padding: 2px 7px;
|
||||
border-radius: 5px;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.extras {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.vol-icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.vol-icon:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.vol-icon.on {
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.track.vol {
|
||||
flex: 0 0 90px;
|
||||
overflow: visible;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.track.vol::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -8px 0;
|
||||
}
|
||||
|
||||
.track.vol .fill {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.vol-thumb {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-primary);
|
||||
transform: translate(-50%, -50%) scale(0);
|
||||
transition: transform 0.12s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.track.vol:hover .vol-thumb,
|
||||
.track.vol.dragging .vol-thumb,
|
||||
.track.vol:focus-visible .vol-thumb {
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
|
||||
.track.vol:hover,
|
||||
.track.vol.dragging {
|
||||
height: 5px;
|
||||
}
|
||||
|
||||
.track.vol:hover .fill,
|
||||
.track.vol.dragging .fill {
|
||||
background: var(--accent);
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user