subs2srs.lua (26835B)
1 --[[ 2 Copyright (C) 2020-2022 Ren Tatsumoto and contributors 3 Copyright (C) 2022 Randy Palamar 4 5 This program is free software: you can redistribute it and/or modify 6 it under the terms of the GNU General Public License as published by 7 the Free Software Foundation, either version 3 of the License, or 8 (at your option) any later version. 9 10 This program is distributed in the hope that it will be useful, 11 but WITHOUT ANY WARRANTY; without even the implied warranty of 12 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 GNU General Public License for more details. 14 15 You should have received a copy of the GNU General Public License 16 along with this program. If not, see <https://www.gnu.org/licenses/>. 17 18 Requirements: 19 * mpv >= 0.32.0 20 * xclip (when running X11) 21 * wl-copy (when running Wayland) 22 23 Usage: 24 1. Change `config` according to your needs 25 * Config path: ~/.config/mpv/script-opts/subs2srs.conf 26 * Config file isn't created automatically. 27 28 2. Open a video 29 30 3. Use key bindings to manipulate the script 31 * Open mpvacious menu - `a` 32 * Create a note from the current subtitle line - `Ctrl + e` 33 34 For complete usage guide, see <https://github.com/Ajatt-Tools/mpvacious/blob/master/README.md> 35 ]] 36 37 local config = { 38 -- Common 39 autoclip = false, -- enable copying subs to the clipboard when mpv starts 40 nuke_spaces = false, -- remove all spaces from exported anki cards 41 clipboard_trim_enabled = true, -- remove unnecessary characters from strings before copying to the clipboard 42 use_ffmpeg = false, -- if set to true, use ffmpeg to create audio clips and snapshots. by default use mpv. 43 snapshot_format = "webp", -- webp or jpg 44 snapshot_quality = 15, -- from 0=lowest to 100=highest 45 snapshot_width = -2, -- a positive integer or -2 for auto 46 snapshot_height = 200, -- same 47 audio_format = "opus", -- opus or mp3 48 audio_bitrate = "18k", -- from 16k to 32k 49 audio_padding = 0.12, -- Set a pad to the dialog timings. 0.5 = audio is padded by .5 seconds. 0 = disable. 50 tie_volumes = false, -- if set to true, the volume of the outputted audio file depends on the volume of the player at the time of export 51 menu_font_size = 25, 52 53 -- Custom encoding args 54 ffmpeg_audio_args = '-af silenceremove=1:0:-50dB', 55 mpv_audio_args = '--af-append=silenceremove=1:0:-50dB', 56 57 -- Anki 58 sentence_field = "SentKanji", 59 audio_field = "SentAudio", 60 create_image = true, 61 } 62 63 -- Defines config profiles 64 -- Each name references a file in ~/.config/mpv/script-opts/*.conf 65 -- Profiles themselves are defined in ~/.config/mpv/script-opts/subs2srs_profiles.conf 66 local profiles = { 67 profiles = "subs2srs,subs2srs_english", 68 active = "subs2srs", 69 } 70 71 local mp = require('mp') 72 local utils = require('mp.utils') 73 local msg = require('mp.msg') 74 local OSD = require('osd_styler') 75 local config_manager = require('config') 76 local encoder = require('encoder') 77 local helpers = require('helpers') 78 local Menu = require('menu') 79 80 -- namespaces 81 local subs 82 local clip_autocopy 83 local menu 84 local platform 85 86 -- classes 87 local Subtitle 88 89 ------------------------------------------------------------ 90 -- utility functions 91 92 ---Returns true if table contains element. Returns false otherwise. 93 ---@param table table 94 ---@param element any 95 ---@return boolean 96 function table.contains(table, element) 97 for _, value in pairs(table) do 98 if value == element then 99 return true 100 end 101 end 102 return false 103 end 104 105 ---Returns the largest numeric index. 106 ---@param table table 107 ---@return number 108 function table.max_num(table) 109 local max = table[1] 110 for _, value in ipairs(table) do 111 if value > max then 112 max = value 113 end 114 end 115 return max 116 end 117 118 ---Returns a value for the given key. If key is not available then returns default value 'nil'. 119 ---@param table table 120 ---@param key string 121 ---@param default any 122 ---@return any 123 function table.get(table, key, default) 124 if table[key] == nil then 125 return default or 'nil' 126 else 127 return table[key] 128 end 129 end 130 131 local function is_running_wayland() 132 return os.getenv('WAYLAND_DISPLAY') ~= nil 133 end 134 135 local function contains_non_latin_letters(str) 136 return str:match("[^%c%p%s%w]") 137 end 138 139 local function capitalize_first_letter(string) 140 return string:gsub("^%l", string.upper) 141 end 142 143 local escape_special_characters 144 do 145 local entities = { 146 ['&'] = '&', 147 ['"'] = '"', 148 ["'"] = ''', 149 ['<'] = '<', 150 ['>'] = '>', 151 } 152 escape_special_characters = function(s) 153 return s:gsub('[&"\'<>]', entities) 154 end 155 end 156 157 local function remove_extension(filename) 158 return filename:gsub('%.%w+$', '') 159 end 160 161 local function remove_special_characters(str) 162 return str:gsub('[%c%p%s]', ''):gsub(' ', '') 163 end 164 165 local function remove_text_in_brackets(str) 166 return str:gsub('%b[]', ''):gsub('【.-】', '') 167 end 168 169 local function remove_filename_text_in_parentheses(str) 170 return str:gsub('%b()', ''):gsub('(.-)', '') 171 end 172 173 local function remove_common_resolutions(str) 174 -- Also removes empty leftover parentheses and brackets. 175 return str:gsub("2160p", ""):gsub("1080p", ""):gsub("720p", ""):gsub("576p", ""):gsub("480p", ""):gsub("%(%)", ""):gsub("%[%]", "") 176 end 177 178 local function remove_text_in_parentheses(str) 179 -- Remove text like (泣き声) or (ドアの開く音) 180 -- No deletion is performed if there's no text after the parentheses. 181 -- Note: the modifier `-´ matches zero or more occurrences. 182 -- However, instead of matching the longest sequence, it matches the shortest one. 183 return str:gsub('(%b())(.)', '%2'):gsub('((.-))(.)', '%2') 184 end 185 186 local function remove_newlines(str) 187 return str:gsub('[\n\r]+', ' ') 188 end 189 190 local function remove_leading_trailing_spaces(str) 191 return str:gsub('^%s*(.-)%s*$', '%1') 192 end 193 194 local function remove_leading_trailing_dashes(str) 195 return str:gsub('^[%-_]*(.-)[%-_]*$', '%1') 196 end 197 198 local function remove_all_spaces(str) 199 return str:gsub('%s*', '') 200 end 201 202 local function remove_spaces(str) 203 if config.nuke_spaces == true and contains_non_latin_letters(str) then 204 return remove_all_spaces(str) 205 else 206 return remove_leading_trailing_spaces(str) 207 end 208 end 209 210 local function trim(str) 211 str = remove_spaces(str) 212 str = remove_text_in_parentheses(str) 213 str = remove_newlines(str) 214 return str 215 end 216 217 local function copy_to_clipboard(_, text) 218 if not helpers.is_empty(text) then 219 text = config.clipboard_trim_enabled and trim(text) or remove_newlines(text) 220 platform.copy_to_clipboard(text) 221 end 222 end 223 224 local function copy_sub_to_clipboard() 225 copy_to_clipboard("copy-on-demand", mp.get_property("sub-text")) 226 end 227 228 local function human_readable_time(seconds) 229 if type(seconds) ~= 'number' or seconds < 0 then 230 return 'empty' 231 end 232 233 local parts = { 234 h = math.floor(seconds / 3600), 235 m = math.floor(seconds / 60) % 60, 236 s = math.floor(seconds % 60), 237 ms = math.floor((seconds * 1000) % 1000), 238 } 239 240 local ret = string.format("%02dm%02ds%03dms", parts.m, parts.s, parts.ms) 241 242 if parts.h > 0 then 243 ret = string.format('%dh%s', parts.h, ret) 244 end 245 246 return ret 247 end 248 249 local function subprocess(args, completion_fn) 250 -- if `completion_fn` is passed, the command is ran asynchronously, 251 -- and upon completion, `completion_fn` is called to process the results. 252 local command_native = type(completion_fn) == 'function' and mp.command_native_async or mp.command_native 253 local command_table = { 254 name = "subprocess", 255 playback_only = false, 256 capture_stdout = true, 257 args = args 258 } 259 return command_native(command_table, completion_fn) 260 end 261 262 local codec_support = (function() 263 local ovc_help = subprocess { 'mpv', '--ovc=help' } 264 local oac_help = subprocess { 'mpv', '--oac=help' } 265 266 local function is_audio_supported(codec) 267 return oac_help.status == 0 and oac_help.stdout:match('--oac=' .. codec) ~= nil 268 end 269 270 local function is_image_supported(codec) 271 return ovc_help.status == 0 and ovc_help.stdout:match('--ovc=' .. codec) ~= nil 272 end 273 274 return { 275 snapshot = { 276 libwebp = is_image_supported('libwebp'), 277 mjpeg = is_image_supported('mjpeg'), 278 }, 279 audio = { 280 libmp3lame = is_audio_supported('libmp3lame'), 281 libopus = is_audio_supported('libopus'), 282 }, 283 } 284 end)() 285 286 local function warn_formats(osd) 287 if config.use_ffmpeg then 288 return 289 end 290 for type, codecs in pairs(codec_support) do 291 for codec, supported in pairs(codecs) do 292 if not supported and config[type .. '_codec'] == codec then 293 osd:red('warning: '):newline() 294 osd:tab():text(string.format("your version of mpv does not support %s.", codec)):newline() 295 osd:tab():text(string.format("mpvacious won't be able to create %s files.", type)):newline() 296 end 297 end 298 end 299 end 300 301 local function load_next_profile() 302 config_manager.next_profile() 303 helpers.notify("Loaded profile " .. profiles.active) 304 end 305 306 local function minutes_ago(m) 307 return (os.time() - 60 * m) * 1000 308 end 309 310 local function audio_padding() 311 local video_duration = mp.get_property_number('duration') 312 if config.audio_padding == 0.0 or not video_duration then 313 return 0.0 314 end 315 if subs.user_timings.is_set('start') or subs.user_timings.is_set('end') then 316 return 0.0 317 end 318 return config.audio_padding 319 end 320 321 ------------------------------------------------------------ 322 -- utility classes 323 324 local function new_timings() 325 local self = { ['start'] = -1, ['end'] = -1, } 326 local is_set = function(position) 327 return self[position] >= 0 328 end 329 local set = function(position) 330 self[position] = mp.get_property_number('time-pos') 331 end 332 local get = function(position) 333 return self[position] 334 end 335 return { 336 is_set = is_set, 337 set = set, 338 get = get, 339 } 340 end 341 342 local function new_sub_list() 343 local subs_list = {} 344 local _is_empty = function() 345 return next(subs_list) == nil 346 end 347 local find_i = function(sub) 348 for i, v in ipairs(subs_list) do 349 if sub < v then 350 return i 351 end 352 end 353 return #subs_list + 1 354 end 355 local get_time = function(position) 356 local i = position == 'start' and 1 or #subs_list 357 return subs_list[i][position] 358 end 359 local get_text = function() 360 local speech = {} 361 for _, sub in ipairs(subs_list) do 362 table.insert(speech, sub['text']) 363 end 364 return table.concat(speech, ' ') 365 end 366 local insert = function(sub) 367 if sub ~= nil and not table.contains(subs_list, sub) then 368 table.insert(subs_list, find_i(sub), sub) 369 return true 370 end 371 return false 372 end 373 return { 374 get_time = get_time, 375 get_text = get_text, 376 is_empty = _is_empty, 377 insert = insert 378 } 379 end 380 381 local function make_switch(states) 382 local self = { 383 states = states, 384 current_state = 1 385 } 386 local bump = function() 387 self.current_state = self.current_state + 1 388 if self.current_state > #self.states then 389 self.current_state = 1 390 end 391 end 392 local get = function() 393 return self.states[self.current_state] 394 end 395 return { 396 bump = bump, 397 get = get 398 } 399 end 400 401 local filename_factory = (function() 402 local filename 403 404 local anki_compatible_length = (function() 405 -- Anki forcibly mutilates all filenames longer than 119 bytes when you run `Tools->Check Media...`. 406 local allowed_bytes = 119 407 local timestamp_bytes = #'_99h99m99s999ms-99h99m99s999ms.webp' 408 409 return function(str, timestamp) 410 -- if timestamp provided, recalculate limit_bytes 411 local limit_bytes = allowed_bytes - (timestamp and #timestamp or timestamp_bytes) 412 413 if #str <= limit_bytes then 414 return str 415 end 416 417 local bytes_per_char = contains_non_latin_letters(str) and #'車' or #'z' 418 local limit_chars = math.floor(limit_bytes / bytes_per_char) 419 420 if limit_chars == limit_bytes then 421 return str:sub(1, limit_bytes) 422 end 423 424 local ret = subprocess { 425 'awk', 426 '-v', string.format('str=%s', str), 427 '-v', string.format('limit=%d', limit_chars), 428 'BEGIN{print substr(str, 1, limit); exit}' 429 } 430 431 if ret.status == 0 then 432 ret.stdout = remove_newlines(ret.stdout) 433 ret.stdout = remove_leading_trailing_spaces(ret.stdout) 434 return ret.stdout 435 else 436 return 'subs2srs_' .. os.time() 437 end 438 end 439 end)() 440 441 local make_media_filename = function() 442 filename = mp.get_property("filename") -- filename without path 443 filename = remove_extension(filename) 444 filename = remove_text_in_brackets(filename) 445 filename = remove_special_characters(filename) 446 end 447 448 local make_audio_filename = function(speech_start, speech_end) 449 local filename_timestamp = string.format( 450 '_%s-%s%s', 451 human_readable_time(speech_start), 452 human_readable_time(speech_end), 453 config.audio_extension 454 ) 455 return anki_compatible_length(filename, filename_timestamp) .. filename_timestamp 456 end 457 458 local make_snapshot_filename = function(timestamp) 459 local filename_timestamp = string.format( 460 '_%s%s', 461 human_readable_time(timestamp), 462 config.snapshot_extension 463 ) 464 return anki_compatible_length(filename, filename_timestamp) .. filename_timestamp 465 end 466 467 mp.register_event("file-loaded", make_media_filename) 468 469 return { 470 make_audio_filename = make_audio_filename, 471 make_snapshot_filename = make_snapshot_filename, 472 } 473 end)() 474 475 ------------------------------------------------------------ 476 -- front for adding and updating notes 477 478 local function export_data() 479 local sub = subs.get() 480 if sub == nil then 481 helpers.notify("Nothing to export.", "warn", 1) 482 return 483 end 484 485 local snapshot_timestamp = mp.get_property_number("time-pos", 0) 486 local snapshot_filename = filename_factory.make_snapshot_filename(snapshot_timestamp) 487 local audio_filename = filename_factory.make_audio_filename(sub['start'], sub['end']) 488 489 encoder.create_snapshot(snapshot_timestamp, snapshot_filename) 490 encoder.create_audio(sub['start'], sub['end'], audio_filename, audio_padding()) 491 -- FIXME: export to correct folder 492 subs.clear() 493 end 494 495 ------------------------------------------------------------ 496 -- seeking: sub replay, sub seek, sub rewind 497 498 local function _(params) 499 return function() 500 return pcall(helpers.unpack(params)) 501 end 502 end 503 504 local pause_timer = (function() 505 local stop_time = -1 506 local check_stop 507 local set_stop_time = function(time) 508 stop_time = time 509 mp.observe_property("time-pos", "number", check_stop) 510 end 511 local stop = function() 512 mp.unobserve_property(check_stop) 513 stop_time = -1 514 end 515 check_stop = function(_, time) 516 if time > stop_time then 517 stop() 518 mp.set_property("pause", "yes") 519 end 520 end 521 return { 522 set_stop_time = set_stop_time, 523 check_stop = check_stop, 524 stop = stop, 525 } 526 end)() 527 528 local play_control = (function() 529 local current_sub 530 531 local function stop_at_the_end(sub) 532 pause_timer.set_stop_time(sub['end'] - 0.050) 533 helpers.notify("Playing till the end of the sub...", "info", 3) 534 end 535 536 local function play_till_sub_end() 537 local sub = subs.get_current() 538 mp.commandv('seek', sub['start'], 'absolute') 539 mp.set_property("pause", "no") 540 stop_at_the_end(sub) 541 end 542 543 local function sub_seek(direction, pause) 544 mp.commandv("sub_seek", direction == 'backward' and '-1' or '1') 545 mp.commandv("seek", "0.015", "relative+exact") 546 if pause then 547 mp.set_property("pause", "yes") 548 end 549 pause_timer.stop() 550 end 551 552 local function sub_rewind() 553 mp.commandv('seek', subs.get_current()['start'] + 0.015, 'absolute') 554 pause_timer.stop() 555 end 556 557 local function check_sub() 558 local sub = subs.get_current() 559 if sub and sub ~= current_sub then 560 mp.unobserve_property(check_sub) 561 stop_at_the_end(sub) 562 end 563 end 564 565 local function play_till_next_sub_end() 566 current_sub = subs.get_current() 567 mp.observe_property("sub-text", "string", check_sub) 568 mp.set_property("pause", "no") 569 helpers.notify("Waiting till next sub...", "info", 10) 570 end 571 572 return { 573 play_till_sub_end = play_till_sub_end, 574 play_till_next_sub_end = play_till_next_sub_end, 575 sub_seek = sub_seek, 576 sub_rewind = sub_rewind, 577 } 578 end)() 579 580 ------------------------------------------------------------ 581 -- platform specific 582 583 local function init_platform_nix() 584 local self = {} 585 local clip = is_running_wayland() and 'wl-copy' or 'xclip -i -selection clipboard' 586 587 self.tmp_dir = function() 588 return '/tmp' 589 end 590 591 self.copy_to_clipboard = function(text) 592 local handle = io.popen(clip, 'w') 593 handle:write(text) 594 handle:close() 595 end 596 597 return self 598 end 599 600 platform = init_platform_nix() 601 602 ------------------------------------------------------------ 603 -- subtitles and timings 604 605 subs = { 606 dialogs = new_sub_list(), 607 user_timings = new_timings(), 608 observed = false 609 } 610 611 subs.get_current = function() 612 return Subtitle:now() 613 end 614 615 subs.get_timing = function(position) 616 if subs.user_timings.is_set(position) then 617 return subs.user_timings.get(position) 618 elseif not subs.dialogs.is_empty() then 619 return subs.dialogs.get_time(position) 620 end 621 return -1 622 end 623 624 subs.get = function() 625 if subs.dialogs.is_empty() then 626 subs.dialogs.insert(subs.get_current()) 627 end 628 local sub = Subtitle:new { 629 ['text'] = subs.dialogs.get_text(), 630 ['start'] = subs.get_timing('start'), 631 ['end'] = subs.get_timing('end'), 632 } 633 if sub['start'] < 0 or sub['end'] < 0 then 634 return nil 635 end 636 if sub['start'] == sub['end'] then 637 return nil 638 end 639 if sub['start'] > sub['end'] then 640 sub['start'], sub['end'] = sub['end'], sub['start'] 641 end 642 if not helpers.is_empty(sub['text']) then 643 sub['text'] = trim(sub['text']) 644 sub['text'] = escape_special_characters(sub['text']) 645 end 646 return sub 647 end 648 649 subs.append = function() 650 if subs.dialogs.insert(subs.get_current()) then 651 menu:update() 652 end 653 end 654 655 subs.observe = function() 656 mp.observe_property("sub-text", "string", subs.append) 657 subs.observed = true 658 end 659 660 subs.unobserve = function() 661 mp.unobserve_property(subs.append) 662 subs.observed = false 663 end 664 665 subs.set_timing = function(position) 666 subs.user_timings.set(position) 667 helpers.notify(capitalize_first_letter(position) .. " time has been set.") 668 if not subs.observed then 669 subs.observe() 670 end 671 end 672 673 subs.set_starting_line = function() 674 subs.clear() 675 if subs.get_current() then 676 subs.observe() 677 helpers.notify("Timings have been set to the current sub.", "info", 2) 678 else 679 helpers.notify("There's no visible subtitle.", "info", 2) 680 end 681 end 682 683 subs.clear = function() 684 subs.unobserve() 685 subs.dialogs = new_sub_list() 686 subs.user_timings = new_timings() 687 end 688 689 subs.clear_and_notify = function() 690 subs.clear() 691 helpers.notify("Timings have been reset.", "info", 2) 692 end 693 694 ------------------------------------------------------------ 695 -- send subs to clipboard as they appear 696 697 clip_autocopy = (function() 698 local enable = function() 699 mp.observe_property("sub-text", "string", copy_to_clipboard) 700 end 701 702 local disable = function() 703 mp.unobserve_property(copy_to_clipboard) 704 end 705 706 local state_notify = function() 707 helpers.notify(string.format("Clipboard autocopy has been %s.", config.autoclip and 'enabled' or 'disabled')) 708 end 709 710 local toggle = function() 711 config.autoclip = not config.autoclip 712 if config.autoclip == true then 713 enable() 714 else 715 disable() 716 end 717 state_notify() 718 end 719 720 local is_enabled = function() 721 return config.autoclip == true and 'enabled' or 'disabled' 722 end 723 724 local init = function() 725 if config.autoclip == true then 726 enable() 727 end 728 end 729 730 return { 731 enable = enable, 732 disable = disable, 733 init = init, 734 toggle = toggle, 735 is_enabled = is_enabled, 736 } 737 end)() 738 739 ------------------------------------------------------------ 740 -- Subtitle class provides methods for comparing subtitle lines 741 742 Subtitle = { 743 ['text'] = '', 744 ['start'] = -1, 745 ['end'] = -1, 746 } 747 748 function Subtitle:new(o) 749 o = o or {} 750 setmetatable(o, self) 751 self.__index = self 752 return o 753 end 754 755 function Subtitle:now() 756 local delay = mp.get_property_native("sub-delay") - mp.get_property_native("audio-delay") 757 local text = mp.get_property("sub-text") 758 local this = self:new { 759 ['text'] = text, -- if is_empty then it's dealt with later 760 ['start'] = mp.get_property_number("sub-start"), 761 ['end'] = mp.get_property_number("sub-end"), 762 } 763 return this:valid() and this:delay(delay) or nil 764 end 765 766 function Subtitle:delay(delay) 767 self['start'] = self['start'] + delay 768 self['end'] = self['end'] + delay 769 return self 770 end 771 772 function Subtitle:valid() 773 return self['start'] and self['end'] and self['start'] >= 0 and self['end'] > 0 774 end 775 776 Subtitle.__eq = function(lhs, rhs) 777 return lhs['text'] == rhs['text'] 778 end 779 780 Subtitle.__lt = function(lhs, rhs) 781 return lhs['start'] < rhs['start'] 782 end 783 784 ------------------------------------------------------------ 785 -- main menu 786 787 menu = Menu:new { 788 hints_state = make_switch { 'hidden', 'menu', 'global', }, 789 } 790 791 menu.keybindings = { 792 { key = 's', fn = menu:with_update { subs.set_timing, 'start' } }, 793 { key = 'e', fn = menu:with_update { subs.set_timing, 'end' } }, 794 { key = 'c', fn = menu:with_update { subs.set_starting_line } }, 795 { key = 'r', fn = menu:with_update { subs.clear_and_notify } }, 796 { key = 'g', fn = menu:with_update { export_data } }, 797 { key = 'n', fn = menu:with_update { export_data } }, 798 { key = 't', fn = menu:with_update { clip_autocopy.toggle } }, 799 { key = 'i', fn = menu:with_update { menu.hints_state.bump } }, 800 { key = 'p', fn = menu:with_update { load_next_profile } }, 801 { key = 'ESC', fn = function() menu:close() end }, 802 { key = 'q', fn = function() menu:close() end }, 803 } 804 805 function menu:make_osd() 806 local osd = OSD:new():size(config.menu_font_size):align(4) 807 808 osd:submenu('mpvacious options'):newline() 809 osd:item('Timings: '):text(human_readable_time(subs.get_timing('start'))) 810 osd:item(' to '):text(human_readable_time(subs.get_timing('end'))):newline() 811 osd:item('Clipboard autocopy: '):text(clip_autocopy.is_enabled()):newline() 812 osd:item('Active profile: '):text(profiles.active):newline() 813 814 if self.hints_state.get() == 'global' then 815 osd:submenu('Global bindings'):newline() 816 osd:tab():item('ctrl+c: '):text('Copy current subtitle to clipboard'):newline() 817 osd:tab():item('ctrl+h: '):text('Seek to the start of the line'):newline() 818 osd:tab():item('ctrl+shift+h: '):text('Replay current subtitle'):newline() 819 osd:tab():item('shift+h/l: '):text('Seek to the previous/next subtitle'):newline() 820 osd:tab():item('alt+h/l: '):text('Seek to the previous/next subtitle and pause'):newline() 821 osd:italics("Press "):item('i'):italics(" to hide bindings."):newline() 822 elseif self.hints_state.get() == 'menu' then 823 osd:submenu('Menu bindings'):newline() 824 osd:tab():item('c: '):text('Set timings to the current sub'):newline() 825 osd:tab():item('s: '):text('Set start time to current position'):newline() 826 osd:tab():item('e: '):text('Set end time to current position'):newline() 827 osd:tab():item('r: '):text('Reset timings'):newline() 828 osd:tab():item('n: '):text('Export note'):newline() 829 osd:tab():item('g: '):text('GUI export'):newline() 830 osd:tab():item('m: '):text('Update the last added note '):italics('(+shift to overwrite)'):newline() 831 osd:tab():item('t: '):text('Toggle clipboard autocopy'):newline() 832 osd:tab():item('p: '):text('Switch to next profile'):newline() 833 osd:tab():item('ESC: '):text('Close'):newline() 834 osd:italics("Press "):item('i'):italics(" to show global bindings."):newline() 835 else 836 osd:italics("Press "):item('i'):italics(" to show menu bindings."):newline() 837 end 838 839 warn_formats(osd) 840 841 return osd 842 end 843 844 ------------------------------------------------------------ 845 -- main 846 847 local main = (function() 848 local main_executed = false 849 return function() 850 if main_executed then 851 return 852 else 853 main_executed = true 854 end 855 856 config_manager.init(config, profiles) 857 encoder.init(config, platform.tmp_dir, subprocess) 858 clip_autocopy.init() 859 860 -- Key bindings 861 mp.add_forced_key_binding("Ctrl+n", "mpvacious-export-note", export_data) 862 mp.add_forced_key_binding("Ctrl+c", "mpvacious-copy-sub-to-clipboard", copy_sub_to_clipboard) 863 mp.add_key_binding("Ctrl+t", "mpvacious-autocopy-toggle", clip_autocopy.toggle) 864 865 -- Open advanced menu 866 mp.add_key_binding("a", "mpvacious-menu-open", function() menu:open() end) 867 868 -- Vim-like seeking between subtitle lines 869 mp.add_key_binding("H", "mpvacious-sub-seek-back", _ { play_control.sub_seek, 'backward' }) 870 mp.add_key_binding("L", "mpvacious-sub-seek-forward", _ { play_control.sub_seek, 'forward' }) 871 872 mp.add_key_binding("Alt+h", "mpvacious-sub-seek-back-pause", _ { play_control.sub_seek, 'backward', true }) 873 mp.add_key_binding("Alt+l", "mpvacious-sub-seek-forward-pause", _ { play_control.sub_seek, 'forward', true }) 874 875 mp.add_key_binding("Ctrl+h", "mpvacious-sub-rewind", _ { play_control.sub_rewind }) 876 mp.add_key_binding("Ctrl+H", "mpvacious-sub-replay", _ { play_control.play_till_sub_end }) 877 mp.add_key_binding("Ctrl+L", "mpvacious-sub-play-up-to-next", _ { play_control.play_till_next_sub_end }) 878 end 879 end)() 880 881 mp.register_event("file-loaded", main)