Compare commits

..
25 Commits
Author SHA1 Message Date
John O'Keefe 9d0ae2bcc4 feat(web): sync-conflict banner at top of media item page + end-time help row
Release / build-and-push (push) Successful in 3m9s
- A progress sync conflict used to be flagged only inside the progress
  card, far down a long page. An unresolved conflict now also renders
  a full-width warning strip at the very top (under the app bar) that
  opens the same resolution modal on click. Rendered from the same
  ActiveConflict the progress section uses — absent when clean.
- Shortcuts dialog Mouse section gains an 'End time' row: click to
  cycle total → book left → chapter left (§10's toggle was
  undiscoverable).
2026-10-23 12:30:12 -04:00
John O'Keefe f8de17723a feat(web): sleep timer upgrades — undo toast, chain mode, clock stop, wind-down dim (plan §17)
- Undo: a genuine sleep fire now toasts with a Resume action (toast.ts
  grows an optional action button; acting dismisses).
- Chain: 'End of chapter, then 15 min' — leaving the chapter starts a
  15-min countdown instead of fading (SleepState.followUpMinutes).
- Clock: the popover gains a time input + Arm — armClockTime resolves
  HH:MM to its next occurrence and reuses the minutes machinery.
- Dim: the ticker eases a pointer-events-none overlay toward 35% black
  across a countdown's final 30s; stop/finish resets it.
(Fade-out and countdown labels already existed — §3 built them.)
2026-10-23 11:06:42 -04:00
John O'Keefe 4769e832c4 fix(web): favicon missing on the audiobook listening page
audio_reader.templ's <head> was the only page head without the
favicon link — the tab icon vanished the moment you opened a
listener. Standard <link rel=icon> added, matching every sibling
page.
2026-10-23 10:43:10 -04:00
John O'Keefe 03c3e151dc feat(audio-player): gear-menu default speed + split shortcuts into Mouse/Touch (plan §12)
Release / build-and-push (push) Successful in 3m13s
The Player settings popover gains a 'Default playback speed' row
beside autoplay — a compact rate select, always visible (the Speed ▾
footer hides when current == default, which made the setting
undiscoverable at 1x/1×); same setDefaultSpeed PUT. The shortcuts
dialog splits its pointer section into MOUSE (click/drag/shift/wheel)
and TOUCH (drag/pull-away) — the bar has two scrub systems and the
sections now say so.
2026-10-22 15:39:14 -04:00
John O'Keefe 3576a4415a feat(audio-player): per-book playback speed + user default (plan §8)
Speed becomes a per-USER-per-BOOK preference that syncs across
devices, with a user-level default for books not yet played:
- new audio_playback_speed table (mirrors reading_speed's shape; a
  preference, NOT a measurement and NOT a reading_progress column) +
  users.default_audio_speed, schema in place
- chapters endpoint resolves per-book row → user default → 1.0 and
  ships playback_speed + default_playback_speed (zero extra round
  trips; default powers the dropdown marker)
- PUT /api/media-items/:id/audio-speed upserts (range-checked; the
  client SPEEDS list stays the real gate; library visibility gates
  writes)
- the user default rides the profile path like theme/timezone:
  profile form gains a 'Default Playback Speed' select (server-side
  SpeedOptions mirrors the web SPEEDS list), GET /profile carries it
- web: restorePosition applies the resolved server speed (localStorage
  audio_speed demotes to a first-paint cache); setSpeed PUTs
  fire-and-forget with a failure toast; the Speed dropdown gains a
  '(default)' row marker and a 'Make N× my default' action with a
  success toast

ALSO fixes a pre-existing profile-page bug surfaced by the §8 form
verification: the password section's current_password input was
required INSIDE the shared form, so HTML5 validation blocked every
Save Changes submission (dead since the shell migration). The
password flow keeps its own gates (disabled-button checkers + server
400).

Integration test TestAudioPlaybackSpeed pins the full contract (unset
→ 1x, upsert resolution, range rejection, default fallback for
row-less books, default updates re-satisfying unset books). Verified
live: 9 Playwright checks.
2026-10-22 15:01:41 -04:00
John O'Keefe 3325b64463 fix(audio-player): precision rail rendered transparent
Alpine :style string bindings REPLACE the element's style attribute —
the rail's static background/border/z-index were wiped the moment the
binding evaluated, leaving bare text over the transports. Fold all
declarations into the single :style binding.
2026-10-22 14:10:42 -04:00
John O'Keefe a9430f3c06 feat(audio-player): end-time label toggles total / book-left / chapter-left (plan §10 web)
Clicking the end time cycles the three display modes, persisted per
device in localStorage (audio_time_display). Pure helpers (mode list,
normalize, cycle, endTimeLabel with 0-clamp and no-chapters fallback
to book remaining) + vitest; aria-label announces the current mode and
the next. Remaining values track the scrub cursor mid-drag. 140 unit
tests; 15 Playwright checks on KJV + Gardens.
2026-10-22 13:11:16 -04:00
John O'Keefe 96fd2aad42 feat(audio-player): scrub interaction rework — free-flow drag, pixel magnetism, touch pull-away precision (plan §9)
Mouse: drag is free-flow 1:1 (rail only on Shift+drag, now with
window from/to edge labels); clicks land with pixel magnetism
(MAGNET_RADIUS_PX=10) — the old 5-second threshold was sub-pixel on
long books and never fired, which was the 'chapter scrub isn't
accurate' bug. Hover inside the radius locks the tooltip to the line
with a highlight. Shift+click jumps to the nearest line from anywhere.

Touch: drag is an incremental cursor (applyDrag) whose scale tightens
continuously as the finger pulls off the bar (dragScale, exponential
10^(-|dy|/80px)) — the rail is the feedback: window shrinks with the
zoom (touchRailSpan) and lifts toward the finger (railLiftPx).

Dialog retitled 'Shortcuts & interactions' with a Mouse & touch
section driven by POINTER_GUIDE (audio-keys.ts). Old seconds-threshold
snapToChapter deleted. 133 unit tests; 19 Playwright checks on the
KJV (1189ch) and Gardens (34ch) books.
2026-10-22 13:05:10 -04:00
John O'Keefe 1f30602c23 feat(audio-player): progress bar overhaul — ticks, buffer, wheel, tooltip, precision rail (plan §1)
The scrubber becomes a layered bar: the native <input type=range> stays
on top for a11y (focus, arrow keys, aria — its track goes transparent
via static page CSS) while the layers beneath render the state:

- Chapter ticks: density-decimated (denseTicks keeps only marks >= 4px
  apart) so an 1189-chapter 86-hour book gets ~100 readable marks
  instead of a solid mass; the current chapter's tick is tinted.
- Buffered span: the ACTIVE track's buffered-ahead end mapped into the
  global bar (buffers are per-track and never span the whole bar).
  bufferedAheadEnd reads the range containing the playhead — a huge
  m4b's moov index at the file tail is its own buffered range and must
  not read as 'the whole book is buffered'. Rides progress + the
  events that demonstrably fire for Range-served files that go
  network-idle while paused (timeupdate/canplay/loadedmetadata/playing).
- Wheel over the bar: ±5s nudge, Shift = ±60s.
- Hover tooltip: time + chapter title under the pointer.
- Precision rail: while dragging, a ±5-minute window floats below the
  bar (fixed anchor; release and re-grab to travel far) and the drag
  maps at minutes-per-pixel instead of hours-per-pixel — the whole
  point for 80h+ books. Click snaps to a chapter line within ~5s
  (snapToChapter).

Pointer handling is ours end-to-end: pointerdown maps hours-per-pixel
+ snaps, captured moves remap through the rail, release seeks. The
native drag default is prevented so the two mappings never fight;
keyboard arrows keep their native step and seek via change.

Time labels get reserved width — the bar no longer resizes when the
elapsed label grows from 0:00 to 43:07 mid-drag (caught by the rail
drag check measuring a 25% mapping drift).

Pure math in audio-scrubber.ts: pointer mapping, chapter lookup, snap,
tick density, per-track buffer→global span, rail window/mapping/ticks.
Unit: 16 new tests including both book shapes (86h/1189ch and
26h/34ch). Live: 23 Playwright checks on KJV (folder) and Gardens of
the Moon (single-file m4b); zip books share the folder manifest shape
(no zip fixture exists in the dev library). Probe user deleted.
2026-10-22 09:49:02 -04:00
John O'Keefe 18bed79d3a feat(audio-player): settings popover + autoplay-on-open (plan §4)
Header gear (beside the keyboard icon) opens a 'Player settings'
popover — one option row today, structured for more: 'Autoplay on
open', a role=switch toggle persisted to localStorage audio_autoplay
('1'/'0', default off) via the normalizeAutoplay one-source helper.

maybeAutoplay() runs strictly AFTER restorePosition resolves: the
?loc=audio:<seconds> deep link keeps winning on position; the setting
only decides whether playback starts. A browser autoplay-policy
rejection (or any play() failure) is swallowed silently — the user
just taps play; the unhandled-rejection trap in init would otherwise
shout 'REJ:' into the title. settingsOpen joins closeTopSurface, so
Esc closes the panel with the other surfaces.

Unit: normalizeAutoplay tests. Live: 13 Playwright checks — default
off stays paused, toggle + persistence, reload autoplays with no
gesture, deep link lands at 600s global while autoplay starts, off
stays paused, and a stubbed NotAllowedError proves the silent
swallow; probe user created and deleted.
2026-10-22 09:04:03 -04:00
John O'Keefe fe7eab91e8 feat(audio-player): sleep timer — moon pill, countdown, 5s fade then pause (plan §3)
Off / 5 / 10 / 15 / 30 / 45 / 60 minutes / end-of-chapter. The moon
pill sits in the settings strip between Bookmark and Volume and shows
the live mm:ss countdown while armed (or 'End of chapter'); the
dropdown's armed row highlights like Skip/Speed.

audio-sleep.ts is the pure, DOM-free state machine (arm/due/fired/
fade-ramp/label + the SLEEP_CHOICES vocabulary the templ dropdown
iterates). The component owns the timers: a 1s countdown ticker in
minutes mode, and a ~5s element-volume fade on expiry that ends in
audio.pause() — the SAME pause listener a manual pause fires, so the
progress push and media-session state land. The user's configured
volume is restored right after the pause, and the fade operates on
the element's volume the user controls, not a parallel path.

End-of-chapter fires when the armed chapter is left — natural
crossing (tickChapter) or a manual jump (jumpToChapter) alike. Manual
play during a fade aborts the expiry (you're awake, you asked);
stopSleep restores any partial fade. Per-session ONLY: nothing
touches localStorage — the reload check proves it starts Off.

sleepOpen joins closeTopSurface's dropdown branch, so Esc closes it
with the other settings-strip surfaces. Muted (or zero-volume) expiry
skips the pantomime and pauses immediately.

Unit: audio-sleep.test.ts (deadline mechanics, chapter-fired
semantics both directions, fade ramp clamps, label formatting,
SLEEP_CHOICES integrity). Live: 24 Playwright checks on the KJV book
— dropdown, countdown ticking, armed-row highlight, end-of-chapter
fire + reset, internal 3s deadline fade→pause→restore, play-abort,
reload-starts-Off; probe user created and deleted.
2026-10-22 08:40:54 -04:00
John O'Keefe 295c4b0664 feat(audio-player): keyboard shortcuts — declarative keymap, dialog, media keys (plan §2)
Space/Enter (body-focus only), H/L + arrows skip, J/K + PgDn/PgUp
chapter steps, B bookmark capture (note box auto-focuses, Enter saves
even when blank, Shift+Enter inserts a newline, Esc cancels),
Alt+arrows / Alt+H/L speed steps, up/down volume with a brief OSD,
M mute, [ ] skip-duration cycle, ? shortcut dialog, Esc closes the
top-most surface (help → capture → note edit → drawers → dropdowns).

audio-keys.ts is the ONE declarative table: resolveShortcut() drives
the document keydown dispatcher and visibleKeymap() renders the
templ dialog (x-for over the exposed keymap — templ cannot import
TS). Hardware media keys ride the existing MediaSession handlers;
the Media* keydown codes resolve through the same table to the SAME
methods, so both layers stay one behavior.

Guards mirror the ebook reader: typing in any
INPUT/TEXTAREA/contentEditable never triggers shortcuts (bookmark
notes included), a focused button keeps Space/Enter for itself, and
the focused scrubber keeps its arrows (its own @change seeks — a
native arrow nudge moves 0.08s, not a 10s skip).

SKIP_SECONDS consolidates to one const beside SPEEDS (the templ skip
dropdown now iterates skipChoices; [ ] cycles via cycleSkip()) — the
same one-source pattern as the §5 speed-list consolidation.

Unit: audio-keys.test.ts (resolution, Alt disambiguation, media
keys, keymap integrity and conflict-freedom, dialog visibility) and
cycleSkip tests. Live: 39 Playwright checks on the KJV folder book
covering every binding, the guards, dialog and OSD; probe user
created and deleted.
2026-10-22 08:15:06 -04:00
John O'Keefe db96ea91cd refactor(audio-player): one source for the speed list (plan §5)
The rate list was duplicated — hardcoded in the templ x-for AND
implicitly in audio.ts — and phase 2's keyboard stepping would have
needed a third copy. SPEEDS is now the single exported const (the
agreed nine values, 0.5 through 3); the component exposes it as
'speeds' for the dropdown's x-for (templ cannot import TS), and
stepSpeed() walks the same array for the future audio-keys consumer.

normalizeSpeed() maps persisted values onto the list: garbage or
out-of-list localStorage entries fall back to 1x, and init() writes
the normalized value back so storage heals instead of staying
poisoned.

Pure logic unit-tested in audio.test.ts; live-verified with Playwright
on the KJV folder book: nine options render, pick/reload/persist and
the stale-value fallback all behave, button label and
audio.playbackRate agree in every case.
2026-10-21 21:08:27 -04:00
John O'Keefe 408a972665 fix(audio-player): playback speed silently reverted to 1x on every reload
restorePosition() swaps the <audio> element's src (manifest track for
multi-file books, whole file for single-file, plain URL on offline
start), and loading new media resets playbackRate to the element
default. init() had applied the persisted speed before any src
existed, so after the swap every load actually played at 1x while the
Speed pill still showed the stored value — pick 1.5x, reload, and
audio was back at 1x.

The seekTo() src-swap path already re-applied the rate; the initial
load paths now do the same (manifest branch + offline-catch branch).
Surfaced by the phase-1 speed-list browser check: button label and
audio.playbackRate disagreed after reload.
2026-10-21 21:08:06 -04:00
John O'Keefe 6508abc00f docs(agents): the doctrine — tests never run against a real server
Codify the standing rule at the top of the Tests section: the dev DB is
the test environment, and the invariant every test must uphold is the
testuser experience — its settings, scans, and reading data must be
exactly as the suite found them. The helpers exist to enforce this;
when a test needs protection the helpers don't provide, expand the
helpers rather than hand-rolling.

New known-gotchas from the 2026-10-21 incident cluster:
7. scanner re-extraction is write-after-success — audio_metadata is
   guarded on both writers; new scan-owned column writes need the same
   guard.
8. templ double-escapes style attribute values (sanitizer + EscapeString)
   — emit quote-free CSS via unquoted url() tokens through SafeCSS.
9. vp test runs the vite config — build-hook plugins fire in the test
   context too; outDir-mutating plugins need an "am I a build?" guard.
2026-10-21 20:25:26 -04:00
John O'Keefe 3ed852515b test(helpers): setSystemSettingRestored — leave shared settings as found
The testuser-experience rule: the suite must leave shared settings
exactly as it found them. setSystemSettingRestored snapshots the prior
system_settings row, writes through the sanctioned API path (the only
one that reloads the settings registry — raw SQL leaves the cache
stale), and registers a cleanup restoring what WAS there, never a
hardcoded value.

setExtractorVersion now rides it, so the ev-pin tests no longer
restore scan_extractor_version to a hardcoded 1: a deliberate dev bump
of the extractor version survives a suite run instead of being
silently cancelled (the live dev server obeys that setting on its next
poll and would stop re-extracting parser fixes). The now-redundant
explicit t.Cleanup restores are removed from
TestExtractorVersionReextract and pinExtractorVersionOne.
2026-10-21 20:24:44 -04:00
John O'Keefe f62b46fb15 test(integration): route regression tests for the audio_metadata guards
Three tests in scanner_audio_guard_test.go pin the routes the wipes
traveled; each drives a real scan/rescan/edit over ffmpeg-rendered
fixtures and asserts the payload survives with duration and manifest
intact:

- TestRescanMediaItemPreservesAudioMetadata: folder book through the
  focused payload writer.
- TestRescanMediaItemPreservesAudioMetadataSingleFileM4B: the incident
  class verbatim — single-file m4b (no layout key) routes through the
  generic updater; fixture rendered with ffmpeg AAC/ipod
  (writeTestTrackM4B).
- TestMetadataEditorSavePreservesAudioMetadata: the metadata editor's
  full-row save through PUT /api/media-items/:id.

Verified to bite: with the handler fix stashed, the editor test fails
with "metadata edit must never NULL audio_metadata"; with it, green.
2026-10-21 20:24:09 -04:00
John O'Keefe 35d8ba32e5 fix(media): metadata editor save no longer wipes audio_metadata
PUT /api/media-items/:id (the metadata editor) writes the FULL row via
UpdateMediaItem, whose query sets audio_metadata unconditionally — and
audio_metadata has no editor field, so the params literal left it nil
and every save NULLed the payload. A plain metadata edit (genre tweak,
title rename) dropped any audiobook back to "1 second long" with no
track manifest: the same incident class as the scanner wipe, sitting
on the most-used admin path in the app.

Carry existing.AudioMetadata through the write. Write-after-success:
only the audio chain (probe-backed extraction /
UpdateMediaItemAudioBookPayload) ever replaces that column.
2026-10-21 20:23:42 -04:00
John O'Keefe 80bfc1a77b fix(scanner): re-extraction is write-after-success — no NULL audio_metadata
The 2026-10-21 wipe: a re-extraction pass NULLed audio_metadata on the
m4b audiobooks — every affected book dropped its chapters, track
manifest and duration and displayed as 1 second long (the reader also
lost its manifest, since single-file books synthesize it from the
payload).

Guards on both writers of that column:
- updateMediaItemWithMetadata (generic full-row pass): when the new
  extraction carries no audio payload, keep existing.AudioMetadata.
  Single-file m4b books route here, and a failed/absent extraction
  leaves AudioMeta nil.
- updateAudioBookItem (focused payload write): an empty payload (no
  tracks, no chapters, no duration) over a populated row is a failed
  extraction — refuse the write with a loud warning and let the next
  scan retry.

audioPayloadIsEmpty() pins the empty-payload predicate (unit-tested):
the column is only ever replaced by fresh data from a real audio
extraction, never by absence of one.
2026-10-21 20:22:57 -04:00
John O'Keefe 970beee4b8 fix(pwa): add the manifest icons the manifest always referenced
manifest.json has referenced /static/icons/icon-{192,512}.png since
April, but the directory was never committed — ~100 404s per browsing
session and a broken PWA install. Rendered both sizes from the
existing favicon book glyph on the Tokyo Night palette
(#1a1b26 background, #7aa2f7 stroke).
2026-10-21 20:22:28 -04:00
John O'Keefe fa5b952c54 fix(audio-reader): valid doctype — page loaded in quirks mode
audio_reader.templ was the only template emitting <!DOCTYPE html/>
(trailing slash is a parse error), which put the audio player page in
quirks mode — document.compatMode was BackCompat while every other
page is CSS1Compat. Quirks layout rules are a standing hazard for the
player's h-screen/flex page even where it happens to render. Drop the
slash.
2026-10-21 20:21:58 -04:00
John O'Keefe ff17d14694 fix(book-detail): hero backdrop — templ double-escapes style values
templ's codegen wraps style attribute expressions in a sanitizer AND
templ.EscapeString, so any quote character — even inside templ.SafeCSS
— reached the DOM as literal &#39; text. The hero backdrop's url('...')
never parsed: no blurred cover on any book detail page, and Firefox
resolved the mangled token as a relative URL, fetching /media/& (500)
once per page load.

Fix: emit quote-free CSS — an unquoted url(...) token built through a
new cssURL() helper that percent-encodes the characters that would
break it (space, quotes, parens, backslash). Stored cover paths are
already percent-encoded URL paths, so the escaping is defensive only.
2026-10-21 20:21:33 -04:00
John O'Keefe 807899b137 fix(reader): arrows stay page turns while the progress slider holds focus
Clicking the bottom progress slider left it focused, and the reader's
keydown guard treats INPUT focus as typing — so arrow keys stepped the
range input instead of turning pages. With step="any" each press moved
~1% of the book and key repeat crossed the whole book in seconds; the
debounced progress saves then persisted every wild landing (positions
scattered from page 5 to 285 to 100%, "progress no longer saves
correctly after page turns").

Two layers:
- reader.templ: all three progress sliders blur on pointerup, so a
  mouse scrub no longer leaves focus behind.
- reader.ts handleKeydown: ArrowLeft/ArrowRight on a focused progress
  slider preventDefault, blur, and fall through to the page-turn
  handlers; every other key still belongs to the control, so a
  keyboard user who Tabbed to the slider keeps arrows for it.
2026-10-21 20:21:07 -04:00
John O'Keefe 6791d611f0 fix(vite): guard cleanStaleChunks — vp test deleted every built chunk
The cleanStaleChunks plugin lives in the shared vite config, so its
closeBundle hook also fired under `vp test` — where generateBundle never
runs, produced stayed empty, and the sweep deleted every hashed chunk
in web/static (28 files → 3). The entries still referenced their old
imports, so every page 404'd and Alpine never started: the whole reader
and media system appeared dead after merely running the test suite.

Guard: skip the sweep when produced.size === 0 — not a build run, no
build output to clean. Verified: build → 28 files, test → 28 files;
back-to-back builds stay byte-identical.
2026-10-21 20:20:41 -04:00
John O'Keefe e5315f1b94 test(web): vitest suites for markdown, filters, password rules, api, storage, toast
Test infrastructure uses the vite-plus stack: vp test (bundled Vitest 5),
jsdom environment configured in vite.config.ts, colocated web/src/*.test.ts
files that tsc type-checks, vp fmt formats, and the bundler never imports.

- markdown.test.ts pins the notes XSS defenses: parser-level html escaping,
  DOMPurify FORBID_TAGS, scheme checks, footnote rendering, html->markdown
  round-trips incl. the headingless-table rule.
- filter_builder.test.ts pins the deep-link wire contract (ruleToFlatParam),
  smart-serialization rules (isFlatable), and pruneIncomplete semantics.
- password_validation.test.ts covers the rules fetch + fallback and the
  full submit-gating matrix through the real DOM flow (debounce included).
- api.test.ts pins response/error contracts incl. the HTTP-status and
  Unknown-error fallbacks and the 404-as-null progress contract.
- storage.test.ts covers token/theme/library round-trips, the __all__
  sentinel, and the SSR cookie.
- toast.test.ts pins config completeness for every ToastType (the missing
  'warning' entry regression) and render behavior.

Found + fixed while writing these: the no-useless-escape fix in
d7d14957 had de-escaped the ']' in hasSpecialChar's character class,
closing it early — special-character validation silently stopped
matching most specials. The new password suite caught it immediately.

jsdom chosen over happy-dom after empirical comparison: DOMPurify's
sanitizer output is mangled under happy-dom (forbidden tags survive),
which would have made the security suite test nothing.

Note: AGENTS.md gains a 'Web (TypeScript) tests' section documenting the
workflow and the happy-dom gotcha.
2026-10-21 15:34:29 -04:00
50 changed files with 4714 additions and 627 deletions

No files matched your search

+49
View File
@@ -71,6 +71,16 @@ were name-only across ALL libraries. Fixed; do not regress.
## Tests ## Tests
- **THE DOCTRINE — tests are never run against a real/production
server.** The dev DB is the test environment, and the invariant every
test must uphold is: **leave the testuser experience alone** — its
settings, scans, and reading data must be exactly as the suite found
them. The helpers exist to enforce this; if a test needs protection
the helpers don't provide, EXPAND THE HELPERS rather than hand-rolling
(2026-10-21: `setSystemSettingRestored` — snapshot-and-restore for
system_settings — exists because a hardcoded ev restore could clobber
a deliberate dev bump, and the live dev server obeys that setting on
its next poll).
- Use `setupTestServer()` from `cmd/server/tests/test_helpers_test.go` - Use `setupTestServer()` from `cmd/server/tests/test_helpers_test.go`
(see the Conventions section below for the pool/cleanup rules). (see the Conventions section below for the pool/cleanup rules).
- Integration tests run against the SHARED dev database: never write - Integration tests run against the SHARED dev database: never write
@@ -113,6 +123,23 @@ were name-only across ALL libraries. Fixed; do not regress.
tested with synthetic ffprobeReport values. Recipes: tested with synthetic ffprobeReport values. Recipes:
docs/contributing/development.md → Media fixtures. docs/contributing/development.md → Media fixtures.
## Web (TypeScript) tests
- `npm run test` (vp test) runs the vitest suite: colocated `web/src/*.test.ts`
files, jsdom environment (configured in vite.config.ts `test`). Do NOT switch
to happy-dom — DOMPurify's sanitizer output is mangled under it, which makes
the markdown security suite test nothing (verified empirically; jsdom is the
documented fallback for exactly this reason).
- Static gates: `npx tsc --noEmit`, `npx vp check web/src` (format + lint),
`npx vp fmt web/src` to apply formatting.
- Tests are never bundled (build entries are main.ts/reader.ts only). When
touching build config, re-prove it: back-to-back `npm run build:ts` runs must
produce byte-identical hashes, and no test-only strings may appear in
web/static.
- markdown.test.ts pins the notes XSS defenses (FORBID_TAGS, scheme checks,
parser-level html escaping). Loosening any assertion there is a security
decision, not cleanup.
## Timezones ## Timezones
- The server owns display conversion: clients send instants (RFC3339), - The server owns display conversion: clients send instants (RFC3339),
@@ -206,3 +233,25 @@ of a server bug: stop and flag the user.
test pools use `max_conns=1`; never create a `pgxpool` per subtest test pools use `max_conns=1`; never create a `pgxpool` per subtest
(connection exhaustion); use `t.Cleanup()`-based helpers; table-driven (connection exhaustion); use `t.Cleanup()`-based helpers; table-driven
subtests over duplicate functions. subtests over duplicate functions.
7. **Scanner re-extraction is write-after-success** — a failed/empty
extraction must NEVER clear scan-derived columns. `updateMediaItem`
(generic full-row path) preserves `existing.AudioMetadata` when the
new extraction carries no audio payload, and `updateAudioBookItem`
refuses empty payloads over populated rows (both guarded 2026-10-21
after an ev-gated re-extract NULLed every multi-file audiobook's
audio_metadata — books displayed as 1 second long and lost their
track manifests). New scan-owned column writes need the same guard.
8. **templ double-escapes style attribute values** — the codegen wraps
style exprs in a sanitizer AND `templ.EscapeString`, so ANY quote
character (even inside `templ.SafeCSS`) reaches the DOM as literal
`&#39;` text and the CSS never parses (2026-10-21: every book-detail
hero backdrop broken + Firefox fetched `/media/&` → 500 spam).
Emit quote-free CSS: unquoted `url(...)` tokens via `SafeCSS` +
`cssURL()` in book_detail.templ.
9. **`vp test` runs the vite config** — plugins with build hooks fire
in the test context too. `cleanStaleChunks` guards on
`produced.size === 0` precisely because a bare sweep deleted every
hashed chunk in `web/static` when run under vitest (2026-10-21:
`npm run test` gutted the build; every page 404'd its imports).
Any future outDir-mutating plugin needs the same "am I a build?"
guard.
+172
View File
@@ -0,0 +1,172 @@
package main
// Playback speed preferences (player plan §8): a per-user PER-BOOK
// speed plus a user-level default, resolved at the chapters endpoint
// (per-book row → users.default_audio_speed → 1.0). All speed writes
// ride the fresh per-test regular user — the shared admin account is
// never touched (AGENTS doctrine). Fixture books live in test-named
// libraries that cascade away with the library delete.
import (
"encoding/json"
"fmt"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
)
// writeTestTrackTone renders a short sine mp3 at a GIVEN frequency so
// multiple fixture books never collide on the scanner's content dedup.
// Falls back to dummy bytes (ingest is ffprobe-optional) like
// writeTestTrack.
func writeTestTrackTone(t *testing.T, path string, hz int) {
t.Helper()
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
if ffmpeg, err := exec.LookPath("ffmpeg"); err == nil {
cmd := exec.Command(ffmpeg, "-y", "-v", "error", "-f", "lavfi",
"-i", fmt.Sprintf("sine=frequency=%d:duration=0.3", hz), "-codec:a", "libmp3lame", "-b:a", "32k", path)
if out, err := cmd.CombinedOutput(); err == nil {
return
} else {
t.Logf("ffmpeg track render failed (%s), falling back to dummy bytes: %s", err, out)
}
}
require.NoError(t, os.WriteFile(path, []byte(fmt.Sprintf("ID3 dummy audio %d", hz)), 0o644))
}
func TestAudioPlaybackSpeed(t *testing.T) {
setup := setupTestServer(t)
t.Cleanup(func() { _ = setup.Close() })
admin, _ := moveTestAdminID(t, setup)
libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Audio Speed %d", time.Now().UnixNano()))
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
writeTestTrack(t, filepath.Join(folder, "Speed Book", "01 one.mp3"))
// 552 Hz (not the shared helper's 440) so the scanner's sha-256
// dedup doesn't skip the second book as a byte-duplicate.
writeTestTrackTone(t, filepath.Join(folder, "Speed Book Two", "01 one.mp3"), 552)
scanTestFolder(t, setup, admin, folder)
// Grouping depends on track count (single-track folders ingest as
// single-file items whose path IS the file; multi-track folders get
// the folder path) — find by the library-relative path CONTAINING
// the book folder so the test holds either way.
var bookPath, book2Path string
for _, item := range listLibraryAudioItems(t, setup, libID) {
p := filepath.ToSlash(item.FilePath)
t.Logf("ingested item path: %s", p)
if strings.Contains(p, "Speed Book Two") && book2Path == "" {
book2Path = p
} else if strings.Contains(p, "Speed Book/") && bookPath == "" {
bookPath = p
}
}
require.NotEmpty(t, bookPath, "Speed Book item must exist")
require.NotEmpty(t, book2Path, "Speed Book Two item must exist")
book, found := findAudioItemByPath(t, setup, libID, bookPath)
require.True(t, found)
bookID := uuid.UUID(book.ID.Bytes)
book2, found := findAudioItemByPath(t, setup, libID, book2Path)
require.True(t, found)
book2ID := uuid.UUID(book2.ID.Bytes)
client := &http.Client{}
token := setup.RegularToken
authed := func(method, url, body string) (*http.Response, func()) {
var req *http.Request
if body == "" {
req, _ = http.NewRequest(method, url, nil)
} else {
req, _ = http.NewRequest(method, url, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := client.Do(req)
require.NoError(t, err)
return resp, func() { _ = resp.Body.Close() }
}
getSpeeds := func(itemID uuid.UUID) (playback, def float64) {
resp, closer := authed("GET", setup.Server.URL+"/api/media-items/"+itemID.String()+"/chapters", "")
defer closer()
require.Equal(t, http.StatusOK, resp.StatusCode)
var data struct {
PlaybackSpeed float64 `json:"playback_speed"`
DefaultPlaybackSpeed float64 `json:"default_playback_speed"`
}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&data))
return data.PlaybackSpeed, data.DefaultPlaybackSpeed
}
t.Run("unset resolves to 1x", func(t *testing.T) {
playback, def := getSpeeds(bookID)
require.Equal(t, 1.0, playback)
require.Equal(t, 1.0, def)
})
t.Run("put per-book speed upserts and resolves", func(t *testing.T) {
resp, closer := authed("PUT", setup.Server.URL+"/api/media-items/"+bookID.String()+"/audio-speed", `{"speed":1.5}`)
defer closer()
require.Equal(t, http.StatusOK, resp.StatusCode)
var saved struct {
PlaybackSpeed float64 `json:"playback_speed"`
}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&saved))
require.Equal(t, 1.5, saved.PlaybackSpeed)
playback, def := getSpeeds(bookID)
require.Equal(t, 1.5, playback)
require.Equal(t, 1.0, def) // user default untouched
})
t.Run("out of range speed is rejected", func(t *testing.T) {
for _, body := range []string{`{"speed":99}`, `{"speed":0}`, `{"speed":0.1}`} {
resp, closer := authed("PUT", setup.Server.URL+"/api/media-items/"+bookID.String()+"/audio-speed", body)
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
closer()
}
// The stored speed is untouched by the rejects.
playback, _ := getSpeeds(bookID)
require.Equal(t, 1.5, playback)
})
t.Run("profile default applies to books without their own speed", func(t *testing.T) {
resp, closer := authed("PUT", setup.Server.URL+"/api/auth/profile", `{"default_audio_speed":1.25}`)
defer closer()
require.Equal(t, http.StatusOK, resp.StatusCode)
// Per-book row wins over the default on book one...
playback, def := getSpeeds(bookID)
require.Equal(t, 1.5, playback)
require.Equal(t, 1.25, def)
// ...book two has no row and picks the default up.
playback2, _ := getSpeeds(book2ID)
require.Equal(t, 1.25, playback2)
// GET /profile carries it for the profile form.
resp2, closer2 := authed("GET", setup.Server.URL+"/api/auth/profile", "")
defer closer2()
var profile struct {
DefaultAudioSpeed float64 `json:"default_audio_speed"`
}
require.NoError(t, json.NewDecoder(resp2.Body).Decode(&profile))
require.Equal(t, 1.25, profile.DefaultAudioSpeed)
})
t.Run("updating the default re-satisfies unset books", func(t *testing.T) {
resp, closer := authed("PUT", setup.Server.URL+"/api/auth/profile", `{"default_audio_speed":0.75}`)
defer closer()
require.Equal(t, http.StatusOK, resp.StatusCode)
playback2, _ := getSpeeds(book2ID)
require.Equal(t, 0.75, playback2)
playback1, _ := getSpeeds(bookID)
require.Equal(t, 1.5, playback1) // per-book pin survives
})
}
@@ -47,6 +47,24 @@ func writeTestTrack(t *testing.T, path string) {
require.NoError(t, os.WriteFile(path, []byte("ID3 dummy audio"), 0o644)) require.NoError(t, os.WriteFile(path, []byte("ID3 dummy audio"), 0o644))
} }
// writeTestTrackM4B is writeTestTrack for the m4b (AAC/ipod muxer)
// container — the audiobook format whose rescan route is under guard.
// ffmpeg needs -f ipod for the .m4b extension; falls back to dummy bytes.
func writeTestTrackM4B(t *testing.T, path string) {
t.Helper()
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
if ffmpeg, err := exec.LookPath("ffmpeg"); err == nil {
cmd := exec.Command(ffmpeg, "-y", "-v", "error", "-f", "lavfi",
"-i", "sine=frequency=440:duration=0.3", "-codec:a", "aac", "-b:a", "32k", "-f", "ipod", path)
if out, err := cmd.CombinedOutput(); err == nil {
return
} else {
t.Logf("ffmpeg m4b render failed (%s), falling back to dummy bytes: %s", err, out)
}
}
require.NoError(t, os.WriteFile(path, []byte("dummy m4b audio"), 0o644))
}
// audioTestLibrary builds an audiobooks-type library with a fresh temp // audioTestLibrary builds an audiobooks-type library with a fresh temp
// folder, returning its UUID and the folder path. // folder, returning its UUID and the folder path.
func audioTestLibrary(t *testing.T, setup *TestServerSetup, name string) (pgtype.UUID, string) { func audioTestLibrary(t *testing.T, setup *TestServerSetup, name string) (pgtype.UUID, string) {
+9 -12
View File
@@ -17,11 +17,9 @@ package main
// equal → stable (second corruption persists) // equal → stable (second corruption persists)
import ( import (
"bytes"
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http"
"os" "os"
"path/filepath" "path/filepath"
"strconv" "strconv"
@@ -58,14 +56,11 @@ func corruptItemColumn(t *testing.T, setup *TestServerSetup, itemID pgtype.UUID,
func setExtractorVersion(t *testing.T, setup *TestServerSetup, v int) { func setExtractorVersion(t *testing.T, setup *TestServerSetup, v int) {
t.Helper() t.Helper()
body, _ := json.Marshal(map[string]string{"key": "scan_extractor_version", "value": strconv.Itoa(v)}) // Restore-to-prior (not a hardcoded value): the shared dev DB's
req, _ := http.NewRequest("PUT", setup.Server.URL+"/api/system/settings", bytes.NewBuffer(body)) // setting must survive the suite exactly as the test found it — if a
req.Header.Set("Content-Type", "application/json") // parser-fix bump was in flight, a hardcoded 1 would silently cancel
req.Header.Set("Authorization", "Bearer "+setup.Token) // it and the live server would stop re-extracting.
resp, err := http.DefaultClient.Do(req) setSystemSettingRestored(t, setup, "scan_extractor_version", strconv.Itoa(v))
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
require.Equal(t, http.StatusOK, resp.StatusCode, "settings API is the only sanctioned write path")
} }
func TestExtractorVersionReextract(t *testing.T) { func TestExtractorVersionReextract(t *testing.T) {
@@ -123,7 +118,8 @@ func TestExtractorVersionReextract(t *testing.T) {
// re-extract once: the corruption heals and the fresh manifest // re-extract once: the corruption heals and the fresh manifest
// carries ev=2. // carries ev=2.
setExtractorVersion(t, setup, 2) setExtractorVersion(t, setup, 2)
t.Cleanup(func() { setExtractorVersion(t, setup, 1) }) // Prior-value restore rides inside setExtractorVersion — never a
// hardcoded 1, so a deliberate dev bump survives the suite.
scanTestFolder(t, setup, admin, folder) scanTestFolder(t, setup, admin, folder)
item, found = findAudioItemByPath(t, setup, libID, filepath.ToSlash("EV Probe Author/EV Probe Book/EV Probe Book.epub")) item, found = findAudioItemByPath(t, setup, libID, filepath.ToSlash("EV Probe Author/EV Probe Book/EV Probe Book.epub"))
require.True(t, found) require.True(t, found)
@@ -168,7 +164,8 @@ func TestExtractorVersionMultifile(t *testing.T) {
// Bump → the ev gate fires through multifileSidecarChanged → the // Bump → the ev gate fires through multifileSidecarChanged → the
// editorial apply re-derives the title from the folder basename. // editorial apply re-derives the title from the folder basename.
setExtractorVersion(t, setup, 2) setExtractorVersion(t, setup, 2)
t.Cleanup(func() { setExtractorVersion(t, setup, 1) }) // Prior-value restore rides inside setExtractorVersion — never a
// hardcoded 1, so a deliberate dev bump survives the suite.
scanTestFolder(t, setup, admin, folder) scanTestFolder(t, setup, admin, folder)
book, found = findAudioItemByPath(t, setup, libID, filepath.ToSlash("EV Audio Author/EV Audio Title")) book, found = findAudioItemByPath(t, setup, libID, filepath.ToSlash("EV Audio Author/EV Audio Title"))
require.True(t, found) require.True(t, found)
+2 -2
View File
@@ -32,11 +32,11 @@ import (
// the default 1 for the test's duration. Prior interrupted runs can // the default 1 for the test's duration. Prior interrupted runs can
// leave it bumped (an early test failure aborts before its restore // leave it bumped (an early test failure aborts before its restore
// cleanup registers), and every stamp assertion in these tests assumes // cleanup registers), and every stamp assertion in these tests assumes
// the default. The cleanup restores 1 — the repo's steady state. // the default. Restore-to-prior rides inside setExtractorVersion: the
// suite leaves the setting exactly as it found it.
func pinExtractorVersionOne(t *testing.T, setup *TestServerSetup) { func pinExtractorVersionOne(t *testing.T, setup *TestServerSetup) {
t.Helper() t.Helper()
setExtractorVersion(t, setup, 1) setExtractorVersion(t, setup, 1)
t.Cleanup(func() { setExtractorVersion(t, setup, 1) })
} }
// forceScanTestFolder runs a FORCE scan over one folder. // forceScanTestFolder runs a FORCE scan over one folder.
@@ -0,0 +1,182 @@
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"path/filepath"
"testing"
"time"
"bookhoard/internal/services"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/stretchr/testify/require"
)
// TestRescanMediaItemPreservesAudioMetadata pins the 2026-10-21 incident:
// a per-item rescan of a multi-file audiobook routes book-level metadata
// (sidecars/tags — AudioMeta nil) through the generic row update, whose
// unguarded write NULLed audio_metadata. A NULL payload drops every
// track/chapter and the duration, so the book displays as 1 second long
// and the audio reader loses its track manifest. Re-extraction must be
// write-after-success: no fresh audio payload from the audio chain means
// the existing column survives byte-for-byte.
func TestRescanMediaItemPreservesAudioMetadata(t *testing.T) {
setup := setupTestServer(t)
t.Cleanup(func() { _ = setup.Close() })
ctx := context.Background()
admin, _ := moveTestAdminID(t, setup)
libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Audio Guard %d", time.Now().UnixNano()))
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
writeTestTrack(t, filepath.Join(folder, "Guard Author", "Guard Book", "01 One.mp3"))
writeTestTrack(t, filepath.Join(folder, "Guard Author", "Guard Book", "02 Two.mp3"))
scanTestFolder(t, setup, admin, folder)
rel := filepath.ToSlash("Guard Author/Guard Book")
book, found := findAudioItemByPath(t, setup, libID, rel)
require.True(t, found, "folder book must import")
audioBytes := func(t *testing.T) []byte {
t.Helper()
var raw []byte
require.NoError(t, setup.DBPool.QueryRow(ctx,
`SELECT audio_metadata FROM media_items WHERE id = $1`, book.ID).Scan(&raw))
return raw
}
before := audioBytes(t)
require.NotNil(t, before, "initial scan must populate audio_metadata")
var payload services.AudioMetadataPayload
require.NoError(t, json.Unmarshal(before, &payload), "payload must be valid JSON")
require.Greater(t, payload.DurationSeconds, 0.0, "initial scan must carry a duration")
require.Len(t, payload.Tracks, 2, "initial scan must carry the track manifest")
scanner := services.NewMediaScanner(setup.DB)
defer func() { _ = scanner.Close() }()
scanner.SetAdminID(admin)
require.NoError(t, scanner.RescanMediaItem(ctx, book.ID, false), "per-item rescan must succeed")
after := audioBytes(t)
require.NotNil(t, after, "rescan must never NULL audio_metadata (2026-10-21 incident)")
var afterPayload services.AudioMetadataPayload
require.NoError(t, json.Unmarshal(after, &afterPayload), "surviving payload must stay valid JSON")
require.Greater(t, afterPayload.DurationSeconds, 0.0, "duration must survive the rescan")
require.Len(t, afterPayload.Tracks, 2, "track manifest must survive the rescan")
}
// TestMetadataEditorSavePreservesAudioMetadata pins the editor-side half
// of the incident: PUT /api/media-items/:id (the metadata editor) writes
// the FULL row, and audio_metadata has no editor field — an unguarded
// save NULLed every audiobook's payload on a plain metadata edit.
func TestMetadataEditorSavePreservesAudioMetadata(t *testing.T) {
setup := setupTestServer(t)
t.Cleanup(func() { _ = setup.Close() })
ctx := context.Background()
admin, _ := moveTestAdminID(t, setup)
libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Editor Guard %d", time.Now().UnixNano()))
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
writeTestTrack(t, filepath.Join(folder, "Editor Author", "Editor Book", "01 One.mp3"))
writeTestTrack(t, filepath.Join(folder, "Editor Author", "Editor Book", "02 Two.mp3"))
scanTestFolder(t, setup, admin, folder)
rel := filepath.ToSlash("Editor Author/Editor Book")
book, found := findAudioItemByPath(t, setup, libID, rel)
require.True(t, found, "folder book must import")
audioBytes := func(t *testing.T) []byte {
t.Helper()
var raw []byte
require.NoError(t, setup.DBPool.QueryRow(ctx,
`SELECT audio_metadata FROM media_items WHERE id = $1`, book.ID).Scan(&raw))
return raw
}
before := audioBytes(t)
require.NotNil(t, before, "initial scan must populate audio_metadata")
// A plain metadata edit through the editor endpoint (admin token —
// the editor is admin-only), no audio fields involved.
editorSave(t, setup, book.ID, map[string]interface{}{
"title": "Editor Book Renamed",
"cover_action": "keep",
})
after := audioBytes(t)
require.NotNil(t, after, "metadata edit must never NULL audio_metadata")
var afterPayload services.AudioMetadataPayload
require.NoError(t, json.Unmarshal(after, &afterPayload))
require.Greater(t, afterPayload.DurationSeconds, 0.0, "duration must survive the edit")
require.Len(t, afterPayload.Tracks, 2, "track manifest must survive the edit")
renamed, found := findAudioItemByPath(t, setup, libID, rel)
require.True(t, found)
require.Equal(t, "Editor Book Renamed", renamed.Title, "the edit itself must still apply")
}
// TestRescanMediaItemPreservesAudioMetadataSingleFileM4B is the incident
// class verbatim: the failing books were SINGLE-FILE m4b audiobooks
// (audio_metadata without a layout key), which RescanMediaItem routes
// through the generic per-file updater — the same writer whose unguarded
// pass NULLed their payloads. The whole flow must keep the m4b's
// duration and chapters intact.
func TestRescanMediaItemPreservesAudioMetadataSingleFileM4B(t *testing.T) {
setup := setupTestServer(t)
t.Cleanup(func() { _ = setup.Close() })
ctx := context.Background()
admin, _ := moveTestAdminID(t, setup)
libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test M4B Guard %d", time.Now().UnixNano()))
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
writeTestTrackM4B(t, filepath.Join(folder, "M4B Guard Author", "M4B Guard Book.m4b"))
scanTestFolder(t, setup, admin, folder)
rel := filepath.ToSlash("M4B Guard Author/M4B Guard Book.m4b")
book, found := findAudioItemByPath(t, setup, libID, rel)
require.True(t, found, "single-file m4b must import")
audioBytes := func(t *testing.T) []byte {
t.Helper()
var raw []byte
require.NoError(t, setup.DBPool.QueryRow(ctx,
`SELECT audio_metadata FROM media_items WHERE id = $1`, book.ID).Scan(&raw))
return raw
}
before := audioBytes(t)
require.NotNil(t, before, "initial scan must populate audio_metadata")
var beforePayload services.AudioMetadataPayload
require.NoError(t, json.Unmarshal(before, &beforePayload))
require.Greater(t, beforePayload.DurationSeconds, 0.0, "m4b probe must carry a real duration")
scanner := services.NewMediaScanner(setup.DB)
defer func() { _ = scanner.Close() }()
scanner.SetAdminID(admin)
require.NoError(t, scanner.RescanMediaItem(ctx, book.ID, false))
after := audioBytes(t)
require.NotNil(t, after, "m4b rescan must never NULL audio_metadata")
var afterPayload services.AudioMetadataPayload
require.NoError(t, json.Unmarshal(after, &afterPayload))
require.Greater(t, afterPayload.DurationSeconds, 0.0, "m4b duration must survive the rescan")
}
// editorSave drives PUT /api/media-items/:id as the admin (the metadata
// editor's endpoint; admin-only per the handler).
func editorSave(t *testing.T, setup *TestServerSetup, itemID pgtype.UUID, fields map[string]interface{}) {
t.Helper()
body, _ := json.Marshal(fields)
req, _ := http.NewRequest("PUT", setup.Server.URL+"/api/media-items/"+uuid.UUID(itemID.Bytes).String(), bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
require.Equal(t, http.StatusOK, resp.StatusCode, "metadata save must succeed")
}
+49
View File
@@ -25,6 +25,7 @@ import (
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"github.com/labstack/echo/v5" "github.com/labstack/echo/v5"
@@ -181,6 +182,54 @@ func getUploadPath() string {
return "./uploads" // Host path (left side of volume mount) return "./uploads" // Host path (left side of volume mount)
} }
// setSystemSettingRestored writes a system setting through the sanctioned
// API path and registers a cleanup that restores the value the row had
// BEFORE the write. The testuser-experience rule: the suite must leave
// shared settings exactly as it found them — a hardcoded restore would
// silently clobber a deliberate dev bump (e.g. scan_extractor_version
// pinned mid-deploy), and the live dev server obeys this setting on its
// next poll, so what tests write here reaches real scans.
//
// Writes ride the settings API (never raw SQL) because the registry
// caches values and only the API path reloads it. If the row did not
// exist before the write, the test value is left in place and the test
// log says so — there is no sanctioned delete; settings rows that
// matter (scan_extractor_version) always exist.
func setSystemSettingRestored(t *testing.T, setup *TestServerSetup, key, value string) {
t.Helper()
var prior *string
err := setup.DBPool.QueryRow(context.Background(),
`SELECT setting_value FROM system_settings WHERE setting_key = $1`, key).Scan(&prior)
if err != nil && err != pgx.ErrNoRows {
require.NoError(t, err, "failed to read prior value of %s", key)
}
setSettingViaAPI(t, setup, key, value)
if prior == nil {
t.Logf("setSystemSettingRestored: %s had no prior row; leaving %q in place after the test", key, value)
return
}
priorValue := *prior
t.Cleanup(func() {
setSettingViaAPI(t, setup, key, priorValue)
})
}
// setSettingViaAPI PUTs one setting through /api/system/settings — the
// only sanctioned write path (the settings registry reloads here).
func setSettingViaAPI(t *testing.T, setup *TestServerSetup, key, value string) {
t.Helper()
body, _ := json.Marshal(map[string]string{"key": key, "value": value})
req, _ := http.NewRequest("PUT", setup.Server.URL+"/api/system/settings", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
require.Equal(t, http.StatusOK, resp.StatusCode, "settings API is the only sanctioned write path")
}
// getCachePath returns the appropriate cache path based on runtime environment // getCachePath returns the appropriate cache path based on runtime environment
func getCachePath() string { func getCachePath() string {
// Check for explicit override first // Check for explicit override first
+23
View File
@@ -37,6 +37,11 @@ CREATE TABLE IF NOT EXISTS users (
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
); );
-- User-level default audiobook playback speed (player plan §8): books
-- without a per-book audio_playback_speed row pick this up. REAL
-- mirrors the per-book table's column.
ALTER TABLE users ADD COLUMN IF NOT EXISTS default_audio_speed REAL NOT NULL DEFAULT 1.0;
-- Create system_settings table -- Create system_settings table
CREATE TABLE IF NOT EXISTS system_settings ( CREATE TABLE IF NOT EXISTS system_settings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -1563,6 +1568,24 @@ CREATE TABLE IF NOT EXISTS reading_speed (
CREATE INDEX IF NOT EXISTS idx_reading_speed_user ON reading_speed(user_id); CREATE INDEX IF NOT EXISTS idx_reading_speed_user ON reading_speed(user_id);
CREATE INDEX IF NOT EXISTS idx_reading_speed_item ON reading_speed(media_item_id); CREATE INDEX IF NOT EXISTS idx_reading_speed_item ON reading_speed(media_item_id);
-- Per-user PER-BOOK playback speed preference (player plan §8): a
-- preference, NOT a measurement (reading_speed holds measured rates),
-- and NOT a reading_progress column (a speed tap must not create a
-- "currently reading" row). Resolution lives at the chapters endpoint:
-- per-book row → users.default_audio_speed → 1.0.
CREATE TABLE IF NOT EXISTS audio_playback_speed (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
speed REAL NOT NULL DEFAULT 1.0,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, media_item_id)
);
CREATE INDEX IF NOT EXISTS idx_audio_playback_speed_user ON audio_playback_speed(user_id);
CREATE INDEX IF NOT EXISTS idx_audio_playback_speed_item ON audio_playback_speed(media_item_id);
-- Dictionary cache (for offline use) -- Dictionary cache (for offline use)
CREATE TABLE IF NOT EXISTS dictionary_cache ( CREATE TABLE IF NOT EXISTS dictionary_cache (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+10
View File
@@ -8,6 +8,15 @@ import (
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
) )
type AudioPlaybackSpeed struct {
ID pgtype.UUID `db:"id" json:"id"`
UserID pgtype.UUID `db:"user_id" json:"user_id"`
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
Speed float32 `db:"speed" json:"speed"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
}
type CollectionItems struct { type CollectionItems struct {
ID pgtype.UUID `db:"id" json:"id"` ID pgtype.UUID `db:"id" json:"id"`
CollectionID pgtype.UUID `db:"collection_id" json:"collection_id"` CollectionID pgtype.UUID `db:"collection_id" json:"collection_id"`
@@ -587,4 +596,5 @@ type Users struct {
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"` CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
Timezone pgtype.Text `db:"timezone" json:"timezone"` Timezone pgtype.Text `db:"timezone" json:"timezone"`
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"` UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
DefaultAudioSpeed float32 `db:"default_audio_speed" json:"default_audio_speed"`
} }
+4
View File
@@ -202,6 +202,8 @@ type Querier interface {
GetAllSystemSettings(ctx context.Context) ([]GetAllSystemSettingsRow, error) GetAllSystemSettings(ctx context.Context) ([]GetAllSystemSettingsRow, error)
GetAllSystemSettingsFull(ctx context.Context) ([]SystemSettings, error) GetAllSystemSettingsFull(ctx context.Context) ([]SystemSettings, error)
GetAnnotationsForBook(ctx context.Context, arg GetAnnotationsForBookParams) ([]GetAnnotationsForBookRow, error) GetAnnotationsForBook(ctx context.Context, arg GetAnnotationsForBookParams) ([]GetAnnotationsForBookRow, error)
// Per-book playback speed preference (player plan §8).
GetAudioPlaybackSpeed(ctx context.Context, arg GetAudioPlaybackSpeedParams) (float32, error)
// Lifetime session rows for ONE book (the book-detail stats card). // Lifetime session rows for ONE book (the book-detail stats card).
GetBookReadingSessions(ctx context.Context, arg GetBookReadingSessionsParams) ([]GetBookReadingSessionsRow, error) GetBookReadingSessions(ctx context.Context, arg GetBookReadingSessionsParams) ([]GetBookReadingSessionsRow, error)
GetBooksByTag(ctx context.Context, arg GetBooksByTagParams) ([]MediaItems, error) GetBooksByTag(ctx context.Context, arg GetBooksByTagParams) ([]MediaItems, error)
@@ -696,12 +698,14 @@ type Querier interface {
UpdateSystemSetting(ctx context.Context, arg UpdateSystemSettingParams) error UpdateSystemSetting(ctx context.Context, arg UpdateSystemSettingParams) error
// Update universal progress // Update universal progress
UpdateUniversalProgress(ctx context.Context, arg UpdateUniversalProgressParams) (ReadingProgress, error) UpdateUniversalProgress(ctx context.Context, arg UpdateUniversalProgressParams) (ReadingProgress, error)
UpdateUserDefaultAudioSpeed(ctx context.Context, arg UpdateUserDefaultAudioSpeedParams) error
UpdateUserMaxDevices(ctx context.Context, arg UpdateUserMaxDevicesParams) (Users, error) UpdateUserMaxDevices(ctx context.Context, arg UpdateUserMaxDevicesParams) (Users, error)
UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) error UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) error
UpdateUserRole(ctx context.Context, arg UpdateUserRoleParams) (UpdateUserRoleRow, error) UpdateUserRole(ctx context.Context, arg UpdateUserRoleParams) (UpdateUserRoleRow, error)
UpdateUserTheme(ctx context.Context, arg UpdateUserThemeParams) error UpdateUserTheme(ctx context.Context, arg UpdateUserThemeParams) error
UpdateUserTimezone(ctx context.Context, arg UpdateUserTimezoneParams) error UpdateUserTimezone(ctx context.Context, arg UpdateUserTimezoneParams) error
UpdateUsername(ctx context.Context, arg UpdateUsernameParams) error UpdateUsername(ctx context.Context, arg UpdateUsernameParams) error
UpsertAudioPlaybackSpeed(ctx context.Context, arg UpsertAudioPlaybackSpeedParams) (float32, error)
UpsertDashboardPreferences(ctx context.Context, arg UpsertDashboardPreferencesParams) (UserDashboardPreferences, error) UpsertDashboardPreferences(ctx context.Context, arg UpsertDashboardPreferencesParams) (UserDashboardPreferences, error)
UpsertGlobalDashboardPreferences(ctx context.Context, arg UpsertGlobalDashboardPreferencesParams) (UserDashboardPreferencesGlobal, error) UpsertGlobalDashboardPreferences(ctx context.Context, arg UpsertGlobalDashboardPreferencesParams) (UserDashboardPreferencesGlobal, error)
UpsertMediaReview(ctx context.Context, arg UpsertMediaReviewParams) (MediaReviews, error) UpsertMediaReview(ctx context.Context, arg UpsertMediaReviewParams) (MediaReviews, error)
+59 -1
View File
@@ -3761,6 +3761,25 @@ func (q *Queries) GetAnnotationsForBook(ctx context.Context, arg GetAnnotationsF
return items, nil return items, nil
} }
const GetAudioPlaybackSpeed = `-- name: GetAudioPlaybackSpeed :one
SELECT speed FROM audio_playback_speed
WHERE user_id = $1 AND media_item_id = $2
`
type GetAudioPlaybackSpeedParams struct {
UserID pgtype.UUID `db:"user_id" json:"user_id"`
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
}
// Per-book playback speed preference (player plan §8).
func (q *Queries) GetAudioPlaybackSpeed(ctx context.Context, arg GetAudioPlaybackSpeedParams) (float32, error) {
row := q.db.QueryRow(ctx, GetAudioPlaybackSpeed, arg.UserID, arg.MediaItemID)
var speed float32
err := row.Scan(&speed)
return speed, err
}
const GetBookReadingSessions = `-- name: GetBookReadingSessions :many const GetBookReadingSessions = `-- name: GetBookReadingSessions :many
SELECT SELECT
rh.id, rh.device_id, rh.progress_percentage, rh.id, rh.device_id, rh.progress_percentage,
@@ -9922,6 +9941,7 @@ SELECT
u.created_at, u.created_at,
u.updated_at, u.updated_at,
u.timezone, u.timezone,
u.default_audio_speed,
(SELECT COUNT(*) FROM devices WHERE user_id = u.id) as device_count (SELECT COUNT(*) FROM devices WHERE user_id = u.id) as device_count
FROM users u FROM users u
WHERE u.id = $1 WHERE u.id = $1
@@ -9939,6 +9959,7 @@ type GetUserRow struct {
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"` CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"` UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
Timezone pgtype.Text `db:"timezone" json:"timezone"` Timezone pgtype.Text `db:"timezone" json:"timezone"`
DefaultAudioSpeed float32 `db:"default_audio_speed" json:"default_audio_speed"`
DeviceCount int64 `db:"device_count" json:"device_count"` DeviceCount int64 `db:"device_count" json:"device_count"`
} }
@@ -9957,6 +9978,7 @@ func (q *Queries) GetUser(ctx context.Context, id pgtype.UUID) (GetUserRow, erro
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.Timezone, &i.Timezone,
&i.DefaultAudioSpeed,
&i.DeviceCount, &i.DeviceCount,
) )
return i, err return i, err
@@ -17200,9 +17222,23 @@ func (q *Queries) UpdateUniversalProgress(ctx context.Context, arg UpdateUnivers
return i, err return i, err
} }
const UpdateUserDefaultAudioSpeed = `-- name: UpdateUserDefaultAudioSpeed :exec
UPDATE users SET default_audio_speed = $2, updated_at = NOW() WHERE id = $1
`
type UpdateUserDefaultAudioSpeedParams struct {
ID pgtype.UUID `db:"id" json:"id"`
DefaultAudioSpeed float32 `db:"default_audio_speed" json:"default_audio_speed"`
}
func (q *Queries) UpdateUserDefaultAudioSpeed(ctx context.Context, arg UpdateUserDefaultAudioSpeedParams) error {
_, err := q.db.Exec(ctx, UpdateUserDefaultAudioSpeed, arg.ID, arg.DefaultAudioSpeed)
return err
}
const UpdateUserMaxDevices = `-- name: UpdateUserMaxDevices :one const UpdateUserMaxDevices = `-- name: UpdateUserMaxDevices :one
UPDATE users SET max_devices = $2, updated_at = NOW() WHERE id = $1 UPDATE users SET max_devices = $2, updated_at = NOW() WHERE id = $1
RETURNING id, email, username, password_hash, first_name, last_name, role, theme, max_devices, created_at, timezone, updated_at RETURNING id, email, username, password_hash, first_name, last_name, role, theme, max_devices, created_at, timezone, updated_at, default_audio_speed
` `
type UpdateUserMaxDevicesParams struct { type UpdateUserMaxDevicesParams struct {
@@ -17226,6 +17262,7 @@ func (q *Queries) UpdateUserMaxDevices(ctx context.Context, arg UpdateUserMaxDev
&i.CreatedAt, &i.CreatedAt,
&i.Timezone, &i.Timezone,
&i.UpdatedAt, &i.UpdatedAt,
&i.DefaultAudioSpeed,
) )
return i, err return i, err
} }
@@ -17316,6 +17353,27 @@ func (q *Queries) UpdateUsername(ctx context.Context, arg UpdateUsernameParams)
return err return err
} }
const UpsertAudioPlaybackSpeed = `-- name: UpsertAudioPlaybackSpeed :one
INSERT INTO audio_playback_speed (user_id, media_item_id, speed)
VALUES ($1, $2, $3)
ON CONFLICT (user_id, media_item_id)
DO UPDATE SET speed = EXCLUDED.speed, updated_at = NOW()
RETURNING speed
`
type UpsertAudioPlaybackSpeedParams struct {
UserID pgtype.UUID `db:"user_id" json:"user_id"`
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
Speed float32 `db:"speed" json:"speed"`
}
func (q *Queries) UpsertAudioPlaybackSpeed(ctx context.Context, arg UpsertAudioPlaybackSpeedParams) (float32, error) {
row := q.db.QueryRow(ctx, UpsertAudioPlaybackSpeed, arg.UserID, arg.MediaItemID, arg.Speed)
var speed float32
err := row.Scan(&speed)
return speed, err
}
const UpsertDashboardPreferences = `-- name: UpsertDashboardPreferences :one const UpsertDashboardPreferences = `-- name: UpsertDashboardPreferences :one
INSERT INTO user_dashboard_preferences (user_id, library_id, hidden_collections, collection_order, items_per_section) INSERT INTO user_dashboard_preferences (user_id, library_id, hidden_collections, collection_order, items_per_section)
VALUES ($1, $2, $3, $4, $5) VALUES ($1, $2, $3, $4, $5)
+17
View File
@@ -28,6 +28,7 @@ SELECT
u.created_at, u.created_at,
u.updated_at, u.updated_at,
u.timezone, u.timezone,
u.default_audio_speed,
(SELECT COUNT(*) FROM devices WHERE user_id = u.id) as device_count (SELECT COUNT(*) FROM devices WHERE user_id = u.id) as device_count
FROM users u FROM users u
WHERE u.id = $1; WHERE u.id = $1;
@@ -511,6 +512,22 @@ RETURNING id, email, username, role;
-- name: UpdateUserTimezone :exec -- name: UpdateUserTimezone :exec
UPDATE users SET timezone = $2, updated_at = NOW() WHERE id = $1; UPDATE users SET timezone = $2, updated_at = NOW() WHERE id = $1;
-- name: UpdateUserDefaultAudioSpeed :exec
UPDATE users SET default_audio_speed = $2, updated_at = NOW() WHERE id = $1;
-- Per-book playback speed preference (player plan §8).
-- name: GetAudioPlaybackSpeed :one
SELECT speed FROM audio_playback_speed
WHERE user_id = $1 AND media_item_id = $2;
-- name: UpsertAudioPlaybackSpeed :one
INSERT INTO audio_playback_speed (user_id, media_item_id, speed)
VALUES ($1, $2, $3)
ON CONFLICT (user_id, media_item_id)
DO UPDATE SET speed = EXCLUDED.speed, updated_at = NOW()
RETURNING speed;
-- name: GetSystemTimezone :one -- name: GetSystemTimezone :one
SELECT setting_value FROM system_settings WHERE setting_key = 'default_timezone'; SELECT setting_value FROM system_settings WHERE setting_key = 'default_timezone';
+25
View File
@@ -11,6 +11,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"html/template" "html/template"
"math"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
@@ -120,6 +121,8 @@ type UserProfile struct {
LastName string `json:"last_name"` LastName string `json:"last_name"`
Role string `json:"role"` Role string `json:"role"`
Theme string `json:"theme,omitempty"` Theme string `json:"theme,omitempty"`
// User-level default audiobook playback speed (plan §8); 0 omits.
DefaultAudioSpeed float64 `json:"default_audio_speed,omitempty"`
} }
type UpdateProfileRequest struct { type UpdateProfileRequest struct {
@@ -129,6 +132,9 @@ type UpdateProfileRequest struct {
LastName string `json:"last_name,omitempty" form:"last_name" validate:"omitempty,max=100"` LastName string `json:"last_name,omitempty" form:"last_name" validate:"omitempty,max=100"`
Theme string `json:"theme,omitempty" form:"theme" validate:"omitempty"` Theme string `json:"theme,omitempty" form:"theme" validate:"omitempty"`
Timezone string `json:"timezone,omitempty" form:"timezone" validate:"omitempty"` Timezone string `json:"timezone,omitempty" form:"timezone" validate:"omitempty"`
// Per the SPEEDS list bounds; the profile select always sends it,
// JSON clients omit it (0 = untouched).
DefaultAudioSpeed float64 `json:"default_audio_speed,omitempty" form:"default_audio_speed" validate:"omitempty,min=0.25,max=4"`
} }
type AdminUpdateUserRequest struct { type AdminUpdateUserRequest struct {
@@ -139,6 +145,8 @@ type AdminUpdateUserRequest struct {
Theme string `json:"theme,omitempty" form:"theme" validate:"omitempty"` Theme string `json:"theme,omitempty" form:"theme" validate:"omitempty"`
Timezone string `json:"timezone,omitempty" form:"timezone" validate:"omitempty"` Timezone string `json:"timezone,omitempty" form:"timezone" validate:"omitempty"`
Role string `json:"role,omitempty" form:"role" validate:"omitempty,oneof=user admin"` Role string `json:"role,omitempty" form:"role" validate:"omitempty,oneof=user admin"`
// Same semantics as UpdateProfileRequest.DefaultAudioSpeed.
DefaultAudioSpeed float64 `json:"default_audio_speed,omitempty" form:"default_audio_speed" validate:"omitempty,min=0.25,max=4"`
} }
// Register handles POST /api/auth/register // Register handles POST /api/auth/register
@@ -545,6 +553,7 @@ func (h *AuthHandler) GetProfile(c *echo.Context) error {
LastName: lastName, LastName: lastName,
Role: user.Role, Role: user.Role,
Theme: user.Theme.String, Theme: user.Theme.String,
DefaultAudioSpeed: float64(user.DefaultAudioSpeed),
}) })
} }
@@ -693,6 +702,22 @@ func (h *AuthHandler) UpdateProfile(c *echo.Context) error {
} }
} }
// Update the default audiobook playback speed (plan §8). The form
// select always sends it; JSON clients that omit it decode as 0 and
// leave the column untouched.
if req.DefaultAudioSpeed != 0 {
if req.DefaultAudioSpeed < 0.25 || req.DefaultAudioSpeed > 4 || math.IsNaN(req.DefaultAudioSpeed) || math.IsInf(req.DefaultAudioSpeed, 0) {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "default playback speed out of range"})
}
err := h.db.UpdateUserDefaultAudioSpeed(c.Request().Context(), database.UpdateUserDefaultAudioSpeedParams{
ID: targetUserUUID,
DefaultAudioSpeed: float32(req.DefaultAudioSpeed),
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
}
// Browser path (htmx modal + profile page): refresh so every rendered // Browser path (htmx modal + profile page): refresh so every rendered
// value (timezone select, table rows, header) reflects the save and the // value (timezone select, table rows, header) reflects the save and the
// modal closes. A bare JSON body swapped into #profile-result read as // modal closes. A bare JSON body swapped into #profile-result read as
+89
View File
@@ -12,6 +12,7 @@ import (
"fmt" "fmt"
"io" "io"
"log" "log"
"math"
"mime/multipart" "mime/multipart"
"net/http" "net/http"
"net/url" "net/url"
@@ -1610,6 +1611,14 @@ func (mh *MediaHandler) UpdateMediaItem(c *echo.Context) error {
Summary: pgtype.Text{String: req.Summary, Valid: req.Summary != ""}, Summary: pgtype.Text{String: req.Summary, Valid: req.Summary != ""},
} }
// audio_metadata is scan-owned (track manifest, chapters, duration)
// and has no editor field: the full-row update writes it
// unconditionally, so an unguarded save NULLed every audiobook's
// payload — books dropped back to "1 second long" on a plain
// metadata edit (2026-10-21 incident class). Write-after-success:
// carry the existing bytes; only the audio chain replaces them.
params.AudioMetadata = existing.AudioMetadata
// Fields the user actually changed become overrides so future scans keep // Fields the user actually changed become overrides so future scans keep
// the custom values. An explicit cover upload/removal always overrides. // the custom values. An explicit cover upload/removal always overrides.
overrides := utils.DetectMetadataOverrides(params, existing) overrides := utils.DetectMetadataOverrides(params, existing)
@@ -2982,6 +2991,30 @@ func (mh *MediaHandler) GetAudioChapters(c *echo.Context) error {
}) })
} }
// Playback speed (plan §8): resolve per-book row → user default →
// 1.0 IN the payload so the player applies it with zero extra round
// trips; default_playback_speed powers the dropdown's "(default)"
// marker. Speed is a preference, so this rides the authenticated
// user — anonymous readers just get 1.0.
playbackSpeed, defaultSpeed := 1.0, 1.0
if userID, ok := c.Get("user_id").(string); ok {
if userUUID, err := uuid.Parse(userID); err == nil {
userRow, err := mh.db.GetUser(c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true})
if err == nil && userRow.DefaultAudioSpeed > 0 {
defaultSpeed = float64(userRow.DefaultAudioSpeed)
}
bookSpeed, err := mh.db.GetAudioPlaybackSpeed(c.Request().Context(), database.GetAudioPlaybackSpeedParams{
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
})
if err == nil && bookSpeed > 0 {
playbackSpeed = float64(bookSpeed)
} else {
playbackSpeed = defaultSpeed
}
}
}
return c.JSON(http.StatusOK, map[string]interface{}{ return c.JSON(http.StatusOK, map[string]interface{}{
"media_item_id": item.ID, "media_item_id": item.ID,
"chapters": payload.Chapters, "chapters": payload.Chapters,
@@ -2989,9 +3022,65 @@ func (mh *MediaHandler) GetAudioChapters(c *echo.Context) error {
"codec_profile": payload.CodecProfile, "codec_profile": payload.CodecProfile,
"layout": payload.Layout, "layout": payload.Layout,
"tracks": tracks, "tracks": tracks,
"playback_speed": playbackSpeed,
"default_playback_speed": defaultSpeed,
}) })
} }
// SetAudioSpeed handles PUT /api/media-items/:id/audio-speed — the
// per-user per-book playback speed upsert (plan §8). The client SPEEDS
// list is the real gate; the server sanity-checks the range.
func (mh *MediaHandler) SetAudioSpeed(c *echo.Context) error { mediaID := c.Param("id")
mediaUUID, err := uuid.Parse(mediaID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
}
userID, ok := c.Get("user_id").(string)
if !ok || userID == "" {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "authentication required"})
}
userUUID, err := uuid.Parse(userID)
if err != nil {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid user"})
}
var req struct {
Speed float64 `json:"speed"`
}
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if req.Speed < 0.25 || req.Speed > 4 || math.IsNaN(req.Speed) || math.IsInf(req.Speed, 0) {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "speed out of range"})
}
// The book must exist (and be visible): a preference for a hidden
// library must not be writable — family-lens rule.
item, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true})
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "book not found"})
}
if item.LibraryID.Valid {
visibleSet, _, err := mh.visibleLibrarySet(c)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to check library access"})
}
if !visibleSet[uuid.UUID(item.LibraryID.Bytes).String()] {
return c.JSON(http.StatusForbidden, map[string]string{"error": "library not accessible"})
}
}
speed, err := mh.db.UpsertAudioPlaybackSpeed(c.Request().Context(), database.UpsertAudioPlaybackSpeedParams{
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
Speed: float32(req.Speed),
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to save playback speed"})
}
return c.JSON(http.StatusOK, map[string]float64{"playback_speed": float64(speed)})
}
// GetAudioTrack handles GET /api/media-items/:id/audio/:trackIndex — the // GetAudioTrack handles GET /api/media-items/:id/audio/:trackIndex — the
// per-track byte stream for multi-file audiobooks (folder books serve the // per-track byte stream for multi-file audiobooks (folder books serve the
// real file; zip books extract the entry in place). Library visibility is // real file; zip books extract the entry in place). Library visibility is
+1
View File
@@ -66,6 +66,7 @@ func getTemplateUserWithTheme(c *echo.Context, cfg *Config) (templates.User, err
FirstName: utils.PgTextString(userDB.FirstName), FirstName: utils.PgTextString(userDB.FirstName),
LastName: utils.PgTextString(userDB.LastName), LastName: utils.PgTextString(userDB.LastName),
CreatedAt: userDB.CreatedAt.Time, CreatedAt: userDB.CreatedAt.Time,
DefaultAudioSpeed: float64(userDB.DefaultAudioSpeed),
}, nil }, nil
} }
+1
View File
@@ -22,6 +22,7 @@ func registerMediaRoutes(cfg *Config) {
// books stream through the existing /uploads Range-serving path; // books stream through the existing /uploads Range-serving path;
// multi-file books additionally expose per-track streaming. // multi-file books additionally expose per-track streaming.
protected.GET("/media-items/:id/chapters", cfg.MediaHandler.GetAudioChapters) protected.GET("/media-items/:id/chapters", cfg.MediaHandler.GetAudioChapters)
protected.PUT("/media-items/:id/audio-speed", cfg.MediaHandler.SetAudioSpeed)
protected.GET("/media-items/:id/audio/:trackIndex", cfg.MediaHandler.GetAudioTrack) protected.GET("/media-items/:id/audio/:trackIndex", cfg.MediaHandler.GetAudioTrack)
// Media rating routes (all authenticated users) // Media rating routes (all authenticated users)
+13 -1
View File
@@ -4049,6 +4049,18 @@ func (s *MediaScanner) updateMediaItemWithMetadata(ctx context.Context, existing
if metadata.AlternateInfo != "" { if metadata.AlternateInfo != "" {
alternateInfoBytes = []byte(metadata.AlternateInfo) alternateInfoBytes = []byte(metadata.AlternateInfo)
} }
// A re-extraction that produced no audio payload must never wipe
// scan-derived audio_metadata (tracks/chapters/duration): the
// ev-gated generic pass runs for multi-file books too, where
// extractMetadata leaves AudioMeta nil — writing NULL here deleted
// every folder book's payload and broke playback (2026-10-21
// incident: all multi-file audiobooks showed as 1 second long). The
// audio chain (UpdateMediaItemAudioBookPayload) owns this column;
// without fresh data from THAT chain, keep what the row has.
audioMetadata := marshalAudioMetadata(metadata.AudioMeta)
if audioMetadata == nil {
audioMetadata = existing.AudioMetadata
}
params := database.UpdateMediaItemParams{ params := database.UpdateMediaItemParams{
ID: existing.ID, ID: existing.ID,
Title: metadata.Title, Title: metadata.Title,
@@ -4089,7 +4101,7 @@ func (s *MediaScanner) updateMediaItemWithMetadata(ctx context.Context, existing
AlternateInfo: alternateInfoBytes, AlternateInfo: alternateInfoBytes,
ScanInformation: pgtype.Text{String: metadata.ScanInformation, Valid: metadata.ScanInformation != ""}, ScanInformation: pgtype.Text{String: metadata.ScanInformation, Valid: metadata.ScanInformation != ""},
Summary: pgtype.Text{String: metadata.Summary, Valid: metadata.Summary != ""}, Summary: pgtype.Text{String: metadata.Summary, Valid: metadata.Summary != ""},
AudioMetadata: marshalAudioMetadata(metadata.AudioMeta), AudioMetadata: audioMetadata,
} }
// Keep user-customized fields, and keep the override set itself intact. // Keep user-customized fields, and keep the override set itself intact.
@@ -0,0 +1,33 @@
package services
import (
"testing"
)
// TestAudioPayloadIsEmpty pins the write-after-success guard on
// updateAudioBookItem (2026-10-21 incident: multi-file audiobooks
// surfaced as 1 second long because a failed re-extraction overwrote a
// populated audio_metadata). An empty payload — nil, or no tracks, no
// chapters, no duration — is the signature of a failed extraction and
// must never replace existing audio data.
func TestAudioPayloadIsEmpty(t *testing.T) {
require := func(cond bool, msg string) {
if !cond {
t.Fatal(msg)
}
}
require(audioPayloadIsEmpty(nil), "nil payload must read as empty")
require(audioPayloadIsEmpty(&AudioMetadataPayload{}), "zero-value payload must read as empty")
require(audioPayloadIsEmpty(&AudioMetadataPayload{DurationSeconds: 0}), "zero-duration payload must read as empty")
// A real book always carries at least one of the audio facts.
require(!audioPayloadIsEmpty(&AudioMetadataPayload{DurationSeconds: 302.6}),
"duration-only payload (single-track book) is not empty")
require(!audioPayloadIsEmpty(&AudioMetadataPayload{
Tracks: []AudioTrack{{Index: 0, Path: "01.mp3"}},
}), "track manifest is not empty")
require(!audioPayloadIsEmpty(&AudioMetadataPayload{
Chapters: []AudioChapter{{Index: 0, Title: "Chapter 1"}},
}), "chapters are not empty")
}
@@ -1015,6 +1015,16 @@ func (s *MediaScanner) processAudioFolderBook(ctx context.Context, rootDir strin
// rescan saw the track set change (or the admin forced a rescan). Only // rescan saw the track set change (or the admin forced a rescan). Only
// scan-derived fields move — editorial columns keep admin edits. // scan-derived fields move — editorial columns keep admin edits.
func (s *MediaScanner) updateAudioBookItem(ctx context.Context, existing database.MediaItems, payload *AudioMetadataPayload, mimeType string, totalSize int64, chapterCount int) error { func (s *MediaScanner) updateAudioBookItem(ctx context.Context, existing database.MediaItems, payload *AudioMetadataPayload, mimeType string, totalSize int64, chapterCount int) error {
// Write-after-success discipline (2026-10-21 incident: multi-file
// audiobooks surfaced as 1 second long because a re-extraction wrote
// a NULL/empty audio_metadata over a populated one). An empty payload
// is a FAILED extraction, not a book with no audio: when the row
// already carries audio data, keep it and skip the write — the next
// scan retries, and the reader keeps its manifest in the meantime.
if audioPayloadIsEmpty(payload) && existing.AudioMetadata != nil {
fmt.Printf("[AUDIO] WARNING: re-extraction of '%s' produced an empty audio payload; keeping the existing audio_metadata\n", existing.Title)
return nil
}
if _, err := s.db.UpdateMediaItemAudioBookPayload(ctx, database.UpdateMediaItemAudioBookPayloadParams{ if _, err := s.db.UpdateMediaItemAudioBookPayload(ctx, database.UpdateMediaItemAudioBookPayloadParams{
ID: existing.ID, ID: existing.ID,
FileSize: pgtype.Int8{Int64: totalSize, Valid: true}, FileSize: pgtype.Int8{Int64: totalSize, Valid: true},
@@ -1028,6 +1038,14 @@ func (s *MediaScanner) updateAudioBookItem(ctx context.Context, existing databas
return nil return nil
} }
// audioPayloadIsEmpty reports whether a payload carries no audio facts
// at all — no tracks, no chapters, no duration. Such a payload is the
// signature of a failed/absent extraction, never of a real book.
func audioPayloadIsEmpty(payload *AudioMetadataPayload) bool {
return payload == nil ||
(len(payload.Tracks) == 0 && len(payload.Chapters) == 0 && payload.DurationSeconds <= 0)
}
// multifileSidecarChanged runs the manifest ladder for a folder/zip book // multifileSidecarChanged runs the manifest ladder for a folder/zip book
// (faceted plan Phase 6): a size-stable multi-file book is changed by // (faceted plan Phase 6): a size-stable multi-file book is changed by
// EDITING its sidecars — the track-count/size fingerprint never saw it, // EDITING its sidecars — the track-count/size fingerprint never saw it,
+3 -1
View File
@@ -9,7 +9,8 @@
"build:ts:dev": "cp node_modules/htmx.org/dist/htmx.min.js web/static/htmx.min.js && vp build --mode development", "build:ts:dev": "cp node_modules/htmx.org/dist/htmx.min.js web/static/htmx.min.js && vp build --mode development",
"build:ts:watch": "cp node_modules/htmx.org/dist/htmx.min.js web/static/htmx.min.js && vp build --watch", "build:ts:watch": "cp node_modules/htmx.org/dist/htmx.min.js web/static/htmx.min.js && vp build --watch",
"build": "npm run build:ts && npm run build:css:prod", "build": "npm run build:ts && npm run build:css:prod",
"dev": "npm run build:ts:dev && npm run build:css" "dev": "npm run build:ts:dev && npm run build:css",
"test": "vp test web/src"
}, },
"dependencies": { "dependencies": {
"@bookhoard/foliate-js": "git+https://git.linuxhg.com/Bookhoard/foliate-js.git#f622841", "@bookhoard/foliate-js": "git+https://git.linuxhg.com/Bookhoard/foliate-js.git#f622841",
@@ -31,6 +32,7 @@
"@types/markdown-it": "^14.2.0", "@types/markdown-it": "^14.2.0",
"@types/turndown": "^5.0.6", "@types/turndown": "^5.0.6",
"autoprefixer": "^10.4.27", "autoprefixer": "^10.4.27",
"jsdom": "^30.1.3",
"playwright": "^1.64.1", "playwright": "^1.64.1",
"tailwindcss": "^3.4.19", "tailwindcss": "^3.4.19",
"typescript": "^5.9.3", "typescript": "^5.9.3",
+296 -9
View File
@@ -10,14 +10,26 @@ package templates
import "fmt" import "fmt"
templ AudioReader(user User, metadata ReaderMetadata) { templ AudioReader(user User, metadata ReaderMetadata) {
<!DOCTYPE html/> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<title>{ "Listening · " + metadata.Title } · Bookhoard</title> <title>{ "Listening · " + metadata.Title } · Bookhoard</title>
<meta charset="utf-8"/> <meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<link href="/static/style.css" rel="stylesheet"/> <link href="/static/style.css" rel="stylesheet"/>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
<script type="module" src="/static/main.js"></script> <script type="module" src="/static/main.js"></script>
<style>
/* Layered scrubber (plan §1): the native range stays for
a11y (focus, arrow keys, aria) but its track is
transparent so the tick/buffer/fill layers beneath show
through. Static page CSS — templ owns the look. */
#audio-scrub { -webkit-appearance: none; appearance: none; background: transparent; cursor: pointer; }
#audio-scrub::-webkit-slider-runnable-track { background: transparent; border: none; }
#audio-scrub::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; height: 14px; width: 14px; border-radius: 9999px; background: var(--accent); border: 2px solid var(--bg-primary); box-shadow: 0 1px 4px rgba(0,0,0,0.4); }
#audio-scrub::-moz-range-track { background: transparent; border: none; }
#audio-scrub::-moz-range-thumb { height: 14px; width: 14px; border-radius: 9999px; background: var(--accent); border: 2px solid var(--bg-primary); box-shadow: 0 1px 4px rgba(0,0,0,0.4); }
</style>
</head> </head>
<body <body
x-data="audioReader" x-data="audioReader"
@@ -32,6 +44,12 @@ templ AudioReader(user User, metadata ReaderMetadata) {
"/api/media-items/"+metadata.MediaItemID+"/chapters", "/api/media-items/"+metadata.MediaItemID+"/chapters",
"/api/media-items/"+metadata.MediaItemID+"/progress") } "/api/media-items/"+metadata.MediaItemID+"/progress") }
> >
<!-- §17 wind-down dim: the sleep ticker eases this toward 35%
black across a countdown's final 30s; pointer-events-none
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"> <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 <!-- 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). -->
@@ -75,6 +93,86 @@ templ AudioReader(user User, metadata ReaderMetadata) {
<path d="m6 9 6 6 6-6"></path> <path d="m6 9 6 6 6-6"></path>
</svg> </svg>
</button> </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 &amp; interactions"
title="Shortcuts &amp; 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="absolute top-0.5 left-0.5 h-4 w-4 rounded-full transition-transform" :class="autoplayOn ? 'translate-x-4' : ''" style="background-color: var(--text-primary);"></span>
</button>
</div>
<!-- §12: default speed — always visible (unlike the
Speed ▾ footer, which hides when the current rate
IS the default), same setDefaultSpeed PUT -->
<div class="flex items-center justify-between gap-3 px-1 py-2 rounded-lg">
<span class="text-sm" style="color: var(--text-secondary);">Default playback speed</span>
<select
class="text-xs rounded-md px-1.5 py-1"
style="background-color: var(--surface-hover); color: var(--text-primary); border: 1px solid var(--border);"
aria-label="Default playback speed"
@change="setDefaultSpeed(Number($event.target.value))"
>
for _, rate := range SpeedOptions {
<option
value={ fmt.Sprintf("%g", rate) }
:selected={ fmt.Sprintf("Number(defaultSpeed) === %g", rate) }
>{ fmt.Sprintf("%g×", rate) }</option>
}
</select>
</div>
</div>
</div>
</span> </span>
</div> </div>
@@ -101,7 +199,44 @@ templ AudioReader(user User, metadata ReaderMetadata) {
<!-- Scrubber + transports --> <!-- Scrubber + transports -->
<div class="w-full max-w-xl flex flex-col gap-2"> <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);"> <div class="flex items-center gap-2 text-xs tabular-nums" style="color: var(--text-secondary);">
<span x-text="fmtTime(currentTime)">0:00</span> <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
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 <input
id="audio-scrub" id="audio-scrub"
type="range" type="range"
@@ -109,13 +244,51 @@ templ AudioReader(user User, metadata ReaderMetadata) {
step="0.1" step="0.1"
:max="chaptersDuration() || 1" :max="chaptersDuration() || 1"
:value="scrubbing ? scrubValue : currentTime" :value="scrubbing ? scrubValue : currentTime"
class="flex-1 accent-current" class="relative w-full"
@input="scrubbing = true; scrubValue = Number($event.target.value)" @input="scrubbing = true; scrubValue = Number($event.target.value)"
@pointerup="seekTo(scrubValue); scrubbing = false"
@change="seekTo(scrubValue); scrubbing = false" @change="seekTo(scrubValue); scrubbing = false"
style="z-index: 20;" aria-label="Seek position"
/> />
<span x-text="fmtTime(chaptersDuration() || 1)">0:00</span> <!-- 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
drag pulled off the bar — ±5 min or tighter, free
mapping inside. Edge labels self-document the window
(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="'left:' + ((tick.start - railFrom) / Math.max(1, railTo - railFrom) * 100) + '%;background-color:var(--text-secondary);opacity:0.5;'"></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="cycleEndTime()"
>0:00</button>
</div> </div>
<div class="flex items-center justify-center gap-3 md:gap-5"> <div class="flex items-center justify-center gap-3 md:gap-5">
<button <button
@@ -219,7 +392,7 @@ templ AudioReader(user User, metadata ReaderMetadata) {
class="absolute bottom-full mb-2 left-1/2 -translate-x-1/2 rounded-xl shadow-lg p-1.5 flex flex-col min-w-[7.5rem]" class="absolute bottom-full mb-2 left-1/2 -translate-x-1/2 rounded-xl shadow-lg p-1.5 flex flex-col min-w-[7.5rem]"
style="background-color: var(--bg-secondary); border: 1px solid var(--border); z-index: 30;" style="background-color: var(--bg-secondary); border: 1px solid var(--border); z-index: 30;"
> >
<template x-for="secs in [1, 5, 10, 15, 30]" :key="'skip' + secs"> <template x-for="secs in skipChoices" :key="'skip' + secs">
<button <button
type="button" type="button"
@click="setSkip(secs); skipOpen = false" @click="setSkip(secs); skipOpen = false"
@@ -249,16 +422,26 @@ templ AudioReader(user User, metadata ReaderMetadata) {
class="absolute bottom-full mb-2 left-1/2 -translate-x-1/2 rounded-xl shadow-lg p-1.5 flex flex-col min-w-[7rem]" class="absolute bottom-full mb-2 left-1/2 -translate-x-1/2 rounded-xl shadow-lg p-1.5 flex flex-col min-w-[7rem]"
style="background-color: var(--bg-secondary); border: 1px solid var(--border); z-index: 30;" style="background-color: var(--bg-secondary); border: 1px solid var(--border); z-index: 30;"
> >
<template x-for="rate in [0.75, 1, 1.25, 1.5, 2]" :key="'speed' + rate"> <template x-for="rate in speeds" :key="'speed' + rate">
<button <button
type="button" type="button"
@click="setSpeed(rate); speedOpen = false" @click="setSpeed(rate); speedOpen = false"
class="w-full text-left px-3 py-1.5 rounded-md text-sm" class="w-full text-left px-3 py-1.5 rounded-md text-sm"
:class="Number(speed) === rate ? 'font-semibold' : ''" :class="Number(speed) === rate ? 'font-semibold' : ''"
:style="Number(speed) === rate ? 'background-color: var(--accent-muted); color: var(--text-primary);' : 'color: var(--text-secondary);'" :style="Number(speed) === rate ? 'background-color: var(--accent-muted); color: var(--text-primary);' : 'color: var(--text-secondary);'"
x-text="rate + '×'" x-text="rate + '×' + (isDefaultSpeed(rate) ? ' · default' : '')"
></button> ></button>
</template> </template>
<!-- §8: pin the current rate as the user default —
books without their own speed pick it up -->
<button
type="button"
x-show="!isDefaultSpeed(speed)"
@click="setDefaultSpeed(speed); speedOpen = false"
class="w-full text-left px-3 py-1.5 mt-1 rounded-md text-xs border-t"
style="border-color: var(--border); color: var(--text-secondary);"
x-text="'Make ' + speed + '× my default'"
></button>
</div> </div>
</div> </div>
<button <button
@@ -275,6 +458,55 @@ templ AudioReader(user User, metadata ReaderMetadata) {
</svg> </svg>
<span>Bookmark</span> <span>Bookmark</span>
</button> </button>
<div class="relative" @click.outside="sleepOpen = false">
<button
type="button"
@click="sleepOpen = !sleepOpen"
class="text-xs px-3 py-2 rounded-lg hover:bg-surface-hover transition-colors inline-flex items-center gap-1.5"
:aria-label="'Sleep timer, currently ' + sleepLabelStr"
style="color: var(--text-secondary);"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"></path>
</svg>
<span class="font-semibold tabular-nums" style="color: var(--text-primary);" x-text="sleepLabelStr === 'Off' ? 'Sleep' : sleepLabelStr">Sleep</span>
<svg width="12" height="12" 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>
<div
x-show="sleepOpen"
x-cloak
class="absolute bottom-full mb-2 left-1/2 -translate-x-1/2 rounded-xl shadow-lg p-1.5 flex flex-col min-w-[8.5rem]"
style="background-color: var(--bg-secondary); border: 1px solid var(--border); z-index: 30;"
>
<template x-for="opt in sleepChoices" :key="opt.key">
<button
type="button"
@click="chooseSleep(opt); sleepOpen = false"
class="w-full text-left px-3 py-1.5 rounded-md text-sm"
:class="isSleepActive(opt.key) ? 'font-semibold' : ''"
:style="isSleepActive(opt.key) ? 'background-color: var(--accent-muted); color: var(--text-primary);' : 'color: var(--text-secondary);'"
x-text="opt.label"
></button>
</template>
<!-- §17 clock mode: stop at a wall-clock time (its next
occurrence). The input drives chooseSleepClock(). -->
<div class="flex items-center gap-1.5 px-2 pt-1.5 mt-1 border-t" style="border-color: var(--border);">
<input
type="time"
x-model="sleepClockTime"
class="text-xs px-1.5 py-1 rounded-md flex-1 min-w-0"
style="background-color: var(--surface-hover); color: var(--text-primary); border: 1px solid var(--border);"
aria-label="Stop at time"
/>
<button
type="button"
@click="chooseSleepClock(); sleepOpen = false"
class="text-xs px-2.5 py-1 rounded-md font-semibold"
:style="sleepArmedChoiceKey === 'clock' ? 'background-color: var(--accent-muted); color: var(--text-primary);' : 'background-color: var(--surface-hover); color: var(--text-secondary);'"
>Arm</button>
</div>
</div>
</div>
<div class="relative" @click.outside="volumeOpen = false"> <div class="relative" @click.outside="volumeOpen = false">
<button <button
type="button" type="button"
@@ -453,10 +685,12 @@ templ AudioReader(user User, metadata ReaderMetadata) {
> >
<p class="text-sm font-semibold mb-2" style="color: var(--text-primary);">Bookmark at <span class="tabular-nums" x-text="fmtTime(currentTime)">0:00</span></p> <p class="text-sm font-semibold mb-2" style="color: var(--text-primary);">Bookmark at <span class="tabular-nums" x-text="fmtTime(currentTime)">0:00</span></p>
<textarea <textarea
id="bookmark-note"
x-model="bookmarkNote" x-model="bookmarkNote"
rows="3" rows="3"
placeholder="Optional note — markdown welcome (e.g. **great quote**, list, [link](https://…))" placeholder="Optional note — markdown welcome (e.g. **great quote**, list, [link](https://…))"
class="input mb-2 resize-y" class="input mb-2 resize-y"
@keydown.enter="if (!$event.shiftKey) { $event.preventDefault(); saveBookmark() }"
></textarea> ></textarea>
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2">
<button type="button" @click="bookmarkCaptureOpen = false" class="btn btn-ghost text-sm px-3 py-1.5">Cancel</button> <button type="button" @click="bookmarkCaptureOpen = false" class="btn btn-ghost text-sm px-3 py-1.5">Cancel</button>
@@ -464,6 +698,59 @@ templ AudioReader(user User, metadata ReaderMetadata) {
</div> </div>
</div> </div>
<!-- Volume/mute OSD: brief confirmation for keyboard-driven changes -->
<div
x-show="osdVisible"
x-cloak
x-transition.opacity.duration.200ms
class="fixed bottom-24 left-1/2 -translate-x-1/2 z-50 text-xs px-3 py-1.5 rounded-full shadow-lg tabular-nums"
style="background-color: var(--bg-secondary); color: var(--text-primary); border: 1px solid var(--border);"
x-text="osdText"
></div>
<!-- Shortcut help (?): rows come from the shared KEYMAP
(audio-keys.ts) — the same table drives the keydown handler. -->
<div x-show="helpOpen" x-cloak class="fixed inset-0 z-40" style="background-color: color-mix(in srgb, black 32%, transparent);" @click="helpOpen = false"></div>
<div x-show="helpOpen" x-cloak x-transition class="fixed inset-0 z-50 grid place-items-center p-4" @click.outside="helpOpen = false">
<div class="w-full max-w-md rounded-2xl shadow-2xl p-4 max-h-[80vh] overflow-y-auto" style="background-color: var(--bg-secondary); border: 1px solid var(--border);">
<div class="flex items-center justify-between mb-3 px-1">
<span class="text-sm font-semibold" style="color: var(--text-primary);">Shortcuts &amp; interactions</span>
<button type="button" @click="helpOpen = false" class="rounded-full p-1.5 hover:bg-surface-hover" style="color: var(--text-secondary);" aria-label="Close shortcuts">
<svg width="18" height="18" 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>
<template x-for="spec in visibleKeymap()" :key="spec.action">
<div class="flex items-center justify-between gap-3 px-2 py-1.5 rounded-lg">
<span class="text-sm" style="color: var(--text-secondary);" x-text="spec.description"></span>
<span class="flex items-center gap-1 shrink-0">
<template x-for="k in spec.keys" :key="k.label">
<kbd class="text-xs px-1.5 py-0.5 rounded tabular-nums" style="background-color: var(--surface-hover); color: var(--text-primary); border: 1px solid var(--border);" x-text="k.label"></kbd>
</template>
</span>
</div>
</template>
<!-- Pointer gestures (audio-keys.ts MOUSE_GUIDE/TOUCH_GUIDE):
two sections — the bar has two scrub systems -->
<div class="mt-3 mb-1 px-2 text-xs font-semibold uppercase tracking-wide" style="color: var(--text-secondary);">Mouse</div>
<template x-for="row in mouseGuide" :key="'m' + row.label">
<div class="flex items-center justify-between gap-3 px-2 py-1.5 rounded-lg">
<span class="text-sm" style="color: var(--text-secondary);" x-text="row.description"></span>
<kbd class="text-xs px-1.5 py-0.5 rounded tabular-nums shrink-0" style="background-color: var(--surface-hover); color: var(--text-primary); border: 1px solid var(--border);" x-text="row.label"></kbd>
</div>
</template>
<div class="mt-3 mb-1 px-2 text-xs font-semibold uppercase tracking-wide" style="color: var(--text-secondary);">Touch</div>
<template x-for="row in touchGuide" :key="'t' + row.label">
<div class="flex items-center justify-between gap-3 px-2 py-1.5 rounded-lg">
<span class="text-sm" style="color: var(--text-secondary);" x-text="row.description"></span>
<kbd class="text-xs px-1.5 py-0.5 rounded tabular-nums shrink-0" style="background-color: var(--surface-hover); color: var(--text-primary); border: 1px solid var(--border);" x-text="row.label"></kbd>
</div>
</template>
</div>
</div>
<audio id="audio-element" preload="auto" class="hidden"></audio> <audio id="audio-element" preload="auto" class="hidden"></audio>
</div> </div>
</body> </body>
+78 -29
View File
@@ -38,7 +38,7 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
templ_7745c5c3_Var1 = templ.NopComponent templ_7745c5c3_Var1 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html/><html lang=\"en\"><head><title>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><title>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -51,7 +51,7 @@ func AudioReader(user User, metadata ReaderMetadata) 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, 2, " · Bookhoard</title><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><link href=\"/static/style.css\" rel=\"stylesheet\"><script type=\"module\" src=\"/static/main.js\"></script></head>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " · Bookhoard</title><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"><script type=\"module\" src=\"/static/main.js\"></script><style>\n\t\t\t\t/* Layered scrubber (plan §1): the native range stays for\n\t\t\t\t a11y (focus, arrow keys, aria) but its track is\n\t\t\t\t transparent so the tick/buffer/fill layers beneath show\n\t\t\t\t through. Static page CSS — templ owns the look. */\n\t\t\t\t#audio-scrub { -webkit-appearance: none; appearance: none; background: transparent; cursor: pointer; }\n\t\t\t\t#audio-scrub::-webkit-slider-runnable-track { background: transparent; border: none; }\n\t\t\t\t#audio-scrub::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; height: 14px; width: 14px; border-radius: 9999px; background: var(--accent); border: 2px solid var(--bg-primary); box-shadow: 0 1px 4px rgba(0,0,0,0.4); }\n\t\t\t\t#audio-scrub::-moz-range-track { background: transparent; border: none; }\n\t\t\t\t#audio-scrub::-moz-range-thumb { height: 14px; width: 14px; border-radius: 9999px; background: var(--accent); border: 2px solid var(--bg-primary); box-shadow: 0 1px 4px rgba(0,0,0,0.4); }\n\t\t\t</style></head>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -80,7 +80,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: 27, Col: 44} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 39, 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 +93,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: 28, Col: 30} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 40, 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 +106,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: 29, Col: 39} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 41, 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 +119,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: 30, Col: 35} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 42, 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 {
@@ -134,67 +134,116 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
"/api/media-items/"+metadata.MediaItemID+"/chapters", "/api/media-items/"+metadata.MediaItemID+"/chapters",
"/api/media-items/"+metadata.MediaItemID+"/progress")) "/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: 33, Col: 57} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 45, Col: 57}
} }
_, 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 {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\"><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></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, 9, "\"><!-- §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 &amp; interactions\" title=\"Shortcuts &amp; 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-semLine truncated
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if metadata.CoverImagePath != "" { for _, rate := range SpeedOptions {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<img src=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<option value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
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(metadata.CoverImagePath) templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%g", rate))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 86, Col: 36} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 168, Col: 41}
} }
_, 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, 11, "\" alt=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\" :selected=\"")
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(metadata.Title) templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("Number(defaultSpeed) === %g", rate))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 87, Col: 27} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 169, Col: 70}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(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, "\" class=\"w-full h-full object-cover\" onerror=\"this.style.display='none'\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<span class=\"text-5xl flex items-center justify-center h-full\" style=\"color: var(--text-secondary);\">🎧</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</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_Var12 string var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(metadata.Title) templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%g×", rate))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 96, Col: 117} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 170, Col: 37}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(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, 15, "</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 x-text=\"fmtTime(currentTime)\">0:00</span> <input id=\"audio-scrub\" type=\"range\" min=\"0\" step=\"0.1\" :max=\"chaptersDuration() || 1\" :value=\"scrubbing ? scrubValue : currentTime\" class=\"flex-1 accent-current\" @input=\"scrubbing = true; scrubValue = Number($event.target.value)\" @pointerup=\"seekTo(scrubValue); scrubbing = false\" @change=\"seekTo(scrubValue); scrubbing = false\" style=\"z-index: 20;\"> <span x-text=\"fmtTime(chaptersDuration() || 1)\">0:00</span></div><div class=\"flex items-center justify-center gap-3 md:gap-5\"><button type=\"button\" @click=\"stepChapter(-1)\" class=\"rounded-full p-2.5 hover:bg-surface-hover transition-colors\" aria-label=\"Previous chapter\" title=\"Previous chapter\" style=\"color: var(--text-primary);\"><svg width=\"22\" height=\"22\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.75\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M19 20 9 12l10-8v16z\"></path> <path d=\"M5 19V5\"></path></svg></button> <button type=\"button\" @click=\"skip(-1)\" class=\"relative rounded-full h-14 w-14 grid place-items-center hover:bg-surface-hover transition-colors\" :aria-label=\"'Skip back ' + skipSeconds + 's'\" :title=\"'Skip back ' + skipSeconds + 's'\" style=\"color: var(--text-primary);\"><svg width=\"52\" height=\"52\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8\"></path> <path d=\"M3 3v5h5\"></path></svg> <span class=\"absolute inset-0 grid place-items-center text-[11px] font-semibold pointer-events-none\"><span x-text=\"skipSeconds\">10</span></span></button> <button id=\"audio-play\" type=\"button\" @click=\"togglePlay()\" class=\"rounded-full h-16 w-16 grid place-items-center\" style=\"background-color: var(--accent); color: var(--bg-primary);\" aria-label=\"Play or pause\"><span x-show=\"!playing\" class=\"grid place-items-center\"><svg width=\"28\" height=\"28\" viewBox=\"0 0 24 24\" fill=\"currentColor\" aria-hidden=\"true\" style=\"margin-left: 2px;\"><path d=\"M8 5.2v13.6L19.2 12z\"></path></svg></span> <span x-show=\"playing\" class=\"grid place-items-center\" x-cloak><svg width=\"28\" height=\"28\" viewBox=\"0 0 24 24\" fill=\"currentColor\" aria-hidden=\"true\"><rect x=\"6.2\" y=\"4.5\" width=\"4.2\" height=\"15\" rx=\"1.7\"></rect> <rect x=\"13.6\" y=\"4.5\" width=\"4.2\" height=\"15\" rx=\"1.7\"></rect></svg></span></button> <button type=\"button\" @click=\"skip(1)\" class=\"relative rounded-full h-14 w-14 grid place-items-center hover:bg-surface-hover transition-colors\" :aria-label=\"'Skip forward ' + skipSeconds + 's'\" :title=\"'Skip forward ' + skipSeconds + 's'\" style=\"color: var(--text-primary);\"><svg width=\"52\" height=\"52\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8\"></path> <path d=\"M21 3v5h-5\"></path></svg> <span class=\"absolute inset-0 grid place-items-center text-[11px] font-semibold pointer-events-none\"><span x-text=\"skipSeconds\">10</span></span></button> <button type=\"button\" @click=\"stepChapter(1)\" class=\"rounded-full p-2.5 hover:bg-surface-hover transition-colors\" aria-label=\"Next chapter\" title=\"Next chapter\" style=\"color: var(--text-primary);\"><svg width=\"22\" height=\"22\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.75\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M5 4l10 8-10 8V4z\"></path> <path d=\"M19 5v14\"></path></svg></button></div></div><!-- Settings strip: Skip ▾ · Speed ▾ · Bookmark · Volume ▾ --><div class=\"w-full max-w-xl flex items-center justify-center gap-2\"><div class=\"relative\" @click.outside=\"skipOpen = false\"><button type=\"button\" @click=\"skipOpen = !skipOpen\" class=\"text-xs px-3 py-2 rounded-lg hover:bg-surface-hover transition-colors inline-flex items-center gap-1.5\" :aria-label=\"'Skip duration, currently ' + skipSeconds + ' seconds'\" style=\"color: var(--text-secondary);\"><span>Skip</span> <span class=\"font-semibold\" style=\"color: var(--text-primary);\" x-text=\"skipSeconds + 's'\">10s</span> <svg width=\"12\" height=\"12\" 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></Line truncated templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</option>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</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 {
return templ_7745c5c3_Err
}
if metadata.CoverImagePath != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<img src=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.CoverImagePath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 184, Col: 36}
}
_, 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, 16, "\" alt=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 185, Col: 27}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\" class=\"w-full h-full object-cover\" onerror=\"this.style.display='none'\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<span class=\"text-5xl flex items-center justify-center h-full\" style=\"color: var(--text-secondary);\">🎧</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</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 {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(metadata.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 194, Col: 117}
}
_, 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, 20, "</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=\"scrubbing = true; scrubValue = Number($event.target.value)\" @change=\"seekTo(scrubValue); scrubbing = false\" 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=\"'left:' + ((tick.start - railFrom) / Math.max(1, railTo - railFrom) * 100) + '%;background-color:var(--text-secondary);opacity:0.5;'\"></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 typeLine truncated
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+40 -1
View File
@@ -5,10 +5,32 @@ import (
"fmt" "fmt"
"net/url" "net/url"
// NOTE: do not import "github.com/a-h/templ" here — templ's generator
// already emits that import into the generated file, and a second copy
// is a compile error (redeclared block).
"github.com/microcosm-cc/bluemonday" "github.com/microcosm-cc/bluemonday"
"strings" "strings"
) )
// cssURL neutralizes characters that would break an UNQUOTED CSS url()
// token (whitespace, quotes, parens, backslash). The stored cover paths
// are already percent-encoded URL paths, so this is defensive only — but
// it must exist: templ's codegen HTML-escapes style attribute values a
// second time, so ANY quote character (SafeCSS included) reaches the DOM
// as literal &#39; text and the browser never parses the url() at all
// (2026-10-21: every hero backdrop broken + /media/& 500 spam, because
// Firefox resolved the mangled token relative to the page URL).
func cssURL(s string) string {
return strings.NewReplacer(
" ", "%20",
"(", "%28",
")", "%29",
"'", "%27",
"\"", "%22",
"\\", "%5C",
).Replace(s)
}
templ BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombstoneDays int) { templ BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombstoneDays int) {
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
@@ -30,10 +52,27 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string, tomb
data-conflict-winner={ conflictWinnerSource(book.ActiveConflict) } data-conflict-winner={ conflictWinnerSource(book.ActiveConflict) }
> >
@Header(user, "/media/{ uuidToString(book.ID) }") @Header(user, "/media/{ uuidToString(book.ID) }")
if book.ActiveConflict != nil {
<!-- Sync-conflict banner (user request): the progress
card's warning sits far down a long page — this
strip is unmissable at the very top, and clicking
it opens the same resolution modal. -->
<button
type="button"
@click="showProgressSyncModal()"
class="w-full flex items-center gap-3 px-4 sm:px-6 lg:px-8 py-3 text-left"
style="background-color: color-mix(in srgb, var(--status-warning) 14%, transparent); border-bottom: 1px solid var(--status-warning);"
>
@Icon("alert", "h-5 w-5 shrink-0")
<span class="text-sm" style="color: var(--text-primary);">
Progress sync conflict detected on this item — click to review and resolve.
</span>
</button>
}
<!-- Hero with blurred cover backdrop --> <!-- Hero with blurred cover backdrop -->
<div class="relative overflow-hidden"> <div class="relative overflow-hidden">
if book.CoverImagePath.Valid && book.CoverImagePath.String != "" { if book.CoverImagePath.Valid && book.CoverImagePath.String != "" {
<div class="hero-backdrop" style={ "background-image: url('" + book.CoverImagePath.String + "')" }></div> <div class="hero-backdrop" style={ templ.SafeCSS("background-image: url(" + cssURL(book.CoverImagePath.String) + ")") }></div>
} }
<div class="relative z-10 xs:w-full md:mx-auto md:container px-4 sm:px-6 lg:px-8 pt-6 pb-8"> <div class="relative z-10 xs:w-full md:mx-auto md:container px-4 sm:px-6 lg:px-8 pt-6 pb-8">
<a id="back-link" href="/dashboard" class="inline-flex items-center gap-1.5 text-sm font-medium hover:underline transition-colors mb-6" style="color: var(--text-secondary);"> <a id="back-link" href="/dashboard" class="inline-flex items-center gap-1.5 text-sm font-medium hover:underline transition-colors mb-6" style="color: var(--text-secondary);">
File diff suppressed because it is too large. Load diff
+21 -1
View File
@@ -1,5 +1,15 @@
package templates package templates
import "fmt"
// effectiveAudioSpeed renders 0 (unset) as 1× — the schema default.
func effectiveAudioSpeed(user User) float64 {
if user.DefaultAudioSpeed == 0 {
return 1
}
return user.DefaultAudioSpeed
}
templ ProfileForm(user User, actionURL string, requireCurrentPassword bool, showRoleField bool, showCancelButton bool) { templ ProfileForm(user User, actionURL string, requireCurrentPassword bool, showRoleField bool, showCancelButton bool) {
<form <form
hx-put={ actionURL } hx-put={ actionURL }
@@ -66,6 +76,17 @@ templ ProfileForm(user User, actionURL string, requireCurrentPassword bool, show
<option value="material-dark" selected?={ user.Theme == "material-dark" }>Material Dark</option> <option value="material-dark" selected?={ user.Theme == "material-dark" }>Material Dark</option>
</select> </select>
</div> </div>
<div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1.5" style="color: var(--text-secondary)">Default Playback Speed</label>
<select
name="default_audio_speed"
class="input"
>
for _, rate := range SpeedOptions {
<option value={ fmt.Sprintf("%g", rate) } selected?={ effectiveAudioSpeed(user) == rate }>{ fmt.Sprintf("%g", rate) }×</option>
}
</select>
</div>
<div> <div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1.5" style="color: var(--text-secondary)">Timezone</label> <label class="block text-xs font-semibold uppercase tracking-wide mb-1.5" style="color: var(--text-secondary)">Timezone</label>
<select <select
@@ -125,7 +146,6 @@ templ ProfileForm(user User, actionURL string, requireCurrentPassword bool, show
type="password" type="password"
name="current_password" name="current_password"
class="input" class="input"
required
/> />
</div> </div>
<div> <div>
+132 -76
View File
@@ -8,6 +8,16 @@ package templates
import "github.com/a-h/templ" import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime" import templruntime "github.com/a-h/templ/runtime"
import "fmt"
// effectiveAudioSpeed renders 0 (unset) as 1× — the schema default.
func effectiveAudioSpeed(user User) float64 {
if user.DefaultAudioSpeed == 0 {
return 1
}
return user.DefaultAudioSpeed
}
func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showRoleField bool, showCancelButton bool) templ.Component { func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showRoleField bool, showCancelButton bool) 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
@@ -36,7 +46,7 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
var templ_7745c5c3_Var2 string var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(actionURL) templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(actionURL)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 5, Col: 20} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 15, Col: 20}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -57,7 +67,7 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
var templ_7745c5c3_Var3 string var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(user.Username) templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(user.Username)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 21, Col: 27} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 31, Col: 27}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -70,7 +80,7 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
var templ_7745c5c3_Var4 string var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(user.Email) templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(user.Email)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 30, Col: 24} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 40, Col: 24}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -83,7 +93,7 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
var templ_7745c5c3_Var5 string var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(user.FirstName) templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(user.FirstName)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 39, Col: 28} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 49, Col: 28}
} }
_, 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 {
@@ -96,7 +106,7 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
var templ_7745c5c3_Var6 string var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(user.LastName) templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(user.LastName)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 49, Col: 27} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 59, Col: 27}
} }
_, 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 {
@@ -172,277 +182,323 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, ">Material Dark</option></select></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Timezone</label> <select name=\"timezone\" class=\"input\"><option value=\"UTC\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, ">Material Dark</option></select></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Default Playback Speed</label> <select name=\"default_audio_speed\" class=\"input\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if user.Timezone == "UTC" { for _, rate := range SpeedOptions {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, " selected") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<option value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%g", rate))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 86, Col: 46}
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, ">UTC (UTC+0)</option> <option value=\"Pacific/Honolulu\"") _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if user.Timezone == "Pacific/Honolulu" { templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if effectiveAudioSpeed(user) == rate {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, " selected") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, " selected")
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, 25, ">Hawaii (UTC-10)</option> <option value=\"America/Anchorage\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, ">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if user.Timezone == "America/Anchorage" { var templ_7745c5c3_Var8 string
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, " selected") templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%g", rate))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 86, Col: 122}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "×</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, 27, ">Alaska (UTC-9/-8)</option> <option value=\"America/Los_Angeles\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</select></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Timezone</label> <select name=\"timezone\" class=\"input\"><option value=\"UTC\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if user.Timezone == "America/Los_Angeles" { if user.Timezone == "UTC" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, " selected") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, " selected")
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, ">Pacific (UTC-8/-7)</option> <option value=\"America/Denver\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, ">UTC (UTC+0)</option> <option value=\"Pacific/Honolulu\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if user.Timezone == "America/Denver" { if user.Timezone == "Pacific/Honolulu" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, " selected") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, " selected")
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, 31, ">Mountain (UTC-7/-6)</option> <option value=\"America/Phoenix\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, ">Hawaii (UTC-10)</option> <option value=\"America/Anchorage\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if user.Timezone == "America/Phoenix" { if user.Timezone == "America/Anchorage" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, " selected") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, " selected")
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, 33, ">Mountain - no DST (UTC-7)</option> <option value=\"America/Chicago\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, ">Alaska (UTC-9/-8)</option> <option value=\"America/Los_Angeles\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if user.Timezone == "America/Chicago" { if user.Timezone == "America/Los_Angeles" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, " selected") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, " selected")
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, ">Central (UTC-6/-5)</option> <option value=\"America/New_York\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, ">Pacific (UTC-8/-7)</option> <option value=\"America/Denver\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if user.Timezone == "America/New_York" { if user.Timezone == "America/Denver" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, " selected") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, " selected")
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, 37, ">Eastern (UTC-5/-4)</option> <option value=\"America/Sao_Paulo\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, ">Mountain (UTC-7/-6)</option> <option value=\"America/Phoenix\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if user.Timezone == "America/Sao_Paulo" { if user.Timezone == "America/Phoenix" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, " selected") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, " selected")
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, ">Brasilia (UTC-3/-2)</option> <option value=\"Europe/London\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, ">Mountain - no DST (UTC-7)</option> <option value=\"America/Chicago\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if user.Timezone == "Europe/London" { if user.Timezone == "America/Chicago" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, " selected") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, " selected")
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, 41, ">British (UTC+0/+1)</option> <option value=\"Europe/Paris\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, ">Central (UTC-6/-5)</option> <option value=\"America/New_York\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if user.Timezone == "Europe/Paris" { if user.Timezone == "America/New_York" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, " selected") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, " selected")
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, 43, ">Central European (UTC+1/+2)</option> <option value=\"Europe/Helsinki\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, ">Eastern (UTC-5/-4)</option> <option value=\"America/Sao_Paulo\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if user.Timezone == "Europe/Helsinki" { if user.Timezone == "America/Sao_Paulo" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, " selected") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, " selected")
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, 45, ">Eastern European (UTC+2/+3)</option> <option value=\"Europe/Moscow\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, ">Brasilia (UTC-3/-2)</option> <option value=\"Europe/London\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if user.Timezone == "Europe/Moscow" { if user.Timezone == "Europe/London" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, " selected") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, " selected")
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, 47, ">Moscow (UTC+3)</option> <option value=\"Asia/Tehran\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, ">British (UTC+0/+1)</option> <option value=\"Europe/Paris\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if user.Timezone == "Asia/Tehran" { if user.Timezone == "Europe/Paris" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, " selected") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, " selected")
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, ">Iran (UTC+3:30)</option> <option value=\"Asia/Dubai\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, ">Central European (UTC+1/+2)</option> <option value=\"Europe/Helsinki\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if user.Timezone == "Asia/Dubai" { if user.Timezone == "Europe/Helsinki" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, " selected") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, " selected")
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, ">Gulf (UTC+4)</option> <option value=\"Asia/Karachi\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, ">Eastern European (UTC+2/+3)</option> <option value=\"Europe/Moscow\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if user.Timezone == "Asia/Karachi" { if user.Timezone == "Europe/Moscow" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, " selected") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, " selected")
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, ">Pakistan (UTC+5)</option> <option value=\"Asia/Kolkata\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, ">Moscow (UTC+3)</option> <option value=\"Asia/Tehran\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if user.Timezone == "Asia/Kolkata" { if user.Timezone == "Asia/Tehran" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, " selected") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, " selected")
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, ">India (UTC+5:30)</option> <option value=\"Asia/Dhaka\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, ">Iran (UTC+3:30)</option> <option value=\"Asia/Dubai\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if user.Timezone == "Asia/Dhaka" { if user.Timezone == "Asia/Dubai" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, " selected") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, " selected")
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, 57, ">Bangladesh (UTC+6)</option> <option value=\"Asia/Bangkok\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, ">Gulf (UTC+4)</option> <option value=\"Asia/Karachi\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if user.Timezone == "Asia/Bangkok" { if user.Timezone == "Asia/Karachi" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, " selected") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, " selected")
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, 59, ">Indochina (UTC+7)</option> <option value=\"Asia/Shanghai\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, ">Pakistan (UTC+5)</option> <option value=\"Asia/Kolkata\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if user.Timezone == "Asia/Shanghai" { if user.Timezone == "Asia/Kolkata" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, " selected") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, " selected")
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, 61, ">China (UTC+8)</option> <option value=\"Asia/Tokyo\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, ">India (UTC+5:30)</option> <option value=\"Asia/Dhaka\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if user.Timezone == "Asia/Tokyo" { if user.Timezone == "Asia/Dhaka" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, " selected") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, " selected")
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, 63, ">Japan/Korea (UTC+9)</option> <option value=\"Australia/Darwin\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, ">Bangladesh (UTC+6)</option> <option value=\"Asia/Bangkok\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if user.Timezone == "Australia/Darwin" { if user.Timezone == "Asia/Bangkok" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, " selected") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, " selected")
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, 65, ">Australian Central (UTC+9:30)</option> <option value=\"Australia/Sydney\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, ">Indochina (UTC+7)</option> <option value=\"Asia/Shanghai\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if user.Timezone == "Australia/Sydney" { if user.Timezone == "Asia/Shanghai" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, " selected") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, " selected")
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, 67, ">Australian Eastern (UTC+10/+11)</option> <option value=\"Pacific/Auckland\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, ">China (UTC+8)</option> <option value=\"Asia/Tokyo\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if user.Timezone == "Pacific/Auckland" { if user.Timezone == "Asia/Tokyo" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, " selected") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, " selected")
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, 69, ">New Zealand (UTC+12/+13)</option></select></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, ">Japan/Korea (UTC+9)</option> <option value=\"Australia/Darwin\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "Australia/Darwin" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, ">Australian Central (UTC+9:30)</option> <option value=\"Australia/Sydney\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "Australia/Sydney" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, ">Australian Eastern (UTC+10/+11)</option> <option value=\"Pacific/Auckland\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if user.Timezone == "Pacific/Auckland" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, ">New Zealand (UTC+12/+13)</option></select></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if showRoleField { if showRoleField {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "<div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Role</label> <select name=\"role\" class=\"input\"><option value=\"user\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "<div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Role</label> <select name=\"role\" class=\"input\"><option value=\"user\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if user.Role == "user" { if user.Role == "user" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, " selected") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, " selected")
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, 72, ">User</option> <option value=\"admin\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, ">User</option> <option value=\"admin\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if user.Role == "admin" { if user.Role == "admin" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, " selected") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, " selected")
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, 74, ">Admin</option></select></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, ">Admin</option></select></div>")
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, 75, "</div></div><div x-data=\"profilePasswordForm\"><div class=\"flex items-center gap-2 mb-4\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "</div></div><div x-data=\"profilePasswordForm\"><div class=\"flex items-center gap-2 mb-4\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -450,12 +506,12 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
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, 76, "<h3 class=\"text-lg font-semibold\" style=\"color: var(--text-primary)\">Change Password</h3></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "<h3 class=\"text-lg font-semibold\" style=\"color: var(--text-primary)\">Change Password</h3></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if requireCurrentPassword { if requireCurrentPassword {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "<div class=\"space-y-4 max-w-md\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Current Password</label> <input type=\"password\" name=\"current_password\" class=\"input\" required></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">New Password</label> <input type=\"password\" id=\"new-password\" name=\"new_password\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Confirm New Password</label> <input type=\"password\" id=\"confirm-password\" name=\"confirm_password\" class=\"input\"></div><div id=\"password-requirements\" class=\"text-xs\"></div><button type=\"button\" id=\"password-submit-btn\" hx-put=\"/api/auth/password\" hx-target=\"#password-result\" hx-swap=\"innerHTML\" hx-include=\"closest form\" class=\"btn btn-primary\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "<div class=\"space-y-4 max-w-md\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Current Password</label> <input type=\"password\" name=\"current_password\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">New Password</label> <input type=\"password\" id=\"new-password\" name=\"new_password\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Confirm New Password</label> <input type=\"password\" id=\"confirm-password\" name=\"confirm_password\" class=\"input\"></div><div id=\"password-requirements\" class=\"text-xs\"></div><button type=\"button\" id=\"password-submit-btn\" hx-put=\"/api/auth/password\" hx-target=\"#password-result\" hx-swap=\"innerHTML\" hx-include=\"closest form\" class=\"btn btn-primary\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -463,25 +519,25 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
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, 78, "Update Password</button><div id=\"password-result\" class=\"mt-2\"></div></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "Update Password</button><div id=\"password-result\" class=\"mt-2\"></div></div>")
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, 79, "<p class=\"text-sm italic mb-4\" style=\"color: var(--text-secondary);\">As an admin, you can change this user's password without knowing their current password.</p><div class=\"space-y-4 max-w-md\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">New Password</label> <input type=\"password\" id=\"new-password\" name=\"new_password\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Confirm New Password</label> <input type=\"password\" id=\"confirm-password\" name=\"confirm_password\" class=\"input\"></div><div id=\"password-requirements\" class=\"text-xs\"></div><button type=\"button\" id=\"password-submit-btn\" hx-put=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "<p class=\"text-sm italic mb-4\" style=\"color: var(--text-secondary);\">As an admin, you can change this user's password without knowing their current password.</p><div class=\"space-y-4 max-w-md\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">New Password</label> <input type=\"password\" id=\"new-password\" name=\"new_password\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1.5\" style=\"color: var(--text-secondary)\">Confirm New Password</label> <input type=\"password\" id=\"confirm-password\" name=\"confirm_password\" class=\"input\"></div><div id=\"password-requirements\" class=\"text-xs\"></div><button type=\"button\" id=\"password-submit-btn\" hx-put=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var7 string var templ_7745c5c3_Var9 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/auth/password/" + user.ID) templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/auth/password/" + user.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 191, Col: 46} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 211, Col: 46}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
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, 80, "\" hx-target=\"#password-result\" hx-swap=\"innerHTML\" hx-include=\"closest form\" class=\"btn btn-primary\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "\" hx-target=\"#password-result\" hx-swap=\"innerHTML\" hx-include=\"closest form\" class=\"btn btn-primary\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -489,22 +545,22 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
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, 81, "Reset Password</button><div id=\"password-result\" class=\"mt-2\"></div></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "Reset Password</button><div id=\"password-result\" class=\"mt-2\"></div></div>")
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, 82, "</div><div class=\"flex justify-end gap-3 pt-6 border-t\" style=\"border-color: var(--border);\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "</div><div class=\"flex justify-end gap-3 pt-6 border-t\" style=\"border-color: var(--border);\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if showCancelButton { if showCancelButton {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "<button type=\"button\" @click=\"closeProfileModal()\" class=\"btn btn-secondary\">Cancel</button> ") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "<button type=\"button\" @click=\"closeProfileModal()\" class=\"btn btn-secondary\">Cancel</button> ")
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, "<button type=\"submit\" class=\"btn btn-primary\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "<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
} }
@@ -512,7 +568,7 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
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, 85, "Save Changes</button></div><div id=\"profile-result\" class=\"mt-2\"></div></form>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "Save Changes</button></div><div id=\"profile-result\" class=\"mt-2\"></div></form>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+3
View File
@@ -350,6 +350,7 @@ templ ReaderChrome(metadata ReaderMetadata) {
step="any" step="any"
list="tick-marks" list="tick-marks"
@input="goToFraction($event.target.value)" @input="goToFraction($event.target.value)"
@pointerup="$el.blur()"
class="grow" class="grow"
/> />
<datalist id="tick-marks"></datalist> <datalist id="tick-marks"></datalist>
@@ -392,6 +393,7 @@ templ ReaderChrome(metadata ReaderMetadata) {
max="1" max="1"
step="any" step="any"
@input="goToFraction($event.target.value)" @input="goToFraction($event.target.value)"
@pointerup="$el.blur()"
class="grow min-w-[3rem]" class="grow min-w-[3rem]"
/> />
<button @click="goRight()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Go Right (→)" aria-label="Go right"> <button @click="goRight()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Go Right (→)" aria-label="Go right">
@@ -482,6 +484,7 @@ templ ReaderChrome(metadata ReaderMetadata) {
max="1" max="1"
step="any" step="any"
@input="goToFraction($event.target.value)" @input="goToFraction($event.target.value)"
@pointerup="$el.blur()"
class="grow min-w-[3rem]" class="grow min-w-[3rem]"
/> />
<button @click="goRight()" class="p-1.5 rounded-lg hover:bg-gray-700" title="Go Right (→)" aria-label="Go right"> <button @click="goRight()" class="p-1.5 rounded-lg hover:bg-gray-700" title="Go Right (→)" aria-label="Go right">
+2 -2
View File
@@ -194,7 +194,7 @@ func ReaderChrome(metadata ReaderMetadata) 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, 13, "</h1><div class=\"flex items-center gap-1\"><button @click=\"addBookmark()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Bookmark this position (b)\">🏷️</button> <button x-show=\"(!isFixedLayout || isPDF) && !treatAsComic\" @click=\"toggleSearch()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Search (/)\">🔍</button> <button @click=\"toggleBookmarks()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Annotations (a)\">📝</button> <button @click=\"toggleSettings()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Settings (s)\">Aa</button> <button @click=\"toggleHelp()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700 text-sm font-bold\" title=\"Shortcuts & gestures (?)\" aria-label=\"Shortcuts and gestures\">?</button> <button @click=\"toggleFullscreen()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Fullscreen (f)\" aria-label=\"Toggle fullscreen\"><svg x-show=\"!isFullscreen\" class=\"reader-icon\" width=\"18\" height=\"18\" aria-hidden=\"true\"><path d=\"M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3\"></path></svg> <svg x-show=\"isFullscreen\" x-cloak class=\"reader-icon\" width=\"18\" height=\"18\" aria-hidden=\"true\"><path d=\"M8 3v3a2 2 0 0 1-2 2H3m18 0h-3a2 2 0 0 1-2-2V3m0 18v-3a2 2 0 0 1 2-2h3M3 16h3a2 2 0 0 1 2 2v3\"></path></svg></button></div></div></div><!-- Bottom bar --><div id=\"reader-bottombar\" class=\"fixed bottom-0 left-0 right-0 border-t z-40 pb-[env(safe-area-inset-bottom)] reader-glass\"><!-- Reflowable row --><div x-show=\"!isFixedLayout\" class=\"flex flex-wrap items-center gap-x-1 gap-y-1 px-1.5 py-1.5 sm:px-2 sm:py-2 sm:gap-1\"><div class=\"flex items-center gap-0.5 grow min-w-[190px]\"><button @click=\"goLeft()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Go Left\" aria-label=\"Go left\"><svg class=\"reader-icon\" width=\"24\" height=\"24\" aria-hidden=\"true\"><path d=\"M 15 6 L 9 12 L 15 18\"></path></svg></button> <button x-show=\"backStack.length > 0\" @click=\"goBackToLocation()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Return to previous location (Alt+←)\" aria-label=\"Return to previous location\"><svg class=\"reader-icon\" width=\"20\" height=\"20\" aria-hidden=\"true\"><path d=\"M 12 21 C 9 18 5 14.4 5 9.5 C 5 6.5 8 4 12 4 C 16 4 19 6.5 19 9.5 C 19 14.4 15 18 12 21 Z\"></path> <circle cx=\"12\" cy=\"9.5\" r=\"2\"></circle></svg></button> <input id=\"progress-slider\" type=\"range\" min=\"0\" max=\"1\" step=\"any\" list=\"tick-marks\" @input=\"goToFraction($event.target.value)\" class=\"grow\"> <datalist id=\"tick-marks\"></datalist> <button @click=\"goRight()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Go Right\" aria-label=\"Go right\"><svg class=\"reader-icon\" width=\"24\" height=\"24\" aria-hidden=\"true\"><path d=\"M 9 6 L 15 12 L 9 18\"></path></svg></button></div><div class=\"flex items-center gap-1\"><div class=\"w-px h-6 reader-sep\"></div><div id=\"progress-display\" @click=\"cycleProgressMode()\" :title=\"progressTooltip()\" class=\"text-sm min-w-[4rem] max-w-[5rem] sm:max-w-none text-center cursor-pointer truncate whitespace-nowrap overflow-hidden\"><span class=\"hidden sm:inline\" x-text=\"progressLabel\"></span><span x-text=\"progressMain\">—</span></div><div class=\"w-px h-6 reader-sep\"></div><button @click=\"toggleTOC()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Table of Contents (t)\">📖</button></div></div><!-- Fixed-layout row (comics, PDFs, manga) --><div x-show=\"isFixedLayout\"><!-- Full toolbar: desktop/tablet (>= 768px, md). Wraps gracefully in between. --><div class=\"hidden md:flex flex-wrap items-center gap-x-1 gap-y-1 px-1.5 py-1.5 sm:px-2 sm:py-2 sm:gap-1\"><div class=\"flex items-center gap-0.5\"><button @click=\"goLeft()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Go Left (←)\" aria-label=\"Go left\"><svg class=\"reader-icon\" width=\"24\" height=\"24\" aria-hidden=\"true\"><path d=\"M 15 6 L 9 12 L 15 18\"></path></svg></button> <button x-show=\"backStack.length > 0\" @click=\"goBackToLocation()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Return to previous location (Alt+←)\" aria-label=\"Return to previous location\"><svg class=\"reader-icon\" width=\"20\" height=\"20\" aria-hidden=\"true\"><path d=\"M 12 21 C 9 18 5 14.4 5 9.5 C 5 6.5 8 4 12 4 C 16 4 19 6.5 19 9.5 C 19 14.4 15 18 12 21 Z\"></path> <circle cx=\"12\" cy=\"9.5\" r=\"2\"></circle></svg></button></div><input id=\"progress-slider-fx\" type=\"range\" min=\"0\" max=\"1\" step=\"any\" @input=\"goToFraction($event.target.value)\" class=\"grow min-w-[3rem]\"> <button @click=\"goRight()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Go Right (→)\" aria-label=\"Go right\"><svg class=\"reader-icon\" width=\"24\" height=\"24\" aria-hidden=\"true\Line truncated templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</h1><div class=\"flex items-center gap-1\"><button @click=\"addBookmark()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Bookmark this position (b)\">🏷️</button> <button x-show=\"(!isFixedLayout || isPDF) && !treatAsComic\" @click=\"toggleSearch()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Search (/)\">🔍</button> <button @click=\"toggleBookmarks()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Annotations (a)\">📝</button> <button @click=\"toggleSettings()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Settings (s)\">Aa</button> <button @click=\"toggleHelp()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700 text-sm font-bold\" title=\"Shortcuts & gestures (?)\" aria-label=\"Shortcuts and gestures\">?</button> <button @click=\"toggleFullscreen()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Fullscreen (f)\" aria-label=\"Toggle fullscreen\"><svg x-show=\"!isFullscreen\" class=\"reader-icon\" width=\"18\" height=\"18\" aria-hidden=\"true\"><path d=\"M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3\"></path></svg> <svg x-show=\"isFullscreen\" x-cloak class=\"reader-icon\" width=\"18\" height=\"18\" aria-hidden=\"true\"><path d=\"M8 3v3a2 2 0 0 1-2 2H3m18 0h-3a2 2 0 0 1-2-2V3m0 18v-3a2 2 0 0 1 2-2h3M3 16h3a2 2 0 0 1 2 2v3\"></path></svg></button></div></div></div><!-- Bottom bar --><div id=\"reader-bottombar\" class=\"fixed bottom-0 left-0 right-0 border-t z-40 pb-[env(safe-area-inset-bottom)] reader-glass\"><!-- Reflowable row --><div x-show=\"!isFixedLayout\" class=\"flex flex-wrap items-center gap-x-1 gap-y-1 px-1.5 py-1.5 sm:px-2 sm:py-2 sm:gap-1\"><div class=\"flex items-center gap-0.5 grow min-w-[190px]\"><button @click=\"goLeft()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Go Left\" aria-label=\"Go left\"><svg class=\"reader-icon\" width=\"24\" height=\"24\" aria-hidden=\"true\"><path d=\"M 15 6 L 9 12 L 15 18\"></path></svg></button> <button x-show=\"backStack.length > 0\" @click=\"goBackToLocation()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Return to previous location (Alt+←)\" aria-label=\"Return to previous location\"><svg class=\"reader-icon\" width=\"20\" height=\"20\" aria-hidden=\"true\"><path d=\"M 12 21 C 9 18 5 14.4 5 9.5 C 5 6.5 8 4 12 4 C 16 4 19 6.5 19 9.5 C 19 14.4 15 18 12 21 Z\"></path> <circle cx=\"12\" cy=\"9.5\" r=\"2\"></circle></svg></button> <input id=\"progress-slider\" type=\"range\" min=\"0\" max=\"1\" step=\"any\" list=\"tick-marks\" @input=\"goToFraction($event.target.value)\" @pointerup=\"$el.blur()\" class=\"grow\"> <datalist id=\"tick-marks\"></datalist> <button @click=\"goRight()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Go Right\" aria-label=\"Go right\"><svg class=\"reader-icon\" width=\"24\" height=\"24\" aria-hidden=\"true\"><path d=\"M 9 6 L 15 12 L 9 18\"></path></svg></button></div><div class=\"flex items-center gap-1\"><div class=\"w-px h-6 reader-sep\"></div><div id=\"progress-display\" @click=\"cycleProgressMode()\" :title=\"progressTooltip()\" class=\"text-sm min-w-[4rem] max-w-[5rem] sm:max-w-none text-center cursor-pointer truncate whitespace-nowrap overflow-hidden\"><span class=\"hidden sm:inline\" x-text=\"progressLabel\"></span><span x-text=\"progressMain\">—</span></div><div class=\"w-px h-6 reader-sep\"></div><button @click=\"toggleTOC()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Table of Contents (t)\">📖</button></div></div><!-- Fixed-layout row (comics, PDFs, manga) --><div x-show=\"isFixedLayout\"><!-- Full toolbar: desktop/tablet (>= 768px, md). Wraps gracefully in between. --><div class=\"hidden md:flex flex-wrap items-center gap-x-1 gap-y-1 px-1.5 py-1.5 sm:px-2 sm:py-2 sm:gap-1\"><div class=\"flex items-center gap-0.5\"><button @click=\"goLeft()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Go Left (←)\" aria-label=\"Go left\"><svg class=\"reader-icon\" width=\"24\" height=\"24\" aria-hidden=\"true\"><path d=\"M 15 6 L 9 12 L 15 18\"></path></svg></button> <button x-show=\"backStack.length > 0\" @click=\"goBackToLocation()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Return to previous location (Alt+←)\" aria-label=\"Return to previous location\"><svg class=\"reader-icon\" width=\"20\" height=\"20\" aria-hidden=\"true\"><path d=\"M 12 21 C 9 18 5 14.4 5 9.5 C 5 6.5 8 4 12 4 C 16 4 19 6.5 19 9.5 C 19 14.4 15 18 12 21 Z\"></path> <circle cx=\"12\" cy=\"9.5\" r=\"2\"></circle></svg></button></div><input id=\"progress-slider-fx\" type=\"range\" min=\"0\" max=\"1\" step=\"any\" @input=\"goToFraction($event.target.value)\" @pointerup=\"$el.blur()\" class=\"grow min-w-[3rem]\"> <button @click=\"goRight()\" class=\"p-1.5 sm:p-2 rounded-lg hover:bg-gray-700\" title=\"Go Right (→)\" aria-label=\"Go right\"><svg class=\"reader-iLine truncated
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -230,7 +230,7 @@ func drawerHeader(title string) templ.Component {
var templ_7745c5c3_Var10 string var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(title) templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(title)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 580, Col: 35} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 583, Col: 35}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
+7
View File
@@ -17,8 +17,15 @@ type User struct {
CreatedAt time.Time CreatedAt time.Time
Token string Token string
Timezone string Timezone string
// User-level default audiobook playback speed (plan §8); 0 means
// "unset" and renders as the 1× option.
DefaultAudioSpeed float64
} }
// SpeedOption is one entry of the default-playback-speed select — the
// server-side mirror of the web player's SPEEDS list (plan §5/§8).
var SpeedOptions = []float64{0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 2.5, 3}
type PageData struct { type PageData struct {
User User User User
CurrentPage string CurrentPage string
+12
View File
@@ -19,6 +19,13 @@ const pdfjsAssets = () => ({
// so hashed chunks from previous builds would otherwise accumulate // so hashed chunks from previous builds would otherwise accumulate
// forever and leak into Docker images via the build context. Remove // forever and leak into Docker images via the build context. Remove
// any *-<hash>.js/.css/.mjs(.map) that this build did not produce. // any *-<hash>.js/.css/.mjs(.map) that this build did not produce.
//
// The produced.size === 0 guard is load-bearing: this config also drives
// `vp test`, and vitest runs closeBundle with generateBundle never having
// fired — an empty produced set means "not a build run", and sweeping
// then deleted every live chunk (the 2026-10-21 incident where
// `npm run test` gutted web/static down to the three entry files and
// every page 404'd its imports).
const cleanStaleChunks = () => { const cleanStaleChunks = () => {
const produced = new Set<string>(); const produced = new Set<string>();
return { return {
@@ -27,6 +34,7 @@ const cleanStaleChunks = () => {
for (const fileName of Object.keys(bundle)) produced.add(fileName); for (const fileName of Object.keys(bundle)) produced.add(fileName);
}, },
closeBundle() { closeBundle() {
if (produced.size === 0) return;
const outDir = "web/static"; const outDir = "web/static";
const chunkRe = /-[A-Za-z0-9_-]{8}\.(js|css|mjs)(\.map)?$/; const chunkRe = /-[A-Za-z0-9_-]{8}\.(js|css|mjs)(\.map)?$/;
for (const f of readdirSync(outDir)) { for (const f of readdirSync(outDir)) {
@@ -44,6 +52,10 @@ export default defineConfig({
fmt: { fmt: {
ignorePatterns: ["web/static/**"], ignorePatterns: ["web/static/**"],
}, },
test: {
environment: "jsdom",
include: ["web/src/**/*.test.ts"],
},
resolve: { resolve: {
alias: { alias: {
"@": "/web/src", "@": "/web/src",
+89
View File
@@ -0,0 +1,89 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getReadingProgress, handleResponse, handleVoidResponse } from "./api";
// api.ts registers an Alpine component at import time; the toast import it
// carries installs a fetch interceptor. Neither interferes with the stubbed
// global fetch below, but both are exercised by importing the module.
const jsonResponse = (body: unknown, status = 200): Response =>
new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
describe("handleResponse", () => {
it("parses ok JSON bodies", async () => {
await expect(handleResponse(jsonResponse({ ok: true }))).resolves.toEqual({ ok: true });
});
it("throws the server's error message", async () => {
await expect(handleResponse(jsonResponse({ error: "Nope" }, 404))).rejects.toThrow("Nope");
});
it("falls back to the HTTP status when the body has no message", async () => {
await expect(handleResponse(jsonResponse({ error: "" }, 500))).rejects.toThrow("HTTP 500");
});
it("falls back to Unknown error when the error body is not JSON", async () => {
await expect(handleResponse(new Response("<html>502</html>", { status: 502 }))).rejects.toThrow(
"Unknown error",
);
});
});
describe("handleVoidResponse", () => {
it("resolves on ok regardless of body", async () => {
await expect(
handleVoidResponse(new Response("ignored", { status: 200 })),
).resolves.toBeUndefined();
});
it("throws the server's error message", async () => {
await expect(handleVoidResponse(jsonResponse({ error: "Denied" }, 403))).rejects.toThrow(
"Denied",
);
});
});
describe("getReadingProgress", () => {
beforeEach(() => {
localStorage.clear();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("returns null on 404 (no progress yet)", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => jsonResponse({ error: "none" }, 404)),
);
await expect(getReadingProgress("m1")).resolves.toBeNull();
});
it("returns the progress payload otherwise", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () =>
jsonResponse({ current_page: 3, total_pages: 10, last_read_at: "2026-01-01T00:00:00Z" }),
),
);
const progress = await getReadingProgress("m1");
expect(progress).toMatchObject({ current_page: 3 });
});
it("sends the auth header when a token is stored", async () => {
localStorage.setItem("token", "tok-9");
const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) =>
jsonResponse({ error: "none" }, 404),
);
vi.stubGlobal("fetch", fetchMock);
await getReadingProgress("m1");
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0]?.[1]?.headers).toMatchObject({ Authorization: "Bearer tok-9" });
});
});
+91
View File
@@ -0,0 +1,91 @@
import { describe, expect, it } from "vitest";
import { KEYMAP, resolveShortcut, visibleKeymap } from "./audio-keys";
describe("resolveShortcut", () => {
it("resolves every in-page binding", () => {
expect(resolveShortcut(" ", false)).toBe("toggle-play");
expect(resolveShortcut("Enter", false)).toBe("toggle-play");
expect(resolveShortcut("h", false)).toBe("skip-back");
expect(resolveShortcut("H", false)).toBe("skip-back");
expect(resolveShortcut("ArrowLeft", false)).toBe("skip-back");
expect(resolveShortcut("l", false)).toBe("skip-forward");
expect(resolveShortcut("ArrowRight", false)).toBe("skip-forward");
expect(resolveShortcut("k", false)).toBe("prev-chapter");
expect(resolveShortcut("PageUp", false)).toBe("prev-chapter");
expect(resolveShortcut("j", false)).toBe("next-chapter");
expect(resolveShortcut("PageDown", false)).toBe("next-chapter");
expect(resolveShortcut("b", false)).toBe("bookmark");
expect(resolveShortcut("ArrowUp", false)).toBe("volume-up");
expect(resolveShortcut("ArrowDown", false)).toBe("volume-down");
expect(resolveShortcut("m", false)).toBe("toggle-mute");
expect(resolveShortcut("]", false)).toBe("cycle-skip-up");
expect(resolveShortcut("[", false)).toBe("cycle-skip-down");
expect(resolveShortcut("?", false)).toBe("toggle-help");
expect(resolveShortcut("Escape", false)).toBe("escape");
});
it("keeps Alt bindings distinct from their plain counterparts", () => {
expect(resolveShortcut("l", true)).toBe("speed-up");
expect(resolveShortcut("l", false)).toBe("skip-forward");
expect(resolveShortcut("h", true)).toBe("speed-down");
expect(resolveShortcut("ArrowRight", true)).toBe("speed-up");
expect(resolveShortcut("ArrowLeft", true)).toBe("speed-down");
// Alt on an unbound key claims nothing.
expect(resolveShortcut("ArrowUp", true)).toBeNull();
expect(resolveShortcut("m", true)).toBeNull();
});
it("resolves in-page media key events", () => {
expect(resolveShortcut("MediaPlayPause", false)).toBe("media-toggle-play");
expect(resolveShortcut("MediaPlay", false)).toBe("media-play");
expect(resolveShortcut("MediaPause", false)).toBe("media-pause");
expect(resolveShortcut("MediaStop", false)).toBe("media-pause");
expect(resolveShortcut("MediaTrackNext", false)).toBe("media-next");
expect(resolveShortcut("MediaTrackPrevious", false)).toBe("media-prev");
});
it("returns null for unknown keys", () => {
expect(resolveShortcut("x", false)).toBeNull();
expect(resolveShortcut("F5", false)).toBeNull();
expect(resolveShortcut("", false)).toBeNull();
});
});
describe("KEYMAP integrity", () => {
it("has exactly one spec per action", () => {
const actions = KEYMAP.map((spec) => spec.action);
expect(new Set(actions).size).toBe(actions.length);
});
it("has no conflicting (key, alt) combos across actions", () => {
const seen = new Map<string, string>();
for (const spec of KEYMAP) {
for (const k of spec.keys) {
const combo = `${k.alt ? "Alt+" : ""}${k.key}`;
const owner = seen.get(combo);
expect(owner, `${combo} bound by both ${owner} and ${spec.action}`).toBeUndefined();
seen.set(combo, spec.action);
}
}
});
it("gives every spec at least one key, a label and a description", () => {
for (const spec of KEYMAP) {
expect(spec.keys.length, spec.action).toBeGreaterThan(0);
expect(spec.label.length, spec.action).toBeGreaterThan(0);
expect(spec.description.length, spec.action).toBeGreaterThan(0);
}
});
it("hides only media-key rows from the dialog", () => {
const visible = new Set(visibleKeymap().map((spec) => spec.action));
for (const spec of KEYMAP) {
if (spec.action.startsWith("media-")) {
expect(visible.has(spec.action), spec.action).toBe(false);
} else {
expect(visible.has(spec.action), spec.action).toBe(true);
}
}
});
});
+262
View File
@@ -0,0 +1,262 @@
// Declarative keyboard map for the audio player (player plan §2).
// ONE table drives BOTH the document keydown dispatcher (audio.ts
// resolves events through resolveShortcut) and the templ-rendered
// shortcut dialog (x-for over visibleKeymap). Never hardcode a key
// binding in the handler or a row in the dialog — add it here.
//
// Hardware media keys are a separate layer: they ride the existing
// MediaSession action handlers even when the tab is unfocused. The
// Media* entries below cover browsers that ALSO deliver them as
// in-page keydown events; they resolve to the same methods the
// MediaSession handlers call (togglePlay / stepChapter).
export type KeyAction =
| "toggle-play"
| "skip-back"
| "skip-forward"
| "prev-chapter"
| "next-chapter"
| "bookmark"
| "speed-up"
| "speed-down"
| "volume-up"
| "volume-down"
| "toggle-mute"
| "cycle-skip-up"
| "cycle-skip-down"
| "toggle-help"
| "escape"
| "media-toggle-play"
| "media-play"
| "media-pause"
| "media-next"
| "media-prev";
export interface KeySpec {
// e.key value to match. Single characters are matched
// case-insensitively by resolveShortcut; multi-character key names
// ("Enter", "ArrowLeft", "MediaPlayPause") must match verbatim.
key: string;
// Requires the Alt modifier. Alt-qualified entries disambiguate the
// plain binding (Alt+L = speed up while L = skip forward).
alt?: boolean;
// Dialog badge label, e.g. "Space", "←", "Alt + →".
label: string;
}
export interface KeyActionSpec {
action: KeyAction;
// Dialog row label.
label: string;
description: string;
keys: readonly KeySpec[];
// Media-key entries are resolved but not shown in the dialog —
// hardware keys, not something a keyboard user types.
hidden?: boolean;
}
export const KEYMAP: readonly KeyActionSpec[] = [
{
action: "toggle-play",
label: "Play / pause",
description: "Play or pause (only when no control is focused)",
keys: [
{ key: " ", label: "Space" },
{ key: "Enter", label: "Enter" },
],
},
{
action: "skip-back",
label: "Skip back",
description: "Skip back by the skip duration",
keys: [
{ key: "h", label: "H" },
{ key: "ArrowLeft", label: "←" },
],
},
{
action: "skip-forward",
label: "Skip forward",
description: "Skip forward by the skip duration",
keys: [
{ key: "l", label: "L" },
{ key: "ArrowRight", label: "→" },
],
},
{
action: "prev-chapter",
label: "Previous chapter",
description: "Previous chapter",
keys: [
{ key: "k", label: "K" },
{ key: "PageUp", label: "PgUp" },
],
},
{
action: "next-chapter",
label: "Next chapter",
description: "Next chapter",
keys: [
{ key: "j", label: "J" },
{ key: "PageDown", label: "PgDn" },
],
},
{
action: "bookmark",
label: "Bookmark",
description: "Bookmark this position — Enter saves, Shift+Enter newline",
keys: [{ key: "b", label: "B" }],
},
{
action: "speed-up",
label: "Speed up",
description: "Step playback speed up",
keys: [
{ key: "ArrowRight", alt: true, label: "Alt + →" },
{ key: "l", alt: true, label: "Alt + L" },
],
},
{
action: "speed-down",
label: "Speed down",
description: "Step playback speed down",
keys: [
{ key: "ArrowLeft", alt: true, label: "Alt + ←" },
{ key: "h", alt: true, label: "Alt + H" },
],
},
{
action: "volume-up",
label: "Volume up",
description: "Volume up",
keys: [{ key: "ArrowUp", label: "↑" }],
},
{
action: "volume-down",
label: "Volume down",
description: "Volume down",
keys: [{ key: "ArrowDown", label: "↓" }],
},
{
action: "toggle-mute",
label: "Mute",
description: "Mute or unmute",
keys: [{ key: "m", label: "M" }],
},
{
action: "cycle-skip-up",
label: "Longer skip",
description: "Cycle the skip duration up",
keys: [{ key: "]", label: "]" }],
},
{
action: "cycle-skip-down",
label: "Shorter skip",
description: "Cycle the skip duration down",
keys: [{ key: "[", label: "[" }],
},
{
action: "toggle-help",
label: "Shortcuts",
description: "Show or hide this dialog",
keys: [{ key: "?", label: "?" }],
},
{
action: "escape",
label: "Close",
description: "Close dialog, cancel bookmark, close panels",
keys: [{ key: "Escape", label: "Esc" }],
},
// Media keys: same actions the MediaSession handlers expose.
{
action: "media-toggle-play",
label: "Media play/pause key",
description: "Hardware media play/pause",
keys: [{ key: "MediaPlayPause", label: "Media ⏯" }],
hidden: true,
},
{
action: "media-play",
label: "Media play key",
description: "Hardware media play",
keys: [{ key: "MediaPlay", label: "Media ▶" }],
hidden: true,
},
{
action: "media-pause",
label: "Media pause/stop key",
description: "Hardware media pause or stop",
keys: [
{ key: "MediaPause", label: "Media ⏸" },
{ key: "MediaStop", label: "Media ⏹" },
],
hidden: true,
},
{
action: "media-next",
label: "Media next key",
description: "Hardware media next (chapter step, like the notification)",
keys: [{ key: "MediaTrackNext", label: "Media ⏭" }],
hidden: true,
},
{
action: "media-prev",
label: "Media previous key",
description: "Hardware media previous (chapter step, like the notification)",
keys: [{ key: "MediaTrackPrevious", label: "Media ⏮" }],
hidden: true,
},
];
// visibleKeymap filters out the hardware-media rows for the dialog.
export function visibleKeymap(): KeyActionSpec[] {
return KEYMAP.filter((spec) => !spec.hidden);
}
// Pointer/wheel/touch interactions (plan §9) are not key events — the
// dispatcher never sees them — but they belong in the same dialog so
// every scrub gesture is discoverable in one place. TWO sections,
// because the bar really does have two scrub systems (plan §12): the
// desktop-pointer family and the touch family. templ x-fors these
// through the component's `mouseGuide`/`touchGuide` properties (same
// binding pattern as speeds/skipChoices).
export interface PointerGuideRow {
// Badge label, e.g. "Shift + drag".
label: string;
description: string;
}
export const MOUSE_GUIDE: readonly PointerGuideRow[] = [
{ label: "Click", description: "Seek — lands on a chapter line within a pixel's reach" },
{ label: "Drag", description: "Free scrub across the whole bar" },
{ label: "Shift + click", description: "Jump to the nearest chapter line" },
{ label: "Shift + drag", description: "Precision rail (±5 min) around the nearest line" },
{ label: "Wheel", description: "Nudge ±5s (Shift: ±60s)" },
{
label: "End time",
description: "Click to cycle the −label: total → book left → chapter left",
},
];
export const TOUCH_GUIDE: readonly PointerGuideRow[] = [
{ label: "Drag", description: "Free scrub across the whole bar" },
{
label: "Pull away",
description: "Drag off the bar while holding — the further you pull, the finer it gets",
},
];
// resolveShortcut maps a keydown's (key, altKey) to an action, or null
// when nothing claims it. Single characters match case-insensitively;
// Alt-qualified bindings take precedence order for free: a plain key
// never matches an alt binding and vice versa, so L and Alt+L are
// distinct by construction.
export function resolveShortcut(key: string, altKey: boolean): KeyAction | null {
const normalized = key.length === 1 ? key.toLowerCase() : key;
for (const spec of KEYMAP) {
for (const k of spec.keys) {
if (k.key === normalized && (k.alt ?? false) === altKey) return spec.action;
}
}
return null;
}
+331
View File
@@ -0,0 +1,331 @@
import { describe, expect, it } from "vitest";
import {
applyDrag,
bufferedAheadEnd,
bufferedSpanPct,
chapterIndexFor,
denseTicks,
dragScale,
DRAG_REF_PX,
DRAG_SCALE_MIN,
nearestChapterLine,
railLiftPx,
railSecondsAtPointer,
railTicks,
railWindow,
RAIL_SPAN,
secondsAtPointer,
snapToChapterPx,
TOUCH_RAIL_LIFT_MAX_PX,
TOUCH_RAIL_MIN_DY_PX,
touchRailSpan,
} from "./audio-scrubber";
// An 86-hour book (KJV-shaped: 1189 even chapters) and a 26-hour
// single-file book (Gardens-shaped: 34 chapters).
const duration86h = 310_320;
const chapters1189 = Array.from({ length: 1189 }, (_, i) => ({ start: (i * duration86h) / 1189 }));
const duration26h = 93_807;
const chapters34 = Array.from({ length: 34 }, (_, i) => ({
start: (i * duration26h) / 34,
title: `Chapter ${i + 1}`,
}));
const rect = (width: number): DOMRect =>
({
left: 100,
right: 100 + width,
width,
top: 0,
bottom: 10,
height: 10,
x: 100,
y: 0,
toJSON: () => ({}),
}) as DOMRect;
describe("secondsAtPointer", () => {
it("maps the bar linearly and clamps outside it", () => {
const r = rect(800);
expect(secondsAtPointer(100, r, duration86h)).toBe(0);
expect(secondsAtPointer(500, r, duration86h)).toBeCloseTo(duration86h / 2, 5);
expect(secondsAtPointer(900, r, duration86h)).toBe(duration86h);
expect(secondsAtPointer(50, r, duration86h)).toBe(0);
expect(secondsAtPointer(1200, r, duration86h)).toBe(duration86h);
});
it("is safe with degenerate rects and durations", () => {
expect(secondsAtPointer(150, rect(0), duration86h)).toBe(0);
expect(secondsAtPointer(150, rect(800), 0)).toBe(0);
});
});
describe("chapterIndexFor", () => {
it("finds the containing chapter in both shapes", () => {
expect(chapterIndexFor(chapters1189, 0)).toBe(0);
expect(chapterIndexFor(chapters1189, chapters1189[500].start + 1)).toBe(500);
expect(chapterIndexFor(chapters34, chapters34[33].start + 60)).toBe(33);
expect(chapterIndexFor(chapters34, duration26h + 5000)).toBe(33);
});
it("returns -1 before the first chapter and for none at all", () => {
expect(chapterIndexFor(chapters1189, -1)).toBe(-1);
expect(chapterIndexFor([], 100)).toBe(-1);
});
});
describe("nearestChapterLine / snapToChapterPx (§9 pixel magnetism)", () => {
// 34-chapter book on an 800px bar: ~23.5px between lines — the
// radius discriminates. (On the KJV-shaped bar the 1189 lines sit
// ~0.4px apart — magnetism lands on SOME line from anywhere, which
// is correct for a book that dense.)
const pxSec = 800 / duration26h; // bar px per second
it("reports the closest line and its distance in bar pixels", () => {
const start = chapters34[5].start;
const near = nearestChapterLine(start + 5 / pxSec, chapters34, duration26h, 800);
expect(near!.index).toBe(5);
expect(near!.seconds).toBe(start);
expect(near!.distancePx).toBeCloseTo(5, 5);
expect(nearestChapterLine(42, [], duration26h, 800)).toBeNull();
});
it("finds sub-pixel lines on a book as dense as the KJV", () => {
// 1px ≈ 633s — a seconds threshold could never fire here; a pixel
// radius always can. This is the §9 root-cause fix.
const dense = nearestChapterLine(duration86h / 2, chapters1189, duration86h, 489);
expect(dense).not.toBeNull();
expect(dense!.distancePx).toBeLessThan(0.25);
});
it("snaps within the pixel radius and not beyond it", () => {
const start = chapters34[5].start;
expect(snapToChapterPx(start + 9 / pxSec, chapters34, duration26h, 800)).toEqual({
seconds: start,
snapped: true,
});
expect(snapToChapterPx(start + 11 / pxSec, chapters34, duration26h, 800).snapped).toBe(false);
expect(snapToChapterPx(start - 10 / pxSec, chapters34, duration26h, 800)).toEqual({
seconds: start,
snapped: true,
});
});
it("force snaps to the nearest line regardless of radius (Shift+click)", () => {
const a = chapters34[5].start;
const b = chapters34[6].start;
const mid = (a + b) / 2;
// Radius 0 + force must land where a wide radius lands: distance
// is ignored, nearest wins (mid+1 → the later line).
expect(snapToChapterPx(mid + 1, chapters34, duration26h, 800, 0, true)).toEqual({
seconds: b,
snapped: true,
});
// Past the book's last line, the nearest line is still reachable.
const last = chapters34[33].start;
expect(snapToChapterPx(last + 1000 / pxSec, chapters34, duration26h, 800, 0, true)).toEqual({
seconds: last,
snapped: true,
});
});
it("picks the NEAREST line when two are in radius", () => {
const a = chapters34[5].start;
const b = chapters34[6].start;
const mid = (a + b) / 2;
// Both lines sit ~11.8px from the midpoint; inside a 13px radius
// the closer (later) one must win.
const result = snapToChapterPx(mid + 1, chapters34, duration26h, 800, 13);
expect(result.snapped).toBe(true);
expect(result.seconds).toBe(b);
});
it("leaves the position alone with no chapters or a dead bar", () => {
expect(snapToChapterPx(42, [], duration26h, 800)).toEqual({ seconds: 42, snapped: false });
expect(snapToChapterPx(42, chapters34, duration26h, 0).snapped).toBe(false);
});
});
describe("dragScale / applyDrag (§9 touch progressive precision)", () => {
it("is 1:1 at the bar and decays an order per reference distance", () => {
expect(dragScale(0)).toBe(1);
expect(dragScale(DRAG_REF_PX)).toBeCloseTo(0.1, 5);
expect(dragScale(2 * DRAG_REF_PX)).toBeCloseTo(0.01, 5);
});
it("is symmetric in pull direction and monotonic in |dy|", () => {
expect(dragScale(-60)).toBe(dragScale(60));
expect(dragScale(30)).toBeGreaterThan(dragScale(60));
expect(dragScale(60)).toBeGreaterThan(dragScale(120));
expect(dragScale(10_000)).toBe(DRAG_SCALE_MIN);
});
it("degenerates to plain travel with a dead reference", () => {
expect(dragScale(500, 0)).toBe(1);
});
it("advances the cursor 1:1 at dy=0 (px → seconds at bar scale)", () => {
// 800px bar over 26h: 100px ≈ 11726s.
expect(applyDrag(0, 100, 0, 800 / duration26h, duration26h)).toBeCloseTo(
(100 / 800) * duration26h,
5,
);
});
it("shrinks travel by the current zoom (incremental, not absolute)", () => {
const pxPerSecond = 800 / duration26h;
const onePxSeconds = 1 / pxPerSecond;
// One reference-distance of pull = one order of magnitude finer.
expect(applyDrag(0, 100, DRAG_REF_PX, pxPerSecond, duration26h)).toBeCloseTo(
100 * onePxSeconds * 0.1,
5,
);
// Scale changes only affect SUBSEQUENT travel: the cursor never
// teleports when the finger pulls away mid-drag.
expect(applyDrag(5000, 0, 3 * DRAG_REF_PX, pxPerSecond, duration26h)).toBe(5000);
});
it("clamps to the book in both directions", () => {
const pxPerSecond = 800 / duration26h;
expect(applyDrag(duration26h, 100, 0, pxPerSecond, duration26h)).toBe(duration26h);
expect(applyDrag(1, -100, 0, pxPerSecond, duration26h)).toBe(0);
expect(applyDrag(50, 100, 0, 0, duration26h)).toBe(50);
});
});
describe("touch rail feedback (span shrink + lift)", () => {
it("starts at the full ±5-min span and shrinks with the zoom", () => {
expect(touchRailSpan(0)).toBe(RAIL_SPAN);
expect(touchRailSpan(DRAG_REF_PX)).toBeCloseTo(RAIL_SPAN * 0.1, 5);
expect(touchRailSpan(5 * DRAG_REF_PX)).toBe(2); // zoomed past the floor
expect(touchRailSpan(60)).toBeLessThan(RAIL_SPAN);
});
it("shows the rail only past the pull threshold", () => {
expect(TOUCH_RAIL_MIN_DY_PX).toBeGreaterThan(0);
});
it("lifts toward the finger, capped", () => {
expect(railLiftPx(20)).toBe(-20); // finger above the bar → rail rises
expect(railLiftPx(-30)).toBe(30); // finger below → rail follows down
expect(railLiftPx(500)).toBe(-TOUCH_RAIL_LIFT_MAX_PX);
expect(railLiftPx(-500)).toBe(TOUCH_RAIL_LIFT_MAX_PX);
});
});
describe("denseTicks", () => {
it("decimates an 1189-chapter book to a readable number of marks", () => {
const ticks = denseTicks(chapters1189, duration86h, 800, 4);
expect(ticks.length).toBeGreaterThan(50);
expect(ticks.length).toBeLessThan(300);
expect(ticks[0]).toBe(0);
// kept marks are strictly increasing and spaced by the gap rule
for (let i = 1; i < ticks.length; i += 1) {
const gapPx =
((chapters1189[ticks[i]].start - chapters1189[ticks[i - 1]].start) / duration86h) * 800;
expect(gapPx).toBeGreaterThanOrEqual(4);
}
});
it("keeps every chapter for a sparse 34-chapter book", () => {
const ticks = denseTicks(chapters34, duration26h, 800, 4);
expect(ticks).toHaveLength(34);
});
it("handles degenerate inputs", () => {
expect(denseTicks([], duration86h, 800)).toEqual([]);
expect(denseTicks(chapters34, 0, 800)).toEqual([]);
expect(denseTicks(chapters34, duration26h, 0)).toEqual([]);
});
});
describe("bufferedAheadEnd", () => {
it("returns the end of the range containing the playhead", () => {
const ranges = [
{ start: 0, end: 2 },
{ start: 92_719, end: 93_808 },
];
// A huge m4b's moov index at the tail is its own range — it must
// NOT read as "the whole book is buffered".
expect(bufferedAheadEnd(ranges, 0)).toBe(2);
expect(bufferedAheadEnd(ranges, 1.5)).toBe(2);
expect(bufferedAheadEnd(ranges, 92_800)).toBe(93_808);
});
it("walks into the next range when the playhead sits at a boundary", () => {
expect(
bufferedAheadEnd(
[
{ start: 0, end: 10 },
{ start: 10, end: 30 },
],
10,
),
).toBe(30);
});
it("returns null when the playhead is in a gap or ranges are empty", () => {
expect(bufferedAheadEnd([{ start: 50, end: 60 }], 10)).toBeNull();
expect(bufferedAheadEnd([], 10)).toBeNull();
});
});
describe("bufferedSpanPct", () => {
it("maps only the active track's segment on a multi-track book", () => {
// Track 8 of the KJV shape spans ~2088s..2344s of 310320s.
const track = { start: 2088, end: 2344 };
const span = bufferedSpanPct(track, 2344, duration86h);
expect(span).not.toBeNull();
expect(span!.left).toBeCloseTo((2088 / duration86h) * 100, 5);
expect(span!.width).toBeCloseTo(((2344 - 2088) / duration86h) * 100, 5);
// Element buffer beyond the track end is clamped to the track.
const clamped = bufferedSpanPct(track, 99999, duration86h);
expect(clamped!.width).toBeCloseTo(((2344 - 2088) / duration86h) * 100, 5);
});
it("spans the whole bar for a single-file book (start 0, end unknown)", () => {
const span = bufferedSpanPct({ start: 0, end: 0 }, 40_000, duration26h);
expect(span!.left).toBe(0);
expect(span!.width).toBeCloseTo((40_000 / duration26h) * 100, 5);
});
it("returns null with no track or no buffer", () => {
expect(bufferedSpanPct(undefined, 100, duration86h)).toBeNull();
expect(bufferedSpanPct({ start: 0, end: 0 }, null, duration86h)).toBeNull();
expect(bufferedSpanPct({ start: 0, end: 0 }, 0, duration86h)).toBeNull();
});
});
describe("railWindow / railSecondsAtPointer / railTicks", () => {
it("centers a ±5min window and clamps at the book edges", () => {
const mid = railWindow(duration26h / 2, duration26h);
expect(mid.from).toBeCloseTo(duration26h / 2 - RAIL_SPAN / 2, 5);
expect(mid.to).toBeCloseTo(duration26h / 2 + RAIL_SPAN / 2, 5);
const atStart = railWindow(0, duration26h);
expect(atStart.from).toBe(0);
expect(atStart.to).toBe(RAIL_SPAN);
const atEnd = railWindow(duration26h, duration26h);
expect(atEnd.to).toBe(duration26h);
expect(atEnd.to - atEnd.from).toBe(RAIL_SPAN);
// A book shorter than the window clamps to the whole book.
expect(railWindow(60, 120)).toEqual({ from: 0, to: 120 });
});
it("maps pointer X at minutes-per-pixel inside the window", () => {
const r = rect(600);
const window = { from: 3000, to: 3600 };
expect(railSecondsAtPointer(100, r, window)).toBe(3000);
expect(railSecondsAtPointer(400, r, window)).toBe(3300);
expect(railSecondsAtPointer(700, r, window)).toBe(3600);
});
it("lists the chapter starts inside the window with titles", () => {
// from is 1s past chapter 4's start, so chapter 4 is outside.
const ticks = railTicks(
{ from: chapters34[3].start + 1, to: chapters34[6].start + 1 },
chapters34,
);
expect(ticks.map((t) => t.title)).toEqual(["Chapter 5", "Chapter 6", "Chapter 7"]);
});
});
+227
View File
@@ -0,0 +1,227 @@
// Scrubber math (player plans §1 + §9). Pure and DOM-free except for
// passing rects in; the Alpine component owns the element, the pointer
// events and the state. Everything here is unit-tested — the pixel
// magnetism, the pull-away drag scaling and the precision-rail mapping
// are exactly the parts that must hold for an 86-hour, 1189-track book
// AND a 26-hour single-file m4b.
export interface ScrubChapter {
start: number;
title?: string;
}
// secondsAtPointer maps a pointer X within the scrub bar's rect to a
// global book position, clamped to [0, duration].
export function secondsAtPointer(clientX: number, rect: DOMRect, duration: number): number {
if (rect.width <= 0 || duration <= 0) return 0;
const fraction = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));
return fraction * duration;
}
// chapterIndexFor returns the index of the chapter containing seconds —
// the last chapter whose start is at or before it. Empty chapters → -1.
export function chapterIndexFor(chapters: ScrubChapter[], seconds: number): number {
let found = -1;
for (let i = 0; i < chapters.length; i += 1) {
if (seconds >= chapters[i].start) found = i;
else break;
}
return found;
}
// ---- §9 magnetism: pixel radius, not a seconds threshold ----
// A seconds threshold goes sub-pixel on long books (1px ≈ 633s on the
// KJV bar — 5s could never fire); magnetism must be measured in PIXELS.
// MAGNET_RADIUS_PX is how close a click/hover must be to a chapter line,
// in bar pixels, for it to take over the landing position.
export const MAGNET_RADIUS_PX = 10;
// nearestChapterLine finds the chapter start closest to a position and
// reports its distance in bar pixels. Empty chapters → null.
export function nearestChapterLine(
seconds: number,
chapters: ScrubChapter[],
duration: number,
widthPx: number,
): { index: number; seconds: number; distancePx: number } | null {
if (chapters.length === 0 || duration <= 0 || widthPx <= 0) return null;
const pxPerSecond = widthPx / duration;
let best: { index: number; seconds: number; distancePx: number } | null = null;
for (let i = 0; i < chapters.length; i += 1) {
const distancePx = Math.abs(chapters[i].start - seconds) * pxPerSecond;
if (best === null || distancePx < best.distancePx)
best = { index: i, seconds: chapters[i].start, distancePx };
}
return best;
}
// snapToChapterPx applies the magnet: within radiusPx of a line the
// position lands ON the line; force (Shift+click) extends the pull to
// the nearest line regardless of distance. Otherwise unchanged.
export function snapToChapterPx(
seconds: number,
chapters: ScrubChapter[],
duration: number,
widthPx: number,
radiusPx = MAGNET_RADIUS_PX,
force = false,
): { seconds: number; snapped: boolean } {
const near = nearestChapterLine(seconds, chapters, duration, widthPx);
if (near === null) return { seconds, snapped: false };
if (force || near.distancePx <= radiusPx) return { seconds: near.seconds, snapped: true };
return { seconds, snapped: false };
}
// ---- §9 touch precision: vertical pull-away = progressive zoom ----
// The scale factor decays an order of magnitude for every DRAG_REF_PX of
// vertical distance from the bar: 1:1 at the bar, ~0.1× one screen-gap
// away, ~0.01× further. Exponential keeps the zoom continuous — any
// precision level is one gesture (plan §9 option C).
export const DRAG_REF_PX = 80;
export const DRAG_SCALE_MIN = 0.001;
export function dragScale(dyPx: number, refPx: number = DRAG_REF_PX): number {
if (refPx <= 0) return 1;
return Math.max(DRAG_SCALE_MIN, Math.pow(10, -Math.abs(dyPx) / refPx));
}
// applyDrag advances an INCREMENTAL drag cursor by one pointermove:
// horizontal travel (px) converts at the bar's scale, then shrinks by
// the current vertical offset. Incremental, not absolute — changing the
// scale mid-drag only affects subsequent travel, so pulling away never
// teleports the position. Clamped to the book.
export function applyDrag(
position: number,
dxPx: number,
dyPx: number,
pxPerSecond: number,
duration: number,
refPx: number = DRAG_REF_PX,
): number {
const clamped = Math.min(duration, Math.max(0, position));
if (pxPerSecond <= 0 || duration <= 0) return clamped;
return Math.min(duration, Math.max(0, clamped + (dxPx / pxPerSecond) * dragScale(dyPx, refPx)));
}
// Touch feedback thresholds: the rail appears once the finger is at
// least TOUCH_RAIL_MIN_DY_PX off the bar, and lifts toward the finger up
// to TOUCH_RAIL_LIFT_MAX_PX while the window shrinks with the zoom.
export const TOUCH_RAIL_MIN_DY_PX = 12;
export const TOUCH_RAIL_LIFT_MAX_PX = 40;
// touchRailSpan shrinks the rail window with the zoom (full ±5-min rail
// at 1:1, an order tighter per DRAG_REF_PX of pull) with a floor that
// keeps the edge labels readable.
export function touchRailSpan(dyPx: number, refPx: number = DRAG_REF_PX): number {
return Math.max(2, Math.min(RAIL_SPAN, RAIL_SPAN * dragScale(dyPx, refPx)));
}
// railLiftPx converts the vertical offset into the rail's translateY:
// toward the finger (negative = up), capped so the rail never chases
// the finger off the player.
export function railLiftPx(dyPx: number): number {
return -Math.max(-TOUCH_RAIL_LIFT_MAX_PX, Math.min(TOUCH_RAIL_LIFT_MAX_PX, dyPx));
}
// denseTicks decides which chapter starts get a rendered tick: a
// chapter earns a mark only when it is at least minGapPx from the last
// kept one — an 1189-chapter book would otherwise paint the bar solid.
// Returns chapter INDICES (into chapters), always including the first.
export function denseTicks(
chapters: ScrubChapter[],
duration: number,
widthPx: number,
minGapPx = 4,
): number[] {
if (chapters.length === 0 || duration <= 0 || widthPx <= 0) return [];
const kept: number[] = [0];
let lastPx = (chapters[0].start / duration) * widthPx;
for (let i = 1; i < chapters.length; i += 1) {
const px = (chapters[i].start / duration) * widthPx;
if (px - lastPx >= minGapPx) {
kept.push(i);
lastPx = px;
}
}
return kept;
}
// bufferedAheadEnd returns the end of the buffered range containing
// the playhead — how much audio is actually fetched AHEAD of where you
// are. A huge m4b's moov index at the file tail shows up as its own
// buffered range; counting it would claim the whole book is buffered.
export function bufferedAheadEnd(
ranges: Array<{ start: number; end: number }>,
position: number,
): number | null {
for (const r of ranges) {
if (position >= r.start && position < r.end) return r.end;
}
return null;
}
// bufferedSpanPct maps the ACTIVE track's buffered-ahead end into the
// global bar. Buffers are per-track: the span is drawn only across the
// active track's segment of the bar, never the whole bar. Returns null
// when there is nothing buffered (or the track is unknown).
export function bufferedSpanPct(
track: { start: number; end: number } | undefined,
elementBufferedEnd: number | null,
duration: number,
): { left: number; width: number } | null {
if (!track || elementBufferedEnd === null || elementBufferedEnd <= 0 || duration <= 0)
return null;
const bufferedEnd = track.end > 0 ? Math.min(elementBufferedEnd, track.end) : elementBufferedEnd;
const span = Math.max(0, bufferedEnd - track.start);
const left = Math.min(100, Math.max(0, (track.start / duration) * 100));
const width = Math.min(100 - left, Math.max(0, (span / duration) * 100));
return { left, width };
}
// RAIL_SPAN is the precision rail's total width in seconds: ±5 minutes
// around the drag anchor.
export const RAIL_SPAN = 600;
// railWindow clamps a ±span/2 window around the anchor to the book.
export function railWindow(
anchorSeconds: number,
duration: number,
spanSeconds = RAIL_SPAN,
): { from: number; to: number } {
const from = Math.max(
0,
Math.min(anchorSeconds - spanSeconds / 2, Math.max(0, duration - spanSeconds)),
);
const to = Math.min(duration, from + spanSeconds);
return { from, to };
}
// railSecondsAtPointer maps a pointer X to a position INSIDE the rail
// window — minutes-per-pixel instead of hours-per-pixel.
export function railSecondsAtPointer(
clientX: number,
rect: DOMRect,
window: { from: number; to: number },
): number {
if (rect.width <= 0) return window.from;
const fraction = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));
return window.from + fraction * (window.to - window.from);
}
// railTicks returns the chapter starts (with titles) inside a rail
// window — readable density is the rail's whole point.
export function railTicks(
window: { from: number; to: number },
chapters: ScrubChapter[],
): Array<{ start: number; title: string }> {
const out: Array<{ start: number; title: string }> = [];
for (let i = 0; i < chapters.length; i += 1) {
const start = chapters[i].start;
if (start < window.from) continue;
if (start > window.to) break;
out.push({ start, title: chapters[i].title || `Chapter ${i + 1}` });
}
return out;
}
+164
View File
@@ -0,0 +1,164 @@
import { describe, expect, it } from "vitest";
import {
SLEEP_CHOICES,
SLEEP_OFF,
armClockTime,
armEndOfChapter,
armMinutes,
fadeVolume,
formatRemaining,
sleepChapterFired,
sleepDue,
sleepLabel,
sleepRemainingMs,
} from "./audio-sleep";
describe("armMinutes / sleepDue / sleepRemainingMs", () => {
it("arms a wall-clock deadline", () => {
const state = armMinutes(1_000_000, 5);
expect(state.mode).toBe("minutes");
expect(state.endsAtMs).toBe(1_000_000 + 5 * 60_000);
expect(sleepDue(state, 1_000_000 + 5 * 60_000 - 1)).toBe(false);
expect(sleepDue(state, 1_000_000 + 5 * 60_000)).toBe(true);
expect(sleepDue(state, 1_000_000 + 9 * 60_000)).toBe(true);
});
it("reports remaining time clamped at zero", () => {
const state = armMinutes(0, 10);
expect(sleepRemainingMs(state, 0)).toBe(600_000);
expect(sleepRemainingMs(state, 599_000)).toBe(1000);
expect(sleepRemainingMs(state, 700_000)).toBe(0);
});
it("never fires in non-minutes modes", () => {
expect(sleepDue(SLEEP_OFF, Date.now())).toBe(false);
expect(sleepDue(armEndOfChapter(3), Date.now())).toBe(false);
expect(sleepRemainingMs(SLEEP_OFF, Date.now())).toBeNull();
});
});
describe("armEndOfChapter / sleepChapterFired", () => {
it("fires when the armed chapter is left, in either direction", () => {
const state = armEndOfChapter(7);
expect(sleepChapterFired(state, 7)).toBe(false);
expect(sleepChapterFired(state, 8)).toBe(true);
expect(sleepChapterFired(state, 6)).toBe(true);
});
it("does nothing in other modes", () => {
expect(sleepChapterFired(SLEEP_OFF, 8)).toBe(false);
expect(sleepChapterFired(armMinutes(0, 5), 8)).toBe(false);
});
});
describe("fadeVolume", () => {
it("holds the base at start, ramps linearly, reaches zero at fade end", () => {
expect(fadeVolume(0.5, 0)).toBe(0.5);
expect(fadeVolume(0.5, 2500)).toBeCloseTo(0.25, 10);
expect(fadeVolume(0.5, 5000)).toBe(0);
expect(fadeVolume(0.5, 9000)).toBe(0);
expect(fadeVolume(1, 1000)).toBeCloseTo(0.8, 10);
});
it("clamps negative elapsed to the base and out-of-range volumes", () => {
expect(fadeVolume(0.5, -5)).toBe(0.5);
expect(fadeVolume(2, 1000)).toBeLessThanOrEqual(1);
});
});
describe("formatRemaining / sleepLabel", () => {
it("formats mm:ss, rounding up partial seconds", () => {
expect(formatRemaining(45 * 60_000)).toBe("45:00");
expect(formatRemaining(1000)).toBe("0:01");
expect(formatRemaining(59_500)).toBe("1:00");
expect(formatRemaining(0)).toBe("0:00");
});
it("labels every mode", () => {
expect(sleepLabel(SLEEP_OFF, 0)).toBe("Off");
expect(sleepLabel(armEndOfChapter(2), 0)).toBe("End of chapter");
expect(
sleepLabel({ mode: "fading", endsAtMs: null, chapterIndex: null, followUpMinutes: null }, 0),
).toBe("Fading");
expect(sleepLabel(armMinutes(1000, 5), 1000 + 65_000)).toBe("3:55");
});
});
describe("armClockTime (§17 stop-at-clock)", () => {
it("arms a minutes-state deadline at the next occurrence of HH:MM", () => {
// 10:00 local → 23:00 same day.
const tenAm = new Date();
tenAm.setHours(10, 0, 0, 0);
const state = armClockTime(tenAm.getTime(), "23:00");
expect(state?.mode).toBe("minutes");
const expected = new Date(tenAm);
expected.setHours(23, 0, 0, 0);
expect(state?.endsAtMs).toBe(expected.getTime());
// Label is the ordinary countdown machinery (12h59m → 779:00).
expect(sleepLabel(state!, tenAm.getTime() + 60_000)).toBe("779:00");
});
it("wraps to tomorrow when the time already passed", () => {
const noon = new Date();
noon.setHours(12, 0, 0, 0);
const state = armClockTime(noon.getTime(), "09:15");
const expected = new Date(noon);
expected.setDate(expected.getDate() + 1);
expected.setHours(9, 15, 0, 0);
expect(state?.endsAtMs).toBe(expected.getTime());
});
it("rejects unparseable and out-of-range input", () => {
const now = Date.now();
expect(armClockTime(now, "nope")).toBeNull();
expect(armClockTime(now, "24:00")).toBeNull();
expect(armClockTime(now, "10:61")).toBeNull();
});
});
describe("§17 chain: end-of-chapter with a follow-up countdown", () => {
it("arms the follow-up on the state; the chapter fire contract is unchanged", () => {
const state = armEndOfChapter(2, 15);
expect(state.followUpMinutes).toBe(15);
// The sync contract (any chapter change fires) is the same — the
// component converts to armMinutes instead of fading.
expect(sleepChapterFired(state, 3)).toBe(true);
expect(sleepChapterFired(state, 1)).toBe(true);
expect(sleepChapterFired(state, 2)).toBe(false);
});
it("labels the chapter phase with the follow-up length", () => {
expect(sleepLabel(armEndOfChapter(2, 15), 0)).toBe("Ch + 15m");
expect(sleepLabel(armEndOfChapter(2), 0)).toBe("End of chapter");
});
it("offers the chain row in the dropdown vocabulary", () => {
const chain = SLEEP_CHOICES.find((c) => c.key === "eoch15");
expect(chain).toMatchObject({ mode: "end-of-chapter", minutes: 15, chain: true });
});
});
describe("SLEEP_CHOICES", () => {
it("is the agreed option list: off, 5/10/15/30/45/60, end-of-chapter, chain", () => {
expect(SLEEP_CHOICES.map((c) => c.key)).toEqual([
"off",
"5",
"10",
"15",
"30",
"45",
"60",
"eoch",
"eoch15",
]);
expect(SLEEP_CHOICES.filter((c) => c.mode === "minutes").map((c) => c.minutes)).toEqual([
5, 10, 15, 30, 45, 60,
]);
});
it("has unique keys and labels", () => {
expect(new Set(SLEEP_CHOICES.map((c) => c.key)).size).toBe(SLEEP_CHOICES.length);
expect(new Set(SLEEP_CHOICES.map((c) => c.label)).size).toBe(SLEEP_CHOICES.length);
});
});
+141
View File
@@ -0,0 +1,141 @@
// Sleep timer state machine (player plan §3). Pure and DOM-free — the
// Alpine component owns the timers/intervals and the audio element;
// this module owns the STATE rules. Per-session only: never persisted
// (the Android counterpart's persisted sleep setting is exactly the
// mistake plan §7 removes).
export type SleepMode = "off" | "minutes" | "end-of-chapter" | "fading";
export interface SleepState {
mode: SleepMode;
// Epoch ms deadline for "minutes" mode; null otherwise.
endsAtMs: number | null;
// Chapter index the end-of-chapter timer was armed at; null otherwise.
chapterIndex: number | null;
// §17 chain: when set on an end-of-chapter arm, leaving the chapter
// does NOT fade — it starts a minutes countdown of this length
// ("end of chapter, then 15 min").
followUpMinutes: number | null;
}
export const SLEEP_OFF: SleepState = {
mode: "off",
endsAtMs: null,
chapterIndex: null,
followUpMinutes: null,
};
export function armMinutes(nowMs: number, minutes: number): SleepState {
return {
mode: "minutes",
endsAtMs: nowMs + minutes * 60_000,
chapterIndex: null,
followUpMinutes: null,
};
}
export function armEndOfChapter(
chapterIndex: number,
followUpMinutes: number | null = null,
): SleepState {
return { mode: "end-of-chapter", endsAtMs: null, chapterIndex, followUpMinutes };
}
// armClockTime (§17): "stop at 23:00" — resolves HH:MM to its NEXT
// occurrence (today if still ahead, otherwise tomorrow) and arms the
// ordinary minutes machinery against that epoch deadline. Returns null
// for unparseable strings; the caller keeps the old state then.
export function armClockTime(nowMs: number, hhmm: string): SleepState | null {
const m = /^(\d{1,2}):(\d{2})$/.exec(hhmm.trim());
if (!m) return null;
const h = Number(m[1]);
const min = Number(m[2]);
if (h > 23 || min > 59) return null;
const target = new Date(nowMs);
target.setHours(h, min, 0, 0);
if (target.getTime() <= nowMs) target.setDate(target.getDate() + 1);
return { mode: "minutes", endsAtMs: target.getTime(), chapterIndex: null, followUpMinutes: null };
}
// sleepDue is true when a minutes-mode deadline has passed.
export function sleepDue(state: SleepState, nowMs: number): boolean {
return state.mode === "minutes" && state.endsAtMs !== null && nowMs >= state.endsAtMs;
}
// sleepChapterFired is true when an end-of-chapter timer's chapter has
// been left — ANY change from the armed index fires it (forward into
// the next chapter naturally, or a manual jump away from it).
export function sleepChapterFired(state: SleepState, currentIndex: number): boolean {
return (
state.mode === "end-of-chapter" &&
state.chapterIndex !== null &&
currentIndex !== state.chapterIndex
);
}
export function sleepRemainingMs(state: SleepState, nowMs: number): number | null {
if (state.mode !== "minutes" || state.endsAtMs === null) return null;
return Math.max(0, state.endsAtMs - nowMs);
}
// fadeVolume ramps linearly base → 0 across fadeMs and clamps beyond
// both ends.
export function fadeVolume(baseVolume: number, elapsedMs: number, fadeMs = 5000): number {
const base = Math.min(1, Math.max(0, baseVolume));
if (elapsedMs <= 0) return base;
if (elapsedMs >= fadeMs) return 0;
return Math.max(0, base * (1 - elapsedMs / fadeMs));
}
export function formatRemaining(ms: number): string {
const totalSeconds = Math.ceil(Math.max(0, ms) / 1000);
const m = Math.floor(totalSeconds / 60);
const s = totalSeconds % 60;
return `${m}:${String(s).padStart(2, "0")}`;
}
export function sleepLabel(state: SleepState, nowMs: number): string {
switch (state.mode) {
case "off":
return "Off";
case "end-of-chapter":
return state.followUpMinutes !== null ? `Ch + ${state.followUpMinutes}m` : "End of chapter";
case "fading":
return "Fading";
case "minutes": {
const remaining = sleepRemainingMs(state, nowMs);
return remaining === null ? "Off" : formatRemaining(remaining);
}
}
}
// Dropdown vocabulary — the templ sleep dropdown iterates this (the
// component exposes it; templ cannot import TS) and chooseSleep()
// consumes it. One source for both the rows and their behavior.
export interface SleepChoice {
key: string;
label: string;
mode: "off" | "minutes" | "end-of-chapter";
minutes?: number;
// chain: this end-of-chapter row arms a follow-up minutes countdown
// ("end of chapter, then N min") instead of fading straight away.
chain?: boolean;
}
export const SLEEP_CHOICES: readonly SleepChoice[] = [
{ key: "off", label: "Off", mode: "off" },
{ key: "5", label: "5 minutes", mode: "minutes", minutes: 5 },
{ key: "10", label: "10 minutes", mode: "minutes", minutes: 10 },
{ key: "15", label: "15 minutes", mode: "minutes", minutes: 15 },
{ key: "30", label: "30 minutes", mode: "minutes", minutes: 30 },
{ key: "45", label: "45 minutes", mode: "minutes", minutes: 45 },
{ key: "60", label: "60 minutes", mode: "minutes", minutes: 60 },
{ key: "eoch", label: "End of chapter", mode: "end-of-chapter" },
{
key: "eoch15",
label: "End of chapter, then 15 min",
mode: "end-of-chapter",
minutes: 15,
chain: true,
},
];
+141
View File
@@ -0,0 +1,141 @@
import { describe, expect, it } from "vitest";
import {
SPEEDS,
SKIP_SECONDS,
TIME_DISPLAY_MODES,
cycleSkip,
cycleTimeDisplay,
endTimeLabel,
normalizeAutoplay,
normalizeSpeed,
normalizeTimeDisplay,
stepSpeed,
} from "./audio";
describe("SPEEDS", () => {
it("is the agreed list from the player plan (0.5 through 3)", () => {
expect(SPEEDS).toEqual([0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 2.5, 3]);
});
it("contains 1× exactly once and stays ascending", () => {
expect(SPEEDS.filter((r) => r === 1)).toHaveLength(1);
expect([...SPEEDS].sort((a, b) => a - b)).toEqual([...SPEEDS]);
});
});
describe("normalizeSpeed", () => {
it("passes through every listed rate", () => {
for (const rate of SPEEDS) {
expect(normalizeSpeed(String(rate))).toBe(rate);
}
});
it("falls back to 1× for null, garbage and out-of-list values", () => {
expect(normalizeSpeed(null)).toBe(1);
expect(normalizeSpeed("")).toBe(1);
expect(normalizeSpeed("not-a-number")).toBe(1);
expect(normalizeSpeed("0")).toBe(1);
expect(normalizeSpeed("2.2")).toBe(1);
expect(normalizeSpeed("10")).toBe(1);
});
it("accepts a stale pre-plan entry of 0.75 or 2", () => {
expect(normalizeSpeed("0.75")).toBe(0.75);
expect(normalizeSpeed("2")).toBe(2);
});
});
describe("stepSpeed", () => {
it("steps forward and backward through the list", () => {
expect(stepSpeed(1, 1)).toBe(1.25);
expect(stepSpeed(1, -1)).toBe(0.75);
expect(stepSpeed(0.5, 1)).toBe(0.75);
expect(stepSpeed(2, 1)).toBe(2.5);
});
it("clamps at both ends instead of wrapping", () => {
expect(stepSpeed(0.5, -1)).toBe(0.5);
expect(stepSpeed(3, 1)).toBe(3);
});
it("anchors unknown currents at 1× before stepping", () => {
expect(stepSpeed(2.2, 1)).toBe(1.25);
expect(stepSpeed(2.2, -1)).toBe(0.75);
});
});
describe("SKIP_SECONDS + cycleSkip", () => {
it("is the agreed skip list", () => {
expect(SKIP_SECONDS).toEqual([1, 5, 10, 15, 30]);
});
it("cycles forward and backward, clamping at the ends", () => {
expect(cycleSkip(10, 1)).toBe(15);
expect(cycleSkip(10, -1)).toBe(5);
expect(cycleSkip(1, -1)).toBe(1);
expect(cycleSkip(30, 1)).toBe(30);
});
it("anchors unknown currents at 10s before stepping", () => {
expect(cycleSkip(7, 1)).toBe(15);
expect(cycleSkip(7, -1)).toBe(5);
});
});
describe("normalizeAutoplay", () => {
it("is on only for explicitly-on values", () => {
expect(normalizeAutoplay("1")).toBe(true);
expect(normalizeAutoplay("true")).toBe(true);
});
it("defaults to off for null, off, and garbage", () => {
expect(normalizeAutoplay(null)).toBe(false);
expect(normalizeAutoplay("0")).toBe(false);
expect(normalizeAutoplay("")).toBe(false);
expect(normalizeAutoplay("on")).toBe(false);
expect(normalizeAutoplay("2")).toBe(false);
});
});
describe("time display mode (§10 end-label toggle)", () => {
it("cycles total → book remaining → chapter remaining → total", () => {
expect(TIME_DISPLAY_MODES).toEqual(["total", "book-left", "chapter-left"]);
expect(cycleTimeDisplay("total")).toBe("book-left");
expect(cycleTimeDisplay("book-left")).toBe("chapter-left");
expect(cycleTimeDisplay("chapter-left")).toBe("total");
});
it("normalizes persisted values, defaulting to total", () => {
expect(normalizeTimeDisplay("total")).toBe("total");
expect(normalizeTimeDisplay("book-left")).toBe("book-left");
expect(normalizeTimeDisplay("chapter-left")).toBe("chapter-left");
expect(normalizeTimeDisplay(null)).toBe("total");
expect(normalizeTimeDisplay("")).toBe("total");
expect(normalizeTimeDisplay("remaining")).toBe("total");
});
it("renders total as the book duration", () => {
// 93807s = 26:03:27 (Gardens-shaped)
expect(endTimeLabel("total", 0, 93_807, null)).toBe("26:03:27");
});
it("renders book remaining, clamped at zero", () => {
expect(endTimeLabel("book-left", 93_807 - 3_662, 93_807, null)).toBe("−1:01:02");
expect(endTimeLabel("book-left", 93_807, 93_807, null)).toBe("−0:00");
expect(endTimeLabel("book-left", 99_999, 93_807, null)).toBe("−0:00");
});
it("renders chapter remaining with minutes:seconds formatting", () => {
// 842s left in the chapter → −14:02
expect(endTimeLabel("chapter-left", 5_000, 93_807, 5_842)).toBe("−14:02");
});
it("falls back to book remaining when the book has no chapters", () => {
expect(endTimeLabel("chapter-left", 93_807 - 3_662, 93_807, null)).toBe("−1:01:02");
});
it("treats a chapter end in the past as zero remaining", () => {
expect(endTimeLabel("chapter-left", 6_000, 93_807, 5_842)).toBe("−0:00");
});
});
+792 -20
View File
@@ -15,6 +15,44 @@
import { Alpine } from "./alpine"; import { Alpine } from "./alpine";
import { renderNoteHtml } from "./markdown"; import { renderNoteHtml } from "./markdown";
import { showToast } from "./toast"; import { showToast } from "./toast";
import {
MOUSE_GUIDE,
resolveShortcut,
TOUCH_GUIDE,
visibleKeymap,
type KeyAction,
} from "./audio-keys";
import {
SLEEP_CHOICES,
SLEEP_OFF,
armClockTime,
armEndOfChapter,
armMinutes,
fadeVolume,
sleepChapterFired,
sleepDue,
sleepLabel,
sleepRemainingMs,
type SleepChoice,
type SleepState,
} from "./audio-sleep";
import {
applyDrag,
bufferedAheadEnd,
bufferedSpanPct,
chapterIndexFor,
denseTicks,
MAGNET_RADIUS_PX,
nearestChapterLine,
railLiftPx,
railSecondsAtPointer,
railTicks,
railWindow,
secondsAtPointer,
snapToChapterPx,
TOUCH_RAIL_MIN_DY_PX,
touchRailSpan,
} from "./audio-scrubber";
type AudioChapter = { index: number; title: string; start: number; end: number }; type AudioChapter = { index: number; title: string; start: number; end: number };
@@ -52,6 +90,86 @@ export function globalSecondsFor(
return (track ? track.start : 0) + Math.max(0, localSeconds); return (track ? track.start : 0) + Math.max(0, localSeconds);
} }
// Playback speeds — ONE source. The templ dropdown iterates it through
// the component's `speeds` property, and keyboard stepping (audio-keys,
// player plan §2) walks the same array through stepSpeed. Never hardcode
// a rate list elsewhere.
export const SPEEDS: readonly number[] = [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 2.5, 3];
// normalizeSpeed maps a persisted speed onto the SPEEDS list: missing,
// malformed or out-of-list values (e.g. a stale localStorage entry from
// before the list changed) fall back to 1×.
export function normalizeSpeed(value: string | null): number {
const n = Number(value);
return SPEEDS.includes(n) ? n : 1;
}
// stepSpeed moves through SPEEDS by delta, clamping at both ends; an
// unknown current anchors at 1× before stepping.
export function stepSpeed(current: number, delta: 1 | -1): number {
const idx = SPEEDS.indexOf(current);
const base = idx === -1 ? SPEEDS.indexOf(1) : idx;
return SPEEDS[Math.min(SPEEDS.length - 1, Math.max(0, base + delta))];
}
// Skip durations — ONE source (same pattern as SPEEDS). The templ
// dropdown iterates it through the component's `skipChoices` property,
// and [ / ] keyboard cycling walks it through cycleSkip.
export const SKIP_SECONDS: readonly number[] = [1, 5, 10, 15, 30];
// cycleSkip moves through SKIP_SECONDS by delta, clamping at both
// ends; an unknown current anchors at 10s (the historical default).
export function cycleSkip(current: number, delta: 1 | -1): number {
const idx = SKIP_SECONDS.indexOf(current);
const base = idx === -1 ? SKIP_SECONDS.indexOf(10) : idx;
return SKIP_SECONDS[Math.min(SKIP_SECONDS.length - 1, Math.max(0, base + delta))];
}
// Autoplay-on-open setting — persisted as "1"/"0" (default off). The
// browser's autoplay policy decides whether the attempt succeeds; a
// rejection is swallowed silently (maybeAutoplay).
export function normalizeAutoplay(value: string | null): boolean {
return value === "1" || value === "true";
}
// ---- End-time display mode (plan §10) ----
// Clicking the END time label cycles total → book remaining → chapter
// remaining, persisted per device (localStorage audio_time_display).
export type TimeDisplayMode = "total" | "book-left" | "chapter-left";
export const TIME_DISPLAY_MODES: readonly TimeDisplayMode[] = [
"total",
"book-left",
"chapter-left",
];
export function normalizeTimeDisplay(value: string | null): TimeDisplayMode {
return value === "book-left" || value === "chapter-left" ? value : "total";
}
export function cycleTimeDisplay(current: TimeDisplayMode): TimeDisplayMode {
const idx = TIME_DISPLAY_MODES.indexOf(current);
return TIME_DISPLAY_MODES[(idx + 1) % TIME_DISPLAY_MODES.length] ?? "total";
}
// endTimeLabel renders the END label for a mode. Position is the
// playhead (or the scrub cursor mid-drag) so remaining values track
// what you'd land on. chapterEnd is the current chapter's end — null
// when the book has no chapters, in which case chapter mode falls back
// to book remaining. Remainders clamp at 0.
export function endTimeLabel(
mode: TimeDisplayMode,
position: number,
duration: number,
chapterEnd: number | null,
): string {
const remain = (to: number) => `−${fmtTime(Math.max(0, to - position))}`;
if (mode === "book-left") return remain(duration);
if (mode === "chapter-left") return remain(chapterEnd === null ? duration : chapterEnd);
return fmtTime(duration);
}
const PROGRESS_INTERVAL_MS = 30_000; const PROGRESS_INTERVAL_MS = 30_000;
interface AudioConfig { interface AudioConfig {
@@ -85,12 +203,45 @@ Alpine.data("audioReader", () => ({
chaptersOpen: false, chaptersOpen: false,
currentTime: 0, currentTime: 0,
playing: false, playing: false,
speed: Number(localStorage.getItem("audio_speed") || 1), speed: normalizeSpeed(localStorage.getItem("audio_speed")),
// User-level default speed (plan §8): powers the dropdown's
// "(default)" marker; hydrated from the chapters payload.
defaultSpeed: 1,
// Exposed for the templ dropdown's x-for — templ cannot import the
// const directly; this property is the binding.
speeds: SPEEDS,
// Same binding pattern, for the dialog's two scrub-system sections.
mouseGuide: MOUSE_GUIDE,
touchGuide: TOUCH_GUIDE,
skipSeconds: Number(localStorage.getItem("audio_skip") || 10), skipSeconds: Number(localStorage.getItem("audio_skip") || 10),
// End-label display mode (§10): total → book remaining → chapter
// remaining, persisted per device.
timeDisplay: normalizeTimeDisplay(localStorage.getItem("audio_time_display")),
// Scrub guard: while dragging, timeupdate must not fight the handle // Scrub guard: while dragging, timeupdate must not fight the handle
scrubbing: false, scrubbing: false,
scrubValue: 0, scrubValue: 0,
// Progress-bar overhaul (plan §1): chapter ticks, per-track buffer
// span, hover tooltip and the precision rail. Math lives in
// audio-scrubber.ts (pure, unit-tested).
tickIndices: [] as number[],
bufferedGlobal: null as number | null,
hoverSeconds: null as number | null,
hoverPct: 0,
// §9: hover locks to a chapter line inside the magnet radius (the
// highlight line + tooltip land where the click will).
hoverSnapped: false,
railFrom: 0,
railTo: 0,
railTickList: [] as Array<{ start: number; title: string }>,
// §9 interaction model: "free" = 1:1 mouse drag (no rail), "rail" =
// Shift+drag precision window, "touch" = incremental cursor with
// pull-away precision. The rail is only visible for the last two.
dragMode: null as null | "free" | "rail" | "touch",
railVisible: false,
railLiftY: 0,
touchLastX: 0,
// Track manifest: one AudioTrack per playable file. Single-file books // Track manifest: one AudioTrack per playable file. Single-file books
// (m4b/mp3) carry exactly one track whose URL is the whole file, so // (m4b/mp3) carry exactly one track whose URL is the whole file, so
// every code path below can treat playback as (track, local seconds) // every code path below can treat playback as (track, local seconds)
@@ -116,6 +267,35 @@ Alpine.data("audioReader", () => ({
skipOpen: false, skipOpen: false,
speedOpen: false, speedOpen: false,
volumeOpen: false, volumeOpen: false,
skipChoices: SKIP_SECONDS,
// Keyboard shortcuts (audio-keys.ts keymap): the ? dialog and the
// brief OSD confirming keyboard-driven volume/mute changes.
helpOpen: false,
osdVisible: false,
osdText: "",
osdTimer: null as ReturnType<typeof setTimeout> | null,
// Sleep timer (plan §3): session-scoped, NEVER persisted. The pure
// state machine lives in audio-sleep.ts; this holds the state, the
// 1s countdown ticker and the 5s fade interval.
sleep: SLEEP_OFF as SleepState,
sleepChoices: SLEEP_CHOICES,
sleepOpen: false,
sleepLabelStr: "Off",
sleepArmedChoiceKey: "off",
sleepTicker: null as ReturnType<typeof setInterval> | null,
sleepFadeTimer: null as ReturnType<typeof setInterval> | null,
// §17 clock mode: the popover's time input (armed via chooseSleepClock).
sleepClockTime: "23:00",
// §17 wind-down dim: 0..0.35 black overlay opacity across the final
// 30s of a countdown. Reset by stop/finish.
sleepDim: 0,
// Settings popover (plan §4): header gear → panel. Autoplay-on-open
// is the first option; the panel is structured for more.
settingsOpen: false,
autoplayOn: normalizeAutoplay(localStorage.getItem("audio_autoplay")),
// Volume: level 0..1 + mute, persisted separately. prevVolume restores // Volume: level 0..1 + mute, persisted separately. prevVolume restores
// on unmute (spec: icon shows level arcs, crossed when muted). // on unmute (spec: icon shows level arcs, crossed when muted).
@@ -143,6 +323,12 @@ Alpine.data("audioReader", () => ({
init() { init() {
if (this.initialized) return; // Alpine can re-init the body tree if (this.initialized) return; // Alpine can re-init the body tree
this.initialized = true; this.initialized = true;
// Heal a stale/out-of-list persisted speed so the storage matches
// what the player actually uses (normalizeSpeed already fixed the
// in-memory value at state creation).
if (localStorage.getItem("audio_speed") !== String(this.speed)) {
localStorage.setItem("audio_speed", String(this.speed));
}
window.addEventListener("error", (e) => { window.addEventListener("error", (e) => {
document.title = "ERR: " + (e.message || "unknown"); document.title = "ERR: " + (e.message || "unknown");
}); });
@@ -163,14 +349,17 @@ Alpine.data("audioReader", () => ({
// src is set by restorePosition once the track manifest is known — // src is set by restorePosition once the track manifest is known —
// for multi-file books data-file-url (a directory or a zip root) is // for multi-file books data-file-url (a directory or a zip root) is
// NOT directly playable. The catch branch below falls back to it. // NOT directly playable. The catch branch below falls back to it.
audio.playbackRate = Number(this.speed) || 1; audio.playbackRate = this.speed;
audio.preload = "auto"; audio.preload = "auto";
// Restored volume/mute from the settings strip's persisted state. // Restored volume/mute from the settings strip's persisted state.
audio.volume = Math.min(1, Math.max(0, Number(this.volume))); audio.volume = Math.min(1, Math.max(0, Number(this.volume)));
audio.muted = !!this.muted; audio.muted = !!this.muted;
// Restore position: chapters + progress from the server. // Restore position: chapters + progress from the server. Autoplay
this.restorePosition(); // (if enabled) starts AFTER the position is restored — the ?loc=
// deep link wins on position; autoplay only decides whether
// playback starts.
this.restorePosition().then(() => this.maybeAutoplay());
// Periodic progress push (playing-only; pause/hide push too) // Periodic progress push (playing-only; pause/hide push too)
this.startPushTimer(); this.startPushTimer();
// Bookmarks list (time-located; notes optional) // Bookmarks list (time-located; notes optional)
@@ -224,6 +413,9 @@ Alpine.data("audioReader", () => ({
audio.addEventListener("play", () => { audio.addEventListener("play", () => {
this.playing = true; this.playing = true;
this.prevTick = performance.now(); this.prevTick = performance.now();
// Manual play during a sleep fade aborts the expiry (the user
// is awake and asked for audio); stopSleep restores the volume.
if (this.sleep.mode === "fading") this.stopSleep();
this.updateMediaSessionState(); this.updateMediaSessionState();
}); });
audio.addEventListener("pause", () => { audio.addEventListener("pause", () => {
@@ -237,25 +429,30 @@ Alpine.data("audioReader", () => ({
this.updateMediaSessionState(); this.updateMediaSessionState();
}); });
window.addEventListener("pagehide", () => this.pushProgress(true)); window.addEventListener("pagehide", () => this.pushProgress(true));
window.addEventListener("resize", () => this.recomputeTicks());
document.addEventListener("visibilitychange", () => { document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") this.pushProgress(true); if (document.visibilityState === "hidden") this.pushProgress(true);
}); });
// Buffer readout for the scrubber's per-track buffer span.
// Spacebar = play/pause (the reader's keyboard contract, audio flavor). // 'progress' alone does not fire reliably for Range-served files
document.addEventListener("keydown", (e) => { // that go network-idle while paused — ride the events that
const target = e.target as HTMLElement | null; // demonstrably do, and re-read the (idempotent) ranges on each.
if (target?.tagName === "INPUT" || target?.tagName === "TEXTAREA") return; for (const ev of [
if (e.code === "Space") { "progress",
e.preventDefault(); "loadedmetadata",
this.togglePlay(); "loadeddata",
} else if (e.code === "ArrowLeft") { "canplay",
e.preventDefault(); "playing",
this.skip(-1); "timeupdate",
} else if (e.code === "ArrowRight") { ]) {
e.preventDefault(); audio.addEventListener(ev, () => this.updateBuffered());
this.skip(1);
} }
});
// Keyboard shortcuts (plan §2): one dispatcher resolving through
// the declarative audio-keys.ts keymap. Hardware media keys ride
// the MediaSession handlers above; the Media* keydown codes land
// here and call the SAME methods.
document.addEventListener("keydown", (e) => this.handleKeydown(e));
}, },
audio(): HTMLAudioElement | null { audio(): HTMLAudioElement | null {
@@ -297,13 +494,15 @@ Alpine.data("audioReader", () => ({
if (!audio || index < 0 || index >= this.tracks.length) return; if (!audio || index < 0 || index >= this.tracks.length) return;
const track = this.tracks[index]; const track = this.tracks[index];
this.activeTrack = index; this.activeTrack = index;
this.bufferedGlobal = null; // buffers are per-track; the next
// 'progress' event re-reads the freshly loading element's ranges.
if (audio.getAttribute("src") === track.url) { if (audio.getAttribute("src") === track.url) {
this.pendingSeek = null; this.pendingSeek = null;
audio.currentTime = Math.max(0, localSeconds); audio.currentTime = Math.max(0, localSeconds);
} else { } else {
this.pendingSeek = Math.max(0, localSeconds); this.pendingSeek = Math.max(0, localSeconds);
audio.setAttribute("src", track.url); audio.setAttribute("src", track.url);
audio.playbackRate = Number(this.speed) || 1; audio.playbackRate = this.speed;
audio.load(); audio.load();
} }
if (resume) audio.play().catch(() => showToast("Playback failed to start", "error")); if (resume) audio.play().catch(() => showToast("Playback failed to start", "error"));
@@ -319,10 +518,27 @@ Alpine.data("audioReader", () => ({
const data = await chaptersResp.json(); const data = await chaptersResp.json();
this.chapters = (data.chapters || []) as AudioChapter[]; this.chapters = (data.chapters || []) as AudioChapter[];
this.bookDuration = data.duration_seconds || this.chapterEndEstimate(); this.bookDuration = data.duration_seconds || this.chapterEndEstimate();
// §8: the server owns speed — per-book row, else the user's
// default. localStorage stays only as a first-paint cache; the
// payload value wins over it BEFORE the rate is (re)applied to
// the element below.
const serverSpeed = Number(data.playback_speed);
if (SPEEDS.includes(serverSpeed)) {
this.speed = serverSpeed;
localStorage.setItem("audio_speed", String(serverSpeed));
}
const serverDefault = Number(data.default_playback_speed);
if (SPEEDS.includes(serverDefault)) this.defaultSpeed = serverDefault;
this.buildTracks(data); this.buildTracks(data);
this.recomputeTicks();
const audio = this.audio(); const audio = this.audio();
if (audio && !audio.getAttribute("src") && this.tracks[0]) { if (audio && !audio.getAttribute("src") && this.tracks[0]) {
audio.setAttribute("src", this.tracks[0].url); audio.setAttribute("src", this.tracks[0].url);
// Setting src resets playbackRate to the element default —
// re-apply, mirroring the seekTo() src-swap path. Without this
// the pill said 1.5× while audio actually played at 1× after
// every reload (surfaced by the speed-list browser check).
audio.playbackRate = this.speed;
} }
} }
if (progress.ok) { if (progress.ok) {
@@ -351,6 +567,7 @@ Alpine.data("audioReader", () => ({
if (audio && !audio.getAttribute("src") && this.fileUrl) { if (audio && !audio.getAttribute("src") && this.fileUrl) {
this.tracks = [{ url: this.fileUrl, start: 0, end: 0 }]; this.tracks = [{ url: this.fileUrl, start: 0, end: 0 }];
audio.setAttribute("src", this.fileUrl); audio.setAttribute("src", this.fileUrl);
audio.playbackRate = this.speed;
} }
} }
}, },
@@ -368,6 +585,8 @@ Alpine.data("audioReader", () => ({
if (this.currentChapterIdx !== i) { if (this.currentChapterIdx !== i) {
this.currentChapterIdx = i; this.currentChapterIdx = i;
this.updateMediaSessionMetadata(); this.updateMediaSessionMetadata();
// Natural chapter crossing: fire an end-of-chapter sleep.
if (sleepChapterFired(this.sleep, i)) this.onChapterCrossing(i);
} }
return; return;
} }
@@ -423,6 +642,9 @@ Alpine.data("audioReader", () => ({
const ch = this.chapters[index]; const ch = this.chapters[index];
if (ch) this.seekTo(ch.start); if (ch) this.seekTo(ch.start);
this.currentChapterIdx = index; this.currentChapterIdx = index;
// Manual jump: leaving the armed chapter fires an end-of-chapter
// sleep the same as a natural crossing.
this.onChapterCrossing(index);
}, },
setSpeed(rate: number) { setSpeed(rate: number) {
@@ -430,6 +652,39 @@ Alpine.data("audioReader", () => ({
localStorage.setItem("audio_speed", String(rate)); localStorage.setItem("audio_speed", String(rate));
const audio = this.audio(); const audio = this.audio();
if (audio) audio.playbackRate = rate; if (audio) audio.playbackRate = rate;
// §8: the per-book speed lives server-side (localStorage is only a
// first-paint cache). Fire-and-forget; a failure toasts — the rate
// still applies to this session.
fetch(`/api/media-items/${this.mediaItemId}/audio-speed`, {
method: "PUT",
headers: { ...getAuthHeaders(), "content-type": "application/json" },
body: JSON.stringify({ speed: rate }),
})
.then((r) => {
if (!r.ok) throw new Error(String(r.status));
})
.catch(() => showToast("Speed saved for this session only — server rejected it", "error"));
},
// setDefaultSpeed pins a user-level default (plan §8): books without
// their own per-book speed pick it up. Rides the profile endpoint,
// same as the profile-page select.
setDefaultSpeed(rate: number) {
fetch("/api/auth/profile", {
method: "PUT",
headers: { ...getAuthHeaders(), "content-type": "application/json" },
body: JSON.stringify({ default_audio_speed: rate }),
})
.then((r) => {
if (!r.ok) throw new Error(String(r.status));
this.defaultSpeed = rate;
showToast(`Default playback speed set to ${rate}×`, "success");
})
.catch(() => showToast("Default speed not saved", "error"));
},
isDefaultSpeed(rate: number): boolean {
return Number(this.defaultSpeed) === rate;
}, },
setSkip(seconds: number) { setSkip(seconds: number) {
@@ -514,6 +769,210 @@ Alpine.data("audioReader", () => ({
return Number.isFinite(n) ? n : 0; return Number.isFinite(n) ? n : 0;
}, },
// ---- progress bar (audio-scrubber.ts math) ----
recomputeTicks() {
const area = document.getElementById("scrub-area");
const width = area ? area.getBoundingClientRect().width : 0;
this.tickIndices = denseTicks(
this.chapters,
this.chaptersDuration() || this.bookDuration || 1,
width,
);
},
updateBuffered() {
const audio = this.audio();
if (!audio || audio.buffered.length === 0) {
this.bufferedGlobal = null;
return;
}
// Buffered-ahead of the playhead (element-local): the end of the
// range containing currentTime. The moov-index range at a huge
// m4b's tail must not read as "the whole book is buffered".
const ranges = Array.from({ length: audio.buffered.length }, (_, i) => ({
start: audio.buffered.start(i),
end: audio.buffered.end(i),
}));
this.bufferedGlobal = bufferedAheadEnd(ranges, audio.currentTime);
},
chapterTitleAt(seconds: number): string {
const idx = chapterIndexFor(this.chapters, seconds);
if (idx === -1) return this.title;
const ch = this.chapters[idx];
return ch.title || `Chapter ${idx + 1}`;
},
// ---- end-time label (§10): total → book left → chapter left ----
endTimeText(): string {
const duration = this.chaptersDuration() || 1;
const pos = this.scrubbing ? this.scrubValue : Number(this.currentTime);
const idx = chapterIndexFor(this.chapters, pos);
const ch = this.chapters[idx === -1 ? 0 : idx];
return endTimeLabel(this.timeDisplay, pos, duration, ch ? ch.end : null);
},
endTimeAria(): string {
if (this.timeDisplay === "book-left")
return "Book time remaining — click to show chapter remaining";
if (this.timeDisplay === "chapter-left")
return "Chapter time remaining — click to show total duration";
return "Total duration — click to show book remaining";
},
cycleEndTime() {
this.timeDisplay = cycleTimeDisplay(this.timeDisplay);
localStorage.setItem("audio_time_display", this.timeDisplay);
},
scrubFillPct(): number {
const value = this.scrubbing ? this.scrubValue : Number(this.currentTime);
return Math.min(100, Math.max(0, (value / (this.chaptersDuration() || 1)) * 100));
},
bufferSpan(): { left: number; width: number } | null {
return bufferedSpanPct(
this.tracks[this.activeTrack],
this.bufferedGlobal,
this.chaptersDuration() || 1,
);
},
railMarkerPct(): number {
const span = Math.max(1, this.railTo - this.railFrom);
return Math.min(100, Math.max(0, ((this.scrubValue - this.railFrom) / span) * 100));
},
// Pointer interactions (plan §9): mouse drag is FREE FLOW (1:1, no
// rail, never sticks at chapter lines); clicks land with pixel
// magnetism on nearby chapter lines; Shift+click/drag opens the
// precision rail (±5 min, free mapping inside); touch drags are an
// incremental cursor whose scale tightens as the finger pulls off the
// bar (progressive precision — the rail is its feedback). The native
// range stays underneath for keyboard/aria — its drag default is
// prevented so the mappings never fight.
scrubPointerDown(e: PointerEvent) {
if (e.button !== 0 || this.scrubbing) return;
e.preventDefault();
const area = e.currentTarget as HTMLElement;
const rect = area.getBoundingClientRect();
try {
area.setPointerCapture(e.pointerId);
} catch {
/* synthetic or already-released pointer — drag still works via
the element-level listeners */
}
document.getElementById("audio-scrub")?.focus({ preventScroll: true });
const duration = this.chaptersDuration() || 1;
const at = secondsAtPointer(e.clientX, rect, duration);
if (e.pointerType === "touch") {
// Tap lands magnetically; the drag starts 1:1 and tightens as
// the finger pulls away (scrubPointerMove's incremental cursor).
this.dragMode = "touch";
this.touchLastX = e.clientX;
this.scrubValue = snapToChapterPx(at, this.chapters, duration, rect.width).seconds;
} else if (e.shiftKey) {
// Shift+click/drag: the precision rail, anchored to the NEAREST
// chapter line (Shift+click = snap regardless of radius).
this.dragMode = "rail";
const near = nearestChapterLine(at, this.chapters, duration, rect.width);
this.scrubValue = near ? near.seconds : at;
const window = railWindow(this.scrubValue, duration);
this.railFrom = window.from;
this.railTo = window.to;
this.railTickList = railTicks(window, this.chapters);
this.railVisible = true;
this.railLiftY = 0;
} else {
// Mouse click/drag: the CLICK's landing is magnetic; drags stay
// free-flow (scrubPointerMove overwrites with the raw position).
this.dragMode = "free";
this.scrubValue = snapToChapterPx(at, this.chapters, duration, rect.width).seconds;
}
this.scrubbing = true;
},
scrubPointerMove(e: PointerEvent) {
const area = e.currentTarget as HTMLElement;
const rect = area.getBoundingClientRect();
const duration = this.chaptersDuration() || 1;
if (!this.scrubbing) {
// Hover: time + chapter, locking to a chapter line inside the
// magnet radius so the tooltip shows the click's true landing.
const at = secondsAtPointer(e.clientX, rect, duration);
const near = nearestChapterLine(at, this.chapters, duration, rect.width);
const locked = near !== null && near.distancePx <= MAGNET_RADIUS_PX;
const hoverAt = locked && near ? near.seconds : at;
this.hoverSeconds = hoverAt;
this.hoverPct = rect.width > 0 ? Math.min(100, Math.max(0, (hoverAt / duration) * 100)) : 0;
this.hoverSnapped = locked;
return;
}
if (this.dragMode === "rail") {
this.scrubValue = railSecondsAtPointer(e.clientX, rect, {
from: this.railFrom,
to: this.railTo,
});
return;
}
if (this.dragMode === "touch") {
const dx = e.clientX - this.touchLastX;
this.touchLastX = e.clientX;
const dy = rect.top + rect.height / 2 - e.clientY;
this.scrubValue = applyDrag(this.scrubValue, dx, dy, rect.width / duration, duration);
if (Math.abs(dy) >= TOUCH_RAIL_MIN_DY_PX) {
// Feedback: the rail follows the cursor, its window shrinking
// with the zoom, lifting toward the finger.
const window = railWindow(this.scrubValue, duration, touchRailSpan(dy));
this.railFrom = window.from;
this.railTo = window.to;
this.railTickList = railTicks(window, this.chapters);
this.railVisible = true;
this.railLiftY = railLiftPx(dy);
} else {
this.railVisible = false;
this.railLiftY = 0;
}
return;
}
// Free flow: direct 1:1 mapping — dragging across chapter lines
// never sticks.
this.scrubValue = secondsAtPointer(e.clientX, rect, duration);
},
scrubPointerLeave() {
if (!this.scrubbing) {
this.hoverSeconds = null;
this.hoverSnapped = false;
}
},
scrubPointerUp(e: PointerEvent) {
if (!this.scrubbing) return;
const area = e.currentTarget as HTMLElement;
if (area.hasPointerCapture && area.hasPointerCapture(e.pointerId))
area.releasePointerCapture(e.pointerId);
this.seekTo(this.scrubValue);
this.scrubbing = false;
this.dragMode = null;
this.railVisible = false;
this.railLiftY = 0;
this.hoverSeconds = null;
this.hoverSnapped = false;
},
// Wheel over the bar: ±5s nudge, Shift = ±60s. Scroll up = forward.
onScrubWheel(e: WheelEvent) {
e.preventDefault();
const step = e.shiftKey ? 60 : 5;
const direction = e.deltaY > 0 ? -1 : 1;
const duration = this.chaptersDuration() || 1;
const target = Math.min(duration, Math.max(0, this.globalPosition() + direction * step));
this.seekTo(target);
},
toggleBookmarkCapture() { toggleBookmarkCapture() {
if (this.bookmarkCaptureOpen) { if (this.bookmarkCaptureOpen) {
this.bookmarkCaptureOpen = false; this.bookmarkCaptureOpen = false;
@@ -523,6 +982,319 @@ Alpine.data("audioReader", () => ({
this.bookmarkCaptureOpen = true; this.bookmarkCaptureOpen = true;
}, },
// Keyboard B: open the capture pill and put the cursor in the note
// box (user's explicit flow — Enter then saves, even when blank).
openBookmarkCapture() {
if (!this.bookmarkCaptureOpen) this.toggleBookmarkCapture();
window.setTimeout(() => {
(document.getElementById("bookmark-note") as HTMLTextAreaElement | null)?.focus();
}, 0);
},
// ---- keyboard dispatcher (audio-keys.ts) ----
visibleKeymap() {
return visibleKeymap();
},
// Escape closes the top-most open surface — even while typing, since
// canceling the bookmark note mid-sentence is the expected Esc.
// Returns true when something actually closed.
closeTopSurface(): boolean {
if (this.helpOpen) {
this.helpOpen = false;
return true;
}
if (this.bookmarkCaptureOpen) {
this.bookmarkCaptureOpen = false;
return true;
}
if (this.editingBookmarkId !== "") {
this.editingBookmarkId = "";
return true;
}
if (this.settingsOpen) {
this.settingsOpen = false;
return true;
}
if (this.chaptersOpen) {
this.chaptersOpen = false;
return true;
}
if (this.bookmarksOpen) {
this.bookmarksOpen = false;
return true;
}
if (this.skipOpen || this.speedOpen || this.volumeOpen || this.sleepOpen) {
this.skipOpen = false;
this.speedOpen = false;
this.volumeOpen = false;
this.sleepOpen = false;
return true;
}
return false;
},
showOsd(text: string) {
this.osdText = text;
this.osdVisible = true;
if (this.osdTimer) clearTimeout(this.osdTimer);
this.osdTimer = setTimeout(() => {
this.osdVisible = false;
}, 1200);
},
handleKeydown(e: KeyboardEvent) {
const target = e.target as HTMLElement | null;
const tag = target?.tagName;
const typing = tag === "INPUT" || tag === "TEXTAREA" || target?.isContentEditable === true;
// Escape first: cancel the top-most surface even while typing.
if (e.key === "Escape") {
if (this.closeTopSurface()) {
e.preventDefault();
e.stopPropagation();
}
return;
}
// Typing in any input (bookmark notes!) never triggers shortcuts.
if (typing) return;
const action: KeyAction | null = resolveShortcut(e.key, e.altKey);
if (!action) return;
// Space/Enter activate a focused control — never hijack them for
// play/pause when the user is keyboard-driving a button.
if (action === "toggle-play" && (tag === "BUTTON" || tag === "SELECT" || tag === "A")) {
return;
}
e.preventDefault();
switch (action) {
case "toggle-play":
case "media-toggle-play":
this.togglePlay();
break;
case "media-play":
if (!this.playing) this.togglePlay();
break;
case "media-pause":
if (this.playing) this.togglePlay();
break;
case "skip-back":
this.skip(-1);
break;
case "skip-forward":
this.skip(1);
break;
case "prev-chapter":
case "media-prev":
this.stepChapter(-1);
break;
case "next-chapter":
case "media-next":
this.stepChapter(1);
break;
case "bookmark":
this.openBookmarkCapture();
break;
case "speed-up":
this.setSpeed(stepSpeed(this.speed, 1));
break;
case "speed-down":
this.setSpeed(stepSpeed(this.speed, -1));
break;
case "volume-up":
case "volume-down": {
const step = action === "volume-up" ? 0.05 : -0.05;
const next = Math.round((Number(this.volume) + step) * 100) / 100;
this.setVolume(next);
this.showOsd(`Volume ${Math.round(Number(this.volume) * 100)}%`);
break;
}
case "toggle-mute":
this.toggleMute();
this.showOsd(this.muted ? "Muted" : "Unmuted");
break;
case "cycle-skip-up":
this.setSkip(cycleSkip(this.skipSeconds, 1));
break;
case "cycle-skip-down":
this.setSkip(cycleSkip(this.skipSeconds, -1));
break;
case "toggle-help":
this.helpOpen = !this.helpOpen;
break;
case "escape":
this.closeTopSurface();
break;
}
},
// ---- sleep timer (audio-sleep.ts state machine) ----
isSleepActive(key: string): boolean {
return this.sleepArmedChoiceKey === key;
},
chooseSleep(choice: SleepChoice) {
this.sleepArmedChoiceKey = choice.key;
if (choice.mode === "off") {
this.stopSleep();
return;
}
if (choice.mode === "end-of-chapter") {
this.stopSleepTicker();
// §17 chain: "end of chapter, then N min" — leaving the chapter
// starts the countdown instead of fading straight away.
this.sleep = armEndOfChapter(
this.currentChapterIdx,
choice.chain ? (choice.minutes ?? 15) : null,
);
} else {
this.stopSleepTicker();
this.sleep = armMinutes(Date.now(), choice.minutes ?? 30);
this.startSleepTicker();
}
this.sleepLabelStr = sleepLabel(this.sleep, Date.now());
},
// §17 clock mode: arm against the next occurrence of the popover's
// HH:MM input. Unparseable input keeps the current state.
chooseSleepClock() {
const armed = armClockTime(Date.now(), this.sleepClockTime);
if (!armed) return;
this.stopSleepTicker();
this.sleep = armed;
this.sleepArmedChoiceKey = "clock";
this.sleepLabelStr = sleepLabel(this.sleep, Date.now());
this.startSleepTicker();
},
// A chapter crossing fires an end-of-chapter sleep: straight to the
// fade — or (§17 chain arm) convert to the follow-up countdown.
onChapterCrossing(index: number) {
if (!sleepChapterFired(this.sleep, index)) return;
const follow = this.sleep.followUpMinutes;
if (follow !== null) {
this.sleep = armMinutes(Date.now(), follow);
this.startSleepTicker();
this.sleepLabelStr = sleepLabel(this.sleep, Date.now());
} else {
this.beginSleepFade();
}
},
startSleepTicker() {
this.stopSleepTicker();
this.sleepTicker = setInterval(() => {
if (this.sleep.mode !== "minutes") return;
const now = Date.now();
if (sleepDue(this.sleep, now)) {
this.stopSleepTicker();
this.beginSleepFade();
return;
}
this.sleepLabelStr = sleepLabel(this.sleep, now);
// §17 wind-down dim: ease toward 35% black across the final 30s.
const remaining = sleepRemainingMs(this.sleep, now);
this.sleepDim =
remaining !== null && remaining < 30_000 ? ((30_000 - remaining) / 30_000) * 0.35 : 0;
}, 1000);
},
stopSleepTicker() {
if (this.sleepTicker) {
clearInterval(this.sleepTicker);
this.sleepTicker = null;
}
},
// Expiry: fade the element volume over ~5s, then pause. The pause
// fires the SAME listener a manual pause does (progress push + media
// session state); the user's configured volume is restored right
// after, so the next play starts at full level.
beginSleepFade() {
if (this.sleep.mode === "fading") return;
const audio = this.audio();
if (!audio) {
this.stopSleep();
return;
}
this.sleep = { mode: "fading", endsAtMs: null, chapterIndex: null };
this.sleepLabelStr = "Fading";
if (this.muted || Number(this.volume) <= 0) {
// Inaudible anyway: skip the pantomime, just pause.
this.finishSleepPause();
return;
}
const base = Number(this.volume) || 0.5;
const startedAt = Date.now();
this.sleepFadeTimer = setInterval(() => {
const v = fadeVolume(base, Date.now() - startedAt);
audio.volume = v;
if (v <= 0) this.finishSleepPause();
}, 200);
},
finishSleepPause() {
if (this.sleepFadeTimer) {
clearInterval(this.sleepFadeTimer);
this.sleepFadeTimer = null;
}
this.stopSleepTicker();
const audio = this.audio();
if (audio && !audio.paused) audio.pause();
if (audio) audio.volume = Math.min(1, Math.max(0, Number(this.volume) || 0.5));
this.sleep = SLEEP_OFF;
this.sleepArmedChoiceKey = "off";
this.sleepLabelStr = "Off";
this.sleepDim = 0;
// §17: a genuine fire can catch you still awake — one-click undo.
showToast("Sleep timer: playback paused", "info", 8000, {
label: "Resume",
onClick: () => {
const a = this.audio();
if (a) a.play().catch(() => {});
},
});
},
// Manual off / play-abort: undo any partial fade.
stopSleep() {
if (this.sleepFadeTimer) {
clearInterval(this.sleepFadeTimer);
this.sleepFadeTimer = null;
}
this.stopSleepTicker();
const audio = this.audio();
if (audio) audio.volume = Math.min(1, Math.max(0, Number(this.volume) || 0.5));
this.sleep = SLEEP_OFF;
this.sleepArmedChoiceKey = "off";
this.sleepLabelStr = "Off";
this.sleepDim = 0;
},
// ---- settings popover (plan §4) ----
toggleAutoplay() {
this.autoplayOn = !this.autoplayOn;
localStorage.setItem("audio_autoplay", this.autoplayOn ? "1" : "0");
},
// Attempt playback after restorePosition when the user opted in. A
// browser autoplay-policy block (NotAllowedError) — or any transient
// play rejection — is swallowed silently; the user just taps play.
maybeAutoplay() {
if (!this.autoplayOn) return;
const audio = this.audio();
if (!audio || !audio.paused) return;
audio.play().catch(() => {
/* autoplay blocked — stay silent */
});
},
async saveBookmark() { async saveBookmark() {
const audio = this.audio(); const audio = this.audio();
if (!audio) return; if (!audio) return;
+171
View File
@@ -0,0 +1,171 @@
import { describe, expect, it } from "vitest";
import {
isFlatable,
isRule,
pruneIncomplete,
ruleToFlatParam,
type FilterGroupState,
type FilterRuleState,
} from "./filter_builder";
// ruleToFlatParam/isFlatable encode the PINNED deep-link wire contract:
// these params are byte-compatible with bookmarked URLs already in the
// wild, so assertions here are contract pins, not implementation details.
const rule = (field: string, op: string, values: string[] = []): FilterRuleState => ({
field,
op,
values,
});
const group = (
mode: "all" | "any",
children: (FilterRuleState | FilterGroupState)[],
implicit = false,
): FilterGroupState => ({ mode, children, ...(implicit ? { implicit: true } : {}) });
describe("ruleToFlatParam", () => {
it("maps contains on flat fields to the pinned param", () => {
expect(ruleToFlatParam(rule("author", "contains", ["king"]))).toEqual({
author_filter: "king",
});
});
it("joins multiple values with commas", () => {
expect(ruleToFlatParam(rule("tags", "contains", ["a", "b"]))).toEqual({
tags_filter: "a,b",
});
});
it("maps is on enum fields", () => {
expect(ruleToFlatParam(rule("manga_type", "is", ["manga"]))).toEqual({
manga_type_filter: "manga",
});
});
it("maps published_year at_least/at_most to year_min/year_max", () => {
expect(ruleToFlatParam(rule("published_year", "at_least", ["2000"]))).toEqual({
year_min: "2000",
});
expect(ruleToFlatParam(rule("published_year", "at_most", ["2010"]))).toEqual({
year_max: "2010",
});
});
it("maps published_year between to both bounds", () => {
expect(ruleToFlatParam(rule("published_year", "between", ["2000", "2010"]))).toEqual({
year_min: "2000",
year_max: "2010",
});
});
it("returns null for empty values", () => {
expect(ruleToFlatParam(rule("author", "contains", []))).toBeNull();
});
it("returns null for non-flat-expressible combinations", () => {
// contains on a numeric field has no flat param.
expect(ruleToFlatParam(rule("published_year", "contains", ["2000"]))).toBeNull();
// not_contains is deliberately excluded from the flat contract.
expect(ruleToFlatParam(rule("author", "not_contains", ["king"]))).toBeNull();
});
});
describe("isFlatable", () => {
it("accepts a single ALL group of unique flat rules", () => {
const tree = group("all", [
rule("author", "contains", ["king"]),
rule("published_year", "at_least", ["2000"]),
]);
expect(isFlatable(tree)).toBe(true);
});
it("rejects ANY mode", () => {
expect(isFlatable(group("any", [rule("author", "contains", ["king"])]))).toBe(false);
});
it("rejects nested groups", () => {
expect(isFlatable(group("all", [group("all", [rule("author", "contains", ["king"])])]))).toBe(
false,
);
});
it("rejects duplicate fields", () => {
expect(
isFlatable(
group("all", [rule("author", "contains", ["a"]), rule("author", "contains", ["b"])]),
),
).toBe(false);
});
it("rejects non-flat rules", () => {
expect(isFlatable(group("all", [rule("author", "not_contains", ["king"])]))).toBe(false);
});
});
describe("pruneIncomplete", () => {
it("keeps complete rules untouched", () => {
const tree = group("all", [rule("author", "contains", ["king"])]);
expect(pruneIncomplete(tree, null)).toEqual(tree);
});
it("drops rules with empty values", () => {
const tree = group("all", [
rule("author", "contains", [""]),
rule("series", "contains", ["x"]),
]);
expect(pruneIncomplete(tree, null)).toEqual(group("all", [rule("series", "contains", ["x"])]));
});
it("requires both values for two-arity operators", () => {
const tree = group("all", [rule("published_year", "between", ["2000"])]);
expect(pruneIncomplete(tree, null)).toBeNull();
});
it("keeps none-arity rules without values", () => {
const tree = group("all", [rule("cover", "has")]);
expect(pruneIncomplete(tree, null)).toEqual(group("all", [rule("cover", "has", [])]));
});
it("drops groups left empty after pruning", () => {
const tree = group("all", [
group("any", [rule("author", "contains", [""])]),
rule("series", "contains", ["x"]),
]);
expect(pruneIncomplete(tree, null)).toEqual(group("all", [rule("series", "contains", ["x"])]));
});
it("returns null when nothing complete remains", () => {
expect(pruneIncomplete(group("all", [rule("author", "contains", [""])]), null)).toBeNull();
});
it("excludes the rule at the given path", () => {
const tree = group("all", [
rule("author", "contains", ["a"]),
rule("series", "contains", ["b"]),
]);
expect(pruneIncomplete(tree, [0])).toEqual(group("all", [rule("series", "contains", ["b"])]));
});
it("preserves the implicit marker", () => {
const tree = group("all", [rule("author", "contains", ["a"])], true);
expect(pruneIncomplete(tree, null)).toEqual(
group("all", [rule("author", "contains", ["a"])], true),
);
});
it("deep-clones: pruning does not mutate the input tree", () => {
const tree = group("all", [rule("author", "contains", ["a"])]);
const pruned = pruneIncomplete(tree, null);
expect(pruned).not.toBe(tree);
expect(pruned!.children[0]).not.toBe(tree.children[0]);
});
});
describe("isRule", () => {
it("distinguishes rules from groups", () => {
expect(isRule(rule("author", "contains", ["a"]))).toBe(true);
expect(isRule(group("all", []))).toBe(false);
});
});
+3 -3
View File
@@ -345,7 +345,7 @@ export const FLAT_WIRE_NAMES = [
]; ];
// opIsFlatExhaustible — every op that maps 1:1 onto a flat param. // opIsFlatExhaustible — every op that maps 1:1 onto a flat param.
function ruleToFlatParam(rule: FilterRuleState): Record<string, string> | null { export function ruleToFlatParam(rule: FilterRuleState): Record<string, string> | null {
const joined = rule.values.join(","); const joined = rule.values.join(",");
if (!joined) return null; if (!joined) return null;
const flat = FLAT_PARAMS[rule.field]; const flat = FLAT_PARAMS[rule.field];
@@ -378,7 +378,7 @@ function ruleToFlatParam(rule: FilterRuleState): Record<string, string> | null {
// isFlatable — the smart-serialization rule: a single ALL group of // isFlatable — the smart-serialization rule: a single ALL group of
// rules, one rule per field, every rule flat-expressible. // rules, one rule per field, every rule flat-expressible.
function isFlatable(tree: FilterGroupState): boolean { export function isFlatable(tree: FilterGroupState): boolean {
if (tree.mode !== "all") return false; if (tree.mode !== "all") return false;
const seen = new Set<string>(); const seen = new Set<string>();
for (const child of tree.children) { for (const child of tree.children) {
@@ -395,7 +395,7 @@ function isFlatable(tree: FilterGroupState): boolean {
// groups left empty, and can exclude the rule being edited (suggestion // groups left empty, and can exclude the rule being edited (suggestion
// symmetry). Returns null when nothing complete remains — callers then // symmetry). Returns null when nothing complete remains — callers then
// send NO tree at all (an empty group is a server-side 400). // send NO tree at all (an empty group is a server-side 400).
function pruneIncomplete( export function pruneIncomplete(
group: FilterGroupState, group: FilterGroupState,
excludePath: number[] | null, excludePath: number[] | null,
path: number[] = [], path: number[] = [],
+118
View File
@@ -0,0 +1,118 @@
import { describe, expect, it } from "vitest";
import { htmlToMarkdown, renderNoteHtml, stripNote } from "./markdown";
// The sanitizer pipeline is the notes feature's XSS defense: markdown-it
// with html disabled, then DOMPurify with a restrictive allow-list. These
// tests pin the security-relevant behavior — loosening any of them should
// require a deliberate decision, never an accident.
describe("renderNoteHtml", () => {
it("renders basic markdown to html", () => {
const out = renderNoteHtml("**bold** and _ital_");
expect(out).toContain("<strong>bold</strong>");
expect(out).toContain("<em>ital</em>");
});
it("returns empty string for empty source", () => {
expect(renderNoteHtml("")).toBe("");
});
it("strips raw script tags (parser-level html disabled)", () => {
const out = renderNoteHtml("<script>alert(1)</script>hello");
expect(out).not.toContain("<script");
expect(out).toContain("hello");
});
it("removes img/input/style/form tags", () => {
const out = renderNoteHtml(
'![x](y.png)\n\n<form action="/steal"></form>\n\n<style>.x{}</style>\n\n<input type="text">',
);
expect(out).not.toContain("<img");
expect(out).not.toContain("<form");
expect(out).not.toContain("<style");
expect(out).not.toContain("<input");
});
it("escapes raw html to inert literal text (html disabled at the parser)", () => {
// html:false makes markdown-it ESCAPE raw html — the output string
// contains the tags as visible text, which is inert wherever the
// compact rows render it.
const out = renderNoteHtml('<span onerror="alert(1)">text</span>');
expect(out).not.toContain("<span");
expect(out).toContain("onerror");
expect(out).toContain("text");
});
it("neutralizes javascript: links at the markdown layer (no anchor emitted)", () => {
// markdown-it's validateLink rejects the URL entirely — the raw syntax
// stays as inert literal text and no <a> element ever reaches the
// sanitizer (which would then scheme-check it a second time).
const out = renderNoteHtml("[click](javascript:alert(1))");
expect(out).not.toContain("<a");
expect(out).toContain("[click](javascript:alert(1))");
});
it("removes markdown-syntax images (FORBID_TAGS)", () => {
const out = renderNoteHtml("![x](y.png)");
expect(out).not.toContain("<img");
});
it("keeps safe https links", () => {
const out = renderNoteHtml("[site](https://example.com)");
expect(out).toContain('href="https://example.com"');
});
it("renders footnotes", () => {
const out = renderNoteHtml("note[^1]\n\n[^1]: the footnote");
expect(out).toContain("footnote-ref");
expect(out).toContain("the footnote");
});
});
describe("stripNote", () => {
it("extracts plain text from markdown", () => {
expect(stripNote("**bold** plain")).toBe("bold plain");
});
it("collapses whitespace and trims", () => {
expect(stripNote("a\n\n b ")).toBe("a b");
});
it("reduces raw html to literal text (safe: html disabled, so tags never become elements)", () => {
// markdown-it escapes the tags and DOMPurify leaves escaped entities
// alone — the result is inert visible text, not executable markup.
expect(stripNote("<script>x</script>safe")).toBe("<script>x</script>safe");
});
it("returns empty string for empty source", () => {
expect(stripNote("")).toBe("");
});
});
describe("htmlToMarkdown", () => {
it("converts emphasis", () => {
expect(htmlToMarkdown("<strong>b</strong>")).toBe("**b**");
});
it("converts atx headings", () => {
expect(htmlToMarkdown("<h2>Title</h2>")).toBe("## Title");
});
it("blanks images entirely", () => {
expect(htmlToMarkdown('<p>a<img src="x.png">b</p>')).toBe("ab");
});
it("converts tables with heading rows to pipe syntax", () => {
const md = htmlToMarkdown(
"<table><thead><tr><th>A</th><th>B</th></tr></thead><tbody><tr><td>1</td><td>2</td></tr></tbody></table>",
);
expect(md).toContain("| A | B |");
expect(md).toContain("| 1 | 2 |");
});
it("converts headingless tables to blank-header pipes (never raw html)", () => {
const md = htmlToMarkdown("<table><tr><td>x</td><td>y</td></tr></table>");
expect(md).toContain("| x | y |");
expect(md).not.toContain("<table");
});
});
+159
View File
@@ -0,0 +1,159 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { initPasswordValidation, loadPasswordRules } from "./password_validation";
// The checklist is display-only (the server enforces), but it gates the
// submit button and mirrors the ACTIVE server rules — these tests pin the
// rules matrix, the fetch fallback behavior, and the submit gating.
function buildDom(): void {
document.body.innerHTML = `
<input id="password" />
<input id="confirm-password" />
<input id="username" />
<input id="email" />
<div id="password-requirements"></div>
<button id="register-btn">Register</button>
`;
}
function setPassword(value: string): void {
(document.getElementById("password") as HTMLInputElement).value = value;
(document.getElementById("password") as HTMLInputElement).dispatchEvent(new Event("input"));
}
const submitButton = (): HTMLButtonElement =>
document.getElementById("register-btn") as HTMLButtonElement;
describe("loadPasswordRules", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("adopts fetched rules", async () => {
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
new Response(JSON.stringify({ min_length: 12, require_upper: false }), { status: 200 }),
),
);
const rules = await loadPasswordRules();
expect(rules).toEqual({
min_length: 12,
require_upper: false,
require_lower: true,
require_number: true,
require_special: true,
});
});
it("falls back to defaults when the endpoint fails", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response("nope", { status: 500 })),
);
const rules = await loadPasswordRules();
expect(rules).toEqual({
min_length: 8,
require_upper: true,
require_lower: true,
require_number: true,
require_special: true,
});
});
it("falls back to defaults on invalid payloads", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response(JSON.stringify({ min_length: -5 }), { status: 200 })),
);
const rules = await loadPasswordRules();
expect(rules.min_length).toBe(8);
});
});
describe("initPasswordValidation", () => {
beforeEach(() => {
// Deterministic rules: fetch failure lands on the historical defaults.
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response("nope", { status: 500 })),
);
});
afterEach(() => {
vi.unstubAllGlobals();
});
// initPasswordValidation debounces through window.setTimeout, which under
// jsdom is a distinct timer from the faked global — so these tests wait
// out the real 100ms debounce instead of advancing fake clocks.
const settleDebounce = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 150));
it("generates one checklist row per active rule plus the match row", async () => {
buildDom();
await loadPasswordRules();
initPasswordValidation({});
const rows = document.querySelectorAll("#password-requirements li");
expect(rows.length).toBe(6); // length, upper, lower, number, special, match
});
it("starts with the submit button disabled for an empty password", async () => {
buildDom();
await loadPasswordRules();
initPasswordValidation({});
expect(submitButton().disabled).toBe(true);
});
it("enables submit when all requirements pass", async () => {
buildDom();
await loadPasswordRules();
initPasswordValidation({});
setPassword("Abcdef1!");
(document.getElementById("confirm-password") as HTMLInputElement).value = "Abcdef1!";
(document.getElementById("confirm-password") as HTMLInputElement).dispatchEvent(
new Event("input"),
);
(document.getElementById("username") as HTMLInputElement).value = "user";
(document.getElementById("username") as HTMLInputElement).dispatchEvent(new Event("input"));
(document.getElementById("email") as HTMLInputElement).value = "user@example.com";
(document.getElementById("email") as HTMLInputElement).dispatchEvent(new Event("input"));
await settleDebounce();
expect(submitButton().disabled).toBe(false);
});
it("keeps submit disabled on a confirmation mismatch", async () => {
buildDom();
await loadPasswordRules();
initPasswordValidation({});
setPassword("Abcdef1!");
(document.getElementById("confirm-password") as HTMLInputElement).value = "Different1!";
(document.getElementById("confirm-password") as HTMLInputElement).dispatchEvent(
new Event("input"),
);
await settleDebounce();
expect(submitButton().disabled).toBe(true);
});
it("does not require username/email when requireUsernameEmail is false", async () => {
buildDom();
await loadPasswordRules();
initPasswordValidation({ requireUsernameEmail: false });
setPassword("Abcdef1!");
(document.getElementById("confirm-password") as HTMLInputElement).value = "Abcdef1!";
(document.getElementById("confirm-password") as HTMLInputElement).dispatchEvent(
new Event("input"),
);
await settleDebounce();
expect(submitButton().disabled).toBe(false);
});
});
+1 -1
View File
@@ -63,7 +63,7 @@ function hasNumber(password: string): boolean {
return /[0-9]/.test(password); return /[0-9]/.test(password);
} }
function hasSpecialChar(password: string): boolean { function hasSpecialChar(password: string): boolean {
return /[!@#$%^&*()_+\-=[]{};':"\\|,.<>?]/.test(password); return /[!@#$%^&*()_+\-=[\]{};':"\\|,.<>?]/.test(password);
} }
function passwordsMatch(password: string, confirm: string): boolean { function passwordsMatch(password: string, confirm: string): boolean {
return password.length > 0 && password === confirm; return password.length > 0 && password === confirm;
+13 -1
View File
@@ -3993,6 +3993,8 @@ document.addEventListener("alpine:init", () => {
// Escape stays live so popovers/drawers can still be dismissed from // Escape stays live so popovers/drawers can still be dismissed from
// the keyboard even mid-note. // the keyboard even mid-note.
const t = event.target as HTMLElement | null; const t = event.target as HTMLElement | null;
const isProgressSlider =
t instanceof HTMLInputElement && t.type === "range" && t.id.startsWith("progress-slider");
const typing = const typing =
t?.tagName === "INPUT" || t?.tagName === "INPUT" ||
t?.tagName === "SELECT" || t?.tagName === "SELECT" ||
@@ -4000,7 +4002,17 @@ document.addEventListener("alpine:init", () => {
!!t?.isContentEditable; !!t?.isContentEditable;
// NOTE: no chrome poke here — keyboard page turns are reading // NOTE: no chrome poke here — keyboard page turns are reading
// actions and must leave the chrome alone (handoff item 7). // actions and must leave the chrome alone (handoff item 7).
if (typing && k !== "Escape") return; if (typing && k !== "Escape") {
// A focused progress range swallows arrows as ~1% book-jumps —
// with key repeat, holding an arrow crosses the entire book in
// seconds and the debounced saves persist every wild landing
// (2026-10-21 incident). Arrows are page turns everywhere in the
// reader: release the slider and fall through to the turn
// handlers. Every other key still belongs to the control.
if (!(isProgressSlider && (k === "ArrowLeft" || k === "ArrowRight"))) return;
event.preventDefault();
t.blur();
}
if (k === "ArrowLeft" || k === "h") { if (k === "ArrowLeft" || k === "h") {
if (event.altKey) { if (event.altKey) {
event.preventDefault(); event.preventDefault();
+74
View File
@@ -0,0 +1,74 @@
import { afterEach, describe, expect, it } from "vitest";
import {
clearAll,
getRefreshToken,
getSelectedLibrary,
getTheme,
getToken,
removeRefreshToken,
removeToken,
setRefreshToken,
setSelectedLibrary,
setTheme,
setToken,
} from "./storage";
afterEach(() => {
clearAll();
});
describe("token storage", () => {
it("round-trips and removes the access token", () => {
expect(getToken()).toBeNull();
setToken("tok-1");
expect(getToken()).toBe("tok-1");
removeToken();
expect(getToken()).toBeNull();
});
it("round-trips and removes the refresh token", () => {
setRefreshToken("r-1");
expect(getRefreshToken()).toBe("r-1");
removeRefreshToken();
expect(getRefreshToken()).toBeNull();
});
});
describe("theme storage", () => {
it("defaults to tokyo-night when unset", () => {
expect(getTheme()).toBe("tokyo-night");
});
it("round-trips a chosen theme", () => {
setTheme("dracula");
expect(getTheme()).toBe("dracula");
});
});
describe("library selection", () => {
it("round-trips a concrete library id", () => {
setSelectedLibrary("abc-123");
expect(getSelectedLibrary()).toBe("abc-123");
});
it("stores the __all__ sentinel for an empty selection and reads it back as empty", () => {
setSelectedLibrary("");
expect(getSelectedLibrary()).toBe("");
});
it("writes the selection cookie for SSR", () => {
setSelectedLibrary("abc-123");
expect(document.cookie).toContain("selectedLibrary=abc-123");
});
});
describe("clearAll", () => {
it("wipes everything", () => {
setToken("t");
setTheme("dracula");
clearAll();
expect(getToken()).toBeNull();
expect(getTheme()).toBe("tokyo-night");
});
});
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import { getToastConfig, showToast, type ToastType } from "./toast";
// getToastConfig drives every toast's styling; a missing entry for a
// ToastType member crashes showToast at runtime (that bug shipped once —
// the 'warning' variant — which is why this suite exists).
const ALL_TYPES: ToastType[] = ["error", "success", "info", "warning"];
describe("getToastConfig", () => {
it("has a complete config for every ToastType", () => {
for (const type of ALL_TYPES) {
const config = getToastConfig(type);
expect(config, `missing config for "${type}"`).toBeDefined();
expect(config.bgClass, `missing bgClass for "${type}"`).toBeTruthy();
expect(config.icon, `missing icon for "${type}"`).toBeTruthy();
}
});
it("styles the warning variant with the /90 opacity used by siblings", () => {
expect(getToastConfig("warning").bgClass).toBe("bg-yellow-500/90");
});
it("keeps distinct visuals per type", () => {
const classes = ALL_TYPES.map((type) => getToastConfig(type).bgClass);
expect(new Set(classes).size).toBe(ALL_TYPES.length);
});
});
describe("showToast", () => {
it("creates the container and a styled toast", () => {
showToast("Saved", "success");
const container = document.getElementById("toast-container");
expect(container).not.toBeNull();
expect(container?.innerHTML).toContain("Saved");
expect(container?.innerHTML).toContain("bg-green-500/90");
});
it("renders the warning variant without throwing", () => {
// Regression: 'warning' had no config entry and crashed on render.
expect(() => showToast("Careful", "warning")).not.toThrow();
expect(document.getElementById("toast-container")?.innerHTML).toContain("bg-yellow-500/90");
});
});
+25 -4
View File
@@ -48,15 +48,23 @@ const getToastConfig = (type: ToastType) => {
}; };
// Create a toast element // Create a toast element
const createToastElement = (message: string, type: ToastType): HTMLElement => { const createToastElement = (
message: string,
type: ToastType,
action?: { label: string; onClick: () => void },
): HTMLElement => {
const toast = document.createElement("div"); const toast = document.createElement("div");
const config = getToastConfig(type); const config = getToastConfig(type);
toast.className = `${config.bgClass} text-white p-4 rounded-lg shadow-lg flex items-center gap-3 min-w-[300px] max-w-[500px] text-sm leading-relaxed pointer-events-auto opacity-0 -translate-y-5 transition-all duration-300 border border-white/10`; toast.className = `${config.bgClass} text-white p-4 rounded-lg shadow-lg flex items-center gap-3 min-w-[300px] max-w-[500px] text-sm leading-relaxed pointer-events-auto opacity-0 -translate-y-5 transition-all duration-300 border border-white/10`;
const actionHtml = action
? `<button class="toast-action bg-transparent border border-white/40 text-white cursor-pointer text-xs font-semibold px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 hover:bg-white/10 transition-colors">${toastEscapeHtml(action.label)}</button>`
: "";
toast.innerHTML = ` toast.innerHTML = `
<span class="text-xl flex-shrink-0">${config.icon}</span> <span class="text-xl flex-shrink-0">${config.icon}</span>
<span class="flex-1 break-words">${toastEscapeHtml(message)}</span> <span class="flex-1 break-words">${toastEscapeHtml(message)}</span>
${actionHtml}
<button class="toast-close bg-transparent border-0 text-white cursor-pointer text-lg p-0 w-5 h-5 flex items-center justify-center opacity-70 hover:opacity-100 flex-shrink-0 transition-opacity"> <button class="toast-close bg-transparent border-0 text-white cursor-pointer text-lg p-0 w-5 h-5 flex items-center justify-center opacity-70 hover:opacity-100 flex-shrink-0 transition-opacity">
× ×
</button> </button>
@@ -68,6 +76,16 @@ const createToastElement = (message: string, type: ToastType): HTMLElement => {
closeBtn.onclick = () => removeToast(toast); closeBtn.onclick = () => removeToast(toast);
} }
// Action button: run the handler and dismiss (an action consumes the
// toast — leaving it up after acting invites double-fires).
const actionBtn = toast.querySelector(".toast-action") as HTMLElement | null;
if (actionBtn && action) {
actionBtn.onclick = () => {
removeToast(toast);
action.onClick();
};
}
return toast; return toast;
}; };
@@ -90,14 +108,17 @@ const removeToast = (toast: HTMLElement): void => {
}, 300); }, 300);
}; };
// Show toast notification // Show toast notification. `action` (optional): a labeled button for
// one-click follow-ups (e.g. the sleep timer's "Resume"); acting
// dismisses the toast.
const showToast = ( const showToast = (
message: string, message: string,
type: ToastType, type: ToastType,
duration: number = TOAST_DEFAULT_DURATION, duration: number = TOAST_DEFAULT_DURATION,
action?: { label: string; onClick: () => void },
): void => { ): void => {
const container = createToastContainer(); const container = createToastContainer();
const toast = createToastElement(message, type); const toast = createToastElement(message, type, action);
container.appendChild(toast); container.appendChild(toast);
animateToastIn(toast); animateToastIn(toast);
@@ -262,5 +283,5 @@ const initializeToastSystem = (): void => {
initializeToastSystem(); initializeToastSystem();
export { showToast }; export { showToast, getToastConfig };
export type { ToastType }; export type { ToastType };
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB