Add a mini-player to build mode

Every song in the Build tab gets a play button. A bar along the bottom
plays the folder in order with seeking, previous/next, volume (kept per
browser) and space to pause, and says so when the browser can't play a
format. Songs stream from the master library through a new endpoint with
range requests; it only serves regular music files inside the library.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cdnutterandClaude Opus 5.5 committed 2026-09-30 17:14:17 -07:00
1 parent da74756c24
commit 491d679233
7 files changed
+443 -4

No files matched your search

+30
View File
@@ -79,3 +79,33 @@ select:focus-visible,
textarea:focus-visible {
outline: none;
}
/* Player sliders: a slim track filled with the brand gradient up to --fill. */
.player-range {
appearance: none;
height: 4px;
border-radius: 9999px;
cursor: pointer;
background: linear-gradient(90deg, var(--color-accent), var(--color-accent-2)) 0 / var(--fill, 0%) 100% no-repeat, var(--color-line);
}
.player-range:disabled {
cursor: default;
}
.player-range::-webkit-slider-thumb {
appearance: none;
width: 12px;
height: 12px;
border-radius: 9999px;
background: #fff;
box-shadow: 0 0 0 3px rgb(255 122 89 / 0.25);
}
.player-range::-moz-range-thumb {
width: 12px;
height: 12px;
border: 0;
border-radius: 9999px;
background: #fff;
}
.player-range:focus-visible {
outline-offset: 4px;
}
+34 -4
View File
@@ -1,14 +1,18 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ChevronRight, Disc3, FileAudio, Folder, Link2, Unlink } from "lucide-react";
import { AudioLines, ChevronRight, Disc3, Folder, Link2, Play, Unlink } from "lucide-react";
import { api, type Pair, type TreeEntry } from "../api";
import { Alert, Badge, Button, Card, Empty, Modal, Spinner, cx, formatBytes } from "../ui";
import { useToast } from "../toast";
import { MiniPlayer, type Track } from "./Player";
export function Build({ pair }: { pair: Pair }) {
const [path, setPath] = useState("");
const [sel, setSel] = useState<Set<string>>(new Set());
const [confirm, setConfirm] = useState<null | "build" | "remove">(null);
// The mini-player keeps its own copy of the folder it started in.
const [player, setPlayer] = useState<{ queue: Track[]; index: number } | null>(null);
const nowPlaying = player ? player.queue[player.index].rel : null;
const qc = useQueryClient();
const toast = useToast();
const tree = useQuery({
@@ -37,7 +41,7 @@ export function Build({ pair }: { pair: Pair }) {
const allSelected = all.length > 0 && all.every((e) => sel.has(e.rel));
return (
<div className="space-y-4">
<div className={cx("space-y-4", player && "pb-28")}>
<Alert tone="blue">
Build the slave library from the master. Browse the master library, pick folders or songs, and Playlink hardlinks them into the slave
library at the same relative path. No extra space is used. Existing slave files are never
@@ -91,13 +95,29 @@ export function Build({ pair }: { pair: Pair }) {
{all.map((e) => (
<div key={e.rel} className={cx("flex items-center gap-3 border-b border-line px-4 py-2 last:border-0", sel.has(e.rel) && "bg-accent/5")}>
<input type="checkbox" className="" checked={sel.has(e.rel)} onChange={() => toggle(e.rel)} />
{e.dir ? <Folder className="size-4 shrink-0 text-accent" /> : <FileAudio className="size-4 shrink-0 text-muted" />}
{e.dir ? (
<Folder className="size-4 shrink-0 text-accent" />
) : (
<button
className={cx(
"grid size-6 shrink-0 place-items-center rounded-full cursor-pointer",
nowPlaying === e.rel ? "brand-gradient text-on-accent" : "text-muted hover:bg-accent/15 hover:text-accent",
)}
title={nowPlaying === e.rel ? "Playing" : "Listen"}
onClick={() => {
const queue = all.filter((x) => !x.dir).map((x) => ({ rel: x.rel, name: x.name }));
setPlayer({ queue, index: queue.findIndex((t) => t.rel === e.rel) });
}}
>
{nowPlaying === e.rel ? <AudioLines className="size-3.5" /> : <Play className="ml-px size-3 fill-current" />}
</button>
)}
{e.dir ? (
<button className="min-w-0 flex-1 truncate text-left text-sm hover:underline cursor-pointer" onClick={() => { setPath(e.rel); setSel(new Set()); }}>
{e.name}
</button>
) : (
<span className="min-w-0 flex-1 truncate text-sm">{e.name}</span>
<span className={cx("min-w-0 flex-1 truncate text-sm", nowPlaying === e.rel && "font-medium text-accent")}>{e.name}</span>
)}
{e.dir && !!e.linked && <Badge tone="green">{e.linked} linked</Badge>}
{e.state === "linked" && <Badge tone="green">in slave</Badge>}
@@ -108,6 +128,16 @@ export function Build({ pair }: { pair: Pair }) {
</Card>
<p className="text-xs text-muted">Folder link counts come from the last scan. Song states are live.</p>
{player && (
<MiniPlayer
pairId={pair.id}
queue={player.queue}
index={player.index}
onIndex={(index) => setPlayer({ ...player, index })}
onClose={() => setPlayer(null)}
/>
)}
<Modal open={confirm !== null} onClose={() => setConfirm(null)} title={confirm === "build" ? "Link into slave library?" : "Remove from slave library?"}>
<p className="text-sm text-gray-300">
{confirm === "build" ? (
+219
View File
@@ -0,0 +1,219 @@
import { useEffect, useRef, useState } from "react";
import { CircleAlert, Disc3, Pause, Play, SkipBack, SkipForward, Volume1, Volume2, VolumeX, X } from "lucide-react";
import { cx } from "../ui";
/** A song the player can play: its path in the master library and a display name. */
export type Track = { rel: string; name: string };
export function listenURL(pairId: number, rel: string) {
return `/api/pairs/${pairId}/listen?side=master&path=${encodeURIComponent(rel)}`;
}
function clock(sec: number) {
if (!isFinite(sec) || sec < 0) return "0:00";
const m = Math.floor(sec / 60);
const s = Math.floor(sec % 60);
return `${m}:${s.toString().padStart(2, "0")}`;
}
function loadVolume() {
try {
const v = Number(localStorage.getItem("playlink.volume"));
return isFinite(v) && v > 0 && v <= 1 ? v : 0.8;
} catch {
return 0.8;
}
}
/**
* The build mode mini-player: a bar along the bottom of the page that plays
* one folder's songs in order.
*/
export function MiniPlayer({
pairId,
queue,
index,
onIndex,
onClose,
}: {
pairId: number;
queue: Track[];
index: number;
onIndex: (i: number) => void;
onClose: () => void;
}) {
const audio = useRef<HTMLAudioElement>(null);
const [playing, setPlaying] = useState(false);
const [time, setTime] = useState(0);
const [duration, setDuration] = useState(0);
const [volume, setVolume] = useState(loadVolume);
const [muted, setMuted] = useState(false);
const [error, setError] = useState<string | null>(null);
const track = queue[index];
const folder = track.rel.includes("/") ? track.rel.slice(0, track.rel.lastIndexOf("/")) : "";
// A new song: load it and start playing.
useEffect(() => {
const a = audio.current;
if (!a) return;
setError(null);
setTime(0);
setDuration(0);
a.src = listenURL(pairId, track.rel);
a.play().catch(() => {}); // a refused autoplay just leaves it paused
}, [pairId, track.rel]);
useEffect(() => {
if (audio.current) audio.current.volume = volume;
try {
localStorage.setItem("playlink.volume", String(volume));
} catch {
/* private mode */
}
}, [volume]);
useEffect(() => {
if (audio.current) audio.current.muted = muted;
}, [muted]);
// Space toggles play/pause, unless typing in a field.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const t = e.target as HTMLElement;
if (e.code !== "Space" || ["INPUT", "SELECT", "TEXTAREA", "BUTTON"].includes(t.tagName)) return;
e.preventDefault();
toggle();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
});
const toggle = () => {
const a = audio.current;
if (!a) return;
if (a.paused) a.play().catch(() => {});
else a.pause();
};
const hasPrev = index > 0;
const hasNext = index < queue.length - 1;
// Back restarts the song unless it just began.
const prev = () => {
const a = audio.current;
if (a && a.currentTime > 3) a.currentTime = 0;
else if (hasPrev) onIndex(index - 1);
};
const VolIcon = muted || volume === 0 ? VolumeX : volume < 0.5 ? Volume1 : Volume2;
const pct = duration > 0 ? (time / duration) * 100 : 0;
return (
<div className="fixed inset-x-0 bottom-0 z-30 border-t border-line bg-panel/90 shadow-2xl shadow-black/50 backdrop-blur-lg">
{/* Thin progress line along the top edge. */}
<div className="h-0.5 bg-line">
<div className="h-full brand-gradient transition-[width] duration-200" style={{ width: `${pct}%` }} />
</div>
<div className="mx-auto flex max-w-7xl flex-wrap items-center gap-x-5 gap-y-2 px-4 py-3">
<div className="flex min-w-0 flex-1 basis-56 items-center gap-3">
<div className="grid size-10 shrink-0 place-items-center rounded-lg brand-gradient text-on-accent">
<Disc3 className={cx("size-5", playing && "animate-[spin_3s_linear_infinite]")} />
</div>
<div className="min-w-0">
<div className="truncate text-sm font-medium text-gray-100" title={track.name}>
{track.name}
</div>
<div className="truncate text-xs text-muted" title={folder}>
{error ? (
<span className="flex items-center gap-1 text-amber-300">
<CircleAlert className="size-3.5" /> {error}
</span>
) : (
<>
{folder || "master"} · {index + 1} of {queue.length}
</>
)}
</div>
</div>
</div>
<div className="flex items-center gap-1">
<button className="rounded-md p-2 text-gray-300 hover:bg-panel-2 hover:text-white disabled:opacity-30 cursor-pointer" onClick={prev} title="Previous">
<SkipBack className="size-4 fill-current" />
</button>
<button
className="grid size-10 place-items-center rounded-full brand-gradient text-on-accent shadow-md shadow-accent/20 hover:brightness-110 cursor-pointer"
onClick={toggle}
title={playing ? "Pause (space)" : "Play (space)"}
>
{playing ? <Pause className="size-4 fill-current" /> : <Play className="ml-0.5 size-4 fill-current" />}
</button>
<button
className="rounded-md p-2 text-gray-300 hover:bg-panel-2 hover:text-white disabled:opacity-30 cursor-pointer"
onClick={() => onIndex(index + 1)}
disabled={!hasNext}
title="Next"
>
<SkipForward className="size-4 fill-current" />
</button>
</div>
<div className="flex min-w-0 flex-[2] basis-64 items-center gap-2 text-xs tabular-nums text-muted">
<span className="w-10 text-right">{clock(time)}</span>
<input
type="range"
className="player-range min-w-0 flex-1"
min={0}
max={duration || 0}
step={0.1}
value={time}
disabled={!duration}
style={{ "--fill": `${pct}%` } as React.CSSProperties}
onChange={(e) => {
const t = Number(e.target.value);
if (audio.current) audio.current.currentTime = t;
setTime(t);
}}
aria-label="Seek"
/>
<span className="w-10">{clock(duration)}</span>
</div>
<div className="hidden items-center gap-1.5 md:flex">
<button className="rounded-md p-1.5 text-gray-300 hover:bg-panel-2 hover:text-white cursor-pointer" onClick={() => setMuted(!muted)} title={muted ? "Unmute" : "Mute"}>
<VolIcon className="size-4" />
</button>
<input
type="range"
className="player-range w-24"
min={0}
max={1}
step={0.01}
value={muted ? 0 : volume}
style={{ "--fill": `${(muted ? 0 : volume) * 100}%` } as React.CSSProperties}
onChange={(e) => {
setVolume(Number(e.target.value));
setMuted(false);
}}
aria-label="Volume"
/>
</div>
<button className="rounded-md p-1.5 text-muted hover:bg-panel-2 hover:text-white cursor-pointer" onClick={onClose} title="Close player">
<X className="size-4" />
</button>
</div>
<audio
ref={audio}
preload="metadata"
onPlay={() => setPlaying(true)}
onPause={() => setPlaying(false)}
onTimeUpdate={(e) => setTime(e.currentTarget.currentTime)}
onDurationChange={(e) => setDuration(e.currentTarget.duration)}
onEnded={() => (hasNext ? onIndex(index + 1) : setPlaying(false))}
onError={() => {
setPlaying(false);
const ext = track.name.includes(".") ? track.name.slice(track.name.lastIndexOf(".")).toLowerCase() : "this format";
setError(`Your browser can't play ${ext} files`);
}}
/>
</div>
);
}