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:
1 parent
2235b00c59
commit
28420d56ed
14 files changed
+740
-174
No files matched your search
@@ -7,7 +7,32 @@ package templates
|
||||
// <audio> element streams the m4b through the existing cookie-authed
|
||||
// Range-serving /uploads path.
|
||||
|
||||
import "fmt"
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// AudioReaderConfigJSON builds the reader's body[data-json] config: the
|
||||
// chapter/progress endpoints plus, for podcast episodes, the sequence
|
||||
// context (chain playback — plan D7). Books render the same JSON shape
|
||||
// WITHOUT the sequence key, so the reader code path is identical.
|
||||
func AudioReaderConfigJSON(metadata ReaderMetadata) string {
|
||||
cfg := map[string]any{
|
||||
"chapterEndpoint": "/api/media-items/" + metadata.MediaItemID + "/chapters",
|
||||
"progressEndpoint": "/api/media-items/" + metadata.MediaItemID + "/progress",
|
||||
}
|
||||
if metadata.PodcastSequence != nil {
|
||||
cfg["podcastSequence"] = metadata.PodcastSequence
|
||||
}
|
||||
b, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
// Unreachable for these shapes; degrade to the book-only config.
|
||||
return fmt.Sprintf(`{"chapterEndpoint":"%s","progressEndpoint":"%s"}`,
|
||||
"/api/media-items/"+metadata.MediaItemID+"/chapters",
|
||||
"/api/media-items/"+metadata.MediaItemID+"/progress")
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
templ AudioReader(user User, metadata ReaderMetadata) {
|
||||
<!DOCTYPE html>
|
||||
@@ -41,9 +66,7 @@ templ AudioReader(user User, metadata ReaderMetadata) {
|
||||
data-cover={ metadata.CoverImagePath }
|
||||
data-file-url={ metadata.FileURL }
|
||||
data-keep-awake={ metadata.KeepScreenAwake }
|
||||
data-json={ fmt.Sprintf(`{"chapterEndpoint":"%s","progressEndpoint":"%s"}`,
|
||||
"/api/media-items/"+metadata.MediaItemID+"/chapters",
|
||||
"/api/media-items/"+metadata.MediaItemID+"/progress") }
|
||||
data-json={ AudioReaderConfigJSON(metadata) }
|
||||
>
|
||||
<!-- §17 wind-down dim: the sleep ticker eases this toward 35%
|
||||
black across a countdown's final 30s; pointer-events-none
|
||||
@@ -55,18 +78,37 @@ templ AudioReader(user User, metadata ReaderMetadata) {
|
||||
<!-- Header: back to the detail page (reader-style exit) and the
|
||||
chapter launcher. Floating drawer, never in-flow (A2b). -->
|
||||
<div class="absolute top-0 inset-x-0 z-30 flex items-center justify-between px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
@click="backToDetail()"
|
||||
class="rounded-full p-2 hover:bg-surface-hover transition-colors"
|
||||
style="color: var(--text-primary);"
|
||||
aria-label="Back to book details"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M19 12H5"></path>
|
||||
<path d="m12 19-7-7 7-7"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
@click="backToDetail()"
|
||||
class="rounded-full p-2 hover:bg-surface-hover transition-colors"
|
||||
style="color: var(--text-primary);"
|
||||
aria-label="Back to book details"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M19 12H5"></path>
|
||||
<path d="m12 19-7-7 7-7"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Podcast sequence (D7): episode prev/next + "Ep N of M".
|
||||
Rendered only when the server supplied sequence context. -->
|
||||
if metadata.PodcastSequence != nil {
|
||||
<span class="text-xs tabular-nums ml-1" style="color: var(--text-secondary)">
|
||||
Ep { fmt.Sprintf("%d", metadata.PodcastSequence.EpisodeIndex) }/{ fmt.Sprintf("%d", metadata.PodcastSequence.IndexTotal) }
|
||||
</span>
|
||||
if metadata.PodcastSequence.PrevURL != "" {
|
||||
<a href={ metadata.PodcastSequence.PrevURL } class="rounded-full p-2 hover:bg-surface-hover transition-colors" style="color: var(--text-primary);" aria-label="Previous episode" title="Previous episode">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M6 5h2v14H6zM20 5v14l-11-7z"></path></svg>
|
||||
</a>
|
||||
}
|
||||
if metadata.PodcastSequence.NextURL != "" {
|
||||
<a href={ metadata.PodcastSequence.NextURL } class="rounded-full p-2 hover:bg-surface-hover transition-colors" style="color: var(--text-primary);" aria-label={ "Next episode: " + metadata.PodcastSequence.NextTitle } title={ "Next: " + metadata.PodcastSequence.NextTitle }>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M16 5h2v14h-2zM4 5v14l11-7z"></path></svg>
|
||||
</a>
|
||||
}
|
||||
}
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
Reference in new issue
Block a user