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