├── 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 |
--------------------------------------------------------------------------------