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