update
This commit is contained in:
@@ -49,8 +49,21 @@ icc/ # ICC 色彩配置文件
|
|||||||
| Anime4K | 动画 | 低 |
|
| Anime4K | 动画 | 低 |
|
||||||
| SSIM | 低性能需求 | 低 |
|
| SSIM | 低性能需求 | 低 |
|
||||||
|
|
||||||
Ani4K / AniSD 着色器(`shaders/Ani4K/`)来自 [Sirosky/Upscale-Hub](https://github.com/Sirosky/Upscale-Hub),手动管理,不由 manager.lua 更新。
|
其中:
|
||||||
|
|
||||||
|
- 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)。
|
见 [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` 对照脚本源码更新
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -402,4 +402,4 @@
|
|||||||
"zui": "最嘴罪醉咀蕞䮔厜璻蟕晬嗺噿嶵㠑嶊冣㝡䘹祽鋷錊酻酔樶檌㰎栬槜檇辠䘒稡纗絊",
|
"zui": "最嘴罪醉咀蕞䮔厜璻蟕晬嗺噿嶵㠑嶊冣㝡䘹祽鋷錊酻酔樶檌㰎栬槜檇辠䘒稡纗絊",
|
||||||
"zun": "尊遵樽鳟撙墫噂嶟鶎銌鱒鐏捘罇鷷僔繜譐",
|
"zun": "尊遵樽鳟撙墫噂嶟鶎銌鱒鐏捘罇鷷僔繜譐",
|
||||||
"zuo": "作做坐左座昨佐琢撮柞唑祚捽阼胙嘬怍酢笮葄葃蓙䔘苲莋㸲㝾䞰䎰咗㘀㘴岝岞䝫糳袏鈼㭮稓穝秨筰㛗㑅飵侳繓䋏"
|
"zuo": "作做坐左座昨佐琢撮柞唑祚捽阼胙嘬怍酢笮葄葃蓙䔘苲莋㸲㝾䞰䎰咗㘀㘴岝岞䝫糳袏鈼㭮稓穝秨筰㛗㑅飵侳繓䋏"
|
||||||
}
|
}
|
||||||
@@ -36,4 +36,4 @@ function BufferingIndicator:render()
|
|||||||
return ass
|
return ass
|
||||||
end
|
end
|
||||||
|
|
||||||
return BufferingIndicator
|
return BufferingIndicator
|
||||||
@@ -40,7 +40,7 @@ function Button:render()
|
|||||||
|
|
||||||
local ass = assdraw.ass_new()
|
local ass = assdraw.ass_new()
|
||||||
local is_clickable = self.is_clickable and self.on_click ~= nil
|
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 foreground = self.active and self.background or self.foreground
|
||||||
local background = self.active and self.foreground or self.background
|
local background = self.active and self.foreground or self.background
|
||||||
local background_opacity = self.active and 1 or config.opacity.controls
|
local background_opacity = self.active and 1 or config.opacity.controls
|
||||||
@@ -97,4 +97,4 @@ function Button:render()
|
|||||||
return ass
|
return ass
|
||||||
end
|
end
|
||||||
|
|
||||||
return Button
|
return Button
|
||||||
@@ -37,17 +37,17 @@ function Controls:init_options()
|
|||||||
-- Serialize control elements
|
-- Serialize control elements
|
||||||
local shorthands = {
|
local shorthands = {
|
||||||
['play-pause'] = 'cycle:pause:pause:no/yes=play_arrow?' .. t('Play/Pause'),
|
['play-pause'] = 'cycle:pause:pause:no/yes=play_arrow?' .. t('Play/Pause'),
|
||||||
menu = 'command:menu_book:script-binding uosc/menu-blurred?' .. t('Menu'),
|
menu = 'command:menu:script-binding uosc/menu-blurred?' .. t('Menu'),
|
||||||
subtitles = 'command:closed_caption:script-binding uosc/subtitles#sub>1?' .. t('Subtitles'),
|
subtitles = 'command:subtitles:script-binding uosc/subtitles#sub>0?' .. t('Subtitles'),
|
||||||
audio = 'command:graphic_eq:script-binding uosc/audio#audio>1?' .. t('Audio'),
|
audio = 'command:graphic_eq:script-binding uosc/audio#audio>1?' .. t('Audio'),
|
||||||
['audio-device'] = 'command:speaker:script-binding uosc/audio-device?' .. t('Audio device'),
|
['audio-device'] = 'command:speaker:script-binding uosc/audio-device?' .. t('Audio device'),
|
||||||
video = 'command:smart_display:script-binding uosc/video#video>1?' .. t('Video'),
|
video = 'command:theaters:script-binding uosc/video#video>1?' .. t('Video'),
|
||||||
playlist = 'command:list_alt:script-binding uosc/playlist#playlist>1?' .. t('Playlist'),
|
playlist = 'command:list_alt:script-binding uosc/playlist?' .. t('Playlist'),
|
||||||
chapters = 'command:library_books:script-binding uosc/chapters#chapters>1?' .. t('Chapters'),
|
chapters = 'command:bookmark:script-binding uosc/chapters#chapters>0?' .. t('Chapters'),
|
||||||
['editions'] = 'command:movie_filter:script-binding uosc/editions#editions>1?' .. t('Editions'),
|
['editions'] = 'command:bookmarks:script-binding uosc/editions#editions>1?' .. t('Editions'),
|
||||||
['stream-quality'] = 'command:high_quality:script-binding uosc/stream-quality?' .. t('Stream quality'),
|
['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'),
|
['open-file'] = 'command:file_open:script-binding uosc/open-file?' .. t('Open file'),
|
||||||
['items'] = 'command:list_alt:script-binding uosc/items#playlist>1?' .. t('Playlist/Files'),
|
['items'] = 'command:list_alt:script-binding uosc/items?' .. t('Playlist/Files'),
|
||||||
prev = 'command:arrow_back_ios:script-binding uosc/prev?' .. t('Previous'),
|
prev = 'command:arrow_back_ios:script-binding uosc/prev?' .. t('Previous'),
|
||||||
next = 'command:arrow_forward_ios:script-binding uosc/next?' .. t('Next'),
|
next = 'command:arrow_forward_ios:script-binding uosc/next?' .. t('Next'),
|
||||||
first = 'command:first_page:script-binding uosc/first?' .. t('First'),
|
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
|
for _, track in ipairs(value) do if track.type == prop then count = count + 1 end end
|
||||||
return count
|
return count
|
||||||
end
|
end
|
||||||
elseif prop == 'playlist' then
|
|
||||||
observable_name = 'playlist-count'
|
|
||||||
serializer = function(count) return count end
|
|
||||||
else
|
else
|
||||||
local parts = split(prop, '@')
|
local parts = split(prop, '@')
|
||||||
-- Support both new `prop@owner` and old `@prop` syntaxes
|
-- Support both new `prop@owner` and old `@prop` syntaxes
|
||||||
@@ -428,4 +425,4 @@ function Controls:on_options()
|
|||||||
self:init_options()
|
self:init_options()
|
||||||
end
|
end
|
||||||
|
|
||||||
return Controls
|
return Controls
|
||||||
@@ -32,4 +32,4 @@ function Curtain:render()
|
|||||||
return ass
|
return ass
|
||||||
end
|
end
|
||||||
|
|
||||||
return Curtain
|
return Curtain
|
||||||
@@ -83,4 +83,4 @@ function CycleButton:init(id, props)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
return CycleButton
|
return CycleButton
|
||||||
@@ -262,4 +262,4 @@ function Element:create_action(fn)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
return Element
|
return Element
|
||||||
@@ -48,14 +48,14 @@ function Elements:update_proximities()
|
|||||||
element:update_proximity()
|
element:update_proximity()
|
||||||
end
|
end
|
||||||
|
|
||||||
if element.proximity_raw == 0 then
|
if element.proximity_raw <= 0 then
|
||||||
-- Mouse entered element area
|
-- Mouse entered element area
|
||||||
if previous_proximity_raw ~= 0 then
|
if previous_proximity_raw > 0 then
|
||||||
mouse_enter_elements[#mouse_enter_elements + 1] = element
|
mouse_enter_elements[#mouse_enter_elements + 1] = element
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
-- Mouse left element area
|
-- Mouse left element area
|
||||||
if previous_proximity_raw == 0 then
|
if previous_proximity_raw <= 0 then
|
||||||
mouse_leave_elements[#mouse_leave_elements + 1] = element
|
mouse_leave_elements[#mouse_leave_elements + 1] = element
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -122,7 +122,7 @@ function Elements:proximity_trigger(name, ...)
|
|||||||
for i = #self._all, 1, -1 do
|
for i = #self._all, 1, -1 do
|
||||||
local element = self._all[i]
|
local element = self._all[i]
|
||||||
if element.enabled then
|
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
|
if element:trigger(name, ...) == 'stop_propagation' then break end
|
||||||
end
|
end
|
||||||
if element:trigger('global_' .. name, ...) == 'stop_propagation' then break end
|
if element:trigger('global_' .. name, ...) == 'stop_propagation' then break end
|
||||||
@@ -149,4 +149,4 @@ end
|
|||||||
function Elements:has(id) return self[id] ~= nil end
|
function Elements:has(id) return self[id] ~= nil end
|
||||||
function Elements:ipairs() return ipairs(self._all) end
|
function Elements:ipairs() return ipairs(self._all) end
|
||||||
|
|
||||||
return Elements
|
return Elements
|
||||||
@@ -33,4 +33,4 @@ function ManagedButton:update(data)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
return ManagedButton
|
return ManagedButton
|
||||||
+110
-118
@@ -309,7 +309,7 @@ function Menu:update_content_dimensions()
|
|||||||
|
|
||||||
for _, menu in ipairs(self.all) do
|
for _, menu in ipairs(self.all) do
|
||||||
title_opts.bold, title_opts.italic = true, false
|
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
|
-- Estimate width of a widest item
|
||||||
for _, item in ipairs(menu.items) do
|
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
|
if estimated_width > max_width then max_width = estimated_width end
|
||||||
end
|
end
|
||||||
|
|
||||||
menu.max_width = max_width + 2 * self.padding
|
menu.max_width = max_width
|
||||||
end
|
end
|
||||||
|
|
||||||
self:update_dimensions()
|
self:update_dimensions()
|
||||||
@@ -336,20 +336,21 @@ function Menu:update_dimensions()
|
|||||||
-- and dumb titles with no search inputs. It could use a refactor.
|
-- and dumb titles with no search inputs. It could use a refactor.
|
||||||
local margin = round(self.item_height / 2)
|
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 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 width_available = display.width - margin * 2 - self.padding * 2 - external_buttons_reserve
|
||||||
local height_available = display.height - margin * 2
|
local height_available = display.height - margin * 2 - self.padding * 2
|
||||||
local min_width = math.min(self.min_width, width_available)
|
local min_width = math.min(self.min_width, width_available)
|
||||||
|
|
||||||
for _, menu in ipairs(self.all) do
|
for _, menu in ipairs(self.all) do
|
||||||
local width = math.max(menu.search and menu.search.max_width or 0, menu.max_width)
|
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))
|
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 footnote_height = self.font_size * 1.5
|
||||||
local max_height = height_available - title_height - footnote_height
|
local max_height = height_available - title_height - footnote_height
|
||||||
local content_height = self.scroll_step * #menu.items
|
local content_height = self.scroll_step * #menu.items
|
||||||
menu.height = math.min(content_height - self.item_spacing, max_height)
|
menu.height = math.min(content_height - self.item_spacing, max_height)
|
||||||
menu.top = clamp(
|
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,
|
menu.search and math.min(menu.search.min_top, menu.search.source.top) or height_available,
|
||||||
round((height_available - menu.height + title_height) / 2)
|
round((height_available - menu.height + title_height) / 2)
|
||||||
)
|
)
|
||||||
@@ -364,10 +365,13 @@ function Menu:update_dimensions()
|
|||||||
self:update_coordinates()
|
self:update_coordinates()
|
||||||
end
|
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()
|
function Menu:update_coordinates()
|
||||||
local ax = round((display.width - self.current.width) / 2) + self.offset_x
|
local ax = round((display.width - self.current.width) / 2 - self.padding) + self.offset_x
|
||||||
self:set_coordinates(ax, self.current.top, ax + self.current.width, self.current.top + self.current.height)
|
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
|
end
|
||||||
|
|
||||||
function Menu:reset_navigation()
|
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:on_options() self:update_content_dimensions() end
|
||||||
|
|
||||||
function Menu:handle_cursor_down()
|
function Menu:handle_cursor_down()
|
||||||
if self.proximity_raw == 0 then
|
if self.proximity_raw <= 0 then
|
||||||
self.drag_last_y = cursor.y
|
self.drag_last_y = cursor.y
|
||||||
self.current.fling = nil
|
self.current.fling = nil
|
||||||
else
|
else
|
||||||
@@ -696,7 +700,7 @@ end
|
|||||||
|
|
||||||
---@param shortcut? Shortcut
|
---@param shortcut? Shortcut
|
||||||
function Menu:handle_cursor_up(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)
|
self:activate_selected_item(shortcut, true)
|
||||||
end
|
end
|
||||||
if self.is_dragging then
|
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
|
if ligature_conv_title:find(query, 1, true) then
|
||||||
match = true
|
match = true
|
||||||
score = 1000
|
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
|
if pos then
|
||||||
ass_safe_title = highlight_match(item.title, pos, font_color, bold)
|
ass_safe_title = highlight_match(item.title, pos, font_color, bold)
|
||||||
end
|
end
|
||||||
elseif initials_conv_title:find(query, 1, true) then
|
elseif initials_conv_title:find(query, 1, true) then
|
||||||
match = true
|
match = true
|
||||||
score = 900
|
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
|
if pos then
|
||||||
ass_safe_title = highlight_match(item.title, pos, font_color, bold)
|
ass_safe_title = highlight_match(item.title, pos, font_color, bold)
|
||||||
end
|
end
|
||||||
@@ -1371,7 +1375,6 @@ function Menu:render()
|
|||||||
cursor:zone('wheel_up', self, function() self:handle_wheel_up() end)
|
cursor:zone('wheel_up', self, function() self:handle_wheel_up() end)
|
||||||
|
|
||||||
local ass = assdraw.ass_new()
|
local ass = assdraw.ass_new()
|
||||||
local spacing = self.item_padding
|
|
||||||
local icon_size = self.font_size
|
local icon_size = self.font_size
|
||||||
|
|
||||||
---@param menu MenuStack
|
---@param menu MenuStack
|
||||||
@@ -1380,37 +1383,43 @@ function Menu:render()
|
|||||||
local function draw_menu(menu, x, pos)
|
local function draw_menu(menu, x, pos)
|
||||||
local is_current, is_parent, is_submenu = pos == 0, pos < 0, pos > 0
|
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 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 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 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 end_index = math.ceil((menu.scroll_y + menu.height) / self.scroll_step)
|
||||||
local menu_rect = {
|
local bg_rect = {
|
||||||
ax = ax,
|
ax = x,
|
||||||
ay = ay - (draw_title and self.scroll_step + self.padding or 0) - self.padding,
|
ay = content_rect.ay - (draw_title and self.scroll_step or 0) - self.padding,
|
||||||
bx = bx,
|
bx = content_rect.bx + self.padding,
|
||||||
by = by + 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
|
local blur_action_index = self.mouse_nav and menu.action_index ~= nil
|
||||||
|
|
||||||
-- Background
|
-- 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,
|
color = bg,
|
||||||
opacity = menu_opacity * config.opacity.menu,
|
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
|
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
|
end
|
||||||
|
|
||||||
-- Scrollbar
|
-- Scrollbar
|
||||||
if menu.scroll_height > 0 then
|
if menu.scroll_height > 0 then
|
||||||
local groove_height = menu.height - 2
|
local groove_height = menu.height - 2
|
||||||
local thumb_height = math.max((menu.height / (menu.scroll_height + menu.height)) * groove_height, 40)
|
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 thumb_y = content_rect.ay + 1 + ((menu.scroll_y / menu.scroll_height) * (groove_height - thumb_height))
|
||||||
local sax = bx - round(self.scrollbar_size / 2)
|
local sax = content_rect.bx - round(self.scrollbar_size / 2)
|
||||||
local sbx = sax + self.scrollbar_size
|
local sbx = sax + self.scrollbar_size
|
||||||
ass:rect(sax, thumb_y, sbx, thumb_y + thumb_height, {color = fg, opacity = menu_opacity * 0.8})
|
ass:rect(sax, thumb_y, sbx, thumb_y + thumb_height, {color = fg, opacity = menu_opacity * 0.8})
|
||||||
end
|
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_rect, current_item = nil, is_current and menu.selected_index and menu.items[menu.selected_index]
|
||||||
local submenu_is_hovered = false
|
local submenu_is_hovered = false
|
||||||
if current_item and current_item.items then
|
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)
|
cursor:zone('primary_down', submenu_rect, self:create_action(function(shortcut)
|
||||||
self:activate_selected_item(shortcut, true)
|
self:activate_selected_item(shortcut, true)
|
||||||
end))
|
end))
|
||||||
@@ -1432,21 +1441,32 @@ function Menu:render()
|
|||||||
|
|
||||||
if not item then break end
|
if not item then break end
|
||||||
|
|
||||||
local item_ax = menu_rect.ax + self.padding
|
local item_ay = content_rect.ay - menu.scroll_y + self.scroll_step * (index - 1)
|
||||||
local item_bx = menu_rect.bx - self.padding
|
|
||||||
local item_ay = ay - menu.scroll_y + self.scroll_step * (index - 1)
|
|
||||||
local item_by = item_ay + self.item_height
|
local item_by = item_ay + self.item_height
|
||||||
local item_center_y = item_ay + (self.item_height / 2)
|
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 item_clip = (item_ay < content_rect.ay or item_by > content_rect.by) and scroll_clip or nil
|
||||||
local content_ax, content_bx = ax + self.padding + spacing, bx - self.padding - spacing
|
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 is_selected = menu.selected_index == index
|
||||||
local item_rect_hitbox = {
|
local item_rect_hitbox = {
|
||||||
ax = item_ax,
|
ax = content_rect.ax,
|
||||||
ay = math.max(item_ay, menu_rect.ay),
|
ay = math.max(item_ay, bg_rect.ay),
|
||||||
bx = menu_rect.bx + (item.items and self.gap or -self.padding), -- to bridge the gap with cursor
|
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, menu_rect.by),
|
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 has_background = is_selected or item.active
|
||||||
local next_item = menu.items[index + 1]
|
local next_item = menu.items[index + 1]
|
||||||
local next_is_active = next_item and next_item.active
|
local next_is_active = next_item and next_item.active
|
||||||
@@ -1458,22 +1478,23 @@ function Menu:render()
|
|||||||
if action then selected_action = action end
|
if action then selected_action = action end
|
||||||
|
|
||||||
-- Separator
|
-- Separator
|
||||||
if item_by < by and ((not has_background and not next_has_background) or item.separator) then
|
if item_by < content_rect.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
|
local ay, by = item_by, item_by + self.separator_size
|
||||||
if has_background then
|
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
|
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
|
end
|
||||||
ass:rect(ax + spacing, separator_ay, bx - spacing, separator_by, {
|
ass:rect(
|
||||||
color = fg, opacity = menu_opacity * (item.separator and 0.13 or 0.04),
|
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
|
end
|
||||||
|
|
||||||
-- Background
|
-- Background
|
||||||
local highlight_opacity = 0 + (item.active and 0.8 or 0) + (is_selected and 0.15 or 0)
|
local highlight_opacity = 0 + (item.active and 0.8 or 0) + (is_selected and 0.15 or 0)
|
||||||
if highlight_opacity > 0 then
|
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,
|
radius = state.radius,
|
||||||
color = fg,
|
color = fg,
|
||||||
opacity = highlight_opacity * menu_opacity,
|
opacity = highlight_opacity * menu_opacity,
|
||||||
@@ -1495,9 +1516,10 @@ function Menu:render()
|
|||||||
actions_rect = {
|
actions_rect = {
|
||||||
ay = item_ay + margin,
|
ay = item_ay + margin,
|
||||||
by = item_by - 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
|
actions_rect.ax = actions_rect.bx
|
||||||
|
|
||||||
for i = 1, #actions, 1 do
|
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
|
rect.ay, rect.by, rect.bx = item_ay, item_ay + self.scroll_step, rect.bx + margin
|
||||||
|
|
||||||
-- Select action on cursor hover
|
-- 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)
|
cursor:zone('primary_down', rect, self:create_action(function(shortcut)
|
||||||
self:activate_selected_item(shortcut, true)
|
self:activate_selected_item(shortcut, true)
|
||||||
end))
|
end))
|
||||||
@@ -1553,17 +1575,18 @@ function Menu:render()
|
|||||||
if is_selected and not selected_action then
|
if is_selected and not selected_action then
|
||||||
local size = round(2 * state.scale)
|
local size = round(2 * state.scale)
|
||||||
local v_padding = math.min(state.radius, math.ceil(self.item_height / 3))
|
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,
|
ass:rect(
|
||||||
item_by - v_padding, {
|
content_rect.ax - size - 1, item_ay + v_padding,
|
||||||
radius = 1 * state.scale, color = fg, opacity = menu_opacity, clip = item_clip,
|
content_rect.ax - 1, item_by - v_padding,
|
||||||
})
|
{radius = 1 * state.scale, color = fg, opacity = menu_opacity, clip = item_clip}
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Icon
|
-- Icon
|
||||||
if item.icon then
|
if item.icon then
|
||||||
if not actions_rect or actions_rect.is_outside then
|
if not actions_rect or actions_rect.is_outside then
|
||||||
local x = (not item.title and not item.hint and item.align == 'center')
|
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)
|
or content_bx - (icon_size / 2)
|
||||||
if item.icon == 'spinner' then
|
if item.icon == 'spinner' then
|
||||||
ass:spinner(x, item_center_y, icon_size * 1.5, {color = font_color, opacity = menu_opacity * 0.8})
|
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
|
||||||
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)
|
title_clip_bx = math.min(content_bx, title_clip_bx)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -1581,7 +1604,7 @@ function Menu:render()
|
|||||||
if item.hint_width > 0 then
|
if item.hint_width > 0 then
|
||||||
-- controls title & hint clipping proportional to the ratio of their widths
|
-- 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
|
-- 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 title_min = math.min(item.title_width, width * 0.5)
|
||||||
local hint_min = math.min(item.hint_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)
|
local title_ratio = item.title_width / (item.title_width + item.hint_width)
|
||||||
@@ -1594,8 +1617,9 @@ function Menu:render()
|
|||||||
-- Hint
|
-- Hint
|
||||||
if item.hint then
|
if item.hint then
|
||||||
item.ass_safe_hint = item.ass_safe_hint or ass_escape(item.hint)
|
item.ass_safe_hint = item.ass_safe_hint or ass_escape(item.hint)
|
||||||
local clip = '\\clip(' .. title_clip_bx + spacing .. ',' ..
|
local clip = '\\clip(' .. title_clip_bx + self.item_padding .. ','
|
||||||
math.max(item_ay, ay) .. ',' .. hint_clip_bx .. ',' .. math.min(item_by, by) .. ')'
|
.. 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, {
|
ass:txt(content_bx, item_center_y, 6, item.ass_safe_hint, {
|
||||||
size = self.font_size_hint,
|
size = self.font_size_hint,
|
||||||
color = font_color,
|
color = font_color,
|
||||||
@@ -1608,8 +1632,8 @@ function Menu:render()
|
|||||||
-- Title
|
-- Title
|
||||||
if item.title then
|
if item.title then
|
||||||
item.ass_safe_title = item.ass_safe_title or ass_escape(item.title)
|
item.ass_safe_title = item.ass_safe_title or ass_escape(item.title)
|
||||||
local clip = '\\clip(' .. ax .. ',' .. math.max(item_ay, ay) .. ','
|
local clip = '\\clip(' .. content_rect.ax .. ',' .. math.max(item_ay, content_rect.ay) .. ','
|
||||||
.. title_clip_bx .. ',' .. math.min(item_by, by) .. ')'
|
.. title_clip_bx .. ',' .. math.min(item_by, content_rect.by) .. ')'
|
||||||
local title_x, align = content_ax, 4
|
local title_x, align = content_ax, 4
|
||||||
if item.align == 'right' then
|
if item.align == 'right' then
|
||||||
title_x, align = title_clip_bx, 6
|
title_x, align = title_clip_bx, 6
|
||||||
@@ -1626,29 +1650,12 @@ function Menu:render()
|
|||||||
clip = clip,
|
clip = clip,
|
||||||
})
|
})
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
-- Footnote / Selected action label
|
-- Footnote / Selected action label
|
||||||
if is_current and (menu.footnote or selected_action) then
|
if is_current and (menu.footnote or selected_action) then
|
||||||
local height_half = self.font_size
|
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 is_icon_hovered = false
|
||||||
local icon_hitbox = {
|
local icon_hitbox = {
|
||||||
ax = icon_x - height_half,
|
ax = icon_x - height_half,
|
||||||
@@ -1656,14 +1663,14 @@ function Menu:render()
|
|||||||
bx = icon_x + height_half,
|
bx = icon_x + height_half,
|
||||||
by = icon_y + 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 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
|
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', {
|
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,
|
color = fg, border = state.scale, border_color = bg, opacity = opacity,
|
||||||
})
|
})
|
||||||
if text then
|
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,
|
size = self.font_size,
|
||||||
color = fg,
|
color = fg,
|
||||||
border = state.scale,
|
border = state.scale,
|
||||||
@@ -1676,43 +1683,24 @@ function Menu:render()
|
|||||||
|
|
||||||
-- Menu title
|
-- Menu title
|
||||||
if draw_title then
|
if draw_title then
|
||||||
local title_height = self.item_height + self.padding - 3
|
|
||||||
local requires_submit = menu.search_debounce == 'submit'
|
local requires_submit = menu.search_debounce == 'submit'
|
||||||
local rect = {
|
local rect = {
|
||||||
ax = round(ax + spacing / 2 + self.padding),
|
ax = content_rect.ax,
|
||||||
ay = ay - self.scroll_step - self.padding * 2,
|
ay = content_rect.ay - self.scroll_step - self.separator_size - 1,
|
||||||
bx = round(bx - spacing / 2 - self.padding),
|
bx = content_rect.bx,
|
||||||
by = math.min(by, ay - self.padding),
|
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)
|
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
|
if menu.title and not menu.ass_safe_title then
|
||||||
menu.ass_safe_title = ass_escape(menu.title)
|
menu.ass_safe_title = ass_escape(menu.title)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Background
|
-- Separator
|
||||||
if menu.search then
|
ass:rect(
|
||||||
ass:rect(ax + 3, rect.ay + 3, bx - 3, rect.ay + title_height - 1, {
|
rect.ax, rect.by, rect.bx, rect.by + self.separator_size, {color = fg, opacity = menu_opacity * 0.2}
|
||||||
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})
|
|
||||||
|
|
||||||
-- Blur selection (also activates search input) when user clicks title
|
-- Blur selection (also activates search input) when user clicks title
|
||||||
if is_current then
|
if is_current then
|
||||||
@@ -1725,11 +1713,16 @@ function Menu:render()
|
|||||||
if menu.search then
|
if menu.search then
|
||||||
-- Icon
|
-- Icon
|
||||||
local icon_size, icon_opacity = self.font_size * 1.3, menu_opacity * (requires_submit and 0.5 or 1)
|
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
|
if is_current and requires_submit then
|
||||||
cursor:zone('primary_down', icon_rect, function() self:search_submit() end)
|
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
|
icon_opacity = menu_opacity
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -1778,7 +1771,10 @@ function Menu:render()
|
|||||||
-- (input is selected when `selected_index` is `nil`)
|
-- (input is selected when `selected_index` is `nil`)
|
||||||
if menu.search_debounce == 'submit' and not menu.selected_index then
|
if menu.search_debounce == 'submit' and not menu.selected_index then
|
||||||
local size_half = round(1 * state.scale)
|
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
|
end
|
||||||
local input_is_blurred = menu.search_debounce == 'submit' and menu.selected_index
|
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, {
|
ass:txt(rect.cx, rect.cy, 5, menu.ass_safe_title, {
|
||||||
size = self.font_size,
|
size = self.font_size,
|
||||||
bold = true,
|
bold = true,
|
||||||
color = bg,
|
color = bgt,
|
||||||
wrap = 2,
|
wrap = 2,
|
||||||
opacity = menu_opacity,
|
opacity = menu_opacity,
|
||||||
clip = '\\clip(' .. rect.ax .. ',' .. rect.ay .. ',' .. rect.bx .. ',' .. rect.by .. ')',
|
clip = '\\clip(' .. rect.ax .. ',' .. rect.ay .. ',' .. rect.bx .. ',' .. rect.by .. ')',
|
||||||
@@ -1801,16 +1797,12 @@ function Menu:render()
|
|||||||
end
|
end
|
||||||
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
|
if blur_action_index then
|
||||||
menu.action_index = nil
|
menu.action_index = nil
|
||||||
request_render()
|
request_render()
|
||||||
end
|
end
|
||||||
|
|
||||||
return menu_rect
|
return bg_rect
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Active menu
|
-- Active menu
|
||||||
@@ -1821,7 +1813,7 @@ function Menu:render()
|
|||||||
local parent_offset_x, parent_horizontal_index = self.ax, -1
|
local parent_offset_x, parent_horizontal_index = self.ax, -1
|
||||||
|
|
||||||
while parent_menu do
|
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)
|
draw_menu(parent_menu, parent_offset_x, parent_horizontal_index)
|
||||||
parent_horizontal_index = parent_horizontal_index - 1
|
parent_horizontal_index = parent_horizontal_index - 1
|
||||||
parent_menu = parent_menu.parent_menu
|
parent_menu = parent_menu.parent_menu
|
||||||
@@ -1830,4 +1822,4 @@ function Menu:render()
|
|||||||
return ass
|
return ass
|
||||||
end
|
end
|
||||||
|
|
||||||
return Menu
|
return Menu
|
||||||
@@ -14,7 +14,7 @@ function PauseIndicator:init()
|
|||||||
end
|
end
|
||||||
|
|
||||||
function PauseIndicator:init_options()
|
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.type = options.pause_indicator
|
||||||
self:on_prop_pause()
|
self:on_prop_pause()
|
||||||
end
|
end
|
||||||
@@ -80,4 +80,4 @@ function PauseIndicator:render()
|
|||||||
return ass
|
return ass
|
||||||
end
|
end
|
||||||
|
|
||||||
return PauseIndicator
|
return PauseIndicator
|
||||||
@@ -192,4 +192,4 @@ function Speed:render()
|
|||||||
return ass
|
return ass
|
||||||
end
|
end
|
||||||
|
|
||||||
return Speed
|
return Speed
|
||||||
@@ -18,12 +18,20 @@ function Timeline:init()
|
|||||||
self.progress_line_width = 0
|
self.progress_line_width = 0
|
||||||
self.is_hovered = false
|
self.is_hovered = false
|
||||||
self.has_thumbnail = false
|
self.has_thumbnail = false
|
||||||
|
self.heatmap = nil
|
||||||
|
|
||||||
self:decide_progress_size()
|
self:decide_progress_size()
|
||||||
self:update_dimensions()
|
self:update_dimensions()
|
||||||
|
|
||||||
-- Release any dragging when file gets unloaded
|
-- Load Youtube heatmap data if available
|
||||||
self:register_mp_event('end-file', function() self.pressed = false end)
|
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
|
end
|
||||||
|
|
||||||
function Timeline:get_visibility()
|
function Timeline:get_visibility()
|
||||||
@@ -181,7 +189,7 @@ function Timeline:render()
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
if self.proximity_raw == 0 then
|
if self.proximity_raw <= 0 then
|
||||||
self.is_hovered = true
|
self.is_hovered = true
|
||||||
end
|
end
|
||||||
if visibility > 0 then
|
if visibility > 0 then
|
||||||
@@ -257,7 +265,32 @@ function Timeline:render()
|
|||||||
ass:draw_stop()
|
ass:draw_stop()
|
||||||
|
|
||||||
-- Progress
|
-- Progress
|
||||||
ass:rect(fax, fay, fbx, fby, {opacity = config.opacity.position})
|
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
|
-- Uncached ranges
|
||||||
if state.uncached_ranges then
|
if state.uncached_ranges then
|
||||||
@@ -380,7 +413,7 @@ function Timeline:render()
|
|||||||
|
|
||||||
-- Time values
|
-- Time values
|
||||||
if text_opacity > 0 then
|
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
|
-- Upcoming cache time
|
||||||
local cache_duration = state.cache_duration and state.cache_duration / state.speed or nil
|
local cache_duration = state.cache_duration and state.cache_duration / state.speed or nil
|
||||||
if cache_duration and options.buffered_time_threshold > 0
|
if cache_duration and options.buffered_time_threshold > 0
|
||||||
@@ -412,7 +445,7 @@ function Timeline:render()
|
|||||||
|
|
||||||
-- Hovered time and chapter
|
-- Hovered time and chapter
|
||||||
local rendered_thumbnail = false
|
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 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)
|
local hovered_seconds = hovered_chapter and hovered_chapter.time or self:get_time_at_x(cursor.x)
|
||||||
|
|
||||||
@@ -486,4 +519,4 @@ function Timeline:render()
|
|||||||
return ass
|
return ass
|
||||||
end
|
end
|
||||||
|
|
||||||
return Timeline
|
return Timeline
|
||||||
@@ -21,11 +21,17 @@ function TopBar:init()
|
|||||||
self.current_chapter = nil
|
self.current_chapter = nil
|
||||||
|
|
||||||
local function maximized_command()
|
local function maximized_command()
|
||||||
mp.command(state.fullormaxed and 'set fullscreen no;set window-maximized no' or 'set window-maximized yes')
|
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
|
end
|
||||||
|
|
||||||
local close = {icon = 'close', hover_bg = '2311e8', hover_fg = 'ffffff', command = function() mp.command('quit') 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}
|
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}
|
self.buttons = options.top_bar_controls == 'left' and {close, max, min} or {min, max, close}
|
||||||
|
|
||||||
@@ -237,13 +243,8 @@ function TopBar:render()
|
|||||||
end
|
end
|
||||||
|
|
||||||
for _, button in ipairs(self.buttons) do
|
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 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 opacity = is_hover and 1 or config.opacity.controls
|
||||||
local button_fg = is_hover and (button.hover_fg or bg) or fg
|
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
|
local button_bg = is_hover and (button.hover_bg or fg) or bg
|
||||||
@@ -290,7 +291,7 @@ function TopBar:render()
|
|||||||
bx = ax + rect_width,
|
bx = ax + rect_width,
|
||||||
by = by - margin,
|
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
|
and 1 or config.opacity.playlist_position
|
||||||
if opacity > 0 then
|
if opacity > 0 then
|
||||||
ass:rect(rect.ax, rect.ay, rect.bx, rect.by, {
|
ass:rect(rect.ax, rect.ay, rect.bx, rect.by, {
|
||||||
@@ -427,4 +428,4 @@ function TopBar:render()
|
|||||||
return ass
|
return ass
|
||||||
end
|
end
|
||||||
|
|
||||||
return TopBar
|
return TopBar
|
||||||
@@ -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
|
||||||
@@ -242,6 +242,7 @@ end
|
|||||||
function Volume:on_display() self:update_dimensions() end
|
function Volume:on_display() self:update_dimensions() end
|
||||||
function Volume:on_prop_border() 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_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_controls_reflow() self:update_dimensions() end
|
||||||
function Volume:on_options() self:update_dimensions() end
|
function Volume:on_options() self:update_dimensions() end
|
||||||
|
|
||||||
@@ -280,4 +281,4 @@ function Volume:render()
|
|||||||
return ass
|
return ass
|
||||||
end
|
end
|
||||||
|
|
||||||
return Volume
|
return Volume
|
||||||
@@ -26,10 +26,10 @@ function WindowBorder:render()
|
|||||||
local clip = '\\iclip(' .. self.size .. ',' .. self.size .. ',' ..
|
local clip = '\\iclip(' .. self.size .. ',' .. self.size .. ',' ..
|
||||||
(display.width - self.size) .. ',' .. (display.height - self.size) .. ')'
|
(display.width - self.size) .. ',' .. (display.height - self.size) .. ')'
|
||||||
ass:rect(0, 0, display.width + 1, display.height + 1, {
|
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
|
return ass
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
return WindowBorder
|
return WindowBorder
|
||||||
@@ -80,4 +80,4 @@
|
|||||||
"type to search": "Tippe um zu suchen",
|
"type to search": "Tippe um zu suchen",
|
||||||
"unknown error": "Unbekannter Fehler",
|
"unknown error": "Unbekannter Fehler",
|
||||||
"uosc has been installed. Restart mpv for it to take effect.": "uosc wurde installiert. mpv muss neu gestarted werden um es wirksam zu machen."
|
"uosc has been installed. Restart mpv for it to take effect.": "uosc wurde installiert. mpv muss neu gestarted werden um es wirksam zu machen."
|
||||||
}
|
}
|
||||||
@@ -96,4 +96,4 @@
|
|||||||
"type & ctrl+enter to search": "escriba y presione ctrl+enter para buscar",
|
"type & ctrl+enter to search": "escriba y presione ctrl+enter para buscar",
|
||||||
"type to search": "escriba para buscar",
|
"type to search": "escriba para buscar",
|
||||||
"uosc has been installed. Restart mpv for it to take effect.": "uosc ha sido instalado, Reinicie mpv para que tome efecto."
|
"uosc has been installed. Restart mpv for it to take effect.": "uosc ha sido instalado, Reinicie mpv para que tome efecto."
|
||||||
}
|
}
|
||||||
@@ -56,4 +56,4 @@
|
|||||||
"open file": "sélectionner un fichier",
|
"open file": "sélectionner un fichier",
|
||||||
"parent dir": "répertoire parent",
|
"parent dir": "répertoire parent",
|
||||||
"playlist or file": "fichier ou liste de lecture"
|
"playlist or file": "fichier ou liste de lecture"
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -104,4 +104,4 @@
|
|||||||
"type & ctrl+enter to search": "wpisz i ctrl+enter aby wyszukać",
|
"type & ctrl+enter to search": "wpisz i ctrl+enter aby wyszukać",
|
||||||
"type to search": "wpisz aby wyszukać",
|
"type to search": "wpisz aby wyszukać",
|
||||||
"uosc has been installed. Restart mpv for it to take effect.": "uosc został zainstalowany. Uruchom ponownie mpv, aby zmiany zostały zastosowane."
|
"uosc has been installed. Restart mpv for it to take effect.": "uosc został zainstalowany. Uruchom ponownie mpv, aby zmiany zostały zastosowane."
|
||||||
}
|
}
|
||||||
@@ -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."
|
||||||
|
}
|
||||||
@@ -56,4 +56,4 @@
|
|||||||
"open file": "deschide fișierul",
|
"open file": "deschide fișierul",
|
||||||
"parent dir": "director părinte",
|
"parent dir": "director părinte",
|
||||||
"playlist or file": "fișier sau listă de redare"
|
"playlist or file": "fișier sau listă de redare"
|
||||||
}
|
}
|
||||||
@@ -56,4 +56,4 @@
|
|||||||
"open file": "открыть файл",
|
"open file": "открыть файл",
|
||||||
"parent dir": "родительская папка",
|
"parent dir": "родительская папка",
|
||||||
"playlist or file": "плейлист или файл"
|
"playlist or file": "плейлист или файл"
|
||||||
}
|
}
|
||||||
@@ -104,4 +104,4 @@
|
|||||||
"type & ctrl+enter to search": "Yaz & aramak için Ctrl+Enter'a bas",
|
"type & ctrl+enter to search": "Yaz & aramak için Ctrl+Enter'a bas",
|
||||||
"type to search": "Aramak için yaz",
|
"type to search": "Aramak için yaz",
|
||||||
"uosc has been installed. Restart mpv for it to take effect.": "uosc yüklendi. Etkin olması için mpv'yi yeniden başlatın."
|
"uosc has been installed. Restart mpv for it to take effect.": "uosc yüklendi. Etkin olması için mpv'yi yeniden başlatın."
|
||||||
}
|
}
|
||||||
@@ -66,4 +66,4 @@
|
|||||||
"An error has occurred.": "Сталася помилка.",
|
"An error has occurred.": "Сталася помилка.",
|
||||||
"See above for clues.": "Дивіться підказки вище.",
|
"See above for clues.": "Дивіться підказки вище.",
|
||||||
"Play/Pause": "Відтворення / Пауза"
|
"Play/Pause": "Відтворення / Пауза"
|
||||||
}
|
}
|
||||||
@@ -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 使其生效"
|
||||||
|
}
|
||||||
@@ -96,4 +96,4 @@
|
|||||||
"type & ctrl+enter to search": "输入并按 ctrl+enter 进行搜索",
|
"type & ctrl+enter to search": "输入并按 ctrl+enter 进行搜索",
|
||||||
"type to search": "输入搜索内容",
|
"type to search": "输入搜索内容",
|
||||||
"uosc has been installed. Restart mpv for it to take effect.": "uosc 已经安装,重新启动 mpv 使其生效"
|
"uosc has been installed. Restart mpv for it to take effect.": "uosc 已经安装,重新启动 mpv 使其生效"
|
||||||
}
|
}
|
||||||
@@ -266,3 +266,47 @@ function ass_mt:spinner(x, y, size, opts)
|
|||||||
self:icon(x, y, size, 'autorenew', opts)
|
self:icon(x, y, size, 'autorenew', opts)
|
||||||
request_render()
|
request_render()
|
||||||
end
|
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 (0–1): 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 (0–1) 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
|
||||||
@@ -71,4 +71,4 @@ mp.register_script_message('set-button', function(name, data)
|
|||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
|
|
||||||
return buttons
|
return buttons
|
||||||
@@ -63,4 +63,4 @@ function char_conv(chars, use_ligature, has_separator)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
return char_conv
|
return char_conv
|
||||||
@@ -466,4 +466,4 @@ mp.set_key_bindings({
|
|||||||
{'wheel_down', cursor:create_handler('wheel_down', create_shortcut('wheel_down'))},
|
{'wheel_down', cursor:create_handler('wheel_down', create_shortcut('wheel_down'))},
|
||||||
}, 'wheel', 'force')
|
}, 'wheel', 'force')
|
||||||
|
|
||||||
return cursor
|
return cursor
|
||||||
@@ -294,4 +294,4 @@ function fzy.get_implementation_name()
|
|||||||
return "lua"
|
return "lua"
|
||||||
end
|
end
|
||||||
|
|
||||||
return fzy
|
return fzy
|
||||||
@@ -65,4 +65,4 @@ for i = #languages, 1, -1 do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
return {t = t}
|
return {t = t}
|
||||||
+33
-81
@@ -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
|
return tonumber(mp.get_property(opts.prop)), snd and tonumber(mp.get_property(snd.prop)) or nil
|
||||||
end
|
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 function serialize_tracklist(tracklist)
|
||||||
local items = {}
|
local items = {}
|
||||||
|
|
||||||
@@ -285,15 +260,14 @@ function create_select_tracklist_type_menu_opener(opts)
|
|||||||
if track['demux-h'] then
|
if track['demux-h'] then
|
||||||
h(track['demux-w'] and (track['demux-w'] .. 'x' .. track['demux-h']) or (track['demux-h'] .. 'p'))
|
h(track['demux-w'] and (track['demux-w'] .. 'x' .. track['demux-h']) or (track['demux-h'] .. 'p'))
|
||||||
end
|
end
|
||||||
if track['demux-fps'] then h(string.format('%.5g fps', track['demux-fps'])) end
|
if track['demux-fps'] then h(string.format('%.5gfps', track['demux-fps'])) end
|
||||||
if track['codec'] then h(escape_codec(track.codec)) end
|
h(track.codec)
|
||||||
if track['audio-channels'] then
|
if track['audio-channels'] then
|
||||||
h(track['audio-channels'] == 1
|
h(track['audio-channels'] == 1
|
||||||
and t('%s channel', track['audio-channels'])
|
and t('%s channel', track['audio-channels'])
|
||||||
or t('%s channels', track['audio-channels']))
|
or t('%s channels', track['audio-channels']))
|
||||||
end
|
end
|
||||||
if track['demux-samplerate'] then h(string.format('%.3g kHz', track['demux-samplerate'] / 1000)) end
|
if track['demux-samplerate'] then h(string.format('%.3gkHz', track['demux-samplerate'] / 1000)) end
|
||||||
if track['demux-bitrate'] then h(string.format('%.0f kbps', track['demux-bitrate'] / 1000)) end
|
|
||||||
if track.forced then h(t('forced')) end
|
if track.forced then h(t('forced')) end
|
||||||
if track.default then h(t('default')) end
|
if track.default then h(t('default')) end
|
||||||
if track.external then
|
if track.external then
|
||||||
@@ -920,7 +894,8 @@ function open_subtitle_downloader()
|
|||||||
return
|
return
|
||||||
end
|
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 state.path then
|
||||||
if is_protocol(state.path) then
|
if is_protocol(state.path) then
|
||||||
@@ -930,6 +905,7 @@ function open_subtitle_downloader()
|
|||||||
local serialized_path = serialize_path(state.path)
|
local serialized_path = serialize_path(state.path)
|
||||||
if serialized_path then
|
if serialized_path then
|
||||||
search_suggestion = serialized_path.filename
|
search_suggestion = serialized_path.filename
|
||||||
|
file_path = state.path
|
||||||
destination_directory = serialized_path.dirname
|
destination_directory = serialized_path.dirname
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -942,7 +918,6 @@ function open_subtitle_downloader()
|
|||||||
end
|
end
|
||||||
|
|
||||||
local handle_download, handle_search
|
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,
|
-- Checks if there an error, or data is invalid. If true, reports the error,
|
||||||
-- updates menu to inform about it, and returns true.
|
-- updates menu to inform about it, and returns true.
|
||||||
@@ -991,49 +966,16 @@ function open_subtitle_downloader()
|
|||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
|
|
||||||
local download_url = url .. '/download'
|
local args = itable_join({'download-subtitles'}, credentials, {
|
||||||
|
'--file-id', tostring(data.id),
|
||||||
|
'--destination', destination_directory,
|
||||||
|
})
|
||||||
|
|
||||||
local headers = {
|
call_ziggy_async(args, function(error, data)
|
||||||
['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 not menu:is_alive() then return end
|
||||||
if data and data.link then
|
if should_abort(error, data, function(data) return type(data.file) == 'string' end) then return end
|
||||||
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({
|
load_track('sub', data.file)
|
||||||
name = 'subprocess',
|
|
||||||
capture_stdout = true,
|
|
||||||
capture_stderr = true,
|
|
||||||
playback_only = false,
|
|
||||||
args = arg
|
|
||||||
})
|
|
||||||
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
|
|
||||||
|
|
||||||
load_track('sub', utils.join_path(destination_directory, data.file_name))
|
|
||||||
|
|
||||||
menu:update_items({
|
menu:update_items({
|
||||||
{
|
{
|
||||||
@@ -1043,7 +985,7 @@ function open_subtitle_downloader()
|
|||||||
selectable = false,
|
selectable = false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title = t('Remaining downloads today: %s', data.remaining),
|
title = t('Remaining downloads today: %s', data.remaining .. '/' .. data.total),
|
||||||
italic = true,
|
italic = true,
|
||||||
muted = true,
|
muted = true,
|
||||||
icon = 'file_download',
|
icon = 'file_download',
|
||||||
@@ -1068,22 +1010,32 @@ function open_subtitle_downloader()
|
|||||||
|
|
||||||
menu:update_items({{icon = 'spinner', align = 'center', selectable = false, muted = true}})
|
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)
|
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),
|
args[#args + 1] = '--page'
|
||||||
table.concat(table_keys(create_set(languages)), ','), tostring(page))
|
args[#args + 1] = tostring(page)
|
||||||
|
|
||||||
local headers = {
|
if file_path then
|
||||||
['Api-Key'] = config.open_subtitles_api_key,
|
args[#args + 1] = '--hash'
|
||||||
['User-Agent'] = config.open_subtitles_agent,
|
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
|
if not menu:is_alive() then return end
|
||||||
|
|
||||||
local function check_is_valid(data)
|
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
|
end
|
||||||
|
|
||||||
if should_abort(error, data, check_is_valid) then return end
|
if should_abort(error, data, check_is_valid) then return end
|
||||||
|
|
||||||
local subs = itable_filter(data.data, function(sub)
|
local subs = itable_filter(data.data, function(sub)
|
||||||
@@ -1183,4 +1135,4 @@ function open_subtitle_downloader()
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
@@ -24,34 +24,6 @@ end
|
|||||||
---@return string
|
---@return string
|
||||||
function trim(str) return str:match('^%s*(.-)%s*$') end
|
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.
|
-- Trim any `char` from the end of the string.
|
||||||
---@param str string
|
---@param str string
|
||||||
---@param char string
|
---@param char string
|
||||||
@@ -406,4 +378,4 @@ end
|
|||||||
function CircularBuffer:clear()
|
function CircularBuffer:clear()
|
||||||
itable_clear(self.data)
|
itable_clear(self.data)
|
||||||
self.pos = 0
|
self.pos = 0
|
||||||
end
|
end
|
||||||
@@ -657,4 +657,4 @@ function get_roman_match_positions(title, query, mode, roman)
|
|||||||
end
|
end
|
||||||
|
|
||||||
return byte_positions
|
return byte_positions
|
||||||
end
|
end
|
||||||
+93
-94
@@ -130,12 +130,14 @@ function tween(from, to, setter, duration_or_callback, callback)
|
|||||||
return finish
|
return finish
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Returns signed distance (negative values mean how deep inside the rect the point is).
|
||||||
---@param point Point
|
---@param point Point
|
||||||
---@param rect Rect
|
---@param rect Rect
|
||||||
function get_point_to_rectangle_proximity(point, rect)
|
function get_point_to_rectangle_proximity(point, rect)
|
||||||
local dx = math.max(rect.ax - point.x, 0, point.x - rect.bx)
|
local dx = math.max(rect.ax - point.x, point.x - rect.bx)
|
||||||
local dy = math.max(rect.ay - point.y, 0, point.y - rect.by)
|
local dy = math.max(rect.ay - point.y, point.y - rect.by)
|
||||||
return math.sqrt(dx * dx + dy * dy)
|
local distance = math.sqrt(math.max(0, dx)^2 + math.max(0, dy)^2)
|
||||||
|
return distance + math.min(0, math.max(dx, dy))
|
||||||
end
|
end
|
||||||
|
|
||||||
---@param point_a Point
|
---@param point_a Point
|
||||||
@@ -149,7 +151,7 @@ end
|
|||||||
---@param hitbox Hitbox
|
---@param hitbox Hitbox
|
||||||
function point_collides_with(point, hitbox)
|
function point_collides_with(point, hitbox)
|
||||||
return (hitbox.r and get_point_to_point_proximity(point, hitbox.point) <= hitbox.r) or
|
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
|
end
|
||||||
|
|
||||||
---@param lax number
|
---@param lax number
|
||||||
@@ -221,6 +223,37 @@ function get_ray_to_rectangle_distance(ax, ay, bx, by, rect)
|
|||||||
return closest
|
return closest
|
||||||
end
|
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.
|
-- Extracts the properties used by property expansion of that string.
|
||||||
---@param str string
|
---@param str string
|
||||||
---@param res { [string] : boolean } | nil
|
---@param res { [string] : boolean } | nil
|
||||||
@@ -892,79 +925,17 @@ function call_ziggy_async(args, callback)
|
|||||||
end
|
end
|
||||||
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
|
---@return string|nil
|
||||||
function get_clipboard()
|
function get_clipboard()
|
||||||
if state.current_clipboard_backend then
|
local data, err = mp.get_property('clipboard/text')
|
||||||
if state.platform == 'windows' or state.platform == 'darwin' then
|
if data then
|
||||||
return mp.get_property('clipboard/text', '')
|
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', '')
|
|
||||||
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
|
end
|
||||||
-- Fallback to ziggy
|
if err and err ~= 'property not found' and err ~= 'property unavailable' then
|
||||||
|
mp.commandv('show-text', 'Get clipboard error: ' .. err)
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
local err, data = call_ziggy({'get-clipboard'})
|
local err, data = call_ziggy({'get-clipboard'})
|
||||||
if err then
|
if err then
|
||||||
mp.commandv('show-text', 'Get clipboard error. See console for details.')
|
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.
|
---@return string|nil payload String that was copied to clipboard.
|
||||||
function set_clipboard(payload)
|
function set_clipboard(payload)
|
||||||
payload = tostring(payload)
|
payload = tostring(payload)
|
||||||
if state.current_clipboard_backend then
|
|
||||||
if state.platform == 'windows' or state.platform == 'darwin' then
|
local success, err = mp.set_property('clipboard/text', payload)
|
||||||
return mp.commandv('set', 'clipboard/text', payload)
|
if success then
|
||||||
end
|
mp.commandv('show-text', t('Copied to clipboard') .. ': ' .. payload, 3000)
|
||||||
if state.platform == 'linux' then
|
return payload
|
||||||
-- Wayland
|
|
||||||
if os.getenv('WAYLAND_DISPLAY') or os.getenv('WAYLAND_SOCKET') then
|
|
||||||
return utils.subprocess({ args = { 'wl-copy' }, stdin_data = payload })
|
|
||||||
end
|
|
||||||
-- X11
|
|
||||||
return utils.subprocess({
|
|
||||||
args = { 'xclip', '-silent', '-selection', 'clipboard', '-in' },
|
|
||||||
stdin_data = payload
|
|
||||||
})
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
-- Fallback to ziggy
|
if err and err ~= 'property not found' and err ~= 'property unavailable' then
|
||||||
|
mp.commandv('show-text', 'Set clipboard error: ' .. err)
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
local err, data = call_ziggy({'set-clipboard', payload})
|
local err, data = call_ziggy({'set-clipboard', payload})
|
||||||
if err then
|
if err then
|
||||||
mp.commandv('show-text', 'Set clipboard error. See console for details.')
|
mp.commandv('show-text', 'Set clipboard error. See console for details.')
|
||||||
@@ -1004,6 +969,43 @@ function set_clipboard(payload)
|
|||||||
return data and data.payload
|
return data and data.payload
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Returns Youtube heatmap data if available.
|
||||||
|
---@return number[]|nil Flat table of normalized points (0–1)
|
||||||
|
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 ]]
|
--[[ RENDERING ]]
|
||||||
|
|
||||||
function render()
|
function render()
|
||||||
@@ -1012,9 +1014,6 @@ function render()
|
|||||||
|
|
||||||
cursor:clear_zones()
|
cursor:clear_zones()
|
||||||
|
|
||||||
-- Click on empty area detection
|
|
||||||
if setup_click_detection then setup_click_detection() end
|
|
||||||
|
|
||||||
-- Actual rendering
|
-- Actual rendering
|
||||||
local ass = assdraw.ass_new()
|
local ass = assdraw.ass_new()
|
||||||
|
|
||||||
@@ -1076,4 +1075,4 @@ function request_render()
|
|||||||
local timeout = math.max(0, state.render_delay - (mp.get_time() - state.render_last_time))
|
local timeout = math.max(0, state.render_delay - (mp.get_time() - state.render_last_time))
|
||||||
state.render_timer.timeout = timeout
|
state.render_timer.timeout = timeout
|
||||||
state.render_timer:resume()
|
state.render_timer:resume()
|
||||||
end
|
end
|
||||||
+21
-15
@@ -1,5 +1,5 @@
|
|||||||
--[[ uosc | https://github.com/tomasklaen/uosc ]]
|
--[[ 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)
|
mp.commandv('script-message', 'uosc-version', uosc_version)
|
||||||
|
|
||||||
@@ -27,6 +27,7 @@ defaults = {
|
|||||||
timeline_border = 1,
|
timeline_border = 1,
|
||||||
timeline_step = '5',
|
timeline_step = '5',
|
||||||
timeline_cache = true,
|
timeline_cache = true,
|
||||||
|
timeline_heatmap = 'overlay',
|
||||||
|
|
||||||
controls =
|
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',
|
'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 = 1,
|
||||||
scale_fullscreen = 1.3,
|
scale_fullscreen = 1.3,
|
||||||
font = '',
|
|
||||||
font_scale = 1,
|
font_scale = 1,
|
||||||
text_border = 1.2,
|
text_border = 1.2,
|
||||||
border_radius = 4,
|
border_radius = 4,
|
||||||
@@ -102,7 +102,6 @@ defaults = {
|
|||||||
languages = 'slang,en',
|
languages = 'slang,en',
|
||||||
subtitles_directory = '~~/subtitles',
|
subtitles_directory = '~~/subtitles',
|
||||||
disable_elements = '',
|
disable_elements = '',
|
||||||
ziggy_path = 'default',
|
|
||||||
}
|
}
|
||||||
options = table_copy(defaults)
|
options = table_copy(defaults)
|
||||||
function handle_options(changed_options)
|
function handle_options(changed_options)
|
||||||
@@ -143,10 +142,12 @@ local config_defaults = {
|
|||||||
foreground_text = serialize_rgba('000000').color,
|
foreground_text = serialize_rgba('000000').color,
|
||||||
background = serialize_rgba('000000').color,
|
background = serialize_rgba('000000').color,
|
||||||
background_text = serialize_rgba('ffffff').color,
|
background_text = serialize_rgba('ffffff').color,
|
||||||
|
window_border = serialize_rgba('000000').color,
|
||||||
curtain = serialize_rgba('111111').color,
|
curtain = serialize_rgba('111111').color,
|
||||||
success = serialize_rgba('a5e075').color,
|
success = serialize_rgba('a5e075').color,
|
||||||
error = serialize_rgba('ff616e').color,
|
error = serialize_rgba('ff616e').color,
|
||||||
match = serialize_rgba('69c5ff').color,
|
match = serialize_rgba('69c5ff').color,
|
||||||
|
heatmap = serialize_rgba('00adee').color,
|
||||||
},
|
},
|
||||||
opacity = {
|
opacity = {
|
||||||
timeline = 0.9,
|
timeline = 0.9,
|
||||||
@@ -167,6 +168,7 @@ local config_defaults = {
|
|||||||
audio_indicator = 0.5,
|
audio_indicator = 0.5,
|
||||||
buffering_indicator = 0.3,
|
buffering_indicator = 0.3,
|
||||||
playlist_position = 0.8,
|
playlist_position = 0.8,
|
||||||
|
heatmap = 0.4,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
config = {
|
config = {
|
||||||
@@ -176,7 +178,7 @@ config = {
|
|||||||
-- sets max rendering frequency in case the
|
-- sets max rendering frequency in case the
|
||||||
-- native rendering frequency could not be detected
|
-- native rendering frequency could not be detected
|
||||||
render_delay = 1 / 60,
|
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_x = mp.get_property('osd-margin-x'),
|
||||||
osd_margin_y = mp.get_property('osd-margin-y'),
|
osd_margin_y = mp.get_property('osd-margin-y'),
|
||||||
osd_alignment_x = mp.get_property('osd-align-x'),
|
osd_alignment_x = mp.get_property('osd-align-x'),
|
||||||
@@ -333,7 +335,7 @@ function create_default_menu_items()
|
|||||||
{
|
{
|
||||||
title = t('Aspect ratio'),
|
title = t('Aspect ratio'),
|
||||||
items = {
|
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 = '16:9', value = 'set video-aspect-override "16:9"'},
|
||||||
{title = '4:3', value = 'set video-aspect-override "4:3"'},
|
{title = '4:3', value = 'set video-aspect-override "4:3"'},
|
||||||
{title = '2.35:1', value = 'set video-aspect-override "2.35:1"'},
|
{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('Key bindings'), value = 'script-binding uosc/keybinds'},
|
||||||
{title = t('Show in directory'), value = 'script-binding uosc/show-in-directory'},
|
{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('Open config folder'), value = 'script-binding uosc/open-config-directory'},
|
||||||
|
{title = t('Update uosc'), value = 'script-binding uosc/update'},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{title = t('Quit'), value = 'quit'},
|
{title = t('Quit'), value = 'quit'},
|
||||||
@@ -381,7 +384,6 @@ state = {
|
|||||||
ime_active = mp.get_property_native('input-ime'),
|
ime_active = mp.get_property_native('input-ime'),
|
||||||
chapters = {},
|
chapters = {},
|
||||||
chapter_ranges = {},
|
chapter_ranges = {},
|
||||||
current_clipboard_backend = mp.get_property_native('current-clipboard-backend'),
|
|
||||||
border = mp.get_property_native('border'),
|
border = mp.get_property_native('border'),
|
||||||
title_bar = mp.get_property_native('title-bar'),
|
title_bar = mp.get_property_native('title-bar'),
|
||||||
fullscreen = mp.get_property_native('fullscreen'),
|
fullscreen = mp.get_property_native('fullscreen'),
|
||||||
@@ -442,9 +444,7 @@ require('lib/menus')
|
|||||||
-- Determine path to ziggy
|
-- Determine path to ziggy
|
||||||
do
|
do
|
||||||
local bin = 'ziggy-' .. (state.platform == 'windows' and 'windows.exe' or state.platform)
|
local bin = 'ziggy-' .. (state.platform == 'windows' and 'windows.exe' or state.platform)
|
||||||
config.ziggy_path = os.getenv('MPV_UOSC_ZIGGY') or
|
config.ziggy_path = os.getenv('MPV_UOSC_ZIGGY') or join_path(mp.get_script_directory(), join_path('bin', bin))
|
||||||
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)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
--[[ STATE UPDATERS ]]
|
--[[ 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)
|
set_state('cache_duration', not cache_state.eof and cache_state['cache-duration'] or nil)
|
||||||
else
|
else
|
||||||
cached_ranges = {}
|
cached_ranges = {}
|
||||||
|
set_state('cache_underrun', false)
|
||||||
end
|
end
|
||||||
|
|
||||||
if not (state.duration and (#cached_ranges > 0 or state.cache == 'yes' or
|
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'),
|
footnote = t('Paste path or url to add.') .. ' ' .. t('%s to reorder.', 'ctrl+up/down/pgup/pgdn/home/end'),
|
||||||
serializer = function(playlist)
|
serializer = function(playlist)
|
||||||
local items = {}
|
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
|
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
|
local title = type(item.title) == 'string' and #item.title > 0 and item.title or false
|
||||||
items[index] = {
|
items[index] = {
|
||||||
title = is_url and (title or playlist_titles[item.filename] or url_decode(item.filename)) or
|
title = (not force_filename and title) and title
|
||||||
serialize_path(item.filename).basename,
|
or (is_protocol(item.filename) and item.filename or serialize_path(item.filename).basename),
|
||||||
hint = tostring(index),
|
hint = tostring(index),
|
||||||
active = item.current,
|
active = item.current,
|
||||||
value = index,
|
value = index,
|
||||||
@@ -956,7 +956,10 @@ bind_command('show-in-directory', function()
|
|||||||
end)
|
end)
|
||||||
bind_command('stream-quality', open_stream_quality_menu)
|
bind_command('stream-quality', open_stream_quality_menu)
|
||||||
bind_command('open-file', open_open_file_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()
|
bind_command('items', function()
|
||||||
if state.has_playlist then
|
if state.has_playlist then
|
||||||
mp.command('script-binding uosc/playlist')
|
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 .. '".')
|
msg.error('Couldn\'t serialize config path "' .. config_path .. '".')
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
|
bind_command('update', function()
|
||||||
|
if not Elements:has('updater') then require('elements/Updater'):new() end
|
||||||
|
end)
|
||||||
|
|
||||||
--[[ MESSAGE HANDLERS ]]
|
--[[ MESSAGE HANDLERS ]]
|
||||||
|
|
||||||
@@ -1198,4 +1204,4 @@ function Manager:_commit()
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- Initial commit
|
-- Initial commit
|
||||||
Manager:disable('user', options.disable_elements)
|
Manager:disable('user', options.disable_elements)
|
||||||
@@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
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
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
SOFTWARE.
|
SOFTWARE.
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> 已添加对mpv内部 `mp.input`的支持,在uosc不可用时通过键绑定调用此方式渲染菜单
|
> 已添加对mpv内部 `mp.input`的支持,在uosc不可用时通过键绑定调用此方式渲染菜单
|
||||||
>
|
>
|
||||||
> 欲启用此支持mpv最低版本要求:0.39.0
|
> 欲启用此支持mpv最低版本要求:0.39.0
|
||||||
|
|
||||||
## 项目简介
|
## 项目简介
|
||||||
@@ -24,25 +24,38 @@
|
|||||||
<details open>
|
<details open>
|
||||||
|
|
||||||
1. 从弹弹play或自定义服务的API获取剧集及弹幕数据,并根据用户选择的集数加载弹幕
|
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. 在没有手动加载过弹幕,没有填装自动弹幕记忆之前,通过文件哈希匹配的方式自动添加弹幕(~仅限本地文件~,现已支持网络视频),对于能够哈希匹配关联的文件不再需要手动搜索关联,实现全自动加载弹幕并添加记忆。该功能随记忆型全自动弹幕填装功能一起开启(哈希匹配自动加载准确率较低,如关联到错误的剧集请手动加载正确的剧集)
|
|
||||||
|
|
||||||
|
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
|
> 哈希匹配功能需要 mpv 基于 LuaJIT 或 Lua 5.2 构建,不支持 Lua 5.1
|
||||||
>
|
|
||||||
10. 通过打开配置项load_more_danmaku可以爬取所有可用弹幕源,获取更多弹幕(注意⚠️:爬取所有可用弹幕源默认关闭,如需开启请阅读[load_more_danmaku配置项说明](#load_more_danmaku))
|
10. 自动记忆弹幕开关情况,播放视频时保持上次关闭时的弹幕开关状态
|
||||||
11. 自动记忆弹幕开关情况,播放视频时保持上次关闭时的弹幕开关状态
|
|
||||||
12. 自定义默认播放弹幕样式(具体设置方法详见[自定义弹幕样式](#自定义弹幕样式相关配置))
|
11. 自定义默认播放弹幕样式(具体设置方法详见[自定义弹幕样式](#自定义弹幕样式相关配置))
|
||||||
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))
|
12. 在使用如[Play-With-MPV](https://github.com/LuckyPuppy514/Play-With-MPV)或[ff2mpv](https://github.com/woodruffw/ff2mpv)等网络播放手段时,自动加载弹幕(注意⚠️:目前支持自动加载bilibili和巴哈姆特这两个网站的弹幕,具体说明查看[autoload_for_url配置项说明](#autoload_for_url))
|
||||||
15. 可以合并一定时间段内同时出现的大量重复弹幕(具体设置方法详见[merge_tolerance配置项说明](#merge_tolerance))
|
|
||||||
16. 弹幕简体字繁体字转换,解决弹幕简繁混杂问题(具体设置方法详见[chConvert配置项说明](#chConvert))
|
13. 保存当前弹幕到本地(详细功能说明见[save_danmaku配置项说明](#save_danmaku))
|
||||||
17. 自定义插件相关提示的显示位置,可以自由调节距离画面左上角的两个维度的距离(具体设置方法详见[message_x配置项说明](#message_x)和[message_y配置项说明](#message_y))
|
|
||||||
|
14. 可以合并一定时间段内同时出现的大量重复弹幕(具体设置方法详见[merge_tolerance配置项说明](#merge_tolerance))
|
||||||
|
|
||||||
|
15. 弹幕简体字繁体字转换,解决弹幕简繁混杂问题(具体设置方法详见[chConvert配置项说明](#chConvert))
|
||||||
|
|
||||||
|
16. 自定义插件相关提示的显示位置,可以自由调节距离画面左上角的两个维度的距离(具体设置方法详见[message_x配置项说明](#message_x)和[message_y配置项说明](#message_y))
|
||||||
|
|
||||||
无需亲自下载整合弹幕文件资源,无需亲自处理文件格式转换,在mpv播放器中一键加载包含了哔哩哔哩、巴哈姆特等弹幕网站弹幕的弹弹play的动画弹幕。
|
无需亲自下载整合弹幕文件资源,无需亲自处理文件格式转换,在mpv播放器中一键加载包含了哔哩哔哩、巴哈姆特等弹幕网站弹幕的弹弹play的动画弹幕。
|
||||||
|
|
||||||
@@ -69,7 +82,7 @@
|
|||||||
想要使用本插件,请将本插件完整地[下载](https://github.com/Tony15246/uosc_danmaku/releases)或者克隆到 `scripts`目录下即可使用,文件结构参阅下方
|
想要使用本插件,请将本插件完整地[下载](https://github.com/Tony15246/uosc_danmaku/releases)或者克隆到 `scripts`目录下即可使用,文件结构参阅下方
|
||||||
|
|
||||||
> [!IMPORTANT]
|
> [!IMPORTANT]
|
||||||
>
|
>
|
||||||
> 1. scripts目录下放置本插件的文件夹名称必须为uosc_danmaku,否则必须参照uosc控件配置部分[修改uosc控件](#修改uosc控件可选)
|
> 1. scripts目录下放置本插件的文件夹名称必须为uosc_danmaku,否则必须参照uosc控件配置部分[修改uosc控件](#修改uosc控件可选)
|
||||||
> 2. 记得给bin文件夹下的文件赋予可执行权限
|
> 2. 记得给bin文件夹下的文件赋予可执行权限
|
||||||
|
|
||||||
@@ -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>
|
</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>
|
<details>
|
||||||
<summary>
|
<summary>
|
||||||
auto_load
|
auto_load
|
||||||
@@ -490,11 +475,11 @@ save_danmaku
|
|||||||
当文件关闭时自动保存弹幕文件(xml格式)至视频同目录,保存的弹幕文件名与对应的视频文件名相同。配合[autoload_local_danmaku选项](#autoload_local_danmaku)可以实现弹幕自动保存到本地并且下次播放时自动加载本地保存的弹幕。此功能默认禁用。
|
当文件关闭时自动保存弹幕文件(xml格式)至视频同目录,保存的弹幕文件名与对应的视频文件名相同。配合[autoload_local_danmaku选项](#autoload_local_danmaku)可以实现弹幕自动保存到本地并且下次播放时自动加载本地保存的弹幕。此功能默认禁用。
|
||||||
|
|
||||||
> **⚠️NOTE!**
|
> **⚠️NOTE!**
|
||||||
>
|
>
|
||||||
> 当开启[autoload_local_danmaku选项](#autoload_local_danmaku)时,会自动加载播放文件同目录下同名的 xml 格式的弹幕文件,优先级高于一切其他自动加载弹幕功能。如果不希望每次播放都加载之前保存的本地弹幕,则请关闭[autoload_local_danmaku选项](#autoload_local_danmaku);或者在保存完弹幕之后转移弹幕文件至其他路径并关闭 `save_danmaku`选项。
|
> 当开启[autoload_local_danmaku选项](#autoload_local_danmaku)时,会自动加载播放文件同目录下同名的 xml 格式的弹幕文件,优先级高于一切其他自动加载弹幕功能。如果不希望每次播放都加载之前保存的本地弹幕,则请关闭[autoload_local_danmaku选项](#autoload_local_danmaku);或者在保存完弹幕之后转移弹幕文件至其他路径并关闭 `save_danmaku`选项。
|
||||||
>
|
>
|
||||||
> `save_danmaku`选项的打开和关闭可以运行时实时更新。在 `input.conf`中添加如下内容,可通过快捷键实时控制 `save_danmaku`选项的打开和关闭
|
> `save_danmaku`选项的打开和关闭可以运行时实时更新。在 `input.conf`中添加如下内容,可通过快捷键实时控制 `save_danmaku`选项的打开和关闭
|
||||||
>
|
>
|
||||||
> ```
|
> ```
|
||||||
> key cycle-values script-opts uosc_danmaku-save_danmaku=yes uosc_danmaku-save_danmaku=no
|
> key cycle-values script-opts uosc_danmaku-save_danmaku=yes uosc_danmaku-save_danmaku=no
|
||||||
> ```
|
> ```
|
||||||
@@ -523,7 +508,7 @@ save_danmaku=yes
|
|||||||
### add_from_source
|
### add_from_source
|
||||||
|
|
||||||
> **⚠️NOTE!**
|
> **⚠️NOTE!**
|
||||||
>
|
>
|
||||||
> 该可选配置项在Release v1.2.0之后已废除。现在通过 `从弹幕源向当前弹幕添加新弹幕内容`功能关联过的弹幕源被记录,并且下次播放同一个视频的时候自动关联并加载所有添加过的弹幕源,这样的行为已经成为了插件的默认行为,不需要再通过 `add_from_source`来开启。在[从源获取弹幕](#从弹幕源向当前弹幕添加新弹幕内容可选)菜单中可以可视化地管理所有添加过的弹幕源。
|
> 该可选配置项在Release v1.2.0之后已废除。现在通过 `从弹幕源向当前弹幕添加新弹幕内容`功能关联过的弹幕源被记录,并且下次播放同一个视频的时候自动关联并加载所有添加过的弹幕源,这样的行为已经成为了插件的默认行为,不需要再通过 `add_from_source`来开启。在[从源获取弹幕](#从弹幕源向当前弹幕添加新弹幕内容可选)菜单中可以可视化地管理所有添加过的弹幕源。
|
||||||
|
|
||||||
#### 功能说明
|
#### 功能说明
|
||||||
@@ -627,6 +612,7 @@ merge_tolerance=1
|
|||||||
</details>
|
</details>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>
|
<summary>
|
||||||
max_screen_danmaku
|
max_screen_danmaku
|
||||||
@@ -730,8 +716,8 @@ api_server
|
|||||||
允许自定义弹幕 API 的服务地址
|
允许自定义弹幕 API 的服务地址
|
||||||
|
|
||||||
> **⚠️NOTE!**
|
> **⚠️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.icu,https://dmku.hls.one
|
||||||
|
|
||||||
> **⚠️NOTE!**
|
> **⚠️NOTE!**
|
||||||
>
|
>
|
||||||
> 不设置此选项的情况下默认使用 `https://fc.lyz05.cn`作为兜底服务器,除非你自行部署了弹幕服务器,否则不建议自定义此选项。
|
> 不设置此选项的情况下默认使用 ` https://api.danmu.icu`作为兜底服务器
|
||||||
|
|
||||||
#### 使用方法
|
#### 使用方法
|
||||||
|
|
||||||
想要使用此选项,请在mpv配置文件夹下的 `script-opts`中创建 `uosc_danmaku.conf`文件并自定义如下内容:
|
想要使用此选项,请在mpv配置文件夹下的 `script-opts`中创建 `uosc_danmaku.conf`文件并自定义如下内容:
|
||||||
|
|
||||||
```
|
```
|
||||||
fallback_server=https://fc.lyz05.cn
|
fallback_server= https://api.danmu.icu
|
||||||
```
|
```
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
@@ -790,7 +776,7 @@ tmdb_api_key
|
|||||||
设置 tmdb 的 API Key,用于获取非动画条目的中文信息(当搜索内容非中文时)。可以在 https://www.themoviedb.org 注册后去个人账号设置界面获取个人的tmdb 的 API Key。
|
设置 tmdb 的 API Key,用于获取非动画条目的中文信息(当搜索内容非中文时)。可以在 https://www.themoviedb.org 注册后去个人账号设置界面获取个人的tmdb 的 API Key。
|
||||||
|
|
||||||
> **⚠️NOTE!**
|
> **⚠️NOTE!**
|
||||||
>
|
>
|
||||||
> 不设置此选项的情况下默认使用专为本项目申请的API Key。另外,自定义此选项时还需要对获取到的 API Key 进行 base64 编码。
|
> 不设置此选项的情况下默认使用专为本项目申请的API Key。另外,自定义此选项时还需要对获取到的 API Key 进行 base64 编码。
|
||||||
|
|
||||||
#### 使用方法
|
#### 使用方法
|
||||||
@@ -826,9 +812,9 @@ user_agent
|
|||||||
想要使用此选项,请在mpv配置文件夹下的 `script-opts`中创建 `uosc_danmaku.conf`文件并自定义如下内容(不可为空):
|
想要使用此选项,请在mpv配置文件夹下的 `script-opts`中创建 `uosc_danmaku.conf`文件并自定义如下内容(不可为空):
|
||||||
|
|
||||||
> **⚠️NOTE!**
|
> **⚠️NOTE!**
|
||||||
>
|
>
|
||||||
> User-Agent格式必须符合弹弹play的标准,否则无法成功请求。具体格式要求见[弹弹play官方文档](https://github.com/kaedei/dandanplay-libraryindex/blob/master/api/OpenPlatform.md#5user-agent)
|
> User-Agent格式必须符合弹弹play的标准,否则无法成功请求。具体格式要求见[弹弹play官方文档](https://github.com/kaedei/dandanplay-libraryindex/blob/master/api/OpenPlatform.md#5user-agent)
|
||||||
>
|
>
|
||||||
> 若想提高URL播放的哈希匹配成功率,可以将此项设为 `mpv`或浏览器的User-Agent
|
> 若想提高URL播放的哈希匹配成功率,可以将此项设为 `mpv`或浏览器的User-Agent
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -1020,6 +1006,15 @@ outline=1
|
|||||||
blacklist_path=
|
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的弹幕源问题如何从根源进行调整解决
|
### 来自弹弹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)
|
- [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)
|
||||||
@@ -24,62 +24,43 @@ end
|
|||||||
function set_episode_id(input, from_menu)
|
function set_episode_id(input, from_menu)
|
||||||
from_menu = from_menu or false
|
from_menu = from_menu or false
|
||||||
DANMAKU.source = "dandanplay"
|
DANMAKU.source = "dandanplay"
|
||||||
|
local api_server = options.api_server
|
||||||
for url, source in pairs(DANMAKU.sources) do
|
for url, source in pairs(DANMAKU.sources) do
|
||||||
if source.from == "api_server" then
|
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
|
if not source.from_history then
|
||||||
DANMAKU.sources[url] = nil
|
DANMAKU.sources[url] = nil
|
||||||
else
|
else
|
||||||
DANMAKU.sources[url]["fname"] = nil
|
DANMAKU.sources[url]["data"] = nil
|
||||||
|
api_server = source.api_server or options.api_server
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
local episodeId = tonumber(input)
|
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()
|
set_danmaku_button()
|
||||||
if options.load_more_danmaku then
|
fetch_danmaku(episodeId, from_menu, api_server)
|
||||||
fetch_danmaku_all(episodeId, from_menu)
|
|
||||||
else
|
|
||||||
fetch_danmaku(episodeId, from_menu)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
-- 回退使用额外的弹幕获取方式
|
-- 回退使用额外的弹幕获取方式
|
||||||
function get_danmaku_fallback(query)
|
function get_danmaku_fallback(query)
|
||||||
local url = options.fallback_server .. "/?url=" .. query
|
local url = options.fallback_server .. "/?ac=dm&url=" .. query
|
||||||
msg.verbose("尝试获取弹幕:" .. url)
|
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)
|
local args = make_danmaku_request_args("GET", url)
|
||||||
async_running = false
|
if not args then return end
|
||||||
if error then
|
|
||||||
show_message("HTTP 请求失败,打开控制台查看详情", 5)
|
fetch_danmaku_data(args, function(data)
|
||||||
msg.error(error)
|
if not data or not data["comments"] or data["count"] <= 1 then
|
||||||
|
msg.info("备用服务器无数据或返回格式不正确")
|
||||||
|
show_message("备用服务器无数据或返回格式不正确", 3)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
if file_exists(danmaku_xml) then
|
|
||||||
if query:find("iqiyi%.com") ~= nil then
|
save_danmaku_data(data["comments"], query, "user_custom")
|
||||||
DANMAKU.strict = true
|
load_danmaku(true)
|
||||||
end
|
|
||||||
save_danmaku_downloaded(query, danmaku_xml)
|
|
||||||
load_danmaku(true)
|
|
||||||
end
|
|
||||||
end)
|
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))
|
table.insert(args, string.format('X-Timestamp: %s', time))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
if options.proxy ~= "" then
|
||||||
|
table.insert(args, '-x')
|
||||||
|
table.insert(args, options.proxy)
|
||||||
|
end
|
||||||
|
|
||||||
table.insert(args, url)
|
table.insert(args, url)
|
||||||
|
|
||||||
return args
|
return args
|
||||||
end
|
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 function match_episode(animeTitle, bangumiId, episode_num)
|
||||||
local url = options.api_server .. "/api/v2/bangumi/" .. bangumiId
|
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) .. "季"
|
target_title = title .. " 第" .. number_to_chinese(season_num) .. "季"
|
||||||
end
|
end
|
||||||
for _, anime in ipairs(animes) do
|
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 .. " 第一季"
|
target_title = title .. " 第一季"
|
||||||
end
|
end
|
||||||
local score = jaro_winkler(target_title, anime.animeTitle)
|
local score = jaro_winkler(target_title, animeTitle)
|
||||||
msg.debug(("候选: %s -> 相似度 %.3f"):format(anime.animeTitle, score))
|
msg.debug(("候选: %s -> 相似度 %.3f"):format(animeTitle, score))
|
||||||
if score > best_score then
|
if score > best_score then
|
||||||
best_score = score
|
best_score = score
|
||||||
best_match = anime
|
best_match = anime
|
||||||
@@ -276,7 +305,7 @@ local function match_file(file_path, file_name, callback)
|
|||||||
["Content-Type"] = "application/json"
|
["Content-Type"] = "application/json"
|
||||||
}, {
|
}, {
|
||||||
fileName = file_name,
|
fileName = file_name,
|
||||||
fileHash = hash or "",
|
fileHash = hash or "a1b2c3d4e5f67890abcd1234ef567890",
|
||||||
matchMode = "hashAndFileName"
|
matchMode = "hashAndFileName"
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -291,7 +320,7 @@ local function match_file(file_path, file_name, callback)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
local data = utils.parse_json(json)
|
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("没有匹配的剧集")
|
callback("没有匹配的剧集")
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@@ -314,48 +343,39 @@ function fetch_danmaku_data(args, callback)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
local data = utils.parse_json(json)
|
local data = utils.parse_json(json)
|
||||||
|
data = normalize_danmaku_response(data)
|
||||||
callback(data)
|
callback(data)
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- 保存弹幕数据
|
-- 保存弹幕数据
|
||||||
function save_danmaku_data(comments, query, danmaku_source)
|
function save_danmaku_data(comments, query, danmaku_source)
|
||||||
local temp_file = "danmaku-" .. PID .. DANMAKU.count .. ".json"
|
local danmaku_list = save_danmaku_to_list(comments)
|
||||||
local danmaku_file = utils.join_path(DANMAKU_PATH, temp_file)
|
|
||||||
DANMAKU.count = DANMAKU.count + 1
|
|
||||||
local success = save_danmaku_json(comments, danmaku_file)
|
|
||||||
|
|
||||||
if success then
|
if DANMAKU.sources[query] ~= nil then
|
||||||
if DANMAKU.sources[query] ~= nil then
|
DANMAKU.sources[query]["data"] = danmaku_list
|
||||||
if DANMAKU.sources[query].fname and file_exists(DANMAKU.sources[query].fname) then
|
else
|
||||||
os.remove(DANMAKU.sources[query].fname)
|
DANMAKU.sources[query] = {from = danmaku_source, data = danmaku_list}
|
||||||
end
|
|
||||||
DANMAKU.sources[query]["fname"] = danmaku_file
|
|
||||||
else
|
|
||||||
DANMAKU.sources[query] = {from = danmaku_source, fname = danmaku_file}
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
function save_danmaku_downloaded(url, downloaded_file)
|
function save_danmaku_downloaded(url, downloaded_file)
|
||||||
|
local danmaku_list = parse_danmaku_file(downloaded_file)
|
||||||
|
if file_exists(downloaded_file) then
|
||||||
|
os.remove(downloaded_file)
|
||||||
|
end
|
||||||
if DANMAKU.sources[url] ~= nil then
|
if DANMAKU.sources[url] ~= nil then
|
||||||
if DANMAKU.sources[url].fname and file_exists(DANMAKU.sources[url].fname) then
|
DANMAKU.sources[url]["data"] = danmaku_list
|
||||||
os.remove(DANMAKU.sources[url].fname)
|
|
||||||
end
|
|
||||||
DANMAKU.sources[url]["fname"] = downloaded_file
|
|
||||||
else
|
else
|
||||||
DANMAKU.sources[url] = {from = "user_custom", fname = downloaded_file}
|
DANMAKU.sources[url] = {from = "user_custom", data = danmaku_list}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- 处理弹幕数据
|
-- 处理弹幕数据
|
||||||
function handle_danmaku_data(query, data, from_menu)
|
function handle_danmaku_data(query, data, from_menu)
|
||||||
local comments = data["comments"]
|
|
||||||
local count = data["count"]
|
|
||||||
|
|
||||||
-- 如果没有数据,进行重试
|
-- 如果没有数据,进行重试
|
||||||
if count == 0 then
|
if not data or not data["comments"] or data["count"] <= 1 then
|
||||||
show_message("服务器无缓存数据,再次尝试请求", 30)
|
show_message("服务器无缓存数据,再次尝试请求", 10)
|
||||||
msg.verbose("服务器无缓存数据,再次尝试请求")
|
msg.verbose("服务器无缓存数据,再次尝试请求")
|
||||||
-- 等待 2 秒后重试
|
-- 等待 2 秒后重试
|
||||||
local start = os.time()
|
local start = os.time()
|
||||||
@@ -371,7 +391,7 @@ function handle_danmaku_data(query, data, from_menu)
|
|||||||
end
|
end
|
||||||
|
|
||||||
fetch_danmaku_data(args, function(retry_data)
|
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)
|
get_danmaku_fallback(query)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@@ -379,87 +399,11 @@ function handle_danmaku_data(query, data, from_menu)
|
|||||||
load_danmaku(from_menu)
|
load_danmaku(from_menu)
|
||||||
end)
|
end)
|
||||||
else
|
else
|
||||||
save_danmaku_data(comments, query, "user_custom")
|
save_danmaku_data(data["comments"], query, "user_custom")
|
||||||
load_danmaku(from_menu)
|
load_danmaku(from_menu)
|
||||||
end
|
end
|
||||||
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)
|
function handle_fetched_danmaku(data, url, from_menu)
|
||||||
if data and data["comments"] then
|
if data and data["comments"] then
|
||||||
@@ -481,8 +425,8 @@ end
|
|||||||
|
|
||||||
-- 匹配弹幕库 comment, 仅匹配dandan本身弹幕库
|
-- 匹配弹幕库 comment, 仅匹配dandan本身弹幕库
|
||||||
-- 通过danmaku api(url)+id获取弹幕
|
-- 通过danmaku api(url)+id获取弹幕
|
||||||
function fetch_danmaku(episodeId, from_menu)
|
function fetch_danmaku(episodeId, from_menu, api_server)
|
||||||
local url = options.api_server .. "/api/v2/comment/" .. episodeId .. "?withRelated=true&chConvert=0"
|
local url = (api_server or options.api_server) .. "/api/v2/comment/" .. episodeId .. "?withRelated=true&chConvert=0"
|
||||||
show_message("弹幕加载中...", 30)
|
show_message("弹幕加载中...", 30)
|
||||||
msg.verbose("尝试获取弹幕:" .. url)
|
msg.verbose("尝试获取弹幕:" .. url)
|
||||||
local args = make_danmaku_request_args("GET", url)
|
local args = make_danmaku_request_args("GET", url)
|
||||||
@@ -496,57 +440,6 @@ function fetch_danmaku(episodeId, from_menu)
|
|||||||
end)
|
end)
|
||||||
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)
|
function addon_danmaku(dir, from_menu)
|
||||||
if dir then
|
if dir then
|
||||||
@@ -587,19 +480,16 @@ function add_danmaku_source_local(query, from_menu)
|
|||||||
msg.warn("无效的文件路径")
|
msg.warn("无效的文件路径")
|
||||||
return
|
return
|
||||||
end
|
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("仅支持弹幕文件")
|
msg.warn("仅支持弹幕文件")
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
if DANMAKU.sources[query] ~= nil 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]["from"] = "user_local"
|
DANMAKU.sources[query]["from"] = "user_local"
|
||||||
DANMAKU.sources[query]["fname"] = path
|
DANMAKU.sources[query]["data"] = parse_danmaku_file(path)
|
||||||
else
|
else
|
||||||
DANMAKU.sources[query] = {from = "user_local", fname = path}
|
DANMAKU.sources[query] = {from = "user_local", data = parse_danmaku_file(path)}
|
||||||
end
|
end
|
||||||
|
|
||||||
set_danmaku_button()
|
set_danmaku_button()
|
||||||
@@ -619,53 +509,41 @@ function add_danmaku_source_online(query, from_menu)
|
|||||||
end
|
end
|
||||||
|
|
||||||
fetch_danmaku_data(args, function(data)
|
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)
|
handle_danmaku_data(query, data, from_menu)
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- 将弹幕转换为factory可读的json格式
|
-- 将弹幕转换为 Lua table
|
||||||
function save_danmaku_json(comments, json_filename)
|
function save_danmaku_to_list(comments)
|
||||||
local temp_file = "danmaku-" .. PID .. ".json"
|
local danmaku_list = {}
|
||||||
json_filename = json_filename or utils.join_path(DANMAKU_PATH, temp_file)
|
|
||||||
local json_file = io.open(json_filename, "w")
|
|
||||||
|
|
||||||
if json_file then
|
for _, comment in ipairs(comments) do
|
||||||
json_file:write("[\n")
|
local p = comment["p"]
|
||||||
for _, comment in ipairs(comments) do
|
local shift = comment["shift"]
|
||||||
local p = comment["p"]
|
if p then
|
||||||
local shift = comment["shift"]
|
local fields = split(p, ",")
|
||||||
if p then
|
if shift ~= nil then
|
||||||
local fields = split(p, ",")
|
fields[1] = tonumber(fields[1]) + tonumber(shift)
|
||||||
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 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)
|
|
||||||
end
|
end
|
||||||
|
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("\"", "")
|
||||||
|
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
|
end
|
||||||
|
|
||||||
return false
|
return danmaku_list
|
||||||
end
|
end
|
||||||
|
|
||||||
-- 通过文件前 16M 的 hash 值进行弹幕匹配
|
-- 通过文件前 16M 的 hash 值进行弹幕匹配
|
||||||
@@ -732,4 +610,4 @@ function get_danmaku_with_hash(file_name, file_path)
|
|||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -12,9 +12,18 @@ local function load_extra_danmaku(url, episode, number, class, id, site, title,
|
|||||||
local play_url = nil
|
local play_url = nil
|
||||||
if url:match("^.-%.html") then
|
if url:match("^.-%.html") then
|
||||||
play_url = url:match("^(.-%.html).*")
|
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$",""):gsub("&.*$","")
|
||||||
|
end
|
||||||
else
|
else
|
||||||
play_url = url:gsub("%?bsource=360ogvys$","")
|
play_url = url:gsub("%?bsource=360ogvys$",""):gsub("&.*$","")
|
||||||
end
|
end
|
||||||
|
|
||||||
ENABLED = true
|
ENABLED = true
|
||||||
DANMAKU.anime = title .. " (" .. year .. ")"
|
DANMAKU.anime = title .. " (" .. year .. ")"
|
||||||
DANMAKU.episode = "第" .. episode .. "话"
|
DANMAKU.episode = "第" .. episode .. "话"
|
||||||
@@ -34,7 +43,7 @@ end
|
|||||||
|
|
||||||
local function query_tmdb(title, class, menu)
|
local function query_tmdb(title, class, menu)
|
||||||
local encoded_title = url_encode(title)
|
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)
|
class, Base64.decode(options.tmdb_api_key), encoded_title)
|
||||||
|
|
||||||
local cmd = {
|
local cmd = {
|
||||||
@@ -98,6 +107,63 @@ local function get_number(cat, id, site)
|
|||||||
return nil
|
return nil
|
||||||
end
|
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)
|
function get_details(class, id, site, title, year, number, episodenum)
|
||||||
local message = episodenum and "查询弹幕中..." or "加载数据中..."
|
local message = episodenum and "查询弹幕中..." or "加载数据中..."
|
||||||
local menu_type = "menu_details"
|
local menu_type = "menu_details"
|
||||||
@@ -120,69 +186,91 @@ function get_details(class, id, site, title, year, number, episodenum)
|
|||||||
cat = 4
|
cat = 4
|
||||||
end
|
end
|
||||||
|
|
||||||
if not number and cat ~= 0 then
|
|
||||||
number = get_number(cat, id, site)
|
|
||||||
end
|
|
||||||
if not number or cat == 0 then
|
|
||||||
local message = "无结果"
|
|
||||||
if uosc_available and not episodenum then
|
|
||||||
update_menu_uosc(menu_type, menu_title, message, footnote)
|
|
||||||
else
|
|
||||||
show_message(message, 3)
|
|
||||||
end
|
|
||||||
msg.verbose("无结果")
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
local url = string.format("https://api.web.360kan.com/v1/detail?cat=%s&id=%s&start=1&end=%s&site=%s",
|
|
||||||
cat, id, number, site)
|
|
||||||
|
|
||||||
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
|
|
||||||
local message = "无结果"
|
|
||||||
if uosc_available and not episodenum then
|
|
||||||
update_menu_uosc(menu_type, menu_title, message, footnote)
|
|
||||||
else
|
|
||||||
show_message(message, 3)
|
|
||||||
end
|
|
||||||
msg.verbose("无结果")
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
local result = utils.parse_json(res.stdout)
|
|
||||||
local items = {}
|
local items = {}
|
||||||
if result and result.data and result.data.allepidetail then
|
local episodes = nil
|
||||||
local data = result.data.allepidetail
|
if cat == 2 or cat == 4 then
|
||||||
local playurl, episode = nil, nil
|
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
|
||||||
|
if not number or cat == 0 then
|
||||||
|
local message = "无结果"
|
||||||
|
if uosc_available and not episodenum then
|
||||||
|
update_menu_uosc(menu_type, menu_title, message, footnote)
|
||||||
|
else
|
||||||
|
show_message(message, 3)
|
||||||
|
end
|
||||||
|
msg.verbose("无结果")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local url = string.format("https://api.web.360kan.com/v1/detail?cat=%s&id=%s&start=1&end=%s&site=%s",
|
||||||
|
cat, id, number, site)
|
||||||
|
|
||||||
|
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
|
||||||
|
local message = "无结果"
|
||||||
|
if uosc_available and not episodenum then
|
||||||
|
update_menu_uosc(menu_type, menu_title, message, footnote)
|
||||||
|
else
|
||||||
|
show_message(message, 3)
|
||||||
|
end
|
||||||
|
msg.verbose("无结果")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local result = utils.parse_json(res.stdout)
|
||||||
|
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
|
if episodenum then
|
||||||
for _, item in ipairs(data[site]) do
|
for _, ep in ipairs(episode_rows) do
|
||||||
if tonumber(item.playlink_num) == tonumber(episodenum) then
|
if tonumber(ep.index) == tonumber(episodenum) then
|
||||||
playurl = item.url
|
load_extra_danmaku(ep.url, ep.index, number, class, id, site, title, year)
|
||||||
episode = item.playlink_num
|
return
|
||||||
break
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
if playurl then
|
|
||||||
load_extra_danmaku(playurl, episode, number, class, id, site, title, year)
|
|
||||||
return
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
for _, item in ipairs(data[site]) do
|
|
||||||
|
table.insert(items, {
|
||||||
|
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, {
|
table.insert(items, {
|
||||||
title = "第" .. item.playlink_num .. "集",
|
title = "第" .. ep.index .. "集",
|
||||||
hint = item.playlink_num,
|
hint = ep.index,
|
||||||
value = {
|
value = {
|
||||||
"script-message-to",
|
"script-message-to",
|
||||||
mp.get_script_name(),
|
mp.get_script_name(),
|
||||||
"add-extra-event",
|
"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
|
end
|
||||||
@@ -257,7 +345,7 @@ local function search_query(query, class, menu)
|
|||||||
end
|
end
|
||||||
if #items > 0 then
|
if #items > 0 then
|
||||||
if uosc_available 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
|
else
|
||||||
show_message("", 0)
|
show_message("", 0)
|
||||||
mp.add_timeout(0.1, function()
|
mp.add_timeout(0.1, function()
|
||||||
@@ -344,4 +432,4 @@ mp.register_script_message("add-extra-event", function(url, episode, number, cla
|
|||||||
mp.commandv("script-message-to", "uosc", "close-menu", "menu_details")
|
mp.commandv("script-message-to", "uosc", "close-menu", "menu_details")
|
||||||
end
|
end
|
||||||
load_extra_danmaku(url, episode, number, class, id, site, title, year)
|
load_extra_danmaku(url, episode, number, class, id, site, title, year)
|
||||||
end)
|
end)
|
||||||
@@ -3979,4 +3979,4 @@ return {
|
|||||||
["誉"] = "譽",
|
["誉"] = "譽",
|
||||||
["𫐖"] = "轇",
|
["𫐖"] = "轇",
|
||||||
["输"] = "輸",
|
["输"] = "輸",
|
||||||
}
|
}
|
||||||
@@ -4112,4 +4112,4 @@ return {
|
|||||||
["𪷓"] = "𣶭",
|
["𪷓"] = "𣶭",
|
||||||
["𫒡"] = "𫓷",
|
["𫒡"] = "𫓷",
|
||||||
["𫜦"] = "𫜫",
|
["𫜦"] = "𫜫",
|
||||||
}
|
}
|
||||||
+114
-103
@@ -25,11 +25,11 @@ DANMAKU_PATH = os.getenv("TEMP") or "/tmp/"
|
|||||||
HISTORY_PATH = mp.command_native({"expand-path", options.history_path})
|
HISTORY_PATH = mp.command_native({"expand-path", options.history_path})
|
||||||
PID = utils.getpid()
|
PID = utils.getpid()
|
||||||
DANMAKU = {sources = {}, count = 1}
|
DANMAKU = {sources = {}, count = 1}
|
||||||
DELAYS = {}
|
|
||||||
ENABLED, COMMENTS, DELAY = false, nil, 0
|
ENABLED, COMMENTS, DELAY = false, nil, 0
|
||||||
DELAY_PROPERTY = string.format("user-data/%s/danmaku-delay", mp.get_script_name())
|
DELAY_PROPERTY = string.format("user-data/%s/danmaku-delay", mp.get_script_name())
|
||||||
mp.set_property_native(DELAY_PROPERTY, 0)
|
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({
|
KEY = table_to_zero_indexed({
|
||||||
0x00,0x01,0x02,0x03,0x04,
|
0x00,0x01,0x02,0x03,0x04,
|
||||||
0x05,0x06,0x07,0x08,0x09,
|
0x05,0x06,0x07,0x08,0x09,
|
||||||
@@ -58,6 +58,8 @@ PLATFORM = (function()
|
|||||||
return "linux"
|
return "linux"
|
||||||
end)()
|
end)()
|
||||||
|
|
||||||
|
local rebuild_convert_timer = nil
|
||||||
|
|
||||||
function get_danmaku_visibility()
|
function get_danmaku_visibility()
|
||||||
local history_json = read_file(HISTORY_PATH)
|
local history_json = read_file(HISTORY_PATH)
|
||||||
local history
|
local history
|
||||||
@@ -140,21 +142,6 @@ local function extract_between_colons(input_string)
|
|||||||
end
|
end
|
||||||
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)
|
local function get_type_from_position(position)
|
||||||
if position == 0 then
|
if position == 0 then
|
||||||
return 1
|
return 1
|
||||||
@@ -170,11 +157,13 @@ end
|
|||||||
function get_delay_for_time(delay_segments, time)
|
function get_delay_for_time(delay_segments, time)
|
||||||
if not delay_segments or #delay_segments == 0 then return 0 end
|
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
|
local applied_delay = 0
|
||||||
for i = 1, #delay_segments do
|
for i = 1, #segs do
|
||||||
local seg = delay_segments[i]
|
local seg = segs[i]
|
||||||
local delay = tonumber(seg.delay)
|
local delay = tonumber(seg.delay)
|
||||||
if time >= seg.start and delay then
|
if time >= seg.start and delay then
|
||||||
applied_delay = applied_delay + delay
|
applied_delay = applied_delay + delay
|
||||||
@@ -244,9 +233,29 @@ local function merge_delay_segments(segments)
|
|||||||
return merged
|
return merged
|
||||||
end
|
end
|
||||||
|
|
||||||
local function set_danmaku_delay(dly, time)
|
function parse_delay_input(text)
|
||||||
for url, source in pairs(DANMAKU.sources) do
|
if not text then return nil end
|
||||||
if source.fname and not source.blocked then
|
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 {}
|
source.delay_segments = source.delay_segments or {}
|
||||||
if dly == 0 then
|
if dly == 0 then
|
||||||
source.delay_segments = {}
|
source.delay_segments = {}
|
||||||
@@ -255,32 +264,52 @@ local function set_danmaku_delay(dly, time)
|
|||||||
else
|
else
|
||||||
table.insert(source.delay_segments, {start = 0, delay = dly})
|
table.insert(source.delay_segments, {start = 0, delay = dly})
|
||||||
end
|
end
|
||||||
|
|
||||||
source.delay = nil
|
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)
|
add_source_to_history(specific_source, source)
|
||||||
end
|
end
|
||||||
end
|
|
||||||
|
|
||||||
if time then
|
|
||||||
table.insert(DELAYS, {start = time, delay = dly})
|
|
||||||
else
|
else
|
||||||
table.insert(DELAYS, {start = 0, delay = dly})
|
for url, source in pairs(DANMAKU.sources) do
|
||||||
|
if 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(url, source)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
if dly == 0 then
|
if dly == 0 then
|
||||||
DELAY = 0
|
DELAY = 0
|
||||||
DELAYS = {}
|
|
||||||
else
|
else
|
||||||
DELAY = DELAY + dly
|
DELAY = DELAY + dly
|
||||||
end
|
end
|
||||||
|
|
||||||
DELAYS = merge_delay_segments(DELAYS)
|
|
||||||
|
|
||||||
if ENABLED and COMMENTS ~= nil then
|
if ENABLED and COMMENTS ~= nil then
|
||||||
render()
|
render()
|
||||||
end
|
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')
|
show_message('设置弹幕延迟: ' .. string.format("%.1f", DELAY + 1e-10) .. ' s')
|
||||||
mp.set_property_native(DELAY_PROPERTY, DELAY)
|
mp.set_property_native(DELAY_PROPERTY, DELAY)
|
||||||
end
|
end
|
||||||
@@ -299,9 +328,6 @@ local function clear_source()
|
|||||||
|
|
||||||
for url, source in pairs(DANMAKU.sources) do
|
for url, source in pairs(DANMAKU.sources) do
|
||||||
if source.from == "user_custom" then
|
if source.from == "user_custom" then
|
||||||
if source.fname and file_exists(source.fname) then
|
|
||||||
os.remove(source.fname)
|
|
||||||
end
|
|
||||||
DANMAKU.sources[url] = nil
|
DANMAKU.sources[url] = nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -312,7 +338,7 @@ local function clear_source()
|
|||||||
msg.verbose("已重置当前视频所有弹幕源更改")
|
msg.verbose("已重置当前视频所有弹幕源更改")
|
||||||
end
|
end
|
||||||
|
|
||||||
function write_history(episodeid)
|
function write_history(episodeid, api_server)
|
||||||
local history = {}
|
local history = {}
|
||||||
local path = mp.get_property("path")
|
local path = mp.get_property("path")
|
||||||
local dir = get_parent_directory(path)
|
local dir = get_parent_directory(path)
|
||||||
@@ -353,6 +379,9 @@ function write_history(episodeid)
|
|||||||
elseif DANMAKU.extra then
|
elseif DANMAKU.extra then
|
||||||
history[dir].extra = DANMAKU.extra
|
history[dir].extra = DANMAKU.extra
|
||||||
end
|
end
|
||||||
|
if api_server then
|
||||||
|
history[dir].api_server = api_server
|
||||||
|
end
|
||||||
write_json_file(HISTORY_PATH, history)
|
write_json_file(HISTORY_PATH, history)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -401,6 +430,11 @@ function add_source_to_history(add_url, add_source)
|
|||||||
local record = history[path]["sources"][add_url]
|
local record = history[path]["sources"][add_url]
|
||||||
record.from = add_source.from or "user_custom"
|
record.from = add_source.from or "user_custom"
|
||||||
record.blocked = add_source.blocked or false
|
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 {})
|
local delay_segments = shallow_copy(add_source.delay_segments or {})
|
||||||
if #delay_segments > 0 then
|
if #delay_segments > 0 then
|
||||||
@@ -455,6 +489,7 @@ function read_danmaku_source_record(path)
|
|||||||
blocked = blocked,
|
blocked = blocked,
|
||||||
delay_segments = delay_segments,
|
delay_segments = delay_segments,
|
||||||
from_history = true,
|
from_history = true,
|
||||||
|
api_server = data.api_server,
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
@@ -484,6 +519,7 @@ function read_danmaku_source_record(path)
|
|||||||
blocked = blocked,
|
blocked = blocked,
|
||||||
delay_segments = delay_segments,
|
delay_segments = delay_segments,
|
||||||
from_history = true,
|
from_history = true,
|
||||||
|
api_server = record.api_server,
|
||||||
}
|
}
|
||||||
|
|
||||||
upgraded_sources[source] = shallow_copy(DANMAKU.sources[source])
|
upgraded_sources[source] = shallow_copy(DANMAKU.sources[source])
|
||||||
@@ -496,39 +532,8 @@ function read_danmaku_source_record(path)
|
|||||||
end
|
end
|
||||||
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)
|
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 path = mp.get_property("path")
|
||||||
local dir = get_parent_directory(path) or ""
|
local dir = get_parent_directory(path) or ""
|
||||||
local filename = mp.get_property('filename/no-ext')
|
local filename = mp.get_property('filename/no-ext')
|
||||||
@@ -544,7 +549,7 @@ function save_danmaku(not_forced)
|
|||||||
msg.info("已存在同名弹幕文件:" .. danmaku_out)
|
msg.info("已存在同名弹幕文件:" .. danmaku_out)
|
||||||
return
|
return
|
||||||
else
|
else
|
||||||
convert_danmaku_to_xml(danmaku_input, danmaku_out, delays)
|
convert_danmaku_to_xml(danmaku_out)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -552,19 +557,8 @@ end
|
|||||||
-- 加载弹幕
|
-- 加载弹幕
|
||||||
function load_danmaku(from_menu, no_osd)
|
function load_danmaku(from_menu, no_osd)
|
||||||
if not ENABLED then return end
|
if not ENABLED then return end
|
||||||
local temp_file = "danmaku-" .. PID .. ".ass"
|
convert_danmaku_to_ass_events()
|
||||||
local danmaku_file = utils.join_path(DANMAKU_PATH, temp_file)
|
render_danmaku(from_menu, no_osd)
|
||||||
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)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
-- 为 bilibli 网站的视频播放加载弹幕
|
-- 为 bilibli 网站的视频播放加载弹幕
|
||||||
@@ -620,6 +614,11 @@ function load_danmaku_for_bilibili(path)
|
|||||||
url,
|
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)
|
call_cmd_async(arg, function(error)
|
||||||
async_running = false
|
async_running = false
|
||||||
if error then
|
if error then
|
||||||
@@ -673,6 +672,11 @@ function load_danmaku_for_bahamut(path)
|
|||||||
table.insert(arg, options.proxy)
|
table.insert(arg, options.proxy)
|
||||||
end
|
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)
|
call_cmd_async(arg, function(error)
|
||||||
async_running = false
|
async_running = false
|
||||||
if error then
|
if error then
|
||||||
@@ -688,30 +692,33 @@ function load_danmaku_for_bahamut(path)
|
|||||||
end
|
end
|
||||||
|
|
||||||
local comments_json = read_file(danmaku_json)
|
local comments_json = read_file(danmaku_json)
|
||||||
|
os.remove(danmaku_json)
|
||||||
local comments = utils.parse_json(comments_json)
|
local comments = utils.parse_json(comments_json)
|
||||||
if not comments then
|
if not comments then
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local output_table = {}
|
||||||
|
for _, comment in ipairs(comments) do
|
||||||
|
local color = hex_to_int_color(comment["color"])
|
||||||
|
local mode = get_type_from_position(comment["position"])
|
||||||
|
local time = tonumber(comment["time"]) / 10
|
||||||
|
local c_param = string.format("%s,%s,%s,25,,,", time, color, mode)
|
||||||
|
table.insert(output_table, {
|
||||||
|
c = c_param,
|
||||||
|
m = comment["text"]
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
local final_json_str = utils.format_json(output_table)
|
||||||
|
|
||||||
temp_file = "danmaku-" .. PID .. DANMAKU.count .. ".json"
|
temp_file = "danmaku-" .. PID .. DANMAKU.count .. ".json"
|
||||||
local json_filename = utils.join_path(DANMAKU_PATH, temp_file)
|
local json_filename = utils.join_path(DANMAKU_PATH, temp_file)
|
||||||
DANMAKU.count = DANMAKU.count + 1
|
DANMAKU.count = DANMAKU.count + 1
|
||||||
|
|
||||||
local json_file = io.open(json_filename, "w")
|
local json_file = io.open(json_filename, "w")
|
||||||
|
|
||||||
if json_file then
|
if json_file then
|
||||||
json_file:write("[\n")
|
json_file:write(final_json_str)
|
||||||
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)
|
|
||||||
end
|
|
||||||
json_file:write("]")
|
|
||||||
json_file:close()
|
json_file:close()
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -890,22 +897,27 @@ end)
|
|||||||
mp.register_script_message("danmaku-delay", function(...)
|
mp.register_script_message("danmaku-delay", function(...)
|
||||||
local commands = {...}
|
local commands = {...}
|
||||||
local delay_str, time_str = commands[1], commands[2]
|
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)
|
local time = time_str and tonumber(time_str)
|
||||||
if type(dly) ~= "number" then
|
if type(dly) ~= "number" then
|
||||||
show_message("参数错误:缺少有效的延迟秒数", 3)
|
show_message("参数错误:缺少有效的延迟秒数", 3)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
set_danmaku_delay(dly, time)
|
if source_arg and source_arg ~= "nil" then
|
||||||
|
set_danmaku_delay(dly, time, source_arg)
|
||||||
|
else
|
||||||
|
set_danmaku_delay(dly, time)
|
||||||
|
end
|
||||||
end)
|
end)
|
||||||
|
|
||||||
mp.register_script_message("show_danmaku_keyboard", function()
|
mp.register_script_message("show_danmaku_keyboard", function()
|
||||||
ENABLED = not ENABLED
|
ENABLED = not ENABLED
|
||||||
if ENABLED then
|
if ENABLED then
|
||||||
mp.commandv("script-message-to", "uosc", "set", "show_danmaku", "on")
|
mp.commandv("script-message-to", "uosc", "set", "show_danmaku", "on")
|
||||||
set_danmaku_visibility(true)
|
|
||||||
if COMMENTS == nil then
|
if COMMENTS == nil then
|
||||||
show_message("加载弹幕初始化...", 3)
|
show_message("加载弹幕初始化...", 3)
|
||||||
|
set_danmaku_visibility(true)
|
||||||
local path = mp.get_property("path")
|
local path = mp.get_property("path")
|
||||||
init(path)
|
init(path)
|
||||||
else
|
else
|
||||||
@@ -915,7 +927,6 @@ mp.register_script_message("show_danmaku_keyboard", function()
|
|||||||
else
|
else
|
||||||
show_message("关闭弹幕", 2)
|
show_message("关闭弹幕", 2)
|
||||||
mp.commandv("script-message-to", "uosc", "set", "show_danmaku", "off")
|
mp.commandv("script-message-to", "uosc", "set", "show_danmaku", "off")
|
||||||
set_danmaku_visibility(false)
|
|
||||||
hide_danmaku_func()
|
hide_danmaku_func()
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
@@ -923,7 +934,7 @@ end)
|
|||||||
mp.register_script_message("check-update", check_for_update)
|
mp.register_script_message("check-update", check_for_update)
|
||||||
mp.register_script_message("clear-source", clear_source)
|
mp.register_script_message("clear-source", clear_source)
|
||||||
mp.register_script_message("immediately_save_danmaku", save_danmaku)
|
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_search_danmaku_menu", open_input_menu)
|
||||||
mp.register_script_message("open_add_source_menu", open_add_menu)
|
mp.register_script_message("open_add_source_menu", open_add_menu)
|
||||||
mp.register_script_message("open_add_total_menu", open_add_total_menu)
|
mp.register_script_message("open_add_total_menu", open_add_total_menu)
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
local unpack = unpack or table.unpack
|
||||||
|
|
||||||
-- Clean up media name
|
-- Clean up media name
|
||||||
local function clean_name(name)
|
local function clean_name(name)
|
||||||
return name:gsub("^%[.-%]", " ")
|
return name:gsub("^%[.-%]", " ")
|
||||||
@@ -19,12 +21,6 @@ local formatters = {
|
|||||||
return clean_name(name) .. " S" .. season .. "E" .. episode:gsub("v%d+$","")
|
return clean_name(name) .. " S" .. season .. "E" .. episode:gsub("v%d+$","")
|
||||||
end
|
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*[话集回]",
|
regex = "^(.-)%s*[_%-%.%s]%s*第([一二三四五六七八九十]+)[季部]+%s*[_%-%.%s]%s*第%s*(%d+[%.v]?%d*)%s*[话集回]",
|
||||||
format = function(name, season, episode)
|
format = function(name, season, episode)
|
||||||
@@ -32,7 +28,13 @@ local formatters = {
|
|||||||
end
|
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)
|
format = function(name, season, episode)
|
||||||
return clean_name(name) .. " S" .. chinese_to_number(season) .. "E" .. episode:gsub("v%d+$","")
|
return clean_name(name) .. " S" .. chinese_to_number(season) .. "E" .. episode:gsub("v%d+$","")
|
||||||
end
|
end
|
||||||
@@ -54,7 +56,7 @@ local formatters = {
|
|||||||
end
|
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)
|
format = function(name, year, episode)
|
||||||
return clean_name(name) .. " (" .. year .. ") E" .. episode
|
return clean_name(name) .. " (" .. year .. ") E" .. episode
|
||||||
end
|
end
|
||||||
@@ -78,13 +80,13 @@ local formatters = {
|
|||||||
end
|
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)
|
format = function(name, episode, year)
|
||||||
return clean_name(name) .. " (" .. year .. ") E" .. episode:gsub("v%d+$","")
|
return clean_name(name) .. " (" .. year .. ") E" .. episode:gsub("v%d+$","")
|
||||||
end
|
end
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
regex = "^(.-)%s*[^dD][eEpP]+[_%-%.%s]?(%d+%.?%d*)",
|
regex = "^(.-)%s*[^%ddD][eEpP]+(%d+%.?%d*)",
|
||||||
format = function(name, episode)
|
format = function(name, episode)
|
||||||
return clean_name(name) .. " E" .. episode
|
return clean_name(name) .. " E" .. episode
|
||||||
end
|
end
|
||||||
@@ -154,4 +156,4 @@ function format_filename(title)
|
|||||||
return title
|
return title
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -189,4 +189,4 @@ local function sha256(message)
|
|||||||
return result
|
return result
|
||||||
end
|
end
|
||||||
|
|
||||||
return sha256
|
return sha256
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
local msg = require('mp.msg')
|
local msg = require('mp.msg')
|
||||||
local utils = require("mp.utils")
|
local utils = require("mp.utils")
|
||||||
|
local unpack = unpack or table.unpack
|
||||||
|
|
||||||
input_loaded, input = pcall(require, "mp.input")
|
input_loaded, input = pcall(require, "mp.input")
|
||||||
uosc_available = false
|
uosc_available = false
|
||||||
|
latest_menu_anime = {}
|
||||||
|
|
||||||
-- 打开番剧数据匹配菜单
|
-- 打开番剧数据匹配菜单
|
||||||
function get_animes(query)
|
function get_animes(query)
|
||||||
@@ -69,10 +71,11 @@ function get_animes(query)
|
|||||||
end
|
end
|
||||||
|
|
||||||
if uosc_available then
|
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
|
elseif input_loaded then
|
||||||
show_message("", 0)
|
show_message("", 0)
|
||||||
mp.add_timeout(0.1, function()
|
mp.add_timeout(0.1, function()
|
||||||
|
latest_menu_anime = utils.format_json(items)
|
||||||
open_menu_select(items)
|
open_menu_select(items)
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
@@ -124,6 +127,13 @@ function get_episodes(animeTitle, bangumiId)
|
|||||||
return
|
return
|
||||||
end
|
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
|
for _, episode in ipairs(response.bangumi.episodes) do
|
||||||
table.insert(items, {
|
table.insert(items, {
|
||||||
title = episode.episodeTitle,
|
title = episode.episodeTitle,
|
||||||
@@ -136,6 +146,7 @@ function get_episodes(animeTitle, bangumiId)
|
|||||||
end
|
end
|
||||||
|
|
||||||
if uosc_available then
|
if uosc_available then
|
||||||
|
footnote = mp.get_property("filename")
|
||||||
update_menu_uosc(menu_type, menu_title, items, footnote)
|
update_menu_uosc(menu_type, menu_title, items, footnote)
|
||||||
elseif input_loaded then
|
elseif input_loaded then
|
||||||
mp.add_timeout(0.1, function()
|
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,
|
keep_open = true,
|
||||||
selectable = false,
|
selectable = false,
|
||||||
align = "center",
|
align = "center",
|
||||||
|
icon = "spinner",
|
||||||
})
|
})
|
||||||
else
|
else
|
||||||
items = menu_item
|
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)
|
local json_props = utils.format_json(menu_props)
|
||||||
mp.commandv("script-message-to", "uosc", "open-menu", json_props)
|
mp.commandv("script-message-to", "uosc", "open-menu", json_props)
|
||||||
|
|
||||||
|
return json_props
|
||||||
end
|
end
|
||||||
|
|
||||||
function open_menu_select(menu_items, is_time)
|
function open_menu_select(menu_items, is_time)
|
||||||
@@ -182,10 +196,16 @@ function open_menu_select(menu_items, is_time)
|
|||||||
end
|
end
|
||||||
mp.commandv('script-message-to', 'console', 'disable')
|
mp.commandv('script-message-to', 'console', 'disable')
|
||||||
input.select({
|
input.select({
|
||||||
prompt = '筛选:',
|
prompt = is_time and '筛选:' or '选择:',
|
||||||
items = item_titles,
|
items = item_titles,
|
||||||
submit = function(id)
|
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,
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
@@ -250,12 +270,114 @@ end
|
|||||||
|
|
||||||
-- 打开弹幕源添加管理菜单
|
-- 打开弹幕源添加管理菜单
|
||||||
function open_add_menu_get()
|
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({
|
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)
|
submit = function(text)
|
||||||
input.terminate()
|
text = text:gsub("^%s*(.-)%s*$", "%1")
|
||||||
mp.commandv("script-message-to", mp.get_script_name(), "add-source-event", text)
|
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
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
@@ -263,19 +385,19 @@ end
|
|||||||
function open_add_menu_uosc()
|
function open_add_menu_uosc()
|
||||||
local sources = {}
|
local sources = {}
|
||||||
for url, source in pairs(DANMAKU.sources) do
|
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,}
|
local item = {title = url, value = url, keep_open = true,}
|
||||||
if source.from == "api_server" then
|
if source.from == "api_server" then
|
||||||
if source.blocked then
|
if source.blocked then
|
||||||
item.hint = "来源:弹幕服务器(已屏蔽)"
|
item.hint = "来源:弹幕服务器(已屏蔽)"
|
||||||
item.actions = {{icon = "check", name = "unblock"},}
|
item.actions = {{icon = "check", name = "unblock", label = "解除屏蔽"},}
|
||||||
else
|
else
|
||||||
item.hint = "来源:弹幕服务器(未屏蔽)"
|
item.hint = "来源:弹幕服务器(未屏蔽)"
|
||||||
item.actions = {{icon = "not_interested", name = "block"},}
|
item.actions = {{icon = "not_interested", name = "block", label = "屏蔽"},}
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
item.hint = "来源:用户添加"
|
item.hint = "来源:用户添加"
|
||||||
item.actions = {{icon = "delete", name = "delete"},}
|
item.actions = {{icon = "delete", name = "delete", label = "删除"},}
|
||||||
end
|
end
|
||||||
table.insert(sources, item)
|
table.insert(sources, item)
|
||||||
end
|
end
|
||||||
@@ -312,13 +434,36 @@ function open_content_menu(pos)
|
|||||||
if COMMENTS ~= nil then
|
if COMMENTS ~= nil then
|
||||||
for _, event in ipairs(COMMENTS) do
|
for _, event in ipairs(COMMENTS) do
|
||||||
local text = event.clean_text:gsub("^m%s[mbl%s%-%d%.]+$", ""):gsub("^%s*(.-)%s*$", "%1")
|
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 delay = event.delay
|
||||||
local start_time = event.start_time + delay
|
local start_time = event.start_time
|
||||||
local end_time = event.end_time + delay
|
local end_time = event.end_time
|
||||||
if text and text ~= "" and start_time >= 0 and start_time <= duration then
|
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, {
|
table.insert(items, {
|
||||||
title = abbr_str(text, 60),
|
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" },
|
value = { "seek", start_time, "absolute" },
|
||||||
active = time_pos >= start_time and time_pos <= end_time,
|
active = time_pos >= start_time and time_pos <= end_time,
|
||||||
})
|
})
|
||||||
@@ -330,7 +475,9 @@ function open_content_menu(pos)
|
|||||||
type = "menu_content",
|
type = "menu_content",
|
||||||
title = "弹幕内容",
|
title = "弹幕内容",
|
||||||
footnote = "使用 / 打开搜索",
|
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)
|
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"}
|
local ordered_keys = {"bold", "fontsize", "outline", "shadow", "scrolltime", "opacity", "displayarea"}
|
||||||
|
|
||||||
-- 设置弹幕样式菜单
|
-- 设置弹幕样式菜单
|
||||||
function add_danmaku_setup(actived, status)
|
function open_style_menu_get(query, indicator)
|
||||||
if not uosc_available then
|
mp.commandv('script-message-to', 'console', 'disable')
|
||||||
show_message("无uosc UI框架,不支持使用该功能", 2)
|
local menu_log = {}
|
||||||
return
|
|
||||||
|
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
|
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 = {}
|
local items = {}
|
||||||
for _, key in ipairs(ordered_keys) do
|
for _, key in ipairs(ordered_keys) do
|
||||||
local config = menu_items_config[key]
|
local config = menu_items_config[key]
|
||||||
@@ -407,7 +676,7 @@ function add_danmaku_setup(actived, status)
|
|||||||
elseif status == "error" then
|
elseif status == "error" then
|
||||||
menu_props.title = "输入非数字字符或范围出错"
|
menu_props.title = "输入非数字字符或范围出错"
|
||||||
-- 创建一个定时器,在1秒后触发回调函数,删除搜索栏错误信息
|
-- 创建一个定时器,在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
|
end
|
||||||
menu_props.search_style = "palette"
|
menu_props.search_style = "palette"
|
||||||
menu_props.search_debounce = "submit"
|
menu_props.search_debounce = "submit"
|
||||||
@@ -419,8 +688,216 @@ function add_danmaku_setup(actived, status)
|
|||||||
mp.commandv("script-message-to", "uosc", actions, json_props)
|
mp.commandv("script-message-to", "uosc", actions, json_props)
|
||||||
end
|
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
|
if not uosc_available then
|
||||||
show_message("无uosc UI框架,不支持使用该功能", 2)
|
show_message("无uosc UI框架,不支持使用该功能", 2)
|
||||||
return
|
return
|
||||||
@@ -428,7 +905,7 @@ function danmaku_delay_setup(source_url)
|
|||||||
|
|
||||||
local sources = {}
|
local sources = {}
|
||||||
for url, source in pairs(DANMAKU.sources) do
|
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
|
local delay = 0
|
||||||
if source.delay_segments then
|
if source.delay_segments then
|
||||||
for _, seg in ipairs(source.delay_segments) do
|
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'},
|
callback = {mp.get_script_name(), 'setup-source-delay'},
|
||||||
}
|
}
|
||||||
if source_url ~= nil then
|
if source_url ~= nil then
|
||||||
menu_props.title = "请输入数字,单位(秒)/ 或者按照形如\"14m15s\"的格式输入分钟数加秒数"
|
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_style = "palette"
|
||||||
menu_props.search_debounce = "submit"
|
menu_props.search_debounce = "submit"
|
||||||
menu_props.on_search = { "script-message-to", mp.get_script_name(), "setup-source-delay", source_url }
|
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)
|
mp.commandv("script-message-to", "uosc", "open-menu", json_props)
|
||||||
end
|
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
|
||||||
|
|
||||||
-- 总集合弹幕菜单
|
-- 总集合弹幕菜单
|
||||||
|
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_danmaku_style_menu" },
|
||||||
|
{ title = "弹幕内容", action = "open_content_danmaku_menu" },
|
||||||
|
}
|
||||||
|
|
||||||
function open_add_total_menu_uosc()
|
function open_add_total_menu_uosc()
|
||||||
local items = {}
|
local items = {}
|
||||||
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_content_danmaku_menu" },
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
if DANMAKU.anime and DANMAKU.episode then
|
if DANMAKU.anime and DANMAKU.episode then
|
||||||
local episode = DANMAKU.episode:gsub("%s.-$","")
|
local episode = DANMAKU.episode:gsub("%s.-$","")
|
||||||
@@ -509,11 +1003,6 @@ end
|
|||||||
|
|
||||||
function open_add_total_menu_select()
|
function open_add_total_menu_select()
|
||||||
local item_titles, item_values = {}, {}
|
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
|
for i, config in ipairs(total_menu_items_config) do
|
||||||
item_titles[i] = config.title
|
item_titles[i] = config.title
|
||||||
item_values[i] = { "script-message-to", mp.get_script_name(), config.action }
|
item_values[i] = { "script-message-to", mp.get_script_name(), config.action }
|
||||||
@@ -537,7 +1026,7 @@ function open_add_total_menu()
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- 添加 uosc 菜单栏按钮
|
|
||||||
mp.commandv(
|
mp.commandv(
|
||||||
"script-message-to",
|
"script-message-to",
|
||||||
"uosc",
|
"uosc",
|
||||||
@@ -570,7 +1059,7 @@ mp.commandv(
|
|||||||
utils.format_json({
|
utils.format_json({
|
||||||
icon = "palette",
|
icon = "palette",
|
||||||
tooltip = "弹幕样式",
|
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
|
if value == "on" then
|
||||||
ENABLED = true
|
ENABLED = true
|
||||||
set_danmaku_visibility(true)
|
|
||||||
if COMMENTS == nil then
|
if COMMENTS == nil then
|
||||||
|
set_danmaku_visibility(true)
|
||||||
local path = mp.get_property("path")
|
local path = mp.get_property("path")
|
||||||
init(path)
|
init(path)
|
||||||
else
|
else
|
||||||
@@ -622,7 +1111,6 @@ mp.register_script_message("set", function(prop, value)
|
|||||||
else
|
else
|
||||||
show_message("关闭弹幕", 2)
|
show_message("关闭弹幕", 2)
|
||||||
ENABLED = false
|
ENABLED = false
|
||||||
set_danmaku_visibility(false)
|
|
||||||
hide_danmaku_func()
|
hide_danmaku_func()
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -664,12 +1152,13 @@ mp.register_script_message("add-source-event", function(query)
|
|||||||
add_danmaku_source(query, true)
|
add_danmaku_source(query, true)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
mp.register_script_message("open_setup_danmaku_menu", function()
|
mp.register_script_message("open_danmaku_style_menu", function()
|
||||||
if uosc_available then
|
if uosc_available then
|
||||||
mp.commandv("script-message-to", "uosc", "close-menu", "menu_total")
|
mp.commandv("script-message-to", "uosc", "close-menu", "menu_total")
|
||||||
end
|
end
|
||||||
add_danmaku_setup()
|
open_style_menu()
|
||||||
end)
|
end)
|
||||||
|
|
||||||
mp.register_script_message("open_content_danmaku_menu", function()
|
mp.register_script_message("open_content_danmaku_menu", function()
|
||||||
if uosc_available then
|
if uosc_available then
|
||||||
mp.commandv("script-message-to", "uosc", "close-menu", "menu_total")
|
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()
|
open_content_menu()
|
||||||
end)
|
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)
|
mp.register_script_message("setup-danmaku-style", function(query, text)
|
||||||
local event = utils.parse_json(query)
|
local event = utils.parse_json(query)
|
||||||
if event ~= nil then
|
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"
|
menu_items_config.bold.hint = options.bold and "true" or "false"
|
||||||
end
|
end
|
||||||
-- "updata" 模式会保留输入框文字
|
-- "updata" 模式会保留输入框文字
|
||||||
add_danmaku_setup(ordered_keys[event.index], "updata")
|
open_style_menu(ordered_keys[event.index], "updata")
|
||||||
return
|
return
|
||||||
else
|
else
|
||||||
-- msg.info("event.action:" .. event.action)
|
-- msg.info("event.action:" .. event.action)
|
||||||
options[event.action] = menu_items_config[event.action]["original"]
|
options[event.action] = menu_items_config[event.action]["original"]
|
||||||
menu_items_config[event.action]["hint"] = options[event.action]
|
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
|
if event.action == "fontsize" or event.action == "scrolltime" then
|
||||||
load_danmaku(true)
|
load_danmaku(true)
|
||||||
end
|
end
|
||||||
@@ -718,14 +1218,14 @@ mp.register_script_message("setup-danmaku-style", function(query, text)
|
|||||||
options[query] = tostring(num)
|
options[query] = tostring(num)
|
||||||
menu_items_config[query]["hint"] = options[query]
|
menu_items_config[query]["hint"] = options[query]
|
||||||
-- "refresh" 模式会清除输入框文字
|
-- "refresh" 模式会清除输入框文字
|
||||||
add_danmaku_setup(query, "refresh")
|
open_style_menu(query, "refresh")
|
||||||
if query == "fontsize" or query == "scrolltime" then
|
if query == "fontsize" or query == "scrolltime" then
|
||||||
load_danmaku(true, true)
|
load_danmaku(true, true)
|
||||||
end
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
add_danmaku_setup(query, "error")
|
open_style_menu(query, "error")
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
|
|
||||||
@@ -734,14 +1234,10 @@ mp.register_script_message('setup-danmaku-source', function(json)
|
|||||||
if event.type == 'activate' then
|
if event.type == 'activate' then
|
||||||
|
|
||||||
if event.action == "delete" 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
|
DANMAKU.sources[event.value] = nil
|
||||||
remove_source_from_history(event.value)
|
remove_source_from_history(event.value)
|
||||||
mp.commandv("script-message-to", "uosc", "close-menu", "menu_source")
|
mp.commandv("script-message-to", "uosc", "close-menu", "menu_source")
|
||||||
open_add_menu_uosc()
|
open_add_menu()
|
||||||
load_danmaku(true)
|
load_danmaku(true)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -749,7 +1245,7 @@ mp.register_script_message('setup-danmaku-source', function(json)
|
|||||||
DANMAKU.sources[event.value]["blocked"] = true
|
DANMAKU.sources[event.value]["blocked"] = true
|
||||||
add_source_to_history(event.value, DANMAKU.sources[event.value])
|
add_source_to_history(event.value, DANMAKU.sources[event.value])
|
||||||
mp.commandv("script-message-to", "uosc", "close-menu", "menu_source")
|
mp.commandv("script-message-to", "uosc", "close-menu", "menu_source")
|
||||||
open_add_menu_uosc()
|
open_add_menu()
|
||||||
load_danmaku(true)
|
load_danmaku(true)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -757,7 +1253,7 @@ mp.register_script_message('setup-danmaku-source', function(json)
|
|||||||
DANMAKU.sources[event.value]["blocked"] = false
|
DANMAKU.sources[event.value]["blocked"] = false
|
||||||
add_source_to_history(event.value, DANMAKU.sources[event.value])
|
add_source_to_history(event.value, DANMAKU.sources[event.value])
|
||||||
mp.commandv("script-message-to", "uosc", "close-menu", "menu_source")
|
mp.commandv("script-message-to", "uosc", "close-menu", "menu_source")
|
||||||
open_add_menu_uosc()
|
open_add_menu()
|
||||||
load_danmaku(true)
|
load_danmaku(true)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -768,39 +1264,74 @@ mp.register_script_message("setup-source-delay", function(query, text)
|
|||||||
if event ~= nil then
|
if event ~= nil then
|
||||||
-- item点击
|
-- item点击
|
||||||
if event.type == "activate" then
|
if event.type == "activate" then
|
||||||
danmaku_delay_setup(event.value)
|
open_delay_menu(event.value)
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
-- 数值输入
|
-- 数值输入
|
||||||
if text == nil or text == "" then
|
if text == nil or text == "" then
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local newText, _ = text:gsub("%s", "") -- 移除所有空白字符
|
local delay = parse_delay_input(text)
|
||||||
local num = tonumber(newText)
|
if delay ~= nil then
|
||||||
local delay_segments = shallow_copy(DANMAKU.sources[query]["delay_segments"] or {})
|
mp.commandv("script-message", "danmaku-delay", tostring(delay), "0")
|
||||||
for i = #delay_segments, 1, -1 do
|
mp.commandv("script-message-to", "uosc", "close-menu", "menu_delay")
|
||||||
if delay_segments[i].start == 0 then
|
mp.add_timeout(0.1, function()
|
||||||
table.remove(delay_segments, i)
|
open_delay_menu(query, "refresh")
|
||||||
end
|
end)
|
||||||
|
else
|
||||||
|
open_delay_menu(query, "error")
|
||||||
end
|
end
|
||||||
if num ~= nil then
|
end
|
||||||
table.insert(delay_segments, 1, { start = 0, delay = tonumber(num) })
|
end)
|
||||||
DANMAKU.sources[query]["delay_segments"] = delay_segments
|
|
||||||
add_source_to_history(query, DANMAKU.sources[query])
|
mp.register_script_message('handle-danmaku-content-action', function(json)
|
||||||
mp.commandv("script-message-to", "uosc", "close-menu", "menu_delay")
|
local event = utils.parse_json(json)
|
||||||
danmaku_delay_setup(query)
|
if not event or event.type ~= 'activate' then return end
|
||||||
load_danmaku(true, true)
|
|
||||||
elseif newText:match("^%-?%d+m%d+s$") then
|
if event.action then
|
||||||
local minutes, seconds = string.match(newText, "^(%-?%d+)m(%d+)s$")
|
local d = COMMENTS[event.index]
|
||||||
minutes = tonumber(minutes)
|
if not d or not d.source then return end
|
||||||
seconds = tonumber(seconds)
|
|
||||||
if minutes < 0 then seconds = -seconds end
|
if event.action == "block_source" then
|
||||||
table.insert(delay_segments, 1, { start = 0, delay = 60 * minutes + seconds })
|
DANMAKU.sources[d.source]["blocked"] = true
|
||||||
DANMAKU.sources[query]["delay_segments"] = delay_segments
|
add_source_to_history(d.source, DANMAKU.sources[d.source])
|
||||||
add_source_to_history(query, DANMAKU.sources[query])
|
mp.commandv("script-message-to", "uosc", "close-menu", "menu_content")
|
||||||
mp.commandv("script-message-to", "uosc", "close-menu", "menu_delay")
|
load_danmaku(true)
|
||||||
danmaku_delay_setup(query)
|
elseif event.action == "adjust_delay" then
|
||||||
load_danmaku(true, true)
|
-- 打开以该弹幕时间为起点的延迟菜单(该延迟将作用于该时间点及之后的弹幕),仅针对该条弹幕的 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
|
end
|
||||||
end)
|
end)
|
||||||
@@ -5,19 +5,20 @@ options = {
|
|||||||
-- 指定弹幕服务器地址,自定义服务需兼容 dandanplay 的 api
|
-- 指定弹幕服务器地址,自定义服务需兼容 dandanplay 的 api
|
||||||
api_server = "https://api.dandanplay.net",
|
api_server = "https://api.dandanplay.net",
|
||||||
-- 指定 b 站和爱腾优的弹幕获取的兜底服务器地址,主要用于获取非动画弹幕
|
-- 指定 b 站和爱腾优的弹幕获取的兜底服务器地址,主要用于获取非动画弹幕
|
||||||
-- 服务器可以自托管:https://github.com/lyz05/danmaku
|
-- 可用: https://api.danmu.icu,https://dmku.hls.one
|
||||||
fallback_server = "https://fc.lyz05.cn",
|
fallback_server = "https://api.danmu.icu",
|
||||||
-- 设置 tmdb 的 API Key,用于获取非动画条目的中文信息(当搜索内容非中文时)
|
-- 设置 tmdb 的 API Key,用于获取非动画条目的中文信息(当搜索内容非中文时)
|
||||||
-- 可以在 https://www.themoviedb.org 注册后去个人账号设置界面获取
|
-- 可以在 https://www.themoviedb.org 注册后去个人账号设置界面获取
|
||||||
-- 注意:自定义此参数时还需要对获取到的 API Key 进行 base64 编码
|
-- 注意:自定义此参数时还需要对获取到的 API Key 进行 base64 编码
|
||||||
tmdb_api_key = "NmJmYjIxOTZkNzIyN2UyMTIzMGM3Y2YzZjQ4MDNkZGM=",
|
tmdb_api_key = "NmJmYjIxOTZkNzIyN2UyMTIzMGM3Y2YzZjQ4MDNkZGM=",
|
||||||
load_more_danmaku = false,
|
|
||||||
auto_load = false,
|
auto_load = false,
|
||||||
autoload_local_danmaku = false,
|
autoload_local_danmaku = false,
|
||||||
autoload_for_url = false,
|
autoload_for_url = false,
|
||||||
save_danmaku = false,
|
save_danmaku = false,
|
||||||
user_agent = "mpv_danmaku/1.0",
|
user_agent = "mpv_danmaku/1.0",
|
||||||
proxy = "",
|
proxy = "",
|
||||||
|
-- 可选:向 HTTP 请求传递 cookie.txt 文件路径
|
||||||
|
cookie_file = "",
|
||||||
-- 使用 fps 视频滤镜,大幅提升弹幕平滑度。默认禁用
|
-- 使用 fps 视频滤镜,大幅提升弹幕平滑度。默认禁用
|
||||||
vf_fps = false,
|
vf_fps = false,
|
||||||
-- 设置要使用的 fps 滤镜参数
|
-- 设置要使用的 fps 滤镜参数
|
||||||
@@ -73,4 +74,4 @@ options = {
|
|||||||
]],
|
]],
|
||||||
}
|
}
|
||||||
|
|
||||||
opt.read_options(options, mp.get_script_name(), function() end)
|
opt.read_options(options, mp.get_script_name(), function() end)
|
||||||
@@ -40,10 +40,50 @@ local function load_blacklist_patterns(filepath)
|
|||||||
return patterns
|
return patterns
|
||||||
end
|
end
|
||||||
|
|
||||||
for line in file:lines() do
|
if string.match(filepath, "%.xml$") then
|
||||||
line = line:match("^%s*(.-)%s*$")
|
-- xml文件格式示例
|
||||||
if line ~= "" then
|
--<?xml version="1.0" encoding="utf-8"?>
|
||||||
table.insert(patterns, line)
|
--<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
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -114,50 +154,62 @@ local function merge_duplicate_danmaku(danmakus, threshold)
|
|||||||
local groups = {}
|
local groups = {}
|
||||||
|
|
||||||
for _, d in ipairs(danmakus) do
|
for _, d in ipairs(danmakus) do
|
||||||
local key = d.type .. "|" .. d.color .. "|" .. d.text
|
local tkey = tostring(d.type or "")
|
||||||
if not groups[key] then groups[key] = {} end
|
local ckey = tostring(d.color or "")
|
||||||
table.insert(groups[key], d)
|
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
|
end
|
||||||
|
|
||||||
local merged = {}
|
local merged = {}
|
||||||
|
local abs = math.abs
|
||||||
|
|
||||||
for _, group in pairs(groups) do
|
for _, bytype in pairs(groups) do
|
||||||
table.sort(group, function(a, b) return a.time < b.time end)
|
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
|
local i = 1
|
||||||
while i <= #group do
|
while i <= #group do
|
||||||
local base = group[i]
|
local base = group[i]
|
||||||
local times = { base.time }
|
local times = { base.time }
|
||||||
local count = 1
|
local count = 1
|
||||||
local j = i + 1
|
local j = i + 1
|
||||||
|
|
||||||
while j <= #group and math.abs(group[j].time - base.time) <= threshold do
|
while j <= #group and abs(group[j].time - base.time) <= threshold do
|
||||||
table.insert(times, group[j].time)
|
times[#times+1] = group[j].time
|
||||||
count = count + 1
|
count = count + 1
|
||||||
j = j + 1
|
j = j + 1
|
||||||
end
|
end
|
||||||
|
|
||||||
local same_time = true
|
local same_time = true
|
||||||
for k = 2, #times do
|
for k = 2, #times do
|
||||||
if times[k] ~= times[1] then
|
if times[k] ~= times[1] then
|
||||||
same_time = false
|
same_time = false
|
||||||
break
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local danmaku = {
|
||||||
|
time = base.time,
|
||||||
|
type = base.type,
|
||||||
|
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)
|
||||||
|
end
|
||||||
|
|
||||||
|
table.insert(merged, danmaku)
|
||||||
|
i = j
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
local danmaku = {
|
|
||||||
time = base.time,
|
|
||||||
type = base.type,
|
|
||||||
size = base.size,
|
|
||||||
color = base.color,
|
|
||||||
text = base.text,
|
|
||||||
}
|
|
||||||
if count > 2 or not same_time then
|
|
||||||
danmaku.text = danmaku.text .. string.format("x%d", count)
|
|
||||||
end
|
|
||||||
|
|
||||||
table.insert(merged, danmaku)
|
|
||||||
i = j
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -207,9 +259,11 @@ local function limit_danmaku(danmakus, limit)
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- 解析 XML 弹幕
|
-- 解析 XML 弹幕
|
||||||
local function parse_xml_danmaku(xml_string, delay_segments)
|
local function parse_xml_danmaku(xml_string)
|
||||||
local danmakus = {}
|
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 params = {}
|
||||||
local i = 1
|
local i = 1
|
||||||
for val in p_attr:gmatch("([^,]+)") do
|
for val in p_attr:gmatch("([^,]+)") do
|
||||||
@@ -218,10 +272,8 @@ local function parse_xml_danmaku(xml_string, delay_segments)
|
|||||||
end
|
end
|
||||||
|
|
||||||
if params[1] and params[2] and params[3] and params[4] then
|
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, {
|
table.insert(danmakus, {
|
||||||
time = base_time + delay,
|
time = params[1],
|
||||||
type = params[2] or 1,
|
type = params[2] or 1,
|
||||||
size = params[3] or 25,
|
size = params[3] or 25,
|
||||||
color = params[4] or 0xFFFFFF,
|
color = params[4] or 0xFFFFFF,
|
||||||
@@ -235,7 +287,7 @@ local function parse_xml_danmaku(xml_string, delay_segments)
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- 解析 JSON 弹幕
|
-- 解析 JSON 弹幕
|
||||||
local function parse_json_danmaku(json_string, delay_segments)
|
local function parse_json_danmaku(json_string)
|
||||||
local danmakus = {}
|
local danmakus = {}
|
||||||
if json_string:sub(1, 3) == "\239\187\191" then
|
if json_string:sub(1, 3) == "\239\187\191" then
|
||||||
json_string = json_string:sub(4)
|
json_string = json_string:sub(4)
|
||||||
@@ -259,10 +311,8 @@ local function parse_json_danmaku(json_string, delay_segments)
|
|||||||
end
|
end
|
||||||
|
|
||||||
if params[1] and params[2] and params[3] and params[4] then
|
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, {
|
table.insert(danmakus, {
|
||||||
time = base_time + delay,
|
time = params[1],
|
||||||
color = params[2] or 0xFFFFFF,
|
color = params[2] or 0xFFFFFF,
|
||||||
type = params[3] or 1,
|
type = params[3] or 1,
|
||||||
size = params[4] or 25,
|
size = params[4] or 25,
|
||||||
@@ -277,64 +327,39 @@ local function parse_json_danmaku(json_string, delay_segments)
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- 解析弹幕文件
|
-- 解析弹幕文件
|
||||||
function parse_danmaku_files(danmaku_input, delays)
|
function parse_danmaku_file(danmaku_input)
|
||||||
local DANMAKU_PATHs = {}
|
local danmakus = {}
|
||||||
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
|
|
||||||
|
|
||||||
local all_danmaku = {}
|
if file_exists(danmaku_input) then
|
||||||
|
local content = read_file(danmaku_input)
|
||||||
|
if content then
|
||||||
|
local parsed = {}
|
||||||
|
if danmaku_input:match("%.xml$") then
|
||||||
|
parsed = parse_xml_danmaku(content)
|
||||||
|
elseif danmaku_input:match("%.json$") then
|
||||||
|
parsed = parse_json_danmaku(content)
|
||||||
|
end
|
||||||
|
|
||||||
for i, DANMAKU_PATH in ipairs(DANMAKU_PATHs) do
|
for _, d in ipairs(parsed) do
|
||||||
if file_exists(DANMAKU_PATH) then
|
table.insert(danmakus, d)
|
||||||
local content = read_file(DANMAKU_PATH)
|
|
||||||
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)
|
|
||||||
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
|
|
||||||
end
|
|
||||||
else
|
|
||||||
msg.info("无法读取文件内容: " .. DANMAKU_PATH)
|
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
msg.info("文件不存在: " .. DANMAKU_PATH)
|
msg.info("无法读取文件内容: " .. danmaku_input)
|
||||||
end
|
end
|
||||||
|
else
|
||||||
|
msg.info("文件不存在: " .. danmaku_input)
|
||||||
end
|
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("未能解析任何弹幕")
|
msg.info("未能解析任何弹幕")
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
|
|
||||||
if options.max_screen_danmaku > 0 and options.merge_tolerance <= 0 then
|
return danmakus
|
||||||
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
|
|
||||||
end
|
end
|
||||||
|
|
||||||
--# 弹幕数组与布局算法 (Danmaku Array & Layout Algorithms)
|
--# 弹幕数组与布局算法 (Danmaku Array & Layout Algorithms)
|
||||||
@@ -349,7 +374,7 @@ function DanmakuArray:new(res_x, res_y, font_size)
|
|||||||
time_length_array = {}
|
time_length_array = {}
|
||||||
}
|
}
|
||||||
for i = 1, obj.rows do
|
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
|
end
|
||||||
setmetatable(obj, self)
|
setmetatable(obj, self)
|
||||||
return obj
|
return obj
|
||||||
@@ -357,7 +382,7 @@ end
|
|||||||
|
|
||||||
function DanmakuArray:set_time_length(row, time, length)
|
function DanmakuArray:set_time_length(row, time, length)
|
||||||
if row > 0 and row <= self.rows then
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -375,15 +400,20 @@ function DanmakuArray:get_length(row)
|
|||||||
return 0
|
return 0
|
||||||
end
|
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 坐标算法
|
-- 滚动弹幕 Y 坐标算法
|
||||||
function get_position_y(font_size, appear_time, text_length, resolution_x, roll_time, array)
|
function get_position_y(font_size, appear_time, text_length, resolution_x, roll_time, array)
|
||||||
local velocity = (text_length + resolution_x) / roll_time
|
local velocity = (text_length + resolution_x) / roll_time
|
||||||
local best_row = 0
|
|
||||||
local best_bias = -math.huge
|
|
||||||
|
|
||||||
for i = 1, array.rows do
|
for i = 1, array.rows do
|
||||||
local previous_appear_time = array:get_time(i)
|
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)
|
array:set_time_length(i, appear_time, text_length)
|
||||||
return 1 + (i - 1) * font_size
|
return 1 + (i - 1) * font_size
|
||||||
end
|
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_length = array:get_length(i)
|
||||||
local previous_velocity = (previous_length + resolution_x) / roll_time
|
local previous_velocity = (previous_length + resolution_x) / roll_time
|
||||||
local delta_velocity = velocity - previous_velocity
|
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_x >= 0 then
|
||||||
if delta_velocity <= 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
|
end
|
||||||
|
|
||||||
local delta_time = delta_x / delta_velocity
|
local delta_time = delta_x / delta_velocity
|
||||||
local bias = appear_time - previous_appear_time - delta_time
|
if delta_time >= roll_time then
|
||||||
-- 判断:追及点是否在屏幕之外
|
|
||||||
local t_catch = previous_appear_time + delta_time
|
|
||||||
local distance_prev = previous_velocity * (t_catch - previous_appear_time)
|
|
||||||
if distance_prev > resolution_x then
|
|
||||||
-- 追及发生在屏幕之外,允许放置
|
|
||||||
array:set_time_length(i, appear_time, text_length)
|
array:set_time_length(i, appear_time, text_length)
|
||||||
return 1 + (i - 1) * font_size
|
return 1 + (i - 1) * font_size
|
||||||
end
|
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
|
||||||
end
|
end
|
||||||
-- 所有行都被占用,放弃渲染
|
-- 所有行都被占用,放弃渲染
|
||||||
@@ -424,8 +442,6 @@ end
|
|||||||
|
|
||||||
-- 固定弹幕 Y 坐标算法
|
-- 固定弹幕 Y 坐标算法
|
||||||
function get_fixed_y(font_size, appear_time, fixtime, array, from_top)
|
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
|
local row_start, row_end, row_step
|
||||||
if from_top then
|
if from_top then
|
||||||
row_start, row_end, row_step = 1, array.rows, 1
|
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
|
for i = row_start, row_end, row_step do
|
||||||
local previous_appear_time = array:get_time(i)
|
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)
|
array:set_time_length(i, appear_time, 0)
|
||||||
return (i - 1) * font_size + 1
|
return (i - 1) * font_size + 1
|
||||||
else
|
else
|
||||||
@@ -443,9 +459,6 @@ function get_fixed_y(font_size, appear_time, fixtime, array, from_top)
|
|||||||
if delta_time > fixtime then
|
if delta_time > fixtime then
|
||||||
array:set_time_length(i, appear_time, 0)
|
array:set_time_length(i, appear_time, 0)
|
||||||
return (i - 1) * font_size + 1
|
return (i - 1) * font_size + 1
|
||||||
elseif delta_time > best_bias then
|
|
||||||
best_bias = delta_time
|
|
||||||
best_row = i
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -453,21 +466,155 @@ function get_fixed_y(font_size, appear_time, fixtime, array, from_top)
|
|||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
|
|
||||||
-- 将弹幕转换为 ASS 格式
|
-- 将弹幕转换为 XML 格式
|
||||||
function convert_danmaku_to_ass(all_danmaku, danmaku_file)
|
function convert_danmaku_to_xml(danmaku_out)
|
||||||
if #all_danmaku == 0 then
|
local danmakus = {}
|
||||||
msg.info("弹幕文件为空或解析失败")
|
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
|
return false
|
||||||
end
|
end
|
||||||
msg.info("已解析 " .. #all_danmaku .. " 条弹幕")
|
|
||||||
|
|
||||||
local alpha = string.format("%02X", (1 - tonumber(options.opacity)) * 255)
|
-- 拼接为 XML 内容
|
||||||
local bold = options.bold and "1" or "0"
|
local xml = { '<?xml version="1.0" encoding="UTF-8"?><i>\n' }
|
||||||
|
for _, d in ipairs(danmakus) do
|
||||||
|
local time = d.time
|
||||||
|
local type = d.type or 1
|
||||||
|
local size = d.size or 25
|
||||||
|
local color = d.color or 0xFFFFFF
|
||||||
|
local text = d.text or ""
|
||||||
|
|
||||||
|
text = text:gsub("&", "&")
|
||||||
|
:gsub("<", "<")
|
||||||
|
:gsub(">", ">")
|
||||||
|
:gsub("\"", """)
|
||||||
|
:gsub("'", "'")
|
||||||
|
|
||||||
|
table.insert(xml, string.format('<d p="%s,%s,%s,%s">%s</d>\n', time, type, size, color, text))
|
||||||
|
end
|
||||||
|
table.insert(xml, '</i>')
|
||||||
|
|
||||||
|
-- 写入 XML 文件
|
||||||
|
local file = io.open(danmaku_out, "w")
|
||||||
|
if not file then
|
||||||
|
show_message("无法写入目标 XML 文件", 3)
|
||||||
|
msg.info("无法写入目标 XML 文件: " .. danmaku_out)
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
file:write(table.concat(xml))
|
||||||
|
file:close()
|
||||||
|
show_message("转换 XML 弹幕成功: " .. danmaku_out, 3)
|
||||||
|
msg.info("转换 XML 弹幕成功: " .. danmaku_out)
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
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 fontsize = tonumber(options.fontsize) or 50
|
||||||
local scrolltime = tonumber(options.scrolltime) or 15
|
local scrolltime = tonumber(options.scrolltime) or 15
|
||||||
local fixtime = tonumber(options.fixtime) or 5
|
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_x = 1920
|
||||||
local res_y = 1080
|
local res_y = 1080
|
||||||
@@ -475,33 +622,11 @@ function convert_danmaku_to_ass(all_danmaku, danmaku_file)
|
|||||||
local roll_array = DanmakuArray:new(res_x, res_y, fontsize)
|
local roll_array = DanmakuArray:new(res_x, res_y, fontsize)
|
||||||
local top_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 = {}
|
local pre_events = {}
|
||||||
for _, d in ipairs(all_danmaku) do
|
for _, d in ipairs(danmakus) do
|
||||||
local time = d.type == 1 and math.floor(d.time + 0.5) or d.time
|
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 appear_time = time
|
||||||
local danmaku_type = d.type
|
local danmaku_type = d.type
|
||||||
|
|
||||||
@@ -513,7 +638,7 @@ Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
|||||||
end
|
end
|
||||||
|
|
||||||
if end_time then
|
if end_time then
|
||||||
table.insert(pre_events, {start_time = appear_time, end_time = end_time, danmaku = d})
|
table.insert(pre_events, {orig_time = orig_time, start_time = appear_time, end_time = end_time, danmaku = d})
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -526,7 +651,8 @@ Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
|||||||
local d = ev.danmaku
|
local d = ev.danmaku
|
||||||
local appear_time = ev.start_time
|
local appear_time = ev.start_time
|
||||||
local danmaku_type = d.type
|
local danmaku_type = d.type
|
||||||
local text = ass_escape(decode_html_entities(d.text))
|
local clean_text = ch_convert_cached(decode_html_entities(d.text))
|
||||||
|
local text = ass_escape(clean_text)
|
||||||
:gsub("x(%d+)$", "{\\b1\\i1}x%1")
|
:gsub("x(%d+)$", "{\\b1\\i1}x%1")
|
||||||
|
|
||||||
-- 颜色从十进制转为 BGR Hex
|
-- 颜色从十进制转为 BGR Hex
|
||||||
@@ -537,13 +663,11 @@ Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
|||||||
local b = string.sub(color_hex, 5, 6)
|
local b = string.sub(color_hex, 5, 6)
|
||||||
local color_text = string.format("{\\c&H%s%s%s&}", b, g, r)
|
local color_text = string.format("{\\c&H%s%s%s&}", b, g, r)
|
||||||
|
|
||||||
local start_time_str = seconds_to_time(appear_time)
|
local style, effect
|
||||||
local layer, end_time_str, style, effect
|
local pos, move = nil, nil
|
||||||
|
|
||||||
-- 滚动弹幕 (类型 1, 2, 3)
|
-- 滚动弹幕 (类型 1, 2, 3)
|
||||||
if danmaku_type >= 1 and danmaku_type <= 3 then
|
if danmaku_type >= 1 and danmaku_type <= 3 then
|
||||||
layer = 0
|
|
||||||
end_time_str = seconds_to_time(ev.end_time)
|
|
||||||
style = "R2L"
|
style = "R2L"
|
||||||
local text_length = get_str_width(text, fontsize)
|
local text_length = get_str_width(text, fontsize)
|
||||||
local x1 = res_x + text_length / 2
|
local x1 = res_x + text_length / 2
|
||||||
@@ -551,105 +675,46 @@ Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
|||||||
local y = get_position_y(fontsize, appear_time, text_length, res_x, scrolltime, roll_array)
|
local y = get_position_y(fontsize, appear_time, text_length, res_x, scrolltime, roll_array)
|
||||||
if y then
|
if y then
|
||||||
effect = string.format("{\\move(%d, %d, %d, %d)}", x1, y, x2, y)
|
effect = string.format("{\\move(%d, %d, %d, %d)}", x1, y, x2, y)
|
||||||
|
move = {x1, y, x2, y}
|
||||||
end
|
end
|
||||||
|
|
||||||
-- 顶部弹幕 (类型 5)
|
-- 顶部弹幕 (类型 5)
|
||||||
elseif danmaku_type == 5 then
|
elseif danmaku_type == 5 then
|
||||||
layer = 1
|
|
||||||
end_time_str = seconds_to_time(ev.end_time)
|
|
||||||
style = "TOP"
|
style = "TOP"
|
||||||
local x = res_x / 2
|
local x = res_x / 2
|
||||||
local y = get_fixed_y(fontsize, appear_time, fixtime, top_array, true)
|
local y = get_fixed_y(fontsize, appear_time, fixtime, top_array, true)
|
||||||
if y then
|
if y then
|
||||||
effect = string.format("{\\pos(%d, %d)}", x, y)
|
effect = string.format("{\\pos(%d, %d)}", x, y)
|
||||||
|
pos = {x, y}
|
||||||
end
|
end
|
||||||
|
|
||||||
-- 底部弹幕 (类型 4)
|
-- 底部弹幕 (类型 4)
|
||||||
elseif danmaku_type == 4 then
|
elseif danmaku_type == 4 then
|
||||||
layer = 1
|
|
||||||
end_time_str = seconds_to_time(ev.end_time)
|
|
||||||
style = "BTM"
|
style = "BTM"
|
||||||
local x = res_x / 2
|
local x = res_x / 2
|
||||||
local y = get_fixed_y(fontsize, appear_time, fixtime, top_array, false)
|
local y = get_fixed_y(fontsize, appear_time, fixtime, top_array, false)
|
||||||
if y then
|
if y then
|
||||||
effect = string.format("{\\pos(%d, %d)}", x, y)
|
effect = string.format("{\\pos(%d, %d)}", x, y)
|
||||||
|
pos = {x, y}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
if style then
|
if style and effect then
|
||||||
local line = nil
|
text = effect .. color_text .. text
|
||||||
if effect then
|
local event = {
|
||||||
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)
|
orig_time = ev.orig_time,
|
||||||
else
|
start_time = ev.start_time,
|
||||||
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_time = ev.end_time,
|
||||||
end
|
delay = ev.start_time - (ev.orig_time or ev.start_time),
|
||||||
table.insert(ass_events, line)
|
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
|
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
|
|
||||||
end
|
|
||||||
|
|
||||||
-- 拼接为 XML 内容
|
|
||||||
local xml = { '<?xml version="1.0" encoding="UTF-8"?><i>\n' }
|
|
||||||
for _, d in ipairs(all_danmaku) do
|
|
||||||
local time = d.time
|
|
||||||
local type = d.type or 1
|
|
||||||
local size = d.size or 25
|
|
||||||
local color = d.color or 0xFFFFFF
|
|
||||||
local text = d.text or ""
|
|
||||||
|
|
||||||
text = text:gsub("&", "&")
|
|
||||||
:gsub("<", "<")
|
|
||||||
:gsub(">", ">")
|
|
||||||
:gsub("\"", """)
|
|
||||||
:gsub("'", "'")
|
|
||||||
|
|
||||||
table.insert(xml, string.format('<d p="%s,%s,%s,%s">%s</d>\n', time, type, size, color, text))
|
|
||||||
end
|
|
||||||
table.insert(xml, '</i>')
|
|
||||||
|
|
||||||
-- 写入 XML 文件
|
|
||||||
local file = io.open(danmaku_out, "w")
|
|
||||||
if not file then
|
|
||||||
show_message("无法写入目标 XML 文件", 3)
|
|
||||||
msg.info("无法写入目标 XML 文件: " .. danmaku_out)
|
|
||||||
return false
|
|
||||||
end
|
|
||||||
file:write(table.concat(xml))
|
|
||||||
file:close()
|
|
||||||
show_message("转换 XML 弹幕成功: " .. danmaku_out, 3)
|
|
||||||
msg.info("转换 XML 弹幕成功: " .. danmaku_out)
|
|
||||||
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
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,25 +1,15 @@
|
|||||||
-- modified from https://github.com/rkscv/danmaku/blob/main/danmaku.lua
|
-- modified from https://github.com/rkscv/danmaku/blob/main/danmaku.lua
|
||||||
local msg = require('mp.msg')
|
local msg = require('mp.msg')
|
||||||
local utils = require("mp.utils")
|
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 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 realtime_position_text(event, pos, displayarea)
|
||||||
local function parse_move_tag(text)
|
if not event.move then
|
||||||
-- 匹配包括小数和负数在内的坐标值
|
local _, current_y = unpack(event.pos)
|
||||||
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%.]+).*%)")
|
|
||||||
if not current_y or tonumber(current_y) > displayarea then return end
|
if not current_y or tonumber(current_y) > displayarea then return end
|
||||||
if event.style ~= "SP" and event.style ~= "MSG" then
|
if event.style ~= "SP" and event.style ~= "MSG" then
|
||||||
return string.format("{\\an8}%s", event.text)
|
return string.format("{\\an8}%s", event.text)
|
||||||
@@ -28,9 +18,10 @@ local function parse_comment(event, pos, height, delay)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local x1, y1, x2, y2 = unpack(event.move)
|
||||||
-- 计算移动的时间范围
|
-- 计算移动的时间范围
|
||||||
local duration = event.end_time - event.start_time --mean: options.scrolltime
|
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)
|
local current_x = tonumber(x1 + (x2 - x1) * progress)
|
||||||
@@ -46,60 +37,28 @@ local function parse_comment(event, pos, height, delay)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- 从 ASS 文件中解析样式和事件
|
function render(pos_arg)
|
||||||
local function parse_ass_events(ass_path, callback)
|
if COMMENTS == nil then return end
|
||||||
local ass_file = io.open(ass_path, "r")
|
|
||||||
if not ass_file then
|
local pos, err
|
||||||
callback("无法打开 ASS 文件")
|
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
|
||||||
|
|
||||||
|
if not pos then
|
||||||
|
overlay:remove()
|
||||||
return
|
return
|
||||||
end
|
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()
|
|
||||||
if COMMENTS == nil then return end
|
|
||||||
|
|
||||||
local pos, err = mp.get_property_number('time-pos')
|
|
||||||
if err ~= nil then
|
|
||||||
return msg.error(err)
|
|
||||||
end
|
|
||||||
|
|
||||||
local delay = get_delay_for_time(DELAYS, pos)
|
|
||||||
|
|
||||||
local fontname = options.fontname
|
local fontname = options.fontname
|
||||||
local fontsize = options.fontsize
|
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 width, height = 1920, 1080
|
||||||
local ratio = osd_width / osd_height
|
local ratio = osd_width / osd_height
|
||||||
@@ -109,23 +68,37 @@ function render()
|
|||||||
end
|
end
|
||||||
|
|
||||||
local ass_events = {}
|
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 lo = binary_search(COMMENTS, window_start, function(item) return item.start_time end)
|
||||||
local text = parse_comment(event, pos, height, delay)
|
|
||||||
|
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
|
if text then
|
||||||
text = text:gsub("&#%d+;","")
|
text = text:gsub(re_entity, "")
|
||||||
end
|
end
|
||||||
|
|
||||||
if text and text:match("\\fs%d+") then
|
if text and text:match(re_fs) then
|
||||||
text = text:gsub("\\fs(%d+)", function(size)
|
text = text:gsub(re_fs, function(size)
|
||||||
return string.format("\\fs%d", size * 1.5)
|
local n = tonumber(size) or 0
|
||||||
|
return string.format("\\fs%d", math.floor(n * 1.5))
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- 构建 ASS 字符串
|
-- 构建 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",
|
local ass_text = text and (ass_prefix .. text)
|
||||||
fontname, fontsize, alpha, options.outline, options.shadow, options.bold and "1" or "0", text)
|
|
||||||
|
|
||||||
table.insert(ass_events, ass_text)
|
table.insert(ass_events, ass_text)
|
||||||
end
|
end
|
||||||
@@ -137,27 +110,39 @@ function render()
|
|||||||
overlay:update()
|
overlay:update()
|
||||||
end
|
end
|
||||||
|
|
||||||
local timer = mp.add_periodic_timer(INTERVAL, render, true)
|
local function time_pos_callback(_, time_pos)
|
||||||
|
if time_pos then
|
||||||
|
render(time_pos)
|
||||||
|
else
|
||||||
|
overlay:remove()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
function parse_danmaku(ass_file_path, from_menu, no_osd)
|
local function start_time_observer()
|
||||||
parse_ass_events(ass_file_path, function(err, events)
|
if not time_pos_observer_active then
|
||||||
COMMENTS = events
|
mp.observe_property('time-pos', 'number', time_pos_callback)
|
||||||
if err then
|
time_pos_observer_active = true
|
||||||
msg.error("ASS 解析错误: " .. err)
|
end
|
||||||
return
|
end
|
||||||
end
|
|
||||||
|
|
||||||
if ENABLED and (from_menu or get_danmaku_visibility()) then
|
local function stop_time_observer()
|
||||||
if not no_osd then
|
if time_pos_observer_active then
|
||||||
show_loaded(true)
|
mp.unobserve_property(time_pos_callback)
|
||||||
end
|
time_pos_observer_active = false
|
||||||
mp.commandv("script-message-to", "uosc", "set", "show_danmaku", "on")
|
end
|
||||||
show_danmaku_func()
|
end
|
||||||
else
|
|
||||||
show_message("")
|
function render_danmaku(from_menu, no_osd)
|
||||||
hide_danmaku_func()
|
if ENABLED and (from_menu or get_danmaku_visibility()) then
|
||||||
|
if not no_osd then
|
||||||
|
show_loaded(true)
|
||||||
end
|
end
|
||||||
end)
|
mp.commandv("script-message-to", "uosc", "set", "show_danmaku", "on")
|
||||||
|
show_danmaku_func()
|
||||||
|
else
|
||||||
|
show_message("")
|
||||||
|
hide_danmaku_func()
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
local function filter_state(label, name)
|
local function filter_state(label, name)
|
||||||
@@ -172,9 +157,11 @@ local function filter_state(label, name)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function show_danmaku_func()
|
function show_danmaku_func()
|
||||||
|
mp.set_property_bool(HAS_DANMAKU, true)
|
||||||
|
set_danmaku_visibility(true)
|
||||||
render()
|
render()
|
||||||
if not pause then
|
if not pause then
|
||||||
timer:resume()
|
start_time_observer()
|
||||||
end
|
end
|
||||||
if options.vf_fps then
|
if options.vf_fps then
|
||||||
local display_fps = mp.get_property_number('display-fps')
|
local display_fps = mp.get_property_number('display-fps')
|
||||||
@@ -189,7 +176,9 @@ function show_danmaku_func()
|
|||||||
end
|
end
|
||||||
|
|
||||||
function hide_danmaku_func()
|
function hide_danmaku_func()
|
||||||
timer:kill()
|
stop_time_observer()
|
||||||
|
mp.set_property_bool(HAS_DANMAKU, false)
|
||||||
|
set_danmaku_visibility(false)
|
||||||
overlay:remove()
|
overlay:remove()
|
||||||
if filter_state("danmaku") then
|
if filter_state("danmaku") then
|
||||||
mp.commandv("vf", "remove", "@danmaku")
|
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-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('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)
|
mp.observe_property('pause', 'bool', function(_, value)
|
||||||
if value ~= nil then
|
if value ~= nil then
|
||||||
pause = value
|
pause = value
|
||||||
end
|
end
|
||||||
if ENABLED then
|
if ENABLED then
|
||||||
if pause then
|
if pause then
|
||||||
timer:kill()
|
stop_time_observer()
|
||||||
elseif COMMENTS ~= nil then
|
elseif COMMENTS ~= nil then
|
||||||
timer:resume()
|
start_time_observer()
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
@@ -263,7 +234,7 @@ end)
|
|||||||
|
|
||||||
mp.add_hook("on_unload", 50, function()
|
mp.add_hook("on_unload", 50, function()
|
||||||
COMMENTS, DELAY = nil, 0
|
COMMENTS, DELAY = nil, 0
|
||||||
timer:kill()
|
stop_time_observer()
|
||||||
overlay:remove()
|
overlay:remove()
|
||||||
mp.set_property_native(DELAY_PROPERTY, 0)
|
mp.set_property_native(DELAY_PROPERTY, 0)
|
||||||
if filter_state("danmaku") then
|
if filter_state("danmaku") then
|
||||||
@@ -271,13 +242,10 @@ mp.add_hook("on_unload", 50, function()
|
|||||||
end
|
end
|
||||||
|
|
||||||
local files_to_remove = {
|
local files_to_remove = {
|
||||||
file1 = utils.join_path(DANMAKU_PATH, "danmaku-" .. PID .. ".json"),
|
file1 = utils.join_path(DANMAKU_PATH, "temp-" .. PID .. ".mp4"),
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if options.save_danmaku and file_exists(files_to_remove.file2) then
|
if options.save_danmaku then
|
||||||
save_danmaku(true)
|
save_danmaku(true)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -287,10 +255,5 @@ mp.add_hook("on_unload", 50, function()
|
|||||||
end
|
end
|
||||||
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}
|
DANMAKU = {sources = {}, count = 1}
|
||||||
end)
|
end)
|
||||||
@@ -2,8 +2,10 @@ local msg = require('mp.msg')
|
|||||||
local utils = require("mp.utils")
|
local utils = require("mp.utils")
|
||||||
|
|
||||||
local repo = "Tony15246/uosc_danmaku"
|
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 local_version = VERSION or "0.0.0"
|
||||||
|
local platform = mp.get_property("platform")
|
||||||
|
|
||||||
local function version_greater(v1, v2)
|
local function version_greater(v1, v2)
|
||||||
local function parse(ver)
|
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
|
if not res or res.status ~= 0 then return nil end
|
||||||
local tag = res.stdout:match([["tag_name"%s*:%s*"([^"]+)"]])
|
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
|
end
|
||||||
|
|
||||||
-- 仅检查并提示新版本,不自动下载/覆盖(避免 rm -rf 破坏配置)
|
|
||||||
function check_for_update()
|
function check_for_update()
|
||||||
local latest_version = get_latest_release(repo)
|
local latest_version, download_url = get_latest_release(repo)
|
||||||
if not latest_version then
|
if not latest_version or not download_url then
|
||||||
show_message("无法获取最新版本信息")
|
show_message("❌ 无法获取最新版本信息")
|
||||||
msg.warn("无法获取最新版本信息")
|
msg.warn("❌ 无法获取最新版本信息")
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
if not version_greater(latest_version, local_version) then
|
if not version_greater(latest_version, local_version) then
|
||||||
show_message("uosc_danmaku 已是最新版本 (" .. local_version .. ")")
|
show_message("✅ 已是最新版本 ("..local_version..")")
|
||||||
msg.info("uosc_danmaku 已是最新版本 (" .. local_version .. ")")
|
msg.info("✅ 已是最新版本")
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
local update_url = "https://github.com/" .. repo .. "/releases/tag/" .. latest_version
|
show_message("⬇️ 发现新版本: " .. latest_version .. ",正在下载...")
|
||||||
show_message("uosc_danmaku 有新版本: " .. latest_version .. " (当前: " .. local_version .. ")\n请手动更新: " .. update_url)
|
msg.info("⬇️ 发现新版本: " .. latest_version .. ",地址: " .. download_url)
|
||||||
msg.info("uosc_danmaku 新版本: " .. latest_version .. " 下载地址: " .. update_url)
|
|
||||||
end
|
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
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
local utils = require("mp.utils")
|
local utils = require("mp.utils")
|
||||||
|
local unpack = unpack or table.unpack
|
||||||
|
|
||||||
-- from http://lua-users.org/wiki/LuaUnicode
|
-- from http://lua-users.org/wiki/LuaUnicode
|
||||||
local UTF8_PATTERN = '[%z\1-\127\194-\244][\128-\191]*'
|
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))
|
return string.char(tonumber(x, 16))
|
||||||
end
|
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编码转换
|
-- url编码转换
|
||||||
function url_encode(str)
|
function url_encode(str)
|
||||||
-- 将非安全字符转换为百分号编码
|
-- 将非安全字符转换为百分号编码
|
||||||
@@ -318,6 +344,67 @@ function file_exists(path)
|
|||||||
return false
|
return false
|
||||||
end
|
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)
|
function is_writable(path)
|
||||||
local file = io.open(path, "w")
|
local file = io.open(path, "w")
|
||||||
if file then
|
if file then
|
||||||
@@ -386,10 +473,19 @@ local function split_by_numbers(filename)
|
|||||||
return parts
|
return parts
|
||||||
end
|
end
|
||||||
|
|
||||||
-- 识别并匹配前后剧集
|
-- 识别匹配前后剧集并提取集数
|
||||||
local function compare_filenames(fname1, fname2)
|
local function get_series_episodes(fname1, fname2)
|
||||||
local parts1 = split_by_numbers(fname1)
|
local parts1 = split_by_numbers(fname1)
|
||||||
local parts2 = split_by_numbers(fname2)
|
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)
|
local min_len = math.min(#parts1, #parts2)
|
||||||
|
|
||||||
@@ -400,7 +496,7 @@ local function compare_filenames(fname1, fname2)
|
|||||||
|
|
||||||
-- 比较数字前的字符是否相同
|
-- 比较数字前的字符是否相同
|
||||||
if part1.pre ~= part2.pre then
|
if part1.pre ~= part2.pre then
|
||||||
return false
|
return nil, nil
|
||||||
end
|
end
|
||||||
|
|
||||||
-- 比较数字部分
|
-- 比较数字部分
|
||||||
@@ -410,11 +506,36 @@ local function compare_filenames(fname1, fname2)
|
|||||||
|
|
||||||
-- 比较数字后的字符是否相同
|
-- 比较数字后的字符是否相同
|
||||||
if part1.post ~= part2.post then
|
if part1.post ~= part2.post then
|
||||||
return false
|
return nil, nil
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|
||||||
-- 规范化路径
|
-- 规范化路径
|
||||||
@@ -520,36 +641,6 @@ function parse_title()
|
|||||||
return title_replace(title), season, episode
|
return title_replace(title), season, episode
|
||||||
end
|
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 = {
|
local CHINESE_NUM_MAP = {
|
||||||
["零"] = 0, ["一"] = 1, ["二"] = 2, ["三"] = 3, ["四"] = 4,
|
["零"] = 0, ["一"] = 1, ["二"] = 2, ["三"] = 3, ["四"] = 4,
|
||||||
["五"] = 5, ["六"] = 6, ["七"] = 7, ["八"] = 8, ["九"] = 9,
|
["五"] = 5, ["六"] = 6, ["七"] = 7, ["八"] = 8, ["九"] = 9,
|
||||||
@@ -644,7 +735,7 @@ function call_cmd_async(args, callback)
|
|||||||
name = 'subprocess',
|
name = 'subprocess',
|
||||||
capture_stderr = true,
|
capture_stderr = true,
|
||||||
capture_stdout = true,
|
capture_stdout = true,
|
||||||
playback_only = false,
|
playback_only = true,
|
||||||
args = args,
|
args = args,
|
||||||
}, function(success, result, error)
|
}, function(success, result, error)
|
||||||
if not success or not result or result.status ~= 0 then
|
if not success or not result or result.status ~= 0 then
|
||||||
|
|||||||
Reference in New Issue
Block a user