Recently Written · git

subread.koplugin

KOReader plugin: the book follows the narration of an audiobook, from an .srt made by subread.space

git clone https://github.com/equwal/subread.koplugin

Log | Files | Refs


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) },
             },