@@ -244,14 +244,29 @@ return reply.code(500).send({ ok: false, error: message }); }
});
+ // Throttle track reads per IP. Generous enough for normal use (mount fires a
+ // few requests; each Next/search is one) while capping abuse of the DB-backed
+ // endpoints. Returns true when the request may proceed.
+ const TRACK_READS_PER_MIN = 240;
+ function allowTrackRead(req: { ip: string }, reply: import("fastify").FastifyReply): boolean {
+ const rl = rateLimit(`track:${req.ip}`, TRACK_READS_PER_MIN, 60_000);
+ if (!rl.ok) {
+ reply.code(429).send({ error: "Too many requests", retryAfterSec: rl.retryAfterSec });
+ return false;
+ }
+ return true;
+ }
+
app.get("/api/track/search", async (req, reply) => {
+ if (!allowTrackRead(req, reply)) return reply;
const q = (req.query as { q?: string }).q ?? "";
const limitRaw = (req.query as { limit?: string }).limit;
const limit = limitRaw ? Math.min(50, Math.max(1, Number(limitRaw) || 20)) : 20;
return reply.send({ tracks: searchTracks(q, limit) });
});
- app.get("/api/track/random", async (_req, reply) => {
+ app.get("/api/track/random", async (req, reply) => {
+ if (!allowTrackRead(req, reply)) return reply;
const track = pickRandom();
if (!track) {
return reply.code(503).send({
@@ -262,6 +277,7 @@ return reply.send(toTrackPayload(track)); });
app.get("/api/track/up-next", async (req, reply) => {
+ if (!allowTrackRead(req, reply)) return reply;
const raw = (req.query as { exclude?: string }).exclude;
const excludeId = typeof raw === "string" ? raw.trim() : undefined;
const track = pickRandom(excludeId);
@@ -274,6 +290,7 @@ return reply.send(toTrackPayload(track)); });
app.get("/api/track/:id", async (req, reply) => {
+ if (!allowTrackRead(req, reply)) return reply;
const id = (req.params as { id: string }).id?.trim();
if (!id) return reply.code(400).send({ error: "Missing track id" });
const track = getById(id);
@@ -1,11 +1,24 @@ const buckets = new Map<string, { count: number; resetAt: number }>();
+/** Periodic eviction so idle IPs don't accumulate forever on a public server. */
+let nextSweep = 0;
+const SWEEP_INTERVAL_MS = 60_000;
+
+function sweep(now: number): void {
+ if (now < nextSweep) return;
+ nextSweep = now + SWEEP_INTERVAL_MS;
+ for (const [key, b] of buckets) {
+ if (now >= b.resetAt) buckets.delete(key);
+ }
+}
+
export function rateLimit(
key: string,
maxPerWindow: number,
windowMs: number,
): { ok: true } | { ok: false; retryAfterSec: number } {
const now = Date.now();
+ sweep(now);
let b = buckets.get(key);
if (!b || now >= b.resetAt) {
b = { count: 0, resetAt: now + windowMs };
@@ -39,6 +39,7 @@ }
export class TrackStore {
protected db: Database.Database | null = null;
+ private maxRowidCache: number | null = null;
constructor(private readonly dbPath: string) {}
@@ -114,27 +115,50 @@ .get(id.trim()) as Row | undefined; return row ? rowToMeta(row) : null;
}
+ /** Highest rowid in `tracks`, cached for the store's lifetime (rowids are stable). */
+ private maxRowid(): number {
+ if (this.maxRowidCache !== null) return this.maxRowidCache;
+ const row = this.requireDb()
+ .prepare(`SELECT MAX(rowid) AS m FROM tracks`)
+ .get() as { m: number | null };
+ this.maxRowidCache = row.m ?? 0;
+ return this.maxRowidCache;
+ }
+
+ /**
+ * Pick a random track without an `ORDER BY RANDOM()` full-table scan/sort.
+ * Samples a random rowid and takes the first non-blocked track at or after it
+ * (wrapping to the start if the sample lands past the last row). O(log n) via
+ * the primary-key index instead of O(n log n).
+ */
random(excludeId?: string): TrackMeta | null {
const db = this.requireDb();
+ const max = this.maxRowid();
+ if (max <= 0) return null;
const ex = excludeId?.trim();
- if (ex) {
- const row = db
- .prepare(
- `SELECT id, title, artist, file_key, cdn_url, archive_url FROM tracks t
- WHERE t.id != ?
- AND NOT EXISTS (SELECT 1 FROM blocked_ids b WHERE b.id = t.id)
- ORDER BY RANDOM() LIMIT 1`,
- )
- .get(ex) as Row | undefined;
- if (row) return rowToMeta(row);
+
+ const COLS = `id, title, artist, file_key, cdn_url, archive_url`;
+ const pickAtOrAfter = db.prepare(
+ `SELECT ${COLS} FROM tracks t
+ WHERE t.rowid >= ?
+ AND NOT EXISTS (SELECT 1 FROM blocked_ids b WHERE b.id = t.id)
+ ORDER BY t.rowid LIMIT 1`,
+ );
+ const pickFirst = db.prepare(
+ `SELECT ${COLS} FROM tracks t
+ WHERE NOT EXISTS (SELECT 1 FROM blocked_ids b WHERE b.id = t.id)
+ ORDER BY t.rowid LIMIT 1`,
+ );
+
+ // A few attempts so we can skip the excluded id without another full scan.
+ for (let attempt = 0; attempt < 6; attempt++) {
+ const target = 1 + Math.floor(Math.random() * max);
+ const row = (pickAtOrAfter.get(target) ?? pickFirst.get()) as Row | undefined;
+ if (!row) return null;
+ if (ex && row.id === ex && max > 1) continue;
+ return rowToMeta(row);
}
- const row = db
- .prepare(
- `SELECT id, title, artist, file_key, cdn_url, archive_url FROM tracks t
- WHERE NOT EXISTS (SELECT 1 FROM blocked_ids b WHERE b.id = t.id)
- ORDER BY RANDOM() LIMIT 1`,
- )
- .get() as Row | undefined;
+ const row = pickFirst.get() as Row | undefined;
return row ? rowToMeta(row) : null;
}
@@ -106,6 +106,33 @@ border: 2px dashed var(--border-dash); border-radius: 18px 10px 20px 14px;
padding: 1.25rem 1.25rem 1.5rem;
box-shadow: var(--shadow-card);
+ transition: box-shadow 0.2s ease, transform 0.2s ease, border-color 0.2s ease;
+}
+
+.now-playing:hover,
+.history:hover {
+ border-color: color-mix(in srgb, var(--accent-cyan) 55%, transparent);
+ box-shadow:
+ var(--shadow-card),
+ 0 0 0 1px color-mix(in srgb, var(--accent-cyan) 18%, transparent);
+}
+
+/* Themed scrollbars for the scrolling lists. */
+.history-list,
+.track-search-results {
+ scrollbar-width: thin;
+ scrollbar-color: color-mix(in srgb, var(--accent-cyan) 45%, transparent) transparent;
+}
+
+.history-list::-webkit-scrollbar,
+.track-search-results::-webkit-scrollbar {
+ width: 8px;
+}
+
+.history-list::-webkit-scrollbar-thumb,
+.track-search-results::-webkit-scrollbar-thumb {
+ background: color-mix(in srgb, var(--accent-cyan) 40%, transparent);
+ border-radius: 999px;
}
.card-head {
@@ -382,6 +409,24 @@ gap: 1rem; margin-top: 1.15rem;
}
+.transport {
+ display: inline-flex;
+ align-items: stretch;
+ gap: 0.5rem;
+}
+
+.btn-icon {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.4rem;
+}
+
+.btn-glyph {
+ width: 1.05rem;
+ height: 1.05rem;
+ flex-shrink: 0;
+}
+
.btn {
cursor: pointer;
border: none;
@@ -390,9 +435,25 @@ padding: 0.65rem 1.4rem; font-weight: 700;
font-size: 1rem;
font-family: inherit;
+ color: #f0f6ff;
+ background: color-mix(in srgb, var(--accent-cyan) 18%, var(--logo-navy-deep) 82%);
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.12);
transition: transform 0.08s ease, filter 0.15s ease, box-shadow 0.15s ease;
}
+.btn:hover:not(:disabled) {
+ filter: brightness(1.12);
+}
+
+.btn:active:not(:disabled) {
+ transform: translateY(1px);
+}
+
+.btn:disabled {
+ cursor: not-allowed;
+ opacity: 0.55;
+}
+
.btn.primary {
background: linear-gradient(
175deg,
@@ -432,19 +493,61 @@ color: var(--muted); }
.history-list {
+ list-style: none;
margin: 0;
- padding-left: 1.1rem;
+ padding: 0;
max-height: 14rem;
overflow: auto;
+ display: flex;
+ flex-direction: column;
+ gap: 0.15rem;
+}
+
+.history-empty {
+ margin: 0.25rem 0 0;
+ font-size: 0.9rem;
}
.main-home .history h2 {
margin-bottom: 0.75rem;
}
-.history-list li {
- margin-bottom: 0.45rem;
+.history-item {
font-size: 0.92rem;
+ border-radius: 9px;
+ transition: background 0.15s ease;
+}
+
+.history-item:hover {
+ background: color-mix(in srgb, var(--accent-cyan) 10%, transparent);
+}
+
+.history-item--current {
+ background: color-mix(in srgb, var(--accent-cyan) 16%, transparent);
+}
+
+.history-hit {
+ display: flex;
+ align-items: center;
+ gap: 0.55rem;
+ width: 100%;
+}
+
+.track-search-hit:hover,
+.history-hit:hover {
+ text-decoration: none;
+}
+
+.history-hit:hover .history-text {
+ text-decoration: underline;
+}
+
+.history-text {
+ min-width: 0;
+ flex: 1;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
}
.h-artist {
@@ -459,6 +562,92 @@ }
.h-title {
font-weight: 500;
+}
+
+/* Small equalizer glyph shown on the current history row. */
+.history-eq {
+ display: inline-flex;
+ align-items: flex-end;
+ gap: 2px;
+ width: 0.85rem;
+ height: 0.85rem;
+ flex-shrink: 0;
+ opacity: 0;
+}
+
+.history-item--current .history-eq {
+ opacity: 1;
+}
+
+.history-eq i {
+ width: 3px;
+ height: 40%;
+ border-radius: 2px;
+ background: var(--accent-cyan);
+}
+
+.history-item--current .history-eq i {
+ animation: eq-bounce 0.9s ease-in-out infinite;
+}
+
+.history-eq i:nth-child(2) {
+ animation-delay: 0.18s;
+}
+
+.history-eq i:nth-child(3) {
+ animation-delay: 0.36s;
+}
+
+/* Now-playing equalizer next to the artist name. */
+.np-eq {
+ display: inline-flex;
+ align-items: flex-end;
+ gap: 2.5px;
+ width: 1.1rem;
+ height: 1rem;
+ margin-right: 0.55rem;
+ vertical-align: -0.1rem;
+}
+
+.np-eq i {
+ width: 3px;
+ height: 35%;
+ border-radius: 2px;
+ background: linear-gradient(var(--accent-cyan), var(--accent-sky));
+}
+
+.np-eq--on i {
+ animation: eq-bounce 0.85s ease-in-out infinite;
+}
+
+.np-eq i:nth-child(2) {
+ animation-delay: 0.12s;
+}
+
+.np-eq i:nth-child(3) {
+ animation-delay: 0.28s;
+}
+
+.np-eq i:nth-child(4) {
+ animation-delay: 0.42s;
+}
+
+@keyframes eq-bounce {
+ 0%,
+ 100% {
+ height: 30%;
+ }
+ 50% {
+ height: 100%;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .history-eq i,
+ .np-eq--on i {
+ animation: none;
+ height: 60%;
+ }
}
.health-banner {
@@ -35,9 +35,12 @@ type Props = { audioRef: RefObject<HTMLAudioElement | null>;
/** No track / no usable stream */
disabled?: boolean;
+ /** Bumped by the player when the active audio element is swapped, so the
+ * progress/transport listeners re-bind to the newly active element. */
+ generation?: number;
};
-export function CozyAudioBar({ audioRef, disabled }: Props) {
+export function CozyAudioBar({ audioRef, disabled, generation }: Props) {
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [playing, setPlaying] = useState(false);
@@ -119,7 +122,7 @@ el.removeEventListener("volumechange", onVol); window.removeEventListener("pointerup", endScrub);
window.removeEventListener("pointercancel", endScrub);
};
- }, [audioRef]);
+ }, [audioRef, generation]);
const dur = duration;
const pct =
@@ -17,8 +17,9 @@ useEffect(() => {
const trimmed = q.trim();
if (trimmed.length < 2) {
- setResults([]);
- return;
+ // Defer so we don't setState synchronously in the effect body.
+ const clear = window.setTimeout(() => setResults([]), 0);
+ return () => window.clearTimeout(clear);
}
const t = window.setTimeout(() => {
void (async () => {
@@ -37,6 +37,9 @@ };
const MAX_ARCHIVE_STREAM_ERRORS = 3;
+/** How many recently played tracks to keep in the history list. */
+const HISTORY_LIMIT = 7;
+
export type PlaybackOptions = {
/** Initial track id from URL ?track= or embed ?start= */
startTrackId?: string | null;
@@ -59,12 +62,23 @@ startMuted = false, onTrackChange,
} = options;
- const audioRef = useRef<HTMLAudioElement>(null);
- const preloadAudioRef = useRef<HTMLAudioElement>(null);
+ // Two audio elements: one plays while the other preloads the queued track, so
+ // advancing swaps to already-buffered audio instead of re-downloading it.
+ // `audioRef` always points at whichever element is currently active.
+ const elARef = useRef<HTMLAudioElement>(null);
+ const elBRef = useRef<HTMLAudioElement>(null);
+ const audioRef = useRef<HTMLAudioElement | null>(null);
+ const activeIsBRef = useRef(false);
+ const [audioGeneration, setAudioGeneration] = useState(0);
+
const archiveStreamErrorsRef = useRef(0);
const upNextRef = useRef<QueuedTrack | null>(null);
const advanceStartedAtRef = useRef<number | null>(null);
const reportedPlayRef = useRef(false);
+ const mutedRef = useRef(startMuted);
+
+ const historyRef = useRef<TrackInfo[]>([]);
+ const cursorRef = useRef(0);
const [track, setTrack] = useState<TrackInfo | null>(null);
const [streamUrl, setStreamUrl] = useState<string | null>(null);
@@ -72,80 +86,129 @@ const [upNext, setUpNext] = useState<QueuedTrack | null>(null); const [status, setStatus] = useState<string>("");
const [playbackPhase, setPlaybackPhase] = useState<PlaybackPhase>("idle");
const [history, setHistory] = useState<TrackInfo[]>([]);
+ /** Index into `history` of the currently playing track (0 = newest). */
+ const [historyCursor, setHistoryCursor] = useState(0);
const [autoPlay, setAutoPlay] = useState(autoAdvanceInitial);
const [healthWarn, setHealthWarn] = useState<string | null>(null);
const [poolTrackCount, setPoolTrackCount] = useState<number | null>(null);
const [queueBusy, setQueueBusy] = useState(false);
+ const canGoPrev = history.length > historyCursor + 1;
+
useEffect(() => {
upNextRef.current = upNext;
}, [upNext]);
useEffect(() => {
+ historyRef.current = history;
+ }, [history]);
+
+ useEffect(() => {
onTrackChange?.(track, streamUrl);
}, [track, streamUrl, onTrackChange]);
+ const activeEl = useCallback(
+ () => (activeIsBRef.current ? elBRef.current : elARef.current),
+ [],
+ );
+ const inactiveEl = useCallback(
+ () => (activeIsBRef.current ? elARef.current : elBRef.current),
+ [],
+ );
+
const applyTrack = useCallback(
(info: TrackInfo, url: string, addHistory = true) => {
setTrack(info);
setStreamUrl(url);
- if (addHistory) setHistory((h) => [info, ...h].slice(0, 15));
+ if (addHistory) {
+ // A brand-new track becomes the newest entry; reset the back/forward cursor.
+ setHistory((h) => [info, ...h].slice(0, HISTORY_LIMIT));
+ cursorRef.current = 0;
+ setHistoryCursor(0);
+ }
},
[],
);
- const playUrl = useCallback((url: string) => {
- const a = audioRef.current;
- if (!a) return;
- setPlaybackPhase("buffering");
- a.src = url;
- a.load();
- void a.play().catch(() => {
- setPlaybackPhase("error");
- });
- }, []);
+ /** Play `url` on the inactive element, then make it active (element swap). */
+ const playUrl = useCallback(
+ (url: string) => {
+ const next = inactiveEl();
+ const prev = activeEl();
+ if (!next) return;
+ setPlaybackPhase("buffering");
+ // If the inactive element was preloaded with this url, playback is instant.
+ if (next.src !== url) {
+ next.src = url;
+ next.load();
+ }
+ const vol = loadStoredVolume();
+ if (vol !== null) next.volume = vol;
+ next.muted = mutedRef.current;
+ // Mark `next` active before pausing `prev` so the pause handler reads
+ // the correct (playing) element and doesn't flip the phase to paused.
+ activeIsBRef.current = !activeIsBRef.current;
+ audioRef.current = next;
+ void next.play().catch(() => {
+ setPlaybackPhase("error");
+ });
+ if (prev && prev !== next) prev.pause();
+ setAudioGeneration((g) => g + 1);
+ },
+ [activeEl, inactiveEl],
+ );
- const preloadUrl = useCallback((url: string | null) => {
- const pre = preloadAudioRef.current;
- if (!pre || !url) return;
- if (pre.src === url) return;
- pre.src = url;
- pre.load();
- }, []);
+ /** Warm the inactive element with the queued stream for an instant swap. */
+ const preloadUrl = useCallback(
+ (url: string | null) => {
+ const pre = inactiveEl();
+ if (!pre || !url) return;
+ if (pre.src === url) return;
+ pre.src = url;
+ pre.load();
+ },
+ [inactiveEl],
+ );
- const refillUpNext = useCallback(async (excludeId: string) => {
- setQueueBusy(true);
- try {
- const res = await fetch(
- `/api/track/up-next?exclude=${encodeURIComponent(excludeId)}`,
- );
- const body = (await res.json()) as RandomResponse | ErrBody;
- if (!res.ok) {
+ const refillUpNext = useCallback(
+ async (excludeId: string) => {
+ setQueueBusy(true);
+ try {
+ const res = await fetch(
+ `/api/track/up-next?exclude=${encodeURIComponent(excludeId)}`,
+ );
+ const body = (await res.json()) as RandomResponse | ErrBody;
+ if (!res.ok) {
+ setUpNext(null);
+ return;
+ }
+ const data = body as RandomResponse;
+ const queued: QueuedTrack = {
+ id: data.track.id,
+ title: data.track.title,
+ artist: data.track.artist,
+ streamUrl: data.streamUrl,
+ };
+ setUpNext(queued);
+ preloadUrl(data.streamUrl);
+ } catch {
setUpNext(null);
- return;
+ } finally {
+ setQueueBusy(false);
}
- const data = body as RandomResponse;
- const queued: QueuedTrack = {
- id: data.track.id,
- title: data.track.title,
- artist: data.track.artist,
- streamUrl: data.streamUrl,
- };
- setUpNext(queued);
- preloadUrl(data.streamUrl);
- } catch {
- setUpNext(null);
- } finally {
- setQueueBusy(false);
- }
- }, [preloadUrl]);
+ },
+ [preloadUrl],
+ );
- const fetchTrackById = useCallback(async (id: string): Promise<RandomResponse | null> => {
- const res = await fetch(`/api/track/${encodeURIComponent(id)}`);
- const body = (await res.json()) as RandomResponse | ErrBody;
- if (!res.ok) return null;
- return body as RandomResponse;
- }, []);
+ const fetchTrackById = useCallback(
+ async (id: string): Promise<RandomResponse | null> => {
+ const res = await fetch(`/api/track/${encodeURIComponent(id)}`);
+ const body = (await res.json()) as RandomResponse | ErrBody;
+ if (!res.ok) return null;
+ return body as RandomResponse;
+ },
+ [],
+ );
const advance = useCallback(async () => {
setStatus("");
@@ -227,6 +290,46 @@ }, [applyTrack, fetchTrackById, playUrl, refillUpNext],
);
+ const goPrevious = useCallback(async () => {
+ const h = historyRef.current;
+ const idx = cursorRef.current + 1;
+ // Nothing older than the current position.
+ if (idx >= h.length) return;
+ const prev = h[idx]!;
+
+ setStatus("");
+ setPlaybackPhase("loading");
+ advanceStartedAtRef.current = Date.now();
+ reportedPlayRef.current = false;
+ try {
+ const data = await fetchTrackById(prev.id);
+ if (!data) {
+ setPlaybackPhase("error");
+ setStatus("Previous track is unavailable.");
+ return;
+ }
+ const info: TrackInfo = {
+ id: data.track.id,
+ title: data.track.title,
+ artist: data.track.artist,
+ };
+ // Walk back through history: update current track without re-adding to it.
+ applyTrack(info, data.streamUrl, false);
+ playUrl(data.streamUrl);
+ cursorRef.current = idx;
+ setHistoryCursor(idx);
+ await refillUpNext(info.id);
+ } catch {
+ setPlaybackPhase("error");
+ setStatus("Network error while loading previous track.");
+ }
+ }, [applyTrack, fetchTrackById, playUrl, refillUpNext]);
+
+ const requestPrevTrack = useCallback(() => {
+ archiveStreamErrorsRef.current = 0;
+ void goPrevious();
+ }, [goPrevious]);
+
const handleAudioPlaying = useCallback(() => {
archiveStreamErrorsRef.current = 0;
setStatus("");
@@ -279,6 +382,27 @@ if (!a) return; void a.play().catch(() => {});
}, []);
+ const onEnded = useCallback(() => {
+ if (autoPlay) void advance();
+ }, [autoPlay, advance]);
+
+ // Bind media element events to whichever element is currently active. Rebinds
+ // on every swap (audioGeneration) so handlers always follow the playing audio.
+ useEffect(() => {
+ const el = audioRef.current ?? elARef.current;
+ if (!el) return;
+ el.addEventListener("playing", handleAudioPlaying);
+ el.addEventListener("pause", handleAudioPause);
+ el.addEventListener("error", handleAudioError);
+ el.addEventListener("ended", onEnded);
+ return () => {
+ el.removeEventListener("playing", handleAudioPlaying);
+ el.removeEventListener("pause", handleAudioPause);
+ el.removeEventListener("error", handleAudioError);
+ el.removeEventListener("ended", onEnded);
+ };
+ }, [audioGeneration, handleAudioPlaying, handleAudioPause, handleAudioError, onEnded]);
+
useEffect(() => {
void (async () => {
try {
@@ -306,30 +430,80 @@ } })();
}, []);
+ // Initialise both elements' volume/mute once mounted.
useEffect(() => {
- const a = audioRef.current;
+ const vol = loadStoredVolume();
+ for (const el of [elARef.current, elBRef.current]) {
+ if (!el) continue;
+ if (vol !== null) el.volume = vol;
+ el.muted = startMuted;
+ }
+ mutedRef.current = startMuted;
+ }, [startMuted]);
+
+ // Persist volume and track mute state from the active element.
+ useEffect(() => {
+ const a = audioRef.current ?? elARef.current;
if (!a) return;
- const vol = loadStoredVolume();
- if (vol !== null) a.volume = vol;
- if (startMuted) a.muted = true;
- const onVol = () => saveVolume(a.volume);
+ const onVol = () => {
+ saveVolume(a.volume);
+ mutedRef.current = a.muted;
+ };
a.addEventListener("volumechange", onVol);
return () => a.removeEventListener("volumechange", onVol);
- }, [startMuted]);
+ }, [audioGeneration]);
+ // Media Session API: OS/lock-screen metadata + hardware media keys.
useEffect(() => {
- if (!autoplayOnMount) return;
- if (startTrackId) {
- void loadTrackById(startTrackId);
- return;
+ if (typeof navigator === "undefined" || !("mediaSession" in navigator)) return;
+ const ms = navigator.mediaSession;
+ if (track) {
+ try {
+ ms.metadata = new MediaMetadata({
+ title: track.title,
+ artist: track.artist,
+ album: "The Myspace Dragon Hoard",
+ artwork: [{ src: "/mymusics.png", sizes: "200x80", type: "image/png" }],
+ });
+ } catch {
+ /* MediaMetadata unsupported */
+ }
}
- void advance();
+ ms.setActionHandler("play", () => handlePlay());
+ ms.setActionHandler("pause", () => handlePause());
+ ms.setActionHandler("nexttrack", () => requestNextTrack());
+ ms.setActionHandler("previoustrack", () => {
+ if (canGoPrev) requestPrevTrack();
+ });
+ return () => {
+ ms.setActionHandler("play", null);
+ ms.setActionHandler("pause", null);
+ ms.setActionHandler("nexttrack", null);
+ ms.setActionHandler("previoustrack", null);
+ };
+ }, [track, canGoPrev, handlePlay, handlePause, requestNextTrack, requestPrevTrack]);
+
+ useEffect(() => {
+ if (typeof navigator === "undefined" || !("mediaSession" in navigator)) return;
+ navigator.mediaSession.playbackState =
+ playbackPhase === "playing"
+ ? "playing"
+ : playbackPhase === "paused"
+ ? "paused"
+ : "none";
+ }, [playbackPhase]);
+
+ useEffect(() => {
+ if (!autoplayOnMount) return;
+ // Defer the initial load out of the effect body so the bootstrap doesn't
+ // trigger a synchronous cascade of setState calls during commit.
+ const id = setTimeout(() => {
+ if (startTrackId) void loadTrackById(startTrackId);
+ else void advance();
+ }, 0);
+ return () => clearTimeout(id);
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount bootstrap
}, []);
-
- const onEnded = useCallback(() => {
- if (autoPlay) void advance();
- }, [autoPlay, advance]);
const showUpNextHint =
poolTrackCount === 1 && track && upNext && upNext.id === track.id;
@@ -347,7 +521,9 @@ : "paused";
return {
audioRef,
- preloadAudioRef,
+ audioElARef: elARef,
+ audioElBRef: elBRef,
+ audioGeneration,
track,
streamUrl,
upNext,
@@ -355,19 +531,18 @@ status, playbackPhase,
embedPlaybackState,
history,
+ historyCursor,
autoPlay,
setAutoPlay,
healthWarn,
poolTrackCount,
queueBusy,
requestNextTrack,
+ requestPrevTrack,
+ canGoPrev,
loadTrackById,
- handleAudioPlaying,
- handleAudioError,
handlePlay,
handlePause,
- handleAudioPause,
- onEnded,
showUpNextHint,
};
}
@@ -4,9 +4,10 @@ type Options = { audioRef: RefObject<HTMLAudioElement | null>;
enabled: boolean;
onNext: () => void;
+ onPrev?: () => void;
};
-export function usePlayerKeyboard({ audioRef, enabled, onNext }: Options) {
+export function usePlayerKeyboard({ audioRef, enabled, onNext, onPrev }: Options) {
useEffect(() => {
if (!enabled) return;
const onKey = (e: KeyboardEvent) => {
@@ -21,6 +22,9 @@ else a.pause(); } else if (e.key === "n" || e.key === "N") {
e.preventDefault();
onNext();
+ } else if (e.key === "p" || e.key === "P") {
+ e.preventDefault();
+ onPrev?.();
} else if (e.key === "m" || e.key === "M") {
e.preventDefault();
a.muted = !a.muted;
@@ -28,5 +32,5 @@ } };
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
- }, [audioRef, enabled, onNext]);
+ }, [audioRef, enabled, onNext, onPrev]);
}
@@ -52,7 +52,9 @@ );
const {
audioRef,
- preloadAudioRef,
+ audioElARef,
+ audioElBRef,
+ audioGeneration,
track,
streamUrl,
upNext,
@@ -65,12 +67,10 @@ healthWarn, poolTrackCount,
queueBusy,
requestNextTrack,
- handleAudioPlaying,
- handleAudioError,
- handleAudioPause,
+ requestPrevTrack,
+ canGoPrev,
handlePlay,
handlePause,
- onEnded,
showUpNextHint,
} = useMyMusicsPlayback({
startTrackId: params.startId,
@@ -110,8 +110,19 @@ <header className="card-head"> <h2>Now playing</h2>
</header>
{track ? (
- <div className="track-block">
- <p className="artist">{track.artist}</p>
+ <div className="track-block" role="status" aria-live="polite" aria-atomic="true">
+ <p className="artist">
+ <span
+ className={`np-eq${playbackPhase === "playing" ? " np-eq--on" : ""}`}
+ aria-hidden="true"
+ >
+ <i />
+ <i />
+ <i />
+ <i />
+ </span>
+ {track.artist}
+ </p>
<p className="title">{track.title}</p>
</div>
) : (
@@ -142,24 +153,48 @@ </section>
<div className="player-nook">
<audio
- ref={audioRef}
+ ref={audioElARef}
+ className="audio-hidden"
+ preload="auto"
+ tabIndex={-1}
+ aria-hidden="true"
+ />
+ <audio
+ ref={audioElBRef}
className="audio-hidden"
- preload="metadata"
+ preload="auto"
tabIndex={-1}
aria-hidden="true"
- onEnded={onEnded}
- onPlaying={handleAudioPlaying}
- onPause={handleAudioPause}
- onError={handleAudioError}
/>
- <audio ref={preloadAudioRef} className="audio-hidden" preload="auto" aria-hidden="true" />
- <CozyAudioBar audioRef={audioRef} disabled={!track} />
+ <CozyAudioBar audioRef={audioRef} generation={audioGeneration} disabled={!track} />
<PlayerStatus phase={playbackPhase} status={status} hasTrack={!!track} compact />
<div className="actions">
- <button type="button" className="btn primary" onClick={() => void requestNextTrack()}>
- Next
- </button>
+ <div className="transport" role="group" aria-label="Transport controls">
+ <button
+ type="button"
+ className="btn btn-icon"
+ onClick={() => void requestPrevTrack()}
+ disabled={!canGoPrev}
+ aria-label="Previous track"
+ >
+ <svg viewBox="0 0 24 24" aria-hidden="true" className="btn-glyph">
+ <path d="M7 6v12H5V6h2zm12 0v12l-9-6 9-6z" fill="currentColor" />
+ </svg>
+ <span>Prev</span>
+ </button>
+ <button
+ type="button"
+ className="btn primary btn-icon"
+ onClick={() => void requestNextTrack()}
+ aria-label="Next track"
+ >
+ <span>Next</span>
+ <svg viewBox="0 0 24 24" aria-hidden="true" className="btn-glyph">
+ <path d="M17 6v12h2V6h-2zM5 6v12l9-6-9-6z" fill="currentColor" />
+ </svg>
+ </button>
+ </div>
{params.autoplay ? (
<label className="check">
<input
@@ -19,29 +19,35 @@ const [linkCopied, setLinkCopied] = useState(false);
const {
audioRef,
- preloadAudioRef,
+ audioElARef,
+ audioElBRef,
+ audioGeneration,
track,
status,
playbackPhase,
upNext,
history,
+ historyCursor,
autoPlay,
setAutoPlay,
healthWarn,
queueBusy,
requestNextTrack,
+ requestPrevTrack,
+ canGoPrev,
loadTrackById,
- handleAudioPlaying,
- handleAudioError,
- handleAudioPause,
- onEnded,
showUpNextHint,
} = useMyMusicsPlayback({
startTrackId,
autoplayOnMount: true,
});
- usePlayerKeyboard({ audioRef, enabled: true, onNext: requestNextTrack });
+ usePlayerKeyboard({
+ audioRef,
+ enabled: true,
+ onNext: requestNextTrack,
+ onPrev: requestPrevTrack,
+ });
const copyShareLink = useCallback(async () => {
if (!track) return;
@@ -75,21 +81,39 @@ <TrackSearch onSelect={(id) => void loadTrackById(id)} disabled={!!healthWarn} />
<aside className="card history" aria-label="Recently played">
<h2>History</h2>
- <ol className="history-list">
- {history.map((t, idx) => (
- <li key={`${t.id}-${idx}-${t.title}`}>
- <button
- type="button"
- className="history-hit"
- onClick={() => void loadTrackById(t.id)}
- >
- <span className="h-artist">{t.artist}</span>
- <span className="sep">—</span>
- <span className="h-title">{t.title}</span>
- </button>
- </li>
- ))}
- </ol>
+ {history.length === 0 ? (
+ <p className="history-empty muted">Nothing played yet.</p>
+ ) : (
+ <ol className="history-list">
+ {history.map((t, idx) => {
+ const isCurrent = idx === historyCursor;
+ return (
+ <li
+ key={`${t.id}-${idx}-${t.title}`}
+ className={isCurrent ? "history-item history-item--current" : "history-item"}
+ >
+ <button
+ type="button"
+ className="history-hit"
+ onClick={() => void loadTrackById(t.id)}
+ aria-current={isCurrent ? "true" : undefined}
+ >
+ <span className="history-eq" aria-hidden="true">
+ <i />
+ <i />
+ <i />
+ </span>
+ <span className="history-text">
+ <span className="h-artist">{t.artist}</span>
+ <span className="sep">—</span>
+ <span className="h-title">{t.title}</span>
+ </span>
+ </button>
+ </li>
+ );
+ })}
+ </ol>
+ )}
</aside>
</div>
@@ -103,8 +127,19 @@ </button> ) : null}
</header>
{track ? (
- <div className="track-block">
- <p className="artist">{track.artist}</p>
+ <div className="track-block" role="status" aria-live="polite" aria-atomic="true">
+ <p className="artist">
+ <span
+ className={`np-eq${playbackPhase === "playing" ? " np-eq--on" : ""}`}
+ aria-hidden="true"
+ >
+ <i />
+ <i />
+ <i />
+ <i />
+ </span>
+ {track.artist}
+ </p>
<p className="title">{track.title}</p>
</div>
) : (
@@ -135,24 +170,50 @@ </section>
<div className="player-nook">
<audio
- ref={audioRef}
+ ref={audioElARef}
className="audio-hidden"
- preload="metadata"
+ preload="auto"
tabIndex={-1}
aria-hidden="true"
- onEnded={onEnded}
- onPlaying={handleAudioPlaying}
- onPause={handleAudioPause}
- onError={handleAudioError}
/>
- <audio ref={preloadAudioRef} className="audio-hidden" preload="auto" aria-hidden="true" />
- <CozyAudioBar audioRef={audioRef} disabled={!track} />
+ <audio
+ ref={audioElBRef}
+ className="audio-hidden"
+ preload="auto"
+ tabIndex={-1}
+ aria-hidden="true"
+ />
+ <CozyAudioBar audioRef={audioRef} generation={audioGeneration} disabled={!track} />
<PlayerStatus phase={playbackPhase} status={status} hasTrack={!!track} />
<div className="actions">
- <button type="button" className="btn primary" onClick={() => void requestNextTrack()}>
- Next
- </button>
+ <div className="transport" role="group" aria-label="Transport controls">
+ <button
+ type="button"
+ className="btn btn-icon"
+ onClick={() => void requestPrevTrack()}
+ disabled={!canGoPrev}
+ aria-label="Previous track"
+ title="Previous (P)"
+ >
+ <svg viewBox="0 0 24 24" aria-hidden="true" className="btn-glyph">
+ <path d="M7 6v12H5V6h2zm12 0v12l-9-6 9-6z" fill="currentColor" />
+ </svg>
+ <span>Prev</span>
+ </button>
+ <button
+ type="button"
+ className="btn primary btn-icon"
+ onClick={() => void requestNextTrack()}
+ aria-label="Next track"
+ title="Next (N)"
+ >
+ <span>Next</span>
+ <svg viewBox="0 0 24 24" aria-hidden="true" className="btn-glyph">
+ <path d="M17 6v12h2V6h-2zM5 6v12l9-6-9-6z" fill="currentColor" />
+ </svg>
+ </button>
+ </div>
<label className="check">
<input
type="checkbox"
@@ -163,7 +224,7 @@ Auto-advance when track ends </label>
</div>
<p className="player-keys-hint muted">
- Shortcuts: Space play/pause, N next, M mute
+ Shortcuts: Space play/pause, P previous, N next, M mute
</p>
</div>
<PlayerAttribution />