feat(podcasts): chain playback + Continue section — sequence mode in the web player (phase 2 core)

D7 chain-by-default, delivered with minimal player surgery:

- ReaderMetadata gains PodcastSequence: the neighboring DOWNLOADED
  episodes in the caller's effective order (D5 override applied,
  feed-native fallback; computed in the reader route for
  source='podcast_episode' items — RLS already scoped the reads; gaps
  never enter the chain, invariant 6). ?single=1 escapes chaining (the
  'play single episode' affordance).
- Audio reader renders the sequence into body[data-json]
  (AudioReaderConfigJSON — books produce the identical shape minus the
  sequence key, so the reader path is unchanged for them) and an
  'Ep N/M' indicator + prev/next episode controls in the header.
- audio.ts: the last-track 'ended' handler chains into the next
  episode's reader page (final progress push precedes navigation);
  back button exits episodes to their feed page. Books are untouched —
  no sequence context, zero behavior change. Day-one chaining is
  page-navigation based (progress persists; position restores).

Continue aggregation (2.5): GetPodcastContinueForUser (in-progress
episodes, most-recent-first, ≥95% excluded) feeds a Continue section
on /podcasts — sorted most recently listened, cards show 'Ep N · title'
— ahead of All Feeds. Cards prefer the continue line over the
description.
This commit is contained in:
John O'Keefe committed 2026-10-29 16:25:44 -04:00
1 parent 2235b00c59
commit 28420d56ed
14 files changed
+740 -174

No files matched your search

+49 -1
View File
@@ -171,9 +171,20 @@ export function endTimeLabel(
const PROGRESS_INTERVAL_MS = 30_000;
interface PodcastSequenceConfig {
feed_url?: string;
feed_title?: string;
prev_url?: string;
next_url?: string;
next_title?: string;
episode_index?: number;
index_total?: number;
}
interface AudioConfig {
chapterEndpoint: string;
progressEndpoint: string;
podcastSequence?: PodcastSequenceConfig;
}
function readConfig(): AudioConfig {
@@ -419,10 +430,14 @@ Alpine.data("audioReader", () => ({
});
audio.addEventListener("ended", () => {
// Natural end of track n: roll into n+1 and keep playing. At the
// last track the book is done — nothing to advance into.
// last track the book is done — nothing to advance into... unless
// this is a podcast episode with a sequence (D7): chain into the
// next downloaded episode's reader page.
if (this.activeTrack < this.tracks.length - 1) {
this.setActiveTrack(this.activeTrack + 1, 0, true);
return;
}
this.goToNextEpisode(true);
});
audio.addEventListener("play", () => {
this.playing = true;
@@ -760,9 +775,42 @@ Alpine.data("audioReader", () => ({
backToDetail() {
this.pushProgress(true);
// Podcast episodes exit to their feed page (D7 sequence context);
// books keep the book-detail exit.
const seq = readConfig().podcastSequence;
if (seq && seq.feed_url) {
window.location.href = seq.feed_url;
return;
}
window.location.href = `/media/${this.mediaItemId}`;
},
// ---- podcast sequence chaining (D7) ----
// Auto-advance across episodes: the last track's `ended` rolls into
// the next episode's reader page (progress already pushed on
// timeupdate; a final push fires before navigating). Books are
// untouched — no sequence context, no behavior change.
goToNextEpisode(finalPush) {
const seq = readConfig().podcastSequence;
if (!seq || !seq.next_url) return false;
const go = () => {
window.location.href = seq.next_url as string;
};
if (finalPush) {
Promise.resolve(this.pushProgress(true)).then(go).catch(go);
} else {
go();
}
return true;
},
goToPrevEpisode() {
const seq = readConfig().podcastSequence;
if (seq && seq.prev_url) {
this.pushProgress(true);
window.location.href = seq.prev_url;
}
},
// ---- volume (settings strip) ----
volumeIcon(): "muted" | "low" | "mid" | "high" {