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