├── lang-learner.conf ├── README.md └── lang-learner.lua /lang-learner.conf: -------------------------------------------------------------------------------- 1 | # Print each sub in stdout. 2 | # Use for easy access or may be integration 3 | trace_subs=yes 4 | 5 | # Language you learn 6 | learn=jpn jp 7 | 8 | # List of languages you know 9 | know=jpn+rus jpn+eng rus eng 10 | 11 | # Urls for F1, F2, F3 12 | # %s will be replaced with a subtitle 13 | browser=x-www-browser 14 | url1=https://jisho.org/search/%s 15 | url2=https://translate.google.com/?sl=auto&text=%s 16 | url3= 17 | 18 | # Dir to store info about subtitle 19 | # during 'key_store' command 20 | store_dir=to_learn 21 | 22 | # External script 23 | # Arguments: 24 | # 1 - subtitle 25 | # 2 - file of the movie 26 | # 3 - start time 27 | # 4 - end time 28 | script=echo 29 | 30 | # Default Kebindings 31 | key_toggle_lang=b 32 | key_seek_cur_sub=c 33 | key_ab_loop_sub=g 34 | key_open_url1=F1 35 | key_open_url2=F2 36 | key_open_url3=F3 37 | key_auto_ab_loop=F5 38 | key_store=F6 39 | key_script=F7 40 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mpv-lang-learner 2 | 3 | Scripts to tune MPV into the video player for language learners. 4 | 5 | ## Features 6 | 7 | * Key to switch subtitles between language you learn and language you 8 | know. Very useful when there are 30 languages in `.mkv`.
9 | Default: `b` 10 | * Cycle prefered languages you know if there are any.
11 | Default: `B` 12 | * Key to AB loop current sutitle, and reset.
13 | Default: `F5` 14 | * Auto AB loop mode that will loop each subtitle. Use key above or `l` 15 | (default) to move forward.
16 | Default: `g` 17 | * Quick seek to begining of current subtitle (addition to standard 18 | `Ctrl+left` and `Ctrl+right`).
19 | Default: `c` 20 | * Open subtitle in browser. 3 possible URLs.
21 | Default URLs: Jisho and google translate
22 | Default: `F1`, `F2`, `F3`. 23 | * Store current subtitle in `to_learn` folder. It stores text (`.txt`), 24 | meta info (`.json`) and audio (`.mp3`). For later adding into flash 25 | cards and so on. (`ffmpeg` is needed for audio).
26 | Default: `F6` 27 | * Call external script with current subtitle, file, start, stop 28 | timestamps.
29 | Default: `F7` 30 | 31 | 32 | ## How to use 33 | 34 | ### Train READING 35 | * Use `b` to cycle between language you know and language you learn. 36 | * If there are several languages you know, use `B` to cycle between them. 37 | After that `b` will use selected language. 38 | * `Ctrl+left` / `Ctrl+right` (native bindings) to find needed subtitle. 39 | (notice it work well only for subtitles that were already shown) 40 | * Use `F1` or `F2` to quickly lookup current text in dictionaries or 41 | transators. It will also pause the video. May be default bindings 42 | `f` (full screen) and `T` (on top) will be useful when working with 43 | multiple windows. 44 | * Use `F6` to save current subtitle in `to_learn` folder. It's for 45 | good phrases or frequent words you want to learn separately. 46 | * You can prepare a script and setup it for `F7` to (for example) 47 | automatically create flash cards in Anki. 48 | 49 | (tip: it also dumps current subtitile into console, for the case if you 50 | want to copy-paste something!) 51 | 52 | ### Train LISTENING 53 | * Important button - `v` (native binding). It will show/hide subtitle, 54 | but it's still there! All saving/repeating and so on for that subitile 55 | should work. So right from the begining, switch to lang you want to 56 | learn (once `b`) and press `v` to hide it. 57 | * Use `c` to seek to the begining of current subtitle, or (for listeners) 58 | to repeat the phrase. 59 | * Use `g` to AB loop current subtitle (phrase). Press `g` again to move 60 | forward. 61 | * If everything gets complex, use `F5` to AB loop each phrase 62 | automatically. Each time you understand it, press `g` to move forward. 63 | If you give up, press `v` to show answer, and `b` or `F1`/`F2`/`F3` to 64 | show translation. 65 | * Again use `F6` or `F7` to remember good phrases for later learning. 66 | * Use `[`, `]` (native) to change the speed. Use `Backspace` to reset 67 | speed to normal. (tip: `Shift+[` will half the speed) 68 | 69 | ## Configuration 70 | 71 | Look at `lang-learner.conf` for example and description of options. 72 | 73 | ## How to prepare movie 74 | 75 | Find movie/anime with switchable subtitles. Look for `.mkv` files and 76 | sources that embedds a lot of subtitles in one file. If you are lucky, 77 | you can find media with needed subtitles right inside. 78 | 79 | If you miss some subtitle, look at sites like https://kitsunekko.net/ 80 | (for anime). Download and rename them to match movie/episode name. 81 | 82 | (tip: to make external subtitle to have lang id, use following template 83 | for naming: `..ass`. 84 | For instance: `Some anime - 02.jpn.ass`) 85 | 86 | Usually you have to sync external subtitles. Use GUI tools to edit subs 87 | like [Aegisub](https://github.com/Aegisub/Aegisub), 88 | [subtitlecomposer](https://github.com/maxrd2/SubtitleComposer) or magic 89 | syncers like [ffsubsync](https://github.com/smacke/ffsubsync). Don't 90 | forget to share your work for others! 91 | 92 | ## Installation 93 | 94 | Follow MPV instructions to install. (usually `.lua` should go into 95 | `~/.config/mpv/scripts/` and `.conf` into `~/.config/mpv/scrip-opts/`). 96 | 97 | ## Possible problems 98 | 99 | * **doesn't load** Check script path. Try to call `mpv` from shell, 100 | some frontends known to disable script loading. Uncomment line 101 | `print("LOADED")` at the begining of the script as a sign that it's 102 | loaded for easier debugging. 103 | * **suddenly stops working.** Run in terminal, try to reproduce, look 104 | in terminal for possible stack traces. If script crashes, then MPV 105 | disables it and disables all bindings. 106 | * **configuration option doesn't work.** Run in console and watch for 107 | warnings about unknown options. Add wrong option (`abc=123`) in 108 | config file, and start mpv. If there is no warning, then MPV doesn't 109 | load config file at all. Check paths and naming. 110 | -------------------------------------------------------------------------------- /lang-learner.lua: -------------------------------------------------------------------------------- 1 | -- MPV for language learners setup 2 | -- 3 | -- Provides following features: 4 | -- 1. One-key switch between subs you can read and 5 | -- lang you are learning 6 | -- 2. Ability to seek to start or AB loop current subtitle 7 | -- 3. Auto AB loop mode - will loop each subtitle one by one 8 | -- 4. Open subtitle (3 keys for 3 possible sites) 9 | -- 5. One-key saving subtitle and audio in "to_learn" directory 10 | -- for later adding to cards and so on 11 | -- 6. Ability to run external script, to create cards right 12 | -- from MPV 13 | -- 14 | -- To install follow MPV instructions to install scripts. 15 | -- .lua file should go into scripts/ directory, 16 | -- .conf into script-opts/ 17 | -- 18 | -- License: GNU Lesser General Public License as published 19 | -- by the Free Software Foundation; either version 2.1 of 20 | -- the License, or (at your option) any later version. 21 | -- 22 | 23 | -- print("LOADED") 24 | 25 | local utils = require("mp.utils") 26 | local options = require("mp.options") 27 | 28 | local o = { 29 | trace_subs = true, 30 | 31 | learn = "jpn", 32 | know = "jpn+eng eng", 33 | 34 | browser = "x-www-browser", 35 | url1 = "https://jisho.org/search/%s", 36 | url2 = "https://translate.google.com/?sl=auto&text=%s", 37 | url3 = "", 38 | 39 | store_dir = "to_learn", 40 | script = "", 41 | 42 | key_toggle_lang = "b", 43 | key_cycle_known = "B", 44 | key_seek_cur_sub = "c", 45 | key_ab_loop_sub = "g", 46 | key_open_url1 = "F1", 47 | key_open_url2 = "F2", 48 | key_open_url3 = "F3", 49 | key_auto_ab_loop = "F5", 50 | 51 | key_store = "F6", 52 | key_script = "F7", 53 | } 54 | options.read_options(o, "lang-learner") 55 | 56 | local auto_ab_loop_sub = false 57 | local is_learn_lang = false 58 | local data = nil 59 | 60 | -- 61 | -- Handlers for commands 62 | -- 63 | function do_toggle_lang() 64 | load_data() 65 | 66 | if is_learn_lang then 67 | set_slang('know') 68 | is_learn_lang = false 69 | else 70 | set_slang('learn') 71 | is_learn_lang = true 72 | end 73 | end 74 | 75 | function do_cycle_known() 76 | load_data() 77 | cycle_lang_type('know') 78 | set_slang('know') 79 | end 80 | 81 | function do_seek_current_sub() 82 | mp.commandv('sub-seek', '0') 83 | end 84 | 85 | function do_ab_loop_sub() 86 | if mp.get_property("ab-loop-a") ~= "no" then 87 | clear_ab_loop() 88 | mp.osd_message("Clear AB loop", 0.5) 89 | else 90 | if get_sub() == nil then return; end 91 | set_ab_loop() 92 | mp.osd_message("AB-Loop subtitle", 0.5) 93 | end 94 | end 95 | 96 | function toggle_auto_ab_loop() 97 | if auto_ab_loop_sub then 98 | auto_ab_loop_sub = false 99 | clear_ab_loop() 100 | mp.osd_message('Disable auto AB loop') 101 | else 102 | auto_ab_loop_sub = true 103 | set_ab_loop() 104 | mp.osd_message('Enable auto AB loop') 105 | end 106 | end 107 | 108 | function do_open_in_url(tag) 109 | if o[tag] == "" then return; end 110 | 111 | local sub = get_sub() 112 | if sub == nil then return; end 113 | 114 | local url = string.format(o[tag], sub['text']) 115 | -- print("Open URL: " .. url) 116 | mp.set_property("pause", "yes") 117 | mp.commandv("run", o["browser"], url) 118 | end 119 | 120 | function do_store(tag) 121 | local sub = get_sub() 122 | if sub == nil then return; end 123 | sub['source'] = mp.get_property('path') 124 | 125 | local dir = o['store_dir'] 126 | if dir == "" or dir == nil then return; end 127 | 128 | mp.commandv("run", "mkdir", "-p", dir) 129 | 130 | local filename = string.format("%s/%s-sub", dir, os.date("!%Y%m%dT%H%M%S")) 131 | save_sub(filename .. '.txt', sub) 132 | save_json(filename .. '.json', sub) 133 | save_audio(filename .. '.mp3', sub) 134 | end 135 | 136 | function do_script(tag) 137 | local sub = get_sub() 138 | if sub == nil then return; end 139 | 140 | call_ext_script(sub) 141 | end 142 | 143 | mp.add_key_binding(o["key_toggle_lang"], "ll-toggle-lang", do_toggle_lang) 144 | mp.add_key_binding(o["key_cycle_known"], "ll-cycle-known", do_cycle_known) 145 | 146 | mp.add_key_binding(o["key_seek_cur_sub"], "ll-seek-cur-sub", do_seek_current_sub) 147 | 148 | mp.add_key_binding(o["key_ab_loop_sub"], "ll-ab-loop-sub", do_ab_loop_sub) 149 | mp.add_key_binding(o["key_auto_ab_loop"], "ll-toggle-auto-ab-loop", toggle_auto_ab_loop) 150 | 151 | mp.add_key_binding(o["key_open_url1"], "ll-open-in-url1", function() do_open_in_url('url1'); end) 152 | mp.add_key_binding(o["key_open_url2"], "ll-open-in-url2", function() do_open_in_url('url2'); end) 153 | mp.add_key_binding(o["key_open_url3"], "ll-open-in-url3", function() do_open_in_url('url3'); end) 154 | 155 | mp.add_key_binding(o["key_store"], "ll-store", do_store) 156 | mp.add_key_binding(o["key_script"], "ll-script", do_script) 157 | 158 | -- 159 | -- Auto AB loop for new subs 160 | -- 161 | local cur_subs = {} 162 | function on_new_sub(name, val) 163 | if val == nil then return; end 164 | if val == "" then 165 | cur_subs = {} 166 | return 167 | end 168 | 169 | if cur_subs[val] == true then 170 | return 171 | end 172 | cur_subs[val] = true 173 | 174 | if o["trace_subs"] then 175 | print("Sub: " .. utils.to_string(val)) 176 | end 177 | 178 | if auto_ab_loop_sub then 179 | set_ab_loop() 180 | end 181 | end 182 | 183 | mp.observe_property("sub-text", "string", on_new_sub) 184 | 185 | -- 186 | -- Helpers 187 | -- 188 | function load_data() 189 | if data ~= nil then return; end 190 | 191 | data = {} 192 | data['by_lang'] = {} 193 | for i,track in ipairs(mp.get_property_native("track-list")) do 194 | if track['type'] == "sub" then 195 | lang = track['lang'] or string.format('id-%d', track['id']) 196 | data['by_lang'][lang] = track 197 | end 198 | end 199 | 200 | for i,tag in ipairs({'learn', 'know'}) do 201 | prepare_lang_tag(tag) 202 | end 203 | end 204 | 205 | function prepare_lang_tag(tag) 206 | local res = { 207 | list = {}, 208 | len = 0, 209 | cur = nil, 210 | idx = 1, 211 | } 212 | 213 | for lang in string.gmatch(o[tag], "%S+") do 214 | if data['by_lang'][lang] then 215 | table.insert(res['list'], lang) 216 | res['len'] = res['len'] + 1 217 | end 218 | end 219 | res['cur'] = res['list'][1] 220 | data[tag] = res 221 | end 222 | 223 | function clear_ab_loop() 224 | mp.set_property("ab-loop-a", "no") 225 | mp.set_property("ab-loop-b", "no") 226 | end 227 | 228 | function set_ab_loop() 229 | if mp.get_property("ab-loop-a") ~= "no" then return ; end 230 | 231 | local a = mp.get_property("sub-start") 232 | local b = mp.get_property("sub-end") 233 | local delay = mp.get_property_native("sub-delay") 234 | if a == nil or b == nil then return; end 235 | 236 | mp.set_property("ab-loop-a", a+delay) 237 | mp.set_property("ab-loop-b", b+delay) 238 | end 239 | 240 | function set_slang(tag) 241 | local lang = data[tag]['cur'] 242 | 243 | if lang ~= nil then 244 | mp.set_property('sub', tonumber(data['by_lang'][lang]['id'])) 245 | else 246 | mp.osd_message('Cant find sub with lang: ' .. o[tag]) 247 | end 248 | end 249 | 250 | function cycle_lang_type(tag) 251 | local idx = data[tag]['idx'] 252 | idx = idx + 1 253 | if idx > data[tag]['len'] then 254 | idx = 1 255 | end 256 | data[tag]['cur'] = data[tag]['list'][idx] 257 | data[tag]['idx'] = idx 258 | end 259 | 260 | function get_sub() 261 | local res = {} 262 | res['text'] = mp.get_property("sub-text") 263 | if res['text'] == "" or res['text'] == nil then return nil; end 264 | 265 | res['start'] = mp.get_property("sub-start") 266 | res['end'] = mp.get_property("sub-end") 267 | return res 268 | end 269 | 270 | function make_store_dir() 271 | mp.commandv("run", "mkdir", "-p", os['store_dir']) 272 | end 273 | 274 | function call_ext_script(sub) 275 | if o['script'] == "" then return ; end 276 | local filename = mp.get_property("filename") 277 | 278 | mp.commandv("run", o["script"], sub['text'], filename, sub['start'], sub['end']) 279 | end 280 | 281 | function save_sub(filename, sub) 282 | local fp = io.open(filename, "w") 283 | fp:write(sub['text']) 284 | fp:close() 285 | print("saved text to " .. filename) 286 | end 287 | 288 | function save_json(filename, sub) 289 | local str = utils.format_json(sub) 290 | if str == nil then 291 | print("Cant format json for sub: " .. sub) 292 | return 293 | end 294 | 295 | local fp = io.open(filename, "w") 296 | fp:write(str) 297 | fp:close() 298 | print("saved JSON to " .. filename) 299 | end 300 | 301 | function save_audio(filename, sub) 302 | local duration = sub['end'] - sub['start'] + 0.1 303 | 304 | local ffmpeg = get_ffmpeg() 305 | if ffmpeg == nil then 306 | print("Can't save audio: no ffmpeg") 307 | return 308 | end 309 | 310 | -- Get the currently selected audio track ID from MPV 311 | local audio_track_id = mp.get_property("aid") 312 | 313 | -- Map the audio track ID to ffmpeg format (e.g., if aid=2, use "0:a:1" in ffmpeg) 314 | local ffmpeg_audio_track = string.format("0:a:%d", tonumber(audio_track_id) - 1) 315 | 316 | -- Run the ffmpeg command with the correct audio track 317 | mp.commandv("run", ffmpeg, "-y", 318 | "-loglevel", "error", 319 | "-i", sub['source'], 320 | "-map", ffmpeg_audio_track, -- Use the currently selected audio track 321 | "-ss", sub['start'], "-t", duration, 322 | "-vn", "-ar", "44100", "-ac", "2", 323 | "-ab", "192k", "-f", "mp3", filename) 324 | print("saved audio to " .. filename) 325 | end 326 | 327 | local ffmpeg_path = nil 328 | function get_ffmpeg() 329 | if ffmpeg_path == nil then 330 | local proc = io.popen("which ffmpeg") 331 | ffmpeg_path = proc:lines()() 332 | proc:close() 333 | end 334 | return ffmpeg_path 335 | end 336 | --------------------------------------------------------------------------------