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