code.pablomurad.com

mymusics

Retro web player for random tracks from the Myspace Dragon Hoard

melhorias3
author
Pablo Murad
date
commit
355280687f0700cbe1beaf4b3e12d85cf6580dc8
parent
f778f47dfc3e68bc5b5688551e7e99f377733c88
changes
10 files +672 −150
@@ -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 />