1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
|
--[[ UI specific utilities that might or might not depend on its state or options ]]
-- Sorting comparator close to (but not exactly) how file explorers sort files.
sort_filenames = (function()
local symbol_order
local default_order
if state.os == 'windows' then
symbol_order = {
['!'] = 1, ['#'] = 2, ['$'] = 3, ['%'] = 4, ['&'] = 5, ['('] = 6, [')'] = 6, [','] = 7,
['.'] = 8, ["'"] = 9, ['-'] = 10, [';'] = 11, ['@'] = 12, ['['] = 13, [']'] = 13, ['^'] = 14,
['_'] = 15, ['`'] = 16, ['{'] = 17, ['}'] = 17, ['~'] = 18, ['+'] = 19, ['='] = 20,
}
default_order = 21
else
symbol_order = {
['`'] = 1, ['^'] = 2, ['~'] = 3, ['='] = 4, ['_'] = 5, ['-'] = 6, [','] = 7, [';'] = 8,
['!'] = 9, ["'"] = 10, ['('] = 11, [')'] = 11, ['['] = 12, [']'] = 12, ['{'] = 13, ['}'] = 14,
['@'] = 15, ['$'] = 16, ['*'] = 17, ['&'] = 18, ['%'] = 19, ['+'] = 20, ['.'] = 22, ['#'] = 23,
}
default_order = 21
end
-- Alphanumeric sorting for humans in Lua
-- http://notebook.kulchenko.com/algorithms/alphanumeric-natural-sorting-for-humans-in-lua
local function pad_number(d)
local dec, n = d:match('(%.?)0*(.+)')
return #dec > 0 and ('%.12f'):format(d) or ('%03d%s'):format(#n, n)
end
--- In place sorting of filenames
---@param filenames string[]
return function(filenames)
local tuples = {}
for i, filename in ipairs(filenames) do
local first_char = filename:sub(1, 1)
local order = symbol_order[first_char] or default_order
local formatted = filename:lower():gsub('%.?%d+', pad_number)
tuples[i] = {order, formatted, filename}
end
table.sort(tuples, function(a, b)
if a[1] ~= b[1] then return a[1] < b[1] end
return a[2] == b[2] and #b[3] < #a[3] or a[2] < b[2]
end)
for i, tuple in ipairs(tuples) do filenames[i] = tuple[3] end
end
end)()
-- Creates in-between frames to animate value from `from` to `to` numbers.
---@param from number
---@param to number|fun():number
---@param setter fun(value: number)
---@param factor_or_callback? number|fun()
---@param callback? fun() Called either on animation end, or when animation is killed.
function tween(from, to, setter, factor_or_callback, callback)
local factor = factor_or_callback
if type(factor_or_callback) == 'function' then callback = factor_or_callback end
if type(factor) ~= 'number' then factor = 0.3 end
local current, done, timeout = from, false, nil
local get_to = type(to) == 'function' and to or function() return to --[[@as number]] end
local cutoff = math.abs(get_to() - from) * 0.01
local function finish()
if not done then
done = true
timeout:kill()
if callback then callback() end
end
end
local function tick()
local to = get_to()
current = current + ((to - current) * factor)
local is_end = math.abs(to - current) <= cutoff
setter(is_end and to or current)
request_render()
if is_end then finish()
else timeout:resume() end
end
timeout = mp.add_timeout(state.render_delay, tick)
tick()
return finish
end
---@param point {x: number; y: number}
---@param rect {ax: number; ay: number; bx: number; by: number}
function get_point_to_rectangle_proximity(point, rect)
local dx = math.max(rect.ax - point.x, 0, point.x - rect.bx)
local dy = math.max(rect.ay - point.y, 0, point.y - rect.by)
return math.sqrt(dx * dx + dy * dy)
end
-- Extracts the properties used by property expansion of that string.
---@param str string
---@param res { [string] : boolean } | nil
---@return { [string] : boolean }
function get_expansion_props(str, res)
res = res or {}
for str in str:gmatch('%$(%b{})') do
local name, str = str:match('^{[?!]?=?([^:]+):?(.*)}$')
if name then
local s = name:find('==') or nil
if s then name = name:sub(0, s - 1) end
res[name] = true
if str and str ~= '' then get_expansion_props(str, res) end
end
end
return res
end
-- Escape a string for verbatim display on the OSD.
---@param str string
function ass_escape(str)
-- There is no escape for '\' in ASS (I think?) but '\' is used verbatim if
-- it isn't followed by a recognized character, so add a zero-width
-- non-breaking space
str = str:gsub('\\', '\\\239\187\191')
str = str:gsub('{', '\\{')
str = str:gsub('}', '\\}')
-- Precede newlines with a ZWNBSP to prevent ASS's weird collapsing of
-- consecutive newlines
str = str:gsub('\n', '\239\187\191\\N')
-- Turn leading spaces into hard spaces to prevent ASS from stripping them
str = str:gsub('\\N ', '\\N\\h')
str = str:gsub('^ ', '\\h')
return str
end
---@param seconds number
---@return string
function format_time(seconds)
local human = mp.format_time(seconds)
if options.time_precision > 0 then
local formatted = string.format('%.' .. options.time_precision .. 'f', math.abs(seconds) % 1)
human = human .. '.' .. string.sub(formatted, 3)
end
return human
end
---@param opacity number 0-1
function opacity_to_alpha(opacity)
return 255 - math.ceil(255 * opacity)
end
path_separator = (function()
local os_separator = state.os == 'windows' and '\\' or '/'
-- Get appropriate path separator for the given path.
---@param path string
---@return string
return function(path)
return path:sub(1, 2) == '\\\\' and '\\' or os_separator
end
end)()
-- Joins paths with the OS aware path separator or UNC separator.
---@param p1 string
---@param p2 string
---@return string
function join_path(p1, p2)
local p1, separator = trim_trailing_separator(p1)
-- Prevents joining drive letters with a redundant separator (`C:\\foo`),
-- as `trim_trailing_separator()` doesn't trim separators from drive letters.
return p1:sub(#p1) == separator and p1 .. p2 or p1 .. separator.. p2
end
-- Check if path is absolute.
---@param path string
---@return boolean
function is_absolute(path)
if path:sub(1, 2) == '\\\\' then return true
elseif state.os == 'windows' then return path:find('^%a+:') ~= nil
else return path:sub(1, 1) == '/' end
end
-- Ensure path is absolute.
---@param path string
---@return string
function ensure_absolute(path)
if is_absolute(path) then return path end
return join_path(state.cwd, path)
end
-- Remove trailing slashes/backslashes.
---@param path string
---@return string path, string trimmed_separator_type
function trim_trailing_separator(path)
local separator = path_separator(path)
path = trim_end(path, separator)
if state.os == 'windows' then
-- Drive letters on windows need trailing backslash
if path:sub(#path) == ':' then path = path .. '\\' end
else
if path == '' then path = '/' end
end
return path, separator
end
-- Ensures path is absolute, remove trailing slashes/backslashes.
-- Lightweight version of normalize_path for performance critical parts.
---@param path string
---@return string
function normalize_path_lite(path)
if not path or is_protocol(path) then return path end
path = trim_trailing_separator(ensure_absolute(path))
return path
end
-- Ensures path is absolute, remove trailing slashes/backslashes, normalization of path separators and deduplication.
---@param path string
---@return string
function normalize_path(path)
if not path or is_protocol(path) then return path end
path = ensure_absolute(path)
local is_unc = path:sub(1, 2) == '\\\\'
if state.os == 'windows' or is_unc then path = path:gsub('/', '\\') end
path = trim_trailing_separator(path)
--Deduplication of path separators
if is_unc then path = path:gsub('(.\\)\\+', '%1')
elseif state.os == 'windows' then path = path:gsub('\\\\+', '\\')
else path = path:gsub('//+', '/') end
return path
end
-- Check if path is a protocol, such as `http://...`.
---@param path string
function is_protocol(path)
return type(path) == 'string' and (path:find('^%a[%a%d-_]+://') ~= nil or path:find('^%a[%a%d-_]+:\\?') ~= nil)
end
---@param path string
---@param extensions string[] Lowercase extensions without the dot.
function has_any_extension(path, extensions)
local path_last_dot_index = string_last_index_of(path, '.')
if not path_last_dot_index then return false end
local path_extension = path:sub(path_last_dot_index + 1):lower()
for _, extension in ipairs(extensions) do
if path_extension == extension then return true end
end
return false
end
---@return string
function get_default_directory()
return mp.command_native({'expand-path', options.default_directory})
end
-- Serializes path into its semantic parts.
---@param path string
---@return nil|{path: string; is_root: boolean; dirname?: string; basename: string; filename: string; extension?: string;}
function serialize_path(path)
if not path or is_protocol(path) then return end
local normal_path = normalize_path_lite(path)
local dirname, basename = utils.split_path(normal_path)
if basename == '' then basename, dirname = dirname:sub(1, #dirname - 1), nil end
local dot_i = string_last_index_of(basename, '.')
return {
path = normal_path,
is_root = dirname == nil,
dirname = dirname,
basename = basename,
filename = dot_i and basename:sub(1, dot_i - 1) or basename,
extension = dot_i and basename:sub(dot_i + 1) or nil,
}
end
-- Reads items in directory and splits it into directories and files tables.
---@param path string
---@param allowed_types? string[] Filter `files` table to contain only files with these extensions.
---@return string[]|nil files
---@return string[]|nil directories
function read_directory(path, allowed_types)
local items, error = utils.readdir(path, 'all')
if not items then
msg.error('Reading files from "' .. path .. '" failed: ' .. error)
return nil, nil
end
local files, directories = {}, {}
for _, item in ipairs(items) do
if item ~= '.' and item ~= '..' then
local info = utils.file_info(join_path(path, item))
if info then
if info.is_file then
if not allowed_types or has_any_extension(item, allowed_types) then
files[#files + 1] = item
end
else directories[#directories + 1] = item end
end
end
end
return files, directories
end
-- Returns full absolute paths of files in the same directory as file_path,
-- and index of the current file in the table.
---@param file_path string
---@param allowed_types? string[]
function get_adjacent_files(file_path, allowed_types)
local current_file = serialize_path(file_path)
if not current_file then return end
local files = read_directory(current_file.dirname, allowed_types)
if not files then return end
sort_filenames(files)
local current_file_index
local paths = {}
for index, file in ipairs(files) do
paths[#paths + 1] = join_path(current_file.dirname, file)
if current_file.basename == file then current_file_index = index end
end
if not current_file_index then return end
return paths, current_file_index
end
-- Navigates in a list, using delta or, when `state.shuffle` is enabled,
-- randomness to determine the next item. Loops around if `loop-playlist` is enabled.
---@param list table
---@param current_index number
---@param delta number
function decide_navigation_in_list(list, current_index, delta)
if #list < 2 then return #list, list[#list] end
if state.shuffle then
local new_index = current_index
math.randomseed(os.time())
while current_index == new_index do new_index = math.random(#list) end
return new_index, list[new_index]
end
local new_index = current_index + delta
if mp.get_property_native('loop-playlist') then
if new_index > #list then new_index = new_index % #list
elseif new_index < 1 then new_index = #list - new_index end
elseif new_index < 1 or new_index > #list then
return
end
return new_index, list[new_index]
end
---@param delta number
function navigate_directory(delta)
if not state.path or is_protocol(state.path) then return false end
local paths, current_index = get_adjacent_files(state.path, config.media_types)
if paths and current_index then
local _, path = decide_navigation_in_list(paths, current_index, delta)
if path then mp.commandv('loadfile', path) return true end
end
return false
end
---@param delta number
function navigate_playlist(delta)
local playlist, pos = mp.get_property_native('playlist'), mp.get_property_native('playlist-pos-1')
if playlist and #playlist > 1 and pos then
local index = decide_navigation_in_list(playlist, pos, delta)
if index then mp.commandv('playlist-play-index', index - 1) return true end
end
return false
end
---@param delta number
function navigate_item(delta)
if state.has_playlist then return navigate_playlist(delta) else return navigate_directory(delta) end
end
-- Can't use `os.remove()` as it fails on paths with unicode characters.
-- Returns `result, error`, result is table of:
-- `status:number(<0=error), stdout, stderr, error_string, killed_by_us:boolean`
---@param path string
function delete_file(path)
local args = state.os == 'windows' and {'cmd', '/C', 'del', path} or {'rm', path}
return mp.command_native({
name = 'subprocess',
args = args,
playback_only = false,
capture_stdout = true,
capture_stderr = true,
})
end
function serialize_chapter_ranges(normalized_chapters)
local ranges = {}
local simple_ranges = {
{name = 'openings', patterns = {'^op ', '^op$', ' op$', 'opening$'}, requires_next_chapter = true},
{name = 'intros', patterns = {'^intro$'}, requires_next_chapter = true},
{name = 'endings', patterns = {'^ed ', '^ed$', ' ed$', 'ending$', 'closing$'}},
{name = 'outros', patterns = {'^outro$'}},
}
local sponsor_ranges = {}
-- Extend with alt patterns
for _, meta in ipairs(simple_ranges) do
local alt_patterns = config.chapter_ranges[meta.name] and config.chapter_ranges[meta.name].patterns
if alt_patterns then meta.patterns = itable_join(meta.patterns, alt_patterns) end
end
-- Clone chapters
local chapters = {}
for i, normalized in ipairs(normalized_chapters) do chapters[i] = table_shallow_copy(normalized) end
for i, chapter in ipairs(chapters) do
-- Simple ranges
for _, meta in ipairs(simple_ranges) do
if config.chapter_ranges[meta.name] then
local match = itable_find(meta.patterns, function(p) return chapter.lowercase_title:find(p) end)
if match then
local next_chapter = chapters[i + 1]
if next_chapter or not meta.requires_next_chapter then
ranges[#ranges + 1] = table_assign({
start = chapter.time,
['end'] = next_chapter and next_chapter.time or infinity,
}, config.chapter_ranges[meta.name])
end
end
end
end
-- Sponsor blocks
if config.chapter_ranges.ads then
local id = chapter.lowercase_title:match('segment start *%(([%w]%w-)%)')
if id then -- ad range from sponsorblock
for j = i + 1, #chapters, 1 do
local end_chapter = chapters[j]
local end_match = end_chapter.lowercase_title:match('segment end *%(' .. id .. '%)')
if end_match then
local range = table_assign({
start_chapter = chapter, end_chapter = end_chapter,
start = chapter.time, ['end'] = end_chapter.time,
}, config.chapter_ranges.ads)
ranges[#ranges + 1], sponsor_ranges[#sponsor_ranges + 1] = range, range
end_chapter.is_end_only = true
break
end
end -- single chapter for ad
elseif not chapter.is_end_only and
(chapter.lowercase_title:find('%[sponsorblock%]:') or chapter.lowercase_title:find('^sponsors?')) then
local next_chapter = chapters[i + 1]
ranges[#ranges + 1] = table_assign({
start = chapter.time,
['end'] = next_chapter and next_chapter.time or infinity,
}, config.chapter_ranges.ads)
end
end
end
-- Fix overlapping sponsor block segments
for index, range in ipairs(sponsor_ranges) do
local next_range = sponsor_ranges[index + 1]
if next_range then
local delta = next_range.start - range['end']
if delta < 0 then
local mid_point = range['end'] + delta / 2
range['end'], range.end_chapter.time = mid_point - 0.01, mid_point - 0.01
next_range.start, next_range.start_chapter.time = mid_point, mid_point
end
end
end
table.sort(chapters, function(a, b) return a.time < b.time end)
return chapters, ranges
end
-- Ensures chapters are in chronological order
function normalize_chapters(chapters)
if not chapters then return {} end
-- Ensure chronological order
table.sort(chapters, function(a, b) return a.time < b.time end)
-- Ensure titles
for index, chapter in ipairs(chapters) do
chapter.title = chapter.title or ('Chapter ' .. index)
chapter.lowercase_title = chapter.title:lower()
end
return chapters
end
function serialize_chapters(chapters)
chapters = normalize_chapters(chapters)
if not chapters then return end
--- timeline font size isn't accessible here, so normalize to size 1 and then scale during rendering
local opts = {size = 1, bold = true}
for index, chapter in ipairs(chapters) do
chapter.index = index
chapter.title_wrapped = wrap_text(chapter.title, opts, 25)
chapter.title_wrapped_width = text_width(chapter.title_wrapped, opts)
chapter.title_wrapped = ass_escape(chapter.title_wrapped)
end
return chapters
end
--[[ RENDERING ]]
function render()
if not display.initialized then return end
state.render_last_time = mp.get_time()
-- Actual rendering
local ass = assdraw.ass_new()
for _, element in Elements:ipairs() do
if element.enabled then
local result = element:maybe('render')
if result then
ass:new_event()
ass:merge(result)
end
end
end
-- submit
if osd.res_x == display.width and osd.res_y == display.height and osd.data == ass.text then
return
end
osd.res_x = display.width
osd.res_y = display.height
osd.data = ass.text
osd.z = 2000
osd:update()
update_margins()
end
-- Request that render() is called.
-- The render is then either executed immediately, or rate-limited if it was
-- called a small time ago.
state.render_timer = mp.add_timeout(0, render)
state.render_timer:kill()
function request_render()
if state.render_timer:is_enabled() then return end
local timeout = math.max(0, state.render_delay - (mp.get_time() - state.render_last_time))
state.render_timer.timeout = timeout
state.render_timer:resume()
end
|