diff --git a/agent-docs/reading-stats.md b/agent-docs/reading-stats.md index 0303d48d72c..e227fb5a163 100644 --- a/agent-docs/reading-stats.md +++ b/agent-docs/reading-stats.md @@ -29,6 +29,21 @@ metrics. - When changing export/import schema, preserve backward compatibility or add a clear migration path. +## Chapter time remaining (status bar) + +- Rate is live-session only (`getEffectivePagesPerMs`): no active session ⇒ no ETA. +- Credits 1 page per qualified dwell sample (EPUB and TXT); ETA = remaining pages ÷ pages/ms. +- Gate: ≥3 paired pages and ≥60s paired dwell ms. +- Remaining pages use the same page-count model as the status bar (EPUB + `estimatedTotalPages`, TXT `totalPages`), inclusive of the current page. +- `STR_ETA_UNIT_MINUTE` / `_HOUR` / `_DAY` / `_YEAR` exist in EN+ES only; other + locales fall back to English. +- The Pages + Time setting label is composed via `CrossPointSettings::formatChapterProgressLabel` + (`STR_PAGES + ' + ' + STR_TIME`); always use that helper for enum display. +- XTC has no chapter ETA (bitmap pages, no status-bar chapter-time slot). +- Older `totalWordsRead` / `totalWordsReadingMs` samples are ignored; page-rate + fields start fresh. + ## Design Rules - Do not save stats on every tiny interaction. Debounce or save on activity exit diff --git a/docs/reading-stats-editor/index.html b/docs/reading-stats-editor/index.html index b24329b304b..38ccb1ca621 100644 --- a/docs/reading-stats-editor/index.html +++ b/docs/reading-stats-editor/index.html @@ -736,6 +736,13 @@ grid-column: 1 / -1; } + .field .hint { + margin: 0; + color: var(--muted); + font-size: 12px; + line-height: 1.45; + } + .field label { display: block; margin-bottom: 5px; @@ -1384,6 +1391,9 @@

CPR-vCodex Reading Stats Editor

coverBmpPath: "Cover BMP path", chapterTitle: "Chapter title", totalReadingMinutes: "Total reading minutes", + totalPagesRead: "Pages read (ETA samples)", + pagesReadingMinutes: "Minutes paired with pages (ETA)", + pagesRateHint: "ETA rate uses pages ÷ paired minutes (fractional minutes OK). Unpaired pages are cleared on import when paired minutes are truly zero. Recalculate from days only updates total reading minutes.", lastSessionMinutes: "Last session minutes", bookProgress: "Book progress %", chapterProgress: "Chapter progress %", @@ -1458,6 +1468,9 @@

CPR-vCodex Reading Stats Editor

coverBmpPath: "Ruta de portada BMP", chapterTitle: "Título de capítulo", totalReadingMinutes: "Minutos totales de lectura", + totalPagesRead: "Páginas leídas (muestras ETA)", + pagesReadingMinutes: "Minutos emparejados con páginas (ETA)", + pagesRateHint: "La tasa ETA usa páginas ÷ minutos emparejados (se permiten fracciones). Las páginas sin emparejar se borran al importar si los minutos emparejados son realmente cero. Recalcular desde días solo actualiza los minutos totales de lectura.", lastSessionMinutes: "Minutos de la última sesión", bookProgress: "Progreso del libro %", chapterProgress: "Progreso del capítulo %", @@ -3237,8 +3250,16 @@

CPR-vCodex Reading Stats Editor

return Math.round(toUInt(ms) / MS_PER_MINUTE); } + // Fractional minutes for ETA paired-ms editing (avoids wiping sub-30s samples via round-to-0). + function msToExactMinutes(ms) { + const value = toUInt(ms) / MS_PER_MINUTE; + return Number(value.toFixed(4)); + } + function minutesToMs(minutes) { - return toUInt(minutes) * MS_PER_MINUTE; + const value = Number(minutes); + if (!Number.isFinite(value) || value <= 0) return 0; + return Math.round(value * MS_PER_MINUTE); } function formatDuration(ms) { @@ -3366,6 +3387,8 @@

CPR-vCodex Reading Stats Editor

chapterTitle: String(book.chapterTitle || ""), readingDays: readingDays.map(normalizeDay).filter(day => day.dayOrdinal && day.readingMs), totalReadingMs: toUInt(book.totalReadingMs), + totalPagesReadingMs: toUInt(book.totalPagesReadingMs), + totalPagesRead: toUInt(book.totalPagesRead), sessions: toUInt(book.sessions), lastSessionMs: toUInt(book.lastSessionMs), firstReadAt: toUInt(book.firstReadAt), @@ -3375,6 +3398,10 @@

CPR-vCodex Reading Stats Editor

chapterProgressPercent: clampPercent(book.chapterProgressPercent), completed: Boolean(book.completed) }; + // Match firmware: unpaired page samples must not skew ETA rate. + if (!normalized.totalPagesReadingMs && normalized.totalPagesRead) { + normalized.totalPagesRead = 0; + } if (!normalized.bookId) normalized.bookId = normalized.path; if (!normalized.knownPaths.includes(normalized.path) && normalized.path) { normalized.knownPaths.unshift(normalized.path); @@ -3492,6 +3519,8 @@

CPR-vCodex Reading Stats Editor

chapterTitle: "", readingDays: [], totalReadingMs: 0, + totalPagesReadingMs: 0, + totalPagesRead: 0, sessions: 0, lastSessionMs: 0, firstReadAt: 0, @@ -3829,6 +3858,9 @@

${escapeHtml(t("monthlyReading"))}

${textField("coverBmpPath", t("coverBmpPath"), book.coverBmpPath)} ${textField("chapterTitle", t("chapterTitle"), book.chapterTitle)} ${numberField("totalReadingMinutes", t("totalReadingMinutes"), msToMinutes(book.totalReadingMs))} + ${numberField("totalPagesRead", t("totalPagesRead"), book.totalPagesRead)} + ${numberField("pagesReadingMinutes", t("pagesReadingMinutes"), msToExactMinutes(book.totalPagesReadingMs), "0.01")} +

${escapeHtml(t("pagesRateHint"))}

${numberField("sessions", t("sessions"), book.sessions)} ${numberField("lastSessionMinutes", t("lastSessionMinutes"), msToMinutes(book.lastSessionMs))} ${numberField("lastProgressPercent", t("bookProgress"), book.lastProgressPercent)} @@ -3877,6 +3909,11 @@

${escapeHtml(t("monthlyReading"))}

book.coverBmpPath = value("coverBmpPath"); book.chapterTitle = value("chapterTitle"); book.totalReadingMs = minutesToMs(value("totalReadingMinutes")); + book.totalPagesRead = toUInt(value("totalPagesRead")); + book.totalPagesReadingMs = minutesToMs(value("pagesReadingMinutes")); + if (!book.totalPagesReadingMs && book.totalPagesRead) { + book.totalPagesRead = 0; + } book.sessions = toUInt(value("sessions")); book.lastSessionMs = minutesToMs(value("lastSessionMinutes")); book.lastProgressPercent = clampPercent(value("lastProgressPercent")); @@ -3994,11 +4031,11 @@

${escapeHtml(t("monthlyReading"))}

`; } - function numberField(id, label, currentValue) { + function numberField(id, label, currentValue, step = "1") { return `
- +
`; } diff --git a/lib/Epub/Epub/Section.cpp b/lib/Epub/Epub/Section.cpp index 4186028af5b..f72ce47620e 100644 --- a/lib/Epub/Epub/Section.cpp +++ b/lib/Epub/Epub/Section.cpp @@ -22,6 +22,8 @@ namespace { // first render). // v40: progressive/partial cache, with vCodex ruby blocks, paragraph/list // mapping and XHTML byte offsets retained. +// (A transient v41 word-count LUT was explored for chapter ETA then abandoned; +// page-rate ETA needs no section format change, so the on-disk layout stays v40.) constexpr uint8_t SECTION_FILE_VERSION = 40; // Written into the version field while a build is in progress; patched to // SECTION_FILE_VERSION only when the build is finalized. An abandoned / @@ -171,12 +173,16 @@ bool Section::loadSectionFile(const ReaderRenderSpec& spec) { serialization::readPod(file, pageCount); + // One seek for the li LUT offset (also locates the partial watermark trailer). + uint32_t liLutOffset = 0; + if (pageCount > 0 || filePartial) { + file.seek(HEADER_SIZE - sizeof(uint32_t)); + serialization::readPod(file, liLutOffset); + } + if (filePartial) { // A partial's pageCount is the watermark of a suspended build. Read the watermark // trailer (appended after the li LUT) so estimatedTotalPages can extrapolate. - uint32_t liLutOffset = 0; - file.seek(HEADER_SIZE - sizeof(uint32_t)); - serialization::readPod(file, liLutOffset); const uint32_t trailerOffset = liLutOffset + static_cast(pageCount) * sizeof(uint16_t); const bool trailerValid = pageCount > 0 && liLutOffset >= HEADER_SIZE && trailerOffset + 2 * sizeof(uint32_t) <= file.size(); diff --git a/lib/I18n/translations/belarusian.yaml b/lib/I18n/translations/belarusian.yaml index a0d8028a3be..99a547ccbf8 100644 --- a/lib/I18n/translations/belarusian.yaml +++ b/lib/I18n/translations/belarusian.yaml @@ -270,7 +270,6 @@ STR_SLEEP_COVER_FILTER: "Фільтр экрана сну" STR_SET_SLEEP_COVER: "Усталяваць вокладку" STR_FILTER_CONTRAST: "Кантраст" STR_CUSTOMISE_STATUS_BAR: "Наладзіць радок стану" -STR_CHAPTER_PAGE_COUNT: "Колькасць старонак раздзела" STR_BOOK_PROGRESS_PERCENTAGE: "Працэнт прагрэсу кнігі" STR_PROGRESS_BAR: "Паласа прагрэсу" STR_PROGRESS_BAR_THICKNESS: "Таўшчыня паласы прагрэсу" diff --git a/lib/I18n/translations/catalan.yaml b/lib/I18n/translations/catalan.yaml index 22227308117..25cf6f9c12c 100644 --- a/lib/I18n/translations/catalan.yaml +++ b/lib/I18n/translations/catalan.yaml @@ -270,7 +270,6 @@ STR_SLEEP_COVER_FILTER: "Filtre de pantalla de repòs" STR_SET_SLEEP_COVER: "Estableix portada" STR_FILTER_CONTRAST: "Contrast" STR_CUSTOMISE_STATUS_BAR: "Personalitza la barra d'estat" -STR_CHAPTER_PAGE_COUNT: "Comptador de pàgines del capítol" STR_BOOK_PROGRESS_PERCENTAGE: "Percentatge de progrés del llibre" STR_PROGRESS_BAR: "Barra de progrés" STR_PROGRESS_BAR_THICKNESS: "Gruix de la barra de progrés" diff --git a/lib/I18n/translations/czech.yaml b/lib/I18n/translations/czech.yaml index ba6626371c4..d74459a8dc2 100644 --- a/lib/I18n/translations/czech.yaml +++ b/lib/I18n/translations/czech.yaml @@ -270,7 +270,6 @@ STR_SLEEP_COVER_FILTER: "Filtr obrazovky spánku" STR_SET_SLEEP_COVER: "Nastavit obálku" STR_FILTER_CONTRAST: "Kontrast" STR_CUSTOMISE_STATUS_BAR: "Přizpůsobit stavový řádek" -STR_CHAPTER_PAGE_COUNT: "Počet stránek kapitoly" STR_BOOK_PROGRESS_PERCENTAGE: "Procento průběhu knihy" STR_PROGRESS_BAR: "Ukazatel průběhu" STR_PROGRESS_BAR_THICKNESS: "Tloušťka ukazatele průběhu" diff --git a/lib/I18n/translations/danish.yaml b/lib/I18n/translations/danish.yaml index 08fadc1b443..7d6925c8510 100644 --- a/lib/I18n/translations/danish.yaml +++ b/lib/I18n/translations/danish.yaml @@ -270,7 +270,6 @@ STR_SLEEP_COVER_FILTER: "Hvile-skærm omslag-filter" STR_SET_SLEEP_COVER: "Angiv omslag" STR_FILTER_CONTRAST: "Kontrast" STR_CUSTOMISE_STATUS_BAR: "Tilpas statuslinje" -STR_CHAPTER_PAGE_COUNT: "Kapitelsidetæller" STR_BOOK_PROGRESS_PERCENTAGE: "Bogfremskridtsprocent" STR_PROGRESS_BAR: "Fremskridtslinje" STR_PROGRESS_BAR_THICKNESS: "Fremskridtslinjens tykkelse" diff --git a/lib/I18n/translations/dutch.yaml b/lib/I18n/translations/dutch.yaml index c2a7ca8a05e..b48d629bb76 100644 --- a/lib/I18n/translations/dutch.yaml +++ b/lib/I18n/translations/dutch.yaml @@ -270,7 +270,6 @@ STR_SLEEP_COVER_FILTER: "Slaapscherm omslag-filter" STR_SET_SLEEP_COVER: "Omslag instellen" STR_FILTER_CONTRAST: "Contrast" STR_CUSTOMISE_STATUS_BAR: "Statusbalk aanpassen" -STR_CHAPTER_PAGE_COUNT: "Paginanummering hoofdstuk" STR_BOOK_PROGRESS_PERCENTAGE: "Percentage voortgang boek" STR_PROGRESS_BAR: "Voortgangsbalk" STR_PROGRESS_BAR_THICKNESS: "Dikte voortgangsbalk" diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index 77a3dbdc3bd..ad7979c4eba 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -289,7 +289,13 @@ STR_SLEEP_COVER_FILTER: "Sleep Screen Cover Filter" STR_SET_SLEEP_COVER: "Set Cover" STR_FILTER_CONTRAST: "Contrast" STR_CUSTOMISE_STATUS_BAR: "Customise Status Bar" -STR_CHAPTER_PAGE_COUNT: "Chapter Page Count" +STR_STATUS_BAR_CHAPTER_PROGRESS: "Chapter Progress" +STR_PAGES: "Pages" +STR_TIME: "Time" +STR_ETA_UNIT_MINUTE: "m" +STR_ETA_UNIT_HOUR: "h" +STR_ETA_UNIT_DAY: "d" +STR_ETA_UNIT_YEAR: "y" STR_BOOK_PROGRESS_PERCENTAGE: "Book Progress Percentage" STR_PROGRESS_BAR: "Progress Bar" STR_PROGRESS_BAR_THICKNESS: "Progress Bar Thickness" diff --git a/lib/I18n/translations/finnish.yaml b/lib/I18n/translations/finnish.yaml index 95ed5757b70..e17820e8c51 100644 --- a/lib/I18n/translations/finnish.yaml +++ b/lib/I18n/translations/finnish.yaml @@ -270,7 +270,6 @@ STR_SLEEP_COVER_FILTER: "Lepotilanäytön kansisuodatin" STR_SET_SLEEP_COVER: "Aseta kansi" STR_FILTER_CONTRAST: "Kontrasti" STR_CUSTOMISE_STATUS_BAR: "Mukauta tilapalkkia" -STR_CHAPTER_PAGE_COUNT: "Luvun sivumäärä" STR_BOOK_PROGRESS_PERCENTAGE: "Kirjan edistymisprosentti" STR_PROGRESS_BAR: "Edistymispalkki" STR_PROGRESS_BAR_THICKNESS: "Edistymispalkin paksuus" diff --git a/lib/I18n/translations/french.yaml b/lib/I18n/translations/french.yaml index 73724edfd27..81ee7fe865f 100644 --- a/lib/I18n/translations/french.yaml +++ b/lib/I18n/translations/french.yaml @@ -270,7 +270,6 @@ STR_SLEEP_COVER_FILTER: "Filtre écran de veille" STR_SET_SLEEP_COVER: "Définir la couverture" STR_FILTER_CONTRAST: "Contraste" STR_CUSTOMISE_STATUS_BAR: "Personnaliser la barre d'état" -STR_CHAPTER_PAGE_COUNT: "Nombre de pages du chapitre" STR_BOOK_PROGRESS_PERCENTAGE: "Pourcentage de progression" STR_PROGRESS_BAR: "Barre de progression" STR_PROGRESS_BAR_THICKNESS: "Épaisseur de la barre" diff --git a/lib/I18n/translations/german.yaml b/lib/I18n/translations/german.yaml index b24b0e18463..ebeea66a8f9 100644 --- a/lib/I18n/translations/german.yaml +++ b/lib/I18n/translations/german.yaml @@ -270,7 +270,6 @@ STR_SLEEP_COVER_FILTER: "Standby-Coverfilter" STR_SET_SLEEP_COVER: "Cover festlegen" STR_FILTER_CONTRAST: "Kontrast" STR_CUSTOMISE_STATUS_BAR: "Statusleiste anpassen" -STR_CHAPTER_PAGE_COUNT: "Kapitel-Seitenanzahl" STR_BOOK_PROGRESS_PERCENTAGE: "Buchfortschritt in %" STR_PROGRESS_BAR: "Fortschrittsbalken" STR_PROGRESS_BAR_THICKNESS: "Balkenstärke" diff --git a/lib/I18n/translations/hungarian.yaml b/lib/I18n/translations/hungarian.yaml index 7660ba7b899..02839a1680d 100644 --- a/lib/I18n/translations/hungarian.yaml +++ b/lib/I18n/translations/hungarian.yaml @@ -270,7 +270,6 @@ STR_SLEEP_COVER_FILTER: "Alvásképernyő borítószűrő" STR_SET_SLEEP_COVER: "Borító beállítása" STR_FILTER_CONTRAST: "Kontraszt" STR_CUSTOMISE_STATUS_BAR: "Állapotsáv testreszabása" -STR_CHAPTER_PAGE_COUNT: "Fejezet oldalszám" STR_BOOK_PROGRESS_PERCENTAGE: "Könyv haladás százaléka" STR_PROGRESS_BAR: "Haladássáv" STR_PROGRESS_BAR_THICKNESS: "Haladássáv vastagsága" diff --git a/lib/I18n/translations/italian.yaml b/lib/I18n/translations/italian.yaml index c6d61c85e2c..250475f6445 100644 --- a/lib/I18n/translations/italian.yaml +++ b/lib/I18n/translations/italian.yaml @@ -270,7 +270,6 @@ STR_OK_BUTTON: "OK" STR_SLEEP_COVER_FILTER: "Filtro copertina" STR_FILTER_CONTRAST: "Contrasto" STR_CUSTOMISE_STATUS_BAR: "Personalizza la barra di stato" -STR_CHAPTER_PAGE_COUNT: "Conteggio pagine capitolo" STR_BOOK_PROGRESS_PERCENTAGE: "Percentuale di avanzamento del libro" STR_PROGRESS_BAR: "Barra di avanzamento" STR_PROGRESS_BAR_THICKNESS: "Spessore della barra di avanzamento" diff --git a/lib/I18n/translations/kazakh.yaml b/lib/I18n/translations/kazakh.yaml index a60063a5738..7ad5cc6c7a4 100644 --- a/lib/I18n/translations/kazakh.yaml +++ b/lib/I18n/translations/kazakh.yaml @@ -270,7 +270,6 @@ STR_SLEEP_COVER_FILTER: "Ұйқы экраны мұқаба сүзгісі" STR_SET_SLEEP_COVER: "Мұқабаны орнату" STR_FILTER_CONTRAST: "Контраст" STR_CUSTOMISE_STATUS_BAR: "Күй жолағын баптау" -STR_CHAPTER_PAGE_COUNT: "Тараудың бет саны" STR_BOOK_PROGRESS_PERCENTAGE: "Кітап үлгерімі пайызы" STR_PROGRESS_BAR: "Үлгерім жолағы" STR_PROGRESS_BAR_THICKNESS: "Үлгерім жолағының қалыңдығы" diff --git a/lib/I18n/translations/lithuanian.yaml b/lib/I18n/translations/lithuanian.yaml index de4328a9321..f4f567c50f3 100644 --- a/lib/I18n/translations/lithuanian.yaml +++ b/lib/I18n/translations/lithuanian.yaml @@ -270,7 +270,6 @@ STR_SLEEP_COVER_FILTER: "Viršelio filtras" STR_SET_SLEEP_COVER: "Nustatyti viršelį" STR_FILTER_CONTRAST: "Kontrastas" STR_CUSTOMISE_STATUS_BAR: "Būsenos juosta" -STR_CHAPTER_PAGE_COUNT: "Skyriaus psl." STR_BOOK_PROGRESS_PERCENTAGE: "Progresas %" STR_PROGRESS_BAR: "Progreso juosta" STR_PROGRESS_BAR_THICKNESS: "Juostos storis" diff --git a/lib/I18n/translations/polish.yaml b/lib/I18n/translations/polish.yaml index 9419dd387bc..c465fa653cc 100644 --- a/lib/I18n/translations/polish.yaml +++ b/lib/I18n/translations/polish.yaml @@ -270,7 +270,6 @@ STR_SLEEP_COVER_FILTER: "Filtr okładek wygaszacza" STR_SET_SLEEP_COVER: "Ustaw okładkę" STR_FILTER_CONTRAST: "Kontrast" STR_CUSTOMISE_STATUS_BAR: "Dostosowanie paska statusu" -STR_CHAPTER_PAGE_COUNT: "Strona rozdziału" STR_BOOK_PROGRESS_PERCENTAGE: "Postęp książki" STR_PROGRESS_BAR: "Pasek postępu" STR_PROGRESS_BAR_THICKNESS: "Grubość paska postępu" diff --git a/lib/I18n/translations/portuguese.yaml b/lib/I18n/translations/portuguese.yaml index 45929b8e991..52380fb8e28 100644 --- a/lib/I18n/translations/portuguese.yaml +++ b/lib/I18n/translations/portuguese.yaml @@ -270,7 +270,6 @@ STR_SLEEP_COVER_FILTER: "Filtro capa tela repouso" STR_SET_SLEEP_COVER: "Definir capa" STR_FILTER_CONTRAST: "Contraste" STR_CUSTOMISE_STATUS_BAR: "Personalizar barra de status" -STR_CHAPTER_PAGE_COUNT: "Contagem de páginas do capítulo" STR_BOOK_PROGRESS_PERCENTAGE: "Porcentagem de progresso do livro" STR_PROGRESS_BAR: "Barra de progresso" STR_PROGRESS_BAR_THICKNESS: "Espessura da barra de progresso" diff --git a/lib/I18n/translations/romanian.yaml b/lib/I18n/translations/romanian.yaml index 21039eb6291..fbd8ca3b850 100644 --- a/lib/I18n/translations/romanian.yaml +++ b/lib/I18n/translations/romanian.yaml @@ -270,7 +270,6 @@ STR_SLEEP_COVER_FILTER: "Filtru ecran de repaus" STR_SET_SLEEP_COVER: "Setează coperta" STR_FILTER_CONTRAST: "Contrast" STR_CUSTOMISE_STATUS_BAR: "Customizaţi bara de stare" -STR_CHAPTER_PAGE_COUNT: "Număr de pagini în capitol" STR_BOOK_PROGRESS_PERCENTAGE: "Progres carte procentual" STR_PROGRESS_BAR: "Bară de progres" STR_PROGRESS_BAR_THICKNESS: "Grosime bară de progres" diff --git a/lib/I18n/translations/russian.yaml b/lib/I18n/translations/russian.yaml index 88c62a719a4..6f67c2b23fc 100644 --- a/lib/I18n/translations/russian.yaml +++ b/lib/I18n/translations/russian.yaml @@ -270,7 +270,6 @@ STR_SLEEP_COVER_FILTER: "Фильтр экрана сна" STR_SET_SLEEP_COVER: "Установить обложку" STR_FILTER_CONTRAST: "Контраст" STR_CUSTOMISE_STATUS_BAR: "Настройка строки состояния" -STR_CHAPTER_PAGE_COUNT: "Количество страниц главы" STR_BOOK_PROGRESS_PERCENTAGE: "% прочтения книги" STR_PROGRESS_BAR: "Полоса прогресса" STR_PROGRESS_BAR_THICKNESS: "Толщина индикатора прогресса" diff --git a/lib/I18n/translations/slovenian.yaml b/lib/I18n/translations/slovenian.yaml index 6231c59a84d..dff7033a5cf 100644 --- a/lib/I18n/translations/slovenian.yaml +++ b/lib/I18n/translations/slovenian.yaml @@ -270,7 +270,6 @@ STR_SLEEP_COVER_FILTER: "Filter naslovnice v spanju" STR_SET_SLEEP_COVER: "Nastavi naslovnico" STR_FILTER_CONTRAST: "Kontrast" STR_CUSTOMISE_STATUS_BAR: "Prilagodi vrstico stanja" -STR_CHAPTER_PAGE_COUNT: "Število strani v poglavju" STR_BOOK_PROGRESS_PERCENTAGE: "Odstotek napredka v knjigi" STR_PROGRESS_BAR: "Vrstica napredka" STR_PROGRESS_BAR_THICKNESS: "Debelina vrstice napredka" diff --git a/lib/I18n/translations/spanish.yaml b/lib/I18n/translations/spanish.yaml index 2688a7b7a18..d89acfb7415 100644 --- a/lib/I18n/translations/spanish.yaml +++ b/lib/I18n/translations/spanish.yaml @@ -281,7 +281,13 @@ STR_SLEEP_COVER_FILTER: "Filtro de pantalla de suspensión" STR_SET_SLEEP_COVER: "Establecer portada" STR_FILTER_CONTRAST: "Contraste" STR_CUSTOMISE_STATUS_BAR: "Personalizar barra de estado" -STR_CHAPTER_PAGE_COUNT: "Contador de pág. por cap." +STR_STATUS_BAR_CHAPTER_PROGRESS: "Progreso del capítulo" +STR_PAGES: "Páginas" +STR_TIME: "Tiempo" +STR_ETA_UNIT_MINUTE: "m" +STR_ETA_UNIT_HOUR: "h" +STR_ETA_UNIT_DAY: "d" +STR_ETA_UNIT_YEAR: "y" STR_BOOK_PROGRESS_PERCENTAGE: "Porcentaje progreso libro" STR_PROGRESS_BAR: "Barra de progreso" STR_PROGRESS_BAR_THICKNESS: "Grosor de barra de progreso" diff --git a/lib/I18n/translations/swedish.yaml b/lib/I18n/translations/swedish.yaml index 3cb77ad2f23..2bc07e7d28b 100644 --- a/lib/I18n/translations/swedish.yaml +++ b/lib/I18n/translations/swedish.yaml @@ -270,7 +270,6 @@ STR_SLEEP_COVER_FILTER: "Viloskärmens omslagsfilter" STR_SET_SLEEP_COVER: "Ange omslag" STR_FILTER_CONTRAST: "Kontrast" STR_CUSTOMISE_STATUS_BAR: "Anpassa statusfält" -STR_CHAPTER_PAGE_COUNT: "Antal sidor i kapitel" STR_BOOK_PROGRESS_PERCENTAGE: "Procentuellt bokframsteg" STR_PROGRESS_BAR: "Framstegsindikator" STR_PROGRESS_BAR_THICKNESS: "Tjocklek på framstegsindikator" diff --git a/lib/I18n/translations/turkish.yaml b/lib/I18n/translations/turkish.yaml index 117e845dd75..6cd89ca6368 100644 --- a/lib/I18n/translations/turkish.yaml +++ b/lib/I18n/translations/turkish.yaml @@ -270,7 +270,6 @@ STR_SLEEP_COVER_FILTER: "Uyku Ekranı Kapak Filtresi" STR_SET_SLEEP_COVER: "Kapağı ayarla" STR_FILTER_CONTRAST: "Kontrast" STR_CUSTOMISE_STATUS_BAR: "Durum Çubuğunu Özelleştir" -STR_CHAPTER_PAGE_COUNT: "Bölüm Sayfa Sayısı" STR_BOOK_PROGRESS_PERCENTAGE: "Kitap İlerleme Yüzdesi" STR_PROGRESS_BAR: "İlerleme Çubuğu" STR_PROGRESS_BAR_THICKNESS: "İlerleme Çubuğu Kalınlığı" diff --git a/lib/I18n/translations/ukrainian.yaml b/lib/I18n/translations/ukrainian.yaml index 2a328337ec2..a57d0c94ff7 100644 --- a/lib/I18n/translations/ukrainian.yaml +++ b/lib/I18n/translations/ukrainian.yaml @@ -270,7 +270,6 @@ STR_SLEEP_COVER_FILTER: "????? ????????" STR_SET_SLEEP_COVER: "Встановити обкладинку" STR_FILTER_CONTRAST: "Контраст" STR_CUSTOMISE_STATUS_BAR: "Налаштувати рядок стану" -STR_CHAPTER_PAGE_COUNT: "????????? ???????? ? ???????" STR_BOOK_PROGRESS_PERCENTAGE: "Відсоток прочитаного" STR_PROGRESS_BAR: "Рядок прогресу" STR_PROGRESS_BAR_THICKNESS: "Товщина рядку прогресу" diff --git a/lib/I18n/translations/vietnamese.yaml b/lib/I18n/translations/vietnamese.yaml index 7aa1fca7b66..940965b3d04 100644 --- a/lib/I18n/translations/vietnamese.yaml +++ b/lib/I18n/translations/vietnamese.yaml @@ -270,7 +270,6 @@ STR_SLEEP_COVER_FILTER: "Bộ lọc bìa khi ngủ" STR_SET_SLEEP_COVER: "Đặt bìa" STR_FILTER_CONTRAST: "Tương phản" STR_CUSTOMISE_STATUS_BAR: "Tùy chỉnh thanh trạng thái" -STR_CHAPTER_PAGE_COUNT: "Số trang chương" STR_BOOK_PROGRESS_PERCENTAGE: "Phần trăm tiến trình sách" STR_PROGRESS_BAR: "Thanh tiến trình" STR_PROGRESS_BAR_THICKNESS: "Độ dày thanh tiến trình" diff --git a/src/CrossPointSettings.cpp b/src/CrossPointSettings.cpp index 9215937f191..03a90d16a7b 100644 --- a/src/CrossPointSettings.cpp +++ b/src/CrossPointSettings.cpp @@ -2,10 +2,12 @@ #include #include +#include #include #include #include +#include #include #include @@ -490,3 +492,28 @@ int CrossPointSettings::getReaderFontId() const { } } } + +bool CrossPointSettings::formatChapterProgressLabel(const uint8_t mode, char* buf, const size_t bufSize) { + if (!buf || bufSize == 0) { + return false; + } + switch (mode) { + case CHAPTER_PROGRESS_PAGES: { + const int written = snprintf(buf, bufSize, "%s", tr(STR_PAGES)); + return written > 0 && static_cast(written) < bufSize; + } + case CHAPTER_PROGRESS_PAGES_TIME: { + const int written = snprintf(buf, bufSize, "%s + %s", tr(STR_PAGES), tr(STR_TIME)); + return written > 0 && static_cast(written) < bufSize; + } + case CHAPTER_PROGRESS_TIME: { + const int written = snprintf(buf, bufSize, "%s", tr(STR_TIME)); + return written > 0 && static_cast(written) < bufSize; + } + case CHAPTER_PROGRESS_HIDE: + default: { + const int written = snprintf(buf, bufSize, "%s", tr(STR_HIDE)); + return written > 0 && static_cast(written) < bufSize; + } + } +} diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index 56791c91cfa..da9b998c9da 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -3,6 +3,7 @@ #include #include +#include #include class CrossPointSettings { @@ -56,6 +57,13 @@ class CrossPointSettings { HIDE_PROGRESS = 2, STATUS_BAR_PROGRESS_BAR_COUNT }; + enum STATUS_BAR_CHAPTER_PROGRESS { + CHAPTER_PROGRESS_PAGES = 0, + CHAPTER_PROGRESS_PAGES_TIME = 1, + CHAPTER_PROGRESS_TIME = 2, + CHAPTER_PROGRESS_HIDE = 3, + STATUS_BAR_CHAPTER_PROGRESS_COUNT + }; enum STATUS_BAR_PROGRESS_BAR_THICKNESS { PROGRESS_BAR_THIN = 0, PROGRESS_BAR_NORMAL = 1, @@ -261,7 +269,7 @@ class CrossPointSettings { uint8_t cleanSleepRefresh = 1; // Status bar settings (statusBar retained for migration only) uint8_t statusBar = FULL; - uint8_t statusBarChapterPageCount = 1; + uint8_t statusBarChapterProgress = CHAPTER_PROGRESS_PAGES; uint8_t statusBarBookProgressPercentage = 1; uint8_t statusBarProgressBar = HIDE_PROGRESS; uint8_t statusBarProgressBarThickness = PROGRESS_BAR_NORMAL; @@ -459,6 +467,15 @@ class CrossPointSettings { void normalizeDisplayDay(); int getRefreshFrequency() const; bool getForcedReaderRefreshMode(HalDisplay::RefreshMode& mode) const; + + // Chapter Progress setting: Pages+Time / Time modes want a chapter ETA slot. + bool statusBarWantsChapterTime() const { + return statusBarChapterProgress == CHAPTER_PROGRESS_PAGES_TIME || + statusBarChapterProgress == CHAPTER_PROGRESS_TIME; + } + // Label for statusBarChapterProgress enum index. Pages+Time is composed from + // STR_PAGES + ' + ' + STR_TIME (no dedicated i18n key). + static bool formatChapterProgressLabel(uint8_t mode, char* buf, size_t bufSize); }; // Helper macro to access settings diff --git a/src/JsonSettingsIO.cpp b/src/JsonSettingsIO.cpp index 44326d994e8..df6a0b507d2 100644 --- a/src/JsonSettingsIO.cpp +++ b/src/JsonSettingsIO.cpp @@ -309,35 +309,35 @@ void migrateLegacyStatsShortcut(CrossPointSettings& settings, const JsonDocument void applyLegacyStatusBarSettings(CrossPointSettings& settings) { switch (static_cast(settings.statusBar)) { case CrossPointSettings::NONE: - settings.statusBarChapterPageCount = 0; + settings.statusBarChapterProgress = CrossPointSettings::CHAPTER_PROGRESS_HIDE; settings.statusBarBookProgressPercentage = 0; settings.statusBarProgressBar = CrossPointSettings::HIDE_PROGRESS; settings.statusBarTitle = CrossPointSettings::HIDE_TITLE; settings.statusBarBattery = 0; break; case CrossPointSettings::NO_PROGRESS: - settings.statusBarChapterPageCount = 0; + settings.statusBarChapterProgress = CrossPointSettings::CHAPTER_PROGRESS_HIDE; settings.statusBarBookProgressPercentage = 0; settings.statusBarProgressBar = CrossPointSettings::HIDE_PROGRESS; settings.statusBarTitle = CrossPointSettings::CHAPTER_TITLE; settings.statusBarBattery = 1; break; case CrossPointSettings::BOOK_PROGRESS_BAR: - settings.statusBarChapterPageCount = 1; + settings.statusBarChapterProgress = CrossPointSettings::CHAPTER_PROGRESS_PAGES; settings.statusBarBookProgressPercentage = 0; settings.statusBarProgressBar = CrossPointSettings::BOOK_PROGRESS; settings.statusBarTitle = CrossPointSettings::CHAPTER_TITLE; settings.statusBarBattery = 1; break; case CrossPointSettings::ONLY_BOOK_PROGRESS_BAR: - settings.statusBarChapterPageCount = 1; + settings.statusBarChapterProgress = CrossPointSettings::CHAPTER_PROGRESS_PAGES; settings.statusBarBookProgressPercentage = 0; settings.statusBarProgressBar = CrossPointSettings::BOOK_PROGRESS; settings.statusBarTitle = CrossPointSettings::HIDE_TITLE; settings.statusBarBattery = 0; break; case CrossPointSettings::CHAPTER_PROGRESS_BAR: - settings.statusBarChapterPageCount = 0; + settings.statusBarChapterProgress = CrossPointSettings::CHAPTER_PROGRESS_HIDE; settings.statusBarBookProgressPercentage = 1; settings.statusBarProgressBar = CrossPointSettings::CHAPTER_PROGRESS; settings.statusBarTitle = CrossPointSettings::CHAPTER_TITLE; @@ -345,7 +345,7 @@ void applyLegacyStatusBarSettings(CrossPointSettings& settings) { break; case CrossPointSettings::FULL: default: - settings.statusBarChapterPageCount = 1; + settings.statusBarChapterProgress = CrossPointSettings::CHAPTER_PROGRESS_PAGES; settings.statusBarBookProgressPercentage = 1; settings.statusBarProgressBar = CrossPointSettings::HIDE_PROGRESS; settings.statusBarTitle = CrossPointSettings::CHAPTER_TITLE; @@ -402,8 +402,22 @@ bool loadSettingsDirect(CrossPointSettings& s, const JsonDocument& doc, bool* ne dest[maxLen - 1] = '\0'; }; - if (doc["statusBarChapterPageCount"].isNull()) { + if (doc["statusBarChapterProgress"].isNull() && doc["statusBarChapterPageCount"].isNull()) { applyLegacyStatusBarSettings(s); + } else if (doc["statusBarChapterProgress"].isNull()) { + // Migrate pre-enum toggles (page count, and briefly also time remaining) into the enum. + const uint8_t showPages = doc["statusBarChapterPageCount"] | static_cast(1); + const uint8_t showTime = doc["statusBarChapterTimeRemaining"] | static_cast(0); + if (showPages && showTime) { + s.statusBarChapterProgress = CrossPointSettings::CHAPTER_PROGRESS_PAGES_TIME; + } else if (showTime) { + s.statusBarChapterProgress = CrossPointSettings::CHAPTER_PROGRESS_TIME; + } else if (showPages) { + s.statusBarChapterProgress = CrossPointSettings::CHAPTER_PROGRESS_PAGES; + } else { + s.statusBarChapterProgress = CrossPointSettings::CHAPTER_PROGRESS_HIDE; + } + if (needsResave) *needsResave = true; } loadEnum("sleepScreen", s.sleepScreen, CrossPointSettings::SLEEP_SCREEN_MODE_COUNT); @@ -522,7 +536,8 @@ bool loadSettingsDirect(CrossPointSettings& s, const JsonDocument& doc, bool* ne s.opdsPassword[sizeof(s.opdsPassword) - 1] = '\0'; } - loadToggle("statusBarChapterPageCount", s.statusBarChapterPageCount); + loadEnum("statusBarChapterProgress", s.statusBarChapterProgress, + CrossPointSettings::STATUS_BAR_CHAPTER_PROGRESS_COUNT); loadToggle("statusBarBookProgressPercentage", s.statusBarBookProgressPercentage); loadEnum("statusBarProgressBar", s.statusBarProgressBar, CrossPointSettings::STATUS_BAR_PROGRESS_BAR_COUNT); loadEnum("statusBarProgressBarThickness", s.statusBarProgressBarThickness, @@ -892,7 +907,7 @@ bool JsonSettingsIO::saveSettings(const CrossPointSettings& s, const char* path) doc["koSyncAutoPullOnOpen"] = s.koSyncAutoPullOnOpen; doc["koSyncAutoPushOnClose"] = s.koSyncAutoPushOnClose; - doc["statusBarChapterPageCount"] = s.statusBarChapterPageCount; + doc["statusBarChapterProgress"] = s.statusBarChapterProgress; doc["statusBarBookProgressPercentage"] = s.statusBarBookProgressPercentage; doc["statusBarProgressBar"] = s.statusBarProgressBar; doc["statusBarProgressBarThickness"] = s.statusBarProgressBarThickness; @@ -983,240 +998,6 @@ bool JsonSettingsIO::loadSettings(CrossPointSettings& s, const char* json, bool* } return loadSettingsDirect(s, doc, needsResave); - - auto clamp = [](uint8_t val, uint8_t maxVal, uint8_t def) -> uint8_t { return val < maxVal ? val : def; }; - - // Legacy migration: if statusBarChapterPageCount is absent this is a pre-refactor settings file. - // Populate s with migrated values now so the generic loop below picks them up as defaults and clamps them. - if (doc["statusBarChapterPageCount"].isNull()) { - applyLegacyStatusBarSettings(s); - } - - for (const auto& info : getSettingsList()) { - if (!info.key) continue; - // Dynamic entries (KOReader etc.) are stored in their own files - skip. - if (!info.valuePtr && !info.stringOffset) continue; - - if (info.stringOffset) { - const char* strPtr = (const char*)&s + info.stringOffset; - const std::string fieldDefault = strPtr; // current buffer = struct-initializer default - std::string val; - if (info.obfuscated) { - bool ok = false; - val = obfuscation::deobfuscateFromBase64(doc[std::string(info.key) + "_obf"] | "", &ok); - if (!ok || val.empty()) { - val = doc[info.key] | fieldDefault; - if (val != fieldDefault && needsResave) *needsResave = true; - } - } else { - val = doc[info.key] | fieldDefault; - } - char* destPtr = (char*)&s + info.stringOffset; - if (info.stringMaxLen == 0) { - LOG_ERR("CPS", "Misconfigured SettingInfo: stringMaxLen is 0 for key '%s'", info.key); - destPtr[0] = '\0'; - if (needsResave) *needsResave = true; - continue; - } - strncpy(destPtr, val.c_str(), info.stringMaxLen - 1); - destPtr[info.stringMaxLen - 1] = '\0'; - } else { - const uint8_t fieldDefault = s.*(info.valuePtr); // struct-initializer default, read before we overwrite it - uint8_t v = doc[info.key] | fieldDefault; - if (info.type == SettingType::ENUM) { - v = clamp(v, (uint8_t)info.enumValues.size(), fieldDefault); - } else if (info.type == SettingType::TOGGLE) { - v = clamp(v, (uint8_t)2, fieldDefault); - } else if (info.type == SettingType::VALUE) { - if (v < info.valueRange.min) - v = info.valueRange.min; - else if (v > info.valueRange.max) - v = info.valueRange.max; - } - s.*(info.valuePtr) = v; - } - } - - // Front button remap - managed by RemapFrontButtons sub-activity, not in SettingsList. - const uint8_t fontSizeSchemaVersion = doc["fontSizeSchemaVersion"] | static_cast(0); - if (fontSizeSchemaVersion < FONT_SIZE_SCHEMA_VERSION && !doc["fontSize"].isNull()) { - const uint8_t legacyFontSize = doc["fontSize"] | static_cast(CrossPointSettings::MEDIUM - 1); - if (legacyFontSize < static_cast(CrossPointSettings::EXTRA_LARGE)) { - s.fontSize = static_cast(legacyFontSize + 1); - if (needsResave) *needsResave = true; - } - } - - const uint8_t rawFontFamily = doc["fontFamily"] | s.fontFamily; - if (rawFontFamily >= static_cast(CrossPointSettings::FONT_FAMILY_COUNT)) { - s.fontFamily = CrossPointSettings::BOOKERLY; - if (needsResave) *needsResave = true; - } else { - s.fontFamily = rawFontFamily; - } - - using S = CrossPointSettings; - s.frontButtonBack = - clamp(doc["frontButtonBack"] | (uint8_t)S::FRONT_HW_BACK, S::FRONT_BUTTON_HARDWARE_COUNT, S::FRONT_HW_BACK); - s.frontButtonConfirm = clamp(doc["frontButtonConfirm"] | (uint8_t)S::FRONT_HW_CONFIRM, S::FRONT_BUTTON_HARDWARE_COUNT, - S::FRONT_HW_CONFIRM); - s.frontButtonLeft = - clamp(doc["frontButtonLeft"] | (uint8_t)S::FRONT_HW_LEFT, S::FRONT_BUTTON_HARDWARE_COUNT, S::FRONT_HW_LEFT); - s.frontButtonRight = - clamp(doc["frontButtonRight"] | (uint8_t)S::FRONT_HW_RIGHT, S::FRONT_BUTTON_HARDWARE_COUNT, S::FRONT_HW_RIGHT); - s.displayDay = clamp(doc["displayDay"] | s.displayDay, S::DISPLAY_HEADER_MODE_COUNT, s.displayDay); - migrateDisplayHeaderSettings(s, doc, needsResave); - s.autoSyncDay = clamp(doc["autoSyncDay"] | s.autoSyncDay, static_cast(2), s.autoSyncDay); - s.syncDayWifiChoice = - clamp(doc["syncDayWifiChoice"] | s.syncDayWifiChoice, S::SYNC_DAY_WIFI_CHOICE_COUNT, s.syncDayWifiChoice); - s.syncDayReminderStarts = clamp(doc["syncDayReminderStarts"] | s.syncDayReminderStarts, - S::SYNC_DAY_REMINDER_STARTS_COUNT, s.syncDayReminderStarts); - { - const std::string sleepDirectory = doc["sleepDirectory"] | std::string(""); - strncpy(s.sleepDirectory, sleepDirectory.c_str(), sizeof(s.sleepDirectory) - 1); - s.sleepDirectory[sizeof(s.sleepDirectory) - 1] = '\0'; - } - s.sleepImageOrder = clamp(doc["sleepImageOrder"] | static_cast(S::SLEEP_IMAGE_SHUFFLE), - S::SLEEP_IMAGE_ORDER_COUNT, S::SLEEP_IMAGE_SHUFFLE); - s.timeZonePreset = - TimeZoneRegistry::clampPresetIndex(doc["timeZonePreset"] | TimeZoneRegistry::DEFAULT_TIME_ZONE_INDEX); - s.dateFormat = clamp(doc["dateFormat"] | s.dateFormat, S::DATE_FORMAT_COUNT, s.dateFormat); - s.opdsFilenameFormat = - clamp(doc["opdsFilenameFormat"] | s.opdsFilenameFormat, S::OPDS_FILENAME_FORMAT_COUNT, s.opdsFilenameFormat); - s.koSyncAutoPullOnOpen = - clamp(doc["koSyncAutoPullOnOpen"] | s.koSyncAutoPullOnOpen, static_cast(2), s.koSyncAutoPullOnOpen); - s.koSyncAutoPushOnClose = - clamp(doc["koSyncAutoPushOnClose"] | s.koSyncAutoPushOnClose, static_cast(2), s.koSyncAutoPushOnClose); - s.dailyGoalTarget = clamp(doc["dailyGoalTarget"] | s.dailyGoalTarget, S::DAILY_GOAL_TARGET_COUNT, s.dailyGoalTarget); - { - const uint8_t rawFlashcardStudyMode = doc["flashcardStudyMode"] | s.flashcardStudyMode; - const uint8_t flashcardStudyModeSchemaVersion = doc["flashcardStudyModeSchemaVersion"] | static_cast(0); - s.flashcardStudyMode = migrateStoredFlashcardStudyMode(rawFlashcardStudyMode, flashcardStudyModeSchemaVersion, - s.flashcardStudyMode, nullptr); - } - s.flashcardSessionSize = clamp(doc["flashcardSessionSize"] | s.flashcardSessionSize, S::FLASHCARD_SESSION_SIZE_COUNT, - s.flashcardSessionSize); - s.showStatsAfterReading = - clamp(doc["showStatsAfterReading"] | s.showStatsAfterReading, static_cast(2), s.showStatsAfterReading); - s.achievementsEnabled = - clamp(doc["achievementsEnabled"] | s.achievementsEnabled, static_cast(2), s.achievementsEnabled); - s.achievementPopups = - clamp(doc["achievementPopups"] | s.achievementPopups, static_cast(2), s.achievementPopups); - - const uint8_t shortcutLocationCount = S::SHORTCUT_LOCATION_COUNT; - const uint8_t shortcutOrderCount = static_cast(getShortcutDefinitions().size() + 1); - s.appsHubShortcutOrder = - clamp(doc["appsHubShortcutOrder"] | s.appsHubShortcutOrder, shortcutOrderCount, s.appsHubShortcutOrder); - s.browseFilesShortcut = - clamp(doc["browseFilesShortcut"] | s.browseFilesShortcut, shortcutLocationCount, s.browseFilesShortcut); - s.browseFilesShortcutOrder = clamp(doc["browseFilesShortcutOrder"] | s.browseFilesShortcutOrder, shortcutOrderCount, - s.browseFilesShortcutOrder); - s.statsShortcut = clamp(doc["statsShortcut"] | s.statsShortcut, shortcutLocationCount, s.statsShortcut); - s.statsShortcutOrder = - clamp(doc["statsShortcutOrder"] | s.statsShortcutOrder, shortcutOrderCount, s.statsShortcutOrder); - s.syncDayShortcut = clamp(doc["syncDayShortcut"] | s.syncDayShortcut, shortcutLocationCount, s.syncDayShortcut); - s.syncDayShortcutOrder = - clamp(doc["syncDayShortcutOrder"] | s.syncDayShortcutOrder, shortcutOrderCount, s.syncDayShortcutOrder); - s.settingsShortcut = clamp(doc["settingsShortcut"] | s.settingsShortcut, shortcutLocationCount, s.settingsShortcut); - s.settingsShortcutOrder = - clamp(doc["settingsShortcutOrder"] | s.settingsShortcutOrder, shortcutOrderCount, s.settingsShortcutOrder); - s.readingStatsShortcut = - clamp(doc["readingStatsShortcut"] | s.readingStatsShortcut, shortcutLocationCount, s.readingStatsShortcut); - s.readingStatsShortcutOrder = clamp(doc["readingStatsShortcutOrder"] | s.readingStatsShortcutOrder, - shortcutOrderCount, s.readingStatsShortcutOrder); - s.readingHeatmapShortcut = - clamp(doc["readingHeatmapShortcut"] | s.readingHeatmapShortcut, shortcutLocationCount, s.readingHeatmapShortcut); - s.readingHeatmapShortcutOrder = clamp(doc["readingHeatmapShortcutOrder"] | s.readingHeatmapShortcutOrder, - shortcutOrderCount, s.readingHeatmapShortcutOrder); - s.readingProfileShortcut = - clamp(doc["readingProfileShortcut"] | s.readingProfileShortcut, shortcutLocationCount, s.readingProfileShortcut); - s.readingProfileShortcutOrder = clamp(doc["readingProfileShortcutOrder"] | s.readingProfileShortcutOrder, - shortcutOrderCount, s.readingProfileShortcutOrder); - s.achievementsShortcut = - clamp(doc["achievementsShortcut"] | s.achievementsShortcut, shortcutLocationCount, s.achievementsShortcut); - s.achievementsShortcutOrder = clamp(doc["achievementsShortcutOrder"] | s.achievementsShortcutOrder, - shortcutOrderCount, s.achievementsShortcutOrder); - s.ifFoundShortcut = clamp(doc["ifFoundShortcut"] | s.ifFoundShortcut, shortcutLocationCount, s.ifFoundShortcut); - s.ifFoundShortcutOrder = - clamp(doc["ifFoundShortcutOrder"] | s.ifFoundShortcutOrder, shortcutOrderCount, s.ifFoundShortcutOrder); - s.readMeShortcut = clamp(doc["readMeShortcut"] | s.readMeShortcut, shortcutLocationCount, s.readMeShortcut); - s.readMeShortcutOrder = - clamp(doc["readMeShortcutOrder"] | s.readMeShortcutOrder, shortcutOrderCount, s.readMeShortcutOrder); - s.recentBooksShortcut = - clamp(doc["recentBooksShortcut"] | s.recentBooksShortcut, shortcutLocationCount, s.recentBooksShortcut); - s.recentBooksShortcutOrder = clamp(doc["recentBooksShortcutOrder"] | s.recentBooksShortcutOrder, shortcutOrderCount, - s.recentBooksShortcutOrder); - s.bookmarksShortcut = - clamp(doc["bookmarksShortcut"] | s.bookmarksShortcut, shortcutLocationCount, s.bookmarksShortcut); - s.bookmarksShortcutOrder = - clamp(doc["bookmarksShortcutOrder"] | s.bookmarksShortcutOrder, shortcutOrderCount, s.bookmarksShortcutOrder); - s.favoritesShortcut = - clamp(doc["favoritesShortcut"] | s.favoritesShortcut, shortcutLocationCount, s.favoritesShortcut); - s.favoritesShortcutOrder = - clamp(doc["favoritesShortcutOrder"] | s.favoritesShortcutOrder, shortcutOrderCount, s.favoritesShortcutOrder); - s.flashcardsShortcut = - clamp(doc["flashcardsShortcut"] | s.flashcardsShortcut, shortcutLocationCount, s.flashcardsShortcut); - s.flashcardsShortcutOrder = - clamp(doc["flashcardsShortcutOrder"] | s.flashcardsShortcutOrder, shortcutOrderCount, s.flashcardsShortcutOrder); - s.fileTransferShortcut = - clamp(doc["fileTransferShortcut"] | s.fileTransferShortcut, shortcutLocationCount, s.fileTransferShortcut); - s.fileTransferShortcutOrder = clamp(doc["fileTransferShortcutOrder"] | s.fileTransferShortcutOrder, - shortcutOrderCount, s.fileTransferShortcutOrder); - s.screenCleanShortcut = - clamp(doc["screenCleanShortcut"] | s.screenCleanShortcut, shortcutLocationCount, s.screenCleanShortcut); - s.screenCleanShortcutOrder = clamp(doc["screenCleanShortcutOrder"] | s.screenCleanShortcutOrder, shortcutOrderCount, - s.screenCleanShortcutOrder); - s.sleepShortcut = clamp(doc["sleepShortcut"] | s.sleepShortcut, shortcutLocationCount, s.sleepShortcut); - s.sleepShortcutOrder = - clamp(doc["sleepShortcutOrder"] | s.sleepShortcutOrder, shortcutOrderCount, s.sleepShortcutOrder); - s.opdsBrowserShortcut = - clamp(doc["opdsBrowserShortcut"] | s.opdsBrowserShortcut, shortcutLocationCount, s.opdsBrowserShortcut); - s.opdsBrowserShortcutOrder = clamp(doc["opdsBrowserShortcutOrder"] | s.opdsBrowserShortcutOrder, shortcutOrderCount, - s.opdsBrowserShortcutOrder); - - s.browseFilesShortcutVisible = clamp(doc["browseFilesShortcutVisible"] | s.browseFilesShortcutVisible, - static_cast(2), s.browseFilesShortcutVisible); - s.statsShortcutVisible = - clamp(doc["statsShortcutVisible"] | s.statsShortcutVisible, static_cast(2), s.statsShortcutVisible); - s.syncDayShortcutVisible = clamp(doc["syncDayShortcutVisible"] | s.syncDayShortcutVisible, static_cast(2), - s.syncDayShortcutVisible); - s.settingsShortcutVisible = clamp(doc["settingsShortcutVisible"] | s.settingsShortcutVisible, static_cast(2), - s.settingsShortcutVisible); - s.readingStatsShortcutVisible = clamp(doc["readingStatsShortcutVisible"] | s.readingStatsShortcutVisible, - static_cast(2), s.readingStatsShortcutVisible); - s.readingHeatmapShortcutVisible = clamp(doc["readingHeatmapShortcutVisible"] | s.readingHeatmapShortcutVisible, - static_cast(2), s.readingHeatmapShortcutVisible); - s.readingProfileShortcutVisible = clamp(doc["readingProfileShortcutVisible"] | s.readingProfileShortcutVisible, - static_cast(2), s.readingProfileShortcutVisible); - s.achievementsShortcutVisible = clamp(doc["achievementsShortcutVisible"] | s.achievementsShortcutVisible, - static_cast(2), s.achievementsShortcutVisible); - s.ifFoundShortcutVisible = clamp(doc["ifFoundShortcutVisible"] | s.ifFoundShortcutVisible, static_cast(2), - s.ifFoundShortcutVisible); - s.readMeShortcutVisible = - clamp(doc["readMeShortcutVisible"] | s.readMeShortcutVisible, static_cast(2), s.readMeShortcutVisible); - s.recentBooksShortcutVisible = clamp(doc["recentBooksShortcutVisible"] | s.recentBooksShortcutVisible, - static_cast(2), s.recentBooksShortcutVisible); - s.bookmarksShortcutVisible = clamp(doc["bookmarksShortcutVisible"] | s.bookmarksShortcutVisible, - static_cast(2), s.bookmarksShortcutVisible); - s.favoritesShortcutVisible = clamp(doc["favoritesShortcutVisible"] | s.favoritesShortcutVisible, - static_cast(2), s.favoritesShortcutVisible); - s.flashcardsShortcutVisible = clamp(doc["flashcardsShortcutVisible"] | s.flashcardsShortcutVisible, - static_cast(2), s.flashcardsShortcutVisible); - s.fileTransferShortcutVisible = clamp(doc["fileTransferShortcutVisible"] | s.fileTransferShortcutVisible, - static_cast(2), s.fileTransferShortcutVisible); - s.screenCleanShortcutVisible = clamp(doc["screenCleanShortcutVisible"] | s.screenCleanShortcutVisible, - static_cast(2), s.screenCleanShortcutVisible); - s.sleepShortcutVisible = - clamp(doc["sleepShortcutVisible"] | s.sleepShortcutVisible, static_cast(2), s.sleepShortcutVisible); - s.opdsBrowserShortcutVisible = clamp(doc["opdsBrowserShortcutVisible"] | s.opdsBrowserShortcutVisible, - static_cast(2), s.opdsBrowserShortcutVisible); - - normalizeShortcutOrderSettings(s); - CrossPointSettings::validateFrontButtonMapping(s); - - LOG_DBG("CPS", "Settings loaded from file"); - - return true; } // ---- KOReaderCredentialStore ---- @@ -1547,6 +1328,8 @@ bool JsonSettingsIO::saveReadingStats(const ReadingStatsStore& store, const char obj["coverBmpPath"] = book.coverBmpPath; obj["chapterTitle"] = book.chapterTitle; obj["totalReadingMs"] = book.totalReadingMs; + obj["totalPagesReadingMs"] = book.totalPagesReadingMs; + obj["totalPagesRead"] = book.totalPagesRead; obj["sessions"] = book.sessions; obj["lastSessionMs"] = book.lastSessionMs; obj["firstReadAt"] = book.firstReadAt; @@ -1695,6 +1478,10 @@ bool JsonSettingsIO::loadReadingStatsDocument(ReadingStatsStore& store, const Js book.coverBmpPath = obj["coverBmpPath"] | std::string(""); book.chapterTitle = obj["chapterTitle"] | std::string(""); book.totalReadingMs = obj["totalReadingMs"] | static_cast(0); + // Page-rate ETA samples. Legacy totalWordsRead / totalWordsReadingMs (word-rate + // prototypes) are intentionally ignored — rates are not convertible. + book.totalPagesReadingMs = obj["totalPagesReadingMs"] | static_cast(0); + book.totalPagesRead = obj["totalPagesRead"] | static_cast(0); book.sessions = obj["sessions"] | static_cast(0); book.lastSessionMs = obj["lastSessionMs"] | static_cast(0); book.firstReadAt = obj["firstReadAt"] | static_cast(0); diff --git a/src/ReadingStatsStore.cpp b/src/ReadingStatsStore.cpp index 6bd70f9477c..6b88b9e2105 100644 --- a/src/ReadingStatsStore.cpp +++ b/src/ReadingStatsStore.cpp @@ -522,6 +522,8 @@ void ReadingStatsStore::mergeBookInto(ReadingBookStats& primary, const ReadingBo } primary.totalReadingMs += duplicate.totalReadingMs; + primary.totalPagesReadingMs += duplicate.totalPagesReadingMs; + primary.totalPagesRead += duplicate.totalPagesRead; primary.sessions += duplicate.sessions; primary.lastSessionMs = std::max(primary.lastSessionMs, duplicate.lastSessionMs); if (primary.firstReadAt == 0 || (duplicate.firstReadAt != 0 && duplicate.firstReadAt < primary.firstReadAt)) { @@ -556,6 +558,10 @@ void ReadingStatsStore::normalizeBook(ReadingBookStats& book) { normalizeReadingDays(book.readingDays); book.lastProgressPercent = clampPercent(book.lastProgressPercent); book.chapterProgressPercent = clampPercent(book.chapterProgressPercent); + // Drop unpaired page samples so ETA rate cannot open on pages without dwell ms. + if (book.totalPagesReadingMs == 0 && book.totalPagesRead > 0) { + book.totalPagesRead = 0; + } } void ReadingStatsStore::normalizeBooks() { @@ -1247,6 +1253,27 @@ void ReadingStatsStore::noteActivity() { } } +void ReadingStatsStore::notePagesRead(const uint32_t pages, const uint32_t associatedMs) { + if (!activeSession.active || activeSession.bookIndex >= books.size() || pages == 0 || associatedMs == 0) { + return; + } + auto& book = books[activeSession.bookIndex]; + if (book.totalPagesRead > UINT64_MAX - pages) { + book.totalPagesRead = UINT64_MAX; + } else { + book.totalPagesRead += pages; + } + if (book.totalPagesReadingMs > UINT64_MAX - associatedMs) { + book.totalPagesReadingMs = UINT64_MAX; + } else { + book.totalPagesReadingMs += associatedMs; + } + markDirty(); + if (shouldSaveDeferred()) { + saveToFile(); + } +} + void ReadingStatsStore::tickActiveSession() { if (!activeSession.active || activeSession.bookIndex >= books.size()) { return; @@ -1430,7 +1457,7 @@ bool ReadingStatsStore::removeBook(const std::string& path) { void ReadingStatsStore::endSession() { if (!activeSession.active || activeSession.bookIndex >= books.size()) { - lastSessionSnapshot = {}; + // Already ended — keep lastSessionSnapshot for post-exit UI (e.g. completion banner). activeSession = {}; return; } @@ -1466,8 +1493,26 @@ void ReadingStatsStore::endSession() { saveToFile(); } +double ReadingStatsStore::getEffectivePagesPerMs() const { + constexpr uint64_t MIN_RATE_PAGES = 3; + constexpr uint64_t MIN_RATE_MS = 60ULL * 1000ULL; + + // Live status-bar rate only: no active session ⇒ no ETA (rate is not a lifetime average). + if (!activeSession.active || activeSession.bookIndex >= books.size()) { + return 0.0; + } + + const auto& book = books[activeSession.bookIndex]; + // Only dwell ms paired with credited pages — never lifetime totalReadingMs. + if (book.totalPagesRead < MIN_RATE_PAGES || book.totalPagesReadingMs < MIN_RATE_MS) { + return 0.0; + } + return static_cast(book.totalPagesRead) / static_cast(book.totalPagesReadingMs); +} + bool ReadingStatsStore::adjustBookReadingTime(const std::string& path, const uint32_t dayOrdinal, const int32_t deltaMs) { + // Manual day corrections adjust lifetime reading time only — never page-rate ETA samples. if (dayOrdinal == 0 || deltaMs == 0) { return false; } diff --git a/src/ReadingStatsStore.h b/src/ReadingStatsStore.h index febeb62e5d0..f0bd2111964 100644 --- a/src/ReadingStatsStore.h +++ b/src/ReadingStatsStore.h @@ -25,6 +25,9 @@ struct ReadingBookStats { std::string chapterTitle; std::vector readingDays; uint64_t totalReadingMs = 0; + // Page-rate samples only: ms co-collected with totalPagesRead (not lifetime reading time). + uint64_t totalPagesReadingMs = 0; + uint64_t totalPagesRead = 0; uint32_t sessions = 0; uint32_t lastSessionMs = 0; uint32_t firstReadAt = 0; @@ -150,11 +153,18 @@ class ReadingStatsStore { const std::string& coverBmpPath, uint8_t progressPercent = 0, const std::string& chapterTitle = "", uint8_t chapterProgressPercent = 0); void noteActivity(); + // Credit pages finished together with the dwell time spent on that page. + // Samples are marked dirty immediately; deferred save runs when the interval is due. + void notePagesRead(uint32_t pages, uint32_t associatedMs); void tickActiveSession(); void resumeSession(); void updateProgress(uint8_t progressPercent, bool completed = false, const std::string& chapterTitle = "", uint8_t chapterProgressPercent = 0); void endSession(); + // Live status-bar rate only: paired page/dwell samples for the active book. + // Returns 0 with no active session or when paired samples are below the gate + // (rate is not a lifetime average and vanishes when the session ends). + double getEffectivePagesPerMs() const; bool adjustBookReadingTime(const std::string& path, uint32_t dayOrdinal, int32_t deltaMs); bool setBookFirstReadDate(const std::string& path, uint32_t dayOrdinal); bool updateBookMetadata(const std::string& path, const std::string& title, const std::string& author, diff --git a/src/SettingsList.cpp b/src/SettingsList.cpp index c405b35f7d4..14a5f8aef8a 100644 --- a/src/SettingsList.cpp +++ b/src/SettingsList.cpp @@ -227,8 +227,11 @@ const std::vector& getSettingsList() { {StrId::STR_AUTHOR_TITLE, StrId::STR_TITLE_AUTHOR}, "opdsFilenameFormat", StrId::STR_KOREADER_SYNC), // --- Status Bar Settings (web-only, uses StatusBarSettingsActivity) --- - SettingInfo::Toggle(StrId::STR_CHAPTER_PAGE_COUNT, &CrossPointSettings::statusBarChapterPageCount, - "statusBarChapterPageCount", StrId::STR_CUSTOMISE_STATUS_BAR), + // Enum length only — UI labels always come from formatChapterProgressLabel + // (Pages+Time is composed; these StrIds are never shown raw). + SettingInfo::Enum(StrId::STR_STATUS_BAR_CHAPTER_PROGRESS, &CrossPointSettings::statusBarChapterProgress, + {StrId::STR_PAGES, StrId::STR_PAGES, StrId::STR_TIME, StrId::STR_HIDE}, + "statusBarChapterProgress", StrId::STR_CUSTOMISE_STATUS_BAR), SettingInfo::Toggle(StrId::STR_BOOK_PROGRESS_PERCENTAGE, &CrossPointSettings::statusBarBookProgressPercentage, "statusBarBookProgressPercentage", StrId::STR_CUSTOMISE_STATUS_BAR), SettingInfo::Enum(StrId::STR_PROGRESS_BAR, &CrossPointSettings::statusBarProgressBar, diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index cb4cb681a98..c4fd89c8c2e 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -45,6 +45,7 @@ #include "fontIds.h" #include "util/AchievementPopupUtils.h" #include "util/BookIdentity.h" +#include "util/PageDwell.h" #include "util/CompletedBookMover.h" #include "util/ScreenshotUtil.h" @@ -336,6 +337,11 @@ void EpubReaderActivity::onExit() { // Reset orientation back to portrait for the rest of the UI renderer.setOrientation(GfxRenderer::Orientation::Portrait); + // Credit if this path did not already (early exits credit before endSession). + // endSession is idempotent: a second call keeps lastSessionSnapshot for the + // post-read stats banner. recordSessionEnded dedupes by snapshot serial. + creditCurrentPage(); + APP_STATE.readerActivityLoadCount = 0; APP_STATE.saveToFile(); READING_STATS.endSession(); @@ -427,6 +433,8 @@ void EpubReaderActivity::loop() { if (mappedInput.wasReleased(MappedInputManager::Button::Confirm) || mappedInput.wasReleased(MappedInputManager::Button::Back)) { automaticPageTurnActive = false; + pageDwell.noteEntered(section && section->currentPage >= 0 ? currentSpineIndex : -1, + section ? section->currentPage : 0, millis(), true); // updates chapter title space to indicate page turn disabled requestUpdate(); return; @@ -517,7 +525,6 @@ void EpubReaderActivity::loop() { if (ReaderUtils::hasPendingConfirmSingleClickExpired(waitingForConfirmSecondClick, firstConfirmClickMs, nowMs)) { waitingForConfirmSecondClick = false; firstConfirmClickMs = 0UL; - READING_STATS.noteActivity(); int currentPage = 0; int totalPages = 0; float bookProgress = 0.0f; @@ -533,23 +540,24 @@ void EpubReaderActivity::loop() { } const int bookProgressPercent = clampPercent(static_cast(bookProgress + 0.5f)); ReaderUtils::requestReaderUiTransitionRefresh(renderer); - startActivityForResult(std::make_unique( - renderer, mappedInput, epub->getTitle(), currentPage, totalPages, bookProgressPercent, - SETTINGS.orientation, !currentPageFootnotes.empty()), - [this](const ActivityResult& result) { - READING_STATS.resumeSession(); - // Always apply orientation change even if the menu was cancelled - const auto& menu = std::get(result.data); - applyOrientation(menu.orientation); - toggleAutoPageTurn(menu.pageTurnOption); - if (!result.isCancelled) { - onReaderMenuConfirm(static_cast(menu.action)); - } - }); + openReaderSubactivity( + std::make_unique(renderer, mappedInput, epub->getTitle(), currentPage, totalPages, + bookProgressPercent, SETTINGS.orientation, + !currentPageFootnotes.empty()), + [this](const ActivityResult& result) { + // Always apply orientation change even if the menu was cancelled + const auto& menu = std::get(result.data); + applyOrientation(menu.orientation); + toggleAutoPageTurn(menu.pageTurnOption); + if (!result.isCancelled) { + onReaderMenuConfirm(static_cast(menu.action)); + } + }); } // Long press BACK (1s+) goes to file selection if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= ReaderUtils::GO_HOME_MS) { + creditCurrentPage(); const std::string fileBrowserPath = moveCompletedBookIfEnabled(); READING_STATS.endSession(); ACHIEVEMENTS.recordSessionEnded(READING_STATS.getLastSessionSnapshot()); @@ -609,6 +617,7 @@ void EpubReaderActivity::loop() { if (longPress && SETTINGS.longPressButtonBehavior == CrossPointSettings::LONG_PRESS_CHAPTER_SKIP) { READING_STATS.noteActivity(); lastPageTurnTime = millis(); + pageDwell.clear(); if (!nextTriggered && section && section->currentPage > 0) { section->currentPage = 0; @@ -671,6 +680,7 @@ void EpubReaderActivity::toggleTemporaryStatusBar() { READING_STATS.noteActivity(); statusBarTemporarilyHidden = !statusBarTemporarilyHidden; invalidateCurrentOverlayPageCache(); + pageDwell.clear(); RenderLock lock(*this); if (section) { cachedSpineIndex = currentSpineIndex; @@ -824,6 +834,8 @@ void EpubReaderActivity::jumpToPercent(int percent) { } // Reset state so render() reloads and repositions on the target spine. + // Clear dwell tracking so the left page is not credited as read. + pageDwell.clear(); currentSpineIndex = targetSpineIndex; nextPageNumber = 0; pendingPercentJump = true; @@ -919,26 +931,21 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction switch (action) { case EpubReaderMenuActivity::MenuAction::READER_SETTINGS: { const auto before = captureReaderSettingsSnapshot(); - READING_STATS.noteActivity(); - startActivityForResult(std::make_unique(renderer, mappedInput), - [this, before](const ActivityResult&) { - applyReaderSettingsChanges(before); - READING_STATS.resumeSession(); - }); + openReaderSubactivity(std::make_unique(renderer, mappedInput), + [this, before](const ActivityResult&) { applyReaderSettingsChanges(before); }); break; } case EpubReaderMenuActivity::MenuAction::SELECT_CHAPTER: { const int spineIdx = currentSpineIndex; const std::string path = epub->getPath(); - READING_STATS.noteActivity(); - startActivityForResult( + openReaderSubactivity( std::make_unique(renderer, mappedInput, epub, path, spineIdx), [this](const ActivityResult& result) { - READING_STATS.resumeSession(); if (!result.isCancelled) { const auto& chapterResult = std::get(result.data); RenderLock lock(*this); + pageDwell.clear(); currentSpineIndex = chapterResult.spineIndex; // If anchor is not empty, it will be used later to calculate the page number. @@ -953,16 +960,15 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction break; } case EpubReaderMenuActivity::MenuAction::FOOTNOTES: { - READING_STATS.noteActivity(); - startActivityForResult(std::make_unique(renderer, mappedInput, currentPageFootnotes), - [this](const ActivityResult& result) { - READING_STATS.resumeSession(); - if (!result.isCancelled) { - const auto& footnoteResult = std::get(result.data); - navigateToHref(footnoteResult.href, true); - } - requestUpdate(); - }); + openReaderSubactivity( + std::make_unique(renderer, mappedInput, currentPageFootnotes), + [this](const ActivityResult& result) { + if (!result.isCancelled) { + const auto& footnoteResult = std::get(result.data); + navigateToHref(footnoteResult.href, true); + } + requestUpdate(); + }); break; } case EpubReaderMenuActivity::MenuAction::LOOK_UP_WORD: { @@ -973,12 +979,10 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction requestUpdate(); break; } - READING_STATS.noteActivity(); - startActivityForResult( + openReaderSubactivity( std::make_unique(renderer, mappedInput, page, SETTINGS.getReaderFontId(), overlayMarginLeft, overlayMarginTop), [this](const ActivityResult&) { - READING_STATS.resumeSession(); ReaderUtils::requestReaderUiTransitionRefresh(renderer); requestUpdate(); }); @@ -992,30 +996,24 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction requestUpdate(); break; } - READING_STATS.noteActivity(); - startActivityForResult( + openReaderSubactivity( std::make_unique(renderer, mappedInput, page, SETTINGS.getReaderFontId(), overlayMarginLeft, overlayMarginTop), [this](const ActivityResult&) { - READING_STATS.resumeSession(); ReaderUtils::requestReaderUiTransitionRefresh(renderer); requestUpdate(); }); break; } case EpubReaderMenuActivity::MenuAction::DICTIONARY: { - READING_STATS.noteActivity(); - startActivityForResult(std::make_unique(renderer, mappedInput), - [this](const ActivityResult&) { - READING_STATS.resumeSession(); - ReaderUtils::requestReaderUiTransitionRefresh(renderer); - requestUpdate(); - }); + openReaderSubactivity(std::make_unique(renderer, mappedInput), [this](const ActivityResult&) { + ReaderUtils::requestReaderUiTransitionRefresh(renderer); + requestUpdate(); + }); break; } case EpubReaderMenuActivity::MenuAction::VIEW_HIGHLIGHTS: { - READING_STATS.noteActivity(); - startActivityForResult( + openReaderSubactivity( std::make_unique(renderer, mappedInput, bookmarkStore.getAll(), epub, "", [this](const BookmarkStore::Bookmark& bookmark) { const bool removed = bookmarkStore.removeItem(bookmark); @@ -1025,12 +1023,12 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction return removed; }), [this](const ActivityResult& result) { - READING_STATS.resumeSession(); if (!result.isCancelled) { const auto& bookmark = std::get(result.data); if (currentSpineIndex != bookmark.spineIndex || !section || section->currentPage != static_cast(bookmark.page)) { RenderLock lock(*this); + pageDwell.clear(); currentSpineIndex = bookmark.spineIndex; nextPageNumber = static_cast(bookmark.page); sessionProgressTouched = true; @@ -1055,12 +1053,10 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction } const uint16_t selectionSpine = static_cast(currentSpineIndex); const uint16_t selectionPage = static_cast(section->currentPage); - READING_STATS.noteActivity(); - startActivityForResult( + openReaderSubactivity( std::make_unique(renderer, mappedInput, page, SETTINGS.getReaderFontId(), overlayMarginLeft, overlayMarginTop, true), [this, selectionSpine, selectionPage](const ActivityResult& result) { - READING_STATS.resumeSession(); if (!result.isCancelled) { const auto& highlight = std::get(result.data); const bool saved = @@ -1091,11 +1087,9 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction } } const int initialPercent = clampPercent(static_cast(bookProgress + 0.5f)); - READING_STATS.noteActivity(); - startActivityForResult( + openReaderSubactivity( std::make_unique(renderer, mappedInput, initialPercent), [this](const ActivityResult& result) { - READING_STATS.resumeSession(); if (!result.isCancelled) { jumpToPercent(std::get(result.data).percent); } @@ -1119,9 +1113,8 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction } } if (!fullText.empty()) { - READING_STATS.noteActivity(); - startActivityForResult(std::make_unique(renderer, mappedInput, fullText), - [this](const ActivityResult& result) { READING_STATS.resumeSession(); }); + openReaderSubactivity(std::make_unique(renderer, mappedInput, fullText), + [](const ActivityResult&) {}); break; } } @@ -1139,11 +1132,9 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction } case EpubReaderMenuActivity::MenuAction::MARK_AS_FINISHED: { const std::string title = epub ? epub->getTitle() : ""; - READING_STATS.noteActivity(); - startActivityForResult( + openReaderSubactivity( std::make_unique(renderer, mappedInput, tr(STR_MARK_AS_FINISHED_CONFIRM), title), [this](const ActivityResult& result) { - READING_STATS.resumeSession(); if (!result.isCancelled) { markCurrentBookAsFinished(); } else { @@ -1213,6 +1204,7 @@ void EpubReaderActivity::applyOrientation(const uint8_t orientation) { ReaderUtils::applyOrientation(renderer, SETTINGS.orientation); // Reset section to force re-layout in the new orientation. + pageDwell.clear(); section.reset(); } } @@ -1220,6 +1212,8 @@ void EpubReaderActivity::applyOrientation(const uint8_t orientation) { void EpubReaderActivity::toggleAutoPageTurn(const uint8_t selectedPageTurnOption) { if (selectedPageTurnOption == 0 || selectedPageTurnOption >= std::size(PAGE_TURN_RATES)) { automaticPageTurnActive = false; + pageDwell.noteEntered(section && section->currentPage >= 0 ? currentSpineIndex : -1, + section ? section->currentPage : 0, millis(), true); return; } @@ -1227,6 +1221,7 @@ void EpubReaderActivity::toggleAutoPageTurn(const uint8_t selectedPageTurnOption // calculates page turn duration by dividing by number of pages pageTurnDuration = (1UL * 60 * 1000) / PAGE_TURN_RATES[selectedPageTurnOption]; automaticPageTurnActive = true; + pageDwell.clear(); const uint8_t statusBarHeight = statusBarTemporarilyHidden ? 0 : UITheme::getInstance().getStatusBarHeight(); // resets cached section so that space is reserved for auto page turn indicator when None or progress bar only @@ -1289,6 +1284,7 @@ std::string EpubReaderActivity::moveCompletedBookIfEnabled() { } void EpubReaderActivity::exitReaderAfterOptionalCompletedMove() { + creditCurrentPage(); const std::string exitPath = moveCompletedBookIfEnabled(); exitReaderToHomeOrStats(renderer, mappedInput, exitPath); } @@ -1304,6 +1300,45 @@ void EpubReaderActivity::markCurrentBookAsFinished() { exitReaderAfterOptionalCompletedMove(); } +void EpubReaderActivity::resumeAfterSubactivity() { + READING_STATS.resumeSession(); + pageDwell.noteEntered(section && section->currentPage >= 0 ? currentSpineIndex : -1, + section ? section->currentPage : 0, millis(), true); +} + +void EpubReaderActivity::openReaderSubactivity(std::unique_ptr&& activity, + ActivityResultHandler onResult) { + READING_STATS.noteActivity(); + pageDwell.clear(); + startActivityForResult(std::move(activity), [this, onResult = std::move(onResult)](const ActivityResult& result) { + resumeAfterSubactivity(); + if (onResult) { + onResult(result); + } + }); +} + +void EpubReaderActivity::creditCurrentPage() { + if (!automaticPageTurnActive && section && section->currentPage >= 0) { + maybeCreditPage(currentSpineIndex, section->currentPage); + } + pageDwell.clear(); +} + +void EpubReaderActivity::maybeCreditPage(const int spineIndex, const int page) { + if (!section || page < 0 || spineIndex < 0) { + return; + } + + constexpr uint32_t kPages = 1; + const uint32_t associatedMs = pageDwell.takeCredit(spineIndex, page, kPages, millis()); + if (associatedMs == 0) { + return; + } + + READING_STATS.notePagesRead(kPages, associatedMs); +} + void EpubReaderActivity::pageTurn(bool isForwardTurn) { if (!section) { nextPageNumber = 0; @@ -1317,6 +1352,10 @@ void EpubReaderActivity::pageTurn(bool isForwardTurn) { const int oldPage = section ? section->currentPage : nextPageNumber; if (isForwardTurn) { + // Auto page-turn must not train the reading-rate samples. + if (!automaticPageTurnActive) { + maybeCreditPage(oldSpineIndex, oldPage); + } if (section->currentPage < section->pageCount - 1 || section->isBuilding() || section->isPartial()) { section->currentPage++; } else { @@ -1329,6 +1368,7 @@ void EpubReaderActivity::pageTurn(bool isForwardTurn) { } } } else { + // Backward turns never credit; re-reads are credited later if the reader lingers. if (section->currentPage > 0) { section->currentPage--; } else if (currentSpineIndex > 0) { @@ -1346,6 +1386,8 @@ void EpubReaderActivity::pageTurn(bool isForwardTurn) { sessionProgressTouched = true; } lastPageTurnTime = millis(); + pageDwell.noteEntered(section && section->currentPage >= 0 ? currentSpineIndex : -1, + section ? section->currentPage : 0, millis()); requestUpdate(); } @@ -1621,6 +1663,10 @@ void EpubReaderActivity::render(RenderLock&& lock) { renderContents(page, orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft); LOG_DBG("ERS", "Rendered page in %dms", millis() - start); } + { + const int page = section ? section->currentPage : -1; + pageDwell.noteEntered(page >= 0 ? currentSpineIndex : -1, page, millis()); + } // Menus, screenshots and overlays can request a render without moving the // reader. Avoid several FAT operations for the same six-byte position file. if (currentSpineIndex != lastSavedSpineIndex || section->currentPage != lastSavedPage || @@ -1980,7 +2026,25 @@ void EpubReaderActivity::renderStatusBar() const { title = epub->getTitle(); } - GUI.drawStatusBar(renderer, bookProgress, currentPage, pageCount, title, 0, textYOffset); + // Sized for multi-byte unit suffixes; formatCompactDuration fails closed if still too small. + char chapterTimeBuf[24] = {}; + const char* chapterTimeEstimate = nullptr; + if (section->currentPage >= 0) { + const double pagesPerMs = READING_STATS.getEffectivePagesPerMs(); + if (SETTINGS.statusBarWantsChapterTime() && pagesPerMs > 0.0) { + const uint16_t estimatedTotal = section->estimatedTotalPages(); + const int current = section->currentPage; + const uint32_t remainingPages = + (current >= 0 && estimatedTotal > static_cast(current)) + ? static_cast(estimatedTotal - static_cast(current)) + : 0; + if (ReaderUtils::formatRemainingFromRate(remainingPages, pagesPerMs, chapterTimeBuf, sizeof(chapterTimeBuf))) { + chapterTimeEstimate = chapterTimeBuf; + } + } + } + + GUI.drawStatusBar(renderer, bookProgress, currentPage, pageCount, title, 0, textYOffset, true, chapterTimeEstimate); } void EpubReaderActivity::renderSectionLoadFailure() { @@ -2040,6 +2104,7 @@ void EpubReaderActivity::navigateToHref(const std::string& hrefStr, const bool s { RenderLock lock(*this); + pageDwell.clear(); pendingAnchor = std::move(anchor); currentSpineIndex = targetSpineIndex; nextPageNumber = 0; @@ -2057,6 +2122,7 @@ void EpubReaderActivity::restoreSavedPosition() { { RenderLock lock(*this); + pageDwell.clear(); currentSpineIndex = pos.spineIndex; nextPageNumber = pos.pageNumber; section.reset(); @@ -2175,6 +2241,8 @@ void EpubReaderActivity::launchKOReaderSync(const SyncLaunchMode mode) { cachedSpineIndex = currentSpineIndex; cachedChapterTotalPageCount = section->estimatedTotalPages(); } + // Credit before releasing the section — onExit cannot credit after section.reset(). + creditCurrentPage(); section.reset(); epub.reset(); } @@ -2248,6 +2316,8 @@ void EpubReaderActivity::applyPendingSyncSession() { cachedChapterTotalPageCount = restorePageCount; } + pageDwell.clear(); + sync.clear(); APP_STATE.saveToFile(); } diff --git a/src/activities/reader/EpubReaderActivity.h b/src/activities/reader/EpubReaderActivity.h index 8ea2a1e45e4..d77067508f5 100644 --- a/src/activities/reader/EpubReaderActivity.h +++ b/src/activities/reader/EpubReaderActivity.h @@ -9,6 +9,7 @@ #include "EndOfBookOptions.h" #include "EpubReaderMenuActivity.h" #include "activities/Activity.h" +#include "util/PageDwell.h" class Page; @@ -45,6 +46,8 @@ class EpubReaderActivity final : public Activity { int sessionStartSpineIndex = 0; int sessionStartPage = 0; bool sessionProgressTouched = false; + // Word-rate samples: dwell on the page currently displayed. Jumps never credit the left page. + PageDwell pageDwell; std::shared_ptr currentOverlayPageCache; EndOfBookOptions endOfBookOptions; int currentOverlayPageSpineIndex = -1; @@ -119,6 +122,12 @@ class EpubReaderActivity final : public Activity { void exitReaderAfterOptionalCompletedMove(); void markCurrentBookAsFinished(); void pageTurn(bool isForwardTurn); + void resumeAfterSubactivity(); + // noteActivity + clear dwell, then resumeSession + restart dwell on return. + void openReaderSubactivity(std::unique_ptr&& activity, ActivityResultHandler onResult); + // Credit the current page's dwell sample while the reading session is still active. + void creditCurrentPage(); + void maybeCreditPage(int spineIndex, int page); void requestCurrentPageFullRefresh(); void toggleTemporaryStatusBar(); void cacheCurrentPageForOverlay(const std::shared_ptr& page, int marginLeft, int marginTop); diff --git a/src/activities/reader/ReaderUtils.cpp b/src/activities/reader/ReaderUtils.cpp new file mode 100644 index 00000000000..a055f450250 --- /dev/null +++ b/src/activities/reader/ReaderUtils.cpp @@ -0,0 +1,63 @@ +#include "ReaderUtils.h" + +#include + +#include +#include + +namespace ReaderUtils { +namespace { +constexpr uint64_t MS_PER_MINUTE = 60ULL * 1000ULL; + +bool formatRoundedUnit(const uint64_t value, const char* unit, char* buf, const size_t bufSize) { + if (!unit || unit[0] == '\0') { + return false; + } + // ETA unit suffixes are authored in EN+ES only; other locales fall back to + // English via I18n (intentional — do not invent unit translations everywhere). + const int written = snprintf(buf, bufSize, "%llu%s", static_cast(value), unit); + return written > 0 && static_cast(written) < bufSize; +} +} // namespace + +bool formatCompactDuration(const uint64_t totalMs, char* buf, const size_t bufSize) { + if (!buf || bufSize < 3 || totalMs == 0) { + return false; + } + + // Cascade on rounded smaller units so 60m → 1h and 24h → 1d (never "60m" / "24h"). + uint64_t minutes = (totalMs + MS_PER_MINUTE / 2) / MS_PER_MINUTE; + if (minutes == 0) { + minutes = 1; + } + if (minutes < 60) { + return formatRoundedUnit(minutes, tr(STR_ETA_UNIT_MINUTE), buf, bufSize); + } + + const uint64_t hours = (minutes + 30) / 60; + if (hours < 24) { + return formatRoundedUnit(hours, tr(STR_ETA_UNIT_HOUR), buf, bufSize); + } + + const uint64_t days = (hours + 12) / 24; + if (days < 365) { + return formatRoundedUnit(days, tr(STR_ETA_UNIT_DAY), buf, bufSize); + } + + const uint64_t years = (days + 182) / 365; + return formatRoundedUnit(years, tr(STR_ETA_UNIT_YEAR), buf, bufSize); +} + +bool formatRemainingFromRate(const uint32_t remainingPages, const double pagesPerMs, char* buf, + const size_t bufSize) { + if (remainingPages == 0 || pagesPerMs <= 0.0) { + return false; + } + const double ms = static_cast(remainingPages) / pagesPerMs; + if (ms <= 0.0 || ms >= static_cast(UINT64_MAX)) { + return false; + } + return formatCompactDuration(static_cast(ms), buf, bufSize); +} + +} // namespace ReaderUtils diff --git a/src/activities/reader/ReaderUtils.h b/src/activities/reader/ReaderUtils.h index f4aa09a0678..e41948b6741 100644 --- a/src/activities/reader/ReaderUtils.h +++ b/src/activities/reader/ReaderUtils.h @@ -7,6 +7,8 @@ #include #include +#include +#include #include #include @@ -240,4 +242,8 @@ void renderAntiAliased(GfxRenderer& renderer, RenderFn&& renderFn) { renderer.restoreBwBuffer(); } +// Compact chapter-ETA duration / remaining-time helpers (defined in ReaderUtils.cpp). +bool formatCompactDuration(uint64_t totalMs, char* buf, size_t bufSize); +bool formatRemainingFromRate(uint32_t remainingPages, double pagesPerMs, char* buf, size_t bufSize); + } // namespace ReaderUtils diff --git a/src/activities/reader/TxtReaderActivity.cpp b/src/activities/reader/TxtReaderActivity.cpp index 9a848341008..633320a8baf 100644 --- a/src/activities/reader/TxtReaderActivity.cpp +++ b/src/activities/reader/TxtReaderActivity.cpp @@ -6,7 +6,6 @@ #include #include #include -#include #include #include @@ -31,7 +30,8 @@ namespace { constexpr size_t CHUNK_SIZE = 8 * 1024; // 8KB chunk for reading // Cache file magic and version constexpr uint32_t CACHE_MAGIC = 0x54585449; // "TXTI" -constexpr uint8_t CACHE_VERSION = 4; // Increment when cache format changes +// v10: page offsets only (chapter ETA uses page-rate; no word totals). +constexpr uint8_t CACHE_VERSION = 10; constexpr uint8_t MARKDOWN_QUOTE_INDENT = 1; constexpr uint8_t MARKDOWN_LIST_INDENT = 1; @@ -322,6 +322,11 @@ void TxtReaderActivity::onExit() { // Reset orientation back to portrait for the rest of the UI renderer.setOrientation(GfxRenderer::Orientation::Portrait); + // Credit if this path did not already (early exits credit before endSession). + // endSession is idempotent: a second call keeps lastSessionSnapshot for the + // post-read stats banner. recordSessionEnded dedupes by snapshot serial. + creditCurrentPage(); + pageOffsets.clear(); currentPageLines.clear(); APP_STATE.readerActivityLoadCount = 0; @@ -353,6 +358,7 @@ void TxtReaderActivity::loop() { // Long press BACK (1s+) goes to file selection if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= ReaderUtils::GO_HOME_MS) { + creditCurrentPage(); const std::string fileBrowserPath = moveCompletedBookIfEnabled(); READING_STATS.endSession(); ACHIEVEMENTS.recordSessionEnded(READING_STATS.getLastSessionSnapshot()); @@ -379,14 +385,20 @@ void TxtReaderActivity::loop() { if (prevTriggered && currentPage > 0) { READING_STATS.noteActivity(); + // Backward turns never credit; re-reads credit later if the reader lingers. currentPage--; + pageDwell.noteEntered(currentPage, 0, millis()); requestUpdate(); } else if (nextTriggered) { if (currentPage < totalPages - 1) { READING_STATS.noteActivity(); + maybeCreditPage(currentPage); currentPage++; + pageDwell.noteEntered(currentPage, 0, millis()); requestUpdate(); } else { + READING_STATS.noteActivity(); + maybeCreditPage(currentPage); READING_STATS.updateProgress(100, true, "", 100); exitReaderAfterOptionalCompletedMove(); } @@ -402,9 +414,12 @@ void TxtReaderActivity::requestCurrentPageFullRefresh() { void TxtReaderActivity::toggleTemporaryStatusBar() { READING_STATS.noteActivity(); statusBarTemporarilyHidden = !statusBarTemporarilyHidden; + // Full reinit is required: status-bar height changes the viewport and linesPerPage, + // which invalidates pageOffsets (different page breaks). initialized = false; pageOffsets.clear(); currentPageLines.clear(); + pageDwell.clear(); pendingForceFullRefresh = true; requestUpdate(); } @@ -434,6 +449,7 @@ std::string TxtReaderActivity::moveCompletedBookIfEnabled() { } void TxtReaderActivity::exitReaderAfterOptionalCompletedMove() { + creditCurrentPage(); const std::string exitPath = moveCompletedBookIfEnabled(); exitReaderToHomeOrStats(renderer, mappedInput, exitPath); } @@ -468,10 +484,7 @@ void TxtReaderActivity::initializeReader() { // Try to load cached page index first if (!loadPageIndexCache()) { - // Cache not found, build page index - buildPageIndex(); - // Save to cache for next time - savePageIndexCache(); + buildPageIndex(); // builds and saves cache } // Load saved progress @@ -517,6 +530,43 @@ void TxtReaderActivity::buildPageIndex() { totalPages = pageOffsets.size(); LOG_DBG("TRS", "Built page index: %d pages", totalPages); + savePageIndexCache(); +} + +void TxtReaderActivity::creditCurrentPage() { + maybeCreditPage(currentPage); + pageDwell.clear(); +} + +void TxtReaderActivity::maybeCreditPage(const int page) { + if (page < 0) { + return; + } + + constexpr uint32_t kPages = 1; + const uint32_t associatedMs = pageDwell.takeCredit(page, 0, kPages, millis()); + if (associatedMs == 0) { + return; + } + + READING_STATS.notePagesRead(kPages, associatedMs); +} + +void TxtReaderActivity::resumeAfterSubactivity() { + READING_STATS.resumeSession(); + pageDwell.noteEntered(currentPage, 0, millis(), true); +} + +void TxtReaderActivity::openReaderSubactivity(std::unique_ptr&& activity, + ActivityResultHandler onResult) { + READING_STATS.noteActivity(); + pageDwell.clear(); + startActivityForResult(std::move(activity), [this, onResult = std::move(onResult)](const ActivityResult& result) { + resumeAfterSubactivity(); + if (onResult) { + onResult(result); + } + }); } bool TxtReaderActivity::loadPageAtOffset(size_t offset, std::vector& outLines, size_t& nextOffset) { @@ -764,6 +814,9 @@ void TxtReaderActivity::renderPage() { // BW rendering renderLines(); + if (currentPage >= 0) { + pageDwell.noteEntered(currentPage, 0, millis()); + } renderStatusBar(); const bool forceFullRefresh = pendingForceFullRefresh; @@ -786,7 +839,19 @@ void TxtReaderActivity::renderStatusBar() const { if (SETTINGS.statusBarTitle != CrossPointSettings::STATUS_BAR_TITLE::HIDE_TITLE) { title = txt->getTitle(); } - GUI.drawStatusBar(renderer, progress, currentPage + 1, totalPages, title); + + // Sized for multi-byte unit suffixes; formatCompactDuration fails closed if still too small. + char chapterTimeBuf[24] = {}; + const char* chapterTimeEstimate = nullptr; + const double pagesPerMs = READING_STATS.getEffectivePagesPerMs(); + const uint32_t remainingPages = + (currentPage >= 0 && totalPages > currentPage) ? static_cast(totalPages - currentPage) : 0; + if (SETTINGS.statusBarWantsChapterTime() && pagesPerMs > 0.0 && + ReaderUtils::formatRemainingFromRate(remainingPages, pagesPerMs, chapterTimeBuf, sizeof(chapterTimeBuf))) { + chapterTimeEstimate = chapterTimeBuf; + } + + GUI.drawStatusBar(renderer, progress, currentPage + 1, totalPages, title, 0, 0, true, chapterTimeEstimate); } void TxtReaderActivity::saveProgress() const { @@ -838,15 +903,15 @@ void TxtReaderActivity::loadProgress() { } bool TxtReaderActivity::loadPageIndexCache() { - // Cache file format (using serialization module): + // Cache file format (serialization module, little-endian POD writes): // - uint32_t: magic "TXTI" // - uint8_t: cache version // - uint32_t: file size (to validate cache) // - int32_t: viewport width // - int32_t: lines per page - // - int32_t: font ID (to invalidate cache on font change) - // - int32_t: screen margin (to invalidate cache on margin change) - // - uint8_t: paragraph alignment (to invalidate cache on alignment change) + // - int32_t: font ID + // - int32_t: screen margin + // - uint8_t: paragraph alignment // - uint32_t: total pages count // - N * uint32_t: page offsets @@ -857,7 +922,6 @@ bool TxtReaderActivity::loadPageIndexCache() { return false; } - // Read and validate header using serialization module uint32_t magic; serialization::readPod(f, magic); if (magic != CACHE_MAGIC) { @@ -917,7 +981,6 @@ bool TxtReaderActivity::loadPageIndexCache() { uint32_t numPages; serialization::readPod(f, numPages); - // Read page offsets pageOffsets.clear(); pageOffsets.reserve(numPages); @@ -940,7 +1003,6 @@ void TxtReaderActivity::savePageIndexCache() const { return; } - // Write header using serialization module serialization::writePod(f, CACHE_MAGIC); serialization::writePod(f, CACHE_VERSION); serialization::writePod(f, static_cast(txt->getFileSize())); @@ -951,7 +1013,6 @@ void TxtReaderActivity::savePageIndexCache() const { serialization::writePod(f, cachedParagraphAlignment); serialization::writePod(f, static_cast(pageOffsets.size())); - // Write page offsets for (size_t offset : pageOffsets) { serialization::writePod(f, static_cast(offset)); } diff --git a/src/activities/reader/TxtReaderActivity.h b/src/activities/reader/TxtReaderActivity.h index 52ee51ff263..c36eca7ae81 100644 --- a/src/activities/reader/TxtReaderActivity.h +++ b/src/activities/reader/TxtReaderActivity.h @@ -2,11 +2,13 @@ #include +#include #include #include #include "CrossPointSettings.h" #include "activities/Activity.h" +#include "util/PageDwell.h" class TxtReaderActivity final : public Activity { public: @@ -41,6 +43,8 @@ class TxtReaderActivity final : public Activity { bool pendingForceFullRefresh = false; bool waitingForConfirmSecondClick = false; unsigned long firstConfirmClickMs = 0UL; + // Word-rate samples: dwell on the page currently displayed. + PageDwell pageDwell; // Cached settings for cache validation (different fonts/margins require re-indexing) int cachedFontId = 0; @@ -63,6 +67,10 @@ class TxtReaderActivity final : public Activity { void loadProgress(); void requestCurrentPageFullRefresh(); void toggleTemporaryStatusBar(); + void creditCurrentPage(); + void maybeCreditPage(int page); + void resumeAfterSubactivity(); + void openReaderSubactivity(std::unique_ptr&& activity, ActivityResultHandler onResult); std::string moveCompletedBookIfEnabled(); void exitReaderAfterOptionalCompletedMove(); diff --git a/src/activities/reader/XtcReaderActivity.cpp b/src/activities/reader/XtcReaderActivity.cpp index cafd2acc93a..fc667e1d35f 100644 --- a/src/activities/reader/XtcReaderActivity.cpp +++ b/src/activities/reader/XtcReaderActivity.cpp @@ -3,6 +3,10 @@ * * XTC ebook reader activity implementation * Displays pre-rendered XTC pages on e-ink display + * + * Intentionally has no chapter word-ETA / page-dwell sampling: XTC pages are + * bitmaps without a word count, and this reader does not use drawStatusBar's + * chapter-time slot. */ #include "XtcReaderActivity.h" diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index 3ded5865478..52aa535c2f4 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -345,6 +345,12 @@ std::string getSettingValueText(const SettingInfo& setting) { return ""; } const uint8_t value = SETTINGS.*(setting.valuePtr); + if (setting.nameId == StrId::STR_STATUS_BAR_CHAPTER_PROGRESS) { + char buf[64]; + if (CrossPointSettings::formatChapterProgressLabel(value, buf, sizeof(buf))) { + return buf; + } + } const size_t safeIndex = std::min(value, setting.enumValues.size() - 1); return I18N.get(setting.enumValues[safeIndex]); } diff --git a/src/activities/settings/StatusBarSettingsActivity.cpp b/src/activities/settings/StatusBarSettingsActivity.cpp index a2b06d9d084..81a29488429 100644 --- a/src/activities/settings/StatusBarSettingsActivity.cpp +++ b/src/activities/settings/StatusBarSettingsActivity.cpp @@ -4,7 +4,9 @@ #include #include +#include #include +#include #include "ClockSyncActivity.h" #include "CrossPointSettings.h" @@ -17,7 +19,7 @@ namespace { // Menu items in their natural order. Clock entries are appended only when the // DS3231 RTC is present so X4 devices don't see them at all. enum MenuItem { - ITEM_CHAPTER_PAGE_COUNT = 0, + ITEM_CHAPTER_PROGRESS = 0, ITEM_BOOK_PROGRESS_PERCENTAGE, ITEM_PROGRESS_BAR, ITEM_PROGRESS_BAR_THICKNESS, @@ -34,7 +36,7 @@ constexpr int BASE_MENU_ITEMS = ITEM_CLOCK; // Items shown on every device constexpr int FULL_MENU_ITEMS = ITEM_COUNT; // Items shown when RTC is available const StrId menuNames[FULL_MENU_ITEMS] = { - StrId::STR_CHAPTER_PAGE_COUNT, + StrId::STR_STATUS_BAR_CHAPTER_PROGRESS, StrId::STR_BOOK_PROGRESS_PERCENTAGE, StrId::STR_PROGRESS_BAR, StrId::STR_PROGRESS_BAR_THICKNESS, @@ -49,6 +51,8 @@ const StrId menuNames[FULL_MENU_ITEMS] = { constexpr int CLOCK_FORMAT_ITEMS = 2; const StrId clockFormatNames[CLOCK_FORMAT_ITEMS] = {StrId::STR_CLOCK_FORMAT_24H, StrId::STR_CLOCK_FORMAT_12H}; +constexpr int CHAPTER_PROGRESS_ITEMS = 4; + constexpr int PROGRESS_BAR_ITEMS = 3; const StrId progressBarNames[PROGRESS_BAR_ITEMS] = {StrId::STR_BOOK, StrId::STR_CHAPTER, StrId::STR_HIDE}; @@ -77,6 +81,18 @@ int clockCycleIndex(const uint8_t mode) { return 0; } +bool fillPreviewChapterTimeEstimate(char* buf, const size_t bufSize) { + switch (SETTINGS.statusBarChapterProgress) { + case CrossPointSettings::CHAPTER_PROGRESS_PAGES_TIME: + case CrossPointSettings::CHAPTER_PROGRESS_TIME: { + const int written = snprintf(buf, bufSize, "15%s", tr(STR_ETA_UNIT_MINUTE)); + return written > 0 && static_cast(written) < bufSize; + } + default: + return false; + } +} + const int verticalPreviewPadding = 50; const int verticalPreviewTextPadding = 40; } // namespace @@ -87,13 +103,13 @@ void StatusBarSettingsActivity::onEnter() { selectedIndex = 0; visibleItemCount = halClock.isAvailable() ? FULL_MENU_ITEMS : BASE_MENU_ITEMS; - // Clamp statusBarProgressBar and statusBarTitle in case of corrupt/migrated data - if (SETTINGS.statusBarProgressBar >= PROGRESS_BAR_ITEMS) { - SETTINGS.statusBarProgressBar = CrossPointSettings::STATUS_BAR_PROGRESS_BAR::HIDE_PROGRESS; + // Clamp enum settings in case of corrupt/migrated data + if (SETTINGS.statusBarChapterProgress >= CHAPTER_PROGRESS_ITEMS) { + SETTINGS.statusBarChapterProgress = CrossPointSettings::STATUS_BAR_CHAPTER_PROGRESS::CHAPTER_PROGRESS_PAGES; } - if (SETTINGS.statusBarTitle >= PROGRESS_BAR_THICKNESS_ITEMS) { - SETTINGS.statusBarTitle = CrossPointSettings::STATUS_BAR_PROGRESS_BAR_THICKNESS::PROGRESS_BAR_NORMAL; + if (SETTINGS.statusBarProgressBar >= PROGRESS_BAR_ITEMS) { + SETTINGS.statusBarProgressBar = CrossPointSettings::STATUS_BAR_PROGRESS_BAR::HIDE_PROGRESS; } if (SETTINGS.statusBarTitle >= TITLE_ITEMS) { @@ -153,8 +169,8 @@ void StatusBarSettingsActivity::loop() { void StatusBarSettingsActivity::handleSelection() { switch (selectedIndex) { - case ITEM_CHAPTER_PAGE_COUNT: - SETTINGS.statusBarChapterPageCount = (SETTINGS.statusBarChapterPageCount + 1) % 2; + case ITEM_CHAPTER_PROGRESS: + SETTINGS.statusBarChapterProgress = (SETTINGS.statusBarChapterProgress + 1) % CHAPTER_PROGRESS_ITEMS; break; case ITEM_BOOK_PROGRESS_PERCENTAGE: SETTINGS.statusBarBookProgressPercentage = (SETTINGS.statusBarBookProgressPercentage + 1) % 2; @@ -214,8 +230,13 @@ void StatusBarSettingsActivity::render(RenderLock&&) { [](int index) { return std::string(I18N.get(menuNames[index])); }, nullptr, nullptr, [](int index) -> std::string { switch (index) { - case ITEM_CHAPTER_PAGE_COUNT: - return SETTINGS.statusBarChapterPageCount ? tr(STR_SHOW) : tr(STR_HIDE); + case ITEM_CHAPTER_PROGRESS: { + char buf[64]; + if (CrossPointSettings::formatChapterProgressLabel(SETTINGS.statusBarChapterProgress, buf, sizeof(buf))) { + return buf; + } + return tr(STR_HIDE); + } case ITEM_BOOK_PROGRESS_PERCENTAGE: return SETTINGS.statusBarBookProgressPercentage ? tr(STR_SHOW) : tr(STR_HIDE); case ITEM_PROGRESS_BAR: @@ -253,7 +274,12 @@ void StatusBarSettingsActivity::render(RenderLock&&) { title = tr(STR_EXAMPLE_CHAPTER); } - GUI.drawStatusBar(renderer, 75, 8, 32, title, verticalPreviewPadding, 0, false); + char previewChapterTimeBuf[24] = {}; + const char* previewChapterTime = nullptr; + if (fillPreviewChapterTimeEstimate(previewChapterTimeBuf, sizeof(previewChapterTimeBuf))) { + previewChapterTime = previewChapterTimeBuf; + } + GUI.drawStatusBar(renderer, 75, 8, 32, title, verticalPreviewPadding, 0, false, previewChapterTime); renderer.drawText(UI_10_FONT_ID, metrics.contentSidePadding, renderer.getScreenHeight() - UITheme::getInstance().getStatusBarHeight() - verticalPreviewPadding - diff --git a/src/components/UITheme.cpp b/src/components/UITheme.cpp index 8c427228a5b..7d627373c58 100644 --- a/src/components/UITheme.cpp +++ b/src/components/UITheme.cpp @@ -116,9 +116,10 @@ int UITheme::getStatusBarHeight() { const ThemeMetrics& metrics = UITheme::getInstance().getMetrics(); // Add status bar margin - const bool showStatusBar = SETTINGS.statusBarChapterPageCount || SETTINGS.statusBarBookProgressPercentage || - SETTINGS.statusBarTitle != CrossPointSettings::STATUS_BAR_TITLE::HIDE_TITLE || - SETTINGS.statusBarBattery; + const bool showStatusBar = + SETTINGS.statusBarChapterProgress != CrossPointSettings::CHAPTER_PROGRESS_HIDE || + SETTINGS.statusBarBookProgressPercentage || + SETTINGS.statusBarTitle != CrossPointSettings::STATUS_BAR_TITLE::HIDE_TITLE || SETTINGS.statusBarBattery; const bool showProgressBar = SETTINGS.statusBarProgressBar != CrossPointSettings::STATUS_BAR_PROGRESS_BAR::HIDE_PROGRESS; return (showStatusBar ? (metrics.statusBarVerticalMargin) : 0) + diff --git a/src/components/themes/BaseTheme.cpp b/src/components/themes/BaseTheme.cpp index 92a1223abdf..916d51ef3c2 100644 --- a/src/components/themes/BaseTheme.cpp +++ b/src/components/themes/BaseTheme.cpp @@ -835,7 +835,7 @@ void BaseTheme::fillPopupProgress(const GfxRenderer& renderer, const Rect& layou void BaseTheme::drawStatusBar(GfxRenderer& renderer, const float bookProgress, const int currentPage, const int pageCount, std::string title, const int paddingBottom, const int textYOffset, - const bool fillMargin) const { + const bool fillMargin, const char* chapterTimeEstimate) const { auto metrics = UITheme::getInstance().getMetrics(); int orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft; renderer.getOrientedViewableTRBL(&orientedMarginTop, &orientedMarginRight, &orientedMarginBottom, @@ -846,16 +846,39 @@ void BaseTheme::drawStatusBar(GfxRenderer& renderer, const float bookProgress, c auto textY = screenHeight - UITheme::getInstance().getStatusBarHeight() - orientedMarginBottom - paddingBottom - 4; int progressTextWidth = 0; - if (SETTINGS.statusBarBookProgressPercentage || SETTINGS.statusBarChapterPageCount) { + const bool wantChapterTime = SETTINGS.statusBarWantsChapterTime(); + const bool haveChapterTime = + wantChapterTime && chapterTimeEstimate != nullptr && chapterTimeEstimate[0] != '\0'; + // TIME-only with no rate yet would otherwise leave an empty right cluster; show pages until ETA is ready. + const bool showChapterPages = + SETTINGS.statusBarChapterProgress == CrossPointSettings::CHAPTER_PROGRESS_PAGES || + SETTINGS.statusBarChapterProgress == CrossPointSettings::CHAPTER_PROGRESS_PAGES_TIME || + (SETTINGS.statusBarChapterProgress == CrossPointSettings::CHAPTER_PROGRESS_TIME && !haveChapterTime); + const bool showBookPercent = SETTINGS.statusBarBookProgressPercentage; + const bool showChapterTime = haveChapterTime; + + if (showBookPercent || showChapterPages || showChapterTime) { // Right aligned text for progress counter - char progressStr[32]; - - if (SETTINGS.statusBarBookProgressPercentage && SETTINGS.statusBarChapterPageCount) { - snprintf(progressStr, sizeof(progressStr), "%d/%d %.0f%%", currentPage, pageCount, bookProgress); - } else if (SETTINGS.statusBarBookProgressPercentage) { - snprintf(progressStr, sizeof(progressStr), "%.0f%%", bookProgress); - } else { - snprintf(progressStr, sizeof(progressStr), "%d/%d", currentPage, pageCount); + char progressStr[48]; + size_t offset = 0; + auto appendProgress = [&](const int written) { + if (written > 0) { + offset += static_cast(written); + } + }; + if (showChapterPages) { + appendProgress(snprintf(progressStr + offset, sizeof(progressStr) - offset, "%d/%d", currentPage, pageCount)); + } + if (showChapterTime && offset < sizeof(progressStr)) { + appendProgress(snprintf(progressStr + offset, sizeof(progressStr) - offset, "%s%s", + showChapterPages ? " (" : "", chapterTimeEstimate)); + if (showChapterPages && offset < sizeof(progressStr)) { + appendProgress(snprintf(progressStr + offset, sizeof(progressStr) - offset, ")")); + } + } + if (showBookPercent && offset < sizeof(progressStr)) { + appendProgress(snprintf(progressStr + offset, sizeof(progressStr) - offset, "%s%.0f%%", + (showChapterPages || showChapterTime) ? " " : "", bookProgress)); } progressTextWidth = renderer.getTextWidth(SMALL_FONT_ID, progressStr); @@ -923,37 +946,41 @@ void BaseTheme::drawStatusBar(GfxRenderer& renderer, const float bookProgress, c if (!title.empty()) { textY -= textYOffset; // Centered chapter title text - // Page width minus existing content with 30px padding on each side const int rendererableScreenWidth = renderer.getScreenWidth() - (metrics.statusBarHorizontalMargin * 2) - orientedMarginLeft - orientedMarginRight; const int batteryAreaWidth = statusBarBatteryAreaWidth(renderer, metrics, showBatteryPercentage); const int clockReserveLeft = clockOnLeft && clockTextWidth > 0 ? (clockTextWidth + 10) : 0; const int clockReserveRight = clockOnRight && clockTextWidth > 0 ? (clockTextWidth + 10) : 0; - const int titleMarginLeft = batteryAreaWidth + clockReserveLeft + 30; - const int titleMarginRight = progressTextWidth + clockReserveRight + 30; + // Wider progress clusters (pages+time+%) need less decorative title padding so the + // title can still truncate cleanly instead of colliding with the right cluster. + const int titleSidePad = showChapterTime ? 12 : 30; + const int titleMarginLeft = batteryAreaWidth + clockReserveLeft + titleSidePad; + const int titleMarginRight = progressTextWidth + clockReserveRight + titleSidePad; // Attempt to center title on the screen, but if title is too wide then later we will center it within the // available space. int titleMarginLeftAdjusted = std::max(titleMarginLeft, titleMarginRight); int availableTitleSpace = rendererableScreenWidth - 2 * titleMarginLeftAdjusted; - int titleWidth; - titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str()); + int titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str()); if (titleWidth > availableTitleSpace) { // Not enough space to center on the screen, center it within the remaining space instead availableTitleSpace = rendererableScreenWidth - titleMarginLeft - titleMarginRight; titleMarginLeftAdjusted = titleMarginLeft; } - if (titleWidth > availableTitleSpace) { - title = renderer.truncatedText(SMALL_FONT_ID, title.c_str(), availableTitleSpace); - titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str()); - } + constexpr int MIN_TITLE_SPACE = 40; + if (availableTitleSpace >= MIN_TITLE_SPACE) { + if (titleWidth > availableTitleSpace) { + title = renderer.truncatedText(SMALL_FONT_ID, title.c_str(), availableTitleSpace); + titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str()); + } - renderer.drawText(SMALL_FONT_ID, - titleMarginLeftAdjusted + metrics.statusBarHorizontalMargin + orientedMarginLeft + - (availableTitleSpace - titleWidth) / 2, - textY, title.c_str()); + renderer.drawText(SMALL_FONT_ID, + titleMarginLeftAdjusted + metrics.statusBarHorizontalMargin + orientedMarginLeft + + (availableTitleSpace - titleWidth) / 2, + textY, title.c_str()); + } } } diff --git a/src/components/themes/BaseTheme.h b/src/components/themes/BaseTheme.h index 18853d5f94c..659a178639c 100644 --- a/src/components/themes/BaseTheme.h +++ b/src/components/themes/BaseTheme.h @@ -167,7 +167,8 @@ class BaseTheme { virtual void fillPopupProgress(const GfxRenderer& renderer, const Rect& layout, const int progress) const; virtual void drawStatusBar(GfxRenderer& renderer, const float bookProgress, const int currentPage, const int pageCount, std::string title, const int paddingBottom = 0, - const int textYOffset = 0, const bool fillMargin = true) const; + const int textYOffset = 0, const bool fillMargin = true, + const char* chapterTimeEstimate = nullptr) const; virtual void drawHelpText(const GfxRenderer& renderer, Rect rect, const char* label) const; virtual void drawTextField(const GfxRenderer& renderer, Rect rect, const int textWidth, bool cursorMode = false, int contentStartX = 0, int contentWidth = 0) const; diff --git a/src/network/CrossPointWebServer.cpp b/src/network/CrossPointWebServer.cpp index 24288cf1db9..48129b27761 100644 --- a/src/network/CrossPointWebServer.cpp +++ b/src/network/CrossPointWebServer.cpp @@ -291,6 +291,9 @@ constexpr StrId OPT_SHORTCUT_LOCATION[] = {StrId::STR_HOME_LOCATION, StrId::STR_ constexpr StrId OPT_KO_MATCH[] = {StrId::STR_FILENAME, StrId::STR_BINARY}; constexpr StrId OPT_OPDS_FILENAME_FORMAT[] = {StrId::STR_AUTHOR_TITLE, StrId::STR_TITLE_AUTHOR}; constexpr StrId OPT_BOOK_CHAPTER_HIDE[] = {StrId::STR_BOOK, StrId::STR_CHAPTER, StrId::STR_HIDE}; +// Length-only for WEB_ENUM: JSON option labels always use formatChapterProgressLabel +// (Pages+Time is composed; these StrIds are never sent to the client). +constexpr StrId OPT_CHAPTER_PROGRESS[] = {StrId::STR_PAGES, StrId::STR_PAGES, StrId::STR_TIME, StrId::STR_HIDE}; constexpr StrId OPT_BAR_THICKNESS[] = {StrId::STR_PROGRESS_BAR_THIN, StrId::STR_PROGRESS_BAR_MEDIUM, StrId::STR_PROGRESS_BAR_THICK}; constexpr StrId OPT_XTC_STATUS_BAR[] = {StrId::STR_HIDE, StrId::STR_BOTTOM, StrId::STR_TOP}; @@ -436,8 +439,8 @@ constexpr WebSettingDef WEB_SETTINGS[] = { WEB_ENUM(StrId::STR_OPDS_FILENAME_FORMAT, opdsFilenameFormat, OPT_OPDS_FILENAME_FORMAT, "opdsFilenameFormat", StrId::STR_KOREADER_SYNC), - WEB_TOGGLE(StrId::STR_CHAPTER_PAGE_COUNT, statusBarChapterPageCount, "statusBarChapterPageCount", - StrId::STR_CUSTOMISE_STATUS_BAR), + WEB_ENUM(StrId::STR_STATUS_BAR_CHAPTER_PROGRESS, statusBarChapterProgress, OPT_CHAPTER_PROGRESS, "statusBarChapterProgress", + StrId::STR_CUSTOMISE_STATUS_BAR), WEB_TOGGLE(StrId::STR_BOOK_PROGRESS_PERCENTAGE, statusBarBookProgressPercentage, "statusBarBookProgressPercentage", StrId::STR_CUSTOMISE_STATUS_BAR), WEB_ENUM(StrId::STR_PROGRESS_BAR, statusBarProgressBar, OPT_BOOK_CHAPTER_HIDE, "statusBarProgressBar", @@ -1910,7 +1913,16 @@ void CrossPointWebServer::handleGetSettings() const { } else { seenOption = true; } - sendJsonEscaped(server.get(), I18N.get(s.options[i])); + if (s.key && strcmp(s.key, "statusBarChapterProgress") == 0) { + char label[64]; + if (CrossPointSettings::formatChapterProgressLabel(i, label, sizeof(label))) { + sendJsonEscaped(server.get(), label); + } else { + sendJsonEscaped(server.get(), ""); + } + } else { + sendJsonEscaped(server.get(), I18N.get(s.options[i])); + } } server->sendContent("]", 1); break; diff --git a/src/util/PageDwell.h b/src/util/PageDwell.h new file mode 100644 index 00000000000..a455b71c629 --- /dev/null +++ b/src/util/PageDwell.h @@ -0,0 +1,58 @@ +#pragma once + +#include + +// Page-dwell tracker for EPUB (spine+page) and TXT (page, id1 unused). +// clear() resets the active dwell window only; lastCredited* is kept so re-reads +// of the same page still require REREAD_MIN_MS before another credit. +struct PageDwell { + static constexpr unsigned long MIN_DWELL_MS = 1500UL; + static constexpr unsigned long REREAD_MIN_MS = 8000UL; + static constexpr unsigned long MAX_DWELL_MS = 30UL * 60UL * 1000UL; + + unsigned long enteredMs = 0; + int id0 = -1; + int id1 = -1; + int lastCredited0 = -1; + int lastCredited1 = -1; + + void clear() { + enteredMs = 0; + id0 = -1; + id1 = -1; + } + + // a < 0 clears. forceRestart always resets the dwell clock; otherwise no-op + // when already tracking (a, b). + void noteEntered(const int a, const int b, const unsigned long nowMs, const bool forceRestart = false) { + if (a < 0) { + clear(); + return; + } + if (!forceRestart && a == id0 && b == id1 && enteredMs != 0) { + return; + } + id0 = a; + id1 = b; + enteredMs = nowMs; + } + + // If dwell qualifies and pages > 0, marks credited and returns associated ms. + uint32_t takeCredit(const int a, const int b, const uint32_t pages, const unsigned long nowMs) { + if (pages == 0 || a != id0 || b != id1 || enteredMs == 0 || nowMs < enteredMs) { + return 0; + } + const unsigned long dwellMs = nowMs - enteredMs; + if (dwellMs < MIN_DWELL_MS) { + return 0; + } + const bool sameAsLastCredit = a == lastCredited0 && b == lastCredited1; + if (sameAsLastCredit && dwellMs < REREAD_MIN_MS) { + return 0; + } + lastCredited0 = a; + lastCredited1 = b; + const unsigned long capped = dwellMs > MAX_DWELL_MS ? MAX_DWELL_MS : dwellMs; + return static_cast(capped); + } +};