feat(podcasts): real-time episode list — byte progress bars, direct swaps (issues #1/#3)
WHY: downloads needed manual refreshes to see progress; the 204 + HX-Trigger event indirection missed its listener, and the worker buffered whole enclosures so no progress existed to show. - Worker streams enclosures to disk through a counting loop (64KiB chunks, scheme/redirect/size guards preserved) with an in-memory per-episode progress snapshot (received/total; total from Content-Length, seeded by the feed's declared length). Deliberately transient — the DB row state remains the durable truth. - Episode-list renderer seam: the router injects a closure into PodcastHandler (handlers can't import templates — MediaDetail cycle) so htmx download/retry/Download All/refresh/order/policy actions answer with the REFRESHED partial directly (200, hx-target= #episode-list) — no events to miss. Non-HX callers keep JSON; tests without a renderer keep 204+trigger. - Polling tightens to 2s while downloads are active; downloading rows render a percentage bar + MB counter (quote-free inline style per templ gotcha #8) or an indeterminate bar when the server sends no length. - Issue #3: Download All (button AND the add-feed checkbox) flips the CALLER's subscription policy to 'all' — per-user, never global (D6: files shared, policies not) — and the auto-download select out-of-band-swaps so the dropdown shows what the user is getting. They can turn it down afterwards; the flip is honest communication, not coercion (user-approved 2026-10-29). PINs: Download All with HX answers 200 + episode-list + oob select and flips newest → all; add-feed download_all sets policy all.
This commit is contained in:
1 parent
ffc80f7ba7
commit
30766020c8
8 files changed
+545
-57
No files matched your search
+149
-25
@@ -8,6 +8,8 @@ package templates
|
||||
// AND polls every 4s ONLY while downloads are active (server decides —
|
||||
// the polling attributes render only then).
|
||||
|
||||
import "fmt"
|
||||
|
||||
templ PodcastDetailPage(user User, data PodcastDetailData) {
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
@@ -85,7 +87,8 @@ templ PodcastDetailPage(user User, data PodcastDetailData) {
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
hx-post={ "/api/podcasts/feeds/" + data.Feed.ID + "/episodes/all/download" }
|
||||
hx-swap="none"
|
||||
hx-target="#episode-list"
|
||||
hx-swap="outerHTML"
|
||||
title="Queue every episode that is not downloaded yet"
|
||||
>
|
||||
@Icon("save", "h-4 w-4")
|
||||
@@ -94,7 +97,8 @@ templ PodcastDetailPage(user User, data PodcastDetailData) {
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
hx-post={ "/api/podcasts/feeds/" + data.Feed.ID + "/refresh" }
|
||||
hx-swap="none"
|
||||
hx-target="#episode-list"
|
||||
hx-swap="outerHTML"
|
||||
title="Check the feed for new episodes now"
|
||||
>
|
||||
@Icon("refresh", "h-4 w-4")
|
||||
@@ -108,7 +112,8 @@ templ PodcastDetailPage(user User, data PodcastDetailData) {
|
||||
name="order"
|
||||
class="input w-auto py-1.5 cursor-pointer"
|
||||
hx-put={ "/api/podcasts/feeds/" + data.Feed.ID + "/order" }
|
||||
hx-swap="none"
|
||||
hx-target="#episode-list"
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
<option value="native" selected={ data.Order == "native" }>Feed order</option>
|
||||
<option value="oldest" selected={ data.Order == "oldest" }>Oldest first</option>
|
||||
@@ -121,7 +126,8 @@ templ PodcastDetailPage(user User, data PodcastDetailData) {
|
||||
class="btn btn-secondary text-sm"
|
||||
hx-put={ "/api/podcasts/feeds/" + data.Feed.ID + "/order" }
|
||||
hx-vals='{"order": "oldest"}'
|
||||
hx-swap="none"
|
||||
hx-target="#episode-list"
|
||||
hx-swap="outerHTML"
|
||||
title="Serial feed: publishers list newest first — listen oldest-first"
|
||||
>
|
||||
<span>Serial? Listen oldest-first</span>
|
||||
@@ -130,17 +136,7 @@ templ PodcastDetailPage(user User, data PodcastDetailData) {
|
||||
|
||||
<!-- 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>
|
||||
@PodcastAutoDownloadSelect(data.Feed)
|
||||
if data.Feed.AutoDownload == "newest" {
|
||||
<input
|
||||
type="number"
|
||||
@@ -154,15 +150,33 @@ templ PodcastDetailPage(user User, data PodcastDetailData) {
|
||||
/>
|
||||
}
|
||||
|
||||
<!-- 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>
|
||||
<!-- Unsubscribe (issues #4/#7): the sole subscriber who is
|
||||
an admin gets the three-choice modal (unsubscribe /
|
||||
remove feed / cancel); everyone else gets a plain
|
||||
confirm — file retention is admin knowledge only. -->
|
||||
if data.IsAdmin && data.SubscriberCount <= 1 {
|
||||
<button class="btn btn-secondary ml-auto" @click="unsubOpen = true">
|
||||
<span>Unsubscribe</span>
|
||||
</button>
|
||||
} else if data.IsAdmin {
|
||||
<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>
|
||||
} else {
|
||||
<button
|
||||
class="btn btn-secondary ml-auto"
|
||||
hx-delete={ "/api/podcasts/feeds/" + data.Feed.ID + "/subscription" }
|
||||
hx-confirm="Unsubscribe from this feed?"
|
||||
hx-swap="none"
|
||||
>
|
||||
<span>Unsubscribe</span>
|
||||
</button>
|
||||
}
|
||||
|
||||
if data.IsAdmin {
|
||||
<button
|
||||
@@ -192,6 +206,7 @@ templ PodcastDetailPage(user User, data PodcastDetailData) {
|
||||
<!-- Episode list (self-refreshing htmx partial) -->
|
||||
@PodcastEpisodeList(data)
|
||||
</main>
|
||||
@PodcastUnsubscribeModal(data)
|
||||
</body>
|
||||
</html>
|
||||
}
|
||||
@@ -228,7 +243,7 @@ templ PodcastEpisodeListPolling(data PodcastDetailData) {
|
||||
<div
|
||||
id="episode-list"
|
||||
hx-get={ "/podcasts/" + data.Feed.ID + "/episodes" }
|
||||
hx-trigger="podcast:changed from:body, every 4s"
|
||||
hx-trigger="podcast:changed from:body, every 2s"
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
<h2 class="text-lg font-bold mb-3" style="color: var(--text-primary)">Episodes</h2>
|
||||
@@ -259,6 +274,23 @@ templ PodcastEpisodeRowView(ep PodcastEpisodeRow) {
|
||||
if ep.DownloadError != "" {
|
||||
<p class="text-xs mt-0.5 line-clamp-2" style="color: var(--status-danger)">{ ep.DownloadError }</p>
|
||||
}
|
||||
if ep.DownloadState == "downloading" && ep.ProgressKnown {
|
||||
<div class="mt-1.5">
|
||||
if ep.ProgressTotal > 0 {
|
||||
<div class="h-1.5 w-full rounded-full overflow-hidden" style="background-color: var(--surface-secondary);">
|
||||
<div class="h-full rounded-full transition-all" style={ progressWidth(ep.ProgressPct) + " background-color: var(--accent);" }></div>
|
||||
</div>
|
||||
<p class="text-xs mt-0.5 tabular-nums" style="color: var(--text-secondary)">
|
||||
{ FormatEpisodeSize(ep.ProgressReceived) + " / " + FormatEpisodeSize(ep.ProgressTotal) }
|
||||
</p>
|
||||
} else {
|
||||
<div class="h-1.5 w-full rounded-full overflow-hidden animate-pulse" style="background-color: var(--surface-secondary);">
|
||||
<div class="h-full w-1/3 rounded-full" style="background-color: var(--accent);"></div>
|
||||
</div>
|
||||
<p class="text-xs mt-0.5 tabular-nums" style="color: var(--text-secondary)">{ FormatEpisodeSize(ep.ProgressReceived) }</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<p class="text-xs mt-0.5" style="color: var(--text-secondary)">
|
||||
{ ep.Date }
|
||||
if ep.Duration != "" {
|
||||
@@ -302,9 +334,101 @@ func downloadButtonLabel(state string) string {
|
||||
return "Download"
|
||||
}
|
||||
|
||||
// progressWidth renders the bar width as a quote-free inline style
|
||||
// fragment (templ gotcha #8: style attribute values must not carry
|
||||
// quote characters).
|
||||
func progressWidth(pct float64) string {
|
||||
if pct < 1 {
|
||||
pct = 1
|
||||
}
|
||||
if pct > 100 {
|
||||
pct = 100
|
||||
}
|
||||
return fmt.Sprintf("width: %.1f%%;", pct)
|
||||
}
|
||||
|
||||
func downloadButtonTitle(state string) string {
|
||||
if state == "failed" {
|
||||
return "Retry the download"
|
||||
}
|
||||
return "Download this episode"
|
||||
}
|
||||
|
||||
|
||||
// PodcastAutoDownloadSelect is the policy dropdown (issue #3): extracted
|
||||
// so the Download All response can out-of-band-swap it after flipping
|
||||
// the caller's policy — the dropdown must reflect what the user gets.
|
||||
templ PodcastAutoDownloadSelect(feed PodcastFeedCard) {
|
||||
<select
|
||||
id="podcast-autodl-select"
|
||||
name="auto_download"
|
||||
class="input w-auto py-1.5 cursor-pointer"
|
||||
hx-put={ "/api/podcasts/feeds/" + feed.ID + "/subscription" }
|
||||
hx-target="#episode-list"
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
<option value="off" selected={ feed.AutoDownload == "off" || feed.AutoDownload == "" }>Off</option>
|
||||
<option value="all" selected={ feed.AutoDownload == "all" }>All episodes</option>
|
||||
<option value="newest" selected={ feed.AutoDownload == "newest" }>Newest</option>
|
||||
</select>
|
||||
}
|
||||
|
||||
// PodcastAutoDownloadSelectOOB is the out-of-band variant: same element
|
||||
// id + hx-swap-oob so htmx replaces the action-row select in place.
|
||||
templ PodcastAutoDownloadSelectOOB(feed PodcastFeedCard) {
|
||||
<select
|
||||
id="podcast-autodl-select"
|
||||
name="auto_download"
|
||||
hx-swap-oob="true"
|
||||
class="input w-auto py-1.5 cursor-pointer"
|
||||
hx-put={ "/api/podcasts/feeds/" + feed.ID + "/subscription" }
|
||||
hx-target="#episode-list"
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
<option value="off" selected={ feed.AutoDownload == "off" || feed.AutoDownload == "" }>Off</option>
|
||||
<option value="all" selected={ feed.AutoDownload == "all" }>All episodes</option>
|
||||
<option value="newest" selected={ feed.AutoDownload == "newest" }>Newest</option>
|
||||
</select>
|
||||
}
|
||||
|
||||
// PodcastUnsubscribeModal is the last-subscriber admin choice (issue
|
||||
// #7): unsubscribe (files kept — the D6 default), remove feed + files
|
||||
// entirely, or cancel. Reachable only for admins (the button that opens
|
||||
// it renders for admins alone).
|
||||
templ PodcastUnsubscribeModal(data PodcastDetailData) {
|
||||
<div
|
||||
id="podcast-unsub-modal"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto p-4"
|
||||
style="background-color: var(--surface-overlay);"
|
||||
x-show="unsubOpen"
|
||||
x-cloak
|
||||
@click.self="unsubOpen = false"
|
||||
>
|
||||
<div class="card w-full max-w-md" style="box-shadow: var(--shadow-pop);">
|
||||
<div class="p-6 border-b" style="border-color: var(--border);">
|
||||
<h2 class="text-lg font-bold" style="color: var(--text-primary)">Unsubscribe from { data.Feed.DisplayTitle() }?</h2>
|
||||
<p class="text-sm mt-1" style="color: var(--text-secondary)">You are the only subscriber.</p>
|
||||
</div>
|
||||
<div class="p-6 flex flex-col gap-2">
|
||||
<button
|
||||
class="btn btn-secondary w-full"
|
||||
hx-delete={ "/api/podcasts/feeds/" + data.Feed.ID + "/subscription" }
|
||||
hx-swap="none"
|
||||
@click="unsubOpen = false"
|
||||
>
|
||||
<span>Unsubscribe — files kept</span>
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-secondary w-full"
|
||||
style="color: var(--status-danger);"
|
||||
hx-delete={ "/api/podcasts/feeds/" + data.Feed.ID }
|
||||
hx-swap="none"
|
||||
@click="unsubOpen = false"
|
||||
>
|
||||
<span>Remove feed — delete episodes and files</span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary w-full" @click="unsubOpen = false">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -91,10 +91,23 @@ type PodcastEpisodeRow struct {
|
||||
Size string // human MB/GB when known
|
||||
PlayURL string // reader URL (audio reader serves episodes like audiobooks)
|
||||
CoverImage string
|
||||
ProgressPct float64
|
||||
Downloaded bool
|
||||
DownloadState string // pending|queued|downloading|downloaded|failed
|
||||
DownloadError string
|
||||
|
||||
// Playback progress (reading_progress; the row badge) — reuses the
|
||||
// ProgressPct field below when the row is downloading? No: keep the
|
||||
// two concerns separate (issue #1).
|
||||
ListenPct float64
|
||||
|
||||
// Live byte progress for rows in the downloading state (issue #1):
|
||||
// the worker's in-flight snapshot. ProgressKnown=false renders the
|
||||
// indeterminate bar; ProgressPct (0-100) drives the width when the
|
||||
// total is authoritative.
|
||||
ProgressKnown bool
|
||||
ProgressPct float64
|
||||
ProgressReceived int64
|
||||
ProgressTotal int64
|
||||
}
|
||||
|
||||
// StateBadge renders the canonical state label.
|
||||
@@ -172,6 +185,10 @@ type PodcastDetailData struct {
|
||||
Order string // native | oldest | newest (the caller's override)
|
||||
IsAdmin bool
|
||||
IsSerial bool // serial/audiobook feeds get the oldest-first suggestion (D5)
|
||||
// SubscriberCount (issue #7): drives the last-subscriber
|
||||
// unsubscribe flow — the sole subscriber who is an admin gets the
|
||||
// three-choice modal (unsubscribe / remove feed / cancel).
|
||||
SubscriberCount int
|
||||
}
|
||||
|
||||
// podcastInt renders an int for templates.
|
||||
|
||||
Reference in new issue
Block a user