summaryrefslogtreecommitdiffstats
path: root/.config/mpv/scripts/uosc_shared/lib/utils.lua
blob: 3202ef1fec7dd7b57ec4730751f1b4e0bc984df1 (plain)
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