Skip to content

Play queue: a saved, restorable queue on gapless 5; remove Redux - #134

Merged
switz merged 71 commits into
feat/detail-pagesfrom
feat/play-queue
Nov 14, 2026
Merged

switz merged 71 commits into
feat/detail-pagesfrom
feat/play-queue

Conversation

@switz

@switz switz commented Nov 7, 2026 •

Copy link
Copy Markdown
Member

Stacked on #133. This moves playback onto a play queue: the current track, then Next up, then the show or song list it came from. The queue is saved in localStorage and comes back after a reload. It uses gapless@5.0.0-alpha.0 (RelistenNet/gapless.js#31) and removes Redux.

There's no queue UI yet; "Play next", "Add to queue" and a queue panel come in the next PR. The design is in docs/play-queue.md.

What changes for listeners

  • Reload keeps your queue. It comes back paused at the saved position and is ready to play, with no request: each item stores its mp3Url. This replaces the /api/restore round trip.
  • Playing another show keeps Next up. Clicking another track in the show that's already playing reuses the loaded audio.
  • Song pages: play queues the performances on screen, in table order, starting at that row, from the page's own data, with no request.
  • Lock screens now show the title, artist, date, venue and artwork. The old player sent none of these.
  • The player bar and track list no longer re-render on every progress frame.
  • Still true from c08b1c3: a page load fetches no audio. Preloading stays off until audio plays, and a track page autoplays only for someone who has interacted with it, or for a returning listener: someone who once started playback by interacting, remembered in localStorage, and never for browsers that report navigator.webdriver. Anyone else sees "Ready to play". The browser's autoplay policy can't be used instead: headless Chromium allows autoplay, and Chromium can only be asked by calling play(), which downloads the audio.
  • The old saved-playback keys (lastPlayedUrl and the rest) are ignored. There's no migration.
  • The console-only window.FLAC preference is gone: only mp3Url is stored.

How it's built

  • queue/model.ts: pure data and functions. compose() flattens the queue into the one list gapless plays, keeping history so previous and repeat work. The shuffle is seeded by each entry's key, so it's stable when entries are edited and reproducible after a reload.
  • player.machine.ts (xstate): holds the queue and its lifecycle: booting, then ready (saved), or ephemeral for embeds. It has no side effects.
  • engine.ts: the only code that talks to gapless. It makes gapless match the machine, which is safe to repeat because gapless reconciles by key, and it reports when gapless moves on by itself. Entries join gapless once their stream URL is known.
  • playback.ts: position, playing and load states, kept out of the queue so progress frames don't touch queue selectors.
  • queue/storage.ts: a versioned queue and a throttled position. Only the tab that last started playback writes; the tab id lives in sessionStorage, so it survives a reload.
  • runtime.ts: wires these together with the side effects: URL and title follow playback, scrobbling, listen tracking.
  • commands.ts / hooks.ts: what components use. src/ui still doesn't read the player: TrackList gets a playing prop through PlayingTrackList.
  • Song pages: SongQueueProvider hands the table's visible rows to the play buttons. There are no new API routes.

Testing

  • Unit tests: 252 pass, including the model (with a seeded fuzz over random operations), the machine, the engine (against a fake gapless queue), storage, and item building.
  • Browser checks: 44 Playwright checks against a dev server, passing three runs in a row. Scrobbles and audio were intercepted, and audio was served as generated WAV files. They cover:
    • a crawler-style deep link with no interaction makes 0 audio requests, shows "Ready to play", and Play now works;
    • a crawler that hides navigator.webdriver, with fresh storage, makes 0 requests; an automated browser with the listener flag makes 0; a returning listener autoplays;
    • a track page plays its tape, gapless holds it, and the queue is saved;
    • a Next up item from another show joins at once, with no request, without restarting the current track;
    • reloading a track page keeps that show and carries over Next up;
    • reloading another page restores the queue paused at the saved position, with no requests at all until play;
    • song-page play queues exactly the visible rows, with no request, and another row on the page reuses that queue;
    • a tab that keeps playing takes storage back when it plays something else;
    • auto-advance plays Next up, then drops it;
    • the URL and title follow playback;
    • an embed in an iframe plays and writes nothing to storage;
    • no page errors or hydration warnings.

Bugs the browser run caught (fixed here)

  • The queue was never saved after plain playback. It was saved only if you edited it.
  • A reloaded tab stopped saving until it played again.
  • The URL following playback re-anchored the shuffle, which replayed tracks you'd already heard.

Known

/album-art logs "Failed to render image" when a client abandons an image mid-render (takumi writes to a closed stream). The response is still a 200. It shows up more now because the media session loads artwork. That route already behaved this way, so it's left for a separate fix.

switz added 5 commits November 7, 2026 14:31
Plain data and pure functions for the queue: the current entry, Next up,
and the context it came from. compose() flattens them into the one list
gapless plays, with history kept for previous() and repeat. Operations:
playContext (keeps Next up; reuses keys when the same context plays
again, so gapless keeps its audio), playNext, addToQueue, advanceTo and
jumpTo, move, remove, setShuffle and setRepeat.

Shuffle orders the context by a seeded hash of each entry's key, so it's
reproducible after a reload and stable under edits: removing one entry
doesn't reshuffle the others.

Tests include a seeded fuzz over random operations that checks the
composed list's structure, that played Next up entries never return,
and that operations don't mutate their input.
Not wired up yet; the app still uses player.tsx.

- player.machine.ts: the queue (QueueState) and its lifecycle: booting
  (before the saved queue is read), ready (saved), ephemeral (embeds).
  Edits call the pure model; a model error is logged, not fatal.
- engine.ts: the one place that talks to gapless. sync() makes gapless
  match the machine (entries join once their URL is known; gapless is
  left alone while the current entry has none, and a play waits). It
  reports gapless moving on by itself, but not its own jumps.
- playback.ts: position, playing and load states, apart from the queue,
  so progress frames don't touch queue selectors.
- queue/storage.ts: versioned queue and throttled position in
  localStorage; only the tab that last started playback writes.
- model: refreshContext (fill in a context behind a placeholder),
  restoreOnto (a page that played before hydration keeps its show), and
  playing the current entry of the same context is a no-op, so the URL
  following playback can't re-anchor a shuffle.
- Dependencies: gapless 5.0.0-alpha.0, xstate ^5.33.3 (one copy, shared
  with gapless), @xstate/react 6.1.0.
The player now keeps a queue: the current track, Next up, and the show
or song list it came from. It's saved in localStorage and restored after
a reload, paused at the saved position, with no /api/restore round trip.

- runtime.ts wires the player machine and playback actor to the engine
  (gapless), storage, and side effects: URL and title follow the playing
  track on its show's page; scrobbles and listen tracking key off the
  queue item; missing stream URLs come from /api/queue?shows=.
- commands.ts / hooks.ts are what components use. PlayerManager,
  PlayerBar, the embeds, Providers' keyboard shortcuts and TrackList (via
  PlayingTrackList; src/ui doesn't read the player) move onto them.
- PlayerBar renders from the current item; only the elapsed time, length
  and progress bar subscribe to position, so playback no longer
  re-renders the bar (or the track list) every frame.
- Song pages: play starts the row at once, then /api/queue returns the
  song's whole filtered list in table order (shared spec with the page);
  the playing entry keeps its key, so nothing restarts.
- Media session: entries carry title, artist, album and artwork.
- Removed: player.tsx, the Redux store and packages, /api/restore and
  its search params. The old localStorage keys are ignored.
Ports c08b1c3 (on feat/detail-pages, written against player.tsx) to the
queue player:

- gapless starts with preloadNumTracks: 0; the engine turns preloading
  on (2 tracks) at the first progress frame that's actually playing, so
  a paused queue, restored or set up by a track page, downloads nothing.
- playShow, the path a page load takes, autoplays only after the visitor
  has interacted with the document (canAutoplay); otherwise it queues the
  tape paused and shows a 'Ready to play' prompt. Embeds are exempt.
- showPlayPrompt replaces the autoplay-blocked toast, with the browser
  permission advice only when the browser blocked it.
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 7, 2026 •

Copy link
Copy Markdown
Codex Review Summary: ✅ Completed

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-14T17:53:06.276144Z 3872ae8 New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ae32344125

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/player/runtime.ts Outdated
Comment on lines +250 to +251
} catch {
batch.forEach((id) => failedAt.set(id, Date.now()));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Retry failed stream-URL requests without requiring a queue edit

If /api/queue fails transiently while restoring a saved queue, the current entry remains without a stream URL indefinitely. Recording failedAt only permits a future retry; it does not schedule one, and resolveMissing is called only when the queue changes. Pressing Play merely sets the engine's pending-play flag, so even after 30 seconds the listener cannot resume without reloading or changing the queue. Schedule a retry or make Play retry unresolved entries.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e9aae61. The URL fetching is now its own module, src/player/queue/resolver.ts. A failed request schedules a retry after the 30 s backoff, with no queue change needed. Every play goes through startPlayback(), so pressing Play while the current track has no URL also retries at once, skipping the backoff. Tests in resolver.test.ts: "retries a failed request on its own, without a queue change" and "force (pressing Play) retries at once, within the backoff".

Comment thread src/player/queue/storage.ts Outdated
Comment on lines +128 to +129
claim() {
write(OWNER_KEY, { tabId: thisTab(), at: new Date(now()).toISOString() });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Persist the pending queue when claiming storage ownership

When a second tab's debounced queue save runs while another tab owns storage, flushQueue retains the pending queue but clears its timer. If the second tab subsequently resumes playback, this method changes ownership without flushing or rescheduling that queue, and the runtime does not save it again until the queue changes. Consequently, storage can contain the previous owner's queue alongside the new owner's playback position, so another tab restores the wrong queue. Flush or reschedule the pending queue when ownership is acquired.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e9aae61. claim() now flushes the pending queue and position after taking ownership, so the queue and the position stored next to it always come from the same tab. Test: "taking over ownership writes the queue and position saved while not the owner". It failed before the fix.

- Stream URLs: a failed /api/queue request only recorded a backoff, and
  requests ran only on queue changes, so a blip during restore left the
  current track unplayable until a reload. The resolver (now its own
  module, queue/resolver.ts) schedules a retry after a failure, and
  pressing Play (startPlayback, which every play goes through) retries at
  once when the current track has no URL.
- Storage: a tab's debounced save is held back while another tab owns
  storage, and claim() didn't write it, so storage could pair the old
  owner's queue with the new owner's position. claim() now flushes the
  pending queue and position.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e9aae619c3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/player/runtime.ts Outdated
onPlayingChange: (playing) => {
playbackActor.send({ type: 'PLAYING', playing });
// The tab that starts playback owns the saved queue.
if (playing && playerActor.getSnapshot().matches('ready')) storage?.claim();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Save the current queue whenever playback reclaims ownership

If tab A saves its queue, tab B takes ownership and saves a different queue, and then A resumes without editing its queue, A claims ownership but leaves B's queue in storage while writing A's playback position. The new evidence beyond the earlier pending-save issue is that a successful flushQueue() clears pendingQueue; therefore the added flush in claim() has nothing to write in this scenario, and the runtime's lastSaved identity check prevents another save. Persist the current queue on every ownership claim, even when its previous save already completed.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1f7adcd. Storage now keeps the last queue and position this tab was given, even after they've been written, and claim() always writes those, not only what was pending. Test: "reclaiming ownership writes this tab's queue, even after its own save completed". It covers A saving, B taking over and saving, then A resuming with no edit, and it failed before the fix.

Comment thread src/player/queue/resolver.ts Outdated
Comment on lines +42 to +47
const scheduleRetry = () => {
if (retryTimer) return;
retryTimer = setTimer(() => {
retryTimer = null;
void kick();
}, retryMs);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reschedule retries for failures still inside their backoff

When multiple URL batches fail at different times, only the first failure schedules a timer. When that timer fires, later failures are still inside their individual backoffs and are excluded by kick(). If the eligible batch succeeds, nothing schedules another timer, leaving the remaining tracks unresolved until a queue change or explicit Play. Unlike the earlier absence of automatic retries, the fresh evidence is that the new timer loses later failures: two staggered failures followed by a successful retry of the first leave the second unresolved with no timer. Schedule another wake-up for unresolved failures whose backoff has not expired.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1f7adcd. After every attempt the resolver schedules a wake-up for the earliest backoff among shows that are still missing, replacing the single timer set by the first failure. Test: "retries failures still inside their backoff after an earlier retry succeeds". Two shows fail 10 s apart, the first one's retry succeeds at 30 s, and the second is retried at 40 s. It failed before the fix.

switz added 2 commits November 7, 2026 14:46
The browser's autoplay policy can't tell a crawler from a listener who
allowed sound (headless Chromium allows autoplay, and Chromium can only be
asked by calling play(), which downloads the audio). So a track page now
also autoplays for a returning listener: someone who once started
playback by interacting, remembered in localStorage (relisten.listened).
Never for embeds, or browsers that report navigator.webdriver; crawlers
start with empty storage and can't set it without interacting. If the
browser blocks it anyway, the 'Autoplay blocked' prompt appears.
- claim() wrote only what was pending, so a tab that resumed after its
  own save had completed (while another tab had written its queue since)
  left the other tab's queue next to its position. Storage now keeps the
  last queue and position it was given, and claim() always writes them.
- The resolver scheduled one timer for the first failure, so a later
  failure still inside its backoff when that timer fired was never
  retried. After every attempt it now schedules a wake-up for the
  earliest backoff among shows that are still missing.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1f7adcdf9f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/app/api/queue/route.ts Outdated
if (ids.length > MAX_QUEUE_SHOWS) return new Response('Too many shows', { status: 400 });

const urls: UrlMap = {};
const shows = await Promise.all(ids.map((id) => showById.get(id).catch(() => null)));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve transient upstream failures when resolving queue URLs

When the upstream show API times out or returns a 5xx while restoring a queue, this catch converts the failure into an HTTP 200 response with the affected show's URLs omitted. createUrlResolver treats that as success, clears the show's failure backoff, and schedules no retry, leaving its tracks unresolved until another queue change or explicit Play. Unlike the previously fixed rejected-request retry path, this failure never reaches that path because /api/queue reports success. Propagate transient failures as a non-success response, or explicitly report failed shows so the resolver can retry them.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4a49bcc. The route now separates a show that doesn't exist from one that failed this time. isMissing() (a 404) still means no URLs. Any other error, such as a timeout or a 5xx, lists the show in a new failed field, and the response is sent no-store so the partial result isn't cached. The resolver puts failed shows into the same backoff as a failed request, so they're retried. Test: "retries shows the server reports as failed (a 200 with failed)". It failed before the fix. Checked live: an unknown show id still returns failed: [].

/api/queue?shows= turned every showById error into a 200 with that show's
URLs left out, so the resolver counted a timeout or 5xx as success and
never retried. The route now tells 'doesn't exist' (isMissing: no URLs)
from 'failed this time' (listed in `failed`, response not cached), and
the resolver puts failed shows into the backoff like a failed request.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4a49bcc168

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/player/runtime.ts Outdated
// Someone who started playback by interacting autoplays next time.
if (playing) rememberListener();
// The tab that starts playback owns the saved queue.
if (playing && playerActor.getSnapshot().matches('ready')) storage?.claim();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Claim storage after hydrating playback that started while booting

When a track page starts playback before Providers runs hydratePlayer(), this callback skips claiming ownership because the machine is still booting. The engine only emits onPlayingChange when the playing boolean changes, so becoming ready does not retry the claim. If another tab owns storage, the new tab's queue and subsequent playback positions therefore remain unsaved until playback is paused and resumed, even though it is now the tab that most recently started playback. On hydration, claim ownership if playback is already running, while retaining the embed exclusion.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a3781be. hydratePlayer() now claims storage after HYDRATE if the engine is already playing. Embeds return before that point, so they're still excluded. I reproduced it in the browser first: one tab plays and owns storage, then a returning listener opens a different track in a second tab, which autoplays while booting. Before the fix, the other tab stayed owner and its track (Sugaree) was saved. Now this tab owns storage and its track is saved. The check is "a tab that starts playing before hydration claims storage".

switz added 2 commits November 7, 2026 14:59
A track page can start playing while the player is still booting (a
returning listener's autoplay), and claiming ownership was skipped then.
The engine reports a playing change once, so becoming ready never
retried it, and with another tab owning storage this tab's queue went
unsaved. hydratePlayer now claims if the engine is already playing.
The saved queue held no stream URLs, so a restore had to ask /api/queue
for them, with retries and backoff when that failed. Items now carry
their mp3Url (raw from the API; proxied when played), so a restored queue
plays with no request.

- Removed: the resolver, the per-session URL cache, /api/queue?shows=,
  and the console-only window.FLAC preference (no FLAC URLs are stored).
- /api/queue serves song pages only: the full filtered list, with URLs.
- Tracks without an mp3 file aren't queued.
- Saved queues are version 2; version 1 (no URLs) is ignored.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4d466ec420

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/player/runtime.ts Outdated
Comment on lines +111 to +112
// The tab that starts playback owns the saved queue.
if (playing && playerActor.getSnapshot().matches('ready')) storage?.claim();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reclaim storage when explicitly starting a different track

If tab A is playing, tab B starts playback and takes ownership, and A then selects another show or song performance without pausing first, A never reclaims storage. engine.sync() preserves the playing state across that selection, and startPlayback() calls engine.play() without producing another onPlayingChange(true). Consequently, A's newly selected queue and subsequent positions remain unsaved, and a reload restores B's queue instead. Reclaim ownership on explicit playback requests as well as playing-state transitions, retaining the booting/embed exclusions.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4da35b7. startPlayback() is the single path every play takes: transport, prompts and track selection. It now claims storage when the player is ready, even if this tab was already playing. Booting is still handled by hydratePlayer, and embeds are still excluded. I reproduced it in the browser first: tab A plays, tab B starts a song and takes over, then A picks another track without pausing. Before the fix, B still owned storage and its track was saved. Now A owns it and its track is saved.

switz added 2 commits November 7, 2026 15:18
A song row's play button now queues the performances on screen, in
table order, from the page's own data (a SongQueueProvider around the
table), instead of fetching the song's full filtered list. Another row
on the same page reuses the queue. The queue's identity includes the
page, so another page is another queue.

Removed: /api/queue, its search params, the server-side song queue, the
machine's REFRESH_CONTEXT, and TableResult.sorted.
A tab that kept playing while another tab took over storage, then picked
another track, never claimed it back: its playing state didn't change,
so onPlayingChange didn't fire, and its new queue went unsaved.
startPlayback (every play goes through it) now claims storage when the
player is ready; booting and embeds stay excluded.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4da35b781a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/player/PlayerBar.tsx Outdated
setVolume(Math.max(0, Math.min(1, nextVolume)));
const activeTrack = useCurrentItem();
const isPlaying = useIsPlaying();
const [volume, setVolume] = useState(Number(localStorage.volume) || 1);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve a saved zero volume in the player-bar state

When a listener sets the volume to zero and reloads, Number(localStorage.volume) || 1 converts the saved mute setting into a displayed volume of 100%. Meanwhile, runtime.ts correctly applies the saved zero to the engine, so playback remains silent despite the full-volume indicator. The previous initializer preserved the string '0'. Default only when the stored value is missing or invalid, rather than when it is zero.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 746b2f3, at the root. The bar and the engine each kept their own copy of the volume, and those copies drifted. Volume now lives in one place, the playback actor. The runtime seeds it from the saved value with savedVolume(), which keeps 0 and falls back only when nothing valid is saved. setVolume updates the actor, the engine and localStorage together, and the bar reads from the actor. Tests: volume.test.ts ("keeps a saved zero") and playback.test.ts, plus a browser check: set 0 with the bar's control, reload, and the bar shows 0% while the engine is at 0.

The player bar kept its own copy of the volume, initialised with
Number(localStorage.volume) || 1, which turned a saved 0 (muted) into
100% while the engine correctly applied 0. Volume now lives in the
playback actor: the runtime seeds it from the saved value (savedVolume,
which keeps 0 and defaults only when nothing valid is saved), and
setVolume updates the actor, the engine and the saved value together.
The bar reads the actor.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 746b2f3dfd

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/player/runtime.ts
{ artistSlug: item.artistSlug, year, month, day, songSlug: item.trackSlug },
sourceQuery(item.sourceId)
);
if (pathname + search !== next) replaceUrl(next);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Prevent URL-follow updates from replacing a Next up entry

When a Next up entry belongs to the show currently displayed, updating the URL here retriggers PlayerManager's pathname-dependent effect and calls playShow(). The queued entry has a different key from its counterpart in the show context, so playContext() replaces it with the context entry and moves the cursor. For example, playing track 1 with track 3 queued next should resume at track 2 afterward; this feedback instead restarts track 3 under its context key and continues at track 4, skipping track 2. Distinguish playback-driven URL updates from explicit track selections so following the URL preserves the current Next up entry and cursor.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a16896d. As written this doesn't happen: timber's replaceUrl is a shallow update that changes neither usePathname() nor the source param, so PlayerManager doesn't re-run when the URL follows playback. I checked that in the browser. The same swap does happen on a real navigation, though: while the Next up copy of track 3 plays, clicking track 3's row replaced it with the context copy and moved the cursor to 3, skipping track 2. playShow now leaves the queue alone when a Next up entry for the requested track (same track and source) is playing. Checked in the browser: after the click the Next up entry stays current and the cursor stays on track 1.

switz added 3 commits November 7, 2026 15:43
Cut what nothing uses yet, and the edge cases that kept generating bugs:

- Storage: no tab ownership; whichever tab saves last wins.
- Engine: no waiting for a URL (every item stores its mp3Url); no repeat.
- Model: a context entry's key is its trackId, so replaying a context
  keeps loaded audio without reuseKeys/refreshContext/sameRef. No
  context ref, no restoreOnto (a page that started playing ignores the
  saved queue).
- Shuffle, repeat, playNext, addToQueue, move and remove move to the
  queue UI PR, with their callers. compose/advanceTo still handle Next up.
- The xstate machine becomes a fromTransition reducer (queueLogic.ts).
- Saved queues are version 3.
The operations the queue UI needs, with its callers in the next commit:
- model: playNext and addToQueue (Next up entries get their own keys,
  next:<trackId>:<id>, so a track can be in the context and queued),
  move and remove within Next up, and upcoming() for the panel.
- reducer: PLAY_NEXT, ADD_TO_QUEUE, MOVE, REMOVE (ignored in embeds),
  and unknown events now leave the state alone instead of wiping it.
- items: showTrackItem, one track of a tape as a queue item.
- QueueMenu: a ⋯ menu (base-ui Menu) with Play next and Add to queue on
  show track rows (via TrackList's new trackActions slot, filled by
  PlayingTrackList; src/ui still doesn't read the player) and song rows.
  Not in embeds.
- QueuePanel: the player bar's queue button (with the Next up count)
  opens a base-ui Drawer: a bottom sheet on phones, a right-hand panel on
  desktop. Now playing, Next up (drag a handle or use the arrow keys to
  reorder, with the move announced; remove), then the rest of the show
  or song list. Picking an entry plays it. Escape, a click outside, or a
  swipe closes it; the drag handle opts out of the drawer's swipe.
- The @songs pages pass the artist's name, which queue items need.
switz and others added 2 commits November 7, 2026 16:14
Queue UI: Play next / Add to queue, and the queue panel

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 88fad43b2f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/player/runtime.ts Outdated
if (!isPaused) dismissAutoplayToast();
const item = itemFor(key);
if (!item) return;
if (listens.progress(item.trackId, { currentTime, isPaused })) scrobblePlay(item.trackId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Track listening separately for each queue entry

When a listener queues the currently playing track with Play next, the two entries have different keys but the same trackId. Passing trackId to the long-lived listen tracker means it never resets between those entries: after the first playback is counted, the second playback never calls scrobblePlay, even after sufficient listening. This undercounts plays for consecutive copies of a track, which the new queue explicitly supports. Use the queue entry key as the tracker's identity while retaining item.trackId for the scrobble request.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a16896d. The listen tracker is now keyed by the queue entry key; scrobblePlay still gets item.trackId. A Play next copy of the playing track counts as its own listen. Test: "counts each id separately (two queued copies of a track)".

- The queue opens as a popover off the player bar's queue button, not a
  drawer. A 2px ring and heavier shadow; the arrow meets the ring and
  takes the header's colour (new arrowClassName on PopoverContent).
- Rows like a show's track list: track number (bouncing bars on the
  current track), title, artist · date · venue, duration. The header is
  queue info only: track N of M, time left, how many are up next.
- Played tracks show above the current one, dimmed; picking one plays
  it. The popover opens with the current track in the middle.
- upcoming() is now queueView() and also returns the played tracks
  (the cursor entry counts as played while Next up is playing).
switz and others added 20 commits November 9, 2026 17:08
The route read the show with orDeny(), which calls deny(404) for a
missing show. deny() in a route handler surfaces as a 500; it only
worked because `.catch(() => null)` swallowed the deny signal, and with
it every other error, so an API outage also answered 404.

Use get() and catch only a missing show (404/410/422) as null; any other
failure propagates as a 500 instead of a 404 a CDN or crawler could
keep.
The picker had its own list (cmdk items, a primary/archive search, a
50-result cap, group headings) beside the browse view's artists column,
which already did all of that with richer rows. Two components meant
two places to change for every list behavior.

ArtistsSelectionTab is now the one artist list. It takes the current
artist and how a row links (ArtistLinks: currentSlug, hrefFor) from its
caller instead of reading the browse segment itself, plus a compact
mode for the popover: a fixed height, no heading bar (sort follows the
column's toggles), a focused filter, arrow keys between the filter and
the rows, and onNavigate to close once a row is followed.

ArtistsColumn becomes a small client component that supplies the browse
links. ArtistPicker supplies sectionHref and renders the list in its
popover; PickerPanel, Matches and the cmdk import go. The popover's
rows show what the column's do: shows, tapes, plays.
"RELISTEN TO THE GRATEFUL DEAD" already named the artist, and a second
"Grateful Dead ▾" button sat just under it. The title's artist is now
the picker itself (a title-styled trigger with a chevron), so the tab
row holds only the artist's sections.

Below lg the header hides the title and shows "Re", so there the
picker stays at the start of the tab row; ArtistPicker takes a variant
for the two triggers.

The tab row used to fall back to the default artist on every page, so
/blog showed Grateful Dead's sections under "RELISTEN TO OUR BLOG".
NavBar now takes the page's artist from the URL, or the default artist
only in the browse layout (whose root, /, browses it); pages about no
artist get neither the picker nor the tabs. Browse pages keep the
picker.
Opening an archive artist puts the column on the archive tab, which is
empty until the archive list arrives: a blank column with no sign
anything is coming. A failed load looked the same, forever.

The list now starts with a small spinner while the archive is missing
and it matters (the archive tab, or a search, which covers the
archive), or one line of muted text if it failed. The fallback passes
`undefined` for loading so the two can be told apart from the loader's
`null`. The status is a virtual item, so the rows below it stay where
the virtualizer thinks they are. The tab's count shows "–" instead of
"…" when the load failed.
"Archive Artists (…)" grew to "Archive Artists (4142)" when the archive
arrived, shifting the tab's label. The count now sits in a box four
tabular digits wide, so "…", "–" and the number all take the same
space.
The title's artist turned accent blue on hover and while open, while the
chevron stayed grey, so it read like a link next to an unrelated icon.
Keep the title's color and the faint hover background, which with the
chevron is enough to say it opens.
The rows rode along on every client navigation: no layout is skipped
in this app (the root reads the theme cookie), so <PrimaryArtists>
re-serialized ~16 KB into each RSC payload.

Every visit starts with a document request, so that's where the rows
go. <PrimaryArtists> moves to the root layout and omits the rows from
RSC payloads (Accept: text/x-component). In the root layout the client
provider mounts once per document, so it keeps the first rows it gets
and ignores the rest.

The tradeoff: the list is as fresh as the page load. Measured: the
document has the rows; payloads for /grateful-dead/1977, /phish,
/artists/phish/songs and /blog have none; the picker still lists
artists after those navigations.
It opens over the busy browse columns, and the default popover's
hairline ring (7% in light mode) and shadow-lg let it blend into them.
Give this popover a 2px ring at 15% of the text color and shadow-2xl
(darker in dark mode).
alpha.2 drops what a refused play() started (the Web Audio HEAD and
GET, and the element's buffering) and puts the track back to idle. A
page whose autoplay the browser blocks now costs at most an aborted
HEAD, where it used to download the whole first track behind the
"Autoplay blocked" prompt.

The engine reports load states on progress, syncs and queue changes,
but not on a blocked play, so the track stayed LOADING in the track
list after gapless had reset it. Report them when play is blocked.

Checked in Chromium with autoplay requiring a click: one HEAD, aborted;
the track shows no load state.
A returning listener's page attempts playback on load, and the browser
may refuse it. Firefox can be asked first, without calling play():
navigator.getAutoplayPolicy('mediaelement'). When it answers
'disallowed' (or 'allowed-muted', since the player plays with sound),
show the "Autoplay blocked" prompt without an attempt, so nothing is
requested at all.

Chrome and Safari don't have the API (checked in Chromium 156 and
WebKit 27.2), so they still attempt and find out; gapless alpha.2 makes
that attempt cheap. The type isn't in TypeScript's DOM lib yet, so it's
declared locally.
Sub-nav review: fixes, shared artist list, header picker
…ggle

# Conflicts:
#	src/app/(browse)/layout.tsx
Select artist from sub-nav View detail screens from browse screen
timber 0.2.0-alpha.241 adds lifetime: 'document': a segment renders on the
document request and client navigations skip it, without rendering, until
its key changes.

- Root layout: 'document', keyed on the theme cookie. PrimaryArtists no
  longer matches text/x-component in Accept to tell document requests
  apart; the layout only renders on those.
- Artists, years and shows columns: 'document' slot pages. The artists
  column keys on nothing, years on artistSlug, shows on artistSlug and
  year. The shows column moves from [...years] to [year]/[[...rest]] so
  its key can name the year.
- The years column drops its Today in History row: it read the
  cf-timezone header, which a 'document' segment cannot read.
The songs and sources slots each got the full show. Each now gets a
projection (src/lib/showProjection.ts), typed so a field a column
starts reading is a type error until the projection carries it.

- Songs column: every tape's sets and tracks (the active tape is picked
  client-side from ?source=), plus links. Tracks keep mp3Url, which the
  queue menu builds queue items from; they drop flacUrl, position and
  sourceId (the menu takes the tape's id instead).
- Sources column: each tape's summary, with no sets or links.
- Both keep the fields sortSources reads, so they order tapes alike.

On timber 0.2.0-alpha.240+, with segment hashing on, each slot is
serialized on its own and the full show went out twice: a show-to-show
navigation (/grateful-dead/1977/05/09 -> 05/08) was 470 KB raw / 66 KB
gzip, and is 189 KB / 34 KB with this. With hashing off it is 222 KB /
40 KB, from 276 KB / 43 KB. The HTML document drops from 551 to 494 KB.
The @songs and @sources day pages are 'document' segments keyed on
their path params (artistSlug, year, month, days). They read nothing
else: the active tape is picked client-side from ?source=, so a tape
switch skips both slots without rendering them.

With segment hashing off, a tape switch re-sent the whole show (68 KB
raw with the 'document' browse columns); it is now 10 KB raw / 3 KB
gzip. The show's data, popularity included, stays as it was when the
date was loaded until a navigation to another date.
clientSegmentCache renders every segment and hashes its output to skip
the ones the client holds. On relisten that costs about a third more
server CPU per navigation (design/53: 1.06 s vs 0.77 s per run), and the
'document' segments now skip the stable ones without rendering them.

The option is dropped rather than set to false: off is the default, and
timber's next release removes it.
The (browse) layout is lifetime: 'document'. Its nav moves into an @nav
slot of 'document' pages keyed on the artist and the user agent, since
the layout may not read the artist param below it. NavBar takes the
artist as a prop there.

The iframe check moves to the browser: sec-fetch-dest says iframe only
on the document request and empty on every client navigation's fetch,
so the server could not tell after the first page.
switz and others added 3 commits November 13, 2026 15:21
…#140)

* timber 0.2.0-alpha.244: layoutDefaults with a user-agent key; (content) nav as a 'document' @nav slot

* Browse columns as nested 'document' layouts instead of parallel slots
The upgrade banner already rendered nothing on the server and gated on
localStorage via useSyncExternalStore; it now checks the platform there
too (userAgentData.platform, falling back to the UA string). NavBar no
longer reads request headers, so its HTML doesn't vary by user agent.
…tion

Remove movedSectionUrl and the browse middleware's segment regex loop;
schema.ts codecs 404 malformed segments before middleware runs. Read
artistSlug from ctx.segmentParams. Drop the stale [...days] codec, whose
routes no longer exist.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 93ff82bb9e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/app/Providers.tsx
Comment on lines 20 to +22
if (e.code === 'Space') {
e.preventDefault();
player.togglePlayPause();
togglePlayPause();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Let focused queue buttons handle Space

When a keyboard user focuses a track or Remove button in the new queue panel and presses Space, the document-level handler in Providers.tsx calls preventDefault() and toggles playback instead. Those buttons do not stop propagation, so their normal Space-generated click is canceled: the selected track does not play, or the entry is not removed, and unrelated playback changes instead. Exclude interactive controls from the global playback shortcut, or stop propagation within the queue controls.

Useful? React with 👍 / 👎.

switz and others added 3 commits November 13, 2026 17:46
The nav no longer depends on the page. A browsing-artist context in the
root layout reads the artist from the URL as it renders, and remembers it
on the site's pages (today, live, venues, …), where the artist picker
switches it in place. NavBar takes no props, so (browse) and (content)
render it directly, once per document: both @nav slots and the unused
[[...rest]] codec are gone.

- Artist tabs on every page, led by a fixed-width artist picker; the
  header is the Re brand mark and RELISTEN, plus the site page's title
- The home link goes to the browsed artist's browse view
- One list of site links for the header, the menu and the tabs
- Space no longer toggles playback when a focused control owns the key
- Bump timber to 0.2.0-alpha.245
…rver

Every page with the nav now sits under one layout tree, so the nav renders
once per document and never rides along in an RSC payload, including when
moving between the browse view and the content pages.

- (main)/layout.tsx is keyed on artistSlug alone and sends the URL's
  artist row (archive artists included, from the cached directory) to the
  browsing-artist provider. It renders again only when the artist appears,
  changes or goes away, and then sends one row. Archive artists are right
  on the first frame, with no client fetch; any other slug shows the
  default artist.
- (main)/(shell)/layout.tsx renders the nav; (browse), (content) and
  (bare) under it keep only their own wrappers.
- One (shell)/404.tsx: a not-found page keeps the nav, without the browse
  layout's artists column.
- Column headings truncate beside their sort toggles instead of wrapping
  under them.
* feat: show detail screen

* refactor: improve show detail screen ui

* refactor: move source selection to dedicated column

* refactor: remove sorting

* refactor: streamline show detail navigation

* refactor: show detail header data

* feat: add review section

* refactor: match overview header style

* refactor: remove track number column

* refactor: venue pill classes

* fix: source list doesn't extend past song list

* feat: add link back to browse screen

* refactor: review section ui

* feat: link queue track name to track

* chore: bump dependencies, drop @radix-ui/react-popover

* fix: stop the artists list shifting when the archive arrives

The list suspended on the archive, so a second list replaced the fallback
when it loaded, seeded from a measurements snapshot that still held
estimates (the virtualizer recomputes measurementsCache lazily). Every row
jumped by the difference, then jumped back once measured.

Read the archive with useSyncExternalStore instead (undefined in the server
render and hydration), so one list and one virtualizer persist. Seed the
virtualizer's estimates from the in-flow rows the server rendered, so
virtualizing them after hydration moves none of them.

* refactor: close popovers on link click; queue track link as an icon

PopoverContent closes itself when a link inside it is followed, replacing
the per-popover onNavigate plumbing (artist picker, queue). The nav menu
moves from the Radix popover to the shared one.

Queue rows: the play button covers the row, and the track's page gets its
own arrow-icon link column instead of linking the title.

* feat: link the queue row's date to the show, drop the track icon

---------

Co-authored-by: Daniel Saewitz <git@saewitz.com>
@switz
switz merged commit f7be78f into feat/detail-pages Nov 14, 2026
switz added a commit that referenced this pull request Nov 14, 2026
* Add artist, song, and venue detail pages on a URL-driven table base

Detail pages
- /artists/{artist}: totals, shows per year, links to the artist's pages.
- /artists/{artist}/songs and /artists/{artist}/venues: searchable, sortable
  indexes.
- /artists/{artist}/songs/{song}: one row per performance (a song repeated in
  a show gets a row each), with real lengths from v4 song timings, a "~" and
  tooltip for lengths measured from multi-song files, and a play button that
  plays the track in place from its mp3_url.
- /venues/{venue} (all artists) and /artists/{artist}/venues/{venue} (one
  artist), sharing VenueDetail, with an artist picker between them.
- Headers: compact stat cards and a per-year chart whose bars filter the table.
- URLs are built only in lib/urls.ts.

Table base (lib/table, components/table)
- queryTable: search, filters with facet counts, sort, paging over a cached
  full list; pure and unit-tested. Pages send one 25-row page.
- All state in the URL (?q=&sort=-date&page=2&year=1977&sbd=1); sort headers
  and pagination are server-rendered links; only search and filters are a
  client island.
- Fixed table layout with declared column widths and an always-reserved sort
  arrow, so paging, sorting, and filtering never shift the layout.

API (v4 #124-#126)
- Song detail is one request: artists/{id}/songs/by-slug/{slug} with
  include_tracks=top. Merged slugs redirect; imported (setlist-only) songs
  fall back to the song list, redirecting to a linked canonical song.
- Song shows are now { show, timings }; the browse song column reads .show.
- Venue other-names leave out spelling variants.

404s
- Shared NotFound; (browse), (content), and a root 404.tsx so deny(404) never
  falls through to error.tsx.

* Move browse sections to detail pages; add the artist tab row

Artist tab row (NavBar)
- A second nav row on every page: Browse · Overview · Songs · Venues · Tours ·
  Recently Added · Today In History, for the artist in the URL or the default
  artist. Server passes the artist; the active tab follows the pathname on
  the client, since the row lives in a persistent layout.
- Chat, Apps, About, and GitHub move into a "⋯" menu.

Detail pages replace the browse view's sections
- /artists/{a}: every show as a table, with stats and a year chart that
  filters it. Top shows are this table sorted by rating.
- /artists/{a}/recently-added, /tours, /tours/{tour}, /today-in-history
  (?month=&day=), on a shared ArtistShowList.
- The browse view drops its quick hits (top, recently-added, songs, venues,
  tours, today-in-history): their routes, columns, and the column's section
  tabs. The years column's Today In History row links to the new page.
- Old section URLs 308 to their pages; ones with an open show (?date=…,
  &track=…) go to that show.

* Split RelistenAPI into a data layer with slim, budgeted caches

Layers
- src/api: the generated v4 client, fetcher, and pinned spec (moved from
  src/lib/v4). A lint rule allows only src/data to import it.
- src/data: cached loaders, one module per area (artists, shows, songs,
  venues, tours, live, search, legacy), each built with defineResource():
  one place for cache key, TTL, stale-while-revalidate, the 404 rule, and a
  required slim step from API response to domain value.
- src/domain: the app's own types (ShowSummary, SongDetail, SongPerformance,
  VenueDetail, TourDetail, PlayableTrack).
- RelistenAPI is now a thin facade over src/data (743 → ~150 lines), keeping
  the shapes existing pages read. Table rows are built per request from
  cached domain data, so per-row links and artist names stay out of the cache.

Memory
- Caches hold slim values: live history 1.4 MB → 0.09 MB, live summary
  1.0 MB → 0.03 MB, shows −27%, song pages −21%, artist show lists −29%;
  duplicate "on this date" caches merged. Cached tracks drop unused md5s and
  set/artist ids. Same warm-up: 42.5 MB → 33.2 MB.
- The memory cache is bounded by bytes (128 MB, 16 MB per entry) as well as
  entries.

* Add unit tests for redirects, data conversions, rows, and URLs

158 tests (117 new), with small trimmed fixtures from real v4 responses
(GD 1977-05-08; Phish Tweezer Reprise incl. SPAC 2025-07-27, played six
times on one tape).

- Old browse-section redirect mapping, extracted unchanged from the browse
  middleware into lib/movedSectionUrl.ts. Query values copied into redirect
  targets are now URL-encoded, so a value can't add path segments or params.
- Data conversions: show summaries, venues, tracks, tapes, and the song
  by-slug slim (exported as slimSongBySlug, same behavior).
- Table rows, URL helpers, and ?source= helpers.
- vitest aliases 'server-only' to its empty module so src/data's pure
  conversions can be imported in tests.

* Organize detail pages as features over a shared ui layer

src/features: one folder per page family, holding everything specific to it.
- artist (overview, recently added, today in history, tours, a tour),
  song-detail, songs-index, venue-detail, venues-index.
- Each feature has its page component(s) and a load.ts that reads src/data
  directly and builds table rows per request (moved off the RelistenAPI
  facade). Song-only columns (Play, Length) and PlayTrackButton live in
  song-detail; artist-scoped venue filtering lives in venue-detail/load.ts.

src/ui: shared building blocks that take data as props.
- ui/table: DataTable, TableToolbar, queryTable, URL params.
- ui/show-table: ShowRow and row builders, shared show columns, show stats,
  ArtistShowList.
- ui/detail: StatGrid, YearChart.

Routes are thin: read segment params, render a feature, export its metadata.
RelistenAPI shrinks to what the browse view, live pages, and search use
(743 → 92 lines across this and the previous commit).

Lint-enforced boundaries (no-restricted-imports):
- only src/data imports the API client;
- a feature never imports another feature (relative imports stay inside it);
- src/ui never imports features or src/data;
- tests and fixtures may cross layers.

* Move the browse view to features/browse; remove RelistenAPI

- features/browse: the artists, years, shows, and tapes columns, the Today In
  History row, BrowseContainer, and MobileBrowseNav, with a load.ts whose
  loaders are cached per request, so the view's parallel columns share
  fetches (on /, the shows and songs columns get the same random show).
- ui/show/SongsColumn: a show's track list with tape selection, shared by
  the browse view, the embeds, and PlayerManager.
- features/today/load.ts: the Today page's shows joined with their artists.
- Every other former RelistenAPI caller (embeds, admin clip, the month index,
  album art, Today, Live, and the search/live/restore API routes) uses the
  browse loaders or src/data directly. RelistenAPI is deleted.

* Replace types.ts with camelCase domain types

The app's catalog types now live only in src/domain/types.ts; src/types.ts
(snake_case shapes mirroring the pre-v4 API) and lib/slimShow.ts are deleted.

- Domain: Artist (+ ArtistFeatures, Popularity), YearSummary, ShowSummary
  (+ optional avgDuration/popularity for the browse shows column),
  ShowDetail = ShowSummary + sources → sets → tracks, Source, SourceSet,
  SourceLink, PlayableTrack (the player's one track type) and Track,
  SongListItem, VenueListItem, TourSummary. Only fields something reads.
- src/data converts API resources straight to domain types; cache resource
  names are unchanged.
- Player and Redux store PlayableTrack (mp3Url/flacUrl); GaplessMetadata
  lives with the playback module. Saved state is primitives only, so no
  storage migration.
- /api/restore now returns camelCase (its only consumer, lib/player.tsx,
  ships with it).
- Entries without a slug (artists, songs, venues, tours) are dropped in the
  data layer; pages no longer filter them.

Verified: 166 tests, build, every detail-page count, and real playback in
headless Chromium (show page, scrobble body, reload restore, play in place).

* Retire src/components: ui primitives, player service, site shell

src/components is gone; everything has a layer:
- src/ui/base: generic building blocks (Row, Column, ColumnWithToggleControls,
  Count, Flex, Tag, Tooltip, TimeAgo, PopularityBadge, Scroller, Popover,
  DateJumper, TodayDateNav) and the shadcn primitives (ui/base/shadcn;
  components.json aliases updated).
- src/player: the player as a service: PlayerBar (was Player), PlayerManager,
  the gapless engine (was lib/player.tsx), scrobble, listen tracker, stream
  proxy.
- src/shell: site chrome: NavBar and its headers, menus, ArtistTabs, theme
  toggle, GlobalSearch, NotFound, admin/Android bits.
- Feature-specific pieces moved into features/today, features/embed, and
  features/blog. ScrollToTrack and Spinner were unused and are deleted.

Boundary lint extended: src/ui never imports the shell or the player; the
player never imports features or the shell. The shell composes the site like
a route and stays unrestricted.

* Drop types/params.ts: components declare their own props

RawParams was a leftover from routes passing raw params around; its four
users each picked one to four optional string fields from it.

* Simplify src/ui into base, table, and shows

- ui/base: generic building blocks; StatGrid and YearChart join it (they
  take plain numbers and know nothing about Relisten).
- ui/table: the generic table kit (unchanged).
- ui/shows: shared, Relisten-specific show UI, merged from ui/show and
  ui/show-table: TrackList (was SongsColumn), rows.ts, columns.tsx, stats.ts.
- ArtistShowList moves to features/artist, its only user.

* Restore the last-played track after hydration

Restoring at module load filled the playback store before React hydrated, so a
track row rendered active on the client but not the server whenever
/api/restore answered first. The restore now runs from an effect, and skips
when the page already loaded a track.

* Decode artist features from the v4 bitmask

v4 now sends features as a decimal-string u64. gen:api writes the flag map
from the swagger's x-feature-flags (scripts/gen-artist-features.mjs, an orval
hook), and toFeatures decodes the mask with BigInt into the same camelCase
flags. A compile-time check keeps the domain keys in step with the API's.

* Artist tabs: pending state, and Songs for normalized artists

A clicked tab shows a faded underline until its page arrives. Normalized
artists have songs without the setlist songs flag, so either flag shows Songs.

* Year chart: year labels, counts, and a tooltip

Years label themselves below the bars as space allows (every year, or every
2, 5, or 10), by each column's width via container queries; years with no
entries stay unlabeled. The busiest and selected years show their count;
hovering any year shows its count and a tooltip.

* Pagination above and below tables, without layout shift

One bar (range summary, then first/previous/pages/next/last) renders at both
ends of every table. Buttons are always present and disabled at the ends, the
page list always has seven slots, and each slot fits the widest page number,
so nothing moves between pages.

* One compact header for every detail page

PageHeader: an artist cover (their gradient and initials), the title, a muted
meta line, and stats top right as hairline-separated values instead of cards.
No breadcrumbs: the artist tabs say where the page is. Counts move from the
meta line into stats. Replaces StatGrid (removed in 9b31221 by mistake).

* Restore the nav bar's links

Chat, Apps, About, and GitHub go back in the bar; they had been folded into an
overflow menu alongside the artist tab row.

* Hide meta

* Tables: search status, calmer feedback, and fixes from a UX pass

- Search shows its state in a ring on the right of the input: muted while
  the debounce runs, an accent arc spinning while searching, then it closes
  into a check and fades. A muted clear button replaces the browser's, and
  autocomplete is off.
- The rows fade from the first keystroke until the results render (not just
  while the request is in flight, which locally lasts ~50ms), via a small
  context the toolbar and table share. Focus styling is subtler.
- The bottom pager returns to the top of the table (#results); the top pager
  keeps the scroll position.
- On narrow screens, search gets its own row.
- No matches: an empty state naming the search, with a link to clear it.
- Where a year chart filters by year, the year select becomes a removable
  chip, and the Clear button goes.

* Bump dependencies (OpenTelemetry, lucide, Tailwind, oxfmt, oxlint)

* Tables: dim only for the table's own navigations

usePendingNavigation is app-wide, so leaving the page dimmed the table too.
Clicks on links to this page with a new query (pager, sorts, chart bars,
captured before the router cancels them) and the toolbar's own controls mark a
navigation as the table's; only those dim it. Also: 'All countries', not
'All countrys'.

* Add /venues: every venue across all artists

The per-artist venues index, generalized: with no artist it reads the global
venue list (cached slim, ~4,800 venues), adds a country filter, and searches
official and short names too ('msg' finds Madison Square Garden). Linked from
the main nav. The regenerated API leaves spelling names out of venue
responses, so the client-side spelling filter goes.

* Show the Songs tab only for normalized artists

The setlist songs flag is set on some artists with no songs (Trey, Marco),
which left them an empty Songs tab.

* Tables: dim via usePendingNavigation's selector

timber alpha.229's usePendingNavigation((to, from) => …) knows where a
navigation is headed, so the table dims only when it replaces this page's
content (same path, new query). Replaces the click-capture workaround: the
document listener, the own-navigation flag, and its timeout.

* Tables: drop the search box's stale state

The table now dims only while a navigation replaces its rows; the search
ring shows the debounce. Removes TableState, useMarkStale, and setStale.

* Build query strings with defineSearchParams

Every internal URL's query goes through a definition (.href or
buildSearchParams) instead of template strings:

- tableHref builds through the table definition (still dropping empty values,
  so no '?q='); artistTopShowsUrl uses it.
- One numeric month/day definition for /today and Today In History, used by
  the date arrows, the date picker, and artistTodayUrl (invalid dates fall
  back to today rather than passing through).
- sourceHref(path, sourceId) for every ?source= link; the embed's share link
  extends it with ?t=.
- The app's /api routes share their definitions with the client code that
  calls them (restore, live summary and history, search).

* Bump timber to alpha.231

* Songs index: drop the 'With recordings' filter

* Build internal URLs with timber's href()

src/lib/urls.ts is gone: every internal URL goes through href(pattern,
params, query) from @timber-js/app/routes, so a renamed route fails the
build. That covers the old helpers' call sites and the template-string paths
elsewhere (browse, rows, nav, search, blog, live, player, embeds). Track links
take a typed trackRoute (site, embed, embed-track) via trackHref() instead of
a routePrefix string, and sourceHref becomes sourceQuery for href's third
argument.

The types caught links that could render /undefined; those now skip the link.
Also: ?playImmediately=false never paused embeds (z.coerce.boolean reads
'false' as true); it and album art's ?debug= use z.stringbool().

* Tables: drive the search box and filters from the URL

TableToolbar reads and writes everything through useQueryStates, with
the search debounced by limitUrlUpdates, so it has no local state or
effects. q keeps its spaces in the URL and is trimmed for the query.
Sort lookups use Object.hasOwn so inherited keys can't match. Bump
timber to alpha.233.

* Don't fetch audio until playback starts

Crawlers that render pages triggered full track downloads: gapless preloaded
upcoming tracks while paused, and headless browsers often allow autoplay.
Start queues with preloading off and enable it once audio plays, and only
autoplay after the visitor has interacted with the page.

* Play queue: a saved, restorable queue on gapless 5; remove Redux (#134)

* Add the play queue design doc

* Add the play queue model

Plain data and pure functions for the queue: the current entry, Next up,
and the context it came from. compose() flattens them into the one list
gapless plays, with history kept for previous() and repeat. Operations:
playContext (keeps Next up; reuses keys when the same context plays
again, so gapless keeps its audio), playNext, addToQueue, advanceTo and
jumpTo, move, remove, setShuffle and setRepeat.

Shuffle orders the context by a seeded hash of each entry's key, so it's
reproducible after a reload and stable under edits: removing one entry
doesn't reshuffle the others.

Tests include a seeded fuzz over random operations that checks the
composed list's structure, that played Next up entries never return,
and that operations don't mutate their input.

* Add the player machine, engine, playback actor and queue storage

Not wired up yet; the app still uses player.tsx.

- player.machine.ts: the queue (QueueState) and its lifecycle: booting
  (before the saved queue is read), ready (saved), ephemeral (embeds).
  Edits call the pure model; a model error is logged, not fatal.
- engine.ts: the one place that talks to gapless. sync() makes gapless
  match the machine (entries join once their URL is known; gapless is
  left alone while the current entry has none, and a play waits). It
  reports gapless moving on by itself, but not its own jumps.
- playback.ts: position, playing and load states, apart from the queue,
  so progress frames don't touch queue selectors.
- queue/storage.ts: versioned queue and throttled position in
  localStorage; only the tab that last started playback writes.
- model: refreshContext (fill in a context behind a placeholder),
  restoreOnto (a page that played before hydration keeps its show), and
  playing the current entry of the same context is a no-op, so the URL
  following playback can't re-anchor a shuffle.
- Dependencies: gapless 5.0.0-alpha.0, xstate ^5.33.3 (one copy, shared
  with gapless), @xstate/react 6.1.0.

* Move playback onto the play queue; remove Redux

The player now keeps a queue: the current track, Next up, and the show
or song list it came from. It's saved in localStorage and restored after
a reload, paused at the saved position, with no /api/restore round trip.

- runtime.ts wires the player machine and playback actor to the engine
  (gapless), storage, and side effects: URL and title follow the playing
  track on its show's page; scrobbles and listen tracking key off the
  queue item; missing stream URLs come from /api/queue?shows=.
- commands.ts / hooks.ts are what components use. PlayerManager,
  PlayerBar, the embeds, Providers' keyboard shortcuts and TrackList (via
  PlayingTrackList; src/ui doesn't read the player) move onto them.
- PlayerBar renders from the current item; only the elapsed time, length
  and progress bar subscribe to position, so playback no longer
  re-renders the bar (or the track list) every frame.
- Song pages: play starts the row at once, then /api/queue returns the
  song's whole filtered list in table order (shared spec with the page);
  the playing entry keeps its key, so nothing restarts.
- Media session: entries carry title, artist, album and artwork.
- Removed: player.tsx, the Redux store and packages, /api/restore and
  its search params. The old localStorage keys are ignored.

* Don't fetch audio until playback starts (the queue player)

Ports c08b1c3 (on feat/detail-pages, written against player.tsx) to the
queue player:

- gapless starts with preloadNumTracks: 0; the engine turns preloading
  on (2 tracks) at the first progress frame that's actually playing, so
  a paused queue, restored or set up by a track page, downloads nothing.
- playShow, the path a page load takes, autoplays only after the visitor
  has interacted with the document (canAutoplay); otherwise it queues the
  tape paused and shows a 'Ready to play' prompt. Embeds are exempt.
- showPlayPrompt replaces the autoplay-blocked toast, with the browser
  permission advice only when the browser blocked it.

* Fix review findings: retry failed URL requests; flush saves on claim

- Stream URLs: a failed /api/queue request only recorded a backoff, and
  requests ran only on queue changes, so a blip during restore left the
  current track unplayable until a reload. The resolver (now its own
  module, queue/resolver.ts) schedules a retry after a failure, and
  pressing Play (startPlayback, which every play goes through) retries at
  once when the current track has no URL.
- Storage: a tab's debounced save is held back while another tab owns
  storage, and claim() didn't write it, so storage could pair the old
  owner's queue with the new owner's position. claim() now flushes the
  pending queue and position.

* Autoplay for returning listeners

The browser's autoplay policy can't tell a crawler from a listener who
allowed sound (headless Chromium allows autoplay, and Chromium can only be
asked by calling play(), which downloads the audio). So a track page now
also autoplays for a returning listener: someone who once started
playback by interacting, remembered in localStorage (relisten.listened).
Never for embeds, or browsers that report navigator.webdriver; crawlers
start with empty storage and can't set it without interacting. If the
browser blocks it anyway, the 'Autoplay blocked' prompt appears.

* Fix review findings: write the latest queue on claim; retry each backoff

- claim() wrote only what was pending, so a tab that resumed after its
  own save had completed (while another tab had written its queue since)
  left the other tab's queue next to its position. Storage now keeps the
  last queue and position it was given, and claim() always writes them.
- The resolver scheduled one timer for the first failure, so a later
  failure still inside its backoff when that timer fired was never
  retried. After every attempt it now schedules a wake-up for the
  earliest backoff among shows that are still missing.

* Retry shows /api/queue couldn't load this time

/api/queue?shows= turned every showById error into a 200 with that show's
URLs left out, so the resolver counted a timeout or 5xx as success and
never retried. The route now tells 'doesn't exist' (isMissing: no URLs)
from 'failed this time' (listed in `failed`, response not cached), and
the resolver puts failed shows into the backoff like a failed request.

* Claim storage when playback started before hydration

A track page can start playing while the player is still booting (a
returning listener's autoplay), and claiming ownership was skipped then.
The engine reports a playing change once, so becoming ready never
retried it, and with another tab owning storage this tab's queue went
unsaved. hydratePlayer now claims if the engine is already playing.

* Store each queue item's mp3Url; drop URL resolution

The saved queue held no stream URLs, so a restore had to ask /api/queue
for them, with retries and backoff when that failed. Items now carry
their mp3Url (raw from the API; proxied when played), so a restored queue
plays with no request.

- Removed: the resolver, the per-session URL cache, /api/queue?shows=,
  and the console-only window.FLAC preference (no FLAC URLs are stored).
- /api/queue serves song pages only: the full filtered list, with URLs.
- Tracks without an mp3 file aren't queued.
- Saved queues are version 2; version 1 (no URLs) is ignored.

* Song pages queue the visible rows; remove /api/queue

A song row's play button now queues the performances on screen, in
table order, from the page's own data (a SongQueueProvider around the
table), instead of fetching the song's full filtered list. Another row
on the same page reuses the queue. The queue's identity includes the
page, so another page is another queue.

Removed: /api/queue, its search params, the server-side song queue, the
machine's REFRESH_CONTEXT, and TableResult.sorted.

* Reclaim storage on an explicit play

A tab that kept playing while another tab took over storage, then picked
another track, never claimed it back: its playing state didn't change,
so onPlayingChange didn't fire, and its new queue went unsaved.
startPlayback (every play goes through it) now claims storage when the
player is ready; booting and embeds stay excluded.

* Keep the volume in one place

The player bar kept its own copy of the volume, initialised with
Number(localStorage.volume) || 1, which turned a saved 0 (muted) into
100% while the engine correctly applied 0. Volume now lives in the
playback actor: the runtime seeds it from the saved value (savedVolume,
which keeps 0 and defaults only when nothing valid is saved), and
setVolume updates the actor, the engine and the saved value together.
The bar reads the actor.

* Simplify the play queue

Cut what nothing uses yet, and the edge cases that kept generating bugs:

- Storage: no tab ownership; whichever tab saves last wins.
- Engine: no waiting for a URL (every item stores its mp3Url); no repeat.
- Model: a context entry's key is its trackId, so replaying a context
  keeps loaded audio without reuseKeys/refreshContext/sameRef. No
  context ref, no restoreOnto (a page that started playing ignores the
  saved queue).
- Shuffle, repeat, playNext, addToQueue, move and remove move to the
  queue UI PR, with their callers. compose/advanceTo still handle Next up.
- The xstate machine becomes a fromTransition reducer (queueLogic.ts).
- Saved queues are version 3.

* Next up: playNext, addToQueue, move and remove

The operations the queue UI needs, with its callers in the next commit:
- model: playNext and addToQueue (Next up entries get their own keys,
  next:<trackId>:<id>, so a track can be in the context and queued),
  move and remove within Next up, and upcoming() for the panel.
- reducer: PLAY_NEXT, ADD_TO_QUEUE, MOVE, REMOVE (ignored in embeds),
  and unknown events now leave the state alone instead of wiping it.
- items: showTrackItem, one track of a tape as a queue item.

* Queue UI: row menus and the queue panel

- QueueMenu: a ⋯ menu (base-ui Menu) with Play next and Add to queue on
  show track rows (via TrackList's new trackActions slot, filled by
  PlayingTrackList; src/ui still doesn't read the player) and song rows.
  Not in embeds.
- QueuePanel: the player bar's queue button (with the Next up count)
  opens a base-ui Drawer: a bottom sheet on phones, a right-hand panel on
  desktop. Now playing, Next up (drag a handle or use the arrow keys to
  reorder, with the move announced; remove), then the rest of the show
  or song list. Picking an entry plays it. Escape, a click outside, or a
  swipe closes it; the drag handle opts out of the drawer's swipe.
- The @songs pages pass the artist's name, which queue items need.

* Bump timber

* Queue: a popover of rows instead of a drawer

- The queue opens as a popover off the player bar's queue button, not a
  drawer. A 2px ring and heavier shadow; the arrow meets the ring and
  takes the header's colour (new arrowClassName on PopoverContent).
- Rows like a show's track list: track number (bouncing bars on the
  current track), title, artist · date · venue, duration. The header is
  queue info only: track N of M, time left, how many are up next.
- Played tracks show above the current one, dimmed; picking one plays
  it. The popover opens with the current track in the middle.
- upcoming() is now queueView() and also returns the played tracks
  (the cursor entry counts as played while Next up is playing).

* Queue: reorder and remove any entry, gapless status per row

- Drag or arrow-key reorder across played, current, Next up and Then rows;
  remove any row (removing the current one skips to the next)
- Gapless status icons with tooltips, aligned columns
- Popover rebuilt on css-anchor-kit (native popover + CSS anchor
  positioning), so anchored tooltips inside it land correctly in Firefox

* Bump gapless to 5.0.0-alpha.1 (unload tracks that leave the preload window)

* Player: stop when the last entry is removed; count each queue entry's listen; keep a playing Next up entry on page loads

- engine.sync with no current entry clears gapless instead of letting it
  start the first (already played) entry
- The listen tracker is keyed by queue entry, so two queued copies of a
  track each scrobble
- playShow leaves the queue alone when a Next up entry for the requested
  track is playing, instead of swapping it for its context copy and moving
  the cursor (which skipped tracks)

* playShow: leave the queue alone when the requested track is already current

Rebuilding the context for the playing track undid the listener's removes
and moves (and swapped a Next up entry for its context copy).

* Bump @timber-js/app to 0.2.0-alpha.235

* Hide the Tours tab for now (the route and data stay)

* Tables: a tinted card with striped rows, whole-row links, count bars, muted secondary columns

* Never autoplay embeds on load

Embeds pass an explicit embed prop to PlayerManager; the first sync after
load never plays, while in-embed track clicks still do. Removes the
?playImmediately param, whose z.coerce.boolean() parsed "false" as true.

* feat: add artist drop down to sub nav

* feat: link browse view to detail view for artist, venue and song

* feat: create show column metadata

* refactor: claude cleanup

* Player: keep the context when moving the cursor; leave the current track alone on page syncs

- move(): moving the cursor while Next up plays no longer marks the rest of
  the context as played
- playShow: an already-current track keeps its play state and position, so
  the URL following playback can't resume a paused track or re-seek a stale ?t=
- ?t= on a new tape starts the entry there (engine.startAt) instead of seeking
  before audio loads
- canAutoplay: no userActivation API means no interaction
- PlayerManager: compare trackSlugs by value
- runtime: index the queue once per change for progress frames

* Player: say why a track can't play; keep the queue on a track page reload

Failures
- Estimate the cause from what the browser already has, no extra requests:
  navigator.onLine, the HTTP status of gapless's own download (Resource
  Timing), then the <audio> MediaError code. Names the source (archive.org,
  Phish.in, host) and whether it went through Relisten's audio proxy
- Toast "Can't play" with the reason and Skip; pause on the failed entry so
  the bar doesn't claim it's playing. Playing it again re-shows the reason
  (gapless can't reload an entry's audio)
- Warn when playback is stuck loading for 20s
- Mount the player's Toaster in NavBar: content pages had the player but no
  Toaster, so their failures and play prompts were invisible

Reload
- A track page reload restored nothing: the page queues its track before the
  saved queue is read, and hydration dropped the saved queue. Restore it (and
  the position) when the page started the saved current track
- engine.sync: start a surviving current entry at a pending start time
  (setEntries ignores startTime for it)

* Queue: keep the current entry the cursor when moved; honor a start entry in Next up; keep ?t= until hydration

- move(): the current entry stays the cursor wherever it goes (moving it
  backed the cursor up, so Next up resumed before it and replayed it)
- playContext(): a start entry that was moved into Next up plays from the
  context, and leaves Next up, instead of the context starting at track 1
- runtime: don't follow playback in the URL while booting; it dropped a ?t=
  before hydratePlayer could see it, so the saved position won over the link

* ShowHeader: reset failed album art per show; build its URL with href()

The header stays mounted while the @songs slot moves between shows, so a
single `artFailed` flag, once set by one show's 404, hid the art of every
show after it. Remember which show's art failed instead.

The image URL was a template string with a hand-built query. Move the
album-art route's search params to a shared module so the header builds
it with href() and buildSearchParams(); renaming the route or its params
now fails the build. `size` stays a string enum in the shared def and the
route converts it, so the codec serializes cleanly.

* QueueMenu: close the menu when a song link is clicked

Menu.LinkItem defaults to closeOnClick: false in base-ui 1.9, which suits
full page loads. Its links here are client navigations, so the popup
stayed open over the page until the new route unmounted it, or for good
if the menu survived the navigation.

* Song links: carry song ids from the track, resolve the slug on click

The "See all <song>" links needed slugs, and the API's track songs carry
only an id and name. toLinkedShowDetail filled them in by fetching the
artist's whole song list inside showByDate's slim, which:

- ran after the show request, so every cold show load (and every random
  show, which is uncached) waited on two API calls in series;
- swallowed a failed list fetch to [] and then cached the unlinked show
  for its full TTL;
- made embeds pay for links they never show.

Now toTrack reads the matched songs straight from the track's
normalization (id and name, no fetch), and the links point at the song
page by id. `[songSlug]` also accepts a strict song_… id, like
`[venueSlug]` accepts imported venue ids; songDetail looks the id up in
the cached song list and loads the song under its slug, so the page's
existing slug-mismatch redirect sends the URL to the slug. The list is
only fetched when someone follows a link. The song endpoints by id
return every show even with include_tracks=none (~1 MB for Sugaree),
so the cached ~120 KB list is the cheaper lookup.

When the API adds `slug` to track songs, link by slug and drop the id
branch.

* Archive artists: one shared loader; ArtistPicker reads it with use()

createArchiveLoader() was held in useState by each consumer, so the
browse view's artists column and the nav's artist picker each fetched
and parsed the ~4k-row archive and kept their own copy. The loader only
ever caches one promise, so make it a module-level function: one request
per page load, and no prop to thread through the column.

ArtistPicker mirrored that promise into state with an effect (a live
flag, an archive state, a "searching" dependency) while the column read
the same loader with use() under Suspense. Use the column's pattern: the
matches render in a child that suspends on the archive, with the
primary-only matches as its fallback. The effect, the state and the
memo over them go away.

* ArtistPicker: pick the section on the client; drop requireArtistSection

Switching artists kept the section, so from /artists/a/songs the picker
sent an artist without songs to /artists/b/songs, and the page then
redirected to the overview: a server round trip on every such switch,
to correct a URL the client built knowing it could be wrong. The guard
also turned direct visits into 307s, and covered only the index pages.

The picker couldn't decide because its rows carried no features. Add
the four features artistSections reads to ArtistRow as a bitmask
(one small int per row; the ~4k-row archive grows by a few KB), decode
them in fromArtistRow, and have sectionHref send an artist without the
section to its overview. requireArtistSection and the pages' awaits go;
the section pages are as they were before this branch.

* Artist groups and plays: one helper for the column and the picker

ArtistPicker split featured from primary with Number(a.featured) === 1
and sorted by a hand-copied 30-day plays expression, while the artists
column grouped with groupBy, a GROUP_ORDER table and a groupLabel
switch. Two copies of what "featured" means can drift apart.

Move both into artistRows next to isPrimaryArtist: groupArtists()
returns the ordered, labelled, non-empty groups and artistPlays() the
30-day plays. Archive tiers (featured ≥ 2) now share one group, where
the column used to give each distinct value its own "Archive Artists"
header. groupBy has no callers left, so it goes.

* Primary artists: one context for the nav's picker and the artists column

NavBar loaded the artists twice (loadArtists, then getArtistColumnRows
for the picker's rows) and threaded the rows through ArtistTabs, while
the browse view's @artists slot loaded them again for the column. Two
components got the same list down two prop paths.

The layouts that render NavBar now wrap their tree in <PrimaryArtists>,
which loads the rows once and provides them, decoded, through context.
The picker and ArtistsSelectionTab read usePrimaryArtists(); the
pickerRows prop, NavBar's second load and ArtistsColumn's fetch go.

The picker opens on the list with no fetch, which a deferred
/api/artists/primary route would have cost (and on touch, where there's
no hover to prefetch on, a visible loading state). The list was never
serialized twice on browse pages: Flight sends the shared cached array
once. Its cost is on the other pages, ~1.2 KB gzipped on a blog page.
The archive stays deferred behind its route.

* ShowHeader keeps the art's box when it fails; picker search lives in its panel

Album art that failed to load removed its 64px <img>, so the venue and
stats jumped left. Render a placeholder of the same size instead. And a
server-rendered image can fail before hydration, when React isn't
listening for onError yet, which left a broken image on the page; check
the image on mount as well.

ArtistPicker held the search text next to the open state and cleared it
in onOpenChange. The popover already unmounts its content on close, so
move the search into the panel component it renders: each opening
starts empty without the reset, and the trigger keeps only `open`.

* Archive artists: build the URL with href(); time the request out

The archive fetch used a hand-written '/api/artists/archive'; build it
with href() like every other internal URL, so renaming the route fails
the build.

It also had no timeout. The loader resolves to null on failure, but a
stalled request never settled, leaving the picker on "Searching…" and
the archive tab's count on "…". Abort it after 15s (generous: ~4k rows,
and a cold server cache rebuilds them); it then fails like any other
error, and searches cover the primary artists.

* Album art: read the show with get(), and 404 only a missing show

The route read the show with orDeny(), which calls deny(404) for a
missing show. deny() in a route handler surfaces as a 500; it only
worked because `.catch(() => null)` swallowed the deny signal, and with
it every other error, so an API outage also answered 404.

Use get() and catch only a missing show (404/410/422) as null; any other
failure propagates as a 500 instead of a 404 a CDN or crawler could
keep.

* Artist picker: open the artists column's list instead of a second one

The picker had its own list (cmdk items, a primary/archive search, a
50-result cap, group headings) beside the browse view's artists column,
which already did all of that with richer rows. Two components meant
two places to change for every list behavior.

ArtistsSelectionTab is now the one artist list. It takes the current
artist and how a row links (ArtistLinks: currentSlug, hrefFor) from its
caller instead of reading the browse segment itself, plus a compact
mode for the popover: a fixed height, no heading bar (sort follows the
column's toggles), a focused filter, arrow keys between the filter and
the rows, and onNavigate to close once a row is followed.

ArtistsColumn becomes a small client component that supplies the browse
links. ArtistPicker supplies sectionHref and renders the list in its
popover; PickerPanel, Matches and the cmdk import go. The popover's
rows show what the column's do: shows, tapes, plays.

* Header: the title is the artist picker; artist tabs only on artist pages

"RELISTEN TO THE GRATEFUL DEAD" already named the artist, and a second
"Grateful Dead ▾" button sat just under it. The title's artist is now
the picker itself (a title-styled trigger with a chevron), so the tab
row holds only the artist's sections.

Below lg the header hides the title and shows "Re", so there the
picker stays at the start of the tab row; ArtistPicker takes a variant
for the two triggers.

The tab row used to fall back to the default artist on every page, so
/blog showed Grateful Dead's sections under "RELISTEN TO OUR BLOG".
NavBar now takes the page's artist from the URL, or the default artist
only in the browse layout (whose root, /, browses it); pages about no
artist get neither the picker nor the tabs. Browse pages keep the
picker.

* Artists column: a spinner while the archive loads; say if it failed

Opening an archive artist puts the column on the archive tab, which is
empty until the archive list arrives: a blank column with no sign
anything is coming. A failed load looked the same, forever.

The list now starts with a small spinner while the archive is missing
and it matters (the archive tab, or a search, which covers the
archive), or one line of muted text if it failed. The fallback passes
`undefined` for loading so the two can be told apart from the loader's
`null`. The status is a virtual item, so the rows below it stay where
the virtualizer thinks they are. The tab's count shows "–" instead of
"…" when the load failed.

* Artists column: hold the archive count's width while it loads

"Archive Artists (…)" grew to "Archive Artists (4142)" when the archive
arrived, shifting the tab's label. The count now sits in a box four
tabular digits wide, so "…", "–" and the number all take the same
space.

* ArtistPicker: no accent color on the title trigger

The title's artist turned accent blue on hover and while open, while the
chevron stayed grey, so it read like a link next to an unrelated icon.
Keep the title's color and the faint hover background, which with the
chevron is enough to say it opens.

* Primary artists: seed once per document from the root layout

The rows rode along on every client navigation: no layout is skipped
in this app (the root reads the theme cookie), so <PrimaryArtists>
re-serialized ~16 KB into each RSC payload.

Every visit starts with a document request, so that's where the rows
go. <PrimaryArtists> moves to the root layout and omits the rows from
RSC payloads (Accept: text/x-component). In the root layout the client
provider mounts once per document, so it keeps the first rows it gets
and ignores the rest.

The tradeoff: the list is as fresh as the page load. Measured: the
document has the rows; payloads for /grateful-dead/1977, /phish,
/artists/phish/songs and /blog have none; the picker still lists
artists after those navigations.

* ArtistPicker: a firmer edge and deeper shadow on the popover

It opens over the busy browse columns, and the default popover's
hairline ring (7% in light mode) and shadow-lg let it blend into them.
Give this popover a 2px ring at 15% of the text color and shadow-2xl
(darker in dark mode).

* gapless 5.0.0-alpha.2: a blocked play() no longer downloads

alpha.2 drops what a refused play() started (the Web Audio HEAD and
GET, and the element's buffering) and puts the track back to idle. A
page whose autoplay the browser blocks now costs at most an aborted
HEAD, where it used to download the whole first track behind the
"Autoplay blocked" prompt.

The engine reports load states on progress, syncs and queue changes,
but not on a blocked play, so the track stayed LOADING in the track
list after gapless had reset it. Report them when play is blocked.

Checked in Chromium with autoplay requiring a click: one HEAD, aborted;
the track shows no load state.

* Player: don't attempt autoplay where the browser says it would refuse

A returning listener's page attempts playback on load, and the browser
may refuse it. Firefox can be asked first, without calling play():
navigator.getAutoplayPolicy('mediaelement'). When it answers
'disallowed' (or 'allowed-muted', since the player plays with sound),
show the "Autoplay blocked" prompt without an attempt, so nothing is
requested at all.

Chrome and Safari don't have the API (checked in Chromium 156 and
WebKit 27.2), so they still attempt and find out; gapless alpha.2 makes
that attempt cheap. The type isn't in TypeScript's DOM lib yet, so it's
declared locally.

* Remove the gd

* Render the shell and browse columns once per document

timber 0.2.0-alpha.241 adds lifetime: 'document': a segment renders on the
document request and client navigations skip it, without rendering, until
its key changes.

- Root layout: 'document', keyed on the theme cookie. PrimaryArtists no
  longer matches text/x-component in Accept to tell document requests
  apart; the layout only renders on those.
- Artists, years and shows columns: 'document' slot pages. The artists
  column keys on nothing, years on artistSlug, shows on artistSlug and
  year. The shows column moves from [...years] to [year]/[[...rest]] so
  its key can name the year.
- The years column drops its Today in History row: it read the
  cf-timezone header, which a 'document' segment cannot read.

* Show columns: send each column only the fields it renders

The songs and sources slots each got the full show. Each now gets a
projection (src/lib/showProjection.ts), typed so a field a column
starts reading is a type error until the projection carries it.

- Songs column: every tape's sets and tracks (the active tape is picked
  client-side from ?source=), plus links. Tracks keep mp3Url, which the
  queue menu builds queue items from; they drop flacUrl, position and
  sourceId (the menu takes the tape's id instead).
- Sources column: each tape's summary, with no sets or links.
- Both keep the fields sortSources reads, so they order tapes alike.

On timber 0.2.0-alpha.240+, with segment hashing on, each slot is
serialized on its own and the full show went out twice: a show-to-show
navigation (/grateful-dead/1977/05/09 -> 05/08) was 470 KB raw / 66 KB
gzip, and is 189 KB / 34 KB with this. With hashing off it is 222 KB /
40 KB, from 276 KB / 43 KB. The HTML document drops from 551 to 494 KB.

* Show columns: render the songs and sources slots once per show

The @songs and @sources day pages are 'document' segments keyed on
their path params (artistSlug, year, month, days). They read nothing
else: the active tape is picked client-side from ?source=, so a tape
switch skips both slots without rendering them.

With segment hashing off, a tape switch re-sent the whole show (68 KB
raw with the 'document' browse columns); it is now 10 KB raw / 3 KB
gzip. The show's data, popularity included, stays as it was when the
date was loaded until a navigation to another date.

* Turn off segment hashing

clientSegmentCache renders every segment and hashes its output to skip
the ones the client holds. On relisten that costs about a third more
server CPU per navigation (design/53: 1.06 s vs 0.77 s per run), and the
'document' segments now skip the stable ones without rendering them.

The option is dropped rather than set to false: off is the default, and
timber's next release removes it.

* Bump timber to 0.2.0-alpha.242

* Render the browse layout and nav once per document

The (browse) layout is lifetime: 'document'. Its nav moves into an @nav
slot of 'document' pages keyed on the artist and the user agent, since
the layout may not read the artist param below it. NavBar takes the
artist as a prop there.

The iframe check moves to the browser: sec-fetch-dest says iframe only
on the document request and empty on every client navigation's fetch,
so the server could not tell after the first page.

* Browse columns as nested 'document' layouts instead of parallel slots (#140)

* timber 0.2.0-alpha.244: layoutDefaults with a user-agent key; (content) nav as a 'document' @nav slot

* Browse columns as nested 'document' layouts instead of parallel slots

* Detect Android on the client and drop ua-parser-js

The upgrade banner already rendered nothing on the server and gated on
localStorage via useSyncExternalStore; it now checks the platform there
too (userAgentData.platform, falling back to the UA string). NavBar no
longer reads request headers, so its HTML doesn't vary by user agent.

* Drop moved-section redirects and rely on schema.ts for segment validation

Remove movedSectionUrl and the browse middleware's segment regex loop;
schema.ts codecs 404 malformed segments before middleware runs. Read
artistSlug from ctx.segmentParams. Drop the stale [...days] codec, whose
routes no longer exist.

* Drive the nav's artist from client state; one nav per document

The nav no longer depends on the page. A browsing-artist context in the
root layout reads the artist from the URL as it renders, and remembers it
on the site's pages (today, live, venues, …), where the artist picker
switches it in place. NavBar takes no props, so (browse) and (content)
render it directly, once per document: both @nav slots and the unused
[[...rest]] codec are gone.

- Artist tabs on every page, led by a fixed-width artist picker; the
  header is the Re brand mark and RELISTEN, plus the site page's title
- The home link goes to the browsed artist's browse view
- One list of site links for the header, the menu and the tabs
- Space no longer toggles playback when a focused control owns the key
- Bump timber to 0.2.0-alpha.245

* Render the nav once under (main)/(shell); seed its artist from the server

Every page with the nav now sits under one layout tree, so the nav renders
once per document and never rides along in an RSC payload, including when
moving between the browse view and the content pages.

- (main)/layout.tsx is keyed on artistSlug alone and sends the URL's
  artist row (archive artists included, from the cached directory) to the
  browsing-artist provider. It renders again only when the artist appears,
  changes or goes away, and then sends one row. Archive artists are right
  on the first frame, with no client fetch; any other slug shows the
  default artist.
- (main)/(shell)/layout.tsx renders the nav; (browse), (content) and
  (bare) under it keep only their own wrappers.
- One (shell)/404.tsx: a not-found page keeps the nav, without the browse
  layout's artists column.
- Column headings truncate beside their sort toggles instead of wrapping
  under them.

* Show detail page (#141)

* feat: show detail screen

* refactor: improve show detail screen ui

* refactor: move source selection to dedicated column

* refactor: remove sorting

* refactor: streamline show detail navigation

* refactor: show detail header data

* feat: add review section

* refactor: match overview header style

* refactor: remove track number column

* refactor: venue pill classes

* fix: source list doesn't extend past song list

* feat: add link back to browse screen

* refactor: review section ui

* feat: link queue track name to track

* chore: bump dependencies, drop @radix-ui/react-popover

* fix: stop the artists list shifting when the archive arrives

The list suspended on the archive, so a second list replaced the fallback
when it loaded, seeded from a measurements snapshot that still held
estimates (the virtualizer recomputes measurementsCache lazily). Every row
jumped by the difference, then jumped back once measured.

Read the archive with useSyncExternalStore instead (undefined in the server
render and hydration), so one list and one virtualizer persist. Seed the
virtualizer's estimates from the in-flow rows the server rendered, so
virtualizing them after hydration moves none of them.

* refactor: close popovers on link click; queue track link as an icon

PopoverContent closes itself when a link inside it is followed, replacing
the per-popover onNavigate plumbing (artist picker, queue). The nav menu
moves from the Radix popover to the shared one.

Queue rows: the play button covers the row, and the track's page gets its
own arrow-icon link column instead of linking the title.

* feat: link the queue row's date to the show, drop the track icon

---------

Co-authored-by: Daniel Saewitz <git@saewitz.com>

---------

Co-authored-by: Thenlie <leithen113@gmail.com>
Co-authored-by: Leithen <lcrider@cvent.com>

---------

Co-authored-by: Thenlie <leithen113@gmail.com>
Co-authored-by: Leithen <lcrider@cvent.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants