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

+21
View File
@@ -107,6 +107,18 @@ func buildPodcastsPageData(c *echo.Context, cfg *Config) (*templates.PodcastsPag
if err != nil {
return data, err
}
// Continue aggregation (plan 2.5): first in-progress episode per
// feed (the query is already most-recent-first).
continueByFeed := make(map[string]*database.GetPodcastContinueForUserRow)
if continueRows, err := cfg.Queries.GetPodcastContinueForUser(ctx, user.ID); err == nil {
for i := range continueRows {
row := &continueRows[i]
key := uuid.UUID(row.SourceFeedID.Bytes).String()
if _, ok := continueByFeed[key]; !ok {
continueByFeed[key] = row
}
}
}
for i := range feeds {
row := &feeds[i]
total, _ := cfg.Queries.CountPodcastEpisodesForFeed(ctx, row.ID)
@@ -137,6 +149,14 @@ func buildPodcastsPageData(c *echo.Context, cfg *Config) (*templates.PodcastsPag
AutoDownloadCount: int(row.AutoDownloadCount.Int32),
Subscribed: true,
})
if cont := continueByFeed[uuid.UUID(row.ID.Bytes).String()]; cont != nil {
card := &data.Feeds[len(data.Feeds)-1]
card.HasContinue = true
card.ContinueTitle = cont.Title
card.ContinueEpisode = int(cont.FeedPosition.Int32)
card.ContinuePercent = cont.Percentage.Float64
card.ContinueLastRead = cont.LastReadAt.Time
}
}
// Facet values from the unfiltered subscription list (D18).
@@ -178,6 +198,7 @@ func buildPodcastsPageData(c *echo.Context, cfg *Config) (*templates.PodcastsPag
}
data.Feeds = filtered
data.Query = q
data.ContinueFeeds = templates.PodcastContinueFeeds(data.Feeds)
return data, nil
}