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

+5
View File
@@ -76,6 +76,11 @@ Each row has the match status and type, a recommendation, both paths, where link
bit depth and duration for both sides, the quality verdict and any notes. The file opens cleanly in Excel, Numbers and LibreOffice
(UTF-8, and cells a spreadsheet would treat as formulas are escaped).
**Listening in build mode.** Every song in the Build tab has a play button. A mini-player along the bottom of the page plays the
folder in order, with seeking, previous/next, volume and space to pause. Songs are streamed from the master library as they are
(nothing is converted), so they play when the browser supports the format: FLAC, MP3, AAC/M4A, Ogg/Opus and WAV in current browsers,
while formats such as WMA or APE show a message instead.
**Folders without music** (the *Cleanup* tab). Choose the slave or master library and **Find folders**: Playlink lists the top-most
folders with no music file anywhere inside them (music = the pair's file extensions), such as album folders left empty after linking or
ones holding only a cover or playlist, with what they still contain. Empty folders are pre-selected, folders that still hold files are
+76
View File
@@ -0,0 +1,76 @@
package api
import (
"context"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"testing"
"testing/fstest"
"time"
"github.com/cdnutter/playlink/internal/engine"
"github.com/cdnutter/playlink/internal/probe"
"github.com/cdnutter/playlink/internal/store"
)
func TestListen(t *testing.T) {
ctx := context.Background()
root, _ := filepath.EvalSymlinks(t.TempDir())
master, slave := filepath.Join(root, "Albums"), filepath.Join(root, "Singles")
os.MkdirAll(filepath.Join(master, "A"), 0o755)
os.MkdirAll(slave, 0o755)
os.WriteFile(filepath.Join(master, "A/song.flac"), []byte("0123456789"), 0o644)
os.WriteFile(filepath.Join(master, "A/cover.jpg"), []byte("img"), 0o644)
os.WriteFile(filepath.Join(root, "secret.flac"), []byte("outside"), 0o644)
os.Symlink(filepath.Join(root, "secret.flac"), filepath.Join(master, "A/link.flac"))
st, err := store.Open(root)
if err != nil {
t.Fatal(err)
}
defer st.DB.Close()
eng := engine.New(st, probe.Prober{}, []string{root}, slog.New(slog.NewTextHandler(io.Discard, nil)))
p := store.Pair{Name: "Music", MasterRoot: master, SlaveRoot: slave}
if _, err := eng.NormalizePair(&p); err != nil {
t.Fatal(err)
}
st.SavePair(ctx, &p)
if err := st.CreateFirstUser(ctx, "chris", "x"); err != nil {
t.Fatal(err)
}
u, _ := st.UserByName(ctx, "chris")
st.CreateSession(ctx, hashToken("tok"), u.ID, time.Now().Add(time.Hour).Unix())
h := New(Config{}, st, eng, slog.New(slog.NewTextHandler(io.Discard, nil)), fstest.MapFS{}).Handler()
get := func(path string, auth bool, rng string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, "/api/pairs/1/listen?side=master&path="+url.QueryEscape(path), nil)
if auth {
req.AddCookie(&http.Cookie{Name: cookieName, Value: "tok"})
}
if rng != "" {
req.Header.Set("Range", rng)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
if rec := get("A/song.flac", true, ""); rec.Code != 200 || rec.Body.String() != "0123456789" || rec.Header().Get("Content-Type") != "audio/flac" {
t.Fatalf("song: %d %q %q", rec.Code, rec.Body.String(), rec.Header().Get("Content-Type"))
}
if rec := get("A/song.flac", true, "bytes=2-5"); rec.Code != http.StatusPartialContent || rec.Body.String() != "2345" {
t.Fatalf("seeking needs range requests: %d %q", rec.Code, rec.Body.String())
}
for _, bad := range []string{"A/cover.jpg", "../secret.flac", "A/link.flac", "/etc/passwd"} {
if rec := get(bad, true, ""); rec.Code < 400 {
t.Errorf("%s must be refused, got %d", bad, rec.Code)
}
}
if rec := get("A/song.flac", false, ""); rec.Code != http.StatusUnauthorized {
t.Fatalf("listening needs a session, got %d", rec.Code)
}
}
+31
View File
@@ -101,6 +101,7 @@ func (s *Server) Handler() http.Handler {
r.Post("/renames", s.handleRenames)
r.Post("/link-identical", s.handleLinkIdentical)
r.Get("/tree", s.handleTree)
r.Get("/listen", s.handleListen)
r.Get("/empty-folders", s.handleEmptyFolders)
r.Post("/empty-folders/scan", s.handleEmptyFolderScan)
r.Post("/empty-folders/remove", s.handleRemoveEmptyFolders)
@@ -786,3 +787,33 @@ func (s *Server) handleRemoveEmptyFolders(w http.ResponseWriter, r *http.Request
s.log.Info("removing folders without music", "pair", id, "folders", len(body.Folders))
writeJSON(w, 202, j)
}
// ---- listening (build mode player) ----
var audioTypes = map[string]string{
".flac": "audio/flac", ".mp3": "audio/mpeg", ".m4a": "audio/mp4", ".aac": "audio/aac", ".alac": "audio/mp4",
".ogg": "audio/ogg", ".oga": "audio/ogg", ".opus": "audio/ogg", ".wav": "audio/wav", ".aif": "audio/aiff",
".aiff": "audio/aiff", ".mka": "audio/x-matroska", ".wma": "audio/x-ms-wma",
}
// handleListen streams a song with range support, so the browser can seek.
func (s *Server) handleListen(w http.ResponseWriter, r *http.Request) {
id, _ := idParam(r)
q := r.URL.Query()
f, fi, err := s.eng.OpenSong(r.Context(), id, q.Get("side"), q.Get("path"))
if err != nil {
opErr(w, err)
return
}
defer f.Close()
ct := audioTypes[strings.ToLower(filepath.Ext(fi.Name()))]
if ct == "" {
ct = "application/octet-stream"
}
w.Header().Set("Content-Type", ct)
w.Header().Set("Cache-Control", "private, max-age=300")
// A long song on a slow connection may take longer than the server's
// write timeout.
_ = http.NewResponseController(w).SetWriteDeadline(time.Time{})
http.ServeContent(w, r, fi.Name(), fi.ModTime(), f)
}
+48
View File
@@ -0,0 +1,48 @@
package engine
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"github.com/cdnutter/playlink/internal/fsops"
)
// OpenSong opens a song in one library for listening (the build mode
// player). Only regular music files inside the library are served: no
// symlinks, no other file types.
func (e *Engine) OpenSong(ctx context.Context, pairID int64, side, rel string) (*os.File, os.FileInfo, error) {
l, err := e.loadPair(ctx, pairID)
if err != nil {
return nil, nil, err
}
root, err := sideRoot(l, side)
if err != nil {
return nil, nil, err
}
abs, err := root.Resolve(rel)
if err != nil {
return nil, nil, err
}
if !l.isMusic(filepath.Base(abs)) {
return nil, nil, fmt.Errorf("%w: not a music file", ErrInvalidBulk)
}
if _, err := fsops.RegularFile(abs); err != nil {
return nil, nil, err
}
f, err := os.Open(abs)
if err != nil {
return nil, nil, err
}
// What was opened must still be the regular file that was checked, not
// something swapped in for it since.
fi, err := f.Stat()
li, lerr := os.Lstat(abs)
if err != nil || lerr != nil || !li.Mode().IsRegular() || !os.SameFile(fi, li) {
f.Close()
return nil, nil, errors.New("the file changed while opening it")
}
return f, fi, nil
}
+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>
);
}