Skip to content

Reader settings that can be pinned to a source - #95

Merged
AngeloSha merged 16 commits into
AngeloSha:mainfrom
Squeaks72:pr/per-source-reader-prefs
Sep 25, 2026
Merged

AngeloSha merged 16 commits into
AngeloSha:mainfrom
Squeaks72:pr/per-source-reader-prefs

Conversation

@Squeaks72

@Squeaks72 Squeaks72 commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Summary

The reader has one global default (mode, theme, two-page spread) and a per-series memory. A source is usually one format, though. A webtoon site wants the continuous vertical scroll, and a manga site wants paged right-to-left. So the global default is wrong for part of the library, and the only fix was to adjust each of those series by hand.

This PR adds a default per source, resolved global < source < series:

  • Resolution (web/app/reader/page.tsx): the per-series effect now layers loadSourcePrefs(<source of the opened chapter>) under loadSeriesPrefs(seriesId). A series you adjusted by hand still wins.
  • Storage and sync (web/lib/readerPrefs.ts): loadSourcePrefs / saveSourcePrefs / clearSourcePrefs, stored in localStorage and synced in the existing settings blob as readerSource. Both directions work: the debounced PUT /api/settings, and syncPrefsFromServer adopting what another device saved. The server needs no change, because PUT /api/settings already merges arbitrary top-level keys.
  • Key prefix yomi_rp_, not yomi_rs_. allSeriesPrefs() collects every key under yomi_rs_ and pushes each one as readerSeries[id]. A source default stored under that prefix would go to every other device as a series override keyed by a source id, and the source default itself would never sync. The new test pins this.
  • UI (web/components/ReaderSettings.tsx): at the bottom of the reader's settings sheet, Use this reader for everything from {source} saves the current mode, theme and spread. Forget the default for {source} clears it. The row is hidden when the chapter has no source on record, which is the case for a downloaded copy opened offline.

Known limit, the same as the per-series memory it sits beside: forgetting a default on one device removes it from the account on the next push. Another device that still has it locally will push it back the next time that device saves a reader setting.

Type

  • Fix
  • Feature
  • Docs
  • Refactor / chore

Testing

Re-run after rebasing onto v0.45.0 (91b41aa): bff 1857 tests, 1843 pass, 10 fail, 4 skipped — the 9 embedded-Postgres entrypoint failures main has in this environment (main: 1857 / 1844 / 9 / 4) plus the one flaky test explained at the end of this paragraph; web 434/434; both typechecks OK. The detail below is from the first run, on v0.44.0. One extra bff failure in that run, "the verify button answers started and the panel shows the run" (verifyFiles.int.test.ts), came from running seven suites in parallel: this PR changes no bff code, and that file then passed 14/14 three times in a row on this branch and on main.

I ran the upstream CI setup in node:24-alpine against a real postgres:16-alpine (TEST_DATABASE_URL set, so the SQL tests run instead of skipping), first on plain upstream/main (a8bc502) to get a baseline and then on this branch.

upstream/main (baseline) this branch
bff tsc --noEmit OK OK
bff tests 1834 tests, 1821 pass, 9 fail, 4 skipped 1834 tests, 1821 pass, same 9 fail, 4 skipped
web tsc --noEmit OK OK
web tests 421/421 424/424 (3 new)

The 9 bff failures are the same on both: they are the embedded-Postgres entrypoint tests (not ok 291-299), which need an initdb/socket setup this sandbox doesn't have. No new failures.

New test web/test/readerSourcePrefs.test.ts (3 tests) drives the real module against a stubbed server and reads the PUT bodies:

  • a source default is pushed as readerSource and never shows up in readerSeries. This fails if SOURCE_KEY is set to yomi_rs_, which I checked.
  • forgetting a default removes it locally and from the next push
  • a pull adopts source defaults saved on another device, and not as series overrides

The three new strings are translated in all eight locale files, and _meta.strings is bumped. npm run test:i18n is a Puppeteer walk against a running server, so it could not run in this sandbox. I checked separately that every tr() key this PR adds is present in all eight files.

Checklist

  • Focused scope (one topic)
  • Matches the existing TypeScript style
  • Docs updated if user-facing (docs/USAGE.md)
  • No new default sources hardcoded to specific sites (the engine system reaches sites by URL)

🤖 Generated with Claude Code

Squeaks72 and others added 16 commits September 24, 2026 10:27
The keydown handler had no ArrowLeft/ArrowRight cases at all, so in paged
mode arrows fell through to the browser's native scrolling of the snap-x
track: the first press nudged it a few pixels and the snap pulled it back,
so it took two presses to land on the next page. Bind them (plus PageUp/
PageDown/Space) to the same scrollBy the tap zones use, with preventDefault
so the native scroll stops competing.

A focused form control (the page slider, the settings sheet) keeps its own
arrow keys and Space in both modes, so it can still be nudged by keyboard.
USAGE.md lists the reader's keys.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A term submitted with a source chosen now asks only that source (&source= on
search-all) instead of fanning out to every one and answering with everything,
and the picker stays on screen so the filter is visible and clearable rather
than silently narrowing the results.

`source` only narrows the route's existing surfaceable() set, so an adult
source without adult=1, one above the account's age limit, or an unknown id
asks nobody and answers the empty shape. The search query's key now includes
the chosen source; discoverSearch.test.ts and discoverAdult.test.ts pin the
new key and the picker being mounted in both modes, and searchAll.int.test.ts
covers the narrowed fan-out. Documented in openapi.yaml, docs/api.md and
USAGE.md.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Upstream's reader already knows a series' readingDirection (the `rtl`
state, from the series or the downloaded record) but used it only to flip
the order of a double spread; the paged track always ran left to right.

New reader pref `pagedDirection` (series | ltr | rtl, default series).
With `series`, a RIGHT_TO_LEFT series lays paged mode's track out
right-to-left: the next page is to the left (swipe right, tap the left
edge, ←), and spreads put their first page on the right. Every other
series reads exactly as before -- including every series on the built-in
library, which reports WEBTOON. `ltr`/`rtl` override it for all series.
Toggle in the reader sheet and Profile → Reading.

The track now always states its `dir`. Under the Arabic UI it used to
inherit dir="rtl" from <html>, and the positive-scrollLeft maths left the
page counter on 1 and clamped a jump or resume to the first page.

On a right-to-left track the footer row takes dir="rtl": the page slider
fills and drags from the right, matching the page track, and
previous/next chapter swap sides with their chevrons swapped to keep
pointing outwards. The page counter stays LTR.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A source is the closest thing the app has to a format: a webtoon source wants
continuous vertical scroll, a manga source wants paged right-to-left. One
global default was wrong for half the library and the alternative was fixing
every series by hand.

Reader prefs now resolve global < source < series, and the settings sheet can
pin the current look to the chapter's source (or forget it again). Stored and
synced beside the per-series memory, under a key prefix that cannot be picked
up by the per-series collector.

Tests pin the prefix, the forget path and the pull; the three new strings are
translated in all eight locales, and USAGE.md describes the button.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…t Continue

Chapter list
- The series page and the reader asked the books route for one page of
  1000, so One Piece's chapters 1001+ never appeared in the list or in the
  reader's prev/next/jump list. lib/seriesBooks.ts fetchAllBooks() walks
  every page (tested). The smart-offline planner had the same cap
  server-side.
- The list now renders 100 rows a page (lib/chapterPages.ts, tested) with
  a pager above and below (first/prev/range picker/next/last). It opens on
  the page holding the "Continue" chapter; a tap on the pager pins a page,
  and a new series, sort, or filter follows Continue again. A chapter chip
  in Sources & translations turns to the page holding that row before it
  scrolls to it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…per book

On top of @Squeaks72's paging, which fixes a real bug on main -- Continue opened chapter 1 for anyone past
chapter 1000:

- The picker named pages by row position. Ghost, run and "show all" rows are rows but not chapters, and
  newest first labelled the page holding 1193-1094 "1-100". Pages are now named by the first and last chapter
  numbers they show, in the list's own order, and the count beside it is chapters.
- The page followed Continue live, so expanding an older-chapters run, "Show all" or "Mark all read" flipped
  the list away from what the reader was doing. It is decided once per series, sort and filter, once the
  chapters and the sources' listing (whose ghost rows would shift it) have both arrived.
- A scan between the two requests could hand the same book back at the 1000 boundary: two rows, one React key.
  Kept once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ns keep their direction

On top of @Squeaks72's right-to-left paging, which also fixes a bug on main (under the Arabic interface the
paged track inherited RTL and the page counter stuck on page 1):

- Resume and `?page=` links opened a right-to-left series on page 1. A streamed series learns its direction in
  the same tick it becomes ready, so the new flip effect ran right after the resume scroll with `current` still
  0 in its closure. It now acts only on a real flip, tracked by the direction the track was last laid out for.
- In paged mode the bottom bar follows the track both ways. On a left-to-right read under the Arabic interface
  it inherited RTL and ran opposite to its own pages.
- Text inside the track (the missing-page caption, the Up Next and failure cards) took the track's direction:
  Arabic in an LTR paragraph, or English in RTL on a right-to-left read. It keeps the interface's, via useRtl().
- A double-tap zoom in paged mode centred scrollLeft, which there IS the page: it jumped to mid-chapter (page 1
  on a right-to-left track). Centring is for the webtoon column only.
- A right-to-left series forced to read left to right still swaps its spreads, on purpose: a double-page spread
  is one drawing and only reassembles with its first page on the right. Said so in the code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…keys leave sheets and controls alone

On top of @Squeaks72's paged-mode keys:

- Steps are computed from the slide on screen, not "one width from wherever the scroll is": scrollBy stacked on
  a smooth scroll still in flight, so two quick presses or a held key landed one or two pages on.
- A key with Alt, Ctrl or Meta is never the reader's. Alt+Left (browser Back) turned a page, and Ctrl+F opened
  the browser's Find AND toggled fullscreen -- the second one was already on main.
- With a sheet open (settings, page grid, chapter list) keys no longer act underneath it, and Escape closes the
  sheet instead of leaving the reader; none of the three listens for Escape itself.
- A focused button or link keeps Space and Enter: Space on a chapter row turned the page and swallowed the click.
- Shift+Space goes back a page, in both modes.
- On a right-to-left read (with the paged-direction change beneath this) Left and Right stay physical, like the
  tap zones, while Space, PageDown and Down mean "next" whichever way the pages run.
- The page slider lets go of focus after a drag, so in the webtoon column Space scrolls again straight away.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	web/public/locales/ar.json
#	web/public/locales/de.json
#	web/public/locales/es.json
#	web/public/locales/fr.json
#	web/public/locales/ja.json
#	web/public/locales/pt-BR.json
#	web/public/locales/ru.json
#	web/public/locales/zh.json
…aks into the global one

On top of @Squeaks72's per-source reader settings:

- Choosing a look in the reader also saved it as the GLOBAL default: setPref saved the reader's live prefs
  wholesale, and those are the default with the source's and the series' settings laid over it. So "switch to
  paged, pin this source" left every other source opening paged too, and even a brightness change saved the
  source's look as everyone's. A change made with a title open now writes its look to that title only, and
  only what it touched to the global default, which is set under Profile -> Settings. globalPrefsChange() is
  the rule, and it has a test.
- The default never applied to a downloaded chapter, online or off: the reader loads the offline record first
  and it names no source, so `chapters[0].sourceId` was empty. A series mixing copies from several sources
  flipped its look mid-read. It is keyed by the series' primary source now, learned when the series loads
  and remembered on the device for opening it offline.
- The row could show a nineteen-digit extension id for a name. With no name there is no row.
- The reading direction from the paged-direction change beneath this is part of the remembered look, per
  series and per source ("this manga site: paged, right to left" can now be pinned).
- The settings sheet is capped at 85dvh and scrolls: with the two new rows it pushed its top off a short phone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tings sheet speaks every language

Two bugs found while reviewing the reader pull requests, both already on main:

- In paged mode with the default "Collapse", a repeated page got a slide of its own that never rendered: the
  render window skips collapsed pages, and a paged track has no thin band to draw one as. The reader saw a
  blank screen with a faint page number. There, Collapse now shows the page like any other -- one swipe.
  Not removed, although the sheet's comment said so: removing would give the two modes different page orders,
  and nothing re-anchors the current page when the flow is rebuilt, so switching mode mid-chapter would land on
  another page. The comment now says what happens.
- The reader settings sheet hard-coded English: Mode, Theme, Fit, Pages per view, Brightness, Page gap,
  Auto-scroll, the mode buttons and the theme names -- every one of them already translated in all eight
  locale files, just never asked for. The CSS `capitalize` that dressed up the raw theme ids is gone with them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ranch

The PRs merge in order, so each branch carries the ones before it and merges without conflicts.
The PRs merge in order, so each branch carries the ones before it and merges without conflicts.

# Conflicts:
#	web/public/locales/ar.json
#	web/public/locales/de.json
#	web/public/locales/es.json
#	web/public/locales/fr.json
#	web/public/locales/ja.json
#	web/public/locales/pt-BR.json
#	web/public/locales/ru.json
#	web/public/locales/zh.json
The PRs merge in order, so each branch carries the ones before it and merges without conflicts.
The PRs merge in order, so each branch carries the ones before it and merges without conflicts.

# Conflicts:
#	web/public/locales/ar.json
#	web/public/locales/de.json
#	web/public/locales/es.json
#	web/public/locales/fr.json
#	web/public/locales/ja.json
#	web/public/locales/pt-BR.json
#	web/public/locales/ru.json
#	web/public/locales/zh.json
AngeloSha added a commit to Squeaks72/uchiyomi that referenced this pull request Sep 25, 2026
The PRs merge in order, so each branch carries the ones before it and merges without conflicts.

# Conflicts:
#	web/public/locales/ar.json
#	web/public/locales/de.json
#	web/public/locales/es.json
#	web/public/locales/fr.json
#	web/public/locales/ja.json
#	web/public/locales/pt-BR.json
#	web/public/locales/ru.json
#	web/public/locales/zh.json
@AngeloSha
AngeloSha merged commit ff9fb69 into AngeloSha:main Sep 25, 2026
12 checks passed
AngeloSha added a commit that referenced this pull request Sep 25, 2026
… extension engine

Right-to-left paging (#90), one key per page (#92), reader settings pinned to
a source (#95), type-to-search (#94), chapter names (#84), a chapter list that
pages past chapter 1000 (#89), Discover keeping its source through a search
(#87) and an 18+ filter an admin can widen (#86) -- seven merged with fixes on
top, one as it was. The extension engine (Suwayomi-Server, headless) gets the
desktop app's memory cap in every compose file (#72).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@AngeloSha

Copy link
Copy Markdown
Owner

Thanks, the source really is the best signal for the format, and this is a nice way to use it.

Fixes on top:

  • Choosing a look in the reader was also saving it as the global default, because the sheet saved the reader's live settings wholesale, source and series overrides included. So "switch to paged, pin this source" left every other source opening paged as well. Now mode, theme, spread and direction changed with a title open go to that title only, and anything else you change there (brightness, gap and so on) saves just that one setting as the default.
  • It never applied to downloaded chapters, online or offline, because the reader loads the offline record first and that record has no source. It's keyed by the series' main source now, remembered on the device for offline use. That also stops a series that mixes sources from flipping its look mid-read.
  • The row could show a 19-digit extension id instead of a name. With no name there's no row.
  • The reading direction from Reader: paged mode reads right to left when the series does #90 is part of what gets pinned, and the settings sheet scrolls now instead of pushing its top off a short phone.

Merged, and it shipped in v0.46.0.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants