commit 8360d05182c072986441c8585830d00b89e3f3a8 equwal <truex@equwal.com> 2026-09-21 17:01:16 -0700 Keep the follow near the last found cue, add the mark style A short cue can match the same words at another place of the book. When the user turned pages ahead, the forward search found such a hit, the lower limit moved there, and every cue after it was dropped. The follow then froze. A hit is now dropped when it lies many pages after the nearest cue already found. When no hit is found after the current page, the whole book is read once from that cue. After two misses in a row the page limit is dropped, so a subtitle file that skips a part of the book still works. The miss backoff is capped at four cues. An error in the follow step is logged and the next tick is still scheduled. The controls get a "Mark" button that cycles the style of the mark: Shade, Underline, Strikeout, Invert, None. The mark is drawn with ReaderView:drawHighlightRect, the same as a highlight.
README.md | 22 ++++++----- main.lua | 134 +++++++++++++++++++++++++++++++++++++++++++++++++------------- 2 files changed, 118 insertions(+), 38 deletions(-)
diff --git a/README.md b/README.md index f7de58a..a97a19a 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,7 @@ that is on. | Go to time… | Seeks to a position you type | | Speed | 0.5 to 3.0, for the own clock. The player reports its own speed. | | Offset | Seconds to add to the subtitle times. Use a positive value when the audio file starts with an intro that is not in the book. | +| Mark | How the line is marked: Shade, Underline, Strikeout, Invert or None. Each tap sets the next one. | | What time is this page? | Shows the time of the first cue on the page | Select text in the book and use **SubRead: sync here** to move the audio, or @@ -108,18 +109,19 @@ The plugin adds three actions to the gesture manager and the key bindings: The plugin does not read the whole book for every cue. It takes the first few words of the cue and searches forward from the page you are on, the same -search that KOReader's own full text search uses. A hit before the last found -place is dropped, so the follow can only move forward. Found places are kept -for the session. - -When a cue is not found, the plugin keeps the last place and waits a few cues -before it searches again. The wait doubles with each miss. This keeps the cost +search that KOReader's own full text search uses. A hit before the nearest cue +already found is dropped, and so is a hit many pages after it: the same words +at another place of the book must not pull the follow away. Found places are +kept for the session. + +When a cue is not found after the page you are on, the plugin reads the +whole book once, with the place of the nearest cue it already found as the +lower limit. This is what happens after a jump in the audio, or after you +turned pages away from the narration. When the cue is not in the book at +all, the plugin keeps the last place and waits a few cues before it searches +again. The wait doubles with each miss, up to four cues. This keeps the cost low when a stretch of the book is missing from the subtitle file. -After a jump that you asked for, the place can be behind the page you are on. -The plugin then reads the whole book once, and uses the place of the nearest -cue it already found as the lower limit. - A cue whose text starts with `*` has no place in the book. The plugin leaves the view alone for such a cue. diff --git a/main.lua b/main.lua index 14d452e..53bce65 100644 --- a/main.lua +++ b/main.lua @@ -64,7 +64,7 @@ local MIN_SLEEP = 0.05 -- After a cue is not found, the plugin waits this many cues before it -- searches again. The wait doubles with each miss. A failed search reads the -- book to its end, so this limits the cost when the book misses a stretch. -local MISS_BACKOFF_MAX = 16 +local MISS_BACKOFF_MAX = 4 -- Seconds between two reads of the audio player. The player reports its -- position and speed, so the clock is exact between two reads. A read only @@ -93,6 +93,17 @@ local SETTING_SPEED = "subread_speed" local SETTING_OFFSET = "subread_offset" -- Global setting: follow the audio player through SubRead Overlay. local SETTING_FOLLOW_PLAYER = "subread_follow_player" +-- Global setting: how the mark is drawn. The names are the highlight +-- drawers of ReaderView:drawHighlightRect (readerview.lua:659), plus none. +local SETTING_MARK_STYLE = "subread_mark_style" +local MARK_STYLES = { "lighten", "underscore", "strikeout", "invert", "none" } +local MARK_STYLE_NAMES = { + lighten = _("Shade"), + underscore = _("Underline"), + strikeout = _("Strikeout"), + invert = _("Invert"), + none = _("None"), +} local SubRead = WidgetContainer:extend{ name = "subread", @@ -105,7 +116,6 @@ function SubRead:init() self.srt_path = nil self.current_index = nil -- cue shown now self.located = {} -- cue index -> { start xpointer, end xpointer } - self.last_xpointer = nil -- place of the last found cue self.miss_streak = 0 self.retry_from_index = 0 self.manual_seek = false @@ -287,7 +297,6 @@ function SubRead:setSubtitleFile(path) self.cues = nil self.located = {} self.current_index = nil - self.last_xpointer = nil self:saveState() end @@ -416,9 +425,6 @@ function SubRead:start() if self.player_status ~= "no_player" then self.clock:start(now) end - -- The reader may have moved the view since the last pause, so drop the - -- lower limit and let the search start at the page that is shown. - self.last_xpointer = nil self.miss_streak = 0 self.retry_from_index = 0 self.current_index = nil -- force a redraw of the cue that is on @@ -513,17 +519,26 @@ function SubRead:_tick() self.scheduled = false if not self.cues then return end local now = self:_now() + -- An error in the step must not end the loop: the next tick is always + -- scheduled. + local ok, err = pcall(self._follow, self, now) + if not ok then + logger.err("SubRead: follow step failed:", err) + end + self:_schedule(now, self.clock:getCueTime(now)) +end + +--- Reads the player, then shows the cue of the clock time. +function SubRead:_follow(now) if self.player_on and not self.skip_player_read then self:_syncClockToPlayer(now) end self.skip_player_read = false - local cue_time = self.clock:getCueTime(now) - local index = self.cues:findByTime(cue_time) + local index = self.cues:findByTime(self.clock:getCueTime(now)) if index and index ~= self.current_index then self.current_index = index self:showCue(index) end - self:_schedule(now, cue_time) end --- Sleeps until the next cue starts, or the next read of the player, or @@ -559,22 +574,43 @@ end -- How many cues back to look for a place that is already known. local FLOOR_LOOKBACK = 50 ---- Returns the place of the nearest cue before index that is already found. +--- Returns the place of the nearest cue before index that is already found, +--- and how many cues back it is. function SubRead:_floorFor(index) local first = index - FLOOR_LOOKBACK if first < 1 then first = 1 end for at = index - 1, first, -1 do local found = self.located[at] - if found then return found[1] end + if found then return found[1], index - at end end - return nil + return nil, 0 +end + +-- A hit may lie this many pages after the nearest cue already found, plus +-- one page for each NEAR_CUES_PER_PAGE cues between the two. A cue is about +-- one line, so a hit further away is the same words at another place. +local NEAR_PAGES = 2 +local NEAR_CUES_PER_PAGE = 8 +-- After this many misses in a row the page limit is dropped: the subtitle +-- file may skip a long part of the book. +local NEAR_GIVE_UP = 2 + +--- True when the hit is not too many pages after the floor. +function SubRead:_isNear(floor_xpointer, cue_gap, xpointer) + if not floor_xpointer then return true end + local document = self.ui.document + local from = document:getPageFromXPointer(floor_xpointer) + local to = document:getPageFromXPointer(xpointer) + if not from or not to then return true end + return to - from <= NEAR_PAGES + math.ceil(cue_gap / NEAR_CUES_PER_PAGE) end --- Finds the place of a cue in the book. -- The search starts at the current page and runs forward, so the plugin never --- reads the whole book for every cue. Hits before the last found place are --- dropped, so a word that comes again on the same page cannot pull the --- follow backward. +-- reads the whole book for every cue. A hit before the nearest cue already +-- found is dropped, so a word that comes again on the same page cannot pull +-- the follow backward. A hit many pages after it is dropped too, so the same +-- words at another place of the book cannot pull the follow forward. -- @return start xpointer, end xpointer, or nil function SubRead:_locate(index) local cached = self.located[index] @@ -585,14 +621,35 @@ function SubRead:_locate(index) local cue = self.cues:get(index) if not cue or cue.no_place then return nil end - local origin, floor = SEARCH_FROM_CURRENT_PAGE, self.last_xpointer - if self.manual_seek then + local anchors = Text.anchors(cue.norm) + local floor, gap = self:_floorFor(index) + local pos0, pos1 + if not self.manual_seek then + pos0, pos1 = self:_search(cue, anchors, SEARCH_FROM_CURRENT_PAGE, floor, gap) + end + if not pos0 then + -- The cue is not after the current page: the user turned pages, or + -- the player went back. Read the whole book once, from the nearest + -- cue that is already found. self.manual_seek = false - origin, floor = SEARCH_WHOLE_BOOK, self:_floorFor(index) + pos0, pos1 = self:_search(cue, anchors, SEARCH_WHOLE_BOOK, floor, gap) + end + if not pos0 and floor and self.miss_streak >= NEAR_GIVE_UP then + -- Nothing near the floor for a while: the book may skip a part, or + -- the floor itself is wrong. Take the first hit in the book. + pos0, pos1 = self:_search(cue, anchors, SEARCH_WHOLE_BOOK, nil, 0) + end + if pos0 then + self.located[index] = { pos0, pos1 } end + return pos0, pos1 +end +--- Searches the anchors of a cue. Returns the first hit at or after floor +--- that is near it. +function SubRead:_search(cue, anchors, origin, floor, gap) local document = self.ui.document - for _, anchor in ipairs(Text.anchors(cue.norm)) do + for _, anchor in ipairs(anchors) do -- findText(pattern, origin, direction, case_insensitive, page, regex, -- max_hits, search_flags) (credocument.lua:1442). Each hit holds the -- xpointers "start" and "end" (readersearch.lua:446-447). @@ -600,12 +657,12 @@ function SubRead:_locate(index) true, self.view.state.page, false, SEARCH_MAX_HITS, SEARCH_FLAGS) if hits then for _, hit in ipairs(hits) do - if hit.start and self:_isAtOrAfter(floor, hit.start) then + if hit.start and self:_isAtOrAfter(floor, hit.start) + and self:_isNear(floor, gap, hit.start) then local pos1 = hit["end"] if Text.len(anchor) < Text.len(cue.norm) then - pos1 = self:_cueEnd(cue, hit.start, pos1) + pos1 = self:_cueEnd(cue, hit.start, pos1, origin) end - self.located[index] = { hit.start, pos1 } return hit.start, pos1 end end @@ -624,11 +681,11 @@ local CUE_END_SLACK = 2 -- searched forward from the current page, and the first hit after the start -- that lies close to it gives the end. When the tail is not found, the end -- of the anchor stays. -function SubRead:_cueEnd(cue, pos0, anchor_end) +function SubRead:_cueEnd(cue, pos0, anchor_end, origin) local tail = Text.tail(cue.norm) if not tail then return anchor_end end local document = self.ui.document - local hits = document:findText(tail, SEARCH_FROM_CURRENT_PAGE, SEARCH_FORWARD, + local hits = document:findText(tail, origin, SEARCH_FORWARD, true, self.view.state.page, false, SEARCH_MAX_HITS, SEARCH_FLAGS) if not hits then return anchor_end end local limit = Text.len(cue.norm) * CUE_END_SLACK + Text.TAIL_LENGTH @@ -663,7 +720,6 @@ function SubRead:showCue(index) end self.miss_streak = 0 self.retry_from_index = 0 - self.last_xpointer = pos0 self:drawCue(pos0, pos1) -- Open controls show the clock and the cue of the time when they were built. if self.controls_dialog then self:showControls() end @@ -690,13 +746,33 @@ end --- of a range only. function SubRead:paintTo(bb, x, y) if not self.mark then return end + local style = self:markStyle() + if style == "none" then return end local boxes = self.ui.document:getScreenBoxesFromPositions(self.mark[1], self.mark[2], true) if not boxes then return end for _, box in ipairs(boxes) do - -- The same drawing as the "lighten" highlight style - -- (readerview.lua:673). The boxes are in screen coordinates. - bb:darkenRect(box.x, box.y, box.w, box.h, self.view.highlight.lighten_factor) + -- The same drawing as a highlight. The boxes are in screen + -- coordinates. No color: the gray of the highlight setting. + self.view:drawHighlightRect(bb, x, y, box, style, nil) + end +end + +function SubRead:markStyle() + local style = G_reader_settings:readSetting(SETTING_MARK_STYLE) + if not MARK_STYLE_NAMES[style] then style = MARK_STYLES[1] end + return style +end + +--- Sets the next style of the list and redraws the page. +function SubRead:cycleMarkStyle() + local current = self:markStyle() + for at, style in ipairs(MARK_STYLES) do + if style == current then + G_reader_settings:saveSetting(SETTING_MARK_STYLE, MARK_STYLES[at % #MARK_STYLES + 1]) + break + end end + UIManager:setDirty(self.view.dialog, "ui") end --[[-- Page and time ]]-- @@ -982,6 +1058,8 @@ function SubRead:showControls() end }, }, { + { text = T(_("Mark: %1"), MARK_STYLE_NAMES[self:markStyle()]), + callback = again(function() self:cycleMarkStyle() end) }, { text = _("What time is this page?"), callback = again(function() self:showPageTime() end) }, },