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

+31 -1
View File
@@ -6,6 +6,7 @@ package templates
import (
"fmt"
"sort"
"strings"
"time"
@@ -42,6 +43,14 @@ type PodcastFeedCard struct {
AutoDownload string
AutoDownloadCount int
Subscribed bool
// Continue aggregation (plan 2.5): the episode the user is midway
// through, most recent first across feeds.
HasContinue bool
ContinueTitle string
ContinueEpisode int
ContinuePercent float64
ContinueLastRead time.Time // section sort key (most recent first)
}
// DisplayTitle renders the subscription display name when set (D9).
@@ -138,7 +147,11 @@ func FormatEpisodeSize(bytes int64) string {
// PodcastsPageData carries everything the /podcasts page renders.
type PodcastsPageData struct {
Feeds []PodcastFeedCard
// ContinueFeeds: the in-progress subset, most recently listened
// first (plan 2.5) — split in the page builder (templ bodies can't
// declare locals).
ContinueFeeds []PodcastFeedCard
Feeds []PodcastFeedCard
Libraries []LibraryData // podcasts-type libraries (add-feed targets)
Facets map[string][]string
Query string
@@ -168,3 +181,20 @@ func podcastTimeOrEmpty(t time.Time, valid bool, tz string) string {
}
return utils.FormatTimeInTimezone(t, tz)
}
// PodcastContinueFeeds returns the in-progress feeds, most recently
// listened first (plan 2.5). ≥95% progress counts as finished and is
// excluded (the continue query already applies that bound).
func PodcastContinueFeeds(feeds []PodcastFeedCard) []PodcastFeedCard {
out := make([]PodcastFeedCard, 0, len(feeds))
for _, f := range feeds {
if f.HasContinue {
out = append(out, f)
}
}
sort.SliceStable(out, func(i, j int) bool {
return out[i].ContinueLastRead.After(out[j].ContinueLastRead)
})
return out
}