Repository navigation
Play queue: a saved, restorable queue on gapless 5; remove Redux - #134
Conversation
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.
Codex Review Summary: ✅ CompletedThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 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".
| } catch { | ||
| batch.forEach((id) => failedAt.set(id, Date.now())); |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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".
| claim() { | ||
| write(OWNER_KEY, { tabId: thisTab(), at: new Date(now()).toISOString() }); |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
💡 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".
| onPlayingChange: (playing) => { | ||
| playbackActor.send({ type: 'PLAYING', playing }); | ||
| // The tab that starts playback owns the saved queue. | ||
| if (playing && playerActor.getSnapshot().matches('ready')) storage?.claim(); |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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.
| const scheduleRetry = () => { | ||
| if (retryTimer) return; | ||
| retryTimer = setTimer(() => { | ||
| retryTimer = null; | ||
| void kick(); | ||
| }, retryMs); |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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.
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.
There was a problem hiding this comment.
💡 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".
| 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))); |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
💡 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".
| // 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(); |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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".
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.
There was a problem hiding this comment.
💡 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".
| // The tab that starts playback owns the saved queue. | ||
| if (playing && playerActor.getSnapshot().matches('ready')) storage?.claim(); |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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.
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.
There was a problem hiding this comment.
💡 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".
| setVolume(Math.max(0, Math.min(1, nextVolume))); | ||
| const activeTrack = useCurrentItem(); | ||
| const isPlaying = useIsPlaying(); | ||
| const [volume, setVolume] = useState(Number(localStorage.volume) || 1); |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
💡 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".
| { artistSlug: item.artistSlug, year, month, day, songSlug: item.trackSlug }, | ||
| sourceQuery(item.sourceId) | ||
| ); | ||
| if (pathname + search !== next) replaceUrl(next); |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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.
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.
Queue UI: Play next / Add to queue, and the queue panel
There was a problem hiding this comment.
💡 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".
| if (!isPaused) dismissAutoplayToast(); | ||
| const item = itemFor(key); | ||
| if (!item) return; | ||
| if (listens.progress(item.trackId, { currentTime, isPaused })) scrobblePlay(item.trackId); |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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).
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.
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.
There was a problem hiding this comment.
💡 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".
| if (e.code === 'Space') { | ||
| e.preventDefault(); | ||
| player.togglePlayPause(); | ||
| togglePlayPause(); |
There was a problem hiding this comment.
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 👍 / 👎.
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>
* 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>
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
mp3Url. This replaces the/api/restoreround trip.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 callingplay(), which downloads the audio.lastPlayedUrland the rest) are ignored. There's no migration.window.FLACpreference is gone: onlymp3Urlis 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, thenready(saved), orephemeralfor 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/uistill doesn't read the player:TrackListgets aplayingprop throughPlayingTrackList.SongQueueProviderhands the table's visible rows to the play buttons. There are no new API routes.Testing
navigator.webdriver, with fresh storage, makes 0 requests; an automated browser with the listener flag makes 0; a returning listener autoplays;Bugs the browser run caught (fixed here)
Known
/album-artlogs "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.