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


main.lua (42930 bytes)

1 --[[--
2 SubRead read-along.
3 
4 A SubRead subtitle file (.srt) holds the text of the book as cue text, and the
5 time when the narrator reads each line as cue times. This plugin runs a clock
6 with the audio player and keeps the place of the narration on the screen.
7 
8 Only crengine documents (EPUB, FB2, TXT, HTML) are supported. PDF and DjVu
9 have no xpointer, so the place cannot be followed.
10 
11 @module koplugin.SubRead
12 --]]--
13 
14 local ButtonDialog = require("ui/widget/buttondialog")
15 local DateTimeWidget = require("ui/widget/datetimewidget")
16 local Device = require("device")
17 local DictQuickLookup = require("ui/widget/dictquicklookup")
18 local Dispatcher = require("dispatcher")
19 local InfoMessage = require("ui/widget/infomessage")
20 local Notification = require("ui/widget/notification")
21 local SpinWidget = require("ui/widget/spinwidget")
22 local UIManager = require("ui/uimanager")
23 local WidgetContainer = require("ui/widget/container/widgetcontainer")
24 local datetime = require("datetime")
25 local logger = require("logger")
26 local time = require("ui/time")
27 local util = require("util")
28 local _ = require("gettext")
29 local C_ = _.pgettext
30 local T = require("ffi/util").template
31 
32 local Clock = require("subread.clock")
33 local Cues = require("subread.cues")
34 local PlayerState = require("subread.player_state")
35 local Srt = require("subread.srt")
36 local Text = require("subread.text")
37 
38 -- Search flags of the crengine full text search. The list is in
39 -- frontend/apps/reader/modules/readersearch.lua:33-41.
40 -- 0x00FF is every flag except IGNORE_DIACRITICS, the same value that
41 -- KOReader itself uses by default (readersearch.lua:50). The plugin needs
42 -- MATCH_ACROSS_TEXT_NODES (0x0001), because a cue often runs over an inline
43 -- tag, and FOLD_SPACES (0x0020), because the white space of the cue text and
44 -- of the book text is not the same.
45 local SEARCH_FLAGS = 0x00FF
46 -- Stop the search after this many hits. A small number keeps a search that
47 -- hits early cheap. See readersearch.lua:61.
48 local SEARCH_MAX_HITS = 20
49 -- findText origin: -1 whole book, 0 from the current page, 1 after the
50 -- current page. See ReaderSearch:searchFromStart and searchFromCurrent,
51 -- readersearch.lua:708-724.
52 local SEARCH_WHOLE_BOOK = -1
53 local SEARCH_FROM_CURRENT_PAGE = 0
54 -- findText direction: 0 forward, 1 backward. See readersearch.lua:29.
55 local SEARCH_FORWARD = 0
56 
57 -- The tick never sleeps longer than this, so a change of speed or a device
58 -- suspend cannot make the follow late for long.
59 local MAX_SLEEP = 30
60 local MIN_SLEEP = 0.05
61 -- After a cue is not found, the plugin waits this many cues before it
62 -- searches again. The wait doubles with each miss. A failed search reads the
63 -- book to its end, so this limits the cost when the book misses a stretch.
64 local MISS_BACKOFF_MAX = 16
65 
66 -- Seconds between two reads of the audio player. The player reports its
67 -- position and speed, so the clock is exact between two reads. A read only
68 -- has to notice a pause, a seek or a new speed.
69 local PLAYER_POLL = 2
70 -- Seconds between two reads while no player has a media session.
71 local PLAYER_POLL_IDLE = 5
72 -- A player position this many seconds away from the clock is a seek.
73 local PLAYER_JUMP = 3
74 -- Seconds between two checks for the end of a dictionary lookup.
75 local LOOKUP_POLL = 0.5
76 -- The lookup window can open late. Give up the wait for it after this many
77 -- checks and start the player again.
78 local LOOKUP_WAIT_MAX = 20
79 
80 -- Characters of cue text shown in a dialog title.
81 local TITLE_CHARS = 60
82 -- Characters kept from a selection when the user syncs by selecting text.
83 -- A long selection can hold the text of more than one cue.
84 local SYNC_NEEDLE_CHARS = 24
85 
86 -- Per book settings, kept in the book's sidecar file.
87 local SETTING_FILE = "subread_srt_file"
88 local SETTING_POSITION = "subread_position"
89 local SETTING_SPEED = "subread_speed"
90 local SETTING_OFFSET = "subread_offset"
91 -- Global setting: follow the audio player through SubRead Overlay.
92 local SETTING_FOLLOW_PLAYER = "subread_follow_player"
93 
94 local SubRead = WidgetContainer:extend{
95     name = "subread",
96     is_doc_only = true,
97 }
98 
99 function SubRead:init()
100     self.clock = Clock.new()
101     self.cues = nil            -- Cues index, built when the file is read
102     self.srt_path = nil
103     self.current_index = nil   -- cue shown now
104     self.located = {}          -- cue index -> { start xpointer, end xpointer }
105     self.last_xpointer = nil   -- place of the last found cue
106     self.miss_streak = 0
107     self.retry_from_index = 0
108     self.manual_seek = false
109     self.scheduled = false
110     self.tick_task = function() self:_tick() end
111 
112     -- The bridge to the audio player. Only Android has one.
113     self.player = nil
114     if Device:isAndroid() then
115         local ok, module = pcall(require, "subread.android_player")
116         if ok then
117             self.player = module
118         else
119             logger.warn("SubRead: no player bridge:", module)
120         end
121     end
122     self.player_on = false      -- true while the follow reads the player
123     self.player_status = nil    -- "no_player" while the player has no media session
124     self.lookup_paused = false  -- true while the player waits for a dictionary lookup
125     self.lookup_external = false
126     self.lookup_seen = false
127     self.lookup_waited = 0
128     self.lookup_task = function() self:_checkLookupDone() end
129     self.skip_player_read = false
130 
131     self:placeInToolsMenu()
132     self.ui.menu:registerToMainMenu(self)
133     if self:isSupported() then
134         self:registerDispatcherActions()
135         self:addToHighlightDialog()
136     end
137 end
138 
139 function SubRead:isSupported()
140     -- self.ui.rolling exists only for crengine documents (readerui.lua:382).
141     return self.ui.rolling ~= nil
142 end
143 
144 --- True when the user wants the follow to read the audio player.
145 function SubRead:followsPlayer()
146     return self.player ~= nil and G_reader_settings:nilOrTrue(SETTING_FOLLOW_PLAYER)
147 end
148 
149 --- Puts the menu entry first in the Tools menu, so it is on the first page.
150 -- ReaderMenu sorts the entries with the cached order module
151 -- (readermenu.lua:350). An id that is not in the order goes to the end of
152 -- the menu, which is the second page.
153 function SubRead:placeInToolsMenu()
154     local ok, order = pcall(require, "ui/elements/reader_menu_order")
155     if ok and type(order) == "table" and type(order.tools) == "table"
156         and not util.arrayContains(order.tools, "subread") then
157         table.insert(order.tools, 1, "subread")
158     end
159 end
160 
161 function SubRead:registerDispatcherActions()
162     Dispatcher:registerAction("subread_controls",
163         { category = "none", event = "SubReadShowControls",
164           title = _("SubRead: controls"), rolling = true })
165     Dispatcher:registerAction("subread_toggle",
166         { category = "none", event = "SubReadToggle",
167           title = _("SubRead: start or pause"), rolling = true })
168     Dispatcher:registerAction("subread_sync_page",
169         { category = "none", event = "SubReadSyncToPage",
170           title = _("SubRead: sync to this page"), rolling = true, separator = true })
171 end
172 
173 --[[-- Settings ]]--
174 
175 function SubRead:onReadSettings(config)
176     self.srt_path = config:readSetting(SETTING_FILE)
177     self.clock = Clock.new{
178         position = config:readSetting(SETTING_POSITION) or 0,
179         speed = config:readSetting(SETTING_SPEED) or 1.0,
180         offset = config:readSetting(SETTING_OFFSET) or 0,
181     }
182 end
183 
184 function SubRead:onReaderReady()
185     if not self:isSupported() then return end
186     if self.srt_path and not util.fileExists(self.srt_path) then
187         logger.info("SubRead: saved subtitle file is gone:", self.srt_path)
188         self.srt_path = nil
189     end
190     if not self.srt_path then
191         self.srt_path = self:findSubtitleBeside(self.ui.document.file)
192     end
193 end
194 
195 function SubRead:onSaveSettings()
196     self:saveState()
197 end
198 
199 function SubRead:saveState()
200     local settings = self.ui.doc_settings
201     if not settings then return end
202     if not self.srt_path then
203         -- Leave no keys in the sidecar file of a book that does not use the
204         -- plugin, and clear them when the user forgets the subtitle file.
205         settings:delSetting(SETTING_FILE)
206         settings:delSetting(SETTING_POSITION)
207         settings:delSetting(SETTING_SPEED)
208         settings:delSetting(SETTING_OFFSET)
209         return
210     end
211     settings:saveSetting(SETTING_FILE, self.srt_path)
212     settings:saveSetting(SETTING_POSITION, self.clock:getPosition(self:_now()))
213     settings:saveSetting(SETTING_SPEED, self.clock.speed)
214     settings:saveSetting(SETTING_OFFSET, self.clock.offset)
215 end
216 
217 function SubRead:onCloseDocument()
218     self:stop()
219 end
220 
221 function SubRead:onCloseWidget()
222     self:_unschedule()
223     self:_endLookupWait()
224     self.tick_task = nil
225 end
226 
227 --- Looks for <book>.srt or <book>.<lang>.srt beside the book file.
228 function SubRead:findSubtitleBeside(book_path)
229     if not book_path then return nil end
230     local directory, file_name = util.splitFilePathName(book_path)
231     local base = util.splitFileNameSuffix(file_name)
232     if base == "" then return nil end
233     local candidate = directory .. base .. ".srt"
234     if util.fileExists(candidate) then return candidate end
235     -- <book>.<lang>.srt, for example "Dracula.en.srt". The language part is
236     -- any name without a dot, so the loop does not need a list of languages.
237     local lfs = require("libs/libkoreader-lfs")
238     local ok, iterator = pcall(lfs.dir, directory == "" and "." or directory)
239     if not ok or type(iterator) ~= "function" then return nil end
240     local prefix = base .. "."
241     for entry in iterator do
242         if entry:sub(1, #prefix) == prefix and entry:sub(-4):lower() == ".srt" then
243             local middle = entry:sub(#prefix + 1, -5)
244             if middle ~= "" and not middle:find(".", 1, true) then
245                 return directory .. entry
246             end
247         end
248     end
249     return nil
250 end
251 
252 --[[-- Subtitle file ]]--
253 
254 --- Reads the subtitle file. Returns true when the index is ready.
255 function SubRead:loadSubtitles()
256     if self.cues then return true end
257     if not self.srt_path then
258         self:showMessage(_("No SubRead subtitle file is loaded for this book."))
259         return false
260     end
261     local list, err = Srt.parseFile(self.srt_path)
262     if not list then
263         self:showMessage(T(_("Could not read the subtitle file:\n%1"), tostring(err)))
264         return false
265     end
266     if #list == 0 then
267         self:showMessage(_("The subtitle file holds no cues."))
268         return false
269     end
270     self.cues = Cues.new(list)
271     self.located = {}
272     logger.info("SubRead: loaded", #list, "cues from", self.srt_path)
273     return true
274 end
275 
276 function SubRead:setSubtitleFile(path)
277     self:stop()
278     self.srt_path = path
279     self.cues = nil
280     self.located = {}
281     self.current_index = nil
282     self.last_xpointer = nil
283     self:saveState()
284 end
285 
286 function SubRead:chooseSubtitleFile(touchmenu_instance)
287     local PathChooser = require("ui/widget/pathchooser")
288     local directory = util.splitFilePathName(self.ui.document.file)
289     UIManager:show(PathChooser:new{
290         title = _("Select a SubRead subtitle file"),
291         path = directory,
292         select_directory = false,
293         -- With a file_filter set, FileChooser shows only the files that pass
294         -- it, even if they are not a book (filechooser.lua:92).
295         file_filter = function(file_name)
296             return file_name:sub(-4):lower() == ".srt"
297         end,
298         onConfirm = function(file_path)
299             self:setSubtitleFile(file_path)
300             if touchmenu_instance then touchmenu_instance:updateItems() end
301         end,
302     })
303 end
304 
305 --[[-- The clock ]]--
306 
307 --- Returns a monotonic time in seconds.
308 -- getElapsedTimeSinceBoot adds the time in standby and in suspend
309 -- (uimanager.lua:1133), so the follow stays right when the device sleeps
310 -- while the audio player runs on.
311 function SubRead:_now()
312     return time.to_number(UIManager:getElapsedTimeSinceBoot())
313 end
314 
315 --[[-- The audio player ]]--
316 
317 --- Reads the state of the audio player through SubRead Overlay.
318 -- @return the state table of PlayerState.parse, or nil and an error name:
319 --   "not_installed", "no_notification_access" or "no_player".
320 function SubRead:_readPlayer()
321     local line = self.player.query()
322     if not line then return nil, "not_installed" end
323     return PlayerState.parse(line)
324 end
325 
326 --- Sets the clock from the audio player.
327 -- Returns true when the follow can go on with the player. Returns false when
328 -- the player cannot be reached at all; the follow then runs its own clock.
329 function SubRead:_syncClockToPlayer(now)
330     local state, err = self:_readPlayer()
331     if not state then
332         if err == "no_player" then
333             -- The player app is closed, or it has no session yet. Keep the
334             -- place and read again later.
335             self.player_status = err
336             self.clock:pause(now)
337             return true
338         end
339         self.player_on = false
340         self.player_status = nil
341         self:showMessage(self:playerProblemText(err))
342         return false
343     end
344     self.player_status = nil
345     if state.position then
346         -- A seek in the player app can go backward. The normal search runs
347         -- forward from the current page, so treat a jump like a seek that
348         -- the user asked for here.
349         local jumped = math.abs(state.position - self.clock:getPosition(now)) > PLAYER_JUMP
350         self.clock:setSpeed(state.speed, now)
351         self.clock:seek(state.position, now)
352         if jumped then self:_manualSeek() end
353     end
354     if state.playing then
355         self.clock:start(now)
356     else
357         self.clock:pause(now)
358     end
359     return true
360 end
361 
362 function SubRead:playerProblemText(err)
363     if err == "no_notification_access" then
364         return _("SubRead Overlay has no notification access, so the audio player cannot be read. Give it the access in its app. Until then the plugin runs its own clock.")
365     end
366     return _("SubRead Overlay was not found. Install it and give it notification access, so the plugin can follow the audio player. Until then the plugin runs its own clock.")
367 end
368 
369 --- Sends play or pause to the player when the follow reads it.
370 function SubRead:_playerCall(method)
371     if not self.player_on then return end
372     self.player.call(method)
373     self.skip_player_read = true
374 end
375 
376 --- Moves the clock, and the player when the follow reads it.
377 -- The player takes a moment for a seek or a play. The read that comes at
378 -- once after would bring the old state back, so that one read is skipped.
379 function SubRead:_seekTo(position, now)
380     self.clock:seek(position, now)
381     if self.player_on then
382         self.player.call("seek", PlayerState.seekArgument(self.clock:getPosition(now)))
383         self.skip_player_read = true
384     end
385 end
386 
387 --[[-- Start, pause and stop ]]--
388 
389 function SubRead:start()
390     if not self:checkSupported() then return end
391     if not self:loadSubtitles() then return end
392     local now = self:_now()
393     if self:followsPlayer() then
394         self.player_on = true
395         if self:_syncClockToPlayer(now) then
396             self:_playerCall("play")
397         end
398     end
399     if not self.player_on then
400         if self.clock:getPosition(now) <= 0 then
401             local index = self:cueIndexForCurrentPage()
402             if index then
403                 self.clock:seekCueTime(self.cues:get(index).start, now)
404             end
405         end
406     end
407     -- The player takes a moment to start. The next read confirms the state.
408     if self.player_status ~= "no_player" then
409         self.clock:start(now)
410     end
411     -- The reader may have moved the view since the last pause, so drop the
412     -- lower limit and let the search start at the page that is shown.
413     self.last_xpointer = nil
414     self.miss_streak = 0
415     self.retry_from_index = 0
416     self.current_index = nil -- force a redraw of the cue that is on
417     self:_tick()
418 end
419 
420 function SubRead:pause()
421     local now = self:_now()
422     self.clock:pause(now)
423     self:_playerCall("pause")
424     if self.player_on then
425         -- Keep the reads, so a start from the player app is seen.
426         self:_schedule(now, self.clock:getCueTime(now))
427     else
428         self:_unschedule()
429     end
430     self:saveState()
431 end
432 
433 function SubRead:stop()
434     if self.clock:isRunning() then
435         self.clock:pause(self:_now())
436     end
437     self:_endLookupWait()
438     self.player_on = false
439     self.player_status = nil
440     self:_unschedule()
441     if self:isSupported() and self.ui.document then
442         self.ui.document:clearSelection()
443     end
444     self:saveState()
445 end
446 
447 --- True when the follow runs: the clock, or the reads of the player.
448 function SubRead:isActive()
449     return self.clock:isRunning() or self.player_on
450 end
451 
452 function SubRead:toggle()
453     if self.clock:isRunning() then
454         self:pause()
455     else
456         self:start()
457     end
458 end
459 
460 --- Prepares for a jump that the user asked for.
461 -- The user can jump backward, but the normal search only runs forward from
462 -- the current page. After a manual jump the next search reads the whole book
463 -- and uses the place of the nearest cue found before as its lower limit.
464 function SubRead:_manualSeek()
465     self.manual_seek = true
466     self.miss_streak = 0
467     self.retry_from_index = 0
468     self.current_index = nil
469 end
470 
471 --- Moves the clock and shows the new place at once.
472 function SubRead:seekAndShow(position)
473     local now = self:_now()
474     self:_seekTo(position, now)
475     self:_manualSeek()
476     self:_tick()
477 end
478 
479 function SubRead:skipCues(count)
480     if not self:loadSubtitles() then return end
481     local now = self:_now()
482     local index = self.cues:findNearest(self.clock:getCueTime(now))
483     if not index then return end
484     index = index + count
485     if index < 1 then index = 1 end
486     if index > self.cues:count() then index = self.cues:count() end
487     self:_seekTo(self.cues:get(index).start + self.clock.offset, now)
488     self:_manualSeek()
489     self:_tick()
490 end
491 
492 --[[-- The follow loop ]]--
493 
494 function SubRead:_unschedule()
495     if self.scheduled then
496         UIManager:unschedule(self.tick_task)
497         self.scheduled = false
498     end
499 end
500 
501 --- One step of the follow loop.
502 -- The screen is only touched when the cue changes, never on a bare timer tick.
503 function SubRead:_tick()
504     self.scheduled = false
505     if not self.cues then return end
506     local now = self:_now()
507     if self.player_on and not self.skip_player_read then
508         self:_syncClockToPlayer(now)
509     end
510     self.skip_player_read = false
511     local cue_time = self.clock:getCueTime(now)
512     local index = self.cues:findByTime(cue_time)
513     if index and index ~= self.current_index then
514         self.current_index = index
515         self:showCue(index)
516     end
517     self:_schedule(now, cue_time)
518 end
519 
520 --- Sleeps until the next cue starts, or the next read of the player, or
521 --- MAX_SLEEP, whichever is first.
522 function SubRead:_schedule(now, cue_time)
523     self:_unschedule()
524     local delay = MAX_SLEEP
525     if self.player_on then
526         delay = self.player_status == "no_player" and PLAYER_POLL_IDLE or PLAYER_POLL
527     elseif not self.clock:isRunning() then
528         return
529     end
530     local next_index = self.cues:nextAfter(cue_time)
531     if next_index then
532         local wait = self.clock:realSecondsUntilCueTime(self.cues:get(next_index).start, now)
533         if wait and wait < delay then delay = wait end
534     end
535     if delay < MIN_SLEEP then delay = MIN_SLEEP end
536     UIManager:scheduleIn(delay, self.tick_task)
537     self.scheduled = true
538 end
539 
540 --- True when the xpointer is at or after the lower limit.
541 function SubRead:_isAtOrAfter(floor_xpointer, xpointer)
542     if not floor_xpointer then return true end
543     -- compareXPointers returns 1 when xp2 is after xp1, 0 when they are the
544     -- same, -1 when not, nil when one of them is not valid
545     -- (credocument.lua:750-754).
546     local order = self.ui.document:compareXPointers(floor_xpointer, xpointer)
547     return order == nil or order >= 0
548 end
549 
550 -- How many cues back to look for a place that is already known.
551 local FLOOR_LOOKBACK = 50
552 
553 --- Returns the place of the nearest cue before index that is already found.
554 function SubRead:_floorFor(index)
555     local first = index - FLOOR_LOOKBACK
556     if first < 1 then first = 1 end
557     for at = index - 1, first, -1 do
558         local found = self.located[at]
559         if found then return found[1] end
560     end
561     return nil
562 end
563 
564 --- Finds the place of a cue in the book.
565 -- The search starts at the current page and runs forward, so the plugin never
566 -- reads the whole book for every cue. Hits before the last found place are
567 -- dropped, so a word that comes again on the same page cannot pull the
568 -- follow backward.
569 -- @return start xpointer, end xpointer, or nil
570 function SubRead:_locate(index)
571     local cached = self.located[index]
572     if cached then
573         self.manual_seek = false
574         return cached[1], cached[2]
575     end
576     local cue = self.cues:get(index)
577     if not cue or cue.no_place then return nil end
578 
579     local origin, floor = SEARCH_FROM_CURRENT_PAGE, self.last_xpointer
580     if self.manual_seek then
581         self.manual_seek = false
582         origin, floor = SEARCH_WHOLE_BOOK, self:_floorFor(index)
583     end
584 
585     local document = self.ui.document
586     for _, anchor in ipairs(Text.anchors(cue.norm)) do
587         -- findText(pattern, origin, direction, case_insensitive, page, regex,
588         -- max_hits, search_flags) (credocument.lua:1442). Each hit holds the
589         -- xpointers "start" and "end" (readersearch.lua:446-447).
590         local hits = document:findText(anchor, origin, SEARCH_FORWARD,
591             true, self.view.state.page, false, SEARCH_MAX_HITS, SEARCH_FLAGS)
592         if hits then
593             for _, hit in ipairs(hits) do
594                 if hit.start and self:_isAtOrAfter(floor, hit.start) then
595                     local pos1 = hit["end"]
596                     if Text.len(anchor) < Text.len(cue.norm) then
597                         pos1 = self:_cueEnd(cue, hit.start, pos1)
598                     end
599                     self.located[index] = { hit.start, pos1 }
600                     return hit.start, pos1
601                 end
602             end
603         end
604     end
605     return nil
606 end
607 
608 -- The text between the start of a cue and the end of its tail may hold ruby
609 -- text, so it can be longer than the cue. A hit further away than this
610 -- factor is a tail that belongs to another line.
611 local CUE_END_SLACK = 2
612 
613 --- Finds the end of the cue text, so the mark covers the whole line.
614 -- The anchor is only the first characters of the cue. The tail of the cue is
615 -- searched forward from the current page, and the first hit after the start
616 -- that lies close to it gives the end. When the tail is not found, the end
617 -- of the anchor stays.
618 function SubRead:_cueEnd(cue, pos0, anchor_end)
619     local tail = Text.tail(cue.norm)
620     if not tail then return anchor_end end
621     local document = self.ui.document
622     local hits = document:findText(tail, SEARCH_FROM_CURRENT_PAGE, SEARCH_FORWARD,
623         true, self.view.state.page, false, SEARCH_MAX_HITS, SEARCH_FLAGS)
624     if not hits then return anchor_end end
625     local limit = Text.len(cue.norm) * CUE_END_SLACK + Text.TAIL_LENGTH
626     for _, hit in ipairs(hits) do
627         if hit.start and hit["end"] and self:_isAtOrAfter(pos0, hit.start) then
628             local between = document:getTextFromXPointers(pos0, hit["end"], false)
629             if between and Text.len(Text.normalize(between)) <= limit then
630                 return hit["end"]
631             end
632             -- The first hit after the start is already too far.
633             return anchor_end
634         end
635     end
636     return anchor_end
637 end
638 
639 --- Brings the cue on screen and marks it.
640 function SubRead:showCue(index)
641     local cue = self.cues:get(index)
642     if not cue or cue.no_place then return end
643     -- After a miss, do not search again for the next few cues.
644     if index < self.retry_from_index then return end
645 
646     local pos0, pos1 = self:_locate(index)
647     if not pos0 then
648         self.miss_streak = self.miss_streak + 1
649         local step = math.floor(2 ^ (self.miss_streak - 1))
650         if step > MISS_BACKOFF_MAX then step = MISS_BACKOFF_MAX end
651         self.retry_from_index = index + step
652         logger.dbg("SubRead: cue", index, "not found, next try at", self.retry_from_index)
653         return
654     end
655     self.miss_streak = 0
656     self.retry_from_index = 0
657     self.last_xpointer = pos0
658     self:drawCue(pos0, pos1)
659     -- Open controls show the clock and the cue of the time when they were built.
660     if self.controls_dialog then self:showControls() end
661 end
662 
663 --- Turns the page if needed and draws the mark.
664 -- The mark is the crengine selection, the same one that KOReader draws for a
665 -- full text search hit (readersearch.lua:946). crengine draws it itself, so
666 -- it costs no extra widget, it survives a page turn, and it is never written
667 -- into the book's annotations.
668 function SubRead:drawCue(pos0, pos1)
669     local document = self.ui.document
670     local was_visible = document:isXPointerInCurrentPage(pos0)
671     if not was_visible then
672         self.ui.rolling:onGotoXPointer(pos0)
673     end
674     if pos1 then
675         document:getTextFromXPointers(pos0, pos1, true)
676     end
677     -- A page turn needs a partial refresh. A mark that moves inside the same
678     -- page only needs the light "ui" refresh.
679     UIManager:setDirty(self.view.dialog, was_visible and "ui" or "partial")
680 end
681 
682 --- Draws the cue that is on again, after something else used the selection.
683 function SubRead:redrawCurrentCue()
684     if not self.current_index then return end
685     local pos0, pos1 = self:_locate(self.current_index)
686     if pos0 then self:drawCue(pos0, pos1) end
687 end
688 
689 --[[-- Page and time ]]--
690 
691 --- Returns the text of the current page, or nil.
692 function SubRead:currentPageText()
693     local document = self.ui.document
694     local page = document:getCurrentPage()
695     if not page then return nil end
696     -- getPageXPointer gives the xpointer of the first line of a page
697     -- (credocument.lua:888).
698     local from_xpointer = document:getPageXPointer(page)
699     local to_xpointer = document:getPageXPointer(page + 1)
700     if not from_xpointer or not to_xpointer then return nil end
701     return document:getTextFromXPointers(from_xpointer, to_xpointer, false)
702 end
703 
704 --- Returns the index of the first cue on the current page, or nil.
705 function SubRead:cueIndexForCurrentPage()
706     if not self.cues then return nil end
707     local page_text = self:currentPageText()
708     if not page_text or page_text == "" then return nil end
709     local index = self.cues:findIndexForText(page_text)
710     -- getTextFromXPointers with draw_selection false can drop the mark, so
711     -- put it back.
712     if self.clock:isRunning() then self:redrawCurrentCue() end
713     return index
714 end
715 
716 function SubRead:showPageTime()
717     if not self:checkSupported() then return end
718     if not self:loadSubtitles() then return end
719     local index = self:cueIndexForCurrentPage()
720     if not index then
721         self:showMessage(_("No cue of the subtitle file was found on this page."))
722         return
723     end
724     local cue = self.cues:get(index)
725     local player_time = cue.start + self.clock.offset
726     self:showMessage(T(_("This page starts at %1 in the audio.\n\n%2"),
727         datetime.secondsToClock(player_time, false),
728         Text.ellipsize(cue.text, TITLE_CHARS)))
729 end
730 
731 function SubRead:syncToPage()
732     if not self:checkSupported() then return end
733     if not self:loadSubtitles() then return end
734     local index = self:cueIndexForCurrentPage()
735     if not index then
736         self:showMessage(_("No cue of the subtitle file was found on this page."))
737         return
738     end
739     self:syncToCue(index)
740 end
741 
742 --- Sets the clock to the start of a cue. When the follow reads the player,
743 --- the audio moves there too.
744 function SubRead:syncToCue(index)
745     local cue = self.cues:get(index)
746     if not cue then return end
747     local now = self:_now()
748     if self:followsPlayer() and not self.player_on then
749         -- "Move the audio to this page" before a start: begin the follow of
750         -- the player, so the seek reaches it.
751         self.player_on = true
752         self:_syncClockToPlayer(now)
753     end
754     local position = cue.start + self.clock.offset
755     self:_seekTo(position, now)
756     self:_manualSeek()
757     self:_tick()
758     local clock_text = datetime.secondsToClock(position, false)
759     self:showNotification(self.player_on and T(_("Audio moved to %1"), clock_text)
760                                           or T(_("Synced to %1"), clock_text))
761 end
762 
763 --- Text of the action that sets the place: it moves the audio when the
764 --- follow reads the player, else the clock of the plugin.
765 function SubRead:syncToPageText()
766     if self:followsPlayer() then
767         return _("Move the audio to this page")
768     end
769     return _("Sync the clock to this page")
770 end
771 
772 --[[-- Dictionary lookup ]]--
773 
774 --- ReaderDictionary sends this before it looks a word up
775 --- (readerdictionary.lua:1429). The player pauses while the user reads.
776 function SubRead:onWordLookedUp()
777     if not self.player_on or self.lookup_paused then return end
778     local state = self:_readPlayer()
779     if not state or not state.playing then return end
780     self:_playerCall("pause")
781     self.clock:pause(self:_now())
782     self.lookup_paused = true
783     self.lookup_seen = false
784     self.lookup_waited = 0
785     -- An external dictionary is another app. KOReader gets a Resume event
786     -- when the user comes back (device/android/device.lua:179).
787     self.lookup_external = Device:canExternalDictLookup()
788         and G_reader_settings:isTrue("external_dict_lookup")
789     if not self.lookup_external then
790         UIManager:scheduleIn(LOOKUP_POLL, self.lookup_task)
791     end
792 end
793 
794 --- Starts the player again when the last lookup window is closed.
795 -- DictQuickLookup keeps its open windows in window_list
796 -- (dictquicklookup.lua:176) and sends no event when the last one closes.
797 function SubRead:_checkLookupDone()
798     if not self.lookup_paused then return end
799     local open = #DictQuickLookup.window_list > 0
800     if open then
801         self.lookup_seen = true
802     elseif not self.lookup_seen then
803         -- The lookup runs before the window opens. Wait for the window.
804         self.lookup_waited = self.lookup_waited + 1
805         if self.lookup_waited >= LOOKUP_WAIT_MAX then
806             self:_resumeAfterLookup()
807             return
808         end
809     end
810     if open or not self.lookup_seen then
811         UIManager:scheduleIn(LOOKUP_POLL, self.lookup_task)
812         return
813     end
814     self:_resumeAfterLookup()
815 end
816 
817 function SubRead:_endLookupWait()
818     UIManager:unschedule(self.lookup_task)
819     self.lookup_paused = false
820 end
821 
822 function SubRead:_resumeAfterLookup()
823     self:_endLookupWait()
824     if not self.player_on then return end
825     self:_playerCall("play")
826     self.clock:start(self:_now())
827     self:_tick()
828 end
829 
830 function SubRead:onResume()
831     if self.lookup_paused and self.lookup_external then
832         self:_resumeAfterLookup()
833     end
834 end
835 
836 --- Sets the clock from a piece of text that the user selected.
837 function SubRead:syncToSelectedText(selected)
838     if not self:checkSupported() then return end
839     if not self:loadSubtitles() then return end
840     local needle = Text.normalize(selected or "")
841     if Text.len(needle) < 4 then
842         self:showMessage(_("Select a few more words, so the line can be found."))
843         return
844     end
845     -- A long selection can hold text of more than one cue, so cut it down.
846     if Text.len(needle) > SYNC_NEEDLE_CHARS then
847         needle = Text.sub(needle, 1, SYNC_NEEDLE_CHARS)
848     end
849     local index = self.cues:findIndexContaining(needle)
850     if not index then
851         self:showMessage(_("That text was not found in the subtitle file."))
852         return
853     end
854     self:syncToCue(index)
855 end
856 
857 function SubRead:addToHighlightDialog()
858     -- "13_" sorts after the last button that KOReader itself adds
859     -- (readerhighlight.lua:246-261, qrclipboard.koplugin/main.lua:30).
860     self.ui.highlight:addToHighlightDialog("13_subread_sync", function(this)
861         return {
862             text = _("SubRead: sync here"),
863             callback = function()
864                 local selected = this.selected_text and this.selected_text.text
865                 this:onClose(true)
866                 self:syncToSelectedText(selected)
867             end,
868         }
869     end)
870 end
871 
872 --[[-- Dialogs ]]--
873 
874 function SubRead:checkSupported()
875     if self:isSupported() then return true end
876     self:showMessage(_("SubRead read-along needs an EPUB, FB2, TXT or HTML book. PDF and DjVu are not supported."))
877     return false
878 end
879 
880 function SubRead:showMessage(text)
881     UIManager:show(InfoMessage:new{ text = text })
882 end
883 
884 function SubRead:showNotification(text)
885     UIManager:show(Notification:new{ text = text, timeout = 2 })
886 end
887 
888 function SubRead:statusText()
889     local now = self:_now()
890     local clock_text = datetime.secondsToClock(self.clock:getPosition(now), false)
891     local line
892     if self.player_on and self.player_status == "no_player" then
893         line = _("No audio player is open")
894     elseif self.player_on then
895         line = self.clock:isRunning() and T(_("Player: %1, playing"), clock_text)
896                                       or T(_("Player: %1, paused"), clock_text)
897     else
898         line = self.clock:isRunning() and T(_("Clock: %1, running"), clock_text)
899                                       or T(_("Clock: %1, paused"), clock_text)
900     end
901     if self.cues and self.current_index then
902         local cue = self.cues:get(self.current_index)
903         if cue then
904             line = line .. "\n" .. Text.ellipsize(cue.text, TITLE_CHARS)
905         end
906     end
907     return line
908 end
909 
910 function SubRead:closeControls()
911     if self.controls_dialog then
912         UIManager:close(self.controls_dialog)
913         self.controls_dialog = nil
914     end
915 end
916 
917 --- Shows the read-along controls. The dialog is built again after each
918 --- button, so the title always shows the clock as it is now.
919 function SubRead:showControls()
920     if not self:checkSupported() then return end
921     if not self:loadSubtitles() then return end
922     self:closeControls()
923 
924     local function again(action)
925         return function()
926             action()
927             self:showControls()
928         end
929     end
930 
931     self.controls_dialog = ButtonDialog:new{
932         title = self:statusText(),
933         title_align = "center",
934         buttons = {
935             {
936                 { text = _("Prev. cue"),
937                   callback = again(function() self:skipCues(-1) end) },
938                 { text = _("-10 s"),
939                   callback = again(function() self:seekAndShow(self.clock:getPosition(self:_now()) - 10) end) },
940                 { text = _("+10 s"),
941                   callback = again(function() self:seekAndShow(self.clock:getPosition(self:_now()) + 10) end) },
942                 { text = _("Next cue"),
943                   callback = again(function() self:skipCues(1) end) },
944             },
945             {
946                 { text = self.clock:isRunning() and _("Pause") or _("Start"),
947                   callback = again(function() self:toggle() end) },
948                 { text = _("Stop"),
949                   callback = function()
950                       self:stop()
951                       self:closeControls()
952                   end },
953             },
954             {
955                 { text = self:syncToPageText(),
956                   callback = again(function() self:syncToPage() end) },
957                 { text = _("Go to time…"),
958                   callback = function()
959                       self:closeControls()
960                       self:showGoToTime()
961                   end },
962             },
963             {
964                 { text = T(_("Speed: %1"), string.format("%.2f", self.clock.speed)),
965                   callback = function()
966                       self:closeControls()
967                       self:showSpeed()
968                   end },
969                 { text = T(_("Offset: %1 s"), string.format("%+d", self.clock.offset)),
970                   callback = function()
971                       self:closeControls()
972                       self:showOffset()
973                   end },
974             },
975             {
976                 { text = _("What time is this page?"),
977                   callback = again(function() self:showPageTime() end) },
978             },
979         },
980     }
981     UIManager:show(self.controls_dialog)
982 end
983 
984 function SubRead:showGoToTime()
985     if not self:checkSupported() then return end
986     if not self:loadSubtitles() then return end
987     local position = self.clock:getPosition(self:_now())
988     local hour = math.floor(position / 3600)
989     local minute = math.floor(position / 60) % 60
990     local second = math.floor(position) % 60
991     UIManager:show(DateTimeWidget:new{
992         title_text = _("Go to time"),
993         info_text = _("Enter the position of the audio player."),
994         hour = hour,
995         hour_max = 99,
996         min = minute,
997         sec = second,
998         ok_text = _("Go"),
999         callback = function(widget)
1000             -- The widget writes the picked values back into itself before it
1001             -- calls us (datetimewidget.lua:396-406).
1002             self:seekAndShow(widget.hour * 3600 + widget.min * 60 + widget.sec)
1003             self:saveState()
1004         end,
1005     })
1006 end
1007 
1008 function SubRead:showSpeed()
1009     UIManager:show(SpinWidget:new{
1010         title_text = _("Player speed"),
1011         info_text = _("Set this to the speed of your audio player."),
1012         value = self.clock.speed,
1013         value_min = Clock.SPEED_MIN,
1014         value_max = Clock.SPEED_MAX,
1015         value_step = 0.05,
1016         value_hold_step = 0.25,
1017         precision = "%.2f",
1018         default_value = 1.0,
1019         callback = function(widget)
1020             self.clock:setSpeed(widget.value, self:_now())
1021             self:saveState()
1022             self:_tick()
1023         end,
1024     })
1025 end
1026 
1027 function SubRead:showOffset()
1028     UIManager:show(SpinWidget:new{
1029         title_text = _("Audio offset"),
1030         info_text = _("Seconds to add to the subtitle times. Use a positive value when the audio file starts with an intro."),
1031         value = self.clock.offset,
1032         value_min = -600,
1033         value_max = 600,
1034         value_step = 1,
1035         value_hold_step = 10,
1036         default_value = 0,
1037         unit = C_("Time", "s"),
1038         callback = function(widget)
1039             self.clock:setOffset(widget.value)
1040             self:saveState()
1041             self:_tick()
1042         end,
1043     })
1044 end
1045 
1046 --[[-- Events from a gesture or a key ]]--
1047 
1048 function SubRead:onSubReadShowControls()
1049     self:showControls()
1050     return true
1051 end
1052 
1053 function SubRead:onSubReadToggle()
1054     if not self:checkSupported() then return true end
1055     if not self:loadSubtitles() then return true end
1056     self:toggle()
1057     if self.player_on then
1058         self:showNotification(self.clock:isRunning() and _("Audio playing") or _("Audio paused"))
1059     else
1060         self:showNotification(self.clock:isRunning() and _("Read-along running") or _("Read-along paused"))
1061     end
1062     return true
1063 end
1064 
1065 function SubRead:onSubReadSyncToPage()
1066     self:syncToPage()
1067     return true
1068 end
1069 
1070 --[[-- Menu ]]--
1071 
1072 function SubRead:subtitleMenuText()
1073     if not self.srt_path then
1074         return _("Subtitle file: none")
1075     end
1076     local _directory, file_name = util.splitFilePathName(self.srt_path)
1077     return T(_("Subtitle file: %1"), file_name)
1078 end
1079 
1080 function SubRead:addToMainMenu(menu_items)
1081     local items = {
1082         {
1083             text_func = function() return self:subtitleMenuText() end,
1084             keep_menu_open = true,
1085             callback = function(touchmenu_instance)
1086                 if self:checkSupported() then
1087                     self:chooseSubtitleFile(touchmenu_instance)
1088                 end
1089             end,
1090             hold_callback = function(touchmenu_instance)
1091                 self:setSubtitleFile(nil)
1092                 touchmenu_instance:updateItems()
1093             end,
1094             separator = true,
1095         },
1096         {
1097             text_func = function()
1098                 return self:isActive() and _("Read-along controls")
1099                                         or _("Start read-along")
1100             end,
1101             callback = function(touchmenu_instance)
1102                 touchmenu_instance:onClose()
1103                 if not self:checkSupported() then return end
1104                 if not self:isActive() then
1105                     -- The controls would cover the line that the narrator reads.
1106                     -- They open with the same menu entry when the user wants them.
1107                     self:start()
1108                     if self:isActive() then
1109                         self:showNotification(_("Read-along started. The controls are in this menu."))
1110                     end
1111                     return
1112                 end
1113                 self:showControls()
1114             end,
1115         },
1116         {
1117             text_func = function() return self:syncToPageText() end,
1118             callback = function(touchmenu_instance)
1119                 touchmenu_instance:onClose()
1120                 self:syncToPage()
1121             end,
1122         },
1123         {
1124             text = _("Go to time…"),
1125             keep_menu_open = true,
1126             callback = function() self:showGoToTime() end,
1127         },
1128         {
1129             text = _("What time is this page?"),
1130             keep_menu_open = true,
1131             callback = function() self:showPageTime() end,
1132             separator = true,
1133         },
1134     }
1135     if self.player then
1136         table.insert(items, {
1137             text = _("Follow the audio player"),
1138             help_text = _("Reads the position of the audio player through the SubRead Overlay app, which needs notification access. Start, pause and the seeks then control the player. When off, the plugin runs its own clock."),
1139             checked_func = function() return G_reader_settings:nilOrTrue(SETTING_FOLLOW_PLAYER) end,
1140             callback = function()
1141                 G_reader_settings:flipNilOrTrue(SETTING_FOLLOW_PLAYER)
1142                 self:stop()
1143             end,
1144             separator = true,
1145         })
1146     end
1147     menu_items.subread = {
1148         -- placeInToolsMenu puts the id first in the Tools order. The hint is
1149         -- for a user order file that does not list the id
1150         -- (menusorter.lua:161-182).
1151         sorting_hint = "tools",
1152         text = _("SubRead read-along"),
1153         sub_item_table = items,
1154     }
1155     table.insert(items, {
1156         text_func = function()
1157             return T(_("Player speed: %1"), string.format("%.2f", self.clock.speed))
1158         end,
1159         -- The player reports its speed, so the setting is for the own clock.
1160         enabled_func = function() return not self:followsPlayer() end,
1161         keep_menu_open = true,
1162         callback = function(touchmenu_instance)
1163             self:showSpeed()
1164             if touchmenu_instance then touchmenu_instance:updateItems() end
1165         end,
1166     })
1167     table.insert(items, {
1168         text_func = function()
1169             return T(_("Audio offset: %1 s"), string.format("%+d", self.clock.offset))
1170         end,
1171         keep_menu_open = true,
1172         callback = function(touchmenu_instance)
1173             self:showOffset()
1174             if touchmenu_instance then touchmenu_instance:updateItems() end
1175         end,
1176     })
1177 end
1178 
1179 return SubRead