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
@@ -455,6 +455,11 @@ type Querier interface {
|
|||||||
// Get OPDS tokens by device
|
// Get OPDS tokens by device
|
||||||
GetOpdsTokensByDevice(ctx context.Context, deviceID pgtype.UUID) ([]OpdsTokens, error)
|
GetOpdsTokensByDevice(ctx context.Context, deviceID pgtype.UUID) ([]OpdsTokens, error)
|
||||||
GetPanelData(ctx context.Context, arg GetPanelDataParams) (PanelData, error)
|
GetPanelData(ctx context.Context, arg GetPanelDataParams) (PanelData, error)
|
||||||
|
// Continue aggregation (plan 2.5): the caller's IN-PROGRESS episodes
|
||||||
|
// (0 < pct < finishedThreshold) across ALL subscribed feeds, most
|
||||||
|
// recent first. Go groups per feed — the first row per feed IS the
|
||||||
|
// continue target.
|
||||||
|
GetPodcastContinueForUser(ctx context.Context, userID pgtype.UUID) ([]GetPodcastContinueForUserRow, error)
|
||||||
GetPodcastEpisode(ctx context.Context, id pgtype.UUID) (MediaItems, error)
|
GetPodcastEpisode(ctx context.Context, id pgtype.UUID) (MediaItems, error)
|
||||||
// Per-user progress for a feed's episodes (row badges + the Phase-2
|
// Per-user progress for a feed's episodes (row badges + the Phase-2
|
||||||
// continue fields). reading_progress is UNIQUE per (media_item, user).
|
// continue fields). reading_progress is UNIQUE per (media_item, user).
|
||||||
|
|||||||
@@ -9310,6 +9310,62 @@ func (q *Queries) GetPanelData(ctx context.Context, arg GetPanelDataParams) (Pan
|
|||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const GetPodcastContinueForUser = `-- name: GetPodcastContinueForUser :many
|
||||||
|
SELECT mi.source_feed_id,
|
||||||
|
mi.id,
|
||||||
|
mi.title,
|
||||||
|
mi.feed_position,
|
||||||
|
rp.percentage,
|
||||||
|
rp.last_read_at
|
||||||
|
FROM reading_progress rp
|
||||||
|
JOIN media_items mi ON mi.id = rp.media_item_id
|
||||||
|
WHERE rp.user_id = $1
|
||||||
|
AND mi.source = 'podcast_episode'
|
||||||
|
AND rp.percentage > 0
|
||||||
|
AND rp.percentage < 0.995
|
||||||
|
ORDER BY rp.last_read_at DESC
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetPodcastContinueForUserRow struct {
|
||||||
|
SourceFeedID pgtype.UUID `db:"source_feed_id" json:"source_feed_id"`
|
||||||
|
ID pgtype.UUID `db:"id" json:"id"`
|
||||||
|
Title string `db:"title" json:"title"`
|
||||||
|
FeedPosition pgtype.Int4 `db:"feed_position" json:"feed_position"`
|
||||||
|
Percentage pgtype.Float8 `db:"percentage" json:"percentage"`
|
||||||
|
LastReadAt pgtype.Timestamptz `db:"last_read_at" json:"last_read_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Continue aggregation (plan 2.5): the caller's IN-PROGRESS episodes
|
||||||
|
// (0 < pct < finishedThreshold) across ALL subscribed feeds, most
|
||||||
|
// recent first. Go groups per feed — the first row per feed IS the
|
||||||
|
// continue target.
|
||||||
|
func (q *Queries) GetPodcastContinueForUser(ctx context.Context, userID pgtype.UUID) ([]GetPodcastContinueForUserRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, GetPodcastContinueForUser, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
items := []GetPodcastContinueForUserRow{}
|
||||||
|
for rows.Next() {
|
||||||
|
var i GetPodcastContinueForUserRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.SourceFeedID,
|
||||||
|
&i.ID,
|
||||||
|
&i.Title,
|
||||||
|
&i.FeedPosition,
|
||||||
|
&i.Percentage,
|
||||||
|
&i.LastReadAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
const GetPodcastEpisode = `-- name: GetPodcastEpisode :one
|
const GetPodcastEpisode = `-- name: GetPodcastEpisode :one
|
||||||
SELECT id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts FROM media_items
|
SELECT id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts FROM media_items
|
||||||
WHERE id = $1 AND source = 'podcast_episode'
|
WHERE id = $1 AND source = 'podcast_episode'
|
||||||
|
|||||||
@@ -5009,3 +5009,22 @@ JOIN media_items mi ON mi.id = rp.media_item_id
|
|||||||
WHERE rp.user_id = $1
|
WHERE rp.user_id = $1
|
||||||
AND mi.source_feed_id = $2
|
AND mi.source_feed_id = $2
|
||||||
AND mi.source = 'podcast_episode';
|
AND mi.source = 'podcast_episode';
|
||||||
|
|
||||||
|
-- name: GetPodcastContinueForUser :many
|
||||||
|
-- Continue aggregation (plan 2.5): the caller's IN-PROGRESS episodes
|
||||||
|
-- (0 < pct < finishedThreshold) across ALL subscribed feeds, most
|
||||||
|
-- recent first. Go groups per feed — the first row per feed IS the
|
||||||
|
-- continue target.
|
||||||
|
SELECT mi.source_feed_id,
|
||||||
|
mi.id,
|
||||||
|
mi.title,
|
||||||
|
mi.feed_position,
|
||||||
|
rp.percentage,
|
||||||
|
rp.last_read_at
|
||||||
|
FROM reading_progress rp
|
||||||
|
JOIN media_items mi ON mi.id = rp.media_item_id
|
||||||
|
WHERE rp.user_id = $1
|
||||||
|
AND mi.source = 'podcast_episode'
|
||||||
|
AND rp.percentage > 0
|
||||||
|
AND rp.percentage < 0.995
|
||||||
|
ORDER BY rp.last_read_at DESC;
|
||||||
@@ -107,6 +107,18 @@ func buildPodcastsPageData(c *echo.Context, cfg *Config) (*templates.PodcastsPag
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return data, err
|
return data, err
|
||||||
}
|
}
|
||||||
|
// Continue aggregation (plan 2.5): first in-progress episode per
|
||||||
|
// feed (the query is already most-recent-first).
|
||||||
|
continueByFeed := make(map[string]*database.GetPodcastContinueForUserRow)
|
||||||
|
if continueRows, err := cfg.Queries.GetPodcastContinueForUser(ctx, user.ID); err == nil {
|
||||||
|
for i := range continueRows {
|
||||||
|
row := &continueRows[i]
|
||||||
|
key := uuid.UUID(row.SourceFeedID.Bytes).String()
|
||||||
|
if _, ok := continueByFeed[key]; !ok {
|
||||||
|
continueByFeed[key] = row
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
for i := range feeds {
|
for i := range feeds {
|
||||||
row := &feeds[i]
|
row := &feeds[i]
|
||||||
total, _ := cfg.Queries.CountPodcastEpisodesForFeed(ctx, row.ID)
|
total, _ := cfg.Queries.CountPodcastEpisodesForFeed(ctx, row.ID)
|
||||||
@@ -137,6 +149,14 @@ func buildPodcastsPageData(c *echo.Context, cfg *Config) (*templates.PodcastsPag
|
|||||||
AutoDownloadCount: int(row.AutoDownloadCount.Int32),
|
AutoDownloadCount: int(row.AutoDownloadCount.Int32),
|
||||||
Subscribed: true,
|
Subscribed: true,
|
||||||
})
|
})
|
||||||
|
if cont := continueByFeed[uuid.UUID(row.ID.Bytes).String()]; cont != nil {
|
||||||
|
card := &data.Feeds[len(data.Feeds)-1]
|
||||||
|
card.HasContinue = true
|
||||||
|
card.ContinueTitle = cont.Title
|
||||||
|
card.ContinueEpisode = int(cont.FeedPosition.Int32)
|
||||||
|
card.ContinuePercent = cont.Percentage.Float64
|
||||||
|
card.ContinueLastRead = cont.LastReadAt.Time
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Facet values from the unfiltered subscription list (D18).
|
// Facet values from the unfiltered subscription list (D18).
|
||||||
@@ -178,6 +198,7 @@ func buildPodcastsPageData(c *echo.Context, cfg *Config) (*templates.PodcastsPag
|
|||||||
}
|
}
|
||||||
data.Feeds = filtered
|
data.Feeds = filtered
|
||||||
data.Query = q
|
data.Query = q
|
||||||
|
data.ContinueFeeds = templates.PodcastContinueFeeds(data.Feeds)
|
||||||
return data, nil
|
return data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
package router
|
package router
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bookhoard/internal/database"
|
||||||
"bookhoard/internal/handlers"
|
"bookhoard/internal/handlers"
|
||||||
"bookhoard/internal/services"
|
"bookhoard/internal/services"
|
||||||
"bookhoard/internal/sync"
|
"bookhoard/internal/sync"
|
||||||
"bookhoard/internal/utils"
|
"bookhoard/internal/utils"
|
||||||
"bookhoard/templates"
|
"bookhoard/templates"
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
@@ -102,6 +106,18 @@ func registerReaderRoutes(cfg *Config) {
|
|||||||
TreatAsComic: handlers.ShouldTreatAsComic(libraryWithType.TypeName, mediaItem.FormatGroup),
|
TreatAsComic: handlers.ShouldTreatAsComic(libraryWithType.TypeName, mediaItem.FormatGroup),
|
||||||
KeepScreenAwake: user.KeepScreenAwake,
|
KeepScreenAwake: user.KeepScreenAwake,
|
||||||
}
|
}
|
||||||
|
// Podcast chaining (plan D7): episodes render the audio reader
|
||||||
|
// with sequence context — the neighboring DOWNLOADED episodes in
|
||||||
|
// the caller's effective order (override applied). RLS already
|
||||||
|
// scoped the episode read; the subscription predicate covers the
|
||||||
|
// neighbors the same way. ?single=1 escapes chaining (the "play
|
||||||
|
// single episode" affordance).
|
||||||
|
if mediaItem.Source == "podcast_episode" && mediaItem.SourceFeedID.Valid && c.QueryParam("single") != "1" {
|
||||||
|
userPG := uuidToPGType(userUUID)
|
||||||
|
if seq := buildPodcastSequenceContext(c, cfg, &mediaItem, userPG, libraryWithType.TypeName); seq != nil {
|
||||||
|
metadata.PodcastSequence = seq
|
||||||
|
}
|
||||||
|
}
|
||||||
// Render template
|
// Render template
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
// Audiobooks get the audio player page — the foliate document
|
// Audiobooks get the audio player page — the foliate document
|
||||||
@@ -144,3 +160,96 @@ func registerReaderRoutes(cfg *Config) {
|
|||||||
reader.GET("/settings", cfg.ReaderHandler.GetSettings)
|
reader.GET("/settings", cfg.ReaderHandler.GetSettings)
|
||||||
reader.PUT("/settings", cfg.ReaderHandler.UpdateSettings)
|
reader.PUT("/settings", cfg.ReaderHandler.UpdateSettings)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildPodcastSequenceContext computes the episode chain payload (plan
|
||||||
|
// D7): effective order — the D5 override applied, feed-native fallback —
|
||||||
|
// DOWNLOADED episodes only (gaps never enter the queue), the current
|
||||||
|
// episode's index, and prev/next reader URLs. RLS scopes the episode
|
||||||
|
// reads; non-subscribers never reach this path in the first place.
|
||||||
|
func buildPodcastSequenceContext(c *echo.Context, cfg *Config, item *database.MediaItems, userID pgtype.UUID, libraryType string) *templates.PodcastSequenceContext {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
eps, err := cfg.Queries.ListPodcastEpisodes(ctx, item.SourceFeedID)
|
||||||
|
if err != nil || len(eps) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
feed, err := cfg.Queries.GetPodcastFeed(ctx, item.SourceFeedID)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Effective order override (D5): reader_settings KV.
|
||||||
|
order := "native"
|
||||||
|
if row, err := cfg.Queries.GetUserSettingByKey(ctx, database.GetUserSettingByKeyParams{
|
||||||
|
UserID: userID,
|
||||||
|
SettingKey: "podcast_order:" + uuid.UUID(item.SourceFeedID.Bytes).String(),
|
||||||
|
}); err == nil {
|
||||||
|
var v struct {
|
||||||
|
Order string `json:"order"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(row, &v) == nil && (v.Order == "oldest" || v.Order == "newest") {
|
||||||
|
order = v.Order
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type seqEp struct {
|
||||||
|
id string
|
||||||
|
title string
|
||||||
|
date string
|
||||||
|
pos int32
|
||||||
|
}
|
||||||
|
ordered := make([]seqEp, 0, len(eps))
|
||||||
|
currentID := uuid.UUID(item.ID.Bytes).String()
|
||||||
|
for i := range eps {
|
||||||
|
ep := eps[i]
|
||||||
|
if ep.DownloadState != "downloaded" {
|
||||||
|
continue // un-downloaded episodes are skipped, not tracked (invariant 6)
|
||||||
|
}
|
||||||
|
date := ""
|
||||||
|
if ep.DatePublished.Valid {
|
||||||
|
date = ep.DatePublished.Time.Format("2006-01-02")
|
||||||
|
}
|
||||||
|
ordered = append(ordered, seqEp{
|
||||||
|
id: uuid.UUID(ep.ID.Bytes).String(),
|
||||||
|
title: ep.Title,
|
||||||
|
date: date,
|
||||||
|
pos: ep.FeedPosition.Int32,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sort.SliceStable(ordered, func(a, b int) bool {
|
||||||
|
ka := ordered[a].date + fmt.Sprintf("%06d", ordered[a].pos)
|
||||||
|
kb := ordered[b].date + fmt.Sprintf("%06d", ordered[b].pos)
|
||||||
|
if order == "newest" {
|
||||||
|
return ka > kb
|
||||||
|
}
|
||||||
|
if order == "oldest" {
|
||||||
|
return ka < kb
|
||||||
|
}
|
||||||
|
return ordered[a].pos < ordered[b].pos
|
||||||
|
})
|
||||||
|
|
||||||
|
idx := -1
|
||||||
|
for i, ep := range ordered {
|
||||||
|
if ep.id == currentID {
|
||||||
|
idx = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if idx < 0 {
|
||||||
|
return nil // current episode not downloaded (or not in sequence)
|
||||||
|
}
|
||||||
|
|
||||||
|
seq := &templates.PodcastSequenceContext{
|
||||||
|
FeedURL: "/podcasts/" + uuid.UUID(item.SourceFeedID.Bytes).String(),
|
||||||
|
FeedTitle: feed.Title,
|
||||||
|
EpisodeIndex: idx + 1,
|
||||||
|
IndexTotal: len(ordered),
|
||||||
|
}
|
||||||
|
if idx > 0 {
|
||||||
|
seq.PrevURL = "/readers/" + ordered[idx-1].id
|
||||||
|
}
|
||||||
|
if idx < len(ordered)-1 {
|
||||||
|
seq.NextURL = "/readers/" + ordered[idx+1].id
|
||||||
|
seq.NextTitle = ordered[idx+1].title
|
||||||
|
}
|
||||||
|
return seq
|
||||||
|
}
|
||||||
@@ -7,7 +7,32 @@ package templates
|
|||||||
// <audio> element streams the m4b through the existing cookie-authed
|
// <audio> element streams the m4b through the existing cookie-authed
|
||||||
// Range-serving /uploads path.
|
// 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) {
|
templ AudioReader(user User, metadata ReaderMetadata) {
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
@@ -41,9 +66,7 @@ templ AudioReader(user User, metadata ReaderMetadata) {
|
|||||||
data-cover={ metadata.CoverImagePath }
|
data-cover={ metadata.CoverImagePath }
|
||||||
data-file-url={ metadata.FileURL }
|
data-file-url={ metadata.FileURL }
|
||||||
data-keep-awake={ metadata.KeepScreenAwake }
|
data-keep-awake={ metadata.KeepScreenAwake }
|
||||||
data-json={ fmt.Sprintf(`{"chapterEndpoint":"%s","progressEndpoint":"%s"}`,
|
data-json={ AudioReaderConfigJSON(metadata) }
|
||||||
"/api/media-items/"+metadata.MediaItemID+"/chapters",
|
|
||||||
"/api/media-items/"+metadata.MediaItemID+"/progress") }
|
|
||||||
>
|
>
|
||||||
<!-- §17 wind-down dim: the sleep ticker eases this toward 35%
|
<!-- §17 wind-down dim: the sleep ticker eases this toward 35%
|
||||||
black across a countdown's final 30s; pointer-events-none
|
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
|
<!-- Header: back to the detail page (reader-style exit) and the
|
||||||
chapter launcher. Floating drawer, never in-flow (A2b). -->
|
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">
|
<div class="absolute top-0 inset-x-0 z-30 flex items-center justify-between px-4 py-3">
|
||||||
<button
|
<span class="inline-flex items-center gap-1">
|
||||||
type="button"
|
<button
|
||||||
@click="backToDetail()"
|
type="button"
|
||||||
class="rounded-full p-2 hover:bg-surface-hover transition-colors"
|
@click="backToDetail()"
|
||||||
style="color: var(--text-primary);"
|
class="rounded-full p-2 hover:bg-surface-hover transition-colors"
|
||||||
aria-label="Back to book details"
|
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>
|
<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="m12 19-7-7 7-7"></path>
|
<path d="M19 12H5"></path>
|
||||||
</svg>
|
<path d="m12 19-7-7 7-7"></path>
|
||||||
</button>
|
</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">
|
<span class="inline-flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
+174
-47
@@ -15,7 +15,32 @@ import templruntime "github.com/a-h/templ/runtime"
|
|||||||
// <audio> element streams the m4b through the existing cookie-authed
|
// <audio> element streams the m4b through the existing cookie-authed
|
||||||
// Range-serving /uploads path.
|
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
func AudioReader(user User, metadata ReaderMetadata) templ.Component {
|
func AudioReader(user User, metadata ReaderMetadata) templ.Component {
|
||||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||||
@@ -45,7 +70,7 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
|
|||||||
var templ_7745c5c3_Var2 string
|
var templ_7745c5c3_Var2 string
|
||||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs("Listening · " + metadata.Title)
|
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs("Listening · " + metadata.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 16, Col: 44}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 41, Col: 44}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -80,7 +105,7 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
|
|||||||
var templ_7745c5c3_Var5 string
|
var templ_7745c5c3_Var5 string
|
||||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.MediaItemID)
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.MediaItemID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 39, Col: 44}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 64, Col: 44}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -93,7 +118,7 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
|
|||||||
var templ_7745c5c3_Var6 string
|
var templ_7745c5c3_Var6 string
|
||||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.Title)
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 40, Col: 30}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 65, Col: 30}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -106,7 +131,7 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
|
|||||||
var templ_7745c5c3_Var7 string
|
var templ_7745c5c3_Var7 string
|
||||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.CoverImagePath)
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.CoverImagePath)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 41, Col: 39}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 66, Col: 39}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -119,7 +144,7 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
|
|||||||
var templ_7745c5c3_Var8 string
|
var templ_7745c5c3_Var8 string
|
||||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.FileURL)
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.FileURL)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 42, Col: 35}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 67, Col: 35}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -132,7 +157,7 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
|
|||||||
var templ_7745c5c3_Var9 string
|
var templ_7745c5c3_Var9 string
|
||||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.KeepScreenAwake)
|
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.KeepScreenAwake)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 43, Col: 45}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 68, Col: 45}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -143,120 +168,222 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
|
|||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var10 string
|
var templ_7745c5c3_Var10 string
|
||||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf(`{"chapterEndpoint":"%s","progressEndpoint":"%s"}`,
|
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(AudioReaderConfigJSON(metadata))
|
||||||
"/api/media-items/"+metadata.MediaItemID+"/chapters",
|
|
||||||
"/api/media-items/"+metadata.MediaItemID+"/progress"))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 46, Col: 57}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 69, Col: 46}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\"><!-- §17 wind-down dim: the sleep ticker eases this toward 35%\n\t\t\t black across a countdown's final 30s; pointer-events-none\n\t\t\t keeps the page usable, stop/finish resets it. --><div class=\"fixed inset-0 pointer-events-none z-40\" :style=\"'opacity:' + sleepDim + '; background: #000; transition: opacity 1s linear;'\"></div><div class=\"h-full flex flex-col items-center justify-center px-4 gap-6 select-none\"><!-- Header: back to the detail page (reader-style exit) and the\n\t\t\t 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-2\"><button type=\"button\" @click=\"bookmarksOpen = !bookmarksOpen\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" :aria-label=\"'Open bookmarks (' + bookmarks.length + ')'\" title=\"Bookmarks\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z\"></path></svg></button> <button type=\"button\" @click=\"chaptersOpen = !chaptersOpen\" class=\"text-sm font-semibold px-4 py-2 rounded-full hover:bg-surface-hover transition-colors inline-flex items-center gap-1.5\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" :aria-label=\"'Open chapter list (currently chapter ' + (currentChapterIdx + 1) + ' of ' + (chapters.length || 1) + ')'\" title=\"Chapters\"><span>Chapter</span> <span class=\"tabular-nums\" x-text=\"(currentChapterIdx + 1) + '/' + (chapters.length || 1)\">1/1</span> <svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m6 9 6 6 6-6\"></path></svg></button> <button type=\"button\" @click=\"helpOpen = !helpOpen\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" aria-label=\"Shortcuts & interactions\" title=\"Shortcuts & interactions (?)\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><rect x=\"2\" y=\"4\" width=\"20\" height=\"16\" rx=\"2\"></rect> <path d=\"M6 8h.01M10 8h.01M14 8h.01M18 8h.01M6 12h.01M10 12h.01M14 12h.01M18 12h.01M8 16h8\"></path></svg></button><div class=\"relative\" @click.outside=\"settingsOpen = false\"><button type=\"button\" @click=\"settingsOpen = !settingsOpen\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" :class=\"settingsOpen ? 'opacity-100' : 'opacity-90'\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" aria-label=\"Player settings\" title=\"Settings\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z\"></path> <circle cx=\"12\" cy=\"12\" r=\"3\"></circle></svg></button><div x-show=\"settingsOpen\" x-cloak x-transition class=\"absolute right-0 top-full mt-2 w-72 rounded-xl shadow-2xl p-3 z-50\" style=\"background-color: var(--bg-secondary); border: 1px solid var(--border);\"><div class=\"flex items-center justify-between mb-2 px-1\"><span class=\"text-sm font-seLine truncated
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\"><!-- §17 wind-down dim: the sleep ticker eases this toward 35%\n\t\t\t black across a countdown's final 30s; pointer-events-none\n\t\t\t keeps the page usable, stop/finish resets it. --><div class=\"fixed inset-0 pointer-events-none z-40\" :style=\"'opacity:' + sleepDim + '; background: #000; transition: opacity 1s linear;'\"></div><div class=\"h-full flex flex-col items-center justify-center px-4 gap-6 select-none\"><!-- Header: back to the detail page (reader-style exit) and the\n\t\t\t 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\"><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\".\n\t\t\t\t\t Rendered only when the server supplied sequence context. -->")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
for _, rate := range SpeedOptions {
|
if metadata.PodcastSequence != nil {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<option value=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<span class=\"text-xs tabular-nums ml-1\" style=\"color: var(--text-secondary)\">Ep ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var11 string
|
var templ_7745c5c3_Var11 string
|
||||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%g", rate))
|
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", metadata.PodcastSequence.EpisodeIndex))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 169, Col: 41}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 98, Col: 68}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\" :selected=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "/")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var12 string
|
var templ_7745c5c3_Var12 string
|
||||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("isDefaultSpeed(%g)", rate))
|
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", metadata.PodcastSequence.IndexTotal))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 170, Col: 61}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 98, Col: 127}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</span> ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var13 string
|
if metadata.PodcastSequence.PrevURL != "" {
|
||||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%g×", rate))
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<a href=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 171, Col: 37}
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var13 templ.SafeURL
|
||||||
|
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinURLErrs(metadata.PodcastSequence.PrevURL)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 101, Col: 49}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\" 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 templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, " ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</option>")
|
if metadata.PodcastSequence.NextURL != "" {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<a href=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var14 templ.SafeURL
|
||||||
|
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinURLErrs(metadata.PodcastSequence.NextURL)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 106, Col: 49}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" style=\"color: var(--text-primary);\" aria-label=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var15 string
|
||||||
|
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("Next episode: " + metadata.PodcastSequence.NextTitle)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 106, Col: 220}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" title=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var16 string
|
||||||
|
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue("Next: " + metadata.PodcastSequence.NextTitle)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 106, Col: 276}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "\"><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>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</span> <span class=\"inline-flex items-center gap-2\"><button type=\"button\" @click=\"bookmarksOpen = !bookmarksOpen\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" :aria-label=\"'Open bookmarks (' + bookmarks.length + ')'\" title=\"Bookmarks\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z\"></path></svg></button> <button type=\"button\" @click=\"chaptersOpen = !chaptersOpen\" class=\"text-sm font-semibold px-4 py-2 rounded-full hover:bg-surface-hover transition-colors inline-flex items-center gap-1.5\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" :aria-label=\"'Open chapter list (currently chapter ' + (currentChapterIdx + 1) + ' of ' + (chapters.length || 1) + ')'\" title=\"Chapters\"><span>Chapter</span> <span class=\"tabular-nums\" x-text=\"(currentChapterIdx + 1) + '/' + (chapters.length || 1)\">1/1</span> <svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m6 9 6 6 6-6\"></path></svg></button> <button type=\"button\" @click=\"helpOpen = !helpOpen\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" aria-label=\"Shortcuts & interactions\" title=\"Shortcuts & interactions (?)\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><rect x=\"2\" y=\"4\" width=\"20\" height=\"16\" rx=\"2\"></rect> <path d=\"M6 8h.01M10 8h.01M14 8h.01M18 8h.01M6 12h.01M10 12h.01M14 12h.01M18 12h.01M8 16h8\"></path></svg></button><div class=\"relative\" @click.outside=\"settingsOpen = false\"><button type=\"button\" @click=\"settingsOpen = !settingsOpen\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" :class=\"settingsOpen ? 'opacity-100' : 'opacity-90'\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" aria-label=\"Player settings\" title=\"Settings\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z\"></path> <circle cx=\"12\" cy=\"12\" r=\"3\"></circle></svg></button><div x-show=\"settingsOpen\" x-cloak x-transition class=\"absolute right-0 top-full mt-2 w-72 rounded-xl shadow-2xl p-3 z-50\" style=\"background-color: var(--bg-secondary); border: 1px solid var(--border);\"><div class=\"flex items-center justify-between mb-2 px-1\"><span class=\"text-sm font-semibold\" style=\"color: var(--text-primary);\">Player settings</span> <button type=\"button\" @click=\"settingsOpen = false\" class=\"rounded-full p-1.5 hover:bg-surface-hover\" style=\"color: var(--text-secondary);\" aria-label=\"Close settings\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M18 6 6 18\"></path> <path d=\"m6 6 12 12\"></path></svg></button></div><!-- Option rows: autoplay first, panel structured for more --><div class=\"flex items-center justify-between gap-3 px-1 py-2 rounded-lg\"><span class=\"text-sm\" style=\"color: var(--text-secondary);\">Autoplay on open</span> <button type=\"button\" role=\"switch\" :aria-checked=\"autoplayOn ? 'true' : 'false'\" @click=\"toggleAutoplay()\" class=\"relative h-5 w-9 rounded-full transition-colors shrink-0\" :style=\"autoplayOn ? 'background-color: var(--accent);' : 'background-color: var(--surface-hover); border: 1px solid var(--border);'\" aria-label=\"Autoplay on open\"><span class=\Line truncated
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
for _, rate := range SpeedOptions {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<option value=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var17 string
|
||||||
|
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%g", rate))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 211, Col: 41}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\" :selected=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var18 string
|
||||||
|
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("isDefaultSpeed(%g)", rate))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 212, Col: 61}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var19 string
|
||||||
|
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%g×", rate))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 213, Col: 37}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</option>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</select></div></div></div></span></div><!-- Cover + title --><div class=\"flex flex-col items-center gap-3 text-center\"><div class=\"w-48 h-72 md:w-56 md:h-80 rounded-2xl overflow-hidden shadow-lg shrink-0 bg-cover bg-center\" style=\"background-color: var(--surface-hover);\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</select></div></div></div></span></div><!-- Cover + title --><div class=\"flex flex-col items-center gap-3 text-center\"><div class=\"w-48 h-72 md:w-56 md:h-80 rounded-2xl overflow-hidden shadow-lg shrink-0 bg-cover bg-center\" style=\"background-color: var(--surface-hover);\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if metadata.CoverImagePath != "" {
|
if metadata.CoverImagePath != "" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<img src=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<img src=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var14 string
|
var templ_7745c5c3_Var20 string
|
||||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.CoverImagePath)
|
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.CoverImagePath)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 185, Col: 36}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 227, Col: 36}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\" alt=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\" alt=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var15 string
|
var templ_7745c5c3_Var21 string
|
||||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.Title)
|
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 186, Col: 27}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 228, Col: 27}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\" class=\"w-full h-full object-cover\" data-fallback=\"hide\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\" class=\"w-full h-full object-cover\" data-fallback=\"hide\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<span class=\"text-5xl flex items-center justify-center h-full\" style=\"color: var(--text-secondary);\">🎧</span>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<span class=\"text-5xl flex items-center justify-center h-full\" style=\"color: var(--text-secondary);\">🎧</span>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</div><div class=\"max-w-md px-4\"><h1 class=\"font-semibold text-lg leading-snug line-clamp-2\" style=\"color: var(--text-primary);\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</div><div class=\"max-w-md px-4\"><h1 class=\"font-semibold text-lg leading-snug line-clamp-2\" style=\"color: var(--text-primary);\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var16 string
|
var templ_7745c5c3_Var22 string
|
||||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(metadata.Title)
|
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(metadata.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 195, Col: 117}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 237, Col: 117}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</h1><p class=\"text-sm mt-0.5 truncate\" style=\"color: var(--text-secondary);\" x-text=\"chapterTitle()\">—</p></div></div><!-- Scrubber + transports --><div class=\"w-full max-w-xl flex flex-col gap-2\"><div class=\"flex items-center gap-2 text-xs tabular-nums\" style=\"color: var(--text-secondary);\"><span class=\"inline-block min-w-[4.75rem]\" x-text=\"fmtTime(scrubbing ? scrubValue : currentTime)\">0:00</span><div id=\"scrub-area\" class=\"relative flex-1 h-6 flex items-center cursor-pointer\" style=\"touch-action: none;\" @pointerdown=\"scrubPointerDown($event)\" @pointermove=\"scrubPointerMove($event)\" @pointerup=\"scrubPointerUp($event)\" @pointercancel=\"scrubPointerUp($event)\" @pointerleave=\"scrubPointerLeave()\" @wheel.prevent=\"onScrubWheel($event)\"><!-- Base track --><div class=\"absolute inset-x-0 top-1/2 -translate-y-1/2 h-1.5 rounded-full pointer-events-none\" style=\"background-color: var(--surface-hover);\"></div><!-- Elapsed fill --><div class=\"absolute top-1/2 -translate-y-1/2 h-1.5 rounded-full pointer-events-none\" :style=\"'left:0;width:' + scrubFillPct() + '%;background-color:var(--accent);'\"></div><!-- Buffered span: the ACTIVE track's element buffer only --><template x-if=\"bufferSpan()\"><div class=\"absolute top-1/2 -translate-y-1/2 h-1.5 rounded-full pointer-events-none\" :style=\"'left:' + bufferSpan().left + '%;width:' + bufferSpan().width + '%;background-color:var(--accent-muted);'\"></div></template><!-- Chapter ticks: density-decimated, current chapter tinted --><div id=\"scrub-ticks\" class=\"absolute inset-0 pointer-events-none\"><template x-for=\"idx in tickIndices\" :key=\"'tick' + idx\"><div class=\"absolute top-1/2 -translate-x-1/2 -translate-y-1/2 h-2 w-0.5 rounded-full\" :class=\"currentChapterIdx === idx ? 'opacity-100' : 'opacity-40'\" :style=\"'left:' + (chapters[idx].start / (chaptersDuration() || 1) * 100) + '%;background-color:' + (currentChapterIdx === idx ? 'var(--accent)' : 'var(--text-secondary)') + ';'\"></div></template></div><!-- §9 magnet: the line a click would land on (hover\n\t\t\t\t\t\t inside the pixel radius locks the tooltip here) --><div x-show=\"hoverSnapped && !scrubbing\" x-cloak class=\"absolute top-1/2 -translate-x-1/2 -translate-y-1/2 h-4 w-px rounded-full pointer-events-none\" :style=\"'left:' + hoverPct + '%;background-color:var(--accent);'\"></div><input id=\"audio-scrub\" type=\"range\" min=\"0\" step=\"0.1\" :max=\"chaptersDuration() || 1\" :value=\"scrubbing ? scrubValue : currentTime\" class=\"relative w-full\" @input=\"onScrubInput($event)\" @change=\"commitScrub()\" aria-label=\"Seek position\"><!-- Hover tooltip: time + chapter under the pointer --><div x-show=\"hoverSeconds !== null && !scrubbing\" x-cloak class=\"absolute bottom-full mb-1 -translate-x-1/2 whitespace-nowrap rounded-md px-2 py-1 text-[11px] shadow-lg pointer-events-none\" :style=\"'left:' + hoverPct + '%;background-color:var(--bg-secondary);border:1px solid var(--border);color:var(--text-primary);'\"><span x-text=\"hoverSeconds !== null ? fmtTime(hoverSeconds) : ''\"></span> <span style=\"color: var(--text-secondary);\" x-text=\"hoverSeconds !== null ? ' — ' + chapterTitleAt(hoverSeconds) : ''\"></span></div><!-- Precision rail (§9): Shift+drag on mouse, or a touch\n\t\t\t\t\t\t drag pulled off the bar — ±5 min or tighter, free\n\t\t\t\t\t\t mapping inside. Edge labels self-document the window\n\t\t\t\t\t\t (floats over the transports, page never shifts) --><div x-show=\"railVisible\" x-cloak class=\"absolute top-full mt-1 inset-x-0 h-12 rounded-lg shadow-lg p-1.5 pointer-events-none\" :style=\"'background-color: var(--bg-secondary); border: 1px solid var(--border); z-index: 30;' + (railLiftY ? ' transform: translateY(' + railLiftY + 'px);' : '')\"><div class=\"relative h-full\"><div class=\"absolute inset-x-0 top-1/2 h-px\" style=\"background-color: var(--border);\"></div><template x-for=\"tick in railTickList\" :key=\"'rtick' + tick.start\"><div class=\"absolute top-1 bottom-1 w-px\" :style=\"railTickStyle(tick)\"></div></template><div class=\"absolute top-0 bottom-0 w-0.5 -translate-x-1/2 rounded-full\" :style=\"'left:' + railMarkerPct() + '%;background-color:var(--accent);'\"></div><div class=\"absolute left-1 top-0.5 text-[10px] tabular-nums whitespace-nowrap\" style=\"color: var(--text-primary);\" x-text=\"fmtTime(scrubValue) + ' — ' + chapterTitleAt(scrubValue)\"></div><div class=\"absolute left-1 bottom-0.5 text-[9px] tabular-nums\" style=\"color: var(--text-secondary);\" x-text=\"fmtTime(railFrom)\"></div><div class=\"absolute right-1 bottom-0.5 text-[9px] tabular-nums\" style=\"color: var(--text-secondary);\" x-text=\"fmtTime(railTo)\"></div></div></div></div><button type=\"button\" class=\"inline-block min-w-[4.75rem] text-right cursor-pointer\" x-text=\"endTimeText()\" :aria-label=\"endTimeAria()\" :title=\"endTimeAria()\" @click=\"cycleEndTLine truncated
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</h1><p class=\"text-sm mt-0.5 truncate\" style=\"color: var(--text-secondary);\" x-text=\"chapterTitle()\">—</p></div></div><!-- Scrubber + transports --><div class=\"w-full max-w-xl flex flex-col gap-2\"><div class=\"flex items-center gap-2 text-xs tabular-nums\" style=\"color: var(--text-secondary);\"><span class=\"inline-block min-w-[4.75rem]\" x-text=\"fmtTime(scrubbing ? scrubValue : currentTime)\">0:00</span><div id=\"scrub-area\" class=\"relative flex-1 h-6 flex items-center cursor-pointer\" style=\"touch-action: none;\" @pointerdown=\"scrubPointerDown($event)\" @pointermove=\"scrubPointerMove($event)\" @pointerup=\"scrubPointerUp($event)\" @pointercancel=\"scrubPointerUp($event)\" @pointerleave=\"scrubPointerLeave()\" @wheel.prevent=\"onScrubWheel($event)\"><!-- Base track --><div class=\"absolute inset-x-0 top-1/2 -translate-y-1/2 h-1.5 rounded-full pointer-events-none\" style=\"background-color: var(--surface-hover);\"></div><!-- Elapsed fill --><div class=\"absolute top-1/2 -translate-y-1/2 h-1.5 rounded-full pointer-events-none\" :style=\"'left:0;width:' + scrubFillPct() + '%;background-color:var(--accent);'\"></div><!-- Buffered span: the ACTIVE track's element buffer only --><template x-if=\"bufferSpan()\"><div class=\"absolute top-1/2 -translate-y-1/2 h-1.5 rounded-full pointer-events-none\" :style=\"'left:' + bufferSpan().left + '%;width:' + bufferSpan().width + '%;background-color:var(--accent-muted);'\"></div></template><!-- Chapter ticks: density-decimated, current chapter tinted --><div id=\"scrub-ticks\" class=\"absolute inset-0 pointer-events-none\"><template x-for=\"idx in tickIndices\" :key=\"'tick' + idx\"><div class=\"absolute top-1/2 -translate-x-1/2 -translate-y-1/2 h-2 w-0.5 rounded-full\" :class=\"currentChapterIdx === idx ? 'opacity-100' : 'opacity-40'\" :style=\"'left:' + (chapters[idx].start / (chaptersDuration() || 1) * 100) + '%;background-color:' + (currentChapterIdx === idx ? 'var(--accent)' : 'var(--text-secondary)') + ';'\"></div></template></div><!-- §9 magnet: the line a click would land on (hover\n\t\t\t\t\t\t inside the pixel radius locks the tooltip here) --><div x-show=\"hoverSnapped && !scrubbing\" x-cloak class=\"absolute top-1/2 -translate-x-1/2 -translate-y-1/2 h-4 w-px rounded-full pointer-events-none\" :style=\"'left:' + hoverPct + '%;background-color:var(--accent);'\"></div><input id=\"audio-scrub\" type=\"range\" min=\"0\" step=\"0.1\" :max=\"chaptersDuration() || 1\" :value=\"scrubbing ? scrubValue : currentTime\" class=\"relative w-full\" @input=\"onScrubInput($event)\" @change=\"commitScrub()\" aria-label=\"Seek position\"><!-- Hover tooltip: time + chapter under the pointer --><div x-show=\"hoverSeconds !== null && !scrubbing\" x-cloak class=\"absolute bottom-full mb-1 -translate-x-1/2 whitespace-nowrap rounded-md px-2 py-1 text-[11px] shadow-lg pointer-events-none\" :style=\"'left:' + hoverPct + '%;background-color:var(--bg-secondary);border:1px solid var(--border);color:var(--text-primary);'\"><span x-text=\"hoverSeconds !== null ? fmtTime(hoverSeconds) : ''\"></span> <span style=\"color: var(--text-secondary);\" x-text=\"hoverSeconds !== null ? ' — ' + chapterTitleAt(hoverSeconds) : ''\"></span></div><!-- Precision rail (§9): Shift+drag on mouse, or a touch\n\t\t\t\t\t\t drag pulled off the bar — ±5 min or tighter, free\n\t\t\t\t\t\t mapping inside. Edge labels self-document the window\n\t\t\t\t\t\t (floats over the transports, page never shifts) --><div x-show=\"railVisible\" x-cloak class=\"absolute top-full mt-1 inset-x-0 h-12 rounded-lg shadow-lg p-1.5 pointer-events-none\" :style=\"'background-color: var(--bg-secondary); border: 1px solid var(--border); z-index: 30;' + (railLiftY ? ' transform: translateY(' + railLiftY + 'px);' : '')\"><div class=\"relative h-full\"><div class=\"absolute inset-x-0 top-1/2 h-px\" style=\"background-color: var(--border);\"></div><template x-for=\"tick in railTickList\" :key=\"'rtick' + tick.start\"><div class=\"absolute top-1 bottom-1 w-px\" :style=\"railTickStyle(tick)\"></div></template><div class=\"absolute top-0 bottom-0 w-0.5 -translate-x-1/2 rounded-full\" :style=\"'left:' + railMarkerPct() + '%;background-color:var(--accent);'\"></div><div class=\"absolute left-1 top-0.5 text-[10px] tabular-nums whitespace-nowrap\" style=\"color: var(--text-primary);\" x-text=\"fmtTime(scrubValue) + ' — ' + chapterTitleAt(scrubValue)\"></div><div class=\"absolute left-1 bottom-0.5 text-[9px] tabular-nums\" style=\"color: var(--text-secondary);\" x-text=\"fmtTime(railFrom)\"></div><div class=\"absolute right-1 bottom-0.5 text-[9px] tabular-nums\" style=\"color: var(--text-secondary);\" x-text=\"fmtTime(railTo)\"></div></div></div></div><button type=\"button\" class=\"inline-block min-w-[4.75rem] text-right cursor-pointer\" x-text=\"endTimeText()\" :aria-label=\"endTimeAria()\" :title=\"endTimeAria()\" @click=\"cycleEndTLine truncated
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -270,7 +270,7 @@ templ PodcastEpisodeRowView(ep PodcastEpisodeRow) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
if ep.Downloaded {
|
if ep.Downloaded {
|
||||||
<a href={ ep.PlayURL } class="btn btn-secondary shrink-0" title="Play episode">
|
<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")
|
@Icon("book-open", "h-4 w-4")
|
||||||
<span>Play</span>
|
<span>Play</span>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -910,7 +910,7 @@ func PodcastEpisodeRowView(ep PodcastEpisodeRow) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "\" class=\"btn btn-secondary shrink-0\" title=\"Play episode\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "\" 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)\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ package templates
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -42,6 +43,14 @@ type PodcastFeedCard struct {
|
|||||||
AutoDownload string
|
AutoDownload string
|
||||||
AutoDownloadCount int
|
AutoDownloadCount int
|
||||||
Subscribed bool
|
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).
|
// 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.
|
// PodcastsPageData carries everything the /podcasts page renders.
|
||||||
type PodcastsPageData struct {
|
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)
|
Libraries []LibraryData // podcasts-type libraries (add-feed targets)
|
||||||
Facets map[string][]string
|
Facets map[string][]string
|
||||||
Query string
|
Query string
|
||||||
@@ -168,3 +181,20 @@ func podcastTimeOrEmpty(t time.Time, valid bool, tz string) string {
|
|||||||
}
|
}
|
||||||
return utils.FormatTimeInTimezone(t, tz)
|
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
|
||||||
|
}
|
||||||
@@ -67,7 +67,7 @@ templ PodcastsPage(user User, data PodcastsPageData, currentPath string) {
|
|||||||
<p class="text-sm" style="color: var(--text-secondary)">Add an RSS feed to start listening — episodes land here and download on demand.</p>
|
<p class="text-sm" style="color: var(--text-secondary)">Add an RSS feed to start listening — episodes land here and download on demand.</p>
|
||||||
</div>
|
</div>
|
||||||
} else {
|
} else {
|
||||||
@PodcastsGrid(data.Feeds)
|
@PodcastsSections(data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</main>
|
</main>
|
||||||
@@ -115,6 +115,22 @@ templ PodcastsFilterBar(data PodcastsPageData) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PodcastsSections renders the Continue section (feeds with in-progress
|
||||||
|
// episodes, most recently listened first — plan 2.5) ahead of the
|
||||||
|
// alphabetical All Feeds grid.
|
||||||
|
templ PodcastsSections(data PodcastsPageData) {
|
||||||
|
if len(data.ContinueFeeds) > 0 {
|
||||||
|
<h2 class="text-lg font-bold mb-3 mt-2" style="color: var(--text-primary)">Continue</h2>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 mb-8">
|
||||||
|
for _, feed := range data.ContinueFeeds {
|
||||||
|
@PodcastFeedCardView(feed)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<h2 class="text-lg font-bold mb-3" style="color: var(--text-primary)">All Feeds</h2>
|
||||||
|
}
|
||||||
|
@PodcastsGrid(data.Feeds)
|
||||||
|
}
|
||||||
|
|
||||||
templ PodcastsGrid(feeds []PodcastFeedCard) {
|
templ PodcastsGrid(feeds []PodcastFeedCard) {
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||||
for _, feed := range feeds {
|
for _, feed := range feeds {
|
||||||
@@ -151,7 +167,12 @@ templ PodcastFeedCardView(feed PodcastFeedCard) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
if feed.Description != "" {
|
if feed.HasContinue {
|
||||||
|
<p class="text-xs font-medium line-clamp-1" style="color: var(--accent)">
|
||||||
|
@Icon("book-open", "h-3.5 w-3.5 inline")
|
||||||
|
{ " " + podcastInt(feed.ContinueEpisode) + ". " + feed.ContinueTitle }
|
||||||
|
</p>
|
||||||
|
} else if feed.Description != "" {
|
||||||
<p class="text-xs line-clamp-2" style="color: var(--text-secondary)">{ feed.Description }</p>
|
<p class="text-xs line-clamp-2" style="color: var(--text-secondary)">{ feed.Description }</p>
|
||||||
}
|
}
|
||||||
<div class="mt-auto flex flex-wrap items-center gap-1.5 text-xs">
|
<div class="mt-auto flex flex-wrap items-center gap-1.5 text-xs">
|
||||||
|
|||||||
+175
-105
@@ -192,7 +192,7 @@ func PodcastsPage(user User, data PodcastsPageData, currentPath string) templ.Co
|
|||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
templ_7745c5c3_Err = PodcastsGrid(data.Feeds).Render(ctx, templ_7745c5c3_Buffer)
|
templ_7745c5c3_Err = PodcastsSections(data).Render(ctx, templ_7745c5c3_Buffer)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -306,7 +306,10 @@ func PodcastsFilterBar(data PodcastsPageData) templ.Component {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func PodcastsGrid(feeds []PodcastFeedCard) templ.Component {
|
// PodcastsSections renders the Continue section (feeds with in-progress
|
||||||
|
// episodes, most recently listened first — plan 2.5) ahead of the
|
||||||
|
// alphabetical All Feeds grid.
|
||||||
|
func PodcastsSections(data PodcastsPageData) templ.Component {
|
||||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||||
@@ -327,7 +330,52 @@ func PodcastsGrid(feeds []PodcastFeedCard) templ.Component {
|
|||||||
templ_7745c5c3_Var10 = templ.NopComponent
|
templ_7745c5c3_Var10 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<div class=\"grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4\">")
|
if len(data.ContinueFeeds) > 0 {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<h2 class=\"text-lg font-bold mb-3 mt-2\" style=\"color: var(--text-primary)\">Continue</h2><div class=\"grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 mb-8\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
for _, feed := range data.ContinueFeeds {
|
||||||
|
templ_7745c5c3_Err = PodcastFeedCardView(feed).Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</div><h2 class=\"text-lg font-bold mb-3\" style=\"color: var(--text-primary)\">All Feeds</h2>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = PodcastsGrid(data.Feeds).Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func PodcastsGrid(feeds []PodcastFeedCard) templ.Component {
|
||||||
|
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||||
|
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||||
|
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||||
|
return templ_7745c5c3_CtxErr
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||||
|
if !templ_7745c5c3_IsBuffer {
|
||||||
|
defer func() {
|
||||||
|
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err == nil {
|
||||||
|
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
ctx = templ.InitializeContext(ctx)
|
||||||
|
templ_7745c5c3_Var11 := templ.GetChildren(ctx)
|
||||||
|
if templ_7745c5c3_Var11 == nil {
|
||||||
|
templ_7745c5c3_Var11 = templ.NopComponent
|
||||||
|
}
|
||||||
|
ctx = templ.ClearChildren(ctx)
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<div class=\"grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -337,7 +385,7 @@ func PodcastsGrid(feeds []PodcastFeedCard) templ.Component {
|
|||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -361,56 +409,56 @@ func PodcastFeedCardView(feed PodcastFeedCard) templ.Component {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
ctx = templ.InitializeContext(ctx)
|
ctx = templ.InitializeContext(ctx)
|
||||||
templ_7745c5c3_Var11 := templ.GetChildren(ctx)
|
templ_7745c5c3_Var12 := templ.GetChildren(ctx)
|
||||||
if templ_7745c5c3_Var11 == nil {
|
if templ_7745c5c3_Var12 == nil {
|
||||||
templ_7745c5c3_Var11 = templ.NopComponent
|
templ_7745c5c3_Var12 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<a href=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<a href=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var12 templ.SafeURL
|
var templ_7745c5c3_Var13 templ.SafeURL
|
||||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinURLErrs("/podcasts/" + feed.ID)
|
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinURLErrs("/podcasts/" + feed.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 128, Col: 31}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 144, Col: 31}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\" class=\"card p-4 flex flex-col gap-3 hover:-translate-y-0.5 transition-transform\"><div class=\"flex items-start gap-3\"><div class=\"h-16 w-16 rounded-lg shrink-0 overflow-hidden grid place-items-center\" style=\"background-color: var(--surface-secondary); color: var(--text-secondary);\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "\" class=\"card p-4 flex flex-col gap-3 hover:-translate-y-0.5 transition-transform\"><div class=\"flex items-start gap-3\"><div class=\"h-16 w-16 rounded-lg shrink-0 overflow-hidden grid place-items-center\" style=\"background-color: var(--surface-secondary); color: var(--text-secondary);\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if feed.ArtworkPath != "" {
|
if feed.ArtworkPath != "" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<img src=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<img src=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var13 string
|
|
||||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue("/uploads/library-" + feed.LibraryID + "/" + feed.ArtworkPath)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 135, Col: 73}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "\" alt=\"")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var14 string
|
var templ_7745c5c3_Var14 string
|
||||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue("Cover for " + feed.DisplayTitle())
|
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue("/uploads/library-" + feed.LibraryID + "/" + feed.ArtworkPath)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 136, Col: 46}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 151, Col: 73}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "\" class=\"h-full w-full object-cover\" data-image-fallback>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\" alt=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var15 string
|
||||||
|
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("Cover for " + feed.DisplayTitle())
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 152, Col: 46}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\" class=\"h-full w-full object-cover\" data-image-fallback>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -420,106 +468,128 @@ func PodcastFeedCardView(feed PodcastFeedCard) templ.Component {
|
|||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</div><div class=\"min-w-0\"><p class=\"font-semibold leading-snug line-clamp-2\" style=\"color: var(--text-primary)\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</div><div class=\"min-w-0\"><p class=\"font-semibold leading-snug line-clamp-2\" style=\"color: var(--text-primary)\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var15 string
|
|
||||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(feed.DisplayTitle())
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 145, Col: 111}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</p><p class=\"text-xs mt-0.5\" style=\"color: var(--text-secondary)\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var16 string
|
var templ_7745c5c3_Var16 string
|
||||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(feed.TypeLabel())
|
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(feed.DisplayTitle())
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 147, Col: 23}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 161, Col: 111}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, " ")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</p><p class=\"text-xs mt-0.5\" style=\"color: var(--text-secondary)\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var17 string
|
||||||
|
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(feed.TypeLabel())
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 163, Col: 23}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, " ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if feed.Host != "" {
|
if feed.Host != "" {
|
||||||
var templ_7745c5c3_Var17 string
|
|
||||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(" · " + feed.Host)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 149, Col: 26}
|
|
||||||
}
|
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</p></div></div>")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
if feed.Description != "" {
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<p class=\"text-xs line-clamp-2\" style=\"color: var(--text-secondary)\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
var templ_7745c5c3_Var18 string
|
var templ_7745c5c3_Var18 string
|
||||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(feed.Description)
|
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(" · " + feed.Host)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 155, Col: 90}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 165, Col: 26}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</p>")
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</p></div></div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if feed.HasContinue {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<p class=\"text-xs font-medium line-clamp-1\" style=\"color: var(--accent)\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("book-open", "h-3.5 w-3.5 inline").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var19 string
|
||||||
|
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(" " + podcastInt(feed.ContinueEpisode) + ". " + feed.ContinueTitle)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 173, Col: 72}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</p>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
} else if feed.Description != "" {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<p class=\"text-xs line-clamp-2\" style=\"color: var(--text-secondary)\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var20 string
|
||||||
|
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(feed.Description)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 176, Col: 90}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</p>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<div class=\"mt-auto flex flex-wrap items-center gap-1.5 text-xs\"><span class=\"badge\" style=\"background-color: var(--surface-secondary); color: var(--text-secondary)\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<div class=\"mt-auto flex flex-wrap items-center gap-1.5 text-xs\"><span class=\"badge\" style=\"background-color: var(--surface-secondary); color: var(--text-secondary)\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var19 string
|
var templ_7745c5c3_Var21 string
|
||||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(podcastInt(feed.DownloadedN) + " / " + podcastInt(feed.EpisodeTotal) + " downloaded")
|
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(podcastInt(feed.DownloadedN) + " / " + podcastInt(feed.EpisodeTotal) + " downloaded")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 159, Col: 90}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 180, Col: 90}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</span> ")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "</span> ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if feed.NewEpisodes {
|
if feed.NewEpisodes {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<span class=\"badge\" style=\"background-color: var(--accent-muted); color: var(--accent)\">new episodes</span> ")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<span class=\"badge\" style=\"background-color: var(--accent-muted); color: var(--accent)\">new episodes</span> ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if feed.IsComplete {
|
if feed.IsComplete {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "<span class=\"badge\" style=\"background-color: var(--surface-secondary); color: var(--text-secondary)\">complete</span> ")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "<span class=\"badge\" style=\"background-color: var(--surface-secondary); color: var(--text-secondary)\">complete</span> ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if feed.Explicit {
|
if feed.Explicit {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<span class=\"badge\" style=\"background-color: var(--surface-secondary); color: var(--text-secondary)\">explicit</span>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<span class=\"badge\" style=\"background-color: var(--surface-secondary); color: var(--text-secondary)\">explicit</span>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "</div></a>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</div></a>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -543,12 +613,12 @@ func PodcastAddFeedModal(libraries []LibraryData) templ.Component {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
ctx = templ.InitializeContext(ctx)
|
ctx = templ.InitializeContext(ctx)
|
||||||
templ_7745c5c3_Var20 := templ.GetChildren(ctx)
|
templ_7745c5c3_Var22 := templ.GetChildren(ctx)
|
||||||
if templ_7745c5c3_Var20 == nil {
|
if templ_7745c5c3_Var22 == nil {
|
||||||
templ_7745c5c3_Var20 = templ.NopComponent
|
templ_7745c5c3_Var22 = templ.NopComponent
|
||||||
}
|
}
|
||||||
ctx = templ.ClearChildren(ctx)
|
ctx = templ.ClearChildren(ctx)
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<div id=\"podcast-add-feed-modal\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center overflow-y-auto p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"card w-full max-w-lg\" style=\"box-shadow: var(--shadow-pop);\"><div class=\"flex justify-between items-center p-6 border-b\" style=\"border-color: var(--border);\"><h2 class=\"text-lg font-bold\" style=\"color: var(--text-primary)\">Add Podcast Feed</h2><button class=\"icon-btn\" aria-label=\"Close\" @click=\"hideAddFeed()\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<div id=\"podcast-add-feed-modal\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center overflow-y-auto p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"card w-full max-w-lg\" style=\"box-shadow: var(--shadow-pop);\"><div class=\"flex justify-between items-center p-6 border-b\" style=\"border-color: var(--border);\"><h2 class=\"text-lg font-bold\" style=\"color: var(--text-primary)\">Add Podcast Feed</h2><button class=\"icon-btn\" aria-label=\"Close\" @click=\"hideAddFeed()\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -556,71 +626,71 @@ func PodcastAddFeedModal(libraries []LibraryData) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "</button></div><form hx-post=\"/api/podcasts/feeds\" hx-swap=\"none\" class=\"p-6 flex flex-col gap-4\"><div><label class=\"text-sm font-medium mb-1 block\" for=\"podcast-feed-url\" style=\"color: var(--text-secondary)\">RSS feed URL</label> <input type=\"url\" id=\"podcast-feed-url\" name=\"url\" placeholder=\"https://example.com/feed.xml\" class=\"input w-full\" required></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</button></div><form hx-post=\"/api/podcasts/feeds\" hx-swap=\"none\" class=\"p-6 flex flex-col gap-4\"><div><label class=\"text-sm font-medium mb-1 block\" for=\"podcast-feed-url\" style=\"color: var(--text-secondary)\">RSS feed URL</label> <input type=\"url\" id=\"podcast-feed-url\" name=\"url\" placeholder=\"https://example.com/feed.xml\" class=\"input w-full\" required></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if len(libraries) > 1 {
|
if len(libraries) > 1 {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<div><label class=\"text-sm font-medium mb-1 block\" for=\"podcast-feed-library\" style=\"color: var(--text-secondary)\">Library</label> <select id=\"podcast-feed-library\" name=\"library_id\" class=\"input w-full\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<div><label class=\"text-sm font-medium mb-1 block\" for=\"podcast-feed-library\" style=\"color: var(--text-secondary)\">Library</label> <select id=\"podcast-feed-library\" name=\"library_id\" class=\"input w-full\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
for _, lib := range libraries {
|
for _, lib := range libraries {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "<option value=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "<option value=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var21 string
|
var templ_7745c5c3_Var23 string
|
||||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 208, Col: 30}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 229, Col: 30}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var22 string
|
var templ_7745c5c3_Var24 string
|
||||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 208, Col: 43}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 229, Col: 43}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</option>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "</option>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</select></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "</select></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
} else if len(libraries) == 1 {
|
} else if len(libraries) == 1 {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "<input type=\"hidden\" name=\"library_id\" value=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<input type=\"hidden\" name=\"library_id\" value=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var23 string
|
var templ_7745c5c3_Var25 string
|
||||||
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraries[0].ID)
|
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraries[0].ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 213, Col: 67}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/podcasts.templ`, Line: 234, Col: 67}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "\"> ")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "\"> ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "<label class=\"flex items-center gap-2 text-sm\" style=\"color: var(--text-secondary)\"><input type=\"checkbox\" name=\"download_all\" value=\"true\" class=\"cursor-pointer\"> Download all episodes now</label><div id=\"podcast-add-feed-error\" class=\"text-sm hidden\" style=\"color: var(--status-danger)\"></div><div class=\"flex justify-end gap-2\"><button type=\"button\" class=\"btn btn-secondary\" @click=\"hideAddFeed()\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "<label class=\"flex items-center gap-2 text-sm\" style=\"color: var(--text-secondary)\"><input type=\"checkbox\" name=\"download_all\" value=\"true\" class=\"cursor-pointer\"> Download all episodes now</label><div id=\"podcast-add-feed-error\" class=\"text-sm hidden\" style=\"color: var(--status-danger)\"></div><div class=\"flex justify-end gap-2\"><button type=\"button\" class=\"btn btn-secondary\" @click=\"hideAddFeed()\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -628,7 +698,7 @@ func PodcastAddFeedModal(libraries []LibraryData) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "<span>Add & Subscribe</span></button></div></form></div></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "<span>Add & Subscribe</span></button></div></form></div></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -312,6 +312,24 @@ type ReaderMetadata struct {
|
|||||||
// KeepScreenAwake: user preference — the reader holds a Screen Wake
|
// KeepScreenAwake: user preference — the reader holds a Screen Wake
|
||||||
// Lock while open when true.
|
// Lock while open when true.
|
||||||
KeepScreenAwake bool `json:"keep_screen_awake"`
|
KeepScreenAwake bool `json:"keep_screen_awake"`
|
||||||
|
// PodcastSequence (plan D7/Phase 2): set ONLY for podcast episodes —
|
||||||
|
// the neighboring downloaded episodes in the caller's effective
|
||||||
|
// order. Powers chain-by-default playback (auto-advance across
|
||||||
|
// episodes) and the player's episode prev/next. nil for books.
|
||||||
|
PodcastSequence *PodcastSequenceContext `json:"podcast_sequence,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PodcastSequenceContext is the episode-level chain payload rendered
|
||||||
|
// into the audio reader's config JSON.
|
||||||
|
type PodcastSequenceContext struct {
|
||||||
|
FeedURL string `json:"feed_url,omitempty"`
|
||||||
|
FeedTitle string `json:"feed_title,omitempty"`
|
||||||
|
PrevURL string `json:"prev_url,omitempty"`
|
||||||
|
NextURL string `json:"next_url,omitempty"`
|
||||||
|
NextTitle string `json:"next_title,omitempty"`
|
||||||
|
// EpisodeIndex/IndexTotal render the "Ep N of M" indicator (D7).
|
||||||
|
EpisodeIndex int `json:"episode_index,omitempty"`
|
||||||
|
IndexTotal int `json:"index_total,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ReadingProgress struct {
|
type ReadingProgress struct {
|
||||||
|
|||||||
+49
-1
@@ -171,9 +171,20 @@ export function endTimeLabel(
|
|||||||
|
|
||||||
const PROGRESS_INTERVAL_MS = 30_000;
|
const PROGRESS_INTERVAL_MS = 30_000;
|
||||||
|
|
||||||
|
interface PodcastSequenceConfig {
|
||||||
|
feed_url?: string;
|
||||||
|
feed_title?: string;
|
||||||
|
prev_url?: string;
|
||||||
|
next_url?: string;
|
||||||
|
next_title?: string;
|
||||||
|
episode_index?: number;
|
||||||
|
index_total?: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface AudioConfig {
|
interface AudioConfig {
|
||||||
chapterEndpoint: string;
|
chapterEndpoint: string;
|
||||||
progressEndpoint: string;
|
progressEndpoint: string;
|
||||||
|
podcastSequence?: PodcastSequenceConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
function readConfig(): AudioConfig {
|
function readConfig(): AudioConfig {
|
||||||
@@ -419,10 +430,14 @@ Alpine.data("audioReader", () => ({
|
|||||||
});
|
});
|
||||||
audio.addEventListener("ended", () => {
|
audio.addEventListener("ended", () => {
|
||||||
// Natural end of track n: roll into n+1 and keep playing. At the
|
// Natural end of track n: roll into n+1 and keep playing. At the
|
||||||
// last track the book is done — nothing to advance into.
|
// last track the book is done — nothing to advance into... unless
|
||||||
|
// this is a podcast episode with a sequence (D7): chain into the
|
||||||
|
// next downloaded episode's reader page.
|
||||||
if (this.activeTrack < this.tracks.length - 1) {
|
if (this.activeTrack < this.tracks.length - 1) {
|
||||||
this.setActiveTrack(this.activeTrack + 1, 0, true);
|
this.setActiveTrack(this.activeTrack + 1, 0, true);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
this.goToNextEpisode(true);
|
||||||
});
|
});
|
||||||
audio.addEventListener("play", () => {
|
audio.addEventListener("play", () => {
|
||||||
this.playing = true;
|
this.playing = true;
|
||||||
@@ -760,9 +775,42 @@ Alpine.data("audioReader", () => ({
|
|||||||
|
|
||||||
backToDetail() {
|
backToDetail() {
|
||||||
this.pushProgress(true);
|
this.pushProgress(true);
|
||||||
|
// Podcast episodes exit to their feed page (D7 sequence context);
|
||||||
|
// books keep the book-detail exit.
|
||||||
|
const seq = readConfig().podcastSequence;
|
||||||
|
if (seq && seq.feed_url) {
|
||||||
|
window.location.href = seq.feed_url;
|
||||||
|
return;
|
||||||
|
}
|
||||||
window.location.href = `/media/${this.mediaItemId}`;
|
window.location.href = `/media/${this.mediaItemId}`;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ---- podcast sequence chaining (D7) ----
|
||||||
|
// Auto-advance across episodes: the last track's `ended` rolls into
|
||||||
|
// the next episode's reader page (progress already pushed on
|
||||||
|
// timeupdate; a final push fires before navigating). Books are
|
||||||
|
// untouched — no sequence context, no behavior change.
|
||||||
|
goToNextEpisode(finalPush) {
|
||||||
|
const seq = readConfig().podcastSequence;
|
||||||
|
if (!seq || !seq.next_url) return false;
|
||||||
|
const go = () => {
|
||||||
|
window.location.href = seq.next_url as string;
|
||||||
|
};
|
||||||
|
if (finalPush) {
|
||||||
|
Promise.resolve(this.pushProgress(true)).then(go).catch(go);
|
||||||
|
} else {
|
||||||
|
go();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
goToPrevEpisode() {
|
||||||
|
const seq = readConfig().podcastSequence;
|
||||||
|
if (seq && seq.prev_url) {
|
||||||
|
this.pushProgress(true);
|
||||||
|
window.location.href = seq.prev_url;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// ---- volume (settings strip) ----
|
// ---- volume (settings strip) ----
|
||||||
|
|
||||||
volumeIcon(): "muted" | "low" | "mid" | "high" {
|
volumeIcon(): "muted" | "low" | "mid" | "high" {
|
||||||
|
|||||||
Reference in new issue
Block a user