summaryrefslogtreecommitdiff
path: root/mac/.config/mpv/scripts/navigator.lua
blob: 91f920848b7a7e01b666f40573e223d83e0b8e79 (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
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
local utils = require("mp.utils")
local mpopts = require("mp.options")
local assdraw = require("mp.assdraw")
local user = os.getenv("USER") or "si"
local home = os.getenv("HOME") or ("/home/" .. user)

ON_WINDOWS = (package.config:sub(1, 1) ~= "/")
WINDOWS_ROOTDIR = false
WINDOWS_ROOT_DESC = "Select drive"
SEPARATOR_WINDOWS = "\\"

SEPARATOR = "/"

local windows_desktop = ON_WINDOWS
		and utils.join_path(os.getenv("USERPROFILE"), "Desktop"):gsub(SEPARATOR, SEPARATOR_WINDOWS) .. SEPARATOR_WINDOWS
	or nil

local settings = {
	--navigation keybinds override arrowkeys and enter when activating navigation menu, false means keys are always actíve
	dynamic_binds = true,
	navigator_mainkey = "shift+v", --the key to bring up navigator's menu, can be bound on input.conf aswell

	--dynamic binds, should not be bound in input.conf unless dynamic binds is false
	key_navfavorites = "ctrl+f",
	key_navup = "k",
	key_navdown = "j",
	key_navback = "h",
	key_navforward = "l",
	key_navopen = "ENTER",
	key_navclose = "q",

	--fallback if no file is open, should be a string that points to a path in your system
	defaultpath = windows_desktop or os.getenv("HOME") or "/",
	forcedefault = false, --force navigation to start from defaultpath instead of currently playing file
	--favorites in format { 'Path to directory, notice trailing /' }
	--on windows use double backslash c:\\my\\directory\\
	favorites = {
		home .. "/Downloads",
		home .. "/Torrents/complete",
		home .. "/Movies",
		home .. "/.config/mpv/playlists",
	},
	--list of paths to ignore. the value is anything that returns true for if-statement.
	--directory ignore entries must end with a trailing slash,
	--but files and all symlinks (even to dirs) must be without slash!
	--to help you with the format, simply run "ls -1p <parent folder>" in a terminal,
	--and you will see if the file/folder to ignore is listed as "file" or "folder/" (trailing slash).
	--you can ignore children without ignoring their parent.
	ignorePaths = {
		--general linux system paths (some are used by macOS too):
		["/bin/"] = "1",
		["/boot/"] = "1",
		["/cdrom/"] = "1",
		["/dev/"] = "1",
		["/etc/"] = "1",
		["/lib/"] = "1",
		["/lib32/"] = "1",
		["/lib64/"] = "1",
		["/tmp/"] = "1",
		["/srv/"] = "1",
		["/sys/"] = "1",
		["/snap/"] = "1",
		["/root/"] = "1",
		["/sbin/"] = "1",
		["/proc/"] = "1",
		["/opt/"] = "1",
		["/usr/"] = "1",
		["/run/"] = "1",
		--useless macOS system paths (some of these standard folders are actually files (symlinks) into /private/ subpaths, hence some repetition):
		["/cores/"] = "1",
		["/etc"] = "1",
		["/installer.failurerequests"] = "1",
		["/net/"] = "1",
		["/private/"] = "1",
		["/tmp"] = "1",
		["/var"] = "1",
	},
	--ignore folders and files that match patterns regardless of where they exist on disk.
	--make sure you use ^ (start of string) and $ (end of string) to catch the whole str instead of risking partial false positives.
	--read about patterns at https://www.lua.org/pil/20.2.html or http://lua-users.org/wiki/PatternsTutorial
	ignorePatterns = {
		"^initrd%..*/?$", --hide files and folders folders starting with "initrd.<something>"
		"^vmlinuz.*/?$", --hide files and folders starting with "vmlinuz<something>"
		"^lost%+found/?$", --hide files and folders named "lost+found"
		"^%$.*$", --ignore files starting with $
		"^.*%.ico$",
		"^.*%.txt$",
		"^.*%.ahk$",
		"^.*%.reg$",
		"^.*%.exe$",
		"^.*%.bin$",
		"^.*%.mpq$",
		"^.*%.inf$",
		"^.*%.pdf$",
		"^.*%.docx$",
		"^.*%.xlsx$",
		"^.*%.pptx$",
		"^.*%.zip$",
		"^.*%.rar$",
		"^.*%.tar$",
		"^.*%.gz$",
		"^.*%.bz2$",
		"^.*%.7z$",
		"^.*%.pkg$",
		"^.*%.deb$",
		"^.*%.rpm$",
		"^.*%.dll$",
		"^.*%.sys$",
		"^.*%.cfg$",
		"^.*%.ini$",
		"^.*%.dat$",
		"^.*%.bat$",
		"^.*%.cmd$",
		"^.*%.js$",
		"^.*%.html$",
		"^.*%.htm$",
		"^.*%.css$",
		"^.*%.xml$",
		"^.*%.json$",
		"^.*%.csv$",
		"^.*%.md$",
		"^.*%.yml$",
		"^.*%.yaml$",
		"^.*%.sql$",
		"^.*%.py$",
		"^.*%.java$",
		"^.*%.c$",
		"^.*%.cpp$",
		"^.*%.h$",
		"^.*%.rb$",
		"^.*%.pl$",
		"^.*%.php$",
		"^.*%.asp$",
		"^.*%.aspx$",
		"^.*%.jsp$",
		"^.*%.sh$",
		"^.*%.vbs$",
		"^.*%.ps1$",
		"^.*%.log$",
		"^.*%.msg$",
		"^.*%.eml$",
		"^.*%.ics$",
		"^.*%.vcard$",
		"^.*%.vcf$",
		"^.*%.mdf$",
		"^.*%.ldf$",
		"^.*%.nfo$",
		"^.*%.tmp$",
		"^.*%.bak$",
		"^.*%.hax$",
	},

	subtitleformats = {
		"srt",
		"smi",
		"ass",
		"lrc",
		"ssa",
		"ttml",
		"sbv",
		"vtt",
		"txt",
	},

	navigator_menu_favkey = "F", --this key will always be bound when the menu is open, and is the key you use to cycle your favorites list!
	menu_timeout = false, --menu timeouts and closes itself after navigator_duration seconds, else will be toggled by keybind
	navigator_duration = 13, --osd duration before the navigator closes, if timeout is set to true
	visible_item_count = 20, --how many menu items to show per screen

	--font size scales by window, if false requires larger font and padding sizes
	scale_by_window = true,
	--paddings from top left corner
	text_padding_x = 10,
	text_padding_y = 20,
	-- --ass style overrides inside curly brackets, \keyvalue is one field, extra \ for escape in lua
	-- --example {\\fnUbuntu\\fs10\\b0\\bord1} equals: font=Ubuntu, size=10, bold=no, border=1
	-- --read http://docs.aegisub.org/3.2/ASS_Tags/ for reference of tags
	-- --undeclared tags will use default osd settings
	-- --these styles will be used for the whole navigator
	-- style_ass_tags = "{}",
	-- --you can also use the ass tags mentioned above. For example:
	-- --selection_prefix="{\\c&HFF00FF&}● " - to add a color for selected file. However, if you
	-- --use ass tags you need to set them for both name and selection prefix (see https://github.com/jonniek/mpv-playlistmanager/issues/20)
	-- name_prefix = "○ ",
	-- selection_prefix = "● ",

	-- For white color:
	style_ass_tags = "{\\q2\\an7\\fnUbuntu\\fs8\\b0\\bord1\\c&HFFFFFF&}",
	name_prefix = "{\\c&HFFFFFF&}○ ",

	-- For orange selection:
	selection_prefix = "{\\c&H0080FF&}● ",
}

mpopts.read_options(settings)

--escape a file or directory path for use in shell arguments
function escapepath(dir, escapechar)
	return string.gsub(dir, escapechar, "\\" .. escapechar)
end

local sub_lookup = {}
for _, ext in ipairs(settings.subtitleformats) do
	sub_lookup[ext] = true
end

--ensures directories never accidentally end in "//" due to our added slash
function stripdoubleslash(dir)
	if string.sub(dir, -2) == "//" then
		return string.sub(dir, 1, -2) --negative 2 removes the last character
	else
		return dir
	end
end

function os.capture(cmd, raw)
	local f = assert(io.popen(cmd, "r"))
	local s = assert(f:read("*a"))
	f:close()
	return string.sub(s, 0, -2)
end

dir = nil
path = nil
cursor = 0
length = 0
--osd handler that displays your navigation and information
function handler()
	add_keybinds()
	timer:kill()
	local ass = assdraw.ass_new()
	ass:new_event()
	ass:pos(settings.text_padding_x, settings.text_padding_y)
	ass:append(settings.style_ass_tags)

	if not path then
		if mp.get_property("path") and not settings.forcedefault then
			--determine path from currently playing file...
			local workingdir = mp.get_property("working-directory")
			local playfilename = mp.get_property("filename") --just the filename, without path
			local playpath = mp.get_property("path") --can be relative or absolute depending on what args mpv was given
			local firstchar = string.sub(playpath, 1, 1)
			--first we need to remove the filename (may give us empty path if mpv was started in same dir as file)
			path = string.sub(playpath, 1, string.len(playpath) - string.len(playfilename))
			if firstchar ~= "/" and not ON_WINDOWS then --the path of the playing file wasn't absolute, so we need to add mpv's working dir to it
				path = workingdir .. "/" .. path
			end
			--now resolve that path (to resolve things like "/home/anon/Movies/../Movies/foo.mkv")
			path = resolvedir(path)
			--lastly, check if the folder exists, and if not then fall back to the current mpv working dir
			if not isfolder(path) then
				if ON_WINDOWS then
					path = workingdir .. SEPARATOR_WINDOWS
				else
					path = workingdir
				end
			end
		else
			path = settings.defaultpath
		end
		dir, length = scandirectory(path)
	end
	ass:append(path .. "\\N\\N")
	local b = cursor - math.floor(settings.visible_item_count / 2)
	if b > 0 then
		ass:append("...\\N")
	end
	if b < 0 then
		b = 0
	end
	for a = b, (b + settings.visible_item_count), 1 do
		if a == length then
			break
		end
		local prefix = (a == cursor and settings.selection_prefix or settings.name_prefix)
		ass:append(prefix .. dir[a] .. "\\N")
		if a == (b + settings.visible_item_count) then
			ass:append("...")
		end
	end
	local w, h = mp.get_osd_size()
	if settings.scale_by_window then
		w, h = 0, 0
	end
	mp.set_osd_ass(w, h, ass.text)
	if settings.menu_timeout then
		timer:resume()
	end
end

function navdown()
	if cursor ~= length - 1 then
		cursor = cursor + 1
	else
		cursor = 0
	end
	handler()
end

function navup()
	if cursor ~= 0 then
		cursor = cursor - 1
	else
		cursor = length - 1
	end
	handler()
end

--moves into selected directory, or appends to playlist incase of file
function childdir()
	local item = dir[cursor]

	-- windows only
	if ON_WINDOWS then
		if WINDOWS_ROOTDIR then
			WINDOWS_ROOTDIR = false
		end
		if item then
			local newdir = utils.join_path(path, item):gsub(SEPARATOR, SEPARATOR_WINDOWS) .. SEPARATOR_WINDOWS
			local info, error = utils.file_info(newdir)

			if info and info.is_dir then
				changepath(newdir)
			else
				if issubtitle(item) then
					loadsubs(utils.join_path(path, item))
				else
					mp.commandv("loadfile", utils.join_path(path, item), "append-play")
					mp.osd_message("Appended file to playlist: " .. item)
				end
				handler()
			end
		end

		return
	end

	if item then
		if isfolder(utils.join_path(path, item)) then
			local newdir = stripdoubleslash(utils.join_path(path, dir[cursor] .. "/"))
			changepath(newdir)
		else
			if issubtitle(item) then
				loadsubs(utils.join_path(path, item))
			else
				mp.commandv("loadfile", utils.join_path(path, item), "append-play")
				mp.osd_message("Appended file to playlist: " .. item)
			end
			handler()
		end
	end
end

function issubtitle(file)
	local ext = file:match("^.+%.(.+)$")
	return ext and sub_lookup[ext:lower()]
end

function loadsubs(file)
	mp.commandv("sub_add", file)
	mp.osd_message("Loaded subtitle: " .. file)
end

--replace current playlist with directory or file
--if directory, mpv will recursively queue all items found in the directory and its subfolders
function opendir()
	local item = dir[cursor]

	if item then
		remove_keybinds()

		local filepath = utils.join_path(path, item)
		if ON_WINDOWS then
			filepath = filepath:gsub(SEPARATOR, SEPARATOR_WINDOWS)
		end

		if issubtitle(item) then
			return loadsubs(filepath)
		end

		mp.commandv("loadfile", filepath, "replace")
	end
end

--changes the directory to the path in argument
function changepath(args)
	path = args
	if WINDOWS_ROOTDIR then
		path = WINDOWS_ROOT_DESC
	end
	dir, length = scandirectory(path)
	cursor = 0
	handler()
end

--move up to the parent directory
function parentdir()
	-- windows only
	if ON_WINDOWS then
		if path:sub(-1) == SEPARATOR_WINDOWS then
			path = path:sub(1, -2)
		end
		local parent = utils.split_path(path)
		if path == parent then
			WINDOWS_ROOTDIR = true
		end
		changepath(parent)
		return
	end

	--if path doesn't exist or can't be entered, this returns "/" (root of the drive) as the parent
	local parent = stripdoubleslash(
		os.capture('cd "' .. escapepath(path, '"') .. '" 2>/dev/null && cd .. 2>/dev/null && pwd') .. "/"
	)

	changepath(parent)
end

--resolves relative paths such as "/home/foo/../foo/Music" (to "/home/foo/Music") if the folder exists!
function resolvedir(dir)
	local safedir = escapepath(dir, '"')

	-- windows only
	if ON_WINDOWS then
		local resolved = stripdoubleslash(os.capture('cd /d "' .. safedir .. '" && cd'))
		return resolved .. SEPARATOR_WINDOWS
	end

	--if dir doesn't exist or can't be entered, this returns "/" (root of the drive) as the resolved path
	local resolved = stripdoubleslash(os.capture('cd "' .. safedir .. '" 2>/dev/null && pwd') .. "/")
	return resolved
end

--true if path exists and is a folder, otherwise false
function isfolder(dir)
	-- windows only
	if ON_WINDOWS then
		local info, error = utils.file_info(dir)
		return info and info.is_dir or nil
	end

	local lua51returncode, _, lua52returncode = os.execute('test -d "' .. escapepath(dir, '"') .. '"')
	return lua51returncode == 0 or lua52returncode == 0
end

function scandirectory(searchdir)
	local directory = {}
	--list all files, using universal utilities and flags available on both Linux and macOS
	--  ls: -1 = list one file per line, -p = append "/" indicator to the end of directory names, -v = display in natural order
	--  stderr messages are ignored by sending them to /dev/null
	--  hidden files ("." prefix) are skipped, since they exist everywhere and never contain media
	--  if we cannot list the contents (due to no permissions, etc), this returns an empty list

	-- windows only
	if ON_WINDOWS then
		-- handle drive letters
		if WINDOWS_ROOTDIR then
			local popen, err = io.popen("wmic logicaldisk get caption")
			local i = 0
			if popen then
				for direntry in popen:lines() do
					-- only single letter followed by colon (:) are valid
					if string.find(direntry, "^%a:") then
						direntry = string.sub(direntry, 1, 2)
						local matchedignore = false
						for k, pattern in pairs(settings.ignorePatterns) do
							if direntry:find(pattern) then
								matchedignore = true
								break --don't waste time scanning further patterns
							end
						end
						if not matchedignore and not settings.ignorePaths[path .. direntry] then
							directory[i] = direntry
							i = i + 1
						end
					end
				end
				popen:close()
			else
				mp.msg.error("Could not scan for files :" .. (err or ""))
			end

			return directory, i
		end

		local i = 0
		local files = utils.readdir(searchdir)

		if not files then
			mp.msg.error("Could not scan for files :" .. (err or ""))
			return directory, i
		end

		for _, direntry in ipairs(files) do
			local matchedignore = false
			for k, pattern in pairs(settings.ignorePatterns) do
				if direntry:find(pattern) then
					matchedignore = true
					break --don't waste time scanning further patterns
				end
			end
			if not matchedignore and not settings.ignorePaths[path .. direntry] then
				directory[i] = direntry
				i = i + 1
			end
		end

		return directory, i
	end

	local popen, err = io.popen('ls -1vp "' .. escapepath(searchdir, '"') .. '" 2>/dev/null')
	local i = 0
	if popen then
		for direntry in popen:lines() do
			local matchedignore = false
			for k, pattern in pairs(settings.ignorePatterns) do
				if direntry:find(pattern) then
					matchedignore = true
					break --don't waste time scanning further patterns
				end
			end
			if not matchedignore and not settings.ignorePaths[path .. direntry] then
				directory[i] = direntry
				i = i + 1
			end
		end
		popen:close()
	else
		mp.msg.error("Could not scan for files :" .. (err or ""))
	end
	return directory, i
end

favcursor = 1
function cyclefavorite()
	local firstpath = settings.favorites[1]
	if not firstpath then
		return
	end
	local favpath = nil
	local favlen = 0
	for key, fav in pairs(settings.favorites) do
		favlen = favlen + 1
		if key == favcursor then
			favpath = fav
		end
	end
	if favpath then
		changepath(favpath)
		favcursor = favcursor + 1
	else
		changepath(firstpath)
		favcursor = 2
	end
end

function add_keybinds()
	mp.add_forced_key_binding(settings.key_navdown, "navdown", navdown, "repeatable")
	mp.add_forced_key_binding(settings.key_navup, "navup", navup, "repeatable")
	mp.add_forced_key_binding(settings.key_navopen, "navopen", opendir)
	mp.add_forced_key_binding(settings.key_navforward, "navforward", childdir)
	mp.add_forced_key_binding(settings.key_navback, "navback", parentdir)
	mp.add_forced_key_binding(settings.key_navfavorites, "navfavorites", cyclefavorite)
	mp.add_forced_key_binding(settings.key_navclose, "navclose", remove_keybinds)
end

function remove_keybinds()
	timer:kill()
	mp.set_osd_ass(0, 0, "")
	if settings.dynamic_binds then
		mp.remove_key_binding("navdown")
		mp.remove_key_binding("navup")
		mp.remove_key_binding("navopen")
		mp.remove_key_binding("navforward")
		mp.remove_key_binding("navback")
		mp.remove_key_binding("navfavorites")
		mp.remove_key_binding("navclose")
	end
end

timer = mp.add_periodic_timer(settings.navigator_duration, remove_keybinds)
timer:kill()

if not settings.dynamic_binds then
	add_keybinds()
end

active = false
function activate()
	if settings.menu_timeout then
		handler()
	else
		if active then
			remove_keybinds()
			active = false
		else
			handler()
			active = true
		end
	end
end

mp.add_key_binding(settings.navigator_mainkey, "navigator", activate)