mpv2oboeru

mpv helpers to create flashcards from movies and TV shows
git clone anongit@rnpnr.xyz:mpv2oboeru.git
Log | Files | Refs | Feed | README | LICENSE

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         ['&'] = '&amp;',
    147         ['"'] = '&quot;',
    148         ["'"] = '&apos;',
    149         ['<'] = '&lt;',
    150         ['>'] = '&gt;',
    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)