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.
311 lines
12 KiB
Templ
311 lines
12 KiB
Templ
package templates
|
|
|
|
// Feed detail page (plan task 1.9, D5/D7/D8/D19): hero (artwork,
|
|
// metadata, admin cluster), action row (Download All, Refresh, order
|
|
// override, subscription policy, admin delete), and the episode list
|
|
// with the download lifecycle inline. The episode list is a self-
|
|
// refreshing htmx partial: it re-fetches on the podcast:changed event
|
|
// AND polls every 4s ONLY while downloads are active (server decides —
|
|
// the polling attributes render only then).
|
|
|
|
templ PodcastDetailPage(user User, data PodcastDetailData) {
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8"/>
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
|
<title>{ data.Feed.DisplayTitle() } - Bookhoard</title>
|
|
<link href="/static/style.css" rel="stylesheet"/>
|
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
|
</head>
|
|
<body x-data="podcastDetail" class={ "theme-" + user.Theme }>
|
|
@Header(user, "/podcasts")
|
|
<main class="mx-auto container px-4 sm:px-6 lg:px-8 py-8">
|
|
<a href="/podcasts" class="inline-flex items-center gap-1 text-sm mb-4 hover:underline" style="color: var(--text-secondary)">
|
|
@Icon("arrow-left", "h-4 w-4")
|
|
<span>Podcasts</span>
|
|
</a>
|
|
|
|
<!-- Hero -->
|
|
<div class="flex flex-col sm:flex-row gap-6 mb-8">
|
|
<div class="h-44 w-44 rounded-xl shrink-0 overflow-hidden grid place-items-center" style="background-color: var(--surface-secondary); color: var(--text-secondary);">
|
|
if data.Feed.ArtworkPath != "" {
|
|
<img
|
|
src={ "/uploads/library-" + data.Feed.LibraryID + "/" + data.Feed.ArtworkPath }
|
|
alt={ "Cover for " + data.Feed.DisplayTitle() }
|
|
class="h-full w-full object-cover"
|
|
data-image-fallback
|
|
/>
|
|
} else {
|
|
@Icon("podcast", "h-12 w-12")
|
|
}
|
|
</div>
|
|
<div class="min-w-0 flex-1">
|
|
<div class="flex flex-wrap items-center gap-2 mb-1">
|
|
<span class="badge" style="background-color: var(--accent-muted); color: var(--accent)">{ data.Feed.TypeLabel() }</span>
|
|
if data.Feed.IsComplete {
|
|
<span class="badge" style="background-color: var(--surface-secondary); color: var(--text-secondary)">complete</span>
|
|
}
|
|
if data.Feed.Language != "" {
|
|
<span class="badge" style="background-color: var(--surface-secondary); color: var(--text-secondary)">{ data.Feed.Language }</span>
|
|
}
|
|
</div>
|
|
<h1 class="text-2xl font-bold tracking-tight mb-1" style="color: var(--text-primary)">{ data.Feed.DisplayTitle() }</h1>
|
|
if data.Feed.Host != "" || data.Feed.Publisher != "" {
|
|
<p class="text-sm mb-2" style="color: var(--text-secondary)">
|
|
if data.Feed.Host != "" {
|
|
{ "Host: " + data.Feed.Host }
|
|
}
|
|
if data.Feed.Publisher != "" && data.Feed.Publisher != data.Feed.Host {
|
|
{ " · Publisher: " + data.Feed.Publisher }
|
|
}
|
|
</p>
|
|
}
|
|
if data.Feed.Description != "" {
|
|
<p class="text-sm line-clamp-3 mb-2" style="color: var(--text-secondary)">{ data.Feed.Description }</p>
|
|
}
|
|
if len(data.Feed.Tags) > 0 {
|
|
<div class="flex flex-wrap gap-1.5 mb-2">
|
|
for _, t := range data.Feed.Tags {
|
|
<span class="badge" style="background-color: var(--surface-secondary); color: var(--text-secondary)">{ t }</span>
|
|
}
|
|
</div>
|
|
}
|
|
<p class="text-xs" style="color: var(--text-secondary)">
|
|
{ podcastInt(data.Feed.DownloadedN) + " of " + podcastInt(data.Feed.EpisodeTotal) + " episodes downloaded" }
|
|
if data.Feed.LastFetched != "" {
|
|
{ " · last checked " + data.Feed.LastFetched }
|
|
}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Actions -->
|
|
<div class="flex flex-wrap items-center gap-2 mb-6">
|
|
<button
|
|
class="btn btn-primary"
|
|
hx-post={ "/api/podcasts/feeds/" + data.Feed.ID + "/episodes/all/download" }
|
|
hx-swap="none"
|
|
title="Queue every episode that is not downloaded yet"
|
|
>
|
|
@Icon("save", "h-4 w-4")
|
|
<span>Download All</span>
|
|
</button>
|
|
<button
|
|
class="btn btn-secondary"
|
|
hx-post={ "/api/podcasts/feeds/" + data.Feed.ID + "/refresh" }
|
|
hx-swap="none"
|
|
title="Check the feed for new episodes now"
|
|
>
|
|
@Icon("refresh", "h-4 w-4")
|
|
<span>Refresh</span>
|
|
</button>
|
|
|
|
<!-- Order override (D5): per-user; refreshes the episode list -->
|
|
<label class="text-sm ml-2" for="podcast-order-select" style="color: var(--text-secondary)">Order</label>
|
|
<select
|
|
id="podcast-order-select"
|
|
name="order"
|
|
class="input w-auto py-1.5 cursor-pointer"
|
|
hx-put={ "/api/podcasts/feeds/" + data.Feed.ID + "/order" }
|
|
hx-swap="none"
|
|
>
|
|
<option value="native" selected={ data.Order == "native" }>Feed order</option>
|
|
<option value="oldest" selected={ data.Order == "oldest" }>Oldest first</option>
|
|
<option value="newest" selected={ data.Order == "newest" }>Newest first</option>
|
|
</select>
|
|
|
|
<!-- Serial suggestion (D5): one-click, never silent -->
|
|
if data.IsSerial && data.Order == "native" {
|
|
<button
|
|
class="btn btn-secondary text-sm"
|
|
hx-put={ "/api/podcasts/feeds/" + data.Feed.ID + "/order" }
|
|
hx-vals='{"order": "oldest"}'
|
|
hx-swap="none"
|
|
title="Serial feed: publishers list newest first — listen oldest-first"
|
|
>
|
|
<span>Serial? Listen oldest-first</span>
|
|
</button>
|
|
}
|
|
|
|
<!-- Auto-download policy (D6) -->
|
|
<label class="text-sm ml-2" for="podcast-autodl-select" style="color: var(--text-secondary)">Auto-download</label>
|
|
<select
|
|
id="podcast-autodl-select"
|
|
name="auto_download"
|
|
class="input w-auto py-1.5 cursor-pointer"
|
|
hx-put={ "/api/podcasts/feeds/" + data.Feed.ID + "/subscription" }
|
|
hx-swap="none"
|
|
>
|
|
<option value="off" selected={ data.Feed.AutoDownload == "off" || data.Feed.AutoDownload == "" }>Off</option>
|
|
<option value="all" selected={ data.Feed.AutoDownload == "all" }>All episodes</option>
|
|
<option value="newest" selected={ data.Feed.AutoDownload == "newest" }>Newest</option>
|
|
</select>
|
|
if data.Feed.AutoDownload == "newest" {
|
|
<input
|
|
type="number"
|
|
id="podcast-autodl-count"
|
|
name="auto_download_count"
|
|
min="1"
|
|
max="100"
|
|
value={ podcastInt(data.Feed.AutoDownloadCount) }
|
|
class="input w-20 py-1.5"
|
|
aria-label="Newest N episodes"
|
|
/>
|
|
}
|
|
|
|
<!-- Unsubscribe: files retained (D6) -->
|
|
<button
|
|
class="btn btn-secondary ml-auto"
|
|
hx-delete={ "/api/podcasts/feeds/" + data.Feed.ID + "/subscription" }
|
|
hx-confirm="Unsubscribe from this feed? Episodes and files are kept."
|
|
hx-swap="none"
|
|
>
|
|
<span>Unsubscribe</span>
|
|
</button>
|
|
|
|
if data.IsAdmin {
|
|
<button
|
|
class="btn btn-secondary"
|
|
style="color: var(--status-danger);"
|
|
hx-delete={ "/api/podcasts/feeds/" + data.Feed.ID }
|
|
hx-confirm="ADMIN: remove this feed for everyone? Episode rows AND downloaded files are deleted."
|
|
hx-swap="none"
|
|
>
|
|
@Icon("trash", "h-4 w-4")
|
|
<span>Remove Feed</span>
|
|
</button>
|
|
}
|
|
</div>
|
|
|
|
<!-- Gap banner (D8): middle episodes missing -->
|
|
if data.GapCount > 0 {
|
|
<div class="mb-4 card p-4 flex items-center gap-3" style="border-color: var(--status-warning, #d97706);">
|
|
@Icon("circle", "h-5 w-5")
|
|
<div>
|
|
<p class="font-semibold text-sm" style="color: var(--text-primary)">{ podcastInt(data.GapCount) + " episode(s) not downloaded in the middle of this feed" }</p>
|
|
<p class="text-xs" style="color: var(--text-secondary)">Playback skips them and keeps going — use Download All to fill the gaps.</p>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
<!-- Episode list (self-refreshing htmx partial) -->
|
|
@PodcastEpisodeList(data)
|
|
</main>
|
|
</body>
|
|
</html>
|
|
}
|
|
|
|
// PodcastEpisodeList is the refresh unit: re-fetched on the
|
|
// podcast:changed event (download/refresh/order endpoints fire it) and
|
|
// polled every 4s only while the server says downloads are active.
|
|
templ PodcastEpisodeList(data PodcastDetailData) {
|
|
<div
|
|
id="episode-list"
|
|
hx-get={ "/podcasts/" + data.Feed.ID + "/episodes" }
|
|
hx-trigger="podcast:changed from:body"
|
|
hx-swap="outerHTML"
|
|
>
|
|
<h2 class="text-lg font-bold mb-3" style="color: var(--text-primary)">Episodes</h2>
|
|
if len(data.Episodes) == 0 {
|
|
<div class="card p-6 text-center text-sm" style="color: var(--text-secondary)">
|
|
No episodes yet — hit Refresh to check the feed.
|
|
</div>
|
|
} else {
|
|
<div class="flex flex-col gap-2">
|
|
for _, ep := range data.Episodes {
|
|
@PodcastEpisodeRowView(ep)
|
|
}
|
|
</div>
|
|
}
|
|
</div>
|
|
}
|
|
|
|
// PodcastEpisodeListPolling renders the poll-active variant of the list
|
|
// container (same content; the hx-trigger adds the 4s poll + load). The
|
|
// SSR episode route picks one of the two by download activity.
|
|
templ PodcastEpisodeListPolling(data PodcastDetailData) {
|
|
<div
|
|
id="episode-list"
|
|
hx-get={ "/podcasts/" + data.Feed.ID + "/episodes" }
|
|
hx-trigger="podcast:changed from:body, every 4s"
|
|
hx-swap="outerHTML"
|
|
>
|
|
<h2 class="text-lg font-bold mb-3" style="color: var(--text-primary)">Episodes</h2>
|
|
<div class="flex flex-col gap-2">
|
|
for _, ep := range data.Episodes {
|
|
@PodcastEpisodeRowView(ep)
|
|
}
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
templ PodcastEpisodeRowView(ep PodcastEpisodeRow) {
|
|
<div class="card p-3 flex items-center gap-3">
|
|
<span class="text-xs font-mono w-10 shrink-0 text-right" style="color: var(--text-secondary)">
|
|
{ podcastInt(ep.Position) }
|
|
</span>
|
|
<div class="min-w-0 flex-1">
|
|
<div class="flex items-center gap-2">
|
|
<a href={ ep.PlayURL } class="font-medium text-sm hover:underline line-clamp-1" style="color: var(--text-primary)">{ ep.Title }</a>
|
|
if ep.DownloadState == "failed" {
|
|
<span class="badge shrink-0" style="background-color: color-mix(in srgb, var(--status-danger) 15%, transparent); color: var(--status-danger);">{ ep.StateBadge() }</span>
|
|
} else if ep.DownloadState == "queued" || ep.DownloadState == "downloading" {
|
|
<span class="badge shrink-0 animate-pulse" style="background-color: var(--accent-muted); color: var(--accent);">{ ep.StateBadge() }</span>
|
|
} else if ep.DownloadState == "pending" {
|
|
<span class="badge shrink-0" style="background-color: var(--surface-secondary); color: var(--text-secondary);">{ ep.StateBadge() }</span>
|
|
}
|
|
</div>
|
|
if ep.DownloadError != "" {
|
|
<p class="text-xs mt-0.5 line-clamp-2" style="color: var(--status-danger)">{ ep.DownloadError }</p>
|
|
}
|
|
<p class="text-xs mt-0.5" style="color: var(--text-secondary)">
|
|
{ ep.Date }
|
|
if ep.Duration != "" {
|
|
{ " · " + ep.Duration }
|
|
}
|
|
if ep.Size != "" {
|
|
{ " · " + ep.Size }
|
|
}
|
|
</p>
|
|
</div>
|
|
if ep.Downloaded {
|
|
<a href={ ep.PlayURL } class="btn btn-secondary shrink-0" title="Play — chains into the next downloaded episode (append ?single=1 to the URL to play just this one)">
|
|
@Icon("book-open", "h-4 w-4")
|
|
<span>Play</span>
|
|
</a>
|
|
} else if ep.DownloadState == "queued" || ep.DownloadState == "downloading" {
|
|
<span class="btn btn-secondary shrink-0 pointer-events-none opacity-60" aria-busy="true">
|
|
@Icon("refresh", "h-4 w-4")
|
|
</span>
|
|
} else {
|
|
<!-- pending AND failed: (re)download — same endpoint, idempotent -->
|
|
<button
|
|
class={ "btn shrink-0", templ.KV("btn-secondary", ep.DownloadState != "failed"), templ.KV("btn-primary", ep.DownloadState == "failed") }
|
|
hx-post={ "/api/podcasts/feeds/" + ep.FeedID + "/episodes/" + ep.ID + "/download" }
|
|
hx-swap="none"
|
|
title={ downloadButtonTitle(ep.DownloadState) }
|
|
>
|
|
@Icon("save", "h-4 w-4")
|
|
<span>{ downloadButtonLabel(ep.DownloadState) }</span>
|
|
</button>
|
|
}
|
|
</div>
|
|
}
|
|
|
|
// downloadButtonLabel: pending → Download; failed → Retry (the reason
|
|
// text above it explains what went wrong — D8/D19 inline failure UX).
|
|
func downloadButtonLabel(state string) string {
|
|
if state == "failed" {
|
|
return "Retry"
|
|
}
|
|
return "Download"
|
|
}
|
|
|
|
func downloadButtonTitle(state string) string {
|
|
if state == "failed" {
|
|
return "Retry the download"
|
|
}
|
|
return "Download this episode"
|
|
}
|