Compare commits

...

2 Commits

Author SHA1 Message Date
Uyanide 0ed904319d update 2026-04-03 11:33:51 +02:00
Uyanide 64922e1ae3 a bit clarify 2026-04-03 11:06:17 +02:00
122 changed files with 3011 additions and 9316 deletions
-2
View File
@@ -16,8 +16,6 @@ historybookmarks
*.log
**/.git/
!/.git/
.claude/
.vscode/
*.swp
+15
View File
@@ -49,6 +49,21 @@ icc/ # ICC 色彩配置文件
| Anime4K | 动画 | 低 |
| SSIM | 低性能需求 | 低 |
其中:
- Ani4K / AniSD 着色器(`shaders/Ani4K/`)来自 [Sirosky/Upscale-Hub](https://github.com/Sirosky/Upscale-Hub)。
- nnedi3 / ravu 着色器(`shaders/nnedi3/``shaders/ravu/`)来自 [mpv-prescalers](https://github.com/bjin/mpv-prescalers)。
## 外部依赖
见 [DEPENDENCIES.md](DEPENDENCIES.md)。
## 更新流程
1. 在 mpv 中按 `M` 触发 manager.lua观察控制台输出确认无 `FAILED` 条目
2. 更新完成后删除 manager 在子目录留下的嵌套 `.git`(否则 `git add` 会失败):
```bash
find ~/.config/mpv -mindepth 2 -name .git -type d | sort -r | xargs rm -rvf
```
3. 重启 mpv检查控制台有无 `unknown key` 或脚本加载失败的警告
4. 若有 `unknown key` 警告,说明对应脚本的配置项发生变化,找 `script-opts/` 下同名 `.conf` 对照脚本源码更新
-1
View File
@@ -2,7 +2,6 @@
##⇘⇘uosc 一级菜单:打开
o script-message-to uosc open-file #menu: 打开 > 打开内置浏览器
TAB script-message-to file_browser browse-files;script-message-to file_browser dynamic/reload;show-text '' #menu: 打开 > 打开 OSD 浏览器
# script-message-to uosc playlist #menu: 打开 > 播放菜单
# script-message-to uosc chapters #menu: 打开 > 章节菜单
# script-message-to uosc editions #menu: 打开 > 版本菜单
+1 -1
View File
@@ -9,7 +9,7 @@ RIGHT seek 5 #event:click
RIGHT script-message-to evafast speedup #event:press
RIGHT script-message-to evafast slowdown #event:release
TAB script-message-to file_browser browse-files;script-message-to file_browser dynamic/reload;show-text '' #event:click
TAB script-message-to uosc open-file #event:click
TAB script-message-to uosc toggle-ui #event:press
TAB script-message-to uosc toggle-ui #event:release
+74 -71
View File
@@ -1,111 +1,114 @@
[
{
"git":"https://github.com/po5/evafast",
"branch":"rewrite",
"whitelist":"%.lua$",
"dest":"~~/scripts"
"git": "https://github.com/po5/evafast",
"branch": "rewrite",
"whitelist": "%.lua$",
"dest": "~~/scripts"
},
{
"git":"https://github.com/stax76/mpv-scripts",
"branch":"main",
"whitelist":"delete_current_file%.lua$",
"dest":"~~/scripts"
"git": "https://github.com/stax76/mpv-scripts",
"branch": "main",
"whitelist": "delete_current_file%.lua$",
"dest": "~~/scripts"
},
{
"git":"https://github.com/dyphire/mpv-scripts",
"branch":"main",
"blacklist":"license|%.md$|drcbox%.lua$|.-%-list%.lua$",
"dest":"~~/scripts"
"git": "https://github.com/dyphire/mpv-scripts",
"branch": "main",
"blacklist": "license|%.md$|drcbox%.lua$|.-%-list%.lua$|mpv-torrserver.lua",
"dest": "~~/scripts"
},
{
"git":"https://github.com/dyphire/mpv-playlistmanager",
"branch":"dev",
"whitelist":"playlistmanager%.lua$",
"dest":"~~/scripts"
"git": "https://github.com/dyphire/mpv-playlistmanager",
"branch": "dev",
"whitelist": "playlistmanager%.lua$",
"dest": "~~/scripts"
},
{
"git":"https://github.com/dyphire/mpv-sub-assrt",
"whitelist":"%.lua$",
"dest":"~~/scripts"
"git": "https://github.com/dyphire/mpv-sub-assrt",
"whitelist": "%.lua$",
"dest": "~~/scripts"
},
{
"git":"https://github.com/dyphire/chapterskip",
"branch":"dev",
"whitelist":"%.lua$",
"dest":"~~/scripts"
"git": "https://github.com/dyphire/chapterskip",
"branch": "dev",
"whitelist": "%.lua$",
"dest": "~~/scripts"
},
{
"git":"https://github.com/dyphire/Eisa01_mpv-scripts",
"branch":"dev",
"whitelist":"undoredo%.lua$|simplehistory%.lua$",
"dest":"~~/scripts"
"git": "https://github.com/dyphire/Eisa01_mpv-scripts",
"branch": "dev",
"whitelist": "undoredo%.lua$|simplehistory%.lua$",
"dest": "~~/scripts"
},
{
"git":"https://github.com/dyphire/autosubsync-mpv",
"branch":"v0.33_CM",
"whitelist":"readme%.md$|%.lua$",
"dest":"~~/scripts/autosubsync"
"git": "https://github.com/dyphire/autosubsync-mpv",
"branch": "v0.33_CM",
"whitelist": "readme%.md$|%.lua$",
"dest": "~~/scripts/autosubsync"
},
{
"git":"https://github.com/christoph-heinrich/mpv-quality-menu",
"whitelist":"quality%-menu%.lua$",
"dest":"~~/scripts"
"git": "https://github.com/christoph-heinrich/mpv-quality-menu",
"whitelist": "quality%-menu%.lua$",
"dest": "~~/scripts"
},
{
"git":"https://github.com/CogentRedTester/mpv-file-browser",
"whitelist":"main%.lua$|readme%.md$|doc|modules",
"dest":"~~/scripts/file-browser"
"git": "https://github.com/tomasklaen/uosc",
"branch": "main",
"whitelist": "src/uosc/",
"dest": "~~/scripts/uosc"
},
{
"git":"https://github.com/CogentRedTester/mpv-sub-select",
"whitelist":"sub%-select%.lua$",
"dest":"~~/scripts"
"git": "https://github.com/Tony15246/uosc_danmaku",
"branch": "main",
"blacklist": "^%.",
"dest": "~~/scripts/uosc_danmaku"
},
{
"git":"https://github.com/CogentRedTester/mpv-scripts",
"whitelist":"cycle%-commands%.lua$",
"dest":"~~/scripts"
"git": "https://github.com/CogentRedTester/mpv-sub-select",
"whitelist": "sub%-select%.lua$",
"dest": "~~/scripts"
},
{
"git":"https://github.com/CogentRedTester/mpv-file-browser",
"whitelist":"favourites%.lua$|find%.lua$|home%-label%.lua$|url%-decode%.lua$|windir%.lua$|winroot%.lua$",
"dest":"~~/script-modules/file-browser-addons"
"git": "https://github.com/CogentRedTester/mpv-scripts",
"whitelist": "cycle%-commands%.lua$",
"dest": "~~/scripts"
},
{
"git":"https://gist.github.com/igv/8a77e4eb8276753b54bb94c1c50c317e",
"whitelist":"%.glsl$",
"dest":"~~/shaders/igv"
"git": "https://gist.github.com/igv/8a77e4eb8276753b54bb94c1c50c317e",
"whitelist": "%.glsl$",
"dest": "~~/shaders/igv"
},
{
"git":"https://gist.github.com/igv/a015fc885d5c22e6891820ad89555637",
"whitelist":"%.glsl$",
"dest":"~~/shaders/igv"
"git": "https://gist.github.com/igv/a015fc885d5c22e6891820ad89555637",
"whitelist": "%.glsl$",
"dest": "~~/shaders/igv"
},
{
"git":"https://gist.github.com/igv/2364ffa6e81540f29cb7ab4c9bc05b6b",
"whitelist":"%.glsl$",
"dest":"~~/shaders/igv"
"git": "https://gist.github.com/igv/2364ffa6e81540f29cb7ab4c9bc05b6b",
"whitelist": "%.glsl$",
"dest": "~~/shaders/igv"
},
{
"git":"https://gist.github.com/igv/36508af3ffc84410fe39761d6969be10",
"whitelist":"%.glsl$",
"dest":"~~/shaders/igv"
"git": "https://gist.github.com/igv/36508af3ffc84410fe39761d6969be10",
"whitelist": "%.glsl$",
"dest": "~~/shaders/igv"
},
{
"git":"https://github.com/Artoriuz/glsl-joint-bilateral",
"branch":"main",
"whitelist":"jointbilateral%.glsl$",
"dest":"~~/shaders/other"
"git": "https://github.com/Artoriuz/glsl-joint-bilateral",
"branch": "main",
"whitelist": "jointbilateral%.glsl$",
"dest": "~~/shaders/other"
},
{
"git":"https://github.com/Artoriuz/glsl-pixel-clipper",
"branch":"main",
"whitelist":"%.glsl$",
"dest":"~~/shaders/other"
"git": "https://github.com/Artoriuz/glsl-pixel-clipper",
"branch": "main",
"whitelist": "%.glsl$",
"dest": "~~/shaders/other"
},
{
"git":"https://github.com/bloc97/Anime4K",
"whitelist":"%.md$|%.glsl$",
"blacklist":"tensorflow",
"dest":"~~/shaders/Anime4K"
}]
"git": "https://github.com/bloc97/Anime4K",
"whitelist": "%.md$|%.glsl$",
"blacklist": "tensorflow",
"dest": "~~/shaders/Anime4K"
}
]
@@ -1,206 +0,0 @@
--[[
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 fb = require 'file-browser'
local save_path = mp.command_native({"expand-path", "~~/script-opts/file_browser_favourites.txt"}) --[[@as string]]
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
---@type Item[]
local favourites = {}
local favourites_loaded = false
---@type ParserConfig
local favs = {
api_version = "1.8.0",
priority = 30,
cursor = 1
}
local use_virtual_directory = true
---@type table<string,string>
local full_paths = {}
---@param str string
---@return Item
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
---@param self Parser
function favs:setup()
self:register_root_item('Favourites/')
end
local function update_favourites()
local file = io.open(save_path, "r")
if not file then return end
favourites = {}
for str in file:lines() do
table.insert(favourites, create_favourite_object(str))
end
file:close()
favourites_loaded = true
end
function favs:can_parse(directory)
return directory:find("Favourites/") == 1
end
---@async
---@param self Parser
---@param directory string
---@return List?
---@return Opts?
function favs:parse(directory)
if not favourites_loaded then update_favourites() end
if directory == "Favourites/" then
local opts = {
filtered = true,
sorted = true
}
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 = opts or {}
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 = opts or {}
opts.directory = opts.directory or path
return list, opts
end
---@param path string
---@return integer?
---@return Item?
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
---@async
local function update_browser()
if favs.get_directory():find("^[fF]avourites/$") then
local cursor = favs.get_selected_index()
fb.rescan_await()
fb.set_selected_index(cursor)
else
fb.clear_cache({'favourites/', 'Favourites/'})
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
---@async
local function toggle_favourite(cmd, state, co)
local path = fb.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
---@async
local function move_key(cmd, state, co)
if not state.directory:find("[fF]avourites/") then return false end
local path = fb.get_full_path(state.list[state.selected], state.directory)
local cursor = fb.get_selected_index()
if cmd.name == favs:get_id().."/move_up" then
move_favourite(path, -1)
fb.set_selected_index(cursor-1)
else
move_favourite(path, 1)
fb.set_selected_index(cursor+1)
end
update_browser()
end
update_favourites()
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
@@ -1,39 +0,0 @@
--[[
An addon for file-browser which decodes URLs so that they are more readable
]]
---@type ParserConfig
local urldecode = {
priority = 5,
api_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
---@type fun(s: string): string
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) ~= nil
end
---@async
function urldecode:parse(directory)
local list, opts = self:defer(directory)
opts = opts or {}
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
@@ -1,206 +0,0 @@
--[[
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 fb = require 'file-browser'
local save_path = mp.command_native({"expand-path", "~~/script-opts/file_browser_favourites.txt"}) --[[@as string]]
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
---@type Item[]
local favourites = {}
local favourites_loaded = false
---@type ParserConfig
local favs = {
api_version = "1.8.0",
priority = 30,
cursor = 1
}
local use_virtual_directory = true
---@type table<string,string>
local full_paths = {}
---@param str string
---@return Item
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
---@param self Parser
function favs:setup()
self:register_root_item('Favourites/')
end
local function update_favourites()
local file = io.open(save_path, "r")
if not file then return end
favourites = {}
for str in file:lines() do
table.insert(favourites, create_favourite_object(str))
end
file:close()
favourites_loaded = true
end
function favs:can_parse(directory)
return directory:find("Favourites/") == 1
end
---@async
---@param self Parser
---@param directory string
---@return List?
---@return Opts?
function favs:parse(directory)
if not favourites_loaded then update_favourites() end
if directory == "Favourites/" then
local opts = {
filtered = true,
sorted = true
}
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 = opts or {}
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 = opts or {}
opts.directory = opts.directory or path
return list, opts
end
---@param path string
---@return integer?
---@return Item?
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
---@async
local function update_browser()
if favs.get_directory():find("^[fF]avourites/$") then
local cursor = favs.get_selected_index()
fb.rescan_await()
fb.set_selected_index(cursor)
else
fb.clear_cache({'favourites/', 'Favourites/'})
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
---@async
local function toggle_favourite(cmd, state, co)
local path = fb.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
---@async
local function move_key(cmd, state, co)
if not state.directory:find("[fF]avourites/") then return false end
local path = fb.get_full_path(state.list[state.selected], state.directory)
local cursor = fb.get_selected_index()
if cmd.name == favs:get_id().."/move_up" then
move_favourite(path, -1)
fb.set_selected_index(cursor-1)
else
move_favourite(path, 1)
fb.set_selected_index(cursor+1)
end
update_browser()
end
update_favourites()
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
@@ -1,45 +0,0 @@
local fb = require "file-browser"
local opt = require "mp.options"
local o = {
--list of absolute paths separated by the root separators
paths = ""
}
--config file stored in ~~/script-opts/file-browser/filter.conf
opt.read_options(o, "file-browser/filter")
local parser = {
priority = 10,
api_version = "1.3.0"
}
local paths = {}
for str in fb.iterate_opt(o.paths) do
paths[str] = true
end
local function filter(path)
return paths[path]
end
function parser:can_parse()
return true
end
function parser:parse(directory)
local list, opts = self:defer(directory)
if not list then return list, opts end
directory = opts.directory or directory
for i=#list, 1, -1 do
if filter( fb.get_full_path(list[i], directory) ) then
table.remove(list, i)
end
end
return list, opts
end
return parser
@@ -1,41 +0,0 @@
local mp = require 'mp'
local msg = require 'mp.msg'
local fb = require 'file-browser'
local isos = {
name = 'iso-loader',
priority = 20,
api_version = '1.5'
}
function isos:setup()
fb.add_default_extension('iso')
end
function isos:can_parse()
return true
end
function isos:parse(directory, parse_state)
local list, opts = self:defer(directory, parse_state)
if not list or #list == 0 then return list, opts end
for _, item in ipairs(list) do
local path = fb.get_full_path(item, opts.directory or directory)
if fb.get_extension(path) == 'iso' then
item.mpv_options = { ['bluray-device'] = path, ['dvd-device'] = path }
item.path = 'bd://'
end
end
return list, opts
end
mp.add_hook('on_load_fail', 50, function()
if mp.get_property('stream-open-filename') == 'bd://' then
msg.info('failed to load bluray-device, attempting dvd-device')
mp.set_property('stream-open-filename', 'dvd://')
end
end)
return isos
@@ -1,124 +0,0 @@
--[[
This file is an internal file-browser addon.
It should not be imported like a normal module.
Allows searching the current directory.
]]--
local msg = require "mp.msg"
local fb = require "file-browser"
local input_loaded, input = pcall(require, "mp.input")
local user_input_loaded, user_input = pcall(require, "user-input-module")
---@type ParserConfig
local find = {
api_version = "1.3.0"
}
---@type thread|nil
local latest_coroutine = nil
---@type State
local global_fb_state = getmetatable(fb.get_state()).__original
---@param name string
---@param query string
---@return boolean
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
---@async
---@param key Keybind
---@param state State
---@param co thread
---@return boolean?
local function main(key, state, co)
if not state.list then return false end
---@type string
local text
if key.name == "find/find" then text = "Find: enter search string"
else text = "Find: enter advanced search string" end
if input_loaded then
input.get({
prompt = text .. "\n>",
id = "file-browser/find",
submit = fb.coroutine.callback(),
})
elseif user_input_loaded then
user_input.get_user_input( fb.coroutine.callback(), { text = text, id = "find", replace = true } )
end
local query, error = coroutine.yield()
if input_loaded then input.terminate() end
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
---@async
fb.coroutine.run(function()
latest_coroutine = coroutine.running()
---@type number
local rindex = 1
while (true) do
if rindex == 0 then rindex = #results
elseif rindex == #results + 1 then rindex = 1 end
fb.set_selected_index(results[rindex])
local direction = coroutine.yield(true) --[[@as number]]
rindex = rindex + direction
if parse_id ~= global_fb_state.co then
latest_coroutine = nil
return
end
end
end)
end
local function step_find(key)
if not latest_coroutine then return false end
---@type number
local direction = 0
if key.name == "find/next" then direction = 1
elseif key.name == "find/prev" then direction = -1 end
return fb.coroutine.resume_err(latest_coroutine, direction)
end
find.keybinds = {
{"Ctrl+f", "find", main, {}},
{"Ctrl+F", "find_advanced", main, {}},
{"n", "next", step_find, {}},
{"N", "prev", step_find, {}},
}
return find
@@ -1,31 +0,0 @@
--[[
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", "~/"}) --[[@as string]], true)
---@type ParserConfig
local home_label = {
priority = 100,
api_version = "1.0.0"
}
function home_label:can_parse(directory)
if not fb.get_opt('home_label') then return false end
return directory:sub(1, home:len()) == home
end
---@async
function home_label:parse(directory)
local list, opts = self:defer(directory)
if not opts then opts = {} end
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
@@ -1,218 +0,0 @@
--[[
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"
local PLATFORM = fb.get_platform()
---@param bytes string
---@return fun(): number, number
local function byte_iterator(bytes)
---@async
---@return number?
local function iter()
for i = 1, #bytes do
coroutine.yield(bytes:byte(i), i)
end
error('malformed utf16le string - expected byte but found end of string')
end
return coroutine.wrap(iter)
end
---@param bits number
---@param by number
---@return number
local function lshift(bits, by)
return bits * 2^by
end
---@param bits number
---@param by number
---@return integer
local function rshift(bits, by)
return math.floor(bits / 2^by)
end
---@param bits number
---@param i number
---@return number
local function bits_below(bits, i)
return bits % 2^i
end
---@param bits number
---@param i number exclusive
---@param j number inclusive
---@return integer
local function bits_between(bits, i, j)
return rshift(bits_below(bits, j), i)
end
---@param bytes string
---@return number[]
local function utf16le_to_unicode(bytes)
msg.trace('converting from utf16-le to unicode codepoints')
---@type number[]
local codepoints = {}
local get_byte = byte_iterator(bytes)
while true do
-- start of a char
local success, little, i = pcall(get_byte)
if not success then break end
local big = get_byte()
local codepoint = little + lshift(big, 8)
if codepoint < 0xd800 or codepoint > 0xdfff then
table.insert(codepoints, codepoint)
else
-- handling surrogate pairs
-- grab the next two bytes to grab the low surrogate
local high_pair = codepoint
local low_pair = get_byte() + lshift(get_byte(), 8)
if high_pair >= 0xdc00 then
error(('malformed utf16le string at byte #%d (0x%04X) - high surrogate pair should be < 0xDC00'):format(i, high_pair))
elseif low_pair < 0xdc00 then
error(('malformed utf16le string at byte #%d (0x%04X) - low surrogate pair should be >= 0xDC00'):format(i+2, low_pair))
end
-- The last 10 bits of each surrogate are the two halves of the codepoint
-- https://en.wikipedia.org/wiki/UTF-16#Code_points_from_U+010000_to_U+10FFFF
local high_bits = bits_below(high_pair, 10)
local low_bits = bits_below(low_pair, 10)
local surrogate_par = (low_bits + lshift(high_bits, 10)) + 0x10000
table.insert(codepoints, surrogate_par)
end
end
return codepoints
end
---@param codepoints number[]
---@return string
local function unicode_to_utf8(codepoints)
---@type number[]
local bytes = {}
-- https://en.wikipedia.org/wiki/UTF-8#Description
for i, codepoint in ipairs(codepoints) do
if codepoint >= 0xd800 and codepoint <= 0xdfff then
error(('codepoint %d (U+%05X) is within the reserved surrogate pair range (U+D800-U+DFFF)'):format(i, codepoint))
elseif codepoint <= 0x7f then
table.insert(bytes, codepoint)
elseif codepoint <= 0x7ff then
table.insert(bytes, 0xC0 + rshift(codepoint, 6))
table.insert(bytes, 0x80 + bits_below(codepoint, 6))
elseif codepoint <= 0xffff then
table.insert(bytes, 0xE0 + rshift(codepoint, 12))
table.insert(bytes, 0x80 + bits_between(codepoint, 6, 12))
table.insert(bytes, 0x80 + bits_below(codepoint, 6))
elseif codepoint <= 0x10ffff then
table.insert(bytes, 0xF0 + rshift(codepoint, 18))
table.insert(bytes, 0x80 + bits_between(codepoint, 12, 18))
table.insert(bytes, 0x80 + bits_between(codepoint, 6, 12))
table.insert(bytes, 0x80 + bits_below(codepoint, 6))
else
error(('codepoint %d (U+%05X) is larger than U+10FFFF'):format(i, codepoint))
end
end
return string.char(table.unpack(bytes))
end
local function utf8(text)
return unicode_to_utf8(utf16le_to_unicode(text))
end
---@type ParserConfig
local dir = {
priority = 109,
api_version = "1.9.0",
name = "cmd-dir",
keybind_name = "file"
}
---@async
---@param args string[]
---@param parse_state ParseState
---@return string|nil
local function command(args, parse_state)
local async = mp.command_native_async({
name = "subprocess",
playback_only = false,
capture_stdout = true,
capture_stderr = true,
args = args,
}, fb.coroutine.callback(30) )
---@type boolean, boolean, MPVSubprocessResult
local completed, _, cmd = parse_state:yield()
if not completed then
msg.warn('read timed out for:', table.unpack(args))
mp.abort_async_command(async)
return nil
end
local success = xpcall(function()
cmd.stdout = utf8(cmd.stdout) or ''
cmd.stderr = utf8(cmd.stderr) or ''
end, fb.traceback)
if not success then return msg.error('failed to convert utf16-le string to utf8') end
--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
if cmd.status ~= 0 then return msg.error(cmd.stderr) end
return cmd.status == 0 and cmd.stdout or nil
end
function dir:can_parse(directory)
if not fb.get_opt('windir_parser') then return false end
return PLATFORM == 'windows' and directory ~= '' and not fb.get_protocol(directory)
end
---@async
function dir:parse(directory, parse_state)
local list = {}
-- the dir command expects backslashes for our paths
directory = string.gsub(directory, "/", "\\")
local dirs = command({ "cmd", "/U", "/c", "dir", "/b", "/ad", directory }, parse_state)
if not dirs then return end
local files = command({ "cmd", "/U", "/c", "dir", "/b", "/a-d", directory }, parse_state)
if not files then return 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
@@ -1,62 +0,0 @@
--[[
This file is an internal file-browser addon.
It should not be imported like a normal module.
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.
]]
local mp = require 'mp'
local msg = require 'mp.msg'
local fb = require 'file-browser'
local PLATFORM = fb.get_platform()
---returns a list of windows drives
---@return string[]?
local function get_drives()
---@type MPVSubprocessResult?, string?
local result, err = mp.command_native({
name = 'subprocess',
playback_only = false,
capture_stdout = true,
args = {'fsutil', 'fsinfo', 'drives'}
})
if not result then return msg.error(err) end
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()
if fb.get_opt('auto_detect_windows_drives') and PLATFORM ~= 'windows' then return end
local drives = get_drives()
if not drives then return end
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
}
---@type ParserConfig
return {
api_version = '1.9.0',
setup = import_drives,
keybinds = { keybind }
}
@@ -1,86 +0,0 @@
local msg = require 'mp.msg'
local utils = require 'mp.utils'
local fb = require 'file-browser'
local parser = {
priority = 105,
api_version = '1.2.0'
}
-- stores a table of the parsers loaded by file-browser
-- we will use this to check if a parser is for a local file system
local parsers
local sort_mode = 0
function parser:setup()
parsers = fb.get_parsers()
end
function parser:parse(directory)
if sort_mode == 0 or fb.get_protocol(directory) then return end
local list, opts = self:defer(directory)
if not list then return list, opts end
-- Only run this on parsers that are for the local filesystem.
-- We assume that custom addons for the local filesystem are setting the keybind_name field to 'file'
-- for compatability.
if parsers[opts.id] then
if parsers[opts.id].keybind_name ~= 'file' and parsers[opts.id].name ~= 'file' then
return list, opts
end
end
directory = opts.directory or directory
local cache = {}
-- gets the file info of an item
-- uses memoisation to speed things up
function get_file_info(item)
if cache[item] then return cache[item] end
local path = fb.get_full_path(item, directory)
local file_info = utils.file_info(path)
if not file_info then
msg.warn('failed to read file info for', path)
return {}
end
cache[item] = file_info
return file_info
end
-- sorts the items based on the latest modification time
-- if mtime is undefined due to a file read failure then use 0
table.sort(list, function(a, b)
-- `dir` will compare as less than `file`
if a.type ~= b.type then return a.type < b.type end
if sort_mode == 1 then
return (get_file_info(a).mtime or 0) < (get_file_info(b).mtime or 0)
elseif sort_mode == 2 then
return (get_file_info(a).mtime or 0) > (get_file_info(b).mtime or 0)
elseif sort_mode == 3 then
return (get_file_info(a).size or 0) < (get_file_info(b).size or 0)
elseif sort_mode == 4 then
return (get_file_info(a).size or 0) > (get_file_info(b).size or 0)
end
end)
opts.sorted = true
return list, opts
end
-- adds the keybind to toggle sorting
parser.keybinds = {
{
key = '^',
name = 'toggle_sort',
command = function()
sort_mode = sort_mode + 1
if sort_mode > 4 then sort_mode = 0 end
fb.rescan()
end
}
}
return parser
@@ -1,39 +0,0 @@
--[[
An addon for file-browser which decodes URLs so that they are more readable
]]
---@type ParserConfig
local urldecode = {
priority = 5,
api_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
---@type fun(s: string): string
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) ~= nil
end
---@async
function urldecode:parse(directory)
local list, opts = self:defer(directory)
opts = opts or {}
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
@@ -1,51 +0,0 @@
local fb = require "file-browser"
local fb_utils = require 'modules.utils'
local PLATFORM = fb.get_platform()
-- Only enable Windows-specific sorting on Windows platforms
if PLATFORM == 'windows' then
-- this code is based on https://github.com/mpvnet-player/mpv.net/issues/575#issuecomment-1817413401
local ffi = require "ffi"
local winapi = {
ffi = ffi,
C = ffi.C,
CP_UTF8 = 65001,
shlwapi = ffi.load("shlwapi"),
}
-- ffi code from https://github.com/po5/thumbfast, Mozilla Public License Version 2.0
ffi.cdef[[
int __stdcall MultiByteToWideChar(unsigned int CodePage, unsigned long dwFlags, const char *lpMultiByteStr,
int cbMultiByte, wchar_t *lpWideCharStr, int cchWideChar);
int __stdcall StrCmpLogicalW(wchar_t *psz1, wchar_t *psz2);
]]
winapi.utf8_to_wide = function(utf8_str)
if utf8_str then
local utf16_len = winapi.C.MultiByteToWideChar(winapi.CP_UTF8, 0, utf8_str, -1, nil, 0)
if utf16_len > 0 then
local utf16_str = winapi.ffi.new("wchar_t[?]", utf16_len)
if winapi.C.MultiByteToWideChar(winapi.CP_UTF8, 0, utf8_str, -1, utf16_str, utf16_len) > 0 then
return utf16_str
end
end
end
return ""
end
fb_utils.sort = function (t)
table.sort(t, function(a, b)
local a_wide = winapi.utf8_to_wide(a.type:sub(1, 1) .. (a.label or a.name))
local b_wide = winapi.utf8_to_wide(b.type:sub(1, 1) .. (b.label or b.name))
return winapi.shlwapi.StrCmpLogicalW(a_wide, b_wide) == -1
end)
return t
end
end
return { api_version = '1.2.0' }
-3
View File
@@ -1,3 +0,0 @@
##指定在file-browser文件浏览器中需隐藏的目录以逗号分隔
##示例F:/$RECYCLE.BIN/
paths=
-238
View File
@@ -1,238 +0,0 @@
#######################################################
# This is the default config file for mpv-file-browser
# https://github.com/CogentRedTester/mpv-file-browser
#######################################################
# root 目录,以逗号分隔
# on linux你可能想要添加"/"
# on windows这应该用于添加不同的驱动器号
# Examples
# linux:
# root=~/,/
# windows:
# root=~/,C:/
root=~/
# characters 单独的根目录,每个字符单独工作
# 以防万一个人使用具有奇怪名称的目录
root_separators=,
# 要同时显示在屏幕上的条目数量
num_entries=20
# 要保留历史记录的目录数,大小为 0 时禁用历史记录
history_size=100
# 目录是否循环滚动,默认 yes
wrap=yes
# 是否启用插件默认no
addons=yes
# 启用自定义键绑定
# he keybind json 文件必须位于 ~~/script-opts
custom_keybinds=yes
# 自动检测 Windows 驱动器并将其添加到根目录
# 在根目录下使用 Ctrl+r 会运行另一次扫描
auto_detect_windows_drives=yes
# 当空闲模式下打开浏览器时,首选当前工作目录而不是根目录
# 工作目录无论如何都被设置为"当前"目录,因此播放时浏览器将自动定位至当前工作目录,即使此选项设置为 no
default_to_working_directory=no
# 打开浏览器时,更喜欢由文件浏览器的先前 MPV 实例打开的目录
# 覆盖`default_to_working_directory`选项
# 需要`save_last_opened_directory`为 yes
# 使用内部开放的 `last-opened-directory` 插件
default_to_last_opened_directory=no
# 是否保存最后一个打开的目录
save_last_opened_directory=no
# 播放文件更改时,将光标移至当前播放项目(如果有)
cursor_follows_playing_item=no
####################################
########## filter settings #########
####################################
# 只在浏览器中显示与 mpv 兼容的文件
filter_files=yes
# file 浏览器仅显示默认情况下与 mpv 兼容的文件
# 加入此列表中的文件扩展名将将其添加到扩展名白名单中
# 用根分隔符分隔,请勿使用任何空格
extension_whitelist=amv;bdmv;ifo;iso
# 加入此列表的文件扩展名以禁用默认文件类型
# 这将覆盖上面以及下面所有的白名单选项
#extension_blacklist=mpls
# 加入此列表中的文件扩展名将会添加到外挂音轨扩展名白名单中
# 用根分隔符分隔,请勿使用任何空格
audio_extensions=mka,dts,dtshd,dts-hd,truehd,true-hd,flac
# 加入此列表中的文件扩展名将会添加到字幕扩展名白名单中
# 用根分隔符分隔,请勿使用任何空格
subtitle_extensions=etf,etf8,utf-8,idx,sub,srt,rt,ssa,ass,mks,vtt,sup,scc,smi,lrc,pgs
# 过滤 .config 等以 '.' 开头的目录或文件
# 用于 linux 系统
#filter_dot_dirs=no
#filter_dot_files=no
####################################
###### file loading settings #######
####################################
# 这个选项可反转 alt+ENTER 键绑定的行为
# 当禁用密钥绑定,则需要为文件启用自动加载
# 当启用键绑定将禁用文件的自动加载
autoload=no
# 启用在将项目追加到播放列表时同时递归目录的功能实验性默认值no
# 此功能在将插件与异步 IO 结合使用时具有巨大的性能改进潜力
concurrent_recursion=yes
# 可以并发运行的最大递归数量
# 如果此数字太高,则可能会使 mpv 事件队列溢出从而导致某些目录被完全丢弃默认值16
max_concurrency=16
# 将本地文件追加到播放列表时,用正斜杠代替反斜杠
# 在 Windows 系统上可能有用默认值no
substitute_backslash=no
# 如果通过选择当前播放的文件触发自动加载,则当前文件在关闭和重新打开之前将保存其稍后观看的配置
# 禁用时当前文件将不会重新启动
autoload_save_current=yes
####################################
### directory parsing settings #####
####################################
# 目录缓存用于提高目录读取速度,
# 如果加载目录需要较长时间,可以启用此功能。
# 但可能会导致显示“幽灵”文件(已删除但仍然存在)
# 或者无法显示最近创建的文件。
# 使用 Ctrl+r 重新加载目录时不会使用缓存。
# 使用 Ctrl+Shift+r 可强制清除缓存。
cache=no
# 启用内部 `ls` 插件,该插件使用 `ls` 命令解析目录。
# 允许目录解析并行运行,从而防止浏览器卡顿。
# 在 Windows 系统上会自动禁用此功能。
ls_parser=yes
# 启用内部 `windir` 插件,该插件使用 cmd.exe 中的 `dir` 命令解析目录。
# 允许目录解析并行运行,从而防止浏览器卡顿。
# 在非 Windows 系统上会自动禁用此功能。
windir_parser=no
# 向上移动目录时,不要停止在空协议方案上,例如 `ftp://`
# 例如从 `ftp://localhost/` 向上移动将直接移动到根目录,而不是 `ftp://`
skip_protocol_schemes=yes
# 将光盘的驱动路径映射到它们各自的文件路径
# 例如,将 bd:// 映射到 bluray-device 属性的值
map_bd_device=yes
map_dvd_device=yes
map_cdda_device=yes
####################################
########## misc settings ###########
####################################
# 是否启用脚本信息来控制空闲屏幕上的徽标文字的显示
toggle_idlescreen=no
# 将路径中的反斜杠 '\' 解释为正斜杠 '/'
# 这在 Windows 上很有用,因为 Windows 本身使用反斜杠。
# 由于反斜杠是 Unix 系统中有效的文件名字符,因此可能导致路径损坏,但此类文件名很少见
# 使用"yes"和"no"启用/禁用。"auto"尝试使用 MPV 的 "platform" 该属性mpv v0.36+)来决定
# 如果该属性不可用,则默认为 "yes"
normalise_backslash=auto
# 在`user-data`属性的`file_browser/open`字段中设置浏览器当前的打开状态
# 此属性仅在 mpv v0.36+ 中可用
set_user_data=yes
# 在`shared-script-properties`属性的`file_browser-open`字段中设置浏览器当前的打开状态
# 该属性已被弃用
set_shared_script_properties=no
####################################
########## file overrides #########
####################################
# directory 加载外部模块
module_directory=~~/script-modules
addon_directory=~~/script-modules/file-browser-addons
custom_keybinds_file=~~/script-opts/file-browser-keybinds.json
last_opened_directory_file=~~/files/file_browser-last_opened_directory
####################################
######### style settings ###########
####################################
# 用"~/"在标题中替换用户的主目录,使用内部标签插件实现
home_label=yes
# 设置文件浏览器以使用特定的文本对齐方式(默认:左上角)
# 使用 ASS 标签对齐编号https://aegi.vmoe.info/docs/3.0/ASS_Tags/#index23h3
# 设置为 'auto' 以使用默认的 mpv osd 对齐选项
# 选项:'auto'|'top'|'center'|'bottom'
align_y=top
# 选项: 'auto'|'left'|'center'|'right'
align_x=left
# 用于标头的格式字符串。使用自定义键绑定替换代码
# 动态更改标头的内容。请参阅docs/custom-keybinds.md#codes
# 例如,要添加文件编号请将其设置为: {\fnMonospace}[%i/%x]{\fn<font_name_header or blank>} %q\N----------------------------------------------------
format_string_header=%q\N----------------------------------------------------
# 用于包装器的格式字符串。支持自定义键绑定替换代码,以及支持两个附加代码:'%<'和'%>',分别显示可见列表前后的项数
# 将这些选项设置为空字符串将禁用包装器
format_string_topwrapper=%< 项 覆盖\N
format_string_bottomwrapper=\N%> 项 剩余
# 允许为光标和文件夹自定义图标,可以为矢量图形或 Unicode 字形。示例即为默认设置(矢量图形)
#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
#cursor_icon_flipped={\p1}m 0.13 6.86 l 13.9 0.02 b 14 -0.02 14.11 -0 14.19 0.08 b 14.26 0.16 14.27 0.28 14.21 0.36 l 10.87 5.55 l 10.87 5.55 10.44 6.79 10.64 8.08 10.86 8.5l 14.21 13.77 b 14.27 13.86 14.26 13.98 14.19 14.06 b 14.14 14.11 14.07 14.13 14.01 14.13 b 13.97 14.13 13.94 14.13 13.9 14.11 l 0.13 7.28 b 0.05 7.24 0 7.16 0 7.07 b 0 6.98 0.05 6.9 0.13 6.86{\p0}\h
# 设置字体的不透明度(十六进制),从 00不透明到 FF透明
font_opacity_selection_marker=99
# 页眉使用粗体
font_bold_header=yes
# 指定缩放浏览器的大小2 会使大小增加一倍0.5 会将其减半,依此类推。
# header 和 wrappers 相对于 base 的大小进行缩放
scaling_factor_base=1
scaling_factor_header=1.4
scaling_factor_wrappers=0.64
# 自定义字体名称,默认值为空白
# 设置文件夹/光标的自定义字体可以修复损坏或丢失的图标
#font_name_header=
font_name_body=Noto Sans CJK SC,Noto Color Emoji
#font_name_wrappers=
#font_name_folder=
#font_name_cursor=
# 自定义字体颜色
# colours 采用十六进制格式,按蓝绿色红色顺序排列
# 这与大多数 RGB 颜色代码的顺序相反
font_colour_header=00ccff
font_colour_body=ffffff
font_colour_wrappers=00ccff
font_colour_cursor=00ccff
font_colour_escape_chars=413eff
# 以下选项是应用于不同状态的列表项的颜色
font_colour_selected=fce788
font_colour_multiselect=fcad88
font_colour_playing=33ff66
font_colour_playing_multiselected=22b547
-21
View File
@@ -1,21 +0,0 @@
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.
-229
View File
@@ -1,229 +0,0 @@
# 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 older versions of file-browser in the
[mpv-v0.31 branch](https://github.com/CogentRedTester/mpv-file-browser/tree/mpv-v0.31).
That branch will no longer be receiving any feature updates,
but I will try to fix any bugs that are reported on the issue
tracker.
## 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.
### Configuration
Create a `file_browser.conf` file in the `~~/script-opts/` directory to configure the script.
See [docs/file_browser.conf](docs/file_browser.conf) for the full list of options and their default values.
The [`root` option](#root-directory) may be worth tweaking for your system.
### Addons
To use [addons](addons/README.md) place addon files in the `~~/script-modules/file-browser-addons/` directory.
### Custom Keybinds
To setup [custom keybinds](docs/custom-keybinds.md) 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.
### File Structure
<details>
<summary>Expected directory tree (basic):</summary>
```
~~/
├── script-opts
│   └── file_browser.conf
└── scripts
   └── file-browser
      ├── addons/
      ├── docs/
      ├── modules/
      ├── screenshots/
      ├── LICENSE
      ├── main.lua
      └── README.md
```
</details>
<details>
<summary>Expected directory tree (full):</summary>
```
~~/
├── script-modules
│   └── file-browser-addons
│   ├── addon1.lua
│   ├── addon2.lua
│   └── etc.lua
├── script-opts
│   ├── file_browser.conf
│   └── file-browser-keybinds.json
└── scripts
   └── file-browser
      ├── addons/
      ├── docs/
      ├── modules/
      ├── screenshots/
      ├── LICENSE
      ├── main.lua
      └── README.md
```
</details>
## 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) when mpv < v0.38 |
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 |
| Alt+LEFT | history_back | move to previously open directory |
| Alt+RIGHT | history_forward| move forwards again in history to the next directory |
| Ctrl+r | reload | reload current directory |
| Ctrl+Shift+r| cache/clear | clears the directory cache (disabled by default) |
| 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 |
| Ctrl+f | find/find | Opens a text input to search the contents of the folder - requires [mpv-user-input](#mpv-user-input) when mpv < v0.38|
| Ctrl+F | find/find_advanced| Allows using [Lua Patterns](https://www.lua.org/manual/5.1/manual.html#5.4.1) in the search input|
| n | find/next | Jumps to the next matching entry for the latest search term |
| N | find/prev | Jumps to the previous matching entry for the latest search term |
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 `~/` and `C:/` are set on Windows
and `~/` and `/` are set on non-Windows systems.
Extra locations can be added manually, 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.
## 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.
Mpv v0.38 added the `mp.input` module, which means `mpv-user-input` is no-longer necessary from that version onwards.
-12
View File
@@ -1,12 +0,0 @@
# 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).
File diff suppressed because it is too large Load Diff
@@ -1,330 +0,0 @@
# 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) |
| api_version | no | - | tie the keybind to a particular [addon API version](./addons.md#api-version), printing warnings and throwing errors if the keybind is used with wrong versions |
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, in mpv v0.38+ the `mp.input` module is available as `input`.
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 `user_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(user_input, 'install mpv-user-input!')",
"local line, err = user_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).
@@ -1,51 +0,0 @@
[
{
"comment": "deletes the currently selected file",
"key": "Alt+DEL",
"command": ["script-message", "run-statement", "os.remove(%F) ; fb.rescan()"],
"multiselect": true,
"multi-type": "repeat"
},
{
"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"]
],
"condition": "fb.get_platform() == 'windows'",
"api_version": "1.9.0",
"multiselect": true,
"delay": 0.3
},
{
"key": "WHEEL_UP",
"command": ["script-binding", "file_browser/dynamic/scroll_up"],
"flags": { "repeat": true }
},
{
"key": "WHEEL_DOWN",
"command": ["script-binding", "file_browser/dynamic/scroll_down"],
"flags": { "repeat": true }
},
{
"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"]
}
]
-247
View File
@@ -1,247 +0,0 @@
#######################################################
# This is the default config file for mpv-file-browser
# https://github.com/CogentRedTester/mpv-file-browser
#######################################################
####################################
######## browser settings ##########
####################################
# Root directories, separated by commas.
# `C:/` and `/` are automatically added on Windows and non-windows systems, respectively.
# The order of automatically added items can be changed by entering them here manually.
root=~/
# characters to separate root directories, each character works individually
root_separators=,
# number of entries to show on the screen at once
num_entries=20
# number of directories to keep in the history.
# A size of 0 disables the history.
history_size=100
# wrap the cursor around the top and bottom of the list
wrap=no
# enables loading external addons
addons=yes
# enable custom keybinds
# the keybind json file must go in ~~/script-opts
custom_keybinds=yes
# Automatically detect windows drives and adds them to the root.
# Using Ctrl+r in the root will run another scan.
auto_detect_windows_drives=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
# When opening the browser prefer the directory last opened by a previous mpv instance of file-browser.
# Overrides the `default_to_working_directory` option.
# Requires `save_last_opened_directory` to be `yes`.
# Uses the internal `last-opened-directory` addon.
default_to_last_opened_directory=no
# Whether to save the last opened directory.
save_last_opened_directory=no
# Move the cursor to the currently playing item (if available) when the playing file changes.
cursor_follows_playing_item=no
####################################
########## filter settings #########
####################################
# only show files compatible with mpv in the browser
filter_files=yes
# 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 commas, 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
# auto will show dot entries on windows and hide them otherwise
filter_dot_dirs=auto
filter_dot_files=auto
####################################
###### file loading settings #######
####################################
# 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
# 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=yes
# 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
# substitute forward slashes for backslashes when appending a local file to the playlist
# may be useful on windows systems
substitute_backslash=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
####################################
### directory parsing settings #####
####################################
# a directory cache to improve directory reading time,
# enable if it takes a long time to load directories.
# May cause 'ghost' files to be shown that no-longer exist or
# fail to show files that have recently been created.
# Reloading the directory with Ctrl+r will never use the cache.
# Use Ctrl+Shift+r to forcibly clear the cache.
cache=no
# Enables the internal `ls` addon that parses directories using the `ls` commandline tool.
# Allows directory parsing to run concurrently, which prevents the browser from locking up.
# Automatically disables itself on Windows systems.
ls_parser=yes
# Enables the internal `windir` addon that parses directories using the `dir` command in cmd.exe.
# Allows directory parsing to run concurrently, which prevents the browser from locking up.
# Automatically disables itself on non-Windows systems.
windir_parser=yes
# when moving up a directory do not stop on empty protocol schemes like `ftp://`
# e.g. moving up from `ftp://localhost/` will move straight to the root instead of `ftp://`
skip_protocol_schemes=yes
# map optical device paths to their respective file paths,
# e.g. mapping bd:// to the value of the bluray-device property
map_bd_device=yes
map_dvd_device=yes
map_cdda_device=yes
####################################
########## misc settings ###########
####################################
# 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
# interpret backslashes `\` in paths as forward slashes `/`
# this is useful on Windows, which natively uses backslashes.
# As backslashes are valid filename characters in Unix systems this could
# cause mangled paths, though such filenames are rare.
# Use `yes` and `no` to enable/disable. `auto` tries to use the mpv `platform`
# property (mpv v0.36+) to decide. If the property is unavailable it defaults to `yes`.
normalise_backslash=auto
# 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=no
####################################
########## file overrides #########
####################################
# directory to load external modules - currently just user-input-module
module_directory=~~/script-modules
addon_directory=~~/script-modules/file-browser-addons
custom_keybinds_file=~~/script-opts/file-browser-keybinds.json
last_opened_directory_file=~~state/file_browser-last_opened_directory
####################################
######### style settings ###########
####################################
# Replace the user's home directory with `~/` in the header.
# Uses the internal home-label addon.
home_label=yes
# force file-browser to use a specific alignment (default: top-left)
# set to auto to use the default mpv osd-align options
# Options: 'auto'|'top'|'center'|'bottom'
align_y=top
# Options: 'auto'|'left'|'center'|'right'
align_x=left
# 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)
# and supports the additional code `%^`which re-applies the default header ass style.
# The original style used before the current one was: %q\N----------------------------------------------------
format_string_header={\fnMonospace}[%i/%x]%^ %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.
# Original styles used before the current ones were:
# top: %< item(s) above\N
# bottom: \N%> item(s) remaining
format_string_topwrapper=...
format_string_bottomwrapper=...
# 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
cursor_icon_flipped={\p1}m 0.13 6.86 l 13.9 0.02 b 14 -0.02 14.11 -0 14.19 0.08 b 14.26 0.16 14.27 0.28 14.21 0.36 l 10.87 5.55 l 10.87 5.55 10.44 6.79 10.64 8.08 10.86 8.5l 14.21 13.77 b 14.27 13.86 14.26 13.98 14.19 14.06 b 14.14 14.11 14.07 14.13 14.01 14.13 b 13.97 14.13 13.94 14.13 13.9 14.11 l 0.13 7.28 b 0.05 7.24 0 7.16 0 7.07 b 0 6.98 0.05 6.9 0.13 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=1
# 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 RGB colour codes
font_colour_header=00ccff
font_colour_body=ffffff
font_colour_wrappers=00ccff
font_colour_cursor=00ccff
font_colour_escape_chars=413eff
# 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
-76
View File
@@ -1,76 +0,0 @@
--[[
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'
-- setting the package paths
package.path = mp.command_native({"expand-path", o.module_directory}).."/?.lua;"..package.path
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 input_loaded, input = pcall(require, "mp.input")
local user_input_loaded, user_input = pcall(require, "user-input-module")
-- root and addon setup
setup.root()
addons.load_internal_addons()
if o.addons then addons.load_external_addons() end
addons.setup_addons()
--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)
if o.map_dvd_device then mp.observe_property('dvd-device', 'string', observers.dvd_device) end
if o.map_bd_device then mp.observe_property('bluray-device', 'string', observers.bd_device) end
if o.map_cdda_device then mp.observe_property('cdda-device', 'string', observers.cd_device) end
if o.align_x == 'auto' then mp.observe_property('osd-align-x', 'string', observers.osd_align) end
if o.align_y == 'auto' then mp.observe_property('osd-align-y', 'string', observers.osd_align) end
-- 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_loaded then
mp.add_key_binding("Alt+o", "browse-directory/get-user-input", function()
input.get({
prompt = "open directory:",
id = "file-browser/browse-directory",
submit = function(text)
controls.browse_directory(text)
input.terminate()
end
})
end)
elseif user_input_loaded then
mp.add_key_binding("Alt+o", "browse-directory/get-user-input", function()
user_input.get_user_input(controls.browse_directory, {request_text = "open directory:"})
end)
end
-204
View File
@@ -1,204 +0,0 @@
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_utils = require 'modules.utils'
local parser_API = require 'modules.apis.parser'
local API_MAJOR, API_MINOR, API_PATCH = g.API_VERSION:match("(%d+)%.(%d+)%.(%d+)")
API_MAJOR, API_MINOR, API_PATCH = tonumber(API_MAJOR), tonumber(API_MINOR), tonumber(API_PATCH)
---checks if the given parser has a valid version number
---@param parser Parser|Keybind
---@param id string
---@return boolean?
local function check_api_version(parser, id)
if parser.version then
msg.warn(('%s: use of the `version` field is deprecated - use `api_version` instead'):format(id))
parser.api_version = parser.version
end
local version = parser.api_version
if type(version) ~= 'string' then return msg.error(("%s: field `api_version` must be a string, got %s"):format(id, tostring(version))) end
local major, minor = version:match("(%d+)%.(%d+)")
major, minor = tonumber(major), tonumber(minor)
if not major or not minor then
return msg.error(("%s: invalid version number, expected v%d.%d.x, got v%s"):format(id, API_MAJOR, API_MINOR, version))
elseif major ~= API_MAJOR then
return msg.error(("%s has wrong major version number, expected v%d.x.x, got, v%s"):format(id, API_MAJOR, version))
elseif minor > API_MINOR then
msg.warn(("%s has newer minor version number than API, expected v%d.%d.x, got v%s"):format(id, API_MAJOR, API_MINOR, version))
end
return true
end
---create a unique id for the given parser
---@param parser 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
---runs an addon in a separate environment
---@param path string
---@return unknown
local function run_addon(path)
local name_sqbr = string.format("[%s]", path:match("/([^/]*)%.lua$"))
local addon_environment = fb_utils.redirect_table(_G)
addon_environment._G = addon_environment ---@diagnostic disable-line inject-field
--gives each addon custom debug messages
addon_environment.package = fb_utils.redirect_table(addon_environment.package) ---@diagnostic disable-line inject-field
addon_environment.package.loaded = fb_utils.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 ---@diagnostic disable-line inject-field
addon_environment.require = function(module) ---@diagnostic disable-line inject-field
if module == "mp.msg" then return msg_module end
return require(module)
end
---@type function?, string?
local chunk, err
if setfenv then ---@diagnostic disable-line deprecated
--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) ---@diagnostic disable-line deprecated
else
chunk, err = _G.loadfile(path, "bt", addon_environment) ---@diagnostic disable-line redundant-parameter
if not chunk then return msg.error(err) end
end
---@diagnostic disable-next-line no-unknown
local success, result = xpcall(chunk, fb_utils.traceback)
return success and result or nil
end
---Setup an internal or external parser.
---Note that we're somewhat bypassing the type system here as we're converting from a
---ParserConfig object to a Parser object. As such we need to make sure that the
---we're doing everything correctly. A 2.0 release of the addon API could simplify
---this by formally separating ParserConfigs from Parsers and providing an
---API to register parsers.
---@param parser ParserConfig
---@param file string
---@return nil
local function setup_parser(parser, file)
parser = setmetatable(parser, { __index = parser_API }) --[[@as Parser]]
parser.name = parser.name or file:gsub("%-browser%.lua$", ""):gsub("%.lua$", "")
set_parser_id(parser)
if not check_api_version(parser, file) 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
table.insert(g.parsers, parser)
end
---load an external addon
---@param file string
---@param path string
---@return nil
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 = run_addon(path) --[=[@as ParserConfig|ParserConfig[]]=]
if addon_parsers and not next(addon_parsers) then return msg.verbose('addon', path, 'returned empry table - special case, ignoring') end
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 --[=[@as ParserConfig[]]=]) do
setup_parser(parser, file)
end
end
---loading external addons
---@param directory string
---@return nil
local function load_addons(directory)
directory = fb_utils.fix_path(directory, true)
local files = utils.readdir(directory)
if not files then return msg.verbose('not loading external addons - could not read', o.addon_directory) end
for _, file in ipairs(files) do
setup_addon(file, directory..file)
end
end
local function load_internal_addons()
local script_dir = mp.get_script_directory()
if not script_dir then return msg.error('script is not being run as a directory script!') end
local internal_addon_dir = script_dir..'/modules/addons/'
load_addons(internal_addon_dir)
end
local function load_external_addons()
local addon_dir = mp.command_native({"expand-path", o.addon_directory..'/'}) --[[@as string|nil]]
if not addon_dir then return msg.verbose('not loading external addons - could not resolve', o.addon_directory) end
load_addons(addon_dir)
end
---Orders the addons by priority, sets the parser index values,
---and runs the setup methods of the addons.
local function setup_addons()
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, fb_utils.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
---@class addons
return {
check_api_version = check_api_version,
load_internal_addons = load_internal_addons,
load_external_addons = load_external_addons,
setup_addons = setup_addons,
}
@@ -1,146 +0,0 @@
--[[
This file is an internal file-browser addon.
It should not be imported like a normal module.
Maintains a cache of the accessed directories to improve
parsing speed. Disabled by default.
]]
local mp = require 'mp'
local msg = require 'mp.msg'
local utils = require 'mp.utils'
local fb = require 'file-browser'
---@type ParserConfig
local cacheParser = {
name = 'cache',
priority = 0,
api_version = '1.9',
}
---@class CacheEntry
---@field list List
---@field opts Opts?
---@field timeout MPTimer
---@type table<string,CacheEntry>
local cache = {}
---@type table<string,(async fun(list: List?, opts: Opts?))[]>
local pending_parses = {}
---@param directories? string[]
local function clear_cache(directories)
if directories then
msg.debug('clearing cache for', #directories, 'directorie(s)')
for _, dir in ipairs(directories) do
if cache[dir] then
msg.trace('clearing cache for', dir)
cache[dir].timeout:kill()
cache[dir] = nil
end
end
else
msg.debug('clearing cache')
for _, entry in pairs(cache) do
entry.timeout:kill()
end
cache = {}
end
end
---@type string
local prev_directory = ''
function cacheParser:can_parse(directory, parse_state)
-- allows the cache to be forcibly used or bypassed with the
-- cache/use parse property.
if parse_state.properties.cache and parse_state.properties.cache.use ~= nil then
if parse_state.source == 'browser' then prev_directory = directory end
return parse_state.properties.cache.use
end
-- the script message is guaranteed to always bypass the cache
if parse_state.source == 'script-message' then return false end
if not fb.get_opt('cache') or directory == '' then return false end
-- clear the cache if reloading the current directory in the browser
-- this means that fb.rescan() should maintain expected behaviour
if parse_state.source == 'browser' then
if prev_directory == directory then clear_cache({directory}) end
prev_directory = directory
end
return true
end
---@async
function cacheParser:parse(directory)
if cache[directory] then
msg.verbose('fetching', directory, 'contents from cache')
cache[directory].timeout:kill()
cache[directory].timeout:resume()
return cache[directory].list, cache[directory].opts
end
---@type List?, Opts?
local list, opts
-- if another parse is already running on the same directory, then wait and use the same result
if not pending_parses[directory] then
pending_parses[directory] = {}
list, opts = self:defer(directory)
else
msg.debug('parse for', directory, 'already running - waiting for other parse to finish...')
table.insert(pending_parses[directory], fb.coroutine.callback(30))
list, opts = coroutine.yield()
end
local pending = pending_parses[directory]
-- need to clear the pending parses before resuming them or they will also attempt to resume the parses
pending_parses[directory] = nil
if pending and #pending > 0 then
msg.debug('resuming', #pending, 'pending parses for', directory)
for _, cb in ipairs(pending) do
cb(list, opts)
end
end
if not list then return end
-- pending will be truthy for the original parse and falsy for any parses that were pending
if pending then
msg.debug('storing', directory, 'contents in cache')
cache[directory] = {
list = list,
opts = opts,
timeout = mp.add_timeout(120, function() cache[directory] = nil end),
}
end
return list, opts
end
cacheParser.keybinds = {
{
key = 'Ctrl+Shift+r',
name = 'clear',
command = function() clear_cache() ; fb.rescan() end,
}
}
-- provide method of clearing the cache through script messages
mp.register_script_message('cache/clear', function(dirs)
if not dirs then
return clear_cache()
end
---@type string[]?
local directories = utils.parse_json(dirs)
if not directories then msg.error('unable to parse', dirs) end
clear_cache(directories)
end)
return cacheParser
@@ -1,46 +0,0 @@
-- This file is an internal file-browser addon.
-- It should not be imported like a normal module.
local msg = require 'mp.msg'
local utils = require 'mp.utils'
---Parser for native filesystems
---@type ParserConfig
local file_parser = {
name = "file",
priority = 110,
api_version = '1.0.0',
}
--try to parse any directory except for the root
function file_parser:can_parse(directory)
return directory ~= ''
end
--scans the given directory using the mp.utils.readdir function
function file_parser:parse(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]
msg.trace(item..'/')
table.insert(new_list, {name = item..'/', type = 'dir'})
end
--appends files to the list of directory items
local list2 = utils.readdir(directory, 'files')
if list2 == nil then return nil end
for i=1, #list2 do
local item = list2[i]
msg.trace(item)
table.insert(new_list, {name = item, type = 'file'})
end
return new_list
end
return file_parser
@@ -1,124 +0,0 @@
--[[
This file is an internal file-browser addon.
It should not be imported like a normal module.
Allows searching the current directory.
]]--
local msg = require "mp.msg"
local fb = require "file-browser"
local input_loaded, input = pcall(require, "mp.input")
local user_input_loaded, user_input = pcall(require, "user-input-module")
---@type ParserConfig
local find = {
api_version = "1.3.0"
}
---@type thread|nil
local latest_coroutine = nil
---@type State
local global_fb_state = getmetatable(fb.get_state()).__original
---@param name string
---@param query string
---@return boolean
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
---@async
---@param key Keybind
---@param state State
---@param co thread
---@return boolean?
local function main(key, state, co)
if not state.list then return false end
---@type string
local text
if key.name == "find/find" then text = "Find: enter search string"
else text = "Find: enter advanced search string" end
if input_loaded then
input.get({
prompt = text .. "\n>",
id = "file-browser/find",
submit = fb.coroutine.callback(),
})
elseif user_input_loaded then
user_input.get_user_input( fb.coroutine.callback(), { text = text, id = "find", replace = true } )
end
local query, error = coroutine.yield()
if input_loaded then input.terminate() end
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
---@async
fb.coroutine.run(function()
latest_coroutine = coroutine.running()
---@type number
local rindex = 1
while (true) do
if rindex == 0 then rindex = #results
elseif rindex == #results + 1 then rindex = 1 end
fb.set_selected_index(results[rindex])
local direction = coroutine.yield(true) --[[@as number]]
rindex = rindex + direction
if parse_id ~= global_fb_state.co then
latest_coroutine = nil
return
end
end
end)
end
local function step_find(key)
if not latest_coroutine then return false end
---@type number
local direction = 0
if key.name == "find/next" then direction = 1
elseif key.name == "find/prev" then direction = -1 end
return fb.coroutine.resume_err(latest_coroutine, direction)
end
find.keybinds = {
{"Ctrl+f", "find", main, {}},
{"Ctrl+F", "find_advanced", main, {}},
{"n", "next", step_find, {}},
{"N", "prev", step_find, {}},
}
return find
@@ -1,31 +0,0 @@
--[[
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", "~/"}) --[[@as string]], true)
---@type ParserConfig
local home_label = {
priority = 100,
api_version = "1.0.0"
}
function home_label:can_parse(directory)
if not fb.get_opt('home_label') then return false end
return directory:sub(1, home:len()) == home
end
---@async
function home_label:parse(directory)
local list, opts = self:defer(directory)
if not opts then opts = {} end
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
@@ -1,62 +0,0 @@
--[[
An addon for mpv-file-browser which stores the last opened directory and
sets it as the opened directory the next time mpv is opened.
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'
local state_file = mp.command_native({'expand-path', fb.get_opt('last_opened_directory_file')}) --[[@as string]]
msg.verbose('using', state_file)
---@param directory? string
---@return nil
local function write_directory(directory)
if not fb.get_opt('save_last_opened_directory') then return end
local file = io.open(state_file, 'w+')
if not file then return msg.error('could not open', state_file, 'for writing') end
directory = directory or fb.get_directory() or ''
msg.verbose('writing', directory, 'to', state_file)
file:write(directory)
file:close()
end
---@type ParserConfig
local addon = {
api_version = '1.7.0',
priority = 0,
}
function addon:setup()
if not fb.get_opt('default_to_last_opened_directory') then return end
local file = io.open(state_file, "r")
if not file then
return msg.info('failed to open', state_file, 'for reading (may be due to first load)')
end
local dir = file:read("*a")
msg.verbose('setting default directory to', dir)
fb.browse_directory(dir, false)
file:close()
end
function addon:can_parse(dir, parse_state)
if parse_state.source == 'browser' then write_directory(dir) end
return false
end
function addon:parse()
return nil
end
mp.register_event('shutdown', function() write_directory() end)
return addon
@@ -1,68 +0,0 @@
--[[
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 msg = require "mp.msg"
local fb = require "file-browser"
local PLATFORM = fb.get_platform()
---@type ParserConfig
local ls = {
priority = 109,
api_version = "1.9.0",
name = "ls",
keybind_name = "file"
}
---@async
---@param args string[]
---@param parse_state ParseState
---@return string|nil
local function command(args, parse_state)
local async = mp.command_native_async({
name = "subprocess",
playback_only = false,
capture_stdout = true,
capture_stderr = true,
args = args
}, fb.coroutine.callback(30))
---@type boolean, boolean, MPVSubprocessResult
local completed, _, cmd = parse_state:yield()
if not completed then
msg.warn('read timed out for:', table.unpack(args))
mp.abort_async_command(async)
return nil
end
return cmd.status == 0 and cmd.stdout or nil
end
function ls:can_parse(directory)
if not fb.get_opt('ls_parser') then return false end
return PLATFORM ~= 'windows' and directory ~= '' and not fb.get_protocol(directory)
end
---@async
function ls:parse(directory, parse_state)
local list = {}
local files = command({"ls", "-1", "-p", "-A", "-N", "--zero", "-L", directory}, parse_state)
if not files then return nil end
for str in files:gmatch("%Z+") do
local is_dir = str:sub(-1) == "/"
msg.trace(str)
table.insert(list, {name = str, type = is_dir and "dir" or "file"})
end
return list
end
return ls
@@ -1,26 +0,0 @@
-- This file is an internal file-browser addon.
-- It should not be imported like a normal module.
local g = require 'modules.globals'
---Parser for the root.
---@type ParserConfig
local root_parser = {
name = "root",
priority = math.huge,
api_version = '1.0.0',
}
function root_parser:can_parse(directory)
return directory == ''
end
--we return the root directory exactly as setup
function root_parser:parse()
return g.root, {
sorted = true,
filtered = true,
}
end
return root_parser
@@ -1,218 +0,0 @@
--[[
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"
local PLATFORM = fb.get_platform()
---@param bytes string
---@return fun(): number, number
local function byte_iterator(bytes)
---@async
---@return number?
local function iter()
for i = 1, #bytes do
coroutine.yield(bytes:byte(i), i)
end
error('malformed utf16le string - expected byte but found end of string')
end
return coroutine.wrap(iter)
end
---@param bits number
---@param by number
---@return number
local function lshift(bits, by)
return bits * 2^by
end
---@param bits number
---@param by number
---@return integer
local function rshift(bits, by)
return math.floor(bits / 2^by)
end
---@param bits number
---@param i number
---@return number
local function bits_below(bits, i)
return bits % 2^i
end
---@param bits number
---@param i number exclusive
---@param j number inclusive
---@return integer
local function bits_between(bits, i, j)
return rshift(bits_below(bits, j), i)
end
---@param bytes string
---@return number[]
local function utf16le_to_unicode(bytes)
msg.trace('converting from utf16-le to unicode codepoints')
---@type number[]
local codepoints = {}
local get_byte = byte_iterator(bytes)
while true do
-- start of a char
local success, little, i = pcall(get_byte)
if not success then break end
local big = get_byte()
local codepoint = little + lshift(big, 8)
if codepoint < 0xd800 or codepoint > 0xdfff then
table.insert(codepoints, codepoint)
else
-- handling surrogate pairs
-- grab the next two bytes to grab the low surrogate
local high_pair = codepoint
local low_pair = get_byte() + lshift(get_byte(), 8)
if high_pair >= 0xdc00 then
error(('malformed utf16le string at byte #%d (0x%04X) - high surrogate pair should be < 0xDC00'):format(i, high_pair))
elseif low_pair < 0xdc00 then
error(('malformed utf16le string at byte #%d (0x%04X) - low surrogate pair should be >= 0xDC00'):format(i+2, low_pair))
end
-- The last 10 bits of each surrogate are the two halves of the codepoint
-- https://en.wikipedia.org/wiki/UTF-16#Code_points_from_U+010000_to_U+10FFFF
local high_bits = bits_below(high_pair, 10)
local low_bits = bits_below(low_pair, 10)
local surrogate_par = (low_bits + lshift(high_bits, 10)) + 0x10000
table.insert(codepoints, surrogate_par)
end
end
return codepoints
end
---@param codepoints number[]
---@return string
local function unicode_to_utf8(codepoints)
---@type number[]
local bytes = {}
-- https://en.wikipedia.org/wiki/UTF-8#Description
for i, codepoint in ipairs(codepoints) do
if codepoint >= 0xd800 and codepoint <= 0xdfff then
error(('codepoint %d (U+%05X) is within the reserved surrogate pair range (U+D800-U+DFFF)'):format(i, codepoint))
elseif codepoint <= 0x7f then
table.insert(bytes, codepoint)
elseif codepoint <= 0x7ff then
table.insert(bytes, 0xC0 + rshift(codepoint, 6))
table.insert(bytes, 0x80 + bits_below(codepoint, 6))
elseif codepoint <= 0xffff then
table.insert(bytes, 0xE0 + rshift(codepoint, 12))
table.insert(bytes, 0x80 + bits_between(codepoint, 6, 12))
table.insert(bytes, 0x80 + bits_below(codepoint, 6))
elseif codepoint <= 0x10ffff then
table.insert(bytes, 0xF0 + rshift(codepoint, 18))
table.insert(bytes, 0x80 + bits_between(codepoint, 12, 18))
table.insert(bytes, 0x80 + bits_between(codepoint, 6, 12))
table.insert(bytes, 0x80 + bits_below(codepoint, 6))
else
error(('codepoint %d (U+%05X) is larger than U+10FFFF'):format(i, codepoint))
end
end
return string.char(table.unpack(bytes))
end
local function utf8(text)
return unicode_to_utf8(utf16le_to_unicode(text))
end
---@type ParserConfig
local dir = {
priority = 109,
api_version = "1.9.0",
name = "cmd-dir",
keybind_name = "file"
}
---@async
---@param args string[]
---@param parse_state ParseState
---@return string|nil
local function command(args, parse_state)
local async = mp.command_native_async({
name = "subprocess",
playback_only = false,
capture_stdout = true,
capture_stderr = true,
args = args,
}, fb.coroutine.callback(30) )
---@type boolean, boolean, MPVSubprocessResult
local completed, _, cmd = parse_state:yield()
if not completed then
msg.warn('read timed out for:', table.unpack(args))
mp.abort_async_command(async)
return nil
end
local success = xpcall(function()
cmd.stdout = utf8(cmd.stdout) or ''
cmd.stderr = utf8(cmd.stderr) or ''
end, fb.traceback)
if not success then return msg.error('failed to convert utf16-le string to utf8') end
--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
if cmd.status ~= 0 then return msg.error(cmd.stderr) end
return cmd.status == 0 and cmd.stdout or nil
end
function dir:can_parse(directory)
if not fb.get_opt('windir_parser') then return false end
return PLATFORM == 'windows' and directory ~= '' and not fb.get_protocol(directory)
end
---@async
function dir:parse(directory, parse_state)
local list = {}
-- the dir command expects backslashes for our paths
directory = string.gsub(directory, "/", "\\")
local dirs = command({ "cmd", "/U", "/c", "dir", "/b", "/ad", directory }, parse_state)
if not dirs then return end
local files = command({ "cmd", "/U", "/c", "dir", "/b", "/a-d", directory }, parse_state)
if not files then return 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
@@ -1,62 +0,0 @@
--[[
This file is an internal file-browser addon.
It should not be imported like a normal module.
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.
]]
local mp = require 'mp'
local msg = require 'mp.msg'
local fb = require 'file-browser'
local PLATFORM = fb.get_platform()
---returns a list of windows drives
---@return string[]?
local function get_drives()
---@type MPVSubprocessResult?, string?
local result, err = mp.command_native({
name = 'subprocess',
playback_only = false,
capture_stdout = true,
args = {'fsutil', 'fsinfo', 'drives'}
})
if not result then return msg.error(err) end
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()
if fb.get_opt('auto_detect_windows_drives') and PLATFORM ~= 'windows' then return end
local drives = get_drives()
if not drives then return end
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
}
---@type ParserConfig
return {
api_version = '1.9.0',
setup = import_drives,
keybinds = { keybind }
}
-198
View File
@@ -1,198 +0,0 @@
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_utils = require 'modules.utils'
local ass = require 'modules.ass'
local directory_movement = require 'modules.navigation.directory-movement'
local scanning = require 'modules.navigation.scanning'
local controls = require 'modules.controls'
---@class FbAPI: fb_utils
local fb = setmetatable({}, { __index = setmetatable({}, { __index = fb_utils }) })
package.loaded["file-browser"] = setmetatable({}, { __index = fb })
--these functions we'll provide as-is
fb.redraw = ass.update_ass
fb.browse_directory = controls.browse_directory
---Clears the directory cache.
---@return thread
function fb.rescan()
return scanning.rescan()
end
---@async
---@return thread
function fb.rescan_await()
local co = scanning.rescan(nil, fb_utils.coroutine.callback())
coroutine.yield()
return co
end
---@param directories? string[]
function fb.clear_cache(directories)
if directories then
mp.commandv('script-message-to', mp.get_script_name(), 'cache/clear', utils.format_json(directories))
else
mp.commandv('script-message-to', mp.get_script_name(), 'cache/clear')
end
end
---A wrapper around scan_directory for addon API.
---@async
---@param directory string
---@param parse_state ParseStateTemplate
---@return Item[]|nil
---@return Opts
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.
---@param ext string
function fb.register_parseable_extension(ext)
g.parseable_extensions[string.lower(ext)] = true
end
---Deregister file extensions which can be opened by the browser.
---@param ext string
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.
---@param ext string
function fb.add_default_extension(ext)
table.insert(g.compatible_file_extensions, ext)
end
---Add item to root at position pos.
---@param item Item
---@param pos? number
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
---Add a new mapping to the given directory.
---@param directory string
---@param mapping string
---@param pattern? boolean
---@return string
function fb.register_directory_mapping(directory, mapping, pattern)
if not pattern then mapping = '^'..fb_utils.pattern_escape(mapping) end
g.directory_mappings[mapping] = directory
msg.verbose('registering directory alias', mapping, directory)
directory_movement.set_current_file(g.current_file.original_path)
return mapping
end
---Remove all directory mappings that map to the given directory.
---@param directory string
---@return string[]
function fb.remove_all_mappings(directory)
local removed = {}
for mapping, target in pairs(g.directory_mappings) do
if target == directory then
g.directory_mappings[mapping] = nil
table.insert(removed, mapping)
end
end
return removed
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.
---@param item Item|string
---@param priority? number Specifies the insertion location, a lower priority
--- is placed higher in the list and the default is 100.
---@return boolean
function fb.register_root_item(item, priority)
msg.verbose('registering root item:', utils.to_string(item))
if type(item) == 'string' then
item = {name = item, type = 'dir'}
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
---@type table<Item,number>
local priorities = {}
priorities[item] = priority
for i, v in ipairs(g.root) do
if (priorities[v] 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
---@param key string
---@return boolean|string|number
function fb.get_opt(key) return o[key] end
function fb.get_script_opts() return fb.copy_table(o) end
function fb.get_platform() return g.PLATFORM 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_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.get_history() return fb.copy_table(g.history.list) end
function fb.get_history_index() return g.history.position end
---@deprecated
---@return string|nil
function fb.get_dvd_device()
local dvd_device = mp.get_property('dvd-device')
if not dvd_device or dvd_device == '' then return nil end
return fb_utils.fix_path(dvd_device, true)
end
---@param str string
function fb.set_empty_text(str)
g.state.empty_text = str
fb.redraw()
end
---@param index number
---@return number|false
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
fb.set_history_index = directory_movement.goto_history
return fb
@@ -1,34 +0,0 @@
local msg = require 'mp.msg'
local g = require 'modules.globals'
---@class ParseStateAPI
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
---@async
---@param self ParseState
---@param ... any
---@return unknown ...
function parse_state_API:yield(...)
local co = coroutine.running()
local is_browser = co == g.state.co
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 table.unpack(result, 1, result.n)
end
---Checks if the current coroutine is the one handling the browser's request.
---@return boolean
function parse_state_API:is_coroutine_current()
return coroutine.running() == g.state.co
end
return parse_state_API
@@ -1,40 +0,0 @@
local msg = require 'mp.msg'
local g = require 'modules.globals'
local scanning = require 'modules.navigation.scanning'
local fb = require 'modules.apis.fb'
---@class ParserAPI: FbAPI
local parser_api = setmetatable({}, { __index = fb })
---Returns the index of the parser.
---@return number
function parser_api:get_index() return g.parsers[self].index end
---Returns the ID of the parser
---@return string
function parser_api:get_id() return g.parsers[self].id 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.
---Wrapper around `fb.register_root_item`.
---@param item Item|string
---@param priority? number The priority for the added item. Uses the parsers priority by default.
---@return boolean
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.
---@async
---@param directory string
---@return Item[]?
---@return Opts?
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
-238
View File
@@ -1,238 +0,0 @@
--------------------------------------------------------------------------------------------------------
-----------------------------------------List Formatting------------------------------------------------
--------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------
local g = require 'modules.globals'
local o = require 'modules.options'
local fb_utils = require 'modules.utils'
local state = g.state
local style = g.style
local ass = g.ass
--- https://www.unicode.org/reports/tr9/#Explicit_Directional_Isolates
local ISOLATE_DIRECTION_START = '\226\129\168' -- U+2068 FIRST STRONG ISOLATE
local ISOLATE_DIRECTION_END = '\226\129\169' -- U+2069 POP DIRECTIONAL ISOLATE
local function draw()
ass:update()
end
local function remove()
ass:remove()
end
---@type string[]
local string_buffer = {}
---appends the entered text to the overlay
---@param ... string
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
---@param v Item
---@return boolean
local function highlight_entry(v)
if g.current_file.path == nil then return false end
local full_path = fb_utils.get_full_path(v)
local alt_path = v.name and g.state.directory..v.name or nil
if fb_utils.parseable_item(v) then
return (
string.find(g.current_file.directory, full_path, 1, true)
or (alt_path and string.find(g.current_file.directory, alt_path, 1, true))
) ~= nil
else
return g.current_file.path == full_path
or (alt_path and g.current_file.path == alt_path)
end
end
---Escapes unwanted unicode control characters that may affect the rest of the display.
---Currently this only isolates unicode directional overrides.
---Based on: https://github.com/mpv-player/mpv/pull/17606
---@param str string
---@return string
local function unicode_escape(str)
return ISOLATE_DIRECTION_START..str..ISOLATE_DIRECTION_END
end
---escape ass values and replace newlines
---@param str string
---@param style_reset string?
---@return string
local function ass_escape(str, style_reset)
return fb_utils.ass_escape(str, style_reset and style.warning..''..style_reset or true)
end
local header_overrides = {['^'] = style.header}
---@return number start
---@return number finish
---@return boolean is_overflowing
local function calculate_view_window()
---@type number
local start = 1
---@type number
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
---@type number
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
return start, finish, overflow
end
---@param i number index
---@return string
local function calculate_item_style(i)
local is_playing_file = highlight_entry(state.list[i])
--sets the selection colour scheme
local multiselected = state.selection[i]
--sets the colour for the item
local item_style = style.body
if multiselected then item_style = item_style..style.multiselect
elseif i == state.selected then item_style = item_style..style.selected end
if is_playing_file then item_style = item_style..(multiselected and style.playing_selected or style.playing) end
return item_style
end
local function draw_header()
append(style.header)
append(fb_utils.substitute_codes(o.format_string_header, header_overrides, nil, nil, function(str, code)
if code == '^' then return str end
return ass_escape(str, style.header)
end))
newline()
end
---@param wrapper_overrides ReplacerTable
local function draw_top_wrapper(wrapper_overrides)
--adding a header to show there are items above in the list
append(style.footer_header)
append(fb_utils.substitute_codes(o.format_string_topwrapper, wrapper_overrides, nil, nil, function(str)
return ass_escape(str)
end))
newline()
end
---@param wrapper_overrides ReplacerTable
local function draw_bottom_wrapper(wrapper_overrides)
append(style.footer_header)
append(fb_utils.substitute_codes(o.format_string_bottomwrapper, wrapper_overrides, nil, nil, function(str)
return ass_escape(str)
end))
end
---@param i number index
---@param cursor string
local function draw_cursor(i, cursor)
--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
else
append(g.style.indent)
end
append(cursor, '\\h', style.body)
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)
draw_header()
if #state.list < 1 then
append(state.empty_text)
flush_buffer()
draw()
return
end
local start, finish, overflow = calculate_view_window()
-- these are the number values to place into the wrappers
local wrapper_overrides = {['<'] = tostring(start-1), ['>'] = tostring(#state.list-finish)}
if o.format_string_topwrapper ~= '' and start > 1 then
draw_top_wrapper(wrapper_overrides)
end
for i=start, finish do
local v = state.list[i]
append(style.body)
if g.ALIGN_X ~= 'right' then draw_cursor(i, o.cursor_icon) end
local item_style = calculate_item_style(i)
append(item_style)
--sets the folder icon
if v.type == 'dir' then
append(style.folder, o.folder_icon, "\\h", style.body)
append(item_style)
end
--adds the actual name of the item
append(v.ass or ass_escape( unicode_escape(v.label or v.name) , item_style), '\\h')
if g.ALIGN_X == 'right' then draw_cursor(i, o.cursor_icon_flipped) end
newline()
end
if o.format_string_bottomwrapper ~= '' and overflow then
draw_bottom_wrapper(wrapper_overrides)
end
flush_buffer()
draw()
end
---@class ass
return {
update_ass = update_ass,
highlight_entry = highlight_entry,
draw = draw,
remove = remove,
}
-94
View File
@@ -1,94 +0,0 @@
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_utils = require 'modules.utils'
local movement = require 'modules.navigation.directory-movement'
local ass = require 'modules.ass'
local cursor = require 'modules.navigation.cursor'
---@class controls
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 ---@diagnostic disable-line deprecated
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 ---@diagnostic disable-line deprecated
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
---@param directory string
---@param open_browser? boolean
---@return thread|nil
function controls.browse_directory(directory, open_browser)
if not directory then return end
if open_browser == nil then open_browser = true end
directory = mp.command_native({"expand-path", directory}, '') --[[@as string]]
-- directory = join_path( mp.get_property("working-directory", ""), directory )
if directory ~= "" then directory = fb_utils.fix_path(directory, true) end
msg.verbose('recieved directory from script message: '..directory)
directory = fb_utils.resolve_directory_mapping(directory)
local co = movement.goto_directory(directory, nil, nil, {cache={use=false}})
if open_browser then controls.open() end
return co
end
return controls
@@ -1,3 +0,0 @@
---@meta file-browser
return require 'modules.apis.fb'
@@ -1,39 +0,0 @@
---@meta _
---@class KeybindFlags
---@field repeatable boolean?
---@field scalable boolean?
---@field complex boolean?
---@class KeybindCommandTable
---@class Keybind
---@field key string
---@field command KeybindCommand
---@field api_version string?
---
---@field name string?
---@field condition string?
---@field flags KeybindFlags?
---@field filter ('file'|'dir')?
---@field parser string?
---@field multiselect boolean?
---@field multi-type ('repeat'|'concat')?
---@field delay number?
---@field concat-string string?
---@field passthrough boolean?
---
---@field prev_key Keybind? The keybind that was previously set to the same key.
---@field codes Set<string>? Any substituation codes used by the command table.
---@field condition_codes Set<string>? Any substitution codes used by the condition string.
---@field addon boolean? Whether the keybind was created by an addon.
---@alias KeybindFunctionCallback async fun(keybind: Keybind, state: State, co: thread)
---@alias KeybindCommand KeybindFunctionCallback|KeybindCommandTable[]
---@alias KeybindTuple [string,string,KeybindCommand,KeybindFlags?]
---@alias KeybindTupleStrict [string,string,KeybindFunctionCallback,KeybindFlags?]
---@alias KeybindList (Keybind|KeybindTuple)[]
@@ -1,25 +0,0 @@
---@meta _
---@alias List Item[]
---Represents an item returned by the parsers.
---@class Item
---@field type 'file'|'dir'
---@field name string
---@field label string?
---@field path string?
---@field ass string?
---@field redirect boolean?
---@field mpv_options string|{[string]: unknown}?
---The Opts table returned by the parsers.
---@class Opts
---@field filtered boolean?
---@field sorted boolean?
---@field directory string?
---@field directory_label string?
---@field empty_text string?
---@field selected_index number?
---@field id string?
---@field parser Parser?
@@ -1,148 +0,0 @@
---@meta mp
---@class mp
local mp = {}
---@class AsyncReturn
---@class MPTimer
---@field stop fun(self: MPTimer)
---@field kill fun(self: MPTimer)
---@field resume fun(self: MPTimer)
---@field is_enabled fun(self: MPTimer): boolean
---@field timeout number
---@field oneshot boolean
---@class OSDOverlay
---@field data string
---@field res_x number
---@field res_y number
---@field z number
---@field update fun(self:OSDOverlay)
---@field remove fun(self: OSDOverlay)
---@class MPVSubprocessResult
---@field status number
---@field stdout string
---@field stderr string
---@field error_string ''|'killed'|'init'
---@field killed_by_us boolean
---@param key string
---@param name_or_fn string|function
---@param fn? async fun()
---@param flags? KeybindFlags
function mp.add_key_binding(key, name_or_fn, fn, flags) end
---@param key string
---@param name_or_fn string|function
---@param fn? async fun()
---@param flags? KeybindFlags
function mp.add_forced_key_binding(key, name_or_fn, fn, flags) end
---@param seconds number
---@param fn function
---@param disabled? boolean
---@return MPTimer
function mp.add_timeout(seconds, fn, disabled) end
---@param format 'ass-events'
---@return OSDOverlay
function mp.create_osd_overlay(format) end
---@param ... string
function mp.commandv(...) end
---@generic T
---@param t table
---@param def? T
---@return unknown|T result
---@return string? error
---@overload fun(t: table): (unknown|nil, string?)
function mp.command_native(t, def) end
---@nodiscard
---@param t table
---@param cb fun(success: boolean, result: unknown, error: string?)
---@return AsyncReturn
function mp.command_native_async(t, cb) end
---@param t AsyncReturn
function mp.abort_async_command(t) end
---@generic T
---@param name string
---@param def? T
---@return string|T
---@overload fun(name: string): string|nil
function mp.get_property(name, def) end
---@generic T
---@param name string
---@param def? T
---@return boolean|T
---@overload fun(name: string): boolean|nil
function mp.get_property_bool(name, def) end
---@generic T
---@param name string
---@param def? T
---@return number|T
---@overload fun(name: string): number|nil
function mp.get_property_number(name, def) end
---@generic T
---@param name string
---@param def? T
---@return unknown|T
---@overload fun(name: string): unknown|nil
function mp.get_property_native(name, def) end
---@return string|nil
function mp.get_script_directory() end
---@return string
function mp.get_script_name() end
---@param name string
---@param type 'native'|'bool'|'string'|'number'
---@param fn fun(name: string, v: unknown)
function mp.observe_property(name, type, fn) end
---@param name string
---@param fn function
---@return boolean
function mp.register_event(name, fn) end
---@param name string
---@param fn fun(...: string)
function mp.register_script_message(name, fn) end
---@param name string
function mp.remove_key_binding(name) end
---@param name string
---@param value string
---@return true? success # nil if error
---@return string? err
function mp.set_property(name, value) end
---@param name string
---@param value boolean
---@return true? success # nil if error
---@return string? err
function mp.set_property_bool(name, value) end
---@param name string
---@param value number
---@return true? success # nil if error
---@return string? err
function mp.set_property_number(name, value) end
---@param name string
---@param value any
---@return true? success # nil if error
---@return string? err
function mp.set_property_native(name, value) end
return mp
@@ -1,21 +0,0 @@
---@meta mp.input
---@class mp.input
local input = {}
---@class InputGetOpts
---@field prompt string?
---@field default_text string?
---@field id string?
---@field submit (fun(text: string))?
---@field opened (fun())?
---@field edited (fun(text: string))?
---@field complete (fun(text_before_cursor: string): string[], number)?
---@field closed (fun(text: string))?
---@param options InputGetOpts
function input.get(options) end
function input.terminate() end
return input
@@ -1,32 +0,0 @@
---@meta mp.msg
---@class mp.msg
local msg = {}
---@param level 'fatal'|'error'|'warn'|'info'|'v'|'debug'|'trace'
---@param ... any
function msg.log(level, ...) end
---@param ... any
function msg.fatal(...) end
---@param ... any
function msg.error(...) end
---@param ... any
function msg.warn(...) end
---@param ... any
function msg.info(...) end
---@param ... any
function msg.verbose(...) end
---@param ... any
function msg.debug(...) end
---@param ... any
function msg.trace(...) end
return msg
@@ -1,11 +0,0 @@
---@meta mp.options
---@class mp.options
local options = {}
---@param t table<string,string|number|boolean>
---@param identifier? string
---@param on_update? fun(list: table<string,true|nil>)
function options.read_options(t, identifier, on_update) end
return options
@@ -1,43 +0,0 @@
---@meta mp.utils
---@class mp.utils
local utils = {}
---@param v string|boolean|number|table|nil
---@return string? json # nil on error
---@return string? err # error
function utils.format_json(v) end
---@param p1 string
---@param p2 string
---@return string
function utils.join_path(p1, p2) end
---@param str string
---@param trail? boolean
---@return (table|unknown[])? t
---@return string? err # error
---@return string trail # trailing characters
function utils.parse_json(str, trail) end
---@param path string
---@param filter ('files'|'dirs'|'normal'|'all')?
---@return string[]? # nil on error
---@return string? err # error
function utils.readdir(path, filter) end
---@deprecated
---@param name string
---@param value string
function utils.shared_script_property_set(name, value) end
---@param path string
---@return string directory
---@return string filename
function utils.split_path(path) end
---@param v any
---@return string
function utils.to_string(v) end
return utils
@@ -1,41 +0,0 @@
---@meta _
---A ParserConfig object returned by addons
---@class (partial) ParserConfig: ParserAPI
---@field priority number?
---@field api_version string The minimum API version the string requires.
---@field version string? The minimum API version the string requires. @deprecated.
---
---@field can_parse (async fun(self: Parser, directory: string, parse_state: ParseState): boolean)?
---@field parse (async fun(self: Parser, directory: string, parse_state: ParseState): List?, Opts?)?
---@field setup fun(self: Parser)?
---
---@field name string?
---@field keybind_name string?
---@field keybinds KeybindList?
---The parser object used by file-browser once the parsers have been loaded and initialised.
---@class Parser: ParserAPI, ParserConfig
---@field name string
---@field priority number
---@field api_version string
---@field can_parse async fun(self: Parser, directory: string, parse_state: ParseState): boolean
---@field parse async fun(self: Parser, directory: string, parse_state: ParseState): List?, Opts?
---@alias ParseStateSource 'browser'|'loadlist'|'script-message'|'addon'|string
---@alias ParseProperties table<string,any>
---The Parse State object passed to the can_parse and parse methods
---@class ParseStateFields
---@field source ParseStateSource
---@field directory string
---@field already_deferred boolean?
---@field properties ParseProperties
---@class ParseState: ParseStateFields, ParseStateAPI
---@class ParseStateTemplate
---@field source ParseStateSource?
---@field properties ParseProperties?
@@ -1,21 +0,0 @@
---@meta _
---@class Set<T>: {[T]: boolean}
---@class (exact) State
---@field list List
---@field selected number
---@field hidden boolean
---@field flag_update boolean
---@field keybinds KeybindTupleStrict[]?
---
---@field parser Parser?
---@field directory string?
---@field directory_label string?
---@field prev_directory string
---@field empty_text string
---@field co thread?
---
---@field multiselect_start number?
---@field initial_selection Set<number>?
---@field selection Set<number>?
@@ -1,28 +0,0 @@
---@meta user-input-module
---@class user_input_module
local user_input_module = {}
---@class UserInputOpts
---@field id string?
---@field source string?
---@field request_text string?
---@field default_input string?
---@field cursor_pos number?
---@field queueable boolean?
---@field replace boolean?
---@class UserInputRequest
---@field callback function?
---@field passthrough_args any[]?
---@field pending boolean
---@field cancel fun(self: UserInputRequest)
---@field update fun(self: UserInputRequest, opts: UserInputOpts)
---@param fn function
---@param opts UserInputOpts
---@param ... any passthrough arguments
---@return UserInputRequest
function user_input_module.get_user_input(fn, opts, ...) end
return user_input_module
-181
View File
@@ -1,181 +0,0 @@
--------------------------------------------------------------------------------------------------------
------------------------------------------Variable Setup------------------------------------------------
--------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------
local mp = require 'mp'
local o = require 'modules.options'
---@class globals
local globals = {}
--sets the version for the file-browser API
globals.API_VERSION = "1.9.0"
---gets the current platform (in mpv v0.36+)
---in earlier versions it is set to `windows`, `darwin` or `other`
---@type 'windows'|'darwin'|'linux'|'android'|'freebsd'|'other'|string|nil
globals.PLATFORM = mp.get_property_native('platform')
if not globals.PLATFORM then
local _ = {}
if mp.get_property_native('options/vo-mmcss-profile', _) ~= _ then
globals.PLATFORM = 'windows'
elseif mp.get_property_native('options/macos-force-dedicated-gpu', _) ~= _ then
globals.PLATFORM = 'darwin'
end
return 'other'
end
--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
--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
globals.ASS_ALIGNMENT_MATRIX = {
top = {left = 7, center = 8, right = 9},
center = {left = 4, center = 5, right = 6},
bottom = {left = 1, center = 2, right = 3},
}
globals.ALIGN_X = o.align_x == 'auto' and mp.get_property('osd-align-x', 'left') or o.align_x
globals.ALIGN_Y = o.align_y == 'auto' and mp.get_property('osd-align-y', 'top') or o.align_y
globals.style = {
global = ([[{\an%d}]]):format(globals.ASS_ALIGNMENT_MATRIX[globals.ALIGN_Y][globals.ALIGN_X]),
-- 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),
warning = ([[{\c&H%s&}]]):format(o.font_colour_escape_chars),
--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),
}
---@type State
globals.state = {
list = {},
selected = 1,
hidden = true,
flag_update = false,
keybinds = nil,
parser = nil,
directory = nil,
directory_label = nil,
prev_directory = '',
empty_text = 'Empty Directory',
co = nil,
multiselect_start = nil,
initial_selection = nil,
selection = {}
}
---@class ParserRef
---@field id string
---@field index number?
---@type table<number,Parser>|table<string,Parser>|table<Parser,ParserRef>>
--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
---@type table<thread,ParseState>
globals.parse_states = setmetatable({}, { __mode = "k"})
---@type Set<string>
globals.extensions = {}
---@type Set<string>
globals.sub_extensions = {}
---@type Set<string>
globals.audio_extensions = {}
---@type Set<string>
globals.parseable_extensions = {}
---This table contains mappings to convert external directories to cannonical
--locations within the file-browser file tree. The keys of the table are Lua
--patterns used to evaluate external directory paths. The value is the path
--that should replace the part of the path than matched the pattern.
--These mappings should only applied at the edges where external paths are
--ingested by file-browser.
---@type table<string,string>
globals.directory_mappings = {}
---@class CurrentFile
---@field directory string?
---@field name string?
---@field path string?
---@field original_path string?
globals.current_file = {
directory = nil,
name = nil,
path = nil,
original_path = nil,
}
---@type List
globals.root = {}
---@class (strict) History
---@field list string[]
---@field size number
---@field position number
globals.history = {
list = {},
size = 0,
position = 0,
}
---@class (strict) DirectoryStack
---@field stack string[]
---@field position number
globals.directory_stack = {
stack = {},
position = 0,
}
--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"
}
---@class BrowserAbortError
globals.ABORT_ERROR = {
msg = "browser is no longer waiting for list - aborting parse"
}
return globals
-354
View File
@@ -1,354 +0,0 @@
------------------------------------------------------------------------------------------
----------------------------------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 fb_utils = require 'modules.utils'
local addons = require 'modules.addons'
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'
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},
{'Alt+RIGHT', 'history_forward', movement.forwards_history},
{'Alt+LEFT', 'history_back', movement.back_history},
{'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() 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
---@type KeybindList
local top_level_keys = {}
---Format the item string for either single or multiple items.
---@param base_code_fn Replacer
---@param items Item[]
---@param state State
---@param cmd Keybind
---@param quoted? boolean
---@return string|nil
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 = fb_utils.get_code_pattern(fb_utils.code_fns)
local item_specific_codes = 'fnij'
---Replaces codes in the given string using the replacers.
---@param str string
---@param cmd Keybind
---@param items Item[]
---@param state State
---@return string
local function substitute_codes(str, cmd, items, state)
---@type ReplacerTable
local overrides = {}
for code in item_specific_codes:gmatch('.') do
overrides[code] = function(_,s) return create_item_string(fb_utils.code_fns[code], items, s, cmd) end
overrides[code:upper()] = function(_,s) return create_item_string(fb_utils.code_fns[code], items, s, cmd, true) end
end
return fb_utils.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.
---@param cmd Keybind
---@param items Item[]
---@param state State
---@return KeybindCommand
local function format_command_table(cmd, items, state)
local command = cmd.command
if type(command) == 'function' then return command end
---@type string[][]
local copy = {}
for i = 1, #command do
---@type string[]
copy[i] = {}
for j = 1, #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.
---@param cmd Keybind key.command must be an array of command tables compatible with mp.command_native
---@param items Item[] must be an array of multiple items (when multi-type ~= concat the array will be 1 long).
---@param state State
local function run_custom_command(cmd, items, state)
local custom_cmds = cmd.codes and format_command_table(cmd, items, state) or cmd.command
if type(custom_cmds) == 'function' then
error(('attempting to run a function keybind as a command table keybind\n%s'):format(utils.to_string(cmd)))
end
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)
---@param codes Set<string>
---@return boolean
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.
---@async
---@param cmd Keybind
---@param state State
---@param co thread
---@return boolean|nil
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 fb_utils.evaluate_string('return '..cond) == true
end
-- evaluates the string condition to decide if the keybind should be run
---@type boolean
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 fb_utils.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 = fb_utils.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() fb_utils.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.
---@async
---@param keybind Keybind
---@param state State
---@param co thread
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.
---@param key Keybind
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 = fb_utils.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))
fb_utils.traceback(err, co)
end
end
---Scans the given command table to identify if they contain any custom keybind codes.
---@param command_table KeybindCommand
---@param codes Set<string>
---@return Set<string>
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
for code in value:gmatch(KEYBIND_CODE_PATTERN) do
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.
---@param keybind Keybind
local function insert_custom_keybind(keybind)
-- api checking for the keybinds is optional, so set to a valid version if it does not exist
keybind.api_version = keybind.api_version or '1.0.0'
if not addons.check_api_version(keybind, 'keybind '..keybind.name) then return end
local command = keybind.command
--we'll always save the keybinds as either an array of command arrays or a function
if type(command) == "table" and type(command[1]) ~= "table" then
keybind.command = {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()
--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
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 = fb_utils.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
--loads custom keybinds from file-browser-keybinds.json
if o.custom_keybinds then
local path = mp.command_native({"expand-path", o.custom_keybinds_file}) --[[@as string]]
local custom_keybinds, err = io.open( path )
if not custom_keybinds then
msg.debug(err)
msg.verbose('could not read custom keybind file', path)
return
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 --[[@as KeybindList]]) do
keybind.name = "custom/"..(keybind.name or tostring(i))
insert_custom_keybind(keybind)
end
end
end
---@class keybinds
return {
setup_keybinds = setup_keybinds,
}
@@ -1,134 +0,0 @@
--------------------------------------------------------------------------------------------------------
--------------------------------Scroll/Select Implementation--------------------------------------------
--------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------
local g = require 'modules.globals'
local fb_utils = require 'modules.utils'
local ass = require 'modules.ass'
---@class cursor
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 = fb_utils.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 or -1]
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()
-- makes use of the directory stack to more exactly select the prev directory
local down_stack = g.directory_stack.stack[g.directory_stack.position + 1]
if down_stack then
for i, item in ipairs(g.state.list) do
if fb_utils.get_new_directory(item, g.state.directory) == down_stack then
g.state.selected = i
return
end
end
end
if g.state.prev_directory:find(g.state.directory, 1, true) == 1 then
for i, item in ipairs(g.state.list) do
if
g.state.prev_directory:find(fb_utils.get_full_path(item), 1, true) or
g.state.prev_directory:find(fb_utils.get_new_directory(item, g.state.directory), 1, true)
then
g.state.selected = i
return
end
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
@@ -1,209 +0,0 @@
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 ass = require 'modules.ass'
local scanning = require 'modules.navigation.scanning'
local fb_utils = require 'modules.utils'
local cursor = require 'modules.navigation.cursor'
---@class directory_movement
local directory_movement = {}
local NavType = scanning.NavType
---Appends an item to the directory stack, wiping any
---directories further ahead than the current position.
---@param dir string
local function directory_stack_append(dir)
-- don't clear the stack if we're re-entering the same directory
if g.directory_stack.stack[g.directory_stack.position + 1] == dir then
g.directory_stack.position = g.directory_stack.position + 1
return
end
local j = #g.directory_stack.stack
while g.directory_stack.position < j do
g.directory_stack.stack[j] = nil
j = j - 1
end
table.insert(g.directory_stack.stack, dir)
g.directory_stack.position = g.directory_stack.position + 1
end
---@param dir string
local function directory_stack_prepend(dir)
table.insert(g.directory_stack.stack, 1, dir)
g.directory_stack.position = 1
end
---Clears directories from the history
---@param from? number All entries >= this index are cleared.
---@return string[]
function directory_movement.clear_history(from)
---@type string[]
local cleared = {}
from = from or 1
for i = g.history.size, from, -1 do
table.insert(cleared, g.history.list[i])
g.history.list[i] = nil
g.history.size = g.history.size - 1
if g.history.position >= i then
g.history.position = g.history.position - 1
end
end
return cleared
end
---Append a directory to the history
---If we have navigated backward in the history,
---then clear any history beyond the current point.
---@param directory string
function directory_movement.append_history(directory)
if g.history.list[g.history.position] == directory then
msg.debug('reloading same directory - history unchanged:', directory)
return
end
msg.debug('appending to history:', directory)
if g.history.position < g.history.size then
directory_movement.clear_history(g.history.position + 1)
end
table.insert(g.history.list, directory)
g.history.size = g.history.size + 1
g.history.position = g.history.position + 1
if g.history.size > o.history_size then
table.remove(g.history.list, 1)
g.history.size = g.history.size - 1
end
end
---@param filepath string
function directory_movement.set_current_file(filepath)
--if we're in idle mode then we want to open the working directory
if filepath == nil then
g.current_file.directory = fb_utils.fix_path( mp.get_property("working-directory", ""), true)
g.current_file.name = nil
g.current_file.path = nil
g.current_file.original_path = nil
return
end
local absolute_path = fb_utils.absolute_path(filepath)
local resolved_path = fb_utils.resolve_directory_mapping(absolute_path)
g.current_file.directory, g.current_file.name = utils.split_path(resolved_path)
g.current_file.original_path = absolute_path
g.current_file.path = resolved_path
if o.cursor_follows_playing_item then cursor.select_playing_item() end
ass.update_ass()
end
--the base function for moving to a directory
---@param directory string
---@param nav_type? NavigationType
---@param store_history? boolean default `true`
---@param parse_properties? ParseProperties
---@return thread
function directory_movement.goto_directory(directory, nav_type, store_history, parse_properties)
local current = g.state.list[g.state.selected]
g.state.directory = directory
if g.state.directory_label then
if nav_type == NavType.DOWN then
g.state.directory_label = g.state.directory_label..(current.label or current.name)
elseif nav_type == NavType.UP then
g.state.directory_label = string.match(g.state.directory_label, "^(.-/+)[^/]+/*$")
end
end
if o.history_size > 0 and store_history == nil or store_history then
directory_movement.append_history(directory)
end
return scanning.rescan(nav_type or NavType.GOTO, nil, parse_properties)
end
---Move the browser to a particular point in the browser history.
---The history is a linear list of visited directories from oldest to newest.
---If the user changes directories while the current history position is not the head of the list,
---any later directories get cleared and the new directory becomes the new head.
---@param pos number The history index to move to. Clamped to [1,history_length]
---@return number|false # The index actually moved to after clamping. Returns -1 if the index was invalid (can occur if history is empty or disabled)
function directory_movement.goto_history(pos)
if type(pos) ~= "number" then return false end
if pos < 1 then pos = 1
elseif pos > g.history.size then pos = g.history.size end
if not g.history.list[pos] then return false end
g.history.position = pos
directory_movement.goto_directory(g.history.list[pos])
return pos
end
--loads the root list
function directory_movement.goto_root()
msg.verbose('jumping to root')
return 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')
return directory_movement.goto_directory(g.current_file.directory)
end
--moves up a directory
function directory_movement.up_dir()
if g.state.directory == '' then return end
local cached_parent_dir = g.directory_stack.stack[g.directory_stack.position - 1]
if cached_parent_dir then
g.directory_stack.position = g.directory_stack.position - 1
return directory_movement.goto_directory(cached_parent_dir, NavType.UP)
end
local parent_dir = g.state.directory:match("^(.-/+)[^/]+/*$") or ""
if o.skip_protocol_schemes and parent_dir:find("^(%a[%w+-.]*)://$") then
return directory_movement.goto_root()
end
directory_stack_prepend(parent_dir)
return directory_movement.goto_directory(parent_dir, NavType.UP)
end
--moves down a directory
function directory_movement.down_dir()
local current = g.state.list[g.state.selected]
if not current or not fb_utils.parseable_item(current) then return end
local directory, redirected = fb_utils.get_new_directory(current, g.state.directory)
directory_stack_append(directory)
return directory_movement.goto_directory(directory, redirected and NavType.REDIRECT or NavType.DOWN)
end
--moves backwards through the directory history
function directory_movement.back_history()
msg.debug('moving backwards in history to', g.history.list[g.history.position-1])
if g.history.position == 1 then return end
directory_movement.goto_history(g.history.position - 1)
end
--moves forward through the history
function directory_movement.forwards_history()
msg.debug('moving forwards in history to', g.history.list[g.history.position+1])
if g.history.position == g.history.size then return end
directory_movement.goto_history(g.history.position + 1)
end
return directory_movement
@@ -1,210 +0,0 @@
local mp = require 'mp'
local msg = require 'mp.msg'
local utils = require 'mp.utils'
local g = require 'modules.globals'
local fb_utils = require 'modules.utils'
local cursor = require 'modules.navigation.cursor'
local ass = require 'modules.ass'
local parse_state_API = require 'modules.apis.parse-state'
---@class scanning
local scanning = {}
---@enum NavigationType
local NavType = {
DOWN = 1,
UP = -1,
REDIRECT = 2,
GOTO = 3,
RESCAN = 4,
}
scanning.NavType = NavType
---@param directory_stack? boolean
local function clear_non_adjacent_state(directory_stack)
g.state.directory_label = nil
if directory_stack then
g.directory_stack.stack = {g.state.directory}
g.directory_stack.position = 1
end
end
---parses the given directory or defers to the next parser if nil is returned
---@async
---@param directory string
---@param index number
---@return List?
---@return Opts?
function scanning.choose_and_parse(directory, index)
msg.debug(("finding parser for %q"):format(directory))
---@type Parser, List?, Opts?
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.
---@async
---@param directory string
---@param parse_state_template ParseStateTemplate
---@return List|nil
---@return Opts
local function run_parse(directory, parse_state_template)
msg.verbose(("scanning files in %q"):format(directory))
---@type ParseStateFields
local parse_state = {
source = parse_state_template.source,
directory = directory,
properties = parse_state_template.properties or {}
}
local co = coroutine.running()
g.parse_states[co] = fb_utils.set_prototype(parse_state, parse_state_API) --[[@as ParseState]]
local list, opts = scanning.choose_and_parse(directory, 1)
if list == nil then return msg.debug("no successful parsers found"), {} end
opts = opts or {}
opts.parser = g.parsers[opts.id]
if not opts.filtered then fb_utils.filter(list) end
if not opts.sorted then fb_utils.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.
---@async
---@param directory string
---@param parse_state ParseStateTemplate
---@return List|nil
---@return Opts
function scanning.scan_directory(directory, parse_state)
local co = fb_utils.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
---@async
local new_co = coroutine.create(function()
fb_utils.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
fb_utils.traceback(err, new_co)
fb_utils.coroutine.resume_err(co)
end
end)
return g.parse_states[co]:yield()
end
---Sends update requests to the different parsers.
---@async
---@param moving_adjacent? number|boolean
---@param parse_properties? ParseProperties
local function update_list(moving_adjacent, parse_properties)
msg.verbose('opening directory: ' .. g.state.directory)
g.state.selected = 1
g.state.selection = {}
local directory = g.state.directory
local list, opts = scanning.scan_directory(g.state.directory, { source = "browser", properties = parse_properties })
--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 then
msg.warn("could not read directory", g.state.directory)
list, opts = {}, {}
opts.empty_text = g.style.warning..'Error: could not parse directory'
end
g.state.list = list
g.state.parser = opts.parser
--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 we need to change the `moving_adjacent` flag and clear some state values
if opts.directory then
g.state.directory = opts.directory
moving_adjacent = false
clear_non_adjacent_state(true)
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.
---@param nav_type? NavigationType
---@param cb? function
---@param parse_properties? ParseProperties
---@return thread # The coroutine for the triggered parse operation. May be aborted early if directory is in the cache.
function scanning.rescan(nav_type, cb, parse_properties)
if nav_type == nil then nav_type = NavType.RESCAN end
--we can only make assumptions about the directory label when moving from adjacent directories
if nav_type == NavType.GOTO or nav_type == NavType.REDIRECT then
clear_non_adjacent_state(nav_type == NavType.GOTO)
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
---@async
local co = fb_utils.coroutine.queue(function()
update_list(nav_type, parse_properties)
if g.state.empty_text == "~" then g.state.empty_text = "empty directory" end
ass.update_ass()
if cb then fb_utils.coroutine.run(cb) end
end)
g.state.co = co
return co
end
return scanning
@@ -1,48 +0,0 @@
local g = require 'modules.globals'
local directory_movement = require 'modules.navigation.directory-movement'
local fb = require 'modules.apis.fb'
local fb_utils = require 'modules.utils'
local ass = require 'modules.ass'
---@class observers
local observers ={}
---saves the directory and name of the currently playing file
---@param _ string
---@param filepath string
function observers.current_directory(_, filepath)
directory_movement.set_current_file(filepath)
end
---@param _ string
---@param device string
function observers.dvd_device(_, device)
if not device or device == "" then device = '/dev/dvd' end
fb.register_directory_mapping(fb_utils.absolute_path(device), '^dvd://.*', true)
end
---@param _ string
---@param device string
function observers.bd_device(_, device)
if not device or device == '' then device = '/dev/bd' end
fb.register_directory_mapping(fb_utils.absolute_path(device), '^bd://.*', true)
end
---@param _ string
---@param device string
function observers.cd_device(_, device)
if not device or device == '' then device = '/dev/cdrom' end
fb.register_directory_mapping(fb_utils.absolute_path(device), '^cdda://.*', true)
end
---@param property string
---@param alignment string
function observers.osd_align(property, alignment)
if property == 'osd-align-x' then g.ALIGN_X = alignment
elseif property == 'osd-align-y' then g.ALIGN_Y = alignment end
g.style.global = ([[{\an%d}]]):format(g.ASS_ALIGNMENT_MATRIX[g.ALIGN_Y][g.ALIGN_X])
ass.update_ass()
end
return observers
-193
View File
@@ -1,193 +0,0 @@
local utils = require 'mp.utils'
local opt = require 'mp.options'
---@class options
local o = {
--root directories
root = "~/",
--automatically detect windows drives and adds them to the root.
auto_detect_windows_drives = true,
--characters to use as separators
root_separators = ",",
--number of entries to show on the screen at once
num_entries = 20,
--number of directories to keep in the history
history_size = 100,
--wrap the cursor around the top and bottom of the list
wrap = false,
--only show files compatible with mpv
filter_files = true,
--recurses directories concurrently when appending items to the playlist
concurrent_recursion = true,
--maximum number of recursions that can run concurrently
max_concurrency = 16,
--enable custom keybinds
custom_keybinds = true,
custom_keybinds_file = "~~/script-opts/file-browser-keybinds.json",
--blacklist compatible files, it's recommended to use this rather than to edit the
--compatible list directly. A comma 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
---@type 'auto'|'yes'|'no'
filter_dot_dirs = 'auto',
---@type 'auto'|'yes'|'no'
filter_dot_files = 'auto',
--substitute forward slashes for backslashes when appending a local file to the playlist
--potentially useful on windows systems
substitute_backslash = false,
--interpret backslashes `\` in paths as forward slashes `/`
--this is useful on Windows, which natively uses backslashes.
--As backslashes are valid filename characters in Unix systems this could
--cause mangled paths, though such filenames are rare.
--Use `yes` and `no` to enable/disable. `auto` tries to use the mpv `platform`
--property (mpv v0.36+) to decide. If the property is unavailable it defaults to `yes`.
---@type 'auto'|'yes'|'no'
normalise_backslash = 'auto',
--a directory cache to improve directory reading time,
--enable if it takes a long time to load directories.
--may cause 'ghost' files to be shown that no-longer exist or
--fail to show files that have recently been created.
cache = 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,
--When opening the browser prefer the directory last opened by a previous mpv instance of file-browser.
--Overrides the `default_to_working_directory` option.
--Requires `save_last_opened_directory` to be true.
--Uses the internal `last-opened-directory` addon.
default_to_last_opened_directory = false,
--Whether to save the last opened directory and the file to save this value in.
save_last_opened_directory = false,
last_opened_directory_file = '~~state/file_browser-last_opened_directory',
--when moving up a directory do not stop on empty protocol schemes like `ftp://`
--e.g. moving up from `ftp://localhost/` will move straight to the root instead of `ftp://`
skip_protocol_schemes = true,
--move the cursor to the currently playing item (if available) when the playing file changes
cursor_follows_playing_item = false,
--Replace the user's home directory with `~/` in the header.
--Uses the internal home-label addon.
home_label = true,
--map optical device paths to their respective file paths,
--e.g. mapping bd:// to the value of the bluray-device property
map_bd_device = true,
map_dvd_device = true,
map_cdda_device = true,
--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]],
cursor_icon_flipped = [[{\p1}m 0.13 6.86 l 13.9 0.02 b 14 -0.02 14.11 -0 14.19 0.08 b 14.26 0.16 14.27 0.28 14.21 0.36 l 10.87 5.55 l 10.87 5.55 10.44 6.79 10.64 8.08 10.86 8.5l 14.21 13.77 b 14.27 13.86 14.26 13.98 14.19 14.06 b 14.14 14.11 14.07 14.13 14.01 14.13 b 13.97 14.13 13.94 14.13 13.9 14.11 l 0.13 7.28 b 0.05 7.24 0 7.16 0 7.07 b 0 6.98 0.05 6.9 0.13 6.86{\p0}\h]],
--enable addons
addons = true,
addon_directory = "~~/script-modules/file-browser-addons",
--Enables the internal `ls` addon that parses directories using the `ls` commandline tool.
--Allows directory parsing to run concurrently, which prevents the browser from locking up.
--Automatically disables itself on Windows systems.
ls_parser = true,
--Enables the internal `windir` addon that parses directories using the `dir` command in cmd.exe.
--Allows directory parsing to run concurrently, which prevents the browser from locking up.
--Automatically disables itself on non-Windows systems.
windir_parser = true,
--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 = false,
---@type 'auto'|'left'|'center'|'right'
align_x = 'left',
---@type 'auto'|'top'|'center'|'bottom'
align_y = 'top',
--style settings
format_string_header = [[{\fnMonospace}[%i/%x]%^ %q\N------------------------------------------------------------------]],
format_string_topwrapper = '...',
format_string_bottomwrapper = '...',
font_bold_header = true,
font_opacity_selection_marker = "99",
scaling_factor_base = 1,
scaling_factor_header = 1.4,
scaling_factor_wrappers = 1,
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_escape_chars = "413eff",
font_colour_multiselect = "fcad88",
font_colour_selected = "fce788",
font_colour_playing = "33ff66",
font_colour_playing_multiselected = "22b547"
}
opt.read_options(o, 'file_browser')
---@diagnostic disable-next-line deprecated
o.set_shared_script_properties = o.set_shared_script_properties and utils.shared_script_property_set
return o
-362
View File
@@ -1,362 +0,0 @@
------------------------------------------------------------------------------------------
---------------------------------File/Playlist Opening------------------------------------
------------------------------------------------------------------------------------------
------------------------------------------------------------------------------------------
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_utils = 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
---@alias LoadfileFlag 'replace'|'append-play'
---@class LoadOpts
---@field directory string
---@field flag LoadfileFlag
---@field autoload boolean
---@field items_appended number
---@field co thread
---@field concurrency number
---In mpv v0.38 a new index argument was added to the loadfile command.
---For some crazy reason this new argument is placed before the existing options
---argument, breaking any scripts that used it. This function finds the correct index
---for the options argument using the `command-list` property.
---@return integer
local function get_loadfile_options_arg_index()
---@type table[]
local command_list = mp.get_property_native('command-list', {})
for _, command in ipairs(command_list) do
if command.name == 'loadfile' then
for i, arg in ipairs(command.args or {} --[=[@as table[]]=]) do
if arg.name == 'options' then
return i
end
end
end
end
return 3
end
local LEGACY_LOADFILE_SYNTAX = get_loadfile_options_arg_index() == 3
---A wrapper around loadfile to handle the syntax changes introduced in mpv v0.38.
---@param file string
---@param flag string
---@param options? string|table<string,unknown>
---@return boolean
local function legacy_loadfile_wrapper(file, flag, options)
if LEGACY_LOADFILE_SYNTAX then
return mp.command_native({"loadfile", file, flag, options}) ~= nil
else
return mp.command_native({"loadfile", file, flag, -1, options}) ~= nil
end
end
---Adds a file to the playlist and changes the flag to `append-play` in preparation for future items.
---@param file string
---@param opts LoadOpts
---@param mpv_opts? string|table<string,unknown>
local function loadfile(file, opts, mpv_opts)
if o.substitute_backslash and not fb_utils.get_protocol(file) then
file = string.gsub(file, "/", "\\")
end
if opts.flag == "replace" then msg.verbose("Playling file", file)
else msg.verbose("Appending", file, "to the playlist") end
if mpv_opts then
msg.debug('Settings opts on', file, ':', utils.to_string(mpv_opts))
end
if not legacy_loadfile_wrapper(file, opts.flag, mpv_opts) then msg.warn(file) end
if opts.flag == 'replace' and mp.get_property_bool('pause') then mp.set_property_bool('pause', false) end
opts.flag = "append-play"
opts.items_appended = opts.items_appended + 1
end
---@diagnostic disable-next-line no-unknown
local concurrent_loadlist_wrapper
---@alias ConcurrentRefMap table<List|Item,{directory: string?, sublist: List?, recurse: boolean?}>
---This function recursively loads directories concurrently in separate coroutines.
---Results are saved in a tree of tables that allows asynchronous access.
---@async
---@param directory string
---@param load_opts LoadOpts
---@param prev_dirs Set<string>
---@param item_t Item
---@param refs ConcurrentRefMap
---@return boolean?
local function concurrent_loadlist_parse(directory, load_opts, prev_dirs, item_t, refs)
if not refs[item_t] then refs[item_t] = {} end
--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")
refs[item_t].recurse = false
return
end
directory = list_opts.directory or directory
--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)
refs[item_t].sublist = list or {}
refs[list] = {directory = directory}
if directory == "" then return end
--launches new parse operations for directories, each in a different coroutine
for _, item in ipairs(list) do
if fb_utils.parseable_item(item) then
fb_utils.coroutine.run(concurrent_loadlist_wrapper, fb_utils.get_new_directory(item, directory), load_opts, prev_dirs, item, refs)
end
end
return true
end
---A wrapper function that ensures the concurrent_loadlist_parse is run correctly.
---@async
---@param directory string
---@param opts LoadOpts
---@param prev_dirs Set<string>
---@param item Item
---@param refs ConcurrentRefMap
function concurrent_loadlist_wrapper(directory, opts, prev_dirs, item, refs)
--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
fb_utils.coroutine.sleep(0.1)
end
opts.concurrency = opts.concurrency + 1
local success = concurrent_loadlist_parse(directory, opts, prev_dirs, item, refs)
opts.concurrency = opts.concurrency - 1
if not success then refs[item].sublist = {} end
if coroutine.status(opts.co) == "suspended" then fb_utils.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.
---@async
---@param list List
---@param load_opts LoadOpts
---@param refs ConcurrentRefMap
local function concurrent_loadlist_append(list, load_opts, refs)
local directory = refs[list].directory
for _, item in ipairs(list) do
if not g.sub_extensions[ fb_utils.get_extension(item.name, "") ]
and not g.audio_extensions[ fb_utils.get_extension(item.name, "") ]
then
while fb_utils.parseable_item(item) and (not refs[item] or not refs[item].sublist) do
coroutine.yield()
end
if fb_utils.parseable_item(item) and refs[item] ~= false then
concurrent_loadlist_append(refs[item].sublist, load_opts, refs)
else
loadfile(fb_utils.get_full_path(item, directory), load_opts, item.mpv_options)
end
end
end
end
---Recursive function to load directories serially.
---Returns true if any items were appended to the playlist.
---@async
---@param directory string
---@param load_opts LoadOpts
---@param prev_dirs Set<string>
---@return true|nil
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)
return true
end
directory = opts.directory or directory
if directory == "" then return end
for _, item in ipairs(list) do
if not g.sub_extensions[ fb_utils.get_extension(item.name, "") ]
and not g.audio_extensions[ fb_utils.get_extension(item.name, "") ]
then
if fb_utils.parseable_item(item) then
custom_loadlist_recursive( fb_utils.get_new_directory(item, directory) , load_opts, prev_dirs)
else
local path = fb_utils.get_full_path(item, directory)
loadfile(path, load_opts, item.mpv_options)
end
end
end
end
---A wrapper for the custom_loadlist_recursive function.
---@async
---@param item Item
---@param opts LoadOpts
local function loadlist(item, opts)
local dir = fb_utils.get_full_path(item, opts.directory)
local num_items = opts.items_appended
if o.concurrent_recursion then
item = fb_utils.copy_table(item)
opts.co = fb_utils.coroutine.assert()
opts.concurrency = 0
---@type List
local v_list = {item}
---@type ConcurrentRefMap
local refs = setmetatable({[v_list] = {directory = opts.directory}}, {__mode = 'k'})
--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
fb_utils.coroutine.queue(concurrent_loadlist_wrapper, dir, opts, {}, item, refs)
concurrent_loadlist_append(v_list, opts, refs)
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.
---@param path string
---@param opts LoadOpts
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[ fb_utils.get_extension(item.name, "") ]
and not g.audio_extensions[ fb_utils.get_extension(item.name, "") ]
then
local p = fb_utils.get_full_path(item)
if p == path then pos = file_count
else loadfile( p, opts, item.mpv_options) end
file_count = file_count + 1
end
end
mp.commandv("playlist-move", 0, pos+1)
end
---Runs the loadfile or loadlist command.
---@async
---@param item Item
---@param opts LoadOpts
---@return nil
local function open_item(item, opts)
if fb_utils.parseable_item(item) then
return loadlist(item, opts)
end
local path = fb_utils.get_full_path(item, opts.directory)
if g.sub_extensions[ fb_utils.get_extension(item.name, "") ] then
mp.commandv("sub-add", path, opts.flag == "replace" and "select" or "auto")
elseif g.audio_extensions[ fb_utils.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, item.mpv_options) 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.
---@async
---@param opts LoadOpts
---@return nil
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 = fb_utils.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)
---@type LoadOpts
local opts = {
flag = flag,
autoload = (autoload ~= o.autoload and flag == "replace"),
directory = state.directory,
items_appended = 0,
concurrency = 0,
co = coroutine.create(open_file_coroutine)
}
fb_utils.coroutine.resume_err(opts.co, opts)
end
---@class playlist
return {
add_files = open_file,
}
@@ -1,111 +0,0 @@
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_utils = require 'modules.utils'
local scanning = require 'modules.navigation.scanning'
---@class script_messages
local script_messages = {}
---Allows other scripts to request directory contents from file-browser.
---@param directory string
---@param response_str string
function script_messages.get_directory_contents(directory, response_str)
---@async
fb_utils.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}, "") --[[@as string]]
if directory ~= "" then directory = fb_utils.fix_path(directory, true) end
msg.verbose(("recieved %q from 'get-directory-contents' script message - returning result to %q"):format(directory, response_str))
directory = fb_utils.resolve_directory_mapping(directory)
---@class OptsWithVersion: Opts
---@field API_VERSION string?
---@type List|nil, OptsWithVersion|Opts|nil
local list, opts = scanning.scan_directory(directory, { source = "script-message" } )
if opts then opts.API_VERSION = g.API_VERSION end
local list_str, err = fb_utils.format_json_safe(list)
if not list_str then msg.error(err) end
local opts_str, err2 = fb_utils.format_json_safe(opts)
if not opts_str then msg.error(err2) end
mp.commandv("script-message", response_str, list_str or "", opts_str or "")
end)
end
---A helper script message for custom keybinds.
---Substitutes any '=>' arguments for 'script-message'.
---Makes chaining script-messages much easier.
---@param ... string
function script_messages.chain(...)
---@type string[]
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.
---@param delay string
---@param ... string
---@return nil
function script_messages.delay_command(delay, ...)
local command = table.pack(...)
local success, err = pcall(mp.add_timeout, fb_utils.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.
---@param condition string
---@param ... string
function script_messages.conditional_command(condition, ...)
local command = table.pack(...)
fb_utils.coroutine.run(function()
if fb_utils.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.
---@param ... string
function script_messages.evaluate_expressions(...)
---@type string[]
local args = table.pack(...)
fb_utils.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
---@type any
local eval = fb_utils.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.
---@param ... string
function script_messages.run_statement(...)
local statement = table.concat(table.pack(...), '\n')
fb_utils.coroutine.run(fb_utils.evaluate_string, statement)
end
return script_messages
-60
View File
@@ -1,60 +0,0 @@
local mp = require 'mp'
local o = require 'modules.options'
local g = require 'modules.globals'
local fb_utils = require 'modules.utils'
local fb = require 'modules.apis.fb'
--sets up the compatible extensions list
local function setup_extensions_list()
--setting up subtitle extensions
for ext in fb_utils.iterate_opt(o.subtitle_extensions:lower(), ',') do
g.sub_extensions[ext] = true
g.extensions[ext] = true
end
--setting up audio extensions
for ext in fb_utils.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 fb_utils.iterate_opt(o.extension_whitelist:lower(), ',') do
g.extensions[str] = true
end
--removing extensions that are in the blacklist
for str in fb_utils.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 fb_utils.iterate_opt(o.root) do
local path = mp.command_native({'expand-path', str}) --[[@as string]]
path = fb_utils.fix_path(path, true)
local temp = {name = path, type = 'dir', label = str, ass = fb_utils.ass_escape(str, true)}
g.root[#g.root+1] = temp
end
if g.PLATFORM == 'windows' then
fb.register_root_item('C:/')
elseif g.PLATFORM ~= nil then
fb.register_root_item('/')
end
end
---@class setup
return {
extensions_list = setup_extensions_list,
root = setup_root,
}
-637
View File
@@ -1,637 +0,0 @@
--------------------------------------------------------------------------------------------------------
-----------------------------------------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 input_loaded, input = pcall(require, 'mp.input')
local user_input_loaded, user_input = pcall(require, 'user-input-module')
--creates a table for the API functions
--adds one metatable redirect to prevent addon authors from accidentally breaking file-browser
---@class fb_utils
local fb_utils = { API_VERSION = g.API_VERSION }
fb_utils.list = {}
fb_utils.coroutine = {}
--implements table.pack if on lua 5.1
if not table.pack then
table.unpack = unpack ---@diagnostic disable-line deprecated
---@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.
---@generic T
---@param t T[]
---@param item T
---@param from_index? number
---@return integer
function fb_utils.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.
---@generic T
---@param t T[]
---@param fn fun(v: T, i: number, t: T[]): boolean
---@return boolean
function fb_utils.list.some(t, fn)
for i, v in ipairs(t --[=[@as any[]]=]) do
if fn(v, i, t) then return true end
end
return false
end
---Creates a new table populated with the results of
---calling a provided function on every element in t.
---@generic T
---@generic R
---@param t T[]
---@param fn fun(v: T, i: number, t: T[]): R
---@return R[]
function fb_utils.list.map(t, fn)
local new_t = {}
for i, v in ipairs(t --[=[@as any[]]=]) do
new_t[i] = fn(v, i, t) ---@diagnostic disable-line no-unknown
end
return new_t
end
---Prints an error message and a stack trace.
---Can be passed directly to xpcall.
---@param errmsg string
---@param co? thread A coroutine to grab the stack trace from.
function fb_utils.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.
---@generic T: table
---@param t T
---@return T
function fb_utils.redirect_table(t)
return setmetatable({}, { __index = t })
end
---Sets the given table `proto` as the `__index` field in table `t`s metatable.
---@generic T: table
---@param t T
---@param proto table
---@return T
function fb_utils.set_prototype(t, proto)
return setmetatable(t, { __index = proto })
end
---Prints an error if a coroutine returns an error.
---Unlike coroutine.resume_err this still returns the results of coroutine.resume().
---@param ... any
---@return boolean
---@return ...
function fb_utils.coroutine.resume_catch(...)
local returns = table.pack(coroutine.resume(...))
if not returns[1] and returns[2] ~= g.ABORT_ERROR then
fb_utils.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.
---@param ... any
---@return boolean
function fb_utils.coroutine.resume_err(...)
local success, err = coroutine.resume(...)
if not success and err ~= g.ABORT_ERROR then
fb_utils.traceback(err, select(1, ...))
end
return success
end
---Throws an error if not run from within a coroutine.
---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.
---@param err any
---@return thread
function fb_utils.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 function to resume the current coroutine with the given time limit.
---If the time limit expires the coroutine will be resumed. The first return value will be true
---if the callback was resumed within the time limit and false otherwise.
---If time_limit is falsy then there will be no time limit and there will be no additional return value.
---@param time_limit? number seconds
---@return fun(...)
function fb_utils.coroutine.callback(time_limit)
local co = fb_utils.coroutine.assert("cannot create a coroutine callback for the main thread")
local timer = time_limit and mp.add_timeout(time_limit, function ()
msg.debug("time limit on callback expired")
fb_utils.coroutine.resume_err(co, false)
end)
local function fn(...)
if timer then
if not timer:is_enabled() then return
else timer:kill() end
return fb_utils.coroutine.resume_err(co, true, ...)
end
return fb_utils.coroutine.resume_err(co, ...)
end
return fn
end
---Puts the current coroutine to sleep for the given number of seconds.
---@async
---@param n number
---@return nil
function fb_utils.coroutine.sleep(n)
mp.add_timeout(n, fb_utils.coroutine.callback())
coroutine.yield()
end
---Runs the given function in a coroutine, passing through any additional arguments.
---Does not run the coroutine immediately, instead it queues the coroutine to run when the thread is next idle.
---Returns the coroutine object so that the caller can act on it before it is run.
---@param fn async fun()
---@param ... any
---@return thread
function fb_utils.coroutine.queue(fn, ...)
local co = coroutine.create(fn)
local args = table.pack(...)
mp.add_timeout(0, function() fb_utils.coroutine.resume_err(co, table.unpack(args, 1, args.n)) end)
return co
end
---Runs the given function in a coroutine, passing through any additional arguments.
---This is for triggering an event in a coroutine.
---@param fn async fun()
---@param ... any
function fb_utils.coroutine.run(fn, ...)
local co = coroutine.create(fn)
fb_utils.coroutine.resume_err(co, ...)
end
---Get the full path for the current file.
---@param item Item
---@param dir? string
---@return string
function fb_utils.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.
---@param item Item
---@param directory string
---@return string new_directory
---@return boolean? redirected `true` if the path was redirected
function fb_utils.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, or def if there is none.
---@generic T
---@param filename string
---@param def? T
---@return string|T
---@overload fun(filename: string): string|nil
function fb_utils.get_extension(filename, def)
return string.lower(filename):match("%.([^%./]+)$") or def
end
---Returns the protocol scheme of the given url, or def if there is none.
---@generic T
---@param filename string
---@param def T
---@return string|T
---@overload fun(filename: string): string|nil
function fb_utils.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.
---@param str string
---@param replace_newline? true|string
---@return string
function fb_utils.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 = string.gsub(str, "\\N", replace_newline)
end
return str
end
---Escape lua pattern characters.
---@param str string
---@return string
function fb_utils.pattern_escape(str)
return (string.gsub(str, "([%^%$%(%)%%%.%[%]%*%+%-%?])", "%%%1"))
end
---Standardises filepaths across systems.
---@param str string
---@param is_directory? boolean
---@return string
function fb_utils.fix_path(str, is_directory)
if str == '' then return str end
if o.normalise_backslash == 'yes' or (o.normalise_backslash == 'auto' and g.PLATFORM == 'windows') then
str = string.gsub(str, [[\]],[[/]])
end
str = str:gsub([[/%./]], [[/]])
if is_directory and str:sub(-1) ~= '/' then str = str..'/' end
return str
end
---Wrapper for mp.utils.join_path to handle protocols.
---@param working string
---@param relative string
---@return string
function fb_utils.join_path(working, relative)
return fb_utils.get_protocol(relative) and relative or utils.join_path(working, relative)
end
---Converts the given path into an absolute path and normalises it using fb_utils.fix_path.
---@param path string
---@return string
function fb_utils.absolute_path(path)
local absolute_path = fb_utils.join_path(mp.get_property('working-directory', ''), path)
return fb_utils.fix_path(absolute_path)
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.
---@param t List
---@return List
function fb_utils.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
---@type [string,Item][]
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)
-- pretty sure that `#b[2] < #a[2]` does not do anything as they are both Item tables and not strings or arrays
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
---@param dir string
---@return boolean
function fb_utils.valid_dir(dir)
if o.filter_dot_dirs == 'yes' or o.filter_dot_dirs == 'auto' and g.PLATFORM ~= 'windows' then
return string.sub(dir, 1, 1) ~= "."
end
return true
end
---@param file string
---@return boolean
function fb_utils.valid_file(file)
if o.filter_dot_files == 'yes' or o.filter_dot_files == 'auto' and g.PLATFORM ~= 'windows' then
if string.sub(file, 1, 1) == "." then return false end
end
if o.filter_files and not g.extensions[ fb_utils.get_extension(file, "") ] then return false end
return true
end
---Returns whether or not the item can be parsed.
---@param item Item
---@return boolean
function fb_utils.parseable_item(item)
return item.type == "dir" or g.parseable_extensions[fb_utils.get_extension(item.name, "")]
end
---Takes a directory string and resolves any directory mappings,
---returning the resolved directory.
---@param path string
---@return string
function fb_utils.resolve_directory_mapping(path)
if not path then return path end
for mapping, target in pairs(g.directory_mappings) do
local start, finish = string.find(path, mapping)
if start then
msg.debug('mapping', mapping, 'found for', path, 'changing to', target)
-- if the mapping is an exact match then return the target as is
if finish == #path then return target end
-- else make sure the path is correctly formatted
target = fb_utils.fix_path(target, true)
return (string.gsub(path, mapping, target))
end
end
return path
end
---Removes items and folders from the list that fail the configured filters.
---@param t List
---@return List
function fb_utils.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 fb_utils.valid_dir(temp.label or temp.name) ) or
( temp.type == "file" and fb_utils.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.
---@param str any
---@param separators? string Override the root separators.
---@return fun():(string, ...)
function fb_utils.iterate_opt(str, separators)
return string.gmatch(str, "([^"..fb_utils.pattern_escape(separators or 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.
---@param t Set<number>
---@param include_item? fun(item: Item): boolean
---@return Item[]
function fb_utils.sort_keys(t, include_item)
---@class Ref
---@field item Item
---@field index number
---@type Ref[]
local keys = {}
for k in pairs(t) do
local item = g.state.list[k]
if not include_item or include_item(item) then
keys[#keys+1] = {
item = item,
index = k,
}
end
end
table.sort(keys, function(a,b) return a.index < b.index end)
return fb_utils.list.map(keys, function(ref) return ref.item end)
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.
---@param t any[]
---@return integer
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.
---@generic T
---@param t T
---@return T
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 --[[@as table<any,any>]]) 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
---@diagnostic disable-next-line no-unknown
t[key] = json_safe_recursive(t[key])
elseif key then
---@diagnostic disable-next-line no-unknown
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.
---@param t any
---@return string|nil
---@return string|nil err
function fb_utils.format_json_safe(t)
--operate on a copy of the table to prevent any data loss in the original table
t = json_safe_recursive(fb_utils.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.
---Provides the mpv modules and the fb module to the string.
---@param str string
---@param chunkname? string Used for error reporting.
---@param custom_env? table A custom environment that shadows the default environment.
---@param env_defaults? boolean Load lua defaults in environment, as well as mpv and file-browser modules. Defaults to `true`.
---@return unknown
function fb_utils.evaluate_string(str, chunkname, custom_env, env_defaults)
---@type table
local env
if env_defaults ~= false then
---@type table
env = fb_utils.redirect_table(_G)
env.mp = fb_utils.redirect_table(mp)
env.msg = fb_utils.redirect_table(msg)
env.utils = fb_utils.redirect_table(utils)
env.fb = fb_utils.redirect_table(require 'file-browser')
env.input = input_loaded and fb_utils.redirect_table(input)
env.user_input = user_input_loaded and fb_utils.redirect_table(user_input)
env = fb_utils.set_prototype(custom_env or {}, env)
else
env = custom_env or {}
end
---@type function, any
local chunk, err
if setfenv then ---@diagnostic disable-line deprecated
chunk, err = loadstring(str, chunkname) ---@diagnostic disable-line deprecated
if chunk then setfenv(chunk, env) end ---@diagnostic disable-line deprecated
else
chunk, err = load(str, chunkname, 't', env) ---@diagnostic disable-line redundant-parameter
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.
---@generic T
---@param t T
---@param references table<table,table>
---@param depth number
---@return T
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 --[[@as table<any,any>]]) do
key = copy_table_recursive(key, references, depth - 1)
copy[key] = copy_table_recursive(value, references, depth - 1) ---@diagnostic disable-line no-unknown
end
return copy
end
---A wrapper around copy_table to provide the reference table.
---@generic T
---@param t T
---@param depth? number
---@return T
function fb_utils.copy_table(t, depth)
--this is to handle cyclic table references
return copy_table_recursive(t, {}, depth or math.huge)
end
---@alias Replacer fun(item: Item, s: State): (string|number|nil)
---@alias ReplacerTable table<string,Replacer>
---functions to replace custom-keybind codes
---@type ReplacerTable
fb_utils.code_fns = {
["%"] = function() return "%" end,
f = function(item, s) return item and fb_utils.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 = fb_utils.list.indexOf(s.list, item)
if #s.list == 0 then return 0 end
return ('%0'..math.ceil(math.log10(#s.list))..'d'):format(i ~= -1 and i or 0) ---@diagnostic disable-line deprecated
end,
j = function (item, s)
return fb_utils.list.indexOf(s.list, item) ~= -1 and math.abs(fb_utils.list.indexOf( fb_utils.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.
---@param codes ReplacerTable
---@return string
function fb_utils.get_code_pattern(codes)
---@type string
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 {} --[[@as ReplacerTable]]) do
---@type string
CUSTOM_KEYBIND_CODES = CUSTOM_KEYBIND_CODES..key:lower()..key:upper()
end
return('%%%%([%s])'):format(fb_utils.pattern_escape(CUSTOM_KEYBIND_CODES))
end
---Substitutes codes in the given string for other substrings.
---@param str string
---@param overrides? ReplacerTable Replacer functions for additional characters to match to after `%` characters.
---@param item? Item Uses the currently selected item if nil.
---@param state? State Uses the global state if nil.
---@param modifier_fn? fun(new_str: string, code: string): string given the replacement substrings before they are placed in the main string
--- (the return value is the new replacement string).
---@return string
function fb_utils.substitute_codes(str, overrides, item, state, modifier_fn)
local replacers = overrides and setmetatable(fb_utils.copy_table(overrides), {__index = fb_utils.code_fns}) or fb_utils.code_fns
item = item or g.state.list[g.state.selected]
state = state or g.state
return (string.gsub(str, fb_utils.get_code_pattern(replacers), function(code)
---@type string|number|nil
local result
local replacer = replacers[code]
if type(replacer) == "string" then
result = replacer
--encapsulates the string if using an uppercase code
elseif not replacer then
local lower_fn = replacers[code:lower()]
if not lower_fn then return end
result = string.format("%q", lower_fn(item, state))
else
result = replacer(item, state)
end
if result and modifier_fn then return modifier_fn(tostring(result), code) end
return result
end))
end
return fb_utils
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 975 KiB

+224
View File
@@ -0,0 +1,224 @@
-- Install [Torrserver](https://github.com/YouROK/TorrServer)
-- then add "script-opts-append=mpv_torrserver-server=http://[TorrServer ip]:[port]" to mpv.conf
local utils = require 'mp.utils'
local opts = {
server = "http://localhost:8090",
torrserver_init = false,
torrserver_path = "TorrServer",
search_for_external_tracks = true
}
(require 'mp.options').read_options(opts)
local luacurl_available, cURL = pcall(require, 'cURL')
local is_windows = package.config:sub(1, 1) == "\\" -- detect path separator, windows uses backslashes
local function find_executable(name)
local os_path = os.getenv("PATH") or ""
local fallback_path = utils.join_path("/usr/bin", name)
local exec_path
for path in os_path:gmatch("[^:]+") do
exec_path = utils.join_path(path, name)
local meta, meta_error = utils.file_info(exec_path)
if meta and meta.is_file then
return exec_path
end
end
if not is_windows then return fallback_path end
return name -- fallback to just the name, hoping it's in PATH
end
local function init()
local exec_path = find_executable(opts.torrserver_path)
local windows_args = { 'powershell', '-NoProfile', '-Command', exec_path }
local unix_args = { '/bin/bash', '-c', exec_path }
local args = is_windows and windows_args or unix_args
local res = mp.command_native_async({ name = "subprocess", capture_stdout = true, playback_only = false, args = args })
if res.status == 0 then
mp.msg.error("TorrServer failed to start: ")
end
end
local char_to_hex = function(c)
return string.format("%%%02X", string.byte(c))
end
local function urlencode(url)
if url == nil then
return
end
url = url:gsub("\n", "\r\n")
url = url:gsub("([^%w ])", char_to_hex)
url = url:gsub(" ", "+")
return url
end
local function get_magnet_info(url)
local info_url = opts.server .. "/stream?stat&link=" .. urlencode(url)
local res
if not (luacurl_available) then
-- if Lua-cURL is not available on this system
local curl_cmd = {
"curl",
"-L",
"--silent",
"--max-time", "10",
info_url
}
local cmd = mp.command_native {
name = "subprocess",
capture_stdout = true,
playback_only = false,
args = curl_cmd
}
res = cmd.stdout
else
-- otherwise use Lua-cURL (binding to libcurl)
local buf = {}
local c = cURL.easy_init()
c:setopt_followlocation(1)
c:setopt_url(info_url)
c:setopt_writefunction(function(chunk)
table.insert(buf, chunk);
return true;
end)
c:perform()
res = table.concat(buf)
end
if res and res ~= "" then
return (require 'mp.utils').parse_json(res)
else
return nil, "no info response (timeout?)"
end
end
local function edlencode(url)
return "%" .. string.len(url) .. "%" .. url
end
local function guess_type_by_extension(ext)
if ext == "mkv" or ext == "mp4" or ext == "avi" or ext == "wmv" or ext == "vob" or ext == "m2ts" or ext == "ogm" then
return "video"
end
if ext == "mka" or ext == "mp3" or ext == "aac" or ext == "flac" or ext == "ogg" or ext == "wma" or ext == "mpg"
or ext == "wav" or ext == "wv" or ext == "opus" or ext == "ac3" then
return "audio"
end
if ext == "ass" or ext == "srt" or ext == "vtt" then
return "sub"
end
return "other";
end
local function string_replace(str, match, replace)
local s, e = string.find(str, match, 1, true)
if s == nil or e == nil then
return str
end
return string.sub(str, 1, s - 1) .. replace .. string.sub(str, e + 1)
end
-- https://github.com/mpv-player/mpv/blob/master/DOCS/edl-mpv.rst
local function generate_m3u(magnet_uri, files)
for _, fileinfo in ipairs(files) do
-- strip top directory
if fileinfo.path:find("/", 1, true) then
fileinfo.fullpath = string.sub(fileinfo.path, fileinfo.path:find("/", 1, true) + 1)
else
fileinfo.fullpath = fileinfo.path
end
fileinfo.path = {}
for w in fileinfo.fullpath:gmatch("([^/]+)") do table.insert(fileinfo.path, w) end
local ext = string.match(fileinfo.path[#fileinfo.path], "%.(%w+)$")
fileinfo.type = guess_type_by_extension(ext)
end
table.sort(files, function(a, b)
-- make top-level files appear first in the playlist
if (#a.path == 1 or #b.path == 1) and #a.path ~= #b.path then
return #a.path < #b.path
end
-- make videos first
if (a.type == "video" or b.type == "video") and a.type ~= b.type then
return a.type == "video"
end
-- otherwise sort by path
return a.fullpath < b.fullpath
end);
local infohash = magnet_uri:match("^magnet:%?xt=urn:bt[im]h:(%w+)") or urlencode(magnet_uri)
local playlist = { '#EXTM3U' }
for _, fileinfo in ipairs(files) do
if fileinfo.processed ~= true then
table.insert(playlist, '#EXTINF:0,' .. fileinfo.fullpath)
local basename = string.match(fileinfo.path[#fileinfo.path], '^(.+)%.%w+$')
local url = opts.server .. "/stream/" .. urlencode(fileinfo.fullpath) .."?play&index=" .. fileinfo.id .. "&link=" .. infohash
local hdr = { "!new_stream", "!no_clip",
--"!track_meta,title=" .. edlencode(basename),
edlencode(url)
}
local edl = "edl://" .. table.concat(hdr, ";") .. ";"
local external_tracks = 0
fileinfo.processed = true
if opts.search_for_external_tracks and basename ~= nil and fileinfo.type == "video" then
mp.msg.info("!" .. basename)
for _, fileinfo2 in ipairs(files) do
if #fileinfo2.path > 0 and
fileinfo2.type ~= "other" and
fileinfo2.processed ~= true and
string.find(fileinfo2.path[#fileinfo2.path], basename, 1, true) ~= nil
then
mp.msg.info("->" .. fileinfo2.fullpath)
local title = string_replace(fileinfo2.fullpath, basename, "%")
local url = opts.server .. "/stream/" .. urlencode(fileinfo2.fullpath).."?play&index=" .. fileinfo2.id .. "&link=" .. infohash
local hdr = { "!new_stream", "!no_clip", "!no_chapters",
"!delay_open,media_type=" .. fileinfo2.type,
"!track_meta,title=" .. edlencode(title),
edlencode(url)
}
edl = edl .. table.concat(hdr, ";") .. ";"
fileinfo2.processed = true
external_tracks = external_tracks + 1
end
end
end
if external_tracks == 0 then -- dont use edl
table.insert(playlist, url)
else
table.insert(playlist, edl)
end
end
end
return table.concat(playlist, '\n')
end
mp.add_hook("on_load", 5, function()
local url = mp.get_property("stream-open-filename")
if url:find("^magnet:") == 1 or (url:find("^https?://") == 1 and url:find("%.torrent$") ~= nil) then
mp.set_property_bool("file-local-options/ytdl", false)
if opts.torrserver_init then init() end
local magnet_info, err = get_magnet_info(url)
if type(magnet_info) == "table" then
if magnet_info.file_stats then
-- torrent has multiple files. open as playlist
mp.set_property("stream-open-filename", "memory://" .. generate_m3u(url, magnet_info.file_stats))
return
end
-- if not a playlist and has a name
if magnet_info.name then
mp.set_property("stream-open-filename", "memory://#EXTM3U\n" ..
"#EXTINF:0," .. magnet_info.name .. "\n" ..
opts.server .. "/stream?play&index=1&link=" .. urlencode(url))
return
end
else
mp.msg.warn("error: " .. err)
end
mp.set_property("stream-open-filename", opts.server .. "/stream?m3u&link=" .. urlencode(url))
end
end)
+1 -1
View File
@@ -40,7 +40,7 @@ function Button:render()
local ass = assdraw.ass_new()
local is_clickable = self.is_clickable and self.on_click ~= nil
local is_hover = self.proximity_raw == 0
local is_hover = self.proximity_raw <= 0
local foreground = self.active and self.background or self.foreground
local background = self.active and self.foreground or self.background
local background_opacity = self.active and 1 or config.opacity.controls
+8 -11
View File
@@ -37,17 +37,17 @@ function Controls:init_options()
-- Serialize control elements
local shorthands = {
['play-pause'] = 'cycle:pause:pause:no/yes=play_arrow?' .. t('Play/Pause'),
menu = 'command:menu_book:script-binding uosc/menu-blurred?' .. t('Menu'),
subtitles = 'command:closed_caption:script-binding uosc/subtitles#sub>1?' .. t('Subtitles'),
menu = 'command:menu:script-binding uosc/menu-blurred?' .. t('Menu'),
subtitles = 'command:subtitles:script-binding uosc/subtitles#sub>0?' .. t('Subtitles'),
audio = 'command:graphic_eq:script-binding uosc/audio#audio>1?' .. t('Audio'),
['audio-device'] = 'command:speaker:script-binding uosc/audio-device?' .. t('Audio device'),
video = 'command:smart_display:script-binding uosc/video#video>1?' .. t('Video'),
playlist = 'command:list_alt:script-binding uosc/playlist#playlist>1?' .. t('Playlist'),
chapters = 'command:library_books:script-binding uosc/chapters#chapters>1?' .. t('Chapters'),
['editions'] = 'command:movie_filter:script-binding uosc/editions#editions>1?' .. t('Editions'),
video = 'command:theaters:script-binding uosc/video#video>1?' .. t('Video'),
playlist = 'command:list_alt:script-binding uosc/playlist?' .. t('Playlist'),
chapters = 'command:bookmark:script-binding uosc/chapters#chapters>0?' .. t('Chapters'),
['editions'] = 'command:bookmarks:script-binding uosc/editions#editions>1?' .. t('Editions'),
['stream-quality'] = 'command:high_quality:script-binding uosc/stream-quality?' .. t('Stream quality'),
['open-file'] = 'command:folder:script-binding uosc/open-file?' .. t('Open file'),
['items'] = 'command:list_alt:script-binding uosc/items#playlist>1?' .. t('Playlist/Files'),
['open-file'] = 'command:file_open:script-binding uosc/open-file?' .. t('Open file'),
['items'] = 'command:list_alt:script-binding uosc/items?' .. t('Playlist/Files'),
prev = 'command:arrow_back_ios:script-binding uosc/prev?' .. t('Previous'),
next = 'command:arrow_forward_ios:script-binding uosc/next?' .. t('Next'),
first = 'command:first_page:script-binding uosc/first?' .. t('First'),
@@ -271,9 +271,6 @@ function Controls:register_badge_updater(badge, element)
for _, track in ipairs(value) do if track.type == prop then count = count + 1 end end
return count
end
elseif prop == 'playlist' then
observable_name = 'playlist-count'
serializer = function(count) return count end
else
local parts = split(prop, '@')
-- Support both new `prop@owner` and old `@prop` syntaxes
+4 -4
View File
@@ -48,14 +48,14 @@ function Elements:update_proximities()
element:update_proximity()
end
if element.proximity_raw == 0 then
if element.proximity_raw <= 0 then
-- Mouse entered element area
if previous_proximity_raw ~= 0 then
if previous_proximity_raw > 0 then
mouse_enter_elements[#mouse_enter_elements + 1] = element
end
else
-- Mouse left element area
if previous_proximity_raw == 0 then
if previous_proximity_raw <= 0 then
mouse_leave_elements[#mouse_leave_elements + 1] = element
end
end
@@ -122,7 +122,7 @@ function Elements:proximity_trigger(name, ...)
for i = #self._all, 1, -1 do
local element = self._all[i]
if element.enabled then
if element.proximity_raw == 0 then
if element.proximity_raw <= 0 then
if element:trigger(name, ...) == 'stop_propagation' then break end
end
if element:trigger('global_' .. name, ...) == 'stop_propagation' then break end
+109 -117
View File
@@ -309,7 +309,7 @@ function Menu:update_content_dimensions()
for _, menu in ipairs(self.all) do
title_opts.bold, title_opts.italic = true, false
local max_width = text_width(menu.title, title_opts) + 2 * self.padding + 2 * self.item_padding
local max_width = text_width(menu.title, title_opts) + 2 * self.item_padding
-- Estimate width of a widest item
for _, item in ipairs(menu.items) do
@@ -323,7 +323,7 @@ function Menu:update_content_dimensions()
if estimated_width > max_width then max_width = estimated_width end
end
menu.max_width = max_width + 2 * self.padding
menu.max_width = max_width
end
self:update_dimensions()
@@ -336,20 +336,21 @@ function Menu:update_dimensions()
-- and dumb titles with no search inputs. It could use a refactor.
local margin = round(self.item_height / 2)
local external_buttons_reserve = display.width / self.item_height > 14 and self.scroll_step * 6 - margin * 2 or 0
local width_available = display.width - margin * 2 - external_buttons_reserve
local height_available = display.height - margin * 2
local width_available = display.width - margin * 2 - self.padding * 2 - external_buttons_reserve
local height_available = display.height - margin * 2 - self.padding * 2
local min_width = math.min(self.min_width, width_available)
for _, menu in ipairs(self.all) do
local width = math.max(menu.search and menu.search.max_width or 0, menu.max_width)
menu.width = round(clamp(min_width, width, width_available))
local title_height = (menu.is_root and menu.title or menu.search) and self.scroll_step + self.padding or 0
local title_height = (menu.is_root and menu.title or menu.search) and
self.scroll_step + self.separator_size + 1 or 0
local footnote_height = self.font_size * 1.5
local max_height = height_available - title_height - footnote_height
local content_height = self.scroll_step * #menu.items
menu.height = math.min(content_height - self.item_spacing, max_height)
menu.top = clamp(
title_height + margin,
title_height + margin + self.padding,
menu.search and math.min(menu.search.min_top, menu.search.source.top) or height_available,
round((height_available - menu.height + title_height) / 2)
)
@@ -364,10 +365,13 @@ function Menu:update_dimensions()
self:update_coordinates()
end
-- Updates element coordinates to match currently open (sub)menu.
-- Updates element coordinates to match padding box of currently open (sub)menu.
function Menu:update_coordinates()
local ax = round((display.width - self.current.width) / 2) + self.offset_x
self:set_coordinates(ax, self.current.top, ax + self.current.width, self.current.top + self.current.height)
local ax = round((display.width - self.current.width) / 2 - self.padding) + self.offset_x
self:set_coordinates(
ax, self.current.top - self.padding,
ax + self.current.width + self.padding * 2, self.current.top + self.current.height + self.padding
)
end
function Menu:reset_navigation()
@@ -686,7 +690,7 @@ function Menu:on_prop_fullormaxed() self:update_content_dimensions() end
function Menu:on_options() self:update_content_dimensions() end
function Menu:handle_cursor_down()
if self.proximity_raw == 0 then
if self.proximity_raw <= 0 then
self.drag_last_y = cursor.y
self.current.fling = nil
else
@@ -696,7 +700,7 @@ end
---@param shortcut? Shortcut
function Menu:handle_cursor_up(shortcut)
if self.proximity_raw == 0 and self.drag_last_y and not self.is_dragging then
if self.proximity_raw <= -self.padding and self.drag_last_y and not self.is_dragging then
self:activate_selected_item(shortcut, true)
end
if self.is_dragging then
@@ -893,14 +897,14 @@ function search_items(items, query, recursive, prefix)
if ligature_conv_title:find(query, 1, true) then
match = true
score = 1000
local pos = get_roman_match_positions(title, query, "ligature", ligature_roman)
local pos = get_roman_match_positions(title, query, 'ligature', ligature_roman)
if pos then
ass_safe_title = highlight_match(item.title, pos, font_color, bold)
end
elseif initials_conv_title:find(query, 1, true) then
match = true
score = 900
local pos = get_roman_match_positions(title, query, "initial", initials_roman)
local pos = get_roman_match_positions(title, query, 'initial', initials_roman)
if pos then
ass_safe_title = highlight_match(item.title, pos, font_color, bold)
end
@@ -1371,7 +1375,6 @@ function Menu:render()
cursor:zone('wheel_up', self, function() self:handle_wheel_up() end)
local ass = assdraw.ass_new()
local spacing = self.item_padding
local icon_size = self.font_size
---@param menu MenuStack
@@ -1380,37 +1383,43 @@ function Menu:render()
local function draw_menu(menu, x, pos)
local is_current, is_parent, is_submenu = pos == 0, pos < 0, pos > 0
local menu_opacity = (pos == 0 and 1 or config.opacity.submenu ^ math.abs(pos)) * self.opacity
local ax, ay, bx, by = x, menu.top, x + menu.width, menu.top + menu.height
-- Scrollable content area coordinates
local content_rect = {
ax = x + self.padding,
ay = menu.top,
bx = x + self.padding + menu.width,
by = menu.top + menu.height,
}
-- local ax, ay, bx, by = x + self.padding, menu.top, x + menu.width + self.padding, menu.top + menu.height
local draw_title = menu.is_root and menu.title or menu.search
local scroll_clip = '\\clip(0,' .. ay .. ',' .. display.width .. ',' .. by .. ')'
local scroll_clip = '\\clip(0,' .. content_rect.ay .. ',' .. display.width .. ',' .. content_rect.by .. ')'
local start_index = math.floor(menu.scroll_y / self.scroll_step) + 1
local end_index = math.ceil((menu.scroll_y + menu.height) / self.scroll_step)
local menu_rect = {
ax = ax,
ay = ay - (draw_title and self.scroll_step + self.padding or 0) - self.padding,
bx = bx,
by = by + self.padding,
local bg_rect = {
ax = x,
ay = content_rect.ay - (draw_title and self.scroll_step or 0) - self.padding,
bx = content_rect.bx + self.padding,
by = content_rect.by + self.padding,
}
local blur_selected_index = self.mouse_nav and is_current
local blur_action_index = self.mouse_nav and menu.action_index ~= nil
-- Background
ass:rect(menu_rect.ax, menu_rect.ay, menu_rect.bx, menu_rect.by, {
ass:rect(bg_rect.ax, bg_rect.ay, bg_rect.bx, bg_rect.by, {
color = bg,
opacity = menu_opacity * config.opacity.menu,
radius = state.radius > 0 and state.radius + self.padding or 0,
radius = state.radius > 0 and math.min(state.radius + self.padding, state.radius * 3) or 0,
})
if is_parent then
cursor:zone('primary_down', menu_rect, self:create_action(function() self:slide_in_menu(menu.id, x) end))
cursor:zone('primary_down', bg_rect, self:create_action(function() self:slide_in_menu(menu.id, x) end))
end
-- Scrollbar
if menu.scroll_height > 0 then
local groove_height = menu.height - 2
local thumb_height = math.max((menu.height / (menu.scroll_height + menu.height)) * groove_height, 40)
local thumb_y = ay + 1 + ((menu.scroll_y / menu.scroll_height) * (groove_height - thumb_height))
local sax = bx - round(self.scrollbar_size / 2)
local thumb_y = content_rect.ay + 1 + ((menu.scroll_y / menu.scroll_height) * (groove_height - thumb_height))
local sax = content_rect.bx - round(self.scrollbar_size / 2)
local sbx = sax + self.scrollbar_size
ass:rect(sax, thumb_y, sbx, thumb_y + thumb_height, {color = fg, opacity = menu_opacity * 0.8})
end
@@ -1419,7 +1428,7 @@ function Menu:render()
local submenu_rect, current_item = nil, is_current and menu.selected_index and menu.items[menu.selected_index]
local submenu_is_hovered = false
if current_item and current_item.items then
submenu_rect = draw_menu(current_item --[[@as MenuStack]], menu_rect.bx + self.gap, 1)
submenu_rect = draw_menu(current_item --[[@as MenuStack]], bg_rect.bx + self.gap, 1)
cursor:zone('primary_down', submenu_rect, self:create_action(function(shortcut)
self:activate_selected_item(shortcut, true)
end))
@@ -1432,21 +1441,32 @@ function Menu:render()
if not item then break end
local item_ax = menu_rect.ax + self.padding
local item_bx = menu_rect.bx - self.padding
local item_ay = ay - menu.scroll_y + self.scroll_step * (index - 1)
local item_ay = content_rect.ay - menu.scroll_y + self.scroll_step * (index - 1)
local item_by = item_ay + self.item_height
local item_center_y = item_ay + (self.item_height / 2)
local item_clip = (item_ay < ay or item_by > by) and scroll_clip or nil
local content_ax, content_bx = ax + self.padding + spacing, bx - self.padding - spacing
local item_clip = (item_ay < content_rect.ay or item_by > content_rect.by) and scroll_clip or nil
local content_ax, content_bx = content_rect.ax + self.item_padding,
content_rect.bx - self.item_padding
local is_selected = menu.selected_index == index
local item_rect_hitbox = {
ax = item_ax,
ay = math.max(item_ay, menu_rect.ay),
bx = menu_rect.bx + (item.items and self.gap or -self.padding), -- to bridge the gap with cursor
by = math.min(item_ay + self.scroll_step, menu_rect.by),
ax = content_rect.ax,
ay = math.max(item_ay, bg_rect.ay),
bx = bg_rect.bx + (item.items and self.gap or -self.padding), -- to bridge the submenu gap with cursor
by = math.min(item_ay + self.scroll_step, bg_rect.by),
}
-- Select hovered item
if is_current and self.mouse_nav and item.selectable ~= false
-- Do not select items if cursor is moving towards a submenu
and (not submenu_rect or not cursor:direction_to_rectangle_distance(submenu_rect))
and (submenu_is_hovered or get_point_to_rectangle_proximity(cursor, item_rect_hitbox) <= 0) then
menu.selected_index = index
if not is_selected then
is_selected = true
request_render()
end
end
local has_background = is_selected or item.active
local next_item = menu.items[index + 1]
local next_is_active = next_item and next_item.active
@@ -1458,22 +1478,23 @@ function Menu:render()
if action then selected_action = action end
-- Separator
if item_by < by and ((not has_background and not next_has_background) or item.separator) then
local separator_ay, separator_by = item_by, item_by + self.separator_size
if item_by < content_rect.by and ((not has_background and not next_has_background) or item.separator) then
local ay, by = item_by, item_by + self.separator_size
if has_background then
separator_ay, separator_by = separator_ay + self.separator_size, separator_by + self.separator_size
ay, by = ay + self.separator_size, by + self.separator_size
elseif next_has_background then
separator_ay, separator_by = separator_ay - self.separator_size, separator_by - self.separator_size
ay, by = ay - self.separator_size, by - self.separator_size
end
ass:rect(ax + spacing, separator_ay, bx - spacing, separator_by, {
color = fg, opacity = menu_opacity * (item.separator and 0.13 or 0.04),
})
ass:rect(
content_rect.ax + self.item_padding, ay, content_rect.bx - self.item_padding, by,
{color = fg, opacity = menu_opacity * (item.separator and 0.13 or 0.04)}
)
end
-- Background
local highlight_opacity = 0 + (item.active and 0.8 or 0) + (is_selected and 0.15 or 0)
if highlight_opacity > 0 then
ass:rect(ax + self.padding, item_ay, bx - self.padding, item_by, {
ass:rect(content_rect.ax, item_ay, content_rect.bx, item_by, {
radius = state.radius,
color = fg,
opacity = highlight_opacity * menu_opacity,
@@ -1495,9 +1516,10 @@ function Menu:render()
actions_rect = {
ay = item_ay + margin,
by = item_by - margin,
is_outside = place == 'outside' and display.width - menu_rect.bx + margin * 2 > rect_width,
is_outside = place == 'outside' and display.width - bg_rect.bx + margin * 2 > rect_width,
}
actions_rect.bx = actions_rect.is_outside and menu_rect.bx + margin + rect_width or item_bx - margin
actions_rect.bx = actions_rect.is_outside and bg_rect.bx + margin + rect_width or
content_rect.bx - margin
actions_rect.ax = actions_rect.bx
for i = 1, #actions, 1 do
@@ -1532,7 +1554,7 @@ function Menu:render()
rect.ay, rect.by, rect.bx = item_ay, item_ay + self.scroll_step, rect.bx + margin
-- Select action on cursor hover
if self.mouse_nav and get_point_to_rectangle_proximity(cursor, rect) == 0 then
if self.mouse_nav and get_point_to_rectangle_proximity(cursor, rect) <= 0 then
cursor:zone('primary_down', rect, self:create_action(function(shortcut)
self:activate_selected_item(shortcut, true)
end))
@@ -1553,17 +1575,18 @@ function Menu:render()
if is_selected and not selected_action then
local size = round(2 * state.scale)
local v_padding = math.min(state.radius, math.ceil(self.item_height / 3))
ass:rect(ax + self.padding - size - 1, item_ay + v_padding, ax + self.padding - 1,
item_by - v_padding, {
radius = 1 * state.scale, color = fg, opacity = menu_opacity, clip = item_clip,
})
ass:rect(
content_rect.ax - size - 1, item_ay + v_padding,
content_rect.ax - 1, item_by - v_padding,
{radius = 1 * state.scale, color = fg, opacity = menu_opacity, clip = item_clip}
)
end
-- Icon
if item.icon then
if not actions_rect or actions_rect.is_outside then
local x = (not item.title and not item.hint and item.align == 'center')
and menu_rect.ax + (menu_rect.bx - menu_rect.ax) / 2
and bg_rect.ax + (bg_rect.bx - bg_rect.ax) / 2
or content_bx - (icon_size / 2)
if item.icon == 'spinner' then
ass:spinner(x, item_center_y, icon_size * 1.5, {color = font_color, opacity = menu_opacity * 0.8})
@@ -1573,7 +1596,7 @@ function Menu:render()
})
end
end
content_bx = content_bx - icon_size - spacing
content_bx = content_bx - icon_size - self.item_padding
title_clip_bx = math.min(content_bx, title_clip_bx)
end
@@ -1581,7 +1604,7 @@ function Menu:render()
if item.hint_width > 0 then
-- controls title & hint clipping proportional to the ratio of their widths
-- both title and hint get at least 50% of the width, unless they are smaller then that
local width = content_bx - content_ax - spacing
local width = content_bx - content_ax - self.item_padding
local title_min = math.min(item.title_width, width * 0.5)
local hint_min = math.min(item.hint_width, width * 0.5)
local title_ratio = item.title_width / (item.title_width + item.hint_width)
@@ -1594,8 +1617,9 @@ function Menu:render()
-- Hint
if item.hint then
item.ass_safe_hint = item.ass_safe_hint or ass_escape(item.hint)
local clip = '\\clip(' .. title_clip_bx + spacing .. ',' ..
math.max(item_ay, ay) .. ',' .. hint_clip_bx .. ',' .. math.min(item_by, by) .. ')'
local clip = '\\clip(' .. title_clip_bx + self.item_padding .. ','
.. math.max(item_ay, content_rect.ay) .. ',' .. hint_clip_bx .. ','
.. math.min(item_by, content_rect.by) .. ')'
ass:txt(content_bx, item_center_y, 6, item.ass_safe_hint, {
size = self.font_size_hint,
color = font_color,
@@ -1608,8 +1632,8 @@ function Menu:render()
-- Title
if item.title then
item.ass_safe_title = item.ass_safe_title or ass_escape(item.title)
local clip = '\\clip(' .. ax .. ',' .. math.max(item_ay, ay) .. ','
.. title_clip_bx .. ',' .. math.min(item_by, by) .. ')'
local clip = '\\clip(' .. content_rect.ax .. ',' .. math.max(item_ay, content_rect.ay) .. ','
.. title_clip_bx .. ',' .. math.min(item_by, content_rect.by) .. ')'
local title_x, align = content_ax, 4
if item.align == 'right' then
title_x, align = title_clip_bx, 6
@@ -1626,29 +1650,12 @@ function Menu:render()
clip = clip,
})
end
-- Select hovered item
if is_current and self.mouse_nav and item.selectable ~= false then
if submenu_rect and cursor:direction_to_rectangle_distance(submenu_rect)
or actions_rect and actions_rect.is_outside and cursor:direction_to_rectangle_distance(actions_rect) then
blur_selected_index = false
else
if submenu_is_hovered or get_point_to_rectangle_proximity(cursor, item_rect_hitbox) == 0 then
blur_selected_index = false
menu.selected_index = index
if not is_selected then
is_selected = true
request_render()
end
end
end
end
end
-- Footnote / Selected action label
if is_current and (menu.footnote or selected_action) then
local height_half = self.font_size
local icon_x, icon_y = menu_rect.ax + self.padding + self.font_size / 2, menu_rect.by + height_half
local icon_x, icon_y = content_rect.ax + self.font_size / 2, bg_rect.by + height_half
local is_icon_hovered = false
local icon_hitbox = {
ax = icon_x - height_half,
@@ -1656,14 +1663,14 @@ function Menu:render()
bx = icon_x + height_half,
by = icon_y + height_half,
}
is_icon_hovered = get_point_to_rectangle_proximity(cursor, icon_hitbox) == 0
is_icon_hovered = get_point_to_rectangle_proximity(cursor, icon_hitbox) <= 0
local text = selected_action and selected_action.label or is_icon_hovered and menu.footnote
local opacity = (is_icon_hovered and 1 or 0.5) * menu_opacity
ass:icon(icon_x, icon_y, self.font_size, is_icon_hovered and 'help' or 'help_outline', {
color = fg, border = state.scale, border_color = bg, opacity = opacity,
})
if text then
ass:txt(icon_x + self.font_size * 0.75, icon_y, 4, text, {
ass:txt(icon_x + self.font_size * 0.75, icon_y - self.font_size * 0.5, 7, ass_escape(text), {
size = self.font_size,
color = fg,
border = state.scale,
@@ -1676,43 +1683,24 @@ function Menu:render()
-- Menu title
if draw_title then
local title_height = self.item_height + self.padding - 3
local requires_submit = menu.search_debounce == 'submit'
local rect = {
ax = round(ax + spacing / 2 + self.padding),
ay = ay - self.scroll_step - self.padding * 2,
bx = round(bx - spacing / 2 - self.padding),
by = math.min(by, ay - self.padding),
ax = content_rect.ax,
ay = content_rect.ay - self.scroll_step - self.separator_size - 1,
bx = content_rect.bx,
by = content_rect.ay - self.separator_size - 1,
}
-- centers
-- Centers
rect.cx, rect.cy = round(rect.ax + (rect.bx - rect.ax) / 2), round(rect.ay + (rect.by - rect.ay) / 2)
if menu.title and not menu.ass_safe_title then
menu.ass_safe_title = ass_escape(menu.title)
end
-- Background
if menu.search then
ass:rect(ax + 3, rect.ay + 3, bx - 3, rect.ay + title_height - 1, {
color = fg .. '\\1a&HFF', opacity = menu_opacity * 0.1,
radius = state.radius > 0 and state.radius + self.padding or 0,
border = 1, border_color = fg, border_opacity = menu_opacity * 0.8
})
ass:texture(ax + 3, rect.ay + 3, bx - 3, rect.ay + title_height - 1, 'n', {
size = 80, color = bg, opacity = menu_opacity * 0.1, anchor_x = ax + 2, anchor_y = rect.ay + 2,
})
else
ass:rect(ax + 2, rect.ay + 2, bx - 2, rect.ay + title_height, {
color = fg, opacity = menu_opacity * 0.8,
radius = state.radius > 0 and state.radius + self.padding or 0,
})
ass:texture(ax + 2, rect.ay + 2, bx - 2, rect.ay + title_height, 'n', {
size = 80, color = bg, opacity = menu_opacity * 0.1,
})
end
-- Bottom border
ass:rect(ax, rect.by - self.separator_size, bx, rect.by, {color = fg, opacity = menu_opacity * 0.2})
-- Separator
ass:rect(
rect.ax, rect.by, rect.bx, rect.by + self.separator_size, {color = fg, opacity = menu_opacity * 0.2}
)
-- Blur selection (also activates search input) when user clicks title
if is_current then
@@ -1725,11 +1713,16 @@ function Menu:render()
if menu.search then
-- Icon
local icon_size, icon_opacity = self.font_size * 1.3, menu_opacity * (requires_submit and 0.5 or 1)
local icon_rect = {ax = rect.ax, ay = rect.ay, bx = ax + icon_size + spacing * 1.5, by = rect.by}
local icon_rect = {
ax = rect.ax,
ay = rect.ay,
bx = content_rect.ax + icon_size + self.item_padding * 1.5,
by = rect.by,
}
if is_current and requires_submit then
cursor:zone('primary_down', icon_rect, function() self:search_submit() end)
if get_point_to_rectangle_proximity(cursor, icon_rect) == 0 then
if get_point_to_rectangle_proximity(cursor, icon_rect) <= 0 then
icon_opacity = menu_opacity
end
end
@@ -1778,7 +1771,10 @@ function Menu:render()
-- (input is selected when `selected_index` is `nil`)
if menu.search_debounce == 'submit' and not menu.selected_index then
local size_half = round(1 * state.scale)
ass:rect(ax, rect.by - size_half, bx, rect.by + size_half, {color = fg, opacity = menu_opacity})
ass:rect(
content_rect.ax, rect.by - size_half, content_rect.bx, rect.by + size_half,
{color = fg, opacity = menu_opacity}
)
end
local input_is_blurred = menu.search_debounce == 'submit' and menu.selected_index
@@ -1793,7 +1789,7 @@ function Menu:render()
ass:txt(rect.cx, rect.cy, 5, menu.ass_safe_title, {
size = self.font_size,
bold = true,
color = bg,
color = bgt,
wrap = 2,
opacity = menu_opacity,
clip = '\\clip(' .. rect.ax .. ',' .. rect.ay .. ',' .. rect.bx .. ',' .. rect.by .. ')',
@@ -1801,16 +1797,12 @@ function Menu:render()
end
end
-- We are in mouse nav and cursor isn't hovering any item
if blur_selected_index then
menu.selected_index = nil
end
if blur_action_index then
menu.action_index = nil
request_render()
end
return menu_rect
return bg_rect
end
-- Active menu
@@ -1821,7 +1813,7 @@ function Menu:render()
local parent_offset_x, parent_horizontal_index = self.ax, -1
while parent_menu do
parent_offset_x = parent_offset_x - parent_menu.width - self.gap
parent_offset_x = parent_offset_x - parent_menu.width - self.padding * 2 - self.gap
draw_menu(parent_menu, parent_offset_x, parent_horizontal_index)
parent_horizontal_index = parent_horizontal_index - 1
parent_menu = parent_menu.parent_menu
+1 -1
View File
@@ -14,7 +14,7 @@ function PauseIndicator:init()
end
function PauseIndicator:init_options()
self.base_icon_opacity = options.pause_indicator == 'flash' and 1 or 0.8
self.base_icon_opacity = config.opacity.pause_indicator or (options.pause_indicator == 'flash' and 1) or 0.8
self.type = options.pause_indicator
self:on_prop_pause()
end
+38 -5
View File
@@ -18,12 +18,20 @@ function Timeline:init()
self.progress_line_width = 0
self.is_hovered = false
self.has_thumbnail = false
self.heatmap = nil
self:decide_progress_size()
self:update_dimensions()
-- Release any dragging when file gets unloaded
self:register_mp_event('end-file', function() self.pressed = false end)
-- Load Youtube heatmap data if available
self:register_mp_event('file-loaded', function()
self.heatmap = load_youtube_heatmap()
end)
-- Release any dragging and clear heatmap when file gets unloaded
self:register_mp_event('end-file', function()
self.pressed = false
self.heatmap = nil
end)
end
function Timeline:get_visibility()
@@ -181,7 +189,7 @@ function Timeline:render()
return
end
if self.proximity_raw == 0 then
if self.proximity_raw <= 0 then
self.is_hovered = true
end
if visibility > 0 then
@@ -257,7 +265,32 @@ function Timeline:render()
ass:draw_stop()
-- Progress
local function draw_progress()
ass:rect(fax, fay, fbx, fby, {opacity = config.opacity.position})
end
-- Youtube heatmap
local function draw_heatmap()
if options.timeline_heatmap ~= 'no' and self.heatmap and config.opacity.heatmap > 0 and visibility > 0 then
local is_above = options.timeline_heatmap == 'above'
local height = math.min(40, size / self.size * 40)
local ax, ay = bax, is_above and (bay - height) or (bay + self.top_border)
local bx, by = bbx, is_above and bay or bby
local opts = {color = config.color.heatmap, opacity = config.opacity.heatmap * visibility}
local clip_ay = is_above and (ay - 10) or ay
opts.clip = string.format('\\clip(%d,%d,%d,%d)', ax, clip_ay, bx, by)
ass:smooth_curve(ax, ay, bx, by, self.heatmap, opts)
end
end
-- Change draw order based on 'timeline_style' to keep the heatmap visible
if is_line then
draw_heatmap()
draw_progress()
else
draw_progress()
draw_heatmap()
end
-- Uncached ranges
if state.uncached_ranges then
@@ -380,7 +413,7 @@ function Timeline:render()
-- Time values
if text_opacity > 0 then
local time_opts = {size = self.font_size, opacity = text_opacity, border = 2 * state.scale}
local time_opts = {size = self.font_size, opacity = text_opacity, border = options.text_border * state.scale}
-- Upcoming cache time
local cache_duration = state.cache_duration and state.cache_duration / state.speed or nil
if cache_duration and options.buffered_time_threshold > 0
@@ -412,7 +445,7 @@ function Timeline:render()
-- Hovered time and chapter
local rendered_thumbnail = false
if (self.proximity_raw == 0 or self.pressed or hovered_chapter) and not Elements:v('speed', 'dragging') then
if (self.proximity_raw <= 0 or self.pressed or hovered_chapter) and not Elements:v('speed', 'dragging') then
local cursor_x = hovered_chapter and t2x(hovered_chapter.time) or cursor.x
local hovered_seconds = hovered_chapter and hovered_chapter.time or self:get_time_at_x(cursor.x)
+9 -8
View File
@@ -21,11 +21,17 @@ function TopBar:init()
self.current_chapter = nil
local function maximized_command()
if state.platform == 'windows' then
mp.command(state.border
and (state.fullscreen and 'set fullscreen no;cycle window-maximized' or 'cycle window-maximized')
or 'set window-maximized no;cycle fullscreen')
else
mp.command(state.fullormaxed and 'set fullscreen no;set window-maximized no' or 'set window-maximized yes')
end
end
local close = {icon = 'close', hover_bg = '2311e8', hover_fg = 'ffffff', command = function() mp.command('quit') end}
local max = {icon = 'crop_square', command = maximized_command, is_max = true}
local max = {icon = 'crop_square', command = maximized_command}
local min = {icon = 'minimize', command = function() mp.command('cycle window-minimized') end}
self.buttons = options.top_bar_controls == 'left' and {close, max, min} or {min, max, close}
@@ -237,13 +243,8 @@ function TopBar:render()
end
for _, button in ipairs(self.buttons) do
if button.is_max then
button.icon = state.fullscreen and 'close_fullscreen' or
(state.maximized and 'filter_none' or 'crop_square')
end
local rect = {ax = button_ax, ay = ay, bx = button_ax + self.size, by = by}
local is_hover = get_point_to_rectangle_proximity(cursor, rect) == 0
local is_hover = get_point_to_rectangle_proximity(cursor, rect) <= 0
local opacity = is_hover and 1 or config.opacity.controls
local button_fg = is_hover and (button.hover_fg or bg) or fg
local button_bg = is_hover and (button.hover_bg or fg) or bg
@@ -290,7 +291,7 @@ function TopBar:render()
bx = ax + rect_width,
by = by - margin,
}
local opacity = get_point_to_rectangle_proximity(cursor, rect) == 0
local opacity = get_point_to_rectangle_proximity(cursor, rect) <= 0
and 1 or config.opacity.playlist_position
if opacity > 0 then
ass:rect(rect.ax, rect.ay, rect.bx, rect.by, {
+319
View File
@@ -0,0 +1,319 @@
local Element = require('elements/Element')
local dots = {'.', '..', '...'}
local function cleanup_output(output)
return tostring(output):gsub('%c*\n%c*', '\n'):match('^[%s%c]*(.-)[%s%c]*$')
end
---@class Updater : Element
local Updater = class(Element)
function Updater:new() return Class.new(self) --[[@as Updater]] end
function Updater:init()
Element.init(self, 'updater', {render_order = 1000})
self.output = nil
self.title = ''
self.state = 'circle' -- Also used as an icon name. 'pending' maps to 'spinner'.
self.update_available = false
-- Buttons
self.check_button = {method = 'check', title = t('Check for updates')}
self.update_button = {method = 'update', title = t('Update uosc'), color = config.color.success}
self.changelog_button = {method = 'open_changelog', title = t('Open changelog')}
self.close_button = {method = 'destroy', title = t('Close') .. ' (Esc)', color = config.color.error}
self.quit_button = {method = 'quit', title = t('Quit')}
self.buttons = {self.check_button, self.close_button}
self.selected_button_index = 1
-- Key bindings
self:add_key_binding('right', 'select_next_button')
self:add_key_binding('tab', 'select_next_button')
self:add_key_binding('left', 'select_prev_button')
self:add_key_binding('shift+tab', 'select_prev_button')
self:add_key_binding('enter', 'activate_selected_button')
self:add_key_binding('kp_enter', 'activate_selected_button')
self:add_key_binding('esc', 'destroy')
Elements:maybe('curtain', 'register', self.id)
self:check()
end
function Updater:destroy()
Elements:maybe('curtain', 'unregister', self.id)
Element.destroy(self)
end
function Updater:quit()
mp.command('quit')
end
function Updater:select_prev_button()
self.selected_button_index = self.selected_button_index - 1
if self.selected_button_index < 1 then self.selected_button_index = #self.buttons end
request_render()
end
function Updater:select_next_button()
self.selected_button_index = self.selected_button_index + 1
if self.selected_button_index > #self.buttons then self.selected_button_index = 1 end
request_render()
end
function Updater:activate_selected_button()
local button = self.buttons[self.selected_button_index]
if button then self[button.method](self) end
end
---@param msg string
function Updater:append_output(msg)
self.output = (self.output or '') .. ass_escape('\n' .. cleanup_output(msg))
request_render()
end
---@param msg string
function Updater:display_error(msg)
self.state = 'error'
self.title = t('An error has occurred.') .. ' ' .. t('See console for details.')
self:append_output(msg)
print(msg)
end
function Updater:open_changelog()
if self.state == 'pending' then return end
local url = 'https://github.com/tomasklaen/uosc/releases'
self:append_output('Opening URL: ' .. url)
call_ziggy_async({'open', url}, function(error)
if error then
self:display_error(error)
return
end
end)
end
function Updater:check()
if self.state == 'pending' then return end
self.state = 'pending'
self.title = t('Checking for updates') .. '...'
local url = 'https://api.github.com/repos/tomasklaen/uosc/releases/latest'
local headers = utils.format_json({
Accept = 'application/vnd.github+json',
})
local args = {'http-get', '--headers', headers, url}
self:append_output('Fetching: ' .. url)
call_ziggy_async(args, function(error, response)
if error then
self:display_error(error)
return
end
release = utils.parse_json(type(response.body) == 'string' and response.body or '')
if response.status == 200 and type(release) == 'table' and type(release.tag_name) == 'string' then
self.update_available = config.version ~= release.tag_name
self:append_output('Response: 200 OK')
self:append_output('Current version: ' .. config.version)
self:append_output('Latest version: ' .. release.tag_name)
if self.update_available then
self.state = 'upgrade'
self.title = t('Update available')
self.buttons = {self.update_button, self.changelog_button, self.close_button}
self.selected_button_index = 1
else
self.state = 'done'
self.title = t('Up to date')
end
else
self:display_error('Response couldn\'t be parsed, is invalid, or not-OK status code.\nStatus: ' ..
response.status .. '\nBody: ' .. response.body)
end
request_render()
end)
end
function Updater:update()
if self.state == 'pending' then return end
self.state = 'pending'
self.title = t('Updating uosc')
self.output = nil
request_render()
local config_dir = mp.command_native({'expand-path', '~~/'})
local function handle_result(success, result, error)
if success and result and result.status == 0 then
self.state = 'done'
self.title = t('uosc has been installed. Restart mpv for it to take effect.')
self.buttons = {self.quit_button, self.close_button}
self.selected_button_index = 1
else
self.state = 'error'
self.title = t('An error has occurred.') .. ' ' .. t('See above for clues.')
end
local output = (result.stdout or '') .. '\n' .. (error or result.stderr or '')
if state.platform == 'darwin' then
output =
'Self-updater is known not to work on MacOS.\nIf you know about a solution, please make an issue and share it with us!.\n' ..
output
end
self:append_output(output)
end
local function update(args)
local env = utils.get_env_list()
env[#env + 1] = 'MPV_CONFIG_DIR=' .. config_dir
mp.command_native_async({
name = 'subprocess',
capture_stderr = true,
capture_stdout = true,
playback_only = false,
args = args,
env = env,
}, handle_result)
end
if state.platform == 'windows' then
local url = 'https://raw.githubusercontent.com/tomasklaen/uosc/HEAD/installers/windows.ps1'
update({'powershell', '-NoProfile', '-Command', 'irm ' .. url .. ' | iex'})
else
-- Detect missing dependencies. We can't just let the process run and
-- report an error, as on snap packages there's no error. Everything
-- either exits with 0, or no helpful output/error message.
local missing = {}
for _, name in ipairs({'curl', 'unzip'}) do
local result = mp.command_native({
name = 'subprocess',
capture_stdout = true,
playback_only = false,
args = {'which', name},
})
local path = cleanup_output(result and result.stdout or '')
if path == '' then
missing[#missing + 1] = name
end
end
if #missing > 0 then
local stderr = 'Missing dependencies: ' .. table.concat(missing, ', ')
if config_dir:match('/snap/') then
stderr = stderr ..
'\nThis is a known error for mpv snap packages.\nYou can still update uosc by entering the Linux install command from uosc\'s readme into your terminal, it just can\'t be done this way.\nIf you know about a solution, please make an issue and share it with us!'
end
handle_result(false, {stderr = stderr})
else
local url = 'https://raw.githubusercontent.com/tomasklaen/uosc/HEAD/installers/unix.sh'
update({'/bin/bash', '-c', 'source <(curl -fsSL ' .. url .. ')'})
end
end
end
function Updater:render()
local ass = assdraw.ass_new()
local text_size = math.min(20 * state.scale, display.height / 20)
local icon_size = text_size * 2
local center_x = round(display.width / 2)
local color = fg
if self.state == 'done' or self.update_available then
color = config.color.success
elseif self.state == 'error' then
color = config.color.error
end
-- Divider
local divider_width = round(math.min(500 * state.scale, display.width * 0.8))
local divider_half, divider_border_half, divider_y = divider_width / 2, round(1 * state.scale), display.height * 0.65
local divider_ay, divider_by = round(divider_y - divider_border_half), round(divider_y + divider_border_half)
ass:rect(center_x - divider_half, divider_ay, center_x - icon_size, divider_by, {
color = color, border = options.text_border * state.scale, border_color = bg, opacity = 0.5,
})
ass:rect(center_x + icon_size, divider_ay, center_x + divider_half, divider_by, {
color = color, border = options.text_border * state.scale, border_color = bg, opacity = 0.5,
})
if self.state == 'pending' then
ass:spinner(center_x, divider_y, icon_size, {
color = fg, border = options.text_border * state.scale, border_color = bg,
})
else
ass:icon(center_x, divider_y, icon_size * 0.8, self.state, {
color = color, border = options.text_border * state.scale, border_color = bg,
})
end
-- Output
local output = self.output or dots[math.ceil((mp.get_time() % 1) * #dots)]
ass:txt(center_x, divider_y - icon_size, 2, output, {
size = text_size, color = fg, border = options.text_border * state.scale, border_color = bg,
})
-- Title
ass:txt(center_x, divider_y + icon_size, 5, self.title, {
size = text_size, bold = true, color = color, border = options.text_border * state.scale, border_color = bg,
})
-- Buttons
local outline = round(1 * state.scale)
local spacing = outline * 9
local padding = round(text_size * 0.5)
local text_opts = {size = text_size, bold = true}
-- Calculate button text widths
local total_width = (#self.buttons - 1) * spacing
for _, button in ipairs(self.buttons) do
button.width = text_width(button.title, text_opts) + padding * 2
total_width = total_width + button.width
end
-- Render buttons
local ay = round(divider_y + icon_size * 1.8)
local ax = round(display.width / 2 - total_width / 2)
local height = text_size + padding * 2
for index, button in ipairs(self.buttons) do
local rect = {
ax = ax,
ay = ay,
bx = ax + button.width,
by = ay + height,
}
ax = rect.bx + spacing
local is_hovered = get_point_to_rectangle_proximity(cursor, rect) <= 0
-- Background
ass:rect(rect.ax, rect.ay, rect.bx, rect.by, {
color = button.color or fg,
radius = state.radius,
opacity = is_hovered and 1 or 0.8,
})
-- Selected outline
if index == self.selected_button_index then
ass:rect(rect.ax - outline * 4, rect.ay - outline * 4, rect.bx + outline * 4, rect.by + outline * 4, {
border = outline,
border_color = button.color or fg,
radius = state.radius + outline * 4,
opacity = {primary = 0, border = 0.5},
})
end
-- Text
local x, y = rect.ax + (rect.bx - rect.ax) / 2, rect.ay + (rect.by - rect.ay) / 2
ass:txt(x, y, 5, button.title, {size = text_size, bold = true, color = fgt})
cursor:zone('primary_down', rect, self:create_action(button.method))
-- Select hovered button
if is_hovered then self.selected_button_index = index end
end
return ass
end
return Updater
+1
View File
@@ -242,6 +242,7 @@ end
function Volume:on_display() self:update_dimensions() end
function Volume:on_prop_border() self:update_dimensions() end
function Volume:on_prop_title_bar() self:update_dimensions() end
function Volume:on_prop_volume_max() self:update_dimensions() end
function Volume:on_controls_reflow() self:update_dimensions() end
function Volume:on_options() self:update_dimensions() end
+1 -1
View File
@@ -26,7 +26,7 @@ function WindowBorder:render()
local clip = '\\iclip(' .. self.size .. ',' .. self.size .. ',' ..
(display.width - self.size) .. ',' .. (display.height - self.size) .. ')'
ass:rect(0, 0, display.width + 1, display.height + 1, {
color = bg, clip = clip, opacity = config.opacity.border,
color = config.color.window_border, clip = clip, opacity = config.opacity.border,
})
return ass
end
+59
View File
@@ -0,0 +1,59 @@
{
"Aspect ratio": "Proporizioni",
"Audio": "Audio",
"Audio device": "Dispositivo audio",
"Audio devices": "Dispositivi audio",
"Audio tracks": "Tracce audio",
"Autoselect device": "Selezione automatica dispositivo",
"Chapter %s": "Capitolo %s",
"Chapters": "Capitoli",
"Default": "Predefinito",
"Default %s": "Predefinito %s",
"Delete file & Next": "Elimina file e Successivo",
"Delete file & Prev": "Elimina file e Precedente",
"Delete file & Quit": "Elimina file e Esci",
"Disabled": "Disabilitato",
"Drives": "Unità",
"Edition": "Edizione",
"Edition %s": "Edizione %s",
"Editions": "Edizioni",
"Empty": "Vuoto",
"First": "Primo",
"Fullscreen": "Schermo intero",
"Last": "Ultimo",
"Load": "Carica",
"Load audio": "Carica traccia audio",
"Load subtitles": "Carica sottotitoli",
"Load video": "Carica traccia video",
"Loop file": "Ripeti file",
"Loop playlist": "Ripeti playlist",
"Menu": "Menu",
"Navigation": "Navigazione",
"Next": "Successivo",
"No file": "Nessun file",
"Open config folder": "Apri cartella configurazione",
"Open file": "Apri file",
"Playlist": "Playlist",
"Playlist/Files": "Playlist/File",
"Prev": "Precedente",
"Previous": "Precedente",
"Quit": "Esci",
"Screenshot": "Schermata",
"Show in directory": "Mostra nella cartella",
"Shuffle": "Riproduzione casuale",
"Stream quality": "Qualità streaming",
"Subtitles": "Sottotitoli",
"Track": "Traccia",
"Track %s": "Traccia %s",
"Utils": "Utilità",
"Video": "Video",
"%s channel": "%s canale",
"%s channels": "%s canali",
"default": "predefinito",
"drive": "unità",
"external": "esterno",
"forced": "forzato",
"open file": "seleziona file",
"parent dir": "cartella superiore",
"playlist or file": "file o playlist"
}
+99
View File
@@ -0,0 +1,99 @@
{
"%s are empty": "%s estão vazios",
"%s channel": "%s canal",
"%s channels": "%s canais",
"%s to delete": "%s para excluir",
"%s to go up in tree.": "%s para subir na árvore",
"%s to reorder.": "%s para reordenar",
"%s to search": "%s para buscar",
"Add to playlist": "Adicionar à lista",
"Added to playlist": "Adicionado à lista",
"An error has occurred.": "Ocorreu um erro.",
"Aspect ratio": "Proporção da tela",
"Audio": "Áudio",
"Audio device": "Dispositivo de áudio",
"Audio devices": "Dispositivos de áudio",
"Audio tracks": "Faixas de áudio",
"Chapter %s": "Capítulo %s",
"Chapters": "Capítulos",
"Copied to clipboard": "Copiado para a área de transferência",
"Default": "Padrão",
"Default %s": "Padrão %s",
"Delete": "Excluir",
"Delete file & Next": "Excluir arquivo e Próximo",
"Delete file & Prev": "Excluir arquivo e Anterior",
"Delete file & Quit": "Excluir arquivo e Sair",
"Drives": "Unidades",
"Drop files or URLs to play here": "Arraste arquivos ou URLs para reproduzir aqui",
"Edition %s": "Edição %s",
"Editions": "Edições",
"Empty": "Vazio",
"First": "Primeiro",
"Fullscreen": "Tela cheia",
"Key bindings": "Atalhos de teclado",
"Last": "Último",
"Load": "Abrir",
"Load audio": "Carregar faixa de áudio",
"Load subtitles": "Carregar faixa de legenda",
"Load video": "Carregar faixa de vídeo",
"Loaded audio": "Áudio carregado",
"Loaded subtitles": "Legendas carregadas",
"Loaded video": "Vídeo carregado",
"Loop file": "Repetir arquivo",
"Loop playlist": "Repetir lista",
"Menu": "Menu",
"Move down": "Mover para baixo",
"Move up": "Mover para cima",
"Navigation": "Navegação",
"Next": "Próximo",
"Next page": "Próxima página",
"No file": "Nenhum arquivo",
"Open config folder": "Abrir pasta de configuração",
"Open file": "Abrir arquivo",
"Open in browser": "Abrir no navegador",
"Open in mpv": "Abrir no mpv",
"Paste path or url to add.": "Cole o caminho ou URL para adicionar.",
"Paste path or url to open.": "Cole o caminho ou URL para abrir.",
"Play/Pause": "Reproduzir/Pausar",
"Playlist": "Lista de reprodução",
"Playlist/Files": "Lista/Arquivos",
"Prev": "Anterior",
"Previous": "Anterior",
"Previous page": "Página anterior",
"Quit": "Sair",
"Reload": "Recarregar",
"Remaining downloads today: %s": "Restante de downloads hoje: %s",
"Remove": "Remover",
"Resets in: %s": "Reinicia em: %s",
"Screenshot": "Captura de tela",
"Search online": "Pesquisar online",
"See above for clues.": "Veja acima por dicas.",
"See console for details.": "Veja o console para detalhes.",
"Show in directory": "Mostrar na pasta",
"Shuffle": "Aleatório",
"Something went wrong.": "Algo deu errado.",
"Stream quality": "Qualidade da transmissão",
"Subtitles": "Legendas",
"Subtitles loaded & enabled": "Legendas carregadas e ativadas",
"Toggle to disable.": "Alternar para desativar",
"Track %s": "Faixa %s",
"Update uosc": "Atualizar uosc",
"Updating uosc": "Atualizando uosc",
"Use as secondary": "Usar como secundário",
"Utils": "Ferramentas",
"Video": "Vídeo",
"default": "padrão",
"drive": "unidade",
"enter query": "digite a consulta",
"external": "externo",
"forced": "forçada",
"foreign parts only": "somente partes estrangeiras",
"hearing impaired": "deficiência auditiva",
"no results": "sem resultados",
"open file": "abrir arquivo",
"parent dir": "diretório superior",
"playlist or file": "lista ou arquivo",
"type & ctrl+enter to search": "digite e pressione Ctrl+Enter para buscar",
"type to search": "digite para buscar",
"uosc has been installed. Restart mpv for it to take effect.": "uosc foi instalado. Reinicie o mpv para que tenha efeito."
}
+107
View File
@@ -0,0 +1,107 @@
{
"%s are empty": "%s 是空字串",
"%s channel": "%s 聲道",
"%s channels": "%s 聲道",
"%s to delete": "使用 %s 删除",
"%s to go up in tree.": "使用 %s 返回上一級",
"%s to reorder.": "使用 %s 重新排序",
"%s to search": "使用 %s 搜尋",
"Add to playlist": "新增到播放清單",
"Added to playlist": "已新增到播放清單",
"An error has occurred.": "出現錯誤",
"Aspect ratio": "長寬比",
"Audio": "音訊",
"Audio device": "音訊裝置",
"Audio devices": "音訊裝置",
"Audio tracks": "音軌",
"Autoload": "自動載入",
"Chapter %s": "第 %s 章",
"Chapters": "章節",
"Check for updates": "檢查更新",
"Checking for updates": "正在檢查更新",
"Close": "關閉",
"Copied to clipboard": "已複製到剪貼簿",
"Default": "預設",
"Default %s": "預設 %s",
"Delete": "删除",
"Delete file & Next": "删除檔案並播放下一個",
"Delete file & Prev": "删除檔案並播放上一個",
"Delete file & Quit": "删除檔案並退出",
"Drives": "硬碟",
"Drop files or URLs to play here": "拖放檔案或 URLs 到此播放",
"Edition %s": "版本 %s",
"Editions": "版本",
"Empty": "空",
"First": "第一個",
"Fullscreen": "全螢幕",
"Key bindings": "快捷鍵",
"Last": "最後一個",
"Load": "載入",
"Load audio": "載入音訊",
"Load subtitles": "載入字幕",
"Load video": "載入視訊",
"Loaded audio": "已載入音訊",
"Loaded subtitles": "已載入字幕",
"Loaded video": "已載入視訊",
"Loop file": "重複播放",
"Loop playlist": "重複播放清單",
"Menu": "選單",
"Move down": "下移",
"Move up": "上移",
"Navigation": "導覽",
"Next": "下一個",
"Next page": "下一頁",
"No file": "無檔案",
"Nothing to copy": "沒有東西可以複製",
"Open changelog": "開啟更新日誌",
"Open config folder": "開啟設定檔資料夾",
"Open file": "開啟檔案",
"Open in browser": "用瀏覽器開啟",
"Open in mpv": "用 mpv 開啟",
"Paste path or url to add.": "貼上路徑或 url 以新增",
"Paste path or url to open.": "貼上路徑或 url 以開啟",
"Play/Pause": "播放/暫停",
"Playlist": "播放清單",
"Playlist/Files": "播放清單/檔案列表",
"Prev": "上一個",
"Previous": "上一個",
"Previous page": "上一頁",
"Quit": "結束",
"Reload": "重新載入",
"Remaining downloads today: %s": "今天的剩餘下載量: %s",
"Remove": "移除",
"Resets in: %s": "重置: %s",
"Screenshot": "截圖",
"Search online": "線上搜尋",
"See above for clues.": "請參閱上文提示",
"See console for details.": "詳情請參閱終端",
"Show in directory": "開啟所在資料夾",
"Shuffle": "隨機播放",
"Something went wrong.": "出錯了",
"Stream quality": "串流質素",
"Subtitles": "字幕",
"Subtitles loaded & enabled": "已載入及啟用字幕",
"Toggle to disable.": "切換以停用",
"Track %s": "音軌 %s",
"Up to date": "最新版本",
"Update available": "有可用更新",
"Update uosc": "更新 uosc",
"Updating uosc": "正在更新 uosc",
"Use as secondary": "設為副字幕",
"Utils": "工具",
"Video": "影片",
"default": "預設",
"drive": "硬碟",
"enter query": "輸入查詢",
"external": "外置",
"forced": "強制",
"foreign parts only": "只限外語部分",
"hearing impaired": "聽障",
"no results": "沒有結果",
"open file": "開啟檔案",
"parent dir": "父資料夾",
"playlist or file": "播放清單或檔案",
"type & ctrl+enter to search": "輸入並按 ctrl+enter 搜尋",
"type to search": "輸入文字以搜尋內容",
"uosc has been installed. Restart mpv for it to take effect.": "已安装 uosc ,重新開啟 mpv 使其生效"
}
+44
View File
@@ -266,3 +266,47 @@ function ass_mt:spinner(x, y, size, opts)
self:icon(x, y, size, 'autorenew', opts)
request_render()
end
-- Renders a smooth curve from Bezier segments.
---@param ax number
---@param ay number
---@param bx number
---@param by number
---@param points number[] Flat table of normalized points (01): start point followed by segment entries cp1x, cp1y, cp2x, cp2y, px, py, ...
---@param opts? {color?: string; border?: number; border_color?: string; opacity?: number|{primary?: number; border?: number, shadow?: number, main?: number}; clip?: string}
function ass_mt:smooth_curve(ax, ay, bx, by, points, opts)
if not points or #points < 8 then return end
opts = opts or {}
local border_size = opts.border or 0
local tags = '\\pos(0,0)\\rDefault\\an7\\blur0'
-- border
tags = tags .. '\\bord' .. border_size
-- colors
tags = tags .. '\\1c&H' .. (opts.color or fg)
if border_size > 0 then tags = tags .. '\\3c&H' .. (opts.border_color or bg) end
-- opacity
if opts.opacity then tags = tags .. self.opacity(nil, opts.opacity) end
-- clip
if opts.clip then tags = tags .. opts.clip end
-- draw
self:new_event()
self.text = self.text .. '{' .. tags .. '}'
self:draw_start()
-- Scale normalized (01) coordinates to rectangle bounds
local width, height = bx - ax, by - ay
local function scale(x, y)
return ax + x * width, ay + y * height
end
local x0, y0 = scale(points[1], points[2])
self:move_to(x0, y0)
local max = math.floor((#points - 2) / 6) * 6 + 2
for i = 3, max, 6 do
local x1, y1 = scale(points[i], points[i+1])
local x2, y2 = scale(points[i+2], points[i+3])
local x3, y3 = scale(points[i+4], points[i+5])
self:bezier_curve(x1, y1, x2, y2, x3, y3)
end
self:draw_stop()
end
+32 -80
View File
@@ -208,31 +208,6 @@ function create_select_tracklist_type_menu_opener(opts)
return tonumber(mp.get_property(opts.prop)), snd and tonumber(mp.get_property(snd.prop)) or nil
end
local function escape_codec(str)
if not str or str == '' then return '' end
local codec_map = {
mpeg2 = "mpeg2",
dvvideo = "dv",
pcm = "pcm",
pgs = "pgs",
subrip = "srt",
vtt = "vtt",
dvd_sub = "vob",
dvb_sub = "dvb",
dvb_tele = "teletext",
arib = "arib"
}
for key, value in pairs(codec_map) do
if str:find(key) then
return value
end
end
return str
end
local function serialize_tracklist(tracklist)
local items = {}
@@ -285,15 +260,14 @@ function create_select_tracklist_type_menu_opener(opts)
if track['demux-h'] then
h(track['demux-w'] and (track['demux-w'] .. 'x' .. track['demux-h']) or (track['demux-h'] .. 'p'))
end
if track['demux-fps'] then h(string.format('%.5g fps', track['demux-fps'])) end
if track['codec'] then h(escape_codec(track.codec)) end
if track['demux-fps'] then h(string.format('%.5gfps', track['demux-fps'])) end
h(track.codec)
if track['audio-channels'] then
h(track['audio-channels'] == 1
and t('%s channel', track['audio-channels'])
or t('%s channels', track['audio-channels']))
end
if track['demux-samplerate'] then h(string.format('%.3g kHz', track['demux-samplerate'] / 1000)) end
if track['demux-bitrate'] then h(string.format('%.0f kbps', track['demux-bitrate'] / 1000)) end
if track['demux-samplerate'] then h(string.format('%.3gkHz', track['demux-samplerate'] / 1000)) end
if track.forced then h(t('forced')) end
if track.default then h(t('default')) end
if track.external then
@@ -920,7 +894,8 @@ function open_subtitle_downloader()
return
end
local search_suggestion, destination_directory = '', nil
local search_suggestion, file_path, destination_directory = '', nil, nil
local credentials = {'--api-key', config.open_subtitles_api_key, '--agent', config.open_subtitles_agent}
if state.path then
if is_protocol(state.path) then
@@ -930,6 +905,7 @@ function open_subtitle_downloader()
local serialized_path = serialize_path(state.path)
if serialized_path then
search_suggestion = serialized_path.filename
file_path = state.path
destination_directory = serialized_path.dirname
end
end
@@ -942,7 +918,6 @@ function open_subtitle_downloader()
end
local handle_download, handle_search
local url = 'https://api.opensubtitles.com/api/v1'
-- Checks if there an error, or data is invalid. If true, reports the error,
-- updates menu to inform about it, and returns true.
@@ -991,49 +966,16 @@ function open_subtitle_downloader()
end
end)
local download_url = url .. '/download'
local headers = {
['Accept'] = 'application/json',
['Api-Key'] = config.open_subtitles_api_key,
['Content-Type'] = 'application/json',
['User-Agent'] = config.open_subtitles_agent,
}
local body = {
file_id = data.id
}
http_request_async('POST', download_url, headers, body, function(error, data)
if not menu:is_alive() then return end
if data and data.link then
local file_path = utils.join_path(destination_directory, data.file_name)
local arg = {
'curl',
'-sL',
'--user-agent', config.open_subtitles_agent,
'-o', file_path,
data.link
}
mp.command_native({
name = 'subprocess',
capture_stdout = true,
capture_stderr = true,
playback_only = false,
args = arg
local args = itable_join({'download-subtitles'}, credentials, {
'--file-id', tostring(data.id),
'--destination', destination_directory,
})
end
local function check_is_valid(data)
local path = data and utils.join_path(destination_directory, data.file_name) or nil
local meta = path and utils.file_info(path) or nil
return meta and meta.is_file
end
if should_abort(error, data, check_is_valid) then return end
call_ziggy_async(args, function(error, data)
if not menu:is_alive() then return end
if should_abort(error, data, function(data) return type(data.file) == 'string' end) then return end
load_track('sub', utils.join_path(destination_directory, data.file_name))
load_track('sub', data.file)
menu:update_items({
{
@@ -1043,7 +985,7 @@ function open_subtitle_downloader()
selectable = false,
},
{
title = t('Remaining downloads today: %s', data.remaining),
title = t('Remaining downloads today: %s', data.remaining .. '/' .. data.total),
italic = true,
muted = true,
icon = 'file_download',
@@ -1068,22 +1010,32 @@ function open_subtitle_downloader()
menu:update_items({{icon = 'spinner', align = 'center', selectable = false, muted = true}})
local args = itable_join({'search-subtitles'}, credentials)
local languages = itable_filter(get_languages(), function(lang) return lang:match('.json$') == nil end)
args[#args + 1] = '--languages'
args[#args + 1] = table.concat(table_keys(create_set(languages)), ',') -- deduplicates stuff like `en,eng,en`
local search_url = string.format('%s/subtitles?query=%s&languages=%s&page=%s', url, url_encode(query),
table.concat(table_keys(create_set(languages)), ','), tostring(page))
args[#args + 1] = '--page'
args[#args + 1] = tostring(page)
local headers = {
['Api-Key'] = config.open_subtitles_api_key,
['User-Agent'] = config.open_subtitles_agent,
}
if file_path then
args[#args + 1] = '--hash'
args[#args + 1] = file_path
end
http_request_async('GET', search_url, headers, nil, function(error, data)
if query and #query > 0 then
args[#args + 1] = '--query'
args[#args + 1] = query
end
call_ziggy_async(args, function(error, data)
if not menu:is_alive() then return end
local function check_is_valid(data)
return data and type(data.data) == 'table' and data.page and data.total_pages
return type(data.data) == 'table' and data.page and data.total_pages
end
if should_abort(error, data, check_is_valid) then return end
local subs = itable_filter(data.data, function(sub)
-28
View File
@@ -24,34 +24,6 @@ end
---@return string
function trim(str) return str:match('^%s*(.-)%s*$') end
---@param str string
---@return string|nil
function url_encode(str)
if str then
str = str:gsub('([^%w%-%.%_%~])', function(c)
return string.format('%%%02X', string.byte(c))
end)
end
return str
end
-- Escape special characters in url.
---@param str string
---@return string|nil
function url_decode(str)
local function hex_to_char(x)
return string.char(tonumber(x, 16))
end
if str ~= nil then
str = str:gsub('^file://', '')
str = str:gsub('%%(%x%x)', hex_to_char)
if str:find('://localhost:?') then
str = str:gsub('^.*/', '')
end
end
return str
end
-- Trim any `char` from the end of the string.
---@param str string
---@param char string
+90 -91
View File
@@ -130,12 +130,14 @@ function tween(from, to, setter, duration_or_callback, callback)
return finish
end
-- Returns signed distance (negative values mean how deep inside the rect the point is).
---@param point Point
---@param rect Rect
function get_point_to_rectangle_proximity(point, rect)
local dx = math.max(rect.ax - point.x, 0, point.x - rect.bx)
local dy = math.max(rect.ay - point.y, 0, point.y - rect.by)
return math.sqrt(dx * dx + dy * dy)
local dx = math.max(rect.ax - point.x, point.x - rect.bx)
local dy = math.max(rect.ay - point.y, point.y - rect.by)
local distance = math.sqrt(math.max(0, dx)^2 + math.max(0, dy)^2)
return distance + math.min(0, math.max(dx, dy))
end
---@param point_a Point
@@ -149,7 +151,7 @@ end
---@param hitbox Hitbox
function point_collides_with(point, hitbox)
return (hitbox.r and get_point_to_point_proximity(point, hitbox.point) <= hitbox.r) or
(not hitbox.r and get_point_to_rectangle_proximity(point, hitbox --[[@as Rect]]) == 0)
(not hitbox.r and get_point_to_rectangle_proximity(point, hitbox --[[@as Rect]]) <= 0)
end
---@param lax number
@@ -221,6 +223,37 @@ function get_ray_to_rectangle_distance(ax, ay, bx, by, rect)
return closest
end
-- Converts a flat table of points to a smooth curve using Catmull-Rom to Bezier conversion.
---@param points number[] Flat table: x1, y1, x2, y2, ...
---@return number[] Flat table: start point followed by segment entries cp1x, cp1y, cp2x, cp2y, px, py, ...
function points_to_bezier(points)
if not points or #points < 4 then return {} end
local function catmullrom_to_bezier(p0x, p0y, p1x, p1y, p2x, p2y, p3x, p3y)
local cp1x = p1x + (p2x - p0x) / 6
local cp1y = p1y + (p2y - p0y) / 6
local cp2x = p2x - (p3x - p1x) / 6
local cp2y = p2y - (p3y - p1y) / 6
return cp1x, cp1y, cp2x, cp2y
end
-- Helper to get x, y from flat table
local function get_xy(i)
return points[i * 2 - 1], points[i * 2]
end
local curve = {points[1], points[2]}
local xy_pairs = #points / 2
for i = 1, xy_pairs - 1 do
local p0x, p0y = get_xy(math.max(i - 1, 1))
local p1x, p1y = get_xy(i)
local p2x, p2y = get_xy(i+1)
local p3x, p3y = get_xy(math.min(i + 2, xy_pairs))
local cp1x, cp1y, cp2x, cp2y = catmullrom_to_bezier(p0x, p0y, p1x, p1y, p2x, p2y, p3x, p3y)
local n = #curve
curve[n+1], curve[n+2], curve[n+3], curve[n+4], curve[n+5], curve[n+6] =
cp1x, cp1y, cp2x, cp2y, p2x, p2y
end
return curve
end
-- Extracts the properties used by property expansion of that string.
---@param str string
---@param res { [string] : boolean } | nil
@@ -892,79 +925,17 @@ function call_ziggy_async(args, callback)
end
end
---@param url string
---@param method string
---@param callback fun(error: string|nil, data: table|nil)
---@return fun() abort Function to abort the request.
function http_request_async(method, url, headers, body, callback)
local args = { 'curl', '-s', '-L', '-X', method, url }
if headers then
for k, v in pairs(headers) do
table.insert(args, '-H')
table.insert(args, string.format('%s: %s', k, v))
end
end
if body then
table.insert(args, '-d')
table.insert(args, utils.format_json(body))
end
local abort_signal = mp.command_native_async({
name = 'subprocess',
capture_stdout = true,
capture_stderr = true,
playback_only = false,
args = args
}, function(success, res, error)
local error = error ~= '' and error or res and res.stderr ~= '' and res.stderr or nil
if not success or not res or res.status ~= 0 then
msg.error('HTTP request failed: ' .. (res.stderr or 'unknown error'))
callback(error, nil)
return
end
local data = utils.parse_json(res.stdout)
callback(error, data)
end)
return function()
mp.abort_async_command(abort_signal)
end
end
---@return string|nil
function get_clipboard()
if state.current_clipboard_backend then
if state.platform == 'windows' or state.platform == 'darwin' then
return mp.get_property('clipboard/text', '')
local data, err = mp.get_property('clipboard/text')
if data then
return data
end
if state.platform == 'linux' then
-- Wayland
if os.getenv('WAYLAND_DISPLAY') or os.getenv('WAYLAND_SOCKET') then
if state.current_clipboard_backend == "wayland" or mp.get_property_native("focused") then
return mp.get_property('clipboard/text', '')
if err and err ~= 'property not found' and err ~= 'property unavailable' then
mp.commandv('show-text', 'Get clipboard error: ' .. err)
return nil
end
local res = utils.subprocess({
args = { 'wl-paste', '-n' },
playback_only = false,
})
if not res.error then
return res.stdout
end
end
-- X11
local res = utils.subprocess({
args = { 'xclip', '-selection', 'clipboard', '-out' },
playback_only = false,
})
if not res.error then
return res.stdout
end
end
end
-- Fallback to ziggy
local err, data = call_ziggy({'get-clipboard'})
if err then
mp.commandv('show-text', 'Get clipboard error. See console for details.')
@@ -977,23 +948,17 @@ end
---@return string|nil payload String that was copied to clipboard.
function set_clipboard(payload)
payload = tostring(payload)
if state.current_clipboard_backend then
if state.platform == 'windows' or state.platform == 'darwin' then
return mp.commandv('set', 'clipboard/text', payload)
local success, err = mp.set_property('clipboard/text', payload)
if success then
mp.commandv('show-text', t('Copied to clipboard') .. ': ' .. payload, 3000)
return payload
end
if state.platform == 'linux' then
-- Wayland
if os.getenv('WAYLAND_DISPLAY') or os.getenv('WAYLAND_SOCKET') then
return utils.subprocess({ args = { 'wl-copy' }, stdin_data = payload })
if err and err ~= 'property not found' and err ~= 'property unavailable' then
mp.commandv('show-text', 'Set clipboard error: ' .. err)
return nil
end
-- X11
return utils.subprocess({
args = { 'xclip', '-silent', '-selection', 'clipboard', '-in' },
stdin_data = payload
})
end
end
-- Fallback to ziggy
local err, data = call_ziggy({'set-clipboard', payload})
if err then
mp.commandv('show-text', 'Set clipboard error. See console for details.')
@@ -1004,6 +969,43 @@ function set_clipboard(payload)
return data and data.payload
end
-- Returns Youtube heatmap data if available.
---@return number[]|nil Flat table of normalized points (01)
function load_youtube_heatmap()
if not state.path or not is_protocol(state.path) then return end
-- Match mpv's ytdl whitelist
if not (state.path:match('^https?://%w+%.youtube%.com/') or
state.path:match('^https?://youtube%.com/') or
state.path:match('^https?://youtu%.be/')) then return end
local r = mp.get_property_native('user-data/mpv/ytdl/json-subprocess-result')
local ytdl_result = r and utils.parse_json(r.stdout)
if ytdl_result and ytdl_result.heatmap then
local data = ytdl_result.heatmap
local max_val = 0
local vid_length = data[#data].end_time
for _, seg in ipairs(data) do
max_val = math.max(max_val, seg.value)
end
-- Normalize and clamp to avoid gaps in heatmap
local is_above = options.timeline_heatmap == 'above'
local min_height, graph_height = 4, is_above and 40 or options.timeline_size
local max_norm_y = 1 - (min_height / graph_height)
local norm = {0, 1}
for _, seg in ipairs(data) do
local center_time = (seg.start_time + seg.end_time) / 2
local norm_x = center_time / vid_length
local norm_y = math.min(max_norm_y, 1 - (seg.value / max_val))
norm[#norm + 1], norm[#norm + 2] = norm_x, norm_y
end
-- Add final anchor
local last_y = math.min(max_norm_y, 1 - (data[#data].value / max_val))
norm[#norm + 1], norm[#norm + 2] = 1, last_y
norm[#norm + 1], norm[#norm + 2] = 1, 1
return points_to_bezier(norm)
end
end
--[[ RENDERING ]]
function render()
@@ -1012,9 +1014,6 @@ function render()
cursor:clear_zones()
-- Click on empty area detection
if setup_click_detection then setup_click_detection() end
-- Actual rendering
local ass = assdraw.ass_new()
+20 -14
View File
@@ -1,5 +1,5 @@
--[[ uosc | https://github.com/tomasklaen/uosc ]]
local uosc_version = '5.10.0'
local uosc_version = '5.12.0'
mp.commandv('script-message', 'uosc-version', uosc_version)
@@ -27,6 +27,7 @@ defaults = {
timeline_border = 1,
timeline_step = '5',
timeline_cache = true,
timeline_heatmap = 'overlay',
controls =
'menu,gap,<video,audio>subtitles,<has_many_audio>audio,<has_many_video>video,<has_many_edition>editions,<stream>stream-quality,gap,space,<video,audio>speed,space,shuffle,loop-playlist,loop-file,gap,prev,items,next,gap,fullscreen',
@@ -66,7 +67,6 @@ defaults = {
scale = 1,
scale_fullscreen = 1.3,
font = '',
font_scale = 1,
text_border = 1.2,
border_radius = 4,
@@ -102,7 +102,6 @@ defaults = {
languages = 'slang,en',
subtitles_directory = '~~/subtitles',
disable_elements = '',
ziggy_path = 'default',
}
options = table_copy(defaults)
function handle_options(changed_options)
@@ -143,10 +142,12 @@ local config_defaults = {
foreground_text = serialize_rgba('000000').color,
background = serialize_rgba('000000').color,
background_text = serialize_rgba('ffffff').color,
window_border = serialize_rgba('000000').color,
curtain = serialize_rgba('111111').color,
success = serialize_rgba('a5e075').color,
error = serialize_rgba('ff616e').color,
match = serialize_rgba('69c5ff').color,
heatmap = serialize_rgba('00adee').color,
},
opacity = {
timeline = 0.9,
@@ -167,6 +168,7 @@ local config_defaults = {
audio_indicator = 0.5,
buffering_indicator = 0.3,
playlist_position = 0.8,
heatmap = 0.4,
},
}
config = {
@@ -176,7 +178,7 @@ config = {
-- sets max rendering frequency in case the
-- native rendering frequency could not be detected
render_delay = 1 / 60,
font = options.font ~= '' and options.font or mp.get_property('options/osd-font'),
font = mp.get_property('options/osd-font'),
osd_margin_x = mp.get_property('osd-margin-x'),
osd_margin_y = mp.get_property('osd-margin-y'),
osd_alignment_x = mp.get_property('osd-align-x'),
@@ -333,7 +335,7 @@ function create_default_menu_items()
{
title = t('Aspect ratio'),
items = {
{title = t('Default'), value = 'set video-aspect-override "-1"'},
{title = t('Default'), value = 'set video-aspect-override no'},
{title = '16:9', value = 'set video-aspect-override "16:9"'},
{title = '4:3', value = 'set video-aspect-override "4:3"'},
{title = '2.35:1', value = 'set video-aspect-override "2.35:1"'},
@@ -345,6 +347,7 @@ function create_default_menu_items()
{title = t('Key bindings'), value = 'script-binding uosc/keybinds'},
{title = t('Show in directory'), value = 'script-binding uosc/show-in-directory'},
{title = t('Open config folder'), value = 'script-binding uosc/open-config-directory'},
{title = t('Update uosc'), value = 'script-binding uosc/update'},
},
},
{title = t('Quit'), value = 'quit'},
@@ -381,7 +384,6 @@ state = {
ime_active = mp.get_property_native('input-ime'),
chapters = {},
chapter_ranges = {},
current_clipboard_backend = mp.get_property_native('current-clipboard-backend'),
border = mp.get_property_native('border'),
title_bar = mp.get_property_native('title-bar'),
fullscreen = mp.get_property_native('fullscreen'),
@@ -442,9 +444,7 @@ require('lib/menus')
-- Determine path to ziggy
do
local bin = 'ziggy-' .. (state.platform == 'windows' and 'windows.exe' or state.platform)
config.ziggy_path = os.getenv('MPV_UOSC_ZIGGY') or
options.ziggy_path == 'default' and join_path(mp.get_script_directory(), join_path('bin', bin)) or
utils.join_path(mp.command_native({ 'expand-path', options.ziggy_path }) or '', bin)
config.ziggy_path = os.getenv('MPV_UOSC_ZIGGY') or join_path(mp.get_script_directory(), join_path('bin', bin))
end
--[[ STATE UPDATERS ]]
@@ -746,6 +746,7 @@ mp.observe_property('demuxer-cache-state', 'native', function(prop, cache_state)
set_state('cache_duration', not cache_state.eof and cache_state['cache-duration'] or nil)
else
cached_ranges = {}
set_state('cache_underrun', false)
end
if not (state.duration and (#cached_ranges > 0 or state.cache == 'yes' or
@@ -869,13 +870,12 @@ bind_command('playlist', create_self_updating_menu_opener({
footnote = t('Paste path or url to add.') .. ' ' .. t('%s to reorder.', 'ctrl+up/down/pgup/pgdn/home/end'),
serializer = function(playlist)
local items = {}
local playlist_titles = mp.get_property_native('user-data/playlistmanager/titles') or {}
local force_filename = mp.get_property_native('osd-playlist-entry') == 'filename'
for index, item in ipairs(playlist) do
local is_url = is_protocol(item.filename)
local title = type(item.title) == 'string' and #item.title > 0 and item.title or false
items[index] = {
title = is_url and (title or playlist_titles[item.filename] or url_decode(item.filename)) or
serialize_path(item.filename).basename,
title = (not force_filename and title) and title
or (is_protocol(item.filename) and item.filename or serialize_path(item.filename).basename),
hint = tostring(index),
active = item.current,
value = index,
@@ -956,7 +956,10 @@ bind_command('show-in-directory', function()
end)
bind_command('stream-quality', open_stream_quality_menu)
bind_command('open-file', open_open_file_menu)
bind_command('shuffle', function() set_state('shuffle', not state.shuffle) end)
bind_command('shuffle', function()
set_state('shuffle', not state.shuffle)
mp.osd_message(state.shuffle and t('Shuffle ON') or t('Shuffle OFF'))
end)
bind_command('items', function()
if state.has_playlist then
mp.command('script-binding uosc/playlist')
@@ -1073,6 +1076,9 @@ bind_command('open-config-directory', function()
msg.error('Couldn\'t serialize config path "' .. config_path .. '".')
end
end)
bind_command('update', function()
if not Elements:has('updater') then require('elements/Updater'):new() end
end)
--[[ MESSAGE HANDLERS ]]
+40 -44
View File
@@ -24,25 +24,38 @@
<details open>
1. 从弹弹play或自定义服务的API获取剧集及弹幕数据并根据用户选择的集数加载弹幕
2. 通过点击uosc control bar中的弹幕搜索按钮可以显示搜索菜单供用户选择需要的弹幕
3. 通过点击加入uosc control bar中的弹幕开关控件可以控制弹幕的开关
4. 通过点击加入uosc control bar中的[从源获取弹幕](#从弹幕源向当前弹幕添加新弹幕内容可选)按钮可以通过受支持的网络源或本地文件添加弹幕
5. 通过点击加入uosc control bar中的[弹幕样式](#实时修改弹幕样式可选)按钮可以打开uosc弹幕样式菜单供用户在视频播放时实时修改弹幕样式注意⚠未安装uosc框架时该功能不可用
6. 通过点击加入uosc control bar中的[弹幕设置](#弹幕设置总菜单可选)按钮可以打开多级功能复合菜单,包含了插件目前所有的图形化功能。
7. 通过点击加入uosc control bar中的[弹幕源延迟设置](#弹幕源延迟设置可选)按钮可以打开弹幕源延迟控制菜单可以独立控制每个弹幕源的延迟注意⚠未安装uosc框架时该功能不可用
8. 记忆型全自动弹幕填装,在为某个文件夹下的某一集番剧加载过一次弹幕后,加载过的弹幕会自动关联到该集;之后每次重新播放该文件就会自动加载弹幕,同时该文件对应的文件夹下的所有其他集数的文件都会在播放时自动加载弹幕,无需再重复手动输入番剧名进行搜索(注意⚠️:全自动弹幕填装默认关闭,如需开启请阅读[auto_load配置项说明](#auto_load)
9. 在没有手动加载过弹幕,没有填装自动弹幕记忆之前,通过文件哈希匹配的方式自动添加弹幕(~仅限本地文件~,现已支持网络视频),对于能够哈希匹配关联的文件不再需要手动搜索关联,实现全自动加载弹幕并添加记忆。该功能随记忆型全自动弹幕填装功能一起开启(哈希匹配自动加载准确率较低,如关联到错误的剧集请手动加载正确的剧集)
> 哈希匹配功能需要 mpv 基于 LuaJIT 或 Lua 5.2 构建,不支持 Lua 5.1
>
10. 通过打开配置项load_more_danmaku可以爬取所有可用弹幕源获取更多弹幕注意⚠爬取所有可用弹幕源默认关闭如需开启请阅读[load_more_danmaku配置项说明](#load_more_danmaku)
11. 自动记忆弹幕开关情况,播放视频时保持上次关闭时的弹幕开关状态
12. 自定义默认播放弹幕样式(具体设置方法详见[自定义弹幕样式](#自定义弹幕样式相关配置)
13. 在使用如[Play-With-MPV](https://github.com/LuckyPuppy514/Play-With-MPV)或[ff2mpv](https://github.com/woodruffw/ff2mpv)等网络播放手段时自动加载弹幕注意⚠目前支持自动加载bilibili和巴哈姆特这两个网站的弹幕具体说明查看[autoload_for_url配置项说明](#autoload_for_url)
14. 保存当前弹幕到本地(详细功能说明见[save_danmaku配置项说明](#save_danmaku)
15. 可以合并一定时间段内同时出现的大量重复弹幕(具体设置方法详见[merge_tolerance配置项说明](#merge_tolerance)
16. 弹幕简体字繁体字转换,解决弹幕简繁混杂问题(具体设置方法详见[chConvert配置项说明](#chConvert)
17. 自定义插件相关提示的显示位置,可以自由调节距离画面左上角的两个维度的距离(具体设置方法详见[message_x配置项说明](#message_x)和[message_y配置项说明](#message_y)
10. 自动记忆弹幕开关情况,播放视频时保持上次关闭时的弹幕开关状态
11. 自定义默认播放弹幕样式(具体设置方法详见[自定义弹幕样式](#自定义弹幕样式相关配置)
12. 在使用如[Play-With-MPV](https://github.com/LuckyPuppy514/Play-With-MPV)或[ff2mpv](https://github.com/woodruffw/ff2mpv)等网络播放手段时自动加载弹幕注意⚠目前支持自动加载bilibili和巴哈姆特这两个网站的弹幕具体说明查看[autoload_for_url配置项说明](#autoload_for_url)
13. 保存当前弹幕到本地(详细功能说明见[save_danmaku配置项说明](#save_danmaku)
14. 可以合并一定时间段内同时出现的大量重复弹幕(具体设置方法详见[merge_tolerance配置项说明](#merge_tolerance)
15. 弹幕简体字繁体字转换,解决弹幕简繁混杂问题(具体设置方法详见[chConvert配置项说明](#chConvert)
16. 自定义插件相关提示的显示位置,可以自由调节距离画面左上角的两个维度的距离(具体设置方法详见[message_x配置项说明](#message_x)和[message_y配置项说明](#message_y)
无需亲自下载整合弹幕文件资源无需亲自处理文件格式转换在mpv播放器中一键加载包含了哔哩哔哩、巴哈姆特等弹幕网站弹幕的弹弹play的动画弹幕。
@@ -236,12 +249,12 @@ key script-message open_source_delay_menu
> #### 实时修改弹幕样式(可选)
依赖于[uosc UI框架](https://github.com/tomasklaen/uosc)实现**弹幕样式实时修改**,将打开弹幕样式修改图形化菜单供用户手动修改,该功能目前仅依靠 uosc 实现uosc不可用时无法使用此功能默认使用[自定义弹幕样式](#自定义弹幕样式相关配置)里的样式配置)。想要启用此功能,需要参照[uosc控件配置](#uosc控件配置)根据uosc版本添加 `button:danmaku_styles``command:palette:script-message open_setup_danmaku_menu?弹幕样式``uosc.conf`的controls配置项中。
依赖于[uosc UI框架](https://github.com/tomasklaen/uosc)实现**弹幕样式实时修改**,将打开弹幕样式修改图形化菜单供用户手动修改默认使用[自定义弹幕样式](#自定义弹幕样式相关配置)里的样式配置)。想要启用此功能,需要参照[uosc控件配置](#uosc控件配置)根据uosc版本添加 `button:danmaku_styles``command:palette:script-message open_danmaku_style_menu?弹幕样式``uosc.conf`的controls配置项中。
想要通过快捷键使用此功能,请添加类似下面的配置到 `input.conf`中。实时修改弹幕样式功能对应的脚本消息为 `open_setup_danmaku_menu`
想要通过快捷键使用此功能,请添加类似下面的配置到 `input.conf`中。实时修改弹幕样式功能对应的脚本消息为 `open_danmaku_style_menu`
```
key script-message open_setup_danmaku_menu
key script-message open_danmaku_style_menu
```
</details>
@@ -338,34 +351,6 @@ key script-message check-update
<!-- 下列是弹幕加载相关 -->
<details>
<summary>
load_more_danmaku
> 开关全量弹幕源加载
</summary>
### load_more_danmaku
#### 功能说明
由于弹弹Play默认对于弹幕较多的番剧加载并且整合弹幕的上限大约每集7000条而这7000条弹幕也不是均匀分配例如有时弹幕基本只来自于哔哩哔哩有时弹幕又只来自于巴哈姆特。这样的话弹幕观看体验就和直接在哔哩哔哩或者巴哈姆特观看没有区别了失去了弹弹Play整合全平台弹幕的优势。
因此,本人添加了配置选项 `load_more_danmaku`用来将从弹弹Play获取弹幕的逻辑更改为逐一搜索所有弹幕源下的全部弹幕并由本脚本整合加载。开启此选项可以获取到所有可用弹幕源下的所有弹幕。但是对于一些热门番剧来说弹幕数量可能破万如果接受不了屏幕上弹幕太多请不要开启此选项。不过本人看视频从来只会觉得弹幕多多益善
#### 使用方法
想要开启此选项请在mpv配置文件夹下的 `script-opts`中创建 `uosc_danmaku.conf`文件并添加如下内容:
```
load_more_danmaku=yes
```
</details>
---
<details>
<summary>
auto_load
@@ -627,6 +612,7 @@ merge_tolerance=1
</details>
---
<details>
<summary>
max_screen_danmaku
@@ -731,7 +717,7 @@ api_server
> **⚠NOTE**
>
> 请确保自定义服务的 API 与弹弹play 的兼容,已知兼容:[l429609201/misaka_danmu_server](https://github.com/l429609201/misaka_danmu_server)[laozishen/abetsy](https://hub.docker.com/r/laozishen/abetsy)
> 请确保自定义服务的 API 与弹弹play 的兼容,已知兼容:[misaka_danmu_server](https://github.com/l429609201/misaka_danmu_server)[danmu_api](https://github.com/huangxd-/danmu_api)
#### 使用方法
@@ -757,18 +743,18 @@ fallback_server
#### 功能说明
自定义 b 站和爱腾优的弹幕获取的兜底服务器地址主要用于获取非动画弹幕只有在弹弹play无法解析视频源对应弹幕的情况下才会使用此处设置的服务器进行解析。兜底弹幕服务器可以自托管具体方法请参考此仓库https://github.com/lyz05/danmaku
自定义 b 站和爱腾优的弹幕获取的兜底服务器地址主要用于获取非动画弹幕只有在弹弹play无法解析视频源对应弹幕的情况下才会使用此处设置的服务器进行解析。可用: https://api.danmu.icuhttps://dmku.hls.one
> **⚠NOTE**
>
> 不设置此选项的情况下默认使用 `https://fc.lyz05.cn`作为兜底服务器,除非你自行部署了弹幕服务器,否则不建议自定义此选项。
> 不设置此选项的情况下默认使用 ` https://api.danmu.icu`作为兜底服务器
#### 使用方法
想要使用此选项请在mpv配置文件夹下的 `script-opts`中创建 `uosc_danmaku.conf`文件并自定义如下内容:
```
fallback_server=https://fc.lyz05.cn
fallback_server= https://api.danmu.icu
```
</details>
@@ -1020,6 +1006,15 @@ outline=1
blacklist_path=
```
## 插件自定义属性
- `user-data/uosc_danmaku/danmaku-delay`
`user-data/uosc_danmaku/danmaku-delay`属性中可以获取到当前弹幕延迟的值,具体用法可以参考[此issue](https://github.com/Tony15246/uosc_danmaku/issues/77)
- `user-data/uosc_danmaku/has-danmaku`
`user-data/uosc_danmaku/has-danmaku`属性中可以获取到表示当前是否有弹幕在显示的布尔值,具体用法可以参考[此pr](https://github.com/Tony15246/uosc_danmaku/pull/276)
## 常见问题
### 来自弹弹play的弹幕源问题如何从根源进行调整解决
@@ -1060,3 +1055,4 @@ blacklist_path=
## 相关项目
- [slqy123/uosc_danmaku](https://github.com/slqy123/uosc_danmaku) 本项目的fork版本实现了通过dandanplay api发送弹幕的功能由于版本的兼容性以及功能的易用性问题未被合并具体讨论请参阅 [#220](https://github.com/Tony15246/uosc_danmaku/pull/220)
- [Loukyuu1120/uosc_danmaku](https://github.com/Loukyuu1120/uosc_danmaku) 本项目的fork版本实现了自定义多个 api_servers 与 弹幕来源选择菜单 功能,具体讨论请参阅 [#282](https://github.com/Tony15246/uosc_danmaku/issues/282)
+105 -227
View File
@@ -24,62 +24,43 @@ end
function set_episode_id(input, from_menu)
from_menu = from_menu or false
DANMAKU.source = "dandanplay"
local api_server = options.api_server
for url, source in pairs(DANMAKU.sources) do
if source.from == "api_server" then
if source.fname and file_exists(source.fname) then
os.remove(source.fname)
end
if not source.from_history then
DANMAKU.sources[url] = nil
else
DANMAKU.sources[url]["fname"] = nil
DANMAKU.sources[url]["data"] = nil
api_server = source.api_server or options.api_server
end
end
end
local episodeId = tonumber(input)
write_history(episodeId)
local main_url = api_server .. "/api/v2/comment/" .. episodeId .. "?withRelated=true&chConvert=0"
add_source_to_history(main_url, { from = "api_server", api_server = api_server })
write_history(episodeId, api_server)
set_danmaku_button()
if options.load_more_danmaku then
fetch_danmaku_all(episodeId, from_menu)
else
fetch_danmaku(episodeId, from_menu)
end
fetch_danmaku(episodeId, from_menu, api_server)
end
-- 回退使用额外的弹幕获取方式
function get_danmaku_fallback(query)
local url = options.fallback_server .. "/?url=" .. query
local url = options.fallback_server .. "/?ac=dm&url=" .. query
msg.verbose("尝试获取弹幕:" .. url)
local temp_file = "danmaku-" .. PID .. DANMAKU.count .. ".xml"
local danmaku_xml = utils.join_path(DANMAKU_PATH, temp_file)
DANMAKU.count = DANMAKU.count + 1
local arg = {
"curl",
"-L",
"-s",
"--compressed",
"--user-agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 Edg/124.0.0.0",
"--output",
danmaku_xml,
url,
}
call_cmd_async(arg, function(error)
async_running = false
if error then
show_message("HTTP 请求失败,打开控制台查看详情", 5)
msg.error(error)
local args = make_danmaku_request_args("GET", url)
if not args then return end
fetch_danmaku_data(args, function(data)
if not data or not data["comments"] or data["count"] <= 1 then
msg.info("备用服务器无数据或返回格式不正确")
show_message("备用服务器无数据或返回格式不正确", 3)
return
end
if file_exists(danmaku_xml) then
if query:find("iqiyi%.com") ~= nil then
DANMAKU.strict = true
end
save_danmaku_downloaded(query, danmaku_xml)
save_danmaku_data(data["comments"], query, "user_custom")
load_danmaku(true)
end
end)
end
@@ -122,11 +103,55 @@ function make_danmaku_request_args(method, url, headers, body)
table.insert(args, string.format('X-Timestamp: %s', time))
end
if options.proxy ~= "" then
table.insert(args, '-x')
table.insert(args, options.proxy)
end
table.insert(args, url)
return args
end
local function normalize_danmaku_response(d)
if not d then return d end
-- 已经是 comments/count 格式则直接返回
if d.comments or d.count then return d end
if d.danmuku and type(d.danmuku) == "table" then
local out = {}
for _, item in ipairs(d.danmuku) do
-- item 预期为数组,索引: 1=time, 2=pos(right/top/bottom), 3=color(hex), 5=content
local time = tonumber(item[1]) or 0
local pos = item[2] or "right"
local color = item[3] or ""
local content = item[5] or item[4] or ""
local mode = 1
if pos == "right" then
mode = 1
elseif pos == "top" then
mode = 4
elseif pos == "bottom" then
mode = 5
end
local colorDec = 16777215
if type(color) == "number" then
colorDec = color
elseif type(color) == "string" then
colorDec = hex_to_int_color(color)
end
local p = string.format("%.2f,%d,%d", time, mode, colorDec)
table.insert(out, { p = p, m = content })
end
return { comments = out, count = tonumber(d.danum) or #out }
end
return d
end
-- 尝试通过解析文件名匹配剧集
local function match_episode(animeTitle, bangumiId, episode_num)
local url = options.api_server .. "/api/v2/bangumi/" .. bangumiId
@@ -214,11 +239,15 @@ local function match_anime()
target_title = title .. "" .. number_to_chinese(season_num) .. ""
end
for _, anime in ipairs(animes) do
if anime.animeTitle:match("第一[季部]") and tonumber(season_num) == 1 then
local animeTitle = tostring(anime.animeTitle or "")
animeTitle = animeTitle:gsub("^%s*(.-)%s*$", "%1")
:gsub("%s*%(.-%)%s*$", "")
:gsub("%s*【.-】.*$", "")
if animeTitle:match("第一[季部]") and tonumber(season_num) == 1 then
target_title = title .. " 第一季"
end
local score = jaro_winkler(target_title, anime.animeTitle)
msg.debug(("候选: %s -> 相似度 %.3f"):format(anime.animeTitle, score))
local score = jaro_winkler(target_title, animeTitle)
msg.debug(("候选: %s -> 相似度 %.3f"):format(animeTitle, score))
if score > best_score then
best_score = score
best_match = anime
@@ -276,7 +305,7 @@ local function match_file(file_path, file_name, callback)
["Content-Type"] = "application/json"
}, {
fileName = file_name,
fileHash = hash or "",
fileHash = hash or "a1b2c3d4e5f67890abcd1234ef567890",
matchMode = "hashAndFileName"
}
)
@@ -291,7 +320,7 @@ local function match_file(file_path, file_name, callback)
return
end
local data = utils.parse_json(json)
if not data or not data.isMatched or #data.matches > 1 then
if not data or not data.isMatched then
callback("没有匹配的剧集")
return
end
@@ -314,48 +343,39 @@ function fetch_danmaku_data(args, callback)
return
end
local data = utils.parse_json(json)
data = normalize_danmaku_response(data)
callback(data)
end)
end
-- 保存弹幕数据
function save_danmaku_data(comments, query, danmaku_source)
local temp_file = "danmaku-" .. PID .. DANMAKU.count .. ".json"
local danmaku_file = utils.join_path(DANMAKU_PATH, temp_file)
DANMAKU.count = DANMAKU.count + 1
local success = save_danmaku_json(comments, danmaku_file)
local danmaku_list = save_danmaku_to_list(comments)
if success then
if DANMAKU.sources[query] ~= nil then
if DANMAKU.sources[query].fname and file_exists(DANMAKU.sources[query].fname) then
os.remove(DANMAKU.sources[query].fname)
end
DANMAKU.sources[query]["fname"] = danmaku_file
DANMAKU.sources[query]["data"] = danmaku_list
else
DANMAKU.sources[query] = {from = danmaku_source, fname = danmaku_file}
end
DANMAKU.sources[query] = {from = danmaku_source, data = danmaku_list}
end
end
function save_danmaku_downloaded(url, downloaded_file)
if DANMAKU.sources[url] ~= nil then
if DANMAKU.sources[url].fname and file_exists(DANMAKU.sources[url].fname) then
os.remove(DANMAKU.sources[url].fname)
local danmaku_list = parse_danmaku_file(downloaded_file)
if file_exists(downloaded_file) then
os.remove(downloaded_file)
end
DANMAKU.sources[url]["fname"] = downloaded_file
if DANMAKU.sources[url] ~= nil then
DANMAKU.sources[url]["data"] = danmaku_list
else
DANMAKU.sources[url] = {from = "user_custom", fname = downloaded_file}
DANMAKU.sources[url] = {from = "user_custom", data = danmaku_list}
end
end
-- 处理弹幕数据
function handle_danmaku_data(query, data, from_menu)
local comments = data["comments"]
local count = data["count"]
-- 如果没有数据,进行重试
if count == 0 then
show_message("服务器无缓存数据,再次尝试请求", 30)
if not data or not data["comments"] or data["count"] <= 1 then
show_message("服务器无缓存数据,再次尝试请求", 10)
msg.verbose("服务器无缓存数据,再次尝试请求")
-- 等待 2 秒后重试
local start = os.time()
@@ -371,7 +391,7 @@ function handle_danmaku_data(query, data, from_menu)
end
fetch_danmaku_data(args, function(retry_data)
if not retry_data or not retry_data["comments"] or retry_data["count"] == 0 then
if not retry_data or not retry_data["comments"] or retry_data["count"] <= 1 then
get_danmaku_fallback(query)
return
end
@@ -379,87 +399,11 @@ function handle_danmaku_data(query, data, from_menu)
load_danmaku(from_menu)
end)
else
save_danmaku_data(comments, query, "user_custom")
save_danmaku_data(data["comments"], query, "user_custom")
load_danmaku(from_menu)
end
end
-- 处理第三方弹幕数据
function handle_related_danmaku(index, relateds, related, shift, callback)
local url = options.api_server .. "/api/v2/extcomment?url=" .. url_encode(related["url"])
show_message(string.format("正在从第三方库装填弹幕 [%d/%d]", index, #relateds), 30)
msg.verbose("正在从第三方库装填弹幕:" .. url)
local args = make_danmaku_request_args("GET", url)
if args == nil then
return
end
fetch_danmaku_data(args, function(data)
local comments = {}
if data and data["comments"] then
if data["count"] == 0 then
-- 如果没有数据,稍等 2 秒重试
local start = os.time()
while os.time() - start < 2 do
-- 空循环,等待 2 秒
end
fetch_danmaku_data(args, function(data)
for _, comment in ipairs(data["comments"]) do
comment["shift"] = shift
table.insert(comments, comment)
end
callback(comments)
end)
else
for _, comment in ipairs(data["comments"]) do
comment["shift"] = shift
table.insert(comments, comment)
end
callback(comments)
end
else
show_message("无数据", 3)
msg.info("无数据")
callback(comments)
end
end)
end
-- 处理dandan库的弹幕数据
function handle_main_danmaku(url, from_menu)
show_message("正在从弹弹Play库装填弹幕", 30)
msg.verbose("尝试获取弹幕:" .. url)
local args = make_danmaku_request_args("GET", url)
if args == nil then
return
end
fetch_danmaku_data(args, function(data)
if not data or not data["comments"] then
show_message("无数据", 3)
msg.info("无数据")
return
end
local comments = data["comments"]
local count = data["count"]
if count == 0 then
if DANMAKU.sources[url] == nil then
DANMAKU.sources[url] = {from = "api_server"}
end
load_danmaku(from_menu)
return
end
save_danmaku_data(comments, url, "api_server")
load_danmaku(from_menu)
end)
end
-- 处理获取到的数据
function handle_fetched_danmaku(data, url, from_menu)
if data and data["comments"] then
@@ -481,8 +425,8 @@ end
-- 匹配弹幕库 comment, 仅匹配dandan本身弹幕库
-- 通过danmaku apiurl+id获取弹幕
function fetch_danmaku(episodeId, from_menu)
local url = options.api_server .. "/api/v2/comment/" .. episodeId .. "?withRelated=true&chConvert=0"
function fetch_danmaku(episodeId, from_menu, api_server)
local url = (api_server or options.api_server) .. "/api/v2/comment/" .. episodeId .. "?withRelated=true&chConvert=0"
show_message("弹幕加载中...", 30)
msg.verbose("尝试获取弹幕:" .. url)
local args = make_danmaku_request_args("GET", url)
@@ -496,57 +440,6 @@ function fetch_danmaku(episodeId, from_menu)
end)
end
-- 主函数:获取所有相关弹幕
function fetch_danmaku_all(episodeId, from_menu)
local url = options.api_server .. "/api/v2/related/" .. episodeId
show_message("弹幕加载中...", 30)
msg.verbose("尝试获取弹幕:" .. url)
local args = make_danmaku_request_args("GET", url)
if args == nil then
return
end
fetch_danmaku_data(args, function(data)
if not data or not data["relateds"] then
show_message("无数据", 3)
msg.info("无数据")
return
end
-- 处理所有的相关弹幕
local relateds = data["relateds"]
local function process_related(index)
if index > #relateds then
-- 所有相关弹幕加载完成后,开始加载主库弹幕
url = options.api_server .. "/api/v2/comment/" .. episodeId .. "?withRelated=false&chConvert=0"
handle_main_danmaku(url, from_menu)
return
end
local related = relateds[index]
local shift = related["shift"]
-- 处理当前的相关弹幕
handle_related_danmaku(index, relateds, related, shift, function(comments)
if #comments == 0 then
if DANMAKU.sources[related["url"]] == nil then
DANMAKU.sources[related["url"]] = {from = "api_server"}
end
else
save_danmaku_data(comments, related["url"], "api_server")
end
-- 继续处理下一个相关弹幕
process_related(index + 1)
end)
end
-- 从第一个相关库开始请求
process_related(1)
end)
end
-- 从用户添加过的弹幕源添加弹幕
function addon_danmaku(dir, from_menu)
if dir then
@@ -587,19 +480,16 @@ function add_danmaku_source_local(query, from_menu)
msg.warn("无效的文件路径")
return
end
if not (string.match(path, "%.xml$") or string.match(path, "%.json$") or string.match(path, "%.ass$")) then
if not (string.match(path, "%.xml$") or string.match(path, "%.json$")) then
msg.warn("仅支持弹幕文件")
return
end
if DANMAKU.sources[query] ~= nil then
if DANMAKU.sources[query].fname and file_exists(DANMAKU.sources[query].fname) then
os.remove(DANMAKU.sources[query].fname)
end
DANMAKU.sources[query]["from"] = "user_local"
DANMAKU.sources[query]["fname"] = path
DANMAKU.sources[query]["data"] = parse_danmaku_file(path)
else
DANMAKU.sources[query] = {from = "user_local", fname = path}
DANMAKU.sources[query] = {from = "user_local", data = parse_danmaku_file(path)}
end
set_danmaku_button()
@@ -619,23 +509,14 @@ function add_danmaku_source_online(query, from_menu)
end
fetch_danmaku_data(args, function(data)
if not data or not data["comments"] then
show_message("此源弹幕无法加载", 3)
msg.verbose("此源弹幕无法加载")
return
end
handle_danmaku_data(query, data, from_menu)
end)
end
-- 将弹幕转换为factory可读的json格式
function save_danmaku_json(comments, json_filename)
local temp_file = "danmaku-" .. PID .. ".json"
json_filename = json_filename or utils.join_path(DANMAKU_PATH, temp_file)
local json_file = io.open(json_filename, "w")
-- 将弹幕转换为 Lua table
function save_danmaku_to_list(comments)
local danmaku_list = {}
if json_file then
json_file:write("[\n")
for _, comment in ipairs(comments) do
local p = comment["p"]
local shift = comment["shift"]
@@ -644,28 +525,25 @@ function save_danmaku_json(comments, json_filename)
if shift ~= nil then
fields[1] = tonumber(fields[1]) + tonumber(shift)
end
local c_value = string.format(
"%s,%s,%s,25,,,",
tostring(fields[1]), -- first field of p to first field of c
fields[3], -- third field of p to second field of c
fields[2] -- second field of p to third field of c
)
local time = tonumber(fields[1])
local type = tonumber(fields[2])
local color = tonumber(fields[3]) or 0xFFFFFF
local size = 25
local m_value = comment["m"]
:gsub("[%z\1-\31]", "")
:gsub("\\", "")
:gsub("\"", "")
-- Write the JSON object as a single line, no spaces or extra formatting
local json_entry = string.format('{"c":"%s","m":"%s"},\n', c_value, m_value)
json_file:write(json_entry)
table.insert(danmaku_list, {
time = time,
type = type,
size = size,
color = color,
text = m_value
})
end
end
json_file:write("]")
json_file:close()
return true
end
return false
return danmaku_list
end
-- 通过文件前 16M 的 hash 值进行弹幕匹配
+108 -20
View File
@@ -12,9 +12,18 @@ local function load_extra_danmaku(url, episode, number, class, id, site, title,
local play_url = nil
if url:match("^.-%.html") then
play_url = url:match("^(.-%.html).*")
elseif url:match("^https?://v%.youku%.com/") and url:match("[?&]vid=") then
-- 转换 youku 的短链接形式 video?vid=... 到真实播放页 v_show/id_*.html
local vid = url:match("[?&]vid=([^&]+)")
if vid then
play_url = "https://v.youku.com/v_show/id_" .. vid .. ".html"
else
play_url = url:gsub("%?bsource=360ogvys$","")
play_url = url:gsub("%?bsource=360ogvys$",""):gsub("&.*$","")
end
else
play_url = url:gsub("%?bsource=360ogvys$",""):gsub("&.*$","")
end
ENABLED = true
DANMAKU.anime = title .. " (" .. year .. ")"
DANMAKU.episode = "" .. episode .. ""
@@ -34,7 +43,7 @@ end
local function query_tmdb(title, class, menu)
local encoded_title = url_encode(title)
local url = string.format("https://api.themoviedb.org/3/search/%s?api_key=%s&query=%s&language=zh-CN",
local url = string.format("https://api.tmdb.org/3/search/%s?api_key=%s&query=%s&language=zh-CN",
class, Base64.decode(options.tmdb_api_key), encoded_title)
local cmd = {
@@ -98,6 +107,63 @@ local function get_number(cat, id, site)
return nil
end
local function get_episodes_v2(cat, id, site)
local s_param = string.format('[{"cat_id":"%s","ent_id":"%s","site":"%s"}]', tostring(cat), tostring(id), tostring(site))
local url = string.format("https://api.so.360kan.com/episodesv2?v_ap=1&s=%s", url_encode(s_param))
local cmd = { "curl", "-s", url }
local res = mp.command_native({
name = "subprocess",
args = cmd,
capture_stdout = true,
capture_stderr = true,
})
if not res.status or res.status ~= 0 then
msg.error("Failed to fetch episodesv2: " .. (res.stderr or "unknown error"))
return nil
end
local data_text = res.stdout or ""
-- 兼容 JSONP 和 纯 JSON提取最外层括号内 JSON
local json_payload = data_text
local first_paren = data_text:find('%(')
local last_paren = data_text:match('.*()%)')
if first_paren and last_paren and last_paren > first_paren then
json_payload = data_text:sub(first_paren + 1, last_paren - 1)
end
local parsed = utils.parse_json(json_payload)
if not parsed then
msg.error("episodesv2: 解析返回失败: " .. (res.stdout or ""))
return nil
end
local episodes = {}
if parsed.code == 0 and parsed.data and #parsed.data > 0 then
local seriesHTML = parsed.data[1] and parsed.data[1].seriesHTML
if seriesHTML and seriesHTML.seriesPlaylinks then
for i, ep in ipairs(seriesHTML.seriesPlaylinks) do
local episode_url = nil
if type(ep) == 'string' then
episode_url = ep
elseif type(ep) == 'table' and ep.url then
episode_url = ep.url
end
if episode_url and episode_url ~= '' then
table.insert(episodes, { index = i, url = episode_url })
end
end
end
end
if #episodes == 0 then
return nil
end
return episodes
end
function get_details(class, id, site, title, year, number, episodenum)
local message = episodenum and "查询弹幕中..." or "加载数据中..."
local menu_type = "menu_details"
@@ -120,6 +186,20 @@ function get_details(class, id, site, title, year, number, episodenum)
cat = 4
end
local items = {}
local episodes = nil
if cat == 2 or cat == 4 then
episodes = get_episodes_v2(cat, id, site)
end
-- 统一构建 episode_rows优先使用 episodesv2 返回的数据,否则使用 v1/detail
local episode_rows = nil
if episodes then
episode_rows = {}
for _, ep in ipairs(episodes) do
table.insert(episode_rows, { index = tostring(ep.index), url = ep.url })
end
else
if not number and cat ~= 0 then
number = get_number(cat, id, site)
end
@@ -157,32 +237,40 @@ function get_details(class, id, site, title, year, number, episodenum)
end
local result = utils.parse_json(res.stdout)
local items = {}
if result and result.data and result.data.allepidetail then
local data = result.data.allepidetail
local playurl, episode = nil, nil
if result and result.data and result.data.allepidetail and result.data.allepidetail[site] then
episode_rows = {}
for _, it in ipairs(result.data.allepidetail[site]) do
table.insert(episode_rows, { index = tostring(it.playlink_num), url = it.url })
end
end
end
if episode_rows and #episode_rows > 0 then
if episodenum then
for _, item in ipairs(data[site]) do
if tonumber(item.playlink_num) == tonumber(episodenum) then
playurl = item.url
episode = item.playlink_num
break
end
end
if playurl then
load_extra_danmaku(playurl, episode, number, class, id, site, title, year)
for _, ep in ipairs(episode_rows) do
if tonumber(ep.index) == tonumber(episodenum) then
load_extra_danmaku(ep.url, ep.index, number, class, id, site, title, year)
return
end
end
for _, item in ipairs(data[site]) do
end
table.insert(items, {
title = "" .. item.playlink_num .. "",
hint = item.playlink_num,
title = "← 返回搜索结果",
value = { "script-message-to", "uosc", "open-menu", latest_menu_anime },
keep_open = false,
selectable = true,
})
for _, ep in ipairs(episode_rows) do
table.insert(items, {
title = "" .. ep.index .. "",
hint = ep.index,
value = {
"script-message-to",
mp.get_script_name(),
"add-extra-event",
item.url, item.playlink_num, number, class, id, site, title, year
ep.url, ep.index, tostring(number), class, id, site, title, year
},
})
end
@@ -257,7 +345,7 @@ local function search_query(query, class, menu)
end
if #items > 0 then
if uosc_available then
update_menu_uosc(menu.type, menu.title, items, menu.footnote, menu.cmd, query)
latest_menu_anime = update_menu_uosc(menu.type, menu.title, items, menu.footnote, menu.cmd, query)
else
show_message("", 0)
mp.add_timeout(0.1, function()
+109 -98
View File
@@ -25,11 +25,11 @@ DANMAKU_PATH = os.getenv("TEMP") or "/tmp/"
HISTORY_PATH = mp.command_native({"expand-path", options.history_path})
PID = utils.getpid()
DANMAKU = {sources = {}, count = 1}
DELAYS = {}
ENABLED, COMMENTS, DELAY = false, nil, 0
DELAY_PROPERTY = string.format("user-data/%s/danmaku-delay", mp.get_script_name())
mp.set_property_native(DELAY_PROPERTY, 0)
HAS_DANMAKU = string.format("user-data/%s/has-danmaku", mp.get_script_name())
mp.set_property_bool(HAS_DANMAKU, false)
KEY = table_to_zero_indexed({
0x00,0x01,0x02,0x03,0x04,
0x05,0x06,0x07,0x08,0x09,
@@ -58,6 +58,8 @@ PLATFORM = (function()
return "linux"
end)()
local rebuild_convert_timer = nil
function get_danmaku_visibility()
local history_json = read_file(HISTORY_PATH)
local history
@@ -140,21 +142,6 @@ local function extract_between_colons(input_string)
end
end
local function hex_to_int_color(hex_color)
-- 移除颜色代码中的'#'字符
hex_color = hex_color:sub(2) -- 只保留颜色代码部分
-- 提取R, G, B的十六进制值并转为整数
local r = tonumber(hex_color:sub(1, 2), 16)
local g = tonumber(hex_color:sub(3, 4), 16)
local b = tonumber(hex_color:sub(5, 6), 16)
-- 计算32位整数值
local color_int = (r * 256 * 256) + (g * 256) + b
return color_int
end
local function get_type_from_position(position)
if position == 0 then
return 1
@@ -170,11 +157,13 @@ end
function get_delay_for_time(delay_segments, time)
if not delay_segments or #delay_segments == 0 then return 0 end
table.sort(delay_segments, function(a, b) return a.start < b.start end)
local segs = {}
for i = 1, #delay_segments do segs[i] = delay_segments[i] end
table.sort(segs, function(a, b) return a.start < b.start end)
local applied_delay = 0
for i = 1, #delay_segments do
local seg = delay_segments[i]
for i = 1, #segs do
local seg = segs[i]
local delay = tonumber(seg.delay)
if time >= seg.start and delay then
applied_delay = applied_delay + delay
@@ -244,9 +233,44 @@ local function merge_delay_segments(segments)
return merged
end
local function set_danmaku_delay(dly, time)
function parse_delay_input(text)
if not text then return nil end
local s = tostring(text):gsub("%s+", "")
if s == "" then return nil end
-- XmYs 格式,允许负号在分钟部分
local m, sec = string.match(s, "^(%-?%d+)m(%d+)s$")
if m and sec then
m = tonumber(m)
sec = tonumber(sec)
if not m or not sec then return nil end
if m < 0 then sec = -sec end
return m * 60 + sec
end
-- 普通数字(整数或小数),支持负数
local n = tonumber(s)
if n ~= nil then return n end
return nil
end
local function set_danmaku_delay(dly, time, specific_source)
if specific_source then
local source = DANMAKU.sources[specific_source]
if source and source.data and not source.blocked then
source.delay_segments = source.delay_segments or {}
if dly == 0 then
source.delay_segments = {}
elseif time then
table.insert(source.delay_segments, {start = time, delay = dly})
else
table.insert(source.delay_segments, {start = 0, delay = dly})
end
source.delay = nil
source.delay_segments = merge_delay_segments(source.delay_segments)
add_source_to_history(specific_source, source)
end
else
for url, source in pairs(DANMAKU.sources) do
if source.fname and not source.blocked then
if source.data and not source.blocked then
source.delay_segments = source.delay_segments or {}
if dly == 0 then
source.delay_segments = {}
@@ -257,30 +281,35 @@ local function set_danmaku_delay(dly, time)
end
source.delay = nil
table.sort(source.delay_segments, function(a, b) return a.start < b.start end)
source.delay_segments = merge_delay_segments(source.delay_segments)
add_source_to_history(url, source)
end
end
if time then
table.insert(DELAYS, {start = time, delay = dly})
else
table.insert(DELAYS, {start = 0, delay = dly})
end
if dly == 0 then
DELAY = 0
DELAYS = {}
else
DELAY = DELAY + dly
end
DELAYS = merge_delay_segments(DELAYS)
if ENABLED and COMMENTS ~= nil then
render()
end
-- 防抖:批量重建 ASS 事件并渲染,避免频繁变更导致重复重建
if rebuild_convert_timer then
rebuild_convert_timer:kill()
rebuild_convert_timer = nil
end
rebuild_convert_timer = mp.add_timeout(0.1, function()
if convert_danmaku_to_ass_events then
convert_danmaku_to_ass_events(true)
end
render()
rebuild_convert_timer = nil
end)
show_message('设置弹幕延迟: ' .. string.format("%.1f", DELAY + 1e-10) .. ' s')
mp.set_property_native(DELAY_PROPERTY, DELAY)
end
@@ -299,9 +328,6 @@ local function clear_source()
for url, source in pairs(DANMAKU.sources) do
if source.from == "user_custom" then
if source.fname and file_exists(source.fname) then
os.remove(source.fname)
end
DANMAKU.sources[url] = nil
end
end
@@ -312,7 +338,7 @@ local function clear_source()
msg.verbose("已重置当前视频所有弹幕源更改")
end
function write_history(episodeid)
function write_history(episodeid, api_server)
local history = {}
local path = mp.get_property("path")
local dir = get_parent_directory(path)
@@ -353,6 +379,9 @@ function write_history(episodeid)
elseif DANMAKU.extra then
history[dir].extra = DANMAKU.extra
end
if api_server then
history[dir].api_server = api_server
end
write_json_file(HISTORY_PATH, history)
end
end
@@ -401,6 +430,11 @@ function add_source_to_history(add_url, add_source)
local record = history[path]["sources"][add_url]
record.from = add_source.from or "user_custom"
record.blocked = add_source.blocked or false
if record.from == "api_server" then
record.api_server = add_source.api_server or options.api_server
else
record.api_server = nil
end
local delay_segments = shallow_copy(add_source.delay_segments or {})
if #delay_segments > 0 then
@@ -455,6 +489,7 @@ function read_danmaku_source_record(path)
blocked = blocked,
delay_segments = delay_segments,
from_history = true,
api_server = data.api_server,
}
end
else
@@ -484,6 +519,7 @@ function read_danmaku_source_record(path)
blocked = blocked,
delay_segments = delay_segments,
from_history = true,
api_server = record.api_server,
}
upgraded_sources[source] = shallow_copy(DANMAKU.sources[source])
@@ -496,39 +532,8 @@ function read_danmaku_source_record(path)
end
end
-- 收集现有的弹幕文件和延迟记录
local function collect_danmaku_sources()
local danmaku_input = {}
local delays = {}
for _, source in pairs(DANMAKU.sources) do
if not source.blocked and source.fname then
if not file_exists(source.fname) then
show_message("未找到弹幕文件", 3)
msg.info("未找到弹幕文件")
return
end
table.insert(danmaku_input, source.fname)
if source.delay_segments and #source.delay_segments > 0 then
table.insert(delays, source.delay_segments)
end
end
end
return danmaku_input, delays
end
-- 视频播放时保存弹幕
function save_danmaku(not_forced)
local danmaku_input, delays = collect_danmaku_sources()
if #danmaku_input == 0 then
show_message("弹幕内容为空,无法保存", 3)
msg.verbose("弹幕内容为空,无法保存")
COMMENTS = {}
return
end
local path = mp.get_property("path")
local dir = get_parent_directory(path) or ""
local filename = mp.get_property('filename/no-ext')
@@ -544,7 +549,7 @@ function save_danmaku(not_forced)
msg.info("已存在同名弹幕文件:" .. danmaku_out)
return
else
convert_danmaku_to_xml(danmaku_input, danmaku_out, delays)
convert_danmaku_to_xml(danmaku_out)
end
end
end
@@ -552,19 +557,8 @@ end
-- 加载弹幕
function load_danmaku(from_menu, no_osd)
if not ENABLED then return end
local temp_file = "danmaku-" .. PID .. ".ass"
local danmaku_file = utils.join_path(DANMAKU_PATH, temp_file)
local danmaku_input, delays = collect_danmaku_sources()
-- 如果没有弹幕文件,退出加载
if #danmaku_input == 0 then
show_message("该集弹幕内容为空,结束加载", 3)
msg.verbose("该集弹幕内容为空,结束加载")
COMMENTS = {}
return
end
convert_danmaku_format(danmaku_input, danmaku_file, delays)
parse_danmaku(danmaku_file, from_menu, no_osd)
convert_danmaku_to_ass_events()
render_danmaku(from_menu, no_osd)
end
-- 为 bilibli 网站的视频播放加载弹幕
@@ -620,6 +614,11 @@ function load_danmaku_for_bilibili(path)
url,
}
if options.cookie_file and options.cookie_file ~= "" then
table.insert(arg, '-b')
table.insert(arg, mp.command_native({"expand-path", options.cookie_file}))
end
call_cmd_async(arg, function(error)
async_running = false
if error then
@@ -673,6 +672,11 @@ function load_danmaku_for_bahamut(path)
table.insert(arg, options.proxy)
end
if options.cookie_file and options.cookie_file ~= "" then
table.insert(arg, '-b')
table.insert(arg, mp.command_native({"expand-path", options.cookie_file}))
end
call_cmd_async(arg, function(error)
async_running = false
if error then
@@ -688,30 +692,33 @@ function load_danmaku_for_bahamut(path)
end
local comments_json = read_file(danmaku_json)
os.remove(danmaku_json)
local comments = utils.parse_json(comments_json)
if not comments then
return
end
temp_file = "danmaku-" .. PID .. DANMAKU.count .. ".json"
local json_filename = utils.join_path(DANMAKU_PATH, temp_file)
DANMAKU.count = DANMAKU.count + 1
local json_file = io.open(json_filename, "w")
if json_file then
json_file:write("[\n")
local output_table = {}
for _, comment in ipairs(comments) do
local m = comment["text"]
local color = hex_to_int_color(comment["color"])
local mode = get_type_from_position(comment["position"])
local time = tonumber(comment["time"]) / 10
local c = time .. "," .. color .. "," .. mode .. ",25,,,"
-- Write the JSON object as a single line, no spaces or extra formatting
local json_entry = string.format('{"c":"%s","m":"%s"},\n', c, m)
json_file:write(json_entry)
local c_param = string.format("%s,%s,%s,25,,,", time, color, mode)
table.insert(output_table, {
c = c_param,
m = comment["text"]
})
end
json_file:write("]")
local final_json_str = utils.format_json(output_table)
temp_file = "danmaku-" .. PID .. DANMAKU.count .. ".json"
local json_filename = utils.join_path(DANMAKU_PATH, temp_file)
DANMAKU.count = DANMAKU.count + 1
local json_file = io.open(json_filename, "w")
if json_file then
json_file:write(final_json_str)
json_file:close()
end
@@ -890,22 +897,27 @@ end)
mp.register_script_message("danmaku-delay", function(...)
local commands = {...}
local delay_str, time_str = commands[1], commands[2]
local dly = tonumber(delay_str)
local source_arg = commands[3]
local dly = parse_delay_input(delay_str)
local time = time_str and tonumber(time_str)
if type(dly) ~= "number" then
show_message("参数错误:缺少有效的延迟秒数", 3)
return
end
if source_arg and source_arg ~= "nil" then
set_danmaku_delay(dly, time, source_arg)
else
set_danmaku_delay(dly, time)
end
end)
mp.register_script_message("show_danmaku_keyboard", function()
ENABLED = not ENABLED
if ENABLED then
mp.commandv("script-message-to", "uosc", "set", "show_danmaku", "on")
set_danmaku_visibility(true)
if COMMENTS == nil then
show_message("加载弹幕初始化...", 3)
set_danmaku_visibility(true)
local path = mp.get_property("path")
init(path)
else
@@ -915,7 +927,6 @@ mp.register_script_message("show_danmaku_keyboard", function()
else
show_message("关闭弹幕", 2)
mp.commandv("script-message-to", "uosc", "set", "show_danmaku", "off")
set_danmaku_visibility(false)
hide_danmaku_func()
end
end)
@@ -923,7 +934,7 @@ end)
mp.register_script_message("check-update", check_for_update)
mp.register_script_message("clear-source", clear_source)
mp.register_script_message("immediately_save_danmaku", save_danmaku)
mp.register_script_message("open_source_delay_menu", danmaku_delay_setup)
mp.register_script_message("open_source_delay_menu", open_delay_menu)
mp.register_script_message("open_search_danmaku_menu", open_input_menu)
mp.register_script_message("open_add_source_menu", open_add_menu)
mp.register_script_message("open_add_total_menu", open_add_total_menu)
+12 -10
View File
@@ -1,3 +1,5 @@
local unpack = unpack or table.unpack
-- Clean up media name
local function clean_name(name)
return name:gsub("^%[.-%]", " ")
@@ -19,12 +21,6 @@ local formatters = {
return clean_name(name) .. " S" .. season .. "E" .. episode:gsub("v%d+$","")
end
},
{
regex = "^(.-)%s*[_%-%.%s]%s*第%s*(%d+)%s*[季部]+%s*[_%-%.%s]%s*[eEpP]+[_%-%.%s]?(%d+[%.v]?%d*)",
format = function(name, season, episode)
return clean_name(name) .. " S" .. season .. "E" .. episode:gsub("v%d+$","")
end
},
{
regex = "^(.-)%s*[_%-%.%s]%s*第([一二三四五六七八九十]+)[季部]+%s*[_%-%.%s]%s*第%s*(%d+[%.v]?%d*)%s*[话集回]",
format = function(name, season, episode)
@@ -32,7 +28,13 @@ local formatters = {
end
},
{
regex = "^(.-)%s*[_%-%.%s]%s*第([一二三四五六七八九十]+)[季部]+%s*[_%-%.%s]%s*[eEpP]+[_%-%.%s]?(%d+[%.v]?%d*)",
regex = "^(.-)%s*[_%-%.%s]%s*第%s*(%d+)%s*[季部]+%s*[_%-%.%s]%s*[^%ddD][eEpP]+(%d+[%.v]?%d*)",
format = function(name, season, episode)
return clean_name(name) .. " S" .. season .. "E" .. episode:gsub("v%d+$","")
end
},
{
regex = "^(.-)%s*[_%-%.%s]%s*第([一二三四五六七八九十]+)[季部]+%s*[_%-%.%s]%s*[^%ddD][eEpP]+(%d+[%.v]?%d*)",
format = function(name, season, episode)
return clean_name(name) .. " S" .. chinese_to_number(season) .. "E" .. episode:gsub("v%d+$","")
end
@@ -54,7 +56,7 @@ local formatters = {
end
},
{
regex = "^(.-)%s*[_%.%s]%s*(%d%d%d%d)%s*[_%.%s]%s*[eEpP]+[_%-%.%s]?(%d+%.?%d*)",
regex = "^(.-)%s*[_%.%s]%s*(%d%d%d%d)%s*[_%.%s]%s*[^%ddD][eEpP]+(%d+%.?%d*)",
format = function(name, year, episode)
return clean_name(name) .. " (" .. year .. ") E" .. episode
end
@@ -78,13 +80,13 @@ local formatters = {
end
},
{
regex = "^(.-)%s*[^dD][eEpP]+[_%-%.%s]?(%d+[%.v]?%d*)[_%.%s]%s*(%d%d%d%d)[^%dhHxXvVpPkKxXbBfF]",
regex = "^(.-)%s*[^%ddD][eEpP]+(%d+[%.v]?%d*)[_%.%s]%s*(%d%d%d%d)[^%dhHxXvVpPkKxXbBfF]",
format = function(name, episode, year)
return clean_name(name) .. " (" .. year .. ") E" .. episode:gsub("v%d+$","")
end
},
{
regex = "^(.-)%s*[^dD][eEpP]+[_%-%.%s]?(%d+%.?%d*)",
regex = "^(.-)%s*[^%ddD][eEpP]+(%d+%.?%d*)",
format = function(name, episode)
return clean_name(name) .. " E" .. episode
end
+604 -73
View File
@@ -1,8 +1,10 @@
local msg = require('mp.msg')
local utils = require("mp.utils")
local unpack = unpack or table.unpack
input_loaded, input = pcall(require, "mp.input")
uosc_available = false
latest_menu_anime = {}
-- 打开番剧数据匹配菜单
function get_animes(query)
@@ -69,10 +71,11 @@ function get_animes(query)
end
if uosc_available then
update_menu_uosc(menu_type, menu_title, items, footnote, menu_cmd, query)
latest_menu_anime = update_menu_uosc(menu_type, menu_title, items, footnote, menu_cmd, query)
elseif input_loaded then
show_message("", 0)
mp.add_timeout(0.1, function()
latest_menu_anime = utils.format_json(items)
open_menu_select(items)
end)
end
@@ -124,6 +127,13 @@ function get_episodes(animeTitle, bangumiId)
return
end
table.insert(items, {
title = "← 返回搜索结果",
value = { "script-message-to", mp.get_script_name(), "open-latest-menu-anime", latest_menu_anime },
keep_open = false,
selectable = true,
})
for _, episode in ipairs(response.bangumi.episodes) do
table.insert(items, {
title = episode.episodeTitle,
@@ -136,6 +146,7 @@ function get_episodes(animeTitle, bangumiId)
end
if uosc_available then
footnote = mp.get_property("filename")
update_menu_uosc(menu_type, menu_title, items, footnote)
elseif input_loaded then
mp.add_timeout(0.1, function()
@@ -154,6 +165,7 @@ function update_menu_uosc(menu_type, menu_title, menu_item, menu_footnote, menu_
keep_open = true,
selectable = false,
align = "center",
icon = "spinner",
})
else
items = menu_item
@@ -171,6 +183,8 @@ function update_menu_uosc(menu_type, menu_title, menu_item, menu_footnote, menu_
}
local json_props = utils.format_json(menu_props)
mp.commandv("script-message-to", "uosc", "open-menu", json_props)
return json_props
end
function open_menu_select(menu_items, is_time)
@@ -182,10 +196,16 @@ function open_menu_select(menu_items, is_time)
end
mp.commandv('script-message-to', 'console', 'disable')
input.select({
prompt = '筛选:',
prompt = is_time and '筛选:' or '选择:',
items = item_titles,
submit = function(id)
mp.commandv(unpack(item_values[id]))
input.terminate()
local v = item_values[id]
if type(v) == 'table' then
mp.commandv(unpack(v))
elseif type(v) == 'string' then
mp.command(v)
end
end,
})
end
@@ -250,32 +270,134 @@ end
-- 打开弹幕源添加管理菜单
function open_add_menu_get()
mp.commandv('script-message-to', 'console', 'disable')
local menu_log, deal_value = {}, {}
-- 重建菜单内容函数
local function rebuild_menu_log(select_num)
deal_value = {}
menu_log = {
{ text = "【既有弹幕源】", style = "{\\c&H00CCFF&\\b1}" },
{ text = "----------------------------", style = "{\\c&H888888&}" }
}
local serial = 0
for url, source in pairs(DANMAKU.sources) do
if source.data then
serial = serial + 1
local action, text
if source.from == "api_server" then
action = source.blocked and "unblock" or "block"
text = string.format(" [%02d] %s [来源:弹幕服务器%s] ", serial, url,
source.blocked and "(已屏蔽)" or "(未屏蔽)")
else
action = "delete"
text = string.format(" [%02d] %s [来源:用户添加] ", serial, url)
end
local style = (tonumber(select_num) == serial) and
"{\\c&HFFDE7F&\\b1}" or (action == "unblock" and "{\\c&H4C4CC3&\\b0}" or "{\\c&HCCCCCC&\\b0}")
deal_value[serial] = {value = url, action = action}
table.insert(menu_log, {text = text, style = style})
end
end
if serial == 0 then
table.insert(menu_log, { text = "", style = "" })
end
end
-- 显示菜单
local function show_menu(extra_lines, select_num)
rebuild_menu_log(select_num)
local display = {}
for _, item in ipairs(menu_log) do table.insert(display, item) end
table.insert(display, { text = "----------------------------", style = "{\\c&H888888&}" })
if extra_lines then
if #extra_lines < 2 then table.insert(display, { text = "\n", style = "" }) end
for _, line in ipairs(extra_lines) do table.insert(display, line) end
else
table.insert(display, { text = "\n", style = "" })
table.insert(display, {
text = "提示: 输入【选项数字】可屏蔽或删除既有弹幕源",
style = "{\\c&H999999&}"
})
end
input.set_log(display)
end
-- 获取操作提示
local function get_hint(action)
local hints = {
block = "按回车执行,屏蔽该弹幕源",
unblock = "按回车执行,解除该弹幕源的屏蔽",
delete = "按回车执行,删除该弹幕源"
}
return hints[action] or "按回车执行获取输入源地址url的弹幕"
end
input.get({
prompt = 'Input url:',
keep_open = true,
prompt = "请在此输入源地址url: ",
opened = function() show_menu() end,
edited = function(text)
text = text:gsub("^%s*(.-)%s*$", "%1")
if text == "" then
show_menu()
return
end
local num = tonumber(text)
local event = num and deal_value[num]
local hint = get_hint(event and event.action)
show_menu({
{ text = string.format("已输入: %s", text), style = "{\\c&HCCCCCC&}" },
{ text = hint, style = "{\\c&H999999&}" }
}, text)
end,
submit = function(text)
text = text:gsub("^%s*(.-)%s*$", "%1")
if text == "" then return end
local num = tonumber(text)
local event = num and deal_value[num]
if event then
local args = string.format('{"type":"activate","value":"%s","action":"%s"}',
string.gsub(event.value, '\\', '\\\\'), event.action)
mp.commandv("script-message-to", mp.get_script_name(), "setup-danmaku-source", args)
else
input.terminate()
mp.commandv("script-message-to", mp.get_script_name(), "add-source-event", text)
end
mp.add_timeout(0.1, show_menu)
end
})
end
function open_add_menu_uosc()
local sources = {}
for url, source in pairs(DANMAKU.sources) do
if source.fname then
if source.data then
local item = {title = url, value = url, keep_open = true,}
if source.from == "api_server" then
if source.blocked then
item.hint = "来源:弹幕服务器(已屏蔽)"
item.actions = {{icon = "check", name = "unblock"},}
item.actions = {{icon = "check", name = "unblock", label = "解除屏蔽"},}
else
item.hint = "来源:弹幕服务器(未屏蔽)"
item.actions = {{icon = "not_interested", name = "block"},}
item.actions = {{icon = "not_interested", name = "block", label = "屏蔽"},}
end
else
item.hint = "来源:用户添加"
item.actions = {{icon = "delete", name = "delete"},}
item.actions = {{icon = "delete", name = "delete", label = "删除"},}
end
table.insert(sources, item)
end
@@ -312,13 +434,36 @@ function open_content_menu(pos)
if COMMENTS ~= nil then
for _, event in ipairs(COMMENTS) do
local text = event.clean_text:gsub("^m%s[mbl%s%-%d%.]+$", ""):gsub("^%s*(.-)%s*$", "%1")
local delay = get_delay_for_time(DELAYS, event.start_time)
local start_time = event.start_time + delay
local end_time = event.end_time + delay
local delay = event.delay
local start_time = event.start_time
local end_time = event.end_time
if text and text ~= "" and start_time >= 0 and start_time <= duration then
local delay_label_suffix = nil
local delay_num = delay and tonumber(delay)
if delay_num and math.abs(delay_num) > 0 then
delay_label_suffix = string.format("已存在延迟: %+0.1fs", delay_num)
end
local adjust_label = '调整弹幕延迟'
if delay_label_suffix then
adjust_label = adjust_label .. '' .. delay_label_suffix .. ''
end
table.insert(items, {
title = abbr_str(text, 60),
hint = seconds_to_time(start_time),
hint = seconds_to_time(start_time) .. " (" .. utf8_sub(remove_query(event.source), 1, 70) .. ")",
actions = {
{
name = 'block_source',
icon = 'block',
label = '屏蔽对应弹幕源'
},
{
name = 'adjust_delay',
icon = 'more_time',
label = adjust_label,
},
},
value = { "seek", start_time, "absolute" },
active = time_pos >= start_time and time_pos <= end_time,
})
@@ -330,7 +475,9 @@ function open_content_menu(pos)
type = "menu_content",
title = "弹幕内容",
footnote = "使用 / 打开搜索",
items = items
items = items,
item_actions_place = "outside",
callback = {mp.get_script_name(), 'handle-danmaku-content-action'},
}
local json_props = utils.format_json(menu_props)
@@ -361,12 +508,134 @@ local menu_items_config = {
local ordered_keys = {"bold", "fontsize", "outline", "shadow", "scrolltime", "opacity", "displayarea"}
-- 设置弹幕样式菜单
function add_danmaku_setup(actived, status)
if not uosc_available then
show_message("无uosc UI框架不支持使用该功能", 2)
function open_style_menu_get(query, indicator)
mp.commandv('script-message-to', 'console', 'disable')
local menu_log = {}
local select_num = 0
local select_query = nil
if query then
if tonumber(query) ~= nil then
select_num = tonumber(query)
else
for i, v in ipairs(ordered_keys) do
if v == query then
select_num = i
end
end
end
select_query = ordered_keys[select_num]
end
local function build_menu(source)
menu_log = {
{ text = "【弹幕样式菜单】", style = "{\\c&H00CCFF&\\b1}" },
{ text = ("-"):rep(33), style = "{\\c&H888888&}" }
}
local serial = 0
for _, key in ipairs(ordered_keys) do
serial = serial + 1
local config = menu_items_config[key]
local text = string.format(" [%02d] %s [目前:%s] ", serial, config.title, config.hint)
text = config.hint ~= config.original and text .. "" or text
local style = serial == select_num and "{\\c&HFFDE7F&}" or "{\\c&HCCCCCC&}"
local item_config = { text = text, style = style }
table.insert(menu_log, item_config)
end
table.insert(menu_log, { text = ("-"):rep(33), style = "{\\c&H888888&}" })
if select_num == 0 then
table.insert(menu_log, {
text = "注: 样式更改仅在本次播放生效",
style = "{\\c&HFFDE7F&}"
})
table.insert(menu_log, {
text = "提示: 输入【w】可上移选项【s】可下移选项",
style = "{\\c&H999999&}"
})
else
local input_text = source and source or ""
local config = menu_items_config[select_query]
local suffix = ""
if config and config.hint ~= config.original then
suffix = "(输入\\r恢复默认配置"
end
input_text = string.format("已输入%s: %s", suffix, input_text)
local scope = config and config.footnote or ""
local hint_text = select_query == "bold" and "提示: 输入【y】切换状态" or "提示: " .. scope
local hint_style = "{\\c&H999999&}"
if source and source:lower() == "\\r" then
hint_text = string.format("提示: 回车将恢复默认配置 < %s >", config.original)
end
if indicator == "refresh" or indicator == "updata" then
indicator = ""
hint_text = "提示: 样式更改成功"
hint_style = "{\\c&HFFDE7F&}"
mp.add_timeout(1.5, build_menu)
elseif indicator == "error" then
indicator = ""
hint_text = "提示: 输入非数字字符或范围出错"
hint_style = "{\\c&H4C4CC3&}"
mp.add_timeout(1.5, build_menu)
end
table.insert(menu_log, { text = input_text, style = "{\\c&HCCCCCC&}" })
table.insert(menu_log, { text = hint_text, style = hint_style })
end
input.set_log(menu_log)
end
input.get({
keep_open = true,
prompt = "请在此输入操作w/s|上移/下移): ",
opened = function() build_menu() end,
edited = function(text)
text = text:gsub("^%s*(.-)%s*$", "%1")
if text == "" then
build_menu()
return
end
if text:lower() == "w" or text:lower() == "s" then
input.terminate()
select_num = text:lower() == "w" and select_num - 1 or select_num + 1
select_num = (select_num > #ordered_keys) and 1 or (select_num <= 0 and #ordered_keys or select_num)
mp.add_timeout(0.01, function()
open_style_menu_get(select_num)
end)
return
end
build_menu(text)
end,
submit = function(text)
if select_query == nil then return end
text = text:gsub("^%s*(.-)%s*$", "%1")
if text == "" then return end
if text:lower() == "\\r" then
input.terminate()
local args = string.format('{"type":"activate","action":"%s","index":%d}', select_query, select_num)
mp.commandv("script-message-to", mp.get_script_name(), "setup-danmaku-style", args)
else
if menu_items_config[select_query]["scope"] ~= nil then
input.terminate()
mp.commandv("script-message-to", mp.get_script_name(), "setup-danmaku-style", select_query, text)
elseif text:lower() == "y" and select_query == "bold" then
input.terminate()
local args = string.format('{"type":"activate","index":%d}', select_num)
mp.commandv("script-message-to", mp.get_script_name(), "setup-danmaku-style", args)
end
end
return
end
})
end
function open_style_menu_uosc(actived, status)
local items = {}
for _, key in ipairs(ordered_keys) do
local config = menu_items_config[key]
@@ -407,7 +676,7 @@ function add_danmaku_setup(actived, status)
elseif status == "error" then
menu_props.title = "输入非数字字符或范围出错"
-- 创建一个定时器在1秒后触发回调函数删除搜索栏错误信息
mp.add_timeout(1.0, function() add_danmaku_setup(actived, "updata") end)
mp.add_timeout(1.0, function() open_style_menu_uosc(actived, "updata") end)
end
menu_props.search_style = "palette"
menu_props.search_debounce = "submit"
@@ -419,8 +688,216 @@ function add_danmaku_setup(actived, status)
mp.commandv("script-message-to", "uosc", actions, json_props)
end
function open_style_menu(actived, status)
if uosc_available then
open_style_menu_uosc(actived, status)
elseif input_loaded then
mp.add_timeout(0.01, function()
open_style_menu_get(actived, status)
end)
else
show_message("无支持可用的 UI框架不支持使用该功能", 3)
end
end
-- 打开以指定时间为起点的延迟菜单
function open_delay_from_time_get(source, time, status)
mp.commandv('script-message-to', 'console', 'disable')
local menu_log = {}
local function build_menu(query, input_text)
menu_log = {
{ text = "【从该时间起调整弹幕延迟】", style = "{\\c&H00CCFF&\\b1}" },
{ text = ("-"):rep(33), style = "{\\c&H888888&}" }
}
table.insert(menu_log, { text = "\n", style = "" })
local hint_text = "提示:请输入数字,单位(秒)/ 或者按照形如\"14m15s\"的格式输入分钟数加秒数"
local hint_style = "{\\c&H999999&}"
if status == "error" then
hint_text = "提示: 输入非数字字符或范围出错"
hint_style = "{\\c&H4C4CC3&}"
end
table.insert(menu_log, { text = input_text and ("已输入:" .. input_text) or "", style = "{\\c&HCCCCCC&}" })
table.insert(menu_log, { text = hint_text, style = hint_style })
input.set_log(menu_log)
end
input.get({
keep_open = true,
prompt = "请输入要设置的延迟(秒或 XmYs: ",
opened = function() build_menu() end,
edited = function(text)
text = text:gsub("^%s*(.-)%s*$", "%1")
if text == "" then
build_menu()
return
end
build_menu(text)
end,
submit = function(text)
text = text and text:gsub("^%s*(.-)%s*$", "%1") or ""
if text == "" then return end
input.terminate()
local parsed = parse_delay_input(text)
if parsed ~= nil then
mp.commandv("script-message", "danmaku-delay", tostring(parsed), tostring(time), tostring(source))
else
open_delay_from_time(time, "error")
end
end
})
end
function open_delay_from_time_uosc(source, time, status)
if not uosc_available then
show_message("无uosc UI框架不支持使用该功能", 2)
return
end
local menu_props = {
type = "menu_delay_from_time",
title = "从该时间起调整弹幕延迟",
search_style = "palette",
search_debounce = "submit",
footnote = "请输入数字,单位(秒)/ 或者按照形如\"14m15s\"的格式输入分钟数加秒数",
items = {},
on_search = { "script-message-to", mp.get_script_name(), "setup-content-delay", tostring(time), tostring(source) },
}
if status == "error" then
menu_props.title = "输入非数字字符或范围出错"
mp.add_timeout(1.0, function() open_delay_from_time_uosc(source, time) end)
end
local json_props = utils.format_json(menu_props)
mp.commandv("script-message-to", "uosc", "open-menu", json_props)
end
function open_delay_from_time(source, time, status)
if uosc_available then
open_delay_from_time_uosc(source, time, status)
elseif input_loaded then
mp.add_timeout(0.01, function()
open_delay_from_time_get(source, time, status)
end)
else
show_message("无支持可用的 UI框架不支持使用该功能", 3)
end
end
-- 设置弹幕源延迟菜单
function danmaku_delay_setup(source_url)
function open_delay_menu_get(source, status)
mp.commandv('script-message-to', 'console', 'disable')
local menu_log = {}
local serial = 0
local select_num = 0
if source and tonumber(source) ~= nil then
select_num = tonumber(source)
end
local select_url = nil
local function build_menu(query, text)
menu_log = {
{ text = "【弹幕源延迟菜单】", style = "{\\c&H00CCFF&\\b1}" },
{ text = ("-"):rep(33), style = "{\\c&H888888&}" }
}
serial, select_num = 0, 0
for url, src in pairs(DANMAKU.sources) do
if src.data and not src.blocked then
local delay = 0
serial = serial + 1
select_num = (url == source) and serial or select_num
if src.delay_segments then
for _, seg in ipairs(src.delay_segments) do
if seg.start == 0 then
delay = seg.delay or 0
break
end
end
end
local hint = "当前弹幕源延迟: " .. string.format("%.1f", delay + 1e-10) .. ""
local text = string.format(" [%02d] %s [%s] ", serial, url, hint)
local style = (serial == select_num) and "{\\c&HFFDE7F&}" or "{\\c&HCCCCCC&}"
table.insert(menu_log, { text = text, style = style })
select_url = serial == select_num and url or select_url
end
end
if serial == 0 then
table.insert(menu_log, { text = "", style = "" })
end
table.insert(menu_log, { text = ("-"):rep(33), style = "{\\c&H888888&}" })
if select_num == 0 then
table.insert(menu_log, { text = "\n", style = "" })
table.insert(menu_log, {
text = "提示: 输入【w】可上移选项【s】可下移选项",
style = "{\\c&H999999&}"
})
else
local input_text = "已输入:" .. (text ~= nil and text or "")
local hint_text = "提示:请输入数字,单位(秒)/ 或者按照形如\"14m15s\"的格式输入分钟数加秒数"
local hint_style = "{\\c&H999999&}"
if status == "refresh" then
status = ""
hint_text = "提示: 样式更改成功"
hint_style = "{\\c&HFFDE7F&}"
mp.add_timeout(1.5, build_menu)
elseif status == "error" then
status = ""
hint_text = "提示: 输入非数字字符或范围出错"
hint_style = "{\\c&H4C4CC3&}"
mp.add_timeout(1.5, build_menu)
end
-- table.insert(menu_log, { text = input_text, style = "{\\c&HCCCCCC&}" })
table.insert(menu_log, { text = input_text, style = "{\\c&HCCCCCC&}" })
table.insert(menu_log, { text = hint_text, style = hint_style })
end
input.set_log(menu_log)
end
input.get({
keep_open = true,
prompt = "请在此输入操作w/s|上移/下移): ",
opened = function() build_menu() end,
edited = function(text)
text = text:gsub("^%s*(.-)%s*$", "%1")
if text == "" then
build_menu()
return
end
if text:lower() == "w" or text:lower() == "s" then
input.terminate()
select_num = text:lower() == "w" and select_num - 1 or select_num + 1
select_num = (select_num > serial) and 1 or (select_num <= 0 and serial or select_num)
mp.add_timeout(0.01, function()
open_delay_menu_get(select_num)
end)
return
end
build_menu(select_num, text)
end,
submit = function(text)
if select_url == nil then return end
text = text:gsub("^%s*(.-)%s*$", "%1")
if text == "" then return end
input.terminate()
mp.commandv("script-message-to", mp.get_script_name(), "setup-source-delay", select_url, text)
return
end
})
end
function open_delay_menu_uosc(source_url, status)
if not uosc_available then
show_message("无uosc UI框架不支持使用该功能", 2)
return
@@ -428,7 +905,7 @@ function danmaku_delay_setup(source_url)
local sources = {}
for url, source in pairs(DANMAKU.sources) do
if source.fname and not source.blocked then
if source.data and not source.blocked then
local delay = 0
if source.delay_segments then
for _, seg in ipairs(source.delay_segments) do
@@ -453,7 +930,13 @@ function danmaku_delay_setup(source_url)
callback = {mp.get_script_name(), 'setup-source-delay'},
}
if source_url ~= nil then
if status == "error" then
menu_props.title = "输入非数字字符或范围出错"
-- 创建一个定时器在1秒后触发回调函数删除搜索栏错误信息
mp.add_timeout(1.0, function() open_delay_menu_uosc(source_url) end)
else
menu_props.title = "请输入数字,单位(秒)/ 或者按照形如\"14m15s\"的格式输入分钟数加秒数"
end
menu_props.search_style = "palette"
menu_props.search_debounce = "submit"
menu_props.on_search = { "script-message-to", mp.get_script_name(), "setup-source-delay", source_url }
@@ -463,18 +946,29 @@ function danmaku_delay_setup(source_url)
mp.commandv("script-message-to", "uosc", "open-menu", json_props)
end
function open_delay_menu(source, status)
if uosc_available then
open_delay_menu_uosc(source, status)
elseif input_loaded then
mp.add_timeout(0.01, function()
open_delay_menu_get(source, status)
end)
else
show_message("无支持可用的 UI框架不支持使用该功能", 3)
end
end
-- 总集合弹幕菜单
function open_add_total_menu_uosc()
local items = {}
local total_menu_items_config = {
local total_menu_items_config = {
{ title = "弹幕搜索", action = "open_search_danmaku_menu" },
{ title = "从源添加弹幕", action = "open_add_source_menu" },
{ title = "弹幕源延迟设置", action = "open_source_delay_menu" },
{ title = "弹幕样式", action = "open_setup_danmaku_menu" },
{ title = "弹幕样式", action = "open_danmaku_style_menu" },
{ title = "弹幕内容", action = "open_content_danmaku_menu" },
}
}
function open_add_total_menu_uosc()
local items = {}
if DANMAKU.anime and DANMAKU.episode then
local episode = DANMAKU.episode:gsub("%s.-$","")
@@ -509,11 +1003,6 @@ end
function open_add_total_menu_select()
local item_titles, item_values = {}, {}
local total_menu_items_config = {
{ title = "弹幕搜索", action = "open_search_danmaku_menu" },
{ title = "从源添加弹幕", action = "open_add_source_menu" },
{ title = "弹幕内容", action = "open_content_danmaku_menu" },
}
for i, config in ipairs(total_menu_items_config) do
item_titles[i] = config.title
item_values[i] = { "script-message-to", mp.get_script_name(), config.action }
@@ -537,7 +1026,7 @@ function open_add_total_menu()
end
end
-- 添加 uosc 菜单栏按钮
mp.commandv(
"script-message-to",
"uosc",
@@ -570,7 +1059,7 @@ mp.commandv(
utils.format_json({
icon = "palette",
tooltip = "弹幕样式",
command = "script-message open_setup_danmaku_menu",
command = "script-message open_danmaku_style_menu",
})
)
@@ -611,8 +1100,8 @@ mp.register_script_message("set", function(prop, value)
if value == "on" then
ENABLED = true
set_danmaku_visibility(true)
if COMMENTS == nil then
set_danmaku_visibility(true)
local path = mp.get_property("path")
init(path)
else
@@ -622,7 +1111,6 @@ mp.register_script_message("set", function(prop, value)
else
show_message("关闭弹幕", 2)
ENABLED = false
set_danmaku_visibility(false)
hide_danmaku_func()
end
@@ -664,12 +1152,13 @@ mp.register_script_message("add-source-event", function(query)
add_danmaku_source(query, true)
end)
mp.register_script_message("open_setup_danmaku_menu", function()
mp.register_script_message("open_danmaku_style_menu", function()
if uosc_available then
mp.commandv("script-message-to", "uosc", "close-menu", "menu_total")
end
add_danmaku_setup()
open_style_menu()
end)
mp.register_script_message("open_content_danmaku_menu", function()
if uosc_available then
mp.commandv("script-message-to", "uosc", "close-menu", "menu_total")
@@ -677,6 +1166,17 @@ mp.register_script_message("open_content_danmaku_menu", function()
open_content_menu()
end)
mp.register_script_message("open-latest-menu-anime", function ()
if uosc_available then
mp.commandv("script-message-to", "uosc", "open-menu", latest_menu_anime)
elseif input_loaded then
show_message("", 0)
mp.add_timeout(0.1, function()
open_menu_select(utils.parse_json(latest_menu_anime))
end)
end
end)
mp.register_script_message("setup-danmaku-style", function(query, text)
local event = utils.parse_json(query)
if event ~= nil then
@@ -688,13 +1188,13 @@ mp.register_script_message("setup-danmaku-style", function(query, text)
menu_items_config.bold.hint = options.bold and "true" or "false"
end
-- "updata" 模式会保留输入框文字
add_danmaku_setup(ordered_keys[event.index], "updata")
open_style_menu(ordered_keys[event.index], "updata")
return
else
-- msg.info("event.action" .. event.action)
options[event.action] = menu_items_config[event.action]["original"]
menu_items_config[event.action]["hint"] = options[event.action]
add_danmaku_setup(event.action, "updata")
open_style_menu(event.action, "updata")
if event.action == "fontsize" or event.action == "scrolltime" then
load_danmaku(true)
end
@@ -718,14 +1218,14 @@ mp.register_script_message("setup-danmaku-style", function(query, text)
options[query] = tostring(num)
menu_items_config[query]["hint"] = options[query]
-- "refresh" 模式会清除输入框文字
add_danmaku_setup(query, "refresh")
open_style_menu(query, "refresh")
if query == "fontsize" or query == "scrolltime" then
load_danmaku(true, true)
end
return
end
end
add_danmaku_setup(query, "error")
open_style_menu(query, "error")
end
end)
@@ -734,14 +1234,10 @@ mp.register_script_message('setup-danmaku-source', function(json)
if event.type == 'activate' then
if event.action == "delete" then
local rm = DANMAKU.sources[event.value]["fname"]
if rm and file_exists(rm) and DANMAKU.sources[event.value]["from"] ~= "user_local" then
os.remove(rm)
end
DANMAKU.sources[event.value] = nil
remove_source_from_history(event.value)
mp.commandv("script-message-to", "uosc", "close-menu", "menu_source")
open_add_menu_uosc()
open_add_menu()
load_danmaku(true)
end
@@ -749,7 +1245,7 @@ mp.register_script_message('setup-danmaku-source', function(json)
DANMAKU.sources[event.value]["blocked"] = true
add_source_to_history(event.value, DANMAKU.sources[event.value])
mp.commandv("script-message-to", "uosc", "close-menu", "menu_source")
open_add_menu_uosc()
open_add_menu()
load_danmaku(true)
end
@@ -757,7 +1253,7 @@ mp.register_script_message('setup-danmaku-source', function(json)
DANMAKU.sources[event.value]["blocked"] = false
add_source_to_history(event.value, DANMAKU.sources[event.value])
mp.commandv("script-message-to", "uosc", "close-menu", "menu_source")
open_add_menu_uosc()
open_add_menu()
load_danmaku(true)
end
end
@@ -768,39 +1264,74 @@ mp.register_script_message("setup-source-delay", function(query, text)
if event ~= nil then
-- item点击
if event.type == "activate" then
danmaku_delay_setup(event.value)
open_delay_menu(event.value)
end
else
-- 数值输入
if text == nil or text == "" then
return
end
local newText, _ = text:gsub("%s", "") -- 移除所有空白字符
local num = tonumber(newText)
local delay_segments = shallow_copy(DANMAKU.sources[query]["delay_segments"] or {})
for i = #delay_segments, 1, -1 do
if delay_segments[i].start == 0 then
table.remove(delay_segments, i)
end
end
if num ~= nil then
table.insert(delay_segments, 1, { start = 0, delay = tonumber(num) })
DANMAKU.sources[query]["delay_segments"] = delay_segments
add_source_to_history(query, DANMAKU.sources[query])
local delay = parse_delay_input(text)
if delay ~= nil then
mp.commandv("script-message", "danmaku-delay", tostring(delay), "0")
mp.commandv("script-message-to", "uosc", "close-menu", "menu_delay")
danmaku_delay_setup(query)
load_danmaku(true, true)
elseif newText:match("^%-?%d+m%d+s$") then
local minutes, seconds = string.match(newText, "^(%-?%d+)m(%d+)s$")
minutes = tonumber(minutes)
seconds = tonumber(seconds)
if minutes < 0 then seconds = -seconds end
table.insert(delay_segments, 1, { start = 0, delay = 60 * minutes + seconds })
DANMAKU.sources[query]["delay_segments"] = delay_segments
add_source_to_history(query, DANMAKU.sources[query])
mp.commandv("script-message-to", "uosc", "close-menu", "menu_delay")
danmaku_delay_setup(query)
load_danmaku(true, true)
mp.add_timeout(0.1, function()
open_delay_menu(query, "refresh")
end)
else
open_delay_menu(query, "error")
end
end
end)
mp.register_script_message('handle-danmaku-content-action', function(json)
local event = utils.parse_json(json)
if not event or event.type ~= 'activate' then return end
if event.action then
local d = COMMENTS[event.index]
if not d or not d.source then return end
if event.action == "block_source" then
DANMAKU.sources[d.source]["blocked"] = true
add_source_to_history(d.source, DANMAKU.sources[d.source])
mp.commandv("script-message-to", "uosc", "close-menu", "menu_content")
load_danmaku(true)
elseif event.action == "adjust_delay" then
-- 打开以该弹幕时间为起点的延迟菜单(该延迟将作用于该时间点及之后的弹幕),仅针对该条弹幕的 source
mp.commandv("script-message", "open_content_delay_menu", d.source, tostring(d.start_time))
end
else
if event.value then
if type(event.value) == "table" then
mp.commandv(unpack(event.value))
else
mp.command(event.value)
end
mp.commandv("script-message-to", "uosc", "close-menu", "menu_content")
end
end
end)
mp.register_script_message("open_content_delay_menu", function(source, time)
open_delay_from_time(source, tonumber(time))
end)
mp.register_script_message("setup-content-delay", function(...)
local args = {...}
if #args == 1 then
return
end
if #args >= 2 then
local time = tonumber(args[1])
local source = args[2]
local delay_str = args[3]
local delay = parse_delay_input(delay_str)
if delay ~= nil then
mp.commandv("script-message", "danmaku-delay", tostring(delay), tostring(time), tostring(source))
mp.commandv("script-message-to", "uosc", "close-menu", "menu_delay_from_time")
else
open_delay_from_time(source, tonumber(time), "error")
end
end
end)
+4 -3
View File
@@ -5,19 +5,20 @@ options = {
-- 指定弹幕服务器地址,自定义服务需兼容 dandanplay 的 api
api_server = "https://api.dandanplay.net",
-- 指定 b 站和爱腾优的弹幕获取的兜底服务器地址,主要用于获取非动画弹幕
-- 服务器可以自托管https://github.com/lyz05/danmaku
fallback_server = "https://fc.lyz05.cn",
-- 可用: https://api.danmu.icuhttps://dmku.hls.one
fallback_server = "https://api.danmu.icu",
-- 设置 tmdb 的 API Key用于获取非动画条目的中文信息(当搜索内容非中文时)
-- 可以在 https://www.themoviedb.org 注册后去个人账号设置界面获取
-- 注意:自定义此参数时还需要对获取到的 API Key 进行 base64 编码
tmdb_api_key = "NmJmYjIxOTZkNzIyN2UyMTIzMGM3Y2YzZjQ4MDNkZGM=",
load_more_danmaku = false,
auto_load = false,
autoload_local_danmaku = false,
autoload_for_url = false,
save_danmaku = false,
user_agent = "mpv_danmaku/1.0",
proxy = "",
-- 可选:向 HTTP 请求传递 cookie.txt 文件路径
cookie_file = "",
-- 使用 fps 视频滤镜,大幅提升弹幕平滑度。默认禁用
vf_fps = false,
-- 设置要使用的 fps 滤镜参数
+310 -245
View File
@@ -40,12 +40,52 @@ local function load_blacklist_patterns(filepath)
return patterns
end
if string.match(filepath, "%.xml$") then
-- xml文件格式示例
--<?xml version="1.0" encoding="utf-8"?>
--<filters>
-- <item enabled="true">t=卡在</item>
-- <item enabled="true">t=进度条</item>
--</filters>
print("加载黑名单文件: " .. filepath)
for line in file:lines() do
local pattern = line:match('<item%s+enabled="true">t=(.-)</item>')
if pattern then
print("加载黑名单模式: " .. pattern)
table.insert(patterns, pattern)
end
end
end
if string.match(filepath, "%.json$") then
-- json文件格式示例
-- [{"type":0,"filter":"开门","opened":true,"id":15628936}
-- ,{"type":0,"filter":"tony","opened":true,"id":15628939}
-- ,{"type":1,"filter":"0+.1","opened":true,"id":15628951}]
local content = read_file(filepath)
if content then
local json = utils.parse_json(content)
if json and type(json) == "table" then
for _, entry in ipairs(json) do
if entry.opened and entry.filter and entry.type == 0 then
table.insert(patterns, entry.filter)
end
end
end
end
end
if string.match(filepath, "%.txt$") then
-- 文本文件格式示例
-- 卡在
-- 进度条
for line in file:lines() do
line = line:match("^%s*(.-)%s*$")
if line ~= "" then
table.insert(patterns, line)
end
end
end
file:close()
return patterns
@@ -114,14 +154,22 @@ local function merge_duplicate_danmaku(danmakus, threshold)
local groups = {}
for _, d in ipairs(danmakus) do
local key = d.type .. "|" .. d.color .. "|" .. d.text
if not groups[key] then groups[key] = {} end
table.insert(groups[key], d)
local tkey = tostring(d.type or "")
local ckey = tostring(d.color or "")
local text = d.text or ""
groups[tkey] = groups[tkey] or {}
groups[tkey][ckey] = groups[tkey][ckey] or {}
groups[tkey][ckey][text] = groups[tkey][ckey][text] or {}
table.insert(groups[tkey][ckey][text], d)
end
local merged = {}
local abs = math.abs
for _, group in pairs(groups) do
for _, bytype in pairs(groups) do
for _, bycolor in pairs(bytype) do
for _, group in pairs(bycolor) do
table.sort(group, function(a, b) return a.time < b.time end)
local i = 1
@@ -131,8 +179,8 @@ local function merge_duplicate_danmaku(danmakus, threshold)
local count = 1
local j = i + 1
while j <= #group and math.abs(group[j].time - base.time) <= threshold do
table.insert(times, group[j].time)
while j <= #group and abs(group[j].time - base.time) <= threshold do
times[#times+1] = group[j].time
count = count + 1
j = j + 1
end
@@ -151,6 +199,8 @@ local function merge_duplicate_danmaku(danmakus, threshold)
size = base.size,
color = base.color,
text = base.text,
source = base.source,
orig_time = base.orig_time,
}
if count > 2 or not same_time then
danmaku.text = danmaku.text .. string.format("x%d", count)
@@ -160,6 +210,8 @@ local function merge_duplicate_danmaku(danmakus, threshold)
i = j
end
end
end
end
table.sort(merged, function(a, b) return a.time < b.time end)
return merged
@@ -207,9 +259,11 @@ local function limit_danmaku(danmakus, limit)
end
-- 解析 XML 弹幕
local function parse_xml_danmaku(xml_string, delay_segments)
local function parse_xml_danmaku(xml_string)
local danmakus = {}
for p_attr, text in xml_string:gmatch('<d p="([^"]+)">([^<]+)</d>') do
-- [^>]* 匹配其他 attributes
-- %f[^%s] 确保 p= 前面是空白字符
for p_attr, text in xml_string:gmatch('<d%s+[^>]*%f[^%s]p="([^"]+)"[^>]*>([^<]+)</d>') do
local params = {}
local i = 1
for val in p_attr:gmatch("([^,]+)") do
@@ -218,10 +272,8 @@ local function parse_xml_danmaku(xml_string, delay_segments)
end
if params[1] and params[2] and params[3] and params[4] then
local base_time = params[1]
local delay = get_delay_for_time(delay_segments, base_time)
table.insert(danmakus, {
time = base_time + delay,
time = params[1],
type = params[2] or 1,
size = params[3] or 25,
color = params[4] or 0xFFFFFF,
@@ -235,7 +287,7 @@ local function parse_xml_danmaku(xml_string, delay_segments)
end
-- 解析 JSON 弹幕
local function parse_json_danmaku(json_string, delay_segments)
local function parse_json_danmaku(json_string)
local danmakus = {}
if json_string:sub(1, 3) == "\239\187\191" then
json_string = json_string:sub(4)
@@ -259,10 +311,8 @@ local function parse_json_danmaku(json_string, delay_segments)
end
if params[1] and params[2] and params[3] and params[4] then
local base_time = params[1]
local delay = get_delay_for_time(delay_segments, base_time)
table.insert(danmakus, {
time = base_time + delay,
time = params[1],
color = params[2] or 0xFFFFFF,
type = params[3] or 1,
size = params[4] or 25,
@@ -277,64 +327,39 @@ local function parse_json_danmaku(json_string, delay_segments)
end
-- 解析弹幕文件
function parse_danmaku_files(danmaku_input, delays)
local DANMAKU_PATHs = {}
if type(danmaku_input) == "string" then
DANMAKU_PATHs = { danmaku_input }
else
for i, input in ipairs(danmaku_input) do
DANMAKU_PATHs[#DANMAKU_PATHs + 1] = input
end
end
function parse_danmaku_file(danmaku_input)
local danmakus = {}
local all_danmaku = {}
for i, DANMAKU_PATH in ipairs(DANMAKU_PATHs) do
if file_exists(DANMAKU_PATH) then
local content = read_file(DANMAKU_PATH)
if file_exists(danmaku_input) then
local content = read_file(danmaku_input)
if content then
local parsed = {}
local delay_segments = delays and delays[i] or {}
if DANMAKU_PATH:match("%.xml$") then
parsed = parse_xml_danmaku(content, delay_segments)
elseif DANMAKU_PATH:match("%.json$") then
parsed = parse_json_danmaku(content, delay_segments)
if danmaku_input:match("%.xml$") then
parsed = parse_xml_danmaku(content)
elseif danmaku_input:match("%.json$") then
parsed = parse_json_danmaku(content)
end
for _, d in ipairs(parsed) do
local matched, pattern = is_blacklisted(d.text, black_patterns)
if not matched then
d.text = ch_convert_cached(d.text)
table.insert(all_danmaku, d)
else
-- msg.debug("命中黑名单: " .. pattern)
end
table.insert(danmakus, d)
end
else
msg.info("无法读取文件内容: " .. DANMAKU_PATH)
msg.info("无法读取文件内容: " .. danmaku_input)
end
else
msg.info("文件不存在: " .. DANMAKU_PATH)
end
msg.info("文件不存在: " .. danmaku_input)
end
if #all_danmaku == 0 then
for _, d in ipairs(danmakus) do
if d.orig_time == nil then d.orig_time = d.time end
end
if #danmakus == 0 then
msg.info("未能解析任何弹幕")
return nil
end
if options.max_screen_danmaku > 0 and options.merge_tolerance <= 0 then
options.merge_tolerance = options.scrolltime
end
-- 按时间排序
table.sort(all_danmaku, function(a, b)
return a.time < b.time
end)
all_danmaku = merge_duplicate_danmaku(all_danmaku, options.merge_tolerance)
return all_danmaku
return danmakus
end
--# 弹幕数组与布局算法 (Danmaku Array & Layout Algorithms)
@@ -349,7 +374,7 @@ function DanmakuArray:new(res_x, res_y, font_size)
time_length_array = {}
}
for i = 1, obj.rows do
obj.time_length_array[i] = { time = -1, length = 0 }
obj.time_length_array[i] = { time = 0, length = 0, empty = true }
end
setmetatable(obj, self)
return obj
@@ -357,7 +382,7 @@ end
function DanmakuArray:set_time_length(row, time, length)
if row > 0 and row <= self.rows then
self.time_length_array[row] = { time = time, length = length }
self.time_length_array[row] = { time = time, length = length, empty = false }
end
end
@@ -375,15 +400,20 @@ function DanmakuArray:get_length(row)
return 0
end
function DanmakuArray:is_empty(row)
if row > 0 and row <= self.rows then
return self.time_length_array[row].empty
end
return false
end
-- 滚动弹幕 Y 坐标算法
function get_position_y(font_size, appear_time, text_length, resolution_x, roll_time, array)
local velocity = (text_length + resolution_x) / roll_time
local best_row = 0
local best_bias = -math.huge
for i = 1, array.rows do
local previous_appear_time = array:get_time(i)
if array:get_time(i) < 0 then
if array:is_empty(i) then
array:set_time_length(i, appear_time, text_length)
return 1 + (i - 1) * font_size
end
@@ -391,7 +421,7 @@ function get_position_y(font_size, appear_time, text_length, resolution_x, roll_
local previous_length = array:get_length(i)
local previous_velocity = (previous_length + resolution_x) / roll_time
local delta_velocity = velocity - previous_velocity
local delta_x = (appear_time - previous_appear_time) * previous_velocity - (previous_length + text_length) / 2
local delta_x = (appear_time - previous_appear_time) * previous_velocity - previous_length
if delta_x >= 0 then
if delta_velocity <= 0 then
@@ -400,22 +430,10 @@ function get_position_y(font_size, appear_time, text_length, resolution_x, roll_
end
local delta_time = delta_x / delta_velocity
local bias = appear_time - previous_appear_time - delta_time
-- 判断:追及点是否在屏幕之外
local t_catch = previous_appear_time + delta_time
local distance_prev = previous_velocity * (t_catch - previous_appear_time)
if distance_prev > resolution_x then
-- 追及发生在屏幕之外,允许放置
if delta_time >= roll_time then
array:set_time_length(i, appear_time, text_length)
return 1 + (i - 1) * font_size
end
if bias > 0 then
array:set_time_length(i, appear_time, text_length)
return 1 + (i - 1) * font_size
elseif bias > best_bias then
best_bias = bias
best_row = i
end
end
end
-- 所有行都被占用,放弃渲染
@@ -424,8 +442,6 @@ end
-- 固定弹幕 Y 坐标算法
function get_fixed_y(font_size, appear_time, fixtime, array, from_top)
local best_row = 0
local best_bias = -1
local row_start, row_end, row_step
if from_top then
row_start, row_end, row_step = 1, array.rows, 1
@@ -435,7 +451,7 @@ function get_fixed_y(font_size, appear_time, fixtime, array, from_top)
for i = row_start, row_end, row_step do
local previous_appear_time = array:get_time(i)
if previous_appear_time < 0 then
if array:is_empty(i) then
array:set_time_length(i, appear_time, 0)
return (i - 1) * font_size + 1
else
@@ -443,9 +459,6 @@ function get_fixed_y(font_size, appear_time, fixtime, array, from_top)
if delta_time > fixtime then
array:set_time_length(i, appear_time, 0)
return (i - 1) * font_size + 1
elseif delta_time > best_bias then
best_bias = delta_time
best_row = i
end
end
end
@@ -453,166 +466,27 @@ function get_fixed_y(font_size, appear_time, fixtime, array, from_top)
return nil
end
-- 将弹幕转换为 ASS 格式
function convert_danmaku_to_ass(all_danmaku, danmaku_file)
if #all_danmaku == 0 then
msg.info("弹幕文件为空或解析失败")
return false
end
msg.info("已解析 " .. #all_danmaku .. " 条弹幕")
local alpha = string.format("%02X", (1 - tonumber(options.opacity)) * 255)
local bold = options.bold and "1" or "0"
local fontsize = tonumber(options.fontsize) or 50
local scrolltime = tonumber(options.scrolltime) or 15
local fixtime = tonumber(options.fixtime) or 5
local outline = tonumber(options.outline) or 1.0
local shadow = tonumber(options.shadow) or 0.0
local res_x = 1920
local res_y = 1080
local roll_array = DanmakuArray:new(res_x, res_y, fontsize)
local top_array = DanmakuArray:new(res_x, res_y, fontsize)
local ass_header = string.format([[
[Script Info]
Title: DanmakuConvert for mpv
ScriptType: v4.00+
Collisions: Normal
PlayResX: %d
PlayResY: %d
Timer: 100.0000
WrapStyle: 2
ScaledBorderAndShadow: yes
[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Style: R2L,%s,%d,&H%sFFFFFF,&H00FFFFFF,&H00000000,&H%s000000,%d,0,0,0,100,100,0,0,1,%.1f,%.1f,7,0,0,0,1
Style: TOP,%s,%d,&H%sFFFFFF,&H00FFFFFF,&H00000000,&H%s000000,%d,0,0,0,100,100,0,0,1,%.1f,%.1f,8,0,0,0,1
Style: BTM,%s,%d,&H%sFFFFFF,&H00FFFFFF,&H00000000,&H%s000000,%d,0,0,0,100,100,0,0,1,%.1f,%.1f,2,0,0,0,1
[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
]], res_x, res_y, options.fontname, fontsize, alpha, alpha, bold, outline, shadow,
options.fontname, fontsize, alpha, alpha, bold, outline, shadow,
options.fontname, fontsize, alpha, alpha, bold, outline, shadow)
-- 预处理弹幕,先计算时间段以便进行数量限制
local pre_events = {}
for _, d in ipairs(all_danmaku) do
local time = d.type == 1 and math.floor(d.time + 0.5) or d.time
local appear_time = time
local danmaku_type = d.type
local end_time = nil
if danmaku_type >= 1 and danmaku_type <= 3 then
end_time = appear_time + scrolltime
elseif danmaku_type == 5 or danmaku_type == 4 then
end_time = appear_time + fixtime
end
if end_time then
table.insert(pre_events, {start_time = appear_time, end_time = end_time, danmaku = d})
end
end
if options.max_screen_danmaku > 0 then
pre_events = limit_danmaku(pre_events, options.max_screen_danmaku)
end
local ass_events = {}
for _, ev in ipairs(pre_events) do
local d = ev.danmaku
local appear_time = ev.start_time
local danmaku_type = d.type
local text = ass_escape(decode_html_entities(d.text))
:gsub("x(%d+)$", "{\\b1\\i1}x%1")
-- 颜色从十进制转为 BGR Hex
local color = math.max(0, math.min(d.color or 0xFFFFFF, 0xFFFFFF))
local color_hex = string.format("%06X", color)
local r = string.sub(color_hex, 1, 2)
local g = string.sub(color_hex, 3, 4)
local b = string.sub(color_hex, 5, 6)
local color_text = string.format("{\\c&H%s%s%s&}", b, g, r)
local start_time_str = seconds_to_time(appear_time)
local layer, end_time_str, style, effect
-- 滚动弹幕 (类型 1, 2, 3)
if danmaku_type >= 1 and danmaku_type <= 3 then
layer = 0
end_time_str = seconds_to_time(ev.end_time)
style = "R2L"
local text_length = get_str_width(text, fontsize)
local x1 = res_x + text_length / 2
local x2 = -text_length / 2
local y = get_position_y(fontsize, appear_time, text_length, res_x, scrolltime, roll_array)
if y then
effect = string.format("{\\move(%d, %d, %d, %d)}", x1, y, x2, y)
end
-- 顶部弹幕 (类型 5)
elseif danmaku_type == 5 then
layer = 1
end_time_str = seconds_to_time(ev.end_time)
style = "TOP"
local x = res_x / 2
local y = get_fixed_y(fontsize, appear_time, fixtime, top_array, true)
if y then
effect = string.format("{\\pos(%d, %d)}", x, y)
end
-- 底部弹幕 (类型 4)
elseif danmaku_type == 4 then
layer = 1
end_time_str = seconds_to_time(ev.end_time)
style = "BTM"
local x = res_x / 2
local y = get_fixed_y(fontsize, appear_time, fixtime, top_array, false)
if y then
effect = string.format("{\\pos(%d, %d)}", x, y)
end
end
if style then
local line = nil
if effect then
line = string.format("Dialogue: %d,%s,%s,%s,,0,0,0,,%s%s%s", layer, start_time_str, end_time_str, style, effect, color_text, text)
else
line = string.format("Comment: %d,%s,%s,%s,,0,0,0,,%s%s", layer, start_time_str, end_time_str, style, color_text, text)
end
table.insert(ass_events, line)
end
end
local final_ass = ass_header .. table.concat(ass_events, "\n")
local ass_file = io.open(danmaku_file, "w")
if not ass_file then
msg.info("错误: 无法写入 ASS 弹幕文件")
return false
end
ass_file:write(final_ass)
ass_file:close()
msg.debug("已成功转换并写入 ASS" .. danmaku_file)
return true
end
-- 将弹幕转换为 XML 格式
function convert_danmaku_to_xml(danmaku_input, danmaku_out, delays)
local all_danmaku = parse_danmaku_files(danmaku_input, delays)
if not all_danmaku then
show_message("转换 XML 弹幕失败", 3)
msg.info("转换 XML 弹幕失败")
return
function convert_danmaku_to_xml(danmaku_out)
local danmakus = {}
for _, source in pairs(DANMAKU.sources) do
if not source.blocked and source.data then
for _, d in ipairs(source.data) do
table.insert(danmakus, d)
end
end
end
if #danmakus == 0 then
show_message("弹幕内容为空,无法保存", 3)
msg.verbose("弹幕内容为空,无法保存")
COMMENTS = {}
return false
end
-- 拼接为 XML 内容
local xml = { '<?xml version="1.0" encoding="UTF-8"?><i>\n' }
for _, d in ipairs(all_danmaku) do
for _, d in ipairs(danmakus) do
local time = d.time
local type = d.type or 1
local size = d.size or 25
@@ -643,13 +517,204 @@ function convert_danmaku_to_xml(danmaku_input, danmaku_out, delays)
return true
end
-- 解析和转换弹幕
function convert_danmaku_format(danmaku_input, danmaku_file, delays)
local all_danmaku = parse_danmaku_files(danmaku_input, delays)
if all_danmaku then
convert_danmaku_to_ass(all_danmaku, danmaku_file)
else
msg.info("未能解析对应的 .xml 或 .json 弹幕文件")
return false
function convert_danmaku_to_ass_events(force)
local per_source_lists = {}
for url, source in pairs(DANMAKU.sources) do
if not source.blocked and source.data then
local segments = nil
local prefix = nil
if source.delay_segments and #source.delay_segments > 0 then
segments = {}
for i, v in ipairs(source.delay_segments) do segments[i] = v end
table.sort(segments, function(a, b) return (a.start or 0) < (b.start or 0) end)
prefix = {}
local s = 0
for i, v in ipairs(segments) do
s = s + (v.delay or 0)
prefix[i] = s
end
end
local function get_cached_delay(t)
local segs = segments or {}
local pre = prefix or {}
if #segs == 0 then return 0 end
local idx = binary_search(segs, t, function(s) return (s and s.start) or 0 end)
local target = idx - 1
if target < 1 then return 0 end
return pre[target] or 0
end
local list = {}
for _, d in ipairs(source.data) do
local base_time = d.orig_time or d.time
if d.orig_time == nil then d.orig_time = base_time end
local adjusted_time = base_time + get_cached_delay(base_time)
local entry = {
orig_time = d.orig_time,
time = adjusted_time,
type = d.type,
size = d.size,
color = d.color,
text = d.text,
source = url,
}
if not is_blacklisted(d.text, black_patterns) then
table.insert(list, entry)
end
end
if #list > 0 then
table.sort(list, function(a, b) return a.time < b.time end)
per_source_lists[#per_source_lists + 1] = list
end
end
end
local danmakus = {}
local heap = new_min_heap()
for li = 1, #per_source_lists do
local lst = per_source_lists[li]
if lst and #lst > 0 then
heap.push({ time = lst[1].time, list_idx = li, pos = 1, entry = lst[1] })
end
end
while true do
local node = heap.pop()
if not node then break end
table.insert(danmakus, node.entry)
local li = node.list_idx
local next_pos = node.pos + 1
local lst = per_source_lists[li]
if lst and lst[next_pos] then
heap.push({ time = lst[next_pos].time, list_idx = li, pos = next_pos, entry = lst[next_pos] })
end
end
if options.max_screen_danmaku > 0 and options.merge_tolerance <= 0 then
options.merge_tolerance = options.scrolltime
end
danmakus = merge_duplicate_danmaku(danmakus, options.merge_tolerance)
if #danmakus == 0 then
if not force then
show_message("该集弹幕内容为空,结束加载", 3)
msg.verbose("该集弹幕内容为空,结束加载")
end
COMMENTS = {}
return
end
if not force then
msg.info("已解析 " .. #danmakus .. " 条弹幕")
end
local fontsize = tonumber(options.fontsize) or 50
local scrolltime = tonumber(options.scrolltime) or 15
local fixtime = tonumber(options.fixtime) or 5
local res_x = 1920
local res_y = 1080
local roll_array = DanmakuArray:new(res_x, res_y, fontsize)
local top_array = DanmakuArray:new(res_x, res_y, fontsize)
-- 预处理弹幕,先计算时间段以便进行数量限制
local pre_events = {}
for _, d in ipairs(danmakus) do
local time = d.type == 1 and math.floor(d.time + 0.5) or d.time
local orig_time = d.type == 1 and math.floor(d.orig_time + 0.5) or d.orig_time
local appear_time = time
local danmaku_type = d.type
local end_time = nil
if danmaku_type >= 1 and danmaku_type <= 3 then
end_time = appear_time + scrolltime
elseif danmaku_type == 5 or danmaku_type == 4 then
end_time = appear_time + fixtime
end
if end_time then
table.insert(pre_events, {orig_time = orig_time, start_time = appear_time, end_time = end_time, danmaku = d})
end
end
if options.max_screen_danmaku > 0 then
pre_events = limit_danmaku(pre_events, options.max_screen_danmaku)
end
local ass_events = {}
for _, ev in ipairs(pre_events) do
local d = ev.danmaku
local appear_time = ev.start_time
local danmaku_type = d.type
local clean_text = ch_convert_cached(decode_html_entities(d.text))
local text = ass_escape(clean_text)
:gsub("x(%d+)$", "{\\b1\\i1}x%1")
-- 颜色从十进制转为 BGR Hex
local color = math.max(0, math.min(d.color or 0xFFFFFF, 0xFFFFFF))
local color_hex = string.format("%06X", color)
local r = string.sub(color_hex, 1, 2)
local g = string.sub(color_hex, 3, 4)
local b = string.sub(color_hex, 5, 6)
local color_text = string.format("{\\c&H%s%s%s&}", b, g, r)
local style, effect
local pos, move = nil, nil
-- 滚动弹幕 (类型 1, 2, 3)
if danmaku_type >= 1 and danmaku_type <= 3 then
style = "R2L"
local text_length = get_str_width(text, fontsize)
local x1 = res_x + text_length / 2
local x2 = -text_length / 2
local y = get_position_y(fontsize, appear_time, text_length, res_x, scrolltime, roll_array)
if y then
effect = string.format("{\\move(%d, %d, %d, %d)}", x1, y, x2, y)
move = {x1, y, x2, y}
end
-- 顶部弹幕 (类型 5)
elseif danmaku_type == 5 then
style = "TOP"
local x = res_x / 2
local y = get_fixed_y(fontsize, appear_time, fixtime, top_array, true)
if y then
effect = string.format("{\\pos(%d, %d)}", x, y)
pos = {x, y}
end
-- 底部弹幕 (类型 4)
elseif danmaku_type == 4 then
style = "BTM"
local x = res_x / 2
local y = get_fixed_y(fontsize, appear_time, fixtime, top_array, false)
if y then
effect = string.format("{\\pos(%d, %d)}", x, y)
pos = {x, y}
end
end
if style and effect then
text = effect .. color_text .. text
local event = {
orig_time = ev.orig_time,
start_time = ev.start_time,
end_time = ev.end_time,
delay = ev.start_time - (ev.orig_time or ev.start_time),
style = style,
text = text,
clean_text = clean_text,
pos = pos,
move = move,
source = d.source,
}
table.insert(ass_events, event)
COMMENTS = ass_events
end
end
end
+76 -113
View File
@@ -1,25 +1,15 @@
-- modified from https://github.com/rkscv/danmaku/blob/main/danmaku.lua
local msg = require('mp.msg')
local utils = require("mp.utils")
local unpack = unpack or table.unpack
local INTERVAL = options.vf_fps and 0.01 or 0.001
local osd_width, osd_height, pause = 0, 0, true
local time_pos_observer_active = false
local overlay = mp.create_osd_overlay('ass-events')
-- 提取 \move 参数 (x1, y1, x2, y2) 并返回
local function parse_move_tag(text)
-- 匹配包括小数和负数在内的坐标值
local x1, y1, x2, y2 = text:match("\\move%((%-?[%d%.]+),%s*(%-?[%d%.]+),%s*(%-?[%d%.]+),%s*(%-?[%d%.]+).*%)")
if x1 and y1 and x2 and y2 then
return tonumber(x1), tonumber(y1), tonumber(x2), tonumber(y2)
end
return nil
end
local function parse_comment(event, pos, height, delay)
local x1, y1, x2, y2 = parse_move_tag(event.text)
local displayarea = tonumber(height * options.displayarea)
if not x1 then
local current_x, current_y = event.text:match("\\pos%((%-?[%d%.]+),%s*(%-?[%d%.]+).*%)")
local function realtime_position_text(event, pos, displayarea)
if not event.move then
local _, current_y = unpack(event.pos)
if not current_y or tonumber(current_y) > displayarea then return end
if event.style ~= "SP" and event.style ~= "MSG" then
return string.format("{\\an8}%s", event.text)
@@ -28,9 +18,10 @@ local function parse_comment(event, pos, height, delay)
end
end
local x1, y1, x2, y2 = unpack(event.move)
-- 计算移动的时间范围
local duration = event.end_time - event.start_time --mean: options.scrolltime
local progress = (pos - event.start_time - delay) / duration -- 移动进度 [0, 1]
local progress = (pos - event.start_time) / duration -- 移动进度 [0, 1]
-- 计算当前坐标
local current_x = tonumber(x1 + (x2 - x1) * progress)
@@ -46,60 +37,28 @@ local function parse_comment(event, pos, height, delay)
end
end
-- 从 ASS 文件中解析样式和事件
local function parse_ass_events(ass_path, callback)
local ass_file = io.open(ass_path, "r")
if not ass_file then
callback("无法打开 ASS 文件")
return
end
local events = {}
local time_tolerance = options.merge_tolerance
for line in ass_file:lines() do
if line:match("^Dialogue:") then
local start_time, end_time, style, text = line:match("Dialogue:%s*[^,]*,%s*([^,]*),%s*([^,]*),%s*([^,]*),[^,]*,[^,]*,[^,]*,[^,]*,[^,]*,(.*)")
if start_time and end_time and text then
local event = {
start_time = time_to_seconds(start_time),
end_time = time_to_seconds(end_time),
style = style,
text = text:gsub("%s+$", ""),
clean_text = text:gsub("\\h+", " "):gsub("{[\\=].-}", ""):gsub("^%s*(.-)%s*$", "%1"),
pos = text:match("\\pos"),
move = text:match("\\move"),
}
table.insert(events, event)
end
end
end
table.sort(events, function(a, b)
return a.start_time < b.start_time
end)
ass_file:close()
callback(nil, events)
end
local overlay = mp.create_osd_overlay('ass-events')
function render()
function render(pos_arg)
if COMMENTS == nil then return end
local pos, err = mp.get_property_number('time-pos')
local pos, err
if pos_arg == nil then
pos, err = mp.get_property_number('time-pos')
if err ~= nil then
return msg.error(err)
end
else
pos = pos_arg
end
local delay = get_delay_for_time(DELAYS, pos)
if not pos then
overlay:remove()
return
end
local fontname = options.fontname
local fontsize = options.fontsize
local alpha = string.format("%02X", (1 - tonumber(options.opacity)) * 255)
local opacity = tonumber(options.opacity)
local alpha = string.format("%02X", (1 - (opacity or 0)) * 255)
local width, height = 1920, 1080
local ratio = osd_width / osd_height
@@ -109,23 +68,37 @@ function render()
end
local ass_events = {}
local max_display = math.max(options.scrolltime, options.fixtime)
local window_start = pos - max_display
for _, event in ipairs(COMMENTS) do
if pos >= event.start_time + delay and pos <= event.end_time + delay then
local text = parse_comment(event, pos, height, delay)
-- 跳过已结束的弹幕
local lo = binary_search(COMMENTS, window_start, function(item) return item.start_time end)
local re_entity = "&#%d+;"
local re_fs = "\\fs(%d+)"
local ass_prefix = string.format("{\\rDefault\\fn%s\\fs%d\\c&HFFFFFF&\\alpha&H%s\\bord%s\\shad%s\\b%s\\q2}",
fontname, fontsize, alpha, options.outline, options.shadow, options.bold and "1" or "0")
for i = lo, #COMMENTS do
local event = COMMENTS[i]
if not event then break end
if event.start_time > pos then break end -- 后续弹幕提前退出
if event.end_time >= pos then
local text = realtime_position_text(event, pos, height * options.displayarea)
if text then
text = text:gsub("&#%d+;","")
text = text:gsub(re_entity, "")
end
if text and text:match("\\fs%d+") then
text = text:gsub("\\fs(%d+)", function(size)
return string.format("\\fs%d", size * 1.5)
if text and text:match(re_fs) then
text = text:gsub(re_fs, function(size)
local n = tonumber(size) or 0
return string.format("\\fs%d", math.floor(n * 1.5))
end)
end
-- 构建 ASS 字符串
local ass_text = text and string.format("{\\rDefault\\fn%s\\fs%d\\c&HFFFFFF&\\alpha&H%s\\bord%s\\shad%s\\b%s\\q2}%s",
fontname, fontsize, alpha, options.outline, options.shadow, options.bold and "1" or "0", text)
local ass_text = text and (ass_prefix .. text)
table.insert(ass_events, ass_text)
end
@@ -137,16 +110,29 @@ function render()
overlay:update()
end
local timer = mp.add_periodic_timer(INTERVAL, render, true)
function parse_danmaku(ass_file_path, from_menu, no_osd)
parse_ass_events(ass_file_path, function(err, events)
COMMENTS = events
if err then
msg.error("ASS 解析错误: " .. err)
return
local function time_pos_callback(_, time_pos)
if time_pos then
render(time_pos)
else
overlay:remove()
end
end
local function start_time_observer()
if not time_pos_observer_active then
mp.observe_property('time-pos', 'number', time_pos_callback)
time_pos_observer_active = true
end
end
local function stop_time_observer()
if time_pos_observer_active then
mp.unobserve_property(time_pos_callback)
time_pos_observer_active = false
end
end
function render_danmaku(from_menu, no_osd)
if ENABLED and (from_menu or get_danmaku_visibility()) then
if not no_osd then
show_loaded(true)
@@ -157,7 +143,6 @@ function parse_danmaku(ass_file_path, from_menu, no_osd)
show_message("")
hide_danmaku_func()
end
end)
end
local function filter_state(label, name)
@@ -172,9 +157,11 @@ local function filter_state(label, name)
end
function show_danmaku_func()
mp.set_property_bool(HAS_DANMAKU, true)
set_danmaku_visibility(true)
render()
if not pause then
timer:resume()
start_time_observer()
end
if options.vf_fps then
local display_fps = mp.get_property_number('display-fps')
@@ -189,7 +176,9 @@ function show_danmaku_func()
end
function hide_danmaku_func()
timer:kill()
stop_time_observer()
mp.set_property_bool(HAS_DANMAKU, false)
set_danmaku_visibility(false)
overlay:remove()
if filter_state("danmaku") then
mp.commandv("vf", "remove", "@danmaku")
@@ -221,33 +210,15 @@ end
mp.observe_property('osd-width', 'number', function(_, value) osd_width = value or osd_width end)
mp.observe_property('osd-height', 'number', function(_, value) osd_height = value or osd_height end)
mp.observe_property('display-fps', 'number', function(_, value)
if value ~= nil then
local interval = 1 / value / 10
if interval > INTERVAL then
timer:kill()
timer = mp.add_periodic_timer(interval, render, true)
if ENABLED then
timer:resume()
end
else
timer:kill()
timer = mp.add_periodic_timer(INTERVAL, render, true)
if ENABLED then
timer:resume()
end
end
end
end)
mp.observe_property('pause', 'bool', function(_, value)
if value ~= nil then
pause = value
end
if ENABLED then
if pause then
timer:kill()
stop_time_observer()
elseif COMMENTS ~= nil then
timer:resume()
start_time_observer()
end
end
end)
@@ -263,7 +234,7 @@ end)
mp.add_hook("on_unload", 50, function()
COMMENTS, DELAY = nil, 0
timer:kill()
stop_time_observer()
overlay:remove()
mp.set_property_native(DELAY_PROPERTY, 0)
if filter_state("danmaku") then
@@ -271,13 +242,10 @@ mp.add_hook("on_unload", 50, function()
end
local files_to_remove = {
file1 = utils.join_path(DANMAKU_PATH, "danmaku-" .. PID .. ".json"),
file2 = utils.join_path(DANMAKU_PATH, "danmaku-" .. PID .. ".ass"),
file3 = utils.join_path(DANMAKU_PATH, "temp-" .. PID .. ".mp4"),
file4 = utils.join_path(DANMAKU_PATH, "bahamut-" .. PID .. ".json")
file1 = utils.join_path(DANMAKU_PATH, "temp-" .. PID .. ".mp4"),
}
if options.save_danmaku and file_exists(files_to_remove.file2) then
if options.save_danmaku then
save_danmaku(true)
end
@@ -287,10 +255,5 @@ mp.add_hook("on_unload", 50, function()
end
end
for _, source in pairs(DANMAKU.sources) do
if source.fname and source.from and source.from ~= "user_local" and file_exists(source.fname) then
os.remove(source.fname)
end
end
DANMAKU = {sources = {}, count = 1}
end)
+125 -11
View File
@@ -2,8 +2,10 @@ local msg = require('mp.msg')
local utils = require("mp.utils")
local repo = "Tony15246/uosc_danmaku"
local zip_file = utils.join_path(os.getenv("TEMP") or "/tmp/", "uosc_danmaku.zip")
local local_version = VERSION or "0.0.0"
local platform = mp.get_property("platform")
local function version_greater(v1, v2)
local function parse(ver)
@@ -29,25 +31,137 @@ local function get_latest_release(repo)
})
if not res or res.status ~= 0 then return nil end
local tag = res.stdout:match([["tag_name"%s*:%s*"([^"]+)"]])
return tag
local zip_url = res.stdout:match([["browser_download_url"%s*:%s*"([^"]+%.zip)"]])
return tag, zip_url
end
local function escape_ps(str)
return tostring(str):gsub("'", "''")
end
local function unzip_overwrite(zip_file)
local outpath = mp.get_script_directory()
-- 定义临时目录路径,用于安全更新
local tmpdir = utils.join_path(
(platform == "windows" and (os.getenv("TEMP") or "C:\\Windows\\Temp") or "/tmp"),
"uosc_update_" .. tostring(os.time())
)
local cmd_unzip = {}
msg.info("创建临时目录并解压: " .. tmpdir)
if platform == "windows" then
-- PowerShell: Expand-Archive (会自动创建目标目录)
local ps_script = string.format(
"Expand-Archive -LiteralPath '%s' -DestinationPath '%s' -Force",
escape_ps(zip_file),
escape_ps(tmpdir)
)
cmd_unzip = { "powershell", "-NoProfile", "-Command", ps_script }
else
-- Unix: unzip
cmd_unzip = { "unzip", "-o", zip_file, "-d", tmpdir }
end
local res = mp.command_native({
name = "subprocess",
args = cmd_unzip,
capture_stdout = true,
capture_stderr = true,
playback_only = false,
})
if not res or res.status ~= 0 then
msg.error("❌ 解压失败:\n" .. (res and (res.stdout .. res.stderr) or "未知错误"))
-- 清理残留的临时目录
if platform == "windows" then
mp.command_native({
name = "subprocess",
args = {"powershell", "-NoProfile", "-Command", "Remove-Item -LiteralPath '"..escape_ps(tmpdir).."' -Recurse -Force"}
})
else
os.execute("rm -rf \"" .. tmpdir .. "\"")
end
return false
end
msg.info("解压成功,准备替换旧目录...")
local cmd_swap = {}
if platform == "windows" then
-- Windows: 在一个 PowerShell 实例中执行删除和移动
local ps_swap = string.format(
"Remove-Item -LiteralPath '%s' -Recurse -Force -ErrorAction SilentlyContinue; Move-Item -LiteralPath '%s' -Destination '%s' -Force",
escape_ps(outpath),
escape_ps(tmpdir),
escape_ps(outpath)
)
cmd_swap = { "powershell", "-NoProfile", "-Command", ps_swap }
else
-- Unix: rm && mv
cmd_swap = { "sh", "-c", string.format("rm -rf \"%s\" && mv \"%s\" \"%s\"", outpath, tmpdir, outpath) }
end
local res_swap = mp.command_native({
name = "subprocess",
args = cmd_swap,
capture_stdout = true,
capture_stderr = true,
playback_only = false,
})
if not res_swap or res_swap.status ~= 0 then
msg.error("❌ 替换目录失败:\n" .. (res_swap and (res_swap.stdout .. res_swap.stderr) or ""))
return false
end
msg.info("更新完成")
return true
end
-- 仅检查并提示新版本,不自动下载/覆盖(避免 rm -rf 破坏配置)
function check_for_update()
local latest_version = get_latest_release(repo)
if not latest_version then
show_message("无法获取最新版本信息")
msg.warn("无法获取最新版本信息")
local latest_version, download_url = get_latest_release(repo)
if not latest_version or not download_url then
show_message("无法获取最新版本信息")
msg.warn("无法获取最新版本信息")
return
end
if not version_greater(latest_version, local_version) then
show_message("uosc_danmaku 已是最新版本 (" .. local_version .. ")")
msg.info("uosc_danmaku 已是最新版本 (" .. local_version .. ")")
show_message(" 已是最新版本 ("..local_version..")")
msg.info("✅ 已是最新版本")
return
end
local update_url = "https://github.com/" .. repo .. "/releases/tag/" .. latest_version
show_message("uosc_danmaku 有新版本: " .. latest_version .. " (当前: " .. local_version .. ")\n请手动更新: " .. update_url)
msg.info("uosc_danmaku 新版本: " .. latest_version .. " 下载地址: " .. update_url)
show_message("⬇️ 发现新版本: " .. latest_version .. ",正在下载...")
msg.info("⬇️ 发现新版本: " .. latest_version .. ",地址: " .. download_url)
local cmd = { "curl", "-L", "-o", zip_file, download_url }
local res = mp.command_native({
name = "subprocess",
args = cmd,
capture_stdout = true,
capture_stderr = true,
playback_only = false,
})
if not res or res.status ~= 0 then
show_message("❌ 下载失败!")
msg.warn("❌ 下载失败!")
return
end
show_message("📦 下载完成,开始解压覆盖...")
msg.info("📦 下载完成,开始解压覆盖...")
if unzip_overwrite(zip_file) then
os.remove(zip_file)
show_message("✅ 更新成功!请重启 mpv 以应用更新,当前版本为:" .. latest_version)
msg.info("✅ 更新成功,当前版本为:" .. latest_version)
else
os.remove(zip_file)
show_message("❌ 解压失败!请查看控制台日志")
msg.warn("❌ 解压失败!")
end
end
+127 -36
View File
@@ -1,4 +1,5 @@
local utils = require("mp.utils")
local unpack = unpack or table.unpack
-- from http://lua-users.org/wiki/LuaUnicode
local UTF8_PATTERN = '[%z\1-\127\194-\244][\128-\191]*'
@@ -210,6 +211,31 @@ function hex_to_char(x)
return string.char(tonumber(x, 16))
end
function hex_to_int_color(hex_color)
-- 移除颜色代码中的'#'字符
hex_color = hex_color:sub(2) -- 只保留颜色代码部分
-- 提取R, G, B的十六进制值并转为整数
local r = tonumber(hex_color:sub(1, 2), 16)
local g = tonumber(hex_color:sub(3, 4), 16)
local b = tonumber(hex_color:sub(5, 6), 16)
-- 计算32位整数值
local color_int = (r * 256 * 256) + (g * 256) + b
return color_int
end
local function get_type_from_position(position)
if position == 0 then
return 1
end
if position == 1 then
return 4
end
return 5
end
-- url编码转换
function url_encode(str)
-- 将非安全字符转换为百分号编码
@@ -318,6 +344,67 @@ function file_exists(path)
return false
end
function binary_search(tbl, target, key)
if not tbl or #tbl == 0 then return 1 end
key = key or function(x) return x end
local lo, hi = 1, #tbl
local res = #tbl + 1
while lo <= hi do
local mid = math.floor((lo + hi) / 2)
local v = tbl[mid]
local val = key(v)
if val >= target then
res = mid
hi = mid - 1
else
lo = mid + 1
end
end
return res
end
function new_min_heap()
local h = {}
local function swap(i, j)
h[i], h[j] = h[j], h[i]
end
local function up(i)
while i > 1 do
local p = math.floor(i/2)
if h[p].time <= h[i].time then break end
swap(p, i)
i = p
end
end
local function down(i)
local n = #h
while true do
local l = i * 2
local r = l + 1
local smallest = i
if l <= n and h[l].time < h[smallest].time then smallest = l end
if r <= n and h[r].time < h[smallest].time then smallest = r end
if smallest == i then break end
swap(i, smallest)
i = smallest
end
end
local function push(node)
h[#h + 1] = node
up(#h)
end
local function pop()
if #h == 0 then return nil end
local root = h[1]
if #h == 1 then h[1] = nil; return root end
h[1] = h[#h]
h[#h] = nil
down(1)
return root
end
return { push = push, pop = pop, size = function() return #h end }
end
function is_writable(path)
local file = io.open(path, "w")
if file then
@@ -386,10 +473,19 @@ local function split_by_numbers(filename)
return parts
end
-- 识别匹配前后剧集
local function compare_filenames(fname1, fname2)
-- 识别匹配前后剧集并提取集数
local function get_series_episodes(fname1, fname2)
local parts1 = split_by_numbers(fname1)
local parts2 = split_by_numbers(fname2)
local title1 = format_filename(fname1)
local title2 = format_filename(fname2)
if title1 and title2 then
local media_title1, season1, episode1 = title1:match("^(.-)%s*[sS](%d+)[eE](%d+)")
local media_title2, season2, episode2 = title2:match("^(.-)%s*[sS](%d+)[eE](%d+)")
if season1 and season2 and season1 ~= season2 then
return nil, nil
end
end
local min_len = math.min(#parts1, #parts2)
@@ -400,7 +496,7 @@ local function compare_filenames(fname1, fname2)
-- 比较数字前的字符是否相同
if part1.pre ~= part2.pre then
return false
return nil, nil
end
-- 比较数字部分
@@ -410,11 +506,36 @@ local function compare_filenames(fname1, fname2)
-- 比较数字后的字符是否相同
if part1.post ~= part2.post then
return false
return nil, nil
end
end
return false
return nil, nil
end
-- 获取当前文件名所包含的集数
function get_episode_number(filename, fname)
-- 尝试对比记录文件名来获取当前集数
if fname then
return get_series_episodes(fname, filename)
end
local thin_space = string.char(0xE2, 0x80, 0x89)
filename = filename:gsub(thin_space, " ")
local title = format_filename(filename)
if title then
local media_title, season, episode = title:match("^(.-)%s*[sS](%d+)[eE](%d+)")
if season then
return tonumber(episode)
else
local media_title, episode = title:match("^(.-)%s*[eE](%d+)")
if episode then
return tonumber(episode)
end
end
end
return nil
end
-- 规范化路径
@@ -520,36 +641,6 @@ function parse_title()
return title_replace(title), season, episode
end
-- 获取当前文件名所包含的集数
function get_episode_number(filename, fname)
-- 尝试对比记录文件名来获取当前集数
if fname then
local episode_num1, episode_num2 = compare_filenames(fname, filename)
if episode_num1 and episode_num2 then
return episode_num1, episode_num2
else
return nil, nil
end
end
local thin_space = string.char(0xE2, 0x80, 0x89)
filename = filename:gsub(thin_space, " ")
local title = format_filename(filename)
if title then
local media_title, season, episode = title:match("^(.-)%s*[sS](%d+)[eE](%d+)")
if season then
return tonumber(episode)
else
local media_title, episode = title:match("^(.-)%s*[eE](%d+)")
if episode then
return tonumber(episode)
end
end
end
return nil
end
local CHINESE_NUM_MAP = {
[""] = 0, [""] = 1, [""] = 2, [""] = 3, [""] = 4,
[""] = 5, [""] = 6, [""] = 7, [""] = 8, [""] = 9,
@@ -644,7 +735,7 @@ function call_cmd_async(args, callback)
name = 'subprocess',
capture_stderr = true,
capture_stdout = true,
playback_only = false,
playback_only = true,
args = args,
}, function(success, result, error)
if not success or not result or result.status ~= 0 then