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:
1 parent
da74756c24
commit
491d679233
7 files changed
+443
-4
No files matched your search
@@ -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
|
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).
|
(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 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
|
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
|
ones holding only a cover or playlist, with what they still contain. Empty folders are pre-selected, folders that still hold files are
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -101,6 +101,7 @@ func (s *Server) Handler() http.Handler {
|
|||||||
r.Post("/renames", s.handleRenames)
|
r.Post("/renames", s.handleRenames)
|
||||||
r.Post("/link-identical", s.handleLinkIdentical)
|
r.Post("/link-identical", s.handleLinkIdentical)
|
||||||
r.Get("/tree", s.handleTree)
|
r.Get("/tree", s.handleTree)
|
||||||
|
r.Get("/listen", s.handleListen)
|
||||||
r.Get("/empty-folders", s.handleEmptyFolders)
|
r.Get("/empty-folders", s.handleEmptyFolders)
|
||||||
r.Post("/empty-folders/scan", s.handleEmptyFolderScan)
|
r.Post("/empty-folders/scan", s.handleEmptyFolderScan)
|
||||||
r.Post("/empty-folders/remove", s.handleRemoveEmptyFolders)
|
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))
|
s.log.Info("removing folders without music", "pair", id, "folders", len(body.Folders))
|
||||||
writeJSON(w, 202, j)
|
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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -79,3 +79,33 @@ select:focus-visible,
|
|||||||
textarea:focus-visible {
|
textarea:focus-visible {
|
||||||
outline: none;
|
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
@@ -1,14 +1,18 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
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 { api, type Pair, type TreeEntry } from "../api";
|
||||||
import { Alert, Badge, Button, Card, Empty, Modal, Spinner, cx, formatBytes } from "../ui";
|
import { Alert, Badge, Button, Card, Empty, Modal, Spinner, cx, formatBytes } from "../ui";
|
||||||
import { useToast } from "../toast";
|
import { useToast } from "../toast";
|
||||||
|
import { MiniPlayer, type Track } from "./Player";
|
||||||
|
|
||||||
export function Build({ pair }: { pair: Pair }) {
|
export function Build({ pair }: { pair: Pair }) {
|
||||||
const [path, setPath] = useState("");
|
const [path, setPath] = useState("");
|
||||||
const [sel, setSel] = useState<Set<string>>(new Set());
|
const [sel, setSel] = useState<Set<string>>(new Set());
|
||||||
const [confirm, setConfirm] = useState<null | "build" | "remove">(null);
|
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 qc = useQueryClient();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const tree = useQuery({
|
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));
|
const allSelected = all.length > 0 && all.every((e) => sel.has(e.rel));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className={cx("space-y-4", player && "pb-28")}>
|
||||||
<Alert tone="blue">
|
<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
|
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
|
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) => (
|
{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")}>
|
<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)} />
|
<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 ? (
|
{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()); }}>
|
<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}
|
{e.name}
|
||||||
</button>
|
</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.dir && !!e.linked && <Badge tone="green">{e.linked} linked</Badge>}
|
||||||
{e.state === "linked" && <Badge tone="green">in slave</Badge>}
|
{e.state === "linked" && <Badge tone="green">in slave</Badge>}
|
||||||
@@ -108,6 +128,16 @@ export function Build({ pair }: { pair: Pair }) {
|
|||||||
</Card>
|
</Card>
|
||||||
<p className="text-xs text-muted">Folder link counts come from the last scan. Song states are live.</p>
|
<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?"}>
|
<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">
|
<p className="text-sm text-gray-300">
|
||||||
{confirm === "build" ? (
|
{confirm === "build" ? (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user