Updates
This commit is contained in:
parent
dd3111def5
commit
bd98d08202
|
@ -0,0 +1,75 @@
|
|||
theme = "gruvbox"
|
||||
|
||||
[keys.normal]
|
||||
esc = ["collapse_selection", "keep_primary_selection"]
|
||||
|
||||
[keys.normal.space.space]
|
||||
r = ":config-reload"
|
||||
s = ":w"
|
||||
c = ":config-open"
|
||||
w = ":bc"
|
||||
Q = ":buffer-close-others"
|
||||
q = ":q"
|
||||
n = ":n"
|
||||
m = ":run-shell-command make"
|
||||
f = ":set-option auto-format false"
|
||||
F = ":set-option auto-format true"
|
||||
l = [":new", ":insert-output lazygit", ":buffer-close!", ":redraw"]
|
||||
'.' = ":run-shell-command ./main"
|
||||
|
||||
[keys.normal.space.space.t]
|
||||
g = ":theme gruvbox"
|
||||
d = ":theme dracula"
|
||||
o = ":theme onedark"
|
||||
c = ":theme catppuccin_mocha"
|
||||
e = ":theme everforest_dark"
|
||||
|
||||
[editor]
|
||||
line-number = "absolute"
|
||||
mouse = true
|
||||
middle-click-paste = true
|
||||
scroll-lines = 1
|
||||
cursorline = true
|
||||
bufferline = "multiple"
|
||||
scrolloff = 8
|
||||
color-modes = true
|
||||
completion-trigger-len = 1
|
||||
|
||||
[editor.cursor-shape]
|
||||
insert = "bar"
|
||||
normal = "block"
|
||||
select = "underline"
|
||||
|
||||
[editor.file-picker]
|
||||
hidden = false
|
||||
|
||||
[editor.statusline]
|
||||
separator = "│"
|
||||
left = ["mode", "spinner"]
|
||||
center = ["file-name"]
|
||||
right = ["file-type"]
|
||||
mode.normal = "NORMAL"
|
||||
mode.insert = "INSERT"
|
||||
mode.select = "SELECT"
|
||||
|
||||
[editor.lsp]
|
||||
enable = true
|
||||
display-messages = true
|
||||
|
||||
[editor.whitespace.render]
|
||||
space = "all"
|
||||
tab = "all"
|
||||
newline = "none"
|
||||
|
||||
[editor.whitespace.characters]
|
||||
space = "·"
|
||||
tab = "→"
|
||||
newline = "⏎"
|
||||
|
||||
[editor.indent-guides]
|
||||
render = true
|
||||
character = "▏" # Some characters that work well: "▏", "┆", "┊", "⸽"
|
||||
skip-levels = 1
|
||||
|
||||
[editor.soft-wrap]
|
||||
enable = true
|
|
@ -0,0 +1,25 @@
|
|||
use-grammars = { only = [
|
||||
"hare",
|
||||
"nasm",
|
||||
"c",
|
||||
"html",
|
||||
"css",
|
||||
"markdown",
|
||||
"yaml",
|
||||
"toml",
|
||||
"dockerfile",
|
||||
"bash",
|
||||
"make",
|
||||
"ini",
|
||||
"git-commit",
|
||||
"git-config",
|
||||
"git-rebase"
|
||||
]}
|
||||
|
||||
#[[language]]
|
||||
#name = "hare"
|
||||
#indent = { tab-width = 8, unit = " " }
|
||||
|
||||
[[language]]
|
||||
name = "c"
|
||||
indent = { tab-width = 2, unit = " "}
|
|
@ -0,0 +1,57 @@
|
|||
# Beware! This file is rewritten by htop when settings are changed in the interface.
|
||||
# The parser is also very primitive, and not human-friendly.
|
||||
htop_version=3.3.0
|
||||
config_reader_min_version=3
|
||||
fields=0 48 17 18 38 39 40 2 46 47 49 1
|
||||
hide_kernel_threads=1
|
||||
hide_userland_threads=0
|
||||
hide_running_in_container=1
|
||||
shadow_other_users=0
|
||||
show_thread_names=0
|
||||
show_program_path=0
|
||||
highlight_base_name=0
|
||||
highlight_deleted_exe=1
|
||||
shadow_distribution_path_prefix=0
|
||||
highlight_megabytes=1
|
||||
highlight_threads=1
|
||||
highlight_changes=1
|
||||
highlight_changes_delay_secs=5
|
||||
find_comm_in_cmdline=1
|
||||
strip_exe_from_cmdline=1
|
||||
show_merged_command=0
|
||||
header_margin=1
|
||||
screen_tabs=1
|
||||
detailed_cpu_time=0
|
||||
cpu_count_from_one=1
|
||||
show_cpu_usage=1
|
||||
show_cpu_frequency=1
|
||||
show_cpu_temperature=1
|
||||
degree_fahrenheit=0
|
||||
update_process_names=0
|
||||
account_guest_in_cpu_meter=0
|
||||
color_scheme=6
|
||||
enable_mouse=0
|
||||
delay=15
|
||||
hide_function_bar=0
|
||||
header_layout=three_33_34_33
|
||||
column_meters_0=AllCPUs Memory
|
||||
column_meter_modes_0=1 1
|
||||
column_meters_1=Uptime
|
||||
column_meter_modes_1=4
|
||||
column_meters_2=DiskIO NetworkIO Tasks
|
||||
column_meter_modes_2=2 2 2
|
||||
tree_view=1
|
||||
sort_key=46
|
||||
tree_sort_key=0
|
||||
sort_direction=1
|
||||
tree_sort_direction=1
|
||||
tree_view_always_by_pid=0
|
||||
all_branches_collapsed=0
|
||||
screen:Main=PID USER PRIORITY NICE M_VIRT M_RESIDENT M_SHARE STATE PERCENT_CPU PERCENT_MEM TIME Command
|
||||
.sort_key=PERCENT_CPU
|
||||
.tree_sort_key=PID
|
||||
.tree_view_always_by_pid=0
|
||||
.tree_view=1
|
||||
.sort_direction=1
|
||||
.tree_sort_direction=1
|
||||
.all_branches_collapsed=0
|
|
@ -0,0 +1,2 @@
|
|||
g script-message contact-sheet-close; script-message playlist-view-toggle
|
||||
c script-message playlist-view-close; script-message contact-sheet-toggle
|
|
@ -0,0 +1 @@
|
|||
ytdl-format=bestvideo[height<=?720]+bestaudio/best
|
|
@ -0,0 +1,516 @@
|
|||
local utils = require 'mp.utils'
|
||||
local msg = require 'mp.msg'
|
||||
local assdraw = require 'mp.assdraw'
|
||||
|
||||
local gallery_mt = {}
|
||||
gallery_mt.__index = gallery_mt
|
||||
|
||||
function gallery_new()
|
||||
local gallery = setmetatable({
|
||||
-- public, can be modified by user
|
||||
items = {},
|
||||
item_to_overlay_path = function(index, item) return "" end,
|
||||
item_to_thumbnail_params = function(index, item) return "", 0 end,
|
||||
item_to_text = function(index, item) return "", true end,
|
||||
item_to_border = function(index, item) return 0, "" end,
|
||||
ass_show = function(ass) end,
|
||||
config = {
|
||||
background_color = '333333',
|
||||
background_opacity = '33',
|
||||
background_roundness = 5,
|
||||
scrollbar = true,
|
||||
scrollbar_left_side = false,
|
||||
scrollbar_min_size = 10,
|
||||
overlay_range = 0,
|
||||
max_thumbnails = 64,
|
||||
show_placeholders = true,
|
||||
always_show_placeholders = false,
|
||||
placeholder_color = '222222',
|
||||
text_size = 28,
|
||||
align_text = true,
|
||||
accurate = false,
|
||||
generate_thumbnails_with_mpv = false,
|
||||
},
|
||||
|
||||
-- private, can be read but should not be modified
|
||||
active = false,
|
||||
geometry = {
|
||||
ok = false,
|
||||
position = { 0, 0 },
|
||||
size = { 0, 0 },
|
||||
min_spacing = { 0, 0 },
|
||||
thumbnail_size = { 0, 0 },
|
||||
rows = 0,
|
||||
columns = 0,
|
||||
effective_spacing = { 0, 0 },
|
||||
},
|
||||
view = { -- 1-based indices into the "playlist" array
|
||||
first = 0, -- must be equal to N*columns
|
||||
last = 0, -- must be > first and <= first + rows*columns
|
||||
},
|
||||
overlays = {
|
||||
active = {}, -- array of <=64 strings indicating the file associated to the current overlay (false if nothing)
|
||||
missing = {}, -- associative array of thumbnail path to view index it should be shown at
|
||||
},
|
||||
selection = nil,
|
||||
ass = {
|
||||
background = "",
|
||||
selection = "",
|
||||
scrollbar = "",
|
||||
placeholders = "",
|
||||
},
|
||||
generators = {}, -- list of generator scripts
|
||||
|
||||
|
||||
}, gallery_mt)
|
||||
|
||||
for i = 1, gallery.config.max_thumbnails do
|
||||
gallery.overlays.active[i] = false
|
||||
end
|
||||
return gallery
|
||||
end
|
||||
|
||||
function gallery_mt.show_overlay(gallery, index_1, thumb_path)
|
||||
local g = gallery.geometry
|
||||
gallery.overlays.active[index_1] = thumb_path
|
||||
local index_0 = index_1 - 1
|
||||
local x, y = gallery:view_index_position(index_0)
|
||||
mp.commandv("overlay-add",
|
||||
tostring(index_0 + gallery.config.overlay_range),
|
||||
tostring(math.floor(x + 0.5)),
|
||||
tostring(math.floor(y + 0.5)),
|
||||
thumb_path,
|
||||
"0",
|
||||
"bgra",
|
||||
tostring(g.thumbnail_size[1]),
|
||||
tostring(g.thumbnail_size[2]),
|
||||
tostring(4*g.thumbnail_size[1]))
|
||||
mp.osd_message(" ", 0.01)
|
||||
end
|
||||
|
||||
function gallery_mt.remove_overlays(gallery)
|
||||
for view_index, _ in pairs(gallery.overlays.active) do
|
||||
mp.commandv("overlay-remove", gallery.config.overlay_range + view_index - 1)
|
||||
gallery.overlays.active[view_index] = false
|
||||
end
|
||||
gallery.overlays.missing = {}
|
||||
end
|
||||
|
||||
local function file_exists(path)
|
||||
local info = utils.file_info(path)
|
||||
return info ~= nil and info.is_file
|
||||
end
|
||||
|
||||
function gallery_mt.refresh_overlays(gallery, force)
|
||||
local todo = {}
|
||||
local o = gallery.overlays
|
||||
local g = gallery.geometry
|
||||
o.missing = {}
|
||||
for view_index = 1, g.rows * g.columns do
|
||||
local index = gallery.view.first + view_index - 1
|
||||
local active = o.active[view_index]
|
||||
if index > 0 and index <= #gallery.items then
|
||||
local thumb_path = gallery.item_to_overlay_path(index, gallery.items[index])
|
||||
if not force and active == thumb_path then
|
||||
-- nothing to do
|
||||
elseif file_exists(thumb_path) then
|
||||
gallery:show_overlay(view_index, thumb_path)
|
||||
else
|
||||
-- need to generate that thumbnail
|
||||
o.active[view_index] = false
|
||||
mp.commandv("overlay-remove", gallery.config.overlay_range + view_index - 1)
|
||||
o.missing[thumb_path] = view_index
|
||||
todo[#todo + 1] = { index = index, output = thumb_path }
|
||||
end
|
||||
else
|
||||
-- might happen if we're close to the end of gallery.items
|
||||
if active ~= false then
|
||||
o.active[view_index] = false
|
||||
mp.commandv("overlay-remove", gallery.config.overlay_range + view_index - 1)
|
||||
end
|
||||
end
|
||||
end
|
||||
if #gallery.generators >= 1 then
|
||||
-- reverse iterate so that the first thumbnail is at the top of the stack
|
||||
for i = #todo, 1, -1 do
|
||||
local generator = gallery.generators[i % #gallery.generators + 1]
|
||||
local t = todo[i]
|
||||
local input_path, time = gallery.item_to_thumbnail_params(t.index, gallery.items[t.index])
|
||||
mp.commandv("script-message-to", generator, "push-thumbnail-front",
|
||||
mp.get_script_name(),
|
||||
input_path,
|
||||
tostring(g.thumbnail_size[1]),
|
||||
tostring(g.thumbnail_size[2]),
|
||||
time,
|
||||
t.output,
|
||||
gallery.config.accurate and "true" or "false",
|
||||
gallery.config.generate_thumbnails_with_mpv and "true" or "false"
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function gallery_mt.index_at(gallery, mx, my)
|
||||
local g = gallery.geometry
|
||||
if mx < g.position[1] or my < g.position[2] then return nil end
|
||||
mx = mx - g.position[1]
|
||||
my = my - g.position[2]
|
||||
if mx > g.size[1] or my > g.size[2] then return nil end
|
||||
mx = mx - g.effective_spacing[1]
|
||||
my = my - g.effective_spacing[2]
|
||||
local on_column = (mx % (g.thumbnail_size[1] + g.effective_spacing[1])) < g.thumbnail_size[1]
|
||||
local on_row = (my % (g.thumbnail_size[2] + g.effective_spacing[2])) < g.thumbnail_size[2]
|
||||
if on_column and on_row then
|
||||
local column = math.floor(mx / (g.thumbnail_size[1] + g.effective_spacing[1]))
|
||||
local row = math.floor(my / (g.thumbnail_size[2] + g.effective_spacing[2]))
|
||||
local index = gallery.view.first + row * g.columns + column
|
||||
if index > 0 and index <= gallery.view.last then
|
||||
return index
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
function gallery_mt.compute_internal_geometry(gallery)
|
||||
local g = gallery.geometry
|
||||
g.rows = math.floor((g.size[2] - g.min_spacing[2]) / (g.thumbnail_size[2] + g.min_spacing[2]))
|
||||
g.columns = math.floor((g.size[1] - g.min_spacing[1]) / (g.thumbnail_size[1] + g.min_spacing[1]))
|
||||
if g.rows <= 0 or g.columns <= 0 then
|
||||
g.rows = 0
|
||||
g.columns = 0
|
||||
g.effective_spacing[1] = g.size[1]
|
||||
g.effective_spacing[2] = g.size[2]
|
||||
return
|
||||
end
|
||||
if (g.rows * g.columns > gallery.config.max_thumbnails) then
|
||||
local r = math.sqrt(g.rows * g.columns / gallery.config.max_thumbnails)
|
||||
g.rows = math.floor(g.rows / r)
|
||||
g.columns = math.floor(g.columns / r)
|
||||
end
|
||||
g.effective_spacing[1] = (g.size[1] - g.columns * g.thumbnail_size[1]) / (g.columns + 1)
|
||||
g.effective_spacing[2] = (g.size[2] - g.rows * g.thumbnail_size[2]) / (g.rows + 1)
|
||||
end
|
||||
|
||||
-- makes sure that view.first and view.last are valid with regards to the playlist
|
||||
-- and that selection is within the view
|
||||
-- to be called after the playlist, view or selection was modified somehow
|
||||
function gallery_mt.ensure_view_valid(gallery)
|
||||
local g = gallery.geometry
|
||||
if #gallery.items == 0 or g.rows == 0 or g.columns == 0 then
|
||||
gallery.view.first = 0
|
||||
gallery.view.last = 0
|
||||
return
|
||||
end
|
||||
local v = gallery.view
|
||||
local selection_row = math.floor((gallery.selection - 1) / g.columns)
|
||||
local max_thumbs = g.rows * g.columns
|
||||
local changed = false
|
||||
|
||||
if v.last >= #gallery.items then
|
||||
v.last = #gallery.items
|
||||
if g.rows == 1 then
|
||||
v.first = math.max(1, v.last - g.columns + 1)
|
||||
else
|
||||
local last_row = math.floor((v.last - 1) / g.columns)
|
||||
local first_row = math.max(0, last_row - g.rows + 1)
|
||||
v.first = 1 + first_row * g.columns
|
||||
end
|
||||
changed = true
|
||||
elseif v.first == 0 or v.last == 0 or v.last - v.first + 1 ~= max_thumbs then
|
||||
-- special case: the number of possible thumbnails was changed
|
||||
-- just recreate the view such that the selection is in the middle row
|
||||
local max_row = (#gallery.items - 1) / g.columns + 1
|
||||
local row_first = selection_row - math.floor((g.rows - 1) / 2)
|
||||
local row_last = selection_row + math.floor((g.rows - 1) / 2) + g.rows % 2
|
||||
if row_first < 0 then
|
||||
row_first = 0
|
||||
elseif row_last > max_row then
|
||||
row_first = max_row - g.rows + 1
|
||||
end
|
||||
v.first = 1 + row_first * g.columns
|
||||
v.last = math.min(#gallery.items, v.first - 1 + max_thumbs)
|
||||
return true
|
||||
end
|
||||
|
||||
if gallery.selection < v.first then
|
||||
-- the selection is now on the first line
|
||||
v.first = (g.rows == 1) and gallery.selection or selection_row * g.columns + 1
|
||||
v.last = math.min(#gallery.items, v.first + max_thumbs - 1)
|
||||
changed = true
|
||||
elseif gallery.selection > v.last then
|
||||
v.last = (g.rows == 1) and gallery.selection or (selection_row + 1) * g.columns
|
||||
v.first = math.max(1, v.last - max_thumbs + 1)
|
||||
v.last = math.min(#gallery.items, v.last)
|
||||
changed = true
|
||||
end
|
||||
return changed
|
||||
end
|
||||
|
||||
-- ass related stuff
|
||||
function gallery_mt.refresh_background(gallery)
|
||||
local g = gallery.geometry
|
||||
local a = assdraw.ass_new()
|
||||
a:new_event()
|
||||
a:append('{\\an7}')
|
||||
a:append('{\\bord0}')
|
||||
a:append('{\\shad0}')
|
||||
a:append('{\\1c&' .. gallery.config.background_color .. '}')
|
||||
a:append('{\\1a&' .. gallery.config.background_opacity .. '}')
|
||||
a:pos(0, 0)
|
||||
a:draw_start()
|
||||
a:round_rect_cw(g.position[1], g.position[2], g.position[1] + g.size[1], g.position[2] + g.size[2], gallery.config.background_roundness)
|
||||
a:draw_stop()
|
||||
gallery.ass.background = a.text
|
||||
end
|
||||
|
||||
function gallery_mt.refresh_placeholders(gallery)
|
||||
if not gallery.config.show_placeholders then return end
|
||||
if gallery.view.first == 0 then
|
||||
gallery.ass.placeholders = ""
|
||||
return
|
||||
end
|
||||
local g = gallery.geometry
|
||||
local a = assdraw.ass_new()
|
||||
a:new_event()
|
||||
a:append('{\\an7}')
|
||||
a:append('{\\bord0}')
|
||||
a:append('{\\shad0}')
|
||||
a:append('{\\1c&' .. gallery.config.placeholder_color .. '}')
|
||||
a:pos(0, 0)
|
||||
a:draw_start()
|
||||
for i = 0, gallery.view.last - gallery.view.first do
|
||||
if gallery.config.always_show_placeholders or not gallery.overlays.active[i + 1] then
|
||||
local x, y = gallery:view_index_position(i)
|
||||
a:rect_cw(x, y, x + g.thumbnail_size[1], y + g.thumbnail_size[2])
|
||||
end
|
||||
end
|
||||
a:draw_stop()
|
||||
gallery.ass.placeholders = a.text
|
||||
end
|
||||
|
||||
function gallery_mt.refresh_scrollbar(gallery)
|
||||
if not gallery.config.scrollbar then return end
|
||||
gallery.ass.scrollbar = ""
|
||||
if gallery.view.first == 0 then return end
|
||||
local g = gallery.geometry
|
||||
local before = (gallery.view.first - 1) / #gallery.items
|
||||
local after = (#gallery.items - gallery.view.last) / #gallery.items
|
||||
-- don't show the scrollbar if everything is visible
|
||||
if before + after == 0 then return end
|
||||
local p = gallery.config.scrollbar_min_size / 100
|
||||
if before + after > 1 - p then
|
||||
if before == 0 then
|
||||
after = (1 - p)
|
||||
elseif after == 0 then
|
||||
before = (1 - p)
|
||||
else
|
||||
before, after =
|
||||
before / after * (1 - p) / (1 + before / after),
|
||||
after / before * (1 - p) / (1 + after / before)
|
||||
end
|
||||
end
|
||||
local dist_from_edge = g.size[2] * 0.015
|
||||
local y1 = g.position[2] + dist_from_edge + before * (g.size[2] - 2 * dist_from_edge)
|
||||
local y2 = g.position[2] + g.size[2] - (dist_from_edge + after * (g.size[2] - 2 * dist_from_edge))
|
||||
local x1, x2
|
||||
if gallery.config.scrollbar_left_side then
|
||||
x1 = g.position[1] + g.effective_spacing[1] / 2 - 2
|
||||
else
|
||||
x1 = g.position[1] + g.size[1] - g.effective_spacing[1] / 2 - 2
|
||||
end
|
||||
x2 = x1 + 4
|
||||
local scrollbar = assdraw.ass_new()
|
||||
scrollbar:new_event()
|
||||
scrollbar:append('{\\an7}')
|
||||
scrollbar:append('{\\bord0}')
|
||||
scrollbar:append('{\\shad0}')
|
||||
scrollbar:append('{\\1c&AAAAAA&}')
|
||||
scrollbar:pos(0, 0)
|
||||
scrollbar:draw_start()
|
||||
scrollbar:rect_cw(x1, y1, x2, y2)
|
||||
scrollbar:draw_stop()
|
||||
gallery.ass.scrollbar = scrollbar.text
|
||||
end
|
||||
|
||||
function gallery_mt.refresh_selection(gallery)
|
||||
local v = gallery.view
|
||||
if v.first == 0 then
|
||||
gallery.ass.selection = ""
|
||||
return
|
||||
end
|
||||
local selection_ass = assdraw.ass_new()
|
||||
local g = gallery.geometry
|
||||
local draw_frame = function(index, size, color)
|
||||
local x, y = gallery:view_index_position(index - v.first)
|
||||
selection_ass:new_event()
|
||||
selection_ass:append('{\\an7}')
|
||||
selection_ass:append('{\\bord' .. size ..'}')
|
||||
selection_ass:append('{\\3c&'.. color ..'&}')
|
||||
selection_ass:append('{\\1a&FF&}')
|
||||
selection_ass:pos(0, 0)
|
||||
selection_ass:draw_start()
|
||||
selection_ass:rect_cw(x, y, x + g.thumbnail_size[1], y + g.thumbnail_size[2])
|
||||
selection_ass:draw_stop()
|
||||
end
|
||||
for i = v.first, v.last do
|
||||
local size, color = gallery.item_to_border(i, gallery.items[i])
|
||||
if size > 0 then
|
||||
draw_frame(i, size, color)
|
||||
end
|
||||
end
|
||||
|
||||
for index = v.first, v.last do
|
||||
local text = gallery.item_to_text(index, gallery.items[index])
|
||||
if text ~= "" then
|
||||
selection_ass:new_event()
|
||||
local an = 5
|
||||
local x, y = gallery:view_index_position(index - v.first)
|
||||
x = x + g.thumbnail_size[1] / 2
|
||||
y = y + g.thumbnail_size[2] + gallery.config.text_size * 0.75
|
||||
if gallery.config.align_text then
|
||||
local col = (index - v.first) % g.columns
|
||||
if g.columns > 1 then
|
||||
if col == 0 then
|
||||
x = x - g.thumbnail_size[1] / 2
|
||||
an = 4
|
||||
elseif col == g.columns - 1 then
|
||||
x = x + g.thumbnail_size[1] / 2
|
||||
an = 6
|
||||
end
|
||||
end
|
||||
end
|
||||
selection_ass:an(an)
|
||||
selection_ass:pos(x, y)
|
||||
selection_ass:append(string.format("{\\fs%d}", gallery.config.text_size))
|
||||
selection_ass:append("{\\bord0}")
|
||||
selection_ass:append(text)
|
||||
end
|
||||
end
|
||||
gallery.ass.selection = selection_ass.text
|
||||
end
|
||||
|
||||
function gallery_mt.ass_refresh(gallery, selection, scrollbar, placeholders, background)
|
||||
if not gallery.active then return end
|
||||
if selection then gallery:refresh_selection() end
|
||||
if scrollbar then gallery:refresh_scrollbar() end
|
||||
if placeholders then gallery:refresh_placeholders() end
|
||||
if background then gallery:refresh_background() end
|
||||
gallery.ass_show(table.concat({
|
||||
gallery.ass.background,
|
||||
gallery.ass.placeholders,
|
||||
gallery.ass.selection,
|
||||
gallery.ass.scrollbar
|
||||
}, "\n"))
|
||||
end
|
||||
|
||||
function gallery_mt.set_selection(gallery, selection)
|
||||
if not selection or selection ~= selection then return end
|
||||
local new_selection = math.max(1, math.min(selection, #gallery.items))
|
||||
if gallery.selection == new_selection then return end
|
||||
gallery.selection = new_selection
|
||||
if gallery.active then
|
||||
if gallery:ensure_view_valid() then
|
||||
gallery:refresh_overlays(false)
|
||||
gallery:ass_refresh(true, true, true, false)
|
||||
else
|
||||
gallery:ass_refresh(true, false, false, false)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function gallery_mt.set_geometry(gallery, x, y, w, h, sw, sh, tw, th)
|
||||
if w <= 0 or h <= 0 or tw <= 0 or th <= 0 then
|
||||
msg.warn("Invalid coordinates")
|
||||
return
|
||||
end
|
||||
gallery.geometry.position = {x, y}
|
||||
gallery.geometry.size = {w, h}
|
||||
gallery.geometry.min_spacing = {sw, sh}
|
||||
gallery.geometry.thumbnail_size = {tw, th}
|
||||
gallery.geometry.ok = true
|
||||
if not gallery.active then return end
|
||||
if not gallery:enough_space() then
|
||||
msg.warn("Not enough space to display something")
|
||||
end
|
||||
local old_total = gallery.geometry.rows * gallery.geometry.columns
|
||||
gallery:compute_internal_geometry()
|
||||
gallery:ensure_view_valid()
|
||||
local new_total = gallery.geometry.rows * gallery.geometry.columns
|
||||
for view_index = new_total + 1, old_total do
|
||||
if gallery.overlays.active[view_index] then
|
||||
mp.commandv("overlay-remove", gallery.config.overlay_range + view_index - 1)
|
||||
gallery.overlays.active[view_index] = false
|
||||
end
|
||||
end
|
||||
gallery:refresh_overlays(true)
|
||||
gallery:ass_refresh(true, true, true, true)
|
||||
end
|
||||
|
||||
function gallery_mt.items_changed(gallery, new_sel)
|
||||
gallery.selection = math.max(1, math.min(new_sel, #gallery.items))
|
||||
if not gallery.active then return end
|
||||
gallery:ensure_view_valid()
|
||||
gallery:refresh_overlays(false)
|
||||
gallery:ass_refresh(true, true, true, false)
|
||||
end
|
||||
|
||||
function gallery_mt.thumbnail_generated(gallery, thumb_path)
|
||||
if not gallery.active then return end
|
||||
local view_index = gallery.overlays.missing[thumb_path]
|
||||
if view_index == nil then return end
|
||||
gallery:show_overlay(view_index, thumb_path)
|
||||
if not gallery.config.always_show_placeholders then
|
||||
gallery:ass_refresh(false, false, true, false)
|
||||
end
|
||||
gallery.overlays.missing[thumb_path] = nil
|
||||
end
|
||||
|
||||
function gallery_mt.add_generator(gallery, generator_name)
|
||||
for _, g in ipairs(gallery.generators) do
|
||||
if generator_name == g then return end
|
||||
end
|
||||
gallery.generators[#gallery.generators + 1] = generator_name
|
||||
end
|
||||
|
||||
function gallery_mt.view_index_position(gallery, index_0)
|
||||
local g = gallery.geometry
|
||||
return math.floor(g.position[1] + g.effective_spacing[1] + (g.effective_spacing[1] + g.thumbnail_size[1]) * (index_0 % g.columns)),
|
||||
math.floor(g.position[2] + g.effective_spacing[2] + (g.effective_spacing[2] + g.thumbnail_size[2]) * math.floor(index_0 / g.columns))
|
||||
end
|
||||
|
||||
function gallery_mt.enough_space(gallery)
|
||||
if gallery.geometry.size[1] < gallery.geometry.thumbnail_size[1] + 2 * gallery.geometry.min_spacing[1] then return false end
|
||||
if gallery.geometry.size[2] < gallery.geometry.thumbnail_size[2] + 2 * gallery.geometry.min_spacing[2] then return false end
|
||||
return true
|
||||
end
|
||||
|
||||
function gallery_mt.activate(gallery)
|
||||
if gallery.active then return false end
|
||||
if not gallery:enough_space() then
|
||||
msg.warn("Not enough space, refusing to start")
|
||||
return false
|
||||
end
|
||||
if not gallery.geometry.ok then
|
||||
msg.warn("Gallery geometry unitialized, refusing to start")
|
||||
return false
|
||||
end
|
||||
gallery.active = true
|
||||
if not gallery.selection then
|
||||
gallery:set_selection(1)
|
||||
end
|
||||
gallery:compute_internal_geometry()
|
||||
gallery:ensure_view_valid()
|
||||
gallery:refresh_overlays(false)
|
||||
gallery:ass_refresh(true, true, true, true)
|
||||
return true
|
||||
end
|
||||
|
||||
function gallery_mt.deactivate(gallery)
|
||||
if not gallery.active then return end
|
||||
gallery.active = false
|
||||
gallery:remove_overlays()
|
||||
gallery.ass_show("")
|
||||
end
|
||||
|
||||
return {gallery_new = gallery_new}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
# mpv-gallery-view | https://github.com/occivink/mpv-gallery-view
|
||||
# This is the settings file for scripts/contact-sheet.lua
|
||||
# File placement: script-opts/contact_sheet.conf
|
||||
# Settings which are also present in script-opts/playlist_view.conf are only documented in that file
|
||||
# Defaults: https://github.com/occivink/mpv-gallery-view/blob/master/script-opts/contact_sheet.conf
|
||||
|
||||
# on Windows:
|
||||
#thumbs_dir=%APPDATA%\mpv\gallery-thumbs-dir
|
||||
#generate_thumbnails_with_mpv=yes
|
||||
# everywhere else:
|
||||
#thumbs_dir=~/.cache/thumbnails/mpv-gallery
|
||||
#generate_thumbnails_with_mpv=no
|
||||
|
||||
# create thumbs_dir if it doesn't exist
|
||||
# mkdir_thumbs=yes
|
||||
|
||||
gallery_position={ (ww - gw) / 2, (wh - gh) / 2}
|
||||
gallery_size={ 9 * ww / 10, 9 * wh / 10 }
|
||||
min_spacing={ 15, 15 }
|
||||
thumbnail_size=(ww * wh <= 1366 * 768) and {192, 108} or {288, 162}
|
||||
max_thumbnails=64
|
||||
|
||||
# timespan between two thumbnails
|
||||
# can either be a percentage of the video duration or a number of seconds
|
||||
time_distance=2%
|
||||
|
||||
# in chapter mode, a thumbnail is shown for every chapter, instead of regular intervals
|
||||
chapter_mode=no
|
||||
# time offset in seconds from the start of the chapter, to take the thumbnail
|
||||
# to deal with fades to black
|
||||
chapter_mode_time_offset=2
|
||||
# if the video does not have chapters, fall back to using time steps
|
||||
chapter_mode_fallback_to_time_steps=yes
|
||||
|
||||
# seek to the currently selected time when the contact-sheet is toggled off
|
||||
seek_on_toggle_off=no
|
||||
# close the contact-sheet when seeking to a particular thumbnail
|
||||
close_on_seek=yes
|
||||
# pause the current video when the contact-sheet is opened
|
||||
pause_on_start=yes
|
||||
# resume the current video when the contact-sheet is closed
|
||||
resume_on_stop=only-if-did-pause
|
||||
|
||||
# unlike in playlist-view, this can be set to "selection", "everywhere" or "no"
|
||||
show_text=selection
|
||||
show_millisecond_precision=yes
|
||||
text_size=28
|
||||
|
||||
background_color=333333
|
||||
background_opacity=33
|
||||
normal_border_color=BBBBBB
|
||||
normal_border_size=1
|
||||
selected_border_color=E5E4E5
|
||||
selected_border_size=6
|
||||
highlight_previous=yes
|
||||
previous_border_color=EBC5A7
|
||||
previous_border_size=4
|
||||
placeholder_color=222222
|
||||
|
||||
command_on_open=
|
||||
command_on_close=
|
||||
|
||||
# enables mousewheel navigation and click-to-select
|
||||
mouse_support=yes
|
||||
# the bindings when the contact sheet is active, their meaning should be self-explanatory
|
||||
UP=UP
|
||||
DOWN=DOWN
|
||||
LEFT=LEFT
|
||||
RIGHT=RIGHT
|
||||
PAGE_UP=PGUP
|
||||
PAGE_DOWN=PGDWN
|
||||
FIRST=HOME
|
||||
LAST=END
|
||||
RANDOM=r
|
||||
ACCEPT=ENTER
|
||||
CANCEL=ESC
|
|
@ -0,0 +1,156 @@
|
|||
#######################################################
|
||||
# This is the default config file for mpv-file-browser
|
||||
# https://github.com/CogentRedTester/mpv-file-browser
|
||||
#######################################################
|
||||
|
||||
# root directories, separated by commas
|
||||
# on linux you will probably want to add `/`,
|
||||
# on windows this should be used to add different drive letters
|
||||
# Examples:
|
||||
# linux: root=~/,/
|
||||
# windows: root=~/,C:/
|
||||
root=/media
|
||||
|
||||
# characters to separate root directories, each character works individually
|
||||
# this is in case one is using directories with strange names
|
||||
root_separators=,;
|
||||
|
||||
# number of entries to show on the screen at once
|
||||
num_entries=20
|
||||
|
||||
# wrap the cursor around the top and bottom of the list
|
||||
wrap=no
|
||||
|
||||
# only show files compatible with mpv in the browser
|
||||
filter_files=yes
|
||||
|
||||
# experimental feature that recurses directories concurrently when appending items to the playlist
|
||||
# this feature has the potential for massive performance improvements when using addons with asynchronous IO
|
||||
concurrent_recursion=no
|
||||
|
||||
# maximum number of recursions that can run concurrently
|
||||
# if this number is too high it risks overflowing the mpv event queue, which will cause some directories to be dropped entirely
|
||||
max_concurrency=16
|
||||
|
||||
# enable custom keybinds
|
||||
# the keybind json file must go in ~~/script-opts
|
||||
custom_keybinds=no
|
||||
|
||||
# file-browser only shows files that are compatible with mpv by default
|
||||
# adding a file extension to this list will add it to the extension whitelist
|
||||
# extensions are separated with the root separators, do not use any spaces
|
||||
extension_whitelist=
|
||||
|
||||
# add file extensions to this list to disable default filetypes
|
||||
# note that this will also override audio/subtitle_extension options below
|
||||
extension_blacklist=
|
||||
|
||||
# files with these extensions will be added as additional audio tracks for the current file instead of appended to the playlist
|
||||
# items on this list are automatically added to the extension whitelist
|
||||
audio_extensions=mka,dts,dtshd,dts-hd,truehd,true-hd
|
||||
|
||||
# files with these extensions will be added as additional subtitle tracks for the current file instead of appended to the playlist
|
||||
# items on this list are automatically added to the extension whitelist
|
||||
subtitle_extensions=etf,etf8,utf-8,idx,sub,srt,rt,ssa,ass,mks,vtt,sup,scc,smi,lrc,pgs
|
||||
|
||||
# filter directories or files starting with a period like .config
|
||||
# for linux systems
|
||||
filter_dot_dirs=no
|
||||
filter_dot_files=no
|
||||
|
||||
# substitude forward slashes for backslashes when appending a local file to the playlist
|
||||
# may be useful on windows systems
|
||||
substitute_backslash=no
|
||||
|
||||
# this option reverses the behaviour of the alt+ENTER keybind
|
||||
# when disabled the keybind is required to enable autoload for the file
|
||||
# when enabled the keybind disables autoload for the file
|
||||
autoload=no
|
||||
|
||||
# if autoload is triggered by selecting the currently playing file, then
|
||||
# the current file will have it's watch-later config saved before being closed and re-opened
|
||||
# essentially the current file will not be restarted
|
||||
autoload_save_current=yes
|
||||
|
||||
# when opening the browser in idle mode prefer the current working directory over the root
|
||||
# note that the working directory is set as the 'current' directory regardless, so `home` will
|
||||
# move the browser there even if this option is set to false
|
||||
default_to_working_directory=no
|
||||
|
||||
# enables addons
|
||||
addons=no
|
||||
addon_directory=~~/script-modules/file-browser-addons
|
||||
|
||||
# directory to load external modules - currently just user-input-module
|
||||
module_directory=~~/script-modules
|
||||
|
||||
# turn the OSC idle screen off and on when opening and closing the browser
|
||||
# this should only be enabled if file-browser is the only thing controlling the idle-screen,
|
||||
# if multiple sources attempt to control the idle-screen at the same time it can cause unexpected behaviour.
|
||||
toggle_idlescreen=no
|
||||
|
||||
# Set the current open status of the browser in the `file_browser/open` field of the `user-data` property.
|
||||
# This property is only available in mpv v0.36+.
|
||||
set_user_data=yes
|
||||
|
||||
# Set the current open status of the browser in the `file_browser-open` field of the `shared-script-properties` property.
|
||||
# This property is deprecated. When it is removed in mpv v0.37 file-browser will automatically disable this option.
|
||||
set_shared_script_properties=yes
|
||||
|
||||
####################################
|
||||
######### style settings ###########
|
||||
####################################
|
||||
|
||||
# force file-browser to use a specific text alignment (default: top-left)
|
||||
# uses ass tag alignment numbers: https://aegi.vmoe.info/docs/3.0/ASS_Tags/#index23h3
|
||||
# set to 0 to use the default mpv osd-align options
|
||||
alignment=7
|
||||
|
||||
# The format string used for the header. Uses custom-keybind substitution codes to
|
||||
# dynamically change the contents of the header. See: docs/custom-keybinds.md#codes
|
||||
format_string_header=%q\N----------------------------------------------------
|
||||
|
||||
# The format strings used for the wrappers. Supports custom-keybind substitution codes, and
|
||||
# supports two additional codes: `%<` and `%>` to show the number of items before and after the visible list, respectively.
|
||||
# Setting these options to empty strings will disable the wrappers.
|
||||
format_string_topwrapper=%< item(s) above\N
|
||||
format_string_bottomwrapper=\N%> item(s) remaining
|
||||
|
||||
# allows custom icons be set for the folder and cursor
|
||||
# the `\h` character is a hard space to add padding
|
||||
folder_icon={\p1}m 6.52 0 l 1.63 0 b 0.73 0 0.01 0.73 0.01 1.63 l 0 11.41 b 0 12.32 0.73 13.05 1.63 13.05 l 14.68 13.05 b 15.58 13.05 16.31 12.32 16.31 11.41 l 16.31 3.26 b 16.31 2.36 15.58 1.63 14.68 1.63 l 8.15 1.63{\p0}\h
|
||||
cursor_icon={\p1}m 14.11 6.86 l 0.34 0.02 b 0.25 -0.02 0.13 -0 0.06 0.08 b -0.01 0.16 -0.02 0.28 0.04 0.36 l 3.38 5.55 l 3.38 5.55 3.67 6.15 3.81 6.79 3.79 7.45 3.61 8.08 3.39 8.5l 0.04 13.77 b -0.02 13.86 -0.01 13.98 0.06 14.06 b 0.11 14.11 0.17 14.13 0.24 14.13 b 0.27 14.13 0.31 14.13 0.34 14.11 l 14.11 7.28 b 14.2 7.24 14.25 7.16 14.25 7.07 b 14.25 6.98 14.2 6.9 14.11 6.86{\p0}\h
|
||||
|
||||
# set the opacity of fonts in hexadecimal from 00 (opaque) to FF (transparent)
|
||||
font_opacity_selection_marker=99
|
||||
|
||||
# print the header in bold font
|
||||
font_bold_header=yes
|
||||
|
||||
# scale the size of the browser; 2 would double the size, 0.5 would halve it, etc.
|
||||
# the header and wrapper scaling is relative to the base scaling
|
||||
scaling_factor_base=1
|
||||
scaling_factor_header=1.4
|
||||
scaling_factor_wrappers=0.64
|
||||
|
||||
# set custom font names, blank is the default
|
||||
# setting custom fonts for the folder/cursor can fix broken or missing icons
|
||||
font_name_header=
|
||||
font_name_body=
|
||||
font_name_wrappers=
|
||||
font_name_folder=
|
||||
font_name_cursor=
|
||||
|
||||
# set custom font colours
|
||||
# colours are in hexadecimal format in Blue Green Red order
|
||||
# note that this is the opposite order to most RGB colour codes
|
||||
font_colour_header=00ccff
|
||||
font_colour_body=ffffff
|
||||
font_colour_wrappers=00ccff
|
||||
font_colour_cursor=00ccff
|
||||
|
||||
# these are colours applied to list items in different states
|
||||
font_colour_selected=fce788
|
||||
font_colour_multiselect=fcad88
|
||||
font_colour_playing=33ff66
|
||||
font_colour_playing_multiselected=22b547
|
|
@ -0,0 +1,18 @@
|
|||
# mpv-gallery-view | https://github.com/occivink/mpv-gallery-view
|
||||
# This is the settings file for scripts/gallery-thumbgen.lua and its copies
|
||||
# File placement: script-opts/gallery_worker.conf
|
||||
# Defaults: https://github.com/occivink/mpv-gallery-view/blob/master/script-opts/gallery_worker.conf
|
||||
|
||||
# accepts a |-separated list of URL patterns which gallery.lua should thumbnail using youtube-dl.
|
||||
# The patterns are matched after the http(s):// part of the URL.
|
||||
#^ matches the beginning of the URL, $ matches its end, and you should use % before any of the characters ^$()%|,.[]*+-? to match that character.
|
||||
#
|
||||
#Examples
|
||||
# will exclude any URL that starts with http://youtube.com or https://youtube.com:
|
||||
# ytdl_exclude=^youtube%.com
|
||||
# will exclude any URL that ends with .mkv or .mp4:
|
||||
# ytdl_exclude=%.mkv$|%.mp4$
|
||||
# See more lua patterns here: https://www.lua.org/manual/5.1/manual.html#5.4.1
|
||||
#
|
||||
#See also: ytdl_hook-exclude in mpv's manpage.
|
||||
ytdl_exclude=
|
|
@ -0,0 +1,123 @@
|
|||
# mpv-gallery-view | https://github.com/occivink/mpv-gallery-view
|
||||
# This is the settings file for scripts/playlist-view.lua
|
||||
# File placement: script-opts/playlist_view.conf
|
||||
# Defaults: https://github.com/occivink/mpv-gallery-view/blob/master/script-opts/playlist_view.conf
|
||||
|
||||
# thumbnail directory in which to create and look for thumbnails
|
||||
# on Unix-like platforms:
|
||||
#thumbs_dir=~/.cache/thumbnails/mpv-gallery
|
||||
# on Windows:
|
||||
#thumbs_dir=%APPDATA%\mpv\gallery-thumbs-dir
|
||||
# note that not all env vars get expanded, only '~' and 'APPDATA' do
|
||||
|
||||
# create thumbs_dir if it doesn't exist
|
||||
# mkdir_thumbs=yes
|
||||
|
||||
# use mpv instead of ffmpeg for thumbnail generation
|
||||
# slightly slower and does not support transparency, but does not require additional ffmpeg/ffprobe executables
|
||||
# yes on Windows, no on other plateforms
|
||||
#generate_thumbnails_with_mpv=no
|
||||
|
||||
# all options below are platform-independent
|
||||
|
||||
# fine-grained controls for the geometry of the gallery
|
||||
# each option can have a fixed value, or dynamic by using the following variables:
|
||||
# ww, wh: mpv window width, mpv window height (always available)
|
||||
# gx, gy: gallery horizontal position, gallery vertical position
|
||||
# gw, gh: gallery width, gallery height
|
||||
# sw, sh: minimum spacing width, minimum spacing height
|
||||
# tw, th: thumbnail width, thumbnail height
|
||||
# these strings are interpreted using the lua equivalent of "eval" so math functions and logical conditions can be used
|
||||
# if an option references variables, they will be computed in the appropriate order
|
||||
# (for example, if gallery_width == 5 * thumbnail_width, thumbnail_size will be computed before gallery_size)
|
||||
# in case of cyclical dependencies, the script will abort
|
||||
# example
|
||||
# -------
|
||||
# make the gallery centered
|
||||
gallery_position={ (ww - gw) / 2, (wh - gh) / 2 }
|
||||
# make the gallery's size 9/10 the size of the window
|
||||
gallery_size={ 9 * ww / 10, 9 * wh / 10 }
|
||||
# with at least 15 pixels of spacing between each thumbnail
|
||||
min_spacing={ 15, 15 }
|
||||
# and two thumbnail size presets for Windows smaller/bigger than 1366 x 768
|
||||
thumbnail_size=(ww * wh <= 1366 * 768) and {192, 108} or {288, 162}
|
||||
# it is recommended to use discrete increments for thumbnail_size since a new thumbnail needs to be generated for each size
|
||||
|
||||
# limit the number of thumbnails visible, even if more could be shown
|
||||
# 64 is the maximum due to limitations in mpv
|
||||
max_thumbnails=64
|
||||
|
||||
# the position in the file at which to take the thumbnail
|
||||
# can either be a percentage of the video duration, or a number of seconds
|
||||
take_thumbnail_at=20%
|
||||
|
||||
# load to the selected video when the playlist-view is toggled off
|
||||
load_file_on_toggle_off=no
|
||||
# close the playlist-view when loading a video
|
||||
close_on_load_file=yes
|
||||
# pause the current video when the playlist-view is opened
|
||||
pause_on_start=yes
|
||||
# resume the current video when the playlist-view is closed
|
||||
# can be yes, no, or only-if-did-pause
|
||||
# in the latter case, will only resume if the video was actually paused by opening the playlist-view
|
||||
resume_on_stop=only-if-did-pause
|
||||
# automatically start the playlist-view when mpv is started
|
||||
start_on_mpv_startup=no
|
||||
# automatically start the playlist-view when the current file is finished
|
||||
# only has an effect when keep-open=always
|
||||
start_on_file_end=yes
|
||||
# if the currently playing file changes, set the selection to the new one
|
||||
follow_playlist_position=no
|
||||
# when loading a file, remember the time-position of the previous
|
||||
# and restart from there if it's loaded again
|
||||
remember_time_position=yes
|
||||
|
||||
# show the filename below each thumbnail
|
||||
show_text=yes
|
||||
# use the playlist title if it exists instead of the filename
|
||||
show_title=yes
|
||||
strip_directory=yes
|
||||
strip_extension=yes
|
||||
text_size=28
|
||||
|
||||
# colors are defined in hexadecimal in Blue Green Red (BGR) order
|
||||
# if multiple colors should be active, they get evenly blended
|
||||
# opacity is defined between 00 (opaque) and FF (transparent)
|
||||
background_color=333333
|
||||
background_opacity=33
|
||||
normal_border_color=BBBBBB
|
||||
normal_border_size=1
|
||||
selected_border_color=E5E4E5
|
||||
selected_border_size=6
|
||||
# show a special border around the currently playing file
|
||||
highlight_active=yes
|
||||
active_border_color=EBC5A7
|
||||
active_border_size=4
|
||||
flagged_border_color=96B58D
|
||||
flagged_border_size=4
|
||||
placeholder_color=222222
|
||||
|
||||
# arbitrary commands that are run when the playlist-view is opened/closed
|
||||
# this can be used for lowering video settings when the gallery is active, since
|
||||
# high-quality video settings can result in slowdown of the gallery
|
||||
command_on_open=
|
||||
command_on_close=
|
||||
|
||||
# the path of the 'flags' file that is written when you exit mpv
|
||||
flagged_file_path=./mpv_gallery_flagged
|
||||
|
||||
mouse_support=yes
|
||||
UP=UP
|
||||
DOWN=DOWN
|
||||
LEFT=LEFT
|
||||
RIGHT=RIGHT
|
||||
PAGE_UP=PGUP
|
||||
PAGE_DOWN=PGDWN
|
||||
FIRST=HOME
|
||||
LAST=END
|
||||
RANDOM=r
|
||||
ACCEPT=ENTER
|
||||
CANCEL=ESC
|
||||
# this only removes entries from the playlist, not the underlying file
|
||||
REMOVE=DEL
|
||||
FLAG=SPACE
|
|
@ -0,0 +1,590 @@
|
|||
--[[
|
||||
mpv-gallery-view | https://github.com/occivink/mpv-gallery-view
|
||||
|
||||
This mpv script generates and displays a contact sheet of a video.
|
||||
|
||||
File placement: scripts/contact-sheet.lua
|
||||
Settings: script-opts/contact_sheet.conf
|
||||
Requires: script-modules/gallery-module.lua
|
||||
Default keybinding: c script-binding contact-sheet-toggle
|
||||
]]
|
||||
|
||||
local utils = require 'mp.utils'
|
||||
local msg = require 'mp.msg'
|
||||
local options = require 'mp.options'
|
||||
|
||||
package.path = mp.command_native({ "expand-path", "~~/script-modules/?.lua;" }) .. package.path
|
||||
require 'gallery'
|
||||
|
||||
ON_WINDOWS = (package.config:sub(1,1) ~= "/")
|
||||
|
||||
-- global
|
||||
|
||||
path = ""
|
||||
path_hash = ""
|
||||
duration = 0
|
||||
did_pause = false
|
||||
time_pos = 0
|
||||
with_chapters = false
|
||||
|
||||
bindings = {}
|
||||
bindings_repeat = {}
|
||||
|
||||
compute_geometry = function(ww, wh) end
|
||||
|
||||
ass_changed = false
|
||||
ass = ""
|
||||
geometry_changed = false
|
||||
pending_selection = nil
|
||||
|
||||
thumbs_dir = ""
|
||||
|
||||
gallery = gallery_new()
|
||||
gallery.config.accurate = true
|
||||
gallery.config.align_text = false
|
||||
gallery.config.always_show_placeholders = false
|
||||
|
||||
opts = {
|
||||
thumbs_dir = ON_WINDOWS and "%APPDATA%\\mpv\\gallery-thumbs-dir" or "~/.cache/thumbnails/mpv-gallery/",
|
||||
generate_thumbnails_with_mpv = ON_WINDOWS,
|
||||
mkdir_thumbs = true,
|
||||
|
||||
--gallery_position = "{30, 30}",
|
||||
--gallery_size = "{tw + 4*sw, wh - 2*gy }",
|
||||
--min_spacing = "{15, 15}",
|
||||
--thumbnail_size = "(ww * wh <= 1280 * 720) and {192, 108} or (ww * wh <= 1920 * 1080) and {288, 162} or {384, 216}",
|
||||
|
||||
-- basic centered grid
|
||||
--gallery_position = "{ ww/20, wh/20 }",
|
||||
--gallery_size = "{ww - 2*gx, wh - 2*gy}",
|
||||
--min_spacing = "{15, 15}",
|
||||
--thumbnail_size = "(ww * wh <= 1280 * 720) and {192, 108} or (ww * wh <= 1920 * 1080) and {288, 162} or {384, 216}",
|
||||
|
||||
-- grid with minimum margins
|
||||
gallery_position = "{ (ww - gw) / 2, (wh - gh) / 2}",
|
||||
gallery_size = "{ 9 * ww / 10, 9 * wh / 10 }",
|
||||
min_spacing = "{ 15, 15 }",
|
||||
thumbnail_size = "(ww * wh <= 1366 * 768) and {192, 108} or {288, 162}",
|
||||
max_thumbnails = 64,
|
||||
|
||||
seek_on_toggle_off = false,
|
||||
close_on_seek = true,
|
||||
pause_on_start = true,
|
||||
resume_on_stop = "only-if-did-pause",
|
||||
|
||||
time_distance = "2%",
|
||||
|
||||
chapter_mode = false,
|
||||
chapter_mode_time_offset = 2,
|
||||
chapter_mode_fallback_to_time_steps = true,
|
||||
|
||||
show_text = "selection",
|
||||
show_millisecond_precision = true,
|
||||
text_size = 28,
|
||||
|
||||
background_color = "333333",
|
||||
background_opacity = "33",
|
||||
normal_border_color = "BBBBBB",
|
||||
normal_border_size = 1,
|
||||
selected_border_color = "E5E4E5",
|
||||
selected_border_size = 6,
|
||||
highlight_previous = true,
|
||||
previous_border_color = "EBC5A7",
|
||||
previous_border_size = 4,
|
||||
placeholder_color = "222222",
|
||||
|
||||
command_on_open = "",
|
||||
command_on_close = "",
|
||||
|
||||
mouse_support = true,
|
||||
UP = "UP",
|
||||
DOWN = "DOWN",
|
||||
LEFT = "LEFT",
|
||||
RIGHT = "RIGHT",
|
||||
PAGE_UP = "PGUP",
|
||||
PAGE_DOWN = "PGDWN",
|
||||
FIRST = "HOME",
|
||||
LAST = "END",
|
||||
RANDOM = "r",
|
||||
ACCEPT = "ENTER",
|
||||
CANCEL = "ESC",
|
||||
}
|
||||
function reload_config()
|
||||
gallery.config.generate_thumbnails_with_mpv = opts.generate_thumbnails_with_mpv
|
||||
gallery.config.placeholder_color = opts.placeholder_color
|
||||
gallery.config.background_color = opts.background_color
|
||||
gallery.config.background_opacity = opts.background_opacity
|
||||
gallery.config.max_thumbnails = math.min(opts.max_thumbnails, 64)
|
||||
gallery.config.text_size = opts.text_size
|
||||
|
||||
if ON_WINDOWS then
|
||||
thumbs_dir = string.gsub(opts.thumbs_dir, "^%%APPDATA%%", os.getenv("APPDATA") or "%APPDATA%")
|
||||
else
|
||||
thumbs_dir = string.gsub(opts.thumbs_dir, "^~", os.getenv("HOME") or "~")
|
||||
end
|
||||
local res = utils.file_info(thumbs_dir)
|
||||
if not res or not res.is_dir then
|
||||
if opts.mkdir_thumbs then
|
||||
local args = ON_WINDOWS and { "mkdir", thumbs_dir } or { "mkdir", "-p", thumbs_dir }
|
||||
utils.subprocess({ args = args, playback_only = false })
|
||||
else
|
||||
msg.error(string.format("Thumbnail directory \"%s\" does not exist", thumbs_dir))
|
||||
end
|
||||
end
|
||||
|
||||
compute_geometry = get_geometry_function()
|
||||
reload_bindings()
|
||||
if gallery.active then
|
||||
local ww, wh = mp.get_osd_size()
|
||||
compute_geometry(ww, wh)
|
||||
gallery:ass_refresh(true, true, true, true)
|
||||
reload_items()
|
||||
end
|
||||
end
|
||||
options.read_options(opts, mp.get_script_name(), reload_config)
|
||||
|
||||
local sha256
|
||||
--[[
|
||||
minified code below is a combination of:
|
||||
-sha256 implementation from
|
||||
http://lua-users.org/wiki/SecureHashAlgorithm
|
||||
-lua implementation of bit32 (used as fallback on lua5.1) from
|
||||
https://www.snpedia.com/extensions/Scribunto/engines/LuaCommon/lualib/bit32.lua
|
||||
both are licensed under the MIT below:
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
--]]
|
||||
do local b,c,d,e,f;if bit32 then b,c,d,e,f=bit32.band,bit32.rrotate,bit32.bxor,bit32.rshift,bit32.bnot else f=function(g)g=math.floor(tonumber(g))%0x100000000;return(-g-1)%0x100000000 end;local h={[0]={[0]=0,0,0,0},[1]={[0]=0,1,0,1},[2]={[0]=0,0,2,2},[3]={[0]=0,1,2,3}}local i={[0]={[0]=0,1,2,3},[1]={[0]=1,0,3,2},[2]={[0]=2,3,0,1},[3]={[0]=3,2,1,0}}local function j(k,l,m,n,o)for p=1,m do l[p]=math.floor(tonumber(l[p]))%0x100000000 end;local q=1;local r=0;for s=0,31,2 do local t=n;for p=1,m do t=o[t][l[p]%4]l[p]=math.floor(l[p]/4)end;r=r+t*q;q=q*4 end;return r end;b=function(...)return j('band',{...},select('#',...),3,h)end;d=function(...)return j('bxor',{...},select('#',...),0,i)end;e=function(g,u)g=math.floor(tonumber(g))%0x100000000;u=math.floor(tonumber(u))u=math.min(math.max(-32,u),32)return math.floor(g/2^u)%0x100000000 end;c=function(g,u)g=math.floor(tonumber(g))%0x100000000;u=-math.floor(tonumber(u))%32;local g=g*2^u;return g%0x100000000+math.floor(g/0x100000000)end end;local v={0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2}local function w(n)return string.gsub(n,".",function(t)return string.format("%02x",string.byte(t))end)end;local function x(y,z)local n=""for p=1,z do local A=y%256;n=string.char(A)..n;y=(y-A)/256 end;return n end;local function B(n,p)local z=0;for p=p,p+3 do z=z*256+string.byte(n,p)end;return z end;local function C(D,E)local F=-(E+1+8)%64;E=x(8*E,8)D=D.."\128"..string.rep("\0",F)..E;return D end;local function G(H)H[1]=0x6a09e667;H[2]=0xbb67ae85;H[3]=0x3c6ef372;H[4]=0xa54ff53a;H[5]=0x510e527f;H[6]=0x9b05688c;H[7]=0x1f83d9ab;H[8]=0x5be0cd19;return H end;local function I(D,p,H)local J={}for K=1,16 do J[K]=B(D,p+(K-1)*4)end;for K=17,64 do local L=J[K-15]local M=d(c(L,7),c(L,18),e(L,3))L=J[K-2]local N=d(c(L,17),c(L,19),e(L,10))J[K]=J[K-16]+M+J[K-7]+N end;local O,s,t,P,Q,R,S,T=H[1],H[2],H[3],H[4],H[5],H[6],H[7],H[8]for p=1,64 do local M=d(c(O,2),c(O,13),c(O,22))local U=d(b(O,s),b(O,t),b(s,t))local V=M+U;local N=d(c(Q,6),c(Q,11),c(Q,25))local W=d(b(Q,R),b(f(Q),S))local X=T+N+W+v[p]+J[p]T=S;S=R;R=Q;Q=P+X;P=t;t=s;s=O;O=X+V end;H[1]=b(H[1]+O)H[2]=b(H[2]+s)H[3]=b(H[3]+t)H[4]=b(H[4]+P)H[5]=b(H[5]+Q)H[6]=b(H[6]+R)H[7]=b(H[7]+S)H[8]=b(H[8]+T)end;local function Y(H)return w(x(H[1],4)..x(H[2],4)..x(H[3],4)..x(H[4],4)..x(H[5],4)..x(H[6],4)..x(H[7],4)..x(H[8],4))end;local Z={}sha256=function(D)D=C(D,#D)local H=G(Z)for p=1,#D,64 do I(D,p,H)end;return Y(H)end end
|
||||
-- end of sha code
|
||||
|
||||
gallery.ass_show = function(new_ass)
|
||||
ass_changed = true
|
||||
ass = new_ass
|
||||
end
|
||||
function item_to_time(item, with_offset)
|
||||
if not with_chapters then return item end
|
||||
if not with_offset then return item.time end
|
||||
local time_with_offset = item.time + opts.chapter_mode_time_offset
|
||||
if time_with_offset < duration then
|
||||
return time_with_offset
|
||||
else
|
||||
return item.time;
|
||||
end
|
||||
end
|
||||
|
||||
gallery.item_to_overlay_path = function(index, item)
|
||||
local thumb_filename = string.format("%s_%u_%d_%d",
|
||||
path_hash,
|
||||
item_to_time(item, true) * 100,
|
||||
gallery.geometry.thumbnail_size[1],
|
||||
gallery.geometry.thumbnail_size[2])
|
||||
return utils.join_path(thumbs_dir, thumb_filename)
|
||||
end
|
||||
gallery.item_to_thumbnail_params = function(index, item)
|
||||
return path, item_to_time(item, true)
|
||||
end
|
||||
function blend_colors(colors)
|
||||
if #colors == 1 then return colors[1] end
|
||||
local comp1 = 0
|
||||
local comp2 = 0
|
||||
local comp3 = 0
|
||||
for _, val in ipairs(colors) do
|
||||
comp1 = comp1 + tonumber(string.sub(val, 1, 2), 16)
|
||||
comp2 = comp2 + tonumber(string.sub(val, 3, 4), 16)
|
||||
comp3 = comp3 + tonumber(string.sub(val, 5, 6), 16)
|
||||
end
|
||||
return string.format("%02x%02x%02x", comp1 / #colors, comp2 / #colors, comp3 / #colors)
|
||||
end
|
||||
gallery.item_to_border = function(index, item)
|
||||
local size = 0
|
||||
colors = {}
|
||||
if index == gallery.selection then
|
||||
colors[#colors + 1] = opts.selected_border_color
|
||||
size = math.max(size, opts.selected_border_size)
|
||||
end
|
||||
if opts.highlight_previous and time_pos and item_to_time(item, false) <= (time_pos + 0.01) and
|
||||
(index == #gallery.items or item_to_time(gallery.items[index + 1], false) > (time_pos + 0.01))
|
||||
then
|
||||
colors[#colors + 1] = opts.previous_border_color
|
||||
size = math.max(size, opts.previous_border_size)
|
||||
end
|
||||
if #colors == 0 then
|
||||
return opts.normal_border_size, opts.normal_border_color
|
||||
else
|
||||
return size, blend_colors(colors)
|
||||
end
|
||||
end
|
||||
gallery.item_to_text = function(index, item)
|
||||
if opts.show_text == "everywhere" or (opts.show_text == "selection" and index == gallery.selection) then
|
||||
if with_chapters and item.title ~= "" and item.title ~= "(unnamed)" then
|
||||
return item.title
|
||||
else
|
||||
local str
|
||||
local time = item_to_time(item, false)
|
||||
if duration > 3600 then
|
||||
str = string.format("%d:%02d:%02d", time / 3600, (time / 60) % 60, time % 60)
|
||||
else
|
||||
str = string.format("%02d:%02d", (time / 60) % 60, time % 60)
|
||||
end
|
||||
if opts.show_millisecond_precision then
|
||||
str = string.format("%s.%03d", str, math.floor(time * 1000 % 1000))
|
||||
end
|
||||
return str
|
||||
end
|
||||
else
|
||||
return ""
|
||||
end
|
||||
end
|
||||
|
||||
function setup_ui_handlers()
|
||||
for key, func in pairs(bindings_repeat) do
|
||||
mp.add_forced_key_binding(key, "playlist-view-"..key, func, {repeatable = true})
|
||||
end
|
||||
for key, func in pairs(bindings) do
|
||||
mp.add_forced_key_binding(key, "playlist-view-"..key, func)
|
||||
end
|
||||
end
|
||||
|
||||
function teardown_ui_handlers()
|
||||
for key, _ in pairs(bindings_repeat) do
|
||||
mp.remove_key_binding("playlist-view-"..key)
|
||||
end
|
||||
for key, _ in pairs(bindings) do
|
||||
mp.remove_key_binding("playlist-view-"..key)
|
||||
end
|
||||
end
|
||||
|
||||
function reload_bindings()
|
||||
if gallery.active then
|
||||
teardown_ui_handlers()
|
||||
end
|
||||
|
||||
bindings = {}
|
||||
bindings_repeat = {}
|
||||
|
||||
local increment_func = function(increment, clamp)
|
||||
local new = (pending_selection or gallery.selection) + increment
|
||||
if new <= 0 or new > #gallery.items then
|
||||
if not clamp then return end
|
||||
new = math.max(1, math.min(new, #gallery.items))
|
||||
end
|
||||
pending_selection = new
|
||||
end
|
||||
|
||||
bindings[opts.FIRST] = function() pending_selection = 1 end
|
||||
bindings[opts.LAST] = function() pending_selection = #gallery.items end
|
||||
bindings[opts.ACCEPT] = function()
|
||||
seek_to_selection()
|
||||
if opts.close_on_seek then stop() end
|
||||
end
|
||||
bindings[opts.CANCEL] = function() stop() end
|
||||
if opts.mouse_support then
|
||||
bindings["MBTN_LEFT"] = function()
|
||||
local index = gallery:index_at(mp.get_mouse_pos())
|
||||
if not index then return end
|
||||
if index == gallery.selection then
|
||||
seek_to_selection()
|
||||
if opts.close_on_seek then stop() end
|
||||
else
|
||||
pending_selection = index
|
||||
end
|
||||
end
|
||||
bindings["WHEEL_UP"] = function() increment_func(- gallery.geometry.columns, false) end
|
||||
bindings["WHEEL_DOWN"] = function() increment_func( gallery.geometry.columns, false) end
|
||||
end
|
||||
|
||||
bindings_repeat[opts.UP] = function() increment_func(- gallery.geometry.columns, false) end
|
||||
bindings_repeat[opts.DOWN] = function() increment_func( gallery.geometry.columns, false) end
|
||||
bindings_repeat[opts.LEFT] = function() increment_func(- 1, false) end
|
||||
bindings_repeat[opts.RIGHT] = function() increment_func( 1, false) end
|
||||
bindings_repeat[opts.PAGE_UP] = function() increment_func(- gallery.geometry.columns * gallery.geometry.rows, true) end
|
||||
bindings_repeat[opts.PAGE_DOWN] = function() increment_func( gallery.geometry.columns * gallery.geometry.rows, true) end
|
||||
bindings_repeat[opts.RANDOM] = function() pending_selection = math.random(1, #gallery.items) end
|
||||
|
||||
if gallery.active then
|
||||
setup_ui_handlers()
|
||||
end
|
||||
end
|
||||
|
||||
-- the purpose of this highly-convoluted code is to handle the geometries of the gallery
|
||||
-- dynamically, while computing the different properties in the correct order
|
||||
-- so that they can reference one another (barring cyclical dependencies)
|
||||
function get_geometry_function()
|
||||
local geometry_functions = loadstring(string.format([[
|
||||
return {
|
||||
function(ww, wh, gx, gy, gw, gh, sw, sh, tw, th)
|
||||
return %s
|
||||
end,
|
||||
function(ww, wh, gx, gy, gw, gh, sw, sh, tw, th)
|
||||
return %s
|
||||
end,
|
||||
function(ww, wh, gx, gy, gw, gh, sw, sh, tw, th)
|
||||
return %s
|
||||
end,
|
||||
function(ww, wh, gx, gy, gw, gh, sw, sh, tw, th)
|
||||
return %s
|
||||
end
|
||||
}]], opts.gallery_position, opts.gallery_size, opts.min_spacing, opts.thumbnail_size))()
|
||||
|
||||
local names = { "gallery_position", "gallery_size", "min_spacing", "thumbnail_size" }
|
||||
local order = {} -- the order in which the 4 properties should be computed, based on inter-dependencies
|
||||
|
||||
-- build the dependency matrix
|
||||
local patterns = { "g[xy]", "g[wh]", "s[wh]", "t[wh]" }
|
||||
local deps = {}
|
||||
for i = 1,4 do
|
||||
for j = 1,4 do
|
||||
local i_depends_on_j = (string.find(opts[names[i]], patterns[j]) ~= nil)
|
||||
if i == j and i_depends_on_j then
|
||||
msg.error(names[i] .. " depends on itself")
|
||||
return
|
||||
end
|
||||
deps[i * 4 + j] = i_depends_on_j
|
||||
end
|
||||
end
|
||||
|
||||
local has_deps = function(index)
|
||||
for j = 1,4 do
|
||||
if deps[index * 4 + j] then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
local num_resolved = 0
|
||||
local resolved = { false, false, false, false }
|
||||
while true do
|
||||
local resolved_one = false
|
||||
for i = 1, 4 do
|
||||
if resolved[i] then
|
||||
-- nothing to do
|
||||
elseif not has_deps(i) then
|
||||
order[#order + 1] = i
|
||||
-- since i has no deps, anything that depends on it might as well not
|
||||
for j = 1, 4 do
|
||||
deps[j * 4 + i] = false
|
||||
end
|
||||
resolved[i] = true
|
||||
resolved_one = true
|
||||
num_resolved = num_resolved + 1
|
||||
end
|
||||
end
|
||||
if num_resolved == 4 then
|
||||
break
|
||||
elseif not resolved_one then
|
||||
local str = ""
|
||||
for index, resolved in ipairs(resolved) do
|
||||
if not resolved then
|
||||
str = (str == "" and "" or (str .. ", ")) .. names[index]
|
||||
end
|
||||
end
|
||||
msg.error("Circular dependency between " .. str)
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
return function(window_width, window_height)
|
||||
local new_geom = {
|
||||
gallery_position = {},
|
||||
gallery_size = {},
|
||||
min_spacing = {},
|
||||
thumbnail_size = {},
|
||||
}
|
||||
local show_text = (opts.show_text == "selection" or opts.show_text == "everywhere")
|
||||
for _, index in ipairs(order) do
|
||||
new_geom[names[index]] = geometry_functions[index](
|
||||
window_width, window_height,
|
||||
new_geom.gallery_position[1], new_geom.gallery_position[2],
|
||||
new_geom.gallery_size[1], new_geom.gallery_size[2],
|
||||
new_geom.min_spacing[1], new_geom.min_spacing[2],
|
||||
new_geom.thumbnail_size[1], new_geom.thumbnail_size[2]
|
||||
)
|
||||
if show_text and names[index] == "min_spacing" then
|
||||
new_geom.min_spacing[2] = math.max(opts.text_size, new_geom.min_spacing[2])
|
||||
elseif names[index] == "thumbnail_size" then
|
||||
new_geom.thumbnail_size[1] = math.floor(new_geom.thumbnail_size[1])
|
||||
new_geom.thumbnail_size[2] = math.floor(new_geom.thumbnail_size[2])
|
||||
end
|
||||
end
|
||||
gallery:set_geometry(
|
||||
new_geom.gallery_position[1], new_geom.gallery_position[2],
|
||||
new_geom.gallery_size[1], new_geom.gallery_size[2],
|
||||
new_geom.min_spacing[1], new_geom.min_spacing[2],
|
||||
new_geom.thumbnail_size[1], new_geom.thumbnail_size[2]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
function normalize_path(path)
|
||||
if string.find(path, "://") then
|
||||
return path
|
||||
end
|
||||
path = utils.join_path(utils.getcwd(), path)
|
||||
if ON_WINDOWS then
|
||||
path = string.gsub(path, "\\", "/")
|
||||
end
|
||||
path = string.gsub(path, "/%./", "/")
|
||||
local n
|
||||
repeat
|
||||
path, n = string.gsub(path, "/[^/]*/%.%./", "/", 1)
|
||||
until n == 0
|
||||
return path
|
||||
end
|
||||
|
||||
function time_pos_changed(_, val)
|
||||
time_pos = val
|
||||
if opts.highlight_previous then
|
||||
gallery:ass_refresh(true, false, false, false)
|
||||
end
|
||||
end
|
||||
|
||||
function idle()
|
||||
if pending_selection then
|
||||
gallery:set_selection(pending_selection)
|
||||
pending_selection = nil
|
||||
end
|
||||
if ass_changed or geometry_changed then
|
||||
local ww, wh = mp.get_osd_size()
|
||||
if geometry_changed then
|
||||
geometry_changed = false
|
||||
compute_geometry(ww, wh)
|
||||
end
|
||||
if ass_changed then
|
||||
ass_changed = false
|
||||
mp.set_osd_ass(ww, wh, ass)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function mark_geometry_stale()
|
||||
geometry_changed = true
|
||||
end
|
||||
|
||||
function reload_items()
|
||||
with_chapters = false
|
||||
if opts.chapter_mode then
|
||||
local chap_list = mp.get_property_native("chapter-list")
|
||||
if #chap_list > 0 then
|
||||
with_chapters = true
|
||||
gallery.items = chap_list
|
||||
elseif opts.chapter_mode_fallback_to_time_steps then
|
||||
-- empty
|
||||
else
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
if not with_chapters then
|
||||
local effective_time_distance
|
||||
if string.sub(opts.time_distance, -1) == "%" then
|
||||
effective_time_distance = tonumber(string.sub(opts.time_distance, 1, -2)) / 100 * duration
|
||||
else
|
||||
effective_time_distance = tonumber(opts.time_distance)
|
||||
end
|
||||
local time = 0
|
||||
local times = {}
|
||||
while time < duration do
|
||||
times[#times + 1] = time
|
||||
time = time + effective_time_distance
|
||||
end
|
||||
gallery.items = times
|
||||
end
|
||||
|
||||
local selection = #gallery.items
|
||||
for index, value in ipairs(gallery.items) do
|
||||
if item_to_time(value, false) > time_pos + 0.01 then
|
||||
selection = math.max(index - 1, 1)
|
||||
break
|
||||
end
|
||||
end
|
||||
gallery:items_changed(selection)
|
||||
end
|
||||
|
||||
function start()
|
||||
if gallery.active then return end
|
||||
if not mp.get_property_bool("seekable") then
|
||||
msg.error("Video is not seekable")
|
||||
return
|
||||
end
|
||||
|
||||
path = mp.get_property("path")
|
||||
path_hash = string.sub(sha256(normalize_path(path)), 1, 12)
|
||||
duration = mp.get_property_number("duration")
|
||||
if not duration or duration == 0 then return end
|
||||
duration = duration - (1 / mp.get_property_number("container-fps", 30))
|
||||
if duration == 0 then return end
|
||||
|
||||
time_pos = mp.get_property_number("time-pos")
|
||||
reload_items()
|
||||
|
||||
local ww, wh = mp.get_osd_size()
|
||||
compute_geometry(ww, wh)
|
||||
if not gallery:activate() then return end
|
||||
if opts.command_on_open ~= "" then
|
||||
mp.command(opts.command_on_open)
|
||||
end
|
||||
did_pause = false
|
||||
if opts.pause_on_start and not mp.get_property_bool("pause", false) then
|
||||
mp.set_property_bool("pause", true)
|
||||
did_pause = true
|
||||
end
|
||||
mp.observe_property("time-pos", "number", time_pos_changed)
|
||||
mp.observe_property("osd-width", "native", mark_geometry_stale)
|
||||
mp.observe_property("osd-height", "native", mark_geometry_stale)
|
||||
mp.register_idle(idle)
|
||||
mp.register_event("end-file", stop)
|
||||
idle()
|
||||
|
||||
setup_ui_handlers()
|
||||
end
|
||||
|
||||
function seek_to_selection()
|
||||
if not gallery.active then return end
|
||||
local time = item_to_time(gallery.items[gallery.selection], false)
|
||||
if not time then return end
|
||||
mp.commandv("seek", time, "absolute")
|
||||
end
|
||||
|
||||
function stop()
|
||||
if not gallery.active then return end
|
||||
mp.unregister_event(stop)
|
||||
if opts.resume_on_stop == "yes" or (opts.resume_on_stop == "only-if-did-pause" and did_pause) then
|
||||
mp.set_property_bool("pause", false)
|
||||
end
|
||||
if opts.command_on_close ~= "" then
|
||||
mp.command(opts.command_on_close)
|
||||
end
|
||||
mp.unobserve_property(time_pos_changed)
|
||||
mp.unobserve_property(mark_geometry_stale)
|
||||
mp.unregister_idle(idle)
|
||||
gallery:deactivate()
|
||||
teardown_ui_handlers()
|
||||
idle()
|
||||
end
|
||||
|
||||
function toggle()
|
||||
if not gallery.active then
|
||||
start()
|
||||
else
|
||||
if opts.seek_on_toggle_off then seek_to_selection() end
|
||||
stop()
|
||||
end
|
||||
end
|
||||
|
||||
reload_config()
|
||||
|
||||
mp.register_script_message("thumbnail-generated", function(thumb_path)
|
||||
gallery:thumbnail_generated(thumb_path)
|
||||
end)
|
||||
|
||||
mp.register_script_message("thumbnails-generator-broadcast", function(generator_name)
|
||||
gallery:add_generator(generator_name)
|
||||
end)
|
||||
|
||||
mp.add_key_binding(nil, "contact-sheet-open", start)
|
||||
mp.add_key_binding(nil, "contact-sheet-close", stop)
|
||||
mp.add_key_binding('c', "contact-sheet-toggle", toggle)
|
||||
mp.add_key_binding(nil, "contact-sheet-seek", seek_to_selection)
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2020 Oscar Manglaras
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
|
@ -0,0 +1,177 @@
|
|||
# mpv-file-browser
|
||||
|
||||
![cover](screenshots/bunny.png)
|
||||
|
||||
This script allows users to browse and open files and folders entirely from within mpv. The script uses nothing outside the mpv API, so should work identically on all platforms. The browser can move up and down directories, start playing files and folders, or add them to the queue.
|
||||
|
||||
By default only file types compatible with mpv will be shown, but this can be changed in the config file.
|
||||
|
||||
This script requires at least **mpv v0.33**.
|
||||
|
||||
Originally, file-browser worked with versions of mpv going back to
|
||||
v0.31, you can find those (now unsupported) older versions of file-browser
|
||||
[here](https://github.com/CogentRedTester/mpv-file-browser/tree/mpv-v0.31)
|
||||
|
||||
## Installation
|
||||
|
||||
### Basic
|
||||
|
||||
Clone this git repository into the mpv `~~/scripts` directory and
|
||||
change the name of the folder from `mpv-file-browser` to `file-browser`.
|
||||
You can then pull to receive updates.
|
||||
Alternatively, you can download the zip and extract the contents to `~~/scripts/file-browser`.
|
||||
`~~/` is the mpv config directory which is typically `~/.config/mpv/` on linux and `%APPDATA%/mpv/` on windows.
|
||||
|
||||
Create a `file_browser.conf` file in the `~~/script-opts/` directory and customise the [`root` option](#root-directory) for your
|
||||
system. The [`docs/file_browser.conf`](docs/file_browser.conf) file contains the full list of options and their defaults.
|
||||
|
||||
### Advanced
|
||||
|
||||
To setup [custom keybinds](docs/custom-keybinds.md) enable the `custom_keybinds` option in `file_browser.conf` and
|
||||
create a `~~/script-opts/file-browser-keybinds.json` file. Do **not** copy the `file-browser-keybinds.json` file
|
||||
stored in this repository, that file is a collection of random examples, many of which are for completely different
|
||||
operating systems. Use them and the [docs](docs/custom-keybinds.md) to create your own collection of keybinds.
|
||||
|
||||
To setup [addons](addons/README.md) enable the `addons` option in `file_browser.conf` and place the addon files
|
||||
in the `~~/script-modules/file-browser-addons/` directory.
|
||||
|
||||
If you are not going to enable custom keybinds or addons then there is no reason to
|
||||
create `file-browser-keybinds.json` or `script-modules/file-browser-addons/`.
|
||||
|
||||
## Keybinds
|
||||
|
||||
The following keybinds are set by default
|
||||
|
||||
| Key | Name | Description |
|
||||
|-------------|----------------------------------|-------------------------------------------------------------------------------|
|
||||
| MENU | browse-files | toggles the browser |
|
||||
| Ctrl+o | open-browser | opens the browser |
|
||||
| Alt+o | browse-directory/get-user-input | opens a dialogue box to type in a directory - requires [mpv-user-input](#mpv-user-input) |
|
||||
|
||||
The following dynamic keybinds are only set while the browser is open:
|
||||
|
||||
| Key | Name | Description |
|
||||
|-------------|---------------|-------------------------------------------------------------------------------|
|
||||
| ESC | close | closes the browser or clears the selection |
|
||||
| ENTER | play | plays the currently selected file or folder |
|
||||
| Shift+ENTER | play_append | appends the current file or folder to the playlist |
|
||||
| Alt+ENTER | play_autoload | loads playlist entries before and after the selected file (like autoload.lua) |
|
||||
| RIGHT | down_dir | enter the currently selected directory |
|
||||
| LEFT | up_dir | move to the parent directory |
|
||||
| DOWN | scroll_down | move selector down the list |
|
||||
| UP | scroll_up | move selector up the list |
|
||||
| PGDWN | page_down | move selector down the list by a page (the num_entries option) |
|
||||
| PGUP | page_up | move selector up the list by a page (the num_entries option) |
|
||||
| Shift+PGDWN | list_bottom | move selector to the bottom of the list |
|
||||
| Shift+PGUP | list_top | move selector to the top of the list |
|
||||
| HOME | goto_current | move to the directory of the currently playing file |
|
||||
| Shift+HOME | goto_root | move to the root directory |
|
||||
| Ctrl+r | reload | reload directory and reset cache |
|
||||
| s | select_mode | toggles multiselect mode |
|
||||
| S | select_item | toggles selection for the current item |
|
||||
| Ctrl+a | select_all | select all items in the current directory |
|
||||
|
||||
When attempting to play or append a subtitle file the script will instead load the subtitle track into the existing video.
|
||||
|
||||
The behaviour of the autoload keybind can be reversed with the `autoload` script-opt.
|
||||
By default the playlist will only be autoloaded if `Alt+ENTER` is used on a single file, however when the option is switched autoload will always be used on single files *unless* `Alt+ENTER` is used. Using autoload on a directory, or while appending an item, will not work.
|
||||
|
||||
## Root Directory
|
||||
|
||||
To accomodate for both windows and linux this script has its own virtual root directory where drives and file folders can be manually added. The root directory can only contain folders.
|
||||
|
||||
The root directory is set using the `root` option, which is a comma separated list of directories. Entries are sent through mpv's `expand-path` command. By default the only root value is the user's home folder:
|
||||
|
||||
`root=~/`
|
||||
|
||||
It is highly recommended that this be customised for the computer being used; [file_browser.conf](file_browser.conf) contains commented out suggestions for generic linux and windows systems. For example, my windows root looks like:
|
||||
|
||||
`root=~/,C:/,D:/,E:/,Z:/`
|
||||
|
||||
## Multi-Select
|
||||
|
||||
By default file-browser only opens/appends the single item that the cursor has selected.
|
||||
However, using the `s` keybinds specified above, it is possible to select multiple items to open all at once. Selected items are shown in a different colour to the cursor.
|
||||
When in multiselect mode the cursor changes colour and scrolling up and down the list will drag the current selection. If the original item was unselected, then dragging will select items, if the original item was selected, then dragging will unselect items.
|
||||
|
||||
When multiple items are selected using the open or append commands all selected files will be added to the playlist in the order they appear on the screen.
|
||||
The currently selected (with the cursor) file will be ignored, instead the first multi-selected item in the folder will follow replace/append behaviour as normal, and following selected items will be appended to the playlist afterwards in the order that they appear on the screen.
|
||||
|
||||
## Custom Keybinds
|
||||
|
||||
File-browser also supports custom keybinds. These keybinds send normal input commands, but the script will substitute characters in the command strings for specific values depending on the currently open directory, and currently selected item.
|
||||
This allows for a wide range of customised behaviour, such as loading additional audio tracks from the browser, or copying the path of the selected item to the clipboard.
|
||||
|
||||
To see how to enable and use custom keybinds, see [custom-keybinds.md](docs/custom-keybinds.md).
|
||||
|
||||
## Add-ons
|
||||
|
||||
Add-ons are ways to add extra features to file-browser, for example adding support for network file servers like ftp, or implementing virtual directories in the root like recently opened files.
|
||||
They can be enabled by setting `addon` script-opt to yes, and placing the addon file into the `~~/script-modules/file-browser-addons/` directory.
|
||||
|
||||
For a list of existing addons see the [wiki](https://github.com/CogentRedTester/mpv-file-browser/wiki/Addon-List).
|
||||
For instructions on writing your own addons see [addons.md](docs/addons.md).
|
||||
|
||||
## Script Messages
|
||||
|
||||
File-browser supports a small number of script messages that allow the user or other scripts to talk with the browser.
|
||||
|
||||
### `browse-directory`
|
||||
|
||||
`script-message browse-directory [directory]`
|
||||
|
||||
Opens the given directory in the browser. If the browser is currently closed it will be opened.
|
||||
|
||||
### `get-directory-contents`
|
||||
|
||||
`script-message get-directory-contents [directory] [response-string]`
|
||||
|
||||
Reads the given directory, and sends the resulting tables to the specified script-message in the format:
|
||||
|
||||
`script-message [response-string] [list] [opts]`
|
||||
|
||||
The [list](docs/addons.md#the-list-array)
|
||||
and [opts](docs/addons.md#the-opts-table)
|
||||
tables are formatted as json strings through the `mp.utils.format_json` function.
|
||||
See [addons.md](docs/addons.md) for how the tables are structured, and what each field means.
|
||||
The API_VERSION field of the `opts` table refers to what version of the addon API file browser is using.
|
||||
The `response-string` refers to an arbitrary script-message that the tables should be sent to.
|
||||
|
||||
This script-message allows other scripts to utilise file-browser's directory parsing capabilities, as well as those of the file-browser addons.
|
||||
|
||||
## Configuration
|
||||
|
||||
See [file_browser.conf](docs/file_browser.conf) for the full list of options and their default values.
|
||||
The file is placed in the `~~/script-opts/` folder.
|
||||
|
||||
## Conditional Auto-Profiles
|
||||
|
||||
file-browser provides a property that can be used with [conditional auto-profiles](https://mpv.io/manual/master/#conditional-auto-profiles)
|
||||
to detect when the browser is open.
|
||||
On mpv v0.36+ you should use the `user-data` property with the `file_browser/open` boolean.
|
||||
|
||||
Here is an example of an auto-profile that hides the OSC logo when using file-browser in an idle window:
|
||||
|
||||
```properties
|
||||
[hide-logo]
|
||||
profile-cond= idle_active and user_data.file_browser.open
|
||||
profile-restore=copy
|
||||
osc=no
|
||||
```
|
||||
|
||||
On older versions of mpv you can use the `file_browser-open` field of the `shared-script-properties` property:
|
||||
|
||||
```properties
|
||||
[hide-logo]
|
||||
profile-cond= idle_active and shared_script_properties["file_browser-open"] == "yes"
|
||||
profile-restore=copy
|
||||
osc=no
|
||||
```
|
||||
|
||||
See [#55](https://github.com/CogentRedTester/mpv-file-browser/issues/55) for more details on this.
|
||||
|
||||
## [mpv-user-input](https://github.com/CogentRedTester/mpv-user-input)
|
||||
|
||||
mpv-user-input is a script that provides an API to request text input from the user over the OSD.
|
||||
It was built using `console.lua` as a base, so supports almost all the same text input commands.
|
||||
If `user-input.lua` is loaded by mpv, and `user-input-module` is in the `~~/script-modules/` directory, then using `Alt+o` will open an input box that can be used to directly enter directories for file-browser to open.
|
|
@ -0,0 +1,12 @@
|
|||
# addons
|
||||
|
||||
Add-ons are ways to add extra features to file-browser, for example adding support for network file servers like ftp, or implementing virtual directories in the root like recently opened files.
|
||||
They can be enabled by setting `addon` script-opt to yes, and placing the addon file into the `~~/script-modules/file-browser-addons/` directory.
|
||||
|
||||
Browsing filesystems provided by add-ons should feel identical to the normal handling of the script,
|
||||
but they may require extra commandline tools be installed.
|
||||
|
||||
Since addons are loaded programatically from the addon directory it is possible for anyone to write their own addon.
|
||||
Instructions on how to do this are available [here](../docs/addons.md).
|
||||
|
||||
For a list of available addons see the [wiki](https://github.com/CogentRedTester/mpv-file-browser/wiki/Addon-List).
|
|
@ -0,0 +1,89 @@
|
|||
--[[
|
||||
An addon for mpv-file-browser which adds support for apache http directory indexes
|
||||
]]--
|
||||
|
||||
local mp = require 'mp'
|
||||
local msg = require 'mp.msg'
|
||||
local utils = require 'mp.utils'
|
||||
local fb = require "file-browser"
|
||||
|
||||
--decodes a URL address
|
||||
--this piece of code was taken from: https://stackoverflow.com/questions/20405985/lua-decodeuri-luvit/20406960#20406960
|
||||
local decodeURI
|
||||
do
|
||||
local char, gsub, tonumber = string.char, string.gsub, tonumber
|
||||
local function _(hex) return char(tonumber(hex, 16)) end
|
||||
|
||||
function decodeURI(s)
|
||||
s = gsub(s, '%%(%x%x)', _)
|
||||
return s
|
||||
end
|
||||
end
|
||||
|
||||
local apache = {
|
||||
priority = 80,
|
||||
version = "1.1.0"
|
||||
}
|
||||
|
||||
function apache:can_parse(name)
|
||||
return name:find("^https?://")
|
||||
end
|
||||
|
||||
--send curl errors through the browser empty_text
|
||||
function apache:send_error(str)
|
||||
return {}, {empty_text = "curl error: "..str}
|
||||
end
|
||||
|
||||
local function execute(args)
|
||||
msg.trace(utils.to_string(args))
|
||||
local _, cmd = fb.get_parse_state():yield(
|
||||
mp.command_native_async({
|
||||
name = "subprocess",
|
||||
playback_only = false,
|
||||
capture_stdout = true,
|
||||
capture_stderr = true,
|
||||
args = args
|
||||
}, fb.coroutine.callback())
|
||||
)
|
||||
return cmd
|
||||
end
|
||||
|
||||
function apache:parse(directory)
|
||||
msg.verbose(directory)
|
||||
|
||||
local test = execute({"curl", "-k", "-l", "-I", directory})
|
||||
local response = test.stdout:match("(%d%d%d [^\n\r]+)")
|
||||
if test.stdout:match("Content%-Type: ([^\r\n/]+)") ~= "text" then return nil end
|
||||
if response ~= "200 OK" then return self:send_error(response) end
|
||||
|
||||
local html = execute({"curl", "-k", "-l", directory})
|
||||
if html.status ~= 0 then return self:send_error(tostring(html.status))
|
||||
elseif not html.stdout:find("%[PARENTDIR%]") then return nil end
|
||||
|
||||
html = html.stdout
|
||||
local list = {}
|
||||
for str in string.gmatch(html, "[^\r\n]+") do
|
||||
local valid = true
|
||||
if str:sub(1,4) ~= "<tr>" then valid = false end
|
||||
|
||||
local link = str:match('href="(.-)"')
|
||||
local alt = str:match('alt="%[(.-)%]"')
|
||||
|
||||
if valid and not alt or not link then valid = false end
|
||||
if valid and alt == "PARENTDIR" or alt == "ICO" then valid = false end
|
||||
if valid and link:find("[:?<>|]") then valid = false end
|
||||
|
||||
local is_dir = (alt == "DIR")
|
||||
if valid and is_dir and not self.valid_dir(link) then valid = false end
|
||||
if valid and not is_dir and not self.valid_file(link) then valid = false end
|
||||
|
||||
if valid then
|
||||
msg.trace(alt..": "..link)
|
||||
table.insert(list, { name = link, type = (is_dir and "dir" or "file"), label = decodeURI(link) })
|
||||
end
|
||||
end
|
||||
|
||||
return list, {filtered = true, directory_label = decodeURI(directory)}
|
||||
end
|
||||
|
||||
return apache
|
|
@ -0,0 +1,191 @@
|
|||
--[[
|
||||
An addon for mpv-file-browser which adds a Favourites path that can be loaded from the ROOT
|
||||
]]--
|
||||
|
||||
local mp = require "mp"
|
||||
local msg = require "mp.msg"
|
||||
local utils = require "mp.utils"
|
||||
local save_path = mp.command_native({"expand-path", "~~/script-opts/file_browser_favourites.txt"})
|
||||
do
|
||||
local file = io.open(save_path, "a+")
|
||||
if not file then
|
||||
msg.error("cannot access file", ("%q"):format(save_path), "make sure that the directory exists")
|
||||
return {}
|
||||
end
|
||||
file:close()
|
||||
end
|
||||
|
||||
local favourites = nil
|
||||
local favs = {
|
||||
version = "1.4.0",
|
||||
priority = 30,
|
||||
cursor = 1
|
||||
}
|
||||
|
||||
local use_virtual_directory = true
|
||||
local full_paths = {}
|
||||
|
||||
local function create_favourite_object(str)
|
||||
local item = {
|
||||
type = str:sub(-1) == "/" and "dir" or "file",
|
||||
path = str,
|
||||
redirect = not use_virtual_directory,
|
||||
name = str:match("([^/]+/?)$")
|
||||
}
|
||||
full_paths[str:match("([^/]+)/?$")] = str
|
||||
return item
|
||||
end
|
||||
|
||||
function favs:setup()
|
||||
self:register_root_item('Favourites/')
|
||||
end
|
||||
|
||||
local function update_favourites()
|
||||
favourites = {}
|
||||
|
||||
local file = io.open(save_path, "r")
|
||||
if not file then return end
|
||||
|
||||
for str in file:lines() do
|
||||
table.insert(favourites, create_favourite_object(str))
|
||||
end
|
||||
file:close()
|
||||
end
|
||||
|
||||
function favs:can_parse(directory)
|
||||
return directory:find("Favourites/") == 1
|
||||
end
|
||||
|
||||
function favs:parse(directory)
|
||||
if not favourites then update_favourites() end
|
||||
if directory == "Favourites/" then
|
||||
local opts = {
|
||||
filtered = true,
|
||||
sorted = true
|
||||
}
|
||||
if self.cursor ~= 1 then opts.selected_index = self.cursor ; self.cursor = 1 end
|
||||
return favourites, opts
|
||||
end
|
||||
|
||||
if use_virtual_directory then
|
||||
-- converts the relative favourite path into a full path
|
||||
local name = directory:match("Favourites/([^/]+)/?")
|
||||
|
||||
local _, finish = directory:find("Favourites/([^/]+/?)")
|
||||
local full_path = (full_paths[name] or "")..directory:sub(finish+1)
|
||||
local list, opts = self:defer(full_path or "")
|
||||
|
||||
if not list then return nil end
|
||||
opts.id = self:get_id()
|
||||
if opts.directory_label then
|
||||
opts.directory_label = opts.directory_label:gsub(full_paths[name], "Favourites/"..name..'/')
|
||||
if opts.directory_label:find("Favourites/") ~= 1 then opts.directory_label = nil end
|
||||
end
|
||||
|
||||
for _, item in ipairs(list) do
|
||||
if not item.path then item.redirect = false end
|
||||
item.path = item.path or full_path..item.name
|
||||
end
|
||||
|
||||
return list, opts
|
||||
end
|
||||
|
||||
local path = full_paths[ directory:match("([^/]+/?)$") or "" ]
|
||||
|
||||
local list, opts = self:defer(path)
|
||||
if not list then return nil end
|
||||
opts.directory = opts.directory or path
|
||||
return list, opts
|
||||
end
|
||||
|
||||
local function get_favourite(path)
|
||||
for index, value in ipairs(favourites) do
|
||||
if value.path == path then return index, value end
|
||||
end
|
||||
end
|
||||
|
||||
--update the browser with new contents of the file
|
||||
local function update_browser()
|
||||
if favs.get_directory():find("[fF]avourites/") then
|
||||
if favs.get_directory():find("[fF]avourites/$") then
|
||||
local cursor = favs.get_selected_index()
|
||||
favs.rescan()
|
||||
favs.set_selected_index(cursor)
|
||||
favs.redraw()
|
||||
else
|
||||
favs.clear_cache()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--write the contents of favourites to the file
|
||||
local function write_to_file()
|
||||
local file = io.open(save_path, "w+")
|
||||
if not file then return msg.error(file, "could not open favourites file") end
|
||||
for _, item in ipairs(favourites) do
|
||||
file:write(string.format("%s\n", item.path))
|
||||
end
|
||||
file:close()
|
||||
end
|
||||
|
||||
local function add_favourite(path)
|
||||
if get_favourite(path) then return end
|
||||
update_favourites()
|
||||
table.insert(favourites, create_favourite_object(path))
|
||||
write_to_file()
|
||||
end
|
||||
|
||||
local function remove_favourite(path)
|
||||
update_favourites()
|
||||
local index = get_favourite(path)
|
||||
if not index then return end
|
||||
table.remove(favourites, index)
|
||||
write_to_file()
|
||||
end
|
||||
|
||||
local function move_favourite(path, direction)
|
||||
update_favourites()
|
||||
local index, item = get_favourite(path)
|
||||
if not index or not favourites[index + direction] then return end
|
||||
|
||||
favourites[index] = favourites[index + direction]
|
||||
favourites[index + direction] = item
|
||||
write_to_file()
|
||||
end
|
||||
|
||||
local function toggle_favourite(cmd, state, co)
|
||||
local path = favs.get_full_path(state.list[state.selected], state.directory)
|
||||
|
||||
if state.directory:find("[fF]avourites/$") then remove_favourite(path)
|
||||
else add_favourite(path) end
|
||||
update_browser()
|
||||
end
|
||||
|
||||
local function move_key(cmd, state, co)
|
||||
if not state.directory:find("[fF]avourites/") then return false end
|
||||
local path = favs.get_full_path(state.list[state.selected], state.directory)
|
||||
|
||||
local cursor = favs.get_selected_index()
|
||||
if cmd.name == favs:get_id().."/move_up" then
|
||||
move_favourite(path, -1)
|
||||
favs.set_selected_index(cursor-1)
|
||||
else
|
||||
move_favourite(path, 1)
|
||||
favs.set_selected_index(cursor+1)
|
||||
end
|
||||
update_browser()
|
||||
end
|
||||
|
||||
update_favourites()
|
||||
mp.register_script_message("favourites/add_favourite", add_favourite)
|
||||
mp.register_script_message("favourites/remove_favourite", remove_favourite)
|
||||
mp.register_script_message("favourites/move_up", function(path) move_favourite(path, -1) end)
|
||||
mp.register_script_message("favourites/move_down", function(path) move_favourite(path, 1) end)
|
||||
|
||||
favs.keybinds = {
|
||||
{ "F", "toggle_favourite", toggle_favourite, {}, },
|
||||
{ "Ctrl+UP", "move_up", move_key, {repeatable = true} },
|
||||
{ "Ctrl+DOWN", "move_down", move_key, {repeatable = true} },
|
||||
}
|
||||
|
||||
return favs
|
|
@ -0,0 +1,95 @@
|
|||
--[[
|
||||
An addon for mpv-file-browser for searching the current directory
|
||||
Available at: https://github.com/CogentRedTester/mpv-file-browser/tree/master/addons
|
||||
|
||||
Requires mpv-user-input: https://github.com/CogentRedTester/mpv-user-input
|
||||
|
||||
Keybinds:
|
||||
Ctrl+f open search box
|
||||
Ctrl+F open advanced search box (supports Lua patterns)
|
||||
n cycle to next valid item
|
||||
]]--
|
||||
|
||||
local msg = require "mp.msg"
|
||||
local fb = require "file-browser"
|
||||
local input = require "user-input-module"
|
||||
|
||||
local find = {
|
||||
version = "1.3.0"
|
||||
}
|
||||
local latest_coroutine = nil
|
||||
local global_fb_state = getmetatable(fb.get_state()).__original
|
||||
|
||||
local function compare(name, query)
|
||||
if name:find(query) then return true end
|
||||
if name:lower():find(query) then return true end
|
||||
if name:upper():find(query) then return true end
|
||||
|
||||
return false
|
||||
end
|
||||
|
||||
local function main(key, state, co)
|
||||
if not state.list then return false end
|
||||
|
||||
local text
|
||||
if key.name == "find/find" then text = "Find: enter search string"
|
||||
else text = "Find: enter advanced search string" end
|
||||
|
||||
local query, error = coroutine.yield(
|
||||
input.get_user_input( fb.coroutine.callback(), { text = text, id = "find", replace = true } )
|
||||
)
|
||||
|
||||
if not query then return msg.debug(error) end
|
||||
|
||||
-- allow the directory to be changed before this point
|
||||
local list = fb.get_list()
|
||||
local parse_id = global_fb_state.co
|
||||
|
||||
if key.name == "find/find" then
|
||||
query = fb.pattern_escape(query)
|
||||
end
|
||||
|
||||
local results = {}
|
||||
|
||||
for index, item in ipairs(list) do
|
||||
if compare(item.label or item.name, query) then
|
||||
table.insert(results, index)
|
||||
end
|
||||
end
|
||||
|
||||
if (#results < 1) then
|
||||
msg.warn("No matching items for '"..query.."'")
|
||||
return
|
||||
end
|
||||
|
||||
--keep cycling through the search results if any are found
|
||||
--putting this into a separate coroutine removes any passthrough ambiguity
|
||||
--the final return statement should return to `step_find` not any other function
|
||||
fb.coroutine.run(function()
|
||||
latest_coroutine = coroutine.running()
|
||||
while (true) do
|
||||
for _, index in ipairs(results) do
|
||||
fb.set_selected_index(index)
|
||||
coroutine.yield(true)
|
||||
|
||||
if parse_id ~= global_fb_state.co then
|
||||
latest_coroutine = nil
|
||||
return false
|
||||
end
|
||||
end
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
local function step_find()
|
||||
if not latest_coroutine then return false end
|
||||
return fb.coroutine.resume_err(latest_coroutine)
|
||||
end
|
||||
|
||||
find.keybinds = {
|
||||
{"Ctrl+f", "find", main, {}},
|
||||
{"Ctrl+F", "find_advanced", main, {}},
|
||||
{"n", "next", step_find, {}},
|
||||
}
|
||||
|
||||
return find
|
|
@ -0,0 +1,86 @@
|
|||
--[[
|
||||
An addon for mpv-file-browser which adds support for ftp servers
|
||||
]]--
|
||||
|
||||
local mp = require 'mp'
|
||||
local msg = require 'mp.msg'
|
||||
local utils = require 'mp.utils'
|
||||
local fb = require 'file-browser'
|
||||
|
||||
local ftp = {
|
||||
priority = 100,
|
||||
version = "1.1.0"
|
||||
}
|
||||
|
||||
function ftp:can_parse(directory)
|
||||
return directory:sub(1, 6) == "ftp://"
|
||||
end
|
||||
|
||||
--in my experience curl has been somewhat unreliable when it comes to ftp requests
|
||||
--this fuction retries the request a few times just in case
|
||||
local function execute(args)
|
||||
msg.debug(utils.to_string(args))
|
||||
local _, cmd = fb.get_parse_state():yield(
|
||||
mp.command_native_async({
|
||||
name = "subprocess",
|
||||
playback_only = false,
|
||||
capture_stdout = true,
|
||||
capture_stderr = true,
|
||||
args = args
|
||||
}, fb.coroutine.callback())
|
||||
)
|
||||
return cmd
|
||||
end
|
||||
|
||||
-- encodes special characters using the URL percent encoding format
|
||||
function urlEncode(url)
|
||||
local domain, path = string.match(url, '(ftp://[^/]-/)(.*)')
|
||||
if not path then return url end
|
||||
|
||||
-- these are the unreserved URI characters according to RFC 3986
|
||||
-- https://www.rfc-editor.org/rfc/rfc3986#section-2.3
|
||||
path = string.gsub(path, '[^%w.~_%-]', function(c)
|
||||
return ('%%%x'):format(string.byte(c))
|
||||
end)
|
||||
return domain..path
|
||||
end
|
||||
|
||||
function ftp:parse(directory)
|
||||
msg.verbose(directory)
|
||||
|
||||
local ftp = execute({"curl", "-k", "-g", "--retry", "4", urlEncode(directory)})
|
||||
|
||||
local entries = execute({"curl", "-k", "-g", "-l", "--retry", "4", urlEncode(directory)})
|
||||
|
||||
if entries.status == 28 then
|
||||
msg.error(entries.stderr)
|
||||
elseif entries.status ~= 0 or ftp.status ~= 0 then
|
||||
msg.error(entries.stderr)
|
||||
return
|
||||
end
|
||||
|
||||
local response = {}
|
||||
for str in string.gmatch(ftp.stdout, "[^\r\n]+") do
|
||||
table.insert(response, str)
|
||||
end
|
||||
|
||||
local list = {}
|
||||
local i = 1
|
||||
for str in string.gmatch(entries.stdout, "[^\r\n]+") do
|
||||
if str and response[i] then
|
||||
msg.trace(str .. ' | ' .. response[i])
|
||||
|
||||
if response[i]:sub(1,1) == "d" then
|
||||
table.insert(list, { name = str..'/', type = "dir" })
|
||||
else
|
||||
table.insert(list, { name = str, type = "file" })
|
||||
end
|
||||
|
||||
i = i+1
|
||||
end
|
||||
end
|
||||
|
||||
return list
|
||||
end
|
||||
|
||||
return ftp
|
|
@ -0,0 +1,27 @@
|
|||
--[[
|
||||
An addon for mpv-file-browser which displays ~/ for the home directory instead of the full path
|
||||
]]--
|
||||
|
||||
local mp = require "mp"
|
||||
local fb = require "file-browser"
|
||||
|
||||
local home = fb.fix_path(mp.command_native({"expand-path", "~/"}), true)
|
||||
|
||||
local home_label = {
|
||||
priority = 100,
|
||||
version = "1.0.0"
|
||||
}
|
||||
|
||||
function home_label:can_parse(directory)
|
||||
return directory:sub(1, home:len()) == home
|
||||
end
|
||||
|
||||
function home_label:parse(directory)
|
||||
local list, opts = self:defer(directory)
|
||||
if (not opts.directory or opts.directory == directory) and not opts.directory_label then
|
||||
opts.directory_label = "~/"..(directory:sub(home:len()+1) or "")
|
||||
end
|
||||
return list, opts
|
||||
end
|
||||
|
||||
return home_label
|
|
@ -0,0 +1,55 @@
|
|||
--[[
|
||||
An addon for mpv-file-browser which uses the Linux ls command to parse native directories
|
||||
This behaves near identically to the native parser, but IO is done asynchronously.
|
||||
|
||||
Available at: https://github.com/CogentRedTester/mpv-file-browser/tree/master/addons
|
||||
]]--
|
||||
|
||||
local mp = require "mp"
|
||||
local fb = require "file-browser"
|
||||
|
||||
local ls = {
|
||||
priority = 109,
|
||||
version = "1.1.0",
|
||||
name = "ls",
|
||||
keybind_name = "file"
|
||||
}
|
||||
|
||||
local function command(args, parse_state)
|
||||
local _, cmd = parse_state:yield(
|
||||
mp.command_native_async({
|
||||
name = "subprocess",
|
||||
playback_only = false,
|
||||
capture_stdout = true,
|
||||
capture_stderr = true,
|
||||
args = args
|
||||
}, fb.coroutine.callback())
|
||||
)
|
||||
|
||||
return cmd.status == 0 and cmd.stdout or nil
|
||||
end
|
||||
|
||||
function ls:can_parse(directory)
|
||||
return not fb.get_protocol(directory)
|
||||
end
|
||||
|
||||
function ls:parse(directory, parse_state)
|
||||
local list = {}
|
||||
local files = command({"ls", "-1", "-p", "-A", "-N", directory}, parse_state)
|
||||
|
||||
if not files then return nil end
|
||||
|
||||
for str in files:gmatch("[^\n\r]+") do
|
||||
local is_dir = str:sub(-1) == "/"
|
||||
|
||||
if is_dir and fb.valid_dir(str) then
|
||||
table.insert(list, {name = str, type = "dir"})
|
||||
elseif fb.valid_file(str) then
|
||||
table.insert(list, {name = str, type = "file"})
|
||||
end
|
||||
end
|
||||
|
||||
return list, {filtered = true}
|
||||
end
|
||||
|
||||
return ls
|
|
@ -0,0 +1,54 @@
|
|||
--[[
|
||||
An addon for mpv-file-browser which adds support for m3u playlists
|
||||
|
||||
If the first entry of a playlist isn't working it is because some playlists are created with random invisible unicode in the first line
|
||||
Vim makes it easy to detect these
|
||||
|
||||
This addon requires that my API mpv-read-file be available in ~~/script-modules/
|
||||
https://github.com/CogentRedTester/mpv-read-file
|
||||
]]--
|
||||
|
||||
local rf = require "read-file"
|
||||
|
||||
local m3u = {
|
||||
priority = 100,
|
||||
version = "1.0.0",
|
||||
name = "m3u"
|
||||
}
|
||||
|
||||
local full_paths = {}
|
||||
|
||||
function m3u:setup()
|
||||
self.register_parseable_extension("m3u")
|
||||
self.register_parseable_extension("m3u8")
|
||||
end
|
||||
|
||||
function m3u:can_parse(directory)
|
||||
return directory:find("m3u8?/?$")
|
||||
end
|
||||
|
||||
function m3u:parse(directory)
|
||||
directory = directory:gsub("/$", "")
|
||||
local list = {}
|
||||
|
||||
local path = full_paths[ directory ] or directory
|
||||
local playlist = rf.get_file_handler( path )
|
||||
|
||||
--if we can't read the path then stop here
|
||||
if not playlist then return {}, {sorted = true, filtered = true, empty_text = "Could not read filepath"} end
|
||||
|
||||
local parent = self.fix_path(path:match("^(.+/[^/]+)/"), true)
|
||||
|
||||
local lines = playlist:read("*a")
|
||||
|
||||
for item in lines:gmatch("[^%c]+") do
|
||||
item = self.fix_path(item)
|
||||
local fullpath = self.join_path(parent, item)
|
||||
|
||||
local name = ( self.get_protocol(item) and item or fullpath:match("([^/]+)/?$") )
|
||||
table.insert(list, {name = name, path = fullpath, type = "file"})
|
||||
end
|
||||
return list, {filtered = true, sorted = true}
|
||||
end
|
||||
|
||||
return m3u
|
|
@ -0,0 +1,81 @@
|
|||
--[[
|
||||
An addon for mpv-file-browser which uses powershell commands to parse native directories
|
||||
|
||||
This is slower than the default parser for local drives, but faster for network drives
|
||||
The drive_letters array below is used to list the drives to use this parser for
|
||||
]]--
|
||||
|
||||
--list the drive letters to use here (case sensitive)
|
||||
local drive_letters = {
|
||||
"Y", "Z"
|
||||
}
|
||||
|
||||
local mp = require "mp"
|
||||
local msg = require "mp.msg"
|
||||
local fb = require "file-browser"
|
||||
|
||||
local wn = {
|
||||
priority = 109,
|
||||
version = "1.1.0",
|
||||
name = "powershell",
|
||||
keybind_name = "file"
|
||||
}
|
||||
|
||||
local drives = {}
|
||||
for _, letter in ipairs(drive_letters) do
|
||||
drives[letter] = true
|
||||
end
|
||||
|
||||
local function command(args, parse_state)
|
||||
local _, cmd = parse_state:yield(
|
||||
mp.command_native_async({
|
||||
name = "subprocess",
|
||||
playback_only = false,
|
||||
capture_stdout = true,
|
||||
capture_stderr = true,
|
||||
args = args
|
||||
}, fb.coroutine.callback())
|
||||
)
|
||||
|
||||
return cmd.status == 0 and cmd.stdout or nil, cmd.stderr
|
||||
end
|
||||
|
||||
function wn:can_parse(directory)
|
||||
return not self.get_protocol(directory) and drives[ directory:sub(1,1) ]
|
||||
end
|
||||
|
||||
function wn:parse(directory, parse_state)
|
||||
local list = {}
|
||||
local files, err = command({"powershell", "-noprofile", "-command", [[
|
||||
$dirs = Get-ChildItem -LiteralPath ]]..string.format("%q", directory)..[[ -Directory
|
||||
$files = Get-ChildItem -LiteralPath ]]..string.format("%q", directory)..[[ -File
|
||||
|
||||
foreach ($n in $dirs.Name) {
|
||||
$n += "/"
|
||||
$u8clip = [System.Text.Encoding]::UTF8.GetBytes($n)
|
||||
[Console]::OpenStandardOutput().Write($u8clip, 0, $u8clip.Length)
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
foreach ($n in $files.Name) {
|
||||
$u8clip = [System.Text.Encoding]::UTF8.GetBytes($n)
|
||||
[Console]::OpenStandardOutput().Write($u8clip, 0, $u8clip.Length)
|
||||
Write-Host ""
|
||||
}
|
||||
]]}, parse_state)
|
||||
|
||||
if not files then msg.debug(err) ; return nil end
|
||||
|
||||
for str in files:gmatch("[^\n\r]+") do
|
||||
local is_dir = str:sub(-1) == "/"
|
||||
if is_dir and self.valid_dir(str) then
|
||||
table.insert(list, {name = str, type = "dir"})
|
||||
elseif self.valid_file(str) then
|
||||
table.insert(list, {name = str, type = "file"})
|
||||
end
|
||||
end
|
||||
|
||||
return self.sort(list), {filtered = true, sorted = true}
|
||||
end
|
||||
|
||||
return wn
|
|
@ -0,0 +1,54 @@
|
|||
--[[
|
||||
An addon that loads root items from a `~~/script-opts/file-browser-root.json` file.
|
||||
The contents of this file will override the root script-opt.
|
||||
|
||||
The json file takes the form of a list array as defined by the addon API:
|
||||
https://github.com/CogentRedTester/mpv-file-browser/blob/master/addons/addons.md#the-list-array
|
||||
|
||||
The main purpose of this addon is to allow for users to customise the appearance of their root items
|
||||
using the label or ass fields:
|
||||
|
||||
[
|
||||
{ "name": "Favourites/" },
|
||||
{ "label": "~/", "name": "C:/Users/User/" },
|
||||
{ "label": "1TB HDD", "name": "D:/" },
|
||||
{ "ass": "{\\c&H007700&}Green Text", "name": "E:/" },
|
||||
{ "label": "FTP Server", name: "ftp://user:password@server.com/" }
|
||||
]
|
||||
|
||||
Make sure local directories always end with `/`.
|
||||
`path` and `name` behave the same in the root but either name or label should have a value.
|
||||
ASS styling codes: https://aegi.vmoe.info/docs/3.0/ASS_Tags/
|
||||
]]
|
||||
|
||||
local mp = require 'mp'
|
||||
local msg = require 'mp.msg'
|
||||
local utils = require 'mp.utils'
|
||||
local fb = require 'file-browser'
|
||||
|
||||
-- loads the root json file
|
||||
local config_path = mp.command_native({'expand-path', '~~/script-opts/file-browser-root.json'})
|
||||
|
||||
local file = io.open(config_path, 'r')
|
||||
if not file then
|
||||
msg.error('failed to read file', config_path)
|
||||
return
|
||||
end
|
||||
|
||||
local root_config = utils.parse_json(file:read("*a"))
|
||||
if not root_config then
|
||||
msg.error('failed to parse contents of', config_path, '- Check the syntax is correct.')
|
||||
return
|
||||
end
|
||||
|
||||
local function setup()
|
||||
for i, item in ipairs(root_config) do
|
||||
fb.register_root_item(item, item.priority)
|
||||
end
|
||||
end
|
||||
|
||||
return {
|
||||
version = '1.4.0',
|
||||
setup = setup,
|
||||
priority = -1000,
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
--[[
|
||||
An addon for file-browser which decodes URLs so that they are more readable
|
||||
]]
|
||||
|
||||
local urldecode = {
|
||||
priority = 5,
|
||||
version = "1.0.0"
|
||||
}
|
||||
|
||||
--decodes a URL address
|
||||
--this piece of code was taken from: https://stackoverflow.com/questions/20405985/lua-decodeuri-luvit/20406960#20406960
|
||||
local decodeURI
|
||||
do
|
||||
local char, gsub, tonumber = string.char, string.gsub, tonumber
|
||||
local function _(hex) return char(tonumber(hex, 16)) end
|
||||
|
||||
function decodeURI(s)
|
||||
s = gsub(s, '%%(%x%x)', _)
|
||||
return s
|
||||
end
|
||||
end
|
||||
|
||||
function urldecode:can_parse(directory)
|
||||
return self.get_protocol(directory)
|
||||
end
|
||||
|
||||
function urldecode:parse(directory)
|
||||
local list, opts = self:defer(directory)
|
||||
if opts.directory and not self.get_protocol(opts.directory) then return list, opts end
|
||||
|
||||
opts.directory_label = decodeURI(opts.directory_label or (opts.directory or directory))
|
||||
return list, opts
|
||||
end
|
||||
|
||||
return urldecode
|
|
@ -0,0 +1,97 @@
|
|||
--[[
|
||||
An addon for mpv-file-browser which uses the Windows dir command to parse native directories
|
||||
This behaves near identically to the native parser, but IO is done asynchronously.
|
||||
|
||||
Available at: https://github.com/CogentRedTester/mpv-file-browser/tree/master/addons
|
||||
]]--
|
||||
|
||||
local mp = require "mp"
|
||||
local msg = require "mp.msg"
|
||||
local fb = require "file-browser"
|
||||
|
||||
--this is a LuaJit module this addon will not load if not using LuaJit
|
||||
local ffi = require 'ffi'
|
||||
ffi.cdef([[
|
||||
int __stdcall WideCharToMultiByte(unsigned int CodePage, unsigned int dwFlags, const wchar_t *lpWideCharStr, int cchWideChar, char *lpMultiByteStr, int cbMultiByte, const char *lpDefaultChar, bool *lpUsedDefaultChar);
|
||||
]])
|
||||
|
||||
--converts a UTF16 string to a UTF8 string
|
||||
--this function was adapted from https://github.com/mpv-player/mpv/issues/10139#issuecomment-1117954648
|
||||
local function utf8(WideCharStr)
|
||||
WideCharStr = ffi.cast("wchar_t*", WideCharStr)
|
||||
if not WideCharStr then return nil end
|
||||
|
||||
local utf8_size = ffi.C.WideCharToMultiByte(65001, 0, WideCharStr, -1, nil, 0, nil, nil) --CP_UTF8
|
||||
if utf8_size > 0 then
|
||||
local utf8_path = ffi.new("char[?]", utf8_size)
|
||||
local utf8_size = ffi.C.WideCharToMultiByte(65001, 0, WideCharStr, -1, utf8_path, utf8_size, nil, nil)
|
||||
if utf8_size > 0 then
|
||||
--removes the trailing `\0` character which can break things
|
||||
return ffi.string(utf8_path, utf8_size):sub(1, -2)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local dir = {
|
||||
priority = 109,
|
||||
version = "1.1.0",
|
||||
name = "cmd-dir",
|
||||
keybind_name = "file"
|
||||
}
|
||||
|
||||
local function command(args, parse_state)
|
||||
local _, cmd = parse_state:yield(
|
||||
mp.command_native_async({
|
||||
name = "subprocess",
|
||||
playback_only = false,
|
||||
capture_stdout = true,
|
||||
capture_stderr = true,
|
||||
args = args,
|
||||
}, fb.coroutine.callback() )
|
||||
)
|
||||
cmd.stdout = utf8(cmd.stdout)
|
||||
cmd.stderr = utf8(cmd.stderr)
|
||||
|
||||
--dir returns this exact error message if the directory is empty
|
||||
if cmd.status == 1 and cmd.stderr == "File Not Found\r\n" then cmd.status = 0 end
|
||||
|
||||
return cmd.status == 0 and cmd.stdout or nil, cmd.stderr
|
||||
end
|
||||
|
||||
function dir:can_parse(directory)
|
||||
if directory == "" then return end
|
||||
return not fb.get_protocol(directory)
|
||||
end
|
||||
|
||||
function dir:parse(directory, parse_state)
|
||||
local list = {}
|
||||
local files, dirs, err
|
||||
|
||||
-- the dir command expects backslashes for our paths
|
||||
directory = directory:gsub("/", "\\")
|
||||
|
||||
dirs, err = command({ "cmd", "/U", "/c", "dir", "/b", "/ad", directory }, parse_state)
|
||||
if not dirs then return msg.error(err) end
|
||||
|
||||
files, err = command({ "cmd", "/U", "/c", "dir", "/b", "/a-d", directory }, parse_state)
|
||||
if not files then return msg.error(err) end
|
||||
|
||||
for name in dirs:gmatch("[^\n\r]+") do
|
||||
name = name.."/"
|
||||
if fb.valid_dir(name) then
|
||||
table.insert(list, { name = name, type = "dir" })
|
||||
msg.trace(name)
|
||||
end
|
||||
end
|
||||
|
||||
for name in files:gmatch("[^\n\r]+") do
|
||||
if fb.valid_file(name) then
|
||||
table.insert(list, { name = name, type = "file" })
|
||||
msg.trace(name)
|
||||
end
|
||||
end
|
||||
|
||||
return list, { filtered = true }
|
||||
end
|
||||
|
||||
return dir
|
|
@ -0,0 +1,52 @@
|
|||
--[[
|
||||
Automatically populates the root with windows drives on startup.
|
||||
Ctrl+r will add new drives mounted since startup.
|
||||
|
||||
Drives will only be added if they are not already present in the root.
|
||||
|
||||
Available at: https://github.com/CogentRedTester/mpv-file-browser/tree/master/addons
|
||||
]]
|
||||
|
||||
local mp = require 'mp'
|
||||
local msg = require 'mp.msg'
|
||||
local fb = require 'file-browser'
|
||||
|
||||
-- returns a list of windows drives
|
||||
local function get_drives()
|
||||
local result = mp.command_native({
|
||||
name = 'subprocess',
|
||||
playback_only = false,
|
||||
capture_stdout = true,
|
||||
args = {'wmic', 'logicaldisk', 'get', 'caption'}
|
||||
})
|
||||
if result.status ~= 0 then return msg.error('could not read windows root') end
|
||||
|
||||
local root = {}
|
||||
for drive in result.stdout:gmatch("%a:") do
|
||||
table.insert(root, drive..'/')
|
||||
end
|
||||
return root
|
||||
end
|
||||
|
||||
-- adds windows drives to the root if they are not already present
|
||||
local function import_drives()
|
||||
local drives = get_drives()
|
||||
|
||||
for _, drive in ipairs(drives) do
|
||||
fb.register_root_item(drive)
|
||||
end
|
||||
end
|
||||
|
||||
local keybind = {
|
||||
key = 'Ctrl+r',
|
||||
name = 'import_root_drives',
|
||||
command = import_drives,
|
||||
parser = 'root',
|
||||
passthrough = true
|
||||
}
|
||||
|
||||
return {
|
||||
version = '1.4.0',
|
||||
setup = import_drives,
|
||||
keybinds = { keybind }
|
||||
}
|
|
@ -0,0 +1,833 @@
|
|||
# How to Write an Addon - API v1.4.0
|
||||
|
||||
Addons provide ways for file-browser to parse non-native directory structures. This document describes how one can create their own custom addon.
|
||||
|
||||
If you have an independent script but want to use file-browser's parsing capabilities, perhaps to make use of existing addons, then look [here](https://github.com/CogentRedTester/mpv-file-browser#get-directory-contents).
|
||||
|
||||
## Terminology
|
||||
|
||||
For the purpose of this document addons refer to the scripts being loaded while parsers are the objects the scripts return.
|
||||
An addon can return multiple parsers, but when they only returns one the terms are almost synonymous.
|
||||
Additionally, `method` refers to functions called using the `object:funct()` syntax, and hence have access to the self object, whereas `function` is the standard `object.funct()` syntax.
|
||||
|
||||
## API Version
|
||||
|
||||
The API version, shown in the title of this document, allows file-browser to ensure that addons are using the correct
|
||||
version of the API. It follows [semantic versioning](https://semver.org/) conventions of `MAJOR.MINOR.PATCH`.
|
||||
A parser sets its version string with the `version` field, as seen [below](#overview).
|
||||
|
||||
Any change that breaks backwards compatability will cause the major version number to increase.
|
||||
A parser MUST have the same version number as the API, otherwise an error message will be printed and the parser will
|
||||
not be loaded.
|
||||
|
||||
A minor version number denotes a change to the API that is backwards compatible. This includes additional API functions,
|
||||
or extra fields in tables that were previously unused. It may also include additional arguments to existing functions that
|
||||
add additional behaviour without changing the old behaviour.
|
||||
If the parser's minor version number is greater than the API_VERSION, then a warning is printed to the console.
|
||||
|
||||
Patch numbers denote bug fixes, and are ignored when loading an addon.
|
||||
For this reason addon authors are allowed to leave the patch number out of their version tag and just use `MAJOR.MINOR`.
|
||||
|
||||
## Overview
|
||||
|
||||
File-browser automatically loads any lua files from the `~~/script-modules/file-browser-addons` directory as modules.
|
||||
Each addon must return either a single parser table, or an array of parser tables. Each parser object must contain the following three members:
|
||||
|
||||
| key | type | arguments | returns | description |
|
||||
|-----------|--------|-----------|----------------------------|------------------------------------------------------------------------------------------------------------|
|
||||
| priority | number | - | - | a number to determine what order parsers are tested - see [here](#priority-suggestions) for suggested values |
|
||||
| version | string | - | - | the API version the parser is using - see [API Version](#api-version) |
|
||||
| can_parse | method | string, parse_state_table | boolean | returns whether or not the given path is compatible with the parser |
|
||||
| parse | method | string, parse_state_table | list_table, opts_table | returns an array of item_tables, and a table of options to control how file_browser handles the list |
|
||||
|
||||
Additionally, each parser can optionally contain:
|
||||
|
||||
| key | type | arguments | returns | description |
|
||||
|---------------|--------|-----------|---------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| name | string | - | - | the name of the parser used for debug messages and to create a unique id - by default uses the filename with `.lua` or `-browser.lua` removed |
|
||||
| keybind_name | string | - | - | the name to use when setting custom keybind filters - uses the value of name by default but can be set manually so that the same keys work with multiple addons |
|
||||
| setup | method | - | - | if it exists this method is automatically run after all parsers are imported and API functions are made available |
|
||||
| keybinds | table | - | - | an array of keybind objects for the browser to set when loading - see [#keybinds] |
|
||||
|
||||
All parsers are given a unique string ID based on their name. If there are collisions then numbers are appended to the end of the name until a free name is found.
|
||||
These IDs are primarily used for debug messages, though they may gain additional functionality in the future.
|
||||
|
||||
Here is an extremely simple example of an addon creating a parser table and returning it to file-browser.
|
||||
|
||||
```lua
|
||||
local parser = {
|
||||
version = '1.0.0',
|
||||
priority = 100,
|
||||
name = "example" -- this parser will have the id 'example' or 'example_#' if there are duplicates
|
||||
}
|
||||
|
||||
function parser:can_parse(directory)
|
||||
return directory == "Example/"
|
||||
end
|
||||
|
||||
function parser:parse(directory, state)
|
||||
local list, opts
|
||||
------------------------------
|
||||
--- populate the list here ---
|
||||
------------------------------
|
||||
return list, opts
|
||||
end
|
||||
|
||||
return parser
|
||||
|
||||
```
|
||||
|
||||
## Parsing
|
||||
|
||||
When a directory is loaded file-browser will iterate through the list of parsers from lowest to highest priority.
|
||||
The first parser for which `can_parse` returns true will be selected as the parser for that directory.
|
||||
|
||||
The `parse` method will then be called on the selected parser, which is expected to return either a table of list items, or nil.
|
||||
If an empty table is returned then file-browser will treat the directory as empty, otherwise if the list_table is nil then file-browser will attempt to run `parse` on the next parser for which `can_parse` returns true.
|
||||
This continues until a parser returns a list_table, or until there are no more parsers, after which the root is loaded instead.
|
||||
|
||||
The entire parse operation is run inside of a coroutine, this allows parsers to pause execution to handle asynchronous operations.
|
||||
Please read [coroutines](#coroutines) for all the details.
|
||||
|
||||
### Parse State Table
|
||||
|
||||
The `parse` and `can_parse` functions are passed a state table as its second argument, this contains the following fields.
|
||||
|
||||
| key | type | description |
|
||||
|--------|--------|-----------------------------------------------|
|
||||
| source | string | the source of the parse request |
|
||||
| directory | string | the directory of the parse request - for debugging purposes |
|
||||
| already_deferred| boolean | whether or not [defer](#advanced-functions) was called during this parse, if so then file-browser will not try to query any more parsers after receiving the result - set automatically, but can be manually disabled |
|
||||
| yield | method | a wrapper around `coroutine.yield()` - see [coroutines](#coroutines) |
|
||||
| is_coroutine_current | method | returns if the browser is waiting on the current coroutine to populate the list |
|
||||
|
||||
`already_deferred` is an optimisation. If a script uses defer and still returns nil, then that means that none of the remaining parsers will be able to parse the path.
|
||||
Therefore, it is more efficient to just immediately jump to the root.
|
||||
It is up to the addon author to manually disable this if their use of `defer` conflicts with this assumption.
|
||||
|
||||
Source can have the following values:
|
||||
|
||||
| source | description |
|
||||
|----------------|-----------------------------------------------------------------|
|
||||
| browser | triggered by the main browser window |
|
||||
| loadlist | the browser is scanning the directory to append to the playlist |
|
||||
| script-message | triggered by the `get-directory-contents` script-message |
|
||||
| addon | caused by an addon calling the `parse_directory` API function - note that addons can set a custom state |
|
||||
|
||||
Note that all calls to any `parse` function during a specific parse request will be given the same parse_state table.
|
||||
This theoretically allows parsers to communicate with parsers of a lower priority (or modify how they see source information),
|
||||
but no guarantees are made that specific keys will remain unused by the API.
|
||||
|
||||
#### Coroutines
|
||||
|
||||
Any calls to `parse()` (or `can_parse()`, but you should never be yielding inside there) are done in a [Lua coroutine](https://www.lua.org/manual/5.1/manual.html#2.11).
|
||||
This means that you can arbitrarily pause the parse operation if you need to wait for some asynchronous operation to complete,
|
||||
such as waiting for user input, or for a network request to complete.
|
||||
|
||||
Making these operations asynchronous has performance
|
||||
advantages as well, for example recursively opening a network directory tree could cause the browser to freeze
|
||||
for a long period of time. If the network query were asynchronous then the browser would only freeze during actual operations,
|
||||
during network operations it would be free for the user interract with. The browser has even been designed so that
|
||||
a loadfile/loadlist operation saves it's own copy of the current directory, so even if the user hops around like crazy the original
|
||||
open operation will still occur in the correct order (though there's nothing stopping them starting a new operation which will cause
|
||||
random ordering.)
|
||||
|
||||
However, there is one downside to this behaviour. If the parse operation is requested by the browser, then it is
|
||||
possible for the user to change directories while the coroutine is yielded. If you were to resume the coroutine
|
||||
in that situation, then any operations you do are wasted, and unexpected bahaviour could happen.
|
||||
file-browser will automatically detect when it receives a list from an aborted coroutine, so there is no risk
|
||||
of the current list being replaced, but any other logic in your script will continue until `parse` returns.
|
||||
|
||||
To fix this there are two methods available in the state table, the `yield()` method is a wrapper around `coroutine.yield()` that
|
||||
detects when the browser has abandoned the parse, and automatically kills the coroutine by throwing an error.
|
||||
The `is_coroutine_current()` method simply compares if the current coroutine (as returned by `coroutine.running()`) matches the
|
||||
coroutine that the browser is waiting for. Remember this is only a problem when the browser is the source of the request,
|
||||
if the request came from a script-message, or from a loadlist command there are no issues.
|
||||
|
||||
### The List Array
|
||||
|
||||
The list array must be made up of item_tables, which contain details about each item in the directory.
|
||||
Each item has the following members:
|
||||
|
||||
| key | type | required | description |
|
||||
|-------|--------|----------|-------------------------------------------------------------------------------------------|
|
||||
| name | string | yes | name of the item, and the string to append after the directory when opening a file/folder |
|
||||
| type | string | yes | determines whether the item is a file ("file") or directory ("dir") |
|
||||
| label | string | no | an alternative string to print to the screen instead of name |
|
||||
| ass | string | no | a string to print to the screen without escaping ass styling - overrides label and name |
|
||||
| path | string | no | opening the item uses this full path instead of appending directory and name |
|
||||
| redirect| bool | no | whether path should redirect the browser when opening a directory - default yes (nil counts as true)|
|
||||
|
||||
File-browser expects that `type` and `name` will be set for each item, so leaving these out will probably crash the script.
|
||||
File-browser also assumes that all directories end in a `/` when appending name, and that there will be no backslashes.
|
||||
The API function [`fix_path`](#utility-functions) can be used to ensure that paths conform to file-browser rules.
|
||||
|
||||
Here is an example of a static list table being returned by the `parse` method.
|
||||
This would allow one to specify a custom list of items.
|
||||
|
||||
```lua
|
||||
function parser:parse(directory, state)
|
||||
local list = {
|
||||
{ name = "first/", type = "dir" },
|
||||
{ name = "second/", type = "dir" },
|
||||
{ name = "third/", type = "dir" },
|
||||
{ name = "file%01", type = "file", label = "file1" },
|
||||
{ name = "file2", type = "file", path = "https://youtube.com/video" },
|
||||
}
|
||||
|
||||
return list
|
||||
end
|
||||
```
|
||||
|
||||
### The Opts Table
|
||||
|
||||
The options table allows scripts to better control how they are handled by file-browser.
|
||||
None of these values are required, and the opts table can even left as nil when returning.
|
||||
|
||||
| key | type | description |
|
||||
|-----------------|---------|-------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| filtered | boolean | if true file-browser will not run the standard filter() function on the list |
|
||||
| sorted | boolean | if true file-browser will not sort the list |
|
||||
| directory | string | changes the browser directory to this - used for redirecting to other locations |
|
||||
| directory_label | string | display this label in the header instead of the actual directory - useful to display encoded paths |
|
||||
| empty_text | string | display this text when the list is empty - can be used for error messages |
|
||||
| selected_index | number | the index of the item on the list to select by default - a.k.a. the cursor position |
|
||||
| id | number | id of the parser that successfully returns a list - set automatically, but can be set manually to take ownership (see defer) |
|
||||
|
||||
The previous static example, but modified so that file browser does not try to filter or re-order the list:
|
||||
|
||||
```lua
|
||||
function parser:parse(directory, state)
|
||||
local list = {
|
||||
{ name = "first/", type = "dir" },
|
||||
{ name = "second/", type = "dir" },
|
||||
{ name = "third/", type = "dir" },
|
||||
{ name = "file%01", type = "file", label = "file1" },
|
||||
{ name = "file2", type = "file", path = "https://youtube.com/video" },
|
||||
}
|
||||
|
||||
return list, { sorted = true, filtered = true }
|
||||
end
|
||||
```
|
||||
|
||||
`id` is used to declare ownership of a page. The name of the parser that has ownership is used for custom-keybinds parser filtering.
|
||||
When using `defer` id will be the id of whichever parser first returned a list.
|
||||
This is the only situation when a parser may want to set id manually.
|
||||
|
||||
## Priority Suggestions
|
||||
|
||||
Below is a table of suggested priority ranges:
|
||||
|
||||
| Range | Suggested Use | Example parsers |
|
||||
|--------|----------------------------------------------------------------------------------------------------------------------------|------------------------------------------------|
|
||||
| 0-20 | parsers that purely modify the results of other parsers | [m3u-fixer](m3u-browser.lua) |
|
||||
| 21-40 | virtual filesystems which need to link to the results of other parsers | [favourites](favourites.lua) |
|
||||
| 41-50 | to support specific sites or systems which can be inferred from the path | |
|
||||
| 51-80 | limitted support for specific protocols which requires complex parsing to verify compatability | [apache](apache-browser.lua) |
|
||||
| 81-90 | parsers that only need to modify the results of full parsers | [home-label](home-label.lua) |
|
||||
| 91-100 | use for parsers which fully support a non-native protocol with absolutely no overlap | [ftp](ftp-browser.lua), [m3u](m3u-browser.lua) |
|
||||
| 101-109| replacements for the native file parser or fallbacks for the full parsers | [powershell](powershell.lua) |
|
||||
| 110 | priority of the native file parser - don't use | |
|
||||
| 111+ | fallbacks for native parser - potentially alternatives to the default root | |
|
||||
|
||||
## Keybinds
|
||||
|
||||
Addons have the ability to set custom keybinds using the `keybinds` field in the `parser` table. `keybinds` must be an array of tables, each of which may be in two forms.
|
||||
|
||||
Firstly, the keybind_table may be in the form
|
||||
`{ "key", "name", [function], [flags] }`
|
||||
where the table is an array whose four values corresond to the four arguments for the [mp.add_key_binding](https://mpv.io/manual/master/#lua-scripting-[,flags]]\)) API function.
|
||||
|
||||
```lua
|
||||
local function main(keybind, state, co)
|
||||
-- deletes files
|
||||
end
|
||||
|
||||
parser.keybinds = {
|
||||
{ "Alt+DEL", "delete_files", main, {} },
|
||||
}
|
||||
```
|
||||
|
||||
Secondly, the keybind_table may use the same formatting as file-browser's [custom-keybinds](../custom-keybinds.md).
|
||||
Using the array form is equivalent to setting `key`, `name`, `command`, and `flags` of the custom-keybind form, and leaving everything else on the defaults.
|
||||
|
||||
```lua
|
||||
parser.keybinds = {
|
||||
{
|
||||
key = "Alt+DEL",
|
||||
name = "delete_files",
|
||||
command = {"run", "rm", "%F"},
|
||||
filter = "files"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
These keybinds are evaluated only once shortly after the addon is loaded, they cannot be modified dynamically during runtime.
|
||||
Keybinds are applied after the default keybinds, but before the custom keybinds. This means that addons can overwrite the
|
||||
default keybinds, but that users can ovewrite addon keybinds. Among addons, those with higher priority numbers have their keybinds loaded before those
|
||||
with lower priority numbers.
|
||||
Remember that a lower priority value is better, they will overwrite already loaded keybinds.
|
||||
Keybind passthrough works the same way, though there is some custom behaviour when it comes to [raw functions](#keybind-functions).
|
||||
|
||||
### Keybind Names
|
||||
|
||||
In either form the naming of the function is different from custom keybinds. Instead of using the form `file_browser/dynamic/custom/[name]`
|
||||
they use the form `file_browser/dynamic/[parser_ID]/[name]`, where `[parser_id]` is a unique string ID for the parser, which can be retrieved using the
|
||||
`parser:get_id()` method.
|
||||
|
||||
### Native Functions vs Command Tables
|
||||
|
||||
There are two ways of specifying the behaviour of a keybind.
|
||||
It can be in command table form, as done when using custom-keybind syntax, and it can be done in
|
||||
native function form, as done when using the `mp.add_key_binding` syntax.
|
||||
However, these two ways of specifying commands are independant of how the overall keybind is defined.
|
||||
What this means is that the command field of the custom-keybinds syntax can be an array, and the
|
||||
3rd value in the array syntax can be a table of mpv commands.
|
||||
|
||||
```lua
|
||||
local function main(keybind, state, co)
|
||||
-- deletes files
|
||||
end
|
||||
|
||||
-- this is a valid keybind table
|
||||
parser.keybinds = {
|
||||
{ "Alt+DEL", "delete_files", {"run", "rm", "%F"}, {} },
|
||||
|
||||
{
|
||||
key = "Alt+DEL",
|
||||
name = "delete_files",
|
||||
command = main
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
There are some limitations however, not all custom-keybind options are supported when using native functions.
|
||||
The supported options are: `key`, `name`, `condition`, `flags`, `parser`, `passthrough`. The other options can be replicated manually (see below).
|
||||
|
||||
### Keybind Functions
|
||||
|
||||
This section details the use of keybind functions.
|
||||
|
||||
#### Function Call
|
||||
|
||||
If one uses the raw function then the functions are called directly in the form:
|
||||
|
||||
`fn(keybind, state, coroutine)`
|
||||
|
||||
Where `keybind` is the keybind_table of the key being run, `state` is a table of state values at the time of the key press, and `coroutine` is the coroutine object
|
||||
that the keybind is being executed inside.
|
||||
|
||||
The `keybind` table uses the same fields as defined
|
||||
in [custom-keybinds.md](../custom-keybinds.md). Any random extra fields placed in the original
|
||||
`file-browser-keybinds.json` will likely show up as well (this is not guaranteed).
|
||||
Note that even if the array form is used, the `keybind` table will still use the custom-keybind format.
|
||||
|
||||
The entire process of running a keybind is handled with a coroutine, so the addon can safely pause and resume the coroutine at will. The `state` table is provided to
|
||||
allow addons to keep a record of important state values that may be changed during a paused coroutine.
|
||||
|
||||
#### State Table
|
||||
|
||||
The state table contains copies of the following values at the time of the key press.
|
||||
|
||||
| key | description |
|
||||
|-----------------|------------------------------------------------------------------------------------------|
|
||||
| directory | the current directory |
|
||||
| directory_label | the current directory_label - can (and often will) be `nil` |
|
||||
| list | the current list_table |
|
||||
| selected | index of the currently selected list item |
|
||||
| selection | table of currently selected items (for multi-select) - in the form { index = true, ... } - always available even if the `multiselect` flag is not set |
|
||||
| parser | a copy of the parser object that provided the current directory |
|
||||
|
||||
The following example shows the implementation of the `delete_files` keybind using the state values:
|
||||
|
||||
```lua
|
||||
local fb = require "file-browser" -- see #api-functions and #utility-functions
|
||||
|
||||
local function main(keybind, state, co)
|
||||
for index, item in state.list do
|
||||
if state.selection[index] and item.type == "file" then
|
||||
os.remove( fb.get_full_path(item, state.directory) )
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
parser.keybinds = {
|
||||
{ "Alt+DEL", "delete_files", main, {} },
|
||||
}
|
||||
```
|
||||
|
||||
#### Passthrough
|
||||
|
||||
If the `passthrough` field of the keybind_table is set to `true` or `false` then file-browser will
|
||||
handle everything. However, if the passthrough field is not set (meaning the bahviour should be automatic)
|
||||
then it is up to the addon to ensure that they are
|
||||
correctly notifying when the operation failed and a passthrough should occur.
|
||||
In order to tell the keybind handler to run the next priority command, the keybind function simply needs to return the value `false`,
|
||||
any other value (including `nil`) will be treated as a successful operation.
|
||||
|
||||
The below example only allows removing files from the `/tmp/` directory and allows other
|
||||
keybinds to run in different directories:
|
||||
|
||||
```lua
|
||||
local fb = require "file-browser" -- see #api-functions and #utility-functions
|
||||
|
||||
local function main(keybind, state, co)
|
||||
if state.directory ~= "/tmp/" then return false end
|
||||
|
||||
for index, item in state.list do
|
||||
if state.selection[index] and item.type == "file" then
|
||||
os.remove( fb.get_full_path(item, state.directory) )
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
parser.keybinds = {
|
||||
{ "Alt+DEL", "delete_files", main, {} },
|
||||
}
|
||||
```
|
||||
|
||||
## The API
|
||||
|
||||
The API is available through a module, which can be loaded with `require "file-browser"`.
|
||||
The API provides a variety of different values and functions for an addon to use
|
||||
in order to make them more powerful.
|
||||
Function definitions are written using Typescript-style type annotations.
|
||||
|
||||
```lua
|
||||
local fb = require "file-browser"
|
||||
|
||||
local parser = {
|
||||
priority = 100,
|
||||
}
|
||||
|
||||
function parser:setup()
|
||||
fb.register_root_item("Example/")
|
||||
end
|
||||
|
||||
return parser
|
||||
```
|
||||
|
||||
### Parser API
|
||||
|
||||
In addition to the standard API there is also an extra parser API that provides
|
||||
several parser specific methods, listed below using `parser:method` instead of `fb.function`.
|
||||
This API is added to the parser object after it is loaded by file-browser,
|
||||
so if a script wants to call them immediately on load they must do so in the `setup` method.
|
||||
All the standard API functions are also available in the parser API.
|
||||
|
||||
```lua
|
||||
local parser = {
|
||||
priority = 100,
|
||||
}
|
||||
|
||||
function parser:setup()
|
||||
-- same operations
|
||||
self.insert_root_item({ name = "Example/", type = "dir" })
|
||||
parser.insert_root_item({ name = "Example/", type = "dir" })
|
||||
end
|
||||
|
||||
-- will not work since the API hasn't been added to the parser yet
|
||||
parser.insert_root_item({ name = "Example/", type = "dir" })
|
||||
|
||||
return parser
|
||||
```
|
||||
|
||||
### General Functions
|
||||
|
||||
#### `fb.API_VERSION: string`
|
||||
|
||||
The current API version in use by file-browser.
|
||||
|
||||
#### `fb.add_default_extension(ext: string): void`
|
||||
|
||||
Adds the given extension to the default extension filter whitelist. Can only be run inside the `setup()` method.
|
||||
|
||||
#### `fb.browse_directory(directory: string): void`
|
||||
|
||||
Clears the cache and opens the given directory in the browser. If the browser is closed then it will be opened.
|
||||
This function is non-blocking, it is possible that the function will return before the directory has finished
|
||||
being scanned.
|
||||
|
||||
This is the equivalent of calling the `browse-directory` script-message.
|
||||
|
||||
#### `fb.insert_root_item(item: item_table, pos?: number): void`
|
||||
|
||||
Add an item_table to the root list at the specified position. If `pos` is nil then append to the end of the root.
|
||||
`item` must be a valid item_table of `type='dir'`.
|
||||
|
||||
#### `fb.register_parseable_extension(ext: string): void`
|
||||
|
||||
Register a file extension that the browser will attempt to open, like a directory - for addons which can parse files such
|
||||
as playlist files.
|
||||
|
||||
#### `fb.register_root_item(item: string | item_table, priority?: number): boolean`
|
||||
|
||||
Registers an item to be added to the root and an optional priority value that determines the position relative to other items (default is 100).
|
||||
A lower priority number is better, meaning they will be placed earlier in the list.
|
||||
Only adds the item if it is not already in the root and returns a boolean that specifies whether or not the item was added.
|
||||
Must be called during or after the `parser:setup()` method is run.
|
||||
|
||||
If `item` is a string then a new item_table is created with the values: `{ type = 'dir', name = item }`.
|
||||
If `item` is an item_table then it must be a valid directory item.
|
||||
Use [`fb.fix_path(name, true)`](#fbfix_pathpath-string-is_directory-boolean-string) to ensure the name field is correct.
|
||||
|
||||
This function should be used over the older `fb.insert_root_item`.
|
||||
|
||||
#### `fb.remove_parseable_extension(ext: string): void`
|
||||
|
||||
Remove a file extension that the browser will attempt to open like a directory.
|
||||
|
||||
#### `fb.parse_directory(directory: string, parse?: parse_state_table): (list_table, opts_table) | nil`
|
||||
|
||||
Starts a new scan for the given directory and returns a list_table and opts_table on success and `nil` on failure.
|
||||
Must be called from inside a [coroutine](#coroutines).
|
||||
|
||||
This function allows addons to request the contents of directories from the loaded parsers. There are no protections
|
||||
against infinite recursion, so be careful about calling this from within another parse.
|
||||
|
||||
Do not use the same `parse` table for multiple parses, state values for the two operations may intefere with each other
|
||||
and cause undefined behaviour. If the `parse.source` field is not set then it will be set to `"addon"`.
|
||||
|
||||
Note that this function is for creating new parse operations, if you wish to create virtual directories or modify
|
||||
the results of other parsers then use [`defer`](#parserdeferdirectory-string-list_table-opts_table--nil).
|
||||
|
||||
Also note that every parse operation is expected to have its own unique coroutine. This acts as a unique
|
||||
ID that can be used internally or by other addons. This means that if multiple `parse_directory` operations
|
||||
are run within a single coroutine then file-browser will automatically create a new coroutine for the scan,
|
||||
which hands execution back to the original coroutine upon completion.
|
||||
|
||||
#### `parser:register_root_item(item: string | item_table, priority?: number): boolean`
|
||||
|
||||
A wrapper around [`fb.register_root_item`](#fbregister_root_itemitem-string--item_table-priority-number-boolean)
|
||||
which uses the parser's priority value if `priority` is undefined.
|
||||
|
||||
### Advanced Functions
|
||||
|
||||
#### `fb.clear_cache(): void`
|
||||
|
||||
Clears the directory cache. Use this if you are modifying the contents of directories other
|
||||
than the current one to ensure that their contents will be rescanned when next opened.
|
||||
|
||||
#### `fb.coroutine.assert(err?: string): coroutine`
|
||||
|
||||
Throws an error if it is not called from within a coroutine. Returns the currently running coroutine on success.
|
||||
The string argument can be used to throw a custom error string.
|
||||
|
||||
#### `fb.coroutine.callback(): function`
|
||||
|
||||
Creates and returns a callback function that resumes the current coroutine.
|
||||
This function is designed to help streamline asynchronous operations. The best way to explain is with an example:
|
||||
|
||||
```lua
|
||||
local function execute(args)
|
||||
mp.command_native_async({
|
||||
name = "subprocess",
|
||||
playback_only = false,
|
||||
capture_stdout = true,
|
||||
capture_stderr = true,
|
||||
args = args
|
||||
}, fb.coroutine.callback())
|
||||
|
||||
local _, cmd = coroutine.yield()
|
||||
|
||||
return cmd.status == 0 and cmd.stdout or nil
|
||||
end
|
||||
```
|
||||
|
||||
This function uses the mpv [subprocess](https://mpv.io/manual/master/#command-interface-subprocess)
|
||||
command to execute some system operation. To prevent the whole script (file-browser and all addons) from freezing
|
||||
it uses the [command_native_async](https://mpv.io/manual/master/#lua-scripting-mp-command-native-async(table-[,fn])) command
|
||||
to execute the operation asynchronously and takes a callback function as its second argument.
|
||||
|
||||
`coroutine.callback())` will automatically create a callback function to resume whatever coroutine ran the `execute` function.
|
||||
Any arguments passed into the callback function (by the async function, not by you) will be passed on to the resume;
|
||||
in this case `command_native_async` passes three values into the callback, of which only the second is of interest to me.
|
||||
|
||||
The unsaid expectation is that the programmer will yield execution before that callback returns. In this example I
|
||||
yield immediately after running the async command.
|
||||
|
||||
If you are doing this during a parse operation you could also substitute `coroutine.yield()` with `parse_state:yield()` to abort the parse if the user changed
|
||||
browser directories during the asynchronous operation.
|
||||
|
||||
If you have no idea what I've been talking about read the [Lua manual on coroutines](https://www.lua.org/manual/5.1/manual.html#2.11).
|
||||
|
||||
#### `fb.coroutine.resume_catch(co: coroutine, ...): (boolean, ...)`
|
||||
|
||||
Runs `coroutine.resume(co, ...)` with the given coroutine, passing through any additional arguments.
|
||||
If the coroutine throws an error then an error message and stacktrace is printed to the console.
|
||||
All the return values of `coroutine.resume` are caught and returned.
|
||||
|
||||
#### `fb.coroutine.resume_err(co: coroutine, ...): boolean`
|
||||
|
||||
Runs `coroutine.resume(co, ...)` with the given coroutine, passing through any additional arguments.
|
||||
If the coroutine throws an error then an error message and stacktrace is printed to the console.
|
||||
Returns the success boolean returned by `coroutine.resume`, but drops all other return values.
|
||||
|
||||
#### `fb.coroutine.run(fn: function, ...): void`
|
||||
|
||||
Runs the given function in a new coroutine, passing through any additional arguments.
|
||||
|
||||
#### `fb.rescan(): void`
|
||||
|
||||
Rescans the current directory. Equivalent to Ctrl+r without the cache refresh for higher level directories.
|
||||
|
||||
#### `fb.redraw(): void`
|
||||
|
||||
Forces a redraw of the browser UI.
|
||||
|
||||
#### `parser:defer(directory: string): (list_table, opts_table) | nil`
|
||||
|
||||
Forwards the given directory to the next valid parser. For use from within a parse operation.
|
||||
|
||||
The `defer` function is very powerful, and can be used by scripts to create virtual directories, or to modify the results of other parsers.
|
||||
However, due to how much freedom Lua gives coders, it is impossible for file-browser to ensure that parsers are using defer correctly, which can cause unexpected results.
|
||||
The following are a list of recommendations that will increase the compatability with other parsers:
|
||||
|
||||
* Always return the opts table that is returned by defer, this can contain important values for file-browser, as described [above](#the-opts-table).
|
||||
* If required modify values in the existing opts table, don't create a new one.
|
||||
* Respect the `sorted` and `filtered` values in the opts table. This may mean calling `sort` or `filter` manually.
|
||||
* Think about how to handle the `directory_label` field, especially how it might interract with any virtual paths the parser may be maintaining.
|
||||
* Think about what to do if the `directory` field is set.
|
||||
* Think if you want your parser to take full ownership of the results of `defer`, if so consider setting `opts.id = self:get_id()`.
|
||||
* Currently this only affects custom keybind filtering, though it may be changed in the future.
|
||||
|
||||
The [home-label](https://github.com/CogentRedTester/mpv-file-browser/blob/master/addons/home-label.lua)
|
||||
addon provides a good simple example of the safe use of defer. It lets the normal file
|
||||
parser load the home directory, then modifies the directory label.
|
||||
|
||||
```lua
|
||||
local mp = require "mp"
|
||||
local fb = require "file-browser"
|
||||
|
||||
local home = fb.fix_path(mp.command_native({"expand-path", "~/"}), true)
|
||||
|
||||
local home_label = {
|
||||
version = '1.0.0',
|
||||
priority = 100
|
||||
}
|
||||
|
||||
function home_label:can_parse(directory)
|
||||
return directory:sub(1, home:len()) == home
|
||||
end
|
||||
|
||||
function home_label:parse(directory, ...)
|
||||
local list, opts = self:defer(directory, ...)
|
||||
|
||||
if (not opts.directory or opts.directory == directory) and not opts.directory_label then
|
||||
opts.directory_label = "~/"..(directory:sub(home:len()+1) or "")
|
||||
end
|
||||
|
||||
return list, opts
|
||||
end
|
||||
|
||||
return home_label
|
||||
```
|
||||
|
||||
### Utility Functions
|
||||
|
||||
#### `fb.ass_escape(str: string, substitute_newline?: true | string): string`
|
||||
|
||||
Returns the `str` string with escaped ass styling codes.
|
||||
The optional 2nd argument allows replacing newlines with the given string, or `'\\n'` if set to `true`.
|
||||
|
||||
#### `fb.copy_table(t: table, depth?: number): table`
|
||||
|
||||
Returns a copy of table `t`.
|
||||
The copy is done recursively to the given `depth`, and any cyclical table references are maintained.
|
||||
Both keys and values are copied. If `depth` is undefined then it defaults to `math.huge` (infinity).
|
||||
Additionally, the original table is stored in the `__original` field of the copy's metatable.
|
||||
The copy behaviour of the metatable itself is subject to change, but currently it is not copied.
|
||||
|
||||
#### `fb.filter(list: list_table): list_table`
|
||||
|
||||
Iterates through the given list and removes items that don't pass the user set filters
|
||||
(dot files/directories and valid file extensions).
|
||||
Returns the list but does not create a copy; the `list` table is filtered in-place.
|
||||
|
||||
#### `fb.fix_path(path: string, is_directory?: boolean): string`
|
||||
|
||||
Takes a path and returns a file-browser compatible path string.
|
||||
The optional second argument is a boolean that tells the function to format the path to be a
|
||||
directory.
|
||||
|
||||
#### `fb.get_extension(filename: string, def?: any): string | def`
|
||||
|
||||
Returns the file extension for the string `filename`, or `nil` if there is no extension.
|
||||
If `def` is defined then that is returned instead of `nil`.
|
||||
|
||||
The full stop is not included in the extension, so `test.mkv` will return `mkv`.
|
||||
|
||||
#### `fb.get_full_path(item: item_table, directory?: string): string`
|
||||
|
||||
Takes an item table and returns the item's full path assuming it is in the given directory.
|
||||
Takes into account `item.name`/`item.path` fields, etc.
|
||||
If directory is nil then it uses the currently open directory.
|
||||
|
||||
#### `fb.get_protocol(url: string, def?: any): string | def`
|
||||
|
||||
Returns the protocol scheme for the string `url`, or `nil` if there is no scheme.
|
||||
If `def` is defined then that is returned instead of `nil`.
|
||||
|
||||
The `://` is not included, so `https://example.com/test.mkv` will return `https`.
|
||||
|
||||
#### `fb.iterate_opt(opts: string): iterator`
|
||||
|
||||
Takes an options string consisting of a list of items separated by the `root_separators` defined in `file_browser.conf` and
|
||||
returns an iterator function that can be used to iterate over each item in the list.
|
||||
|
||||
```lua
|
||||
local opt = "a,b,zz z" -- root_separators=,
|
||||
for item in fb.iterate_opt(opt) do
|
||||
print(item) -- prints: 'a', 'b', 'zz z'
|
||||
end
|
||||
```
|
||||
|
||||
#### `fb.join_path(p1: string, p2: string): string`
|
||||
|
||||
A wrapper around [`mp.utils.join_path`](https://mpv.io/manual/master/#lua-scripting-utils-join-path(p1,-p2))
|
||||
which treats paths with network protocols as absolute paths.
|
||||
|
||||
#### `fb.pattern_escape(str: string): string`
|
||||
|
||||
Returns `str` with Lua special pattern characters escaped.
|
||||
|
||||
#### `fb.sort(list: list_table): list_table`
|
||||
|
||||
Iterates through the given list and sorts the items using file-browser's sorting algorithm.
|
||||
Returns the list but does not create a copy; the `list` table is sorted in-place.
|
||||
|
||||
#### `fb.valid_file(name: string): boolean`
|
||||
|
||||
Tests if the string `name` passes the user set filters for valid files (extensions/dot files/etc).
|
||||
|
||||
#### `fb.valid_dir(name: string): boolean`
|
||||
|
||||
Tests if the string `name` passes the user set filters for valid directories (dot folders/etc).
|
||||
|
||||
### Getters
|
||||
|
||||
These functions allow addons to safely get information from file-browser.
|
||||
All tables returned by these functions are copies sent through the [`fb.copy_table`](#fbcopy_tablet-table-depth-number-table)
|
||||
function to ensure addons can't accidentally break things.
|
||||
|
||||
#### `fb.get_audio_extensions(): table`
|
||||
|
||||
Returns a set of extensions like [`fb.get_extensions`](#fbget_extensions-table) but for extensions that are opened
|
||||
as additional audio tracks.
|
||||
All of these are included in `fb.get_extensions`.
|
||||
|
||||
#### `fb.get_current_file(): table`
|
||||
|
||||
A table containing the path of the current open file in the form:
|
||||
`{directory = "/home/me/", name = "bunny.mkv", path = "/home/me/bunny.mkv"}`.
|
||||
|
||||
#### `fb.get_current_parser(): string`
|
||||
|
||||
The unique id of the parser that successfully parsed the current directory.
|
||||
|
||||
#### `fb.get_current_parser_keyname(): string`
|
||||
|
||||
The `keybind_name` of the parser that successfully parsed the current directory.
|
||||
Used for custom-keybind filtering.
|
||||
|
||||
#### `fb.get_directory(): string`
|
||||
|
||||
The current directory open in the browser.
|
||||
|
||||
#### `fb.get_dvd_device(): string`
|
||||
|
||||
The current dvd-device as reported by mpv's `dvd-device` property.
|
||||
Formatted to work with file-browser.
|
||||
|
||||
#### `fb.get_extensions(): table`
|
||||
|
||||
Returns the set of valid extensions after applying the user's whitelist/blacklist options.
|
||||
The table is in the form `{ mkv = true, mp3 = true, ... }`.
|
||||
Sub extensions, audio extensions, and parseable extensions are all included in this set.
|
||||
|
||||
#### `fb.get_list(): list_table`
|
||||
|
||||
The list_table currently open in the browser.
|
||||
|
||||
#### `fb.get_open_status(): boolean`
|
||||
|
||||
Returns true if the browser is currently open and false if not.
|
||||
|
||||
#### `fb.get_opt(name: string): string | number | boolean`
|
||||
|
||||
Returns the script-opt with the given name.
|
||||
|
||||
#### `fb.get_parsers(): table`
|
||||
|
||||
Returns a table of all the loaded parsers/addons.
|
||||
The formatting of this table in undefined, but it should
|
||||
always contain an array of the parsers in order of priority.
|
||||
|
||||
#### `fb.get_parse_state(co?: coroutine): parse_state_table`
|
||||
|
||||
Returns the [parse_state table](#parse-state-table) for the given coroutine.
|
||||
If no coroutine is given then it uses the running coroutine.
|
||||
Every parse operation is guaranteed to have a unique coroutine.
|
||||
|
||||
#### `fb.get_parseable_extensions(): table`
|
||||
|
||||
Returns a set of extensions like [`fb.get_extensions`](#fbget_extensions-table) but for extensions that are
|
||||
treated as parseable by the browser.
|
||||
All of these are included in `fb.get_extensions`.
|
||||
|
||||
#### `fb.get_root(): list_table`
|
||||
|
||||
Returns the root table.
|
||||
|
||||
#### `fb.get_script_opts(): table`
|
||||
|
||||
The table of script opts set by the user. This currently does not get
|
||||
changed during runtime, but that is not guaranteed for future minor version increments.
|
||||
|
||||
#### `fb.get_selected_index(): number`
|
||||
|
||||
The current index of the cursor.
|
||||
Note that it is possible for the cursor to be outside the bounds of the list;
|
||||
for example when the list is empty this usually returns 1.
|
||||
|
||||
#### `fb.get_selected_item(): item_table | nil`
|
||||
|
||||
Returns the item_table of the currently selected item.
|
||||
If no item is selected (for example an empty list) then returns nil.
|
||||
|
||||
#### `fb.get_state(): table`
|
||||
|
||||
Returns the current state values of the browser.
|
||||
These are not documented and are subject to change at any time,
|
||||
adding a proper getter for anything is a valid request.
|
||||
|
||||
#### `fb.get_sub_extensions(): table`
|
||||
|
||||
Returns a set of extensions like [`fb.get_extensions`](#fbget_extensions-table) but for extensions that are opened
|
||||
as additional subtitle tracks.
|
||||
All of these are included in `fb.get_extensions`.
|
||||
|
||||
#### `parser:get_id(): string`
|
||||
|
||||
The unique id of the parser. Used for log messages and various internal functions.
|
||||
|
||||
#### `parser:get_index(): number`
|
||||
|
||||
The index of the parser in order of preference (based on the priority value).
|
||||
`defer` uses this internally.
|
||||
|
||||
### Setters
|
||||
|
||||
#### `fb.set_selected_index(pos: number): number | false`
|
||||
|
||||
Sets the cursor position and returns the new index.
|
||||
If the input is not a number return false, if the input is out of bounds move it in bounds.
|
||||
|
||||
## Examples
|
||||
|
||||
For standard addons that add support for non-native filesystems, but otherwise don't do anything fancy, see [ftp-browser](ftp-browser.lua) and [apache-browser](apache-browser.lua).
|
||||
|
||||
For more simple addons that make a few small modifications to how other parsers are displayed, see [home-label](home-label.lua).
|
||||
|
||||
For more complex addons that maintain their own virtual directory structure, see
|
||||
[favourites](favourites.lua).
|
|
@ -0,0 +1,327 @@
|
|||
# Custom Keybinds
|
||||
|
||||
File-browser also supports custom keybinds. These keybinds send normal input commands, but the script will substitute characters in the command strings for specific values depending on the currently open directory, and currently selected item.
|
||||
This allows for a wide range of customised behaviour, such as loading additional audio tracks from the browser, or copying the path of the selected item to the clipboard.
|
||||
|
||||
The feature is disabled by default, but is enabled with the `custom_keybinds` script-opt.
|
||||
Keybinds are declared in the `~~/script-opts/file-browser-keybinds.json` file, the config takes the form of an array of json objects, with the following keys:
|
||||
|
||||
| option | required | default | description |
|
||||
|---------------|----------|------------|--------------------------------------------------------------------------------------------|
|
||||
| key | yes | - | the key to bind the command to - same syntax as input.conf |
|
||||
| command | yes | - | json array of commands and arguments |
|
||||
| name | no | numeric id | name of the script-binding - see [modifying default keybinds](#modifying-default-keybinds) |
|
||||
| condition | no | - | a Lua [expression](#expressions) - the keybind will only run if this evaluates to true |
|
||||
| flags | no | - | flags to send to the mpv add_keybind function - see [here](https://mpv.io/manual/master/#lua-scripting-[,flags]]\)) |
|
||||
| filter | no | - | run the command on just a file (`file`) or folder (`dir`) |
|
||||
| parser | no | - | run the command only in directories provided by the specified parser. |
|
||||
| multiselect | no | `false` | command is run on all selected items |
|
||||
| multi-type | no | `repeat` | which multiselect mode to use - `repeat` or `concat` |
|
||||
| delay | no | `0` | time to wait between sending repeated multi commands |
|
||||
| concat-string | no | `' '` (space) | string to insert between items when concatenating multi commands |
|
||||
| passthrough | no | - | force or ban passthrough behaviour - see [passthrough](#passthrough-keybinds) |
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"key": "KP1",
|
||||
"command": ["print-text", "example"],
|
||||
}
|
||||
```
|
||||
|
||||
The command can also be an array of arrays, in order to send multiple commands at once:
|
||||
|
||||
```json
|
||||
{
|
||||
"key": "KP2",
|
||||
"command": [
|
||||
["print-text", "example2"],
|
||||
["show-text", "example2"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Filter should not be included unless one wants to limit what types of list entries the command should be run on.
|
||||
To only run the command for directories use `dir`, to only run the command for files use `file`.
|
||||
|
||||
The parser filter is for filtering keybinds to only work inside directories loaded by specific parsers.
|
||||
There are two parsers in the base script, the default parser for native filesystems is called `file`, while the root parser is called `root`.
|
||||
Other parsers can be supplied by addons, and use the addon's filename with `-browser.lua` or just `.lua` stripped unless otherwise stated.
|
||||
For example `ftp-browser.lua` would have a parser called `ftp`.
|
||||
You can set the filter to match multiple parsers by separating the names with spaces.
|
||||
|
||||
```json
|
||||
{
|
||||
"key": "KP2",
|
||||
"command": [ ["print-text", "example3"] ],
|
||||
"parser": "ftp file"
|
||||
}
|
||||
```
|
||||
|
||||
The `flags` field is mostly only useful for addons, but can also be useful if one wants a key to be repeatable.
|
||||
In this case the the keybind would look like the following:
|
||||
|
||||
```json
|
||||
{
|
||||
"key": "p",
|
||||
"command": ["print-text", "spam-text"],
|
||||
"flags": { "repeatable": true }
|
||||
}
|
||||
```
|
||||
|
||||
## Codes
|
||||
|
||||
The script will scan every string in the command for the special substitution strings, they are:
|
||||
|
||||
| code | description |
|
||||
|--------|---------------------------------------------------------------------|
|
||||
| `%%` | escape code for `%` |
|
||||
| `%f` | filepath of the selected item |
|
||||
| `%n` | filename of the selected item |
|
||||
| `%p` | currently open directory |
|
||||
| `%q` | currently open directory but preferring the directory label |
|
||||
| `%d` | name of the current directory (characters between the last two '/') |
|
||||
| `%r` | name of the parser for the currently open directory |
|
||||
| `%x` | number of items in the currently open directory |
|
||||
| `%i` | the 1-based index of the selected item in the list |
|
||||
| `%j` | the 1-based index of the item in a multiselection - returns 1 for single selections |
|
||||
|
||||
Additionally, using the uppercase forms of those codes will send the substituted string through the `string.format("%q", str)` function.
|
||||
This adds double quotes around the string and automatically escapes any characters which would break the string encapsulation.
|
||||
This is not necessary for most mpv commands, but can be very useful when sending commands to the OS with the `run` command,
|
||||
or when passing values into [expressions](#conditional-command-condition-command).
|
||||
|
||||
Example of a command to add an audio track:
|
||||
|
||||
```json
|
||||
{
|
||||
"key": "Ctrl+a",
|
||||
"command": ["audio-add", "%f"],
|
||||
"filter": "file"
|
||||
}
|
||||
```
|
||||
|
||||
Any commands that contain codes representing specific items (`%f`, `%n`, `%i` etc) will
|
||||
not be run if no item is selected (for example in an empty directory).
|
||||
In these cases [passthrough](#passthrough-keybinds) rules will apply.
|
||||
|
||||
## Multiselect Commands
|
||||
|
||||
When multiple items are selected the command can be run for all items in the order they appear on the screen.
|
||||
This can be controlled by the `multiselect` flag, which takes a boolean value.
|
||||
When not set the flag defaults to `false`.
|
||||
|
||||
There are two different multiselect modes, controlled by the `multi-type` option. There are two options:
|
||||
|
||||
### `repeat`
|
||||
|
||||
The default mode that sends the commands once for each item that is selected.
|
||||
If time is needed between running commands of multiple selected items (for example, due to file handlers) then the `delay` option can be used to set a duration (in seconds) between commands.
|
||||
|
||||
### `concat`
|
||||
|
||||
Run a single command, but replace item specific codes with a concatenated string made from each selected item.
|
||||
For example `["print-text", "%n" ]` would print the name of each item selected separated by `' '` (space).
|
||||
The string inserted between each item is determined by the `concat-string` option, but `' '` is the default.
|
||||
|
||||
## Passthrough Keybinds
|
||||
|
||||
When loading keybinds from the json file file-browser will move down the list and overwrite any existing bindings with the same key.
|
||||
This means the lower an item on the list, the higher preference it has.
|
||||
However, file-browser implements a layered passthrough system for its keybinds; if a keybind is blocked from running by user filters, then the next highest preference command will be sent, continuing until a command is sent or there are no more keybinds.
|
||||
The default dynamic keybinds are considered the lowest priority.
|
||||
|
||||
The `filter`, `parser`, and `condition` options can all trigger passthrough, as well as some [codes](#codes).
|
||||
If a multi-select command is run on multiple items then passthrough will occur if any of the selected items fail the filters.
|
||||
|
||||
Passthrough can be forcibly disabled or enabled using the passthrough option.
|
||||
When set to `true` passthrough will always be activate regardless of the state of the filters.
|
||||
|
||||
## Modifying Default Keybinds
|
||||
|
||||
Since the custom keybinds are applied after the default dynamic keybinds they can be used to overwrite the default bindings.
|
||||
Setting new keys for the existing binds can be done with the `script-binding [binding-name]` command, where `binding-name` is the full name of the keybinding.
|
||||
For this script the names of the dynamic keybinds are in the format `file_browser/dynamic/[name]` where `name` is a unique identifier documented in the [keybinds](README.md#keybinds) table.
|
||||
|
||||
For example to change the scroll buttons from the arrows to the scroll wheel:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"key": "WHEEL_UP",
|
||||
"command": ["script-binding", "file_browser/dynamic/scroll_up"]
|
||||
},
|
||||
{
|
||||
"key": "WHEEL_DOWN",
|
||||
"command": ["script-binding", "file_browser/dynamic/scroll_down"]
|
||||
},
|
||||
{
|
||||
"key": "UP",
|
||||
"command": ["osd-auto", "add", "volume", "2"]
|
||||
},
|
||||
{
|
||||
"key": "DOWN",
|
||||
"command": ["osd-auto", "add", "volume", "-2"]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Custom keybinds can be called using the same method, but users must set the `name` value inside the `file-browser-keybinds.json` file.
|
||||
To avoid conflicts custom keybinds use the format: `file_browser/dynamic/custom/[name]`.
|
||||
|
||||
## Expressions
|
||||
|
||||
Expressions are used to evaluate Lua code into a string that can be used for commands.
|
||||
These behave similarly to those used for [`profile-cond`](https://mpv.io/manual/master/#conditional-auto-profiles)
|
||||
values. In an expression the `mp`, `mp.msg`, and `mp.utils` modules are available as `mp`, `msg`, and `utils` respectively.
|
||||
Additionally the file-browser [addon API](addons/addons.md#the-api) is available as `fb` and if [mpv-user-input](https://github.com/CogentRedTester/mpv-user-input)
|
||||
is installed then user-input API will be available in `input`.
|
||||
|
||||
This example only runs the keybind if the browser is in the Windows C drive or if
|
||||
the selected item is a matroska file:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"key": "KP1",
|
||||
"command": ["print-text", "in my C:/ drive!"],
|
||||
"condition": "(%P):find('C:/') == 1"
|
||||
},
|
||||
{
|
||||
"key": "KP2",
|
||||
"command": ["print-text", "Matroska File!"],
|
||||
"condition": "fb.get_extension(%N) == 'mkv'"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
If the `condition` expression contains any item specific codes (`%F`, `%I`, etc) then it will be
|
||||
evaluated on each individual item, otherwise it will evaluated once for the whole keybind.
|
||||
If a code is invalid (for example using `%i` in empty directories) then the expression returns false.
|
||||
|
||||
There are some utility script messages that extend the power of expressions.
|
||||
[`conditional-command`](#conditional-command-condition-command) allows one to specify conditions that
|
||||
can apply to individual items or commands. The tradeoff is that you lose the automated passthrough behaviour.
|
||||
There is also [`evaluate-expressions`](#evaluate-expressions-command) which allows one to evaluate expressions inside commands.
|
||||
|
||||
## Utility Script Messages
|
||||
|
||||
There are a small number of custom script messages defined by file-browser to support custom keybinds.
|
||||
|
||||
### `=> <command...>`
|
||||
|
||||
A basic script message that makes it easier to chain multiple utility script messages together.
|
||||
Any `=>` string will be substituted for `script-message`.
|
||||
|
||||
```json
|
||||
{
|
||||
"key": "KP1",
|
||||
"command": ["script-message", "=>", "delay-command", "%j * 2", "=>", "evaluate-expressions", "print-text", "!{%j * 2}"],
|
||||
"multiselect": true
|
||||
}
|
||||
```
|
||||
|
||||
### `conditional-command [condition] <command...>`
|
||||
|
||||
Runs the following command only if the condition [expression](#expressions) is `true`.
|
||||
|
||||
This example command will only run if the player is currently paused:
|
||||
|
||||
```json
|
||||
{
|
||||
"key": "KP1",
|
||||
"command": ["script-message", "conditional-command", "mp.get_property_bool('pause')", "print-text", "is paused"],
|
||||
}
|
||||
```
|
||||
|
||||
Custom keybind codes are evaluated before the expressions.
|
||||
|
||||
This example only runs if the currently selected item in the browser has a `.mkv` extension:
|
||||
|
||||
```json
|
||||
{
|
||||
"key": "KP1",
|
||||
"command": ["script-message", "conditional-command", "fb.get_extension(%N) == 'mkv'", "print-text", "a matroska file"],
|
||||
}
|
||||
```
|
||||
|
||||
### `delay-command [delay] <command...>`
|
||||
|
||||
Delays the following command by `[delay]` seconds.
|
||||
Delay is an [expression](#expressions).
|
||||
|
||||
The following example will send the `print-text` command after 5 seconds:
|
||||
|
||||
```json
|
||||
{
|
||||
"key": "KP1",
|
||||
"command": ["script-message", "delay-command", "5", "print-text", "example"],
|
||||
}
|
||||
```
|
||||
|
||||
### `evaluate-expressions <command...>`
|
||||
|
||||
Evaluates embedded Lua expressions in the following command.
|
||||
Expressions have the same behaviour as the [`conditional-command`](#conditional-command-condition-command) script-message.
|
||||
Expressions must be surrounded by `!{}` characters.
|
||||
Additional `!` characters can be placed at the start of the expression to
|
||||
escape the evaluation.
|
||||
|
||||
For example the following keybind will print 3 to the console:
|
||||
|
||||
```json
|
||||
{
|
||||
"key": "KP1",
|
||||
"command": ["script-message", "evaluate-expressions", "print-text", "!{1 + 2}"],
|
||||
}
|
||||
```
|
||||
|
||||
This example replaces all `/` characters in the path with `\`
|
||||
(note that the `\` needs to be escaped twice, once for the json file, and once for the string in the lua expression):
|
||||
|
||||
```json
|
||||
{
|
||||
"key": "KP1",
|
||||
"command": ["script-message", "evaluate-expressions", "print-text", "!{ string.gsub(%F, '/', '\\\\') }"],
|
||||
}
|
||||
```
|
||||
|
||||
### `run-statement <statement...>`
|
||||
|
||||
Runs the following string a as a Lua statement. This is similar to an [expression](#expressions),
|
||||
but instead of the code evaluating to a value it must run a series of statements. Basically it allows
|
||||
for function bodies to be embedded into custom keybinds. All the same modules are available.
|
||||
If multiple strings are sent to the script-message then they will be concatenated together with newlines.
|
||||
|
||||
The following keybind will use [mpv-user-input](https://github.com/CogentRedTester/mpv-user-input) to
|
||||
rename items in file-browser:
|
||||
|
||||
```json
|
||||
{
|
||||
"key": "KP1",
|
||||
"command": ["script-message", "run-statement",
|
||||
"assert(input, 'install mpv-user-input!')",
|
||||
|
||||
"local line, err = input.get_user_input_co({",
|
||||
"id = 'rename-file',",
|
||||
"source = 'custom-keybind',",
|
||||
"request_text = 'rename file:',",
|
||||
"queueable = true,",
|
||||
"default_input = %N,",
|
||||
"cursor_pos = #(%N) - #fb.get_extension(%N, '')",
|
||||
"})",
|
||||
|
||||
"if not line then return end",
|
||||
"os.rename(%F, utils.join_path(%P, line))",
|
||||
|
||||
"fb.rescan()"
|
||||
],
|
||||
"parser": "file",
|
||||
"multiselect": true
|
||||
}
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
See [here](file-browser-keybinds.json).
|
|
@ -0,0 +1,118 @@
|
|||
[
|
||||
{
|
||||
"key": "KP1",
|
||||
"command": ["print-text", "file: %f"],
|
||||
"multiselect": true
|
||||
},
|
||||
{
|
||||
"key": "KP2",
|
||||
"command": ["print-text", "name: %n"],
|
||||
"multiselect": true
|
||||
},
|
||||
{
|
||||
"key": "KP3",
|
||||
"command": ["print-text", "open directory: %p"]
|
||||
},
|
||||
{
|
||||
"key": "KP4",
|
||||
"command": ["print-text", "directory name: %d"]
|
||||
},
|
||||
{
|
||||
"key": "KP5",
|
||||
"command": ["print-text", "escape the code: %%f"],
|
||||
"multiselect": true
|
||||
},
|
||||
{
|
||||
"key": "KP6",
|
||||
"command": ["print-text", "full filepath via concatenation: %p%n"],
|
||||
"multiselect": true
|
||||
},
|
||||
{
|
||||
"key": "KP7",
|
||||
"command": ["print-text", "quote/escape filepath: %F"],
|
||||
"multiselect": true
|
||||
},
|
||||
|
||||
|
||||
{
|
||||
"comment": "deletes the currently selected file",
|
||||
"key": "Alt+DEL",
|
||||
"command": ["run", "rm", "%F"],
|
||||
"filter": "file",
|
||||
"multiselect": true,
|
||||
"multi-type": "concat"
|
||||
},
|
||||
{
|
||||
"comment": "opens the currently selected items in a new mpv window",
|
||||
"key": "Ctrl+ENTER",
|
||||
"command": ["run", "mpv", "%F"],
|
||||
"multiselect": true,
|
||||
"multi-type": "concat"
|
||||
},
|
||||
{
|
||||
"key": "Ctrl+c",
|
||||
"command": [
|
||||
["run", "powershell", "-command", "Set-Clipboard", "%F"],
|
||||
["print-text", "copied filepath to clipboard"]
|
||||
],
|
||||
"multiselect": true,
|
||||
"delay": 0.3
|
||||
},
|
||||
{
|
||||
"comment": "Opens the current directory in windows explorer",
|
||||
"key": "Ctrl+o",
|
||||
"command": ["run", "powershell", "-command", "explorer.exe", "(( %P ).TrimEnd('/') -replace '/', '\\' )"],
|
||||
"multiselect": false
|
||||
},
|
||||
{
|
||||
"comment": "Opens the selected directory in windows explorer",
|
||||
"key": "Ctrl+O",
|
||||
"command": ["run", "powershell", "-command", "explorer.exe", "(( %F ).TrimEnd('/') -replace '/', '\\' )"],
|
||||
"filter": "dir",
|
||||
"multiselect": true
|
||||
},
|
||||
{
|
||||
"comment": "Opens the current directory in windows explorer and highlights the currently selected file",
|
||||
"key": "Ctrl+O",
|
||||
"command": ["run", "powershell", "-command", "explorer.exe", "'/select,'", "( %F -replace '/', '\\' )"],
|
||||
"filter": "file",
|
||||
"multiselect": true
|
||||
},
|
||||
{
|
||||
"key": "INS",
|
||||
"command": ["run", "powershell", "-command", "Set-Content", "-LiteralPath", "( %P + '/.ordered-chapters.m3u' )", "-Value", "( %N )"],
|
||||
"multiselect": true,
|
||||
"multi-type": "concat",
|
||||
"concat-string": "+ '\n' +"
|
||||
},
|
||||
|
||||
|
||||
{
|
||||
"key": "WHEEL_UP",
|
||||
"command": ["script-binding", "file_browser/dynamic/scroll_up"]
|
||||
},
|
||||
{
|
||||
"key": "WHEEL_DOWN",
|
||||
"command": ["script-binding", "file_browser/dynamic/scroll_down"]
|
||||
},
|
||||
{
|
||||
"key": "MBTN_LEFT",
|
||||
"command": ["script-binding", "file_browser/dynamic/down_dir"]
|
||||
},
|
||||
{
|
||||
"key": "MBTN_RIGHT",
|
||||
"command": ["script-binding", "file_browser/dynamic/up_dir"]
|
||||
},
|
||||
{
|
||||
"key": "MBTN_MID",
|
||||
"command": ["script-binding", "file_browser/dynamic/play"]
|
||||
},
|
||||
{
|
||||
"key": "UP",
|
||||
"command": ["osd-auto", "add", "volume", "2"]
|
||||
},
|
||||
{
|
||||
"key": "DOWN",
|
||||
"command": ["osd-auto", "add", "volume", "-2"]
|
||||
}
|
||||
]
|
|
@ -0,0 +1,156 @@
|
|||
#######################################################
|
||||
# This is the default config file for mpv-file-browser
|
||||
# https://github.com/CogentRedTester/mpv-file-browser
|
||||
#######################################################
|
||||
|
||||
# root directories, separated by commas
|
||||
# on linux you will probably want to add `/`,
|
||||
# on windows this should be used to add different drive letters
|
||||
# Examples:
|
||||
# linux: root=~/,/
|
||||
# windows: root=~/,C:/
|
||||
root=~/
|
||||
|
||||
# characters to separate root directories, each character works individually
|
||||
# this is in case one is using directories with strange names
|
||||
root_separators=,;
|
||||
|
||||
# number of entries to show on the screen at once
|
||||
num_entries=20
|
||||
|
||||
# wrap the cursor around the top and bottom of the list
|
||||
wrap=no
|
||||
|
||||
# only show files compatible with mpv in the browser
|
||||
filter_files=yes
|
||||
|
||||
# experimental feature that recurses directories concurrently when appending items to the playlist
|
||||
# this feature has the potential for massive performance improvements when using addons with asynchronous IO
|
||||
concurrent_recursion=no
|
||||
|
||||
# maximum number of recursions that can run concurrently
|
||||
# if this number is too high it risks overflowing the mpv event queue, which will cause some directories to be dropped entirely
|
||||
max_concurrency=16
|
||||
|
||||
# enable custom keybinds
|
||||
# the keybind json file must go in ~~/script-opts
|
||||
custom_keybinds=no
|
||||
|
||||
# file-browser only shows files that are compatible with mpv by default
|
||||
# adding a file extension to this list will add it to the extension whitelist
|
||||
# extensions are separated with the root separators, do not use any spaces
|
||||
extension_whitelist=
|
||||
|
||||
# add file extensions to this list to disable default filetypes
|
||||
# note that this will also override audio/subtitle_extension options below
|
||||
extension_blacklist=
|
||||
|
||||
# files with these extensions will be added as additional audio tracks for the current file instead of appended to the playlist
|
||||
# items on this list are automatically added to the extension whitelist
|
||||
audio_extensions=mka,dts,dtshd,dts-hd,truehd,true-hd
|
||||
|
||||
# files with these extensions will be added as additional subtitle tracks for the current file instead of appended to the playlist
|
||||
# items on this list are automatically added to the extension whitelist
|
||||
subtitle_extensions=etf,etf8,utf-8,idx,sub,srt,rt,ssa,ass,mks,vtt,sup,scc,smi,lrc,pgs
|
||||
|
||||
# filter directories or files starting with a period like .config
|
||||
# for linux systems
|
||||
filter_dot_dirs=no
|
||||
filter_dot_files=no
|
||||
|
||||
# substitude forward slashes for backslashes when appending a local file to the playlist
|
||||
# may be useful on windows systems
|
||||
substitute_backslash=no
|
||||
|
||||
# this option reverses the behaviour of the alt+ENTER keybind
|
||||
# when disabled the keybind is required to enable autoload for the file
|
||||
# when enabled the keybind disables autoload for the file
|
||||
autoload=no
|
||||
|
||||
# if autoload is triggered by selecting the currently playing file, then
|
||||
# the current file will have it's watch-later config saved before being closed and re-opened
|
||||
# essentially the current file will not be restarted
|
||||
autoload_save_current=yes
|
||||
|
||||
# when opening the browser in idle mode prefer the current working directory over the root
|
||||
# note that the working directory is set as the 'current' directory regardless, so `home` will
|
||||
# move the browser there even if this option is set to false
|
||||
default_to_working_directory=no
|
||||
|
||||
# enables addons
|
||||
addons=no
|
||||
addon_directory=~~/script-modules/file-browser-addons
|
||||
|
||||
# directory to load external modules - currently just user-input-module
|
||||
module_directory=~~/script-modules
|
||||
|
||||
# turn the OSC idle screen off and on when opening and closing the browser
|
||||
# this should only be enabled if file-browser is the only thing controlling the idle-screen,
|
||||
# if multiple sources attempt to control the idle-screen at the same time it can cause unexpected behaviour.
|
||||
toggle_idlescreen=no
|
||||
|
||||
# Set the current open status of the browser in the `file_browser/open` field of the `user-data` property.
|
||||
# This property is only available in mpv v0.36+.
|
||||
set_user_data=yes
|
||||
|
||||
# Set the current open status of the browser in the `file_browser-open` field of the `shared-script-properties` property.
|
||||
# This property is deprecated. When it is removed in mpv v0.37 file-browser will automatically disable this option.
|
||||
set_shared_script_properties=yes
|
||||
|
||||
####################################
|
||||
######### style settings ###########
|
||||
####################################
|
||||
|
||||
# force file-browser to use a specific text alignment (default: top-left)
|
||||
# uses ass tag alignment numbers: https://aegi.vmoe.info/docs/3.0/ASS_Tags/#index23h3
|
||||
# set to 0 to use the default mpv osd-align options
|
||||
alignment=7
|
||||
|
||||
# The format string used for the header. Uses custom-keybind substitution codes to
|
||||
# dynamically change the contents of the header. See: docs/custom-keybinds.md#codes
|
||||
format_string_header=%q\N----------------------------------------------------
|
||||
|
||||
# The format strings used for the wrappers. Supports custom-keybind substitution codes, and
|
||||
# supports two additional codes: `%<` and `%>` to show the number of items before and after the visible list, respectively.
|
||||
# Setting these options to empty strings will disable the wrappers.
|
||||
format_string_topwrapper=%< item(s) above\N
|
||||
format_string_bottomwrapper=\N%> item(s) remaining
|
||||
|
||||
# allows custom icons be set for the folder and cursor
|
||||
# the `\h` character is a hard space to add padding
|
||||
folder_icon={\p1}m 6.52 0 l 1.63 0 b 0.73 0 0.01 0.73 0.01 1.63 l 0 11.41 b 0 12.32 0.73 13.05 1.63 13.05 l 14.68 13.05 b 15.58 13.05 16.31 12.32 16.31 11.41 l 16.31 3.26 b 16.31 2.36 15.58 1.63 14.68 1.63 l 8.15 1.63{\p0}\h
|
||||
cursor_icon={\p1}m 14.11 6.86 l 0.34 0.02 b 0.25 -0.02 0.13 -0 0.06 0.08 b -0.01 0.16 -0.02 0.28 0.04 0.36 l 3.38 5.55 l 3.38 5.55 3.67 6.15 3.81 6.79 3.79 7.45 3.61 8.08 3.39 8.5l 0.04 13.77 b -0.02 13.86 -0.01 13.98 0.06 14.06 b 0.11 14.11 0.17 14.13 0.24 14.13 b 0.27 14.13 0.31 14.13 0.34 14.11 l 14.11 7.28 b 14.2 7.24 14.25 7.16 14.25 7.07 b 14.25 6.98 14.2 6.9 14.11 6.86{\p0}\h
|
||||
|
||||
# set the opacity of fonts in hexadecimal from 00 (opaque) to FF (transparent)
|
||||
font_opacity_selection_marker=99
|
||||
|
||||
# print the header in bold font
|
||||
font_bold_header=yes
|
||||
|
||||
# scale the size of the browser; 2 would double the size, 0.5 would halve it, etc.
|
||||
# the header and wrapper scaling is relative to the base scaling
|
||||
scaling_factor_base=1
|
||||
scaling_factor_header=1.4
|
||||
scaling_factor_wrappers=0.64
|
||||
|
||||
# set custom font names, blank is the default
|
||||
# setting custom fonts for the folder/cursor can fix broken or missing icons
|
||||
font_name_header=
|
||||
font_name_body=
|
||||
font_name_wrappers=
|
||||
font_name_folder=
|
||||
font_name_cursor=
|
||||
|
||||
# set custom font colours
|
||||
# colours are in hexadecimal format in Blue Green Red order
|
||||
# note that this is the opposite order to most RGB colour codes
|
||||
font_colour_header=00ccff
|
||||
font_colour_body=ffffff
|
||||
font_colour_wrappers=00ccff
|
||||
font_colour_cursor=00ccff
|
||||
|
||||
# these are colours applied to list items in different states
|
||||
font_colour_selected=fce788
|
||||
font_colour_multiselect=fcad88
|
||||
font_colour_playing=33ff66
|
||||
font_colour_playing_multiselected=22b547
|
|
@ -0,0 +1,60 @@
|
|||
--[[
|
||||
mpv-file-browser
|
||||
|
||||
This script allows users to browse and open files and folders entirely from within mpv.
|
||||
The script uses nothing outside the mpv API, so should work identically on all platforms.
|
||||
The browser can move up and down directories, start playing files and folders, or add them to the queue.
|
||||
|
||||
For full documentation see: https://github.com/CogentRedTester/mpv-file-browser
|
||||
]]--
|
||||
|
||||
local mp = require 'mp'
|
||||
|
||||
local o = require 'modules.options'
|
||||
local addons = require 'modules.addons'
|
||||
local keybinds = require 'modules.keybinds'
|
||||
local setup = require 'modules.setup'
|
||||
local controls = require 'modules.controls'
|
||||
local observers = require 'modules.observers'
|
||||
local script_messages = require 'modules.script-messages'
|
||||
|
||||
local file_parser = require 'modules.parsers.file'
|
||||
local root_parser = require 'modules.parsers.root'
|
||||
|
||||
-- setting the package paths
|
||||
package.path = mp.command_native({"expand-path", o.module_directory}).."/?.lua;"..package.path
|
||||
local _, input = pcall(require, "user-input-module")
|
||||
|
||||
-- root and addon setup
|
||||
setup.root()
|
||||
addons.setup_parser(file_parser, "file-browser.lua")
|
||||
addons.setup_parser(root_parser, 'file-browser.lua')
|
||||
if o.addons then addons.setup_addons() end
|
||||
|
||||
--these need to be below the addon setup in case any parsers add custom entries
|
||||
setup.extensions_list()
|
||||
keybinds.setup_keybinds()
|
||||
|
||||
-- property observers
|
||||
mp.observe_property('path', 'string', observers.current_directory)
|
||||
mp.observe_property('dvd-device', 'string', observers.dvd_device)
|
||||
|
||||
-- scripts messages
|
||||
mp.register_script_message('=>', script_messages.chain)
|
||||
mp.register_script_message('delay-command', script_messages.delay_command)
|
||||
mp.register_script_message('conditional-command', script_messages.conditional_command)
|
||||
mp.register_script_message('evaluate-expressions', script_messages.evaluate_expressions)
|
||||
mp.register_script_message('run-statement', script_messages.run_statement)
|
||||
|
||||
mp.register_script_message('browse-directory', controls.browse_directory)
|
||||
mp.register_script_message("get-directory-contents", script_messages.get_directory_contents)
|
||||
|
||||
--declares the keybind to open the browser
|
||||
mp.add_key_binding('MENU','browse-files', controls.toggle)
|
||||
mp.add_key_binding('Ctrl+o','open-browser', controls.open)
|
||||
|
||||
if input then
|
||||
mp.add_key_binding("Alt+o", "browse-directory/get-user-input", function()
|
||||
input.get_user_input(controls.browse_directory, {request_text = "open directory:"})
|
||||
end)
|
||||
end
|
|
@ -0,0 +1,159 @@
|
|||
local mp = require 'mp'
|
||||
local msg = require 'mp.msg'
|
||||
local utils = require 'mp.utils'
|
||||
|
||||
local o = require 'modules.options'
|
||||
local g = require 'modules.globals'
|
||||
local fb = require 'modules.apis.fb'
|
||||
local API = require 'modules.utils'
|
||||
local parser_API = require 'modules.apis.parser'
|
||||
|
||||
local root_parser = require 'modules.parsers.root'
|
||||
|
||||
local API_MAJOR, API_MINOR, API_PATCH = g.API_VERSION:match("(%d+)%.(%d+)%.(%d+)")
|
||||
|
||||
--checks if the given parser has a valid version number
|
||||
local function check_api_version(parser)
|
||||
local version = parser.version or "1.0.0"
|
||||
|
||||
local major, minor = version:match("(%d+)%.(%d+)")
|
||||
|
||||
if not major or not minor then
|
||||
return msg.error("Invalid version number")
|
||||
elseif major ~= API_MAJOR then
|
||||
return msg.error("parser", parser.name, "has wrong major version number, expected", ("v%d.x.x"):format(API_MAJOR), "got", 'v'..version)
|
||||
elseif minor > API_MINOR then
|
||||
msg.warn("parser", parser.name, "has newer minor version number than API, expected", ("v%d.%d.x"):format(API_MAJOR, API_MINOR), "got", 'v'..version)
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
--create a unique id for the given parser
|
||||
local function set_parser_id(parser)
|
||||
local name = parser.name
|
||||
if g.parsers[name] then
|
||||
local n = 2
|
||||
name = parser.name.."_"..n
|
||||
while g.parsers[name] do
|
||||
n = n + 1
|
||||
name = parser.name.."_"..n
|
||||
end
|
||||
end
|
||||
|
||||
g.parsers[name] = parser
|
||||
g.parsers[parser] = { id = name }
|
||||
end
|
||||
|
||||
--loads an addon in a separate environment
|
||||
local function load_addon(path)
|
||||
local name_sqbr = string.format("[%s]", path:match("/([^/]*)%.lua$"))
|
||||
local addon_environment = API.redirect_table(_G)
|
||||
addon_environment._G = addon_environment
|
||||
|
||||
--gives each addon custom debug messages
|
||||
addon_environment.package = API.redirect_table(addon_environment.package)
|
||||
addon_environment.package.loaded = API.redirect_table(addon_environment.package.loaded)
|
||||
local msg_module = {
|
||||
log = function(level, ...) msg.log(level, name_sqbr, ...) end,
|
||||
fatal = function(...) return msg.fatal(name_sqbr, ...) end,
|
||||
error = function(...) return msg.error(name_sqbr, ...) end,
|
||||
warn = function(...) return msg.warn(name_sqbr, ...) end,
|
||||
info = function(...) return msg.info(name_sqbr, ...) end,
|
||||
verbose = function(...) return msg.verbose(name_sqbr, ...) end,
|
||||
debug = function(...) return msg.debug(name_sqbr, ...) end,
|
||||
trace = function(...) return msg.trace(name_sqbr, ...) end,
|
||||
}
|
||||
addon_environment.print = msg_module.info
|
||||
|
||||
addon_environment.require = function(module)
|
||||
if module == "mp.msg" then return msg_module end
|
||||
return require(module)
|
||||
end
|
||||
|
||||
local chunk, err
|
||||
if setfenv then
|
||||
--since I stupidly named a function loadfile I need to specify the global one
|
||||
--I've been using the name too long to want to change it now
|
||||
chunk, err = _G.loadfile(path)
|
||||
if not chunk then return msg.error(err) end
|
||||
setfenv(chunk, addon_environment)
|
||||
else
|
||||
chunk, err = _G.loadfile(path, "bt", addon_environment)
|
||||
if not chunk then return msg.error(err) end
|
||||
end
|
||||
|
||||
local success, result = xpcall(chunk, API.traceback)
|
||||
return success and result or nil
|
||||
end
|
||||
|
||||
--setup an internal or external parser
|
||||
local function setup_parser(parser, file)
|
||||
parser = setmetatable(parser, { __index = parser_API })
|
||||
parser.name = parser.name or file:gsub("%-browser%.lua$", ""):gsub("%.lua$", "")
|
||||
|
||||
set_parser_id(parser)
|
||||
if not check_api_version(parser) then return msg.error("aborting load of parser", parser:get_id(), "from", file) end
|
||||
|
||||
msg.verbose("imported parser", parser:get_id(), "from", file)
|
||||
|
||||
--sets missing functions
|
||||
if not parser.can_parse then
|
||||
if parser.parse then parser.can_parse = function() return true end
|
||||
else parser.can_parse = function() return false end end
|
||||
end
|
||||
|
||||
if parser.priority == nil then parser.priority = 0 end
|
||||
if type(parser.priority) ~= "number" then return msg.error("parser", parser:get_id(), "needs a numeric priority") end
|
||||
|
||||
--the root parser has special behaviour, so it should not be in the list of parsers
|
||||
if parser == root_parser then return end
|
||||
table.insert(g.parsers, parser)
|
||||
end
|
||||
|
||||
--load an external addon
|
||||
local function setup_addon(file, path)
|
||||
if file:sub(-4) ~= ".lua" then return msg.verbose(path, "is not a lua file - aborting addon setup") end
|
||||
|
||||
local addon_parsers = load_addon(path)
|
||||
if not addon_parsers or type(addon_parsers) ~= "table" then return msg.error("addon", path, "did not return a table") end
|
||||
|
||||
--if the table contains a priority key then we assume it isn't an array of parsers
|
||||
if not addon_parsers[1] then addon_parsers = {addon_parsers} end
|
||||
|
||||
for _, parser in ipairs(addon_parsers) do
|
||||
setup_parser(parser, file)
|
||||
end
|
||||
end
|
||||
|
||||
--loading external addons
|
||||
local function setup_addons()
|
||||
package.loaded["file-browser"] = setmetatable({}, { __index = fb })
|
||||
|
||||
local addon_dir = mp.command_native({"expand-path", o.addon_directory..'/'})
|
||||
local files = utils.readdir(addon_dir)
|
||||
if not files then error("could not read addon directory") end
|
||||
|
||||
for _, file in ipairs(files) do
|
||||
setup_addon(file, addon_dir..file)
|
||||
end
|
||||
table.sort(g.parsers, function(a, b) return a.priority < b.priority end)
|
||||
|
||||
--we want to store the indexes of the parsers
|
||||
for i = #g.parsers, 1, -1 do g.parsers[ g.parsers[i] ].index = i end
|
||||
|
||||
--we want to run the setup functions for each addon
|
||||
for index, parser in ipairs(g.parsers) do
|
||||
if parser.setup then
|
||||
local success = xpcall(function() parser:setup() end, API.traceback)
|
||||
if not success then
|
||||
msg.error("parser", parser:get_id(), "threw an error in the setup method - removing from list of parsers")
|
||||
table.remove(g.parsers, index)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return {
|
||||
setup_parser = setup_parser,
|
||||
setup_addons = setup_addons,
|
||||
}
|
|
@ -0,0 +1,112 @@
|
|||
local msg = require 'mp.msg'
|
||||
local utils = require 'mp.utils'
|
||||
|
||||
local o = require 'modules.options'
|
||||
local g = require 'modules.globals'
|
||||
local API = require 'modules.utils'
|
||||
local ass = require 'modules.ass'
|
||||
local scanning = require 'modules.navigation.scanning'
|
||||
local cache = require 'modules.cache'
|
||||
local controls = require 'modules.controls'
|
||||
|
||||
local fb = setmetatable({}, { __index = setmetatable({}, { __index = API }) })
|
||||
|
||||
--these functions we'll provide as-is
|
||||
fb.redraw = ass.update_ass
|
||||
fb.rescan = scanning.rescan
|
||||
fb.browse_directory = controls.browse_directory
|
||||
|
||||
function fb.clear_cache()
|
||||
cache:clear()
|
||||
end
|
||||
|
||||
--a wrapper around scan_directory for addon API
|
||||
function fb.parse_directory(directory, parse_state)
|
||||
if not parse_state then parse_state = { source = "addon" }
|
||||
elseif not parse_state.source then parse_state.source = "addon" end
|
||||
return scanning.scan_directory(directory, parse_state)
|
||||
end
|
||||
|
||||
--register file extensions which can be opened by the browser
|
||||
function fb.register_parseable_extension(ext)
|
||||
g.parseable_extensions[string.lower(ext)] = true
|
||||
end
|
||||
function fb.remove_parseable_extension(ext)
|
||||
g.parseable_extensions[string.lower(ext)] = nil
|
||||
end
|
||||
|
||||
--add a compatible extension to show through the filter, only applies if run during the setup() method
|
||||
function fb.add_default_extension(ext)
|
||||
table.insert(g.compatible_file_extensions, ext)
|
||||
end
|
||||
|
||||
--add item to root at position pos
|
||||
function fb.insert_root_item(item, pos)
|
||||
msg.debug("adding item to root", item.label or item.name, pos)
|
||||
item.ass = item.ass or fb.ass_escape(item.label or item.name)
|
||||
item.type = "dir"
|
||||
table.insert(g.root, pos or (#g.root + 1), item)
|
||||
end
|
||||
|
||||
--a newer API for adding items to the root
|
||||
--only adds the item if the same item does not already exist in the root
|
||||
--the priority variable is a number that specifies the insertion location
|
||||
--a lower priority is placed higher in the list and the default is 100
|
||||
function fb.register_root_item(item, priority)
|
||||
msg.verbose('registering root item:', utils.to_string(item))
|
||||
if type(item) == 'string' then
|
||||
item = {name = item}
|
||||
end
|
||||
|
||||
-- if the item is already in the list then do nothing
|
||||
if fb.list.some(g.root, function(r)
|
||||
return fb.get_full_path(r, '') == fb.get_full_path(item, '')
|
||||
end) then return false end
|
||||
|
||||
item._priority = priority
|
||||
for i, v in ipairs(g.root) do
|
||||
if (v._priority or 100) > (priority or 100) then
|
||||
fb.insert_root_item(item, i)
|
||||
return true
|
||||
end
|
||||
end
|
||||
fb.insert_root_item(item)
|
||||
return true
|
||||
end
|
||||
|
||||
--providing getter and setter functions so that addons can't modify things directly
|
||||
function fb.get_script_opts() return fb.copy_table(o) end
|
||||
function fb.get_opt(key) return o[key] end
|
||||
function fb.get_extensions() return fb.copy_table(g.extensions) end
|
||||
function fb.get_sub_extensions() return fb.copy_table(g.sub_extensions) end
|
||||
function fb.get_audio_extensions() return fb.copy_table(g.audio_extensions) end
|
||||
function fb.get_parseable_extensions() return fb.copy_table(g.parseable_extensions) end
|
||||
function fb.get_state() return fb.copy_table(g.state) end
|
||||
function fb.get_dvd_device() return g.dvd_device end
|
||||
function fb.get_parsers() return fb.copy_table(g.parsers) end
|
||||
function fb.get_root() return fb.copy_table(g.root) end
|
||||
function fb.get_directory() return g.state.directory end
|
||||
function fb.get_list() return fb.copy_table(g.state.list) end
|
||||
function fb.get_current_file() return fb.copy_table(g.current_file) end
|
||||
function fb.get_current_parser() return g.state.parser:get_id() end
|
||||
function fb.get_current_parser_keyname() return g.state.parser.keybind_name or g.state.parser.name end
|
||||
function fb.get_selected_index() return g.state.selected end
|
||||
function fb.get_selected_item() return fb.copy_table(g.state.list[g.state.selected]) end
|
||||
function fb.get_open_status() return not g.state.hidden end
|
||||
function fb.get_parse_state(co) return g.parse_states[co or coroutine.running() or ""] end
|
||||
|
||||
function fb.set_empty_text(str)
|
||||
g.state.empty_text = str
|
||||
fb.redraw()
|
||||
end
|
||||
|
||||
function fb.set_selected_index(index)
|
||||
if type(index) ~= "number" then return false end
|
||||
if index < 1 then index = 1 end
|
||||
if index > #g.state.list then index = #g.state.list end
|
||||
g.state.selected = index
|
||||
fb.redraw()
|
||||
return index
|
||||
end
|
||||
|
||||
return fb
|
|
@ -0,0 +1,32 @@
|
|||
|
||||
local msg = require 'mp.msg'
|
||||
|
||||
local g = require 'modules.globals'
|
||||
|
||||
local parse_state_API = {}
|
||||
|
||||
--a wrapper around coroutine.yield that aborts the coroutine if
|
||||
--the parse request was cancelled by the user
|
||||
--the coroutine is
|
||||
function parse_state_API:yield(...)
|
||||
local co = coroutine.running()
|
||||
local is_browser = co == g.state.co
|
||||
if self.source == "browser" and not is_browser then
|
||||
msg.error("current coroutine does not match browser's expected coroutine - did you unsafely yield before this?")
|
||||
error("current coroutine does not match browser's expected coroutine - aborting the parse")
|
||||
end
|
||||
|
||||
local result = table.pack(coroutine.yield(...))
|
||||
if is_browser and co ~= g.state.co then
|
||||
msg.verbose("browser no longer waiting for list - aborting parse for", self.directory)
|
||||
error(g.ABORT_ERROR)
|
||||
end
|
||||
return unpack(result, 1, result.n)
|
||||
end
|
||||
|
||||
--checks if the current coroutine is the one handling the browser's request
|
||||
function parse_state_API:is_coroutine_current()
|
||||
return coroutine.running() == g.state.co
|
||||
end
|
||||
|
||||
return parse_state_API
|
|
@ -0,0 +1,25 @@
|
|||
local msg = require 'mp.msg'
|
||||
|
||||
local g = require 'modules.globals'
|
||||
local scanning = require 'modules.navigation.scanning'
|
||||
local fb = require 'modules.apis.fb'
|
||||
|
||||
local parser_api = setmetatable({}, { __index = fb })
|
||||
|
||||
function parser_api:get_index() return g.parsers[self].index end
|
||||
function parser_api:get_id() return g.parsers[self].id end
|
||||
|
||||
--a wrapper that passes the parsers priority value if none other is specified
|
||||
function parser_api:register_root_item(item, priority)
|
||||
return fb.register_root_item(item, priority or g.parsers[self:get_id()].priority)
|
||||
end
|
||||
|
||||
--runs choose_and_parse starting from the next parser
|
||||
function parser_api:defer(directory)
|
||||
msg.trace("deferring to other parsers...")
|
||||
local list, opts = scanning.choose_and_parse(directory, self:get_index() + 1)
|
||||
fb.get_parse_state().already_deferred = true
|
||||
return list, opts
|
||||
end
|
||||
|
||||
return parser_api
|
|
@ -0,0 +1,163 @@
|
|||
--------------------------------------------------------------------------------------------------------
|
||||
-----------------------------------------List Formatting------------------------------------------------
|
||||
--------------------------------------------------------------------------------------------------------
|
||||
--------------------------------------------------------------------------------------------------------
|
||||
|
||||
local g = require 'modules.globals'
|
||||
local o = require 'modules.options'
|
||||
local API = require 'modules.utils'
|
||||
|
||||
local state = g.state
|
||||
local style = g.style
|
||||
local ass = g.ass
|
||||
|
||||
local function draw()
|
||||
ass:update()
|
||||
end
|
||||
|
||||
local function remove()
|
||||
ass:remove()
|
||||
end
|
||||
|
||||
local string_buffer = {}
|
||||
|
||||
--appends the entered text to the overlay
|
||||
local function append(...)
|
||||
for i = 1, select("#", ...) do
|
||||
table.insert(string_buffer, select(i, ...) or '' )
|
||||
end
|
||||
end
|
||||
|
||||
--appends a newline character to the osd
|
||||
local function newline()
|
||||
table.insert(string_buffer, '\\N')
|
||||
end
|
||||
|
||||
local function flush_buffer()
|
||||
ass.data = table.concat(string_buffer, '')
|
||||
string_buffer = {}
|
||||
end
|
||||
|
||||
--detects whether or not to highlight the given entry as being played
|
||||
local function highlight_entry(v)
|
||||
if g.current_file.name == nil then return false end
|
||||
if API.parseable_item(v) then
|
||||
return string.find(g.current_file.directory, API.get_full_path(v), 1, true)
|
||||
else
|
||||
return g.current_file.path == API.get_full_path(v)
|
||||
end
|
||||
end
|
||||
|
||||
-- escape ass values and replace newlines
|
||||
local function ass_escape(str)
|
||||
return API.ass_escape(str, true)
|
||||
end
|
||||
|
||||
--refreshes the ass text using the contents of the list
|
||||
local function update_ass()
|
||||
if state.hidden then state.flag_update = true ; return end
|
||||
|
||||
append(style.global)
|
||||
|
||||
local dir_name = state.directory_label or state.directory
|
||||
if dir_name == "" then dir_name = "ROOT" end
|
||||
append(style.header)
|
||||
append(API.substitute_codes(o.format_string_header, nil, nil, nil, ass_escape))
|
||||
newline()
|
||||
|
||||
if #state.list < 1 then
|
||||
append(state.empty_text)
|
||||
flush_buffer()
|
||||
draw()
|
||||
return
|
||||
end
|
||||
|
||||
local start = 1
|
||||
local finish = start+o.num_entries-1
|
||||
|
||||
--handling cursor positioning
|
||||
local mid = math.ceil(o.num_entries/2)+1
|
||||
if state.selected+mid > finish then
|
||||
local offset = state.selected - finish + mid
|
||||
|
||||
--if we've overshot the end of the list then undo some of the offset
|
||||
if finish + offset > #state.list then
|
||||
offset = offset - ((finish+offset) - #state.list)
|
||||
end
|
||||
|
||||
start = start + offset
|
||||
finish = finish + offset
|
||||
end
|
||||
|
||||
--making sure that we don't overstep the boundaries
|
||||
if start < 1 then start = 1 end
|
||||
local overflow = finish < #state.list
|
||||
--this is necessary when the number of items in the dir is less than the max
|
||||
if not overflow then finish = #state.list end
|
||||
|
||||
-- these are the number values to place into the wrappers
|
||||
local wrapper_overrides = {['<'] = tostring(start-1), ['>'] = tostring(#state.list-finish)}
|
||||
|
||||
--adding a header to show there are items above in the list
|
||||
if o.format_string_topwrapper ~= '' and start > 1 then
|
||||
append(style.footer_header, API.substitute_codes(o.format_string_topwrapper, wrapper_overrides, nil, nil, ass_escape))
|
||||
newline()
|
||||
end
|
||||
|
||||
for i=start, finish do
|
||||
local v = state.list[i]
|
||||
local playing_file = highlight_entry(v)
|
||||
append(style.body)
|
||||
|
||||
--handles custom styles for different entries
|
||||
if i == state.selected or i == state.multiselect_start then
|
||||
if not (i == state.selected) then append(style.selection_marker) end
|
||||
|
||||
if not state.multiselect_start then append(style.cursor)
|
||||
else
|
||||
if state.selection[state.multiselect_start] then append(style.cursor_select)
|
||||
else append(style.cursor_deselect) end
|
||||
end
|
||||
append(o.cursor_icon, "\\h", style.body)
|
||||
else
|
||||
append(g.style.indent, o.cursor_icon, "\\h", style.body)
|
||||
end
|
||||
|
||||
--sets the selection colour scheme
|
||||
local multiselected = state.selection[i]
|
||||
|
||||
--sets the colour for the item
|
||||
local function set_colour()
|
||||
if multiselected then append(style.multiselect)
|
||||
elseif i == state.selected then append(style.selected) end
|
||||
|
||||
if playing_file then append( multiselected and style.playing_selected or style.playing) end
|
||||
end
|
||||
set_colour()
|
||||
|
||||
--sets the folder icon
|
||||
if v.type == 'dir' then
|
||||
append(style.folder, o.folder_icon, "\\h", style.body)
|
||||
set_colour()
|
||||
end
|
||||
|
||||
--adds the actual name of the item
|
||||
append(v.ass or ass_escape(v.label or v.name))
|
||||
newline()
|
||||
end
|
||||
|
||||
if o.format_string_bottomwrapper ~= '' and overflow then
|
||||
append(style.footer_header)
|
||||
append(API.substitute_codes(o.format_string_bottomwrapper, wrapper_overrides, nil, nil, ass_escape))
|
||||
end
|
||||
|
||||
flush_buffer()
|
||||
draw()
|
||||
end
|
||||
|
||||
return {
|
||||
update_ass = update_ass,
|
||||
highlight_entry = highlight_entry,
|
||||
draw = draw,
|
||||
remove = remove,
|
||||
}
|
|
@ -0,0 +1,43 @@
|
|||
--------------------------------------------------------------------------------------------------------
|
||||
--------------------------------------Cache Implementation----------------------------------------------
|
||||
--------------------------------------------------------------------------------------------------------
|
||||
--------------------------------------------------------------------------------------------------------
|
||||
|
||||
local g = require 'modules.globals'
|
||||
|
||||
--metatable of methods to manage the cache
|
||||
local __cache = {}
|
||||
|
||||
__cache.cached_values = {
|
||||
"directory", "directory_label", "list", "selected", "selection", "parser", "empty_text", "co"
|
||||
}
|
||||
|
||||
--inserts latest state values onto the cache stack
|
||||
function __cache:push()
|
||||
local t = {}
|
||||
for _, value in ipairs(self.cached_values) do
|
||||
t[value] = g.state[value]
|
||||
end
|
||||
table.insert(self, t)
|
||||
end
|
||||
|
||||
function __cache:pop()
|
||||
table.remove(self)
|
||||
end
|
||||
|
||||
function __cache:apply()
|
||||
local t = self[#self]
|
||||
for _, value in ipairs(self.cached_values) do
|
||||
g.state[value] = t[value]
|
||||
end
|
||||
end
|
||||
|
||||
function __cache:clear()
|
||||
for i = 1, #self do
|
||||
self[i] = nil
|
||||
end
|
||||
end
|
||||
|
||||
local cache = setmetatable({}, { __index = __cache })
|
||||
|
||||
return cache
|
|
@ -0,0 +1,87 @@
|
|||
|
||||
local mp = require 'mp'
|
||||
local msg = require 'mp.msg'
|
||||
local utils = require 'mp.utils'
|
||||
|
||||
local o = require 'modules.options'
|
||||
local g = require 'modules.globals'
|
||||
local API = require 'modules.utils'
|
||||
local movement = require 'modules.navigation.directory-movement'
|
||||
local ass = require 'modules.ass'
|
||||
local cursor = require 'modules.navigation.cursor'
|
||||
|
||||
local controls = {}
|
||||
|
||||
--opens the browser
|
||||
function controls.open()
|
||||
if not g.state.hidden then return end
|
||||
|
||||
for _,v in ipairs(g.state.keybinds) do
|
||||
mp.add_forced_key_binding(v[1], 'dynamic/'..v[2], v[3], v[4])
|
||||
end
|
||||
|
||||
if o.set_shared_script_properties then utils.shared_script_property_set('file_browser-open', 'yes') end
|
||||
if o.set_user_data then mp.set_property_bool('user-data/file_browser/open', true) end
|
||||
|
||||
if o.toggle_idlescreen then mp.commandv('script-message', 'osc-idlescreen', 'no', 'no_osd') end
|
||||
g.state.hidden = false
|
||||
if g.state.directory == nil then
|
||||
local path = mp.get_property('path')
|
||||
if path or o.default_to_working_directory then movement.goto_current_dir() else movement.goto_root() end
|
||||
return
|
||||
end
|
||||
|
||||
if not g.state.flag_update then ass.draw()
|
||||
else g.state.flag_update = false ; ass.update_ass() end
|
||||
end
|
||||
|
||||
--closes the list and sets the hidden flag
|
||||
function controls.close()
|
||||
if g.state.hidden then return end
|
||||
|
||||
for _,v in ipairs(g.state.keybinds) do
|
||||
mp.remove_key_binding('dynamic/'..v[2])
|
||||
end
|
||||
|
||||
if o.set_shared_script_properties then utils.shared_script_property_set("file_browser-open", "no") end
|
||||
if o.set_user_data then mp.set_property_bool('user-data/file_browser/open', false) end
|
||||
|
||||
if o.toggle_idlescreen then mp.commandv('script-message', 'osc-idlescreen', 'yes', 'no_osd') end
|
||||
g.state.hidden = true
|
||||
ass.remove()
|
||||
end
|
||||
|
||||
--toggles the list
|
||||
function controls.toggle()
|
||||
if g.state.hidden then controls.open()
|
||||
else controls.close() end
|
||||
end
|
||||
|
||||
--run when the escape key is used
|
||||
function controls.escape()
|
||||
--if multiple items are selection cancel the
|
||||
--selection instead of closing the browser
|
||||
if next(g.state.selection) or g.state.multiselect_start then
|
||||
g.state.selection = {}
|
||||
cursor.disable_select_mode()
|
||||
ass.update_ass()
|
||||
return
|
||||
end
|
||||
controls.close()
|
||||
end
|
||||
|
||||
--opens a specific directory
|
||||
function controls.browse_directory(directory)
|
||||
if not directory then return end
|
||||
directory = mp.command_native({"expand-path", directory}) or ''
|
||||
-- directory = join_path( mp.get_property("working-directory", ""), directory )
|
||||
|
||||
if directory ~= "" then directory = API.fix_path(directory, true) end
|
||||
msg.verbose('recieved directory from script message: '..directory)
|
||||
|
||||
if directory == "dvd://" then directory = g.dvd_device end
|
||||
movement.goto_directory(directory)
|
||||
controls.open()
|
||||
end
|
||||
|
||||
return controls
|
|
@ -0,0 +1,104 @@
|
|||
--------------------------------------------------------------------------------------------------------
|
||||
------------------------------------------Variable Setup------------------------------------------------
|
||||
--------------------------------------------------------------------------------------------------------
|
||||
--------------------------------------------------------------------------------------------------------
|
||||
|
||||
local mp = require 'mp'
|
||||
|
||||
local globals = {}
|
||||
local o = require 'modules.options'
|
||||
|
||||
--sets the version for the file-browser API
|
||||
globals.API_VERSION = "1.4.0"
|
||||
|
||||
--the osd_overlay API was not added until v0.31. The expand-path command was not added until 0.30
|
||||
assert(mp.create_osd_overlay, "Script requires minimum mpv version 0.33")
|
||||
|
||||
globals.ass = mp.create_osd_overlay("ass-events")
|
||||
globals.ass.res_y = 720 / o.scaling_factor_base
|
||||
|
||||
local BASE_FONT_SIZE = 25
|
||||
|
||||
globals.style = {
|
||||
global = o.alignment == 0 and "" or ([[{\an%d}]]):format(o.alignment),
|
||||
|
||||
-- full line styles
|
||||
header = ([[{\r\q2\b%s\fs%d\fn%s\c&H%s&}]]):format((o.font_bold_header and "1" or "0"), o.scaling_factor_header*BASE_FONT_SIZE, o.font_name_header, o.font_colour_header),
|
||||
body = ([[{\r\q2\fs%d\fn%s\c&H%s&}]]):format(BASE_FONT_SIZE, o.font_name_body, o.font_colour_body),
|
||||
footer_header = ([[{\r\q2\fs%d\fn%s\c&H%s&}]]):format(o.scaling_factor_wrappers*BASE_FONT_SIZE, o.font_name_wrappers, o.font_colour_wrappers),
|
||||
|
||||
--small section styles (for colours)
|
||||
multiselect = ([[{\c&H%s&}]]):format(o.font_colour_multiselect),
|
||||
selected = ([[{\c&H%s&}]]):format(o.font_colour_selected),
|
||||
playing = ([[{\c&H%s&}]]):format(o.font_colour_playing),
|
||||
playing_selected = ([[{\c&H%s&}]]):format(o.font_colour_playing_multiselected),
|
||||
|
||||
--icon styles
|
||||
indent = ([[{\alpha&H%s}]]):format('ff'),
|
||||
cursor = ([[{\fn%s\c&H%s&}]]):format(o.font_name_cursor, o.font_colour_cursor),
|
||||
cursor_select = ([[{\fn%s\c&H%s&}]]):format(o.font_name_cursor, o.font_colour_multiselect),
|
||||
cursor_deselect = ([[{\fn%s\c&H%s&}]]):format(o.font_name_cursor, o.font_colour_selected),
|
||||
folder = ([[{\fn%s}]]):format(o.font_name_folder),
|
||||
selection_marker = ([[{\alpha&H%s}]]):format(o.font_opacity_selection_marker),
|
||||
}
|
||||
|
||||
globals.state = {
|
||||
list = {},
|
||||
selected = 1,
|
||||
hidden = true,
|
||||
flag_update = false,
|
||||
keybinds = nil,
|
||||
|
||||
parser = nil,
|
||||
directory = nil,
|
||||
directory_label = nil,
|
||||
prev_directory = "",
|
||||
co = nil,
|
||||
|
||||
multiselect_start = nil,
|
||||
initial_selection = nil,
|
||||
selection = {}
|
||||
}
|
||||
|
||||
--the parser table actually contains 3 entries for each parser
|
||||
--a numeric entry which represents the priority of the parsers and has the parser object as the value
|
||||
--a string entry representing the id of each parser and with the parser object as the value
|
||||
--and a table entry with the parser itself as the key and a table value in the form { id = %s, index = %d }
|
||||
globals.parsers = {}
|
||||
|
||||
--this table contains the parse_state tables for every parse operation indexed with the coroutine used for the parse
|
||||
--this table has weakly referenced keys, meaning that once the coroutine for a parse is no-longer used by anything that
|
||||
--field in the table will be removed by the garbage collector
|
||||
globals.parse_states = setmetatable({}, { __mode = "k"})
|
||||
|
||||
globals.extensions = {}
|
||||
globals.sub_extensions = {}
|
||||
globals.audio_extensions = {}
|
||||
globals.parseable_extensions = {}
|
||||
|
||||
globals.dvd_device = nil
|
||||
globals.current_file = {
|
||||
directory = nil,
|
||||
name = nil,
|
||||
path = nil
|
||||
}
|
||||
|
||||
globals.root = {}
|
||||
|
||||
--default list of compatible file extensions
|
||||
--adding an item to this list is a valid request on github
|
||||
globals.compatible_file_extensions = {
|
||||
"264","265","3g2","3ga","3ga2","3gp","3gp2","3gpp","3iv","a52","aac","adt","adts","ahn","aif","aifc","aiff","amr","ape","asf","au","avc","avi","awb","ay",
|
||||
"bmp","cue","divx","dts","dtshd","dts-hd","dv","dvr","dvr-ms","eac3","evo","evob","f4a","flac","flc","fli","flic","flv","gbs","gif","gxf","gym",
|
||||
"h264","h265","hdmov","hdv","hes","hevc","jpeg","jpg","kss","lpcm","m1a","m1v","m2a","m2t","m2ts","m2v","m3u","m3u8","m4a","m4v","mk3d","mka","mkv",
|
||||
"mlp","mod","mov","mp1","mp2","mp2v","mp3","mp4","mp4v","mp4v","mpa","mpe","mpeg","mpeg2","mpeg4","mpg","mpg4","mpv","mpv2","mts","mtv","mxf","nsf",
|
||||
"nsfe","nsv","nut","oga","ogg","ogm","ogv","ogx","opus","pcm","pls","png","qt","ra","ram","rm","rmvb","sap","snd","spc","spx","svg","thd","thd+ac3",
|
||||
"tif","tiff","tod","trp","truehd","true-hd","ts","tsa","tsv","tta","tts","vfw","vgm","vgz","vob","vro","wav","weba","webm","webp","wm","wma","wmv","wtv",
|
||||
"wv","x264","x265","xvid","y4m","yuv"
|
||||
}
|
||||
|
||||
globals.ABORT_ERROR = {
|
||||
msg = "browser is no longer waiting for list - aborting parse"
|
||||
}
|
||||
|
||||
return globals
|
|
@ -0,0 +1,301 @@
|
|||
------------------------------------------------------------------------------------------
|
||||
----------------------------------Keybind Implementation----------------------------------
|
||||
------------------------------------------------------------------------------------------
|
||||
------------------------------------------------------------------------------------------
|
||||
|
||||
local mp = require 'mp'
|
||||
local msg = require 'mp.msg'
|
||||
local utils = require 'mp.utils'
|
||||
|
||||
local o = require 'modules.options'
|
||||
local g = require 'modules.globals'
|
||||
local API = require 'modules.utils'
|
||||
local playlist = require 'modules.playlist'
|
||||
local controls = require 'modules.controls'
|
||||
local movement = require 'modules.navigation.directory-movement'
|
||||
local scanning = require 'modules.navigation.scanning'
|
||||
local cursor = require 'modules.navigation.cursor'
|
||||
local cache = require 'modules.cache'
|
||||
|
||||
g.state.keybinds = {
|
||||
{'ENTER', 'play', function() playlist.add_files('replace', false) end},
|
||||
{'Shift+ENTER', 'play_append', function() playlist.add_files('append-play', false) end},
|
||||
{'Alt+ENTER', 'play_autoload',function() playlist.add_files('replace', true) end},
|
||||
{'ESC', 'close', controls.escape},
|
||||
{'RIGHT', 'down_dir', movement.down_dir},
|
||||
{'LEFT', 'up_dir', movement.up_dir},
|
||||
{'DOWN', 'scroll_down', function() cursor.scroll(1, o.wrap) end, {repeatable = true}},
|
||||
{'UP', 'scroll_up', function() cursor.scroll(-1, o.wrap) end, {repeatable = true}},
|
||||
{'PGDWN', 'page_down', function() cursor.scroll(o.num_entries) end, {repeatable = true}},
|
||||
{'PGUP', 'page_up', function() cursor.scroll(-o.num_entries) end, {repeatable = true}},
|
||||
{'Shift+PGDWN', 'list_bottom', function() cursor.scroll(math.huge) end},
|
||||
{'Shift+PGUP', 'list_top', function() cursor.scroll(-math.huge) end},
|
||||
{'HOME', 'goto_current', movement.goto_current_dir},
|
||||
{'Shift+HOME', 'goto_root', movement.goto_root},
|
||||
{'Ctrl+r', 'reload', function() cache:clear(); scanning.rescan() end},
|
||||
{'s', 'select_mode', cursor.toggle_select_mode},
|
||||
{'S', 'select_item', cursor.toggle_selection},
|
||||
{'Ctrl+a', 'select_all', cursor.select_all}
|
||||
}
|
||||
|
||||
--a map of key-keybinds - only saves the latest keybind if multiple have the same key code
|
||||
local top_level_keys = {}
|
||||
|
||||
--format the item string for either single or multiple items
|
||||
local function create_item_string(base_code_fn, items, state, cmd, quoted)
|
||||
if not items[1] then return end
|
||||
local func = quoted and function(...) return ("%q"):format(base_code_fn(...)) end or base_code_fn
|
||||
|
||||
local out = {}
|
||||
for _, item in ipairs(items) do
|
||||
table.insert(out, func(item, state))
|
||||
end
|
||||
|
||||
return table.concat(out, cmd['concat-string'] or ' ')
|
||||
end
|
||||
|
||||
local KEYBIND_CODE_PATTERN = API.get_code_pattern(API.code_fns)
|
||||
local item_specific_codes = 'fnij'
|
||||
|
||||
--substitutes the key codes for the
|
||||
local function substitute_codes(str, cmd, items, state)
|
||||
local overrides = {}
|
||||
|
||||
for code in item_specific_codes:gmatch('.') do
|
||||
overrides[code] = function(_,s) return create_item_string(API.code_fns[code], items, s, cmd) end
|
||||
overrides[code:upper()] = function(_,s) return create_item_string(API.code_fns[code], items, s, cmd, true) end
|
||||
end
|
||||
|
||||
return API.substitute_codes(str, overrides, items[1], state)
|
||||
end
|
||||
|
||||
--iterates through the command table and substitutes special
|
||||
--character codes for the correct strings used for custom functions
|
||||
local function format_command_table(cmd, items, state)
|
||||
local copy = {}
|
||||
for i = 1, #cmd.command do
|
||||
copy[i] = {}
|
||||
|
||||
for j = 1, #cmd.command[i] do
|
||||
copy[i][j] = substitute_codes(cmd.command[i][j], cmd, items, state)
|
||||
end
|
||||
end
|
||||
return copy
|
||||
end
|
||||
|
||||
--runs all of the commands in the command table
|
||||
--key.command must be an array of command tables compatible with mp.command_native
|
||||
--items must be an array of multiple items (when multi-type ~= concat the array will be 1 long)
|
||||
local function run_custom_command(cmd, items, state)
|
||||
local custom_cmds = cmd.codes and format_command_table(cmd, items, state) or cmd.command
|
||||
|
||||
for _, custom_cmd in ipairs(custom_cmds) do
|
||||
msg.debug("running command:", utils.to_string(custom_cmd))
|
||||
mp.command_native(custom_cmd)
|
||||
end
|
||||
end
|
||||
|
||||
--returns true if the given code set has item specific codes (%f, %i, etc)
|
||||
local function has_item_codes(codes)
|
||||
for code in pairs(codes) do
|
||||
if item_specific_codes:find(code:lower(), 1, true) then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
--runs one of the custom commands
|
||||
local function run_custom_keybind(cmd, state, co)
|
||||
--evaluates a condition and passes through the correct values
|
||||
local function evaluate_condition(condition, items)
|
||||
local cond = substitute_codes(condition, cmd, items, state)
|
||||
return API.evaluate_string('return '..cond) == true
|
||||
end
|
||||
|
||||
-- evaluates the string condition to decide if the keybind should be run
|
||||
local do_item_condition
|
||||
if cmd.condition then
|
||||
if has_item_codes(cmd.condition_codes) then
|
||||
do_item_condition = true
|
||||
elseif not evaluate_condition(cmd.condition, {}) then
|
||||
return false
|
||||
end
|
||||
end
|
||||
|
||||
if cmd.parser then
|
||||
local parser_str = ' '..cmd.parser..' '
|
||||
if not parser_str:find( '%W'..(state.parser.keybind_name or state.parser.name)..'%W' ) then return false end
|
||||
end
|
||||
|
||||
--these are for the default keybinds, or from addons which use direct functions
|
||||
if type(cmd.command) == 'function' then return cmd.command(cmd, cmd.addon and API.copy_table(state) or state, co) end
|
||||
|
||||
--the function terminates here if we are running the command on a single item
|
||||
if not (cmd.multiselect and next(state.selection)) then
|
||||
if cmd.filter then
|
||||
if not state.list[state.selected] then return false end
|
||||
if state.list[state.selected].type ~= cmd.filter then return false end
|
||||
end
|
||||
|
||||
if cmd.codes then
|
||||
--if the directory is empty, and this command needs to work on an item, then abort and fallback to the next command
|
||||
if not state.list[state.selected] and has_item_codes(cmd.codes) then return false end
|
||||
end
|
||||
|
||||
if do_item_condition and not evaluate_condition(cmd.condition, { state.list[state.selected] }) then
|
||||
return false
|
||||
end
|
||||
run_custom_command(cmd, { state.list[state.selected] }, state)
|
||||
return true
|
||||
end
|
||||
|
||||
--runs the command on all multi-selected items
|
||||
local selection = API.sort_keys(state.selection, function(item)
|
||||
if do_item_condition and not evaluate_condition(cmd.condition, { item }) then return false end
|
||||
return not cmd.filter or item.type == cmd.filter
|
||||
end)
|
||||
if not next(selection) then return false end
|
||||
|
||||
if cmd["multi-type"] == "concat" then
|
||||
run_custom_command(cmd, selection, state)
|
||||
|
||||
elseif cmd["multi-type"] == "repeat" or cmd["multi-type"] == nil then
|
||||
for i,_ in ipairs(selection) do
|
||||
run_custom_command(cmd, {selection[i]}, state)
|
||||
|
||||
if cmd.delay then
|
||||
mp.add_timeout(cmd.delay, function() API.coroutine.resume_err(co) end)
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--we passthrough by default if the command is not run on every selected item
|
||||
if cmd.passthrough ~= nil then return end
|
||||
|
||||
local num_selection = 0
|
||||
for _ in pairs(state.selection) do num_selection = num_selection+1 end
|
||||
return #selection == num_selection
|
||||
end
|
||||
|
||||
--recursively runs the keybind functions, passing down through the chain
|
||||
--of keybinds with the same key value
|
||||
local function run_keybind_recursive(keybind, state, co)
|
||||
msg.trace("Attempting custom command:", utils.to_string(keybind))
|
||||
|
||||
if keybind.passthrough ~= nil then
|
||||
run_custom_keybind(keybind, state, co)
|
||||
if keybind.passthrough == true and keybind.prev_key then
|
||||
run_keybind_recursive(keybind.prev_key, state, co)
|
||||
end
|
||||
else
|
||||
if run_custom_keybind(keybind, state, co) == false and keybind.prev_key then
|
||||
run_keybind_recursive(keybind.prev_key, state, co)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--a wrapper to run a custom keybind as a lua coroutine
|
||||
local function run_keybind_coroutine(key)
|
||||
msg.debug("Received custom keybind "..key.key)
|
||||
local co = coroutine.create(run_keybind_recursive)
|
||||
|
||||
local state_copy = {
|
||||
directory = g.state.directory,
|
||||
directory_label = g.state.directory_label,
|
||||
list = g.state.list, --the list should remain unchanged once it has been saved to the global state, new directories get new tables
|
||||
selected = g.state.selected,
|
||||
selection = API.copy_table(g.state.selection),
|
||||
parser = g.state.parser,
|
||||
}
|
||||
local success, err = coroutine.resume(co, key, state_copy, co)
|
||||
if not success then
|
||||
msg.error("error running keybind:", utils.to_string(key))
|
||||
API.traceback(err, co)
|
||||
end
|
||||
end
|
||||
|
||||
--scans the given command table to identify if they contain any custom keybind codes
|
||||
local function scan_for_codes(command_table, codes)
|
||||
if type(command_table) ~= "table" then return codes end
|
||||
for _, value in pairs(command_table) do
|
||||
local type = type(value)
|
||||
if type == "table" then
|
||||
scan_for_codes(value, codes)
|
||||
elseif type == "string" then
|
||||
value:gsub(KEYBIND_CODE_PATTERN, function(code) codes[code] = true end)
|
||||
end
|
||||
end
|
||||
return codes
|
||||
end
|
||||
|
||||
--inserting the custom keybind into the keybind array for declaration when file-browser is opened
|
||||
--custom keybinds with matching names will overwrite eachother
|
||||
local function insert_custom_keybind(keybind)
|
||||
--we'll always save the keybinds as either an array of command arrays or a function
|
||||
if type(keybind.command) == "table" and type(keybind.command[1]) ~= "table" then
|
||||
keybind.command = {keybind.command}
|
||||
end
|
||||
|
||||
keybind.codes = scan_for_codes(keybind.command, {})
|
||||
if not next(keybind.codes) then keybind.codes = nil end
|
||||
keybind.prev_key = top_level_keys[keybind.key]
|
||||
|
||||
if keybind.condition then
|
||||
keybind.condition_codes = {}
|
||||
for code in string.gmatch(keybind.condition, KEYBIND_CODE_PATTERN) do keybind.condition_codes[code] = true end
|
||||
end
|
||||
|
||||
table.insert(g.state.keybinds, {keybind.key, keybind.name, function() run_keybind_coroutine(keybind) end, keybind.flags or {}})
|
||||
top_level_keys[keybind.key] = keybind
|
||||
end
|
||||
|
||||
--loading the custom keybinds
|
||||
--can either load keybinds from the config file, from addons, or from both
|
||||
local function setup_keybinds()
|
||||
if not o.custom_keybinds and not o.addons then return end
|
||||
|
||||
--this is to make the default keybinds compatible with passthrough from custom keybinds
|
||||
for _, keybind in ipairs(g.state.keybinds) do
|
||||
top_level_keys[keybind[1]] = { key = keybind[1], name = keybind[2], command = keybind[3], flags = keybind[4] }
|
||||
end
|
||||
|
||||
--this loads keybinds from addons
|
||||
if o.addons then
|
||||
for i = #g.parsers, 1, -1 do
|
||||
local parser = g.parsers[i]
|
||||
if parser.keybinds then
|
||||
for i, keybind in ipairs(parser.keybinds) do
|
||||
--if addons use the native array command format, then we need to convert them over to the custom command format
|
||||
if not keybind.key then keybind = { key = keybind[1], name = keybind[2], command = keybind[3], flags = keybind[4] }
|
||||
else keybind = API.copy_table(keybind) end
|
||||
|
||||
keybind.name = g.parsers[parser].id.."/"..(keybind.name or tostring(i))
|
||||
keybind.addon = true
|
||||
insert_custom_keybind(keybind)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--loads custom keybinds from file-browser-keybinds.json
|
||||
if o.custom_keybinds then
|
||||
local path = mp.command_native({"expand-path", "~~/script-opts"}).."/file-browser-keybinds.json"
|
||||
local custom_keybinds, err = io.open( path )
|
||||
if not custom_keybinds then return error(err) end
|
||||
|
||||
local json = custom_keybinds:read("*a")
|
||||
custom_keybinds:close()
|
||||
|
||||
json = utils.parse_json(json)
|
||||
if not json then return error("invalid json syntax for "..path) end
|
||||
|
||||
for i, keybind in ipairs(json) do
|
||||
keybind.name = "custom/"..(keybind.name or tostring(i))
|
||||
insert_custom_keybind(keybind)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return {
|
||||
setup_keybinds = setup_keybinds,
|
||||
}
|
|
@ -0,0 +1,121 @@
|
|||
--------------------------------------------------------------------------------------------------------
|
||||
--------------------------------Scroll/Select Implementation--------------------------------------------
|
||||
--------------------------------------------------------------------------------------------------------
|
||||
--------------------------------------------------------------------------------------------------------
|
||||
|
||||
local g = require 'modules.globals'
|
||||
local API = require 'modules.utils'
|
||||
local ass = require 'modules.ass'
|
||||
|
||||
local cursor = {}
|
||||
|
||||
--disables multiselect
|
||||
function cursor.disable_select_mode()
|
||||
g.state.multiselect_start = nil
|
||||
g.state.initial_selection = nil
|
||||
end
|
||||
|
||||
--enables multiselect
|
||||
function cursor.enable_select_mode()
|
||||
g.state.multiselect_start = g.state.selected
|
||||
g.state.initial_selection = API.copy_table(g.state.selection)
|
||||
end
|
||||
|
||||
--calculates what drag behaviour is required for that specific movement
|
||||
local function drag_select(original_pos, new_pos)
|
||||
if original_pos == new_pos then return end
|
||||
|
||||
local setting = g.state.selection[g.state.multiselect_start]
|
||||
for i = original_pos, new_pos, (new_pos > original_pos and 1 or -1) do
|
||||
--if we're moving the cursor away from the starting point then set the selection
|
||||
--otherwise restore the original selection
|
||||
if i > g.state.multiselect_start then
|
||||
if new_pos > original_pos then
|
||||
g.state.selection[i] = setting
|
||||
elseif i ~= new_pos then
|
||||
g.state.selection[i] = g.state.initial_selection[i]
|
||||
end
|
||||
elseif i < g.state.multiselect_start then
|
||||
if new_pos < original_pos then
|
||||
g.state.selection[i] = setting
|
||||
elseif i ~= new_pos then
|
||||
g.state.selection[i] = g.state.initial_selection[i]
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--moves the selector up and down the list by the entered amount
|
||||
function cursor.scroll(n, wrap)
|
||||
local num_items = #g.state.list
|
||||
if num_items == 0 then return end
|
||||
|
||||
local original_pos = g.state.selected
|
||||
|
||||
if original_pos + n > num_items then
|
||||
g.state.selected = wrap and 1 or num_items
|
||||
elseif original_pos + n < 1 then
|
||||
g.state.selected = wrap and num_items or 1
|
||||
else
|
||||
g.state.selected = original_pos + n
|
||||
end
|
||||
|
||||
if g.state.multiselect_start then drag_select(original_pos, g.state.selected) end
|
||||
ass.update_ass()
|
||||
end
|
||||
|
||||
--selects the first item in the list which is highlighted as playing
|
||||
function cursor.select_playing_item()
|
||||
for i,item in ipairs(g.state.list) do
|
||||
if ass.highlight_entry(item) then
|
||||
g.state.selected = i
|
||||
return
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--scans the list for which item to select by default
|
||||
--chooses the folder that the script just moved out of
|
||||
--or, otherwise, the item highlighted as currently playing
|
||||
function cursor.select_prev_directory()
|
||||
if g.state.prev_directory:find(g.state.directory, 1, true) == 1 then
|
||||
local i = 1
|
||||
while (g.state.list[i] and API.parseable_item(g.state.list[i])) do
|
||||
if g.state.prev_directory:find(API.get_full_path(g.state.list[i]), 1, true) then
|
||||
g.state.selected = i
|
||||
return
|
||||
end
|
||||
i = i+1
|
||||
end
|
||||
end
|
||||
|
||||
cursor.select_playing_item()
|
||||
end
|
||||
|
||||
--toggles the selection
|
||||
function cursor.toggle_selection()
|
||||
if not g.state.list[g.state.selected] then return end
|
||||
g.state.selection[g.state.selected] = not g.state.selection[g.state.selected] or nil
|
||||
ass.update_ass()
|
||||
end
|
||||
|
||||
--select all items in the list
|
||||
function cursor.select_all()
|
||||
for i,_ in ipairs(g.state.list) do
|
||||
g.state.selection[i] = true
|
||||
end
|
||||
ass.update_ass()
|
||||
end
|
||||
|
||||
--toggles select mode
|
||||
function cursor.toggle_select_mode()
|
||||
if g.state.multiselect_start == nil then
|
||||
cursor.enable_select_mode()
|
||||
cursor.toggle_selection()
|
||||
else
|
||||
cursor.disable_select_mode()
|
||||
ass.update_ass()
|
||||
end
|
||||
end
|
||||
|
||||
return cursor
|
|
@ -0,0 +1,63 @@
|
|||
|
||||
local msg = require 'mp.msg'
|
||||
|
||||
local g = require 'modules.globals'
|
||||
local cache = require 'modules.cache'
|
||||
local scanning = require 'modules.navigation.scanning'
|
||||
local API = require 'modules.utils'
|
||||
|
||||
local directory_movement = {}
|
||||
|
||||
--the base function for moving to a directory
|
||||
function directory_movement.goto_directory(directory)
|
||||
g.state.directory = directory
|
||||
scanning.rescan(false)
|
||||
end
|
||||
|
||||
--loads the root list
|
||||
function directory_movement.goto_root()
|
||||
msg.verbose('jumping to root')
|
||||
directory_movement.goto_directory("")
|
||||
end
|
||||
|
||||
--switches to the directory of the currently playing file
|
||||
function directory_movement.goto_current_dir()
|
||||
msg.verbose('jumping to current directory')
|
||||
directory_movement.goto_directory(g.current_file.directory)
|
||||
end
|
||||
|
||||
--moves up a directory
|
||||
function directory_movement.up_dir()
|
||||
local dir = g.state.directory:reverse()
|
||||
local index = dir:find("[/\\]")
|
||||
|
||||
while index == 1 do
|
||||
dir = dir:sub(2)
|
||||
index = dir:find("[/\\]")
|
||||
end
|
||||
|
||||
if index == nil then g.state.directory = ""
|
||||
else g.state.directory = dir:sub(index):reverse() end
|
||||
|
||||
--we can make some assumptions about the next directory label when moving up or down
|
||||
if g.state.directory_label then g.state.directory_label = string.match(g.state.directory_label, "^(.+/)[^/]+/$") end
|
||||
|
||||
scanning.rescan(true)
|
||||
cache:pop()
|
||||
end
|
||||
|
||||
--moves down a directory
|
||||
function directory_movement.down_dir()
|
||||
local current = g.state.list[g.state.selected]
|
||||
if not current or not API.parseable_item(current) then return end
|
||||
|
||||
cache:push()
|
||||
local directory, redirected = API.get_new_directory(current, g.state.directory)
|
||||
g.state.directory = directory
|
||||
|
||||
--we can make some assumptions about the next directory label when moving up or down
|
||||
if g.state.directory_label then g.state.directory_label = g.state.directory_label..(current.label or current.name) end
|
||||
scanning.rescan(not redirected)
|
||||
end
|
||||
|
||||
return directory_movement
|
|
@ -0,0 +1,176 @@
|
|||
local mp = require 'mp'
|
||||
local msg = require 'mp.msg'
|
||||
local utils = require 'mp.utils'
|
||||
|
||||
local g = require 'modules.globals'
|
||||
local API = require 'modules.utils'
|
||||
local root_parser = require 'modules.parsers.root'
|
||||
local cache = require 'modules.cache'
|
||||
local cursor = require 'modules.navigation.cursor'
|
||||
local ass = require 'modules.ass'
|
||||
|
||||
local parse_state_API = require 'modules.apis.parse-state'
|
||||
|
||||
--parses the given directory or defers to the next parser if nil is returned
|
||||
local function choose_and_parse(directory, index)
|
||||
msg.debug("finding parser for", directory)
|
||||
local parser, list, opts
|
||||
local parse_state = g.parse_states[coroutine.running() or ""]
|
||||
while list == nil and not parse_state.already_deferred and index <= #g.parsers do
|
||||
parser = g.parsers[index]
|
||||
if parser:can_parse(directory, parse_state) then
|
||||
msg.debug("attempting parser:", parser:get_id())
|
||||
list, opts = parser:parse(directory, parse_state)
|
||||
end
|
||||
index = index + 1
|
||||
end
|
||||
if not list then return nil, {} end
|
||||
|
||||
msg.debug("list returned from:", parser:get_id())
|
||||
opts = opts or {}
|
||||
if list then opts.id = opts.id or parser:get_id() end
|
||||
return list, opts
|
||||
end
|
||||
|
||||
--sets up the parse_state table and runs the parse operation
|
||||
local function run_parse(directory, parse_state)
|
||||
msg.verbose("scanning files in", directory)
|
||||
parse_state.directory = directory
|
||||
|
||||
local co = coroutine.running()
|
||||
g.parse_states[co] = setmetatable(parse_state, { __index = parse_state_API })
|
||||
|
||||
if directory == "" then return root_parser:parse() end
|
||||
local list, opts = choose_and_parse(directory, 1)
|
||||
|
||||
if list == nil then return msg.debug("no successful parsers found") end
|
||||
opts.parser = g.parsers[opts.id]
|
||||
|
||||
if not opts.filtered then API.filter(list) end
|
||||
if not opts.sorted then API.sort(list) end
|
||||
return list, opts
|
||||
end
|
||||
|
||||
--returns the contents of the given directory using the given parse state
|
||||
--if a coroutine has already been used for a parse then create a new coroutine so that
|
||||
--the every parse operation has a unique thread ID
|
||||
local function parse_directory(directory, parse_state)
|
||||
local co = API.coroutine.assert("scan_directory must be executed from within a coroutine - aborting scan "..utils.to_string(parse_state))
|
||||
if not g.parse_states[co] then return run_parse(directory, parse_state) end
|
||||
|
||||
--if this coroutine is already is use by another parse operation then we create a new
|
||||
--one and hand execution over to that
|
||||
local new_co = coroutine.create(function()
|
||||
API.coroutine.resume_err(co, run_parse(directory, parse_state))
|
||||
end)
|
||||
|
||||
--queue the new coroutine on the mpv event queue
|
||||
mp.add_timeout(0, function()
|
||||
local success, err = coroutine.resume(new_co)
|
||||
if not success then
|
||||
API.traceback(err, new_co)
|
||||
API.coroutine.resume_err(co)
|
||||
end
|
||||
end)
|
||||
return g.parse_states[co]:yield()
|
||||
end
|
||||
|
||||
--sends update requests to the different parsers
|
||||
local function update_list(moving_adjacent)
|
||||
msg.verbose('opening directory: ' .. g.state.directory)
|
||||
|
||||
g.state.selected = 1
|
||||
g.state.selection = {}
|
||||
|
||||
--loads the current directry from the cache to save loading time
|
||||
--there will be a way to forcibly reload the current directory at some point
|
||||
--the cache is in the form of a stack, items are taken off the stack when the dir moves up
|
||||
if cache[1] and cache[#cache].directory == g.state.directory then
|
||||
msg.verbose('found directory in cache')
|
||||
cache:apply()
|
||||
g.state.prev_directory = g.state.directory
|
||||
return
|
||||
end
|
||||
local directory = g.state.directory
|
||||
local list, opts = parse_directory(g.state.directory, { source = "browser" })
|
||||
|
||||
--if the running coroutine isn't the one stored in the state variable, then the user
|
||||
--changed directories while the coroutine was paused, and this operation should be aborted
|
||||
if coroutine.running() ~= g.state.co then
|
||||
msg.verbose(g.ABORT_ERROR.msg)
|
||||
msg.debug("expected:", g.state.directory, "received:", directory)
|
||||
return
|
||||
end
|
||||
|
||||
--apply fallbacks if the scan failed
|
||||
if not list and cache[1] then
|
||||
--switches settings back to the previously opened directory
|
||||
--to the user it will be like the directory never changed
|
||||
msg.warn("could not read directory", g.state.directory)
|
||||
cache:apply()
|
||||
return
|
||||
elseif not list then
|
||||
msg.warn("could not read directory", g.state.directory)
|
||||
list, opts = root_parser:parse()
|
||||
end
|
||||
|
||||
g.state.list = list
|
||||
g.state.parser = opts.parser
|
||||
|
||||
--this only matters when displaying the list on the screen, so it doesn't need to be in the scan function
|
||||
if not opts.escaped then
|
||||
for i = 1, #list do
|
||||
list[i].ass = list[i].ass or API.ass_escape(list[i].label or list[i].name, true)
|
||||
end
|
||||
end
|
||||
|
||||
--setting custom options from parsers
|
||||
g.state.directory_label = opts.directory_label
|
||||
g.state.empty_text = opts.empty_text or g.state.empty_text
|
||||
|
||||
--we assume that directory is only changed when redirecting to a different location
|
||||
--therefore, the cache should be wiped
|
||||
if opts.directory then
|
||||
g.state.directory = opts.directory
|
||||
cache:clear()
|
||||
end
|
||||
|
||||
if opts.selected_index then
|
||||
g.state.selected = opts.selected_index or g.state.selected
|
||||
if g.state.selected > #g.state.list then g.state.selected = #g.state.list
|
||||
elseif g.state.selected < 1 then g.state.selected = 1 end
|
||||
end
|
||||
|
||||
if moving_adjacent then cursor.select_prev_directory()
|
||||
else cursor.select_playing_item() end
|
||||
g.state.prev_directory = g.state.directory
|
||||
end
|
||||
|
||||
--rescans the folder and updates the list
|
||||
local function update(moving_adjacent)
|
||||
--we can only make assumptions about the directory label when moving from adjacent directories
|
||||
if not moving_adjacent then
|
||||
g.state.directory_label = nil
|
||||
cache:clear()
|
||||
end
|
||||
|
||||
g.state.empty_text = "~"
|
||||
g.state.list = {}
|
||||
cursor.disable_select_mode()
|
||||
ass.update_ass()
|
||||
|
||||
--the directory is always handled within a coroutine to allow addons to
|
||||
--pause execution for asynchronous operations
|
||||
API.coroutine.run(function()
|
||||
g.state.co = coroutine.running()
|
||||
update_list(moving_adjacent)
|
||||
g.state.empty_text = "empty directory"
|
||||
ass.update_ass()
|
||||
end)
|
||||
end
|
||||
|
||||
return {
|
||||
rescan = update,
|
||||
scan_directory = parse_directory,
|
||||
choose_and_parse = choose_and_parse,
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
|
||||
local mp = require 'mp'
|
||||
local utils = require 'mp.utils'
|
||||
|
||||
local g = require 'modules.globals'
|
||||
local API = require 'modules.utils'
|
||||
local ass = require 'modules.ass'
|
||||
|
||||
local observers ={}
|
||||
|
||||
--saves the directory and name of the currently playing file
|
||||
function observers.current_directory(_, filepath)
|
||||
--if we're in idle mode then we want to open the working directory
|
||||
if filepath == nil then
|
||||
g.current_file.directory = API.fix_path( mp.get_property("working-directory", ""), true)
|
||||
g.current_file.name = nil
|
||||
g.current_file.path = nil
|
||||
return
|
||||
elseif filepath:find("dvd://") == 1 then
|
||||
filepath = g.dvd_device..filepath:match("dvd://(.*)")
|
||||
end
|
||||
|
||||
local workingDirectory = mp.get_property('working-directory', '')
|
||||
local exact_path = API.join_path(workingDirectory, filepath)
|
||||
exact_path = API.fix_path(exact_path, false)
|
||||
g.current_file.directory, g.current_file.name = utils.split_path(exact_path)
|
||||
g.current_file.path = exact_path
|
||||
|
||||
if not g.state.hidden then ass.update_ass()
|
||||
else g.state.flag_update = true end
|
||||
end
|
||||
|
||||
function observers.dvd_device(_, device)
|
||||
if not device or device == "" then device = "/dev/dvd/" end
|
||||
g.dvd_device = API.fix_path(device, true)
|
||||
end
|
||||
|
||||
return observers
|
|
@ -0,0 +1,129 @@
|
|||
local utils = require 'mp.utils'
|
||||
local opt = require 'mp.options'
|
||||
|
||||
local o = {
|
||||
--root directories
|
||||
root = "~/",
|
||||
|
||||
--characters to use as separators
|
||||
root_separators = ",;",
|
||||
|
||||
--number of entries to show on the screen at once
|
||||
num_entries = 20,
|
||||
|
||||
--wrap the cursor around the top and bottom of the list
|
||||
wrap = false,
|
||||
|
||||
--only show files compatible with mpv
|
||||
filter_files = true,
|
||||
|
||||
--experimental feature that recurses directories concurrently when
|
||||
--appending items to the playlist
|
||||
concurrent_recursion = false,
|
||||
|
||||
--maximum number of recursions that can run concurrently
|
||||
max_concurrency = 16,
|
||||
|
||||
--enable custom keybinds
|
||||
custom_keybinds = false,
|
||||
|
||||
--blacklist compatible files, it's recommended to use this rather than to edit the
|
||||
--compatible list directly. A semicolon separated list of extensions without spaces
|
||||
extension_blacklist = "",
|
||||
|
||||
--add extra file extensions
|
||||
extension_whitelist = "",
|
||||
|
||||
--files with these extensions will be added as additional audio tracks for the current file instead of appended to the playlist
|
||||
audio_extensions = "mka,dts,dtshd,dts-hd,truehd,true-hd",
|
||||
|
||||
--files with these extensions will be added as additional subtitle tracks instead of appended to the playlist
|
||||
subtitle_extensions = "etf,etf8,utf-8,idx,sub,srt,rt,ssa,ass,mks,vtt,sup,scc,smi,lrc,pgs",
|
||||
|
||||
--filter dot directories like .config
|
||||
--most useful on linux systems
|
||||
filter_dot_dirs = false,
|
||||
filter_dot_files = false,
|
||||
|
||||
--substitude forward slashes for backslashes when appending a local file to the playlist
|
||||
--potentially useful on windows systems
|
||||
substitute_backslash = false,
|
||||
|
||||
--this option reverses the behaviour of the alt+ENTER keybind
|
||||
--when disabled the keybind is required to enable autoload for the file
|
||||
--when enabled the keybind disables autoload for the file
|
||||
autoload = false,
|
||||
|
||||
--if autoload is triggered by selecting the currently playing file, then
|
||||
--the current file will have it's watch-later config saved before being closed
|
||||
--essentially the current file will not be restarted
|
||||
autoload_save_current = true,
|
||||
|
||||
--when opening the browser in idle mode prefer the current working directory over the root
|
||||
--note that the working directory is set as the 'current' directory regardless, so `home` will
|
||||
--move the browser there even if this option is set to false
|
||||
default_to_working_directory = false,
|
||||
|
||||
--allows custom icons be set for the folder and cursor
|
||||
--the `\h` character is a hard space to add padding between the symbol and the text
|
||||
folder_icon = [[{\p1}m 6.52 0 l 1.63 0 b 0.73 0 0.01 0.73 0.01 1.63 l 0 11.41 b 0 12.32 0.73 13.05 1.63 13.05 l 14.68 13.05 b 15.58 13.05 16.31 12.32 16.31 11.41 l 16.31 3.26 b 16.31 2.36 15.58 1.63 14.68 1.63 l 8.15 1.63{\p0}\h]],
|
||||
cursor_icon = [[{\p1}m 14.11 6.86 l 0.34 0.02 b 0.25 -0.02 0.13 -0 0.06 0.08 b -0.01 0.16 -0.02 0.28 0.04 0.36 l 3.38 5.55 l 3.38 5.55 3.67 6.15 3.81 6.79 3.79 7.45 3.61 8.08 3.39 8.5l 0.04 13.77 b -0.02 13.86 -0.01 13.98 0.06 14.06 b 0.11 14.11 0.17 14.13 0.24 14.13 b 0.27 14.13 0.31 14.13 0.34 14.11 l 14.11 7.28 b 14.2 7.24 14.25 7.16 14.25 7.07 b 14.25 6.98 14.2 6.9 14.11 6.86{\p0}\h]],
|
||||
|
||||
--enable addons
|
||||
addons = false,
|
||||
addon_directory = "~~/script-modules/file-browser-addons",
|
||||
|
||||
--directory to load external modules - currently just user-input-module
|
||||
module_directory = "~~/script-modules",
|
||||
|
||||
--turn the OSC idle screen off and on when opening and closing the browser
|
||||
toggle_idlescreen = false,
|
||||
|
||||
--Set the current open status of the browser in the `file_browser/open` field of the `user-data` property.
|
||||
--This property is only available in mpv v0.36+.
|
||||
set_user_data = true,
|
||||
|
||||
--Set the current open status of the browser in the `file_browser-open` field of the `shared-script-properties` property.
|
||||
--This property is deprecated. When it is removed in mpv v0.37 file-browser will automatically ignore this option.
|
||||
set_shared_script_properties = true,
|
||||
|
||||
--force file-browser to use a specific text alignment (default: top-left)
|
||||
--uses ass tag alignment numbers: https://aegi.vmoe.info/docs/3.0/ASS_Tags/#index23h3
|
||||
--set to 0 to use the default mpv osd-align options
|
||||
alignment = 7,
|
||||
|
||||
--style settings
|
||||
format_string_header = '%q\\N----------------------------------------------------',
|
||||
format_string_topwrapper = '%< item(s) above\\N',
|
||||
format_string_bottomwrapper = '\\N%> item(s) remaining',
|
||||
|
||||
font_bold_header = true,
|
||||
font_opacity_selection_marker = "99",
|
||||
|
||||
scaling_factor_base = 1,
|
||||
scaling_factor_header = 1.4,
|
||||
scaling_factor_wrappers = 0.64,
|
||||
|
||||
font_name_header = "",
|
||||
font_name_body = "",
|
||||
font_name_wrappers = "",
|
||||
font_name_folder = "",
|
||||
font_name_cursor = "",
|
||||
|
||||
font_colour_header = "00ccff",
|
||||
font_colour_body = "ffffff",
|
||||
font_colour_wrappers = "00ccff",
|
||||
font_colour_cursor = "00ccff",
|
||||
|
||||
font_colour_multiselect = "fcad88",
|
||||
font_colour_selected = "fce788",
|
||||
font_colour_playing = "33ff66",
|
||||
font_colour_playing_multiselected = "22b547"
|
||||
|
||||
}
|
||||
|
||||
opt.read_options(o, 'file_browser')
|
||||
|
||||
o.set_shared_script_properties = o.set_shared_script_properties and utils.shared_script_property_set
|
||||
|
||||
return o
|
|
@ -0,0 +1,47 @@
|
|||
|
||||
local msg = require 'mp.msg'
|
||||
local utils = require 'mp.utils'
|
||||
|
||||
local API = require 'modules.utils'
|
||||
|
||||
--parser ofject for native filesystems
|
||||
local file_parser = {
|
||||
name = "file",
|
||||
priority = 110,
|
||||
|
||||
--as the default parser we'll always attempt to use it if all others fail
|
||||
can_parse = function(_, directory) return true end,
|
||||
|
||||
--scans the given directory using the mp.utils.readdir function
|
||||
parse = function(self, directory)
|
||||
local new_list = {}
|
||||
local list1 = utils.readdir(directory, 'dirs')
|
||||
if list1 == nil then return nil end
|
||||
|
||||
--sorts folders and formats them into the list of directories
|
||||
for i=1, #list1 do
|
||||
local item = list1[i]
|
||||
|
||||
--filters hidden dot directories for linux
|
||||
if self.valid_dir(item) then
|
||||
msg.trace(item..'/')
|
||||
table.insert(new_list, {name = item..'/', type = 'dir'})
|
||||
end
|
||||
end
|
||||
|
||||
--appends files to the list of directory items
|
||||
local list2 = utils.readdir(directory, 'files')
|
||||
for i=1, #list2 do
|
||||
local item = list2[i]
|
||||
|
||||
--only adds whitelisted files to the browser
|
||||
if self.valid_file(item) then
|
||||
msg.trace(item)
|
||||
table.insert(new_list, {name = item, type = 'file'})
|
||||
end
|
||||
end
|
||||
return API.sort(new_list), {filtered = true, sorted = true}
|
||||
end
|
||||
}
|
||||
|
||||
return file_parser
|
|
@ -0,0 +1,26 @@
|
|||
|
||||
local g = require 'modules.globals'
|
||||
|
||||
--parser object for the root
|
||||
--not inserted to the parser list as it has special behaviour
|
||||
--it does get get added to parsers under it's ID to prevent confusing duplicates
|
||||
local root_parser = {
|
||||
name = "root",
|
||||
priority = math.huge,
|
||||
|
||||
--if this is being called then all other parsers have failed and we've fallen back to root
|
||||
can_parse = function() return true end,
|
||||
|
||||
--we return the root directory exactly as setup
|
||||
parse = function(self)
|
||||
return g.root, {
|
||||
sorted = true,
|
||||
filtered = true,
|
||||
escaped = true,
|
||||
parser = self,
|
||||
directory = "",
|
||||
}
|
||||
end
|
||||
}
|
||||
|
||||
return root_parser
|
|
@ -0,0 +1,257 @@
|
|||
------------------------------------------------------------------------------------------
|
||||
---------------------------------File/Playlist Opening------------------------------------
|
||||
------------------------------------------------------------------------------------------
|
||||
------------------------------------------------------------------------------------------
|
||||
|
||||
local mp = require 'mp'
|
||||
local msg = require 'mp.msg'
|
||||
|
||||
local o = require 'modules.options'
|
||||
local g = require 'modules.globals'
|
||||
local API = require 'modules.utils'
|
||||
local ass = require 'modules.ass'
|
||||
local cursor = require 'modules.navigation.cursor'
|
||||
local controls = require 'modules.controls'
|
||||
local scanning = require 'modules.navigation.scanning'
|
||||
local movement = require 'modules.navigation.directory-movement'
|
||||
|
||||
local state = g.state
|
||||
|
||||
--adds a file to the playlist and changes the flag to `append-play` in preparation
|
||||
--for future items
|
||||
local function loadfile(file, opts)
|
||||
if o.substitute_backslash and not API.get_protocol(file) then
|
||||
file = file:gsub("/", "\\")
|
||||
end
|
||||
|
||||
if opts.flag == "replace" then msg.verbose("Playling file", file)
|
||||
else msg.verbose("Appending", file, "to the playlist") end
|
||||
|
||||
if not mp.commandv("loadfile", file, opts.flag) then msg.warn(file) end
|
||||
opts.flag = "append-play"
|
||||
opts.items_appended = opts.items_appended + 1
|
||||
end
|
||||
|
||||
--this function recursively loads directories concurrently in separate coroutines
|
||||
--results are saved in a tree of tables that allows asynchronous access
|
||||
local function concurrent_loadlist_parse(directory, load_opts, prev_dirs, item_t)
|
||||
--prevents infinite recursion from the item.path or opts.directory fields
|
||||
if prev_dirs[directory] then return end
|
||||
prev_dirs[directory] = true
|
||||
|
||||
local list, list_opts = scanning.scan_directory(directory, { source = "loadlist" })
|
||||
if list == g.root then return end
|
||||
|
||||
--if we can't parse the directory then append it and hope mpv fares better
|
||||
if list == nil then
|
||||
msg.warn("Could not parse", directory, "appending to playlist anyway")
|
||||
item_t.type = "file"
|
||||
return
|
||||
end
|
||||
|
||||
directory = list_opts.directory or directory
|
||||
if directory == "" then return end
|
||||
|
||||
--we must declare these before we start loading sublists otherwise the append thread will
|
||||
--need to wait until the whole list is loaded (when synchronous IO is used)
|
||||
item_t._sublist = list or {}
|
||||
list._directory = directory
|
||||
|
||||
--launches new parse operations for directories, each in a different coroutine
|
||||
for _, item in ipairs(list) do
|
||||
if API.parseable_item(item) then
|
||||
API.coroutine.run(concurrent_loadlist_wrapper, API.get_new_directory(item, directory), load_opts, prev_dirs, item)
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
--a wrapper function that ensures the concurrent_loadlist_parse is run correctly
|
||||
function concurrent_loadlist_wrapper(directory, opts, prev_dirs, item)
|
||||
--ensures that only a set number of concurrent parses are operating at any one time.
|
||||
--the mpv event queue is seemingly limited to 1000 items, but only async mpv actions like
|
||||
--command_native_async should use that, events like mp.add_timeout (which coroutine.sleep() uses) should
|
||||
--be handled enturely on the Lua side with a table, which has a significantly larger maximum size.
|
||||
while (opts.concurrency > o.max_concurrency) do
|
||||
API.coroutine.sleep(0.1)
|
||||
end
|
||||
opts.concurrency = opts.concurrency + 1
|
||||
|
||||
local success = concurrent_loadlist_parse(directory, opts, prev_dirs, item)
|
||||
opts.concurrency = opts.concurrency - 1
|
||||
if not success then item._sublist = {} end
|
||||
if coroutine.status(opts.co) == "suspended" then API.coroutine.resume_err(opts.co) end
|
||||
end
|
||||
|
||||
--recursively appends items to the playlist, acts as a consumer to the previous functions producer;
|
||||
--if the next directory has not been parsed this function will yield until the parse has completed
|
||||
local function concurrent_loadlist_append(list, load_opts)
|
||||
local directory = list._directory
|
||||
|
||||
for _, item in ipairs(list) do
|
||||
if not g.sub_extensions[ API.get_extension(item.name, "") ]
|
||||
and not g.audio_extensions[ API.get_extension(item.name, "") ]
|
||||
then
|
||||
while (not item._sublist and API.parseable_item(item)) do
|
||||
coroutine.yield()
|
||||
end
|
||||
|
||||
if API.parseable_item(item) then
|
||||
concurrent_loadlist_append(item._sublist, load_opts)
|
||||
else
|
||||
loadfile(API.get_full_path(item, directory), load_opts)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--recursive function to load directories using the script custom parsers
|
||||
--returns true if any items were appended to the playlist
|
||||
local function custom_loadlist_recursive(directory, load_opts, prev_dirs)
|
||||
--prevents infinite recursion from the item.path or opts.directory fields
|
||||
if prev_dirs[directory] then return end
|
||||
prev_dirs[directory] = true
|
||||
|
||||
local list, opts = scanning.scan_directory(directory, { source = "loadlist" })
|
||||
if list == g.root then return end
|
||||
|
||||
--if we can't parse the directory then append it and hope mpv fares better
|
||||
if list == nil then
|
||||
msg.warn("Could not parse", directory, "appending to playlist anyway")
|
||||
loadfile(directory, load_opts.flag)
|
||||
return true
|
||||
end
|
||||
|
||||
directory = opts.directory or directory
|
||||
if directory == "" then return end
|
||||
|
||||
for _, item in ipairs(list) do
|
||||
if not g.sub_extensions[ API.get_extension(item.name, "") ]
|
||||
and not g.audio_extensions[ API.get_extension(item.name, "") ]
|
||||
then
|
||||
if API.parseable_item(item) then
|
||||
custom_loadlist_recursive( API.get_new_directory(item, directory) , load_opts, prev_dirs)
|
||||
else
|
||||
local path = API.get_full_path(item, directory)
|
||||
loadfile(path, load_opts)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
--a wrapper for the custom_loadlist_recursive function
|
||||
local function loadlist(item, opts)
|
||||
local dir = API.get_full_path(item, opts.directory)
|
||||
local num_items = opts.items_appended
|
||||
|
||||
if o.concurrent_recursion then
|
||||
item = API.copy_table(item)
|
||||
opts.co = API.coroutine.assert()
|
||||
opts.concurrency = 0
|
||||
|
||||
--we need the current coroutine to suspend before we run the first parse operation, so
|
||||
--we schedule the coroutine to run on the mpv event queue
|
||||
mp.add_timeout(0, function()
|
||||
API.coroutine.run(concurrent_loadlist_wrapper, dir, opts, {}, item)
|
||||
end)
|
||||
concurrent_loadlist_append({item, _directory = opts.directory}, opts)
|
||||
else
|
||||
custom_loadlist_recursive(dir, opts, {})
|
||||
end
|
||||
|
||||
if opts.items_appended == num_items then msg.warn(dir, "contained no valid files") end
|
||||
end
|
||||
|
||||
--load playlist entries before and after the currently playing file
|
||||
local function autoload_dir(path, opts)
|
||||
if o.autoload_save_current and path == g.current_file.path then
|
||||
mp.commandv("write-watch-later-config") end
|
||||
|
||||
--loads the currently selected file, clearing the playlist in the process
|
||||
loadfile(path, opts)
|
||||
|
||||
local pos = 1
|
||||
local file_count = 0
|
||||
for _,item in ipairs(state.list) do
|
||||
if item.type == "file"
|
||||
and not g.sub_extensions[ API.get_extension(item.name, "") ]
|
||||
and not g.audio_extensions[ API.get_extension(item.name, "") ]
|
||||
then
|
||||
local p = API.get_full_path(item)
|
||||
|
||||
if p == path then pos = file_count
|
||||
else loadfile( p, opts) end
|
||||
|
||||
file_count = file_count + 1
|
||||
end
|
||||
end
|
||||
mp.commandv("playlist-move", 0, pos+1)
|
||||
end
|
||||
|
||||
--runs the loadfile or loadlist command
|
||||
local function open_item(item, opts)
|
||||
if API.parseable_item(item) then
|
||||
return loadlist(item, opts)
|
||||
end
|
||||
|
||||
local path = API.get_full_path(item, opts.directory)
|
||||
if g.sub_extensions[ API.get_extension(item.name, "") ] then
|
||||
mp.commandv("sub-add", path, opts.flag == "replace" and "select" or "auto")
|
||||
elseif g.audio_extensions[ API.get_extension(item.name, "") ] then
|
||||
mp.commandv("audio-add", path, opts.flag == "replace" and "select" or "auto")
|
||||
else
|
||||
if opts.autoload then autoload_dir(path, opts)
|
||||
else loadfile(path, opts) end
|
||||
end
|
||||
end
|
||||
|
||||
--handles the open options as a coroutine
|
||||
--once loadfile has been run we can no-longer guarantee synchronous execution - the state values may change
|
||||
--therefore, we must ensure that any state values that could be used after a loadfile call are saved beforehand
|
||||
local function open_file_coroutine(opts)
|
||||
if not state.list[state.selected] then return end
|
||||
if opts.flag == 'replace' then controls.close() end
|
||||
|
||||
--we want to set the idle option to yes to ensure that if the first item
|
||||
--fails to load then the player has a chance to attempt to load further items (for async append operations)
|
||||
local idle = mp.get_property("idle", "once")
|
||||
mp.set_property("idle", "yes")
|
||||
|
||||
--handles multi-selection behaviour
|
||||
if next(state.selection) then
|
||||
local selection = API.sort_keys(state.selection)
|
||||
--reset the selection after
|
||||
state.selection = {}
|
||||
|
||||
cursor.disable_select_mode()
|
||||
ass.update_ass()
|
||||
|
||||
--the currently selected file will be loaded according to the flag
|
||||
--the flag variable will be switched to append once a file is loaded
|
||||
for i=1, #selection do
|
||||
open_item(selection[i], opts)
|
||||
end
|
||||
|
||||
else
|
||||
local item = state.list[state.selected]
|
||||
if opts.flag == "replace" then movement.down_dir() end
|
||||
open_item(item, opts)
|
||||
end
|
||||
|
||||
if mp.get_property("idle") == "yes" then mp.set_property("idle", idle) end
|
||||
end
|
||||
|
||||
--opens the selelected file(s)
|
||||
local function open_file(flag, autoload)
|
||||
API.coroutine.run(open_file_coroutine, {
|
||||
flag = flag,
|
||||
autoload = (autoload ~= o.autoload and flag == "replace"),
|
||||
directory = state.directory,
|
||||
items_appended = 0
|
||||
})
|
||||
end
|
||||
|
||||
return {
|
||||
add_files = open_file,
|
||||
}
|
|
@ -0,0 +1,91 @@
|
|||
local mp = require 'mp'
|
||||
local msg = require 'mp.msg'
|
||||
local utils = require 'mp.utils'
|
||||
|
||||
local o = require 'modules.options'
|
||||
local g = require 'modules.globals'
|
||||
local API = require 'modules.utils'
|
||||
local scanning = require 'modules.navigation.scanning'
|
||||
|
||||
local script_messages = {}
|
||||
|
||||
--allows other scripts to request directory contents from file-browser
|
||||
function script_messages.get_directory_contents(directory, response_str)
|
||||
API.coroutine.run(function()
|
||||
if not directory then msg.error("did not receive a directory string"); return end
|
||||
if not response_str then msg.error("did not receive a response string"); return end
|
||||
|
||||
directory = mp.command_native({"expand-path", directory}, "")
|
||||
if directory ~= "" then directory = API.fix_path(directory, true) end
|
||||
msg.verbose(("recieved %q from 'get-directory-contents' script message - returning result to %q"):format(directory, response_str))
|
||||
|
||||
local list, opts = scanning.scan_directory(directory, { source = "script-message" } )
|
||||
if opts then opts.API_VERSION = g.API_VERSION end
|
||||
|
||||
local err
|
||||
list, err = API.format_json_safe(list)
|
||||
if not list then msg.error(err) end
|
||||
|
||||
opts, err = API.format_json_safe(opts)
|
||||
if not opts then msg.error(err) end
|
||||
|
||||
mp.commandv("script-message", response_str, list or "", opts or "")
|
||||
end)
|
||||
end
|
||||
|
||||
--a helper script message for custom keybinds
|
||||
--substitutes any '=>' arguments for 'script-message'
|
||||
--makes chaining script-messages much easier
|
||||
function script_messages.chain(...)
|
||||
local command = table.pack('script-message', ...)
|
||||
for i, v in ipairs(command) do
|
||||
if v == '=>' then command[i] = 'script-message' end
|
||||
end
|
||||
mp.commandv(table.unpack(command))
|
||||
end
|
||||
|
||||
--a helper script message for custom keybinds
|
||||
--sends a command after the specified delay
|
||||
function script_messages.delay_command(delay, ...)
|
||||
local command = table.pack(...)
|
||||
local success, err = pcall(mp.add_timeout, API.evaluate_string('return '..delay), function() mp.commandv(table.unpack(command)) end)
|
||||
if not success then return msg.error(err) end
|
||||
end
|
||||
|
||||
--a helper script message for custom keybinds
|
||||
--sends a command only if the given expression returns true
|
||||
function script_messages.conditional_command(condition, ...)
|
||||
local command = table.pack(...)
|
||||
API.coroutine.run(function()
|
||||
if API.evaluate_string('return '..condition) == true then mp.commandv(table.unpack(command)) end
|
||||
end)
|
||||
end
|
||||
|
||||
--a helper script message for custom keybinds
|
||||
--extracts lua expressions from the command and evaluates them
|
||||
--expressions must be surrounded by !{}. Another ! before the { will escape the evaluation
|
||||
function script_messages.evaluate_expressions(...)
|
||||
local args = table.pack(...)
|
||||
API.coroutine.run(function()
|
||||
for i, arg in ipairs(args) do
|
||||
args[i] = arg:gsub('(!+)(%b{})', function(lead, expression)
|
||||
if #lead % 2 == 0 then return string.rep('!', #lead/2)..expression end
|
||||
|
||||
local eval = API.evaluate_string('return '..expression:sub(2, -2))
|
||||
return type(eval) == "table" and utils.to_string(eval) or tostring(eval)
|
||||
end)
|
||||
end
|
||||
|
||||
mp.commandv(table.unpack(args))
|
||||
end)
|
||||
end
|
||||
|
||||
--a helper function for custom-keybinds
|
||||
--concatenates the command arguments with newlines and runs the
|
||||
--string as a statement of code
|
||||
function script_messages.run_statement(...)
|
||||
local statement = table.concat(table.pack(...), '\n')
|
||||
API.coroutine.run(API.evaluate_string, statement)
|
||||
end
|
||||
|
||||
return script_messages
|
|
@ -0,0 +1,52 @@
|
|||
local mp = require 'mp'
|
||||
|
||||
local o = require 'modules.options'
|
||||
local g = require 'modules.globals'
|
||||
local API = require 'modules.utils'
|
||||
|
||||
--sets up the compatible extensions list
|
||||
local function setup_extensions_list()
|
||||
--setting up subtitle extensions
|
||||
for ext in API.iterate_opt(o.subtitle_extensions:lower()) do
|
||||
g.sub_extensions[ext] = true
|
||||
g.extensions[ext] = true
|
||||
end
|
||||
|
||||
--setting up audio extensions
|
||||
for ext in API.iterate_opt(o.audio_extensions:lower()) do
|
||||
g.audio_extensions[ext] = true
|
||||
g.extensions[ext] = true
|
||||
end
|
||||
|
||||
--adding file extensions to the set
|
||||
for _, ext in ipairs(g.compatible_file_extensions) do
|
||||
g.extensions[ext] = true
|
||||
end
|
||||
|
||||
--adding extra extensions on the whitelist
|
||||
for str in API.iterate_opt(o.extension_whitelist:lower()) do
|
||||
g.extensions[str] = true
|
||||
end
|
||||
|
||||
--removing extensions that are in the blacklist
|
||||
for str in API.iterate_opt(o.extension_blacklist:lower()) do
|
||||
g.extensions[str] = nil
|
||||
end
|
||||
end
|
||||
|
||||
--splits the string into a table on the separators
|
||||
local function setup_root()
|
||||
for str in API.iterate_opt(o.root) do
|
||||
local path = mp.command_native({'expand-path', str})
|
||||
path = API.fix_path(path, true)
|
||||
|
||||
local temp = {name = path, type = 'dir', label = str, ass = API.ass_escape(str, true)}
|
||||
|
||||
g.root[#g.root+1] = temp
|
||||
end
|
||||
end
|
||||
|
||||
return {
|
||||
extensions_list = setup_extensions_list,
|
||||
root = setup_root,
|
||||
}
|
|
@ -0,0 +1,424 @@
|
|||
--------------------------------------------------------------------------------------------------------
|
||||
-----------------------------------------Utility Functions----------------------------------------------
|
||||
---------------------------------------Part of the addon API--------------------------------------------
|
||||
--------------------------------------------------------------------------------------------------------
|
||||
|
||||
local mp = require 'mp'
|
||||
local msg = require 'mp.msg'
|
||||
local utils = require 'mp.utils'
|
||||
|
||||
local o = require 'modules.options'
|
||||
local g = require 'modules.globals'
|
||||
|
||||
local success, input = pcall(require, "user-input-module")
|
||||
if not success then input = nil end
|
||||
|
||||
--creates a table for the API functions
|
||||
--adds one metatable redirect to prevent addon authors from accidentally breaking file-browser
|
||||
local API = { API_VERSION = g.API_VERSION }
|
||||
|
||||
API.list = {}
|
||||
API.coroutine = {}
|
||||
|
||||
--implements table.pack if on lua 5.1
|
||||
if not table.pack then
|
||||
table.unpack = unpack
|
||||
---@diagnostic disable-next-line: duplicate-set-field
|
||||
function table.pack(...)
|
||||
local t = {n = select("#", ...), ...}
|
||||
return t
|
||||
end
|
||||
end
|
||||
|
||||
-- returns the index of the given item in the table
|
||||
-- return -1 if item does not exist
|
||||
function API.list.indexOf(t, item, from_index)
|
||||
for i = from_index or 1, #t, 1 do
|
||||
if t[i] == item then return i end
|
||||
end
|
||||
return -1
|
||||
end
|
||||
|
||||
--returns whether or not the given table contains an entry that
|
||||
--causes the given function to evaluate to true
|
||||
function API.list.some(t, fn)
|
||||
for i, v in ipairs(t) do
|
||||
if fn(v, i, t) then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
--prints an error message and a stack trace
|
||||
--accepts an error object and optionally a coroutine
|
||||
--can be passed directly to xpcall
|
||||
function API.traceback(errmsg, co)
|
||||
if co then
|
||||
msg.warn(debug.traceback(co))
|
||||
else
|
||||
msg.warn(debug.traceback("", 2))
|
||||
end
|
||||
msg.error(errmsg)
|
||||
end
|
||||
|
||||
--returns a table that stores the given table t as the __index in its metatable
|
||||
--creates a prototypally inherited table
|
||||
function API.redirect_table(t)
|
||||
return setmetatable({}, { __index = t })
|
||||
end
|
||||
|
||||
--prints an error if a coroutine returns an error
|
||||
--unlike the next function this one still returns the results of coroutine.resume()
|
||||
function API.coroutine.resume_catch(...)
|
||||
local returns = table.pack(coroutine.resume(...))
|
||||
if not returns[1] and returns[2] ~= g.ABORT_ERROR then
|
||||
API.traceback(returns[2], select(1, ...))
|
||||
end
|
||||
return table.unpack(returns, 1, returns.n)
|
||||
end
|
||||
|
||||
--resumes a coroutine and prints an error if it was not sucessful
|
||||
function API.coroutine.resume_err(...)
|
||||
local success, err = coroutine.resume(...)
|
||||
if not success and err ~= g.ABORT_ERROR then
|
||||
API.traceback(err, select(1, ...))
|
||||
end
|
||||
return success
|
||||
end
|
||||
|
||||
--in lua 5.1 there is only one return value which will be nil if run from the main thread
|
||||
--in lua 5.2 main will be true if running from the main thread
|
||||
function API.coroutine.assert(err)
|
||||
local co, main = coroutine.running()
|
||||
assert(not main and co, err or "error - function must be executed from within a coroutine")
|
||||
return co
|
||||
end
|
||||
|
||||
--creates a callback fuction to resume the current coroutine
|
||||
function API.coroutine.callback()
|
||||
local co = API.coroutine.assert("cannot create a coroutine callback for the main thread")
|
||||
return function(...)
|
||||
return API.coroutine.resume_err(co, ...)
|
||||
end
|
||||
end
|
||||
|
||||
--puts the current coroutine to sleep for the given number of seconds
|
||||
function API.coroutine.sleep(n)
|
||||
mp.add_timeout(n, API.coroutine.callback())
|
||||
coroutine.yield()
|
||||
end
|
||||
|
||||
--runs the given function in a coroutine, passing through any additional arguments
|
||||
--this is for triggering an event in a coroutine
|
||||
function API.coroutine.run(fn, ...)
|
||||
local co = coroutine.create(fn)
|
||||
API.coroutine.resume_err(co, ...)
|
||||
end
|
||||
|
||||
--get the full path for the current file
|
||||
function API.get_full_path(item, dir)
|
||||
if item.path then return item.path end
|
||||
return (dir or g.state.directory)..item.name
|
||||
end
|
||||
|
||||
--gets the path for a new subdirectory, redirects if the path field is set
|
||||
--returns the new directory path and a boolean specifying if a redirect happened
|
||||
function API.get_new_directory(item, directory)
|
||||
if item.path and item.redirect ~= false then return item.path, true end
|
||||
if directory == "" then return item.name end
|
||||
if string.sub(directory, -1) == "/" then return directory..item.name end
|
||||
return directory.."/"..item.name
|
||||
end
|
||||
|
||||
--returns the file extension of the given file
|
||||
function API.get_extension(filename, def)
|
||||
return string.lower(filename):match("%.([^%./]+)$") or def
|
||||
end
|
||||
|
||||
--returns the protocol scheme of the given url, or nil if there is none
|
||||
function API.get_protocol(filename, def)
|
||||
return string.lower(filename):match("^(%a[%w+-.]*)://") or def
|
||||
end
|
||||
|
||||
--formats strings for ass handling
|
||||
--this function is based on a similar function from https://github.com/mpv-player/mpv/blob/master/player/lua/console.lua#L110
|
||||
function API.ass_escape(str, replace_newline)
|
||||
if replace_newline == true then replace_newline = "\\\239\187\191n" end
|
||||
|
||||
--escape the invalid single characters
|
||||
str = string.gsub(str, '[\\{}\n]', {
|
||||
-- There is no escape for '\' in ASS (I think?) but '\' is used verbatim if
|
||||
-- it isn't followed by a recognised character, so add a zero-width
|
||||
-- non-breaking space
|
||||
['\\'] = '\\\239\187\191',
|
||||
['{'] = '\\{',
|
||||
['}'] = '\\}',
|
||||
-- Precede newlines with a ZWNBSP to prevent ASS's weird collapsing of
|
||||
-- consecutive newlines
|
||||
['\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')
|
||||
|
||||
if replace_newline then
|
||||
str = str:gsub("\\N", replace_newline)
|
||||
end
|
||||
return str
|
||||
end
|
||||
|
||||
--escape lua pattern characters
|
||||
function API.pattern_escape(str)
|
||||
return string.gsub(str, "([%^%$%(%)%%%.%[%]%*%+%-])", "%%%1")
|
||||
end
|
||||
|
||||
--standardises filepaths across systems
|
||||
function API.fix_path(str, is_directory)
|
||||
str = string.gsub(str, [[\]],[[/]])
|
||||
str = str:gsub([[/%./]], [[/]])
|
||||
if is_directory and str:sub(-1) ~= '/' then str = str..'/' end
|
||||
return str
|
||||
end
|
||||
|
||||
--wrapper for utils.join_path to handle protocols
|
||||
function API.join_path(working, relative)
|
||||
return API.get_protocol(relative) and relative or utils.join_path(working, relative)
|
||||
end
|
||||
|
||||
--sorts the table lexicographically ignoring case and accounting for leading/non-leading zeroes
|
||||
--the number format functionality was proposed by github user twophyro, and was presumably taken
|
||||
--from here: http://notebook.kulchenko.com/algorithms/alphanumeric-natural-sorting-for-humans-in-lua
|
||||
function API.sort(t)
|
||||
local function padnum(n, d)
|
||||
return #d > 0 and ("%03d%s%.12f"):format(#n, n, tonumber(d) / (10 ^ #d))
|
||||
or ("%03d%s"):format(#n, n)
|
||||
end
|
||||
|
||||
--appends the letter d or f to the start of the comparison to sort directories and folders as well
|
||||
local tuples = {}
|
||||
for i, f in ipairs(t) do
|
||||
tuples[i] = {f.type:sub(1, 1) .. (f.label or f.name):lower():gsub("0*(%d+)%.?(%d*)", padnum), f}
|
||||
end
|
||||
table.sort(tuples, function(a, b)
|
||||
return a[1] == b[1] and #b[2] < #a[2] or a[1] < b[1]
|
||||
end)
|
||||
for i, tuple in ipairs(tuples) do t[i] = tuple[2] end
|
||||
return t
|
||||
end
|
||||
|
||||
function API.valid_dir(dir)
|
||||
if o.filter_dot_dirs and string.sub(dir, 1, 1) == "." then return false end
|
||||
return true
|
||||
end
|
||||
|
||||
function API.valid_file(file)
|
||||
if o.filter_dot_files and (string.sub(file, 1, 1) == ".") then return false end
|
||||
if o.filter_files and not g.extensions[ API.get_extension(file, "") ] then return false end
|
||||
return true
|
||||
end
|
||||
|
||||
--returns whether or not the item can be parsed
|
||||
function API.parseable_item(item)
|
||||
return item.type == "dir" or g.parseable_extensions[API.get_extension(item.name, "")]
|
||||
end
|
||||
|
||||
--removes items and folders from the list
|
||||
--this is for addons which can't filter things during their normal processing
|
||||
function API.filter(t)
|
||||
local max = #t
|
||||
local top = 1
|
||||
for i = 1, max do
|
||||
local temp = t[i]
|
||||
t[i] = nil
|
||||
|
||||
if ( temp.type == "dir" and API.valid_dir(temp.label or temp.name) ) or
|
||||
( temp.type == "file" and API.valid_file(temp.label or temp.name) )
|
||||
then
|
||||
t[top] = temp
|
||||
top = top+1
|
||||
end
|
||||
end
|
||||
return t
|
||||
end
|
||||
|
||||
--returns a string iterator that uses the root separators
|
||||
function API.iterate_opt(str)
|
||||
return string.gmatch(str, "([^"..API.pattern_escape(o.root_separators).."]+)")
|
||||
end
|
||||
|
||||
--sorts a table into an array of selected items in the correct order
|
||||
--if a predicate function is passed, then the item will only be added to
|
||||
--the table if the function returns true
|
||||
function API.sort_keys(t, include_item)
|
||||
local keys = {}
|
||||
for k in pairs(t) do
|
||||
local item = g.state.list[k]
|
||||
if not include_item or include_item(item) then
|
||||
item.index = k
|
||||
keys[#keys+1] = item
|
||||
end
|
||||
end
|
||||
|
||||
table.sort(keys, function(a,b) return a.index < b.index end)
|
||||
return keys
|
||||
end
|
||||
|
||||
--Uses a loop to get the length of an array. The `#` operator is undefined if there
|
||||
--are gaps in the array, this ensures there are none as expected by the mpv node function.
|
||||
local function get_length(t)
|
||||
local i = 1
|
||||
while t[i] do i = i+1 end
|
||||
return i - 1
|
||||
end
|
||||
|
||||
--recursively removes elements of the table which would cause
|
||||
--utils.format_json to throw an error
|
||||
local function json_safe_recursive(t)
|
||||
if type(t) ~= "table" then return t end
|
||||
|
||||
local array_length = get_length(t)
|
||||
local isarray = array_length > 0
|
||||
|
||||
for key, value in pairs(t) do
|
||||
local ktype = type(key)
|
||||
local vtype = type(value)
|
||||
|
||||
if vtype ~= "userdata" and vtype ~= "function" and vtype ~= "thread"
|
||||
and (( isarray and ktype == "number" and key <= array_length)
|
||||
or (not isarray and ktype == "string"))
|
||||
then
|
||||
t[key] = json_safe_recursive(t[key])
|
||||
elseif key then
|
||||
t[key] = nil
|
||||
if isarray then array_length = get_length(t) end
|
||||
end
|
||||
end
|
||||
return t
|
||||
end
|
||||
|
||||
--formats a table into a json string but ensures there are no invalid datatypes inside the table first
|
||||
function API.format_json_safe(t)
|
||||
--operate on a copy of the table to prevent any data loss in the original table
|
||||
t = json_safe_recursive(API.copy_table(t))
|
||||
local success, result, err = pcall(utils.format_json, t)
|
||||
if success then return result, err
|
||||
else return nil, result end
|
||||
end
|
||||
|
||||
--evaluates and runs the given string in both Lua 5.1 and 5.2
|
||||
--the name argument is used for error reporting
|
||||
--provides the mpv modules and the fb module to the string
|
||||
function API.evaluate_string(str, name)
|
||||
local env = API.redirect_table(_G)
|
||||
env.mp = API.redirect_table(mp)
|
||||
env.msg = API.redirect_table(msg)
|
||||
env.utils = API.redirect_table(utils)
|
||||
env.fb = API.redirect_table(API)
|
||||
env.input = input and API.redirect_table(input)
|
||||
|
||||
local chunk, err
|
||||
if setfenv then
|
||||
chunk, err = loadstring(str, name)
|
||||
if chunk then setfenv(chunk, env) end
|
||||
else
|
||||
chunk, err = load(str, name, 't', env)
|
||||
end
|
||||
if not chunk then
|
||||
msg.warn('failed to load string:', str)
|
||||
msg.error(err)
|
||||
chunk = function() return nil end
|
||||
end
|
||||
|
||||
return chunk()
|
||||
end
|
||||
|
||||
--copies a table without leaving any references to the original
|
||||
--uses a structured clone algorithm to maintain cyclic references
|
||||
local function copy_table_recursive(t, references, depth)
|
||||
if type(t) ~= "table" or depth == 0 then return t end
|
||||
if references[t] then return references[t] end
|
||||
|
||||
local copy = setmetatable({}, { __original = t })
|
||||
references[t] = copy
|
||||
|
||||
for key, value in pairs(t) do
|
||||
key = copy_table_recursive(key, references, depth - 1)
|
||||
copy[key] = copy_table_recursive(value, references, depth - 1)
|
||||
end
|
||||
return copy
|
||||
end
|
||||
|
||||
--a wrapper around copy_table to provide the reference table
|
||||
function API.copy_table(t, depth)
|
||||
--this is to handle cyclic table references
|
||||
return copy_table_recursive(t, {}, depth or math.huge)
|
||||
end
|
||||
|
||||
--format the item string for either single or multiple items
|
||||
local function create_item_string(base_code_fn, items, state, cmd, quoted)
|
||||
if not items[1] then return end
|
||||
local func = quoted and function(...) return ("%q"):format(base_code_fn(...)) end or base_code_fn
|
||||
|
||||
local out = {}
|
||||
for _, item in ipairs(items) do
|
||||
table.insert(out, func(item, state))
|
||||
end
|
||||
|
||||
return table.concat(out, cmd['concat-string'] or ' ')
|
||||
end
|
||||
|
||||
--functions to replace custom-keybind codes
|
||||
API.code_fns = {
|
||||
["%"] = "%",
|
||||
|
||||
f = function(item, s) return item and API.get_full_path(item, s.directory) or "" end,
|
||||
n = function(item, s) return item and (item.label or item.name) or "" end,
|
||||
i = function(item, s) local i = API.list.indexOf(s.list, item) ; return i ~= -1 and i or 0 end,
|
||||
j = function (item, s) return API.list.indexOf(s.list, item) ~= -1 and math.abs(API.list.indexOf( API.sort_keys(s.selection) , item)) or 0 end,
|
||||
|
||||
x = function(_, s) return #s.list or 0 end,
|
||||
p = function(_, s) return s.directory or "" end,
|
||||
q = function(_, s) return s.directory == '' and 'ROOT' or s.directory_label or s.directory or "" end,
|
||||
d = function(_, s) return (s.directory_label or s.directory):match("([^/]+)/?$") or "" end,
|
||||
r = function(_, s) return s.parser.keybind_name or s.parser.name or "" end,
|
||||
}
|
||||
|
||||
-- programatically creates a pattern that matches any key code
|
||||
-- this will result in some duplicates but that shouldn't really matter
|
||||
function API.get_code_pattern(codes)
|
||||
local CUSTOM_KEYBIND_CODES = ""
|
||||
for key in pairs(codes) do CUSTOM_KEYBIND_CODES = CUSTOM_KEYBIND_CODES..key:lower()..key:upper() end
|
||||
for key in pairs((getmetatable(codes) or {}).__index or {}) do CUSTOM_KEYBIND_CODES = CUSTOM_KEYBIND_CODES..key:lower()..key:upper() end
|
||||
return('%%%%([%s])'):format(API.pattern_escape(CUSTOM_KEYBIND_CODES))
|
||||
end
|
||||
|
||||
-- substitutes codes in the given string for other substrings
|
||||
-- overrides is a map of characters->strings|functions that determines the replacement string is
|
||||
-- item and state are values passed to functions in the map
|
||||
-- modifier_fn is given the replacement substrings before they are placed in the main string (the return value is the new replacement string)
|
||||
function API.substitute_codes(str, overrides, item, state, modifier_fn)
|
||||
local replacers = overrides and setmetatable(API.copy_table(overrides), {__index = API.code_fns}) or API.code_fns
|
||||
item = item or g.state.list[g.state.selected]
|
||||
state = state or g.state
|
||||
|
||||
return (string.gsub(str, API.get_code_pattern(replacers), function(code)
|
||||
local result
|
||||
|
||||
if type(replacers[code]) == "string" then
|
||||
result = replacers[code]
|
||||
--encapsulates the string if using an uppercase code
|
||||
elseif not replacers[code] then
|
||||
local lower_fn = replacers[code:lower()]
|
||||
if not lower_fn then return end
|
||||
result = string.format("%q", lower_fn(item, state))
|
||||
else
|
||||
result = replacers[code](item, state)
|
||||
end
|
||||
|
||||
if modifier_fn then return modifier_fn(result) end
|
||||
return result
|
||||
end))
|
||||
end
|
||||
|
||||
|
||||
return API
|
Binary file not shown.
After Width: | Height: | Size: 2.8 MiB |
Binary file not shown.
After Width: | Height: | Size: 975 KiB |
|
@ -0,0 +1,308 @@
|
|||
--[[
|
||||
mpv-gallery-view | https://github.com/occivink/mpv-gallery-view
|
||||
|
||||
This mpv script implements a worker for generating gallery thumbnails.
|
||||
It is meant to be used by other scripts.
|
||||
Multiple copies of this script can be loaded by mpv.
|
||||
|
||||
File placement: inside scripts directory
|
||||
Settings: script-opts/gallery_worker.conf
|
||||
]]
|
||||
|
||||
local utils = require 'mp.utils'
|
||||
local msg = require 'mp.msg'
|
||||
|
||||
local jobs_queue = {} -- queue of thumbnail jobs
|
||||
local failed = {} -- list of failed output paths, to avoid redoing them
|
||||
local script_id = mp.get_script_name() .. utils.getpid()
|
||||
|
||||
local opts = {
|
||||
ytdl_exclude = "",
|
||||
}
|
||||
(require 'mp.options').read_options(opts, "gallery_worker")
|
||||
|
||||
local ytdl = {
|
||||
path = "youtube-dl",
|
||||
searched = false,
|
||||
blacklisted = {} -- Add patterns of URLs you want blacklisted from youtube-dl,
|
||||
-- see gallery_worker.conf or ytdl_hook-exclude in the mpv manpage for more info
|
||||
}
|
||||
|
||||
function append_table(lhs, rhs)
|
||||
for i = 1,#rhs do
|
||||
lhs[#lhs+1] = rhs[i]
|
||||
end
|
||||
return lhs
|
||||
end
|
||||
|
||||
local function file_exists(path)
|
||||
local info = utils.file_info(path)
|
||||
return info ~= nil and info.is_file
|
||||
end
|
||||
|
||||
local video_extensions = { "mkv", "webm", "mp4", "avi", "wmv" }
|
||||
|
||||
function is_video(input_path)
|
||||
local extension = string.match(input_path, "%.([^.]+)$")
|
||||
if extension then
|
||||
extension = string.lower(extension)
|
||||
for _, ext in ipairs(video_extensions) do
|
||||
if extension == ext then
|
||||
return true
|
||||
end
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
function is_blacklisted(url)
|
||||
if opts.ytdl_exclude == "" then return false end
|
||||
if #ytdl.blacklisted == 0 then
|
||||
local joined = opts.ytdl_exclude
|
||||
while joined:match('%|?[^|]+') do
|
||||
local _, e, substring = joined:find('%|?([^|]+)')
|
||||
table.insert(ytdl.blacklisted, substring)
|
||||
joined = joined:sub(e+1)
|
||||
end
|
||||
end
|
||||
if #ytdl.blacklisted > 0 then
|
||||
url = url:match('https?://(.+)')
|
||||
for _, exclude in ipairs(ytdl.blacklisted) do
|
||||
if url:match(exclude) then
|
||||
msg.verbose('URL matches excluded substring. Skipping.')
|
||||
return true
|
||||
end
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
|
||||
function ytdl_thumbnail_url(input_path)
|
||||
local function exec(args)
|
||||
local ret = utils.subprocess({args = args, cancellable=false})
|
||||
return ret.status, ret.stdout, ret
|
||||
end
|
||||
local function first_non_nil(x, ...)
|
||||
if x ~= nil then return x end
|
||||
return first_non_nil(...)
|
||||
end
|
||||
|
||||
-- if input_path is youtube, generate our own URL
|
||||
youtube_id1 = string.match(input_path, "https?://youtu%.be/([%a%d%-_]+).*")
|
||||
youtube_id2 = string.match(input_path, "https?://w?w?w?%.?youtube%.com/v/([%a%d%-_]+).*")
|
||||
youtube_id3 = string.match(input_path, "https?://w?w?w?%.?youtube%.com/watch%?v=([%a%d%-_]+).*")
|
||||
youtube_id4 = string.match(input_path, "https?://w?w?w?%.?youtube%.com/embed/([%a%d%-_]+).*")
|
||||
youtube_id = youtube_id1 or youtube_id2 or youtube_id3 or youtube_id4
|
||||
|
||||
if youtube_id then
|
||||
-- the hqdefault.jpg thumbnail should always exist, since it's used on the search result page
|
||||
return "https://i.ytimg.com/vi/" .. youtube_id .. "/hqdefault.jpg"
|
||||
end
|
||||
|
||||
--otherwise proceed with the slower `youtube-dl -J` method
|
||||
if not (ytdl.searched) then --search for youtude-dl in mpv's config directory
|
||||
local exesuf = (package.config:sub(1,1) == '\\') and '.exe' or ''
|
||||
local ytdl_mcd = mp.find_config_file("youtube-dl")
|
||||
if not (ytdl_mcd == nil) then
|
||||
msg.error("found youtube-dl at: " .. ytdl_mcd)
|
||||
ytdl.path = ytdl_mcd
|
||||
end
|
||||
ytdl.searched = true
|
||||
end
|
||||
local command = {ytdl.path, "--no-warnings", "--no-playlist", "-J", input_path}
|
||||
local es, json, result = exec(command)
|
||||
|
||||
if (es < 0) or (json == nil) or (json == "") then
|
||||
msg.error("fetching thumbnail url with youtube-dl failed for" .. input_path)
|
||||
return input_path
|
||||
end
|
||||
local json, err = utils.parse_json(json)
|
||||
if (json == nil) then
|
||||
msg.error("failed to parse json for youtube-dl thumbnail: " .. err)
|
||||
return input_path
|
||||
end
|
||||
|
||||
if (json.thumbnail == nil) or (json.thumbnail == "") then
|
||||
msg.error("no thumbnail url from youtube-dl.")
|
||||
return input_path
|
||||
end
|
||||
return json.thumbnail
|
||||
end
|
||||
|
||||
function thumbnail_command(input_path, width, height, take_thumbnail_at, output_path, accurate, with_mpv)
|
||||
local vf = string.format("%s,%s",
|
||||
string.format("scale=iw*min(1\\,min(%d/iw\\,%d/ih)):-2", width, height),
|
||||
string.format("pad=%d:%d:(%d-iw)/2:(%d-ih)/2:color=0x00000000", width, height, width, height)
|
||||
)
|
||||
local out = {}
|
||||
local add = function(table) out = append_table(out, table) end
|
||||
|
||||
|
||||
if input_path:find("^https?://") and not is_blacklisted(input_path) then
|
||||
-- returns the original input_path on failure
|
||||
input_path = ytdl_thumbnail_url(input_path)
|
||||
end
|
||||
|
||||
if input_path:find("^archive://") or input_path:find("^edl://") then
|
||||
with_mpv = true
|
||||
end
|
||||
|
||||
|
||||
if not with_mpv then
|
||||
out = { "ffmpeg" }
|
||||
if is_video(input_path) then
|
||||
if string.sub(take_thumbnail_at, -1) == "%" then
|
||||
--if only fucking ffmpeg supported percent-style seeking
|
||||
local res = utils.subprocess({ args = {
|
||||
"ffprobe", "-v", "error",
|
||||
"-show_entries", "format=duration", "-of",
|
||||
"default=noprint_wrappers=1:nokey=1", input_path
|
||||
}, cancellable = false })
|
||||
if res.status == 0 then
|
||||
local duration = tonumber(string.match(res.stdout, "^%s*(.-)%s*$"))
|
||||
if duration then
|
||||
local percent = tonumber(string.sub(take_thumbnail_at, 1, -2))
|
||||
local start = tostring(duration * percent / 100)
|
||||
add({ "-ss", start })
|
||||
end
|
||||
end
|
||||
else
|
||||
add({ "-ss", take_thumbnail_at })
|
||||
end
|
||||
end
|
||||
if not accurate then
|
||||
add({"-noaccurate_seek"})
|
||||
end
|
||||
add({
|
||||
"-i", input_path,
|
||||
"-vf", vf,
|
||||
"-map", "v:0",
|
||||
"-f", "rawvideo",
|
||||
"-pix_fmt", "bgra",
|
||||
"-c:v", "rawvideo",
|
||||
"-frames:v", "1",
|
||||
"-y", "-loglevel", "quiet",
|
||||
output_path
|
||||
})
|
||||
else
|
||||
out = { "mpv", input_path }
|
||||
if take_thumbnail_at ~= "0" and is_video(input_path) then
|
||||
if not accurate then
|
||||
add({ "--hr-seek=no"})
|
||||
end
|
||||
add({ "--start="..take_thumbnail_at })
|
||||
end
|
||||
add({
|
||||
"--no-config", "--msg-level=all=no",
|
||||
"--vf=lavfi=[" .. vf .. ",format=bgra]",
|
||||
"--audio=no",
|
||||
"--sub=no",
|
||||
"--frames=1",
|
||||
"--image-display-duration=0",
|
||||
"--of=rawvideo", "--ovc=rawvideo",
|
||||
"--o="..output_path
|
||||
})
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
function generate_thumbnail(thumbnail_job)
|
||||
if file_exists(thumbnail_job.output_path) then return true end
|
||||
|
||||
local dir, _ = utils.split_path(thumbnail_job.output_path)
|
||||
local tmp_output_path = utils.join_path(dir, script_id)
|
||||
|
||||
local command = thumbnail_command(
|
||||
thumbnail_job.input_path,
|
||||
thumbnail_job.width,
|
||||
thumbnail_job.height,
|
||||
thumbnail_job.take_thumbnail_at,
|
||||
tmp_output_path,
|
||||
thumbnail_job.accurate,
|
||||
thumbnail_job.with_mpv
|
||||
)
|
||||
|
||||
local res = utils.subprocess({ args = command, cancellable = false })
|
||||
--"atomically" generate the output to avoid loading half-generated thumbnails (results in crashes)
|
||||
if res.status == 0 then
|
||||
local info = utils.file_info(tmp_output_path)
|
||||
if not info or not info.is_file or info.size == 0 then
|
||||
return false
|
||||
end
|
||||
if os.rename(tmp_output_path, thumbnail_job.output_path) then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
function handle_events(wait)
|
||||
e = mp.wait_event(wait)
|
||||
while e.event ~= "none" do
|
||||
if e.event == "shutdown" then
|
||||
return false
|
||||
elseif e.event == "client-message" then
|
||||
if e.args[1] == "push-thumbnail-front" or e.args[1] == "push-thumbnail-back" then
|
||||
local thumbnail_job = {
|
||||
requester = e.args[2],
|
||||
input_path = e.args[3],
|
||||
width = tonumber(e.args[4]),
|
||||
height = tonumber(e.args[5]),
|
||||
take_thumbnail_at = e.args[6],
|
||||
output_path = e.args[7],
|
||||
accurate = (e.args[8] == "true"),
|
||||
with_mpv = (e.args[9] == "true"),
|
||||
}
|
||||
if e.args[1] == "push-thumbnail-front" then
|
||||
jobs_queue[#jobs_queue + 1] = thumbnail_job
|
||||
else
|
||||
table.insert(jobs_queue, 1, thumbnail_job)
|
||||
end
|
||||
end
|
||||
end
|
||||
e = mp.wait_event(0)
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
local registration_timeout = 2 -- seconds
|
||||
local registration_period = 0.2
|
||||
|
||||
-- shitty custom event loop because I can't figure out a better way
|
||||
-- works pretty well though
|
||||
function mp_event_loop()
|
||||
local start_time = mp.get_time()
|
||||
local sleep_time = registration_period
|
||||
local last_broadcast_time = -registration_period
|
||||
local broadcast_func
|
||||
broadcast_func = function()
|
||||
local now = mp.get_time()
|
||||
if now >= start_time + registration_timeout then
|
||||
mp.commandv("script-message", "thumbnails-generator-broadcast", mp.get_script_name())
|
||||
sleep_time = 1e20
|
||||
broadcast_func = function() end
|
||||
elseif now >= last_broadcast_time + registration_period then
|
||||
mp.commandv("script-message", "thumbnails-generator-broadcast", mp.get_script_name())
|
||||
last_broadcast_time = now
|
||||
end
|
||||
end
|
||||
|
||||
while true do
|
||||
if not handle_events(sleep_time) then return end
|
||||
broadcast_func()
|
||||
while #jobs_queue > 0 do
|
||||
local thumbnail_job = jobs_queue[#jobs_queue]
|
||||
if not failed[thumbnail_job.output_path] then
|
||||
if generate_thumbnail(thumbnail_job) then
|
||||
mp.commandv("script-message-to", thumbnail_job.requester, "thumbnail-generated", thumbnail_job.output_path)
|
||||
else
|
||||
failed[thumbnail_job.output_path] = true
|
||||
end
|
||||
end
|
||||
jobs_queue[#jobs_queue] = nil
|
||||
if not handle_events(0) then return end
|
||||
broadcast_func()
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,618 @@
|
|||
--[[
|
||||
mpv-gallery-view | https://github.com/occivink/mpv-gallery-view
|
||||
|
||||
This mpv script generates and displays an overview of the current playlist with thumbnails.
|
||||
|
||||
File placement: scripts/playlist-view.lua
|
||||
Settings: script-opts/playlist_view.conf
|
||||
Requires: script-modules/gallery-module.lua
|
||||
Default keybinding: g script-binding playlist-view-toggle
|
||||
]]
|
||||
|
||||
local utils = require 'mp.utils'
|
||||
local msg = require 'mp.msg'
|
||||
local options = require 'mp.options'
|
||||
|
||||
package.path = mp.command_native({ "expand-path", "~~/script-modules/?.lua;" }) .. package.path
|
||||
require 'gallery'
|
||||
|
||||
ON_WINDOWS = (package.config:sub(1,1) ~= "/")
|
||||
|
||||
-- global variables
|
||||
|
||||
flags = {}
|
||||
resume = {}
|
||||
did_pause = false
|
||||
hash_cache = {}
|
||||
playlist_pos = 0
|
||||
|
||||
bindings = {}
|
||||
bindings_repeat = {}
|
||||
|
||||
compute_geometry = function(ww, wh) end
|
||||
|
||||
ass_changed = false
|
||||
ass = ""
|
||||
geometry_changed = false
|
||||
pending_selection = nil
|
||||
|
||||
thumb_dir = ""
|
||||
|
||||
gallery = gallery_new()
|
||||
gallery.config.always_show_placeholders = true
|
||||
gallery.config.accurate = false
|
||||
|
||||
opts = {
|
||||
thumbs_dir = ON_WINDOWS and "%APPDATA%\\mpv\\gallery-thumbs-dir" or "~/.cache/thumbnails/mpv-gallery/",
|
||||
generate_thumbnails_with_mpv = ON_WINDOWS,
|
||||
mkdir_thumbs = true,
|
||||
|
||||
gallery_position = "{ (ww - gw) / 2, (wh - gh) / 2}",
|
||||
gallery_size = "{ 9 * ww / 10, 9 * wh / 10 }",
|
||||
min_spacing = "{ 15, 15 }",
|
||||
thumbnail_size = "(ww * wh <= 1366 * 768) and {192, 108} or {288, 162}",
|
||||
max_thumbnails = 64,
|
||||
|
||||
take_thumbnail_at = "20%",
|
||||
|
||||
load_file_on_toggle_off = false,
|
||||
close_on_load_file = true,
|
||||
pause_on_start = true,
|
||||
resume_on_stop = "only-if-did-pause",
|
||||
follow_playlist_position = false,
|
||||
remember_time_position = true,
|
||||
|
||||
start_on_mpv_startup = false,
|
||||
start_on_file_end = true,
|
||||
|
||||
show_text = true,
|
||||
show_title = true,
|
||||
strip_directory = true,
|
||||
strip_extension = true,
|
||||
text_size = 28,
|
||||
|
||||
background_color = "333333",
|
||||
background_opacity = "33",
|
||||
normal_border_color = "BBBBBB",
|
||||
normal_border_size = 1,
|
||||
selected_border_color = "E5E4E5",
|
||||
selected_border_size = 6,
|
||||
highlight_active = true,
|
||||
active_border_color = "EBC5A7",
|
||||
active_border_size = 4,
|
||||
flagged_border_color = "96B58D",
|
||||
flagged_border_size = 4,
|
||||
placeholder_color = "222222",
|
||||
|
||||
command_on_open = "",
|
||||
command_on_close = "",
|
||||
|
||||
flagged_file_path = "./mpv_gallery_flagged",
|
||||
|
||||
mouse_support = true,
|
||||
UP = "UP",
|
||||
DOWN = "DOWN",
|
||||
LEFT = "LEFT",
|
||||
RIGHT = "RIGHT",
|
||||
PAGE_UP = "PGUP",
|
||||
PAGE_DOWN = "PGDWN",
|
||||
FIRST = "HOME",
|
||||
LAST = "END",
|
||||
RANDOM = "r",
|
||||
ACCEPT = "ENTER",
|
||||
CANCEL = "ESC",
|
||||
REMOVE = "DEL",
|
||||
FLAG = "SPACE",
|
||||
}
|
||||
function reload_config()
|
||||
gallery.config.background_color = opts.background_color
|
||||
gallery.config.background_opacity = opts.background_opacity
|
||||
gallery.config.max_thumbnails = math.min(opts.max_thumbnails, 64)
|
||||
gallery.config.placeholder_color = opts.placeholder_color
|
||||
gallery.config.text_size = opts.text_size
|
||||
gallery.config.generate_thumbnails_with_mpv = opts.generate_thumbnails_with_mpv
|
||||
if ON_WINDOWS then
|
||||
thumbs_dir = string.gsub(opts.thumbs_dir, "^%%APPDATA%%", os.getenv("APPDATA") or "%APPDATA%")
|
||||
else
|
||||
thumbs_dir = string.gsub(opts.thumbs_dir, "^~", os.getenv("HOME") or "~")
|
||||
end
|
||||
local res = utils.file_info(thumbs_dir)
|
||||
if not res or not res.is_dir then
|
||||
if opts.mkdir_thumbs then
|
||||
local args = ON_WINDOWS and { "mkdir", thumbs_dir } or { "mkdir", "-p", thumbs_dir }
|
||||
utils.subprocess({ args = args, playback_only = false })
|
||||
else
|
||||
msg.error(string.format("Thumbnail directory \"%s\" does not exist", thumbs_dir))
|
||||
end
|
||||
end
|
||||
|
||||
compute_geometry = get_geometry_function()
|
||||
reload_bindings()
|
||||
if gallery.active then
|
||||
local ww, wh = mp.get_osd_size()
|
||||
compute_geometry(ww, wh)
|
||||
gallery:ass_refresh(true, true, true, true)
|
||||
end
|
||||
end
|
||||
options.read_options(opts, mp.get_script_name(), reload_config)
|
||||
|
||||
|
||||
|
||||
local sha256
|
||||
--[[
|
||||
minified code below is a combination of:
|
||||
-sha256 implementation from
|
||||
http://lua-users.org/wiki/SecureHashAlgorithm
|
||||
-lua implementation of bit32 (used as fallback on lua5.1) from
|
||||
https://www.snpedia.com/extensions/Scribunto/engines/LuaCommon/lualib/bit32.lua
|
||||
both are licensed under the MIT below:
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
--]]
|
||||
do local b,c,d,e,f;if bit32 then b,c,d,e,f=bit32.band,bit32.rrotate,bit32.bxor,bit32.rshift,bit32.bnot else f=function(g)g=math.floor(tonumber(g))%0x100000000;return(-g-1)%0x100000000 end;local h={[0]={[0]=0,0,0,0},[1]={[0]=0,1,0,1},[2]={[0]=0,0,2,2},[3]={[0]=0,1,2,3}}local i={[0]={[0]=0,1,2,3},[1]={[0]=1,0,3,2},[2]={[0]=2,3,0,1},[3]={[0]=3,2,1,0}}local function j(k,l,m,n,o)for p=1,m do l[p]=math.floor(tonumber(l[p]))%0x100000000 end;local q=1;local r=0;for s=0,31,2 do local t=n;for p=1,m do t=o[t][l[p]%4]l[p]=math.floor(l[p]/4)end;r=r+t*q;q=q*4 end;return r end;b=function(...)return j('band',{...},select('#',...),3,h)end;d=function(...)return j('bxor',{...},select('#',...),0,i)end;e=function(g,u)g=math.floor(tonumber(g))%0x100000000;u=math.floor(tonumber(u))u=math.min(math.max(-32,u),32)return math.floor(g/2^u)%0x100000000 end;c=function(g,u)g=math.floor(tonumber(g))%0x100000000;u=-math.floor(tonumber(u))%32;local g=g*2^u;return g%0x100000000+math.floor(g/0x100000000)end end;local v={0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2}local function w(n)return string.gsub(n,".",function(t)return string.format("%02x",string.byte(t))end)end;local function x(y,z)local n=""for p=1,z do local A=y%256;n=string.char(A)..n;y=(y-A)/256 end;return n end;local function B(n,p)local z=0;for p=p,p+3 do z=z*256+string.byte(n,p)end;return z end;local function C(D,E)local F=-(E+1+8)%64;E=x(8*E,8)D=D.."\128"..string.rep("\0",F)..E;return D end;local function G(H)H[1]=0x6a09e667;H[2]=0xbb67ae85;H[3]=0x3c6ef372;H[4]=0xa54ff53a;H[5]=0x510e527f;H[6]=0x9b05688c;H[7]=0x1f83d9ab;H[8]=0x5be0cd19;return H end;local function I(D,p,H)local J={}for K=1,16 do J[K]=B(D,p+(K-1)*4)end;for K=17,64 do local L=J[K-15]local M=d(c(L,7),c(L,18),e(L,3))L=J[K-2]local N=d(c(L,17),c(L,19),e(L,10))J[K]=J[K-16]+M+J[K-7]+N end;local O,s,t,P,Q,R,S,T=H[1],H[2],H[3],H[4],H[5],H[6],H[7],H[8]for p=1,64 do local M=d(c(O,2),c(O,13),c(O,22))local U=d(b(O,s),b(O,t),b(s,t))local V=M+U;local N=d(c(Q,6),c(Q,11),c(Q,25))local W=d(b(Q,R),b(f(Q),S))local X=T+N+W+v[p]+J[p]T=S;S=R;R=Q;Q=P+X;P=t;t=s;s=O;O=X+V end;H[1]=b(H[1]+O)H[2]=b(H[2]+s)H[3]=b(H[3]+t)H[4]=b(H[4]+P)H[5]=b(H[5]+Q)H[6]=b(H[6]+R)H[7]=b(H[7]+S)H[8]=b(H[8]+T)end;local function Y(H)return w(x(H[1],4)..x(H[2],4)..x(H[3],4)..x(H[4],4)..x(H[5],4)..x(H[6],4)..x(H[7],4)..x(H[8],4))end;local Z={}sha256=function(D)D=C(D,#D)local H=G(Z)for p=1,#D,64 do I(D,p,H)end;return Y(H)end end
|
||||
-- end of sha code
|
||||
|
||||
gallery.ass_show = function(new_ass)
|
||||
ass_changed = true
|
||||
ass = new_ass
|
||||
end
|
||||
gallery.item_to_overlay_path = function(index, item)
|
||||
local filename = item.filename
|
||||
local filename_hash = hash_cache[filename]
|
||||
if filename_hash == nil then
|
||||
filename_hash = string.sub(sha256(normalize_path(filename)), 1, 12)
|
||||
hash_cache[filename] = filename_hash
|
||||
end
|
||||
local thumb_filename = string.format("%s_%d_%d_%s", filename_hash, gallery.geometry.thumbnail_size[1], gallery.geometry.thumbnail_size[2], string.gsub(opts.take_thumbnail_at, '%%', 'p'))
|
||||
return utils.join_path(thumbs_dir, thumb_filename)
|
||||
end
|
||||
gallery.item_to_thumbnail_params = function(index, item)
|
||||
return item.filename, opts.take_thumbnail_at
|
||||
end
|
||||
function blend_colors(colors)
|
||||
if #colors == 1 then return colors[1] end
|
||||
local comp1 = 0
|
||||
local comp2 = 0
|
||||
local comp3 = 0
|
||||
for _, val in ipairs(colors) do
|
||||
comp1 = comp1 + tonumber(string.sub(val, 1, 2), 16)
|
||||
comp2 = comp2 + tonumber(string.sub(val, 3, 4), 16)
|
||||
comp3 = comp3 + tonumber(string.sub(val, 5, 6), 16)
|
||||
end
|
||||
return string.format("%02x%02x%02x", comp1 / #colors, comp2 / #colors, comp3 / #colors)
|
||||
end
|
||||
gallery.item_to_border = function(index, item)
|
||||
local size = 0
|
||||
colors = {}
|
||||
if flags[item.filename] then
|
||||
colors[#colors + 1] = opts.flagged_border_color
|
||||
size = math.max(size, opts.flagged_border_size)
|
||||
end
|
||||
if index == gallery.selection then
|
||||
colors[#colors + 1] = opts.selected_border_color
|
||||
size = math.max(size, opts.selected_border_size)
|
||||
end
|
||||
if opts.highlight_active and index == playlist_pos then
|
||||
colors[#colors + 1] = opts.active_border_color
|
||||
size = math.max(size, opts.active_border_size)
|
||||
end
|
||||
if #colors == 0 then
|
||||
return opts.normal_border_size, opts.normal_border_color
|
||||
else
|
||||
return size, blend_colors(colors)
|
||||
end
|
||||
end
|
||||
gallery.item_to_text = function(index, item)
|
||||
if not opts.show_text or index ~= gallery.selection then return "", false end
|
||||
local f
|
||||
if opts.show_title and item.title then
|
||||
f = item.title
|
||||
else
|
||||
f = item.filename
|
||||
if opts.strip_directory then
|
||||
if ON_WINDOWS then
|
||||
f = string.match(f, "([^\\/]+)$") or f
|
||||
else
|
||||
f = string.match(f, "([^/]+)$") or f
|
||||
end
|
||||
end
|
||||
if opts.strip_extension then
|
||||
f = string.match(f, "(.+)%.[^.]+$") or f
|
||||
end
|
||||
end
|
||||
return f, true
|
||||
end
|
||||
|
||||
|
||||
function setup_ui_handlers()
|
||||
for key, func in pairs(bindings_repeat) do
|
||||
mp.add_forced_key_binding(key, "playlist-view-"..key, func, {repeatable = true})
|
||||
end
|
||||
for key, func in pairs(bindings) do
|
||||
mp.add_forced_key_binding(key, "playlist-view-"..key, func)
|
||||
end
|
||||
end
|
||||
|
||||
function teardown_ui_handlers()
|
||||
for key, _ in pairs(bindings_repeat) do
|
||||
mp.remove_key_binding("playlist-view-"..key)
|
||||
end
|
||||
for key, _ in pairs(bindings) do
|
||||
mp.remove_key_binding("playlist-view-"..key)
|
||||
end
|
||||
end
|
||||
|
||||
function reload_bindings()
|
||||
if gallery.active then
|
||||
teardown_ui_handlers()
|
||||
end
|
||||
|
||||
bindings = {}
|
||||
bindings_repeat = {}
|
||||
|
||||
local increment_func = function(increment, clamp)
|
||||
local new = (pending_selection or gallery.selection) + increment
|
||||
if new <= 0 or new > #gallery.items then
|
||||
if not clamp then return end
|
||||
new = math.max(1, math.min(new, #gallery.items))
|
||||
end
|
||||
pending_selection = new
|
||||
end
|
||||
|
||||
bindings[opts.FIRST] = function() pending_selection = 1 end
|
||||
bindings[opts.LAST] = function() pending_selection = #gallery.items end
|
||||
bindings[opts.ACCEPT] = function()
|
||||
load_selection()
|
||||
if opts.close_on_load_file then stop() end
|
||||
end
|
||||
bindings[opts.CANCEL] = function() stop() end
|
||||
bindings[opts.FLAG] = function()
|
||||
local name = gallery.items[gallery.selection].filename
|
||||
if flags[name] == nil then
|
||||
flags[name] = true
|
||||
else
|
||||
flags[name] = nil
|
||||
end
|
||||
gallery:ass_refresh(true, false, false, false)
|
||||
end
|
||||
if opts.mouse_support then
|
||||
bindings["MBTN_LEFT"] = function()
|
||||
local index = gallery:index_at(mp.get_mouse_pos())
|
||||
if not index then return end
|
||||
if index == gallery.selection then
|
||||
load_selection()
|
||||
if opts.close_on_load_file then stop() end
|
||||
else
|
||||
pending_selection= index
|
||||
end
|
||||
end
|
||||
bindings["WHEEL_UP"] = function() increment_func(- gallery.geometry.columns, false) end
|
||||
bindings["WHEEL_DOWN"] = function() increment_func( gallery.geometry.columns, false) end
|
||||
end
|
||||
|
||||
bindings_repeat[opts.UP] = function() increment_func(- gallery.geometry.columns, false) end
|
||||
bindings_repeat[opts.DOWN] = function() increment_func( gallery.geometry.columns, false) end
|
||||
bindings_repeat[opts.LEFT] = function() increment_func(- 1, false) end
|
||||
bindings_repeat[opts.RIGHT] = function() increment_func( 1, false) end
|
||||
bindings_repeat[opts.PAGE_UP] = function() increment_func(- gallery.geometry.columns * gallery.geometry.rows, true) end
|
||||
bindings_repeat[opts.PAGE_DOWN] = function() increment_func( gallery.geometry.columns * gallery.geometry.rows, true) end
|
||||
bindings_repeat[opts.RANDOM] = function() pending_selection = math.random(1, #gallery.items) end
|
||||
bindings_repeat[opts.REMOVE] = function()
|
||||
local s = gallery.selection
|
||||
mp.commandv("playlist-remove", s - 1)
|
||||
gallery:set_selection(s + (s == #gallery.items and -1 or 1))
|
||||
end
|
||||
|
||||
if gallery.active then
|
||||
setup_ui_handlers()
|
||||
end
|
||||
end
|
||||
|
||||
function get_geometry_function()
|
||||
local geometry_functions = loadstring(string.format([[
|
||||
return {
|
||||
function(ww, wh, gx, gy, gw, gh, sw, sh, tw, th)
|
||||
return %s
|
||||
end,
|
||||
function(ww, wh, gx, gy, gw, gh, sw, sh, tw, th)
|
||||
return %s
|
||||
end,
|
||||
function(ww, wh, gx, gy, gw, gh, sw, sh, tw, th)
|
||||
return %s
|
||||
end,
|
||||
function(ww, wh, gx, gy, gw, gh, sw, sh, tw, th)
|
||||
return %s
|
||||
end
|
||||
}]], opts.gallery_position, opts.gallery_size, opts.min_spacing, opts.thumbnail_size))()
|
||||
|
||||
local names = { "gallery_position", "gallery_size", "min_spacing", "thumbnail_size" }
|
||||
local order = {} -- the order in which the 4 properties should be computed, based on inter-dependencies
|
||||
|
||||
-- build the dependency matrix
|
||||
local patterns = { "g[xy]", "g[wh]", "s[wh]", "t[wh]" }
|
||||
local deps = {}
|
||||
for i = 1,4 do
|
||||
for j = 1,4 do
|
||||
local i_depends_on_j = (string.find(opts[names[i]], patterns[j]) ~= nil)
|
||||
if i == j and i_depends_on_j then
|
||||
msg.error(names[i] .. " depends on itself")
|
||||
return
|
||||
end
|
||||
deps[i * 4 + j] = i_depends_on_j
|
||||
end
|
||||
end
|
||||
|
||||
local has_deps = function(index)
|
||||
for j = 1,4 do
|
||||
if deps[index * 4 + j] then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
local num_resolved = 0
|
||||
local resolved = { false, false, false, false }
|
||||
while true do
|
||||
local resolved_one = false
|
||||
for i = 1, 4 do
|
||||
if resolved[i] then
|
||||
-- nothing to do
|
||||
elseif not has_deps(i) then
|
||||
order[#order + 1] = i
|
||||
-- since i has no deps, anything that depends on it might as well not
|
||||
for j = 1, 4 do
|
||||
deps[j * 4 + i] = false
|
||||
end
|
||||
resolved[i] = true
|
||||
resolved_one = true
|
||||
num_resolved = num_resolved + 1
|
||||
end
|
||||
end
|
||||
if num_resolved == 4 then
|
||||
break
|
||||
elseif not resolved_one then
|
||||
local str = ""
|
||||
for index, resolved in ipairs(resolved) do
|
||||
if not resolved then
|
||||
str = (str == "" and "" or (str .. ", ")) .. names[index]
|
||||
end
|
||||
end
|
||||
msg.error("Circular dependency between " .. str)
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
return function(window_width, window_height)
|
||||
local new_geom = {
|
||||
gallery_position = {},
|
||||
gallery_size = {},
|
||||
min_spacing = {},
|
||||
thumbnail_size = {},
|
||||
}
|
||||
for _, index in ipairs(order) do
|
||||
new_geom[names[index]] = geometry_functions[index](
|
||||
window_width, window_height,
|
||||
new_geom.gallery_position[1], new_geom.gallery_position[2],
|
||||
new_geom.gallery_size[1], new_geom.gallery_size[2],
|
||||
new_geom.min_spacing[1], new_geom.min_spacing[2],
|
||||
new_geom.thumbnail_size[1], new_geom.thumbnail_size[2]
|
||||
)
|
||||
-- extrawuerst
|
||||
if opts.show_text and names[index] == "min_spacing" then
|
||||
new_geom.min_spacing[2] = math.max(opts.text_size, new_geom.min_spacing[2])
|
||||
elseif names[index] == "thumbnail_size" then
|
||||
new_geom.thumbnail_size[1] = math.floor(new_geom.thumbnail_size[1])
|
||||
new_geom.thumbnail_size[2] = math.floor(new_geom.thumbnail_size[2])
|
||||
end
|
||||
end
|
||||
gallery:set_geometry(
|
||||
new_geom.gallery_position[1], new_geom.gallery_position[2],
|
||||
new_geom.gallery_size[1], new_geom.gallery_size[2],
|
||||
new_geom.min_spacing[1], new_geom.min_spacing[2],
|
||||
new_geom.thumbnail_size[1], new_geom.thumbnail_size[2]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
function normalize_path(path)
|
||||
if string.find(path, "://") then
|
||||
return path
|
||||
end
|
||||
path = utils.join_path(utils.getcwd(), path)
|
||||
if ON_WINDOWS then
|
||||
path = string.gsub(path, "\\", "/")
|
||||
end
|
||||
path = string.gsub(path, "/%./", "/")
|
||||
local n
|
||||
repeat
|
||||
path, n = string.gsub(path, "/[^/]*/%.%./", "/", 1)
|
||||
until n == 0
|
||||
return path
|
||||
end
|
||||
|
||||
function playlist_changed(key, playlist)
|
||||
if not gallery.active then return end
|
||||
local did_change = function()
|
||||
if #gallery.items ~= #playlist then return true end
|
||||
for i = 1, #gallery.items do
|
||||
if gallery.items[i].filename ~= playlist[i].filename then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
if not did_change() then return end
|
||||
if #playlist == 0 then
|
||||
stop()
|
||||
return
|
||||
end
|
||||
local selection_filename = gallery.items[gallery.selection].filename
|
||||
gallery.items = playlist
|
||||
local new_selection = math.max(1, math.min(gallery.selection, #gallery.items))
|
||||
for i, f in ipairs(gallery.items) do
|
||||
if selection_filename == f.filename then
|
||||
new_selection = i
|
||||
break
|
||||
end
|
||||
end
|
||||
gallery:items_changed(new_selection)
|
||||
end
|
||||
|
||||
function playlist_pos_changed(_, val)
|
||||
playlist_pos = val
|
||||
if opts.highlight_active then
|
||||
gallery:ass_refresh(true, false, false, false)
|
||||
end
|
||||
if opts.follow_playlist_position then
|
||||
pending_selection = val
|
||||
end
|
||||
end
|
||||
|
||||
function idle()
|
||||
if pending_selection then
|
||||
gallery:set_selection(pending_selection)
|
||||
pending_selection = nil
|
||||
end
|
||||
if ass_changed or geometry_changed then
|
||||
local ww, wh = mp.get_osd_size()
|
||||
if geometry_changed then
|
||||
geometry_changed = false
|
||||
compute_geometry(ww, wh)
|
||||
end
|
||||
if ass_changed then
|
||||
ass_changed = false
|
||||
mp.set_osd_ass(ww, wh, ass)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function mark_geometry_stale()
|
||||
geometry_changed = true
|
||||
end
|
||||
|
||||
function start()
|
||||
if gallery.active then return end
|
||||
playlist = mp.get_property_native("playlist")
|
||||
if #playlist == 0 then return end
|
||||
gallery.items = playlist
|
||||
|
||||
local ww, wh = mp.get_osd_size()
|
||||
compute_geometry(ww, wh)
|
||||
|
||||
playlist_pos = mp.get_property_number("playlist-pos-1")
|
||||
gallery:set_selection(playlist_pos or 1)
|
||||
if not gallery:activate() then return end
|
||||
|
||||
did_pause = false
|
||||
if opts.pause_on_start and not mp.get_property_bool("pause", false) then
|
||||
mp.set_property_bool("pause", true)
|
||||
did_pause = true
|
||||
end
|
||||
if opts.command_on_open ~= "" then
|
||||
mp.command(opts.command_on_open)
|
||||
end
|
||||
mp.observe_property("playlist-pos-1", "native", playlist_pos_changed)
|
||||
mp.observe_property("playlist", "native", playlist_changed)
|
||||
mp.observe_property("osd-width", "native", mark_geometry_stale)
|
||||
mp.observe_property("osd-height", "native", mark_geometry_stale)
|
||||
mp.register_idle(idle)
|
||||
idle()
|
||||
|
||||
setup_ui_handlers()
|
||||
end
|
||||
|
||||
function load_selection()
|
||||
local sel = mp.get_property_number("playlist-pos-1", -1)
|
||||
if sel == gallery.selection then return end
|
||||
if opts.remember_time_position then
|
||||
if sel then
|
||||
local time = mp.get_property_number("time-pos")
|
||||
if time and time > 1 then
|
||||
resume[gallery.items[sel].filename] = time
|
||||
end
|
||||
end
|
||||
mp.set_property("playlist-pos-1", gallery.selection)
|
||||
local time = resume[gallery.items[gallery.selection].filename]
|
||||
if not time then return end
|
||||
local func
|
||||
func = function()
|
||||
mp.commandv("osd-msg-bar", "seek", time, "absolute")
|
||||
mp.unregister_event(func)
|
||||
end
|
||||
mp.register_event("file-loaded", func)
|
||||
else
|
||||
mp.set_property("playlist-pos-1", gallery.selection)
|
||||
end
|
||||
end
|
||||
|
||||
function stop()
|
||||
if not gallery.active then return end
|
||||
if opts.resume_on_stop == "yes" or (opts.resume_on_stop == "only-if-did-pause" and did_pause) then
|
||||
mp.set_property_bool("pause", false)
|
||||
end
|
||||
if opts.command_on_close ~= "" then
|
||||
mp.command(opts.command_on_close)
|
||||
end
|
||||
mp.unobserve_property(playlist_pos_changed)
|
||||
mp.unobserve_property(playlist_changed)
|
||||
mp.unobserve_property(mark_geometry_stale)
|
||||
mp.unregister_idle(idle)
|
||||
teardown_ui_handlers()
|
||||
gallery:deactivate()
|
||||
idle()
|
||||
end
|
||||
|
||||
function toggle()
|
||||
if not gallery.active then
|
||||
start()
|
||||
else
|
||||
if opts.load_file_on_toggle_off then load_selection() end
|
||||
stop()
|
||||
end
|
||||
end
|
||||
|
||||
mp.register_script_message("thumbnail-generated", function(thumb_path)
|
||||
gallery:thumbnail_generated(thumb_path)
|
||||
end)
|
||||
|
||||
mp.register_script_message("thumbnails-generator-broadcast", function(generator_name)
|
||||
gallery:add_generator(generator_name)
|
||||
end)
|
||||
|
||||
function write_flag_file()
|
||||
if next(flags) == nil then return end
|
||||
local out = io.open(opts.flagged_file_path, "w")
|
||||
for f, _ in pairs(flags) do
|
||||
out:write(f .. "\n")
|
||||
end
|
||||
out:close()
|
||||
end
|
||||
mp.register_event("shutdown", write_flag_file)
|
||||
|
||||
reload_config()
|
||||
|
||||
if opts.start_on_file_end then
|
||||
mp.observe_property("eof-reached", "bool", function(_, val)
|
||||
if val and mp.get_property_number("playlist-count") > 1 then
|
||||
start()
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
if opts.start_on_mpv_startup then
|
||||
local autostart
|
||||
autostart = function()
|
||||
if mp.get_property_number("playlist-count") == 0 then return end
|
||||
if mp.get_property_number("osd-width") <= 0 then return end
|
||||
start()
|
||||
mp.unobserve_property(autostart)
|
||||
end
|
||||
mp.observe_property("playlist-count", "number", autostart)
|
||||
mp.observe_property("osd-width", "number", autostart)
|
||||
end
|
||||
|
||||
mp.add_key_binding(nil, "playlist-view-open", function() start() end)
|
||||
mp.add_key_binding(nil, "playlist-view-close", stop)
|
||||
mp.add_key_binding('g', "playlist-view-toggle", toggle)
|
||||
mp.add_key_binding(nil, "playlist-view-load-selection", load_selection)
|
||||
mp.add_key_binding(nil, "playlist-view-write-flag-file", write_flag_file)
|
|
@ -0,0 +1,569 @@
|
|||
-- sponsorblock.lua
|
||||
--
|
||||
-- This script skips sponsored segments of YouTube videos
|
||||
-- using data from https://github.com/ajayyy/SponsorBlock
|
||||
|
||||
local ON_WINDOWS = package.config:sub(1,1) ~= "/"
|
||||
|
||||
local options = {
|
||||
server_address = "https://sponsor.ajay.app",
|
||||
|
||||
python_path = ON_WINDOWS and "python" or "python3",
|
||||
|
||||
-- Categories to fetch
|
||||
categories = "sponsor,intro,outro,interaction,selfpromo,filler",
|
||||
|
||||
-- Categories to skip automatically
|
||||
skip_categories = "sponsor",
|
||||
|
||||
-- If true, sponsored segments will only be skipped once
|
||||
skip_once = true,
|
||||
|
||||
-- Note that sponsored segments may ocasionally be inaccurate if this is turned off
|
||||
-- see https://blog.ajay.app/voting-and-pseudo-randomness-or-sponsorblock-or-youtube-sponsorship-segment-blocker
|
||||
local_database = false,
|
||||
|
||||
-- Update database on first run, does nothing if local_database is false
|
||||
auto_update = true,
|
||||
|
||||
-- How long to wait between local database updates
|
||||
-- Format: "X[d,h,m]", leave blank to update on every mpv run
|
||||
auto_update_interval = "6h",
|
||||
|
||||
-- User ID used to submit sponsored segments, leave blank for random
|
||||
user_id = "",
|
||||
|
||||
-- Name to display on the stats page https://sponsor.ajay.app/stats/ leave blank to keep current name
|
||||
display_name = "",
|
||||
|
||||
-- Tell the server when a skip happens
|
||||
report_views = true,
|
||||
|
||||
-- Auto upvote skipped sponsors
|
||||
auto_upvote = false,
|
||||
|
||||
-- Use sponsor times from server if they're more up to date than our local database
|
||||
server_fallback = true,
|
||||
|
||||
-- Create chapters at sponsor boundaries for OSC display and manual skipping
|
||||
make_chapters = true,
|
||||
|
||||
-- Minimum duration for sponsors (in seconds), segments under that threshold will be ignored
|
||||
min_duration = 1,
|
||||
|
||||
-- Fade audio for smoother transitions
|
||||
audio_fade = false,
|
||||
|
||||
-- Audio fade step, applied once every 100ms until cap is reached
|
||||
audio_fade_step = 10,
|
||||
|
||||
-- Audio fade cap
|
||||
audio_fade_cap = 0,
|
||||
|
||||
-- Fast forward through sponsors instead of skipping
|
||||
fast_forward = false,
|
||||
|
||||
-- Playback speed modifier when fast forwarding, applied once every second until cap is reached
|
||||
fast_forward_increase = .2,
|
||||
|
||||
-- Playback speed cap
|
||||
fast_forward_cap = 2,
|
||||
|
||||
-- Length of the sha256 prefix (3-32) when querying server, 0 to disable
|
||||
sha256_length = 4,
|
||||
|
||||
-- Pattern for video id in local files, ignored if blank
|
||||
-- Recommended value for base youtube-dl is "-([%w-_]+)%.[mw][kpe][v4b]m?$"
|
||||
local_pattern = "",
|
||||
|
||||
-- Legacy option, use skip_categories instead
|
||||
skip = true
|
||||
}
|
||||
|
||||
mp.options = require "mp.options"
|
||||
mp.options.read_options(options, "sponsorblock")
|
||||
|
||||
local legacy = mp.command_native_async == nil
|
||||
--[[
|
||||
if legacy then
|
||||
options.local_database = false
|
||||
end
|
||||
--]]
|
||||
options.local_database = false
|
||||
|
||||
local utils = require "mp.utils"
|
||||
scripts_dir = mp.find_config_file("scripts")
|
||||
|
||||
local sponsorblock = utils.join_path(scripts_dir, "sponsorblock_shared/sponsorblock.py")
|
||||
local uid_path = utils.join_path(scripts_dir, "sponsorblock_shared/sponsorblock.txt")
|
||||
local database_file = options.local_database and utils.join_path(scripts_dir, "sponsorblock_shared/sponsorblock.db") or ""
|
||||
local youtube_id = nil
|
||||
local ranges = {}
|
||||
local init = false
|
||||
local segment = {a = 0, b = 0, progress = 0, first = true}
|
||||
local retrying = false
|
||||
local last_skip = {uuid = "", dir = nil}
|
||||
local speed_timer = nil
|
||||
local fade_timer = nil
|
||||
local fade_dir = nil
|
||||
local volume_before = mp.get_property_number("volume")
|
||||
local categories = {}
|
||||
local all_categories = {"sponsor", "intro", "outro", "interaction", "selfpromo", "preview", "music_offtopic", "filler"}
|
||||
local chapter_cache = {}
|
||||
|
||||
for category in string.gmatch(options.skip_categories, "([^,]+)") do
|
||||
categories[category] = true
|
||||
end
|
||||
|
||||
function file_exists(name)
|
||||
local f = io.open(name,"r")
|
||||
if f ~= nil then io.close(f) return true else return false end
|
||||
end
|
||||
|
||||
function t_count(t)
|
||||
local count = 0
|
||||
for _ in pairs(t) do count = count + 1 end
|
||||
return count
|
||||
end
|
||||
|
||||
function time_sort(a, b)
|
||||
if a.time == b.time then
|
||||
return string.match(a.title, "segment end")
|
||||
end
|
||||
return a.time < b.time
|
||||
end
|
||||
|
||||
function parse_update_interval()
|
||||
local s = options.auto_update_interval
|
||||
if s == "" then return 0 end -- Interval Disabled
|
||||
|
||||
local num, mod = s:match "^(%d+)([hdm])$"
|
||||
|
||||
if num == nil or mod == nil then
|
||||
mp.osd_message("[sponsorblock] auto_update_interval " .. s .. " is invalid", 5)
|
||||
return nil
|
||||
end
|
||||
|
||||
local time_table = {
|
||||
m = 60,
|
||||
h = 60 * 60,
|
||||
d = 60 * 60 * 24,
|
||||
}
|
||||
|
||||
return num * time_table[mod]
|
||||
end
|
||||
|
||||
function clean_chapters()
|
||||
local chapters = mp.get_property_native("chapter-list")
|
||||
local new_chapters = {}
|
||||
for _, chapter in pairs(chapters) do
|
||||
if chapter.title ~= "Preview segment start" and chapter.title ~= "Preview segment end" then
|
||||
table.insert(new_chapters, chapter)
|
||||
end
|
||||
end
|
||||
mp.set_property_native("chapter-list", new_chapters)
|
||||
end
|
||||
|
||||
function create_chapter(chapter_title, chapter_time)
|
||||
local chapters = mp.get_property_native("chapter-list")
|
||||
local duration = mp.get_property_native("duration")
|
||||
table.insert(chapters, {title=chapter_title, time=(duration == nil or duration > chapter_time) and chapter_time or duration - .001})
|
||||
table.sort(chapters, time_sort)
|
||||
mp.set_property_native("chapter-list", chapters)
|
||||
end
|
||||
|
||||
function process(uuid, t, new_ranges)
|
||||
start_time = tonumber(string.match(t, "[^,]+"))
|
||||
end_time = tonumber(string.sub(string.match(t, ",[^,]+"), 2))
|
||||
for o_uuid, o_t in pairs(ranges) do
|
||||
if (start_time >= o_t.start_time and start_time <= o_t.end_time) or (o_t.start_time >= start_time and o_t.start_time <= end_time) then
|
||||
new_ranges[o_uuid] = o_t
|
||||
return
|
||||
end
|
||||
end
|
||||
category = string.match(t, "[^,]+$")
|
||||
if categories[category] and end_time - start_time >= options.min_duration then
|
||||
new_ranges[uuid] = {
|
||||
start_time = start_time,
|
||||
end_time = end_time,
|
||||
category = category,
|
||||
skipped = false
|
||||
}
|
||||
end
|
||||
if options.make_chapters and not chapter_cache[uuid] then
|
||||
chapter_cache[uuid] = true
|
||||
local category_title = (category:gsub("^%l", string.upper):gsub("_", " "))
|
||||
create_chapter(category_title .. " segment start (" .. string.sub(uuid, 1, 6) .. ")", start_time)
|
||||
create_chapter(category_title .. " segment end (" .. string.sub(uuid, 1, 6) .. ")", end_time)
|
||||
end
|
||||
end
|
||||
|
||||
function getranges(_, exists, db, more)
|
||||
if type(exists) == "table" and exists["status"] == "1" then
|
||||
if options.server_fallback then
|
||||
mp.add_timeout(0, function() getranges(true, true, "") end)
|
||||
else
|
||||
return mp.osd_message("[sponsorblock] database update failed, gave up")
|
||||
end
|
||||
end
|
||||
if db ~= "" and db ~= database_file then db = database_file end
|
||||
if exists ~= true and not file_exists(db) then
|
||||
if not retrying then
|
||||
mp.osd_message("[sponsorblock] database update failed, retrying...")
|
||||
retrying = true
|
||||
end
|
||||
return update()
|
||||
end
|
||||
if retrying then
|
||||
mp.osd_message("[sponsorblock] database update succeeded")
|
||||
retrying = false
|
||||
end
|
||||
local sponsors
|
||||
local args = {
|
||||
options.python_path,
|
||||
sponsorblock,
|
||||
"ranges",
|
||||
db,
|
||||
options.server_address,
|
||||
youtube_id,
|
||||
options.categories,
|
||||
tostring(options.sha256_length)
|
||||
}
|
||||
if not legacy then
|
||||
sponsors = mp.command_native({name = "subprocess", capture_stdout = true, playback_only = false, args = args})
|
||||
else
|
||||
sponsors = utils.subprocess({args = args})
|
||||
end
|
||||
mp.msg.debug("Got: " .. string.gsub(sponsors.stdout, "[\n\r]", ""))
|
||||
if not string.match(sponsors.stdout, "^%s*(.*%S)") then return end
|
||||
if string.match(sponsors.stdout, "error") then return getranges(true, true) end
|
||||
local new_ranges = {}
|
||||
local r_count = 0
|
||||
if more then r_count = -1 end
|
||||
for t in string.gmatch(sponsors.stdout, "[^:%s]+") do
|
||||
uuid = string.match(t, "([^,]+),[^,]+$")
|
||||
if ranges[uuid] then
|
||||
new_ranges[uuid] = ranges[uuid]
|
||||
else
|
||||
process(uuid, t, new_ranges)
|
||||
end
|
||||
r_count = r_count + 1
|
||||
end
|
||||
local c_count = t_count(ranges)
|
||||
if c_count == 0 or r_count >= c_count then
|
||||
ranges = new_ranges
|
||||
end
|
||||
end
|
||||
|
||||
function fast_forward()
|
||||
if options.fast_forward and options.fast_forward == true then
|
||||
speed_timer = nil
|
||||
mp.set_property("speed", 1)
|
||||
end
|
||||
local last_speed = mp.get_property_number("speed")
|
||||
local new_speed = math.min(last_speed + options.fast_forward_increase, options.fast_forward_cap)
|
||||
if new_speed <= last_speed then return end
|
||||
mp.set_property("speed", new_speed)
|
||||
end
|
||||
|
||||
function fade_audio(step)
|
||||
local last_volume = mp.get_property_number("volume")
|
||||
local new_volume = math.max(options.audio_fade_cap, math.min(last_volume + step, volume_before))
|
||||
if new_volume == last_volume then
|
||||
if step >= 0 then fade_dir = nil end
|
||||
if fade_timer ~= nil then fade_timer:kill() end
|
||||
fade_timer = nil
|
||||
return
|
||||
end
|
||||
mp.set_property("volume", new_volume)
|
||||
end
|
||||
|
||||
function skip_ads(name, pos)
|
||||
if pos == nil then return end
|
||||
local sponsor_ahead = false
|
||||
for uuid, t in pairs(ranges) do
|
||||
if (options.fast_forward == uuid or not options.skip_once or not t.skipped) and t.start_time <= pos and t.end_time > pos then
|
||||
if options.fast_forward == uuid then return end
|
||||
if options.fast_forward == false then
|
||||
mp.osd_message("[sponsorblock] " .. t.category .. " skipped")
|
||||
mp.set_property("time-pos", t.end_time)
|
||||
else
|
||||
mp.osd_message("[sponsorblock] skipping " .. t.category)
|
||||
end
|
||||
t.skipped = true
|
||||
last_skip = {uuid = uuid, dir = nil}
|
||||
if options.report_views or options.auto_upvote then
|
||||
local args = {
|
||||
options.python_path,
|
||||
sponsorblock,
|
||||
"stats",
|
||||
database_file,
|
||||
options.server_address,
|
||||
youtube_id,
|
||||
uuid,
|
||||
options.report_views and "1" or "",
|
||||
uid_path,
|
||||
options.user_id,
|
||||
options.auto_upvote and "1" or ""
|
||||
}
|
||||
if not legacy then
|
||||
mp.command_native_async({name = "subprocess", playback_only = false, args = args}, function () end)
|
||||
else
|
||||
utils.subprocess_detached({args = args})
|
||||
end
|
||||
end
|
||||
if options.fast_forward ~= false then
|
||||
options.fast_forward = uuid
|
||||
if speed_timer ~= nil then speed_timer:kill() end
|
||||
speed_timer = mp.add_periodic_timer(1, fast_forward)
|
||||
end
|
||||
return
|
||||
elseif (not options.skip_once or not t.skipped) and t.start_time <= pos + 1 and t.end_time > pos + 1 then
|
||||
sponsor_ahead = true
|
||||
end
|
||||
end
|
||||
if options.audio_fade then
|
||||
if sponsor_ahead then
|
||||
if fade_dir ~= false then
|
||||
if fade_dir == nil then volume_before = mp.get_property_number("volume") end
|
||||
if fade_timer ~= nil then fade_timer:kill() end
|
||||
fade_dir = false
|
||||
fade_timer = mp.add_periodic_timer(.1, function() fade_audio(-options.audio_fade_step) end)
|
||||
end
|
||||
elseif fade_dir == false then
|
||||
fade_dir = true
|
||||
if fade_timer ~= nil then fade_timer:kill() end
|
||||
fade_timer = mp.add_periodic_timer(.1, function() fade_audio(options.audio_fade_step) end)
|
||||
end
|
||||
end
|
||||
if options.fast_forward and options.fast_forward ~= true then
|
||||
options.fast_forward = true
|
||||
speed_timer:kill()
|
||||
speed_timer = nil
|
||||
mp.set_property("speed", 1)
|
||||
end
|
||||
end
|
||||
|
||||
function vote(dir)
|
||||
if last_skip.uuid == "" then return mp.osd_message("[sponsorblock] no sponsors skipped, can't submit vote") end
|
||||
local updown = dir == "1" and "up" or "down"
|
||||
if last_skip.dir == dir then return mp.osd_message("[sponsorblock] " .. updown .. "vote already submitted") end
|
||||
last_skip.dir = dir
|
||||
local args = {
|
||||
options.python_path,
|
||||
sponsorblock,
|
||||
"stats",
|
||||
database_file,
|
||||
options.server_address,
|
||||
youtube_id,
|
||||
last_skip.uuid,
|
||||
"",
|
||||
uid_path,
|
||||
options.user_id,
|
||||
dir
|
||||
}
|
||||
if not legacy then
|
||||
mp.command_native_async({name = "subprocess", playback_only = false, args = args}, function () end)
|
||||
else
|
||||
utils.subprocess({args = args})
|
||||
end
|
||||
mp.osd_message("[sponsorblock] " .. updown .. "vote submitted")
|
||||
end
|
||||
|
||||
function update()
|
||||
mp.command_native_async({name = "subprocess", playback_only = false, args = {
|
||||
options.python_path,
|
||||
sponsorblock,
|
||||
"update",
|
||||
database_file,
|
||||
options.server_address
|
||||
}}, getranges)
|
||||
end
|
||||
|
||||
function file_loaded()
|
||||
local initialized = init
|
||||
ranges = {}
|
||||
segment = {a = 0, b = 0, progress = 0, first = true}
|
||||
last_skip = {uuid = "", dir = nil}
|
||||
chapter_cache = {}
|
||||
local video_path = mp.get_property("path", "")
|
||||
mp.msg.debug("Path: " .. video_path)
|
||||
local video_referer = string.match(mp.get_property("http-header-fields", ""), "Referer:([^,]+)") or ""
|
||||
mp.msg.debug("Referer: " .. video_referer)
|
||||
|
||||
local urls = {
|
||||
"ytdl://([%w-_]+).*",
|
||||
"https?://youtu%.be/([%w-_]+).*",
|
||||
"https?://w?w?w?%.?youtube%.com/v/([%w-_]+).*",
|
||||
"/watch.*[?&]v=([%w-_]+).*",
|
||||
"/embed/([%w-_]+).*"
|
||||
}
|
||||
youtube_id = nil
|
||||
for i, url in ipairs(urls) do
|
||||
youtube_id = youtube_id or string.match(video_path, url) or string.match(video_referer, url)
|
||||
if youtube_id then break end
|
||||
end
|
||||
youtube_id = youtube_id or string.match(video_path, options.local_pattern)
|
||||
|
||||
if not youtube_id or string.len(youtube_id) < 11 or (local_pattern and string.len(youtube_id) ~= 11) then return end
|
||||
youtube_id = string.sub(youtube_id, 1, 11)
|
||||
mp.msg.debug("Found YouTube ID: " .. youtube_id)
|
||||
init = true
|
||||
if not options.local_database then
|
||||
getranges(true, true)
|
||||
else
|
||||
local exists = file_exists(database_file)
|
||||
if exists and options.server_fallback then
|
||||
getranges(true, true)
|
||||
mp.add_timeout(0, function() getranges(true, true, "", true) end)
|
||||
elseif exists then
|
||||
getranges(true, true)
|
||||
elseif options.server_fallback then
|
||||
mp.add_timeout(0, function() getranges(true, true, "") end)
|
||||
end
|
||||
end
|
||||
if initialized then return end
|
||||
if options.skip then
|
||||
mp.observe_property("time-pos", "native", skip_ads)
|
||||
end
|
||||
if options.display_name ~= "" then
|
||||
local args = {
|
||||
options.python_path,
|
||||
sponsorblock,
|
||||
"username",
|
||||
database_file,
|
||||
options.server_address,
|
||||
youtube_id,
|
||||
"",
|
||||
"",
|
||||
uid_path,
|
||||
options.user_id,
|
||||
options.display_name
|
||||
}
|
||||
if not legacy then
|
||||
mp.command_native_async({name = "subprocess", playback_only = false, args = args}, function () end)
|
||||
else
|
||||
utils.subprocess_detached({args = args})
|
||||
end
|
||||
end
|
||||
if not options.local_database or (not options.auto_update and file_exists(database_file)) then return end
|
||||
|
||||
if file_exists(database_file) then
|
||||
local db_info = utils.file_info(database_file)
|
||||
local cur_time = os.time(os.date("*t"))
|
||||
local upd_interval = parse_update_interval()
|
||||
if upd_interval == nil or os.difftime(cur_time, db_info.mtime) < upd_interval then return end
|
||||
end
|
||||
|
||||
update()
|
||||
end
|
||||
|
||||
function set_segment()
|
||||
if not youtube_id then return end
|
||||
local pos = mp.get_property_number("time-pos")
|
||||
if pos == nil then return end
|
||||
if segment.progress > 1 then
|
||||
segment.progress = segment.progress - 2
|
||||
end
|
||||
if segment.progress == 1 then
|
||||
segment.progress = 0
|
||||
segment.b = pos
|
||||
mp.osd_message("[sponsorblock] segment boundary B set, press again for boundary A", 3)
|
||||
else
|
||||
segment.progress = 1
|
||||
segment.a = pos
|
||||
mp.osd_message("[sponsorblock] segment boundary A set, press again for boundary B", 3)
|
||||
end
|
||||
if options.make_chapters and not segment.first then
|
||||
local start_time = math.min(segment.a, segment.b)
|
||||
local end_time = math.max(segment.a, segment.b)
|
||||
if end_time - start_time ~= 0 and end_time ~= 0 then
|
||||
clean_chapters()
|
||||
create_chapter("Preview segment start", start_time)
|
||||
create_chapter("Preview segment end", end_time)
|
||||
end
|
||||
end
|
||||
segment.first = false
|
||||
end
|
||||
|
||||
function select_category(selected)
|
||||
for category in string.gmatch(options.categories, "([^,]+)") do
|
||||
mp.remove_key_binding("select_category_"..category)
|
||||
mp.remove_key_binding("kp_select_category_"..category)
|
||||
end
|
||||
submit_segment(selected)
|
||||
end
|
||||
|
||||
function submit_segment(category)
|
||||
if not youtube_id then return end
|
||||
local start_time = math.min(segment.a, segment.b)
|
||||
local end_time = math.max(segment.a, segment.b)
|
||||
if end_time - start_time == 0 or end_time == 0 then
|
||||
mp.osd_message("[sponsorblock] empty segment, not submitting")
|
||||
elseif segment.progress <= 1 then
|
||||
segment.progress = segment.progress + 2
|
||||
local category_list = ""
|
||||
for category_id, category in pairs(all_categories) do
|
||||
local category_title = (category:gsub("^%l", string.upper):gsub("_", " "))
|
||||
category_list = category_list .. category_id .. ": " .. category_title .. "\n"
|
||||
mp.add_forced_key_binding(tostring(category_id), "select_category_"..category, function() select_category(category) end)
|
||||
mp.add_forced_key_binding("KP"..tostring(category_id), "kp_select_category_"..category, function() select_category(category) end)
|
||||
end
|
||||
mp.osd_message(string.format("[sponsorblock] press a number to select category for segment: %.2d:%.2d:%.2d to %.2d:%.2d:%.2d\n\n" .. category_list .. "\nyou can press Shift+G again for default (Sponsor) or hide this message with g", math.floor(start_time/(60*60)), math.floor(start_time/60%60), math.floor(start_time%60), math.floor(end_time/(60*60)), math.floor(end_time/60%60), math.floor(end_time%60)), 30)
|
||||
else
|
||||
mp.osd_message("[sponsorblock] submitting segment...", 30)
|
||||
local submit
|
||||
local args = {
|
||||
options.python_path,
|
||||
sponsorblock,
|
||||
"submit",
|
||||
database_file,
|
||||
options.server_address,
|
||||
youtube_id,
|
||||
tostring(start_time),
|
||||
tostring(end_time),
|
||||
uid_path,
|
||||
options.user_id,
|
||||
category or "sponsor"
|
||||
}
|
||||
if not legacy then
|
||||
submit = mp.command_native({name = "subprocess", capture_stdout = true, playback_only = false, args = args})
|
||||
else
|
||||
submit = utils.subprocess({args = args})
|
||||
end
|
||||
if string.match(submit.stdout, "success") then
|
||||
segment = {a = 0, b = 0, progress = 0, first = true}
|
||||
mp.osd_message("[sponsorblock] segment submitted")
|
||||
if options.make_chapters then
|
||||
clean_chapters()
|
||||
create_chapter("Submitted segment start", start_time)
|
||||
create_chapter("Submitted segment end", end_time)
|
||||
end
|
||||
elseif string.match(submit.stdout, "error") then
|
||||
mp.osd_message("[sponsorblock] segment submission failed, server may be down. try again", 5)
|
||||
elseif string.match(submit.stdout, "502") then
|
||||
mp.osd_message("[sponsorblock] segment submission failed, server is down. try again", 5)
|
||||
elseif string.match(submit.stdout, "400") then
|
||||
mp.osd_message("[sponsorblock] segment submission failed, impossible inputs", 5)
|
||||
segment = {a = 0, b = 0, progress = 0, first = true}
|
||||
elseif string.match(submit.stdout, "429") then
|
||||
mp.osd_message("[sponsorblock] segment submission failed, rate limited. try again", 5)
|
||||
elseif string.match(submit.stdout, "409") then
|
||||
mp.osd_message("[sponsorblock] segment already submitted", 3)
|
||||
segment = {a = 0, b = 0, progress = 0, first = true}
|
||||
else
|
||||
mp.osd_message("[sponsorblock] segment submission failed", 5)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
mp.register_event("file-loaded", file_loaded)
|
||||
mp.add_key_binding("g", "set_segment", set_segment)
|
||||
mp.add_key_binding("G", "submit_segment", submit_segment)
|
||||
mp.add_key_binding("h", "upvote_segment", function() return vote("1") end)
|
||||
mp.add_key_binding("H", "downvote_segment", function() return vote("0") end)
|
||||
-- Bindings below are for backwards compatibility and could be removed at any time
|
||||
mp.add_key_binding(nil, "sponsorblock_set_segment", set_segment)
|
||||
mp.add_key_binding(nil, "sponsorblock_submit_segment", submit_segment)
|
||||
mp.add_key_binding(nil, "sponsorblock_upvote", function() return vote("1") end)
|
||||
mp.add_key_binding(nil, "sponsorblock_downvote", function() return vote("0") end)
|
|
@ -0,0 +1,3 @@
|
|||
-- This is a dummy main.lua
|
||||
-- required for mpv 0.33
|
||||
-- do not delete
|
|
@ -0,0 +1,122 @@
|
|||
import urllib.request
|
||||
import urllib.parse
|
||||
import hashlib
|
||||
import sqlite3
|
||||
import random
|
||||
import string
|
||||
import json
|
||||
import sys
|
||||
import os
|
||||
|
||||
if sys.argv[1] in ["submit", "stats", "username"]:
|
||||
if not sys.argv[8]:
|
||||
if os.path.isfile(sys.argv[7]):
|
||||
with open(sys.argv[7]) as f:
|
||||
uid = f.read()
|
||||
else:
|
||||
uid = "".join(random.choices(string.ascii_letters + string.digits, k=36))
|
||||
with open(sys.argv[7], "w") as f:
|
||||
f.write(uid)
|
||||
else:
|
||||
uid = sys.argv[8]
|
||||
|
||||
opener = urllib.request.build_opener()
|
||||
opener.addheaders = [("User-Agent", "mpv_sponsorblock/1.0 (https://github.com/po5/mpv_sponsorblock)")]
|
||||
urllib.request.install_opener(opener)
|
||||
|
||||
if sys.argv[1] == "ranges" and (not sys.argv[2] or not os.path.isfile(sys.argv[2])):
|
||||
sha = None
|
||||
if 3 <= int(sys.argv[6]) <= 32:
|
||||
sha = hashlib.sha256(sys.argv[4].encode()).hexdigest()[:int(sys.argv[6])]
|
||||
times = []
|
||||
try:
|
||||
response = urllib.request.urlopen(sys.argv[3] + "/api/skipSegments" + ("/" + sha + "?" if sha else "?videoID=" + sys.argv[4] + "&") + urllib.parse.urlencode([("categories", json.dumps(sys.argv[5].split(",")))]))
|
||||
segments = json.load(response)
|
||||
for segment in segments:
|
||||
if sha and sys.argv[4] != segment["videoID"]:
|
||||
continue
|
||||
if sha:
|
||||
for s in segment["segments"]:
|
||||
times.append(str(s["segment"][0]) + "," + str(s["segment"][1]) + "," + s["UUID"] + "," + s["category"])
|
||||
else:
|
||||
times.append(str(segment["segment"][0]) + "," + str(segment["segment"][1]) + "," + segment["UUID"] + "," + segment["category"])
|
||||
print(":".join(times))
|
||||
except (TimeoutError, urllib.error.URLError) as e:
|
||||
print("error")
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 404:
|
||||
print("")
|
||||
else:
|
||||
print("error")
|
||||
elif sys.argv[1] == "ranges":
|
||||
conn = sqlite3.connect(sys.argv[2])
|
||||
conn.row_factory = sqlite3.Row
|
||||
c = conn.cursor()
|
||||
times = []
|
||||
for category in sys.argv[5].split(","):
|
||||
c.execute("SELECT startTime, endTime, votes, UUID, category FROM sponsorTimes WHERE videoID = ? AND shadowHidden = 0 AND votes > -1 AND category = ?", (sys.argv[4], category))
|
||||
sponsors = c.fetchall()
|
||||
best = list(sponsors)
|
||||
dealtwith = []
|
||||
similar = []
|
||||
for sponsor_a in sponsors:
|
||||
for sponsor_b in sponsors:
|
||||
if sponsor_a is not sponsor_b and sponsor_a["startTime"] >= sponsor_b["startTime"] and sponsor_a["startTime"] <= sponsor_b["endTime"]:
|
||||
similar.append([sponsor_a, sponsor_b])
|
||||
if sponsor_a in best:
|
||||
best.remove(sponsor_a)
|
||||
if sponsor_b in best:
|
||||
best.remove(sponsor_b)
|
||||
for sponsors_a in similar:
|
||||
if sponsors_a in dealtwith:
|
||||
continue
|
||||
group = set(sponsors_a)
|
||||
for sponsors_b in similar:
|
||||
if sponsors_b[0] in group or sponsors_b[1] in group:
|
||||
group.add(sponsors_b[0])
|
||||
group.add(sponsors_b[1])
|
||||
dealtwith.append(sponsors_b)
|
||||
best.append(max(group, key=lambda x:x["votes"]))
|
||||
for time in best:
|
||||
times.append(str(time["startTime"]) + "," + str(time["endTime"]) + "," + time["UUID"] + "," + time["category"])
|
||||
print(":".join(times))
|
||||
elif sys.argv[1] == "update":
|
||||
try:
|
||||
urllib.request.urlretrieve(sys.argv[3] + "/database.db", sys.argv[2] + ".tmp")
|
||||
os.replace(sys.argv[2] + ".tmp", sys.argv[2])
|
||||
except PermissionError:
|
||||
print("database update failed, file currently in use", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except ConnectionResetError:
|
||||
print("database update failed, connection reset", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except TimeoutError:
|
||||
print("database update failed, timed out", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except urllib.error.URLError:
|
||||
print("database update failed", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
elif sys.argv[1] == "submit":
|
||||
try:
|
||||
req = urllib.request.Request(sys.argv[3] + "/api/skipSegments", data=json.dumps({"videoID": sys.argv[4], "segments": [{"segment": [float(sys.argv[5]), float(sys.argv[6])], "category": sys.argv[9]}], "userID": uid}).encode(), headers={"Content-Type": "application/json"})
|
||||
response = urllib.request.urlopen(req)
|
||||
print("success")
|
||||
except urllib.error.HTTPError as e:
|
||||
print(e.code)
|
||||
except:
|
||||
print("error")
|
||||
elif sys.argv[1] == "stats":
|
||||
try:
|
||||
if sys.argv[6]:
|
||||
urllib.request.urlopen(sys.argv[3] + "/api/viewedVideoSponsorTime?UUID=" + sys.argv[5])
|
||||
if sys.argv[9]:
|
||||
urllib.request.urlopen(sys.argv[3] + "/api/voteOnSponsorTime?UUID=" + sys.argv[5] + "&userID=" + uid + "&type=" + sys.argv[9])
|
||||
except:
|
||||
pass
|
||||
elif sys.argv[1] == "username":
|
||||
try:
|
||||
data = urllib.parse.urlencode({"userID": uid, "userName": sys.argv[9]}).encode()
|
||||
req = urllib.request.Request(sys.argv[3] + "/api/setUsername", data=data)
|
||||
urllib.request.urlopen(req)
|
||||
except:
|
||||
pass
|
|
@ -0,0 +1 @@
|
|||
lfitZB2RsiJRN8VaF6OTfmra9UaKIDfHXlWa
|
|
@ -2,7 +2,7 @@ require("nvim-treesitter.configs").setup {
|
|||
ensure_installed = {
|
||||
"c", "lua", "vim", "rust", "bash", "markdown", "xml", "css",
|
||||
"html", "javascript", "ini", "toml", "yaml", "hare", "asm",
|
||||
"dockerfile", "python", "go"
|
||||
"dockerfile", "python", "go", "nasm"
|
||||
},
|
||||
highlight = { enable = true },
|
||||
}
|
||||
|
|
|
@ -3,15 +3,16 @@ configuration {
|
|||
lines: 5;
|
||||
font: "JetBrains Mono Nerd Font Bold 14";
|
||||
show-icons: true;
|
||||
terminal: "alacritty";
|
||||
terminal: "foot";
|
||||
drun-display-format: "{icon} {name}";
|
||||
location: 0;
|
||||
disable-history: false;
|
||||
hide-scrollbar: true;
|
||||
sidebar-mode: true;
|
||||
display-drun: " Apps ";
|
||||
display-run: " Command ";
|
||||
display-window: " Window ";
|
||||
display-drun: " ⭕ Apps ";
|
||||
display-run: " 🚀 Command ";
|
||||
display-emoji: " 😀 Emoji ";
|
||||
display-window: " 🪟 Window ";
|
||||
}
|
||||
|
||||
@theme "theme"
|
||||
|
|
|
@ -139,7 +139,6 @@ set $lock swaylock -e -i ~/.config/sway/wp
|
|||
|
||||
# Trocar de wallpaper
|
||||
bindsym $mod+i exec walls
|
||||
bindsym $mod+Shift+i exec walls-lf
|
||||
|
||||
# rofi-mpd
|
||||
bindsym $mod+c exec rofi-mpd -a
|
||||
|
|
BIN
.config/sway/wp
BIN
.config/sway/wp
Binary file not shown.
Before Width: | Height: | Size: 269 KiB After Width: | Height: | Size: 4.0 MiB |
|
@ -1,4 +1,2 @@
|
|||
#!/usr/bin/env bash
|
||||
ESCOLHA=$(ls ~/.config/sway/wallpapers/ | rofi -dmenu -i -p "Escolha o wallpaper")
|
||||
cp ~/.config/sway/wallpapers/$ESCOLHA ~/.config/sway/wp
|
||||
swaymsg reload &
|
||||
foot lf -command 'map <enter> walls' ~/.config/sway/wallpapers
|
||||
|
|
|
@ -1,2 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
foot lf ~/.config/sway/wallpapers
|
|
@ -0,0 +1,11 @@
|
|||
[Desktop Entry]
|
||||
Name=Neovim
|
||||
TryExec=foot helix
|
||||
Exec=foot helix %F
|
||||
Terminal=true
|
||||
Type=Application
|
||||
Keywords=Text;editor;
|
||||
Icon=Neovim
|
||||
Categories=Utility;TextEditor;
|
||||
StartupNotify=false
|
||||
MimeType=text/english;text/plain;text/x-makefile;text/x-c++hdr;text/x-c++src;text/x-chdr;text/x-csrc;text/x-java;text/x-moc;text/x-pascal;text/x-tcl;text/x-tex;application/x-shellscript;text/x-c;text/x-c++;
|
Loading…
Reference in New Issue