diff --git a/README.md b/README.md index 2b80b05..35773b6 100644 --- a/README.md +++ b/README.md @@ -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` 对照脚本源码更新 diff --git a/scripts/mpv-torrserver.lua b/scripts/mpv-torrserver.lua new file mode 100644 index 0000000..ea913a3 --- /dev/null +++ b/scripts/mpv-torrserver.lua @@ -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) \ No newline at end of file diff --git a/scripts/uosc/char-conv/zh.json b/scripts/uosc/char-conv/zh.json index d1ada3b..ddab0d3 100644 --- a/scripts/uosc/char-conv/zh.json +++ b/scripts/uosc/char-conv/zh.json @@ -402,4 +402,4 @@ "zui": "最嘴罪醉咀蕞䮔厜璻蟕晬嗺噿嶵㠑嶊冣㝡䘹祽鋷錊酻酔樶檌㰎栬槜檇辠䘒稡纗絊", "zun": "尊遵樽鳟撙墫噂嶟鶎銌鱒鐏捘罇鷷僔繜譐", "zuo": "作做坐左座昨佐琢撮柞唑祚捽阼胙嘬怍酢笮葄葃蓙䔘苲莋㸲㝾䞰䎰咗㘀㘴岝岞䝫糳袏鈼㭮稓穝秨筰㛗㑅飵侳繓䋏" -} +} \ No newline at end of file diff --git a/scripts/uosc/elements/BufferingIndicator.lua b/scripts/uosc/elements/BufferingIndicator.lua index 13674f3..e288a1f 100644 --- a/scripts/uosc/elements/BufferingIndicator.lua +++ b/scripts/uosc/elements/BufferingIndicator.lua @@ -36,4 +36,4 @@ function BufferingIndicator:render() return ass end -return BufferingIndicator +return BufferingIndicator \ No newline at end of file diff --git a/scripts/uosc/elements/Button.lua b/scripts/uosc/elements/Button.lua index 38a3d1f..422f024 100644 --- a/scripts/uosc/elements/Button.lua +++ b/scripts/uosc/elements/Button.lua @@ -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 \ No newline at end of file diff --git a/scripts/uosc/elements/Controls.lua b/scripts/uosc/elements/Controls.lua index 41978a0..7d45b1b 100644 --- a/scripts/uosc/elements/Controls.lua +++ b/scripts/uosc/elements/Controls.lua @@ -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 \ No newline at end of file diff --git a/scripts/uosc/elements/Curtain.lua b/scripts/uosc/elements/Curtain.lua index ccebfb0..8c53fd1 100644 --- a/scripts/uosc/elements/Curtain.lua +++ b/scripts/uosc/elements/Curtain.lua @@ -32,4 +32,4 @@ function Curtain:render() return ass end -return Curtain +return Curtain \ No newline at end of file diff --git a/scripts/uosc/elements/CycleButton.lua b/scripts/uosc/elements/CycleButton.lua index 8aa8175..23f981b 100644 --- a/scripts/uosc/elements/CycleButton.lua +++ b/scripts/uosc/elements/CycleButton.lua @@ -83,4 +83,4 @@ function CycleButton:init(id, props) end end -return CycleButton +return CycleButton \ No newline at end of file diff --git a/scripts/uosc/elements/Element.lua b/scripts/uosc/elements/Element.lua index 03656d8..3fdae20 100644 --- a/scripts/uosc/elements/Element.lua +++ b/scripts/uosc/elements/Element.lua @@ -262,4 +262,4 @@ function Element:create_action(fn) end end -return Element +return Element \ No newline at end of file diff --git a/scripts/uosc/elements/Elements.lua b/scripts/uosc/elements/Elements.lua index 86568a0..d97cf6a 100644 --- a/scripts/uosc/elements/Elements.lua +++ b/scripts/uosc/elements/Elements.lua @@ -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 \ No newline at end of file diff --git a/scripts/uosc/elements/ManagedButton.lua b/scripts/uosc/elements/ManagedButton.lua index e36bf59..69edc40 100644 --- a/scripts/uosc/elements/ManagedButton.lua +++ b/scripts/uosc/elements/ManagedButton.lua @@ -33,4 +33,4 @@ function ManagedButton:update(data) end end -return ManagedButton +return ManagedButton \ No newline at end of file diff --git a/scripts/uosc/elements/Menu.lua b/scripts/uosc/elements/Menu.lua index 68d3813..137979d 100644 --- a/scripts/uosc/elements/Menu.lua +++ b/scripts/uosc/elements/Menu.lua @@ -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 \ No newline at end of file diff --git a/scripts/uosc/elements/PauseIndicator.lua b/scripts/uosc/elements/PauseIndicator.lua index 004a9fe..01da5ee 100644 --- a/scripts/uosc/elements/PauseIndicator.lua +++ b/scripts/uosc/elements/PauseIndicator.lua @@ -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 \ No newline at end of file diff --git a/scripts/uosc/elements/Speed.lua b/scripts/uosc/elements/Speed.lua index f994e69..271c87e 100644 --- a/scripts/uosc/elements/Speed.lua +++ b/scripts/uosc/elements/Speed.lua @@ -192,4 +192,4 @@ function Speed:render() return ass end -return Speed +return Speed \ No newline at end of file diff --git a/scripts/uosc/elements/Timeline.lua b/scripts/uosc/elements/Timeline.lua index 38e8ebf..86f1f98 100644 --- a/scripts/uosc/elements/Timeline.lua +++ b/scripts/uosc/elements/Timeline.lua @@ -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 \ No newline at end of file diff --git a/scripts/uosc/elements/TopBar.lua b/scripts/uosc/elements/TopBar.lua index 969d6ae..f8ecc6f 100644 --- a/scripts/uosc/elements/TopBar.lua +++ b/scripts/uosc/elements/TopBar.lua @@ -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 \ No newline at end of file diff --git a/scripts/uosc/elements/Updater.lua b/scripts/uosc/elements/Updater.lua new file mode 100644 index 0000000..4a98036 --- /dev/null +++ b/scripts/uosc/elements/Updater.lua @@ -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 \ No newline at end of file diff --git a/scripts/uosc/elements/Volume.lua b/scripts/uosc/elements/Volume.lua index 12904e7..4528083 100644 --- a/scripts/uosc/elements/Volume.lua +++ b/scripts/uosc/elements/Volume.lua @@ -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 \ No newline at end of file diff --git a/scripts/uosc/elements/WindowBorder.lua b/scripts/uosc/elements/WindowBorder.lua index c872627..ddcefe8 100644 --- a/scripts/uosc/elements/WindowBorder.lua +++ b/scripts/uosc/elements/WindowBorder.lua @@ -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 \ No newline at end of file diff --git a/scripts/uosc/intl/de.json b/scripts/uosc/intl/de.json index 4e488c1..0f98a6f 100644 --- a/scripts/uosc/intl/de.json +++ b/scripts/uosc/intl/de.json @@ -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." -} +} \ No newline at end of file diff --git a/scripts/uosc/intl/es.json b/scripts/uosc/intl/es.json index 4679541..303dab9 100644 --- a/scripts/uosc/intl/es.json +++ b/scripts/uosc/intl/es.json @@ -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." -} +} \ No newline at end of file diff --git a/scripts/uosc/intl/fr.json b/scripts/uosc/intl/fr.json index 254c74c..093541c 100644 --- a/scripts/uosc/intl/fr.json +++ b/scripts/uosc/intl/fr.json @@ -56,4 +56,4 @@ "open file": "sélectionner un fichier", "parent dir": "répertoire parent", "playlist or file": "fichier ou liste de lecture" -} +} \ No newline at end of file diff --git a/scripts/uosc/intl/it.json b/scripts/uosc/intl/it.json new file mode 100644 index 0000000..f540f16 --- /dev/null +++ b/scripts/uosc/intl/it.json @@ -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" +} \ No newline at end of file diff --git a/scripts/uosc/intl/pl.json b/scripts/uosc/intl/pl.json index e7aaed0..37b6524 100644 --- a/scripts/uosc/intl/pl.json +++ b/scripts/uosc/intl/pl.json @@ -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." -} +} \ No newline at end of file diff --git a/scripts/uosc/intl/pt.json b/scripts/uosc/intl/pt.json new file mode 100644 index 0000000..3b0a518 --- /dev/null +++ b/scripts/uosc/intl/pt.json @@ -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." +} \ No newline at end of file diff --git a/scripts/uosc/intl/ro.json b/scripts/uosc/intl/ro.json index 82bc3df..ea69b49 100644 --- a/scripts/uosc/intl/ro.json +++ b/scripts/uosc/intl/ro.json @@ -56,4 +56,4 @@ "open file": "deschide fișierul", "parent dir": "director părinte", "playlist or file": "fișier sau listă de redare" -} +} \ No newline at end of file diff --git a/scripts/uosc/intl/ru.json b/scripts/uosc/intl/ru.json index 2dc148e..eec50b3 100644 --- a/scripts/uosc/intl/ru.json +++ b/scripts/uosc/intl/ru.json @@ -56,4 +56,4 @@ "open file": "открыть файл", "parent dir": "родительская папка", "playlist or file": "плейлист или файл" -} +} \ No newline at end of file diff --git a/scripts/uosc/intl/tr.json b/scripts/uosc/intl/tr.json index 4928c2f..359f205 100644 --- a/scripts/uosc/intl/tr.json +++ b/scripts/uosc/intl/tr.json @@ -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." -} +} \ No newline at end of file diff --git a/scripts/uosc/intl/uk.json b/scripts/uosc/intl/uk.json index a5ced6d..42009c5 100644 --- a/scripts/uosc/intl/uk.json +++ b/scripts/uosc/intl/uk.json @@ -66,4 +66,4 @@ "An error has occurred.": "Сталася помилка.", "See above for clues.": "Дивіться підказки вище.", "Play/Pause": "Відтворення / Пауза" -} +} \ No newline at end of file diff --git a/scripts/uosc/intl/zh-HK.json b/scripts/uosc/intl/zh-HK.json new file mode 100644 index 0000000..565719c --- /dev/null +++ b/scripts/uosc/intl/zh-HK.json @@ -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 使其生效" +} \ No newline at end of file diff --git a/scripts/uosc/intl/zh-hans.json b/scripts/uosc/intl/zh-hans.json index 5411c65..7669e03 100644 --- a/scripts/uosc/intl/zh-hans.json +++ b/scripts/uosc/intl/zh-hans.json @@ -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 使其生效" -} +} \ No newline at end of file diff --git a/scripts/uosc/lib/ass.lua b/scripts/uosc/lib/ass.lua index c20349f..2cd78cd 100644 --- a/scripts/uosc/lib/ass.lua +++ b/scripts/uosc/lib/ass.lua @@ -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 (0–1): start point followed by segment entries cp1x, cp1y, cp2x, cp2y, px, py, ... +---@param opts? {color?: string; border?: number; border_color?: string; opacity?: number|{primary?: number; border?: number, shadow?: number, main?: number}; clip?: string} +function ass_mt:smooth_curve(ax, ay, bx, by, points, opts) + if not points or #points < 8 then return end + opts = opts or {} + local border_size = opts.border or 0 + local tags = '\\pos(0,0)\\rDefault\\an7\\blur0' + -- border + tags = tags .. '\\bord' .. border_size + -- colors + tags = tags .. '\\1c&H' .. (opts.color or fg) + if border_size > 0 then tags = tags .. '\\3c&H' .. (opts.border_color or bg) end + -- opacity + if opts.opacity then tags = tags .. self.opacity(nil, opts.opacity) end + -- clip + if opts.clip then tags = tags .. opts.clip end + -- draw + self:new_event() + self.text = self.text .. '{' .. tags .. '}' + self:draw_start() + + -- Scale normalized (0–1) coordinates to rectangle bounds + local width, height = bx - ax, by - ay + local function scale(x, y) + return ax + x * width, ay + y * height + end + + local x0, y0 = scale(points[1], points[2]) + self:move_to(x0, y0) + local max = math.floor((#points - 2) / 6) * 6 + 2 + for i = 3, max, 6 do + local x1, y1 = scale(points[i], points[i+1]) + local x2, y2 = scale(points[i+2], points[i+3]) + local x3, y3 = scale(points[i+4], points[i+5]) + self:bezier_curve(x1, y1, x2, y2, x3, y3) + end + self:draw_stop() +end \ No newline at end of file diff --git a/scripts/uosc/lib/buttons.lua b/scripts/uosc/lib/buttons.lua index b4551aa..4a492fd 100644 --- a/scripts/uosc/lib/buttons.lua +++ b/scripts/uosc/lib/buttons.lua @@ -71,4 +71,4 @@ mp.register_script_message('set-button', function(name, data) end end) -return buttons +return buttons \ No newline at end of file diff --git a/scripts/uosc/lib/char_conv.lua b/scripts/uosc/lib/char_conv.lua index 07085b2..3e45467 100644 --- a/scripts/uosc/lib/char_conv.lua +++ b/scripts/uosc/lib/char_conv.lua @@ -63,4 +63,4 @@ function char_conv(chars, use_ligature, has_separator) end end -return char_conv +return char_conv \ No newline at end of file diff --git a/scripts/uosc/lib/cursor.lua b/scripts/uosc/lib/cursor.lua index c581e38..c7a5cb5 100644 --- a/scripts/uosc/lib/cursor.lua +++ b/scripts/uosc/lib/cursor.lua @@ -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 \ No newline at end of file diff --git a/scripts/uosc/lib/fzy.lua b/scripts/uosc/lib/fzy.lua index 014a800..e13ea8c 100644 --- a/scripts/uosc/lib/fzy.lua +++ b/scripts/uosc/lib/fzy.lua @@ -294,4 +294,4 @@ function fzy.get_implementation_name() return "lua" end -return fzy +return fzy \ No newline at end of file diff --git a/scripts/uosc/lib/intl.lua b/scripts/uosc/lib/intl.lua index 79a7c64..7589eab 100644 --- a/scripts/uosc/lib/intl.lua +++ b/scripts/uosc/lib/intl.lua @@ -65,4 +65,4 @@ for i = #languages, 1, -1 do end end -return {t = t} +return {t = t} \ No newline at end of file diff --git a/scripts/uosc/lib/menus.lua b/scripts/uosc/lib/menus.lua index 89be921..5e7f844 100644 --- a/scripts/uosc/lib/menus.lua +++ b/scripts/uosc/lib/menus.lua @@ -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 \ No newline at end of file diff --git a/scripts/uosc/lib/std.lua b/scripts/uosc/lib/std.lua index 9417bcf..5462f7e 100644 --- a/scripts/uosc/lib/std.lua +++ b/scripts/uosc/lib/std.lua @@ -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 \ No newline at end of file diff --git a/scripts/uosc/lib/text.lua b/scripts/uosc/lib/text.lua index a102d4c..7875e9c 100644 --- a/scripts/uosc/lib/text.lua +++ b/scripts/uosc/lib/text.lua @@ -657,4 +657,4 @@ function get_roman_match_positions(title, query, mode, roman) end return byte_positions -end +end \ No newline at end of file diff --git a/scripts/uosc/lib/utils.lua b/scripts/uosc/lib/utils.lua index 7f3963e..0e2f2a8 100644 --- a/scripts/uosc/lib/utils.lua +++ b/scripts/uosc/lib/utils.lua @@ -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 (0–1) +function load_youtube_heatmap() + if not state.path or not is_protocol(state.path) then return end + -- Match mpv's ytdl whitelist + if not (state.path:match('^https?://%w+%.youtube%.com/') or + state.path:match('^https?://youtube%.com/') or + state.path:match('^https?://youtu%.be/')) then return end + + local r = mp.get_property_native('user-data/mpv/ytdl/json-subprocess-result') + local ytdl_result = r and utils.parse_json(r.stdout) + if ytdl_result and ytdl_result.heatmap then + local data = ytdl_result.heatmap + local max_val = 0 + local vid_length = data[#data].end_time + for _, seg in ipairs(data) do + max_val = math.max(max_val, seg.value) + end + -- Normalize and clamp to avoid gaps in heatmap + local is_above = options.timeline_heatmap == 'above' + local min_height, graph_height = 4, is_above and 40 or options.timeline_size + local max_norm_y = 1 - (min_height / graph_height) + local norm = {0, 1} + for _, seg in ipairs(data) do + local center_time = (seg.start_time + seg.end_time) / 2 + local norm_x = center_time / vid_length + local norm_y = math.min(max_norm_y, 1 - (seg.value / max_val)) + norm[#norm + 1], norm[#norm + 2] = norm_x, norm_y + end + -- Add final anchor + local last_y = math.min(max_norm_y, 1 - (data[#data].value / max_val)) + norm[#norm + 1], norm[#norm + 2] = 1, last_y + norm[#norm + 1], norm[#norm + 2] = 1, 1 + return points_to_bezier(norm) + end +end + --[[ RENDERING ]] 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 \ No newline at end of file diff --git a/scripts/uosc/main.lua b/scripts/uosc/main.lua index 75fc007..8ff6f6a 100644 --- a/scripts/uosc/main.lua +++ b/scripts/uosc/main.lua @@ -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,subtitles,audio,video,editions,stream-quality,gap,space,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) \ No newline at end of file diff --git a/scripts/uosc_danmaku/LICENSE b/scripts/uosc_danmaku/LICENSE index 8cb6f79..39bf231 100644 --- a/scripts/uosc_danmaku/LICENSE +++ b/scripts/uosc_danmaku/LICENSE @@ -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. \ No newline at end of file diff --git a/scripts/uosc_danmaku/README.md b/scripts/uosc_danmaku/README.md index b4a9f2a..a154e6e 100644 --- a/scripts/uosc_danmaku/README.md +++ b/scripts/uosc_danmaku/README.md @@ -7,7 +7,7 @@ > [!NOTE] > 已添加对mpv内部 `mp.input`的支持,在uosc不可用时通过键绑定调用此方式渲染菜单 -> +> > 欲启用此支持mpv最低版本要求:0.39.0 ## 项目简介 @@ -24,25 +24,38 @@
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 ```
@@ -338,34 +351,6 @@ key script-message check-update -
- -load_more_danmaku - -> 开关全量弹幕源加载 - - - -### load_more_danmaku - -#### 功能说明 - -由于弹弹Play默认对于弹幕较多的番剧加载并且整合弹幕的上限大约每集7000条,而这7000条弹幕也不是均匀分配,例如有时弹幕基本只来自于哔哩哔哩,有时弹幕又只来自于巴哈姆特。这样的话弹幕观看体验就和直接在哔哩哔哩或者巴哈姆特观看没有区别了,失去了弹弹Play整合全平台弹幕的优势。 - -因此,本人添加了配置选项 `load_more_danmaku`,用来将从弹弹Play获取弹幕的逻辑更改为逐一搜索所有弹幕源下的全部弹幕,并由本脚本整合加载。开启此选项可以获取到所有可用弹幕源下的所有弹幕。但是对于一些热门番剧来说,弹幕数量可能破万,如果接受不了屏幕上弹幕太多,请不要开启此选项。(嘛,不过本人看视频从来只会觉得弹幕多多益善) - -#### 使用方法 - -想要开启此选项,请在mpv配置文件夹下的 `script-opts`中创建 `uosc_danmaku.conf`文件并添加如下内容: - -``` -load_more_danmaku=yes -``` - -
- ---- -
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
--- +
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.icu,https://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 ```
@@ -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) \ No newline at end of file diff --git a/scripts/uosc_danmaku/apis/dandanplay.lua b/scripts/uosc_danmaku/apis/dandanplay.lua index 1a35e87..fa8f42e 100644 --- a/scripts/uosc_danmaku/apis/dandanplay.lua +++ b/scripts/uosc_danmaku/apis/dandanplay.lua @@ -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 api(url)+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 \ No newline at end of file diff --git a/scripts/uosc_danmaku/apis/extra.lua b/scripts/uosc_danmaku/apis/extra.lua index 064791d..a124d0b 100644 --- a/scripts/uosc_danmaku/apis/extra.lua +++ b/scripts/uosc_danmaku/apis/extra.lua @@ -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) \ No newline at end of file diff --git a/scripts/uosc_danmaku/dicts/s2t_chars.lua b/scripts/uosc_danmaku/dicts/s2t_chars.lua index cf2a095..0893b07 100644 --- a/scripts/uosc_danmaku/dicts/s2t_chars.lua +++ b/scripts/uosc_danmaku/dicts/s2t_chars.lua @@ -3979,4 +3979,4 @@ return { ["誉"] = "譽", ["𫐖"] = "轇", ["输"] = "輸", -} +} \ No newline at end of file diff --git a/scripts/uosc_danmaku/dicts/t2s_chars.lua b/scripts/uosc_danmaku/dicts/t2s_chars.lua index 5d6e8bd..f456942 100644 --- a/scripts/uosc_danmaku/dicts/t2s_chars.lua +++ b/scripts/uosc_danmaku/dicts/t2s_chars.lua @@ -4112,4 +4112,4 @@ return { ["𪷓"] = "𣶭", ["𫒡"] = "𫓷", ["𫜦"] = "𫜫", -} +} \ No newline at end of file diff --git a/scripts/uosc_danmaku/main.lua b/scripts/uosc_danmaku/main.lua index a22311d..935af72 100644 --- a/scripts/uosc_danmaku/main.lua +++ b/scripts/uosc_danmaku/main.lua @@ -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) \ No newline at end of file diff --git a/scripts/uosc_danmaku/modules/guess.lua b/scripts/uosc_danmaku/modules/guess.lua index e0ff7ea..5440cc9 100644 --- a/scripts/uosc_danmaku/modules/guess.lua +++ b/scripts/uosc_danmaku/modules/guess.lua @@ -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 \ No newline at end of file diff --git a/scripts/uosc_danmaku/modules/hash.lua b/scripts/uosc_danmaku/modules/hash.lua index 04029b2..50240a4 100644 --- a/scripts/uosc_danmaku/modules/hash.lua +++ b/scripts/uosc_danmaku/modules/hash.lua @@ -189,4 +189,4 @@ local function sha256(message) return result end -return sha256 +return sha256 \ No newline at end of file diff --git a/scripts/uosc_danmaku/modules/menu.lua b/scripts/uosc_danmaku/modules/menu.lua index 820bcbf..691f30b 100644 --- a/scripts/uosc_danmaku/modules/menu.lua +++ b/scripts/uosc_danmaku/modules/menu.lua @@ -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) \ No newline at end of file diff --git a/scripts/uosc_danmaku/modules/options.lua b/scripts/uosc_danmaku/modules/options.lua index 5168d26..370e741 100644 --- a/scripts/uosc_danmaku/modules/options.lua +++ b/scripts/uosc_danmaku/modules/options.lua @@ -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.icu,https://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) \ No newline at end of file diff --git a/scripts/uosc_danmaku/modules/parse.lua b/scripts/uosc_danmaku/modules/parse.lua index 5675f56..ad28c40 100644 --- a/scripts/uosc_danmaku/modules/parse.lua +++ b/scripts/uosc_danmaku/modules/parse.lua @@ -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文件格式示例 + -- + -- + -- t=卡在 + -- t=进度条 + -- + print("加载黑名单文件: " .. filepath) + for line in file:lines() do + local pattern = line:match('t=(.-)') + 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('([^<]+)') do + -- [^>]* 匹配其他 attributes + -- %f[^%s] 确保 p= 前面是空白字符 + for p_attr, text in xml_string:gmatch(']*%f[^%s]p="([^"]+)"[^>]*>([^<]+)') 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 = { '\n' } + for _, d in ipairs(danmakus) do + local time = d.time + local type = d.type or 1 + local size = d.size or 25 + local color = d.color or 0xFFFFFF + local text = d.text or "" + + text = text:gsub("&", "&") + :gsub("<", "<") + :gsub(">", ">") + :gsub("\"", """) + :gsub("'", "'") + + table.insert(xml, string.format('%s\n', time, type, size, color, text)) + end + table.insert(xml, '') + + -- 写入 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 = { '\n' } - for _, d in ipairs(all_danmaku) do - local time = d.time - local type = d.type or 1 - local size = d.size or 25 - local color = d.color or 0xFFFFFF - local text = d.text or "" - - text = text:gsub("&", "&") - :gsub("<", "<") - :gsub(">", ">") - :gsub("\"", """) - :gsub("'", "'") - - table.insert(xml, string.format('%s\n', time, type, size, color, text)) - end - table.insert(xml, '') - - -- 写入 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 \ No newline at end of file diff --git a/scripts/uosc_danmaku/modules/render.lua b/scripts/uosc_danmaku/modules/render.lua index ffae9ed..2088859 100644 --- a/scripts/uosc_danmaku/modules/render.lua +++ b/scripts/uosc_danmaku/modules/render.lua @@ -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) \ No newline at end of file diff --git a/scripts/uosc_danmaku/modules/update.lua b/scripts/uosc_danmaku/modules/update.lua index 9119b2a..bd6a96c 100644 --- a/scripts/uosc_danmaku/modules/update.lua +++ b/scripts/uosc_danmaku/modules/update.lua @@ -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 \ No newline at end of file diff --git a/scripts/uosc_danmaku/modules/utils.lua b/scripts/uosc_danmaku/modules/utils.lua index bd291a8..46fc31b 100644 --- a/scripts/uosc_danmaku/modules/utils.lua +++ b/scripts/uosc_danmaku/modules/utils.lua @@ -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