feat(podcasts): queue context source + player order control — item 2.2

Foundation rule R2 (§10): the queue context is the only queue, and
every producer emits the same shape. PodcastSequenceContext gains
source (feed|collection), feed_id, and order; the feed producer sets
them now — the collection producer (2.3) slots into the same shape
without the player ever special-casing beyond showing/hiding controls.

The player header gains the FEED-mode order control next to the
"Ep N/M" counter: a dropdown (native/reverse/oldest/newest) plus a
one-click reverse button. Both PUT the existing per-user feed order
override — the SAME state the feed page writes, so flipping it in the
player updates the feed page's dropdown too. The control renders only
when source=feed: a collection queue's order is curated content
(R1), never a listening preference. Behavior is hx-put +
hx-swap="none" (response discarded) with a module-level
htmx:afterRequest listener in audio.ts reloading the page — the
server re-renders prev/next/index in the new order; templ decides
what it looks like, JS only triggers the round trip (CSP-clean, no
inline handlers).

Pinned by TestPodcastPlayerOrderControl: the config JSON carries
source/feed_id/order/episode_index; the control renders feed-mode;
an order override re-renders with the dropdown reflecting it (data-
json pins must match templ's " attribute escaping).
This commit is contained in:
John O'Keefe committed 2026-10-30 15:52:14 -04:00
1 parent 7bf0b1257d
commit a90e7debfd
6 files changed
+279 -81

No files matched your search

+32
View File
@@ -116,6 +116,38 @@ templ AudioReader(user User, metadata ReaderMetadata) {
<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>
<!-- Order control (item 2.2, §10 R2): FEED-mode only — a
collection queue's order is curated content, never a
preference (hidden in collection mode). PUTs the same
per-user override the feed page writes; hx-swap="none"
discards the response and web/src/audio.ts reloads so
the server re-renders prev/next/index in the new order. -->
if metadata.PodcastSequence.Source == "feed" && metadata.PodcastSequence.FeedID != "" {
<select
id="player-order-select"
name="order"
aria-label="Episode order"
class="input w-auto py-0.5 text-xs ml-2 cursor-pointer"
hx-put={ "/api/podcasts/feeds/" + metadata.PodcastSequence.FeedID + "/order" }
hx-swap="none"
>
<option value="native" selected?={ metadata.PodcastSequence.Order == "native" }>Feed order</option>
<option value="reverse" selected?={ metadata.PodcastSequence.Order == "reverse" }>Feed order (reversed)</option>
<option value="oldest" selected?={ metadata.PodcastSequence.Order == "oldest" }>Oldest first (pub date)</option>
<option value="newest" selected?={ metadata.PodcastSequence.Order == "newest" }>Newest first (pub date)</option>
</select>
<button
type="button"
aria-label="Reverse episode order"
title="Reverse episode order (start from the feed's beginning)"
class="rounded-full p-1.5 hover:bg-surface-hover transition-colors"
hx-put={ "/api/podcasts/feeds/" + metadata.PodcastSequence.FeedID + "/order" }
hx-vals='{"order": "reverse"}'
hx-swap="none"
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color: var(--text-secondary)"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>
</button>
}
}
</span>
<span class="inline-flex items-center gap-2">