From 9e0f713ed05eff199a20c5ae186fd7c24d20df62 Mon Sep 17 00:00:00 2001 From: KodeStar Date: Tue, 4 Aug 2026 21:35:39 +0100 Subject: [PATCH 1/2] server+manager docs: album/title tag arbitration and the bare-title sequence veto Document the two matching fixes: metadata extraction now prefers the title tag when it extends the album with a real subtitle (the Audible series-in-album shape), and pkg/match disqualifies a same-series candidate on a sequence conflict only when both titles are bare "series + number". --- docs-developers/manager/server-integration.md | 6 +++++- docs-developers/server/scanner.md | 8 +++++--- docs-users/getting-started/organizing-your-library.md | 2 +- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/docs-developers/manager/server-integration.md b/docs-developers/manager/server-integration.md index 6e5066d..0124f48 100644 --- a/docs-developers/manager/server-integration.md +++ b/docs-developers/manager/server-integration.md @@ -174,7 +174,11 @@ so the manager and the server can never drift apart on identity. Where the manager uses each entry point: - **`match.Best`** - the scoring matcher (ASIN → author + series + - series-stripped title-token overlap, sequence-boosted). Wrapped by + series-stripped title-token overlap; sequence agreement boosts the score, and + when *both* titles reduce to bare "series + number", a sequence conflict + disqualifies the candidate - so a not-yet-imported volume can't match an indexed + sibling, while titles with real words still match on words even when sub-series + numbering disagrees). Wrapped by `importjob.BookMatcher` (converts a fetched book list once, so matching a whole Audible library is O(library)), it drives: the plan-time *exists-on-server* check, the Audible pre-flight, the stats-sync book pairing, and the series-gap diff --git a/docs-developers/server/scanner.md b/docs-developers/server/scanner.md index 8eaf5d8..a39f659 100644 --- a/docs-developers/server/scanner.md +++ b/docs-developers/server/scanner.md @@ -132,9 +132,11 @@ embedded data winning where it is trustworthy: free. 2. **Embedded tags + probe** (`metadata.Extract` on the primary file - the first part for folder books) overlay the baseline: tags via `dhowden/tag` - in-process (album ≻ title for the book title, album-artist ≻ artist for the - author, composer as narrator, plus raw-tag lookups for series/narrator - atoms), then ffprobe (when configured) for duration, chapters, the audio + in-process (album ≻ title for the book title - except when the title tag + extends the album with a real subtitle, the Audible shape where album holds + the *series* and only the title tag carries the actual book title; album-artist + ≻ artist for the author, composer as narrator, plus raw-tag lookups for + series/narrator atoms), then ffprobe (when configured) for duration, chapters, the audio `codec` (`codec_name` of the first audio stream - this is what feeds the `direct_playable` API flag), and richer container tags. 3. **Generic-title guard**: `chooseTitle` keeps the path-derived title when the diff --git a/docs-users/getting-started/organizing-your-library.md b/docs-users/getting-started/organizing-your-library.md index 22d5c42..ac41797 100644 --- a/docs-users/getting-started/organizing-your-library.md +++ b/docs-users/getting-started/organizing-your-library.md @@ -73,7 +73,7 @@ Audible's DRM-protected files are deliberately **not** indexed: the server can n For each book, AudioSilo starts with the names on disk, then lets the audio files' embedded tags override them when they're present and meaningful: - **From your folders**: the book's folder (or file) name becomes the title; a leading number like `03 - The Title` is read as its position in a series; the parent folder is read as the series and the folder above that as the author. So an `Author/Series/03 - Title/` layout works with no tags at all. -- **From embedded tags**: a title (audiobooks usually carry it in the *album* tag), the author (album artist or artist tag), the narrator (composer or narrator tag), and the series where a tag exists. Junk tag titles like "Track 01" are ignored in favour of the folder name. +- **From embedded tags**: a title (audiobooks usually carry it in the *album* tag; when the album holds the series name and the *title* tag carries the full "Series: Book" title, the more specific title tag wins), the author (album artist or artist tag), the narrator (composer or narrator tag), and the series where a tag exists. Junk tag titles like "Track 01" are ignored in favour of the folder name. - **From ffmpeg's ffprobe**, when available: durations and any chapter list embedded in the file, so a chaptered M4B shows its real chapters in the player. Audiobook tags are famously messy, which is why the folder names always provide a sane baseline - a well-named folder beats a badly-tagged file. From b6b811155b0bf174aeb5ae1615c6f235973a11b1 Mon Sep 17 00:00:00 2001 From: KodeStar Date: Thu, 13 Aug 2026 19:28:59 +0100 Subject: [PATCH 2/2] Document the sleep timer redesign Shake now extends a running sleep timer instead of cancelling it, so the User Guide's "shake to cancel" claims were wrong in two places. Covers what the fade does and does not apply to (set-time timers fade over their last 30 seconds, end-of-chapter timers play out at full volume), the "Keep listening" button as the web's only route, the countdown freezing while paused with its 20 minute reset and 2 hour abandon, and the new Auto sleep timer setting with its arming rules. Developer docs cover the timer's phase machine, the required setVolume on PlaybackService, the shake hook mounting at the root rather than in the player modal, the auto sleep controller and its session memory, and the shared player-store test double. --- docs-developers/frontend/overview.md | 9 +- docs-developers/frontend/playback.md | 344 ++++++++++++++++++++++++++- docs-developers/frontend/testing.md | 97 +++++++- docs-users/listening/account.md | 16 ++ docs-users/listening/mobile-apps.md | 2 +- docs-users/listening/playback.md | 46 +++- 6 files changed, 502 insertions(+), 12 deletions(-) diff --git a/docs-developers/frontend/overview.md b/docs-developers/frontend/overview.md index 144ddf4..15d9c58 100644 --- a/docs-developers/frontend/overview.md +++ b/docs-developers/frontend/overview.md @@ -34,7 +34,7 @@ src/api/ client.ts (typed fetch wrapper), types.ts (wire mirrors), reachability.ts (online/offline tracking) src/playback/ PlaybackService interface + per-platform engines, the player store, book-queue (timeline math), progress-sync (offline-safe saves), - sleep-timer, rate helpers + sleep-timer (+ auto-sleep, use-shake-to-extend), rate helpers src/downloads/ offline downloads: native/web engines + registry store (a sibling of playback, not inside it) src/components/ ui/ (design-system primitives - Text, Icon, Button, Card, Sheet, @@ -44,7 +44,8 @@ src/components/ ui/ (design-system primitives - Text, Icon, Button, Card, Sh src/stores/ Zustand: session (connections + tokens), settings, search src/i18n/ i18next init, LanguageProvider, locale catalogs (locales/*.json) src/theme/ ThemeProvider + raw color tokens (tokens.ts) -src/lib/ storage, secure-store, paths, format, pairing, known-servers, device, +src/lib/ storage, secure-store, paths, format, hhmm (wall-clock "HH:MM"), + ticker (one start/stop interval), pairing, known-servers, device, base-url, layout (the one phone->desktop breakpoint), register-sw, and other pure helpers modules/audiosilo-player/ the local Expo module (Swift + Kotlin + TS bridge) @@ -73,7 +74,7 @@ handful of standalone screens. | Route | File | Purpose | |---|---|---| -| - (root layout) | `src/app/_layout.tsx` | Mounts the provider tree (`GestureHandlerRootView` → `SafeAreaProvider` → `LanguageProvider` → `ThemeProvider` → `ApiProvider`), hydrates the session/settings/downloads stores, imports `@/lib/register-sw` for its side effect, mounts the headless `BookEndedListener` (drives the end-of-book flow, see [Playback](playback.md#ending-a-book-end-credits-and-up-next)), and runs `useAppResume` (foreground refresh + the Android swipe-from-recents reset). Declares the `(app)` stack and the `player`/`finished` screens as `fullScreenModal`s. | +| - (root layout) | `src/app/_layout.tsx` | Mounts the provider tree (`GestureHandlerRootView` → `SafeAreaProvider` → `LanguageProvider` → `ThemeProvider` → `ApiProvider`), hydrates the session/settings/downloads stores, imports `@/lib/register-sw` for its side effect, mounts the headless `BookEndedListener` (drives the end-of-book flow, see [Playback](playback.md#ending-a-book-end-credits-and-up-next)), starts the framework-free `startAutoSleep()` controller (arms the nightly sleep timer, see [Playback](playback.md#auto-sleep-timer-auto-sleepts--auto-sleep-controllerts)), and runs `useAppResume` (foreground refresh + the Android swipe-from-recents reset). Declares the `(app)` stack and the `player`/`finished` screens as `fullScreenModal`s. | | - (web HTML shell) | `src/app/+html.tsx` | The static HTML wrapper for every exported web route: PWA manifest/favicon links (base-prefixed) and a dark backdrop painted before React mounts so there is no white flash. | | `(app)` guard | `src/app/(app)/_layout.tsx` | The auth gate: `loading` → spinner, `unauthenticated` → ``, otherwise wraps children in `AppShell` (header + nav, which renders the `ReconnectBanner` when a connection's token is rejected - see below). Also backfills `has_password`/`has_recovery` on sessions persisted before those flags existed. | | `/` | `(app)/index.tsx` | Home: continue-listening cards, recently-added shelf, favourites - aggregated **across every connected server** via the `use*All` hooks. | @@ -84,7 +85,7 @@ handful of standalone screens. | `/library/[libraryId]?connection=…&path=…` | `(app)/library/[libraryId].tsx` | Library browse, root and nested folders alike - a two-line re-export of `src/components/library/browse-screen.tsx`. Content routes are **flat**: the connection id and the library-relative folder `path` ride as query params, never as nested route segments (an in-app `router.push` cannot resolve a route nested under a dynamic layout segment - it lands on the group's first child; rationale and helpers in `src/lib/paths.ts`). The `(app)` layout republishes `?connection=` as the scope the content hooks read via `useScopedCid()`. | | `/book/[libraryId]?connection=…&path=…` | `(app)/book/[libraryId].tsx` | Book detail: play/resume, download control, chapters, bookmarks, notes, listening history, other versions of the same book, and a capability-gated community-metadata section ([State & data](state-and-data.md#enriched-book-metadata)). Same flat query-param addressing as the library routes. | | `/downloads` | `(app)/downloads.tsx` | Downloaded books + storage used ([Offline](offline.md)). | -| `/settings` | `(app)/settings.tsx` | App-level preferences only: playback tunables, language, theme, plus the Servers list that opens each connection's account screen. | +| `/settings` | `(app)/settings.tsx` | App-level preferences only: playback tunables, the auto sleep timer's window and type, up-next/download behaviour, language, theme, plus the Servers list that opens each connection's account screen. | | `/account?connection=…` | `(app)/account.tsx` | Per-connection account screen, reached from the Settings screen's Servers list: set/change the self-service password (the sign-out guard nudges a password-less user here via `sign-out-confirm.tsx`), personal API keys (capability-gated, demo-hidden), and sign-out. | | `/player` | `src/app/player.tsx` | The full player, presented as a full-screen modal above the shell. Accepts `libraryId`/`path` (+ optional `position`/`track`) params and gates playback start on the chapters query settling. | | `/finished` | `src/app/finished.tsx` | The end-credits screen shown when a book finishes (or from the player's menu). A root modal sibling of the player; renders `EndCredits` with an "up next" suggestion. See [Playback](playback.md#ending-a-book-end-credits-and-up-next). | diff --git a/docs-developers/frontend/playback.md b/docs-developers/frontend/playback.md index d039120..14236c6 100644 --- a/docs-developers/frontend/playback.md +++ b/docs-developers/frontend/playback.md @@ -38,6 +38,13 @@ identically. - `swapTo?(…)` - optional gapless queue swap, used to move a streaming book onto its just-finished download without an audible gap (returns `false` when refused; see [Offline](offline.md)). +- `setVolume(volume)` - **required** linear output gain (0-1) applied to the + engine's own volume, **not** the device volume. It exists for the sleep + timer's fade-out on **duration** timers (below). It is deliberately not + optional: both engines implement it, and the one real "no volume here" case is + each engine's own private business, which it degrades internally (below). + Callers pass an already-clamped value - `usePlayer.setOutputVolume` is the only + route in and clamps once. - `configure(config)` - runtime tunables from the settings store: auto-rewind window, lock-screen skip intervals. - `getSnapshot()` / `subscribe(listener)` - a single merged @@ -265,7 +272,14 @@ Other queue math that lives here: coordinates; **`chapterAt`** finds the active chapter by `book_offset`; **`chapterCountdowns`** feeds the sleep timer's end-of-chapter picker (wall-clock times scaled by the playback rate via `rate.ts` - `wallClockSeconds`). + `wallClockSeconds`), and **`nextChapterEnd`** answers the sleep timer's "where + does the next worthwhile chapter end?" from the same `chapterEndPosition`, so + the list the listener picks from and the boundary a shake retargets cannot + disagree. Neither assumes the chapters ascend by position - `chapterBookOffset`'s + out-of-range `file_index` fallback degrades to 0 preceding files, so stale or + duplicated metadata yields non-monotonic ends; `chapterCountdowns` locates the + current chapter with `chapterAt` and `nextChapterEnd` takes the **nearest** + qualifying end rather than the first qualifying array element. `total` is the max of the book's reported duration, the summed file durations, and the furthest chapter end - so `duration: 0` metadata degrades instead of @@ -523,6 +537,322 @@ positively-known metered connection is skipped. The three settings persisted `autoDownloadNext` key name predates the download-on-start behavior and is kept for hydration compatibility. +## The sleep timer (`sleep-timer.ts`) + +`useSleepTimer` is a second Zustand store, deliberately framework-free. It reads +`usePlayer` through `getState()` and subscribes to it for exactly one thing - +"is the transport running?", which freezes a duration countdown while playback is +paused (see below). It arms three ways - `startDuration(minutes)`, +`startUntilPosition(position, label)` and `startChapterTimer(opts?)` - which all +funnel through one private `arm()` that restores the volume, clears the +ending/grace flags and restarts the 1 s tick. The tick counts down, fires, and +expires the grace window; nothing else drives the machine. + +```mermaid +stateDiagram-v2 + idle --> running: startDuration / startUntilPosition / startChapterTimer + running --> ending: remaining <= FADE_SECONDS (30 s) - duration timers ramp the gain down at 4 Hz + ending --> running: backward seek pushes the target back out of the window, or the countdown freezes + ending --> grace: fire() - pause first, then restore the gain + grace --> idle: tick() - GRACE_SECONDS (30 s) elapsed, no shake - records 'expired' + ending --> running: keepListening() re-arms from origin + grace --> running: keepListening() re-arms from origin AND resumes playback + running --> idle: cancel() - records 'cancelled' + running --> idle: a freeze longer than ABANDON_AFTER_PAUSE_SECONDS - records 'expired' +``` + +Every edge back to `idle` goes through `endTimer(reason)`, which notifies the +`onSleepTimerEnded(fn)` registry synchronously - once per ending, with the store +already back at `idle` - so the auto sleep controller below can tell a dismissal +from a timer that simply ran out. That is the only event this store emits; a +timer armed with nothing loaded (`bookKey === null`) notifies nothing. + +The pieces worth knowing before touching it: + +- **Two selectors are the whole public surface for UI.** The phase + (`idle | running | ending | grace`) is **stored**, not derived - three booleans + could spell out twice as many combinations as are legal, and the UI kept + re-deriving the phase from them by hand - so `selectSleepPhase` simply reads it + and `selectSleepExtendable` is `phase === 'ending' || phase === 'grace'` + (nothing branches on `graceUntil`; it is only ever the answer to "until + when?"). The second one answers "can a shake or a *Keep listening* tap do + anything right now?" and also **gates the accelerometer listener**, so the + sensor runs only in those two short windows rather than for the whole timer. + That gate is why the phase must be *true*: a frozen countdown leaves `ending` + (below), or a book paused with 20 seconds left would keep the sensor + subscribed - and the badge solid pink, and the sheet saying "Fading out" about + a paused book at full volume - indefinitely. +- **Only a duration timer fades.** The phase is called `ending`, not `fading`, + because it means "about to stop, a shake still saves it" for **both** kinds of + timer while only one of them touches the gain. `fadesAudio(origin)` is the + single gate: `{kind:'duration'}` fades, because its stopping point is arbitrary + and an abrupt cut mid-sentence is jarring; `{kind:'chapter'}` (which includes + the end-of-book target and every `startUntilPosition` timer) plays its last 30 + seconds at **full volume**, because those are the words the listener stayed + awake for and the chapter ending is its own signal. On the chapter path the + fade ticker never starts, so there are **no gain writes at all** - a unit test + asserts the gain is never below 1 for a whole chapter-timer run, including + through its pause and grace. +- **The fade has its own faster ticker.** The 1 s countdown tick is far too + coarse to ramp against, so `FADE_TICK_MS` (250 ms) drives `syncFade` - the one + reconciler that owns both the fade ticker and the engine gain, holding the rule + *the ramp runs iff `phase === 'ending'` and the origin fades and it is not + frozen*, so every site that writes `phase` or `frozenAt` just calls it + afterwards. It ramps only while a **duration** timer is `ending`. + `fadeGain(remaining)` is the + exported, unit-tested curve: `(remaining / FADE_SECONDS)²`, squared because + perceived loudness is roughly the square root of linear gain, so a linear ramp + stays loud and then drops off a cliff. The gain is **never written into the + store** - it changes four times a second and nothing renders it, so storing it + would re-render every subscriber at 4 Hz. +- **`syncEndingPhase` works in both directions.** It enters the `ending` phase + when the remaining time drops into the window *and leaves it, restoring full + volume, when the remaining time climbs back out* - which a backward seek on an + end-of-chapter timer does; without the second half the rest of the chapter + would be stranded at a fraction of its volume (back when that timer still + faded). A **frozen** countdown is never in the phase either, by the same rule + rather than a second one: `ending` means "about to stop", and a countdown that + is not counting is not about to stop. Otherwise the phase change is + unconditional; only the ramp is gated on `fadesAudio`. +- **`fire()` pauses first, then restores the gain**, chained in a `finally` so a + rejected pause can't leave a manual resume silently muted. It does not go to + `idle`: it opens the `GRACE_SECONDS` (30 s) window and keeps ticking. +- **`keepListening()` is a no-op outside the `ending` phase and the grace.** + Inside them it re-arms from the recorded `origin` (`{kind:'duration', minutes}` + or `{kind:'chapter'}`), so a duration timer restarts its full length and a + chapter timer retargets. From the grace it additionally calls + `resumePlayback()`, which checks the live snapshot before calling `toggle()` - + `toggle` from a *playing* state would pause a book the listener had already + resumed by hand. +- **One constant makes "one more chapter" work.** + `nextChapterTarget(allowEndOfBook)` picks the **nearest** upcoming chapter end + more than `MIN_CHAPTER_SECONDS` (30 s) away (`nextChapterEnd` in + `book-queue.ts`, which scans for the nearest qualifying end rather than the + first qualifying array element - chapter offsets are not guaranteed to ascend). + In the `ending` phase the current chapter's end *is* the boundary being stopped + at, so it fails that test and the re-arm naturally lands on the **next** + chapter; for a timer armed at the start of playback the current chapter usually + qualifies. It is deliberately its **own** constant and not an alias of + `FADE_SECONDS`: they share a value but are unrelated, and while they were tied + together retuning the fade silently changed what "one more chapter" retargets. +- **Who asked decides the fallback**, which is what `startChapterTimer`'s + `{ allowEndOfBook }` option carries. The **listener's** reset (`keepListening` + passes `{ allowEndOfBook: true }`) means "one more chapter", and where there is + no next chapter the end of the book is the honest answer. The **automatic** + nightly arm passes nothing (the default is `false`) and refuses both the + end-of-book fallback *and* a chapter that ends exactly where the book does, + degrading to a 15-minute duration timer instead. A folder of MP3s with no + chapter metadata has `queue.chapters === []` (`buildBookQueue` synthesizes + virtual chapters only for a single-file book), so the shared fallback armed a + target ten hours out: it never fired, so the timer never returned to `idle`, so + the controller's "a timer already stands" guard blocked every later arm and + stopped its poll - no working sleep timer at all, all night. Whichever + fallback applies, a chapter arm always ends up with a timer that fires. + +### Freezing the countdown while paused (`syncPlaybackFreeze`) + +A duration timer counts **listening** time, not wall-clock time: it must not +expire while the book is paused, which it used to do silently - pause with a +headphone button, never look at the screen, and come back to no timer armed. +Every open-source *audiobook* player does the same (Audiobookshelf, Absorb, +Voice; AntennaPod switched off wall clock in 2025). The subtleties are all in +*how* it freezes: + +- **The freeze must survive the JS runtime being suspended.** iOS suspends the + app once it stops producing audio and a hidden web tab is throttled, so **no + ticks arrive at all** while paused - any scheme that decrements a balance per + tick silently loses an untick'd hour. So the representation is a **stopped + clock**, not a running total: a duration timer stays a `Date.now()` deadline + (`endsAt`), pausing records **`frozenAt`** (the moment the clock stopped), and + `preciseRemaining` reads the deadline against `frozenAt` instead of the live + clock. That answer needs no ticks to stay correct. Resuming slides `endsAt` + forward by `Date.now() - frozenAt` - two timestamps, so any length of gap + reconciles exactly. +- **The same shape fixes the other direction.** Because the countdown is a + deadline rather than a per-tick decrement, a *playing* context whose ticks are + throttled to one a minute can't make the timer run long either. +- **The play state is a level, read from a subscription - not a transition.** + `playbackWatch` subscribes to `usePlayer` while a countdown is running and + **ignores the `(state, prev)` payload**, re-reading `isTransportLive()` + (`playing || loading`) instead. The engine's resume stream is a jumble of + `ready` / `loading` / spurious `paused` (see [the stall watchdog](#the-stall--error-watchdog)), + and matching individual transitions is the approach that has failed repeatedly + here. Subscribing (rather than waiting for the tick) is what makes the + suspension case airtight: the store write happens while the app is still awake + handling the pause, so `frozenAt` is always recorded. The 1 s tick and every + `arm()` call the same reconcile as a backstop, so a missed notification costs + at most one second. +- **A thaw has three outcomes, by how long the freeze lasted.** Freezing + introduces the inverse failure - a timer frozen with 3 minutes left, forgotten, + firing 3 minutes into tomorrow's session - and nothing ever cancels a frozen + timer, so the length of the freeze is the only evidence there is. On the + transition back to playing: + - **under `RESET_AFTER_PAUSE_SECONDS` (20 min)**: the frozen countdown + continues untouched. 20 minutes clears the longest ordinary in-session + interruption while being far short of "later that day"; Audiobookshelf + re-arms unconditionally above 3 seconds, which throws away a 25-minute + countdown because someone answered the door. + - **between that and `ABANDON_AFTER_PAUSE_SECONDS` (2 h)**: a new sitting, so + the timer is re-armed at its **full original** `origin.minutes`. + - **beyond 2 h**: the timer is **ended** (`endTimer('expired')`), not + resurrected. Re-arming at any length there was the "armed at 22:30, paused at + 22:40, resumed at 08:00, book fades out at 08:30" bug - no user action, hours + outside the auto sleep window, and no re-check of why the timer existed. + `expired` rather than `cancelled`, so auto sleep is free to arm a fresh one on + tonight's terms. + + There is deliberately **no setting** for any of it. The frozen span is also + **clamped at zero**: it is two readings of a clock the device owns, so a + backward jump (an NTP correction, a manual change) would otherwise slide + `endsAt` *earlier* and fire the timer early once the clock came back. +- **Chapter timers and the grace window are exempt** (both have `endsAt === + null`). A position target does not advance while paused and stays valid however + long the pause was, so it is frozen by construction and must not be re-armed. + The post-pause grace is genuinely wall-clock - it exists to expire *while* the + audio is stopped. +- **A pause mid-fade hands the volume back, and leaves the `ending` phase.** + Freezing stops the fade ticker and writes gain 1 immediately: the listener may + hit play on the next breath, and near-silent audio with no visible cause is the + worst outcome this feature has. With the ramp suspended and the volume back, + the phase is no longer true either, so the freeze drops to `running` (see + `syncEndingPhase` above) - which is what takes the accelerometer back off, the + badge back to a countdown, and a stray shake out of the picture (outside the + `ending`/grace windows `keepListening()` is a no-op, and there it would have + silently reset the timer without resuming). The ramp is not lost: the thaw + re-enters the phase if the remaining time still warrants it and `syncFade()` + picks up at the gain the frozen countdown implies, so it neither restarts nor + jumps. A frozen timer also never `fire()`s (it would be pausing an + already-paused book) and never writes a gain at all. + +### Writing the gain: `setVolume` is required, degraded per engine + +The fade reaches the engine through `usePlayer.setOutputVolume(gain)`, which +clamps once and calls `service.setVolume(…)`. The interface method is +**required** (`types.ts` says so, with the reasoning): an optional marker would +push a `?.` onto every caller to model something no caller can act on. Instead +each engine handles its own "no volume here" case internally and still resolves: + +- **`service.native.ts` feature-detects the native function** + (`typeof AudiosiloPlayer.setVolume !== 'function'`) and also wraps the call in + a `try`. The JS bundle can be **newer than the native binary it runs on** - an + installed dev build, or a shipped App Store / Play build from before + `setVolume` existed - and calling a function the native module doesn't define + *throws*, which would turn every sleep-timer fade into a playback-breaking + rejection on those installs. Older binaries degrade to "no fade" and resolve. +- **`service.web.ts`** sets the element's `volume` and swallows the failure on + **iOS Safari**, which refuses per-element volume outright (system volume is the + only control there). The fade is inaudible on iPhone/iPad web; it must never + become a thrown error. +- **`playBook` resets the gain to 1** (`svc.setVolume(1)`, with the store's + cached `outputVolume` written alongside it) when starting a book. The timer + restores the volume on every path it owns; this is the backstop for the one it + doesn't, because near-silent audio with no visible cause is the worst outcome + this feature can produce. It runs **immediately after `nowPlaying` is swapped** + to the new book, at every site that swaps it - the fade ticker stands down by + comparing the *playing* book against the timer's own, so a restore written + while `nowPlaying` still held the old book was one the 4 Hz ticker could + overwrite during the resume lookup's network round trip, and the new book could + start attenuated. + +### Shake to extend (`use-shake-to-extend.ts`) + +A shake **extends** the timer; it does not cancel it. (The hook replaces an +earlier `use-shake-to-cancel.ts`, which is gone.) `useShakeToExtend` subscribes +the accelerometer only while `selectSleepExtendable` holds, so the sensor is off +for the other 29 minutes of a 30-minute timer. + +It is mounted **exactly once, at the app root**: the headless +`ShakeToExtendListener` (`src/components/player/shake-to-extend-listener.tsx`) +rendered by `src/app/_layout.tsx`, alongside `startAutoSleep()`. Deliberately +**not** from `player-view.tsx` - the feature's main case is a nightly timer on a +locked phone with no player screen open, where a hook mounted in the player modal +would never be listening; mounting it in both places would double-fire a single +shake. + +- It imports **`expo-sensors/build/Accelerometer` directly**, never the + `expo-sensors` barrel: the barrel does `import * as Pedometer`, and + `Pedometer.ts` resolves its native module at load time, throwing + "Cannot find native module 'ExponentPedometer'" on builds that don't link it - + crashing the player for a sensor we never use. +- Detection requires a **burst**, not a single sample: 100 ms sampling, total + acceleration above **1.4 g**, **two** qualifying samples inside a 1 s window, + then a 2 s debounce. A single-sample threshold both false-fires on a pocket + bump and misses a genuine shake landing between samples. The bar is + deliberately low: a false positive only grants more listening time, while a + false negative stops the book on someone who was awake. +- Native only, and the whole subscription is wrapped in a `try` so a build + without the sensor degrades to a no-op. The sheet's **Keep listening** button + is the equivalent, mandatory on web (no accelerometer) and offered on native + too. + +### Auto sleep timer (`auto-sleep.ts` + `auto-sleep-controller.ts`) + +The nightly auto-arm is split into a pure decision function and a framework-free +controller, so all the policy is unit-tested and none of it lives in a component. + +- **`decideAutoSleep(input)`** takes the four `autoSleep*` settings, `now`, the + `bookKey` and the per-book memory, and returns + `{arm:'none'} | {arm:'chapter'} | {arm:'duration', minutes}`. It bails when the + feature is off, when the memory says this book is blocked (`canAutoSleepArm`), + and when the clock is outside the window. A persisted `autoSleepType` that isn't + `chapter` or a positive number arms nothing rather than a nonsense timer. + "A timer is already standing" is deliberately **not** an input: that is a live + reading of the timer store, enforced by the controller (below), and restating it + here would be a second, always-false copy of a rule enforced elsewhere. + An `{arm:'chapter'}` decision arms through `startChapterTimer()` with **no** + options - i.e. `allowEndOfBook: false`, the automatic fallback rules above - so + a chapterless book gets a duration timer that fires rather than a target hours + away that would block every later arm for the session. +- **The window test is `withinAutoSleepWindow` (`src/lib/hhmm.ts`, with + `parseHhMm`/`formatHhMm`)**, half-open `[from, until)` over local wall-clock + "HH:MM", wrapping past midnight (the 22:00-06:00 default). `from === until` reads + as **never**, and a malformed bound is `false`, so a corrupt value can't arm a + timer unexpectedly. It lives in `lib`, not in the settings store that persists the + strings, so `@/lib/format` (imported by some twenty modules) doesn't drag zustand + and the persisted settings into their graphs. +- **The timer says how it ended**, through `onSleepTimerEnded(fn)` - see the sleep + timer above. `cancel()` reports `cancelled`; the grace closing, a fire against a + book that was not playing, and `cancelIfBookChanged` all report `expired`. A book + change is an expiry, not a cancellation: nobody dismissed that timer, and blocking + the book for the night because the listener dipped into another one would recreate + the failure this design fixes. Never infer the reason from the leftover fields - + the version that read `graceUntil !== null` as "it fired" filed a cancellation made + *during* the grace window as an expiry, which is precisely the case that must not + re-arm. +- **The anti-nag memory** (`AutoSleepMemory`) is ONE bounded, insertion-ordered set + of book keys: the books the listener has **cancelled** a timer on. It is folded by + the pure `recordAutoSleepOutcome` (a `cancelled` outcome adds, an `expired` one + changes nothing) and read by `canAutoSleepArm`, and it caps at + `MAX_REMEMBERED_BOOKS` (50), evicting the oldest. A block is final for the session: + never unblocked by anything later, including the listener's own manual timer + running out on the same book. + The other half - "a timer is standing for this book right now, so nothing may arm + a second one" - is **not remembered at all**: the timer store answers it live as + `phase !== 'idle'`, and answers it better. A remembered copy was only as good as the + bookkeeping that maintained it, and could go stale in a way the live reading cannot. +- **Re-arming can't loop.** Firing pauses playback, so the only route back to an + armed timer is a fresh transition into `playing` - the listener's own hand. The + one case with no such edge is a listener who resumed by hand *during* the grace + window; there the poll (60s) picks it up once the grace closes, which is also the + floor on how often anything can be armed. +- **`startAutoSleep()` (`src/playback/auto-sleep-controller.ts`)** is started once + from `src/app/_layout.tsx` (`useEffect(() => startAutoSleep(), [])`) and returns its + teardown. It is a module with subscriptions rather than a component that renders + `null` - it uses no context, router, props or rendering - and it holds the session + memory in **module state**, so "never again for this book" lasts as long as the JS + context rather than as long as a mounted component. It must run whether or not the + player modal is open: the timer has to arm for a book started from the mini player, + the library, or a lock-screen play. + It listens to three things: the **setting** (which attaches and detaches everything + else - `autoSleepTimer` defaults to off, and the player subscription would otherwise + run on every progress write for a feature nobody switched on); the **player**, for + the transition edge into `playing` (one look per resume, not one per progress tick); + and **`onSleepTimerEnded`**, which is installed for the whole session because a + cancellation counts even if auto sleep is enabled later. The 60s poll + (`ticker` from `src/lib/ticker.ts`) runs only while a book plays with the feature + on, no timer standing and the book unblocked - each gate being a "re-asking cannot + change the answer" test. + ## The player controls and title display Two smaller UI concerns round out the player: @@ -533,7 +863,17 @@ Two smaller UI concerns round out the player: primitive from `src/components/ui/` - a footer-nested sheet would be clipped to the footer's bounds. Speed drives a `Stepper` (0.5-2x, 0.05 steps); the sleep sheet offers duration presets, an end-of-chapter list (from `chapterCountdowns` - at the live rate), and an end-of-book fallback. + at the live rate), and an end-of-book fallback. `SleepSheetBody` is a child of + `Sheet` so the per-tick countdown scan mounts only while the sheet is open, and + it swaps its header for a **Keep listening** call to action whenever + `selectSleepPhase` is `ending` or `grace` (demoting *Cancel timer* to a neutral + button so the two can't compete). That header reads the timer's `origin` so it + can only promise what is happening: `player.sleepTimer.fading` ("Fading out") + for a duration timer, `player.sleepTimer.ending` ("Ending soon") for a chapter + one, and `player.sleepTimer.grace` once playback has paused. `SleepTimerButton` + and the badge over the cover in `player-view.tsx` read only the phase - a + countdown while `running`, solid pink plus a short "keep going" label once the + timer is ending or has paused - so they are identical for both kinds. - **`prettify-title.ts` cleans filename-shaped labels for display.** Audiobook "chapter" labels are often just the underlying audio *filename* (`01_the_hobbit_ch1.mp3`). `prettifyChapterTitle` strips a recognised audio diff --git a/docs-developers/frontend/testing.md b/docs-developers/frontend/testing.md index 63569b8..295aa02 100644 --- a/docs-developers/frontend/testing.md +++ b/docs-developers/frontend/testing.md @@ -62,6 +62,89 @@ needed. tiny probe component** with `render(...)` that calls the hook and exposes its result. +### The shared player-store double (`src/testing/player-store-mock.ts`) + +The player store is the hardest dependency to bring into a test: it owns the +native engine, the API layer and the download store. Three suites need it +without any of that - `src/playback/sleep-timer.test.ts`, +`src/playback/auto-sleep-controller.test.ts` and +`src/components/player/sleep-timer-button.test.tsx` - and they share **one** +double rather than three near-copies, so its fidelity is decided in one place. + +Use it as the whole mocked module, and pull the same instance back out with +`playerStoreMock()` to drive it: + +```ts +jest.mock('@/playback/store', () => + // `require` (not an import) because a jest.mock factory is hoisted above every import. + require('@/testing/player-store-mock').createPlayerStoreMock(), +); +const player = playerStoreMock(); +``` + +What it models **faithfully** - the parts a test may lean on: + +- it is a **real zustand store**, so `subscribe((state, prev) => …)`, + notification order and equality behave exactly as in production (the sleep + timer freezes its countdown off a store notification, and the auto sleep + controller detects the play edge by comparing `state`/`prev` - both would be + testing a fake otherwise); +- the store's **real selectors** over the stand-in state: `selectBookKey` + (`connectionId:libraryId:path`), `selectIsPlaying` (strictly `playing`), + `selectIsTransportLive` (`playing` or `loading`), `selectBookPosition`; +- `pause()` records the call **and then** writes the paused snapshot, in that + order, so a listener reacting to the write is ordered after the pause as it is + in production; +- `setOutputVolume` drops a write that would not change the gain, exactly as the + real store does - so the timer's many defensive volume restores do not show up + as writes production never makes. + +Its deliberate **divergences**, which a test must not read as production +behaviour: + +- **`toggle()` is a pure spy.** It does not synthesise a `playing` snapshot; a + test that needs a resume to land writes the snapshot itself (`setPlayState`). + The real `toggle` goes through the engine, and faking the outcome would test + the double. +- **`bookPosition` is a plain field**, set by the test. The real + `selectBookPosition` derives the whole-book position from the queue's chapter + offsets and the engine's per-track position. +- **`MockNowPlaying` is a subset** of the real `NowPlaying`: `connectionId`, + `libraryId`, `path` and `queue` (`chapters` + `total`), which is all the + selectors and the chapter scan read. +- **`subscribe` is wrapped** so the double can report `subscriberCount()` and + `dropSubscribers()`. Production has no such hook; they exist for the suites + that attach and detach a subscription rather than holding one for the process + lifetime. +- **`patch()` writes without notifying** - fixture setup, and the shape of a + change that happened while nothing was subscribed. Use `setPlayState()` when + the notification is the point. +- Nothing loads a book: there is no engine, no API and no persistence, so + `nowPlaying`, `bookPosition` and the play state are whatever the test sets. + +`createPlayerStoreMock()` runs once per module registry, so a suite that calls +`jest.resetModules()` must re-require both the mocked module and anything under +test (`playerStoreMock()` throws rather than hand back a stale instance). + +### `render` and `fireEvent` are async (RNTL 14) + +In `@testing-library/react-native` 14 **both `render` and `fireEvent` return +promises** and both must be awaited. The failure mode is nastier than a flake: +a test that fires two un-awaited presses leaves **every later `render` in that +file** mounting into a detached tree, so unrelated cases further down the file +fail with queries that find nothing - which reads as "the component stopped +mounting" rather than as a missing `await` several tests earlier. + +```ts +await render(); +await fireEvent.press(screen.getByLabelText(LATER)); +``` + +Note that the common `await act(async () => { render(ui) })` helper does **not** +await `render` - the `act` callback returns before the render promise settles. +Prefer awaiting `render` directly; where a mount helper wraps it in `act`, the +`render` inside still needs its own `await`. + ### Mocking `fetch` `src/api/client.test.ts` installs a fake global fetch driven by a per-test @@ -130,14 +213,20 @@ Co-located suites exist for: | Area | Tested modules | |---|---| -| API layer | `src/api/client.test.ts`, `src/api/reachability.test.ts` | -| Playback | `src/playback/book-queue.test.ts`, `progress-sync.test.ts`, `store.test.ts`, `service.web.test.ts`, `sleep-timer.test.ts`, `rate.test.ts` | +| API layer | `src/api/client.test.ts`, `connection-clients.test.ts`, `reachability.test.ts` | +| Playback | `src/playback/book-queue.test.ts`, `progress-sync.test.ts`, `store.test.ts`, `service.web.test.ts`, `sleep-timer.test.ts`, `auto-sleep.test.ts`, `auto-sleep-controller.test.ts`, `rate.test.ts`, `next-book.test.ts`, `prettify-title.test.ts`, `types.test.ts` | | Downloads | `src/downloads/store.test.ts` | | Stores | `src/stores/session.test.ts`, `settings.test.ts` | | i18n | `src/i18n/language.test.ts`, `language-provider.test.tsx` | | Account flows | `src/components/account/use-api-keys-manager.test.tsx`, `use-sign-out.test.tsx` | -| UI data | `src/components/ui/icon-data.test.ts` (validates every vendored SVG glyph) | -| `src/lib` helpers | `alpha-sections`, `app-resume`, `auth-failure`, `base-url`, `dedup`, `format`, `known-servers`, `nav`, `pairing`, `paths`, `progress-view`, `scroll-memory`, `secure-store`, `share`, `support` | +| Player UI | `src/components/player/sleep-timer-button.test.tsx`, `end-credits-logic.test.ts` | +| Library UI | `src/components/library/book-meta.test.ts`, `book-meta.render.test.tsx`, `entry-row.test.tsx`, `progress-card.test.tsx`, `skeletons.test.tsx`; `src/components/layout/content-scope.test.tsx` | +| UI primitives | `src/components/ui/` - `animated-pressable`, `empty-state`, `icon-data` (validates every vendored SVG glyph), `overlay-host`, `section-header`, `segmented-control`, `select-row`, `sheet`, `skeleton`, `time-stepper` | +| `src/lib` helpers | `account`, `alpha-sections`, `app-resume`, `auth-failure`, `base-url`, `clipboard`, `content-key`, `dedup`, `format`, `hhmm`, `known-servers`, `nav`, `network`, `pairing`, `paths`, `progress-view`, `rnw-button-fix`, `scroll-memory`, `secure-store`, `share`, `support`, `ticker` | + +The shared test double for the player store lives outside that list, in +`src/testing/player-store-mock.ts` - see +[the section above](#the-shared-player-store-double-srctestingplayer-store-mockts). Not covered by unit tests, by design or necessity: `src/app/**` screens (kept logic-free), and the **native module** (`modules/audiosilo-player`) - Swift and diff --git a/docs-users/listening/account.md b/docs-users/listening/account.md index ff544a2..acb04a6 100644 --- a/docs-users/listening/account.md +++ b/docs-users/listening/account.md @@ -35,6 +35,22 @@ Your playback preferences (kept per device, so your phone and your desktop can d | **Auto-rewind on resume** | How far playback backs up after a pause, so you regain the thread | Off–30 s (default 5 s) | | **Chapter length (unchaptered)** | Size of the virtual chapters created for long books with no chapter markers | 5–60 min (default 30 min) | +## Sleep timer + +Whether AudioSilo should set a [sleep timer](playback.md#sleep-timer) for you at night, so you don't have to remember. Off out of the box; the three settings below only appear once it's on. Like Playback, these are kept per device. + +| Setting | What it does | Options (default) | +|---|---|---| +| **Auto sleep timer** | Start a sleep timer automatically for playback that begins between the hours below | On / Off (default **Off**) | +| **From** / **Until** | The nightly window it applies to, adjustable in **30-minute steps** and shown in your device's own clock format | Any times (default **10:00 PM** to **6:00 AM**) | +| **Timer type** | What the automatic timer does | End of chapter, or 15 / 30 / 45 / 60 minutes (default **End of chapter**) | + +The window may cross midnight, which is the whole point of the default. Setting **From** and **Until** to the same time switches it off rather than covering the whole day. + +:::note +The automatic timer stays out of your way: only ever one at a time, and it never replaces a timer you set yourself. If one runs out and you press play again inside the window, you get a fresh one - waking at 3:00 AM shouldn't leave you unprotected. **Cancel** one, though, and nothing will arm another for that book for the rest of the session. +::: + ## Up next Controls for what happens as one book ends and the next begins (see [When a book finishes](playback.md#when-a-book-finishes)). Like Playback, these are kept per device. diff --git a/docs-users/listening/mobile-apps.md b/docs-users/listening/mobile-apps.md index 5ba9d6e..b891e7d 100644 --- a/docs-users/listening/mobile-apps.md +++ b/docs-users/listening/mobile-apps.md @@ -37,7 +37,7 @@ Playback keeps going when you switch apps, turn the screen off, or pocket the ph - **Android** gives you full audiobook controls on the lock screen and in the notification: **previous chapter**, a **draggable chapter scrubber**, **next chapter**, and **30-second skip back/forward** buttons - no need to unlock the phone to hop around a book. - **iOS** shows the book on the lock screen and in Control Centre with play/pause, a scrubber, and **skip back/forward** buttons that use the skip lengths from your in-app Settings. -Headphone and earbud buttons work everywhere, and the sleep timer can be cancelled with a **shake of the phone**. +Headphone and earbud buttons work everywhere. And in the [sleep timer](playback.md#the-last-30-seconds-and-how-to-keep-listening)'s last 30 seconds - or just after it has paused the book - a **shake of the phone** sets the timer again (bringing the volume back if it had started to fade) and resumes playback if it had stopped. ## Gapless, chapter-aware listening diff --git a/docs-users/listening/playback.md b/docs-users/listening/playback.md index d1d0c0d..a6e5a42 100644 --- a/docs-users/listening/playback.md +++ b/docs-users/listening/playback.md @@ -45,12 +45,56 @@ Tap the moon icon to set a sleep timer. You can stop: - at the **end of a chapter** - the current one or any of the next few; - at the **end of the book**, for books without chapters. -While a timer runs, a countdown shows on the moon icon and on the cover, and the sheet offers **Cancel timer**. When it fires, playback simply pauses - nothing is lost. In the mobile apps you can also **shake your phone** to cancel a running timer without looking. +While a timer runs, a countdown shows on the moon icon and on the cover, and the sheet offers **Cancel timer**. When it fires, playback simply pauses - nothing is lost. + +A timer belongs to the book you set it on. Start a different book, or finish the one you're on, and the timer goes with it instead of following you to the next book. And if a timer reaches its point when you'd already paused by hand, it just ends quietly: there was nothing left to stop. :::tip The countdown is real ("wall clock") time: listening at 2× speed, an end-of-chapter timer shows how long the chapter actually takes to reach at that speed. ::: +### Pausing with a timer running + +**A set-time timer pauses with the book.** Pause, and the countdown stops where it is; press play and it carries on from there. Thirty minutes means thirty minutes of *listening*, not thirty minutes on the clock - so answering the door, taking a call, or pausing with a headphone button without ever looking at the screen can't quietly use up a timer you were relying on. It works the same way if you arm a timer before you press play: the countdown starts when the audio does. + +**Come back after more than 20 minutes and the timer starts again at its full length.** A break that long is a new sitting rather than an interruption, and the alternative is worse: a timer frozen with three minutes left, forgotten about, stopping you three minutes into the next evening's listening. Under 20 minutes it simply picks up where it left off. There's nothing to configure. + +An **end-of-chapter** timer needs none of this: it stops at a place in the book, not after an amount of time, so it waits exactly where you left it however long you're away, and still stops at the chapter you chose. + +If you pause during a set-time timer's fade-out (the last 30 seconds, below), the volume comes straight back up so the book is never left sounding quiet, and the fade picks up where it stopped when you press play again. + +### The last 30 seconds, and how to keep listening + +Every timer has a **final 30 seconds** that are your cue it's about to stop - and your chance to carry on if you're still awake. The badge on the cover turns solid pink for them, and the sheet shows the seconds left. + +What you hear in that window depends on the kind of timer: + +- A **set-time** timer stops at an arbitrary point mid-chapter, so it doesn't cut the book off mid-word: the audio **fades out gently** over those 30 seconds. The sheet shows *Fading out*. +- An **end-of-chapter** timer (and an end-of-book one) plays those 30 seconds at **full volume** and stops at the boundary. Those closing words are the ones you stayed awake for, and the chapter ending is its own signal that the book is about to stop. The sheet shows *Ending soon*. + +During that final window, **and for 30 seconds after playback has paused**, one gesture keeps you going: + +- **Shake your phone** - in the iOS and Android apps. +- **Tap "Keep listening"** - open the sleep timer from the moon icon and tap the pink **Keep listening** button. This works everywhere, and it's the only way in the web player, because browsers can't feel the phone move. + +Either one brings the volume straight back up (if it had started to fade) and **starts the timer again at the setting you chose**: a 30-minute timer becomes a fresh 30 minutes, and an end-of-chapter timer moves its target to the end of the **next** chapter. If playback had already stopped, it starts playing again too - so you never have to unlock the phone to rescue a book you were still listening to. + +Once those 30 seconds are up, the timer is finished and the book stays paused where it was. Shaking the phone at any other time does nothing, so a bump in your pocket can't disturb a running timer. + +:::note +Safari on iPhone and iPad doesn't let a web page change its own volume, so in the **web player on those devices** a set-time timer's last 30 seconds don't audibly fade. Everything else works as described: the badge, the countdown, and the **Keep listening** button. (An end-of-chapter timer never fades anywhere, so it behaves identically on every platform.) +::: + +### Starting a timer automatically at night + +If you listen yourself to sleep most nights, you can have AudioSilo arm the timer for you instead of remembering to. Turn on **Auto sleep timer** in [Settings](account.md#sleep-timer), choose the hours it applies to (10:00 PM to 6:00 AM out of the box) and what kind of timer it should set. Any book you start inside that window gets one automatically. + +It's deliberately unobtrusive: never more than one timer at a time, and never on top of a timer you set yourself. + +**If a timer runs out and you press play again inside the window, you get a fresh one.** Starting the book again at 3:00 AM is you saying you're still listening, so the night's remaining hours are covered too rather than leaving you unprotected after the first timer. + +**Cancel a timer and that's final.** Nothing will arm another one for that book for the rest of the session (until you next start the app), because a timer you've just dismissed coming quietly back is the last thing you want. That holds for the timers you set by hand as well: cancelling your own keeps the automatic one away too, while letting your own run out doesn't. + ## Bookmarks, notes, and history - **Bookmarks** - tap the bookmark icon, then **Add bookmark at 1:23:45** to pin the current moment. Bookmarks are listed there and on the book's detail page; tap one to jump back.