diff options
Diffstat (limited to 'player/lua')
-rw-r--r-- | player/lua/assdraw.lua | 160 | ||||
-rw-r--r-- | player/lua/auto_profiles.lua | 198 | ||||
-rw-r--r-- | player/lua/console.lua | 1204 | ||||
-rw-r--r-- | player/lua/defaults.lua | 836 | ||||
-rw-r--r-- | player/lua/meson.build | 10 | ||||
-rw-r--r-- | player/lua/options.lua | 164 | ||||
-rw-r--r-- | player/lua/osc.lua | 2917 | ||||
-rw-r--r-- | player/lua/stats.lua | 1417 | ||||
-rw-r--r-- | player/lua/ytdl_hook.lua | 1191 |
9 files changed, 8097 insertions, 0 deletions
diff --git a/player/lua/assdraw.lua b/player/lua/assdraw.lua new file mode 100644 index 0000000..06079d5 --- /dev/null +++ b/player/lua/assdraw.lua @@ -0,0 +1,160 @@ +local ass_mt = {} +ass_mt.__index = ass_mt +local c = 0.551915024494 -- circle approximation + +local function ass_new() + return setmetatable({ scale = 4, text = "" }, ass_mt) +end + +function ass_mt.new_event(ass) + -- osd_libass.c adds an event per line + if #ass.text > 0 then + ass.text = ass.text .. "\n" + end +end + +function ass_mt.draw_start(ass) + ass.text = string.format("%s{\\p%d}", ass.text, ass.scale) +end + +function ass_mt.draw_stop(ass) + ass.text = ass.text .. "{\\p0}" +end + +function ass_mt.coord(ass, x, y) + local scale = 2 ^ (ass.scale - 1) + local ix = math.ceil(x * scale) + local iy = math.ceil(y * scale) + ass.text = string.format("%s %d %d", ass.text, ix, iy) +end + +function ass_mt.append(ass, s) + ass.text = ass.text .. s +end + +function ass_mt.merge(ass1, ass2) + ass1.text = ass1.text .. ass2.text +end + +function ass_mt.pos(ass, x, y) + ass:append(string.format("{\\pos(%f,%f)}", x, y)) +end + +function ass_mt.an(ass, an) + ass:append(string.format("{\\an%d}", an)) +end + +function ass_mt.move_to(ass, x, y) + ass:append(" m") + ass:coord(x, y) +end + +function ass_mt.line_to(ass, x, y) + ass:append(" l") + ass:coord(x, y) +end + +function ass_mt.bezier_curve(ass, x1, y1, x2, y2, x3, y3) + ass:append(" b") + ass:coord(x1, y1) + ass:coord(x2, y2) + ass:coord(x3, y3) +end + + +function ass_mt.rect_ccw(ass, x0, y0, x1, y1) + ass:move_to(x0, y0) + ass:line_to(x0, y1) + ass:line_to(x1, y1) + ass:line_to(x1, y0) +end + +function ass_mt.rect_cw(ass, x0, y0, x1, y1) + ass:move_to(x0, y0) + ass:line_to(x1, y0) + ass:line_to(x1, y1) + ass:line_to(x0, y1) +end + +function ass_mt.hexagon_cw(ass, x0, y0, x1, y1, r1, r2) + if r2 == nil then + r2 = r1 + end + ass:move_to(x0 + r1, y0) + if x0 ~= x1 then + ass:line_to(x1 - r2, y0) + end + ass:line_to(x1, y0 + r2) + if x0 ~= x1 then + ass:line_to(x1 - r2, y1) + end + ass:line_to(x0 + r1, y1) + ass:line_to(x0, y0 + r1) +end + +function ass_mt.hexagon_ccw(ass, x0, y0, x1, y1, r1, r2) + if r2 == nil then + r2 = r1 + end + ass:move_to(x0 + r1, y0) + ass:line_to(x0, y0 + r1) + ass:line_to(x0 + r1, y1) + if x0 ~= x1 then + ass:line_to(x1 - r2, y1) + end + ass:line_to(x1, y0 + r2) + if x0 ~= x1 then + ass:line_to(x1 - r2, y0) + end +end + +function ass_mt.round_rect_cw(ass, x0, y0, x1, y1, r1, r2) + if r2 == nil then + r2 = r1 + end + local c1 = c * r1 -- circle approximation + local c2 = c * r2 -- circle approximation + ass:move_to(x0 + r1, y0) + ass:line_to(x1 - r2, y0) -- top line + if r2 > 0 then + ass:bezier_curve(x1 - r2 + c2, y0, x1, y0 + r2 - c2, x1, y0 + r2) -- top right corner + end + ass:line_to(x1, y1 - r2) -- right line + if r2 > 0 then + ass:bezier_curve(x1, y1 - r2 + c2, x1 - r2 + c2, y1, x1 - r2, y1) -- bottom right corner + end + ass:line_to(x0 + r1, y1) -- bottom line + if r1 > 0 then + ass:bezier_curve(x0 + r1 - c1, y1, x0, y1 - r1 + c1, x0, y1 - r1) -- bottom left corner + end + ass:line_to(x0, y0 + r1) -- left line + if r1 > 0 then + ass:bezier_curve(x0, y0 + r1 - c1, x0 + r1 - c1, y0, x0 + r1, y0) -- top left corner + end +end + +function ass_mt.round_rect_ccw(ass, x0, y0, x1, y1, r1, r2) + if r2 == nil then + r2 = r1 + end + local c1 = c * r1 -- circle approximation + local c2 = c * r2 -- circle approximation + ass:move_to(x0 + r1, y0) + if r1 > 0 then + ass:bezier_curve(x0 + r1 - c1, y0, x0, y0 + r1 - c1, x0, y0 + r1) -- top left corner + end + ass:line_to(x0, y1 - r1) -- left line + if r1 > 0 then + ass:bezier_curve(x0, y1 - r1 + c1, x0 + r1 - c1, y1, x0 + r1, y1) -- bottom left corner + end + ass:line_to(x1 - r2, y1) -- bottom line + if r2 > 0 then + ass:bezier_curve(x1 - r2 + c2, y1, x1, y1 - r2 + c2, x1, y1 - r2) -- bottom right corner + end + ass:line_to(x1, y0 + r2) -- right line + if r2 > 0 then + ass:bezier_curve(x1, y0 + r2 - c2, x1 - r2 + c2, y0, x1 - r2, y0) -- top right corner + end +end + +return {ass_new = ass_new} diff --git a/player/lua/auto_profiles.lua b/player/lua/auto_profiles.lua new file mode 100644 index 0000000..9dca878 --- /dev/null +++ b/player/lua/auto_profiles.lua @@ -0,0 +1,198 @@ +-- Note: anything global is accessible by profile condition expressions. + +local utils = require 'mp.utils' +local msg = require 'mp.msg' + +local profiles = {} +local watched_properties = {} -- indexed by property name (used as a set) +local cached_properties = {} -- property name -> last known raw value +local properties_to_profiles = {} -- property name -> set of profiles using it +local have_dirty_profiles = false -- at least one profile is marked dirty +local pending_hooks = {} -- as set (keys only, meaningless values) + +-- Used during evaluation of the profile condition, and should contain the +-- profile the condition is evaluated for. +local current_profile = nil + +-- Cached set of all top-level mpv properities. Only used for extra validation. +local property_set = {} +for _, property in pairs(mp.get_property_native("property-list")) do + property_set[property] = true +end + +local function evaluate(profile) + msg.verbose("Re-evaluating auto profile " .. profile.name) + + current_profile = profile + local status, res = pcall(profile.cond) + current_profile = nil + + if not status then + -- errors can be "normal", e.g. in case properties are unavailable + msg.verbose("Profile condition error on evaluating: " .. res) + res = false + end + res = not not res + if res ~= profile.status then + if res == true then + msg.info("Applying auto profile: " .. profile.name) + mp.commandv("apply-profile", profile.name) + elseif profile.status == true and profile.has_restore_opt then + msg.info("Restoring profile: " .. profile.name) + mp.commandv("apply-profile", profile.name, "restore") + end + end + profile.status = res + profile.dirty = false +end + +local function on_property_change(name, val) + cached_properties[name] = val + -- Mark all profiles reading this property as dirty, so they get re-evaluated + -- the next time the script goes back to sleep. + local dependent_profiles = properties_to_profiles[name] + if dependent_profiles then + for profile, _ in pairs(dependent_profiles) do + assert(profile.cond) -- must be a profile table + profile.dirty = true + have_dirty_profiles = true + end + end +end + +local function on_idle() + -- When events and property notifications stop, re-evaluate all dirty profiles. + if have_dirty_profiles then + for _, profile in ipairs(profiles) do + if profile.dirty then + evaluate(profile) + end + end + end + have_dirty_profiles = false + -- Release all hooks (the point was to wait until an idle event) + while true do + local h = next(pending_hooks) + if not h then + break + end + pending_hooks[h] = nil + h:cont() + end +end + +local function on_hook(h) + h:defer() + pending_hooks[h] = true +end + +function get(name, default) + -- Normally, we use the cached value only + if not watched_properties[name] then + watched_properties[name] = true + local res, err = mp.get_property_native(name) + -- Property has to not exist and the toplevel of property in the name must also + -- not have an existing match in the property set for this to be considered an error. + -- This allows things like user-data/test to still work. + if err == "property not found" and property_set[name:match("^([^/]+)")] == nil then + msg.error("Property '" .. name .. "' was not found.") + return default + end + cached_properties[name] = res + mp.observe_property(name, "native", on_property_change) + end + -- The first time the property is read we need add it to the + -- properties_to_profiles table, which will be used to mark the profile + -- dirty if a property referenced by it changes. + if current_profile then + local map = properties_to_profiles[name] + if not map then + map = {} + properties_to_profiles[name] = map + end + map[current_profile] = true + end + local val = cached_properties[name] + if val == nil then + val = default + end + return val +end + +local function magic_get(name) + -- Lua identifiers can't contain "-", so in order to match with mpv + -- property conventions, replace "_" to "-" + name = string.gsub(name, "_", "-") + return get(name, nil) +end + +local evil_magic = {} +setmetatable(evil_magic, { + __index = function(table, key) + -- interpret everything as property, unless it already exists as + -- a non-nil global value + local v = _G[key] + if type(v) ~= "nil" then + return v + end + return magic_get(key) + end, +}) + +p = {} +setmetatable(p, { + __index = function(table, key) + return magic_get(key) + end, +}) + +local function compile_cond(name, s) + local code, chunkname = "return " .. s, "profile " .. name .. " condition" + local chunk, err + if setfenv then -- lua 5.1 + chunk, err = loadstring(code, chunkname) + if chunk then + setfenv(chunk, evil_magic) + end + else -- lua 5.2 + chunk, err = load(code, chunkname, "t", evil_magic) + end + if not chunk then + msg.error("Profile '" .. name .. "' condition: " .. err) + chunk = function() return false end + end + return chunk +end + +local function load_profiles() + for i, v in ipairs(mp.get_property_native("profile-list")) do + local cond = v["profile-cond"] + if cond and #cond > 0 then + local profile = { + name = v.name, + cond = compile_cond(v.name, cond), + properties = {}, + status = nil, + dirty = true, -- need re-evaluate + has_restore_opt = v["profile-restore"] and v["profile-restore"] ~= "default" + } + profiles[#profiles + 1] = profile + have_dirty_profiles = true + end + end +end + +load_profiles() + +if #profiles < 1 and mp.get_property("load-auto-profiles") == "auto" then + -- make it exit immediately + _G.mp_event_loop = function() end + return +end + +mp.register_idle(on_idle) +for _, name in ipairs({"on_load", "on_preloaded", "on_before_start_file"}) do + mp.add_hook(name, 50, on_hook) +end + +on_idle() -- re-evaluate all profiles immediately diff --git a/player/lua/console.lua b/player/lua/console.lua new file mode 100644 index 0000000..44e9436 --- /dev/null +++ b/player/lua/console.lua @@ -0,0 +1,1204 @@ +-- Copyright (C) 2019 the mpv developers +-- +-- Permission to use, copy, modify, and/or distribute this software for any +-- purpose with or without fee is hereby granted, provided that the above +-- copyright notice and this permission notice appear in all copies. +-- +-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +-- WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +-- MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +-- SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +-- WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION +-- OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +-- CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +local utils = require 'mp.utils' +local assdraw = require 'mp.assdraw' + +-- Default options +local opts = { + -- All drawing is scaled by this value, including the text borders and the + -- cursor. Change it if you have a high-DPI display. + scale = 1, + -- Set the font used for the REPL and the console. + -- This has to be a monospaced font. + font = "", + -- Set the font size used for the REPL and the console. This will be + -- multiplied by "scale". + font_size = 16, + border_size = 1, + -- Remove duplicate entries in history as to only keep the latest one. + history_dedup = true, + -- The ratio of font height to font width. + -- Adjusts table width of completion suggestions. + font_hw_ratio = 2.0, +} + +function detect_platform() + local platform = mp.get_property_native('platform') + if platform == 'darwin' or platform == 'windows' then + return platform + elseif os.getenv('WAYLAND_DISPLAY') then + return 'wayland' + end + return 'x11' +end + +-- Pick a better default font for Windows and macOS +local platform = detect_platform() +if platform == 'windows' then + opts.font = 'Consolas' +elseif platform == 'darwin' then + opts.font = 'Menlo' +else + opts.font = 'monospace' +end + +-- Apply user-set options +require 'mp.options'.read_options(opts) + +local styles = { + -- Colors are stolen from base16 Eighties by Chris Kempson + -- and converted to BGR as is required by ASS. + -- 2d2d2d 393939 515151 697374 + -- 939fa0 c8d0d3 dfe6e8 ecf0f2 + -- 7a77f2 5791f9 66ccff 99cc99 + -- cccc66 cc9966 cc99cc 537bd2 + + debug = '{\\1c&Ha09f93&}', + verbose = '{\\1c&H99cc99&}', + warn = '{\\1c&H66ccff&}', + error = '{\\1c&H7a77f2&}', + fatal = '{\\1c&H5791f9&\\b1}', + suggestion = '{\\1c&Hcc99cc&}', +} + +local repl_active = false +local insert_mode = false +local pending_update = false +local line = '' +local cursor = 1 +local history = {} +local history_pos = 1 +local log_buffer = {} +local suggestion_buffer = {} +local key_bindings = {} +local global_margins = { t = 0, b = 0 } + +local file_commands = {} +local path_separator = platform == 'windows' and '\\' or '/' + +local update_timer = nil +update_timer = mp.add_periodic_timer(0.05, function() + if pending_update then + update() + else + update_timer:kill() + end +end) +update_timer:kill() + +mp.observe_property("user-data/osc/margins", "native", function(_, val) + if val then + global_margins = val + else + global_margins = { t = 0, b = 0 } + end + update() +end) + +-- Add a line to the log buffer (which is limited to 100 lines) +function log_add(style, text) + log_buffer[#log_buffer + 1] = { style = style, text = text } + if #log_buffer > 100 then + table.remove(log_buffer, 1) + end + + if repl_active then + if not update_timer:is_enabled() then + update() + update_timer:resume() + else + pending_update = true + end + end +end + +-- Escape a string for verbatim display on the OSD +function ass_escape(str) + -- There is no escape for '\' in ASS (I think?) but '\' is used verbatim if + -- it isn't followed by a recognised character, so add a zero-width + -- non-breaking space + str = str:gsub('\\', '\\\239\187\191') + str = str:gsub('{', '\\{') + str = str:gsub('}', '\\}') + -- Precede newlines with a ZWNBSP to prevent ASS's weird collapsing of + -- consecutive newlines + str = str:gsub('\n', '\239\187\191\\N') + -- Turn leading spaces into hard spaces to prevent ASS from stripping them + str = str:gsub('\\N ', '\\N\\h') + str = str:gsub('^ ', '\\h') + return str +end + +-- Takes a list of strings, a max width in characters and +-- optionally a max row count. +-- The result contains at least one column. +-- Rows are cut off from the top if rows_max is specified. +-- returns a string containing the formatted table and the row count +function format_table(list, width_max, rows_max) + if #list == 0 then + return '', 0 + end + + local spaces_min = 2 + local spaces_max = 8 + local list_size = #list + local column_count = 1 + local row_count = list_size + local column_widths + -- total width without spacing + local width_total = 0 + + local list_widths = {} + for i, item in ipairs(list) do + list_widths[i] = len_utf8(item) + end + + -- use as many columns as possible + for columns = 2, list_size do + local rows_lower_bound = math.min(rows_max, math.ceil(list_size / columns)) + local rows_upper_bound = math.min(rows_max, list_size, math.ceil(list_size / (columns - 1) - 1)) + for rows = rows_upper_bound, rows_lower_bound, -1 do + cw = {} + width_total = 0 + + -- find out width of each column + for column = 1, columns do + local width = 0 + for row = 1, rows do + local i = row + (column - 1) * rows + local item_width = list_widths[i] + if not item_width then break end + if width < item_width then + width = item_width + end + end + cw[column] = width + width_total = width_total + width + if width_total + (columns - 1) * spaces_min > width_max then + break + end + end + + if width_total + (columns - 1) * spaces_min <= width_max then + row_count = rows + column_count = columns + column_widths = cw + else + break + end + end + if width_total + (columns - 1) * spaces_min > width_max then + break + end + end + + local spaces = math.floor((width_max - width_total) / (column_count - 1)) + spaces = math.max(spaces_min, math.min(spaces_max, spaces)) + local spacing = column_count > 1 and string.format('%' .. spaces .. 's', ' ') or '' + + local rows = {} + for row = 1, row_count do + local columns = {} + for column = 1, column_count do + local i = row + (column - 1) * row_count + if i > #list then break end + -- more then 99 leads to 'invalid format (width or precision too long)' + local format_string = column == column_count and '%s' + or '%-' .. math.min(column_widths[column], 99) .. 's' + columns[column] = string.format(format_string, list[i]) + end + -- first row is at the bottom + rows[row_count - row + 1] = table.concat(columns, spacing) + end + return table.concat(rows, '\n'), row_count +end + +local function print_to_terminal() + -- Clear the log after closing the console. + if not repl_active then + mp.osd_message('') + return + end + + local log = '' + for _, log_line in ipairs(log_buffer) do + log = log .. log_line.text + end + + local suggestions = table.concat(suggestion_buffer, '\t') + if suggestions ~= '' then + suggestions = suggestions .. '\n' + end + + local before_cur = line:sub(1, cursor - 1) + local after_cur = line:sub(cursor) + -- Ensure there is a character with inverted colors to print. + if after_cur == '' then + after_cur = ' ' + end + + mp.osd_message(log .. suggestions .. '> ' .. before_cur .. '\027[7m' .. + after_cur:sub(1, 1) .. '\027[0m' .. after_cur:sub(2), 999) +end + +-- Render the REPL and console as an ASS OSD +function update() + pending_update = false + + -- Print to the terminal when there is no VO. Check both vo-configured so + -- it works with --force-window --idle and no video tracks, and whether + -- there is a video track so that the condition doesn't become true while + -- switching VO at runtime, making mp.osd_message() print to the VO's OSD. + -- This issue does not happen when switching VO without any video track + -- regardless of the condition used. + if not mp.get_property_native('vo-configured') + and not mp.get_property('current-tracks/video') then + print_to_terminal() + return + end + + local dpi_scale = mp.get_property_native("display-hidpi-scale", 1.0) + + dpi_scale = dpi_scale * opts.scale + + local screenx, screeny, aspect = mp.get_osd_size() + screenx = screenx / dpi_scale + screeny = screeny / dpi_scale + + -- Clear the OSD if the REPL is not active + if not repl_active then + mp.set_osd_ass(screenx, screeny, '') + return + end + + local coordinate_top = math.floor(global_margins.t * screeny + 0.5) + local clipping_coordinates = '0,' .. coordinate_top .. ',' .. + screenx .. ',' .. screeny + local ass = assdraw.ass_new() + local has_shadow = mp.get_property('osd-back-color'):sub(2, 3) == '00' + local style = '{\\r' .. + '\\1a&H00&\\3a&H00&\\1c&Heeeeee&\\3c&H111111&' .. + (has_shadow and '\\4a&H99&\\4c&H000000&' or '') .. + '\\fn' .. opts.font .. '\\fs' .. opts.font_size .. + '\\bord' .. opts.border_size .. '\\xshad0\\yshad1\\fsp0\\q1' .. + '\\clip(' .. clipping_coordinates .. ')}' + -- Create the cursor glyph as an ASS drawing. ASS will draw the cursor + -- inline with the surrounding text, but it sets the advance to the width + -- of the drawing. So the cursor doesn't affect layout too much, make it as + -- thin as possible and make it appear to be 1px wide by giving it 0.5px + -- horizontal borders. + local cheight = opts.font_size * 8 + local cglyph = '{\\r' .. + '\\1a&H44&\\3a&H44&\\4a&H99&' .. + '\\1c&Heeeeee&\\3c&Heeeeee&\\4c&H000000&' .. + '\\xbord0.5\\ybord0\\xshad0\\yshad1\\p4\\pbo24}' .. + 'm 0 0 l 1 0 l 1 ' .. cheight .. ' l 0 ' .. cheight .. + '{\\p0}' + local before_cur = ass_escape(line:sub(1, cursor - 1)) + local after_cur = ass_escape(line:sub(cursor)) + + -- Render log messages as ASS. + -- This will render at most screeny / font_size - 1 messages. + + -- lines above the prompt + -- subtract 1.5 to account for the input line + local screeny_factor = (1 - global_margins.t - global_margins.b) + local lines_max = math.ceil(screeny * screeny_factor / opts.font_size - 1.5) + -- Estimate how many characters fit in one line + local width_max = math.ceil(screenx / opts.font_size * opts.font_hw_ratio) + + local suggestions, rows = format_table(suggestion_buffer, width_max, lines_max) + local suggestion_ass = style .. styles.suggestion .. ass_escape(suggestions) + + local log_ass = '' + local log_messages = #log_buffer + local log_max_lines = math.max(0, lines_max - rows) + if log_max_lines < log_messages then + log_messages = log_max_lines + end + for i = #log_buffer - log_messages + 1, #log_buffer do + log_ass = log_ass .. style .. log_buffer[i].style .. ass_escape(log_buffer[i].text) + end + + ass:new_event() + ass:an(1) + ass:pos(2, screeny - 2 - global_margins.b * screeny) + ass:append(log_ass .. '\\N') + if #suggestions > 0 then + ass:append(suggestion_ass .. '\\N') + end + ass:append(style .. '> ' .. before_cur) + ass:append(cglyph) + ass:append(style .. after_cur) + + -- Redraw the cursor with the REPL text invisible. This will make the + -- cursor appear in front of the text. + ass:new_event() + ass:an(1) + ass:pos(2, screeny - 2 - global_margins.b * screeny) + ass:append(style .. '{\\alpha&HFF&}> ' .. before_cur) + ass:append(cglyph) + ass:append(style .. '{\\alpha&HFF&}' .. after_cur) + + mp.set_osd_ass(screenx, screeny, ass.text) +end + +-- Set the REPL visibility ("enable", Esc) +function set_active(active) + if active == repl_active then return end + if active then + repl_active = true + insert_mode = false + mp.enable_key_bindings('console-input', 'allow-hide-cursor+allow-vo-dragging') + mp.enable_messages('terminal-default') + define_key_bindings() + else + repl_active = false + undefine_key_bindings() + mp.enable_messages('silent:terminal-default') + collectgarbage() + end + update() +end + +-- Show the repl if hidden and replace its contents with 'text' +-- (script-message-to repl type) +function show_and_type(text, cursor_pos) + text = text or '' + cursor_pos = tonumber(cursor_pos) + + -- Save the line currently being edited, just in case + if line ~= text and line ~= '' and history[#history] ~= line then + history_add(line) + end + + line = text + if cursor_pos ~= nil and cursor_pos >= 1 + and cursor_pos <= line:len() + 1 then + cursor = math.floor(cursor_pos) + else + cursor = line:len() + 1 + end + history_pos = #history + 1 + insert_mode = false + if repl_active then + update() + else + set_active(true) + end +end + +-- Naive helper function to find the next UTF-8 character in 'str' after 'pos' +-- by skipping continuation bytes. Assumes 'str' contains valid UTF-8. +function next_utf8(str, pos) + if pos > str:len() then return pos end + repeat + pos = pos + 1 + until pos > str:len() or str:byte(pos) < 0x80 or str:byte(pos) > 0xbf + return pos +end + +-- As above, but finds the previous UTF-8 character in 'str' before 'pos' +function prev_utf8(str, pos) + if pos <= 1 then return pos end + repeat + pos = pos - 1 + until pos <= 1 or str:byte(pos) < 0x80 or str:byte(pos) > 0xbf + return pos +end + +function len_utf8(str) + local len = 0 + local pos = 1 + while pos <= str:len() do + pos = next_utf8(str, pos) + len = len + 1 + end + return len +end + +-- Insert a character at the current cursor position (any_unicode) +function handle_char_input(c) + if insert_mode then + line = line:sub(1, cursor - 1) .. c .. line:sub(next_utf8(line, cursor)) + else + line = line:sub(1, cursor - 1) .. c .. line:sub(cursor) + end + cursor = cursor + #c + suggestion_buffer = {} + update() +end + +-- Remove the character behind the cursor (Backspace) +function handle_backspace() + if cursor <= 1 then return end + local prev = prev_utf8(line, cursor) + line = line:sub(1, prev - 1) .. line:sub(cursor) + cursor = prev + suggestion_buffer = {} + update() +end + +-- Remove the character in front of the cursor (Del) +function handle_del() + if cursor > line:len() then return end + line = line:sub(1, cursor - 1) .. line:sub(next_utf8(line, cursor)) + suggestion_buffer = {} + update() +end + +-- Toggle insert mode (Ins) +function handle_ins() + insert_mode = not insert_mode +end + +-- Move the cursor to the next character (Right) +function next_char(amount) + cursor = next_utf8(line, cursor) + update() +end + +-- Move the cursor to the previous character (Left) +function prev_char(amount) + cursor = prev_utf8(line, cursor) + update() +end + +-- Clear the current line (Ctrl+C) +function clear() + line = '' + cursor = 1 + insert_mode = false + history_pos = #history + 1 + suggestion_buffer = {} + update() +end + +-- Close the REPL if the current line is empty, otherwise delete the next +-- character (Ctrl+D) +function maybe_exit() + if line == '' then + set_active(false) + else + handle_del() + end +end + +function help_command(param) + local cmdlist = mp.get_property_native('command-list') + table.sort(cmdlist, function(c1, c2) + return c1.name < c2.name + end) + local output = '' + if param == '' then + output = 'Available commands:\n' + for _, cmd in ipairs(cmdlist) do + output = output .. ' ' .. cmd.name + end + output = output .. '\n' + output = output .. 'Use "help command" to show information about a command.\n' + output = output .. "ESC or Ctrl+d exits the console.\n" + else + local cmd = nil + for _, curcmd in ipairs(cmdlist) do + if curcmd.name:find(param, 1, true) then + cmd = curcmd + if curcmd.name == param then + break -- exact match + end + end + end + if not cmd then + log_add(styles.error, 'No command matches "' .. param .. '"!') + return + end + output = output .. 'Command "' .. cmd.name .. '"\n' + for _, arg in ipairs(cmd.args) do + output = output .. ' ' .. arg.name .. ' (' .. arg.type .. ')' + if arg.optional then + output = output .. ' (optional)' + end + output = output .. '\n' + end + if cmd.vararg then + output = output .. 'This command supports variable arguments.\n' + end + end + log_add('', output) +end + +-- Add a line to the history and deduplicate +function history_add(text) + if opts.history_dedup then + -- More recent entries are more likely to be repeated + for i = #history, 1, -1 do + if history[i] == text then + table.remove(history, i) + break + end + end + end + + history[#history + 1] = text +end + +-- Run the current command and clear the line (Enter) +function handle_enter() + if line == '' then + return + end + if history[#history] ~= line then + history_add(line) + end + + -- match "help [<text>]", return <text> or "", strip all whitespace + local help = line:match('^%s*help%s+(.-)%s*$') or + (line:match('^%s*help$') and '') + if help then + help_command(help) + else + mp.command(line) + end + + clear() +end + +-- Go to the specified position in the command history +function go_history(new_pos) + local old_pos = history_pos + history_pos = new_pos + + -- Restrict the position to a legal value + if history_pos > #history + 1 then + history_pos = #history + 1 + elseif history_pos < 1 then + history_pos = 1 + end + + -- Do nothing if the history position didn't actually change + if history_pos == old_pos then + return + end + + -- If the user was editing a non-history line, save it as the last history + -- entry. This makes it much less frustrating to accidentally hit Up/Down + -- while editing a line. + if old_pos == #history + 1 and line ~= '' and history[#history] ~= line then + history_add(line) + end + + -- Now show the history line (or a blank line for #history + 1) + if history_pos <= #history then + line = history[history_pos] + else + line = '' + end + cursor = line:len() + 1 + insert_mode = false + update() +end + +-- Go to the specified relative position in the command history (Up, Down) +function move_history(amount) + go_history(history_pos + amount) +end + +-- Go to the first command in the command history (PgUp) +function handle_pgup() + go_history(1) +end + +-- Stop browsing history and start editing a blank line (PgDown) +function handle_pgdown() + go_history(#history + 1) +end + +-- Move to the start of the current word, or if already at the start, the start +-- of the previous word. (Ctrl+Left) +function prev_word() + -- This is basically the same as next_word() but backwards, so reverse the + -- string in order to do a "backwards" find. This wouldn't be as annoying + -- to do if Lua didn't insist on 1-based indexing. + cursor = line:len() - select(2, line:reverse():find('%s*[^%s]*', line:len() - cursor + 2)) + 1 + update() +end + +-- Move to the end of the current word, or if already at the end, the end of +-- the next word. (Ctrl+Right) +function next_word() + cursor = select(2, line:find('%s*[^%s]*', cursor)) + 1 + update() +end + +local function command_list() + local commands = {} + for i, command in ipairs(mp.get_property_native('command-list')) do + commands[i] = command.name + end + + return commands +end + +local function command_list_and_help() + local commands = command_list() + commands[#commands + 1] = 'help' + + return commands +end + +local function property_list() + local option_info = { + 'name', 'type', 'set-from-commandline', 'set-locally', 'default-value', + 'min', 'max', 'choices', + } + + local properties = mp.get_property_native('property-list') + + for _, option in ipairs(mp.get_property_native('options')) do + properties[#properties + 1] = 'options/' .. option + properties[#properties + 1] = 'file-local-options/' .. option + properties[#properties + 1] = 'option-info/' .. option + + for _, sub_property in ipairs(option_info) do + properties[#properties + 1] = 'option-info/' .. option .. '/' .. + sub_property + end + end + + return properties +end + +local function profile_list() + local profiles = {} + + for i, profile in ipairs(mp.get_property_native('profile-list')) do + profiles[i] = profile.name + end + + return profiles +end + +local function list_option_list() + local options = {} + + -- Don't log errors for renamed and removed properties. + -- (Just mp.enable_messages('fatal') still logs them to the terminal.) + local msg_level_backup = mp.get_property('msg-level') + mp.set_property('msg-level', msg_level_backup == '' and 'cplayer=no' + or msg_level_backup .. ',cplayer=no') + + for _, option in pairs(mp.get_property_native('options')) do + if mp.get_property('option-info/' .. option .. '/type', ''):find(' list$') then + options[#options + 1] = option + end + end + + mp.set_property('msg-level', msg_level_backup) + + return options +end + +local function list_option_verb_list(option) + local type = mp.get_property('option-info/' .. option .. '/type') + + if type == 'Key/value list' then + return {'add', 'append', 'set', 'remove'} + end + + if type == 'String list' or type == 'Object settings list' then + return {'add', 'append', 'clr', 'pre', 'set', 'remove', 'toggle'} + end + + return {} +end + +local function choice_list(option) + local info = mp.get_property_native('option-info/' .. option, {}) + + if info.type == 'Flag' then + return { 'no', 'yes' } + end + + return info.choices or {} +end + +local function find_commands_with_file_argument() + if #file_commands > 0 then + return file_commands + end + + for _, command in pairs(mp.get_property_native('command-list')) do + if command.args[1] and + (command.args[1].name == 'filename' or command.args[1].name == 'url') then + file_commands[#file_commands + 1] = command.name + end + end + + return file_commands +end + +local function file_list(directory) + if directory == '' then + directory = '.' + end + + local files = utils.readdir(directory, 'files') or {} + + for _, dir in pairs(utils.readdir(directory, 'dirs') or {}) do + files[#files + 1] = dir .. path_separator + end + + return files +end + +-- List of tab-completions: +-- pattern: A Lua pattern used in string:match. It should return the start +-- position of the word to be completed in the first capture (using +-- the empty parenthesis notation "()"). In patterns with 2 +-- captures, the first determines the completions, and the second is +-- the start of the word to be completed. +-- list: A function that returns a list of candidate completion values. +-- append: An extra string to be appended to the end of a successful +-- completion. It is only appended if 'list' contains exactly one +-- match. +function build_completers() + local completers = { + { pattern = '^%s*()[%w_-]*$', list = command_list_and_help, append = ' ' }, + { pattern = '^%s*help%s+()[%w_-]*$', list = command_list }, + { pattern = '^%s*set%s+"?([%w_-]+)"?%s+()%S*$', list = choice_list }, + { pattern = '^%s*set%s+"?([%w_-]+)"?%s+"()%S*$', list = choice_list, append = '"' }, + { pattern = '^%s*cycle[-_]values%s+"?([%w_-]+)"?.-%s+()%S*$', list = choice_list, append = " " }, + { pattern = '^%s*cycle[-_]values%s+"?([%w_-]+)"?.-%s+"()%S*$', list = choice_list, append = '" ' }, + { pattern = '^%s*apply[-_]profile%s+"()%S*$', list = profile_list, append = '"' }, + { pattern = '^%s*apply[-_]profile%s+()%S*$', list = profile_list }, + { pattern = '^%s*change[-_]list%s+()[%w_-]*$', list = list_option_list, append = ' ' }, + { pattern = '^%s*change[-_]list%s+()"[%w_-]*$', list = list_option_list, append = '" ' }, + { pattern = '^%s*change[-_]list%s+"?([%w_-]+)"?%s+()%a*$', list = list_option_verb_list, append = ' ' }, + { pattern = '^%s*change[-_]list%s+"?([%w_-]+)"?%s+"()%a*$', list = list_option_verb_list, append = '" ' }, + { pattern = '^%s*([av]f)%s+()%a*$', list = list_option_verb_list, append = ' ' }, + { pattern = '^%s*([av]f)%s+"()%a*$', list = list_option_verb_list, append = '" ' }, + { pattern = '${=?()[%w_/-]*$', list = property_list, append = '}' }, + } + + for _, command in pairs({'set', 'add', 'cycle', 'cycle[-_]values', 'multiply'}) do + completers[#completers + 1] = { + pattern = '^%s*' .. command .. '%s+()[%w_/-]*$', + list = property_list, + append = ' ', + } + completers[#completers + 1] = { + pattern = '^%s*' .. command .. '%s+"()[%w_/-]*$', + list = property_list, + append = '" ', + } + end + + + for _, command in pairs(find_commands_with_file_argument()) do + completers[#completers + 1] = { + pattern = '^%s*' .. command:gsub('-', '[-_]') .. + '%s+["\']?(.-)()[^' .. path_separator ..']*$', + list = file_list, + -- Unfortunately appending " here would append it everytime a + -- directory is fully completed, even if you intend to browse it + -- afterwards. + } + end + + return completers +end + +-- Use 'list' to find possible tab-completions for 'part.' +-- Returns a list of all potential completions and the longest +-- common prefix of all the matching list items. +function complete_match(part, list) + local completions = {} + local prefix = nil + + for _, candidate in ipairs(list) do + if candidate:sub(1, part:len()) == part then + if prefix and prefix ~= candidate then + local prefix_len = part:len() + while prefix:sub(1, prefix_len + 1) + == candidate:sub(1, prefix_len + 1) do + prefix_len = prefix_len + 1 + end + prefix = candidate:sub(1, prefix_len) + else + prefix = candidate + end + completions[#completions + 1] = candidate + end + end + + return completions, prefix +end + +function common_prefix_length(s1, s2) + local common_count = 0 + for i = 1, #s1 do + if s1:byte(i) ~= s2:byte(i) then + break + end + common_count = common_count + 1 + end + return common_count +end + +function max_overlap_length(s1, s2) + for s1_offset = 0, #s1 - 1 do + local match = true + for i = 1, #s1 - s1_offset do + if s1:byte(s1_offset + i) ~= s2:byte(i) then + match = false + break + end + end + if match then + return #s1 - s1_offset + end + end + return 0 +end + +-- Complete the option or property at the cursor (TAB) +function complete() + local before_cur = line:sub(1, cursor - 1) + local after_cur = line:sub(cursor) + + -- Try the first completer that works + for _, completer in ipairs(build_completers()) do + -- Completer patterns should return the start of the word to be + -- completed as the first capture. + local s, s2 = before_cur:match(completer.pattern) + if not s then + -- Multiple input commands can be separated by semicolons, so all + -- completions that are anchored at the start of the string with + -- '^' can start from a semicolon as well. Replace ^ with ; and try + -- to match again. + s, s2 = before_cur:match(completer.pattern:gsub('^^', ';')) + end + if s then + local hint + if s2 then + hint = s + s = s2 + end + + -- If the completer's pattern found a word, check the completer's + -- list for possible completions + local part = before_cur:sub(s) + local completions, prefix = complete_match(part, completer.list(hint)) + if #completions > 0 then + -- If there was only one full match from the list, add + -- completer.append to the final string. This is normally a + -- space or a quotation mark followed by a space. + local after_cur_index = 1 + if #completions == 1 then + local append = completer.append or '' + prefix = prefix .. append + + -- calculate offset into after_cur + local prefix_len = common_prefix_length(append, after_cur) + local overlap_size = max_overlap_length(append, after_cur) + after_cur_index = math.max(prefix_len, overlap_size) + 1 + else + table.sort(completions) + suggestion_buffer = completions + end + + -- Insert the completion and update + before_cur = before_cur:sub(1, s - 1) .. prefix + cursor = before_cur:len() + 1 + line = before_cur .. after_cur:sub(after_cur_index) + update() + return + end + end + end +end + +-- Move the cursor to the beginning of the line (HOME) +function go_home() + cursor = 1 + update() +end + +-- Move the cursor to the end of the line (END) +function go_end() + cursor = line:len() + 1 + update() +end + +-- Delete from the cursor to the beginning of the word (Ctrl+Backspace) +function del_word() + local before_cur = line:sub(1, cursor - 1) + local after_cur = line:sub(cursor) + + before_cur = before_cur:gsub('[^%s]+%s*$', '', 1) + line = before_cur .. after_cur + cursor = before_cur:len() + 1 + update() +end + +-- Delete from the cursor to the end of the word (Ctrl+Del) +function del_next_word() + if cursor > line:len() then return end + + local before_cur = line:sub(1, cursor - 1) + local after_cur = line:sub(cursor) + + after_cur = after_cur:gsub('^%s*[^%s]+', '', 1) + line = before_cur .. after_cur + update() +end + +-- Delete from the cursor to the end of the line (Ctrl+K) +function del_to_eol() + line = line:sub(1, cursor - 1) + update() +end + +-- Delete from the cursor back to the start of the line (Ctrl+U) +function del_to_start() + line = line:sub(cursor) + cursor = 1 + update() +end + +-- Empty the log buffer of all messages (Ctrl+L) +function clear_log_buffer() + log_buffer = {} + update() +end + +-- Returns a string of UTF-8 text from the clipboard (or the primary selection) +function get_clipboard(clip) + if platform == 'x11' then + local res = utils.subprocess({ + args = { 'xclip', '-selection', clip and 'clipboard' or 'primary', '-out' }, + playback_only = false, + }) + if not res.error then + return res.stdout + end + elseif platform == 'wayland' then + local res = utils.subprocess({ + args = { 'wl-paste', clip and '-n' or '-np' }, + playback_only = false, + }) + if not res.error then + return res.stdout + end + elseif platform == 'windows' then + local res = utils.subprocess({ + args = { 'powershell', '-NoProfile', '-Command', [[& { + Trap { + Write-Error -ErrorRecord $_ + Exit 1 + } + + $clip = "" + if (Get-Command "Get-Clipboard" -errorAction SilentlyContinue) { + $clip = Get-Clipboard -Raw -Format Text -TextFormatType UnicodeText + } else { + Add-Type -AssemblyName PresentationCore + $clip = [Windows.Clipboard]::GetText() + } + + $clip = $clip -Replace "`r","" + $u8clip = [System.Text.Encoding]::UTF8.GetBytes($clip) + [Console]::OpenStandardOutput().Write($u8clip, 0, $u8clip.Length) + }]] }, + playback_only = false, + }) + if not res.error then + return res.stdout + end + elseif platform == 'darwin' then + local res = utils.subprocess({ + args = { 'pbpaste' }, + playback_only = false, + }) + if not res.error then + return res.stdout + end + end + return '' +end + +-- Paste text from the window-system's clipboard. 'clip' determines whether the +-- clipboard or the primary selection buffer is used (on X11 and Wayland only.) +function paste(clip) + local text = get_clipboard(clip) + local before_cur = line:sub(1, cursor - 1) + local after_cur = line:sub(cursor) + line = before_cur .. text .. after_cur + cursor = cursor + text:len() + update() +end + +-- List of input bindings. This is a weird mashup between common GUI text-input +-- bindings and readline bindings. +function get_bindings() + local bindings = { + { 'esc', function() set_active(false) end }, + { 'ctrl+[', function() set_active(false) end }, + { 'enter', handle_enter }, + { 'kp_enter', handle_enter }, + { 'shift+enter', function() handle_char_input('\n') end }, + { 'ctrl+j', handle_enter }, + { 'ctrl+m', handle_enter }, + { 'bs', handle_backspace }, + { 'shift+bs', handle_backspace }, + { 'ctrl+h', handle_backspace }, + { 'del', handle_del }, + { 'shift+del', handle_del }, + { 'ins', handle_ins }, + { 'shift+ins', function() paste(false) end }, + { 'mbtn_mid', function() paste(false) end }, + { 'left', function() prev_char() end }, + { 'ctrl+b', function() prev_char() end }, + { 'right', function() next_char() end }, + { 'ctrl+f', function() next_char() end }, + { 'up', function() move_history(-1) end }, + { 'ctrl+p', function() move_history(-1) end }, + { 'wheel_up', function() move_history(-1) end }, + { 'down', function() move_history(1) end }, + { 'ctrl+n', function() move_history(1) end }, + { 'wheel_down', function() move_history(1) end }, + { 'wheel_left', function() end }, + { 'wheel_right', function() end }, + { 'ctrl+left', prev_word }, + { 'alt+b', prev_word }, + { 'ctrl+right', next_word }, + { 'alt+f', next_word }, + { 'tab', complete }, + { 'ctrl+i', complete }, + { 'ctrl+a', go_home }, + { 'home', go_home }, + { 'ctrl+e', go_end }, + { 'end', go_end }, + { 'pgup', handle_pgup }, + { 'pgdwn', handle_pgdown }, + { 'ctrl+c', clear }, + { 'ctrl+d', maybe_exit }, + { 'ctrl+k', del_to_eol }, + { 'ctrl+l', clear_log_buffer }, + { 'ctrl+u', del_to_start }, + { 'ctrl+v', function() paste(true) end }, + { 'meta+v', function() paste(true) end }, + { 'ctrl+bs', del_word }, + { 'ctrl+w', del_word }, + { 'ctrl+del', del_next_word }, + { 'alt+d', del_next_word }, + { 'kp_dec', function() handle_char_input('.') end }, + } + + for i = 0, 9 do + bindings[#bindings + 1] = + {'kp' .. i, function() handle_char_input('' .. i) end} + end + + return bindings +end + +local function text_input(info) + if info.key_text and (info.event == "press" or info.event == "down" + or info.event == "repeat") + then + handle_char_input(info.key_text) + end +end + +function define_key_bindings() + if #key_bindings > 0 then + return + end + for _, bind in ipairs(get_bindings()) do + -- Generate arbitrary name for removing the bindings later. + local name = "_console_" .. (#key_bindings + 1) + key_bindings[#key_bindings + 1] = name + mp.add_forced_key_binding(bind[1], name, bind[2], {repeatable = true}) + end + mp.add_forced_key_binding("any_unicode", "_console_text", text_input, + {repeatable = true, complex = true}) + key_bindings[#key_bindings + 1] = "_console_text" +end + +function undefine_key_bindings() + for _, name in ipairs(key_bindings) do + mp.remove_key_binding(name) + end + key_bindings = {} +end + +-- Add a global binding for enabling the REPL. While it's enabled, its bindings +-- will take over and it can be closed with ESC. +mp.add_key_binding(nil, 'enable', function() + set_active(true) +end) + +-- Add a script-message to show the REPL and fill it with the provided text +mp.register_script_message('type', function(text, cursor_pos) + show_and_type(text, cursor_pos) +end) + +-- Redraw the REPL when the OSD size changes. This is needed because the +-- PlayRes of the OSD will need to be adjusted. +mp.observe_property('osd-width', 'native', update) +mp.observe_property('osd-height', 'native', update) +mp.observe_property('display-hidpi-scale', 'native', update) + +-- Enable log messages. In silent mode, mpv will queue log messages in a buffer +-- until enable_messages is called again without the silent: prefix. +mp.enable_messages('silent:terminal-default') + +mp.register_event('log-message', function(e) + -- Ignore log messages from the OSD because of paranoia, since writing them + -- to the OSD could generate more messages in an infinite loop. + if e.prefix:sub(1, 3) == 'osd' then return end + + -- Ignore messages output by this script. + if e.prefix == mp.get_script_name() then return end + + -- Ignore buffer overflow warning messages. Overflowed log messages would + -- have been offscreen anyway. + if e.prefix == 'overflow' then return end + + -- Filter out trace-level log messages, even if the terminal-default log + -- level includes them. These aren't too useful for an on-screen display + -- without scrollback and they include messages that are generated from the + -- OSD display itself. + if e.level == 'trace' then return end + + -- Use color for debug/v/warn/error/fatal messages. + local style = '' + if e.level == 'debug' then + style = styles.debug + elseif e.level == 'v' then + style = styles.verbose + elseif e.level == 'warn' then + style = styles.warn + elseif e.level == 'error' then + style = styles.error + elseif e.level == 'fatal' then + style = styles.fatal + end + + log_add(style, '[' .. e.prefix .. '] ' .. e.text) +end) + +collectgarbage() diff --git a/player/lua/defaults.lua b/player/lua/defaults.lua new file mode 100644 index 0000000..233d1d6 --- /dev/null +++ b/player/lua/defaults.lua @@ -0,0 +1,836 @@ +-- Compatibility shim for lua 5.2/5.3 +unpack = unpack or table.unpack + +-- these are used internally by lua.c +mp.UNKNOWN_TYPE.info = "this value is inserted if the C type is not supported" +mp.UNKNOWN_TYPE.type = "UNKNOWN_TYPE" + +mp.ARRAY.info = "native array" +mp.ARRAY.type = "ARRAY" + +mp.MAP.info = "native map" +mp.MAP.type = "MAP" + +function mp.get_script_name() + return mp.script_name +end + +function mp.get_opt(key, def) + local opts = mp.get_property_native("options/script-opts") + local val = opts[key] + if val == nil then + val = def + end + return val +end + +function mp.input_define_section(section, contents, flags) + if flags == nil or flags == "" then + flags = "default" + end + mp.commandv("define-section", section, contents, flags) +end + +function mp.input_enable_section(section, flags) + if flags == nil then + flags = "" + end + mp.commandv("enable-section", section, flags) +end + +function mp.input_disable_section(section) + mp.commandv("disable-section", section) +end + +function mp.get_mouse_pos() + local m = mp.get_property_native("mouse-pos") + return m.x, m.y +end + +-- For dispatching script-binding. This is sent as: +-- script-message-to $script_name $binding_name $keystate +-- The array is indexed by $binding_name, and has functions like this as value: +-- fn($binding_name, $keystate) +local dispatch_key_bindings = {} + +local message_id = 0 +local function reserve_binding() + message_id = message_id + 1 + return "__keybinding" .. tostring(message_id) +end + +local function dispatch_key_binding(name, state, key_name, key_text) + local fn = dispatch_key_bindings[name] + if fn then + fn(name, state, key_name, key_text) + end +end + +-- "Old", deprecated API + +-- each script has its own section, so that they don't conflict +local default_section = "input_dispatch_" .. mp.script_name + +-- Set the list of key bindings. These will override the user's bindings, so +-- you should use this sparingly. +-- A call to this function will remove all bindings previously set with this +-- function. For example, set_key_bindings({}) would remove all script defined +-- key bindings. +-- Note: the bindings are not active by default. Use enable_key_bindings(). +-- +-- list is an array of key bindings, where each entry is an array as follow: +-- {key, callback_press, callback_down, callback_up} +-- key is the key string as used in input.conf, like "ctrl+a" +-- +-- callback can be a string too, in which case the following will be added like +-- an input.conf line: key .. " " .. callback +-- (And callback_down is ignored.) +function mp.set_key_bindings(list, section, flags) + local cfg = "" + for i = 1, #list do + local entry = list[i] + local key = entry[1] + local cb = entry[2] + local cb_down = entry[3] + local cb_up = entry[4] + if type(cb) ~= "string" then + local mangle = reserve_binding() + dispatch_key_bindings[mangle] = function(name, state) + local event = state:sub(1, 1) + local is_mouse = state:sub(2, 2) == "m" + local def = (is_mouse and "u") or "d" + if event == "r" then + return + end + if event == "p" and cb then + cb() + elseif event == "d" and cb_down then + cb_down() + elseif event == "u" and cb_up then + cb_up() + elseif event == def and cb then + cb() + end + end + cfg = cfg .. key .. " script-binding " .. + mp.script_name .. "/" .. mangle .. "\n" + else + cfg = cfg .. key .. " " .. cb .. "\n" + end + end + mp.input_define_section(section or default_section, cfg, flags) +end + +function mp.enable_key_bindings(section, flags) + mp.input_enable_section(section or default_section, flags) +end + +function mp.disable_key_bindings(section) + mp.input_disable_section(section or default_section) +end + +function mp.set_mouse_area(x0, y0, x1, y1, section) + mp.input_set_section_mouse_area(section or default_section, x0, y0, x1, y1) +end + +-- "Newer" and more convenient API + +local key_bindings = {} +local key_binding_counter = 0 +local key_bindings_dirty = false + +function mp.flush_keybindings() + if not key_bindings_dirty then + return + end + key_bindings_dirty = false + + for i = 1, 2 do + local section, flags + local def = i == 1 + if def then + section = "input_" .. mp.script_name + flags = "default" + else + section = "input_forced_" .. mp.script_name + flags = "force" + end + local bindings = {} + for k, v in pairs(key_bindings) do + if v.bind and v.forced ~= def then + bindings[#bindings + 1] = v + end + end + table.sort(bindings, function(a, b) + return a.priority < b.priority + end) + local cfg = "" + for _, v in ipairs(bindings) do + cfg = cfg .. v.bind .. "\n" + end + mp.input_define_section(section, cfg, flags) + -- TODO: remove the section if the script is stopped + mp.input_enable_section(section, "allow-hide-cursor+allow-vo-dragging") + end +end + +local function add_binding(attrs, key, name, fn, rp) + if type(name) ~= "string" and name ~= nil then + rp = fn + fn = name + name = nil + end + rp = rp or "" + if name == nil then + name = reserve_binding() + end + local repeatable = rp == "repeatable" or rp["repeatable"] + if rp["forced"] then + attrs.forced = true + end + local key_cb, msg_cb + if not fn then + fn = function() end + end + if rp["complex"] then + local key_states = { + ["u"] = "up", + ["d"] = "down", + ["r"] = "repeat", + ["p"] = "press", + } + key_cb = function(name, state, key_name, key_text) + if key_text == "" then + key_text = nil + end + fn({ + event = key_states[state:sub(1, 1)] or "unknown", + is_mouse = state:sub(2, 2) == "m", + key_name = key_name, + key_text = key_text, + }) + end + msg_cb = function() + fn({event = "press", is_mouse = false}) + end + else + key_cb = function(name, state) + -- Emulate the same semantics as input.c uses for most bindings: + -- For keyboard, "down" runs the command, "up" does nothing; + -- for mouse, "down" does nothing, "up" runs the command. + -- Also, key repeat triggers the binding again. + local event = state:sub(1, 1) + local is_mouse = state:sub(2, 2) == "m" + if event == "r" and not repeatable then + return + end + if is_mouse and (event == "u" or event == "p") then + fn() + elseif not is_mouse and (event == "d" or event == "r" or event == "p") then + fn() + end + end + msg_cb = fn + end + if key and #key > 0 then + attrs.bind = key .. " script-binding " .. mp.script_name .. "/" .. name + end + attrs.name = name + -- new bindings override old ones (but do not overwrite them) + key_binding_counter = key_binding_counter + 1 + attrs.priority = key_binding_counter + key_bindings[name] = attrs + key_bindings_dirty = true + dispatch_key_bindings[name] = key_cb + mp.register_script_message(name, msg_cb) +end + +function mp.add_key_binding(...) + add_binding({forced=false}, ...) +end + +function mp.add_forced_key_binding(...) + add_binding({forced=true}, ...) +end + +function mp.remove_key_binding(name) + key_bindings[name] = nil + dispatch_key_bindings[name] = nil + key_bindings_dirty = true + mp.unregister_script_message(name) +end + +local timers = {} + +local timer_mt = {} +timer_mt.__index = timer_mt + +function mp.add_timeout(seconds, cb, disabled) + local t = mp.add_periodic_timer(seconds, cb, disabled) + t.oneshot = true + return t +end + +function mp.add_periodic_timer(seconds, cb, disabled) + local t = { + timeout = seconds, + cb = cb, + oneshot = false, + } + setmetatable(t, timer_mt) + if not disabled then + t:resume() + end + return t +end + +function timer_mt.stop(t) + if timers[t] then + timers[t] = nil + t.next_deadline = t.next_deadline - mp.get_time() + end +end + +function timer_mt.kill(t) + timers[t] = nil + t.next_deadline = nil +end +mp.cancel_timer = timer_mt.kill + +function timer_mt.resume(t) + if not timers[t] then + local timeout = t.next_deadline + if timeout == nil then + timeout = t.timeout + end + t.next_deadline = mp.get_time() + timeout + timers[t] = t + end +end + +function timer_mt.is_enabled(t) + return timers[t] ~= nil +end + +-- Return the timer that expires next. +local function get_next_timer() + local best = nil + for t, _ in pairs(timers) do + if best == nil or t.next_deadline < best.next_deadline then + best = t + end + end + return best +end + +function mp.get_next_timeout() + local timer = get_next_timer() + if not timer then + return + end + local now = mp.get_time() + return timer.next_deadline - now +end + +-- Run timers that have met their deadline at the time of invocation. +-- Return: time>0 in seconds till the next due timer, 0 if there are due timers +-- (aborted to avoid infinite loop), or nil if no timers +local function process_timers() + local t0 = nil + while true do + local timer = get_next_timer() + if not timer then + return + end + local now = mp.get_time() + local wait = timer.next_deadline - now + if wait > 0 then + return wait + else + if not t0 then + t0 = now -- first due callback: always executes, remember t0 + elseif timer.next_deadline > t0 then + -- don't block forever with slow callbacks and endless timers. + -- we'll continue right after checking mpv events. + return 0 + end + + if timer.oneshot then + timer:kill() + else + timer.next_deadline = now + timer.timeout + end + timer.cb() + end + end +end + +local messages = {} + +function mp.register_script_message(name, fn) + messages[name] = fn +end + +function mp.unregister_script_message(name) + messages[name] = nil +end + +local function message_dispatch(ev) + if #ev.args > 0 then + local handler = messages[ev.args[1]] + if handler then + handler(unpack(ev.args, 2)) + end + end +end + +local property_id = 0 +local properties = {} + +function mp.observe_property(name, t, cb) + local id = property_id + 1 + property_id = id + properties[id] = cb + mp.raw_observe_property(id, name, t) +end + +function mp.unobserve_property(cb) + for prop_id, prop_cb in pairs(properties) do + if cb == prop_cb then + properties[prop_id] = nil + mp.raw_unobserve_property(prop_id) + end + end +end + +local function property_change(ev) + local prop = properties[ev.id] + if prop then + prop(ev.name, ev.data) + end +end + +-- used by default event loop (mp_event_loop()) to decide when to quit +mp.keep_running = true + +local event_handlers = {} + +function mp.register_event(name, cb) + local list = event_handlers[name] + if not list then + list = {} + event_handlers[name] = list + end + list[#list + 1] = cb + return mp.request_event(name, true) +end + +function mp.unregister_event(cb) + for name, sub in pairs(event_handlers) do + local found = false + for i, e in ipairs(sub) do + if e == cb then + found = true + break + end + end + if found then + -- create a new array, just in case this function was called + -- from an event handler + local new = {} + for i = 1, #sub do + if sub[i] ~= cb then + new[#new + 1] = sub[i] + end + end + event_handlers[name] = new + if #new == 0 then + mp.request_event(name, false) + end + end + end +end + +-- default handlers +mp.register_event("shutdown", function() mp.keep_running = false end) +mp.register_event("client-message", message_dispatch) +mp.register_event("property-change", property_change) + +-- called before the event loop goes back to sleep +local idle_handlers = {} + +function mp.register_idle(cb) + idle_handlers[#idle_handlers + 1] = cb +end + +function mp.unregister_idle(cb) + local new = {} + for _, handler in ipairs(idle_handlers) do + if handler ~= cb then + new[#new + 1] = handler + end + end + idle_handlers = new +end + +-- sent by "script-binding" +mp.register_script_message("key-binding", dispatch_key_binding) + +mp.msg = { + log = mp.log, + fatal = function(...) return mp.log("fatal", ...) end, + error = function(...) return mp.log("error", ...) end, + warn = function(...) return mp.log("warn", ...) end, + info = function(...) return mp.log("info", ...) end, + verbose = function(...) return mp.log("v", ...) end, + debug = function(...) return mp.log("debug", ...) end, + trace = function(...) return mp.log("trace", ...) end, +} + +_G.print = mp.msg.info + +package.loaded["mp"] = mp +package.loaded["mp.msg"] = mp.msg + +function mp.wait_event(t) + local r = mp.raw_wait_event(t) + if r and r.file_error and not r.error then + -- compat; deprecated + r.error = r.file_error + end + return r +end + +_G.mp_event_loop = function() + mp.dispatch_events(true) +end + +local function call_event_handlers(e) + local handlers = event_handlers[e.event] + if handlers then + for _, handler in ipairs(handlers) do + handler(e) + end + end +end + +mp.use_suspend = false + +local suspend_warned = false + +function mp.dispatch_events(allow_wait) + local more_events = true + if mp.use_suspend then + if not suspend_warned then + mp.msg.error("mp.use_suspend is now ignored.") + suspend_warned = true + end + end + while mp.keep_running do + local wait = 0 + if not more_events then + wait = process_timers() or 1e20 -- infinity for all practical purposes + if wait ~= 0 then + local idle_called = nil + for _, handler in ipairs(idle_handlers) do + idle_called = true + handler() + end + if idle_called then + -- handlers don't complete in 0 time, and may modify timers + wait = mp.get_next_timeout() or 1e20 + if wait < 0 then + wait = 0 + end + end + end + if allow_wait ~= true then + return + end + end + local e = mp.wait_event(wait) + more_events = false + if e.event ~= "none" then + call_event_handlers(e) + more_events = true + end + end +end + +mp.register_idle(mp.flush_keybindings) + +-- additional helpers + +function mp.osd_message(text, duration) + if not duration then + duration = "-1" + else + duration = tostring(math.floor(duration * 1000)) + end + mp.commandv("show-text", text, duration) +end + +local hook_table = {} + +local hook_mt = {} +hook_mt.__index = hook_mt + +function hook_mt.cont(t) + if t._id == nil then + mp.msg.error("hook already continued") + else + mp.raw_hook_continue(t._id) + t._id = nil + end +end + +function hook_mt.defer(t) + t._defer = true +end + +mp.register_event("hook", function(ev) + local fn = hook_table[tonumber(ev.id)] + local hookobj = { + _id = ev.hook_id, + _defer = false, + } + setmetatable(hookobj, hook_mt) + if fn then + fn(hookobj) + end + if not hookobj._defer and hookobj._id ~= nil then + hookobj:cont() + end +end) + +function mp.add_hook(name, pri, cb) + local id = #hook_table + 1 + hook_table[id] = cb + -- The C API suggests using 0 for a neutral priority, but lua.rst suggests + -- 50 (?), so whatever. + mp.raw_hook_add(id, name, pri - 50) +end + +local async_call_table = {} +local async_next_id = 1 + +function mp.command_native_async(node, cb) + local id = async_next_id + async_next_id = async_next_id + 1 + cb = cb or function() end + local res, err = mp.raw_command_native_async(id, node) + if not res then + mp.add_timeout(0, function() cb(false, nil, err) end) + return res, err + end + local t = {cb = cb, id = id} + async_call_table[id] = t + return t +end + +mp.register_event("command-reply", function(ev) + local id = tonumber(ev.id) + local t = async_call_table[id] + local cb = t.cb + t.id = nil + async_call_table[id] = nil + if ev.error then + cb(false, nil, ev.error) + else + cb(true, ev.result, nil) + end +end) + +function mp.abort_async_command(t) + if t.id ~= nil then + mp.raw_abort_async_command(t.id) + end +end + +local overlay_mt = {} +overlay_mt.__index = overlay_mt +local overlay_new_id = 0 + +function mp.create_osd_overlay(format) + overlay_new_id = overlay_new_id + 1 + local overlay = { + format = format, + id = overlay_new_id, + data = "", + res_x = 0, + res_y = 720, + } + setmetatable(overlay, overlay_mt) + return overlay +end + +function overlay_mt.update(ov) + local cmd = {} + for k, v in pairs(ov) do + cmd[k] = v + end + cmd.name = "osd-overlay" + cmd.res_x = math.floor(cmd.res_x) + cmd.res_y = math.floor(cmd.res_y) + return mp.command_native(cmd) +end + +function overlay_mt.remove(ov) + mp.command_native { + name = "osd-overlay", + id = ov.id, + format = "none", + data = "", + } +end + +-- legacy API +function mp.set_osd_ass(res_x, res_y, data) + if not mp._legacy_overlay then + mp._legacy_overlay = mp.create_osd_overlay("ass-events") + end + if mp._legacy_overlay.res_x ~= res_x or + mp._legacy_overlay.res_y ~= res_y or + mp._legacy_overlay.data ~= data + then + mp._legacy_overlay.res_x = res_x + mp._legacy_overlay.res_y = res_y + mp._legacy_overlay.data = data + mp._legacy_overlay:update() + end +end + +function mp.get_osd_size() + local prop = mp.get_property_native("osd-dimensions") + return prop.w, prop.h, prop.aspect +end + +function mp.get_osd_margins() + local prop = mp.get_property_native("osd-dimensions") + return prop.ml, prop.mt, prop.mr, prop.mb +end + +local mp_utils = package.loaded["mp.utils"] + +function mp_utils.format_table(t, set) + if not set then + set = { [t] = true } + end + local res = "{" + -- pretty expensive but simple way to distinguish array and map parts of t + local keys = {} + local vals = {} + local arr = 0 + for i = 1, #t do + if t[i] == nil then + break + end + keys[i] = i + vals[i] = t[i] + arr = i + end + for k, v in pairs(t) do + if not (type(k) == "number" and k >= 1 and k <= arr and keys[k]) then + keys[#keys + 1] = k + vals[#keys] = v + end + end + for i = 1, #keys do + if #res > 1 then + res = res .. ", " + end + if i > arr then + res = res .. mp_utils.to_string(keys[i], set) .. " = " + end + res = res .. mp_utils.to_string(vals[i], set) + end + res = res .. "}" + return res +end + +function mp_utils.to_string(v, set) + if type(v) == "string" then + return "\"" .. v .. "\"" + elseif type(v) == "table" then + if set then + if set[v] then + return "[cycle]" + end + set[v] = true + end + return mp_utils.format_table(v, set) + else + return tostring(v) + end +end + +function mp_utils.getcwd() + return mp.get_property("working-directory") +end + +function mp_utils.getpid() + return mp.get_property_number("pid") +end + +function mp_utils.format_bytes_humanized(b) + local d = {"Bytes", "KiB", "MiB", "GiB", "TiB", "PiB"} + local i = 1 + while b >= 1024 do + b = b / 1024 + i = i + 1 + end + return string.format("%0.2f %s", b, d[i] and d[i] or "*1024^" .. (i-1)) +end + +function mp_utils.subprocess(t) + local cmd = {} + cmd.name = "subprocess" + cmd.capture_stdout = true + for k, v in pairs(t) do + if k == "cancellable" then + k = "playback_only" + elseif k == "max_size" then + k = "capture_size" + end + cmd[k] = v + end + local res, err = mp.command_native(cmd) + if res == nil then + -- an error usually happens only if parsing failed (or no args passed) + res = {error_string = err, status = -1} + end + if res.error_string ~= "" then + res.error = res.error_string + end + return res +end + +function mp_utils.subprocess_detached(t) + mp.commandv("run", unpack(t.args)) +end + +function mp_utils.shared_script_property_set(name, value) + if value ~= nil then + -- no such thing as change-list with mpv_node, so build a string value + mp.commandv("change-list", "shared-script-properties", "append", + name .. "=" .. value) + else + mp.commandv("change-list", "shared-script-properties", "remove", name) + end +end + +function mp_utils.shared_script_property_get(name) + local map = mp.get_property_native("shared-script-properties") + return map and map[name] +end + +-- cb(name, value) on change and on init +function mp_utils.shared_script_property_observe(name, cb) + -- it's _very_ wasteful to observe the mpv core "super" property for every + -- shared sub-property, but then again you shouldn't use this + mp.observe_property("shared-script-properties", "native", function(_, val) + cb(name, val and val[name]) + end) +end + +return {} diff --git a/player/lua/meson.build b/player/lua/meson.build new file mode 100644 index 0000000..362c87c --- /dev/null +++ b/player/lua/meson.build @@ -0,0 +1,10 @@ +lua_files = ['defaults.lua', 'assdraw.lua', 'options.lua', 'osc.lua', + 'ytdl_hook.lua', 'stats.lua', 'console.lua', 'auto_profiles.lua'] +foreach file: lua_files + lua_file = custom_target(file, + input: join_paths(source_root, 'player', 'lua', file), + output: file + '.inc', + command: [file2string, '@INPUT@', '@OUTPUT@'], + ) + sources += lua_file +endforeach diff --git a/player/lua/options.lua b/player/lua/options.lua new file mode 100644 index 0000000..b05b734 --- /dev/null +++ b/player/lua/options.lua @@ -0,0 +1,164 @@ +local msg = require 'mp.msg' + +-- converts val to type of desttypeval +local function typeconv(desttypeval, val) + if type(desttypeval) == "boolean" then + if val == "yes" then + val = true + elseif val == "no" then + val = false + else + msg.error("Error: Can't convert '" .. val .. "' to boolean!") + val = nil + end + elseif type(desttypeval) == "number" then + if tonumber(val) ~= nil then + val = tonumber(val) + else + msg.error("Error: Can't convert '" .. val .. "' to number!") + val = nil + end + end + return val +end + +-- performs a deep-copy of the given option value +local function opt_copy(val) + return val -- no tables currently +end + +-- compares the given option values for equality +local function opt_equal(val1, val2) + return val1 == val2 +end + +-- performs a deep-copy of an entire option table +local function opt_table_copy(opts) + local copy = {} + for key, value in pairs(opts) do + copy[key] = opt_copy(value) + end + return copy +end + + +local function read_options(options, identifier, on_update) + local option_types = opt_table_copy(options) + if identifier == nil then + identifier = mp.get_script_name() + end + msg.debug("reading options for " .. identifier) + + -- read config file + local conffilename = "script-opts/" .. identifier .. ".conf" + local conffile = mp.find_config_file(conffilename) + if conffile == nil then + msg.debug(conffilename .. " not found.") + conffilename = "lua-settings/" .. identifier .. ".conf" + conffile = mp.find_config_file(conffilename) + if conffile then + msg.warn("lua-settings/ is deprecated, use directory script-opts/") + end + end + local f = conffile and io.open(conffile,"r") + if f == nil then + -- config not found + msg.debug(conffilename .. " not found.") + else + -- config exists, read values + msg.verbose("Opened config file " .. conffilename .. ".") + local linecounter = 1 + for line in f:lines() do + if line:sub(#line) == "\r" then + line = line:sub(1, #line - 1) + end + if string.find(line, "#") == 1 then + + else + local eqpos = string.find(line, "=") + if eqpos == nil then + + else + local key = string.sub(line, 1, eqpos-1) + local val = string.sub(line, eqpos+1) + + -- match found values with defaults + if option_types[key] == nil then + msg.warn(conffilename..":"..linecounter.. + " unknown key '" .. key .. "', ignoring") + else + local convval = typeconv(option_types[key], val) + if convval == nil then + msg.error(conffilename..":"..linecounter.. + " error converting value '" .. val .. + "' for key '" .. key .. "'") + else + options[key] = convval + end + end + end + end + linecounter = linecounter + 1 + end + io.close(f) + end + + --parse command-line options + local prefix = identifier.."-" + -- command line options are always applied on top of these + local conf_and_default_opts = opt_table_copy(options) + + local function parse_opts(full, options) + for key, val in pairs(full) do + if string.find(key, prefix, 1, true) == 1 then + key = string.sub(key, string.len(prefix)+1) + + -- match found values with defaults + if option_types[key] == nil then + msg.warn("script-opts: unknown key " .. key .. ", ignoring") + else + local convval = typeconv(option_types[key], val) + if convval == nil then + msg.error("script-opts: error converting value '" .. val .. + "' for key '" .. key .. "'") + else + options[key] = convval + end + end + end + end + end + + --initial + parse_opts(mp.get_property_native("options/script-opts"), options) + + --runtime updates + if on_update then + local last_opts = opt_table_copy(options) + + mp.observe_property("options/script-opts", "native", function(name, val) + local new_opts = opt_table_copy(conf_and_default_opts) + parse_opts(val, new_opts) + local changelist = {} + for key, val in pairs(new_opts) do + if not opt_equal(last_opts[key], val) then + -- copy to user + options[key] = opt_copy(val) + changelist[key] = true + end + end + last_opts = new_opts + if next(changelist) ~= nil then + on_update(changelist) + end + end) + end + +end + +-- backwards compatibility with broken read_options export +_G.read_options = read_options + +return { + read_options = read_options, +} diff --git a/player/lua/osc.lua b/player/lua/osc.lua new file mode 100644 index 0000000..45a5d90 --- /dev/null +++ b/player/lua/osc.lua @@ -0,0 +1,2917 @@ +local assdraw = require 'mp.assdraw' +local msg = require 'mp.msg' +local opt = require 'mp.options' +local utils = require 'mp.utils' + +-- +-- Parameters +-- +-- default user option values +-- do not touch, change them in osc.conf +local user_opts = { + showwindowed = true, -- show OSC when windowed? + showfullscreen = true, -- show OSC when fullscreen? + idlescreen = true, -- show mpv logo on idle + scalewindowed = 1, -- scaling of the controller when windowed + scalefullscreen = 1, -- scaling of the controller when fullscreen + scaleforcedwindow = 2, -- scaling when rendered on a forced window + vidscale = true, -- scale the controller with the video? + valign = 0.8, -- vertical alignment, -1 (top) to 1 (bottom) + halign = 0, -- horizontal alignment, -1 (left) to 1 (right) + barmargin = 0, -- vertical margin of top/bottombar + boxalpha = 80, -- alpha of the background box, + -- 0 (opaque) to 255 (fully transparent) + hidetimeout = 500, -- duration in ms until the OSC hides if no + -- mouse movement. enforced non-negative for the + -- user, but internally negative is "always-on". + fadeduration = 200, -- duration of fade out in ms, 0 = no fade + deadzonesize = 0.5, -- size of deadzone + minmousemove = 0, -- minimum amount of pixels the mouse has to + -- move between ticks to make the OSC show up + iamaprogrammer = false, -- use native mpv values and disable OSC + -- internal track list management (and some + -- functions that depend on it) + layout = "bottombar", + seekbarstyle = "bar", -- bar, diamond or knob + seekbarhandlesize = 0.6, -- size ratio of the diamond and knob handle + seekrangestyle = "inverted",-- bar, line, slider, inverted or none + seekrangeseparate = true, -- whether the seekranges overlay on the bar-style seekbar + seekrangealpha = 200, -- transparency of seekranges + seekbarkeyframes = true, -- use keyframes when dragging the seekbar + title = "${media-title}", -- string compatible with property-expansion + -- to be shown as OSC title + tooltipborder = 1, -- border of tooltip in bottom/topbar + timetotal = false, -- display total time instead of remaining time? + remaining_playtime = true, -- display the remaining time in playtime or video-time mode + -- playtime takes speed into account, whereas video-time doesn't + timems = false, -- display timecodes with milliseconds? + tcspace = 100, -- timecode spacing (compensate font size estimation) + visibility = "auto", -- only used at init to set visibility_mode(...) + boxmaxchars = 80, -- title crop threshold for box layout + boxvideo = false, -- apply osc_param.video_margins to video + windowcontrols = "auto", -- whether to show window controls + windowcontrols_alignment = "right", -- which side to show window controls on + greenandgrumpy = false, -- disable santa hat + livemarkers = true, -- update seekbar chapter markers on duration change + chapters_osd = true, -- whether to show chapters OSD on next/prev + playlist_osd = true, -- whether to show playlist OSD on next/prev + chapter_fmt = "Chapter: %s", -- chapter print format for seekbar-hover. "no" to disable + unicodeminus = false, -- whether to use the Unicode minus sign character +} + +-- read options from config and command-line +opt.read_options(user_opts, "osc", function(list) update_options(list) end) + +local osc_param = { -- calculated by osc_init() + playresy = 0, -- canvas size Y + playresx = 0, -- canvas size X + display_aspect = 1, + unscaled_y = 0, + areas = {}, + video_margins = { + l = 0, r = 0, t = 0, b = 0, -- left/right/top/bottom + }, +} + +local osc_styles = { + bigButtons = "{\\blur0\\bord0\\1c&HFFFFFF\\3c&HFFFFFF\\fs50\\fnmpv-osd-symbols}", + smallButtonsL = "{\\blur0\\bord0\\1c&HFFFFFF\\3c&HFFFFFF\\fs19\\fnmpv-osd-symbols}", + smallButtonsLlabel = "{\\fscx105\\fscy105\\fn" .. mp.get_property("options/osd-font") .. "}", + smallButtonsR = "{\\blur0\\bord0\\1c&HFFFFFF\\3c&HFFFFFF\\fs30\\fnmpv-osd-symbols}", + topButtons = "{\\blur0\\bord0\\1c&HFFFFFF\\3c&HFFFFFF\\fs12\\fnmpv-osd-symbols}", + + elementDown = "{\\1c&H999999}", + timecodes = "{\\blur0\\bord0\\1c&HFFFFFF\\3c&HFFFFFF\\fs20}", + vidtitle = "{\\blur0\\bord0\\1c&HFFFFFF\\3c&HFFFFFF\\fs12\\q2}", + box = "{\\rDefault\\blur0\\bord1\\1c&H000000\\3c&HFFFFFF}", + + topButtonsBar = "{\\blur0\\bord0\\1c&HFFFFFF\\3c&HFFFFFF\\fs18\\fnmpv-osd-symbols}", + smallButtonsBar = "{\\blur0\\bord0\\1c&HFFFFFF\\3c&HFFFFFF\\fs28\\fnmpv-osd-symbols}", + timecodesBar = "{\\blur0\\bord0\\1c&HFFFFFF\\3c&HFFFFFF\\fs27}", + timePosBar = "{\\blur0\\bord".. user_opts.tooltipborder .."\\1c&HFFFFFF\\3c&H000000\\fs30}", + vidtitleBar = "{\\blur0\\bord0\\1c&HFFFFFF\\3c&HFFFFFF\\fs18\\q2}", + + wcButtons = "{\\1c&HFFFFFF\\fs24\\fnmpv-osd-symbols}", + wcTitle = "{\\1c&HFFFFFF\\fs24\\q2}", + wcBar = "{\\1c&H000000}", +} + +-- internal states, do not touch +local state = { + showtime, -- time of last invocation (last mouse move) + osc_visible = false, + anistart, -- time when the animation started + anitype, -- current type of animation + animation, -- current animation alpha + mouse_down_counter = 0, -- used for softrepeat + active_element = nil, -- nil = none, 0 = background, 1+ = see elements[] + active_event_source = nil, -- the "button" that issued the current event + rightTC_trem = not user_opts.timetotal, -- if the right timecode should display total or remaining time + tc_ms = user_opts.timems, -- Should the timecodes display their time with milliseconds + mp_screen_sizeX, mp_screen_sizeY, -- last screen-resolution, to detect resolution changes to issue reINITs + initREQ = false, -- is a re-init request pending? + marginsREQ = false, -- is a margins update pending? + last_mouseX, last_mouseY, -- last mouse position, to detect significant mouse movement + mouse_in_window = false, + message_text, + message_hide_timer, + fullscreen = false, + tick_timer = nil, + tick_last_time = 0, -- when the last tick() was run + hide_timer = nil, + cache_state = nil, + idle = false, + enabled = true, + input_enabled = true, + showhide_enabled = false, + windowcontrols_buttons = false, + dmx_cache = 0, + using_video_margins = false, + border = true, + maximized = false, + osd = mp.create_osd_overlay("ass-events"), + chapter_list = {}, -- sorted by time +} + +local window_control_box_width = 80 +local tick_delay = 0.03 + +local is_december = os.date("*t").month == 12 + +-- +-- Helperfunctions +-- + +function kill_animation() + state.anistart = nil + state.animation = nil + state.anitype = nil +end + +function set_osd(res_x, res_y, text, z) + if state.osd.res_x == res_x and + state.osd.res_y == res_y and + state.osd.data == text then + return + end + state.osd.res_x = res_x + state.osd.res_y = res_y + state.osd.data = text + state.osd.z = z + state.osd:update() +end + +local margins_opts = { + {"l", "video-margin-ratio-left"}, + {"r", "video-margin-ratio-right"}, + {"t", "video-margin-ratio-top"}, + {"b", "video-margin-ratio-bottom"}, +} + +-- scale factor for translating between real and virtual ASS coordinates +function get_virt_scale_factor() + local w, h = mp.get_osd_size() + if w <= 0 or h <= 0 then + return 0, 0 + end + return osc_param.playresx / w, osc_param.playresy / h +end + +-- return mouse position in virtual ASS coordinates (playresx/y) +function get_virt_mouse_pos() + if state.mouse_in_window then + local sx, sy = get_virt_scale_factor() + local x, y = mp.get_mouse_pos() + return x * sx, y * sy + else + return -1, -1 + end +end + +function set_virt_mouse_area(x0, y0, x1, y1, name) + local sx, sy = get_virt_scale_factor() + mp.set_mouse_area(x0 / sx, y0 / sy, x1 / sx, y1 / sy, name) +end + +function scale_value(x0, x1, y0, y1, val) + local m = (y1 - y0) / (x1 - x0) + local b = y0 - (m * x0) + return (m * val) + b +end + +-- returns hitbox spanning coordinates (top left, bottom right corner) +-- according to alignment +function get_hitbox_coords(x, y, an, w, h) + + local alignments = { + [1] = function () return x, y-h, x+w, y end, + [2] = function () return x-(w/2), y-h, x+(w/2), y end, + [3] = function () return x-w, y-h, x, y end, + + [4] = function () return x, y-(h/2), x+w, y+(h/2) end, + [5] = function () return x-(w/2), y-(h/2), x+(w/2), y+(h/2) end, + [6] = function () return x-w, y-(h/2), x, y+(h/2) end, + + [7] = function () return x, y, x+w, y+h end, + [8] = function () return x-(w/2), y, x+(w/2), y+h end, + [9] = function () return x-w, y, x, y+h end, + } + + return alignments[an]() +end + +function get_hitbox_coords_geo(geometry) + return get_hitbox_coords(geometry.x, geometry.y, geometry.an, + geometry.w, geometry.h) +end + +function get_element_hitbox(element) + return element.hitbox.x1, element.hitbox.y1, + element.hitbox.x2, element.hitbox.y2 +end + +function mouse_hit(element) + return mouse_hit_coords(get_element_hitbox(element)) +end + +function mouse_hit_coords(bX1, bY1, bX2, bY2) + local mX, mY = get_virt_mouse_pos() + return (mX >= bX1 and mX <= bX2 and mY >= bY1 and mY <= bY2) +end + +function limit_range(min, max, val) + if val > max then + val = max + elseif val < min then + val = min + end + return val +end + +-- translate value into element coordinates +function get_slider_ele_pos_for(element, val) + + local ele_pos = scale_value( + element.slider.min.value, element.slider.max.value, + element.slider.min.ele_pos, element.slider.max.ele_pos, + val) + + return limit_range( + element.slider.min.ele_pos, element.slider.max.ele_pos, + ele_pos) +end + +-- translates global (mouse) coordinates to value +function get_slider_value_at(element, glob_pos) + + local val = scale_value( + element.slider.min.glob_pos, element.slider.max.glob_pos, + element.slider.min.value, element.slider.max.value, + glob_pos) + + return limit_range( + element.slider.min.value, element.slider.max.value, + val) +end + +-- get value at current mouse position +function get_slider_value(element) + return get_slider_value_at(element, get_virt_mouse_pos()) +end + +function countone(val) + if not user_opts.iamaprogrammer then + val = val + 1 + end + return val +end + +-- align: -1 .. +1 +-- frame: size of the containing area +-- obj: size of the object that should be positioned inside the area +-- margin: min. distance from object to frame (as long as -1 <= align <= +1) +function get_align(align, frame, obj, margin) + return (frame / 2) + (((frame / 2) - margin - (obj / 2)) * align) +end + +-- multiplies two alpha values, formular can probably be improved +function mult_alpha(alphaA, alphaB) + return 255 - (((1-(alphaA/255)) * (1-(alphaB/255))) * 255) +end + +function add_area(name, x1, y1, x2, y2) + -- create area if needed + if osc_param.areas[name] == nil then + osc_param.areas[name] = {} + end + table.insert(osc_param.areas[name], {x1=x1, y1=y1, x2=x2, y2=y2}) +end + +function ass_append_alpha(ass, alpha, modifier) + local ar = {} + + for ai, av in pairs(alpha) do + av = mult_alpha(av, modifier) + if state.animation then + av = mult_alpha(av, state.animation) + end + ar[ai] = av + end + + ass:append(string.format("{\\1a&H%X&\\2a&H%X&\\3a&H%X&\\4a&H%X&}", + ar[1], ar[2], ar[3], ar[4])) +end + +function ass_draw_rr_h_cw(ass, x0, y0, x1, y1, r1, hexagon, r2) + if hexagon then + ass:hexagon_cw(x0, y0, x1, y1, r1, r2) + else + ass:round_rect_cw(x0, y0, x1, y1, r1, r2) + end +end + +function ass_draw_rr_h_ccw(ass, x0, y0, x1, y1, r1, hexagon, r2) + if hexagon then + ass:hexagon_ccw(x0, y0, x1, y1, r1, r2) + else + ass:round_rect_ccw(x0, y0, x1, y1, r1, r2) + end +end + + +-- +-- Tracklist Management +-- + +local nicetypes = {video = "Video", audio = "Audio", sub = "Subtitle"} + +-- updates the OSC internal playlists, should be run each time the track-layout changes +function update_tracklist() + local tracktable = mp.get_property_native("track-list", {}) + + -- by osc_id + tracks_osc = {} + tracks_osc.video, tracks_osc.audio, tracks_osc.sub = {}, {}, {} + -- by mpv_id + tracks_mpv = {} + tracks_mpv.video, tracks_mpv.audio, tracks_mpv.sub = {}, {}, {} + for n = 1, #tracktable do + if tracktable[n].type ~= "unknown" then + local type = tracktable[n].type + local mpv_id = tonumber(tracktable[n].id) + + -- by osc_id + table.insert(tracks_osc[type], tracktable[n]) + + -- by mpv_id + tracks_mpv[type][mpv_id] = tracktable[n] + tracks_mpv[type][mpv_id].osc_id = #tracks_osc[type] + end + end +end + +-- return a nice list of tracks of the given type (video, audio, sub) +function get_tracklist(type) + local msg = "Available " .. nicetypes[type] .. " Tracks: " + if not tracks_osc or #tracks_osc[type] == 0 then + msg = msg .. "none" + else + for n = 1, #tracks_osc[type] do + local track = tracks_osc[type][n] + local lang, title, selected = "unknown", "", "○" + if track.lang ~= nil then lang = track.lang end + if track.title ~= nil then title = track.title end + if track.id == tonumber(mp.get_property(type)) then + selected = "●" + end + msg = msg.."\n"..selected.." "..n..": ["..lang.."] "..title + end + end + return msg +end + +-- relatively change the track of given <type> by <next> tracks + --(+1 -> next, -1 -> previous) +function set_track(type, next) + local current_track_mpv, current_track_osc + if mp.get_property(type) == "no" then + current_track_osc = 0 + else + current_track_mpv = tonumber(mp.get_property(type)) + current_track_osc = tracks_mpv[type][current_track_mpv].osc_id + end + local new_track_osc = (current_track_osc + next) % (#tracks_osc[type] + 1) + local new_track_mpv + if new_track_osc == 0 then + new_track_mpv = "no" + else + new_track_mpv = tracks_osc[type][new_track_osc].id + end + + mp.commandv("set", type, new_track_mpv) + + if new_track_osc == 0 then + show_message(nicetypes[type] .. " Track: none") + else + show_message(nicetypes[type] .. " Track: " + .. new_track_osc .. "/" .. #tracks_osc[type] + .. " [".. (tracks_osc[type][new_track_osc].lang or "unknown") .."] " + .. (tracks_osc[type][new_track_osc].title or "")) + end +end + +-- get the currently selected track of <type>, OSC-style counted +function get_track(type) + local track = mp.get_property(type) + if track ~= "no" and track ~= nil then + local tr = tracks_mpv[type][tonumber(track)] + if tr then + return tr.osc_id + end + end + return 0 +end + +-- WindowControl helpers +function window_controls_enabled() + val = user_opts.windowcontrols + if val == "auto" then + return not state.border + else + return val ~= "no" + end +end + +function window_controls_alignment() + return user_opts.windowcontrols_alignment +end + +-- +-- Element Management +-- + +local elements = {} + +function prepare_elements() + + -- remove elements without layout or invisible + local elements2 = {} + for n, element in pairs(elements) do + if element.layout ~= nil and element.visible then + table.insert(elements2, element) + end + end + elements = elements2 + + function elem_compare (a, b) + return a.layout.layer < b.layout.layer + end + + table.sort(elements, elem_compare) + + + for _,element in pairs(elements) do + + local elem_geo = element.layout.geometry + + -- Calculate the hitbox + local bX1, bY1, bX2, bY2 = get_hitbox_coords_geo(elem_geo) + element.hitbox = {x1 = bX1, y1 = bY1, x2 = bX2, y2 = bY2} + + local style_ass = assdraw.ass_new() + + -- prepare static elements + style_ass:append("{}") -- hack to troll new_event into inserting a \n + style_ass:new_event() + style_ass:pos(elem_geo.x, elem_geo.y) + style_ass:an(elem_geo.an) + style_ass:append(element.layout.style) + + element.style_ass = style_ass + + local static_ass = assdraw.ass_new() + + + if element.type == "box" then + --draw box + static_ass:draw_start() + ass_draw_rr_h_cw(static_ass, 0, 0, elem_geo.w, elem_geo.h, + element.layout.box.radius, element.layout.box.hexagon) + static_ass:draw_stop() + + elseif element.type == "slider" then + --draw static slider parts + + local r1 = 0 + local r2 = 0 + local slider_lo = element.layout.slider + -- offset between element outline and drag-area + local foV = slider_lo.border + slider_lo.gap + + -- calculate positions of min and max points + if slider_lo.stype ~= "bar" then + r1 = elem_geo.h / 2 + element.slider.min.ele_pos = elem_geo.h / 2 + element.slider.max.ele_pos = elem_geo.w - (elem_geo.h / 2) + if slider_lo.stype == "diamond" then + r2 = (elem_geo.h - 2 * slider_lo.border) / 2 + elseif slider_lo.stype == "knob" then + r2 = r1 + end + else + element.slider.min.ele_pos = + slider_lo.border + slider_lo.gap + element.slider.max.ele_pos = + elem_geo.w - (slider_lo.border + slider_lo.gap) + end + + element.slider.min.glob_pos = + element.hitbox.x1 + element.slider.min.ele_pos + element.slider.max.glob_pos = + element.hitbox.x1 + element.slider.max.ele_pos + + -- -- -- + + static_ass:draw_start() + + -- the box + ass_draw_rr_h_cw(static_ass, 0, 0, elem_geo.w, elem_geo.h, r1, slider_lo.stype == "diamond") + + -- the "hole" + ass_draw_rr_h_ccw(static_ass, slider_lo.border, slider_lo.border, + elem_geo.w - slider_lo.border, elem_geo.h - slider_lo.border, + r2, slider_lo.stype == "diamond") + + -- marker nibbles + if element.slider.markerF ~= nil and slider_lo.gap > 0 then + local markers = element.slider.markerF() + for _,marker in pairs(markers) do + if marker > element.slider.min.value and + marker < element.slider.max.value then + + local s = get_slider_ele_pos_for(element, marker) + + if slider_lo.gap > 1 then -- draw triangles + + local a = slider_lo.gap / 0.5 --0.866 + + --top + if slider_lo.nibbles_top then + static_ass:move_to(s - (a / 2), slider_lo.border) + static_ass:line_to(s + (a / 2), slider_lo.border) + static_ass:line_to(s, foV) + end + + --bottom + if slider_lo.nibbles_bottom then + static_ass:move_to(s - (a / 2), + elem_geo.h - slider_lo.border) + static_ass:line_to(s, + elem_geo.h - foV) + static_ass:line_to(s + (a / 2), + elem_geo.h - slider_lo.border) + end + + else -- draw 2x1px nibbles + + --top + if slider_lo.nibbles_top then + static_ass:rect_cw(s - 1, slider_lo.border, + s + 1, slider_lo.border + slider_lo.gap); + end + + --bottom + if slider_lo.nibbles_bottom then + static_ass:rect_cw(s - 1, + elem_geo.h -slider_lo.border -slider_lo.gap, + s + 1, elem_geo.h - slider_lo.border); + end + end + end + end + end + end + + element.static_ass = static_ass + + + -- if the element is supposed to be disabled, + -- style it accordingly and kill the eventresponders + if not element.enabled then + element.layout.alpha[1] = 136 + element.eventresponder = nil + end + end +end + + +-- +-- Element Rendering +-- + +-- returns nil or a chapter element from the native property chapter-list +function get_chapter(possec) + local cl = state.chapter_list -- sorted, get latest before possec, if any + + for n=#cl,1,-1 do + if possec >= cl[n].time then + return cl[n] + end + end +end + +function render_elements(master_ass) + + -- when the slider is dragged or hovered and we have a target chapter name + -- then we use it instead of the normal title. we calculate it before the + -- render iterations because the title may be rendered before the slider. + state.forced_title = nil + local se, ae = state.slider_element, elements[state.active_element] + if user_opts.chapter_fmt ~= "no" and se and (ae == se or (not ae and mouse_hit(se))) then + local dur = mp.get_property_number("duration", 0) + if dur > 0 then + local possec = get_slider_value(se) * dur / 100 -- of mouse pos + local ch = get_chapter(possec) + if ch and ch.title and ch.title ~= "" then + state.forced_title = string.format(user_opts.chapter_fmt, ch.title) + end + end + end + + for n=1, #elements do + local element = elements[n] + + local style_ass = assdraw.ass_new() + style_ass:merge(element.style_ass) + ass_append_alpha(style_ass, element.layout.alpha, 0) + + if element.eventresponder and (state.active_element == n) then + + -- run render event functions + if element.eventresponder.render ~= nil then + element.eventresponder.render(element) + end + + if mouse_hit(element) then + -- mouse down styling + if element.styledown then + style_ass:append(osc_styles.elementDown) + end + + if element.softrepeat and state.mouse_down_counter >= 15 + and state.mouse_down_counter % 5 == 0 then + + element.eventresponder[state.active_event_source.."_down"](element) + end + state.mouse_down_counter = state.mouse_down_counter + 1 + end + + end + + local elem_ass = assdraw.ass_new() + + elem_ass:merge(style_ass) + + if element.type ~= "button" then + elem_ass:merge(element.static_ass) + end + + if element.type == "slider" then + + local slider_lo = element.layout.slider + local elem_geo = element.layout.geometry + local s_min = element.slider.min.value + local s_max = element.slider.max.value + + -- draw pos marker + local foH, xp + local pos = element.slider.posF() + local foV = slider_lo.border + slider_lo.gap + local innerH = elem_geo.h - (2 * foV) + local seekRanges = element.slider.seekRangesF() + local seekRangeLineHeight = innerH / 5 + + if slider_lo.stype ~= "bar" then + foH = elem_geo.h / 2 + else + foH = slider_lo.border + slider_lo.gap + end + + if pos then + xp = get_slider_ele_pos_for(element, pos) + + if slider_lo.stype ~= "bar" then + local r = (user_opts.seekbarhandlesize * innerH) / 2 + ass_draw_rr_h_cw(elem_ass, xp - r, foH - r, + xp + r, foH + r, + r, slider_lo.stype == "diamond") + else + local h = 0 + if seekRanges and user_opts.seekrangeseparate and slider_lo.rtype ~= "inverted" then + h = seekRangeLineHeight + end + elem_ass:rect_cw(foH, foV, xp, elem_geo.h - foV - h) + + if seekRanges and not user_opts.seekrangeseparate and slider_lo.rtype ~= "inverted" then + -- Punch holes for the seekRanges to be drawn later + for _,range in pairs(seekRanges) do + if range["start"] < pos then + local pstart = get_slider_ele_pos_for(element, range["start"]) + local pend = xp + + if pos > range["end"] then + pend = get_slider_ele_pos_for(element, range["end"]) + end + elem_ass:rect_ccw(pstart, elem_geo.h - foV - seekRangeLineHeight, pend, elem_geo.h - foV) + end + end + end + end + + if slider_lo.rtype == "slider" then + ass_draw_rr_h_cw(elem_ass, foH - innerH / 6, foH - innerH / 6, + xp, foH + innerH / 6, + innerH / 6, slider_lo.stype == "diamond", 0) + ass_draw_rr_h_cw(elem_ass, xp, foH - innerH / 15, + elem_geo.w - foH + innerH / 15, foH + innerH / 15, + 0, slider_lo.stype == "diamond", innerH / 15) + for _,range in pairs(seekRanges or {}) do + local pstart = get_slider_ele_pos_for(element, range["start"]) + local pend = get_slider_ele_pos_for(element, range["end"]) + ass_draw_rr_h_ccw(elem_ass, pstart, foH - innerH / 21, + pend, foH + innerH / 21, + innerH / 21, slider_lo.stype == "diamond") + end + end + end + + if seekRanges then + if slider_lo.rtype ~= "inverted" then + elem_ass:draw_stop() + elem_ass:merge(element.style_ass) + ass_append_alpha(elem_ass, element.layout.alpha, user_opts.seekrangealpha) + elem_ass:merge(element.static_ass) + end + + for _,range in pairs(seekRanges) do + local pstart = get_slider_ele_pos_for(element, range["start"]) + local pend = get_slider_ele_pos_for(element, range["end"]) + + if slider_lo.rtype == "slider" then + ass_draw_rr_h_cw(elem_ass, pstart, foH - innerH / 21, + pend, foH + innerH / 21, + innerH / 21, slider_lo.stype == "diamond") + elseif slider_lo.rtype == "line" then + if slider_lo.stype == "bar" then + elem_ass:rect_cw(pstart, elem_geo.h - foV - seekRangeLineHeight, pend, elem_geo.h - foV) + else + ass_draw_rr_h_cw(elem_ass, pstart - innerH / 8, foH - innerH / 8, + pend + innerH / 8, foH + innerH / 8, + innerH / 8, slider_lo.stype == "diamond") + end + elseif slider_lo.rtype == "bar" then + if slider_lo.stype ~= "bar" then + ass_draw_rr_h_cw(elem_ass, pstart - innerH / 2, foV, + pend + innerH / 2, foV + innerH, + innerH / 2, slider_lo.stype == "diamond") + elseif range["end"] >= (pos or 0) then + elem_ass:rect_cw(pstart, foV, pend, elem_geo.h - foV) + else + elem_ass:rect_cw(pstart, elem_geo.h - foV - seekRangeLineHeight, pend, elem_geo.h - foV) + end + elseif slider_lo.rtype == "inverted" then + if slider_lo.stype ~= "bar" then + ass_draw_rr_h_ccw(elem_ass, pstart, (elem_geo.h / 2) - 1, pend, + (elem_geo.h / 2) + 1, + 1, slider_lo.stype == "diamond") + else + elem_ass:rect_ccw(pstart, (elem_geo.h / 2) - 1, pend, (elem_geo.h / 2) + 1) + end + end + end + end + + elem_ass:draw_stop() + + -- add tooltip + if element.slider.tooltipF ~= nil then + if mouse_hit(element) then + local sliderpos = get_slider_value(element) + local tooltiplabel = element.slider.tooltipF(sliderpos) + + local an = slider_lo.tooltip_an + + local ty + + if an == 2 then + ty = element.hitbox.y1 - slider_lo.border + else + ty = element.hitbox.y1 + elem_geo.h / 2 + end + + local tx = get_virt_mouse_pos() + if slider_lo.adjust_tooltip then + if an == 2 then + if sliderpos < (s_min + 3) then + an = an - 1 + elseif sliderpos > (s_max - 3) then + an = an + 1 + end + elseif sliderpos > (s_max+s_min) / 2 then + an = an + 1 + tx = tx - 5 + else + an = an - 1 + tx = tx + 10 + end + end + + -- tooltip label + elem_ass:new_event() + elem_ass:pos(tx, ty) + elem_ass:an(an) + elem_ass:append(slider_lo.tooltip_style) + ass_append_alpha(elem_ass, slider_lo.alpha, 0) + elem_ass:append(tooltiplabel) + + end + end + + elseif element.type == "button" then + + local buttontext + if type(element.content) == "function" then + buttontext = element.content() -- function objects + elseif element.content ~= nil then + buttontext = element.content -- text objects + end + + local maxchars = element.layout.button.maxchars + if maxchars ~= nil and #buttontext > maxchars then + local max_ratio = 1.25 -- up to 25% more chars while shrinking + local limit = math.max(0, math.floor(maxchars * max_ratio) - 3) + if #buttontext > limit then + while (#buttontext > limit) do + buttontext = buttontext:gsub(".[\128-\191]*$", "") + end + buttontext = buttontext .. "..." + end + local _, nchars2 = buttontext:gsub(".[\128-\191]*", "") + local stretch = (maxchars/#buttontext)*100 + buttontext = string.format("{\\fscx%f}", + (maxchars/#buttontext)*100) .. buttontext + end + + elem_ass:append(buttontext) + end + + master_ass:merge(elem_ass) + end +end + +-- +-- Message display +-- + +-- pos is 1 based +function limited_list(prop, pos) + local proplist = mp.get_property_native(prop, {}) + local count = #proplist + if count == 0 then + return count, proplist + end + + local fs = tonumber(mp.get_property('options/osd-font-size')) + local max = math.ceil(osc_param.unscaled_y*0.75 / fs) + if max % 2 == 0 then + max = max - 1 + end + local delta = math.ceil(max / 2) - 1 + local begi = math.max(math.min(pos - delta, count - max + 1), 1) + local endi = math.min(begi + max - 1, count) + + local reslist = {} + for i=begi, endi do + local item = proplist[i] + item.current = (i == pos) and true or nil + table.insert(reslist, item) + end + return count, reslist +end + +function get_playlist() + local pos = mp.get_property_number('playlist-pos', 0) + 1 + local count, limlist = limited_list('playlist', pos) + if count == 0 then + return 'Empty playlist.' + end + + local message = string.format('Playlist [%d/%d]:\n', pos, count) + for i, v in ipairs(limlist) do + local title = v.title + local _, filename = utils.split_path(v.filename) + if title == nil then + title = filename + end + message = string.format('%s %s %s\n', message, + (v.current and '●' or '○'), title) + end + return message +end + +function get_chapterlist() + local pos = mp.get_property_number('chapter', 0) + 1 + local count, limlist = limited_list('chapter-list', pos) + if count == 0 then + return 'No chapters.' + end + + local message = string.format('Chapters [%d/%d]:\n', pos, count) + for i, v in ipairs(limlist) do + local time = mp.format_time(v.time) + local title = v.title + if title == nil then + title = string.format('Chapter %02d', i) + end + message = string.format('%s[%s] %s %s\n', message, time, + (v.current and '●' or '○'), title) + end + return message +end + +function show_message(text, duration) + + --print("text: "..text.." duration: " .. duration) + if duration == nil then + duration = tonumber(mp.get_property("options/osd-duration")) / 1000 + elseif not type(duration) == "number" then + print("duration: " .. duration) + end + + -- cut the text short, otherwise the following functions + -- may slow down massively on huge input + text = string.sub(text, 0, 4000) + + -- replace actual linebreaks with ASS linebreaks + text = string.gsub(text, "\n", "\\N") + + state.message_text = text + + if not state.message_hide_timer then + state.message_hide_timer = mp.add_timeout(0, request_tick) + end + state.message_hide_timer:kill() + state.message_hide_timer.timeout = duration + state.message_hide_timer:resume() + request_tick() +end + +function render_message(ass) + if state.message_hide_timer and state.message_hide_timer:is_enabled() and + state.message_text + then + local _, lines = string.gsub(state.message_text, "\\N", "") + + local fontsize = tonumber(mp.get_property("options/osd-font-size")) + local outline = tonumber(mp.get_property("options/osd-border-size")) + local maxlines = math.ceil(osc_param.unscaled_y*0.75 / fontsize) + local counterscale = osc_param.playresy / osc_param.unscaled_y + + fontsize = fontsize * counterscale / math.max(0.65 + math.min(lines/maxlines, 1), 1) + outline = outline * counterscale / math.max(0.75 + math.min(lines/maxlines, 1)/2, 1) + + local style = "{\\bord" .. outline .. "\\fs" .. fontsize .. "}" + + + ass:new_event() + ass:append(style .. state.message_text) + else + state.message_text = nil + end +end + +-- +-- Initialisation and Layout +-- + +function new_element(name, type) + elements[name] = {} + elements[name].type = type + + -- add default stuff + elements[name].eventresponder = {} + elements[name].visible = true + elements[name].enabled = true + elements[name].softrepeat = false + elements[name].styledown = (type == "button") + elements[name].state = {} + + if type == "slider" then + elements[name].slider = {min = {value = 0}, max = {value = 100}} + end + + + return elements[name] +end + +function add_layout(name) + if elements[name] ~= nil then + -- new layout + elements[name].layout = {} + + -- set layout defaults + elements[name].layout.layer = 50 + elements[name].layout.alpha = {[1] = 0, [2] = 255, [3] = 255, [4] = 255} + + if elements[name].type == "button" then + elements[name].layout.button = { + maxchars = nil, + } + elseif elements[name].type == "slider" then + -- slider defaults + elements[name].layout.slider = { + border = 1, + gap = 1, + nibbles_top = true, + nibbles_bottom = true, + stype = "slider", + adjust_tooltip = true, + tooltip_style = "", + tooltip_an = 2, + alpha = {[1] = 0, [2] = 255, [3] = 88, [4] = 255}, + } + elseif elements[name].type == "box" then + elements[name].layout.box = {radius = 0, hexagon = false} + end + + return elements[name].layout + else + msg.error("Can't add_layout to element \""..name.."\", doesn't exist.") + end +end + +-- Window Controls +function window_controls(topbar) + local wc_geo = { + x = 0, + y = 30 + user_opts.barmargin, + an = 1, + w = osc_param.playresx, + h = 30, + } + + local alignment = window_controls_alignment() + local controlbox_w = window_control_box_width + local titlebox_w = wc_geo.w - controlbox_w + + -- Default alignment is "right" + local controlbox_left = wc_geo.w - controlbox_w + local titlebox_left = wc_geo.x + local titlebox_right = wc_geo.w - controlbox_w + + if alignment == "left" then + controlbox_left = wc_geo.x + titlebox_left = wc_geo.x + controlbox_w + titlebox_right = wc_geo.w + end + + add_area("window-controls", + get_hitbox_coords(controlbox_left, wc_geo.y, wc_geo.an, + controlbox_w, wc_geo.h)) + + local lo + + -- Background Bar + new_element("wcbar", "box") + lo = add_layout("wcbar") + lo.geometry = wc_geo + lo.layer = 10 + lo.style = osc_styles.wcBar + lo.alpha[1] = user_opts.boxalpha + + local button_y = wc_geo.y - (wc_geo.h / 2) + local first_geo = + {x = controlbox_left + 5, y = button_y, an = 4, w = 25, h = 25} + local second_geo = + {x = controlbox_left + 30, y = button_y, an = 4, w = 25, h = 25} + local third_geo = + {x = controlbox_left + 55, y = button_y, an = 4, w = 25, h = 25} + + -- Window control buttons use symbols in the custom mpv osd font + -- because the official unicode codepoints are sufficiently + -- exotic that a system might lack an installed font with them, + -- and libass will complain that they are not present in the + -- default font, even if another font with them is available. + + -- Close: 🗙 + ne = new_element("close", "button") + ne.content = "\238\132\149" + ne.eventresponder["mbtn_left_up"] = + function () mp.commandv("quit") end + lo = add_layout("close") + lo.geometry = alignment == "left" and first_geo or third_geo + lo.style = osc_styles.wcButtons + + -- Minimize: 🗕 + ne = new_element("minimize", "button") + ne.content = "\238\132\146" + ne.eventresponder["mbtn_left_up"] = + function () mp.commandv("cycle", "window-minimized") end + lo = add_layout("minimize") + lo.geometry = alignment == "left" and second_geo or first_geo + lo.style = osc_styles.wcButtons + + -- Maximize: 🗖 /🗗 + ne = new_element("maximize", "button") + if state.maximized or state.fullscreen then + ne.content = "\238\132\148" + else + ne.content = "\238\132\147" + end + ne.eventresponder["mbtn_left_up"] = + function () + if state.fullscreen then + mp.commandv("cycle", "fullscreen") + else + mp.commandv("cycle", "window-maximized") + end + end + lo = add_layout("maximize") + lo.geometry = alignment == "left" and third_geo or second_geo + lo.style = osc_styles.wcButtons + + -- deadzone below window controls + local sh_area_y0, sh_area_y1 + sh_area_y0 = user_opts.barmargin + sh_area_y1 = wc_geo.y + get_align(1 - (2 * user_opts.deadzonesize), + osc_param.playresy - wc_geo.y, 0, 0) + add_area("showhide_wc", wc_geo.x, sh_area_y0, wc_geo.w, sh_area_y1) + + if topbar then + -- The title is already there as part of the top bar + return + else + -- Apply boxvideo margins to the control bar + osc_param.video_margins.t = wc_geo.h / osc_param.playresy + end + + -- Window Title + ne = new_element("wctitle", "button") + ne.content = function () + local title = mp.command_native({"expand-text", user_opts.title}) + -- escape ASS, and strip newlines and trailing slashes + title = title:gsub("\\n", " "):gsub("\\$", ""):gsub("{","\\{") + return not (title == "") and title or "mpv" + end + local left_pad = 5 + local right_pad = 10 + lo = add_layout("wctitle") + lo.geometry = + { x = titlebox_left + left_pad, y = wc_geo.y - 3, an = 1, + w = titlebox_w, h = wc_geo.h } + lo.style = string.format("%s{\\clip(%f,%f,%f,%f)}", + osc_styles.wcTitle, + titlebox_left + left_pad, wc_geo.y - wc_geo.h, + titlebox_right - right_pad , wc_geo.y + wc_geo.h) + + add_area("window-controls-title", + titlebox_left, 0, titlebox_right, wc_geo.h) +end + +-- +-- Layouts +-- + +local layouts = {} + +-- Classic box layout +layouts["box"] = function () + + local osc_geo = { + w = 550, -- width + h = 138, -- height + r = 10, -- corner-radius + p = 15, -- padding + } + + -- make sure the OSC actually fits into the video + if osc_param.playresx < (osc_geo.w + (2 * osc_geo.p)) then + osc_param.playresy = (osc_geo.w + (2 * osc_geo.p)) / osc_param.display_aspect + osc_param.playresx = osc_param.playresy * osc_param.display_aspect + end + + -- position of the controller according to video aspect and valignment + local posX = math.floor(get_align(user_opts.halign, osc_param.playresx, + osc_geo.w, 0)) + local posY = math.floor(get_align(user_opts.valign, osc_param.playresy, + osc_geo.h, 0)) + + -- position offset for contents aligned at the borders of the box + local pos_offsetX = (osc_geo.w - (2*osc_geo.p)) / 2 + local pos_offsetY = (osc_geo.h - (2*osc_geo.p)) / 2 + + osc_param.areas = {} -- delete areas + + -- area for active mouse input + add_area("input", get_hitbox_coords(posX, posY, 5, osc_geo.w, osc_geo.h)) + + -- area for show/hide + local sh_area_y0, sh_area_y1 + if user_opts.valign > 0 then + -- deadzone above OSC + sh_area_y0 = get_align(-1 + (2*user_opts.deadzonesize), + posY - (osc_geo.h / 2), 0, 0) + sh_area_y1 = osc_param.playresy + else + -- deadzone below OSC + sh_area_y0 = 0 + sh_area_y1 = (posY + (osc_geo.h / 2)) + + get_align(1 - (2*user_opts.deadzonesize), + osc_param.playresy - (posY + (osc_geo.h / 2)), 0, 0) + end + add_area("showhide", 0, sh_area_y0, osc_param.playresx, sh_area_y1) + + -- fetch values + local osc_w, osc_h, osc_r, osc_p = + osc_geo.w, osc_geo.h, osc_geo.r, osc_geo.p + + local lo + + -- + -- Background box + -- + + new_element("bgbox", "box") + lo = add_layout("bgbox") + + lo.geometry = {x = posX, y = posY, an = 5, w = osc_w, h = osc_h} + lo.layer = 10 + lo.style = osc_styles.box + lo.alpha[1] = user_opts.boxalpha + lo.alpha[3] = user_opts.boxalpha + lo.box.radius = osc_r + + -- + -- Title row + -- + + local titlerowY = posY - pos_offsetY - 10 + + lo = add_layout("title") + lo.geometry = {x = posX, y = titlerowY, an = 8, w = 496, h = 12} + lo.style = osc_styles.vidtitle + lo.button.maxchars = user_opts.boxmaxchars + + lo = add_layout("pl_prev") + lo.geometry = + {x = (posX - pos_offsetX), y = titlerowY, an = 7, w = 12, h = 12} + lo.style = osc_styles.topButtons + + lo = add_layout("pl_next") + lo.geometry = + {x = (posX + pos_offsetX), y = titlerowY, an = 9, w = 12, h = 12} + lo.style = osc_styles.topButtons + + -- + -- Big buttons + -- + + local bigbtnrowY = posY - pos_offsetY + 35 + local bigbtndist = 60 + + lo = add_layout("playpause") + lo.geometry = + {x = posX, y = bigbtnrowY, an = 5, w = 40, h = 40} + lo.style = osc_styles.bigButtons + + lo = add_layout("skipback") + lo.geometry = + {x = posX - bigbtndist, y = bigbtnrowY, an = 5, w = 40, h = 40} + lo.style = osc_styles.bigButtons + + lo = add_layout("skipfrwd") + lo.geometry = + {x = posX + bigbtndist, y = bigbtnrowY, an = 5, w = 40, h = 40} + lo.style = osc_styles.bigButtons + + lo = add_layout("ch_prev") + lo.geometry = + {x = posX - (bigbtndist * 2), y = bigbtnrowY, an = 5, w = 40, h = 40} + lo.style = osc_styles.bigButtons + + lo = add_layout("ch_next") + lo.geometry = + {x = posX + (bigbtndist * 2), y = bigbtnrowY, an = 5, w = 40, h = 40} + lo.style = osc_styles.bigButtons + + lo = add_layout("cy_audio") + lo.geometry = + {x = posX - pos_offsetX, y = bigbtnrowY, an = 1, w = 70, h = 18} + lo.style = osc_styles.smallButtonsL + + lo = add_layout("cy_sub") + lo.geometry = + {x = posX - pos_offsetX, y = bigbtnrowY, an = 7, w = 70, h = 18} + lo.style = osc_styles.smallButtonsL + + lo = add_layout("tog_fs") + lo.geometry = + {x = posX+pos_offsetX - 25, y = bigbtnrowY, an = 4, w = 25, h = 25} + lo.style = osc_styles.smallButtonsR + + lo = add_layout("volume") + lo.geometry = + {x = posX+pos_offsetX - (25 * 2) - osc_geo.p, + y = bigbtnrowY, an = 4, w = 25, h = 25} + lo.style = osc_styles.smallButtonsR + + -- + -- Seekbar + -- + + lo = add_layout("seekbar") + lo.geometry = + {x = posX, y = posY+pos_offsetY-22, an = 2, w = pos_offsetX*2, h = 15} + lo.style = osc_styles.timecodes + lo.slider.tooltip_style = osc_styles.vidtitle + lo.slider.stype = user_opts["seekbarstyle"] + lo.slider.rtype = user_opts["seekrangestyle"] + + -- + -- Timecodes + Cache + -- + + local bottomrowY = posY + pos_offsetY - 5 + + lo = add_layout("tc_left") + lo.geometry = + {x = posX - pos_offsetX, y = bottomrowY, an = 4, w = 110, h = 18} + lo.style = osc_styles.timecodes + + lo = add_layout("tc_right") + lo.geometry = + {x = posX + pos_offsetX, y = bottomrowY, an = 6, w = 110, h = 18} + lo.style = osc_styles.timecodes + + lo = add_layout("cache") + lo.geometry = + {x = posX, y = bottomrowY, an = 5, w = 110, h = 18} + lo.style = osc_styles.timecodes + +end + +-- slim box layout +layouts["slimbox"] = function () + + local osc_geo = { + w = 660, -- width + h = 70, -- height + r = 10, -- corner-radius + } + + -- make sure the OSC actually fits into the video + if osc_param.playresx < (osc_geo.w) then + osc_param.playresy = (osc_geo.w) / osc_param.display_aspect + osc_param.playresx = osc_param.playresy * osc_param.display_aspect + end + + -- position of the controller according to video aspect and valignment + local posX = math.floor(get_align(user_opts.halign, osc_param.playresx, + osc_geo.w, 0)) + local posY = math.floor(get_align(user_opts.valign, osc_param.playresy, + osc_geo.h, 0)) + + osc_param.areas = {} -- delete areas + + -- area for active mouse input + add_area("input", get_hitbox_coords(posX, posY, 5, osc_geo.w, osc_geo.h)) + + -- area for show/hide + local sh_area_y0, sh_area_y1 + if user_opts.valign > 0 then + -- deadzone above OSC + sh_area_y0 = get_align(-1 + (2*user_opts.deadzonesize), + posY - (osc_geo.h / 2), 0, 0) + sh_area_y1 = osc_param.playresy + else + -- deadzone below OSC + sh_area_y0 = 0 + sh_area_y1 = (posY + (osc_geo.h / 2)) + + get_align(1 - (2*user_opts.deadzonesize), + osc_param.playresy - (posY + (osc_geo.h / 2)), 0, 0) + end + add_area("showhide", 0, sh_area_y0, osc_param.playresx, sh_area_y1) + + local lo + + local tc_w, ele_h, inner_w = 100, 20, osc_geo.w - 100 + + -- styles + local styles = { + box = "{\\rDefault\\blur0\\bord1\\1c&H000000\\3c&HFFFFFF}", + timecodes = "{\\1c&HFFFFFF\\3c&H000000\\fs20\\bord2\\blur1}", + tooltip = "{\\1c&HFFFFFF\\3c&H000000\\fs12\\bord1\\blur0.5}", + } + + + new_element("bgbox", "box") + lo = add_layout("bgbox") + + lo.geometry = {x = posX, y = posY - 1, an = 2, w = inner_w, h = ele_h} + lo.layer = 10 + lo.style = osc_styles.box + lo.alpha[1] = user_opts.boxalpha + lo.alpha[3] = 0 + if user_opts["seekbarstyle"] ~= "bar" then + lo.box.radius = osc_geo.r + lo.box.hexagon = user_opts["seekbarstyle"] == "diamond" + end + + + lo = add_layout("seekbar") + lo.geometry = + {x = posX, y = posY - 1, an = 2, w = inner_w, h = ele_h} + lo.style = osc_styles.timecodes + lo.slider.border = 0 + lo.slider.gap = 1.5 + lo.slider.tooltip_style = styles.tooltip + lo.slider.stype = user_opts["seekbarstyle"] + lo.slider.rtype = user_opts["seekrangestyle"] + lo.slider.adjust_tooltip = false + + -- + -- Timecodes + -- + + lo = add_layout("tc_left") + lo.geometry = + {x = posX - (inner_w/2) + osc_geo.r, y = posY + 1, + an = 7, w = tc_w, h = ele_h} + lo.style = styles.timecodes + lo.alpha[3] = user_opts.boxalpha + + lo = add_layout("tc_right") + lo.geometry = + {x = posX + (inner_w/2) - osc_geo.r, y = posY + 1, + an = 9, w = tc_w, h = ele_h} + lo.style = styles.timecodes + lo.alpha[3] = user_opts.boxalpha + + -- Cache + + lo = add_layout("cache") + lo.geometry = + {x = posX, y = posY + 1, + an = 8, w = tc_w, h = ele_h} + lo.style = styles.timecodes + lo.alpha[3] = user_opts.boxalpha + + +end + +function bar_layout(direction) + local osc_geo = { + x = -2, + y, + an = (direction < 0) and 7 or 1, + w, + h = 56, + } + + local padX = 9 + local padY = 3 + local buttonW = 27 + local tcW = (state.tc_ms) and 170 or 110 + if user_opts.tcspace >= 50 and user_opts.tcspace <= 200 then + -- adjust our hardcoded font size estimation + tcW = tcW * user_opts.tcspace / 100 + end + + local tsW = 90 + local minW = (buttonW + padX)*5 + (tcW + padX)*4 + (tsW + padX)*2 + + -- Special topbar handling when window controls are present + local padwc_l + local padwc_r + if direction < 0 or not window_controls_enabled() then + padwc_l = 0 + padwc_r = 0 + elseif window_controls_alignment() == "left" then + padwc_l = window_control_box_width + padwc_r = 0 + else + padwc_l = 0 + padwc_r = window_control_box_width + end + + if osc_param.display_aspect > 0 and osc_param.playresx < minW then + osc_param.playresy = minW / osc_param.display_aspect + osc_param.playresx = osc_param.playresy * osc_param.display_aspect + end + + osc_geo.y = direction * (54 + user_opts.barmargin) + osc_geo.w = osc_param.playresx + 4 + if direction < 0 then + osc_geo.y = osc_geo.y + osc_param.playresy + end + + local line1 = osc_geo.y - direction * (9 + padY) + local line2 = osc_geo.y - direction * (36 + padY) + + osc_param.areas = {} + + add_area("input", get_hitbox_coords(osc_geo.x, osc_geo.y, osc_geo.an, + osc_geo.w, osc_geo.h)) + + local sh_area_y0, sh_area_y1 + if direction > 0 then + -- deadzone below OSC + sh_area_y0 = user_opts.barmargin + sh_area_y1 = osc_geo.y + get_align(1 - (2 * user_opts.deadzonesize), + osc_param.playresy - osc_geo.y, 0, 0) + else + -- deadzone above OSC + sh_area_y0 = get_align(-1 + (2 * user_opts.deadzonesize), osc_geo.y, 0, 0) + sh_area_y1 = osc_param.playresy - user_opts.barmargin + end + add_area("showhide", 0, sh_area_y0, osc_param.playresx, sh_area_y1) + + local lo, geo + + -- Background bar + new_element("bgbox", "box") + lo = add_layout("bgbox") + + lo.geometry = osc_geo + lo.layer = 10 + lo.style = osc_styles.box + lo.alpha[1] = user_opts.boxalpha + + + -- Playlist prev/next + geo = { x = osc_geo.x + padX, y = line1, + an = 4, w = 18, h = 18 - padY } + lo = add_layout("pl_prev") + lo.geometry = geo + lo.style = osc_styles.topButtonsBar + + geo = { x = geo.x + geo.w + padX, y = geo.y, an = geo.an, w = geo.w, h = geo.h } + lo = add_layout("pl_next") + lo.geometry = geo + lo.style = osc_styles.topButtonsBar + + local t_l = geo.x + geo.w + padX + + -- Cache + geo = { x = osc_geo.x + osc_geo.w - padX, y = geo.y, + an = 6, w = 150, h = geo.h } + lo = add_layout("cache") + lo.geometry = geo + lo.style = osc_styles.vidtitleBar + + local t_r = geo.x - geo.w - padX*2 + + -- Title + geo = { x = t_l, y = geo.y, an = 4, + w = t_r - t_l, h = geo.h } + lo = add_layout("title") + lo.geometry = geo + lo.style = string.format("%s{\\clip(%f,%f,%f,%f)}", + osc_styles.vidtitleBar, + geo.x, geo.y-geo.h, geo.w, geo.y+geo.h) + + + -- Playback control buttons + geo = { x = osc_geo.x + padX + padwc_l, y = line2, an = 4, + w = buttonW, h = 36 - padY*2} + lo = add_layout("playpause") + lo.geometry = geo + lo.style = osc_styles.smallButtonsBar + + geo = { x = geo.x + geo.w + padX, y = geo.y, an = geo.an, w = geo.w, h = geo.h } + lo = add_layout("ch_prev") + lo.geometry = geo + lo.style = osc_styles.smallButtonsBar + + geo = { x = geo.x + geo.w + padX, y = geo.y, an = geo.an, w = geo.w, h = geo.h } + lo = add_layout("ch_next") + lo.geometry = geo + lo.style = osc_styles.smallButtonsBar + + -- Left timecode + geo = { x = geo.x + geo.w + padX + tcW, y = geo.y, an = 6, + w = tcW, h = geo.h } + lo = add_layout("tc_left") + lo.geometry = geo + lo.style = osc_styles.timecodesBar + + local sb_l = geo.x + padX + + -- Fullscreen button + geo = { x = osc_geo.x + osc_geo.w - buttonW - padX - padwc_r, y = geo.y, an = 4, + w = buttonW, h = geo.h } + lo = add_layout("tog_fs") + lo.geometry = geo + lo.style = osc_styles.smallButtonsBar + + -- Volume + geo = { x = geo.x - geo.w - padX, y = geo.y, an = geo.an, w = geo.w, h = geo.h } + lo = add_layout("volume") + lo.geometry = geo + lo.style = osc_styles.smallButtonsBar + + -- Track selection buttons + geo = { x = geo.x - tsW - padX, y = geo.y, an = geo.an, w = tsW, h = geo.h } + lo = add_layout("cy_sub") + lo.geometry = geo + lo.style = osc_styles.smallButtonsBar + + geo = { x = geo.x - geo.w - padX, y = geo.y, an = geo.an, w = geo.w, h = geo.h } + lo = add_layout("cy_audio") + lo.geometry = geo + lo.style = osc_styles.smallButtonsBar + + + -- Right timecode + geo = { x = geo.x - padX - tcW - 10, y = geo.y, an = geo.an, + w = tcW, h = geo.h } + lo = add_layout("tc_right") + lo.geometry = geo + lo.style = osc_styles.timecodesBar + + local sb_r = geo.x - padX + + + -- Seekbar + geo = { x = sb_l, y = geo.y, an = geo.an, + w = math.max(0, sb_r - sb_l), h = geo.h } + new_element("bgbar1", "box") + lo = add_layout("bgbar1") + + lo.geometry = geo + lo.layer = 15 + lo.style = osc_styles.timecodesBar + lo.alpha[1] = + math.min(255, user_opts.boxalpha + (255 - user_opts.boxalpha)*0.8) + if user_opts["seekbarstyle"] ~= "bar" then + lo.box.radius = geo.h / 2 + lo.box.hexagon = user_opts["seekbarstyle"] == "diamond" + end + + lo = add_layout("seekbar") + lo.geometry = geo + lo.style = osc_styles.timecodesBar + lo.slider.border = 0 + lo.slider.gap = 2 + lo.slider.tooltip_style = osc_styles.timePosBar + lo.slider.tooltip_an = 5 + lo.slider.stype = user_opts["seekbarstyle"] + lo.slider.rtype = user_opts["seekrangestyle"] + + if direction < 0 then + osc_param.video_margins.b = osc_geo.h / osc_param.playresy + else + osc_param.video_margins.t = osc_geo.h / osc_param.playresy + end +end + +layouts["bottombar"] = function() + bar_layout(-1) +end + +layouts["topbar"] = function() + bar_layout(1) +end + +-- Validate string type user options +function validate_user_opts() + if layouts[user_opts.layout] == nil then + msg.warn("Invalid setting \""..user_opts.layout.."\" for layout") + user_opts.layout = "bottombar" + end + + if user_opts.seekbarstyle ~= "bar" and + user_opts.seekbarstyle ~= "diamond" and + user_opts.seekbarstyle ~= "knob" then + msg.warn("Invalid setting \"" .. user_opts.seekbarstyle + .. "\" for seekbarstyle") + user_opts.seekbarstyle = "bar" + end + + if user_opts.seekrangestyle ~= "bar" and + user_opts.seekrangestyle ~= "line" and + user_opts.seekrangestyle ~= "slider" and + user_opts.seekrangestyle ~= "inverted" and + user_opts.seekrangestyle ~= "none" then + msg.warn("Invalid setting \"" .. user_opts.seekrangestyle + .. "\" for seekrangestyle") + user_opts.seekrangestyle = "inverted" + end + + if user_opts.seekrangestyle == "slider" and + user_opts.seekbarstyle == "bar" then + msg.warn("Using \"slider\" seekrangestyle together with \"bar\" seekbarstyle is not supported") + user_opts.seekrangestyle = "inverted" + end + + if user_opts.windowcontrols ~= "auto" and + user_opts.windowcontrols ~= "yes" and + user_opts.windowcontrols ~= "no" then + msg.warn("windowcontrols cannot be \"" .. + user_opts.windowcontrols .. "\". Ignoring.") + user_opts.windowcontrols = "auto" + end + if user_opts.windowcontrols_alignment ~= "right" and + user_opts.windowcontrols_alignment ~= "left" then + msg.warn("windowcontrols_alignment cannot be \"" .. + user_opts.windowcontrols_alignment .. "\". Ignoring.") + user_opts.windowcontrols_alignment = "right" + end +end + +function update_options(list) + validate_user_opts() + request_tick() + visibility_mode(user_opts.visibility, true) + update_duration_watch() + request_init() +end + +local UNICODE_MINUS = string.char(0xe2, 0x88, 0x92) -- UTF-8 for U+2212 MINUS SIGN + +-- OSC INIT +function osc_init() + msg.debug("osc_init") + + -- set canvas resolution according to display aspect and scaling setting + local baseResY = 720 + local display_w, display_h, display_aspect = mp.get_osd_size() + local scale = 1 + + if mp.get_property("video") == "no" then -- dummy/forced window + scale = user_opts.scaleforcedwindow + elseif state.fullscreen then + scale = user_opts.scalefullscreen + else + scale = user_opts.scalewindowed + end + + if user_opts.vidscale then + osc_param.unscaled_y = baseResY + else + osc_param.unscaled_y = display_h + end + osc_param.playresy = osc_param.unscaled_y / scale + if display_aspect > 0 then + osc_param.display_aspect = display_aspect + end + osc_param.playresx = osc_param.playresy * osc_param.display_aspect + + -- stop seeking with the slider to prevent skipping files + state.active_element = nil + + osc_param.video_margins = {l = 0, r = 0, t = 0, b = 0} + + elements = {} + + -- some often needed stuff + local pl_count = mp.get_property_number("playlist-count", 0) + local have_pl = (pl_count > 1) + local pl_pos = mp.get_property_number("playlist-pos", 0) + 1 + local have_ch = (mp.get_property_number("chapters", 0) > 0) + local loop = mp.get_property("loop-playlist", "no") + + local ne + + -- title + ne = new_element("title", "button") + + ne.content = function () + local title = state.forced_title or + mp.command_native({"expand-text", user_opts.title}) + -- escape ASS, and strip newlines and trailing slashes + title = title:gsub("\\n", " "):gsub("\\$", ""):gsub("{","\\{") + return not (title == "") and title or "mpv" + end + + ne.eventresponder["mbtn_left_up"] = function () + local title = mp.get_property_osd("media-title") + if have_pl then + title = string.format("[%d/%d] %s", countone(pl_pos - 1), + pl_count, title) + end + show_message(title) + end + + ne.eventresponder["mbtn_right_up"] = + function () show_message(mp.get_property_osd("filename")) end + + -- playlist buttons + + -- prev + ne = new_element("pl_prev", "button") + + ne.content = "\238\132\144" + ne.enabled = (pl_pos > 1) or (loop ~= "no") + ne.eventresponder["mbtn_left_up"] = + function () + mp.commandv("playlist-prev", "weak") + if user_opts.playlist_osd then + show_message(get_playlist(), 3) + end + end + ne.eventresponder["shift+mbtn_left_up"] = + function () show_message(get_playlist(), 3) end + ne.eventresponder["mbtn_right_up"] = + function () show_message(get_playlist(), 3) end + + --next + ne = new_element("pl_next", "button") + + ne.content = "\238\132\129" + ne.enabled = (have_pl and (pl_pos < pl_count)) or (loop ~= "no") + ne.eventresponder["mbtn_left_up"] = + function () + mp.commandv("playlist-next", "weak") + if user_opts.playlist_osd then + show_message(get_playlist(), 3) + end + end + ne.eventresponder["shift+mbtn_left_up"] = + function () show_message(get_playlist(), 3) end + ne.eventresponder["mbtn_right_up"] = + function () show_message(get_playlist(), 3) end + + + -- big buttons + + --playpause + ne = new_element("playpause", "button") + + ne.content = function () + if mp.get_property("pause") == "yes" then + return ("\238\132\129") + else + return ("\238\128\130") + end + end + ne.eventresponder["mbtn_left_up"] = + function () mp.commandv("cycle", "pause") end + + --skipback + ne = new_element("skipback", "button") + + ne.softrepeat = true + ne.content = "\238\128\132" + ne.eventresponder["mbtn_left_down"] = + function () mp.commandv("seek", -5, "relative", "keyframes") end + ne.eventresponder["shift+mbtn_left_down"] = + function () mp.commandv("frame-back-step") end + ne.eventresponder["mbtn_right_down"] = + function () mp.commandv("seek", -30, "relative", "keyframes") end + + --skipfrwd + ne = new_element("skipfrwd", "button") + + ne.softrepeat = true + ne.content = "\238\128\133" + ne.eventresponder["mbtn_left_down"] = + function () mp.commandv("seek", 10, "relative", "keyframes") end + ne.eventresponder["shift+mbtn_left_down"] = + function () mp.commandv("frame-step") end + ne.eventresponder["mbtn_right_down"] = + function () mp.commandv("seek", 60, "relative", "keyframes") end + + --ch_prev + ne = new_element("ch_prev", "button") + + ne.enabled = have_ch + ne.content = "\238\132\132" + ne.eventresponder["mbtn_left_up"] = + function () + mp.commandv("add", "chapter", -1) + if user_opts.chapters_osd then + show_message(get_chapterlist(), 3) + end + end + ne.eventresponder["shift+mbtn_left_up"] = + function () show_message(get_chapterlist(), 3) end + ne.eventresponder["mbtn_right_up"] = + function () show_message(get_chapterlist(), 3) end + + --ch_next + ne = new_element("ch_next", "button") + + ne.enabled = have_ch + ne.content = "\238\132\133" + ne.eventresponder["mbtn_left_up"] = + function () + mp.commandv("add", "chapter", 1) + if user_opts.chapters_osd then + show_message(get_chapterlist(), 3) + end + end + ne.eventresponder["shift+mbtn_left_up"] = + function () show_message(get_chapterlist(), 3) end + ne.eventresponder["mbtn_right_up"] = + function () show_message(get_chapterlist(), 3) end + + -- + update_tracklist() + + --cy_audio + ne = new_element("cy_audio", "button") + + ne.enabled = (#tracks_osc.audio > 0) + ne.content = function () + local aid = "–" + if get_track("audio") ~= 0 then + aid = get_track("audio") + end + return ("\238\132\134" .. osc_styles.smallButtonsLlabel + .. " " .. aid .. "/" .. #tracks_osc.audio) + end + ne.eventresponder["mbtn_left_up"] = + function () set_track("audio", 1) end + ne.eventresponder["mbtn_right_up"] = + function () set_track("audio", -1) end + ne.eventresponder["shift+mbtn_left_down"] = + function () show_message(get_tracklist("audio"), 2) end + ne.eventresponder["wheel_down_press"] = + function () set_track("audio", 1) end + ne.eventresponder["wheel_up_press"] = + function () set_track("audio", -1) end + + --cy_sub + ne = new_element("cy_sub", "button") + + ne.enabled = (#tracks_osc.sub > 0) + ne.content = function () + local sid = "–" + if get_track("sub") ~= 0 then + sid = get_track("sub") + end + return ("\238\132\135" .. osc_styles.smallButtonsLlabel + .. " " .. sid .. "/" .. #tracks_osc.sub) + end + ne.eventresponder["mbtn_left_up"] = + function () set_track("sub", 1) end + ne.eventresponder["mbtn_right_up"] = + function () set_track("sub", -1) end + ne.eventresponder["shift+mbtn_left_down"] = + function () show_message(get_tracklist("sub"), 2) end + ne.eventresponder["wheel_down_press"] = + function () set_track("sub", 1) end + ne.eventresponder["wheel_up_press"] = + function () set_track("sub", -1) end + + --tog_fs + ne = new_element("tog_fs", "button") + ne.content = function () + if state.fullscreen then + return ("\238\132\137") + else + return ("\238\132\136") + end + end + ne.eventresponder["mbtn_left_up"] = + function () mp.commandv("cycle", "fullscreen") end + + --seekbar + ne = new_element("seekbar", "slider") + + ne.enabled = mp.get_property("percent-pos") ~= nil + state.slider_element = ne.enabled and ne or nil -- used for forced_title + ne.slider.markerF = function () + local duration = mp.get_property_number("duration", nil) + if duration ~= nil then + local chapters = mp.get_property_native("chapter-list", {}) + local markers = {} + for n = 1, #chapters do + markers[n] = (chapters[n].time / duration * 100) + end + return markers + else + return {} + end + end + ne.slider.posF = + function () return mp.get_property_number("percent-pos", nil) end + ne.slider.tooltipF = function (pos) + local duration = mp.get_property_number("duration", nil) + if duration ~= nil and pos ~= nil then + possec = duration * (pos / 100) + return mp.format_time(possec) + else + return "" + end + end + ne.slider.seekRangesF = function() + if user_opts.seekrangestyle == "none" then + return nil + end + local cache_state = state.cache_state + if not cache_state then + return nil + end + local duration = mp.get_property_number("duration", nil) + if duration == nil or duration <= 0 then + return nil + end + local ranges = cache_state["seekable-ranges"] + if #ranges == 0 then + return nil + end + local nranges = {} + for _, range in pairs(ranges) do + nranges[#nranges + 1] = { + ["start"] = 100 * range["start"] / duration, + ["end"] = 100 * range["end"] / duration, + } + end + return nranges + end + ne.eventresponder["mouse_move"] = --keyframe seeking when mouse is dragged + function (element) + -- mouse move events may pile up during seeking and may still get + -- sent when the user is done seeking, so we need to throw away + -- identical seeks + local seekto = get_slider_value(element) + if element.state.lastseek == nil or + element.state.lastseek ~= seekto then + local flags = "absolute-percent" + if not user_opts.seekbarkeyframes then + flags = flags .. "+exact" + end + mp.commandv("seek", seekto, flags) + element.state.lastseek = seekto + end + + end + ne.eventresponder["mbtn_left_down"] = --exact seeks on single clicks + function (element) mp.commandv("seek", get_slider_value(element), + "absolute-percent", "exact") end + ne.eventresponder["reset"] = + function (element) element.state.lastseek = nil end + ne.eventresponder["wheel_up_press"] = + function () mp.commandv("osd-auto", "seek", 10) end + ne.eventresponder["wheel_down_press"] = + function () mp.commandv("osd-auto", "seek", -10) end + + + -- tc_left (current pos) + ne = new_element("tc_left", "button") + + ne.content = function () + if state.tc_ms then + return (mp.get_property_osd("playback-time/full")) + else + return (mp.get_property_osd("playback-time")) + end + end + ne.eventresponder["mbtn_left_up"] = function () + state.tc_ms = not state.tc_ms + request_init() + end + + -- tc_right (total/remaining time) + ne = new_element("tc_right", "button") + + ne.visible = (mp.get_property_number("duration", 0) > 0) + ne.content = function () + if state.rightTC_trem then + local minus = user_opts.unicodeminus and UNICODE_MINUS or "-" + local property = user_opts.remaining_playtime and "playtime-remaining" + or "time-remaining" + if state.tc_ms then + return (minus..mp.get_property_osd(property .. "/full")) + else + return (minus..mp.get_property_osd(property)) + end + else + if state.tc_ms then + return (mp.get_property_osd("duration/full")) + else + return (mp.get_property_osd("duration")) + end + end + end + ne.eventresponder["mbtn_left_up"] = + function () state.rightTC_trem = not state.rightTC_trem end + + -- cache + ne = new_element("cache", "button") + + ne.content = function () + local cache_state = state.cache_state + if not (cache_state and cache_state["seekable-ranges"] and + #cache_state["seekable-ranges"] > 0) then + -- probably not a network stream + return "" + end + local dmx_cache = cache_state and cache_state["cache-duration"] + local thresh = math.min(state.dmx_cache * 0.05, 5) -- 5% or 5s + if dmx_cache and math.abs(dmx_cache - state.dmx_cache) >= thresh then + state.dmx_cache = dmx_cache + else + dmx_cache = state.dmx_cache + end + local min = math.floor(dmx_cache / 60) + local sec = math.floor(dmx_cache % 60) -- don't round e.g. 59.9 to 60 + return "Cache: " .. (min > 0 and + string.format("%sm%02.0fs", min, sec) or + string.format("%3.0fs", sec)) + end + + -- volume + ne = new_element("volume", "button") + + ne.content = function() + local volume = mp.get_property_number("volume", 0) + local mute = mp.get_property_native("mute") + local volicon = {"\238\132\139", "\238\132\140", + "\238\132\141", "\238\132\142"} + if volume == 0 or mute then + return "\238\132\138" + else + return volicon[math.min(4,math.ceil(volume / (100/3)))] + end + end + ne.eventresponder["mbtn_left_up"] = + function () mp.commandv("cycle", "mute") end + + ne.eventresponder["wheel_up_press"] = + function () mp.commandv("osd-auto", "add", "volume", 5) end + ne.eventresponder["wheel_down_press"] = + function () mp.commandv("osd-auto", "add", "volume", -5) end + + + -- load layout + layouts[user_opts.layout]() + + -- load window controls + if window_controls_enabled() then + window_controls(user_opts.layout == "topbar") + end + + --do something with the elements + prepare_elements() + + update_margins() +end + +function reset_margins() + if state.using_video_margins then + for _, opt in ipairs(margins_opts) do + mp.set_property_number(opt[2], 0.0) + end + state.using_video_margins = false + end +end + +function update_margins() + local margins = osc_param.video_margins + + -- Don't use margins if it's visible only temporarily. + if not state.osc_visible or get_hidetimeout() >= 0 or + (state.fullscreen and not user_opts.showfullscreen) or + (not state.fullscreen and not user_opts.showwindowed) + then + margins = {l = 0, r = 0, t = 0, b = 0} + end + + if user_opts.boxvideo then + -- check whether any margin option has a non-default value + local margins_used = false + + if not state.using_video_margins then + for _, opt in ipairs(margins_opts) do + if mp.get_property_number(opt[2], 0.0) ~= 0.0 then + margins_used = true + end + end + end + + if not margins_used then + for _, opt in ipairs(margins_opts) do + local v = margins[opt[1]] + if v ~= 0 or state.using_video_margins then + mp.set_property_number(opt[2], v) + state.using_video_margins = true + end + end + end + else + reset_margins() + end + + mp.set_property_native("user-data/osc/margins", margins) +end + +function shutdown() + reset_margins() + mp.del_property("user-data/osc") +end + +-- +-- Other important stuff +-- + + +function show_osc() + -- show when disabled can happen (e.g. mouse_move) due to async/delayed unbinding + if not state.enabled then return end + + msg.trace("show_osc") + --remember last time of invocation (mouse move) + state.showtime = mp.get_time() + + osc_visible(true) + + if user_opts.fadeduration > 0 then + state.anitype = nil + end +end + +function hide_osc() + msg.trace("hide_osc") + if not state.enabled then + -- typically hide happens at render() from tick(), but now tick() is + -- no-op and won't render again to remove the osc, so do that manually. + state.osc_visible = false + render_wipe() + elseif user_opts.fadeduration > 0 then + if state.osc_visible then + state.anitype = "out" + request_tick() + end + else + osc_visible(false) + end +end + +function osc_visible(visible) + if state.osc_visible ~= visible then + state.osc_visible = visible + update_margins() + end + request_tick() +end + +function pause_state(name, enabled) + state.paused = enabled + request_tick() +end + +function cache_state(name, st) + state.cache_state = st + request_tick() +end + +-- Request that tick() is called (which typically re-renders the OSC). +-- The tick is then either executed immediately, or rate-limited if it was +-- called a small time ago. +function request_tick() + if state.tick_timer == nil then + state.tick_timer = mp.add_timeout(0, tick) + end + + if not state.tick_timer:is_enabled() then + local now = mp.get_time() + local timeout = tick_delay - (now - state.tick_last_time) + if timeout < 0 then + timeout = 0 + end + state.tick_timer.timeout = timeout + state.tick_timer:resume() + end +end + +function mouse_leave() + if get_hidetimeout() >= 0 then + hide_osc() + end + -- reset mouse position + state.last_mouseX, state.last_mouseY = nil, nil + state.mouse_in_window = false +end + +function request_init() + state.initREQ = true + request_tick() +end + +-- Like request_init(), but also request an immediate update +function request_init_resize() + request_init() + -- ensure immediate update + state.tick_timer:kill() + state.tick_timer.timeout = 0 + state.tick_timer:resume() +end + +function render_wipe() + msg.trace("render_wipe()") + state.osd.data = "" -- allows set_osd to immediately update on enable + state.osd:remove() +end + +function render() + msg.trace("rendering") + local current_screen_sizeX, current_screen_sizeY, aspect = mp.get_osd_size() + local mouseX, mouseY = get_virt_mouse_pos() + local now = mp.get_time() + + -- check if display changed, if so request reinit + if state.mp_screen_sizeX ~= current_screen_sizeX + or state.mp_screen_sizeY ~= current_screen_sizeY then + + request_init_resize() + + state.mp_screen_sizeX = current_screen_sizeX + state.mp_screen_sizeY = current_screen_sizeY + end + + -- init management + if state.active_element then + -- mouse is held down on some element - keep ticking and ignore initReq + -- till it's released, or else the mouse-up (click) will misbehave or + -- get ignored. that's because osc_init() recreates the osc elements, + -- but mouse handling depends on the elements staying unmodified + -- between mouse-down and mouse-up (using the index active_element). + request_tick() + elseif state.initREQ then + osc_init() + state.initREQ = false + + -- store initial mouse position + if (state.last_mouseX == nil or state.last_mouseY == nil) + and not (mouseX == nil or mouseY == nil) then + + state.last_mouseX, state.last_mouseY = mouseX, mouseY + end + end + + + -- fade animation + if state.anitype ~= nil then + + if state.anistart == nil then + state.anistart = now + end + + if now < state.anistart + (user_opts.fadeduration / 1000) then + + if state.anitype == "in" then --fade in + osc_visible(true) + state.animation = scale_value(state.anistart, + (state.anistart + (user_opts.fadeduration / 1000)), + 255, 0, now) + elseif state.anitype == "out" then --fade out + state.animation = scale_value(state.anistart, + (state.anistart + (user_opts.fadeduration / 1000)), + 0, 255, now) + end + + else + if state.anitype == "out" then + osc_visible(false) + end + kill_animation() + end + else + kill_animation() + end + + --mouse show/hide area + for k,cords in pairs(osc_param.areas["showhide"]) do + set_virt_mouse_area(cords.x1, cords.y1, cords.x2, cords.y2, "showhide") + end + if osc_param.areas["showhide_wc"] then + for k,cords in pairs(osc_param.areas["showhide_wc"]) do + set_virt_mouse_area(cords.x1, cords.y1, cords.x2, cords.y2, "showhide_wc") + end + else + set_virt_mouse_area(0, 0, 0, 0, "showhide_wc") + end + do_enable_keybindings() + + --mouse input area + local mouse_over_osc = false + + for _,cords in ipairs(osc_param.areas["input"]) do + if state.osc_visible then -- activate only when OSC is actually visible + set_virt_mouse_area(cords.x1, cords.y1, cords.x2, cords.y2, "input") + end + if state.osc_visible ~= state.input_enabled then + if state.osc_visible then + mp.enable_key_bindings("input") + else + mp.disable_key_bindings("input") + end + state.input_enabled = state.osc_visible + end + + if mouse_hit_coords(cords.x1, cords.y1, cords.x2, cords.y2) then + mouse_over_osc = true + end + end + + if osc_param.areas["window-controls"] then + for _,cords in ipairs(osc_param.areas["window-controls"]) do + if state.osc_visible then -- activate only when OSC is actually visible + set_virt_mouse_area(cords.x1, cords.y1, cords.x2, cords.y2, "window-controls") + end + if state.osc_visible ~= state.windowcontrols_buttons then + if state.osc_visible then + mp.enable_key_bindings("window-controls") + else + mp.disable_key_bindings("window-controls") + end + state.windowcontrols_buttons = state.osc_visible + end + + if mouse_hit_coords(cords.x1, cords.y1, cords.x2, cords.y2) then + mouse_over_osc = true + end + end + end + + if osc_param.areas["window-controls-title"] then + for _,cords in ipairs(osc_param.areas["window-controls-title"]) do + if mouse_hit_coords(cords.x1, cords.y1, cords.x2, cords.y2) then + mouse_over_osc = true + end + end + end + + -- autohide + if state.showtime ~= nil and get_hidetimeout() >= 0 then + local timeout = state.showtime + (get_hidetimeout() / 1000) - now + if timeout <= 0 then + if state.active_element == nil and not mouse_over_osc then + hide_osc() + end + else + -- the timer is only used to recheck the state and to possibly run + -- the code above again + if not state.hide_timer then + state.hide_timer = mp.add_timeout(0, tick) + end + state.hide_timer.timeout = timeout + -- re-arm + state.hide_timer:kill() + state.hide_timer:resume() + end + end + + + -- actual rendering + local ass = assdraw.ass_new() + + -- Messages + render_message(ass) + + -- actual OSC + if state.osc_visible then + render_elements(ass) + end + + -- submit + set_osd(osc_param.playresy * osc_param.display_aspect, + osc_param.playresy, ass.text, 1000) +end + +-- +-- Eventhandling +-- + +local function element_has_action(element, action) + return element and element.eventresponder and + element.eventresponder[action] +end + +function process_event(source, what) + local action = string.format("%s%s", source, + what and ("_" .. what) or "") + + if what == "down" or what == "press" then + + for n = 1, #elements do + + if mouse_hit(elements[n]) and + elements[n].eventresponder and + (elements[n].eventresponder[source .. "_up"] or + elements[n].eventresponder[action]) then + + if what == "down" then + state.active_element = n + state.active_event_source = source + end + -- fire the down or press event if the element has one + if element_has_action(elements[n], action) then + elements[n].eventresponder[action](elements[n]) + end + + end + end + + elseif what == "up" then + + if elements[state.active_element] then + local n = state.active_element + + if n == 0 then + --click on background (does not work) + elseif element_has_action(elements[n], action) and + mouse_hit(elements[n]) then + + elements[n].eventresponder[action](elements[n]) + end + + --reset active element + if element_has_action(elements[n], "reset") then + elements[n].eventresponder["reset"](elements[n]) + end + + end + state.active_element = nil + state.mouse_down_counter = 0 + + elseif source == "mouse_move" then + + state.mouse_in_window = true + + local mouseX, mouseY = get_virt_mouse_pos() + if user_opts.minmousemove == 0 or + ((state.last_mouseX ~= nil and state.last_mouseY ~= nil) and + ((math.abs(mouseX - state.last_mouseX) >= user_opts.minmousemove) + or (math.abs(mouseY - state.last_mouseY) >= user_opts.minmousemove) + ) + ) then + show_osc() + end + state.last_mouseX, state.last_mouseY = mouseX, mouseY + + local n = state.active_element + if element_has_action(elements[n], action) then + elements[n].eventresponder[action](elements[n]) + end + end + + -- ensure rendering after any (mouse) event - icons could change etc + request_tick() +end + + +local logo_lines = { + -- White border + "{\\c&HE5E5E5&\\p6}m 895 10 b 401 10 0 410 0 905 0 1399 401 1800 895 1800 1390 1800 1790 1399 1790 905 1790 410 1390 10 895 10 {\\p0}", + -- Purple fill + "{\\c&H682167&\\p6}m 925 42 b 463 42 87 418 87 880 87 1343 463 1718 925 1718 1388 1718 1763 1343 1763 880 1763 418 1388 42 925 42{\\p0}", + -- Darker fill + "{\\c&H430142&\\p6}m 1605 828 b 1605 1175 1324 1456 977 1456 631 1456 349 1175 349 828 349 482 631 200 977 200 1324 200 1605 482 1605 828{\\p0}", + -- White fill + "{\\c&HDDDBDD&\\p6}m 1296 910 b 1296 1131 1117 1310 897 1310 676 1310 497 1131 497 910 497 689 676 511 897 511 1117 511 1296 689 1296 910{\\p0}", + -- Triangle + "{\\c&H691F69&\\p6}m 762 1113 l 762 708 b 881 776 1000 843 1119 911 1000 978 881 1046 762 1113{\\p0}", +} + +local santa_hat_lines = { + -- Pompoms + "{\\c&HC0C0C0&\\p6}m 500 -323 b 491 -322 481 -318 475 -311 465 -312 456 -319 446 -318 434 -314 427 -304 417 -297 410 -290 404 -282 395 -278 390 -274 387 -267 381 -265 377 -261 379 -254 384 -253 397 -244 409 -232 425 -228 437 -228 446 -218 457 -217 462 -216 466 -213 468 -209 471 -205 477 -203 482 -206 491 -211 499 -217 508 -222 532 -235 556 -249 576 -267 584 -272 584 -284 578 -290 569 -305 550 -312 533 -309 523 -310 515 -316 507 -321 505 -323 503 -323 500 -323{\\p0}", + "{\\c&HE0E0E0&\\p6}m 315 -260 b 286 -258 259 -240 246 -215 235 -210 222 -215 211 -211 204 -188 177 -176 172 -151 170 -139 163 -128 154 -121 143 -103 141 -81 143 -60 139 -46 125 -34 129 -17 132 -1 134 16 142 30 145 56 161 80 181 96 196 114 210 133 231 144 266 153 303 138 328 115 373 79 401 28 423 -24 446 -73 465 -123 483 -174 487 -199 467 -225 442 -227 421 -232 402 -242 384 -254 364 -259 342 -250 322 -260 320 -260 317 -261 315 -260{\\p0}", + -- Main cap + "{\\c&H0000F0&\\p6}m 1151 -523 b 1016 -516 891 -458 769 -406 693 -369 624 -319 561 -262 526 -252 465 -235 479 -187 502 -147 551 -135 588 -111 1115 165 1379 232 1909 761 1926 800 1952 834 1987 858 2020 883 2053 912 2065 952 2088 1000 2146 962 2139 919 2162 836 2156 747 2143 662 2131 615 2116 567 2122 517 2120 410 2090 306 2089 199 2092 147 2071 99 2034 64 1987 5 1928 -41 1869 -86 1777 -157 1712 -256 1629 -337 1578 -389 1521 -436 1461 -476 1407 -509 1343 -507 1284 -515 1240 -519 1195 -521 1151 -523{\\p0}", + -- Cap shadow + "{\\c&H0000AA&\\p6}m 1657 248 b 1658 254 1659 261 1660 267 1669 276 1680 284 1689 293 1695 302 1700 311 1707 320 1716 325 1726 330 1735 335 1744 347 1752 360 1761 371 1753 352 1754 331 1753 311 1751 237 1751 163 1751 90 1752 64 1752 37 1767 14 1778 -3 1785 -24 1786 -45 1786 -60 1786 -77 1774 -87 1760 -96 1750 -78 1751 -65 1748 -37 1750 -8 1750 20 1734 78 1715 134 1699 192 1694 211 1689 231 1676 246 1671 251 1661 255 1657 248 m 1909 541 b 1914 542 1922 549 1917 539 1919 520 1921 502 1919 483 1918 458 1917 433 1915 407 1930 373 1942 338 1947 301 1952 270 1954 238 1951 207 1946 214 1947 229 1945 239 1939 278 1936 318 1924 356 1923 362 1913 382 1912 364 1906 301 1904 237 1891 175 1887 150 1892 126 1892 101 1892 68 1893 35 1888 2 1884 -9 1871 -20 1859 -14 1851 -6 1854 9 1854 20 1855 58 1864 95 1873 132 1883 179 1894 225 1899 273 1908 362 1910 451 1909 541{\\p0}", + -- Brim and tip pompom + "{\\c&HF8F8F8&\\p6}m 626 -191 b 565 -155 486 -196 428 -151 387 -115 327 -101 304 -47 273 2 267 59 249 113 219 157 217 213 215 265 217 309 260 302 285 283 373 264 465 264 555 257 608 252 655 292 709 287 759 294 816 276 863 298 903 340 972 324 1012 367 1061 394 1125 382 1167 424 1213 462 1268 482 1322 506 1385 546 1427 610 1479 662 1510 690 1534 725 1566 752 1611 796 1664 830 1703 880 1740 918 1747 986 1805 1005 1863 991 1897 932 1916 880 1914 823 1945 777 1961 725 1979 673 1957 622 1938 575 1912 534 1862 515 1836 473 1790 417 1755 351 1697 305 1658 266 1633 216 1593 176 1574 138 1539 116 1497 110 1448 101 1402 77 1371 37 1346 -16 1295 15 1254 6 1211 -27 1170 -62 1121 -86 1072 -104 1027 -128 976 -133 914 -130 851 -137 794 -162 740 -181 679 -168 626 -191 m 2051 917 b 1971 932 1929 1017 1919 1091 1912 1149 1923 1214 1970 1254 2000 1279 2027 1314 2066 1325 2139 1338 2212 1295 2254 1238 2281 1203 2287 1158 2282 1116 2292 1061 2273 1006 2229 970 2206 941 2167 938 2138 918{\\p0}", +} + +-- called by mpv on every frame +function tick() + if state.marginsREQ == true then + update_margins() + state.marginsREQ = false + end + + if not state.enabled then return end + + if state.idle then + + -- render idle message + msg.trace("idle message") + local _, _, display_aspect = mp.get_osd_size() + if display_aspect == 0 then + return + end + local display_h = 360 + local display_w = display_h * display_aspect + -- logo is rendered at 2^(6-1) = 32 times resolution with size 1800x1800 + local icon_x, icon_y = (display_w - 1800 / 32) / 2, 140 + local line_prefix = ("{\\rDefault\\an7\\1a&H00&\\bord0\\shad0\\pos(%f,%f)}"):format(icon_x, icon_y) + + local ass = assdraw.ass_new() + -- mpv logo + if user_opts.idlescreen then + for i, line in ipairs(logo_lines) do + ass:new_event() + ass:append(line_prefix .. line) + end + end + + -- Santa hat + if is_december and user_opts.idlescreen and not user_opts.greenandgrumpy then + for i, line in ipairs(santa_hat_lines) do + ass:new_event() + ass:append(line_prefix .. line) + end + end + + if user_opts.idlescreen then + ass:new_event() + ass:pos(display_w / 2, icon_y + 65) + ass:an(8) + ass:append("Drop files or URLs to play here.") + end + set_osd(display_w, display_h, ass.text, -1000) + + if state.showhide_enabled then + mp.disable_key_bindings("showhide") + mp.disable_key_bindings("showhide_wc") + state.showhide_enabled = false + end + + + elseif state.fullscreen and user_opts.showfullscreen + or (not state.fullscreen and user_opts.showwindowed) then + + -- render the OSC + render() + else + -- Flush OSD + render_wipe() + end + + state.tick_last_time = mp.get_time() + + if state.anitype ~= nil then + -- state.anistart can be nil - animation should now start, or it can + -- be a timestamp when it started. state.idle has no animation. + if not state.idle and + (not state.anistart or + mp.get_time() < 1 + state.anistart + user_opts.fadeduration/1000) + then + -- animating or starting, or still within 1s past the deadline + request_tick() + else + kill_animation() + end + end +end + +function do_enable_keybindings() + if state.enabled then + if not state.showhide_enabled then + mp.enable_key_bindings("showhide", "allow-vo-dragging+allow-hide-cursor") + mp.enable_key_bindings("showhide_wc", "allow-vo-dragging+allow-hide-cursor") + end + state.showhide_enabled = true + end +end + +function enable_osc(enable) + state.enabled = enable + if enable then + do_enable_keybindings() + else + hide_osc() -- acts immediately when state.enabled == false + if state.showhide_enabled then + mp.disable_key_bindings("showhide") + mp.disable_key_bindings("showhide_wc") + end + state.showhide_enabled = false + end +end + +-- duration is observed for the sole purpose of updating chapter markers +-- positions. live streams with chapters are very rare, and the update is also +-- expensive (with request_init), so it's only observed when we have chapters +-- and the user didn't disable the livemarkers option (update_duration_watch). +function on_duration() request_init() end + +local duration_watched = false +function update_duration_watch() + local want_watch = user_opts.livemarkers and + (mp.get_property_number("chapters", 0) or 0) > 0 and + true or false -- ensure it's a boolean + + if want_watch ~= duration_watched then + if want_watch then + mp.observe_property("duration", nil, on_duration) + else + mp.unobserve_property(on_duration) + end + duration_watched = want_watch + end +end + +validate_user_opts() +update_duration_watch() + +mp.register_event("shutdown", shutdown) +mp.register_event("start-file", request_init) +mp.observe_property("track-list", nil, request_init) +mp.observe_property("playlist", nil, request_init) +mp.observe_property("chapter-list", "native", function(_, list) + list = list or {} -- safety, shouldn't return nil + table.sort(list, function(a, b) return a.time < b.time end) + state.chapter_list = list + update_duration_watch() + request_init() +end) + +mp.register_script_message("osc-message", show_message) +mp.register_script_message("osc-chapterlist", function(dur) + show_message(get_chapterlist(), dur) +end) +mp.register_script_message("osc-playlist", function(dur) + show_message(get_playlist(), dur) +end) +mp.register_script_message("osc-tracklist", function(dur) + local msg = {} + for k,v in pairs(nicetypes) do + table.insert(msg, get_tracklist(k)) + end + show_message(table.concat(msg, '\n\n'), dur) +end) + +mp.observe_property("fullscreen", "bool", + function(name, val) + state.fullscreen = val + state.marginsREQ = true + request_init_resize() + end +) +mp.observe_property("border", "bool", + function(name, val) + state.border = val + request_init_resize() + end +) +mp.observe_property("window-maximized", "bool", + function(name, val) + state.maximized = val + request_init_resize() + end +) +mp.observe_property("idle-active", "bool", + function(name, val) + state.idle = val + request_tick() + end +) +mp.observe_property("pause", "bool", pause_state) +mp.observe_property("demuxer-cache-state", "native", cache_state) +mp.observe_property("vo-configured", "bool", function(name, val) + request_tick() +end) +mp.observe_property("playback-time", "number", function(name, val) + request_tick() +end) +mp.observe_property("osd-dimensions", "native", function(name, val) + -- (we could use the value instead of re-querying it all the time, but then + -- we might have to worry about property update ordering) + request_init_resize() +end) + +-- mouse show/hide bindings +mp.set_key_bindings({ + {"mouse_move", function(e) process_event("mouse_move", nil) end}, + {"mouse_leave", mouse_leave}, +}, "showhide", "force") +mp.set_key_bindings({ + {"mouse_move", function(e) process_event("mouse_move", nil) end}, + {"mouse_leave", mouse_leave}, +}, "showhide_wc", "force") +do_enable_keybindings() + +--mouse input bindings +mp.set_key_bindings({ + {"mbtn_left", function(e) process_event("mbtn_left", "up") end, + function(e) process_event("mbtn_left", "down") end}, + {"shift+mbtn_left", function(e) process_event("shift+mbtn_left", "up") end, + function(e) process_event("shift+mbtn_left", "down") end}, + {"mbtn_right", function(e) process_event("mbtn_right", "up") end, + function(e) process_event("mbtn_right", "down") end}, + -- alias to shift_mbtn_left for single-handed mouse use + {"mbtn_mid", function(e) process_event("shift+mbtn_left", "up") end, + function(e) process_event("shift+mbtn_left", "down") end}, + {"wheel_up", function(e) process_event("wheel_up", "press") end}, + {"wheel_down", function(e) process_event("wheel_down", "press") end}, + {"mbtn_left_dbl", "ignore"}, + {"shift+mbtn_left_dbl", "ignore"}, + {"mbtn_right_dbl", "ignore"}, +}, "input", "force") +mp.enable_key_bindings("input") + +mp.set_key_bindings({ + {"mbtn_left", function(e) process_event("mbtn_left", "up") end, + function(e) process_event("mbtn_left", "down") end}, +}, "window-controls", "force") +mp.enable_key_bindings("window-controls") + +function get_hidetimeout() + if user_opts.visibility == "always" then + return -1 -- disable autohide + end + return user_opts.hidetimeout +end + +function always_on(val) + if state.enabled then + if val then + show_osc() + else + hide_osc() + end + end +end + +-- mode can be auto/always/never/cycle +-- the modes only affect internal variables and not stored on its own. +function visibility_mode(mode, no_osd) + if mode == "cycle" then + if not state.enabled then + mode = "auto" + elseif user_opts.visibility ~= "always" then + mode = "always" + else + mode = "never" + end + end + + if mode == "auto" then + always_on(false) + enable_osc(true) + elseif mode == "always" then + enable_osc(true) + always_on(true) + elseif mode == "never" then + enable_osc(false) + else + msg.warn("Ignoring unknown visibility mode '" .. mode .. "'") + return + end + + user_opts.visibility = mode + mp.set_property_native("user-data/osc/visibility", mode) + + if not no_osd and tonumber(mp.get_property("osd-level")) >= 1 then + mp.osd_message("OSC visibility: " .. mode) + end + + -- Reset the input state on a mode change. The input state will be + -- recalculated on the next render cycle, except in 'never' mode where it + -- will just stay disabled. + mp.disable_key_bindings("input") + mp.disable_key_bindings("window-controls") + state.input_enabled = false + + update_margins() + request_tick() +end + +function idlescreen_visibility(mode, no_osd) + if mode == "cycle" then + if user_opts.idlescreen then + mode = "no" + else + mode = "yes" + end + end + + if mode == "yes" then + user_opts.idlescreen = true + else + user_opts.idlescreen = false + end + + mp.set_property_native("user-data/osc/idlescreen", user_opts.idlescreen) + + if not no_osd and tonumber(mp.get_property("osd-level")) >= 1 then + mp.osd_message("OSC logo visibility: " .. tostring(mode)) + end + + request_tick() +end + +visibility_mode(user_opts.visibility, true) +mp.register_script_message("osc-visibility", visibility_mode) +mp.add_key_binding(nil, "visibility", function() visibility_mode("cycle") end) + +mp.register_script_message("osc-idlescreen", idlescreen_visibility) + +set_virt_mouse_area(0, 0, 0, 0, "input") +set_virt_mouse_area(0, 0, 0, 0, "window-controls") diff --git a/player/lua/stats.lua b/player/lua/stats.lua new file mode 100644 index 0000000..16e8b68 --- /dev/null +++ b/player/lua/stats.lua @@ -0,0 +1,1417 @@ +-- Display some stats. +-- +-- Please consult the readme for information about usage and configuration: +-- https://github.com/Argon-/mpv-stats +-- +-- Please note: not every property is always available and therefore not always +-- visible. + +local mp = require 'mp' +local options = require 'mp.options' +local utils = require 'mp.utils' + +-- Options +local o = { + -- Default key bindings + key_page_1 = "1", + key_page_2 = "2", + key_page_3 = "3", + key_page_4 = "4", + key_page_0 = "0", + -- For pages which support scrolling + key_scroll_up = "UP", + key_scroll_down = "DOWN", + scroll_lines = 1, + + duration = 4, + redraw_delay = 1, -- acts as duration in the toggling case + ass_formatting = true, + persistent_overlay = false, -- whether the stats can be overwritten by other output + print_perfdata_passes = false, -- when true, print the full information about all passes + filter_params_max_length = 100, -- a filter list longer than this many characters will be shown one filter per line instead + show_frame_info = false, -- whether to show the current frame info + debug = false, + + -- Graph options and style + plot_perfdata = true, + plot_vsync_ratio = true, + plot_vsync_jitter = true, + plot_tonemapping_lut = false, + skip_frames = 5, + global_max = true, + flush_graph_data = true, -- clear data buffers when toggling + plot_bg_border_color = "0000FF", + plot_bg_color = "262626", + plot_color = "FFFFFF", + + -- Text style + font = "sans-serif", + font_mono = "monospace", -- monospaced digits are sufficient + font_size = 8, + font_color = "FFFFFF", + border_size = 0.8, + border_color = "262626", + shadow_x_offset = 0.0, + shadow_y_offset = 0.0, + shadow_color = "000000", + alpha = "11", + + -- Custom header for ASS tags to style the text output. + -- Specifying this will ignore the text style values above and just + -- use this string instead. + custom_header = "", + + -- Text formatting + -- With ASS + ass_nl = "\\N", + ass_indent = "\\h\\h\\h\\h\\h", + ass_prefix_sep = "\\h\\h", + ass_b1 = "{\\b1}", + ass_b0 = "{\\b0}", + ass_it1 = "{\\i1}", + ass_it0 = "{\\i0}", + -- Without ASS + no_ass_nl = "\n", + no_ass_indent = "\t", + no_ass_prefix_sep = " ", + no_ass_b1 = "\027[1m", + no_ass_b0 = "\027[0m", + no_ass_it1 = "\027[3m", + no_ass_it0 = "\027[0m", + + bindlist = "no", -- print page 4 to the terminal on startup and quit mpv +} +options.read_options(o) + +local format = string.format +local max = math.max +local min = math.min + +-- Function used to record performance data +local recorder = nil +-- Timer used for redrawing (toggling) and clearing the screen (oneshot) +local display_timer = nil +-- Timer used to update cache stats. +local cache_recorder_timer = nil +-- Current page and <page key>:<page function> mappings +local curr_page = o.key_page_1 +local pages = {} +local scroll_bound = false +local tm_viz_prev = nil +-- Save these sequences locally as we'll need them a lot +local ass_start = mp.get_property_osd("osd-ass-cc/0") +local ass_stop = mp.get_property_osd("osd-ass-cc/1") +-- Ring buffers for the values used to construct a graph. +-- .pos denotes the current position, .len the buffer length +-- .max is the max value in the corresponding buffer +local vsratio_buf, vsjitter_buf +local function init_buffers() + vsratio_buf = {0, pos = 1, len = 50, max = 0} + vsjitter_buf = {0, pos = 1, len = 50, max = 0} +end +local cache_ahead_buf, cache_speed_buf +local perf_buffers = {} + +local function graph_add_value(graph, value) + graph.pos = (graph.pos % graph.len) + 1 + graph[graph.pos] = value + graph.max = max(graph.max, value) +end + +-- "\\<U+2060>" in UTF-8 (U+2060 is WORD-JOINER) +local ESC_BACKSLASH = "\\" .. string.char(0xE2, 0x81, 0xA0) + +local function no_ASS(t) + if not o.use_ass then + return t + elseif not o.persistent_overlay then + -- mp.osd_message supports ass-escape using osd-ass-cc/{0|1} + return ass_stop .. t .. ass_start + else + -- mp.set_osd_ass doesn't support ass-escape. roll our own. + -- similar to mpv's sub/osd_libass.c:mangle_ass(...), excluding + -- space after newlines because no_ASS is not used with multi-line. + -- space at the beginning is replaced with "\\h" because it matters + -- at the beginning of a line, and we can't know where our output + -- ends up. no issue if it ends up at the middle of a line. + return tostring(t) + :gsub("\\", ESC_BACKSLASH) + :gsub("{", "\\{") + :gsub("^ ", "\\h") + end +end + + +local function b(t) + return o.b1 .. t .. o.b0 +end + + +local function it(t) + return o.it1 .. t .. o.it0 +end + + +local function text_style() + if not o.use_ass then + return "" + end + if o.custom_header and o.custom_header ~= "" then + return o.custom_header + else + local has_shadow = mp.get_property('osd-back-color'):sub(2, 3) == '00' + return format("{\\r\\an7\\fs%d\\fn%s\\bord%f\\3c&H%s&" .. + "\\1c&H%s&\\1a&H%s&\\3a&H%s&" .. + (has_shadow and "\\4a&H%s&\\xshad%f\\yshad%f\\4c&H%s&}" or "}"), + o.font_size, o.font, o.border_size, + o.border_color, o.font_color, o.alpha, o.alpha, o.alpha, + o.shadow_x_offset, o.shadow_y_offset, o.shadow_color) + end +end + + +local function has_vo_window() + return mp.get_property_native("vo-configured") and mp.get_property_native("video-osd") +end + + +-- Generate a graph from the given values. +-- Returns an ASS formatted vector drawing as string. +-- +-- values: Array/table of numbers representing the data. Used like a ring buffer +-- it will get iterated backwards `len` times starting at position `i`. +-- i : Index of the latest data value in `values`. +-- len : The length/amount of numbers in `values`. +-- v_max : The maximum number in `values`. It is used to scale all data +-- values to a range of 0 to `v_max`. +-- v_avg : The average number in `values`. It is used to try and center graphs +-- if possible. May be left as nil +-- scale : A value that will be multiplied with all data values. +-- x_tics: Horizontal width multiplier for the steps +local function generate_graph(values, i, len, v_max, v_avg, scale, x_tics) + -- Check if at least one value exists + if not values[i] then + return "" + end + + local x_max = (len - 1) * x_tics + local y_offset = o.border_size + local y_max = o.font_size * 0.66 + local x = 0 + + if v_max > 0 then + -- try and center the graph if possible, but avoid going above `scale` + if v_avg and v_avg > 0 then + scale = min(scale, v_max / (2 * v_avg)) + end + scale = scale * y_max / v_max + end -- else if v_max==0 then all values are 0 and scale doesn't matter + + local s = {format("m 0 0 n %f %f l ", x, y_max - scale * values[i])} + i = ((i - 2) % len) + 1 + + for p = 1, len - 1 do + if values[i] then + x = x - x_tics + s[#s+1] = format("%f %f ", x, y_max - scale * values[i]) + end + i = ((i - 2) % len) + 1 + end + + s[#s+1] = format("%f %f %f %f", x, y_max, 0, y_max) + + local bg_box = format("{\\bord0.5}{\\3c&H%s&}{\\1c&H%s&}m 0 %f l %f %f %f 0 0 0", + o.plot_bg_border_color, o.plot_bg_color, y_max, x_max, y_max, x_max) + return format("%s{\\r}{\\pbo%f}{\\shad0}{\\alpha&H00}{\\p1}%s{\\p0}{\\bord0}{\\1c&H%s}{\\p1}%s{\\p0}%s", + o.prefix_sep, y_offset, bg_box, o.plot_color, table.concat(s), text_style()) +end + + +local function append(s, str, attr) + if not str then + return false + end + attr.prefix_sep = attr.prefix_sep or o.prefix_sep + attr.indent = attr.indent or o.indent + attr.nl = attr.nl or o.nl + attr.suffix = attr.suffix or "" + attr.prefix = attr.prefix or "" + attr.no_prefix_markup = attr.no_prefix_markup or false + attr.prefix = attr.no_prefix_markup and attr.prefix or b(attr.prefix) + s[#s+1] = format("%s%s%s%s%s%s", attr.nl, attr.indent, + attr.prefix, attr.prefix_sep, no_ASS(str), attr.suffix) + return true +end + + +-- Format and append a property. +-- A property whose value is either `nil` or empty (hereafter called "invalid") +-- is skipped and not appended. +-- Returns `false` in case nothing was appended, otherwise `true`. +-- +-- s : Table containing strings. +-- prop : The property to query and format (based on its OSD representation). +-- attr : Optional table to overwrite certain (formatting) attributes for +-- this property. +-- exclude: Optional table containing keys which are considered invalid values +-- for this property. Specifying this will replace empty string as +-- default invalid value (nil is always invalid). +local function append_property(s, prop, attr, excluded) + excluded = excluded or {[""] = true} + local ret = mp.get_property_osd(prop) + if not ret or excluded[ret] then + if o.debug then + print("No value for property: " .. prop) + end + return false + end + return append(s, ret, attr) +end + +local function sorted_keys(t, comp_fn) + local keys = {} + for k,_ in pairs(t) do + keys[#keys+1] = k + end + table.sort(keys, comp_fn) + return keys +end + +local function append_perfdata(s, dedicated_page, print_passes) + local vo_p = mp.get_property_native("vo-passes") + if not vo_p then + return + end + + local ds = mp.get_property_bool("display-sync-active", false) + local target_fps = ds and mp.get_property_number("display-fps", 0) + or mp.get_property_number("container-fps", 0) + if target_fps > 0 then target_fps = 1 / target_fps * 1e9 end + + -- Sums of all last/avg/peak values + local last_s, avg_s, peak_s = {}, {}, {} + for frame, data in pairs(vo_p) do + last_s[frame], avg_s[frame], peak_s[frame] = 0, 0, 0 + for _, pass in ipairs(data) do + last_s[frame] = last_s[frame] + pass["last"] + avg_s[frame] = avg_s[frame] + pass["avg"] + peak_s[frame] = peak_s[frame] + pass["peak"] + end + end + + -- Pretty print measured time + local function pp(i) + -- rescale to microseconds for a saner display + return format("%5d", i / 1000) + end + + -- Format n/m with a font weight based on the ratio + local function p(n, m) + local i = 0 + if m > 0 then + i = tonumber(n) / m + end + -- Calculate font weight. 100 is minimum, 400 is normal, 700 bold, 900 is max + local w = (700 * math.sqrt(i)) + 200 + return format("{\\b%d}%2d%%{\\b0}", w, i * 100) + end + + -- ensure that the fixed title is one element and every scrollable line is + -- also one single element. + s[#s+1] = format("%s%s%s%s{\\fs%s}%s%s{\\fs%s}", + dedicated_page and "" or o.nl, dedicated_page and "" or o.indent, + b("Frame Timings:"), o.prefix_sep, o.font_size * 0.66, + "(last/average/peak μs)", + dedicated_page and " (hint: scroll with ↑↓)" or "", o.font_size) + + for _,frame in ipairs(sorted_keys(vo_p)) do -- ensure fixed display order + local data = vo_p[frame] + local f = "%s%s%s{\\fn%s}%s / %s / %s %s%s{\\fn%s}%s%s%s" + + if print_passes then + s[#s+1] = format("%s%s%s:", o.nl, o.indent, + b(frame:gsub("^%l", string.upper))) + + for _, pass in ipairs(data) do + s[#s+1] = format(f, o.nl, o.indent, o.indent, + o.font_mono, pp(pass["last"]), + pp(pass["avg"]), pp(pass["peak"]), + o.prefix_sep .. "\\h\\h", p(pass["last"], last_s[frame]), + o.font, o.prefix_sep, o.prefix_sep, pass["desc"]) + + if o.plot_perfdata and o.use_ass then + -- use the same line that was already started for this iteration + s[#s] = s[#s] .. + generate_graph(pass["samples"], pass["count"], + pass["count"], pass["peak"], + pass["avg"], 0.9, 0.25) + end + end + + -- Print sum of timing values as "Total" + s[#s+1] = format(f, o.nl, o.indent, o.indent, + o.font_mono, pp(last_s[frame]), + pp(avg_s[frame]), pp(peak_s[frame]), + o.prefix_sep, b("Total"), o.font, "", "", "") + else + -- for the simplified view, we just print the sum of each pass + s[#s+1] = format(f, o.nl, o.indent, o.indent, o.font_mono, + pp(last_s[frame]), pp(avg_s[frame]), pp(peak_s[frame]), + "", "", o.font, o.prefix_sep, o.prefix_sep, + frame:gsub("^%l", string.upper)) + end + end +end + +local function ellipsis(s, maxlen) + if not maxlen or s:len() <= maxlen then return s end + return s:sub(1, maxlen - 3) .. "..." +end + +-- command prefix tokens to strip - includes generic property commands +local cmd_prefixes = { + osd_auto=1, no_osd=1, osd_bar=1, osd_msg=1, osd_msg_bar=1, raw=1, sync=1, + async=1, expand_properties=1, repeatable=1, set=1, add=1, multiply=1, + toggle=1, cycle=1, cycle_values=1, ["!reverse"]=1, change_list=1, +} +-- commands/writable-properties prefix sub-words (followed by -) to strip +local name_prefixes = { + define=1, delete=1, enable=1, disable=1, dump=1, write=1, drop=1, revert=1, + ab=1, hr=1, secondary=1, current=1, +} +-- extract a command "subject" from a command string, by removing all +-- generic prefix tokens and then returning the first interesting sub-word +-- of the next token. For target-script name we also check another token. +-- The tokenizer works fine for things we care about - valid mpv commands, +-- properties and script names, possibly quoted, white-space[s]-separated. +-- It's decent in practice, and worst case is "incorrect" subject. +local function cmd_subject(cmd) + cmd = cmd:gsub(";.*", ""):gsub("%-", "_") -- only first cmd, s/-/_/ + local TOKEN = '^%s*["\']?([%w_!]*)' -- captures+ends before (maybe) final " + local tok, sname, subw + + repeat tok, cmd = cmd:match(TOKEN .. '["\']?(.*)') + until not cmd_prefixes[tok] + -- tok is the 1st non-generic command/property name token, cmd is the rest + + sname = tok == "script_message_to" and cmd:match(TOKEN) + or tok == "script_binding" and cmd:match(TOKEN .. "/") + if sname and sname ~= "" then + return "script: " .. sname + end + + -- return the first sub-word of tok which is not a useless prefix + repeat subw, tok = tok:match("([^_]*)_?(.*)") + until tok == "" or not name_prefixes[subw] + return subw:len() > 1 and subw or "[unknown]" +end + +-- key names are valid UTF-8, ascii7 except maybe the last/only codepoint. +-- we count codepoints and ignore wcwidth. no need for grapheme clusters. +-- our error for alignment is at most one cell (if last CP is double-width). +-- (if k was valid but arbitrary: we'd count all bytes <0x80 or >=0xc0) +local function keyname_cells(k) + local klen = k:len() + if klen > 1 and k:byte(klen) >= 0x80 then -- last/only CP is not ascii7 + repeat klen = klen-1 + until klen == 1 or k:byte(klen) >= 0xc0 -- last CP begins at klen + end + return klen +end + +local function get_kbinfo_lines(width) + -- active keys: only highest priority of each key, and not our (stats) keys + local bindings = mp.get_property_native("input-bindings", {}) + local active = {} -- map: key-name -> bind-info + for _, bind in pairs(bindings) do + if bind.priority >= 0 and ( + not active[bind.key] or + (active[bind.key].is_weak and not bind.is_weak) or + (bind.is_weak == active[bind.key].is_weak and + bind.priority > active[bind.key].priority) + ) and not bind.cmd:find("script-binding stats/__forced_", 1, true) + then + active[bind.key] = bind + end + end + + -- make an array, find max key len, add sort keys (.subject/.mods[_count]) + local ordered = {} + local kspaces = "" -- as many spaces as the longest key name + for _, bind in pairs(active) do + bind.subject = cmd_subject(bind.cmd) + if bind.subject ~= "ignore" then + ordered[#ordered+1] = bind + _,_, bind.mods = bind.key:find("(.*)%+.") + _, bind.mods_count = bind.key:gsub("%+.", "") + if bind.key:len() > kspaces:len() then + kspaces = string.rep(" ", bind.key:len()) + end + end + end + + local function align_right(key) + return kspaces:sub(keyname_cells(key)) .. key + end + + -- sort by: subject, mod(ifier)s count, mods, key-len, lowercase-key, key + table.sort(ordered, function(a, b) + if a.subject ~= b.subject then + return a.subject < b.subject + elseif a.mods_count ~= b.mods_count then + return a.mods_count < b.mods_count + elseif a.mods ~= b.mods then + return a.mods < b.mods + elseif a.key:len() ~= b.key:len() then + return a.key:len() < b.key:len() + elseif a.key:lower() ~= b.key:lower() then + return a.key:lower() < b.key:lower() + else + return a.key > b.key -- only case differs, lowercase first + end + end) + + -- key/subject pre/post formatting for terminal/ass. + -- key/subject alignment uses spaces (with mono font if ass) + -- word-wrapping is disabled for ass, or cut at 79 for the terminal + local LTR = string.char(0xE2, 0x80, 0x8E) -- U+200E Left To Right mark + local term = not o.use_ass + local kpre = term and "" or format("{\\q2\\fn%s}%s", o.font_mono, LTR) + local kpost = term and " " or format(" {\\fn%s}", o.font) + local spre = term and kspaces .. " " + or format("{\\q2\\fn%s}%s {\\fn%s}{\\fs%d\\u1}", + o.font_mono, kspaces, o.font, 1.3*o.font_size) + local spost = term and "" or format("{\\u0\\fs%d}", o.font_size) + local _, itabs = o.indent:gsub("\t", "") + local cutoff = term and (width or 79) - o.indent:len() - itabs * 7 - spre:len() + + -- create the display lines + local info_lines = {} + local subject = nil + for _, bind in ipairs(ordered) do + if bind.subject ~= subject then -- new subject (title) + subject = bind.subject + append(info_lines, "", {}) + append(info_lines, "", { prefix = spre .. subject .. spost }) + end + if bind.comment then + bind.cmd = bind.cmd .. " # " .. bind.comment + end + append(info_lines, ellipsis(bind.cmd, cutoff), + { prefix = kpre .. no_ASS(align_right(bind.key)) .. kpost }) + end + return info_lines +end + +local function append_general_perfdata(s, offset) + local perf_info = mp.get_property_native("perf-info") or {} + local count = 0 + for _, data in ipairs(perf_info) do + count = count + 1 + end + offset = max(1, min((offset or 1), count)) + + local i = 0 + for _, data in ipairs(perf_info) do + i = i + 1 + if i >= offset then + append(s, data.text or data.value, {prefix="["..tostring(i).."] "..data.name..":"}) + + if o.plot_perfdata and o.use_ass and data.value then + buf = perf_buffers[data.name] + if not buf then + buf = {0, pos = 1, len = 50, max = 0} + perf_buffers[data.name] = buf + end + graph_add_value(buf, data.value) + s[#s+1] = generate_graph(buf, buf.pos, buf.len, buf.max, nil, 0.8, 1) + end + end + end + return offset +end + +local function append_display_sync(s) + if not mp.get_property_bool("display-sync-active", false) then + return + end + + local vspeed = append_property(s, "video-speed-correction", {prefix="DS:"}) + if vspeed then + append_property(s, "audio-speed-correction", + {prefix="/", nl="", indent=" ", prefix_sep=" ", no_prefix_markup=true}) + else + append_property(s, "audio-speed-correction", + {prefix="DS:" .. o.prefix_sep .. " - / ", prefix_sep=""}) + end + + append_property(s, "mistimed-frame-count", {prefix="Mistimed:", nl="", + indent=o.prefix_sep .. o.prefix_sep}) + append_property(s, "vo-delayed-frame-count", {prefix="Delayed:", nl="", + indent=o.prefix_sep .. o.prefix_sep}) + + -- As we need to plot some graphs we print jitter and ratio on their own lines + if not display_timer.oneshot and (o.plot_vsync_ratio or o.plot_vsync_jitter) and o.use_ass then + local ratio_graph = "" + local jitter_graph = "" + if o.plot_vsync_ratio then + ratio_graph = generate_graph(vsratio_buf, vsratio_buf.pos, vsratio_buf.len, vsratio_buf.max, nil, 0.8, 1) + end + if o.plot_vsync_jitter then + jitter_graph = generate_graph(vsjitter_buf, vsjitter_buf.pos, vsjitter_buf.len, vsjitter_buf.max, nil, 0.8, 1) + end + append_property(s, "vsync-ratio", {prefix="VSync Ratio:", suffix=o.prefix_sep .. ratio_graph}) + append_property(s, "vsync-jitter", {prefix="VSync Jitter:", suffix=o.prefix_sep .. jitter_graph}) + else + -- Since no graph is needed we can print ratio/jitter on the same line and save some space + local vr = append_property(s, "vsync-ratio", {prefix="VSync Ratio:"}) + append_property(s, "vsync-jitter", {prefix="VSync Jitter:", + nl=vr and "" or o.nl, + indent=vr and o.prefix_sep .. o.prefix_sep}) + end +end + + +local function append_filters(s, prop, prefix) + local length = 0 + local filters = {} + + for _,f in ipairs(mp.get_property_native(prop, {})) do + local n = f.name + if f.enabled ~= nil and not f.enabled then + n = n .. " (disabled)" + end + + if f.label ~= nil then + n = "@" .. f.label .. ": " .. n + end + + local p = {} + for _,key in ipairs(sorted_keys(f.params)) do + p[#p+1] = key .. "=" .. f.params[key] + end + if #p > 0 then + p = " [" .. table.concat(p, " ") .. "]" + else + p = "" + end + + length = length + n:len() + p:len() + filters[#filters+1] = no_ASS(n) .. it(no_ASS(p)) + end + + if #filters > 0 then + local ret + if length < o.filter_params_max_length then + ret = table.concat(filters, ", ") + else + local sep = o.nl .. o.indent .. o.indent + ret = sep .. table.concat(filters, sep) + end + s[#s+1] = o.nl .. o.indent .. b(prefix) .. o.prefix_sep .. ret + end +end + + +local function add_header(s) + s[#s+1] = text_style() +end + + +local function add_file(s) + append(s, "", {prefix="File:", nl="", indent=""}) + append_property(s, "filename", {prefix_sep="", nl="", indent=""}) + if not (mp.get_property_osd("filename") == mp.get_property_osd("media-title")) then + append_property(s, "media-title", {prefix="Title:"}) + end + + local editions = mp.get_property_number("editions") + local edition = mp.get_property_number("current-edition") + local ed_cond = (edition and editions > 1) + if ed_cond then + append_property(s, "edition-list/" .. tostring(edition) .. "/title", + {prefix="Edition:"}) + append_property(s, "edition-list/count", + {prefix="(" .. tostring(edition + 1) .. "/", suffix=")", nl="", + indent=" ", prefix_sep=" ", no_prefix_markup=true}) + end + + local ch_index = mp.get_property_number("chapter") + if ch_index and ch_index >= 0 then + append_property(s, "chapter-list/" .. tostring(ch_index) .. "/title", {prefix="Chapter:", + nl=ed_cond and "" or o.nl}) + append_property(s, "chapter-list/count", + {prefix="(" .. tostring(ch_index + 1) .. " /", suffix=")", nl="", + indent=" ", prefix_sep=" ", no_prefix_markup=true}) + end + + local fs = append_property(s, "file-size", {prefix="Size:"}) + append_property(s, "file-format", {prefix="Format/Protocol:", + nl=fs and "" or o.nl, + indent=fs and o.prefix_sep .. o.prefix_sep}) + + local demuxer_cache = mp.get_property_native("demuxer-cache-state", {}) + if demuxer_cache["fw-bytes"] then + demuxer_cache = demuxer_cache["fw-bytes"] -- returns bytes + else + demuxer_cache = 0 + end + local demuxer_secs = mp.get_property_number("demuxer-cache-duration", 0) + if demuxer_cache + demuxer_secs > 0 then + append(s, utils.format_bytes_humanized(demuxer_cache), {prefix="Total Cache:"}) + append(s, format("%.1f", demuxer_secs), {prefix="(", suffix=" sec)", nl="", + no_prefix_markup=true, prefix_sep="", indent=o.prefix_sep}) + end +end + + +local function crop_noop(w, h, r) + return r["crop-x"] == 0 and r["crop-y"] == 0 and + r["crop-w"] == w and r["crop-h"] == h +end + + +local function crop_equal(r, ro) + return r["crop-x"] == ro["crop-x"] and r["crop-y"] == ro["crop-y"] and + r["crop-w"] == ro["crop-w"] and r["crop-h"] == ro["crop-h"] +end + + +local function append_resolution(s, r, prefix, w_prop, h_prop, video_res) + if not r then + return + end + w_prop = w_prop or "w" + h_prop = h_prop or "h" + if append(s, r[w_prop], {prefix=prefix}) then + append(s, r[h_prop], {prefix="x", nl="", indent=" ", prefix_sep=" ", + no_prefix_markup=true}) + if r["aspect"] ~= nil and not video_res then + append(s, format("%.2f:1", r["aspect"]), {prefix="", nl="", indent="", + no_prefix_markup=true}) + append(s, r["aspect-name"], {prefix="(", suffix=")", nl="", indent=" ", + prefix_sep="", no_prefix_markup=true}) + end + if r["sar"] ~= nil and video_res then + append(s, format("%.2f:1", r["sar"]), {prefix="", nl="", indent="", + no_prefix_markup=true}) + append(s, r["sar-name"], {prefix="(", suffix=")", nl="", indent=" ", + prefix_sep="", no_prefix_markup=true}) + end + if r["s"] then + append(s, format("%.2f", r["s"]), {prefix="(", suffix="x)", nl="", + indent=o.prefix_sep, prefix_sep="", + no_prefix_markup=true}) + end + -- We can skip crop if it is the same as video decoded resolution + if r["crop-w"] and (not video_res or + not crop_noop(r[w_prop], r[h_prop], r)) then + append(s, format("[x: %d, y: %d, w: %d, h: %d]", + r["crop-x"], r["crop-y"], r["crop-w"], r["crop-h"]), + {prefix="", nl="", indent="", no_prefix_markup=true}) + end + end +end + + +local function pq_eotf(x) + if not x then + return x; + end + + local PQ_M1 = 2610.0 / 4096 * 1.0 / 4 + local PQ_M2 = 2523.0 / 4096 * 128 + local PQ_C1 = 3424.0 / 4096 + local PQ_C2 = 2413.0 / 4096 * 32 + local PQ_C3 = 2392.0 / 4096 * 32 + + x = x ^ (1.0 / PQ_M2) + x = max(x - PQ_C1, 0.0) / (PQ_C2 - PQ_C3 * x) + x = x ^ (1.0 / PQ_M1) + x = x * 10000.0 + + return x +end + + +local function append_hdr(s, hdr, video_out) + if not hdr then + return + end + + local function should_show(val) + return val and val ~= 203 and val > 0 + end + + -- If we are printing video out parameters it is just display, not mastering + local display_prefix = video_out and "Display:" or "Mastering display:" + + local indent = "" + + if should_show(hdr["max-cll"]) or should_show(hdr["max-luma"]) then + append(s, "", {prefix="HDR10:"}) + if hdr["min-luma"] and should_show(hdr["max-luma"]) then + -- libplacebo uses close to zero values as "defined zero" + hdr["min-luma"] = hdr["min-luma"] <= 1e-6 and 0 or hdr["min-luma"] + append(s, format("%.2g / %.0f", hdr["min-luma"], hdr["max-luma"]), + {prefix=display_prefix, suffix=" cd/m²", nl="", indent=indent}) + indent = o.prefix_sep .. o.prefix_sep + end + if should_show(hdr["max-cll"]) then + append(s, hdr["max-cll"], {prefix="MaxCLL:", suffix=" cd/m²", nl="", + indent=indent}) + indent = o.prefix_sep .. o.prefix_sep + end + if hdr["max-fall"] and hdr["max-fall"] > 0 then + append(s, hdr["max-fall"], {prefix="MaxFALL:", suffix=" cd/m²", nl="", + indent=indent}) + end + end + + indent = o.prefix_sep .. o.prefix_sep + + if hdr["scene-max-r"] or hdr["scene-max-g"] or + hdr["scene-max-b"] or hdr["scene-avg"] then + append(s, "", {prefix="HDR10+:"}) + append(s, format("%.1f / %.1f / %.1f", hdr["scene-max-r"] or 0, + hdr["scene-max-g"] or 0, hdr["scene-max-b"] or 0), + {prefix="MaxRGB:", suffix=" cd/m²", nl="", indent=""}) + append(s, format("%.1f", hdr["scene-avg"] or 0), + {prefix="Avg:", suffix=" cd/m²", nl="", indent=indent}) + end + + if hdr["max-pq-y"] and hdr["avg-pq-y"] then + append(s, "", {prefix="PQ(Y):"}) + append(s, format("%.2f cd/m² (%.2f%% PQ)", pq_eotf(hdr["max-pq-y"]), + hdr["max-pq-y"] * 100), {prefix="Max:", nl="", + indent=""}) + append(s, format("%.2f cd/m² (%.2f%% PQ)", pq_eotf(hdr["avg-pq-y"]), + hdr["avg-pq-y"] * 100), {prefix="Avg:", nl="", + indent=indent}) + end +end + + +local function append_img_params(s, r, ro) + if not r then + return + end + + append_resolution(s, r, "Resolution:", "w", "h", true) + if ro and (r["w"] ~= ro["dw"] or r["h"] ~= ro["dh"]) then + if ro["crop-w"] and (crop_noop(r["w"], r["h"], ro) or crop_equal(r, ro)) then + ro["crop-w"] = nil + end + append_resolution(s, ro, "Output Resolution:", "dw", "dh") + end + + local indent = o.prefix_sep .. o.prefix_sep + + local pixel_format = r["hw-pixelformat"] or r["pixelformat"] + append(s, pixel_format, {prefix="Format:"}) + append(s, r["colorlevels"], {prefix="Levels:", nl="", indent=indent}) + if r["chroma-location"] and r["chroma-location"] ~= "unknown" then + append(s, r["chroma-location"], {prefix="Chroma Loc:", nl="", indent=indent}) + end + + -- Group these together to save vertical space + append(s, r["colormatrix"], {prefix="Colormatrix:"}) + append(s, r["primaries"], {prefix="Primaries:", nl="", indent=indent}) + append(s, r["gamma"], {prefix="Transfer:", nl="", indent=indent}) +end + + +local function append_fps(s, prop, eprop) + local fps = mp.get_property_osd(prop) + local efps = mp.get_property_osd(eprop) + local single = fps ~= "" and efps ~= "" and fps == efps + local unit = prop == "display-fps" and " Hz" or " fps" + local suffix = single and "" or " (specified)" + local esuffix = single and "" or " (estimated)" + local prefix = prop == "display-fps" and "Refresh Rate:" or "Frame rate:" + local nl = o.nl + local indent = o.indent + + if fps ~= "" and append(s, fps, {prefix=prefix, suffix=unit .. suffix}) then + prefix = "" + nl = "" + indent = "" + end + + if not single and efps ~= "" then + append(s, efps, + {prefix=prefix, suffix=unit .. esuffix, nl=nl, indent=indent}) + end +end + + +local function add_video_out(s) + local vo = mp.get_property_native("current-vo") + if not vo then + return + end + + append(s, "", {prefix=o.nl .. o.nl .. "Display:", nl="", indent=""}) + append(s, vo, {prefix_sep="", nl="", indent=""}) + append_property(s, "display-names", {prefix_sep="", prefix="(", suffix=")", + no_prefix_markup=true, nl="", indent=" "}) + append_property(s, "avsync", {prefix="A-V:"}) + append_fps(s, "display-fps", "estimated-display-fps") + if append_property(s, "decoder-frame-drop-count", + {prefix="Dropped Frames:", suffix=" (decoder)"}) then + append_property(s, "frame-drop-count", {suffix=" (output)", nl="", indent=""}) + end + append_display_sync(s) + append_perfdata(s, false, o.print_perfdata_passes) + + if mp.get_property_native("deinterlace") then + append_property(s, "deinterlace", {prefix="Deinterlacing:"}) + end + + local scale = nil + if not mp.get_property_native("fullscreen") then + scale = mp.get_property_native("current-window-scale") + end + + local r = mp.get_property_native("video-target-params") + if not r then + local osd_dims = mp.get_property_native("osd-dimensions") + local scaled_width = osd_dims["w"] - osd_dims["ml"] - osd_dims["mr"] + local scaled_height = osd_dims["h"] - osd_dims["mt"] - osd_dims["mb"] + append_resolution(s, {w=scaled_width, h=scaled_height, s=scale}, + "Resolution:") + return + end + + -- Add window scale + r["s"] = scale + + append_img_params(s, r) + append_hdr(s, r, true) +end + + +local function add_video(s) + local r = mp.get_property_native("video-params") + local ro = mp.get_property_native("video-out-params") + -- in case of e.g. lavfi-complex there can be no input video, only output + if not r then + r = ro + end + if not r then + return + end + + local osd_dims = mp.get_property_native("osd-dimensions") + local scaled_width = osd_dims["w"] - osd_dims["ml"] - osd_dims["mr"] + local scaled_height = osd_dims["h"] - osd_dims["mt"] - osd_dims["mb"] + + append(s, "", {prefix=o.nl .. o.nl .. "Video:", nl="", indent=""}) + if append_property(s, "video-codec", {prefix_sep="", nl="", indent=""}) then + append_property(s, "hwdec-current", {prefix="HW:", nl="", + indent=o.prefix_sep .. o.prefix_sep, + no_prefix_markup=false, suffix=""}, {no=true, [""]=true}) + end + local has_prefix = false + if o.show_frame_info then + if append_property(s, "estimated-frame-number", {prefix="Frame:"}) then + append_property(s, "estimated-frame-count", {indent=" / ", nl="", + prefix_sep=""}) + has_prefix = true + end + local frame_info = mp.get_property_native("video-frame-info") + if frame_info and frame_info["picture-type"] then + local attrs = has_prefix and {prefix="(", suffix=")", indent=" ", nl="", + prefix_sep="", no_prefix_markup=true} + or {prefix="Picture Type:"} + append(s, frame_info["picture-type"], attrs) + has_prefix = true + end + if frame_info and frame_info["interlaced"] then + local attrs = has_prefix and {indent=" ", nl="", prefix_sep=""} + or {prefix="Picture Type:"} + append(s, "Interlaced", attrs) + end + end + + if mp.get_property_native("current-tracks/video/image") == false then + append_fps(s, "container-fps", "estimated-vf-fps") + end + append_img_params(s, r, ro) + append_hdr(s, ro) + append_property(s, "packet-video-bitrate", {prefix="Bitrate:", suffix=" kbps"}) + append_filters(s, "vf", "Filters:") +end + + +local function add_audio(s) + local r = mp.get_property_native("audio-params") + -- in case of e.g. lavfi-complex there can be no input audio, only output + if not r then + r = mp.get_property_native("audio-out-params") + end + if not r then + return + end + + append(s, "", {prefix=o.nl .. o.nl .. "Audio:", nl="", indent=""}) + append_property(s, "audio-codec", {prefix_sep="", nl="", indent=""}) + local cc = append(s, r["channel-count"], {prefix="Channels:"}) + append(s, r["format"], {prefix="Format:", nl=cc and "" or o.nl, + indent=cc and o.prefix_sep .. o.prefix_sep}) + append(s, r["samplerate"], {prefix="Sample Rate:", suffix=" Hz"}) + append_property(s, "packet-audio-bitrate", {prefix="Bitrate:", suffix=" kbps"}) + append_filters(s, "af", "Filters:") +end + + +-- Determine whether ASS formatting shall/can be used and set formatting sequences +local function eval_ass_formatting() + o.use_ass = o.ass_formatting and has_vo_window() + if o.use_ass then + o.nl = o.ass_nl + o.indent = o.ass_indent + o.prefix_sep = o.ass_prefix_sep + o.b1 = o.ass_b1 + o.b0 = o.ass_b0 + o.it1 = o.ass_it1 + o.it0 = o.ass_it0 + else + o.nl = o.no_ass_nl + o.indent = o.no_ass_indent + o.prefix_sep = o.no_ass_prefix_sep + o.b1 = o.no_ass_b1 + o.b0 = o.no_ass_b0 + o.it1 = o.no_ass_it1 + o.it0 = o.no_ass_it0 + end +end + + +-- Returns an ASS string with "normal" stats +local function default_stats() + local stats = {} + eval_ass_formatting() + add_header(stats) + add_file(stats) + add_video_out(stats) + add_video(stats) + add_audio(stats) + return table.concat(stats) +end + +local function scroll_vo_stats(stats, fixed_items, offset) + local ret = {} + local count = #stats - fixed_items + offset = max(1, min((offset or 1), count)) + + for i, line in pairs(stats) do + if i <= fixed_items or i >= fixed_items + offset then + ret[#ret+1] = stats[i] + end + end + return ret, offset +end + +-- Returns an ASS string with extended VO stats +local function vo_stats() + local stats = {} + eval_ass_formatting() + add_header(stats) + + -- first line (title) added next is considered fixed + local fixed_items = #stats + 1 + append_perfdata(stats, true, true) + + local page = pages[o.key_page_2] + stats, page.offset = scroll_vo_stats(stats, fixed_items, page.offset) + return table.concat(stats) +end + +local kbinfo_lines = nil +local function keybinding_info(after_scroll) + local header = {} + local page = pages[o.key_page_4] + eval_ass_formatting() + add_header(header) + append(header, "", {prefix=format("%s: {\\fs%s}%s{\\fs%s}", page.desc, + o.font_size * 0.66, "(hint: scroll with ↑↓)", o.font_size), nl="", + indent=""}) + + if not kbinfo_lines or not after_scroll then + kbinfo_lines = get_kbinfo_lines() + end + -- up to 20 lines for the terminal - so that mpv can also print + -- the status line without scrolling, and up to 40 lines for libass + -- because it can put a big performance toll on libass to process + -- many lines which end up outside (below) the screen. + local term = not o.use_ass + local nlines = #kbinfo_lines + page.offset = max(1, min((page.offset or 1), term and nlines - 20 or nlines)) + local maxline = min(nlines, page.offset + (term and 20 or 40)) + return table.concat(header) .. + table.concat(kbinfo_lines, "", page.offset, maxline) +end + +local function perf_stats() + local stats = {} + eval_ass_formatting() + add_header(stats) + local page = pages[o.key_page_0] + append(stats, "", {prefix=page.desc .. ":", nl="", indent=""}) + page.offset = append_general_perfdata(stats, page.offset) + return table.concat(stats) +end + +local function opt_time(t) + if type(t) == type(1.1) then + return mp.format_time(t) + end + return "?" +end + +-- Returns an ASS string with stats about the demuxer cache etc. +local function cache_stats() + local stats = {} + + eval_ass_formatting() + add_header(stats) + append(stats, "", {prefix="Cache info:", nl="", indent=""}) + + local info = mp.get_property_native("demuxer-cache-state") + if info == nil then + append(stats, "Unavailable.", {}) + return table.concat(stats) + end + + local a = info["reader-pts"] + local b = info["cache-end"] + + append(stats, opt_time(a) .. " - " .. opt_time(b), {prefix = "Packet queue:"}) + + local r = nil + if a ~= nil and b ~= nil then + r = b - a + end + + local r_graph = nil + if not display_timer.oneshot and o.use_ass then + r_graph = generate_graph(cache_ahead_buf, cache_ahead_buf.pos, + cache_ahead_buf.len, cache_ahead_buf.max, + nil, 0.8, 1) + r_graph = o.prefix_sep .. r_graph + end + append(stats, opt_time(r), {prefix = "Read-ahead:", suffix = r_graph}) + + -- These states are not necessarily exclusive. They're about potentially + -- separate mechanisms, whose states may be decoupled. + local state = "reading" + local seek_ts = info["debug-seeking"] + if seek_ts ~= nil then + state = "seeking (to " .. mp.format_time(seek_ts) .. ")" + elseif info["eof"] == true then + state = "eof" + elseif info["underrun"] then + state = "underrun" + elseif info["idle"] == true then + state = "inactive" + end + append(stats, state, {prefix = "State:"}) + + local speed = info["raw-input-rate"] or 0 + local speed_graph = nil + if not display_timer.oneshot and o.use_ass then + speed_graph = generate_graph(cache_speed_buf, cache_speed_buf.pos, + cache_speed_buf.len, cache_speed_buf.max, + nil, 0.8, 1) + speed_graph = o.prefix_sep .. speed_graph + end + append(stats, utils.format_bytes_humanized(speed) .. "/s", {prefix="Speed:", + suffix=speed_graph}) + + append(stats, utils.format_bytes_humanized(info["total-bytes"]), + {prefix = "Total RAM:"}) + append(stats, utils.format_bytes_humanized(info["fw-bytes"]), + {prefix = "Forward RAM:"}) + + local fc = info["file-cache-bytes"] + if fc ~= nil then + fc = utils.format_bytes_humanized(fc) + else + fc = "(disabled)" + end + append(stats, fc, {prefix = "Disk cache:"}) + + append(stats, info["debug-low-level-seeks"], {prefix = "Media seeks:"}) + append(stats, info["debug-byte-level-seeks"], {prefix = "Stream seeks:"}) + + append(stats, "", {prefix=o.nl .. o.nl .. "Ranges:", nl="", indent=""}) + + append(stats, info["bof-cached"] and "yes" or "no", + {prefix = "Start cached:"}) + append(stats, info["eof-cached"] and "yes" or "no", + {prefix = "End cached:"}) + + local ranges = info["seekable-ranges"] or {} + for n, r in ipairs(ranges) do + append(stats, mp.format_time(r["start"]) .. " - " .. + mp.format_time(r["end"]), + {prefix = format("Range %s:", n)}) + end + + return table.concat(stats) +end + +-- Record 1 sample of cache statistics. +-- (Unlike record_data(), this does not return a function, but runs directly.) +local function record_cache_stats() + local info = mp.get_property_native("demuxer-cache-state") + if info == nil then + return + end + + local a = info["reader-pts"] + local b = info["cache-end"] + if a ~= nil and b ~= nil then + graph_add_value(cache_ahead_buf, b - a) + end + + graph_add_value(cache_speed_buf, info["raw-input-rate"] or 0) +end + +cache_recorder_timer = mp.add_periodic_timer(0.25, record_cache_stats) +cache_recorder_timer:kill() + +-- Current page and <page key>:<page function> mapping +curr_page = o.key_page_1 +pages = { + [o.key_page_1] = { f = default_stats, desc = "Default" }, + [o.key_page_2] = { f = vo_stats, desc = "Extended Frame Timings", scroll = true }, + [o.key_page_3] = { f = cache_stats, desc = "Cache Statistics" }, + [o.key_page_4] = { f = keybinding_info, desc = "Active key bindings", scroll = true }, + [o.key_page_0] = { f = perf_stats, desc = "Internal performance info", scroll = true }, +} + + +-- Returns a function to record vsratio/jitter with the specified `skip` value +local function record_data(skip) + init_buffers() + skip = max(skip, 0) + local i = skip + return function() + if i < skip then + i = i + 1 + return + else + i = 0 + end + + if o.plot_vsync_jitter then + local r = mp.get_property_number("vsync-jitter", nil) + if r then + vsjitter_buf.pos = (vsjitter_buf.pos % vsjitter_buf.len) + 1 + vsjitter_buf[vsjitter_buf.pos] = r + vsjitter_buf.max = max(vsjitter_buf.max, r) + end + end + + if o.plot_vsync_ratio then + local r = mp.get_property_number("vsync-ratio", nil) + if r then + vsratio_buf.pos = (vsratio_buf.pos % vsratio_buf.len) + 1 + vsratio_buf[vsratio_buf.pos] = r + vsratio_buf.max = max(vsratio_buf.max, r) + end + end + end +end + +-- Call the function for `page` and print it to OSD +local function print_page(page, after_scroll) + -- the page functions assume we start in ass-enabled mode. + -- that's true for mp.set_osd_ass, but not for mp.osd_message. + local ass_content = pages[page].f(after_scroll) + if o.persistent_overlay then + mp.set_osd_ass(0, 0, ass_content) + else + mp.osd_message((o.use_ass and ass_start or "") .. ass_content, + display_timer.oneshot and o.duration or o.redraw_delay + 1) + end +end + + +local function clear_screen() + if o.persistent_overlay then mp.set_osd_ass(0, 0, "") else mp.osd_message("", 0) end +end + +local function scroll_delta(d) + if display_timer.oneshot then display_timer:kill() ; display_timer:resume() end + pages[curr_page].offset = (pages[curr_page].offset or 1) + d + print_page(curr_page, true) +end +local function scroll_up() scroll_delta(-o.scroll_lines) end +local function scroll_down() scroll_delta(o.scroll_lines) end + +local function reset_scroll_offsets() + for _, page in pairs(pages) do + page.offset = nil + end +end +local function bind_scroll() + if not scroll_bound then + mp.add_forced_key_binding(o.key_scroll_up, "__forced_"..o.key_scroll_up, scroll_up, {repeatable=true}) + mp.add_forced_key_binding(o.key_scroll_down, "__forced_"..o.key_scroll_down, scroll_down, {repeatable=true}) + scroll_bound = true + end +end +local function unbind_scroll() + if scroll_bound then + mp.remove_key_binding("__forced_"..o.key_scroll_up) + mp.remove_key_binding("__forced_"..o.key_scroll_down) + scroll_bound = false + end +end +local function update_scroll_bindings(k) + if pages[k].scroll then + bind_scroll() + else + unbind_scroll() + end +end + +-- Add keybindings for every page +local function add_page_bindings() + local function a(k) + return function() + reset_scroll_offsets() + update_scroll_bindings(k) + curr_page = k + print_page(k) + if display_timer.oneshot then display_timer:kill() ; display_timer:resume() end + end + end + for k, _ in pairs(pages) do + mp.add_forced_key_binding(k, "__forced_"..k, a(k), {repeatable=true}) + end + update_scroll_bindings(curr_page) +end + + +-- Remove keybindings for every page +local function remove_page_bindings() + for k, _ in pairs(pages) do + mp.remove_key_binding("__forced_"..k) + end + unbind_scroll() +end + + +local function process_key_binding(oneshot) + reset_scroll_offsets() + -- Stats are already being displayed + if display_timer:is_enabled() then + -- Previous and current keys were oneshot -> restart timer + if display_timer.oneshot and oneshot then + display_timer:kill() + print_page(curr_page) + display_timer:resume() + -- Previous and current keys were toggling -> end toggling + elseif not display_timer.oneshot and not oneshot then + display_timer:kill() + cache_recorder_timer:stop() + if tm_viz_prev ~= nil then + mp.set_property_native("tone-mapping-visualize", tm_viz_prev) + tm_viz_prev = nil + end + clear_screen() + remove_page_bindings() + if recorder then + mp.unobserve_property(recorder) + recorder = nil + end + end + -- No stats are being displayed yet + else + if not oneshot and (o.plot_vsync_jitter or o.plot_vsync_ratio) then + recorder = record_data(o.skip_frames) + -- Rely on the fact that "vsync-ratio" is updated at the same time. + -- Using "none" to get a sample any time, even if it does not change. + -- Will stop working if "vsync-jitter" property change notification + -- changes, but it's fine for an internal script. + mp.observe_property("vsync-jitter", "none", recorder) + end + if not oneshot and o.plot_tonemapping_lut then + tm_viz_prev = mp.get_property_native("tone-mapping-visualize") + mp.set_property_native("tone-mapping-visualize", true) + end + if not oneshot then + cache_ahead_buf = {0, pos = 1, len = 50, max = 0} + cache_speed_buf = {0, pos = 1, len = 50, max = 0} + cache_recorder_timer:resume() + end + display_timer:kill() + display_timer.oneshot = oneshot + display_timer.timeout = oneshot and o.duration or o.redraw_delay + add_page_bindings() + print_page(curr_page) + display_timer:resume() + end +end + + +-- Create the timer used for redrawing (toggling) or clearing the screen (oneshot) +-- The duration here is not important and always set in process_key_binding() +display_timer = mp.add_periodic_timer(o.duration, + function() + if display_timer.oneshot then + display_timer:kill() ; clear_screen() ; remove_page_bindings() + else + print_page(curr_page) + end + end) +display_timer:kill() + +-- Single invocation key binding +mp.add_key_binding(nil, "display-stats", function() process_key_binding(true) end, + {repeatable=true}) + +-- Toggling key binding +mp.add_key_binding(nil, "display-stats-toggle", function() process_key_binding(false) end, + {repeatable=false}) + +-- Single invocation bindings without key, can be used in input.conf to create +-- bindings for a specific page: "e script-binding stats/display-page-2" +for k, _ in pairs(pages) do + mp.add_key_binding(nil, "display-page-" .. k, + function() + curr_page = k + process_key_binding(true) + end, {repeatable=true}) +end + +-- Reprint stats immediately when VO was reconfigured, only when toggled +mp.register_event("video-reconfig", + function() + if display_timer:is_enabled() then + print_page(curr_page) + end + end) + +-- --script-opts=stats-bindlist=[-]{yes|<TERM-WIDTH>} +if o.bindlist ~= "no" then + mp.command("no-osd set really-quiet yes") + if o.bindlist:sub(1, 1) == "-" then + o.bindlist = o.bindlist:sub(2) + o.no_ass_b0 = "" + o.no_ass_b1 = "" + end + local width = max(40, math.floor(tonumber(o.bindlist) or 79)) + mp.add_timeout(0, function() -- wait for all other scripts to finish init + o.ass_formatting = false + o.no_ass_indent = " " + eval_ass_formatting() + io.write(pages[o.key_page_4].desc .. ":" .. + table.concat(get_kbinfo_lines(width)) .. "\n") + mp.command("quit") + end) +end diff --git a/player/lua/ytdl_hook.lua b/player/lua/ytdl_hook.lua new file mode 100644 index 0000000..3161da6 --- /dev/null +++ b/player/lua/ytdl_hook.lua @@ -0,0 +1,1191 @@ +local utils = require 'mp.utils' +local msg = require 'mp.msg' +local options = require 'mp.options' + +local o = { + exclude = "", + try_ytdl_first = false, + use_manifests = false, + all_formats = false, + force_all_formats = true, + thumbnails = "none", + ytdl_path = "", +} + +local ytdl = { + path = "", + paths_to_search = {"yt-dlp", "yt-dlp_x86", "youtube-dl"}, + searched = false, + blacklisted = {} +} + +options.read_options(o, nil, function() + ytdl.blacklisted = {} -- reparse o.exclude next time + ytdl.searched = false +end) + +local chapter_list = {} +local playlist_cookies = {} + +function Set (t) + local set = {} + for _, v in pairs(t) do set[v] = true end + return set +end + +-- ?: surrogate (keep in mind that there is no lazy evaluation) +function iif(cond, if_true, if_false) + if cond then + return if_true + end + return if_false +end + +-- youtube-dl JSON name to mpv tag name +local tag_list = { + ["uploader"] = "uploader", + ["channel_url"] = "channel_url", + -- these titles tend to be a bit too long, so hide them on the terminal + -- (default --display-tags does not include this name) + ["description"] = "ytdl_description", + -- "title" is handled by force-media-title + -- tags don't work with all_formats=yes +} + +local safe_protos = Set { + "http", "https", "ftp", "ftps", + "rtmp", "rtmps", "rtmpe", "rtmpt", "rtmpts", "rtmpte", + "data" +} + +-- For some sites, youtube-dl returns the audio codec (?) only in the "ext" field. +local ext_map = { + ["mp3"] = "mp3", + ["opus"] = "opus", +} + +local codec_map = { + -- src pattern = mpv codec + ["vtt"] = "webvtt", + ["opus"] = "opus", + ["vp9"] = "vp9", + ["avc1%..*"] = "h264", + ["av01%..*"] = "av1", + ["mp4a%..*"] = "aac", +} + +-- Codec name as reported by youtube-dl mapped to mpv internal codec names. +-- Fun fact: mpv will not really use the codec, but will still try to initialize +-- the codec on track selection (just to scrap it), meaning it's only a hint, +-- but one that may make initialization fail. On the other hand, if the codec +-- is valid but completely different from the actual media, nothing bad happens. +local function map_codec_to_mpv(codec) + if codec == nil then + return nil + end + for k, v in pairs(codec_map) do + local s, e = codec:find(k) + if s == 1 and e == #codec then + return v + end + end + return nil +end + +local function platform_is_windows() + return mp.get_property_native("platform") == "windows" +end + +local function exec(args) + msg.debug("Running: " .. table.concat(args, " ")) + + return mp.command_native({ + name = "subprocess", + args = args, + capture_stdout = true, + capture_stderr = true, + }) +end + +-- return true if it was explicitly set on the command line +local function option_was_set(name) + return mp.get_property_bool("option-info/" ..name.. "/set-from-commandline", + false) +end + +-- return true if the option was set locally +local function option_was_set_locally(name) + return mp.get_property_bool("option-info/" ..name.. "/set-locally", false) +end + +-- youtube-dl may set special http headers for some sites (user-agent, cookies) +local function set_http_headers(http_headers) + if not http_headers then + return + end + local headers = {} + local useragent = http_headers["User-Agent"] + if useragent and not option_was_set("user-agent") then + mp.set_property("file-local-options/user-agent", useragent) + end + local additional_fields = {"Cookie", "Referer", "X-Forwarded-For"} + for idx, item in pairs(additional_fields) do + local field_value = http_headers[item] + if field_value then + headers[#headers + 1] = item .. ": " .. field_value + end + end + if #headers > 0 and not option_was_set("http-header-fields") then + mp.set_property_native("file-local-options/http-header-fields", headers) + end +end + +local special_cookie_field_names = Set { + "expires", "max-age", "domain", "path" +} + +-- parse single-line Set-Cookie syntax +local function parse_cookies(cookies_line) + if not cookies_line then + return {} + end + local cookies = {} + local cookie = {} + for stem in cookies_line:gmatch('[^;]+') do + stem = stem:gsub("^%s*(.-)%s*$", "%1") + local name, value = stem:match('^(.-)=(.+)$') + if name and name ~= "" and value then + local cmp_name = name:lower() + if special_cookie_field_names[cmp_name] then + cookie[cmp_name] = value + else + if cookie.name and cookie.value then + table.insert(cookies, cookie) + end + cookie = { + name = name, + value = value, + } + end + end + end + if cookie.name and cookie.value then + local cookie_key = cookie.domain .. ":" .. cookie.name + cookies[cookie_key] = cookie + end + return cookies +end + +-- serialize cookies for avformat +local function serialize_cookies_for_avformat(cookies) + local result = '' + for _, cookie in pairs(cookies) do + local cookie_str = ('%s=%s; '):format(cookie.name, cookie.value) + for k, v in pairs(cookie) do + if k ~= "name" and k ~= "value" then + cookie_str = cookie_str .. ('%s=%s; '):format(k, v) + end + end + result = result .. cookie_str .. '\r\n' + end + return result +end + +-- set file-local cookies, preserving existing ones +local function set_cookies(cookies) + if not cookies or cookies == "" then + return + end + + local option_key = "file-local-options/stream-lavf-o" + local stream_opts = mp.get_property_native(option_key, {}) + local existing_cookies = parse_cookies(stream_opts["cookies"]) + + local new_cookies = parse_cookies(cookies) + for cookie_key, cookie in pairs(new_cookies) do + if not existing_cookies[cookie_key] then + existing_cookies[cookie_key] = cookie + end + end + + stream_opts["cookies"] = serialize_cookies_for_avformat(existing_cookies) + mp.set_property_native(option_key, stream_opts) +end + +local function append_libav_opt(props, name, value) + if not props then + props = {} + end + + if name and value and not props[name] then + props[name] = value + end + + return props +end + +local function edl_escape(url) + return "%" .. string.len(url) .. "%" .. url +end + +local function url_is_safe(url) + local proto = type(url) == "string" and url:match("^(%a[%w+.-]*):") or nil + local safe = proto and safe_protos[proto] + if not safe then + msg.error(("Ignoring potentially unsafe url: '%s'"):format(url)) + end + return safe +end + +local function time_to_secs(time_string) + local ret + + local a, b, c = time_string:match("(%d+):(%d%d?):(%d%d)") + if a ~= nil then + ret = (a*3600 + b*60 + c) + else + a, b = time_string:match("(%d%d?):(%d%d)") + if a ~= nil then + ret = (a*60 + b) + end + end + + return ret +end + +local function extract_chapters(data, video_length) + local ret = {} + + for line in data:gmatch("[^\r\n]+") do + local time = time_to_secs(line) + if time and (time < video_length) then + table.insert(ret, {time = time, title = line}) + end + end + table.sort(ret, function(a, b) return a.time < b.time end) + return ret +end + +local function is_blacklisted(url) + if o.exclude == "" then return false end + if #ytdl.blacklisted == 0 then + for match in o.exclude:gmatch('%|?([^|]+)') do + ytdl.blacklisted[#ytdl.blacklisted + 1] = match + end + end + if #ytdl.blacklisted > 0 then + url = url:match('https?://(.+)') + for _, exclude in ipairs(ytdl.blacklisted) do + if url:match(exclude) then + msg.verbose('URL matches excluded substring. Skipping.') + return true + end + end + end + return false +end + +local function parse_yt_playlist(url, json) + -- return 0-based index to use with --playlist-start + + if not json.extractor or + (json.extractor ~= "youtube:tab" and + json.extractor ~= "youtube:playlist") then + return nil + end + + local query = url:match("%?.+") + if not query then return nil end + + local args = {} + for arg, param in query:gmatch("(%a+)=([^&?]+)") do + if arg and param then + args[arg] = param + end + end + + local maybe_idx = tonumber(args["index"]) + + -- if index matches v param it's probably the requested item + if maybe_idx and #json.entries >= maybe_idx and + json.entries[maybe_idx].id == args["v"] then + msg.debug("index matches requested video") + return maybe_idx - 1 + end + + -- if there's no index or it doesn't match, look for video + for i = 1, #json.entries do + if json.entries[i].id == args["v"] then + msg.debug("found requested video in index " .. (i - 1)) + return i - 1 + end + end + + msg.debug("requested video not found in playlist") + -- if item isn't on the playlist, give up + return nil +end + +local function make_absolute_url(base_url, url) + if url:find("https?://") == 1 then return url end + + local proto, domain, rest = + base_url:match("(https?://)([^/]+/)(.*)/?") + local segs = {} + rest:gsub("([^/]+)", function(c) table.insert(segs, c) end) + url:gsub("([^/]+)", function(c) table.insert(segs, c) end) + local resolved_url = {} + for i, v in ipairs(segs) do + if v == ".." then + table.remove(resolved_url) + elseif v ~= "." then + table.insert(resolved_url, v) + end + end + return proto .. domain .. + table.concat(resolved_url, "/") +end + +local function join_url(base_url, fragment) + local res = "" + if base_url and fragment.path then + res = make_absolute_url(base_url, fragment.path) + elseif fragment.url then + res = fragment.url + end + return res +end + +local function edl_track_joined(fragments, protocol, is_live, base) + if type(fragments) ~= "table" or not fragments[1] then + msg.debug("No fragments to join into EDL") + return nil + end + + local edl = "edl://" + local offset = 1 + local parts = {} + + if protocol == "http_dash_segments" and not is_live then + msg.debug("Using dash") + local args = "" + + -- assume MP4 DASH initialization segment + if not fragments[1].duration and #fragments > 1 then + msg.debug("Using init segment") + args = args .. ",init=" .. edl_escape(join_url(base, fragments[1])) + offset = 2 + end + + table.insert(parts, "!mp4_dash" .. args) + + -- Check remaining fragments for duration; + -- if not available in all, give up. + for i = offset, #fragments do + if not fragments[i].duration then + msg.verbose("EDL doesn't support fragments " .. + "without duration with MP4 DASH") + return nil + end + end + end + + for i = offset, #fragments do + local fragment = fragments[i] + if not url_is_safe(join_url(base, fragment)) then + return nil + end + table.insert(parts, edl_escape(join_url(base, fragment))) + if fragment.duration then + parts[#parts] = + parts[#parts] .. ",length="..fragment.duration + end + end + return edl .. table.concat(parts, ";") .. ";" +end + +local function has_native_dash_demuxer() + local demuxers = mp.get_property_native("demuxer-lavf-list", {}) + for _, v in ipairs(demuxers) do + if v == "dash" then + return true + end + end + return false +end + +local function valid_manifest(json) + local reqfmt = json["requested_formats"] and json["requested_formats"][1] or {} + if not reqfmt["manifest_url"] and not json["manifest_url"] then + return false + end + local proto = reqfmt["protocol"] or json["protocol"] or "" + return (proto == "http_dash_segments" and has_native_dash_demuxer()) or + proto:find("^m3u8") +end + +local function as_integer(v, def) + def = def or 0 + local num = math.floor(tonumber(v) or def) + if num > -math.huge and num < math.huge then + return num + end + return def +end + +local function tags_to_edl(json) + local tags = {} + for json_name, mp_name in pairs(tag_list) do + local v = json[json_name] + if v then + tags[#tags + 1] = mp_name .. "=" .. edl_escape(tostring(v)) + end + end + if #tags == 0 then + return nil + end + return "!global_tags," .. table.concat(tags, ",") +end + +-- Convert a format list from youtube-dl to an EDL URL, or plain URL. +-- json: full json blob by youtube-dl +-- formats: format list by youtube-dl +-- use_all_formats: if=true, then formats is the full format list, and the +-- function will attempt to return them as delay-loaded tracks +-- See res table initialization in the function for result type. +local function formats_to_edl(json, formats, use_all_formats) + local res = { + -- the media URL, which may be EDL + url = nil, + -- for use_all_formats=true: whether any muxed formats are present, and + -- at the same time the separate EDL parts don't have both audio/video + muxed_needed = false, + } + + local default_formats = {} + local requested_formats = json["requested_formats"] or json["requested_downloads"] + if use_all_formats and requested_formats then + for _, track in ipairs(requested_formats) do + local id = track["format_id"] + if id then + default_formats[id] = true + end + end + end + + local duration = as_integer(json["duration"]) + local single_url = nil + local streams = {} + + local tbr_only = true + for index, track in ipairs(formats) do + tbr_only = tbr_only and track["tbr"] and + (not track["abr"]) and (not track["vbr"]) + end + + local has_requested_video = false + local has_requested_audio = false + -- Web players with quality selection always show the highest quality + -- option at the top. Since tracks are usually listed with the first + -- track at the top, that should also be the highest quality track. + -- yt-dlp/youtube-dl sorts it's formats from worst to best. + -- Iterate in reverse to get best track first. + for index = #formats, 1, -1 do + local track = formats[index] + local edl_track = nil + edl_track = edl_track_joined(track.fragments, + track.protocol, json.is_live, + track.fragment_base_url) + if not edl_track and not url_is_safe(track.url) then + msg.error("No safe URL or supported fragmented stream available") + return nil + end + + local is_default = default_formats[track["format_id"]] + local tracks = {} + -- "none" means it is not a video + -- nil means it is unknown + if (o.force_all_formats or track.vcodec) and track.vcodec ~= "none" then + tracks[#tracks + 1] = { + media_type = "video", + codec = map_codec_to_mpv(track.vcodec), + } + if is_default then + has_requested_video = true + end + end + if (o.force_all_formats or track.acodec) and track.acodec ~= "none" then + tracks[#tracks + 1] = { + media_type = "audio", + codec = map_codec_to_mpv(track.acodec) or + ext_map[track.ext], + } + if is_default then + has_requested_audio = true + end + end + + local url = edl_track or track.url + local hdr = {"!new_stream", "!no_clip", "!no_chapters"} + local skip = #tracks == 0 + local params = "" + + if use_all_formats then + for _, sub in ipairs(tracks) do + -- A single track that is either audio or video. Delay load it. + local props = "" + if sub.media_type == "video" then + props = props .. ",w=" .. as_integer(track.width) + .. ",h=" .. as_integer(track.height) + .. ",fps=" .. as_integer(track.fps) + elseif sub.media_type == "audio" then + props = props .. ",samplerate=" .. as_integer(track.asr) + end + hdr[#hdr + 1] = "!delay_open,media_type=" .. sub.media_type .. + ",codec=" .. (sub.codec or "null") .. props + + -- Add bitrate information etc. for better user selection. + local byterate = 0 + local rates = {"tbr", "vbr", "abr"} + if #tracks > 1 then + rates = {({video = "vbr", audio = "abr"})[sub.media_type]} + end + if tbr_only then + rates = {"tbr"} + end + for _, f in ipairs(rates) do + local br = as_integer(track[f]) + if br > 0 then + byterate = math.floor(br * 1000 / 8) + break + end + end + local title = track.format or track.format_note or "" + if #tracks > 1 then + if #title > 0 then + title = title .. " " + end + title = title .. "muxed-" .. index + end + local flags = {} + if is_default then + flags[#flags + 1] = "default" + end + hdr[#hdr + 1] = "!track_meta,title=" .. + edl_escape(title) .. ",byterate=" .. byterate .. + iif(#flags > 0, ",flags=" .. table.concat(flags, "+"), "") + end + + if duration > 0 then + params = params .. ",length=" .. duration + end + end + + if not skip then + hdr[#hdr + 1] = edl_escape(url) .. params + + streams[#streams + 1] = table.concat(hdr, ";") + -- In case there is only 1 of these streams. + -- Note: assumes it has no important EDL headers + single_url = url + end + end + + local tags = tags_to_edl(json) + + -- Merge all tracks into a single virtual file, but avoid EDL if it's + -- only a single track without metadata (i.e. redundant). + if #streams == 1 and single_url and not tags then + res.url = single_url + elseif #streams > 0 then + if tags then + -- not a stream; just for the sake of concatenating the EDL string + streams[#streams + 1] = tags + end + res.url = "edl://" .. table.concat(streams, ";") + else + return nil + end + + if has_requested_audio ~= has_requested_video then + local not_req_prop = has_requested_video and "aid" or "vid" + if mp.get_property(not_req_prop) == "auto" then + mp.set_property("file-local-options/" .. not_req_prop, "no") + end + end + + return res +end + +local function add_single_video(json) + local streamurl = "" + local format_info = "" + local max_bitrate = 0 + local requested_formats = json["requested_formats"] or json["requested_downloads"] + local all_formats = json["formats"] + local has_requested_formats = requested_formats and #requested_formats > 0 + local http_headers = has_requested_formats + and requested_formats[1].http_headers + or json.http_headers + local cookies = has_requested_formats + and requested_formats[1].cookies + or json.cookies + + if o.use_manifests and valid_manifest(json) then + -- prefer manifest_url if present + format_info = "manifest" + + local mpd_url = requested_formats and + requested_formats[1]["manifest_url"] or json["manifest_url"] + if not mpd_url then + msg.error("No manifest URL found in JSON data.") + return + elseif not url_is_safe(mpd_url) then + return + end + + streamurl = mpd_url + + if requested_formats then + for _, track in pairs(requested_formats) do + max_bitrate = (track.tbr and track.tbr > max_bitrate) and + track.tbr or max_bitrate + end + elseif json.tbr then + max_bitrate = json.tbr > max_bitrate and json.tbr or max_bitrate + end + end + + if streamurl == "" then + -- possibly DASH/split tracks + local res = nil + + -- Not having requested_formats usually hints to HLS master playlist + -- usage, which we don't want to split off, at least not yet. + if (all_formats and o.all_formats) and + (has_requested_formats or o.force_all_formats) + then + format_info = "all_formats (separate)" + res = formats_to_edl(json, all_formats, true) + -- Note: since we don't delay-load muxed streams, use normal stream + -- selection if we have to use muxed streams. + if res and res.muxed_needed then + res = nil + end + end + + if not res and has_requested_formats then + format_info = "youtube-dl (separate)" + res = formats_to_edl(json, requested_formats, false) + end + + if res then + streamurl = res.url + end + end + + if streamurl == "" and json.url then + format_info = "youtube-dl (single)" + local edl_track = nil + edl_track = edl_track_joined(json.fragments, json.protocol, + json.is_live, json.fragment_base_url) + + if not edl_track and not url_is_safe(json.url) then + return + end + -- normal video or single track + streamurl = edl_track or json.url + end + + if streamurl == "" then + msg.error("No URL found in JSON data.") + return + end + + set_http_headers(http_headers) + + msg.verbose("format selection: " .. format_info) + msg.debug("streamurl: " .. streamurl) + + mp.set_property("stream-open-filename", streamurl:gsub("^data:", "data://", 1)) + + if mp.get_property("force-media-title", "") == "" then + mp.set_property("file-local-options/force-media-title", json.title) + end + + -- set hls-bitrate for dash track selection + if max_bitrate > 0 and + not option_was_set("hls-bitrate") and + not option_was_set_locally("hls-bitrate") then + mp.set_property_native('file-local-options/hls-bitrate', max_bitrate*1000) + end + + -- add subtitles + if json.requested_subtitles ~= nil then + local subs = {} + for lang, info in pairs(json.requested_subtitles) do + subs[#subs + 1] = {lang = lang or "-", info = info} + end + table.sort(subs, function(a, b) return a.lang < b.lang end) + for _, e in ipairs(subs) do + local lang, sub_info = e.lang, e.info + msg.verbose("adding subtitle ["..lang.."]") + + local sub = nil + + if sub_info.data ~= nil then + sub = "memory://"..sub_info.data + elseif sub_info.url ~= nil and + url_is_safe(sub_info.url) then + sub = sub_info.url + end + + if sub ~= nil then + local edl = "edl://!no_clip;!delay_open,media_type=sub" + local codec = map_codec_to_mpv(sub_info.ext) + if codec then + edl = edl .. ",codec=" .. codec + end + edl = edl .. ";" .. edl_escape(sub) + local title = sub_info.name or sub_info.ext + mp.commandv("sub-add", edl, "auto", title, lang) + else + msg.verbose("No subtitle data/url for ["..lang.."]") + end + end + end + + -- add thumbnails + if (o.thumbnails == 'all' or o.thumbnails == 'best') and json.thumbnails ~= nil then + local thumb = nil + local thumb_height = -1 + local thumb_preference = nil + + for i = #json.thumbnails, 1, -1 do + local thumb_info = json.thumbnails[i] + if thumb_info.url ~= nil then + if o.thumbnails == 'all' then + msg.verbose("adding thumbnail") + mp.commandv("video-add", thumb_info.url, "auto") + thumb_height = 0 + elseif (thumb_preference ~= nil and (thumb_info.preference or -math.huge) > thumb_preference) or + (thumb_preference == nil and ((thumb_info.height or 0) > thumb_height)) then + thumb = thumb_info.url + thumb_height = thumb_info.height or 0 + thumb_preference = thumb_info.preference + end + end + end + + if thumb ~= nil then + msg.verbose("adding thumbnail") + mp.commandv("video-add", thumb, "auto") + elseif thumb_height == -1 then + msg.verbose("No thumbnail url") + end + end + + -- add chapters + if json.chapters then + msg.debug("Adding pre-parsed chapters") + for i = 1, #json.chapters do + local chapter = json.chapters[i] + local title = chapter.title or "" + if title == "" then + title = string.format('Chapter %02d', i) + end + table.insert(chapter_list, {time=chapter.start_time, title=title}) + end + elseif json.description ~= nil and json.duration ~= nil then + chapter_list = extract_chapters(json.description, json.duration) + end + + -- set start time + if json.start_time or json.section_start and + not option_was_set("start") and + not option_was_set_locally("start") then + local start_time = json.start_time or json.section_start + msg.debug("Setting start to: " .. start_time .. " secs") + mp.set_property("file-local-options/start", start_time) + end + + -- set end time + if json.end_time or json.section_end and + not option_was_set("end") and + not option_was_set_locally("end") then + local end_time = json.end_time or json.section_end + msg.debug("Setting end to: " .. end_time .. " secs") + mp.set_property("file-local-options/end", end_time) + end + + -- set aspect ratio for anamorphic video + if json.stretched_ratio ~= nil and + not option_was_set("video-aspect-override") then + mp.set_property('file-local-options/video-aspect-override', json.stretched_ratio) + end + + local stream_opts = mp.get_property_native("file-local-options/stream-lavf-o", {}) + + -- for rtmp + if json.protocol == "rtmp" then + stream_opts = append_libav_opt(stream_opts, + "rtmp_tcurl", streamurl) + stream_opts = append_libav_opt(stream_opts, + "rtmp_pageurl", json.page_url) + stream_opts = append_libav_opt(stream_opts, + "rtmp_playpath", json.play_path) + stream_opts = append_libav_opt(stream_opts, + "rtmp_swfverify", json.player_url) + stream_opts = append_libav_opt(stream_opts, + "rtmp_swfurl", json.player_url) + stream_opts = append_libav_opt(stream_opts, + "rtmp_app", json.app) + end + + if json.proxy and json.proxy ~= "" then + stream_opts = append_libav_opt(stream_opts, + "http_proxy", json.proxy) + end + + if cookies and cookies ~= "" then + local existing_cookies = parse_cookies(stream_opts["cookies"]) + local new_cookies = parse_cookies(cookies) + for cookie_key, cookie in pairs(new_cookies) do + existing_cookies[cookie_key] = cookie + end + stream_opts["cookies"] = serialize_cookies_for_avformat(existing_cookies) + end + + mp.set_property_native("file-local-options/stream-lavf-o", stream_opts) +end + +local function check_version(ytdl_path) + local command = { + name = "subprocess", + capture_stdout = true, + args = {ytdl_path, "--version"} + } + local version_string = mp.command_native(command).stdout + local year, month, day = string.match(version_string, "(%d+).(%d+).(%d+)") + + -- sanity check + if tonumber(year) < 2000 or tonumber(month) > 12 or + tonumber(day) > 31 then + return + end + local version_ts = os.time{year=year, month=month, day=day} + if os.difftime(os.time(), version_ts) > 60*60*24*90 then + msg.warn("It appears that your youtube-dl version is severely out of date.") + end +end + +function run_ytdl_hook(url) + local start_time = os.clock() + + -- strip ytdl:// + if url:find("ytdl://") == 1 then + url = url:sub(8) + end + + local format = mp.get_property("options/ytdl-format") + local raw_options = mp.get_property_native("options/ytdl-raw-options") + local allsubs = true + local proxy = nil + local use_playlist = false + + local command = { + ytdl.path, "--no-warnings", "-J", "--flat-playlist", + "--sub-format", "ass/srt/best" + } + + -- Checks if video option is "no", change format accordingly, + -- but only if user didn't explicitly set one + if mp.get_property("options/vid") == "no" and #format == 0 then + format = "bestaudio/best" + msg.verbose("Video disabled. Only using audio") + end + + if format == "" then + format = "bestvideo+bestaudio/best" + end + + if format ~= "ytdl" then + table.insert(command, "--format") + table.insert(command, format) + end + + for param, arg in pairs(raw_options) do + table.insert(command, "--" .. param) + if arg ~= "" then + table.insert(command, arg) + end + if (param == "sub-lang" or param == "sub-langs" or param == "srt-lang") and (arg ~= "") then + allsubs = false + elseif param == "proxy" and arg ~= "" then + proxy = arg + elseif param == "yes-playlist" then + use_playlist = true + end + end + + if allsubs == true then + table.insert(command, "--all-subs") + end + if not use_playlist then + table.insert(command, "--no-playlist") + end + table.insert(command, "--") + table.insert(command, url) + + local result + if ytdl.searched then + result = exec(command) + else + local separator = platform_is_windows() and ";" or ":" + if o.ytdl_path:match("[^" .. separator .. "]") then + ytdl.paths_to_search = {} + for path in o.ytdl_path:gmatch("[^" .. separator .. "]+") do + table.insert(ytdl.paths_to_search, path) + end + end + + for _, path in pairs(ytdl.paths_to_search) do + -- search for youtube-dl in mpv's config dir + local exesuf = platform_is_windows() and not path:lower():match("%.exe$") and ".exe" or "" + local ytdl_cmd = mp.find_config_file(path .. exesuf) + if ytdl_cmd then + msg.verbose("Found youtube-dl at: " .. ytdl_cmd) + ytdl.path = ytdl_cmd + command[1] = ytdl.path + result = exec(command) + break + else + msg.verbose("No youtube-dl found with path " .. path .. exesuf .. " in config directories") + command[1] = path + result = exec(command) + if result.error_string == "init" then + msg.verbose("youtube-dl with path " .. path .. " not found in PATH or not enough permissions") + else + msg.verbose("Found youtube-dl with path " .. path .. " in PATH") + ytdl.path = path + break + end + end + end + + ytdl.searched = true + end + + if result.killed_by_us then + return + end + + local json = result.stdout + local parse_err = nil + + if result.status ~= 0 or json == "" then + json = nil + elseif json then + json, parse_err = utils.parse_json(json) + end + + if json == nil then + msg.verbose("status:", result.status) + msg.verbose("reason:", result.error_string) + msg.verbose("stdout:", result.stdout) + msg.verbose("stderr:", result.stderr) + + -- trim our stderr to avoid spurious newlines + ytdl_err = result.stderr:gsub("^%s*(.-)%s*$", "%1") + msg.error(ytdl_err) + local err = "youtube-dl failed: " + if result.error_string and result.error_string == "init" then + err = err .. "not found or not enough permissions" + elseif parse_err then + err = err .. "failed to parse JSON data: " .. parse_err + else + err = err .. "unexpected error occurred" + end + msg.error(err) + if parse_err or string.find(ytdl_err, "yt%-dl%.org/bug") then + check_version(ytdl.path) + end + return + end + + msg.verbose("youtube-dl succeeded!") + msg.debug('ytdl parsing took '..os.clock()-start_time..' seconds') + + json["proxy"] = json["proxy"] or proxy + + -- what did we get? + if json["direct"] then + -- direct URL, nothing to do + msg.verbose("Got direct URL") + return + elseif json["_type"] == "playlist" or + json["_type"] == "multi_video" then + -- a playlist + + if #json.entries == 0 then + msg.warn("Got empty playlist, nothing to play.") + return + end + + local self_redirecting_url = + json.entries[1]["_type"] ~= "url_transparent" and + json.entries[1]["webpage_url"] and + json.entries[1]["webpage_url"] == json["webpage_url"] + + + -- some funky guessing to detect multi-arc videos + if self_redirecting_url and #json.entries > 1 + and json.entries[1].protocol == "m3u8_native" + and json.entries[1].url then + msg.verbose("multi-arc video detected, building EDL") + + local playlist = edl_track_joined(json.entries) + + msg.debug("EDL: " .. playlist) + + if not playlist then + return + end + + -- can't change the http headers for each entry, so use the 1st + set_http_headers(json.entries[1].http_headers) + set_cookies(json.entries[1].cookies or json.cookies) + + mp.set_property("stream-open-filename", playlist) + if json.title and mp.get_property("force-media-title", "") == "" then + mp.set_property("file-local-options/force-media-title", + json.title) + end + + -- there might not be subs for the first segment + local entry_wsubs = nil + for i, entry in pairs(json.entries) do + if entry.requested_subtitles ~= nil then + entry_wsubs = i + break + end + end + + if entry_wsubs ~= nil and + json.entries[entry_wsubs].duration ~= nil then + for j, req in pairs(json.entries[entry_wsubs].requested_subtitles) do + local subfile = "edl://" + for i, entry in pairs(json.entries) do + if entry.requested_subtitles ~= nil and + entry.requested_subtitles[j] ~= nil and + url_is_safe(entry.requested_subtitles[j].url) then + subfile = subfile..edl_escape(entry.requested_subtitles[j].url) + else + subfile = subfile..edl_escape("memory://WEBVTT") + end + subfile = subfile..",length="..entry.duration..";" + end + msg.debug(j.." sub EDL: "..subfile) + mp.commandv("sub-add", subfile, "auto", req.ext, j) + end + end + + elseif self_redirecting_url and #json.entries == 1 then + msg.verbose("Playlist with single entry detected.") + add_single_video(json.entries[1]) + else + local playlist_index = parse_yt_playlist(url, json) + local playlist = {"#EXTM3U"} + for i, entry in pairs(json.entries) do + local site = entry.url + local title = entry.title + + if title ~= nil then + title = string.gsub(title, '%s+', ' ') + table.insert(playlist, "#EXTINF:0," .. title) + end + + --[[ some extractors will still return the full info for + all clips in the playlist and the URL will point + directly to the file in that case, which we don't + want so get the webpage URL instead, which is what + we want, but only if we aren't going to trigger an + infinite loop + --]] + if entry["webpage_url"] and not self_redirecting_url then + site = entry["webpage_url"] + end + + local playlist_url = nil + + -- links without protocol as returned by --flat-playlist + if not site:find("://") then + -- youtube extractor provides only IDs, + -- others come prefixed with the extractor name and ":" + local prefix = site:find(":") and "ytdl://" or + "https://youtu.be/" + playlist_url = prefix .. site + elseif url_is_safe(site) then + playlist_url = site + end + + if playlist_url then + table.insert(playlist, playlist_url) + -- save the cookies in a table for the playlist hook + playlist_cookies[playlist_url] = entry.cookies or json.cookies + end + + end + + if use_playlist and + not option_was_set("playlist-start") and playlist_index then + mp.set_property_number("playlist-start", playlist_index) + end + + mp.set_property("stream-open-filename", "memory://" .. table.concat(playlist, "\n")) + end + + else -- probably a video + add_single_video(json) + end + msg.debug('script running time: '..os.clock()-start_time..' seconds') +end + +if not o.try_ytdl_first then + mp.add_hook("on_load", 10, function () + msg.verbose('ytdl:// hook') + local url = mp.get_property("stream-open-filename", "") + if url:find("ytdl://") ~= 1 then + msg.verbose('not a ytdl:// url') + return + end + run_ytdl_hook(url) + end) +end + +mp.add_hook("on_load", 20, function () + msg.verbose('playlist hook') + local url = mp.get_property("stream-open-filename", "") + if playlist_cookies[url] then + set_cookies(playlist_cookies[url]) + end +end) + +mp.add_hook(o.try_ytdl_first and "on_load" or "on_load_fail", 10, function() + msg.verbose('full hook') + local url = mp.get_property("stream-open-filename", "") + if url:find("ytdl://") ~= 1 and + not ((url:find("https?://") == 1) and not is_blacklisted(url)) then + return + end + run_ytdl_hook(url) +end) + +mp.add_hook("on_preloaded", 10, function () + if next(chapter_list) ~= nil then + msg.verbose("Setting chapters") + + mp.set_property_native("chapter-list", chapter_list) + chapter_list = {} + end +end) |