summaryrefslogtreecommitdiffstats
path: root/.config/mpv/scripts
diff options
context:
space:
mode:
Diffstat (limited to '.config/mpv/scripts')
-rw-r--r--.config/mpv/scripts/quality-menu.lua944
-rw-r--r--.config/mpv/scripts/show_chapters.lua145
-rw-r--r--.config/mpv/scripts/uosc.lua1081
-rw-r--r--.config/mpv/scripts/uosc_shared/elements/BufferingIndicator.lua37
-rw-r--r--.config/mpv/scripts/uosc_shared/elements/Button.lua90
-rw-r--r--.config/mpv/scripts/uosc_shared/elements/Controls.lua329
-rw-r--r--.config/mpv/scripts/uosc_shared/elements/Curtain.lua35
-rw-r--r--.config/mpv/scripts/uosc_shared/elements/CycleButton.lua64
-rw-r--r--.config/mpv/scripts/uosc_shared/elements/Element.lua149
-rw-r--r--.config/mpv/scripts/uosc_shared/elements/Elements.lua158
-rw-r--r--.config/mpv/scripts/uosc_shared/elements/Menu.lua783
-rw-r--r--.config/mpv/scripts/uosc_shared/elements/PauseIndicator.lua80
-rw-r--r--.config/mpv/scripts/uosc_shared/elements/Speed.lua183
-rw-r--r--.config/mpv/scripts/uosc_shared/elements/Timeline.lua394
-rw-r--r--.config/mpv/scripts/uosc_shared/elements/TopBar.lua182
-rw-r--r--.config/mpv/scripts/uosc_shared/elements/Volume.lua240
-rw-r--r--.config/mpv/scripts/uosc_shared/elements/WindowBorder.lua33
-rw-r--r--.config/mpv/scripts/uosc_shared/lib/ass.lua170
-rw-r--r--.config/mpv/scripts/uosc_shared/lib/menus.lua282
-rw-r--r--.config/mpv/scripts/uosc_shared/lib/std.lua181
-rw-r--r--.config/mpv/scripts/uosc_shared/lib/text.lua421
-rw-r--r--.config/mpv/scripts/uosc_shared/lib/utils.lua545
-rw-r--r--.config/mpv/scripts/uosc_shared/main.lua5
23 files changed, 6531 insertions, 0 deletions
diff --git a/.config/mpv/scripts/quality-menu.lua b/.config/mpv/scripts/quality-menu.lua
new file mode 100644
index 0000000..35949d5
--- /dev/null
+++ b/.config/mpv/scripts/quality-menu.lua
@@ -0,0 +1,944 @@
+-- quality-menu 3.0.2 - 2023-Jan-10
+-- https://github.com/christoph-heinrich/mpv-quality-menu
+--
+-- Change the stream video and audio quality on the fly.
+--
+-- Usage:
+-- add bindings to input.conf:
+-- F script-binding quality_menu/video_formats_toggle
+-- Alt+f script-binding quality_menu/audio_formats_toggle
+
+local mp = require 'mp'
+local utils = require 'mp.utils'
+local msg = require 'mp.msg'
+local assdraw = require 'mp.assdraw'
+local opt = require('mp.options')
+
+local opts = {
+ --key bindings
+ up_binding = "UP WHEEL_UP",
+ down_binding = "DOWN WHEEL_DOWN",
+ select_binding = "ENTER MBTN_LEFT",
+ close_menu_binding = "ESC MBTN_RIGHT F Alt+f",
+
+ --youtube-dl version(could be youtube-dl or yt-dlp, or something else)
+ ytdl_ver = "yt-dlp",
+
+ --formatting / cursors
+ selected_and_active = "▶ - ",
+ selected_and_inactive = "● - ",
+ unselected_and_active = "▷ - ",
+ unselected_and_inactive = "○ - ",
+
+ --font size scales by window, if false requires larger font and padding sizes
+ scale_playlist_by_window = true,
+
+ --playlist ass style overrides inside curly brackets, \keyvalue is one field, extra \ for escape in lua
+ --example {\\fnUbuntu\\fs10\\b0\\bord1} equals: font=Ubuntu, size=10, bold=no, border=1
+ --read http://docs.aegisub.org/3.2/ASS_Tags/ for reference of tags
+ --undeclared tags will use default osd settings
+ --these styles will be used for the whole playlist. More specific styling will need to be hacked in
+ --
+ --(a monospaced font is recommended but not required)
+ style_ass_tags = "{\\fnmonospace\\fs25\\bord1}",
+
+ -- Shift drawing coordinates. Required for mpv.net compatiblity
+ shift_x = 0,
+ shift_y = 0,
+
+ --paddings from window edge
+ text_padding_x = 5,
+ text_padding_y = 10,
+
+ --Screen dim when menu is open
+ curtain_opacity = 0.7,
+
+ --how many seconds until the quality menu times out
+ --setting this to 0 deactivates the timeout
+ menu_timeout = 6,
+
+ --use youtube-dl to fetch a list of available formats (overrides quality_strings)
+ fetch_formats = true,
+
+ --default menu entries
+ quality_strings = [[
+ [
+ {"4320p" : "bestvideo[height<=?4320p]+bestaudio/best"},
+ {"2160p" : "bestvideo[height<=?2160]+bestaudio/best"},
+ {"1440p" : "bestvideo[height<=?1440]+bestaudio/best"},
+ {"1080p" : "bestvideo[height<=?1080]+bestaudio/best"},
+ {"720p" : "bestvideo[height<=?720]+bestaudio/best"},
+ {"480p" : "bestvideo[height<=?480]+bestaudio/best"},
+ {"360p" : "bestvideo[height<=?360]+bestaudio/best"},
+ {"240p" : "bestvideo[height<=?240]+bestaudio/best"},
+ {"144p" : "bestvideo[height<=?144]+bestaudio/best"}
+ ]
+ ]],
+
+ --reset ytdl-format to the original format string when changing files (e.g. going to the next playlist entry)
+ --if file was opened previously, reset to previously selected format
+ reset_format = true,
+
+ --automatically fetch available formats when opening an url
+ fetch_on_start = true,
+
+ --show the video format menu after opening an url
+ start_with_menu = false,
+
+ --include unknown formats in the list
+ --Unfortunately choosing which formats are video or audio is not always perfect.
+ --Set to true to make sure you don't miss any formats, but then the list
+ --might also include formats that aren't actually video or audio.
+ --Formats that are known to not be video or audio are still filtered out.
+ include_unknown = false,
+
+ --hide columns that are identical for all formats
+ hide_identical_columns = true,
+
+ --which columns are shown in which order
+ --comma separated list, prefix column with "-" to align left
+ --
+ --columns that might be useful are:
+ --resolution, width, height, fps, dynamic_range, tbr, vbr, abr, asr,
+ --filesize, filesize_approx, vcodec, acodec, ext, video_ext, audio_ext,
+ --language, format, format_note, quality
+ --
+ --columns that are derived from the above, but with special treatment:
+ --size, frame_rate, bitrate_total, bitrate_video, bitrate_audio,
+ --codec_video, codec_audio, audio_sample_rate
+ --
+ --If those still aren't enough or you're just curious, run:
+ --yt-dlp -j <url>
+ --This outputs unformatted JSON.
+ --Format it and look under "formats" to see what's available.
+ --
+ --Not all videos have all columns available.
+ --Be careful, misspelled columns simply won't be displayed, there is no error.
+ columns_video = '-resolution,frame_rate,dynamic_range,language,bitrate_total,size,-codec_video,-codec_audio',
+ columns_audio = 'audio_sample_rate,bitrate_total,size,language,-codec_audio',
+
+ --columns used for sorting, see "columns_video" for available columns
+ --comma separated list, prefix column with "-" to reverse sorting order
+ --Leaving this empty keeps the order from yt-dlp/youtube-dl.
+ --Be careful, misspelled columns won't result in an error,
+ --but they might influence the result.
+ sort_video = 'height,fps,tbr,size,format_id',
+ sort_audio = 'asr,tbr,size,format_id',
+}
+opt.read_options(opts, "quality-menu")
+opts.quality_strings = utils.parse_json(opts.quality_strings)
+
+opts.font_size = tonumber(opts.style_ass_tags:match('\\fs(%d+%.?%d*)')) or mp.get_property_number('osd-font-size') or 25
+opts.curtain_opacity = math.max(math.min(opts.curtain_opacity, 1), 0)
+
+-- special thanks to reload.lua (https://github.com/4e6/mpv-reload/)
+local function reload_resume()
+ local playlist_pos = mp.get_property_number("playlist-pos")
+ local reload_duration = mp.get_property_native("duration")
+ local time_pos = mp.get_property("time-pos")
+
+ mp.set_property_number("playlist-pos", playlist_pos)
+
+ -- Tries to determine live stream vs. pre-recorded VOD. VOD has non-zero
+ -- duration property. When reloading VOD, to keep the current time position
+ -- we should provide offset from the start. Stream doesn't have fixed start.
+ -- Decent choice would be to reload stream from it's current 'live' position.
+ -- That's the reason we don't pass the offset when reloading streams.
+ if reload_duration and reload_duration > 0 then
+ local function seeker()
+ mp.commandv("seek", time_pos, "absolute")
+ mp.unregister_event(seeker)
+ end
+
+ mp.register_event("file-loaded", seeker)
+ end
+end
+
+local ytdl = {
+ path = opts.ytdl_ver,
+ searched = false,
+ blacklisted = {}
+}
+
+local function process_json(json)
+ local function is_video(format)
+ -- "none" means it is not a video
+ -- nil means it is unknown
+ return (opts.include_unknown or format.vcodec) and format.vcodec ~= "none"
+ end
+
+ local function is_audio(format)
+ return (opts.include_unknown or format.acodec) and format.acodec ~= "none"
+ end
+
+ local vfmt = nil
+ local afmt = nil
+ local requested_formats = json["requested_formats"] or json["requested_downloads"]
+ for _, format in ipairs(requested_formats) do
+ if is_video(format) then
+ vfmt = format["format_id"]
+ elseif is_audio(format) then
+ afmt = format["format_id"]
+ end
+ end
+
+ local video_formats = {}
+ local audio_formats = {}
+ local all_formats = {}
+ for i = #json.formats, 1, -1 do
+ local format = json.formats[i]
+ if is_video(format) then
+ video_formats[#video_formats + 1] = format
+ all_formats[#all_formats + 1] = format
+ elseif is_audio(format) then
+ audio_formats[#audio_formats + 1] = format
+ all_formats[#all_formats + 1] = format
+ end
+ end
+
+ local function populate_special_fields(format)
+ format.size = format.filesize or format.filesize_approx
+ format.frame_rate = format.fps
+ format.bitrate_total = format.tbr
+ format.bitrate_video = format.vbr
+ format.bitrate_audio = format.abr
+ format.codec_video = format.vcodec
+ format.codec_audio = format.acodec
+ format.audio_sample_rate = format.asr
+ end
+
+ for _, format in ipairs(all_formats) do
+ populate_special_fields(format)
+ end
+
+ local function strip_minus(list)
+ local stripped_list = {}
+ local had_minus = {}
+ for i, val in ipairs(list) do
+ if string.sub(val, 1, 1) == "-" then
+ val = string.sub(val, 2)
+ had_minus[val] = true
+ end
+ stripped_list[i] = val
+ end
+ return stripped_list, had_minus
+ end
+
+ local function string_split(inputstr, sep)
+ if sep == nil then
+ sep = "%s"
+ end
+ local t = {}
+ for str in string.gmatch(inputstr, "([^" .. sep .. "]+)") do
+ table.insert(t, str)
+ end
+ return t
+ end
+
+ local sort_video, reverse_video = strip_minus(string_split(opts.sort_video, ','))
+ local sort_audio, reverse_audio = strip_minus(string_split(opts.sort_audio, ','))
+
+ local function comp(properties, reverse)
+ return function(a, b)
+ for _, prop in ipairs(properties) do
+ local a_val = a[prop]
+ local b_val = b[prop]
+ if a_val and b_val and type(a_val) ~= 'table' and a_val ~= b_val then
+ if reverse[prop] then
+ return a_val < b_val
+ else
+ return a_val > b_val
+ end
+ end
+ end
+ return false
+ end
+ end
+
+ if #sort_video > 0 then
+ table.sort(video_formats, comp(sort_video, reverse_video))
+ end
+ if #sort_audio > 0 then
+ table.sort(audio_formats, comp(sort_audio, reverse_audio))
+ end
+
+ local function scale_filesize(size)
+ if size == nil then
+ return ""
+ end
+ size = tonumber(size)
+
+ local counter = 0
+ while size > 1024 do
+ size = size / 1024
+ counter = counter + 1
+ end
+
+ if counter >= 3 then return string.format("%.1fGiB", size)
+ elseif counter >= 2 then return string.format("%.1fMiB", size)
+ elseif counter >= 1 then return string.format("%.1fKiB", size)
+ else return string.format("%.1fB ", size)
+ end
+ end
+
+ local function scale_bitrate(br)
+ if br == nil then
+ return ""
+ end
+ br = tonumber(br)
+
+ local counter = 0
+ while br > 1000 do
+ br = br / 1000
+ counter = counter + 1
+ end
+
+ if counter >= 2 then return string.format("%.1fGbps", br)
+ elseif counter >= 1 then return string.format("%.1fMbps", br)
+ else return string.format("%.1fKbps", br)
+ end
+ end
+
+ local function format_special_fields(format)
+ local size_prefix = not format.filesize and format.filesize_approx and "~" or ""
+ format.size = (size_prefix) .. scale_filesize(format.size)
+ format.frame_rate = format.fps and format.fps .. "fps" or ""
+ format.bitrate_total = scale_bitrate(format.tbr)
+ format.bitrate_video = scale_bitrate(format.vbr)
+ format.bitrate_audio = scale_bitrate(format.abr)
+ format.codec_video = format.vcodec == nil and "unknown" or format.vcodec == "none" and "" or format.vcodec
+ format.codec_audio = format.acodec == nil and "unknown" or format.acodec == "none" and "" or format.acodec
+ format.audio_sample_rate = format.asr and tostring(format.asr) .. "Hz" or ""
+ end
+
+ for _, format in ipairs(all_formats) do
+ format_special_fields(format)
+ end
+
+ local function format_table(formats, columns)
+ local function calc_shown_columns()
+ local display_col = {}
+ local column_widths = {}
+ local column_values = {}
+ local columns, column_align_left = strip_minus(columns)
+
+ for _, format in pairs(formats) do
+ for col, prop in ipairs(columns) do
+ local label = tostring(format[prop] or "")
+ format[prop] = label
+
+ if not column_widths[col] or column_widths[col] < label:len() then
+ column_widths[col] = label:len()
+ end
+
+ column_values[col] = column_values[col] or label
+ display_col[col] = display_col[col] or (column_values[col] ~= label)
+ end
+ end
+
+ local show_columns = {}
+ for i, width in ipairs(column_widths) do
+ if width > 0 and not opts.hide_identical_columns or display_col[i] then
+ local prop = columns[i]
+ show_columns[#show_columns + 1] = {
+ prop = prop,
+ width = width,
+ align_left = column_align_left[prop]
+ }
+ end
+ end
+ return show_columns
+ end
+
+ local show_columns = calc_shown_columns()
+
+ local spacing = 2
+ local res = {}
+ for _, f in ipairs(formats) do
+ local row = ''
+ for i, column in ipairs(show_columns) do
+ -- lua errors out with width > 99 ("invalid conversion specification")
+ local width = math.min(column.width * (column.align_left and -1 or 1), 99)
+ row = row .. (i > 1 and string.format('%' .. spacing .. 's', '') or '')
+ .. string.format('%' .. width .. 's', f[column.prop] or "")
+ end
+ res[#res + 1] = { label = row:gsub('%s+$', ''), format = f.format_id }
+ end
+ return res
+ end
+
+ local columns_video = string_split(opts.columns_video, ',')
+ local columns_audio = string_split(opts.columns_audio, ',')
+ local vres = format_table(video_formats, columns_video)
+ local ares = format_table(audio_formats, columns_audio)
+ return vres, ares, vfmt, afmt
+end
+
+local function get_url()
+ local path = mp.get_property("path")
+ if not path then return nil end
+ path = string.gsub(path, "ytdl://", "") -- Strip possible ytdl:// prefix.
+
+ local function is_url(s)
+ -- adapted the regex from
+ -- https://stackoverflow.com/questions/3809401/what-is-a-good-regular-expression-to-match-a-url
+ return nil ~=
+ string.match(path,
+ "^[%w]-://[-a-zA-Z0-9@:%._\\+~#=]+%." ..
+ "[a-zA-Z0-9()][a-zA-Z0-9()]?[a-zA-Z0-9()]?[a-zA-Z0-9()]?[a-zA-Z0-9()]?[a-zA-Z0-9()]?" ..
+ "[-a-zA-Z0-9()@:%_\\+.~#?&/=]*")
+ end
+
+ return is_url(path) and path or nil
+end
+
+local uosc = false
+local url_data = {}
+local function uosc_set_format_counts()
+ if not uosc then return end
+
+ local new_path = get_url()
+ if not new_path then return end
+
+ local data = url_data[new_path]
+ if data then
+ mp.commandv('script-message-to', 'uosc', 'set', 'vformats', #data.voptions)
+ mp.commandv('script-message-to', 'uosc', 'set', 'aformats', #data.aoptions)
+ else
+ mp.commandv('script-message-to', 'uosc', 'set', 'vformats', 0)
+ mp.commandv('script-message-to', 'uosc', 'set', 'aformats', 0)
+ end
+end
+
+local function process_json_string(url, json)
+ local json, err = utils.parse_json(json)
+
+ if (json == nil) then
+ mp.osd_message("fetching formats failed...", 2)
+ if err == nil then err = "unexpected error occurred" end
+ msg.error("failed to parse JSON data: " .. err)
+ return
+ end
+
+ if json.formats == nil then
+ return
+ end
+
+ local vres, ares, vfmt, afmt = process_json(json)
+ url_data[url] = { voptions = vres, aoptions = ares, vfmt = vfmt, afmt = afmt }
+ uosc_set_format_counts()
+ return vres, ares, vfmt, afmt
+end
+
+local function download_formats(url)
+
+ if opts.fetch_on_start and not opts.start_with_menu then
+ msg.info("fetching available formats with youtube-dl...")
+ else
+ mp.osd_message("fetching available formats with youtube-dl...", 60)
+ end
+
+ if not (ytdl.searched) then
+ local ytdl_mcd = mp.find_config_file(opts.ytdl_ver)
+ if not (ytdl_mcd == nil) then
+ msg.verbose("found youtube-dl at: " .. ytdl_mcd)
+ ytdl.path = ytdl_mcd
+ end
+ ytdl.searched = true
+ end
+
+ local function exec(args)
+ msg.debug("Running: " .. table.concat(args, " "))
+ local ret = mp.command_native({
+ name = "subprocess",
+ args = args,
+ capture_stdout = true,
+ capture_stderr = true
+ })
+ return ret.status, ret.stdout, ret, ret.killed_by_us
+ 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
+
+ local ytdl_format = mp.get_property("ytdl-format")
+ local command = nil
+ if (ytdl_format == nil or ytdl_format == "") then
+ command = { ytdl.path, "--no-warnings", "--no-playlist", "-J", url }
+ else
+ command = { ytdl.path, "--no-warnings", "--no-playlist", "-J", "-f", ytdl_format, url }
+ end
+
+ msg.verbose("calling youtube-dl with command: " .. table.concat(command, " "))
+
+ local es, json, result, aborted = exec(command)
+
+ if aborted then
+ return
+ end
+
+ if (es ~= 0) or (json == "") then
+ json = nil
+ end
+
+ if (json == nil) then
+ mp.osd_message("fetching formats failed...", 2)
+ msg.verbose("status:", es)
+ msg.verbose("reason:", result.error_string)
+ msg.verbose("stdout:", result.stdout)
+ msg.verbose("stderr:", result.stderr)
+
+ -- trim our stderr to avoid spurious newlines
+ local 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 not result.killed_by_us then
+ err = err .. "unexpected error occurred"
+ else
+ err = string.format("%s returned '%d'", err, es)
+ end
+ msg.error(err)
+ if string.find(ytdl_err, "yt%-dl%.org/bug") then
+ check_version(ytdl.path)
+ end
+ return
+ end
+
+ msg.verbose("youtube-dl succeeded!")
+ mp.osd_message("", 0)
+
+ local vres, ares, vfmt, afmt = process_json_string(url, json)
+ return vres, ares, vfmt, afmt
+end
+
+local function send_formats_to(type, url, script_name, options, format_id)
+ mp.commandv('script-message-to', script_name, type .. '_formats',
+ url, utils.format_json(options or {}), format_id or '')
+end
+
+local queue_callback_video = {}
+local queue_callback_audio = {}
+local function get_formats()
+
+ local url = get_url()
+ if url == nil then
+ return
+ end
+
+ if url_data[url] then
+ local data = url_data[url]
+ return data.voptions, data.aoptions, data.vfmt, data.afmt, url
+ end
+
+ if opts.fetch_formats == false then
+ local vres = {}
+ for i, v in ipairs(opts.quality_strings) do
+ for k, v2 in pairs(v) do
+ vres[i] = { label = k, format = v2 }
+ end
+ end
+ url_data[url] = { voptions = vres, aoptions = {}, vfmt = nil, afmt = nil }
+ return vres, {}, nil, nil, url
+ end
+
+ local vres, ares, vfmt, afmt = download_formats(url)
+
+ for _, script_name in ipairs(queue_callback_video[url] or {}) do
+ send_formats_to('video', url, script_name, vres, vfmt)
+ end
+ for _, script_name in ipairs(queue_callback_audio[url] or {}) do
+ send_formats_to('audio', url, script_name, ares, afmt)
+ end
+
+ queue_callback_video[url] = nil
+ queue_callback_audio[url] = nil
+ return vres, ares, vfmt, afmt, url
+end
+
+local function format_string(vfmt, afmt)
+ if vfmt and afmt then
+ return vfmt .. "+" .. afmt
+ elseif vfmt then
+ return vfmt
+ elseif afmt then
+ return afmt
+ else
+ return ""
+ end
+end
+
+local function set_format(url, vfmt, afmt)
+ if (url_data[url].vfmt ~= vfmt or url_data[url].afmt ~= afmt) then
+ url_data[url].afmt = afmt
+ url_data[url].vfmt = vfmt
+ if url == mp.get_property("path") then
+ mp.set_property("ytdl-format", format_string(vfmt, afmt))
+ reload_resume()
+ end
+ end
+end
+
+local destroyer = nil
+local function show_menu(isvideo)
+
+ if destroyer then
+ destroyer()
+ end
+
+ local voptions, aoptions, vfmt, afmt, url = get_formats()
+
+ local options
+ local fmt
+ if isvideo then
+ options = voptions
+ fmt = vfmt
+ else
+ options = aoptions
+ fmt = afmt
+ end
+
+ if options == nil then
+ if uosc then
+ if isvideo then
+ mp.commandv('script-binding', 'uosc/video')
+ else
+ mp.commandv('script-binding', 'uosc/audio')
+ end
+ end
+
+ return
+ end
+
+ msg.verbose("current ytdl-format: " .. format_string(vfmt, afmt))
+
+ local active = 0
+ local selected = 1
+ --set the cursor to the current format
+ if fmt then
+ for i, v in ipairs(options) do
+ if v.format == fmt then
+ active = i
+ selected = active
+ break
+ end
+ end
+ else
+ active = #options + 1
+ selected = active
+ end
+
+ if uosc then
+ local menu = {
+ title = isvideo and 'Video Formats' or 'Audio Formats',
+ items = {},
+ type = (isvideo and 'video' or 'audio') .. '_formats',
+ }
+ for i, option in ipairs(options) do
+ menu.items[i] = {
+ title = option.label,
+ active = i == active,
+ value = {
+ 'script-message-to',
+ 'quality_menu',
+ (isvideo and 'video' or 'audio') .. '-format-set',
+ url,
+ option.format
+ }
+ }
+ end
+ menu.items[#menu.items + 1] = {
+ title = 'None',
+ value = {
+ 'script-message-to',
+ 'quality_menu',
+ (isvideo and 'video' or 'audio') .. '-format-set',
+ url
+ }
+ }
+ local json = utils.format_json(menu)
+ mp.commandv('script-message-to', 'uosc', 'open-menu', json)
+ return
+ end
+
+ local function choose_prefix(i)
+ if i == selected and i == active then return opts.selected_and_active
+ elseif i == selected then return opts.selected_and_inactive end
+
+ if i ~= selected and i == active then return opts.unselected_and_active
+ elseif i ~= selected then return opts.unselected_and_inactive end
+ return "> " --shouldn't get here.
+ end
+
+ local width, height
+ local margin_top, margin_bottom = 0, 0
+ local num_options = #options + 1
+
+ local function get_scrolled_lines()
+ local output_height = height - opts.text_padding_y * 2 - margin_top * height - margin_bottom * height
+ local screen_lines = math.max(math.floor(output_height / opts.font_size), 1)
+ local max_scroll = math.max(num_options - screen_lines, 0)
+ return math.min(math.max(selected - math.ceil(screen_lines / 2), 0), max_scroll)
+ end
+
+ local function draw_menu()
+ local ass = assdraw.ass_new()
+
+ if opts.curtain_opacity > 0 then
+ local alpha = 255 - math.ceil(255 * opts.curtain_opacity)
+ ass.text = string.format('{\\pos(0,0)\\rDefault\\an7\\1c&H000000&\\alpha&H%X&}', alpha)
+ ass:draw_start()
+ ass:rect_cw(0, 0, width, height)
+ ass:draw_stop()
+ ass:new_event()
+ end
+
+ local scrolled_lines = get_scrolled_lines()
+ local pos_y = opts.shift_y + margin_top * height + opts.text_padding_y - scrolled_lines * opts.font_size
+ ass:pos(opts.shift_x + opts.text_padding_x, pos_y)
+ local clip_top = math.floor(margin_top * height + 0.5)
+ local clip_bottom = math.floor((1 - margin_bottom) * height + 0.5)
+ local clipping_coordinates = '0,' .. clip_top .. ',' .. width .. ',' .. clip_bottom
+ ass:append(opts.style_ass_tags .. '{\\q2\\clip(' .. clipping_coordinates .. ')}')
+
+ if #options > 0 then
+ for i, v in ipairs(options) do
+ ass:append(choose_prefix(i) .. v.label .. "\\N")
+ end
+ ass:append(choose_prefix(#options + 1) .. "None")
+ else
+ ass:append("no formats found")
+ end
+
+ mp.set_osd_ass(width, height, ass.text)
+ end
+
+ local function update_dimensions()
+ local _, h, aspect = mp.get_osd_size()
+ if opts.scale_playlist_by_window then h = 720 end
+ height = h
+ width = h * aspect
+ draw_menu()
+ end
+
+ local function update_margins()
+ local shared_props = mp.get_property_native('shared-script-properties')
+ local val = shared_props['osc-margins']
+ if val then
+ -- formatted as "%f,%f,%f,%f" with left, right, top, bottom, each
+ -- value being the border size as ratio of the window size (0.0-1.0)
+ local vals = {}
+ for v in string.gmatch(val, "[^,]+") do
+ vals[#vals + 1] = tonumber(v)
+ end
+ margin_top = vals[3] -- top
+ margin_bottom = vals[4] -- bottom
+ else
+ margin_top = 0
+ margin_bottom = 0
+ end
+ draw_menu()
+ end
+
+ update_dimensions()
+ update_margins()
+ mp.observe_property('osd-dimensions', 'native', update_dimensions)
+ mp.observe_property('shared-script-properties', 'native', update_margins)
+
+ local timeout = nil
+
+ local function selected_move(amt)
+ selected = selected + amt
+ if selected < 1 then selected = num_options
+ elseif selected > num_options then selected = 1 end
+ if timeout then
+ timeout:kill()
+ timeout:resume()
+ end
+ draw_menu()
+ end
+
+ local function bind_keys(keys, name, func, opts)
+ if not keys then
+ mp.add_forced_key_binding(keys, name, func, opts)
+ return
+ end
+ local i = 1
+ for key in keys:gmatch("[^%s]+") do
+ local prefix = i == 1 and '' or i
+ mp.add_forced_key_binding(key, name .. prefix, func, opts)
+ i = i + 1
+ end
+ end
+
+ local function unbind_keys(keys, name)
+ if not keys then
+ mp.remove_key_binding(name)
+ return
+ end
+ local i = 1
+ for key in keys:gmatch("[^%s]+") do
+ local prefix = i == 1 and '' or i
+ mp.remove_key_binding(name .. prefix)
+ i = i + 1
+ end
+ end
+
+ local function destroy()
+ if timeout then
+ timeout:kill()
+ end
+ mp.set_osd_ass(0, 0, "")
+ unbind_keys(opts.up_binding, "move_up")
+ unbind_keys(opts.down_binding, "move_down")
+ unbind_keys(opts.select_binding, "select")
+ unbind_keys(opts.close_menu_binding, "close")
+ mp.unobserve_property(update_dimensions)
+ mp.unobserve_property(update_margins)
+ destroyer = nil
+ end
+
+ if opts.menu_timeout > 0 then
+ timeout = mp.add_periodic_timer(opts.menu_timeout, destroy)
+ end
+ destroyer = destroy
+
+ bind_keys(opts.up_binding, "move_up", function() selected_move(-1) end, { repeatable = true })
+ bind_keys(opts.down_binding, "move_down", function() selected_move(1) end, { repeatable = true })
+ if #options > 0 then
+ bind_keys(opts.select_binding, "select", function()
+ destroy()
+ if selected == active then return end
+
+ fmt = options[selected] and options[selected].format or nil
+ if isvideo then
+ vfmt = fmt
+ else
+ afmt = fmt
+ end
+ set_format(url, vfmt, afmt)
+ end)
+ end
+ bind_keys(opts.close_menu_binding, "close", destroy) --close menu using ESC
+ mp.osd_message("", 0)
+ draw_menu()
+end
+
+local ui_callback = {}
+
+local function video_formats_toggle()
+ if #ui_callback > 0 then
+ for _, name in ipairs(ui_callback) do
+ mp.commandv('script-message-to', name, 'video-formats-menu')
+ end
+ else
+ show_menu(true)
+ end
+end
+
+local function audio_formats_toggle()
+ if #ui_callback > 0 then
+ for _, name in ipairs(ui_callback) do
+ mp.commandv('script-message-to', name, 'audio-formats-menu')
+ end
+ else
+ show_menu(false)
+ end
+end
+
+-- keybind to launch menu
+mp.add_key_binding(nil, "video_formats_toggle", video_formats_toggle)
+mp.add_key_binding(nil, "audio_formats_toggle", audio_formats_toggle)
+mp.add_key_binding(nil, "reload", reload_resume)
+
+local original_format = mp.get_property("ytdl-format")
+local path = nil
+local function file_start()
+ uosc_set_format_counts()
+
+ local new_path = get_url()
+ if not new_path then return end
+
+ local data = url_data[new_path]
+
+ if opts.reset_format and path and new_path ~= path then
+ if data then
+ msg.verbose("setting previously set format")
+ mp.set_property("ytdl-format", format_string(data.vfmt, data.afmt))
+ else
+ msg.verbose("setting original format")
+ mp.set_property("ytdl-format", original_format)
+ end
+ end
+ if opts.start_with_menu and new_path ~= path then
+ video_formats_toggle()
+ elseif opts.fetch_on_start and not data then
+ download_formats(new_path)
+ end
+ path = new_path
+end
+
+mp.register_event("start-file", file_start)
+
+mp.register_script_message('video-formats-get', function(url, script_name)
+ local data = url_data[url]
+ if data then
+ send_formats_to('video', url, script_name, data.voptions, data.vfmt)
+ else
+ local queue = queue_callback_video[url] or {}
+ queue[#queue + 1] = script_name
+ queue_callback_video[url] = queue
+ get_formats()
+ end
+end)
+
+mp.register_script_message('audio-formats-get', function(url, script_name)
+ local data = url_data[url]
+ if data then
+ send_formats_to('audio', url, script_name, data.aoptions, data.afmt)
+ else
+ local queue = queue_callback_audio[url] or {}
+ queue[#queue + 1] = script_name
+ queue_callback_audio[url] = queue
+ get_formats()
+ end
+end)
+
+mp.register_script_message('video-format-set', function(url, format_id)
+ set_format(url, format_id, url_data[url].afmt)
+end)
+
+mp.register_script_message('audio-format-set', function(url, format_id)
+ set_format(url, url_data[url].vfmt, format_id)
+end)
+
+mp.register_script_message('register-ui', function(script_name)
+ ui_callback[#ui_callback + 1] = script_name
+end)
+
+-- check if uosc is running
+mp.register_script_message('uosc-version', function(version)
+ version = tonumber((version:gsub('%.', '')))
+ ---@diagnostic disable-next-line: cast-local-type
+ uosc = version and version >= 400
+ uosc_set_format_counts()
+end)
+mp.commandv('script-message-to', 'uosc', 'get-version', mp.get_script_name())
diff --git a/.config/mpv/scripts/show_chapters.lua b/.config/mpv/scripts/show_chapters.lua
new file mode 100644
index 0000000..643a51f
--- /dev/null
+++ b/.config/mpv/scripts/show_chapters.lua
@@ -0,0 +1,145 @@
+-- show chapters and their time at the bottom left corner
+-- the timings get proportionally adjusted at different speeds
+
+----------------------------------------
+CHAPTERS_TO_SHOW = 10
+HOTKEY = 'c'
+
+FONT_SCALE = 80 -- in % from osd-font-size
+FONT_SCALE_inactive_chapters = 75
+FONT_ALPHA_inactive_chapters = 60
+
+AUTOSTART_IN_PATHS = {
+ "^edl://",
+ "/med/p/podcasts",
+ "/abooks/"
+}
+----------------------------------------
+
+local assdraw = require('mp.assdraw')
+
+function disp_time(time)
+ local hours = math.floor(time/3600)
+ local minutes = math.floor((time % 3600)/60)
+ local seconds = math.floor(time % 60)
+
+ return string.format("%02d:%02d:%02d", hours, minutes, seconds)
+end
+
+function time_2_seconds(time)
+ h,m,s = time:match('(.*):(.*):(.*)$')
+
+ return h*3600 + m*60 + s
+end
+
+function show_chapters()
+ if observation_active == false then
+ observation_active = true
+ mp.observe_property("chapter", "number", show_chapters)
+ mp.observe_property("speed", "number", show_chapters)
+ end
+
+ local osd_w, osd_h, aspect = mp.get_osd_size()
+ local ass = assdraw:ass_new()
+ ass:new_event()
+ ass:an(1)
+
+ -- font scale
+ ass:append('{\\fscx' .. tostring(FONT_SCALE) .. '}')
+ ass:append('{\\fscy' .. tostring(FONT_SCALE) .. '}')
+
+ local ch_index = mp.get_property_number("chapter")
+ local ch_total = mp.get_property_osd("chapter-list/count")
+
+ if ch_index and ch_index >= 0 then
+ ass:append("(" .. tostring(ch_index + 1) .. "/" .. tostring(ch_total) .. ")")
+
+ shift = 1
+ if ch_index == 0 then
+ start_from = 0
+ else
+ shift = ch_total - CHAPTERS_TO_SHOW - ch_index - 1
+ start_from = -1
+ end
+
+ if shift < 0 then
+ start_from = shift
+ end
+
+ for i_ch = start_from, CHAPTERS_TO_SHOW + start_from do
+ if tonumber(ch_total) == ch_index + i_ch then
+ break
+ end
+
+ -- from overshooting backwards
+ if ch_index + i_ch < 0 then
+ goto continue
+ end
+
+ title = mp.get_property_osd("chapter-list/" .. tostring(ch_index + i_ch) .. "/title")
+ time = mp.get_property_osd("chapter-list/" .. tostring(ch_index + i_ch) .. "/time")
+
+ ass:append("\\N")
+
+ if i_ch == 0 then
+ ass:append("{\\alpha&H" .. '00' .. "}")
+ ass:append('{\\fscx' .. tostring(FONT_SCALE) .. '}')
+ ass:append('{\\fscy' .. tostring(FONT_SCALE) .. '}')
+ else
+ ass:append("{\\alpha&H" .. tostring(FONT_ALPHA_inactive_chapters) .. "}")
+ ass:append('{\\fscx' .. tostring(FONT_SCALE_inactive_chapters) .. '}')
+ ass:append('{\\fscy' .. tostring(FONT_SCALE_inactive_chapters) .. '}')
+ end
+
+ -- removing paths
+ if string.sub(title, 1, 1) == '/' then
+ title = title:match('.+/(.*)$')
+ end
+
+ speed = mp.get_property_number("speed")
+ if speed ~= 1.0 then
+ time = disp_time(time_2_seconds(time) / speed)
+ end
+
+ ass:append('[' .. time .. "] " .. title)
+
+ ::continue::
+ end
+ end
+
+ mp.set_osd_ass(osd_w, osd_h, ass.text)
+end
+
+function started()
+ local pth = mp.get_property("path")
+
+ if pth == nil then
+ return
+ end
+
+ for i, i_path in pairs(AUTOSTART_IN_PATHS) do
+ if pth:find(i_path) then
+ running = true
+ show_chapters()
+ return
+ end
+ end
+end
+
+function show_hide()
+ if running == true then
+ running = false
+ observation_active = false
+ mp.set_osd_ass(0, 0, "{}")
+ mp.unobserve_property(show_chapters)
+ else
+ running = true
+ show_chapters()
+ end
+end
+
+observation_active = false
+
+mp.register_event("file-loaded", started)
+mp.add_forced_key_binding(HOTKEY, 'show_hide', show_hide, {repeatable=true})
+
diff --git a/.config/mpv/scripts/uosc.lua b/.config/mpv/scripts/uosc.lua
new file mode 100644
index 0000000..4c9abdb
--- /dev/null
+++ b/.config/mpv/scripts/uosc.lua
@@ -0,0 +1,1081 @@
+--[[ uosc 4.5.0 - 2022-Dec-07 | https://github.com/tomasklaen/uosc ]]
+local uosc_version = '4.5.0'
+
+assdraw = require('mp.assdraw')
+opt = require('mp.options')
+utils = require('mp.utils')
+msg = require('mp.msg')
+osd = mp.create_osd_overlay('ass-events')
+infinity = 1e309
+quarter_pi_sin = math.sin(math.pi / 4)
+
+-- Enables relative requires from `scripts` directory
+package.path = package.path .. ';' .. mp.find_config_file('scripts') .. '/?.lua'
+
+require('uosc_shared/lib/std')
+
+--[[ OPTIONS ]]
+
+defaults = {
+ timeline_style = 'line',
+ timeline_line_width = 2,
+ timeline_line_width_fullscreen = 3,
+ timeline_line_width_minimized_scale = 10,
+ timeline_size_min = 2,
+ timeline_size_max = 40,
+ timeline_size_min_fullscreen = 0,
+ timeline_size_max_fullscreen = 60,
+ timeline_start_hidden = false,
+ timeline_persistency = 'paused',
+ timeline_opacity = 0.9,
+ timeline_border = 1,
+ timeline_step = 5,
+ timeline_chapters_opacity = 0.8,
+ timeline_cache = true,
+
+ controls = 'menu,gap,subtitles,<has_many_audio>audio,<has_many_video>video,<has_many_edition>editions,<stream>stream-quality,gap,space,speed,space,shuffle,loop-playlist,loop-file,gap,prev,items,next,gap,fullscreen',
+ controls_size = 32,
+ controls_size_fullscreen = 40,
+ controls_margin = 8,
+ controls_spacing = 2,
+ controls_persistency = '',
+
+ volume = 'right',
+ volume_size = 40,
+ volume_size_fullscreen = 52,
+ volume_persistency = '',
+ volume_opacity = 0.9,
+ volume_border = 1,
+ volume_step = 1,
+
+ speed_persistency = '',
+ speed_opacity = 0.6,
+ speed_step = 0.1,
+ speed_step_is_factor = false,
+
+ menu_item_height = 36,
+ menu_item_height_fullscreen = 50,
+ menu_min_width = 260,
+ menu_min_width_fullscreen = 360,
+ menu_opacity = 1,
+ menu_parent_opacity = 0.4,
+
+ top_bar = 'no-border',
+ top_bar_size = 40,
+ top_bar_size_fullscreen = 46,
+ top_bar_persistency = '',
+ top_bar_controls = true,
+ top_bar_title = true,
+ top_bar_title_opacity = 0.8,
+
+ window_border_size = 1,
+ window_border_opacity = 0.8,
+
+ autoload = false,
+ shuffle = false,
+
+ ui_scale = 1,
+ font_scale = 1,
+ text_border = 1.2,
+ text_width_estimation = true,
+ pause_on_click_shorter_than = 0, -- deprecated by below
+ click_threshold = 0,
+ click_command = 'cycle pause; script-binding uosc/flash-pause-indicator',
+ flash_duration = 1000,
+ proximity_in = 40,
+ proximity_out = 120,
+ foreground = 'ffffff',
+ foreground_text = '000000',
+ background = '000000',
+ background_text = 'ffffff',
+ total_time = false,
+ time_precision = 0,
+ font_bold = false,
+ autohide = false,
+ buffered_time_threshold = 60,
+ pause_indicator = 'flash',
+ curtain_opacity = 0.5,
+ stream_quality_options = '4320,2160,1440,1080,720,480,360,240,144',
+ media_types = '3g2,3gp,aac,aiff,ape,apng,asf,au,avi,avif,bmp,dsf,f4v,flac,flv,gif,h264,h265,j2k,jp2,jfif,jpeg,jpg,jxl,m2ts,m4a,m4v,mid,midi,mj2,mka,mkv,mov,mp3,mp4,mp4a,mp4v,mpeg,mpg,oga,ogg,ogm,ogv,opus,png,rm,rmvb,spx,svg,tak,tga,tta,tif,tiff,ts,vob,wav,weba,webm,webp,wma,wmv,wv,y4m',
+ subtitle_types = 'aqt,ass,gsub,idx,jss,lrc,mks,pgs,pjs,psb,rt,slt,smi,sub,sup,srt,ssa,ssf,ttxt,txt,usf,vt,vtt',
+ default_directory = '~/',
+ chapter_ranges = 'openings:30abf964,endings:30abf964,ads:c54e4e80',
+ chapter_range_patterns = 'openings:オープニング;endings:エンディング',
+}
+options = table_shallow_copy(defaults)
+opt.read_options(options, 'uosc')
+-- Normalize values
+options.proximity_out = math.max(options.proximity_out, options.proximity_in + 1)
+if options.chapter_ranges:sub(1, 4) == '^op|' then options.chapter_ranges = defaults.chapter_ranges end
+if options.pause_on_click_shorter_than > 0 and options.click_threshold == 0 then
+ msg.warn('`pause_on_click_shorter_than` is deprecated. Use `click_threshold` and `click_command` instead.')
+ options.click_threshold = options.pause_on_click_shorter_than
+end
+-- Ensure required environment configuration
+if options.autoload then mp.commandv('set', 'keep-open-pause', 'no') end
+-- Color shorthands
+fg, bg = serialize_rgba(options.foreground).color, serialize_rgba(options.background).color
+fgt, bgt = serialize_rgba(options.foreground_text).color, serialize_rgba(options.background_text).color
+
+--[[ CONFIG ]]
+
+function create_default_menu()
+ return {
+ {title = 'Subtitles', value = 'script-binding uosc/subtitles'},
+ {title = 'Audio tracks', value = 'script-binding uosc/audio'},
+ {title = 'Stream quality', value = 'script-binding uosc/stream-quality'},
+ {title = 'Playlist', value = 'script-binding uosc/items'},
+ {title = 'Chapters', value = 'script-binding uosc/chapters'},
+ {title = 'Navigation', items = {
+ {title = 'Next', hint = 'playlist or file', value = 'script-binding uosc/next'},
+ {title = 'Prev', hint = 'playlist or file', value = 'script-binding uosc/prev'},
+ {title = 'Delete file & Next', value = 'script-binding uosc/delete-file-next'},
+ {title = 'Delete file & Prev', value = 'script-binding uosc/delete-file-prev'},
+ {title = 'Delete file & Quit', value = 'script-binding uosc/delete-file-quit'},
+ {title = 'Open file', value = 'script-binding uosc/open-file'},
+ },},
+ {title = 'Utils', items = {
+ {title = 'Aspect ratio', items = {
+ {title = 'Default', value = 'set video-aspect-override "-1"'},
+ {title = '16:9', value = 'set video-aspect-override "16:9"'},
+ {title = '4:3', value = 'set video-aspect-override "4:3"'},
+ {title = '2.35:1', value = 'set video-aspect-override "2.35:1"'},
+ },},
+ {title = 'Audio devices', value = 'script-binding uosc/audio-device'},
+ {title = 'Editions', value = 'script-binding uosc/editions'},
+ {title = 'Screenshot', value = 'async screenshot'},
+ {title = 'Show in directory', value = 'script-binding uosc/show-in-directory'},
+ {title = 'Open config folder', value = 'script-binding uosc/open-config-directory'},
+ },},
+ {title = 'Quit', value = 'quit'},
+ }
+end
+
+config = {
+ version = uosc_version,
+ -- sets max rendering frequency in case the
+ -- native rendering frequency could not be detected
+ render_delay = 1 / 60,
+ font = mp.get_property('options/osd-font'),
+ media_types = split(options.media_types, ' *, *'),
+ subtitle_types = split(options.subtitle_types, ' *, *'),
+ stream_quality_options = split(options.stream_quality_options, ' *, *'),
+ menu_items = (function()
+ local input_conf_property = mp.get_property_native('input-conf')
+ local input_conf_path = mp.command_native({
+ 'expand-path', input_conf_property == '' and '~~/input.conf' or input_conf_property,
+ })
+ local input_conf_meta, meta_error = utils.file_info(input_conf_path)
+
+ -- File doesn't exist
+ if not input_conf_meta or not input_conf_meta.is_file then return create_default_menu() end
+
+ local main_menu = {items = {}, items_by_command = {}}
+ local by_id = {}
+
+ for line in io.lines(input_conf_path) do
+ local key, command, comment = string.match(line, '%s*([%S]+)%s+(.-)%s+#%s*(.-)%s*$')
+ local title = ''
+ if comment then
+ local comments = split(comment, '#')
+ local titles = itable_filter(comments, function(v, i) return v:match('^!') or v:match('^menu:') end)
+ if titles and #titles > 0 then
+ title = titles[1]:match('^!%s*(.*)%s*') or titles[1]:match('^menu:%s*(.*)%s*')
+ end
+ end
+ if title ~= '' then
+ local is_dummy = key:sub(1, 1) == '#'
+ local submenu_id = ''
+ local target_menu = main_menu
+ local title_parts = split(title or '', ' *> *')
+
+ for index, title_part in ipairs(#title_parts > 0 and title_parts or {''}) do
+ if index < #title_parts then
+ submenu_id = submenu_id .. title_part
+
+ if not by_id[submenu_id] then
+ local items = {}
+ by_id[submenu_id] = {items = items, items_by_command = {}}
+ target_menu.items[#target_menu.items + 1] = {title = title_part, items = items}
+ end
+
+ target_menu = by_id[submenu_id]
+ else
+ if command == 'ignore' then break end
+ -- If command is already in menu, just append the key to it
+ if target_menu.items_by_command[command] then
+ local hint = target_menu.items_by_command[command].hint
+ target_menu.items_by_command[command].hint = hint and hint .. ', ' .. key or key
+ else
+ local item = {
+ title = title_part,
+ hint = not is_dummy and key or nil,
+ value = command,
+ }
+ target_menu.items_by_command[command] = item
+ target_menu.items[#target_menu.items + 1] = item
+ end
+ end
+ end
+ end
+ end
+
+ if #main_menu.items > 0 then
+ return main_menu.items
+ else
+ -- Default context menu
+ return create_default_menu()
+ end
+ end)(),
+ chapter_ranges = (function()
+ ---@type table<string, string[]> Alternative patterns.
+ local alt_patterns = {}
+ if options.chapter_range_patterns and options.chapter_range_patterns ~= '' then
+ for _, definition in ipairs(split(options.chapter_range_patterns, ';+ *')) do
+ local name_patterns = split(definition, ' *:')
+ local name, patterns = name_patterns[1], name_patterns[2]
+ if name and patterns then alt_patterns[name] = split(patterns, ',') end
+ end
+ end
+
+ ---@type table<string, {color: string; opacity: number; patterns?: string[]}>
+ local ranges = {}
+ if options.chapter_ranges and options.chapter_ranges ~= '' then
+ for _, definition in ipairs(split(options.chapter_ranges, ' *,+ *')) do
+ local name_color = split(definition, ' *:+ *')
+ local name, color = name_color[1], name_color[2]
+ if name and color
+ and name:match('^[a-zA-Z0-9_]+$') and color:match('^[a-fA-F0-9]+$')
+ and (#color == 6 or #color == 8) then
+ local range = serialize_rgba(name_color[2])
+ range.patterns = alt_patterns[name]
+ ranges[name_color[1]] = range
+ end
+ end
+ end
+ return ranges
+ end)(),
+}
+-- Adds `{element}_persistency` property with table of flags when the element should be visible (`{paused = true}`)
+for _, name in ipairs({'timeline', 'controls', 'volume', 'top_bar', 'speed'}) do
+ local option_name = name .. '_persistency'
+ local value, flags = options[option_name], {}
+ if type(value) == 'string' then
+ for _, state in ipairs(split(value, ' *, *')) do flags[state] = true end
+ end
+ config[option_name] = flags
+end
+
+--[[ STATE ]]
+
+display = {width = 1280, height = 720, scale_x = 1, scale_y = 1, initialized = false}
+cursor = {hidden = true, x = 0, y = 0}
+state = {
+ os = (function()
+ if os.getenv('windir') ~= nil then return 'windows' end
+ local homedir = os.getenv('HOME')
+ if homedir ~= nil and string.sub(homedir, 1, 6) == '/Users' then return 'macos' end
+ return 'linux'
+ end)(),
+ cwd = mp.get_property('working-directory'),
+ path = nil, -- current file path or URL
+ title = nil,
+ time = nil, -- current media playback time
+ speed = 1,
+ duration = nil, -- current media duration
+ time_human = nil, -- current playback time in human format
+ duration_or_remaining_time_human = nil, -- depends on options.total_time
+ pause = mp.get_property_native('pause'),
+ chapters = {},
+ current_chapter = nil,
+ chapter_ranges = {},
+ border = mp.get_property_native('border'),
+ fullscreen = mp.get_property_native('fullscreen'),
+ maximized = mp.get_property_native('window-maximized'),
+ fullormaxed = mp.get_property_native('fullscreen') or mp.get_property_native('window-maximized'),
+ render_timer = nil,
+ render_last_time = 0,
+ volume = nil,
+ volume_max = nil,
+ mute = nil,
+ is_idle = false,
+ is_video = false,
+ is_audio = false, -- true if file is audio only (mp3, etc)
+ is_image = false,
+ is_stream = false,
+ has_audio = false,
+ has_sub = false,
+ has_chapter = false,
+ has_playlist = false,
+ shuffle = options.shuffle,
+ cursor_autohide_timer = mp.add_timeout(mp.get_property_native('cursor-autohide') / 1000, function()
+ if not options.autohide then return end
+ handle_mouse_leave()
+ end),
+ mouse_bindings_enabled = false,
+ uncached_ranges = nil,
+ cache = nil,
+ cache_buffering = 100,
+ cache_underrun = false,
+ core_idle = false,
+ eof_reached = false,
+ render_delay = config.render_delay,
+ first_real_mouse_move_received = false,
+ playlist_count = 0,
+ playlist_pos = 0,
+ margin_top = 0,
+ margin_bottom = 0,
+ hidpi_scale = 1,
+}
+thumbnail = {width = 0, height = 0, disabled = false}
+external = {} -- Properties set by external scripts
+Elements = require('uosc_shared/elements/Elements')
+Menu = require('uosc_shared/elements/Menu')
+
+-- State dependent utilities
+require('uosc_shared/lib/utils')
+require('uosc_shared/lib/text')
+require('uosc_shared/lib/ass')
+require('uosc_shared/lib/menus')
+
+--[[ STATE UPDATERS ]]
+
+function update_display_dimensions()
+ local scale = (state.hidpi_scale or 1) * options.ui_scale
+ local real_width, real_height = mp.get_osd_size()
+ if real_width <= 0 then return end
+ local scaled_width, scaled_height = round(real_width / scale), round(real_height / scale)
+ display.width, display.height = scaled_width, scaled_height
+ display.scale_x, display.scale_y = real_width / scaled_width, real_height / scaled_height
+ display.initialized = true
+
+ -- Tell elements about this
+ Elements:trigger('display')
+
+ -- Some elements probably changed their rectangles as a reaction to `display`
+ Elements:update_proximities()
+ request_render()
+end
+
+function update_fullormaxed()
+ state.fullormaxed = state.fullscreen or state.maximized
+ update_display_dimensions()
+ Elements:trigger('prop_fullormaxed', state.fullormaxed)
+end
+
+function update_human_times()
+ if state.time then
+ state.time_human = format_time(state.time)
+ if state.duration then
+ local speed = state.speed or 1
+ state.duration_or_remaining_time_human = format_time(
+ options.total_time and state.duration or ((state.time - state.duration) / speed)
+ )
+ else
+ state.duration_or_remaining_time_human = nil
+ end
+ else
+ state.time_human = nil
+ end
+end
+
+-- Notifies other scripts such as console about where the unoccupied parts of the screen are.
+function update_margins()
+ -- margins are normalized to window size
+ local timeline, top_bar, controls = Elements.timeline, Elements.top_bar, Elements.controls
+ local bottom_y = controls and controls.enabled and controls.ay or timeline.ay
+ local top, bottom = 0, (display.height - bottom_y) / display.height
+
+ if top_bar.enabled and top_bar:get_visibility() > 0 then
+ top = (top_bar.size or 0) / display.height
+ end
+
+ if top == state.margin_top and bottom == state.margin_bottom then return end
+
+ state.margin_top = top
+ state.margin_bottom = bottom
+
+ utils.shared_script_property_set('osc-margins', string.format('%f,%f,%f,%f', 0, 0, top, bottom))
+end
+function create_state_setter(name, callback)
+ return function(_, value)
+ set_state(name, value)
+ if callback then callback() end
+ request_render()
+ end
+end
+
+function set_state(name, value)
+ state[name] = value
+ Elements:trigger('prop_' .. name, value)
+end
+
+function update_cursor_position(x, y)
+ -- mpv reports initial mouse position on linux as (0, 0), which always
+ -- displays the top bar, so we hardcode cursor position as infinity until
+ -- we receive a first real mouse move event with coordinates other than 0,0.
+ if not state.first_real_mouse_move_received then
+ if x > 0 and y > 0 then state.first_real_mouse_move_received = true
+ else x, y = infinity, infinity end
+ end
+
+ -- add 0.5 to be in the middle of the pixel
+ cursor.x, cursor.y = (x + 0.5) / display.scale_x, (y + 0.5) / display.scale_y
+
+ Elements:update_proximities()
+ request_render()
+end
+
+function handle_mouse_leave()
+ -- Slowly fadeout elements that are currently visible
+ for _, element_name in ipairs({'timeline', 'volume', 'top_bar'}) do
+ local element = Elements[element_name]
+ if element and element.proximity > 0 then
+ element:tween_property('forced_visibility', element:get_visibility(), 0, function()
+ element.forced_visibility = nil
+ end)
+ end
+ end
+
+ cursor.hidden = true
+ Elements:update_proximities()
+ Elements:trigger('global_mouse_leave')
+end
+
+function handle_mouse_enter(x, y)
+ cursor.hidden = false
+ update_cursor_position(x, y)
+ Elements:trigger('global_mouse_enter')
+end
+
+function handle_mouse_move(x, y)
+ update_cursor_position(x, y)
+ Elements:proximity_trigger('mouse_move')
+ request_render()
+
+ -- Restart timer that hides UI when mouse is autohidden
+ if options.autohide then
+ state.cursor_autohide_timer:kill()
+ state.cursor_autohide_timer:resume()
+ end
+end
+
+function handle_file_end()
+ local resume = false
+ if not state.loop_file then
+ if state.has_playlist then resume = state.shuffle and navigate_playlist(1)
+ else resume = options.autoload and navigate_directory(1) end
+ end
+ -- Resume only when navigation happened
+ if resume then mp.command('set pause no') end
+end
+local file_end_timer = mp.add_timeout(1, handle_file_end)
+file_end_timer:kill()
+
+function load_file_index_in_current_directory(index)
+ if not state.path or is_protocol(state.path) then return end
+
+ local serialized = serialize_path(state.path)
+ if serialized and serialized.dirname then
+ local files = read_directory(serialized.dirname, config.media_types)
+
+ if not files then return end
+ sort_filenames(files)
+ if index < 0 then index = #files + index + 1 end
+
+ if files[index] then
+ mp.commandv('loadfile', join_path(serialized.dirname, files[index]))
+ end
+ end
+end
+
+function update_render_delay(name, fps)
+ if fps then state.render_delay = 1 / fps end
+end
+
+function observe_display_fps(name, fps)
+ if fps then
+ mp.unobserve_property(update_render_delay)
+ mp.unobserve_property(observe_display_fps)
+ mp.observe_property('display-fps', 'native', update_render_delay)
+ end
+end
+
+function select_current_chapter()
+ local current_chapter
+ if state.time and state.chapters then
+ _, current_chapter = itable_find(state.chapters, function(c) return state.time >= c.time end, true)
+ end
+ set_state('current_chapter', current_chapter)
+end
+
+--[[ STATE HOOKS ]]
+
+-- Click detection
+if options.click_threshold > 0 then
+ -- Executes custom command for clicks shorter than `options.click_threshold`
+ -- while filtering out double clicks.
+ local click_time = options.click_threshold / 1000
+ local doubleclick_time = mp.get_property_native('input-doubleclick-time') / 1000
+ local last_down, last_up = 0, 0
+ local click_timer = mp.add_timeout(math.max(click_time, doubleclick_time), function()
+ local delta = last_up - last_down
+ if delta > 0 and delta < click_time and delta > 0.02 then mp.command(options.click_command) end
+ end)
+ click_timer:kill()
+ mp.set_key_bindings({{'mbtn_left',
+ function() last_up = mp.get_time() end,
+ function()
+ last_down = mp.get_time()
+ if click_timer:is_enabled() then click_timer:kill() else click_timer:resume() end
+ end,
+ },}, 'mouse_movement', 'force')
+ mp.enable_key_bindings('mouse_movement', 'allow-vo-dragging+allow-hide-cursor')
+end
+
+function update_mouse_pos(_, mouse, ignore_hover)
+ if ignore_hover or mouse.hover then
+ if cursor.hidden then handle_mouse_enter(mouse.x, mouse.y) end
+ handle_mouse_move(mouse.x, mouse.y)
+ else handle_mouse_leave() end
+end
+mp.observe_property('mouse-pos', 'native', update_mouse_pos)
+mp.observe_property('osc', 'bool', function(name, value) if value == true then mp.set_property('osc', 'no') end end)
+mp.register_event('file-loaded', function()
+ set_state('path', normalize_path(mp.get_property_native('path')))
+end)
+mp.register_event('end-file', function(event)
+ if event.reason == 'eof' then
+ file_end_timer:kill()
+ handle_file_end()
+ end
+end)
+do
+ local template = nil
+ function update_title()
+ if template:sub(-6) == ' - mpv' then template = template:sub(1, -7) end
+ -- escape ASS, and strip newlines and trailing slashes and trim whitespace
+ local t = mp.command_native({'expand-text', template}):gsub('\\n', ' '):gsub('[\\%s]+$', ''):gsub('^%s+', '')
+ set_state('title', ass_escape(t))
+ end
+ mp.observe_property('title', 'string', function(_, title)
+ mp.unobserve_property(update_title)
+ template = title
+ local props = get_expansion_props(title)
+ for prop, _ in pairs(props) do
+ mp.observe_property(prop, 'native', update_title)
+ end
+ if not next(props) then update_title() end
+ end)
+end
+mp.observe_property('playback-time', 'number', create_state_setter('time', function()
+ -- Create a file-end event that triggers right before file ends
+ file_end_timer:kill()
+ if state.duration and state.time and not state.pause then
+ local remaining = (state.duration - state.time) / state.speed
+ if remaining < 5 then
+ local timeout = remaining - 0.02
+ if timeout > 0 then
+ file_end_timer.timeout = timeout
+ file_end_timer:resume()
+ else handle_file_end() end
+ end
+ end
+
+ update_human_times()
+ select_current_chapter()
+end))
+mp.observe_property('duration', 'number', create_state_setter('duration', update_human_times))
+mp.observe_property('speed', 'number', create_state_setter('speed', update_human_times))
+mp.observe_property('track-list', 'native', function(name, value)
+ -- checks the file dispositions
+ local is_image = false
+ local types = {sub = 0, audio = 0, video = 0}
+ for _, track in ipairs(value) do
+ if track.type == 'video' then
+ is_image = track.image
+ if not is_image and not track.albumart then types.video = types.video + 1 end
+ elseif types[track.type] then types[track.type] = types[track.type] + 1 end
+ end
+ set_state('is_audio', types.video == 0 and types.audio > 0)
+ set_state('is_image', is_image)
+ set_state('has_audio', types.audio > 0)
+ set_state('has_many_audio', types.audio > 1)
+ set_state('has_sub', types.sub > 0)
+ set_state('has_many_sub', types.sub > 1)
+ set_state('is_video', types.video > 0)
+ set_state('has_many_video', types.video > 1)
+ Elements:trigger('dispositions')
+end)
+mp.observe_property('editions', 'number', function(_, editions)
+ if editions then set_state('has_many_edition', editions > 1) end
+ Elements:trigger('dispositions')
+end)
+mp.observe_property('chapter-list', 'native', function(_, chapters)
+ local chapters, chapter_ranges = serialize_chapters(chapters), {}
+ if chapters then chapters, chapter_ranges = serialize_chapter_ranges(chapters) end
+ set_state('chapters', chapters)
+ set_state('chapter_ranges', chapter_ranges)
+ set_state('has_chapter', #chapters > 0)
+ select_current_chapter()
+ Elements:trigger('dispositions')
+end)
+mp.observe_property('border', 'bool', create_state_setter('border'))
+mp.observe_property('loop-file', 'native', create_state_setter('loop_file'))
+mp.observe_property('ab-loop-a', 'number', create_state_setter('ab_loop_a'))
+mp.observe_property('ab-loop-b', 'number', create_state_setter('ab_loop_b'))
+mp.observe_property('playlist-pos-1', 'number', create_state_setter('playlist_pos'))
+mp.observe_property('playlist-count', 'number', function(_, value)
+ set_state('playlist_count', value)
+ set_state('has_playlist', value > 1)
+ Elements:trigger('dispositions')
+end)
+mp.observe_property('fullscreen', 'bool', create_state_setter('fullscreen', update_fullormaxed))
+mp.observe_property('window-maximized', 'bool', create_state_setter('maximized', update_fullormaxed))
+mp.observe_property('idle-active', 'bool', function(_, idle)
+ set_state('is_idle', idle)
+ Elements:trigger('dispositions')
+end)
+mp.observe_property('pause', 'bool', create_state_setter('pause', function() file_end_timer:kill() end))
+mp.observe_property('volume', 'number', create_state_setter('volume'))
+mp.observe_property('volume-max', 'number', create_state_setter('volume_max'))
+mp.observe_property('mute', 'bool', create_state_setter('mute'))
+mp.observe_property('osd-dimensions', 'native', function(name, val)
+ update_display_dimensions()
+ request_render()
+end)
+mp.observe_property('display-hidpi-scale', 'native', create_state_setter('hidpi_scale', update_display_dimensions))
+mp.observe_property('cache', 'string', create_state_setter('cache'))
+mp.observe_property('cache-buffering-state', 'number', create_state_setter('cache_buffering'))
+mp.observe_property('demuxer-via-network', 'native', create_state_setter('is_stream', function()
+ Elements:trigger('dispositions')
+end))
+mp.observe_property('demuxer-cache-state', 'native', function(prop, cache_state)
+ local cached_ranges, bof, eof, uncached_ranges = nil, nil, nil, nil
+ if cache_state then
+ cached_ranges, bof, eof = cache_state['seekable-ranges'], cache_state['bof-cached'], cache_state['eof-cached']
+ set_state('cache_underrun', cache_state['underrun'])
+ else cached_ranges = {} end
+
+ if not (state.duration and (#cached_ranges > 0 or state.cache == 'yes' or
+ (state.cache == 'auto' and state.is_stream))) then
+ if state.uncached_ranges then set_state('uncached_ranges', nil) end
+ return
+ end
+
+ -- Normalize
+ local ranges = {}
+ for _, range in ipairs(cached_ranges) do
+ ranges[#ranges + 1] = {
+ math.max(range['start'] or 0, 0),
+ math.min(range['end'] or state.duration, state.duration),
+ }
+ end
+ table.sort(ranges, function(a, b) return a[1] < b[1] end)
+ if bof then ranges[1][1] = 0 end
+ if eof then ranges[#ranges][2] = state.duration end
+ -- Invert cached ranges into uncached ranges, as that's what we're rendering
+ local inverted_ranges = {{0, state.duration}}
+ for _, cached in pairs(ranges) do
+ inverted_ranges[#inverted_ranges][2] = cached[1]
+ inverted_ranges[#inverted_ranges + 1] = {cached[2], state.duration}
+ end
+ uncached_ranges = {}
+ local last_range = nil
+ for _, range in ipairs(inverted_ranges) do
+ if last_range and last_range[2] + 0.5 > range[1] then -- fuse ranges
+ last_range[2] = range[2]
+ else
+ if range[2] - range[1] > 0.5 then -- skip short ranges
+ uncached_ranges[#uncached_ranges + 1] = range
+ last_range = range
+ end
+ end
+ end
+
+ set_state('uncached_ranges', uncached_ranges)
+end)
+mp.observe_property('display-fps', 'native', observe_display_fps)
+mp.observe_property('estimated-display-fps', 'native', update_render_delay)
+mp.observe_property('eof-reached', 'native', create_state_setter('eof_reached'))
+mp.observe_property('core-idle', 'native', create_state_setter('core_idle'))
+
+--[[ KEY BINDS ]]
+
+mp.add_key_binding(nil, 'toggle-ui', function() Elements:toggle({'timeline', 'controls', 'volume', 'top_bar'}) end)
+mp.add_key_binding(nil, 'flash-ui', function() Elements:flash({'timeline', 'controls', 'volume', 'top_bar'}) end)
+mp.add_key_binding(nil, 'flash-timeline', function() Elements:flash({'timeline'}) end)
+mp.add_key_binding(nil, 'flash-top-bar', function() Elements:flash({'top_bar'}) end)
+mp.add_key_binding(nil, 'flash-volume', function() Elements:flash({'volume'}) end)
+mp.add_key_binding(nil, 'flash-speed', function() Elements:flash({'speed'}) end)
+mp.add_key_binding(nil, 'flash-pause-indicator', function() Elements:flash({'pause_indicator'}) end)
+mp.add_key_binding(nil, 'toggle-progress', function()
+ local timeline = Elements.timeline
+ if timeline.size_min_override then
+ timeline:tween_property('size_min_override', timeline.size_min_override, timeline.size_min, function()
+ timeline.size_min_override = nil
+ end)
+ else
+ timeline:tween_property('size_min_override', timeline.size_min, 0)
+ end
+end)
+mp.add_key_binding(nil, 'decide-pause-indicator', function() Elements.pause_indicator:decide() end)
+mp.add_key_binding(nil, 'menu', function() toggle_menu_with_items() end)
+mp.add_key_binding(nil, 'menu-blurred', function() toggle_menu_with_items({mouse_nav = true}) end)
+local track_loaders = {
+ {name = 'subtitles', prop = 'sub', allowed_types = config.subtitle_types},
+ {name = 'audio', prop = 'audio', allowed_types = config.media_types},
+ {name = 'video', prop = 'video', allowed_types = config.media_types},
+}
+for _, loader in ipairs(track_loaders) do
+ local menu_type = 'load-' .. loader.name
+ mp.add_key_binding(nil, menu_type, function()
+ if Menu:is_open(menu_type) then Menu:close() return end
+
+ local path = state.path
+ if path then
+ if is_protocol(path) then
+ path = false
+ else
+ local serialized_path = serialize_path(path)
+ path = serialized_path ~= nil and serialized_path.dirname or false
+ end
+ end
+ if not path then
+ path = get_default_directory()
+ end
+ open_file_navigation_menu(
+ path,
+ function(path) mp.commandv(loader.prop .. '-add', path) end,
+ {type = menu_type, title = 'Load ' .. loader.name, allowed_types = loader.allowed_types}
+ )
+ end)
+end
+mp.add_key_binding(nil, 'subtitles', create_select_tracklist_type_menu_opener(
+ 'Subtitles', 'sub', 'sid', 'script-binding uosc/load-subtitles'
+))
+mp.add_key_binding(nil, 'audio', create_select_tracklist_type_menu_opener(
+ 'Audio', 'audio', 'aid', 'script-binding uosc/load-audio'
+))
+mp.add_key_binding(nil, 'video', create_select_tracklist_type_menu_opener(
+ 'Video', 'video', 'vid', 'script-binding uosc/load-video'
+))
+mp.add_key_binding(nil, 'playlist', create_self_updating_menu_opener({
+ title = 'Playlist',
+ type = 'playlist',
+ list_prop = 'playlist',
+ serializer = function(playlist)
+ local items = {}
+ for index, item in ipairs(playlist) do
+ local is_url = item.filename:find('://')
+ local item_title = type(item.title) == 'string' and #item.title > 0 and item.title or false
+ items[index] = {
+ title = item_title or (is_url and item.filename or serialize_path(item.filename).basename),
+ hint = tostring(index),
+ active = item.current,
+ value = index,
+ }
+ end
+ return items
+ end,
+ on_select = function(index) mp.commandv('set', 'playlist-pos-1', tostring(index)) end,
+}))
+mp.add_key_binding(nil, 'chapters', create_self_updating_menu_opener({
+ title = 'Chapters',
+ type = 'chapters',
+ list_prop = 'chapter-list',
+ active_prop = 'chapter',
+ serializer = function(chapters, current_chapter)
+ local items = {}
+ chapters = normalize_chapters(chapters)
+ for index, chapter in ipairs(chapters) do
+ items[index] = {
+ title = chapter.title or '',
+ hint = mp.format_time(chapter.time),
+ value = index,
+ active = index - 1 == current_chapter,
+ }
+ end
+ return items
+ end,
+ on_select = function(index) mp.commandv('set', 'chapter', tostring(index - 1)) end,
+}))
+mp.add_key_binding(nil, 'editions', create_self_updating_menu_opener({
+ title = 'Editions',
+ type = 'editions',
+ list_prop = 'edition-list',
+ active_prop = 'current-edition',
+ serializer = function(editions, current_id)
+ local items = {}
+ for _, edition in ipairs(editions or {}) do
+ items[#items + 1] = {
+ title = edition.title or 'Edition',
+ hint = tostring(edition.id + 1),
+ value = edition.id,
+ active = edition.id == current_id,
+ }
+ end
+ return items
+ end,
+ on_select = function(id) mp.commandv('set', 'edition', id) end,
+}))
+mp.add_key_binding(nil, 'show-in-directory', function()
+ -- Ignore URLs
+ if not state.path or is_protocol(state.path) then return end
+
+ if state.os == 'windows' then
+ utils.subprocess_detached({args = {'explorer', '/select,', state.path}, cancellable = false})
+ elseif state.os == 'macos' then
+ utils.subprocess_detached({args = {'open', '-R', state.path}, cancellable = false})
+ elseif state.os == 'linux' then
+ local result = utils.subprocess({args = {'nautilus', state.path}, cancellable = false})
+
+ -- Fallback opens the folder with xdg-open instead
+ if result.status ~= 0 then
+ utils.subprocess({args = {'xdg-open', serialize_path(state.path).dirname}, cancellable = false})
+ end
+ end
+end)
+mp.add_key_binding(nil, 'stream-quality', function()
+ if Menu:is_open('stream-quality') then Menu:close() return end
+
+ local ytdl_format = mp.get_property_native('ytdl-format')
+ local items = {}
+
+ for _, height in ipairs(config.stream_quality_options) do
+ local format = 'bestvideo[height<=?' .. height .. ']+bestaudio/best[height<=?' .. height .. ']'
+ items[#items + 1] = {title = height .. 'p', value = format, active = format == ytdl_format}
+ end
+
+ Menu:open({type = 'stream-quality', title = 'Stream quality', items = items}, function(format)
+ mp.set_property('ytdl-format', format)
+
+ -- Reload the video to apply new format
+ -- This is taken from https://github.com/jgreco/mpv-youtube-quality
+ -- which is in turn taken from https://github.com/4e6/mpv-reload/
+ -- Dunno if playlist_pos shenanigans below are necessary.
+ local playlist_pos = mp.get_property_number('playlist-pos')
+ local duration = mp.get_property_native('duration')
+ local time_pos = mp.get_property('time-pos')
+
+ mp.set_property_number('playlist-pos', playlist_pos)
+
+ -- Tries to determine live stream vs. pre-recorded VOD. VOD has non-zero
+ -- duration property. When reloading VOD, to keep the current time position
+ -- we should provide offset from the start. Stream doesn't have fixed start.
+ -- Decent choice would be to reload stream from it's current 'live' position.
+ -- That's the reason we don't pass the offset when reloading streams.
+ if duration and duration > 0 then
+ local function seeker()
+ mp.commandv('seek', time_pos, 'absolute')
+ mp.unregister_event(seeker)
+ end
+ mp.register_event('file-loaded', seeker)
+ end
+ end)
+end)
+mp.add_key_binding(nil, 'open-file', function()
+ if Menu:is_open('open-file') then Menu:close() return end
+
+ local directory
+ local active_file
+
+ if state.path == nil or is_protocol(state.path) then
+ local serialized = serialize_path(get_default_directory())
+ if serialized then
+ directory = serialized.path
+ active_file = nil
+ end
+ else
+ local serialized = serialize_path(state.path)
+ if serialized then
+ directory = serialized.dirname
+ active_file = serialized.path
+ end
+ end
+
+ if not directory then
+ msg.error('Couldn\'t serialize path "' .. state.path .. '".')
+ return
+ end
+
+ -- Update active file in directory navigation menu
+ local function handle_file_loaded()
+ if Menu:is_open('open-file') then
+ Elements.menu:activate_one_value(normalize_path(mp.get_property_native('path')))
+ end
+ end
+
+ open_file_navigation_menu(
+ directory,
+ function(path) mp.commandv('loadfile', path) end,
+ {
+ type = 'open-file',
+ allowed_types = config.media_types,
+ active_path = active_file,
+ on_open = function() mp.register_event('file-loaded', handle_file_loaded) end,
+ on_close = function() mp.unregister_event(handle_file_loaded) end,
+ }
+ )
+end)
+mp.add_key_binding(nil, 'shuffle', function() set_state('shuffle', not state.shuffle) end)
+mp.add_key_binding(nil, 'items', function()
+ if state.has_playlist then
+ mp.command('script-binding uosc/playlist')
+ else
+ mp.command('script-binding uosc/open-file')
+ end
+end)
+mp.add_key_binding(nil, 'next', function() navigate_item(1) end)
+mp.add_key_binding(nil, 'prev', function() navigate_item(-1) end)
+mp.add_key_binding(nil, 'next-file', function() navigate_directory(1) end)
+mp.add_key_binding(nil, 'prev-file', function() navigate_directory(-1) end)
+mp.add_key_binding(nil, 'first', function()
+ if state.has_playlist then
+ mp.commandv('set', 'playlist-pos-1', '1')
+ else
+ load_file_index_in_current_directory(1)
+ end
+end)
+mp.add_key_binding(nil, 'last', function()
+ if state.has_playlist then
+ mp.commandv('set', 'playlist-pos-1', tostring(state.playlist_count))
+ else
+ load_file_index_in_current_directory(-1)
+ end
+end)
+mp.add_key_binding(nil, 'first-file', function() load_file_index_in_current_directory(1) end)
+mp.add_key_binding(nil, 'last-file', function() load_file_index_in_current_directory(-1) end)
+mp.add_key_binding(nil, 'delete-file-next', function()
+ local next_file = nil
+ local is_local_file = state.path and not is_protocol(state.path)
+
+ if is_local_file then
+ if Menu:is_open('open-file') then Elements.menu:delete_value(state.path) end
+ end
+
+ if state.has_playlist then
+ mp.commandv('playlist-remove', 'current')
+ else
+ if is_local_file then
+ local paths, current_index = get_adjacent_files(state.path, config.media_types)
+ if paths and current_index then
+ local index, path = decide_navigation_in_list(paths, current_index, 1)
+ if path then next_file = path end
+ end
+ end
+
+ if next_file then mp.commandv('loadfile', next_file)
+ else mp.commandv('stop') end
+ end
+
+ if is_local_file then delete_file(state.path) end
+end)
+mp.add_key_binding(nil, 'delete-file-quit', function()
+ mp.command('stop')
+ if state.path and not is_protocol(state.path) then delete_file(state.path) end
+ mp.command('quit')
+end)
+mp.add_key_binding(nil, 'audio-device', create_self_updating_menu_opener({
+ title = 'Audio devices',
+ type = 'audio-device-list',
+ list_prop = 'audio-device-list',
+ active_prop = 'audio-device',
+ serializer = function(audio_device_list, current_device)
+ current_device = current_device or 'auto'
+ local ao = mp.get_property('current-ao') or ''
+ local items = {}
+ for _, device in ipairs(audio_device_list) do
+ if device.name == 'auto' or string.match(device.name, '^' .. ao) then
+ local hint = string.match(device.name, ao .. '/(.+)')
+ if not hint then hint = device.name end
+ items[#items + 1] = {
+ title = device.description,
+ hint = hint,
+ active = device.name == current_device,
+ value = device.name,
+ }
+ end
+ end
+ return items
+ end,
+ on_select = function(name) mp.commandv('set', 'audio-device', name) end,
+}))
+mp.add_key_binding(nil, 'open-config-directory', function()
+ local config_path = mp.command_native({'expand-path', '~~/mpv.conf'})
+ local config = serialize_path(normalize_path(config_path))
+
+ if config then
+ local args
+
+ if state.os == 'windows' then
+ args = {'explorer', '/select,', config.path}
+ elseif state.os == 'macos' then
+ args = {'open', '-R', config.path}
+ elseif state.os == 'linux' then
+ args = {'xdg-open', config.dirname}
+ end
+
+ utils.subprocess_detached({args = args, cancellable = false})
+ else
+ msg.error('Couldn\'t serialize config path "' .. config_path .. '".')
+ end
+end)
+
+--[[ MESSAGE HANDLERS ]]
+
+mp.register_script_message('show-submenu', function(id) toggle_menu_with_items({submenu = id}) end)
+mp.register_script_message('get-version', function(script)
+ mp.commandv('script-message-to', script, 'uosc-version', config.version)
+end)
+mp.register_script_message('open-menu', function(json, submenu_id)
+ local data = utils.parse_json(json)
+ if type(data) ~= 'table' or type(data.items) ~= 'table' then
+ msg.error('open-menu: received json didn\'t produce a table with menu configuration')
+ else
+ if data.type and Menu:is_open(data.type) then Menu:close()
+ else open_command_menu(data, {submenu_id = submenu_id}) end
+ end
+end)
+mp.register_script_message('update-menu', function(json)
+ local data = utils.parse_json(json)
+ if type(data) ~= 'table' or type(data.items) ~= 'table' then
+ msg.error('update-menu: received json didn\'t produce a table with menu configuration')
+ else
+ local menu = data.type and Menu:is_open(data.type)
+ if menu then menu:update(data)
+ else open_command_menu(data) end
+ end
+end)
+mp.register_script_message('thumbfast-info', function(json)
+ local data = utils.parse_json(json)
+ if type(data) ~= 'table' or not data.width or not data.height then
+ thumbnail.disabled = true
+ msg.error('thumbfast-info: received json didn\'t produce a table with thumbnail information')
+ else
+ thumbnail = data
+ request_render()
+ end
+end)
+mp.register_script_message('set', function(name, value)
+ external[name] = value
+ Elements:trigger('external_prop_' .. name, value)
+end)
+mp.register_script_message('toggle-elements', function(elements) Elements:toggle(split(elements, ' *, *')) end)
+mp.register_script_message('set-min-visibility', function(visibility, elements)
+ local fraction = tonumber(visibility)
+ local ids = split(elements and elements ~= '' and elements or 'timeline,controls,volume,top_bar', ' *, *')
+ if fraction then Elements:set_min_visibility(clamp(0, fraction, 1), ids) end
+end)
+mp.register_script_message('flash-elements', function(elements) Elements:flash(split(elements, ' *, *')) end)
+
+--[[ ELEMENTS ]]
+
+require('uosc_shared/elements/WindowBorder'):new()
+require('uosc_shared/elements/BufferingIndicator'):new()
+require('uosc_shared/elements/PauseIndicator'):new()
+require('uosc_shared/elements/TopBar'):new()
+require('uosc_shared/elements/Timeline'):new()
+if options.controls and options.controls ~= 'never' then require('uosc_shared/elements/Controls'):new() end
+if itable_index_of({'left', 'right'}, options.volume) then require('uosc_shared/elements/Volume'):new() end
+require('uosc_shared/elements/Curtain'):new()
diff --git a/.config/mpv/scripts/uosc_shared/elements/BufferingIndicator.lua b/.config/mpv/scripts/uosc_shared/elements/BufferingIndicator.lua
new file mode 100644
index 0000000..e2aa071
--- /dev/null
+++ b/.config/mpv/scripts/uosc_shared/elements/BufferingIndicator.lua
@@ -0,0 +1,37 @@
+local Element = require('uosc_shared/elements/Element')
+
+---@class BufferingIndicator : Element
+local BufferingIndicator = class(Element)
+
+function BufferingIndicator:new() return Class.new(self) --[[@as BufferingIndicator]] end
+function BufferingIndicator:init()
+ Element.init(self, 'buffer_indicator')
+ self.ignores_menu = true
+ self.enabled = false
+end
+
+function BufferingIndicator:decide_enabled()
+ local cache = state.cache_underrun or state.cache_buffering and state.cache_buffering < 100
+ local player = state.core_idle and not state.eof_reached
+ if self.enabled then
+ if not player or (state.pause and not cache) then self.enabled = false end
+ elseif player and cache and state.uncached_ranges then self.enabled = true end
+end
+
+function BufferingIndicator:on_prop_pause() self:decide_enabled() end
+function BufferingIndicator:on_prop_core_idle() self:decide_enabled() end
+function BufferingIndicator:on_prop_eof_reached() self:decide_enabled() end
+function BufferingIndicator:on_prop_uncached_ranges() self:decide_enabled() end
+function BufferingIndicator:on_prop_cache_buffering() self:decide_enabled() end
+function BufferingIndicator:on_prop_cache_underrun() self:decide_enabled() end
+
+function BufferingIndicator:render()
+ local ass = assdraw.ass_new()
+ ass:rect(0, 0, display.width, display.height, {color = bg, opacity = 0.3})
+ local size = round(30 + math.min(display.width, display.height) / 10)
+ local opacity = (Elements.menu and not Elements.menu.is_closing) and 0.3 or 0.8
+ ass:spinner(display.width / 2, display.height / 2, size, {color = fg, opacity = opacity})
+ return ass
+end
+
+return BufferingIndicator
diff --git a/.config/mpv/scripts/uosc_shared/elements/Button.lua b/.config/mpv/scripts/uosc_shared/elements/Button.lua
new file mode 100644
index 0000000..8d0f725
--- /dev/null
+++ b/.config/mpv/scripts/uosc_shared/elements/Button.lua
@@ -0,0 +1,90 @@
+local Element = require('uosc_shared/elements/Element')
+
+---@alias ButtonProps {icon: string; on_click: function; anchor_id?: string; active?: boolean; badge?: string|number; foreground?: string; background?: string; tooltip?: string}
+
+---@class Button : Element
+local Button = class(Element)
+
+---@param id string
+---@param props ButtonProps
+function Button:new(id, props) return Class.new(self, id, props) --[[@as Button]] end
+---@param id string
+---@param props ButtonProps
+function Button:init(id, props)
+ self.icon = props.icon
+ self.active = props.active
+ self.tooltip = props.tooltip
+ self.badge = props.badge
+ self.foreground = props.foreground or fg
+ self.background = props.background or bg
+ ---@type fun()
+ self.on_click = props.on_click
+ Element.init(self, id, props)
+end
+
+function Button:on_coordinates() self.font_size = round((self.by - self.ay) * 0.7) end
+function Button:on_mbtn_left_down()
+ -- Don't accept clicks while hidden.
+ if self:get_visibility() <= 0 then return end
+ -- We delay the callback to next tick, otherwise we are risking race
+ -- conditions as we are in the middle of event dispatching.
+ -- For example, handler might add a menu to the end of the element stack, and that
+ -- than picks up this click even we are in right now, and instantly closes itself.
+ mp.add_timeout(0.01, self.on_click)
+end
+
+function Button:render()
+ local visibility = self:get_visibility()
+ if visibility <= 0 then return end
+
+ local ass = assdraw.ass_new()
+ local is_hover = self.proximity_raw == 0
+ local is_hover_or_active = is_hover or self.active
+ local foreground = self.active and self.background or self.foreground
+ local background = self.active and self.foreground or self.background
+
+ -- Background
+ if is_hover_or_active then
+ ass:rect(self.ax, self.ay, self.bx, self.by, {
+ color = self.active and background or foreground, radius = 2,
+ opacity = visibility * (self.active and 1 or 0.3),
+ })
+ end
+
+ -- Tooltip on hover
+ if is_hover and self.tooltip then ass:tooltip(self, self.tooltip) end
+
+
+ -- Badge
+ local icon_clip
+ if self.badge then
+ local badge_font_size = self.font_size * 0.6
+ local badge_opts = {size = badge_font_size, color = background, opacity = visibility}
+ local badge_width = text_width(self.badge, badge_opts)
+ local width, height = math.ceil(badge_width + (badge_font_size / 7) * 2), math.ceil(badge_font_size * 0.93)
+ local bx, by = self.bx - 1, self.by - 1
+ ass:rect(bx - width, by - height, bx, by, {
+ color = foreground, radius = 2, opacity = visibility,
+ border = self.active and 0 or 1, border_color = background,
+ })
+ ass:txt(bx - width / 2, by - height / 2, 5, self.badge, badge_opts)
+
+ local clip_border = math.max(self.font_size / 20, 1)
+ local clip_path = assdraw.ass_new()
+ clip_path:round_rect_cw(
+ math.floor((bx - width) - clip_border), math.floor((by - height) - clip_border), bx, by, 3
+ )
+ icon_clip = '\\iclip(' .. clip_path.scale .. ', ' .. clip_path.text .. ')'
+ end
+
+ -- Icon
+ local x, y = round(self.ax + (self.bx - self.ax) / 2), round(self.ay + (self.by - self.ay) / 2)
+ ass:icon(x, y, self.font_size, self.icon, {
+ color = foreground, border = self.active and 0 or options.text_border, border_color = background,
+ opacity = visibility, clip = icon_clip,
+ })
+
+ return ass
+end
+
+return Button
diff --git a/.config/mpv/scripts/uosc_shared/elements/Controls.lua b/.config/mpv/scripts/uosc_shared/elements/Controls.lua
new file mode 100644
index 0000000..9a6be72
--- /dev/null
+++ b/.config/mpv/scripts/uosc_shared/elements/Controls.lua
@@ -0,0 +1,329 @@
+local Element = require('uosc_shared/elements/Element')
+local Button = require('uosc_shared/elements/Button')
+local CycleButton = require('uosc_shared/elements/CycleButton')
+local Speed = require('uosc_shared/elements/Speed')
+
+-- `scale` - `options.controls_size` scale factor.
+-- `ratio` - Width/height ratio of a static or dynamic element.
+-- `ratio_min` Min ratio for 'dynamic' sized element.
+---@alias ControlItem {element?: Element; kind: string; sizing: 'space' | 'static' | 'dynamic'; scale: number; ratio?: number; ratio_min?: number; hide: boolean; dispositions?: table<string, boolean>}
+
+---@class Controls : Element
+local Controls = class(Element)
+
+function Controls:new() return Class.new(self) --[[@as Controls]] end
+function Controls:init()
+ Element.init(self, 'controls')
+ ---@type ControlItem[] All control elements serialized from `options.controls`.
+ self.controls = {}
+ ---@type ControlItem[] Only controls that match current dispositions.
+ self.layout = {}
+
+ -- Serialize control elements
+ local shorthands = {
+ menu = 'command:menu:script-binding uosc/menu-blurred?Menu',
+ subtitles = 'command:subtitles:script-binding uosc/subtitles#sub>0?Subtitles',
+ audio = 'command:graphic_eq:script-binding uosc/audio#audio>1?Audio',
+ ['audio-device'] = 'command:speaker:script-binding uosc/audio-device?Audio device',
+ video = 'command:theaters:script-binding uosc/video#video>1?Video',
+ playlist = 'command:list_alt:script-binding uosc/playlist?Playlist',
+ chapters = 'command:bookmark:script-binding uosc/chapters#chapters>0?Chapters',
+ ['editions'] = 'command:bookmarks:script-binding uosc/editions#editions>1?Editions',
+ ['stream-quality'] = 'command:high_quality:script-binding uosc/stream-quality?Stream quality',
+ ['open-file'] = 'command:file_open:script-binding uosc/open-file?Open file',
+ ['items'] = 'command:list_alt:script-binding uosc/items?Playlist/Files',
+ prev = 'command:arrow_back_ios:script-binding uosc/prev?Previous',
+ next = 'command:arrow_forward_ios:script-binding uosc/next?Next',
+ first = 'command:first_page:script-binding uosc/first?First',
+ last = 'command:last_page:script-binding uosc/last?Last',
+ ['loop-playlist'] = 'cycle:repeat:loop-playlist:no/inf!?Loop playlist',
+ ['loop-file'] = 'cycle:repeat_one:loop-file:no/inf!?Loop file',
+ shuffle = 'toggle:shuffle:shuffle?Shuffle',
+ fullscreen = 'cycle:crop_free:fullscreen:no/yes=fullscreen_exit!?Fullscreen',
+ }
+
+ -- Parse out disposition/config pairs
+ local items = {}
+ local in_disposition = false
+ local current_item = nil
+ for c in options.controls:gmatch('.') do
+ if not current_item then current_item = {disposition = '', config = ''} end
+ if c == '<' and #current_item.config == 0 then in_disposition = true
+ elseif c == '>' and #current_item.config == 0 then in_disposition = false
+ elseif c == ',' and not in_disposition then
+ items[#items + 1] = current_item
+ current_item = nil
+ else
+ local prop = in_disposition and 'disposition' or 'config'
+ current_item[prop] = current_item[prop] .. c
+ end
+ end
+ items[#items + 1] = current_item
+
+ -- Create controls
+ self.controls = {}
+ for i, item in ipairs(items) do
+ local config = shorthands[item.config] and shorthands[item.config] or item.config
+ local config_tooltip = split(config, ' *%? *')
+ local tooltip = config_tooltip[2]
+ config = shorthands[config_tooltip[1]]
+ and split(shorthands[config_tooltip[1]], ' *%? *')[1] or config_tooltip[1]
+ local config_badge = split(config, ' *# *')
+ config = config_badge[1]
+ local badge = config_badge[2]
+ local parts = split(config, ' *: *')
+ local kind, params = parts[1], itable_slice(parts, 2)
+
+ -- Serialize dispositions
+ local dispositions = {}
+ for _, definition in ipairs(split(item.disposition, ' *, *')) do
+ if #definition > 0 then
+ local value = definition:sub(1, 1) ~= '!'
+ local name = not value and definition:sub(2) or definition
+ local prop = name:sub(1, 4) == 'has_' and name or 'is_' .. name
+ dispositions[prop] = value
+ end
+ end
+
+ -- Convert toggles into cycles
+ if kind == 'toggle' then
+ kind = 'cycle'
+ params[#params + 1] = 'no/yes!'
+ end
+
+ -- Create a control element
+ local control = {dispositions = dispositions, kind = kind}
+
+ if kind == 'space' then
+ control.sizing = 'space'
+ elseif kind == 'gap' then
+ table_assign(control, {sizing = 'dynamic', scale = 1, ratio = params[1] or 0.3, ratio_min = 0})
+ elseif kind == 'command' then
+ if #params ~= 2 then
+ mp.error(string.format(
+ 'command button needs 2 parameters, %d received: %s', #params, table.concat(params, '/')
+ ))
+ else
+ local element = Button:new('control_' .. i, {
+ icon = params[1],
+ anchor_id = 'controls',
+ on_click = function() mp.command(params[2]) end,
+ tooltip = tooltip,
+ count_prop = 'sub',
+ })
+ table_assign(control, {element = element, sizing = 'static', scale = 1, ratio = 1})
+ if badge then self:register_badge_updater(badge, element) end
+ end
+ elseif kind == 'cycle' then
+ if #params ~= 3 then
+ mp.error(string.format(
+ 'cycle button needs 3 parameters, %d received: %s',
+ #params, table.concat(params, '/')
+ ))
+ else
+ local state_configs = split(params[3], ' */ *')
+ local states = {}
+
+ for _, state_config in ipairs(state_configs) do
+ local active = false
+ if state_config:sub(-1) == '!' then
+ active = true
+ state_config = state_config:sub(1, -2)
+ end
+ local state_params = split(state_config, ' *= *')
+ local value, icon = state_params[1], state_params[2] or params[1]
+ states[#states + 1] = {value = value, icon = icon, active = active}
+ end
+
+ local element = CycleButton:new('control_' .. i, {
+ prop = params[2], anchor_id = 'controls', states = states, tooltip = tooltip,
+ })
+ table_assign(control, {element = element, sizing = 'static', scale = 1, ratio = 1})
+ if badge then self:register_badge_updater(badge, element) end
+ end
+ elseif kind == 'speed' then
+ if not Elements.speed then
+ local element = Speed:new({anchor_id = 'controls'})
+ table_assign(control, {
+ element = element, sizing = 'dynamic', scale = params[1] or 1.3, ratio = 3.5, ratio_min = 2,
+ })
+ else
+ msg.error('there can only be 1 speed slider')
+ end
+ else
+ msg.error('unknown element kind "' .. kind .. '"')
+ break
+ end
+
+ self.controls[#self.controls + 1] = control
+ end
+
+ self:reflow()
+end
+
+function Controls:reflow()
+ -- Populate the layout only with items that match current disposition
+ self.layout = {}
+ for _, control in ipairs(self.controls) do
+ local matches = true
+ for prop, value in pairs(control.dispositions) do
+ if state[prop] ~= value then
+ matches = false
+ break
+ end
+ end
+ if control.element then control.element.enabled = matches end
+ if matches then self.layout[#self.layout + 1] = control end
+ end
+
+ self:update_dimensions()
+ Elements:trigger('controls_reflow')
+end
+
+---@param badge string
+---@param element Element An element that supports `badge` property.
+function Controls:register_badge_updater(badge, element)
+ local prop_and_limit = split(badge, ' *> *')
+ local prop, limit = prop_and_limit[1], tonumber(prop_and_limit[2] or -1)
+ local observable_name, serializer, is_external_prop = prop, nil, false
+
+ if itable_index_of({'sub', 'audio', 'video'}, prop) then
+ observable_name = 'track-list'
+ serializer = function(value)
+ local count = 0
+ for _, track in ipairs(value) do if track.type == prop then count = count + 1 end end
+ return count
+ end
+ else
+ local parts = split(prop, '@')
+ -- Support both new `prop@owner` and old `@prop` syntaxes
+ if #parts > 1 then prop, is_external_prop = parts[1] ~= '' and parts[1] or parts[2], true end
+ serializer = function(value) return value and (type(value) == 'table' and #value or tostring(value)) or nil end
+ end
+
+ local function handler(_, value)
+ local new_value = serializer(value) --[[@as nil|string|integer]]
+ local value_number = tonumber(new_value)
+ if value_number then new_value = value_number > limit and value_number or nil end
+ element.badge = new_value
+ request_render()
+ end
+
+ if is_external_prop then element['on_external_prop_' .. prop] = function(_, value) handler(prop, value) end
+ else mp.observe_property(observable_name, 'native', handler) end
+end
+
+function Controls:get_visibility()
+ return (Elements.speed and Elements.speed.dragging) and 1 or Elements.timeline:get_is_hovered()
+ and -1 or Element.get_visibility(self)
+end
+
+function Controls:update_dimensions()
+ local window_border = Elements.window_border.size
+ local size = state.fullormaxed and options.controls_size_fullscreen or options.controls_size
+ local spacing = options.controls_spacing
+ local margin = options.controls_margin
+
+ -- Disable when not enough space
+ local available_space = display.height - Elements.window_border.size * 2
+ if Elements.top_bar.enabled then available_space = available_space - Elements.top_bar.size end
+ if Elements.timeline.enabled then available_space = available_space - Elements.timeline.size_max end
+ self.enabled = available_space > size + 10
+
+ -- Reset hide/enabled flags
+ for c, control in ipairs(self.layout) do
+ control.hide = false
+ if control.element then control.element.enabled = self.enabled end
+ end
+
+ if not self.enabled then return end
+
+ -- Container
+ self.bx = display.width - window_border - margin
+ self.by = (Elements.timeline.enabled and Elements.timeline.ay or display.height - window_border) - margin
+ self.ax, self.ay = window_border + margin, self.by - size
+
+ -- Controls
+ local available_width = self.bx - self.ax
+ local statics_width = (#self.layout - 1) * spacing
+ local min_content_width = statics_width
+ local max_dynamics_width, dynamic_units, spaces = 0, 0, 0
+
+ -- Calculate statics_width, min_content_width, and count spaces
+ for c, control in ipairs(self.layout) do
+ if control.sizing == 'space' then
+ spaces = spaces + 1
+ elseif control.sizing == 'static' then
+ local width = size * control.scale * control.ratio
+ statics_width = statics_width + width
+ min_content_width = min_content_width + width
+ elseif control.sizing == 'dynamic' then
+ min_content_width = min_content_width + size * control.scale * control.ratio_min
+ max_dynamics_width = max_dynamics_width + size * control.scale * control.ratio
+ dynamic_units = dynamic_units + control.scale * control.ratio
+ end
+ end
+
+ -- Hide & disable elements in the middle until we fit into available width
+ if min_content_width > available_width then
+ local i = math.ceil(#self.layout / 2 + 0.1)
+ for a = 0, #self.layout - 1, 1 do
+ i = i + (a * (a % 2 == 0 and 1 or -1))
+ local control = self.layout[i]
+
+ if control.kind ~= 'gap' and control.kind ~= 'space' then
+ control.hide = true
+ if control.element then control.element.enabled = false end
+ if control.sizing == 'static' then
+ local width = size * control.scale * control.ratio
+ min_content_width = min_content_width - width - spacing
+ statics_width = statics_width - width - spacing
+ elseif control.sizing == 'dynamic' then
+ min_content_width = min_content_width - size * control.scale * control.ratio_min - spacing
+ max_dynamics_width = max_dynamics_width - size * control.scale * control.ratio
+ dynamic_units = dynamic_units - control.scale * control.ratio
+ end
+
+ if min_content_width < available_width then break end
+ end
+ end
+ end
+
+ -- Lay out the elements
+ local current_x = self.ax
+ local width_for_dynamics = available_width - statics_width
+ local space_width = (width_for_dynamics - max_dynamics_width) / spaces
+
+ for c, control in ipairs(self.layout) do
+ if not control.hide then
+ local sizing, element, scale, ratio = control.sizing, control.element, control.scale, control.ratio
+ local width, height = 0, 0
+
+ if sizing == 'space' then
+ if space_width > 0 then width = space_width end
+ elseif sizing == 'static' then
+ height = size * scale
+ width = height * ratio
+ elseif sizing == 'dynamic' then
+ height = size * scale
+ width = max_dynamics_width < width_for_dynamics
+ and height * ratio or width_for_dynamics * ((scale * ratio) / dynamic_units)
+ end
+
+ local bx = current_x + width
+ if element then element:set_coordinates(round(current_x), round(self.by - height), bx, self.by) end
+ current_x = bx + spacing
+ end
+ end
+
+ Elements:update_proximities()
+ request_render()
+end
+
+function Controls:on_dispositions() self:reflow() end
+function Controls:on_display() self:update_dimensions() end
+function Controls:on_prop_border() self:update_dimensions() end
+function Controls:on_prop_fullormaxed() self:update_dimensions() end
+function Controls:on_timeline_enabled() self:update_dimensions() end
+
+return Controls
diff --git a/.config/mpv/scripts/uosc_shared/elements/Curtain.lua b/.config/mpv/scripts/uosc_shared/elements/Curtain.lua
new file mode 100644
index 0000000..99b9f14
--- /dev/null
+++ b/.config/mpv/scripts/uosc_shared/elements/Curtain.lua
@@ -0,0 +1,35 @@
+local Element = require('uosc_shared/elements/Element')
+
+---@class Curtain : Element
+local Curtain = class(Element)
+
+function Curtain:new() return Class.new(self) --[[@as Curtain]] end
+function Curtain:init()
+ Element.init(self, 'curtain', {ignores_menu = true})
+ self.opacity = 0
+ ---@type string[]
+ self.dependents = {}
+end
+
+---@param id string
+function Curtain:register(id)
+ self.dependents[#self.dependents + 1] = id
+ if #self.dependents == 1 then self:tween_property('opacity', self.opacity, 1) end
+end
+
+---@param id string
+function Curtain:unregister(id)
+ self.dependents = itable_filter(self.dependents, function(item) return item ~= id end)
+ if #self.dependents == 0 then self:tween_property('opacity', self.opacity, 0) end
+end
+
+function Curtain:render()
+ if self.opacity == 0 or options.curtain_opacity == 0 then return end
+ local ass = assdraw.ass_new()
+ ass:rect(0, 0, display.width, display.height, {
+ color = '000000', opacity = options.curtain_opacity * self.opacity,
+ })
+ return ass
+end
+
+return Curtain
diff --git a/.config/mpv/scripts/uosc_shared/elements/CycleButton.lua b/.config/mpv/scripts/uosc_shared/elements/CycleButton.lua
new file mode 100644
index 0000000..7f1c02f
--- /dev/null
+++ b/.config/mpv/scripts/uosc_shared/elements/CycleButton.lua
@@ -0,0 +1,64 @@
+local Button = require('uosc_shared/elements/Button')
+
+---@alias CycleState {value: any; icon: string; active?: boolean}
+---@alias CycleButtonProps {prop: string; states: CycleState[]; anchor_id?: string; tooltip?: string}
+
+---@class CycleButton : Button
+local CycleButton = class(Button)
+
+---@param id string
+---@param props CycleButtonProps
+function CycleButton:new(id, props) return Class.new(self, id, props) --[[@as CycleButton]] end
+---@param id string
+---@param props CycleButtonProps
+function CycleButton:init(id, props)
+ local is_state_prop = itable_index_of({'shuffle'}, props.prop)
+ self.prop = props.prop
+ self.states = props.states
+
+ Button.init(self, id, props)
+
+ self.icon = self.states[1].icon
+ self.active = self.states[1].active
+ self.current_state_index = 1
+ self.on_click = function()
+ local new_state = self.states[self.current_state_index + 1] or self.states[1]
+ local new_value = new_state.value
+ if self.owner then
+ mp.commandv('script-message-to', self.owner, 'set', self.prop, new_value)
+ elseif is_state_prop then
+ if itable_index_of({'yes', 'no'}, new_value) then new_value = new_value == 'yes' end
+ set_state(self.prop, new_value)
+ else
+ mp.set_property(self.prop, new_value)
+ end
+ end
+
+ self.handle_change = function(name, value)
+ if is_state_prop and type(value) == 'boolean' then value = value and 'yes' or 'no' end
+ local index = itable_find(self.states, function(state) return state.value == value end)
+ self.current_state_index = index or 1
+ self.icon = self.states[self.current_state_index].icon
+ self.active = self.states[self.current_state_index].active
+ request_render()
+ end
+
+ local prop_parts = split(self.prop, '@')
+ if #prop_parts == 2 then -- External prop with a script owner
+ self.prop, self.owner = prop_parts[1], prop_parts[2]
+ self['on_external_prop_' .. self.prop] = function(_, value) self.handle_change(self.prop, value) end
+ self.handle_change(self.prop, external[self.prop])
+ elseif is_state_prop then -- uosc's state props
+ self['on_prop_' .. self.prop] = function(self, value) self.handle_change(self.prop, value) end
+ self.handle_change(self.prop, state[self.prop])
+ else
+ mp.observe_property(self.prop, 'string', self.handle_change)
+ end
+end
+
+function CycleButton:destroy()
+ Button.destroy(self)
+ mp.unobserve_property(self.handle_change)
+end
+
+return CycleButton
diff --git a/.config/mpv/scripts/uosc_shared/elements/Element.lua b/.config/mpv/scripts/uosc_shared/elements/Element.lua
new file mode 100644
index 0000000..718782d
--- /dev/null
+++ b/.config/mpv/scripts/uosc_shared/elements/Element.lua
@@ -0,0 +1,149 @@
+---@alias ElementProps {enabled?: boolean; ax?: number; ay?: number; bx?: number; by?: number; ignores_menu?: boolean; anchor_id?: string;}
+
+-- Base class all elements inherit from.
+---@class Element : Class
+local Element = class()
+
+---@param id string
+---@param props? ElementProps
+function Element:init(id, props)
+ self.id = id
+ -- `false` means element won't be rendered, or receive events
+ self.enabled = true
+ -- Element coordinates
+ self.ax, self.ay, self.bx, self.by = 0, 0, 0, 0
+ -- Relative proximity from `0` - mouse outside `proximity_max` range, to `1` - mouse within `proximity_min` range.
+ self.proximity = 0
+ -- Raw proximity in pixels.
+ self.proximity_raw = infinity
+ ---@type number `0-1` factor to force min visibility. Used for toggling element's permanent visibility.
+ self.min_visibility = 0
+ ---@type number `0-1` factor to force a visibility value. Used for flashing, fading out, and other animations
+ self.forced_visibility = nil
+ ---@type boolean Render this element even when menu is open.
+ self.ignores_menu = false
+ ---@type nil|string ID of an element from which this one should inherit visibility.
+ self.anchor_id = nil
+
+ if props then table_assign(self, props) end
+
+ -- Flash timer
+ self._flash_out_timer = mp.add_timeout(options.flash_duration / 1000, function()
+ local getTo = function() return self.proximity end
+ self:tween_property('forced_visibility', 1, getTo, function()
+ self.forced_visibility = nil
+ end)
+ end)
+ self._flash_out_timer:kill()
+
+ Elements:add(self)
+end
+
+function Element:destroy()
+ self.destroyed = true
+ Elements:remove(self)
+end
+
+---@param ax number
+---@param ay number
+---@param bx number
+---@param by number
+function Element:set_coordinates(ax, ay, bx, by)
+ self.ax, self.ay, self.bx, self.by = ax, ay, bx, by
+ Elements:update_proximities()
+ self:maybe('on_coordinates')
+end
+
+function Element:update_proximity()
+ if cursor.hidden then
+ self.proximity_raw = infinity
+ self.proximity = 0
+ else
+ local range = options.proximity_out - options.proximity_in
+ self.proximity_raw = get_point_to_rectangle_proximity(cursor, self)
+ self.proximity = 1 - (clamp(0, self.proximity_raw - options.proximity_in, range) / range)
+ end
+end
+
+-- Decide elements visibility based on proximity and various other factors
+function Element:get_visibility()
+ -- Hide when menu is open, unless this is a menu
+ ---@diagnostic disable-next-line: undefined-global
+ if not self.ignores_menu and Menu and Menu:is_open() then return 0 end
+
+ -- Persistency
+ local persist = config[self.id .. '_persistency']
+ if persist and (
+ (persist.audio and state.is_audio)
+ or (persist.paused and state.pause)
+ or (persist.video and state.is_video)
+ or (persist.image and state.is_image)
+ or (persist.idle and state.is_idle)
+ ) then return 1 end
+
+ -- Forced visibility
+ if self.forced_visibility then return math.max(self.forced_visibility, self.min_visibility) end
+
+ -- Anchor inheritance
+ -- If anchor returns -1, it means all attached elements should force hide.
+ local anchor = self.anchor_id and Elements[self.anchor_id]
+ local anchor_visibility = anchor and anchor:get_visibility() or 0
+
+ return anchor_visibility == -1 and 0 or math.max(self.proximity, anchor_visibility, self.min_visibility)
+end
+
+-- Call method if it exists
+function Element:maybe(name, ...)
+ if self[name] then return self[name](self, ...) end
+end
+
+-- Attach a tweening animation to this element
+---@param from number
+---@param to number|fun():number
+---@param setter fun(value: number)
+---@param factor_or_callback? number|fun()
+---@param callback? fun() Called either on animation end, or when animation is killed.
+function Element:tween(from, to, setter, factor_or_callback, callback)
+ self:tween_stop()
+ self._kill_tween = self.enabled and tween(
+ from, to, setter, factor_or_callback,
+ function()
+ self._kill_tween = nil
+ if callback then callback() end
+ end
+ )
+end
+
+function Element:is_tweening() return self and self._kill_tween end
+function Element:tween_stop() self:maybe('_kill_tween') end
+
+-- Animate an element property between 2 values.
+---@param prop string
+---@param from number
+---@param to number|fun():number
+---@param factor_or_callback? number|fun()
+---@param callback? fun() Called either on animation end, or when animation is killed.
+function Element:tween_property(prop, from, to, factor_or_callback, callback)
+ self:tween(from, to, function(value) self[prop] = value end, factor_or_callback, callback)
+end
+
+---@param name string
+function Element:trigger(name, ...)
+ local result = self:maybe('on_' .. name, ...)
+ request_render()
+ return result
+end
+
+-- Briefly flashes the element for `options.flash_duration` milliseconds.
+-- Useful to visualize changes of volume and timeline when changed via hotkeys.
+function Element:flash()
+ if options.flash_duration > 0 and (self.proximity < 1 or self._flash_out_timer:is_enabled()) then
+ self:tween_stop()
+ self.forced_visibility = 1
+ request_render()
+ self._flash_out_timer:kill()
+ self._flash_out_timer:resume()
+ end
+end
+
+return Element
diff --git a/.config/mpv/scripts/uosc_shared/elements/Elements.lua b/.config/mpv/scripts/uosc_shared/elements/Elements.lua
new file mode 100644
index 0000000..fe2cd6b
--- /dev/null
+++ b/.config/mpv/scripts/uosc_shared/elements/Elements.lua
@@ -0,0 +1,158 @@
+local Elements = {itable = {}}
+
+---@param element Element
+function Elements:add(element)
+ if not element.id then
+ msg.error('attempt to add element without "id" property')
+ return
+ end
+
+ if self:has(element.id) then Elements:remove(element.id) end
+
+ self.itable[#self.itable + 1] = element
+ self[element.id] = element
+
+ request_render()
+end
+
+function Elements:remove(idOrElement)
+ if not idOrElement then return end
+ local id = type(idOrElement) == 'table' and idOrElement.id or idOrElement
+ local element = Elements[id]
+ if element then
+ if not element.destroyed then element:destroy() end
+ element.enabled = false
+ self.itable = itable_remove(self.itable, self[id])
+ self[id] = nil
+ request_render()
+ end
+end
+
+function Elements:update_proximities()
+ local capture_mbtn_left = false
+ local capture_wheel = false
+ local menu_only = Elements.menu ~= nil
+ local mouse_leave_elements = {}
+ local mouse_enter_elements = {}
+
+ -- Calculates proximities and opacities for defined elements
+ for _, element in self:ipairs() do
+ if element.enabled then
+ local previous_proximity_raw = element.proximity_raw
+
+ -- If menu is open, all other elements have to be disabled
+ if menu_only then
+ if element.ignores_menu then
+ capture_mbtn_left = true
+ capture_wheel = true
+ element:update_proximity()
+ else
+ element.proximity_raw = infinity
+ element.proximity = 0
+ end
+ else
+ element:update_proximity()
+ end
+
+ -- Element has global forced key listeners
+ if element.on_global_mbtn_left_down then capture_mbtn_left = true end
+ if element.on_global_wheel_up or element.on_global_wheel_down then capture_wheel = true end
+
+ if element.proximity_raw == 0 then
+ -- Element has local forced key listeners
+ if element.on_mbtn_left_down then capture_mbtn_left = true end
+ if element.on_wheel_up or element.on_wheel_up then capture_wheel = true end
+
+ -- Mouse entered element area
+ if previous_proximity_raw ~= 0 then
+ mouse_enter_elements[#mouse_enter_elements + 1] = element
+ end
+ else
+ -- Mouse left element area
+ if previous_proximity_raw == 0 then
+ mouse_leave_elements[#mouse_leave_elements + 1] = element
+ end
+ end
+ end
+ end
+
+ -- Enable key group captures requested by elements
+ mp[capture_mbtn_left and 'enable_key_bindings' or 'disable_key_bindings']('mbtn_left')
+ mp[capture_wheel and 'enable_key_bindings' or 'disable_key_bindings']('wheel')
+
+ -- Trigger `mouse_leave` and `mouse_enter` events
+ for _, element in ipairs(mouse_leave_elements) do element:trigger('mouse_leave') end
+ for _, element in ipairs(mouse_enter_elements) do element:trigger('mouse_enter') end
+end
+
+-- Toggles passed elements' min visibilities between 0 and 1.
+---@param ids string[] IDs of elements to peek.
+function Elements:toggle(ids)
+ local has_invisible = itable_find(ids, function(id) return Elements[id] and Elements[id].min_visibility ~= 1 end)
+ self:set_min_visibility(has_invisible and 1 or 0, ids)
+end
+
+-- Set (animate) elements' min visibilities to passed value.
+---@param visibility number 0-1 floating point.
+---@param ids string[] IDs of elements to peek.
+function Elements:set_min_visibility(visibility, ids)
+ for _, id in ipairs(ids) do
+ local element = Elements[id]
+ if element then element:tween_property('min_visibility', element.min_visibility, visibility) end
+ end
+end
+
+-- Flash passed elements.
+---@param ids string[] IDs of elements to peek.
+function Elements:flash(ids)
+ local elements = itable_filter(self.itable, function(element) return itable_index_of(ids, element.id) ~= nil end)
+ for _, element in ipairs(elements) do element:flash() end
+end
+
+---@param name string Event name.
+function Elements:trigger(name, ...)
+ for _, element in self:ipairs() do element:trigger(name, ...) end
+end
+
+-- Trigger two events, `name` and `global_name`, depending on element-cursor proximity.
+-- Disabled elements don't receive these events.
+---@param name string Event name.
+function Elements:proximity_trigger(name, ...)
+ local stop_normal, stop_global = false, false
+ for i = #self.itable, 1, -1 do
+ local element = self.itable[i]
+ if element.enabled then
+ if element.proximity_raw == 0 then
+ if element:trigger(name, ...) == 'stop_propagation' then break end
+ end
+ if element:trigger('global_' .. name, ...) == 'stop_propagation' then break end
+ end
+ end
+end
+
+function Elements:has(id) return self[id] ~= nil end
+function Elements:ipairs() return ipairs(self.itable) end
+
+---@param name string Event name.
+function Elements:create_proximity_dispatcher(name)
+ return function(...) self:proximity_trigger(name, ...) end
+end
+
+mp.set_key_bindings({
+ {
+ 'mbtn_left',
+ Elements:create_proximity_dispatcher('mbtn_left_up'),
+ function(...)
+ update_mouse_pos(nil, mp.get_property_native('mouse-pos'), true)
+ Elements:proximity_trigger('mbtn_left_down', ...)
+ end,
+ },
+ {'mbtn_left_dbl', 'ignore'},
+}, 'mbtn_left', 'force')
+
+mp.set_key_bindings({
+ {'wheel_up', Elements:create_proximity_dispatcher('wheel_up')},
+ {'wheel_down', Elements:create_proximity_dispatcher('wheel_down')},
+}, 'wheel', 'force')
+
+return Elements
diff --git a/.config/mpv/scripts/uosc_shared/elements/Menu.lua b/.config/mpv/scripts/uosc_shared/elements/Menu.lua
new file mode 100644
index 0000000..aad5016
--- /dev/null
+++ b/.config/mpv/scripts/uosc_shared/elements/Menu.lua
@@ -0,0 +1,783 @@
+local Element = require('uosc_shared/elements/Element')
+
+-- Menu data structure accepted by `Menu:open(menu)`.
+---@alias MenuData {type?: string; title?: string; hint?: string; keep_open?: boolean; separator?: boolean; items?: MenuDataItem[]; selected_index?: integer;}
+---@alias MenuDataItem MenuDataValue|MenuData
+---@alias MenuDataValue {title?: string; hint?: string; icon?: string; value: any; bold?: boolean; italic?: boolean; muted?: boolean; active?: boolean; keep_open?: boolean; separator?: boolean;}
+---@alias MenuOptions {mouse_nav?: boolean; on_open?: fun(); on_close?: fun(); on_back?: fun()}
+
+-- Internal data structure created from `Menu`.
+---@alias MenuStack {id?: string; type?: string; title?: string; hint?: string; selected_index?: number; keep_open?: boolean; separator?: boolean; items: MenuStackItem[]; parent_menu?: MenuStack; active?: boolean; width: number; height: number; top: number; scroll_y: number; scroll_height: number; title_width: number; hint_width: number; max_width: number; is_root?: boolean; fling?: Fling}
+---@alias MenuStackItem MenuStackValue|MenuStack
+---@alias MenuStackValue {title?: string; hint?: string; icon?: string; value: any; active?: boolean; bold?: boolean; italic?: boolean; muted?: boolean; keep_open?: boolean; separator?: boolean; title_width: number; hint_width: number}
+---@alias Fling {y: number, distance: number, time: number, easing: fun(x: number), duration: number, update_cursor?: boolean}
+
+---@class Menu : Element
+local Menu = class(Element)
+
+---@param data MenuData
+---@param callback fun(value: any)
+---@param opts? MenuOptions
+function Menu:open(data, callback, opts)
+ local open_menu = self:is_open()
+ if open_menu then
+ open_menu.is_being_replaced = true
+ open_menu:close(true)
+ end
+ return Menu:new(data, callback, opts)
+end
+
+---@param menu_type? string
+---@return Menu|nil
+function Menu:is_open(menu_type)
+ return Elements.menu and (not menu_type or Elements.menu.type == menu_type) and Elements.menu or nil
+end
+
+---@param immediate? boolean Close immediately without fadeout animation.
+---@param callback? fun() Called after the animation (if any) ends and element is removed and destroyed.
+---@overload fun(callback: fun())
+function Menu:close(immediate, callback)
+ if type(immediate) ~= 'boolean' then callback = immediate end
+
+ local menu = self == Menu and Elements.menu or self
+
+ if menu and not menu.destroyed then
+ if menu.is_closing then
+ menu:tween_stop()
+ return
+ end
+
+ local function close()
+ Elements:remove('menu')
+ menu.is_closing, menu.stack, menu.current, menu.all, menu.by_id = false, nil, nil, {}, {}
+ menu:disable_key_bindings()
+ Elements:update_proximities()
+ if callback then callback() end
+ request_render()
+ end
+
+ menu.is_closing = true
+
+ if immediate then close()
+ else menu:fadeout(close) end
+ end
+end
+
+---@param data MenuData
+---@param callback fun(value: any)
+---@param opts? MenuOptions
+---@return Menu
+function Menu:new(data, callback, opts) return Class.new(self, data, callback, opts) --[[@as Menu]] end
+---@param data MenuData
+---@param callback fun(value: any)
+---@param opts? MenuOptions
+function Menu:init(data, callback, opts)
+ Element.init(self, 'menu', {ignores_menu = true})
+
+ -----@type fun()
+ self.callback = callback
+ self.opts = opts or {}
+ self.offset_x = 0 -- Used for submenu transition animation.
+ self.mouse_nav = self.opts.mouse_nav -- Stops pre-selecting items
+ self.item_height = nil
+ self.item_spacing = 1
+ self.item_padding = nil
+ self.font_size = nil
+ self.font_size_hint = nil
+ self.scroll_step = nil -- Item height + item spacing.
+ self.scroll_height = nil -- Items + spacings - container height.
+ self.opacity = 0 -- Used to fade in/out.
+ self.type = data.type
+ ---@type MenuStack Root MenuStack.
+ self.root = nil
+ ---@type MenuStack Current MenuStack.
+ self.current = nil
+ ---@type MenuStack[] All menus in a flat array.
+ self.all = nil
+ ---@type table<string, MenuStack> Map of submenus by their ids, such as `'Tools > Aspect ratio'`.
+ self.by_id = {}
+ self.key_bindings = {}
+ self.is_being_replaced = false
+ self.is_closing, self.is_closed = false, false
+ ---@type {y: integer, time: number}[]
+ self.drag_data = nil
+ self.is_dragging = false
+
+ self:update(data)
+
+ if self.mouse_nav then
+ if self.current then self.current.selected_index = nil end
+ else
+ for _, menu in ipairs(self.all) do self:scroll_to_index(menu.selected_index, menu) end
+ end
+
+ self:tween_property('opacity', 0, 1)
+ self:enable_key_bindings()
+ Elements.curtain:register('menu')
+ if self.opts.on_open then self.opts.on_open() end
+end
+
+function Menu:destroy()
+ Element.destroy(self)
+ self:disable_key_bindings()
+ self.is_closed = true
+ if not self.is_being_replaced then Elements.curtain:unregister('menu') end
+ if self.opts.on_close then self.opts.on_close() end
+end
+
+---@param data MenuData
+function Menu:update(data)
+ self.type = data.type
+
+ local new_root = {is_root = true}
+ local new_all = {}
+ local new_by_id = {}
+ local menus_to_serialize = {{new_root, data}}
+ local old_current_id = self.current and self.current.id
+
+ table_assign(new_root, data, {'title', 'hint', 'keep_open'})
+
+ local i = 0
+ while i < #menus_to_serialize do
+ i = i + 1
+ local menu, menu_data = menus_to_serialize[i][1], menus_to_serialize[i][2]
+ local parent_id = menu.parent_menu and not menu.parent_menu.is_root and menu.parent_menu.id
+ if not menu.is_root then
+ menu.id = (parent_id and parent_id .. ' > ' or '') .. (menu_data.title or i)
+ end
+ menu.icon = 'chevron_right'
+
+ -- Update items
+ local first_active_index = nil
+ menu.items = {}
+
+ for i, item_data in ipairs(menu_data.items or {}) do
+ if item_data.active and not first_active_index then first_active_index = i end
+
+ local item = {}
+ table_assign(item, item_data, {
+ 'title', 'icon', 'hint', 'active', 'bold', 'italic', 'muted', 'value', 'keep_open', 'separator',
+ })
+ if item.keep_open == nil then item.keep_open = menu.keep_open end
+
+ -- Submenu
+ if item_data.items then
+ item.parent_menu = menu
+ menus_to_serialize[#menus_to_serialize + 1] = {item, item_data}
+ end
+
+ menu.items[i] = item
+ end
+
+ if menu.is_root then menu.selected_index = menu_data.selected_index or first_active_index end
+
+ -- Retain old state
+ local old_menu = self.by_id[menu.is_root and '__root__' or menu.id]
+ if old_menu then table_assign(menu, old_menu, {'selected_index', 'scroll_y', 'fling'}) end
+
+ new_all[#new_all + 1] = menu
+ new_by_id[menu.is_root and '__root__' or menu.id] = menu
+ end
+
+ self.root, self.all, self.by_id = new_root, new_all, new_by_id
+ self.current = self.by_id[old_current_id] or self.root
+
+ self:update_content_dimensions()
+ self:reset_navigation()
+end
+
+---@param items MenuDataItem[]
+function Menu:update_items(items)
+ local data = table_shallow_copy(self.root)
+ data.items = items
+ self:update(data)
+end
+
+function Menu:update_content_dimensions()
+ self.item_height = state.fullormaxed and options.menu_item_height_fullscreen or options.menu_item_height
+ self.font_size = round(self.item_height * 0.48 * options.font_scale)
+ self.font_size_hint = self.font_size - 1
+ self.item_padding = round((self.item_height - self.font_size) * 0.6)
+ self.scroll_step = self.item_height + self.item_spacing
+
+ local title_opts = {size = self.font_size, italic = false, bold = false}
+ local hint_opts = {size = self.font_size_hint}
+
+ for _, menu in ipairs(self.all) do
+ -- Estimate width of a widest item
+ local max_width = 0
+ for _, item in ipairs(menu.items) do
+ local icon_width = item.icon and self.font_size or 0
+ item.title_width = text_width(item.title, title_opts)
+ item.hint_width = text_width(item.hint, hint_opts)
+ local spacings_in_item = 1 + (item.title_width > 0 and 1 or 0)
+ + (item.hint_width > 0 and 1 or 0) + (icon_width > 0 and 1 or 0)
+ local estimated_width = item.title_width + item.hint_width + icon_width
+ + (self.item_padding * spacings_in_item)
+ if estimated_width > max_width then max_width = estimated_width end
+ end
+
+ -- Also check menu title
+ title_opts.bold, title_opts.italic = true, false
+ local menu_title_width = text_width(menu.title, title_opts)
+ if menu_title_width > max_width then max_width = menu_title_width end
+
+ menu.max_width = max_width
+ end
+
+ self:update_dimensions()
+end
+
+function Menu:update_dimensions()
+ -- Coordinates and sizes are of the scrollable area to make
+ -- consuming values in rendering and collisions easier. Title drawn above this, so
+ -- we need to account for that in max_height and ay position.
+ local min_width = state.fullormaxed and options.menu_min_width_fullscreen or options.menu_min_width
+
+ for _, menu in ipairs(self.all) do
+ menu.width = round(clamp(min_width, menu.max_width, display.width * 0.9))
+ local title_height = (menu.is_root and menu.title) and self.scroll_step or 0
+ local max_height = round((display.height - title_height) * 0.9)
+ local content_height = self.scroll_step * #menu.items
+ menu.height = math.min(content_height - self.item_spacing, max_height)
+ menu.top = round(math.max((display.height - menu.height) / 2, title_height * 1.5))
+ menu.scroll_height = math.max(content_height - menu.height - self.item_spacing, 0)
+ menu.scroll_y = menu.scroll_y or 0
+ self:scroll_to(menu.scroll_y, menu) -- clamps scroll_y to scroll limits
+ end
+
+ local ax = round((display.width - self.current.width) / 2) + self.offset_x
+ self:set_coordinates(ax, self.current.top, ax + self.current.width, self.current.top + self.current.height)
+end
+
+function Menu:reset_navigation()
+ local menu = self.current
+
+ -- Reset indexes and scroll
+ self:scroll_to(menu.scroll_y) -- clamps scroll_y to scroll limits
+ if self.mouse_nav then
+ self:select_item_below_cursor()
+ else
+ self:select_index((menu.items and #menu.items > 0) and clamp(1, menu.selected_index or 1, #menu.items) or nil)
+ end
+
+ -- Walk up the parent menu chain and activate items that lead to current menu
+ local parent = menu.parent_menu
+ while parent do
+ parent.selected_index = itable_index_of(parent.items, menu)
+ menu, parent = parent, parent.parent_menu
+ end
+
+ request_render()
+end
+
+function Menu:set_offset_x(offset)
+ local delta = offset - self.offset_x
+ self.offset_x = offset
+ self:set_coordinates(self.ax + delta, self.ay, self.bx + delta, self.by)
+end
+
+function Menu:fadeout(callback) self:tween_property('opacity', 1, 0, callback) end
+
+function Menu:get_item_index_below_cursor()
+ local menu = self.current
+ if #menu.items < 1 or self.proximity_raw > 0 then return nil end
+ return math.max(1, math.min(math.ceil((cursor.y - self.ay + menu.scroll_y) / self.scroll_step), #menu.items))
+end
+
+function Menu:get_first_active_index(menu)
+ menu = menu or self.current
+ for index, item in ipairs(self.current.items) do
+ if item.active then return index end
+ end
+end
+
+---@param pos? number
+---@param menu? MenuStack
+function Menu:set_scroll_to(pos, menu)
+ menu = menu or self.current
+ menu.scroll_y = clamp(0, pos or 0, menu.scroll_height)
+ request_render()
+end
+
+---@param delta? number
+---@param menu? MenuStack
+function Menu:set_scroll_by(delta, menu)
+ menu = menu or self.current
+ self:set_scroll_to(menu.scroll_y + delta, menu)
+end
+
+---@param pos? number
+---@param menu? MenuStack
+---@param fling_options? table
+function Menu:scroll_to(pos, menu, fling_options)
+ menu = menu or self.current
+ menu.fling = {
+ y = menu.scroll_y, distance = clamp(-menu.scroll_y, pos - menu.scroll_y, menu.scroll_height - menu.scroll_y),
+ time = mp.get_time(), duration = 0.1, easing = ease_out_sext,
+ }
+ if fling_options then table_assign(menu.fling, fling_options) end
+ request_render()
+end
+
+---@param delta? number
+---@param menu? MenuStack
+---@param fling_options? Fling
+function Menu:scroll_by(delta, menu, fling_options)
+ menu = menu or self.current
+ self:scroll_to((menu.fling and (menu.fling.y + menu.fling.distance) or menu.scroll_y) + delta, menu, fling_options)
+end
+
+---@param index? integer
+---@param menu? MenuStack
+---@param immediate? boolean
+function Menu:scroll_to_index(index, menu, immediate)
+ menu = menu or self.current
+ if (index and index >= 1 and index <= #menu.items) then
+ local position = round((self.scroll_step * (index - 1)) - ((menu.height - self.scroll_step) / 2))
+ if immediate then self:set_scroll_to(position, menu)
+ else self:scroll_to(position, menu) end
+ end
+end
+
+---@param index? integer
+---@param menu? MenuStack
+function Menu:select_index(index, menu)
+ menu = menu or self.current
+ menu.selected_index = (index and index >= 1 and index <= #menu.items) and index or nil
+ request_render()
+end
+
+---@param value? any
+---@param menu? MenuStack
+function Menu:select_value(value, menu)
+ menu = menu or self.current
+ local index = itable_find(menu.items, function(item) return item.value == value end)
+ self:select_index(index, 5)
+end
+
+---@param menu? MenuStack
+function Menu:deactivate_items(menu)
+ menu = menu or self.current
+ for _, item in ipairs(menu.items) do item.active = false end
+ request_render()
+end
+
+---@param index? integer
+---@param menu? MenuStack
+function Menu:activate_index(index, menu)
+ menu = menu or self.current
+ if index and index >= 1 and index <= #menu.items then menu.items[index].active = true end
+ request_render()
+end
+
+---@param index? integer
+---@param menu? MenuStack
+function Menu:activate_one_index(index, menu)
+ self:deactivate_items(menu)
+ self:activate_index(index, menu)
+end
+
+---@param value? any
+---@param menu? MenuStack
+function Menu:activate_value(value, menu)
+ menu = menu or self.current
+ local index = itable_find(menu.items, function(item) return item.value == value end)
+ self:activate_index(index, menu)
+end
+
+---@param value? any
+---@param menu? MenuStack
+function Menu:activate_one_value(value, menu)
+ menu = menu or self.current
+ local index = itable_find(menu.items, function(item) return item.value == value end)
+ self:activate_one_index(index, menu)
+end
+
+---@param id string
+function Menu:activate_submenu(id)
+ local submenu = self.by_id[id]
+ if submenu then
+ self.current = submenu
+ request_render()
+ else
+ msg.error(string.format('Requested submenu id "%s" doesn\'t exist', id))
+ end
+ self:reset_navigation()
+end
+
+---@param index? integer
+---@param menu? MenuStack
+function Menu:delete_index(index, menu)
+ menu = menu or self.current
+ if (index and index >= 1 and index <= #menu.items) then
+ table.remove(menu.items, index)
+ self:update_content_dimensions()
+ self:scroll_to_index(menu.selected_index, menu)
+ end
+end
+
+---@param value? any
+---@param menu? MenuStack
+function Menu:delete_value(value, menu)
+ menu = menu or self.current
+ local index = itable_find(menu.items, function(item) return item.value == value end)
+ self:delete_index(index)
+end
+
+---@param menu? MenuStack
+function Menu:prev(menu)
+ menu = menu or self.current
+ menu.selected_index = math.max(menu.selected_index and menu.selected_index - 1 or #menu.items, 1)
+ self:scroll_to_index(menu.selected_index, menu, true)
+end
+
+---@param menu? MenuStack
+function Menu:next(menu)
+ menu = menu or self.current
+ menu.selected_index = math.min(menu.selected_index and menu.selected_index + 1 or 1, #menu.items)
+ self:scroll_to_index(menu.selected_index, menu, true)
+end
+
+function Menu:back()
+ if self.opts.on_back then
+ self.opts.on_back()
+ if self.is_closed then return end
+ end
+
+ local menu = self.current
+ local parent = menu.parent_menu
+
+ if parent then
+ menu.selected_index = nil
+ self.current = parent
+ self:update_dimensions()
+ self:tween(self.offset_x - menu.width / 2, 0, function(offset) self:set_offset_x(offset) end)
+ self.opacity = 1 -- in case tween above canceled fade in animation
+ else
+ self:close()
+ end
+end
+
+---@param opts? {keep_open?: boolean, preselect_submenu_item?: boolean}
+function Menu:open_selected_item(opts)
+ opts = opts or {}
+ local menu = self.current
+ if menu.selected_index then
+ local item = menu.items[menu.selected_index]
+ -- Is submenu
+ if item.items then
+ self.current = item
+ if opts.preselect_submenu_item then
+ item.selected_index = #item.items > 0 and 1 or nil
+ end
+ self:update_dimensions()
+ self:tween(self.offset_x + menu.width / 2, 0, function(offset) self:set_offset_x(offset) end)
+ self.opacity = 1 -- in case tween above canceled fade in animation
+ else
+ self.callback(item.value)
+ if not item.keep_open and not opts.keep_open then self:close() end
+ end
+ end
+end
+
+function Menu:open_selected_item_soft() self:open_selected_item({keep_open = true}) end
+function Menu:open_selected_item_preselect() self:open_selected_item({preselect_submenu_item = true}) end
+function Menu:select_item_below_cursor() self.current.selected_index = self:get_item_index_below_cursor() end
+
+function Menu:on_display() self:update_dimensions() end
+function Menu:on_prop_fullormaxed() self:update_content_dimensions() end
+
+function Menu:on_global_mbtn_left_down()
+ if self.proximity_raw == 0 then
+ self.drag_data = {{y = cursor.y, time = mp.get_time()}}
+ self.current.fling = nil
+ else
+ if cursor.x < self.ax then self:back()
+ else self:close() end
+ end
+end
+
+function Menu:fling_distance()
+ local first, last = self.drag_data[1], self.drag_data[#self.drag_data]
+ if mp.get_time() - last.time > 0.05 then return 0 end
+ for i = #self.drag_data - 1, 1, -1 do
+ local drag = self.drag_data[i]
+ if last.time - drag.time > 0.03 then return ((drag.y - last.y) / ((last.time - drag.time) / 0.03)) * 10 end
+ end
+ return #self.drag_data < 2 and 0 or ((first.y - last.y) / ((first.time - last.time) / 0.03)) * 10
+end
+
+function Menu:on_global_mbtn_left_up()
+ if self.proximity_raw == 0 and self.drag_data and not self.is_dragging then
+ self:select_item_below_cursor()
+ self:open_selected_item({preselect_submenu_item = false})
+ end
+ if self.is_dragging then
+ local distance = self:fling_distance()
+ if math.abs(distance) > 50 then
+ self.current.fling = {
+ y = self.current.scroll_y, distance = distance, time = self.drag_data[#self.drag_data].time,
+ easing = ease_out_quart, duration = 0.5, update_cursor = true,
+ }
+ end
+ end
+ self.is_dragging = false
+ self.drag_data = nil
+end
+
+
+function Menu:on_global_mouse_move()
+ self.mouse_nav = true
+ if self.drag_data then
+ self.is_dragging = self.is_dragging or math.abs(cursor.y - self.drag_data[1].y) >= 10
+ local distance = self.drag_data[#self.drag_data].y - cursor.y
+ if distance ~= 0 then self:set_scroll_by(distance) end
+ self.drag_data[#self.drag_data + 1] = {y = cursor.y, time = mp.get_time()}
+ end
+ if self.proximity_raw == 0 or self.is_dragging then self:select_item_below_cursor()
+ else self.current.selected_index = nil end
+ request_render()
+end
+
+function Menu:on_wheel_up() self:scroll_by(self.scroll_step * -3, nil, {update_cursor = true}) end
+function Menu:on_wheel_down() self:scroll_by(self.scroll_step * 3, nil, {update_cursor = true}) end
+
+function Menu:on_pgup()
+ local menu = self.current
+ local items_per_page = round((menu.height / self.scroll_step) * 0.4)
+ local paged_index = (menu.selected_index and menu.selected_index or #menu.items) - items_per_page
+ menu.selected_index = clamp(1, paged_index, #menu.items)
+ if menu.selected_index > 0 then self:scroll_to_index(menu.selected_index) end
+end
+
+function Menu:on_pgdwn()
+ local menu = self.current
+ local items_per_page = round((menu.height / self.scroll_step) * 0.4)
+ local paged_index = (menu.selected_index and menu.selected_index or 1) + items_per_page
+ menu.selected_index = clamp(1, paged_index, #menu.items)
+ if menu.selected_index > 0 then self:scroll_to_index(menu.selected_index) end
+end
+
+function Menu:on_home()
+ self.current.selected_index = math.min(1, #self.current.items)
+ if self.current.selected_index > 0 then self:scroll_to_index(self.current.selected_index) end
+end
+
+function Menu:on_end()
+ self.current.selected_index = #self.current.items
+ if self.current.selected_index > 0 then self:scroll_to_index(self.current.selected_index) end
+end
+
+function Menu:add_key_binding(key, name, fn, flags)
+ self.key_bindings[#self.key_bindings + 1] = name
+ mp.add_forced_key_binding(key, name, fn, flags)
+end
+
+function Menu:enable_key_bindings()
+ -- The `mp.set_key_bindings()` method would be easier here, but that
+ -- doesn't support 'repeatable' flag, so we are stuck with this monster.
+ self:add_key_binding('up', 'menu-prev1', self:create_key_action('prev'), 'repeatable')
+ self:add_key_binding('down', 'menu-next1', self:create_key_action('next'), 'repeatable')
+ self:add_key_binding('left', 'menu-back1', self:create_key_action('back'))
+ self:add_key_binding('right', 'menu-select1', self:create_key_action('open_selected_item_preselect'))
+ self:add_key_binding('shift+right', 'menu-select-soft1', self:create_key_action('open_selected_item_soft'))
+ self:add_key_binding('shift+mbtn_left', 'menu-select-soft', self:create_key_action('open_selected_item_soft'))
+ self:add_key_binding('mbtn_back', 'menu-back-alt3', self:create_key_action('back'))
+ self:add_key_binding('bs', 'menu-back-alt4', self:create_key_action('back'))
+ self:add_key_binding('enter', 'menu-select-alt3', self:create_key_action('open_selected_item_preselect'))
+ self:add_key_binding('kp_enter', 'menu-select-alt4', self:create_key_action('open_selected_item_preselect'))
+ self:add_key_binding('shift+enter', 'menu-select-alt5', self:create_key_action('open_selected_item_soft'))
+ self:add_key_binding('shift+kp_enter', 'menu-select-alt6', self:create_key_action('open_selected_item_soft'))
+ self:add_key_binding('esc', 'menu-close', self:create_key_action('close'))
+ self:add_key_binding('pgup', 'menu-page-up', self:create_key_action('on_pgup'), 'repeatable')
+ self:add_key_binding('pgdwn', 'menu-page-down', self:create_key_action('on_pgdwn'), 'repeatable')
+ self:add_key_binding('home', 'menu-home', self:create_key_action('on_home'))
+ self:add_key_binding('end', 'menu-end', self:create_key_action('on_end'))
+end
+
+function Menu:disable_key_bindings()
+ for _, name in ipairs(self.key_bindings) do mp.remove_key_binding(name) end
+ self.key_bindings = {}
+end
+
+function Menu:create_key_action(name)
+ return function(...)
+ self.mouse_nav = false
+ self:maybe(name, ...)
+ end
+end
+
+function Menu:render()
+ local update_cursor = false
+ for _, menu in ipairs(self.all) do
+ if menu.fling then
+ update_cursor = update_cursor or menu.fling.update_cursor or false
+ local time_delta = state.render_last_time - menu.fling.time
+ local progress = menu.fling.easing(math.min(time_delta / menu.fling.duration, 1))
+ self:set_scroll_to(round(menu.fling.y + menu.fling.distance * progress), menu)
+ if progress < 1 then request_render() else menu.fling = nil end
+ end
+ end
+ if update_cursor then self:select_item_below_cursor() end
+
+ local ass = assdraw.ass_new()
+ local opacity = options.menu_opacity * self.opacity
+ local spacing = self.item_padding
+ local icon_size = self.font_size
+
+ function draw_menu(menu, x, y, opacity)
+ local ax, ay, bx, by = x, y, x + menu.width, y + menu.height
+ local draw_title = menu.is_root and menu.title
+ local scroll_clip = '\\clip(0,' .. ay .. ',' .. display.width .. ',' .. by .. ')'
+ local start_index = math.floor(menu.scroll_y / self.scroll_step) + 1
+ local end_index = math.ceil((menu.scroll_y + menu.height) / self.scroll_step)
+ local selected_index = menu.selected_index or -1
+ -- remove menu_opacity to start off with full opacity, but still decay for parent menus
+ local text_opacity = opacity / options.menu_opacity
+
+ -- Background
+ ass:rect(ax, ay - (draw_title and self.item_height or 0) - 2, bx, by + 2, {
+ color = bg, opacity = opacity, radius = 4,
+ })
+
+ for index = start_index, end_index, 1 do
+ local item = menu.items[index]
+ local next_item = menu.items[index + 1]
+ local is_highlighted = selected_index == index or item.active
+ local next_is_active = next_item and next_item.active
+ local next_is_highlighted = selected_index == index + 1 or next_is_active
+
+ if not item then break end
+
+ local item_ay = ay - menu.scroll_y + self.scroll_step * (index - 1)
+ local item_by = item_ay + self.item_height
+ local item_center_y = item_ay + (self.item_height / 2)
+ local item_clip = (item_ay < ay or item_by > by) and scroll_clip or nil
+ local content_ax, content_bx = ax + spacing, bx - spacing
+ local font_color = item.active and fgt or bgt
+ local shadow_color = item.active and fg or bg
+
+ -- Separator
+ local separator_ay = item.separator and item_by - 1 or item_by
+ local separator_by = item_by + (item.separator and 2 or 1)
+ if is_highlighted then separator_ay = item_by + 1 end
+ if next_is_highlighted then separator_by = item_by end
+ if separator_by - separator_ay > 0 and item_by < by then
+ ass:rect(ax + spacing / 2, separator_ay, bx - spacing / 2, separator_by, {
+ color = fg, opacity = opacity * (item.separator and 0.08 or 0.06),
+ })
+ end
+
+ -- Highlight
+ local highlight_opacity = 0 + (item.active and 0.8 or 0) + (selected_index == index and 0.15 or 0)
+ if highlight_opacity > 0 then
+ ass:rect(ax + 2, item_ay, bx - 2, item_by, {
+ radius = 2, color = fg, opacity = highlight_opacity * text_opacity,
+ clip = item_clip,
+ })
+ end
+
+ -- Icon
+ if item.icon then
+ local x, y = content_bx - (icon_size / 2), item_center_y
+ if item.icon == 'spinner' then
+ ass:spinner(x, y, icon_size * 1.5, {color = font_color, opacity = text_opacity * 0.8})
+ else
+ ass:icon(x, y, icon_size * 1.5, item.icon, {
+ color = font_color, opacity = text_opacity, clip = item_clip,
+ shadow = 1, shadow_color = shadow_color,
+ })
+ end
+ content_bx = content_bx - icon_size - spacing
+ end
+
+ local title_cut_x = content_bx
+ if item.hint_width > 0 then
+ -- controls title & hint clipping proportional to the ratio of their widths
+ local title_content_ratio = item.title_width / (item.title_width + item.hint_width)
+ title_cut_x = round(content_ax + (content_bx - content_ax - spacing) * title_content_ratio
+ + (item.title_width > 0 and spacing / 2 or 0))
+ end
+
+ -- Hint
+ if item.hint then
+ item.ass_safe_hint = item.ass_safe_hint or ass_escape(item.hint)
+ local clip = '\\clip(' .. title_cut_x .. ',' ..
+ math.max(item_ay, ay) .. ',' .. bx .. ',' .. math.min(item_by, by) .. ')'
+ ass:txt(content_bx, item_center_y, 6, item.ass_safe_hint, {
+ size = self.font_size_hint, color = font_color, wrap = 2, opacity = 0.5 * opacity, clip = clip,
+ shadow = 1, shadow_color = shadow_color,
+ })
+ end
+
+ -- Title
+ if item.title then
+ item.ass_safe_title = item.ass_safe_title or ass_escape(item.title)
+ local clip = '\\clip(' .. ax .. ',' .. math.max(item_ay, ay) .. ','
+ .. title_cut_x .. ',' .. math.min(item_by, by) .. ')'
+ ass:txt(content_ax, item_center_y, 4, item.ass_safe_title, {
+ size = self.font_size, color = font_color, italic = item.italic, bold = item.bold, wrap = 2,
+ opacity = text_opacity * (item.muted and 0.5 or 1), clip = clip,
+ shadow = 1, shadow_color = shadow_color,
+ })
+ end
+ end
+
+ -- Menu title
+ if draw_title then
+ local title_ay = ay - self.item_height
+ local title_height = self.item_height - 3
+ menu.ass_safe_title = menu.ass_safe_title or ass_escape(menu.title)
+
+ -- Background
+ ass:rect(ax + 2, title_ay, bx - 2, title_ay + title_height, {
+ color = fg, opacity = opacity * 0.8, radius = 2,
+ })
+ ass:texture(ax + 2, title_ay, bx - 2, title_ay + title_height, 'n', {
+ size = 80, color = bg, opacity = opacity * 0.1,
+ })
+
+ -- Title
+ ass:txt(ax + menu.width / 2, title_ay + (title_height / 2), 5, menu.ass_safe_title, {
+ size = self.font_size, bold = true, color = bg, wrap = 2, opacity = opacity,
+ clip = '\\clip(' .. ax .. ',' .. title_ay .. ',' .. bx .. ',' .. ay .. ')',
+ })
+ end
+
+ -- Scrollbar
+ if menu.scroll_height > 0 then
+ local groove_height = menu.height - 2
+ local thumb_height = math.max((menu.height / (menu.scroll_height + menu.height)) * groove_height, 40)
+ local thumb_y = ay + 1 + ((menu.scroll_y / menu.scroll_height) * (groove_height - thumb_height))
+ ass:rect(bx - 3, thumb_y, bx - 1, thumb_y + thumb_height, {color = fg, opacity = opacity * 0.8})
+ end
+ end
+
+ -- Main menu
+ draw_menu(self.current, self.ax, self.ay, opacity)
+
+ -- Parent menus
+ local parent_menu = self.current.parent_menu
+ local parent_offset_x = self.ax
+ local parent_opacity_factor = options.menu_parent_opacity
+ local menu_gap = 2
+
+ while parent_menu do
+ parent_offset_x = parent_offset_x - parent_menu.width - menu_gap
+ draw_menu(parent_menu, parent_offset_x, parent_menu.top, parent_opacity_factor * opacity)
+ parent_opacity_factor = parent_opacity_factor * parent_opacity_factor
+ parent_menu = parent_menu.parent_menu
+ end
+
+ -- Selected menu
+ local selected_menu = self.current.items[self.current.selected_index]
+
+ if selected_menu and selected_menu.items then
+ draw_menu(selected_menu, self.bx + menu_gap, selected_menu.top, options.menu_parent_opacity * opacity)
+ end
+
+ return ass
+end
+
+return Menu
diff --git a/.config/mpv/scripts/uosc_shared/elements/PauseIndicator.lua b/.config/mpv/scripts/uosc_shared/elements/PauseIndicator.lua
new file mode 100644
index 0000000..82a7e43
--- /dev/null
+++ b/.config/mpv/scripts/uosc_shared/elements/PauseIndicator.lua
@@ -0,0 +1,80 @@
+local Element = require('uosc_shared/elements/Element')
+
+---@class PauseIndicator : Element
+local PauseIndicator = class(Element)
+
+function PauseIndicator:new() return Class.new(self) --[[@as PauseIndicator]] end
+function PauseIndicator:init()
+ Element.init(self, 'pause_indicator')
+ self.ignores_menu = true
+ self.base_icon_opacity = options.pause_indicator == 'flash' and 1 or 0.8
+ self.paused = state.pause
+ self.type = options.pause_indicator
+ self.is_manual = options.pause_indicator == 'manual'
+ self.fadeout_requested = false
+ self.opacity = 0
+
+ mp.observe_property('pause', 'bool', function(_, paused)
+ if Elements.timeline.pressed then return end
+ if options.pause_indicator == 'flash' then
+ if self.paused == paused then return end
+ self:flash()
+ elseif options.pause_indicator == 'static' then
+ self:decide()
+ end
+ end)
+end
+
+function PauseIndicator:flash()
+ if not self.is_manual and self.type ~= 'flash' then return end
+ -- can't wait for pause property event listener to set this, because when this is used inside a binding like:
+ -- cycle pause; script-binding uosc/flash-pause-indicator
+ -- the pause event is not fired fast enough, and indicator starts rendering with old icon
+ self.paused = mp.get_property_native('pause')
+ if self.is_manual then self.type = 'flash' end
+ self.opacity = 1
+ self:tween_property('opacity', 1, 0, 0.15)
+end
+
+-- decides whether static indicator should be visible or not
+function PauseIndicator:decide()
+ if not self.is_manual and self.type ~= 'static' then return end
+ self.paused = mp.get_property_native('pause') -- see flash() for why this line is necessary
+ if self.is_manual then self.type = 'static' end
+ self.opacity = self.paused and 1 or 0
+ request_render()
+
+ -- Workaround for an mpv race condition bug during pause on windows builds, which causes osd updates to be ignored.
+ -- .03 was still loosing renders, .04 was fine, but to be safe I added 10ms more
+ mp.add_timeout(.05, function() osd:update() end)
+end
+
+function PauseIndicator:render()
+ if self.opacity == 0 then return end
+
+ local ass = assdraw.ass_new()
+ local is_static = self.type == 'static'
+
+ -- Background fadeout
+ if is_static then
+ ass:rect(0, 0, display.width, display.height, {color = bg, opacity = self.opacity * 0.3})
+ end
+
+ -- Icon
+ local size = round(math.min(display.width, display.height) * (is_static and 0.20 or 0.15))
+ size = size + size * (1 - self.opacity)
+
+ if self.paused then
+ ass:icon(display.width / 2, display.height / 2, size, 'pause',
+ {border = 1, opacity = self.base_icon_opacity * self.opacity}
+ )
+ else
+ ass:icon(display.width / 2, display.height / 2, size * 1.2, 'play_arrow',
+ {border = 1, opacity = self.base_icon_opacity * self.opacity}
+ )
+ end
+
+ return ass
+end
+
+return PauseIndicator
diff --git a/.config/mpv/scripts/uosc_shared/elements/Speed.lua b/.config/mpv/scripts/uosc_shared/elements/Speed.lua
new file mode 100644
index 0000000..9681ccf
--- /dev/null
+++ b/.config/mpv/scripts/uosc_shared/elements/Speed.lua
@@ -0,0 +1,183 @@
+local Element = require('uosc_shared/elements/Element')
+
+---@alias Dragging { start_time: number; start_x: number; distance: number; speed_distance: number; start_speed: number; }
+
+---@class Speed : Element
+local Speed = class(Element)
+
+---@param props? ElementProps
+function Speed:new(props) return Class.new(self, props) --[[@as Speed]] end
+function Speed:init(props)
+ Element.init(self, 'speed', props)
+
+ self.width = 0
+ self.height = 0
+ self.notches = 10
+ self.notch_every = 0.1
+ ---@type number
+ self.notch_spacing = nil
+ ---@type number
+ self.font_size = nil
+ ---@type Dragging|nil
+ self.dragging = nil
+end
+
+function Speed:on_coordinates()
+ self.height, self.width = self.by - self.ay, self.bx - self.ax
+ self.notch_spacing = self.width / (self.notches + 1)
+ self.font_size = round(self.height * 0.48 * options.font_scale)
+end
+
+function Speed:speed_step(speed, up)
+ if options.speed_step_is_factor then
+ if up then
+ return speed * options.speed_step
+ else
+ return speed * 1 / options.speed_step
+ end
+ else
+ if up then
+ return speed + options.speed_step
+ else
+ return speed - options.speed_step
+ end
+ end
+end
+
+function Speed:on_mbtn_left_down()
+ -- Don't accept clicks while hidden.
+ if self:get_visibility() <= 0 then return end
+ self:tween_stop() -- Stop and cleanup possible ongoing animations
+ self.dragging = {
+ start_time = mp.get_time(),
+ start_x = cursor.x,
+ distance = 0,
+ speed_distance = 0,
+ start_speed = state.speed,
+ }
+end
+
+function Speed:on_global_mouse_move()
+ if not self.dragging then return end
+
+ self.dragging.distance = cursor.x - self.dragging.start_x
+ self.dragging.speed_distance = (-self.dragging.distance / self.notch_spacing * self.notch_every)
+
+ local speed_current = state.speed
+ local speed_drag_current = self.dragging.start_speed + self.dragging.speed_distance
+ speed_drag_current = clamp(0.01, speed_drag_current, 100)
+ local drag_dir_up = speed_drag_current > speed_current
+
+ local speed_step_next = speed_current
+ local speed_drag_diff = math.abs(speed_drag_current - speed_current)
+ while math.abs(speed_step_next - speed_current) < speed_drag_diff do
+ speed_step_next = self:speed_step(speed_step_next, drag_dir_up)
+ end
+ local speed_step_prev = self:speed_step(speed_step_next, not drag_dir_up)
+
+ local speed_new = speed_step_prev
+ local speed_next_diff = math.abs(speed_drag_current - speed_step_next)
+ local speed_prev_diff = math.abs(speed_drag_current - speed_step_prev)
+ if speed_next_diff < speed_prev_diff then
+ speed_new = speed_step_next
+ end
+
+ if speed_new ~= speed_current then
+ mp.set_property_native('speed', speed_new)
+ end
+end
+
+function Speed:on_mbtn_left_up()
+ -- Reset speed on short clicks
+ if self.dragging and math.abs(self.dragging.distance) < 6 and mp.get_time() - self.dragging.start_time < 0.15 then
+ mp.set_property_native('speed', 1)
+ end
+end
+
+function Speed:on_global_mbtn_left_up()
+ self.dragging = nil
+ request_render()
+end
+
+function Speed:on_global_mouse_leave()
+ self.dragging = nil
+ request_render()
+end
+
+function Speed:on_wheel_up() mp.set_property_native('speed', self:speed_step(state.speed, true)) end
+function Speed:on_wheel_down() mp.set_property_native('speed', self:speed_step(state.speed, false)) end
+
+function Speed:render()
+ local visibility = self:get_visibility()
+ local opacity = self.dragging and 1 or visibility
+
+ if opacity <= 0 then return end
+
+ local ass = assdraw.ass_new()
+
+ -- Background
+ ass:rect(self.ax, self.ay, self.bx, self.by, {color = bg, radius = 2, opacity = opacity * options.speed_opacity})
+
+ -- Coordinates
+ local ax, ay = self.ax, self.ay
+ local bx, by = self.bx, ay + self.height
+ local half_width = (self.width / 2)
+ local half_x = ax + half_width
+
+ -- Notches
+ local speed_at_center = state.speed
+ if self.dragging then
+ speed_at_center = self.dragging.start_speed + self.dragging.speed_distance
+ speed_at_center = clamp(0.01, speed_at_center, 100)
+ end
+ local nearest_notch_speed = round(speed_at_center / self.notch_every) * self.notch_every
+ local nearest_notch_x = half_x + (((nearest_notch_speed - speed_at_center) / self.notch_every) * self.notch_spacing)
+ local guide_size = math.floor(self.height / 7.5)
+ local notch_by = by - guide_size
+ local notch_ay_big = ay + round(self.font_size * 1.1)
+ local notch_ay_medium = notch_ay_big + ((notch_by - notch_ay_big) * 0.2)
+ local notch_ay_small = notch_ay_big + ((notch_by - notch_ay_big) * 0.4)
+ local from_to_index = math.floor(self.notches / 2)
+
+ for i = -from_to_index, from_to_index do
+ local notch_speed = nearest_notch_speed + (i * self.notch_every)
+
+ if notch_speed >= 0 and notch_speed <= 100 then
+ local notch_x = nearest_notch_x + (i * self.notch_spacing)
+ local notch_thickness = 1
+ local notch_ay = notch_ay_small
+ if (notch_speed % (self.notch_every * 10)) < 0.00000001 then
+ notch_ay = notch_ay_big
+ notch_thickness = 1.5
+ elseif (notch_speed % (self.notch_every * 5)) < 0.00000001 then
+ notch_ay = notch_ay_medium
+ end
+
+ ass:rect(notch_x - notch_thickness, notch_ay, notch_x + notch_thickness, notch_by, {
+ color = fg, border = 1, border_color = bg,
+ opacity = math.min(1.2 - (math.abs((notch_x - ax - half_width) / half_width)), 1) * opacity,
+ })
+ end
+ end
+
+ -- Center guide
+ ass:new_event()
+ ass:append('{\\rDefault\\an7\\blur0\\bord1\\shad0\\1c&H' .. fg .. '\\3c&H' .. bg .. '}')
+ ass:opacity(opacity)
+ ass:pos(0, 0)
+ ass:draw_start()
+ ass:move_to(half_x, by - 2 - guide_size)
+ ass:line_to(half_x + guide_size, by - 2)
+ ass:line_to(half_x - guide_size, by - 2)
+ ass:draw_stop()
+
+ -- Speed value
+ local speed_text = (round(state.speed * 100) / 100) .. 'x'
+ ass:txt(half_x, ay + (notch_ay_big - ay) / 2, 5, speed_text, {
+ size = self.font_size, color = bgt, border = options.text_border, border_color = bg, opacity = opacity,
+ })
+
+ return ass
+end
+
+return Speed
diff --git a/.config/mpv/scripts/uosc_shared/elements/Timeline.lua b/.config/mpv/scripts/uosc_shared/elements/Timeline.lua
new file mode 100644
index 0000000..5ef798f
--- /dev/null
+++ b/.config/mpv/scripts/uosc_shared/elements/Timeline.lua
@@ -0,0 +1,394 @@
+local Element = require('uosc_shared/elements/Element')
+
+---@class Timeline : Element
+local Timeline = class(Element)
+
+function Timeline:new() return Class.new(self) --[[@as Timeline]] end
+function Timeline:init()
+ Element.init(self, 'timeline')
+ self.pressed = false
+ self.obstructed = false
+ self.size_max = 0
+ self.size_min = 0
+ self.size_min_override = options.timeline_start_hidden and 0 or nil
+ self.font_size = 0
+ self.top_border = options.timeline_border
+ self.hovered_chapter = nil
+
+ -- Release any dragging when file gets unloaded
+ mp.register_event('end-file', function() self.pressed = false end)
+end
+
+function Timeline:get_visibility()
+ return Elements.controls and math.max(Elements.controls.proximity, Element.get_visibility(self))
+ or Element.get_visibility(self)
+end
+
+function Timeline:decide_enabled()
+ local previous = self.enabled
+ self.enabled = not self.obstructed and state.duration ~= nil and state.duration > 0 and state.time ~= nil
+ if self.enabled ~= previous then Elements:trigger('timeline_enabled', self.enabled) end
+end
+
+function Timeline:get_effective_size_min()
+ return self.size_min_override or self.size_min
+end
+
+function Timeline:get_effective_size()
+ if Elements.speed and Elements.speed.dragging then return self.size_max end
+ local size_min = self:get_effective_size_min()
+ return size_min + math.ceil((self.size_max - size_min) * self:get_visibility())
+end
+
+function Timeline:get_effective_line_width()
+ return state.fullormaxed and options.timeline_line_width_fullscreen or options.timeline_line_width
+end
+
+function Timeline:get_is_hovered() return self.enabled and (self.proximity_raw == 0 or self.hovered_chapter ~= nil) end
+
+function Timeline:update_dimensions()
+ if state.fullormaxed then
+ self.size_min = options.timeline_size_min_fullscreen
+ self.size_max = options.timeline_size_max_fullscreen
+ else
+ self.size_min = options.timeline_size_min
+ self.size_max = options.timeline_size_max
+ end
+ self.font_size = math.floor(math.min((self.size_max + 60) * 0.2, self.size_max * 0.96) * options.font_scale)
+ self.ax = Elements.window_border.size
+ self.ay = display.height - Elements.window_border.size - self.size_max - self.top_border
+ self.bx = display.width - Elements.window_border.size
+ self.by = display.height - Elements.window_border.size
+ self.width = self.bx - self.ax
+ self.chapter_size = math.max((self.by - self.ay) / 10, 3)
+ self.chapter_size_hover = self.chapter_size * 2
+
+ -- Disable if not enough space
+ local available_space = display.height - Elements.window_border.size * 2
+ if Elements.top_bar.enabled then available_space = available_space - Elements.top_bar.size end
+ self.obstructed = available_space < self.size_max + 10
+ self:decide_enabled()
+end
+
+function Timeline:get_time_at_x(x)
+ local line_width = (options.timeline_style == 'line' and self:get_effective_line_width() - 1 or 0)
+ local time_width = self.width - line_width - 1
+ local fax = (time_width) * state.time / state.duration
+ local fbx = fax + line_width
+ -- time starts 0.5 pixels in
+ x = x - self.ax - 0.5
+ if x > fbx then x = x - line_width
+ elseif x > fax then x = fax end
+ local progress = clamp(0, x / time_width, 1)
+ return state.duration * progress
+end
+
+---@param fast? boolean
+function Timeline:set_from_cursor(fast)
+ if state.time and state.duration then
+ mp.commandv('seek', self:get_time_at_x(cursor.x), fast and 'absolute+keyframes' or 'absolute+exact')
+ end
+end
+function Timeline:clear_thumbnail() mp.commandv('script-message-to', 'thumbfast', 'clear') end
+
+function Timeline:determine_chapter_click_handler()
+ if self.hovered_chapter then
+ if not self.on_global_mbtn_left_down then
+ self.on_global_mbtn_left_down = function()
+ if self.hovered_chapter then mp.commandv('seek', self.hovered_chapter.time, 'absolute+exact') end
+ end
+ end
+ else
+ if self.on_global_mbtn_left_down then
+ self.on_global_mbtn_left_down = nil
+ if self.proximity_raw ~= 0 then self:clear_thumbnail() end
+ end
+ end
+end
+
+function Timeline:on_mbtn_left_down()
+ -- `self.on_global_mbtn_left_down` has precedent
+ if self.on_global_mbtn_left_down then return end
+
+ self.pressed = true
+ self.pressed_pause = state.pause
+ mp.set_property_native('pause', true)
+ self:set_from_cursor()
+end
+function Timeline:on_prop_duration() self:decide_enabled() end
+function Timeline:on_prop_time() self:decide_enabled() end
+function Timeline:on_prop_border() self:update_dimensions() end
+function Timeline:on_prop_fullormaxed() self:update_dimensions() end
+function Timeline:on_display() self:update_dimensions() end
+function Timeline:on_mouse_leave()
+ if not self.hovered_chapter then self:clear_thumbnail() end
+end
+function Timeline:on_global_mbtn_left_up()
+ if self.pressed then
+ mp.set_property_native('pause', self.pressed_pause)
+ self.pressed = false
+ end
+ self:clear_thumbnail()
+end
+function Timeline:on_global_mouse_leave()
+ self.pressed = false
+ self:clear_thumbnail()
+end
+
+Timeline.seek_timer = mp.add_timeout(0.05, function() Elements.timeline:set_from_cursor() end)
+Timeline.seek_timer:kill()
+function Timeline:on_global_mouse_move()
+ if self.pressed then
+ if self.width / state.duration < 10 then
+ self:set_from_cursor(true)
+ self.seek_timer:kill()
+ self.seek_timer:resume()
+ else self:set_from_cursor() end
+ end
+ self:determine_chapter_click_handler()
+end
+function Timeline:on_wheel_up() mp.commandv('seek', options.timeline_step) end
+function Timeline:on_wheel_down() mp.commandv('seek', -options.timeline_step) end
+
+function Timeline:render()
+ if self.size_max == 0 then return end
+
+ local size_min = self:get_effective_size_min()
+ local size = self:get_effective_size()
+ local visibility = self:get_visibility()
+
+ if size < 1 then return end
+
+ local ass = assdraw.ass_new()
+
+ -- Text opacity rapidly drops to 0 just before it starts overflowing, or before it reaches timeline.size_min
+ local hide_text_below = math.max(self.font_size * 0.8, size_min * 2)
+ local hide_text_ramp = hide_text_below / 2
+ local text_opacity = clamp(0, size - hide_text_below, hide_text_ramp) / hide_text_ramp
+
+ local spacing = math.max(math.floor((self.size_max - self.font_size) / 2.5), 4)
+ local progress = state.time / state.duration
+ local is_line = options.timeline_style == 'line'
+
+ -- Foreground & Background bar coordinates
+ local bax, bay, bbx, bby = self.ax, self.by - size - self.top_border, self.bx, self.by
+ local fax, fay, fbx, fby = 0, bay + self.top_border, 0, bby
+ local fcy = fay + (size / 2)
+
+ local line_width = 0
+
+ if is_line then
+ local minimized_fraction = 1 - math.min((size - size_min) / ((self.size_max - size_min) / 8), 1)
+ local line_width_max = self:get_effective_line_width()
+ local max_min_width_delta = size_min > 0
+ and line_width_max - line_width_max * options.timeline_line_width_minimized_scale
+ or 0
+ line_width = line_width_max - (max_min_width_delta * minimized_fraction)
+ fax = bax + (self.width - line_width) * progress
+ fbx = fax + line_width
+ line_width = line_width - 1
+ else
+ fax, fbx = bax, bax + self.width * progress
+ end
+
+ local foreground_size = fby - fay
+ local foreground_coordinates = round(fax) .. ',' .. fay .. ',' .. round(fbx) .. ',' .. fby -- for clipping
+
+ -- time starts 0.5 pixels in
+ local time_ax = bax + 0.5
+ local time_width = self.width - line_width - 1
+
+ -- time to x: calculates x coordinate so that it never lies inside of the line
+ local function t2x(time)
+ local x = time_ax + time_width * time / state.duration
+ return time <= state.time and x or x + line_width
+ end
+
+ -- Background
+ ass:new_event()
+ ass:pos(0, 0)
+ ass:append('{\\rDefault\\an7\\blur0\\bord0\\1c&H' .. bg .. '}')
+ ass:opacity(options.timeline_opacity)
+ ass:draw_start()
+ ass:rect_cw(bax, bay, fax, bby) --left of progress
+ ass:rect_cw(fbx, bay, bbx, bby) --right of progress
+ ass:rect_cw(fax, bay, fbx, fay) --above progress
+ ass:draw_stop()
+
+ -- Progress
+ ass:rect(fax, fay, fbx, fby, {opacity = options.timeline_opacity})
+
+ -- Uncached ranges
+ local buffered_time = nil
+ if state.uncached_ranges then
+ local opts = {size = 80, anchor_y = fby}
+ local texture_char = visibility > 0 and 'b' or 'a'
+ local offset = opts.size / (visibility > 0 and 24 or 28)
+ for _, range in ipairs(state.uncached_ranges) do
+ if not buffered_time and (range[1] > state.time or range[2] > state.time) then
+ buffered_time = range[1] - state.time
+ end
+ if options.timeline_cache then
+ local ax = range[1] < 0.5 and bax or math.floor(t2x(range[1]))
+ local bx = range[2] > state.duration - 0.5 and bbx or math.ceil(t2x(range[2]))
+ opts.color, opts.opacity, opts.anchor_x = 'ffffff', 0.4 - (0.2 * visibility), bax
+ ass:texture(ax, fay, bx, fby, texture_char, opts)
+ opts.color, opts.opacity, opts.anchor_x = '000000', 0.6 - (0.2 * visibility), bax + offset
+ ass:texture(ax, fay, bx, fby, texture_char, opts)
+ end
+ end
+ end
+
+ -- Custom ranges
+ for _, chapter_range in ipairs(state.chapter_ranges) do
+ local rax = chapter_range.start < 0.1 and bax or t2x(chapter_range.start)
+ local rbx = chapter_range['end'] > state.duration - 0.1 and bbx
+ or t2x(math.min(chapter_range['end'], state.duration))
+ ass:rect(rax, fay, rbx, fby, {color = chapter_range.color, opacity = chapter_range.opacity})
+ end
+
+ -- Chapters
+ self.hovered_chapter = nil
+ if (options.timeline_chapters_opacity > 0
+ and (#state.chapters > 0 or state.ab_loop_a or state.ab_loop_b)
+ ) then
+ local diamond_radius = foreground_size < 3 and foreground_size or self.chapter_size
+ local diamond_radius_hovered = diamond_radius * 2
+ local diamond_border = options.timeline_border and math.max(options.timeline_border, 1) or 1
+
+ if diamond_radius > 0 then
+ local function draw_chapter(time, radius)
+ local chapter_x, chapter_y = t2x(time), fay - 1
+ ass:new_event()
+ ass:append(string.format(
+ '{\\pos(0,0)\\rDefault\\an7\\blur0\\yshad0.01\\bord%f\\1c&H%s\\3c&H%s\\4c&H%s\\1a&H%X&\\3a&H00&\\4a&H00&}',
+ diamond_border, fg, bg, bg, opacity_to_alpha(options.timeline_opacity * options.timeline_chapters_opacity)
+ ))
+ ass:draw_start()
+ ass:move_to(chapter_x - radius, chapter_y)
+ ass:line_to(chapter_x, chapter_y - radius)
+ ass:line_to(chapter_x + radius, chapter_y)
+ ass:line_to(chapter_x, chapter_y + radius)
+ ass:draw_stop()
+ end
+
+ if #state.chapters > 0 then
+ -- Find hovered chapter indicator
+ local hovered_chapter, closest_delta = nil, infinity
+
+ if self.proximity_raw < diamond_radius_hovered then
+ for i, chapter in ipairs(state.chapters) do
+ local chapter_x, chapter_y = t2x(chapter.time), fay - 1
+ local cursor_chapter_delta = math.sqrt((cursor.x - chapter_x) ^ 2 + (cursor.y - chapter_y) ^ 2)
+ if cursor_chapter_delta <= diamond_radius_hovered and cursor_chapter_delta < closest_delta then
+ hovered_chapter, closest_delta = chapter, cursor_chapter_delta
+ end
+ end
+ end
+
+ for i, chapter in ipairs(state.chapters) do
+ if chapter ~= hovered_chapter then draw_chapter(chapter.time, diamond_radius) end
+ end
+
+ -- Render hovered chapter above others
+ if hovered_chapter then
+ draw_chapter(hovered_chapter.time, diamond_radius_hovered)
+ self.hovered_chapter = hovered_chapter
+ self:determine_chapter_click_handler()
+ end
+ end
+
+ if state.ab_loop_a and state.ab_loop_a > 0 then draw_chapter(state.ab_loop_a) end
+ if state.ab_loop_b and state.ab_loop_b > 0 then draw_chapter(state.ab_loop_b) end
+ end
+ end
+
+ local function draw_timeline_text(x, y, align, text, opts)
+ opts.color, opts.border_color = fgt, fg
+ opts.clip = '\\clip(' .. foreground_coordinates .. ')'
+ ass:txt(x, y, align, text, opts)
+ opts.color, opts.border_color = bgt, bg
+ opts.clip = '\\iclip(' .. foreground_coordinates .. ')'
+ ass:txt(x, y, align, text, opts)
+ end
+
+ -- Time values
+ if text_opacity > 0 then
+ local time_opts = {size = self.font_size, opacity = text_opacity, border = 2}
+ -- Upcoming cache time
+ if buffered_time and options.buffered_time_threshold > 0 and buffered_time < options.buffered_time_threshold then
+ local x, align = fbx + 5, 4
+ local cache_opts = {size = self.font_size * 0.8, opacity = text_opacity * 0.6, border = 1}
+ local human = round(math.max(buffered_time, 0)) .. 's'
+ local width = text_width(human, cache_opts)
+ local time_width = text_width('00:00:00', time_opts)
+ local min_x, max_x = bax + spacing + 5 + time_width, bbx - spacing - 5 - time_width
+ if x < min_x then x = min_x elseif x + width > max_x then x, align = max_x, 6 end
+ draw_timeline_text(x, fcy, align, human, cache_opts)
+ end
+
+ -- Elapsed time
+ if state.time_human then
+ draw_timeline_text(bax + spacing, fcy, 4, state.time_human, time_opts)
+ end
+
+ -- End time
+ if state.duration_or_remaining_time_human then
+ draw_timeline_text(bbx - spacing, fcy, 6, state.duration_or_remaining_time_human, time_opts)
+ end
+ end
+
+ -- Hovered time and chapter
+ if (self.proximity_raw == 0 or self.pressed or self.hovered_chapter) and
+ not (Elements.speed and Elements.speed.dragging) then
+ local cursor_x = self.hovered_chapter and t2x(self.hovered_chapter.time) or cursor.x
+ local hovered_seconds = self.hovered_chapter and self.hovered_chapter.time or self:get_time_at_x(cursor.x)
+
+ -- Cursor line
+ -- 0.5 to switch when the pixel is half filled in
+ local color = ((fax - 0.5) < cursor_x and cursor_x < (fbx + 0.5)) and bg or fg
+ local ax, ay, bx, by = cursor_x - 0.5, fay, cursor_x + 0.5, fby
+ ass:rect(ax, ay, bx, by, {color = color, opacity = 0.2})
+ local tooltip_anchor = {ax = ax, ay = ay, bx = bx, by = by}
+
+ -- Timestamp
+ local offset = #state.chapters > 0 and 10 or 4
+ local opts = {size = self.font_size, offset = offset}
+ opts.width_overwrite = text_width('00:00:00', opts)
+ ass:tooltip(tooltip_anchor, format_time(hovered_seconds), opts)
+ tooltip_anchor.ay = tooltip_anchor.ay - self.font_size - offset
+
+ -- Thumbnail
+ if not thumbnail.disabled and thumbnail.width ~= 0 and thumbnail.height ~= 0 then
+ local scale_x, scale_y = display.scale_x, display.scale_y
+ local border, margin_x, margin_y = math.ceil(2 * scale_x), round(10 * scale_x), round(5 * scale_y)
+ local thumb_x_margin, thumb_y_margin = border + margin_x, border + margin_y
+ local thumb_width, thumb_height = thumbnail.width, thumbnail.height
+ local thumb_x = round(clamp(
+ thumb_x_margin, cursor_x * scale_x - thumb_width / 2,
+ display.width * scale_x - thumb_width - thumb_x_margin
+ ))
+ local thumb_y = round(tooltip_anchor.ay * scale_y - thumb_y_margin - thumb_height)
+ local ax, ay = (thumb_x - border) / scale_x, (thumb_y - border) / scale_y
+ local bx, by = (thumb_x + thumb_width + border) / scale_x, (thumb_y + thumb_height + border) / scale_y
+ ass:rect(ax, ay, bx, by, {color = bg, border = 1, border_color = fg, border_opacity = 0.08, radius = 2})
+ mp.commandv('script-message-to', 'thumbfast', 'thumb', hovered_seconds, thumb_x, thumb_y)
+ tooltip_anchor.ax, tooltip_anchor.bx, tooltip_anchor.ay = ax, bx, ay
+ end
+
+ -- Chapter title
+ if #state.chapters > 0 then
+ local _, chapter = itable_find(state.chapters, function(c) return hovered_seconds >= c.time end, true)
+ if chapter and not chapter.is_end_only then
+ ass:tooltip(tooltip_anchor, chapter.title_wrapped, {
+ size = self.font_size, offset = 10, responsive = false, bold = true,
+ width_overwrite = chapter.title_wrapped_width * self.font_size,
+ })
+ end
+ end
+ end
+
+ return ass
+end
+
+return Timeline
diff --git a/.config/mpv/scripts/uosc_shared/elements/TopBar.lua b/.config/mpv/scripts/uosc_shared/elements/TopBar.lua
new file mode 100644
index 0000000..977d5b8
--- /dev/null
+++ b/.config/mpv/scripts/uosc_shared/elements/TopBar.lua
@@ -0,0 +1,182 @@
+local Element = require('uosc_shared/elements/Element')
+
+---@alias TopBarButtonProps {icon: string; background: string; anchor_id?: string; command: string|fun()}
+
+---@class TopBarButton : Element
+local TopBarButton = class(Element)
+
+---@param id string
+---@param props TopBarButtonProps
+function TopBarButton:new(id, props) return Class.new(self, id, props) --[[@as TopBarButton]] end
+function TopBarButton:init(id, props)
+ Element.init(self, id, props)
+ self.anchor_id = 'top_bar'
+ self.icon = props.icon
+ self.background = props.background
+ self.command = props.command
+end
+
+function TopBarButton:on_mbtn_left_down()
+ mp.command(type(self.command) == 'function' and self.command() or self.command)
+end
+
+function TopBarButton:render()
+ local visibility = self:get_visibility()
+ if visibility <= 0 then return end
+ local ass = assdraw.ass_new()
+
+ -- Background on hover
+ if self.proximity_raw == 0 then
+ ass:rect(self.ax, self.ay, self.bx, self.by, {color = self.background, opacity = visibility})
+ end
+
+ local width, height = self.bx - self.ax, self.by - self.ay
+ local icon_size = math.min(width, height) * 0.5
+ ass:icon(self.ax + width / 2, self.ay + height / 2, icon_size, self.icon, {
+ opacity = visibility, border = options.text_border,
+ })
+
+ return ass
+end
+
+--[[ TopBar ]]
+
+---@class TopBar : Element
+local TopBar = class(Element)
+
+function TopBar:new() return Class.new(self) --[[@as TopBar]] end
+function TopBar:init()
+ Element.init(self, 'top_bar')
+ self.pressed = false
+ self.size, self.size_max, self.size_min = 0, 0, 0
+ self.icon_size, self.spacing, self.font_size, self.title_bx = 1, 1, 1, 1
+ self.size_min_override = options.timeline_start_hidden and 0 or nil
+ self.top_border = options.timeline_border
+
+ local function decide_maximized_command()
+ return state.border
+ and (state.fullscreen and 'set fullscreen no;cycle window-maximized' or 'cycle window-maximized')
+ or 'set window-maximized no;cycle fullscreen'
+ end
+
+ -- Order aligns from right to left
+ self.buttons = {
+ TopBarButton:new('tb_close', {icon = 'close', background = '2311e8', command = 'quit'}),
+ TopBarButton:new('tb_max', {icon = 'crop_square', background = '222222', command = decide_maximized_command}),
+ TopBarButton:new('tb_min', {icon = 'minimize', background = '222222', command = 'cycle window-minimized'}),
+ }
+end
+
+function TopBar:decide_enabled()
+ if options.top_bar == 'no-border' then
+ self.enabled = not state.border or state.fullscreen
+ else
+ self.enabled = options.top_bar == 'always'
+ end
+ self.enabled = self.enabled and (options.top_bar_controls or options.top_bar_title)
+ for _, element in ipairs(self.buttons) do
+ element.enabled = self.enabled and options.top_bar_controls
+ end
+end
+
+function TopBar:update_dimensions()
+ self.size = state.fullormaxed and options.top_bar_size_fullscreen or options.top_bar_size
+ self.icon_size = round(self.size * 0.5)
+ self.spacing = math.ceil(self.size * 0.25)
+ self.font_size = math.floor((self.size - (self.spacing * 2)) * options.font_scale)
+ self.button_width = round(self.size * 1.15)
+ self.ay = Elements.window_border.size
+ self.bx = display.width - Elements.window_border.size
+ self.by = self.size + Elements.window_border.size
+ self.title_bx = self.bx - (options.top_bar_controls and (self.button_width * 3) or 0)
+ self.ax = options.top_bar_title and Elements.window_border.size or self.title_bx
+
+ local button_bx = self.bx
+ for _, element in pairs(self.buttons) do
+ element.ax, element.bx = button_bx - self.button_width, button_bx
+ element.ay, element.by = self.ay, self.by
+ button_bx = button_bx - self.button_width
+ end
+end
+
+function TopBar:on_prop_border()
+ self:decide_enabled()
+ self:update_dimensions()
+end
+
+function TopBar:on_prop_fullscreen()
+ self:decide_enabled()
+ self:update_dimensions()
+end
+
+function TopBar:on_prop_maximized()
+ self:decide_enabled()
+ self:update_dimensions()
+end
+
+function TopBar:on_display() self:update_dimensions() end
+
+function TopBar:render()
+ local visibility = self:get_visibility()
+ if visibility <= 0 then return end
+ local ass = assdraw.ass_new()
+
+ -- Window title
+ if options.top_bar_title and (state.title or state.has_playlist) then
+ local bg_margin = math.floor((self.size - self.font_size) / 4)
+ local padding = self.font_size / 2
+ local title_ax = self.ax + bg_margin
+ local title_ay = self.ay + bg_margin
+ local max_bx = self.title_bx - self.spacing
+
+ -- Playlist position
+ if state.has_playlist then
+ local text = state.playlist_pos .. '' .. state.playlist_count
+ local formatted_text = '{\\b1}' .. state.playlist_pos .. '{\\b0\\fs' .. self.font_size * 0.9 .. '}/'
+ .. state.playlist_count
+ local opts = {size = self.font_size, wrap = 2, color = fgt, opacity = visibility}
+ local bx = round(title_ax + text_width(text, opts) + padding * 2)
+ ass:rect(title_ax, title_ay, bx, self.by - bg_margin, {color = fg, opacity = visibility, radius = 2})
+ ass:txt(title_ax + (bx - title_ax) / 2, self.ay + (self.size / 2), 5, formatted_text, opts)
+ title_ax = bx + bg_margin
+ end
+
+ -- Title
+ local text = state.title
+ if max_bx - title_ax > self.font_size * 3 and text and text ~= '' then
+ local opts = {
+ size = self.font_size, wrap = 2, color = bgt, border = 1, border_color = bg, opacity = visibility,
+ clip = string.format('\\clip(%d, %d, %d, %d)', self.ax, self.ay, max_bx, self.by),
+ }
+ local bx = math.min(max_bx, title_ax + text_width(text, opts) + padding * 2)
+ local by = self.by - bg_margin
+ ass:rect(title_ax, title_ay, bx, by, {
+ color = bg, opacity = visibility * options.top_bar_title_opacity, radius = 2,
+ })
+ ass:txt(title_ax + padding, self.ay + (self.size / 2), 4, text, opts)
+ title_ay = by + 1
+ end
+
+ -- Subtitle: current chapter
+ if state.current_chapter and max_bx - title_ax > self.font_size * 3 then
+ local font_size = self.font_size * 0.8
+ local height = font_size * 1.5
+ local text = '└ ' .. state.current_chapter.index .. ': ' .. state.current_chapter.title
+ local by = title_ay + height
+ local opts = {
+ size = font_size, italic = true, wrap = 2, color = bgt,
+ border = 1, border_color = bg, opacity = visibility * 0.8,
+ }
+ local bx = math.min(max_bx, title_ax + text_width(text, opts) + padding * 2)
+ opts.clip = string.format('\\clip(%d, %d, %d, %d)', title_ax, title_ay, bx, by)
+ ass:rect(title_ax, title_ay, bx, by, {
+ color = bg, opacity = visibility * options.top_bar_title_opacity, radius = 2,
+ })
+ ass:txt(title_ax + padding, title_ay + height / 2, 4, text, opts)
+ end
+ end
+
+ return ass
+end
+
+return TopBar
diff --git a/.config/mpv/scripts/uosc_shared/elements/Volume.lua b/.config/mpv/scripts/uosc_shared/elements/Volume.lua
new file mode 100644
index 0000000..8533ebd
--- /dev/null
+++ b/.config/mpv/scripts/uosc_shared/elements/Volume.lua
@@ -0,0 +1,240 @@
+local Element = require('uosc_shared/elements/Element')
+
+--[[ MuteButton ]]
+
+---@class MuteButton : Element
+local MuteButton = class(Element)
+---@param props? ElementProps
+function MuteButton:new(props) return Class.new(self, 'volume_mute', props) --[[@as MuteButton]] end
+function MuteButton:on_mbtn_left_down() mp.commandv('cycle', 'mute') end
+function MuteButton:render()
+ local visibility = self:get_visibility()
+ if visibility <= 0 then return end
+ local ass = assdraw.ass_new()
+ local icon_name = state.mute and 'volume_off' or 'volume_up'
+ local width = self.bx - self.ax
+ ass:icon(self.ax + (width / 2), self.by, width * 0.7, icon_name,
+ {border = options.text_border, opacity = options.volume_opacity * visibility, align = 2}
+ )
+ return ass
+end
+
+--[[ VolumeSlider ]]
+
+---@class VolumeSlider : Element
+local VolumeSlider = class(Element)
+---@param props? ElementProps
+function VolumeSlider:new(props) return Class.new(self, props) --[[@as VolumeSlider]] end
+function VolumeSlider:init(props)
+ Element.init(self, 'volume_slider', props)
+ self.pressed = false
+ self.nudge_y = 0 -- vertical position where volume overflows 100
+ self.nudge_size = 0
+ self.draw_nudge = false
+ self.spacing = 0
+ self.radius = 1
+end
+
+function VolumeSlider:set_volume(volume)
+ volume = round(volume / options.volume_step) * options.volume_step
+ if state.volume == volume then return end
+ mp.commandv('set', 'volume', clamp(0, volume, state.volume_max))
+end
+
+function VolumeSlider:set_from_cursor()
+ local volume_fraction = (self.by - cursor.y - options.volume_border) / (self.by - self.ay - options.volume_border)
+ self:set_volume(volume_fraction * state.volume_max)
+end
+
+function VolumeSlider:on_coordinates()
+ if type(state.volume_max) ~= 'number' or state.volume_max <= 0 then return end
+ local width = self.bx - self.ax
+ self.nudge_y = self.by - round((self.by - self.ay) * (100 / state.volume_max))
+ self.nudge_size = round(width * 0.18)
+ self.draw_nudge = self.ay < self.nudge_y
+ self.spacing = round(width * 0.2)
+ self.radius = math.max(2, (self.bx - self.ax) / 10)
+end
+function VolumeSlider:on_mbtn_left_down()
+ self.pressed = true
+ self:set_from_cursor()
+end
+function VolumeSlider:on_global_mbtn_left_up() self.pressed = false end
+function VolumeSlider:on_global_mouse_leave() self.pressed = false end
+function VolumeSlider:on_global_mouse_move()
+ if self.pressed then self:set_from_cursor() end
+end
+function VolumeSlider:on_wheel_up() self:set_volume(state.volume + options.volume_step) end
+function VolumeSlider:on_wheel_down() self:set_volume(state.volume - options.volume_step) end
+
+function VolumeSlider:render()
+ local visibility = self:get_visibility()
+ local ax, ay, bx, by = self.ax, self.ay, self.bx, self.by
+ local width, height = bx - ax, by - ay
+
+ if width <= 0 or height <= 0 or visibility <= 0 then return end
+
+ local ass = assdraw.ass_new()
+ local nudge_y, nudge_size = self.draw_nudge and self.nudge_y or -infinity, self.nudge_size
+ local volume_y = self.ay + options.volume_border +
+ ((height - (options.volume_border * 2)) * (1 - math.min(state.volume / state.volume_max, 1)))
+
+ -- Draws a rectangle with nudge at requested position
+ ---@param p number Padding from slider edges.
+ ---@param cy? number A y coordinate where to clip the path from the bottom.
+ function create_nudged_path(p, cy)
+ cy = cy or ay + p
+ local ax, bx, by = ax + p, bx - p, by - p
+ local r = math.max(1, self.radius - p)
+ local d, rh = r * 2, r / 2
+ local nudge_size = ((quarter_pi_sin * (nudge_size - p)) + p) / quarter_pi_sin
+ local path = assdraw.ass_new()
+ path:move_to(bx - r, by)
+ path:line_to(ax + r, by)
+ if cy > by - d then
+ local subtracted_radius = (d - (cy - (by - d))) / 2
+ local xbd = (r - subtracted_radius * 1.35) -- x bezier delta
+ path:bezier_curve(ax + xbd, by, ax + xbd, cy, ax + r, cy)
+ path:line_to(bx - r, cy)
+ path:bezier_curve(bx - xbd, cy, bx - xbd, by, bx - r, by)
+ else
+ path:bezier_curve(ax + rh, by, ax, by - rh, ax, by - r)
+ local nudge_bottom_y = nudge_y + nudge_size
+
+ if cy + rh <= nudge_bottom_y then
+ path:line_to(ax, nudge_bottom_y)
+ if cy <= nudge_y then
+ path:line_to((ax + nudge_size), nudge_y)
+ local nudge_top_y = nudge_y - nudge_size
+ if cy <= nudge_top_y then
+ local r, rh = r, rh
+ if cy > nudge_top_y - r then
+ r = nudge_top_y - cy
+ rh = r / 2
+ end
+ path:line_to(ax, nudge_top_y)
+ path:line_to(ax, cy + r)
+ path:bezier_curve(ax, cy + rh, ax + rh, cy, ax + r, cy)
+ path:line_to(bx - r, cy)
+ path:bezier_curve(bx - rh, cy, bx, cy + rh, bx, cy + r)
+ path:line_to(bx, nudge_top_y)
+ else
+ local triangle_side = cy - nudge_top_y
+ path:line_to((ax + triangle_side), cy)
+ path:line_to((bx - triangle_side), cy)
+ end
+ path:line_to((bx - nudge_size), nudge_y)
+ else
+ local triangle_side = nudge_bottom_y - cy
+ path:line_to((ax + triangle_side), cy)
+ path:line_to((bx - triangle_side), cy)
+ end
+ path:line_to(bx, nudge_bottom_y)
+ else
+ path:line_to(ax, cy + r)
+ path:bezier_curve(ax, cy + rh, ax + rh, cy, ax + r, cy)
+ path:line_to(bx - r, cy)
+ path:bezier_curve(bx - rh, cy, bx, cy + rh, bx, cy + r)
+ end
+ path:line_to(bx, by - r)
+ path:bezier_curve(bx, by - rh, bx - rh, by, bx - r, by)
+ end
+ return path
+ end
+
+ -- BG & FG paths
+ local bg_path = create_nudged_path(0)
+ local fg_path = create_nudged_path(options.volume_border, volume_y)
+
+ -- Background
+ ass:new_event()
+ ass:append('{\\rDefault\\an7\\blur0\\bord0\\1c&H' .. bg ..
+ '\\iclip(' .. fg_path.scale .. ', ' .. fg_path.text .. ')}')
+ ass:opacity(options.volume_opacity, visibility)
+ ass:pos(0, 0)
+ ass:draw_start()
+ ass:append(bg_path.text)
+ ass:draw_stop()
+
+ -- Foreground
+ ass:new_event()
+ ass:append('{\\rDefault\\an7\\blur0\\bord0\\1c&H' .. fg .. '}')
+ ass:opacity(options.volume_opacity, visibility)
+ ass:pos(0, 0)
+ ass:draw_start()
+ ass:append(fg_path.text)
+ ass:draw_stop()
+
+ -- Current volume value
+ local volume_string = tostring(round(state.volume * 10) / 10)
+ local font_size = round(((width * 0.6) - (#volume_string * (width / 20))) * options.font_scale)
+ if volume_y < self.by - self.spacing then
+ ass:txt(self.ax + (width / 2), self.by - self.spacing, 2, volume_string, {
+ size = font_size, color = fgt, opacity = visibility,
+ clip = '\\clip(' .. fg_path.scale .. ', ' .. fg_path.text .. ')',
+ })
+ end
+ if volume_y > self.by - self.spacing - font_size then
+ ass:txt(self.ax + (width / 2), self.by - self.spacing, 2, volume_string, {
+ size = font_size, color = bgt, opacity = visibility,
+ clip = '\\iclip(' .. fg_path.scale .. ', ' .. fg_path.text .. ')',
+ })
+ end
+
+ -- Disabled stripes for no audio
+ if not state.has_audio then
+ local fg_100_path = create_nudged_path(options.volume_border)
+ local texture_opts = {
+ size = 200, color = 'ffffff', opacity = visibility * 0.1, anchor_x = ax,
+ clip = '\\clip(' .. fg_100_path.scale .. ',' .. fg_100_path.text .. ')',
+ }
+ ass:texture(ax, ay, bx, by, 'a', texture_opts)
+ texture_opts.color = '000000'
+ texture_opts.anchor_x = ax + texture_opts.size / 28
+ ass:texture(ax, ay, bx, by, 'a', texture_opts)
+ end
+
+ return ass
+end
+
+--[[ Volume ]]
+
+---@class Volume : Element
+local Volume = class(Element)
+
+function Volume:new() return Class.new(self) --[[@as Volume]] end
+function Volume:init()
+ Element.init(self, 'volume')
+ self.mute = MuteButton:new({anchor_id = 'volume'})
+ self.slider = VolumeSlider:new({anchor_id = 'volume'})
+end
+
+function Volume:get_visibility()
+ return self.slider.pressed and 1 or Elements.timeline:get_is_hovered() and -1 or Element.get_visibility(self)
+end
+
+function Volume:update_dimensions()
+ local width = state.fullormaxed and options.volume_size_fullscreen or options.volume_size
+ local controls, timeline, top_bar = Elements.controls, Elements.timeline, Elements.top_bar
+ local min_y = top_bar.enabled and top_bar.by or 0
+ local max_y = (controls and controls.enabled and controls.ay) or (timeline.enabled and timeline.ay)
+ or display.height - top_bar.size
+ local available_height = max_y - min_y
+ local max_height = available_height * 0.8
+ local height = round(math.min(width * 8, max_height))
+ self.enabled = height > width * 2 -- don't render if too small
+ local margin = (width / 2) + Elements.window_border.size
+ self.ax = round(options.volume == 'left' and margin or display.width - margin - width)
+ self.ay = min_y + round((available_height - height) / 2)
+ self.bx = round(self.ax + width)
+ self.by = round(self.ay + height)
+ self.mute.enabled, self.slider.enabled = self.enabled, self.enabled
+ self.mute:set_coordinates(self.ax, self.by - round(width * 0.8), self.bx, self.by)
+ self.slider:set_coordinates(self.ax, self.ay, self.bx, self.mute.ay)
+end
+
+function Volume:on_display() self:update_dimensions() end
+function Volume:on_prop_border() self:update_dimensions() end
+function Volume:on_controls_reflow() self:update_dimensions() end
+
+return Volume
diff --git a/.config/mpv/scripts/uosc_shared/elements/WindowBorder.lua b/.config/mpv/scripts/uosc_shared/elements/WindowBorder.lua
new file mode 100644
index 0000000..c5544f5
--- /dev/null
+++ b/.config/mpv/scripts/uosc_shared/elements/WindowBorder.lua
@@ -0,0 +1,33 @@
+local Element = require('uosc_shared/elements/Element')
+
+---@class WindowBorder : Element
+local WindowBorder = class(Element)
+
+function WindowBorder:new() return Class.new(self) --[[@as WindowBorder]] end
+function WindowBorder:init()
+ Element.init(self, 'window_border')
+ self.ignores_menu = true
+ self.size = 0
+end
+
+function WindowBorder:decide_enabled()
+ self.enabled = options.window_border_size > 0 and not state.fullormaxed and not state.border
+ self.size = self.enabled and options.window_border_size or 0
+end
+
+function WindowBorder:on_prop_border() self:decide_enabled() end
+function WindowBorder:on_prop_fullormaxed() self:decide_enabled() end
+
+function WindowBorder:render()
+ if self.size > 0 then
+ local ass = assdraw.ass_new()
+ local clip = '\\iclip(' .. self.size .. ',' .. self.size .. ',' ..
+ (display.width - self.size) .. ',' .. (display.height - self.size) .. ')'
+ ass:rect(0, 0, display.width + 1, display.height + 1, {
+ color = bg, clip = clip, opacity = options.window_border_opacity,
+ })
+ return ass
+ end
+end
+
+return WindowBorder
diff --git a/.config/mpv/scripts/uosc_shared/lib/ass.lua b/.config/mpv/scripts/uosc_shared/lib/ass.lua
new file mode 100644
index 0000000..108953f
--- /dev/null
+++ b/.config/mpv/scripts/uosc_shared/lib/ass.lua
@@ -0,0 +1,170 @@
+--[[ ASSDRAW EXTENSIONS ]]
+
+local ass_mt = getmetatable(assdraw.ass_new())
+
+-- Opacity.
+---@param opacity number|number[] Opacity of all elements, or an array of [primary, secondary, border, shadow] opacities.
+---@param fraction? number Optionally adjust the above opacity by this fraction.
+function ass_mt:opacity(opacity, fraction)
+ fraction = fraction ~= nil and fraction or 1
+ if type(opacity) == 'number' then
+ self.text = self.text .. string.format('{\\alpha&H%X&}', opacity_to_alpha(opacity * fraction))
+ else
+ self.text = self.text .. string.format(
+ '{\\1a&H%X&\\2a&H%X&\\3a&H%X&\\4a&H%X&}',
+ opacity_to_alpha((opacity[1] or 0) * fraction),
+ opacity_to_alpha((opacity[2] or 0) * fraction),
+ opacity_to_alpha((opacity[3] or 0) * fraction),
+ opacity_to_alpha((opacity[4] or 0) * fraction)
+ )
+ end
+end
+
+-- Icon.
+---@param x number
+---@param y number
+---@param size number
+---@param name string
+---@param opts? {color?: string; border?: number; border_color?: string; opacity?: number; clip?: string; align?: number}
+function ass_mt:icon(x, y, size, name, opts)
+ opts = opts or {}
+ opts.font, opts.size, opts.bold = 'MaterialIconsRound-Regular', size, false
+ self:txt(x, y, opts.align or 5, name, opts)
+end
+
+-- Text.
+-- Named `txt` because `ass.text` is a value.
+---@param x number
+---@param y number
+---@param align number
+---@param value string|number
+---@param opts {size: number; font?: string; color?: string; bold?: boolean; italic?: boolean; border?: number; border_color?: string; shadow?: number; shadow_color?: string; rotate?: number; wrap?: number; opacity?: number; clip?: string}
+function ass_mt:txt(x, y, align, value, opts)
+ local border_size = opts.border or 0
+ local shadow_size = opts.shadow or 0
+ local tags = '\\pos(' .. x .. ',' .. y .. ')\\rDefault\\an' .. align .. '\\blur0'
+ -- font
+ tags = tags .. '\\fn' .. (opts.font or config.font)
+ -- font size
+ tags = tags .. '\\fs' .. opts.size
+ -- bold
+ if opts.bold or (opts.bold == nil and options.font_bold) then tags = tags .. '\\b1' end
+ -- italic
+ if opts.italic then tags = tags .. '\\i1' end
+ -- rotate
+ if opts.rotate then tags = tags .. '\\frz' .. opts.rotate end
+ -- wrap
+ if opts.wrap then tags = tags .. '\\q' .. opts.wrap end
+ -- border
+ tags = tags .. '\\bord' .. border_size
+ -- shadow
+ tags = tags .. '\\shad' .. shadow_size
+ -- colors
+ tags = tags .. '\\1c&H' .. (opts.color or bgt)
+ if border_size > 0 then tags = tags .. '\\3c&H' .. (opts.border_color or bg) end
+ if shadow_size > 0 then tags = tags .. '\\4c&H' .. (opts.shadow_color or bg) end
+ -- opacity
+ if opts.opacity then tags = tags .. string.format('\\alpha&H%X&', opacity_to_alpha(opts.opacity)) end
+ -- clip
+ if opts.clip then tags = tags .. opts.clip end
+ -- render
+ self:new_event()
+ self.text = self.text .. '{' .. tags .. '}' .. value
+end
+
+-- Tooltip.
+---@param element {ax: number; ay: number; bx: number; by: number}
+---@param value string|number
+---@param opts? {size?: number; offset?: number; bold?: boolean; italic?: boolean; width_overwrite?: number, responsive?: boolean}
+function ass_mt:tooltip(element, value, opts)
+ opts = opts or {}
+ opts.size = opts.size or 16
+ opts.border = options.text_border
+ opts.border_color = bg
+ local offset = opts.offset or opts.size / 2
+ local align_top = opts.responsive == false or element.ay - offset > opts.size * 2
+ local x = element.ax + (element.bx - element.ax) / 2
+ local y = align_top and element.ay - offset or element.by + offset
+ local margin = (opts.width_overwrite or text_width(value, opts)) / 2 + 10
+ self:txt(clamp(margin, x, display.width - margin), y, align_top and 2 or 8, value, opts)
+end
+
+-- Rectangle.
+---@param ax number
+---@param ay number
+---@param bx number
+---@param by number
+---@param opts? {color?: string; border?: number; border_color?: string; opacity?: number; border_opacity?: number; clip?: string, radius?: number}
+function ass_mt:rect(ax, ay, bx, by, opts)
+ opts = opts or {}
+ local border_size = opts.border or 0
+ local tags = '\\pos(0,0)\\rDefault\\an7\\blur0'
+ -- border
+ tags = tags .. '\\bord' .. border_size
+ -- colors
+ tags = tags .. '\\1c&H' .. (opts.color or fg)
+ if border_size > 0 then tags = tags .. '\\3c&H' .. (opts.border_color or bg) end
+ -- opacity
+ if opts.opacity then tags = tags .. string.format('\\alpha&H%X&', opacity_to_alpha(opts.opacity)) end
+ if opts.border_opacity then tags = tags .. string.format('\\3a&H%X&', opacity_to_alpha(opts.border_opacity)) end
+ -- clip
+ if opts.clip then
+ tags = tags .. opts.clip
+ end
+ -- draw
+ self:new_event()
+ self.text = self.text .. '{' .. tags .. '}'
+ self:draw_start()
+ if opts.radius then
+ self:round_rect_cw(ax, ay, bx, by, opts.radius)
+ else
+ self:rect_cw(ax, ay, bx, by)
+ end
+ self:draw_stop()
+end
+
+-- Circle.
+---@param x number
+---@param y number
+---@param radius number
+---@param opts? {color?: string; border?: number; border_color?: string; opacity?: number; clip?: string}
+function ass_mt:circle(x, y, radius, opts)
+ opts = opts or {}
+ opts.radius = radius
+ self:rect(x - radius, y - radius, x + radius, y + radius, opts)
+end
+
+-- Texture.
+---@param ax number
+---@param ay number
+---@param bx number
+---@param by number
+---@param char string Texture font character.
+---@param opts {size?: number; color: string; opacity?: number; clip?: string; anchor_x?: number, anchor_y?: number}
+function ass_mt:texture(ax, ay, bx, by, char, opts)
+ opts = opts or {}
+ local anchor_x, anchor_y = opts.anchor_x or ax, opts.anchor_y or ay
+ local clip = opts.clip or ('\\clip(' .. ax .. ',' .. ay .. ',' .. bx .. ',' .. by .. ')')
+ local tile_size, opacity = opts.size or 100, opts.opacity or 0.2
+ local x, y = ax - (ax - anchor_x) % tile_size, ay - (ay - anchor_y) % tile_size
+ local width, height = bx - x, by - y
+ local line = string.rep(char, math.ceil((width / tile_size)))
+ local lines = ''
+ for i = 1, math.ceil(height / tile_size), 1 do lines = lines .. (lines == '' and '' or '\\N') .. line end
+ self:txt(
+ x, y, 7, lines,
+ {font = 'uosc_textures', size = tile_size, color = opts.color, bold = false, opacity = opacity, clip = clip})
+end
+
+-- Rotating spinner icon.
+---@param x number
+---@param y number
+---@param size number
+---@param opts? {color?: string; opacity?: number; clip?: string; border?: number; border_color?: string;}
+function ass_mt:spinner(x, y, size, opts)
+ opts = opts or {}
+ opts.rotate = (state.render_last_time * 1.75 % 1) * -360
+ opts.color = opts.color or fg
+ self:icon(x, y, size, 'autorenew', opts)
+ request_render()
+end
diff --git a/.config/mpv/scripts/uosc_shared/lib/menus.lua b/.config/mpv/scripts/uosc_shared/lib/menus.lua
new file mode 100644
index 0000000..e2a4ccc
--- /dev/null
+++ b/.config/mpv/scripts/uosc_shared/lib/menus.lua
@@ -0,0 +1,282 @@
+---@param data MenuData
+---@param opts? {submenu?: string; mouse_nav?: boolean}
+function open_command_menu(data, opts)
+ local menu = Menu:open(data, function(value)
+ if type(value) == 'string' then
+ mp.command(value)
+ else
+ ---@diagnostic disable-next-line: deprecated
+ mp.commandv(unpack(value))
+ end
+ end, opts)
+ if opts and opts.submenu then menu:activate_submenu(opts.submenu) end
+ return menu
+end
+
+---@param opts? {submenu?: string; mouse_nav?: boolean}
+function toggle_menu_with_items(opts)
+ if Menu:is_open('menu') then Menu:close()
+ else open_command_menu({type = 'menu', items = config.menu_items}, opts) end
+end
+
+---@param options {type: string; title: string; list_prop: string; active_prop?: string; serializer: fun(list: any, active: any): MenuDataItem[]; on_select: fun(value: any)}
+function create_self_updating_menu_opener(options)
+ return function()
+ if Menu:is_open(options.type) then Menu:close() return end
+ local list = mp.get_property_native(options.list_prop)
+ local active = options.active_prop and mp.get_property_native(options.active_prop) or nil
+ local menu
+
+ local function update() menu:update_items(options.serializer(list, active)) end
+
+ local ignore_initial_list = true
+ local function handle_list_prop_change(name, value)
+ if ignore_initial_list then ignore_initial_list = false
+ else list = value update() end
+ end
+
+ local ignore_initial_active = true
+ local function handle_active_prop_change(name, value)
+ if ignore_initial_active then ignore_initial_active = false
+ else active = value update() end
+ end
+
+ local initial_items, selected_index = options.serializer(list, active)
+
+ -- Items and active_index are set in the handle_prop_change callback, since adding
+ -- a property observer triggers its handler immediately, we just let that initialize the items.
+ menu = Menu:open(
+ {type = options.type, title = options.title, items = initial_items, selected_index = selected_index},
+ options.on_select, {
+ on_open = function()
+ mp.observe_property(options.list_prop, 'native', handle_list_prop_change)
+ if options.active_prop then
+ mp.observe_property(options.active_prop, 'native', handle_active_prop_change)
+ end
+ end,
+ on_close = function()
+ mp.unobserve_property(handle_list_prop_change)
+ mp.unobserve_property(handle_active_prop_change)
+ end,
+ })
+ end
+end
+
+function create_select_tracklist_type_menu_opener(menu_title, track_type, track_prop, load_command)
+ local function serialize_tracklist(tracklist)
+ local items = {}
+
+ if load_command then
+ items[#items + 1] = {
+ title = 'Load', bold = true, italic = true, hint = 'open file', value = '{load}', separator = true,
+ }
+ end
+
+ local first_item_index = #items + 1
+ local active_index = nil
+ local disabled_item = nil
+
+ -- Add option to disable a subtitle track. This works for all tracks,
+ -- but why would anyone want to disable audio or video? Better to not
+ -- let people mistakenly select what is unwanted 99.999% of the time.
+ -- If I'm mistaken and there is an active need for this, feel free to
+ -- open an issue.
+ if track_type == 'sub' then
+ disabled_item = {title = 'Disabled', italic = true, muted = true, hint = '—', value = nil, active = true}
+ items[#items + 1] = disabled_item
+ end
+
+ for _, track in ipairs(tracklist) do
+ if track.type == track_type then
+ local hint_values = {}
+ local function h(value) hint_values[#hint_values + 1] = value end
+
+ if track.lang then h(track.lang:upper()) end
+ if track['demux-h'] then
+ h(track['demux-w'] and (track['demux-w'] .. 'x' .. track['demux-h']) or (track['demux-h'] .. 'p'))
+ end
+ if track['demux-fps'] then h(string.format('%.5gfps', track['demux-fps'])) end
+ h(track.codec)
+ if track['audio-channels'] then h(track['audio-channels'] .. ' channels') end
+ if track['demux-samplerate'] then h(string.format('%.3gkHz', track['demux-samplerate'] / 1000)) end
+ if track.forced then h('forced') end
+ if track.default then h('default') end
+ if track.external then h('external') end
+
+ items[#items + 1] = {
+ title = (track.title and track.title or 'Track ' .. track.id),
+ hint = table.concat(hint_values, ', '),
+ value = track.id,
+ active = track.selected,
+ }
+
+ if track.selected then
+ if disabled_item then disabled_item.active = false end
+ active_index = #items
+ end
+ end
+ end
+
+ return items, active_index or first_item_index
+ end
+
+ local function selection_handler(value)
+ if value == '{load}' then
+ mp.command(load_command)
+ else
+ mp.commandv('set', track_prop, value and value or 'no')
+
+ -- If subtitle track was selected, assume user also wants to see it
+ if value and track_type == 'sub' then
+ mp.commandv('set', 'sub-visibility', 'yes')
+ end
+ end
+ end
+
+ return create_self_updating_menu_opener({
+ title = menu_title,
+ type = track_type,
+ list_prop = 'track-list',
+ serializer = serialize_tracklist,
+ on_select = selection_handler,
+ })
+end
+
+---@alias NavigationMenuOptions {type: string, title?: string, allowed_types?: string[], active_path?: string, selected_path?: string; on_open?: fun(); on_close?: fun()}
+
+-- Opens a file navigation menu with items inside `directory_path`.
+---@param directory_path string
+---@param handle_select fun(path: string): nil
+---@param opts NavigationMenuOptions
+function open_file_navigation_menu(directory_path, handle_select, opts)
+ directory = serialize_path(normalize_path(directory_path))
+ opts = opts or {}
+
+ if not directory then
+ msg.error('Couldn\'t serialize path "' .. directory_path .. '.')
+ return
+ end
+
+ local files, directories = read_directory(directory.path, opts.allowed_types)
+ local is_root = not directory.dirname
+ local path_separator = path_separator(directory.path)
+
+ if not files or not directories then return end
+
+ sort_filenames(directories)
+ sort_filenames(files)
+
+ -- Pre-populate items with parent directory selector if not at root
+ -- Each item value is a serialized path table it points to.
+ local items = {}
+
+ if is_root then
+ if state.os == 'windows' then
+ items[#items + 1] = {title = '..', hint = 'Drives', value = '{drives}', separator = true}
+ end
+ else
+ items[#items + 1] = {title = '..', hint = 'parent dir', value = directory.dirname, separator = true}
+ end
+
+ local back_path = items[#items] and items[#items].value
+ local selected_index = #items + 1
+
+ for _, dir in ipairs(directories) do
+ items[#items + 1] = {title = dir, value = join_path(directory.path, dir), hint = path_separator}
+ end
+
+ for _, file in ipairs(files) do
+ items[#items + 1] = {title = file, value = join_path(directory.path, file)}
+ end
+
+ for index, item in ipairs(items) do
+ if not item.value.is_to_parent and opts.active_path == item.value then
+ item.active = true
+ if not opts.selected_path then selected_index = index end
+ end
+
+ if opts.selected_path == item.value then selected_index = index end
+ end
+
+ local function open_path(path)
+ local is_drives = path == '{drives}'
+ local is_to_parent = is_drives or #path < #directory_path
+ local inheritable_options = {
+ type = opts.type, title = opts.title, allowed_types = opts.allowed_types, active_path = opts.active_path,
+ }
+
+ if is_drives then
+ open_drives_menu(function(drive_path)
+ open_file_navigation_menu(drive_path, handle_select, inheritable_options)
+ end, {
+ type = inheritable_options.type, title = inheritable_options.title, selected_path = directory.path,
+ on_open = opts.on_open, on_close = opts.on_close,
+ })
+ return
+ end
+
+ local info, error = utils.file_info(path)
+
+ if not info then
+ msg.error('Can\'t retrieve path info for "' .. path .. '". Error: ' .. (error or ''))
+ return
+ end
+
+ if info.is_dir then
+ -- Preselect directory we are coming from
+ if is_to_parent then
+ inheritable_options.selected_path = directory.path
+ end
+
+ open_file_navigation_menu(path, handle_select, inheritable_options)
+ else
+ handle_select(path)
+ end
+ end
+
+ local function handle_back()
+ if back_path then open_path(back_path) end
+ end
+
+ local menu_data = {
+ type = opts.type, title = opts.title or directory.basename .. path_separator, items = items,
+ selected_index = selected_index,
+ }
+ local menu_options = {on_open = opts.on_open, on_close = opts.on_close, on_back = handle_back}
+
+ return Menu:open(menu_data, open_path, menu_options)
+end
+
+-- Opens a file navigation menu with Windows drives as items.
+---@param handle_select fun(path: string): nil
+---@param opts? NavigationMenuOptions
+function open_drives_menu(handle_select, opts)
+ opts = opts or {}
+ local process = mp.command_native({
+ name = 'subprocess',
+ capture_stdout = true,
+ playback_only = false,
+ args = {'wmic', 'logicaldisk', 'get', 'name', '/value'},
+ })
+ local items, selected_index = {}, 1
+
+ if process.status == 0 then
+ for _, value in ipairs(split(process.stdout, '\n')) do
+ local drive = string.match(value, 'Name=([A-Z]:)')
+ if drive then
+ local drive_path = normalize_path(drive)
+ items[#items + 1] = {
+ title = drive, hint = 'drive', value = drive_path, active = opts.active_path == drive_path,
+ }
+ if opts.selected_path == drive_path then selected_index = #items end
+ end
+ end
+ else
+ msg.error(process.stderr)
+ end
+
+ return Menu:open(
+ {type = opts.type, title = opts.title or 'Drives', items = items, selected_index = selected_index},
+ handle_select
+ )
+end
diff --git a/.config/mpv/scripts/uosc_shared/lib/std.lua b/.config/mpv/scripts/uosc_shared/lib/std.lua
new file mode 100644
index 0000000..1261666
--- /dev/null
+++ b/.config/mpv/scripts/uosc_shared/lib/std.lua
@@ -0,0 +1,181 @@
+--[[ Stateless utilities missing in lua standard library ]]
+
+---@param number number
+function round(number) return math.floor(number + 0.5) end
+
+---@param min number
+---@param value number
+---@param max number
+function clamp(min, value, max) return math.max(min, math.min(value, max)) end
+
+---@param rgba string `rrggbb` or `rrggbbaa` hex string.
+function serialize_rgba(rgba)
+ local a = rgba:sub(7, 8)
+ return {
+ color = rgba:sub(5, 6) .. rgba:sub(3, 4) .. rgba:sub(1, 2),
+ opacity = clamp(0, tonumber(#a == 2 and a or 'ff', 16) / 255, 1),
+ }
+end
+
+-- Trim any `char` from the end of the string.
+---@param str string
+---@param char string
+---@return string
+function trim_end(str, char)
+ local char, end_i = char:byte(), 0
+ for i = #str, 1, -1 do
+ if str:byte(i) ~= char then
+ end_i = i
+ break
+ end
+ end
+ return str:sub(1, end_i)
+end
+
+---@param str string
+---@param pattern string
+---@return string[]
+function split(str, pattern)
+ local list = {}
+ local full_pattern = '(.-)' .. pattern
+ local last_end = 1
+ local start_index, end_index, capture = str:find(full_pattern, 1)
+ while start_index do
+ list[#list + 1] = capture
+ last_end = end_index + 1
+ start_index, end_index, capture = str:find(full_pattern, last_end)
+ end
+ if last_end <= (#str + 1) then
+ capture = str:sub(last_end)
+ list[#list + 1] = capture
+ end
+ return list
+end
+
+-- Get index of the last appearance of `sub` in `str`.
+---@param str string
+---@param sub string
+---@return integer|nil
+function string_last_index_of(str, sub)
+ local sub_length = #sub
+ for i = #str, 1, -1 do
+ for j = 1, sub_length do
+ if str:byte(i + j - 1) ~= sub:byte(j) then break end
+ if j == sub_length then return i end
+ end
+ end
+end
+
+---@param itable table
+---@param value any
+---@return integer|nil
+function itable_index_of(itable, value)
+ for index, item in ipairs(itable) do
+ if item == value then return index end
+ end
+end
+
+---@param itable table
+---@param compare fun(value: any, index: number)
+---@param from_end? boolean Search from the end of the table.
+---@return number|nil index
+---@return any|nil value
+function itable_find(itable, compare, from_end)
+ local from, to, step = from_end and #itable or 1, from_end and 1 or #itable, from_end and -1 or 1
+ for index = from, to, step do
+ if compare(itable[index], index) then return index, itable[index] end
+ end
+end
+
+---@param itable table
+---@param decider fun(value: any, index: number)
+function itable_filter(itable, decider)
+ local filtered = {}
+ for index, value in ipairs(itable) do
+ if decider(value, index) then filtered[#filtered + 1] = value end
+ end
+ return filtered
+end
+
+---@param itable table
+---@param value any
+function itable_remove(itable, value)
+ return itable_filter(itable, function(item) return item ~= value end)
+end
+
+---@param itable table
+---@param start_pos? integer
+---@param end_pos? integer
+function itable_slice(itable, start_pos, end_pos)
+ start_pos = start_pos and start_pos or 1
+ end_pos = end_pos and end_pos or #itable
+
+ if end_pos < 0 then end_pos = #itable + end_pos + 1 end
+ if start_pos < 0 then start_pos = #itable + start_pos + 1 end
+
+ local new_table = {}
+ for index, value in ipairs(itable) do
+ if index >= start_pos and index <= end_pos then
+ new_table[#new_table + 1] = value
+ end
+ end
+ return new_table
+end
+
+---@generic T
+---@param a T[]|nil
+---@param b T[]|nil
+---@return T[]
+function itable_join(a, b)
+ local result = {}
+ if a then for _, value in ipairs(a) do result[#result + 1] = value end end
+ if b then for _, value in ipairs(b) do result[#result + 1] = value end end
+ return result
+end
+
+---@param target any[]
+---@param source any[]
+function itable_append(target, source)
+ for _, value in ipairs(source) do target[#target + 1] = value end
+ return target
+end
+
+---@param target any[]
+---@param source any[]
+---@param props? string[]
+function table_assign(target, source, props)
+ if props then
+ for _, name in ipairs(props) do target[name] = source[name] end
+ else
+ for prop, value in pairs(source) do target[prop] = value end
+ end
+ return target
+end
+
+---@generic T
+---@param table T
+---@return T
+function table_shallow_copy(table)
+ local result = {}
+ for key, value in pairs(table) do result[key] = value end
+ return result
+end
+
+--[[ EASING FUNCTIONS ]]
+
+function ease_out_quart(x) return 1 - ((1 - x) ^ 4) end
+function ease_out_sext(x) return 1 - ((1 - x) ^ 6) end
+
+--[[ CLASSES ]]
+
+---@class Class
+Class = {}
+function Class:new(...)
+ local object = setmetatable({}, {__index = self})
+ object:init(...)
+ return object
+end
+function Class:init() end
+function Class:destroy() end
+
+function class(parent) return setmetatable({}, {__index = parent or Class}) end
diff --git a/.config/mpv/scripts/uosc_shared/lib/text.lua b/.config/mpv/scripts/uosc_shared/lib/text.lua
new file mode 100644
index 0000000..7bb456f
--- /dev/null
+++ b/.config/mpv/scripts/uosc_shared/lib/text.lua
@@ -0,0 +1,421 @@
+-- https://en.wikipedia.org/wiki/Unicode_block
+---@alias CodePointRange {[1]: integer; [2]: integer}
+
+---@type CodePointRange[]
+local zero_width_blocks = {
+ {0x0000, 0x001F}, -- C0
+ {0x007F, 0x009F}, -- Delete + C1
+ {0x034F, 0x034F}, -- combining grapheme joiner
+ {0x061C, 0x061C}, -- Arabic Letter Strong
+ {0x200B, 0x200F}, -- {zero-width space, zero-width non-joiner, zero-width joiner, left-to-right mark, right-to-left mark}
+ {0x2028, 0x202E}, -- {line separator, paragraph separator, Left-to-Right Embedding, Right-to-Left Embedding, Pop Directional Format, Left-to-Right Override, Right-to-Left Override}
+ {0x2060, 0x2060}, -- word joiner
+ {0x2066, 0x2069}, -- {Left-to-Right Isolate, Right-to-Left Isolate, First Strong Isolate, Pop Directional Isolate}
+ {0xFEFF, 0xFEFF}, -- zero-width non-breaking space
+ -- Some other characters can also be combined https://en.wikipedia.org/wiki/Combining_character
+ {0x0300, 0x036F}, -- Combining Diacritical Marks 0 BMP Inherited
+ {0x1AB0, 0x1AFF}, -- Combining Diacritical Marks Extended 0 BMP Inherited
+ {0x1DC0, 0x1DFF}, -- Combining Diacritical Marks Supplement 0 BMP Inherited
+ {0x20D0, 0x20FF}, -- Combining Diacritical Marks for Symbols 0 BMP Inherited
+ {0xFE20, 0xFE2F}, -- Combining Half Marks 0 BMP Cyrillic (2 characters), Inherited (14 characters)
+ -- Egyptian Hieroglyph Format Controls and Shorthand format Controls
+ {0x13430, 0x1345F}, -- Egyptian Hieroglyph Format Controls 1 SMP Egyptian Hieroglyphs
+ {0x1BCA0, 0x1BCAF}, -- Shorthand Format Controls 1 SMP Common
+ -- not sure how to deal with those https://en.wikipedia.org/wiki/Spacing_Modifier_Letters
+ {0x02B0, 0x02FF}, -- Spacing Modifier Letters 0 BMP Bopomofo (2 characters), Latin (14 characters), Common (64 characters)
+}
+
+-- All characters have the same width as the first one
+---@type CodePointRange[]
+local same_width_blocks = {
+ {0x3400, 0x4DBF}, -- CJK Unified Ideographs Extension A 0 BMP Han
+ {0x4E00, 0x9FFF}, -- CJK Unified Ideographs 0 BMP Han
+ {0x20000, 0x2A6DF}, -- CJK Unified Ideographs Extension B 2 SIP Han
+ {0x2A700, 0x2B73F}, -- CJK Unified Ideographs Extension C 2 SIP Han
+ {0x2B740, 0x2B81F}, -- CJK Unified Ideographs Extension D 2 SIP Han
+ {0x2B820, 0x2CEAF}, -- CJK Unified Ideographs Extension E 2 SIP Han
+ {0x2CEB0, 0x2EBEF}, -- CJK Unified Ideographs Extension F 2 SIP Han
+ {0x2F800, 0x2FA1F}, -- CJK Compatibility Ideographs Supplement 2 SIP Han
+ {0x30000, 0x3134F}, -- CJK Unified Ideographs Extension G 3 TIP Han
+ {0x31350, 0x323AF}, -- CJK Unified Ideographs Extension H 3 TIP Han
+}
+
+---Get byte count of utf-8 character at index i in str
+---@param str string
+---@param i integer?
+---@return integer
+local function utf8_char_bytes(str, i)
+ local char_byte = str:byte(i)
+ if char_byte < 0xC0 then return 1
+ elseif char_byte < 0xE0 then return 2
+ elseif char_byte < 0xF0 then return 3
+ elseif char_byte < 0xF8 then return 4
+ else return 1 end
+end
+
+---Creates an iterator for an utf-8 encoded string
+---Iterates over utf-8 characters instead of bytes
+---@param str string
+---@return fun(): integer?, string?
+local function utf8_iter(str)
+ local byte_start = 1
+ return function()
+ local start = byte_start
+ if #str < start then return nil end
+ local byte_count = utf8_char_bytes(str, start)
+ byte_start = start + byte_count
+ return start, str:sub(start, start + byte_count - 1)
+ end
+end
+
+---Extract Unicode code point from utf-8 character at index i in str
+---@param str string
+---@param i integer
+---@return integer
+local function utf8_to_unicode(str, i)
+ local byte_count = utf8_char_bytes(str, i)
+ local char_byte = str:byte(i)
+ local unicode = char_byte
+ if byte_count ~= 1 then
+ local shift = 2 ^ (8 - byte_count)
+ char_byte = char_byte - math.floor(0xFF / shift) * shift
+ unicode = char_byte * (2 ^ 6) ^ (byte_count - 1)
+ end
+ for j = 2, byte_count do
+ char_byte = str:byte(i + j - 1) - 0x80
+ unicode = unicode + char_byte * (2 ^ 6) ^ (byte_count - j)
+ end
+ return round(unicode)
+end
+
+---Convert Unicode code point to utf-8 string
+---@param unicode integer
+---@return string?
+local function unicode_to_utf8(unicode)
+ if unicode < 0x80 then return string.char(unicode)
+ else
+ local byte_count
+ if unicode < 0x800 then byte_count = 2
+ elseif unicode < 0x10000 then byte_count = 3
+ elseif unicode < 0x110000 then byte_count = 4
+ else return end -- too big
+
+ local res = {}
+ local shift = 2 ^ 6
+ local after_shift = unicode
+ for _ = byte_count, 2, -1 do
+ local before_shift = after_shift
+ after_shift = math.floor(before_shift / shift)
+ table.insert(res, 1, before_shift - after_shift * shift + 0x80)
+ end
+ shift = 2 ^ (8 - byte_count)
+ table.insert(res, 1, after_shift + math.floor(0xFF / shift) * shift)
+ ---@diagnostic disable-next-line: deprecated
+ return string.char(unpack(res))
+ end
+end
+
+---Update osd resolution if valid
+---@param width integer
+---@param height integer
+local function update_osd_resolution(width, height)
+ if width > 0 and height > 0 then osd_width, osd_height = width, height end
+end
+
+local text_osd = mp.create_osd_overlay("ass-events")
+text_osd.compute_bounds, text_osd.hidden = true, true
+---@type integer, integer
+local osd_width, osd_height = 100, 100
+mp.observe_property('osd-dimensions', 'native', function (_, dim)
+ if dim then update_osd_resolution(dim.w, dim.h) end
+end)
+
+---@param ass_text string
+---@return integer, integer, integer, integer
+local function measure_bounds(ass_text)
+ update_osd_resolution(mp.get_osd_size())
+ text_osd.res_x, text_osd.res_y = osd_width, osd_height
+ text_osd.data = ass_text
+ local res = text_osd:update()
+ return res.x0, res.y0, res.x1, res.y1
+end
+
+---@type {wrap: integer; bold: boolean; italic: boolean, rotate: number; size: number}
+local bounds_opts = {wrap = 2, bold = false, italic = false, rotate = 0, size = 0}
+
+---Measure text width and normalize to a font size of 1
+---text has to be ass safe
+---@param text string
+---@param size number
+---@param bold boolean
+---@param italic boolean
+---@param horizontal boolean
+---@return number, integer
+local function normalized_text_width(text, size, bold, italic, horizontal)
+ bounds_opts.bold, bounds_opts.italic, bounds_opts.rotate = bold, italic, horizontal and 0 or -90
+ local x1, y1 = nil, nil
+ size = size / 0.8
+ -- prevent endless loop
+ local repetitions_left = 5
+ repeat
+ size = size * 0.8
+ bounds_opts.size = size
+ local ass = assdraw.ass_new()
+ ass:txt(0, 0, horizontal and 7 or 1, text, bounds_opts)
+ _, _, x1, y1 = measure_bounds(ass.text)
+ repetitions_left = repetitions_left - 1
+ -- make sure nothing got clipped
+ until (x1 and x1 < osd_width and y1 < osd_height) or repetitions_left == 0
+ local width = (repetitions_left == 0 and not x1) and 0 or (horizontal and x1 or y1)
+ return width / size, horizontal and osd_width or osd_height
+end
+
+---Estimates character length based on utf8 byte count
+---1 character length is roughly the size of a latin character
+---@param char string
+---@return number
+local function char_length(char)
+ return #char > 2 and 2 or 1
+end
+
+---Estimates string length based on utf8 byte count
+---Note: Making a string in the iterator with the character is a waste here,
+---but as this function is only used when measuring whole string widths it's fine
+---@param text string
+---@return number
+local function text_length(text)
+ if not text or text == '' then return 0 end
+ local text_length = 0
+ for _, char in utf8_iter(tostring(text)) do text_length = text_length + char_length(char) end
+ return text_length
+end
+
+local width_length_ratio = 0.5
+---@type {[boolean]: {[string]: {[1]: number, [2]: integer}}}
+local char_width_cache = {}
+
+---Finds the best orientation of text on screen and returns the estimated max size
+---and if the text should be drawn horizontally
+---@param text string
+---@return number, boolean
+local function fit_on_screen(text)
+ local estimated_width = text_length(text) * width_length_ratio
+ if osd_width >= osd_height then
+ -- Fill the screen as much as we can, bigger is more accurate.
+ return math.min(osd_width / estimated_width, osd_height), true
+ else
+ return math.min(osd_height / estimated_width, osd_width), false
+ end
+end
+
+---Gets next stage from cache
+---@param cache {[any]: table}
+---@param value any
+local function get_cache_stage(cache, value)
+ local stage = cache[value]
+ if not stage then
+ stage = {}
+ cache[value] = stage
+ end
+ return stage
+end
+
+---Is measured resolution sufficient
+---@param px integer
+---@return boolean
+local function no_remeasure_required(px)
+ return px >= 800 or (px * 1.1 >= osd_width and px * 1.1 >= osd_height)
+end
+
+---Get measured width of character
+---@param char string
+---@param bold boolean
+---@return number, integer
+local function character_width(char, bold)
+ ---@type {[string]: {[1]: number, [2]: integer}}
+ local char_widths = get_cache_stage(char_width_cache, bold)
+ local width_px = char_widths[char]
+ if width_px and no_remeasure_required(width_px[2]) then return width_px[1], width_px[2] end
+
+ local unicode = utf8_to_unicode(char, 1)
+ for _, block in ipairs(zero_width_blocks) do
+ if unicode >= block[1] and unicode <= block[2] then
+ char_widths[char] = {0, infinity}
+ return 0, infinity
+ end
+ end
+
+ local measured_char = nil
+ for _, block in ipairs(same_width_blocks) do
+ if unicode >= block[1] and unicode <= block[2] then
+ measured_char = unicode_to_utf8(block[1])
+ width_px = char_widths[measured_char]
+ if width_px and no_remeasure_required(width_px[2]) then
+ char_widths[char] = width_px
+ return width_px[1], width_px[2]
+ end
+ break
+ end
+ end
+
+ if not measured_char then measured_char = char end
+ -- half as many repetitions for wide characters
+ local char_count = 10 / char_length(char)
+ local max_size, horizontal = fit_on_screen(measured_char:rep(char_count))
+ local size = math.min(max_size * 0.9, 50)
+ char_count = math.min(math.floor(char_count * max_size / size * 0.8), 100)
+ local enclosing_char, enclosing_width, next_char_count = '|', 0, char_count
+ if measured_char == enclosing_char then enclosing_char = ''
+ else enclosing_width = 2 * character_width(enclosing_char, bold) end
+ local width_ratio, width, px = nil, nil, nil
+ repeat
+ char_count = next_char_count
+ local str = enclosing_char .. measured_char:rep(char_count) .. enclosing_char
+ width, px = normalized_text_width(str, size, bold, false, horizontal)
+ width = width - enclosing_width
+ width_ratio = width * size / (horizontal and osd_width or osd_height)
+ next_char_count = math.min(math.floor(char_count / width_ratio * 0.9), 100)
+ until width_ratio < 0.05 or width_ratio > 0.5 or char_count == next_char_count
+ width = width / char_count
+
+ width_px = {width, px}
+ if char ~= measured_char then char_widths[measured_char] = width_px end
+ char_widths[char] = width_px
+ return width, px
+end
+
+---Calculate text width from individual measured characters
+---@param text string|number
+---@param bold boolean
+---@return number, integer
+local function character_based_width(text, bold)
+ local max_width = 0
+ local min_px = infinity
+ for line in tostring(text):gmatch("([^\n]*)\n?") do
+ local total_width = 0
+ for _, char in utf8_iter(line) do
+ local width, px = character_width(char, bold)
+ total_width = total_width + width
+ if px < min_px then min_px = px end
+ end
+ if total_width > max_width then max_width = total_width end
+ end
+ return max_width, min_px
+end
+
+---Measure width of whole text
+---@param text string|number
+---@param bold boolean
+---@param italic boolean
+---@return number, integer
+local function whole_text_width(text, bold, italic)
+ text = tostring(text)
+ local size, horizontal = fit_on_screen(text)
+ return normalized_text_width(ass_escape(text), size * 0.9, bold, italic, horizontal)
+end
+
+---Get scale factor calculated from font size, bold and italic
+---@param opts {size: number; bold?: boolean; italic?: boolean}
+local function opts_scale_factor(opts)
+ return (opts.italic and 1.01 or 1) * opts.size
+end
+
+---@type {[boolean]: {[boolean]: {[string|number]: {[1]: number, [2]: integer}}}} | {[boolean]: {[string|number]: {[1]: number, [2]: integer}}}
+local width_cache = {}
+
+---Calculate width of text with the given opts
+---@param text string|number
+---@return number
+---@param opts {size: number; bold?: boolean; italic?: boolean}
+function text_width(text, opts)
+ if not text or text == '' then return 0 end
+
+ ---@type boolean, boolean
+ local bold, italic = opts.bold or options.font_bold, opts.italic or false
+
+ if options.text_width_estimation then
+ ---@type {[string|number]: {[1]: number, [2]: integer}}
+ local text_width = get_cache_stage(width_cache, bold)
+ local width_px = text_width[text]
+ if width_px and no_remeasure_required(width_px[2]) then return width_px[1] * opts_scale_factor(opts) end
+
+ local width, px = character_based_width(text, bold)
+ width_cache[bold][text] = {width, px}
+ return width * opts_scale_factor(opts)
+ else
+ ---@type {[string|number]: {[1]: number, [2]: integer}}
+ local text_width = get_cache_stage(get_cache_stage(width_cache, bold), italic)
+ local width_px = text_width[text]
+ if width_px and no_remeasure_required(width_px[2]) then return width_px[1] * opts.size end
+
+ local width, px = whole_text_width(text, bold, italic)
+ width_cache[bold][italic][text] = {width, px}
+ return width * opts.size
+ end
+end
+
+---Wrap the text at the closest opportunity to target_line_length
+---@param text string
+---@param opts {size: number; bold?: boolean; italic?: boolean}
+---@param target_line_length number
+---@return string
+function wrap_text(text, opts, target_line_length)
+ local target_line_width = target_line_length * width_length_ratio * opts.size
+ local bold, scale_factor = opts.bold or false, opts_scale_factor(opts)
+ local wrap_at_chars = {' ', ' ', '-', '–'}
+ local remove_when_wrap = {' ', ' '}
+ local lines = {}
+ for text_line in text:gmatch("([^\n]*)\n?") do
+ local line_width = 0
+ local line_start = 1
+ local before_end = nil
+ local before_width = 0
+ local before_line_start = 0
+ local before_removed_width = 0
+ for char_start, char in utf8_iter(text_line) do
+ local char_end = char_start + #char - 1
+ local can_wrap = false
+ for _, c in ipairs(wrap_at_chars) do
+ if char == c then
+ can_wrap = true
+ break
+ end
+ end
+ local char_width = character_width(char, bold) * scale_factor
+ line_width = line_width + char_width
+ if can_wrap or (char_end == #text_line) then
+ local remove = false
+ for _, c in ipairs(remove_when_wrap) do
+ if char == c then
+ remove = true
+ break
+ end
+ end
+ local line_width_after_remove = line_width - (remove and char_width or 0)
+ if line_width_after_remove < target_line_width then
+ before_end = remove and char_start - 1 or char_end
+ before_width = line_width_after_remove
+ before_line_start = char_end + 1
+ before_removed_width = remove and char_width or 0
+ else
+ if (target_line_width - before_width) <
+ (line_width_after_remove - target_line_width) then
+ lines[#lines + 1] = text_line:sub(line_start, before_end)
+ line_start = before_line_start
+ line_width = line_width - before_width - before_removed_width
+ else
+ lines[#lines + 1] = text_line:sub(line_start, remove and char_start - 1 or char_end)
+ line_start = char_end + 1
+ line_width = remove and line_width - char_width or line_width
+ line_width = 0
+ end
+ before_end = line_start
+ before_width = 0
+ end
+ end
+ end
+ if #text_line >= line_start then lines[#lines + 1] = text_line:sub(line_start)
+ elseif text_line == '' then lines[#lines + 1] = '' end
+ end
+ return table.concat(lines, '\n')
+end
diff --git a/.config/mpv/scripts/uosc_shared/lib/utils.lua b/.config/mpv/scripts/uosc_shared/lib/utils.lua
new file mode 100644
index 0000000..3202ef1
--- /dev/null
+++ b/.config/mpv/scripts/uosc_shared/lib/utils.lua
@@ -0,0 +1,545 @@
+--[[ UI specific utilities that might or might not depend on its state or options ]]
+
+-- Sorting comparator close to (but not exactly) how file explorers sort files.
+sort_filenames = (function()
+ local symbol_order
+ local default_order
+
+ if state.os == 'windows' then
+ symbol_order = {
+ ['!'] = 1, ['#'] = 2, ['$'] = 3, ['%'] = 4, ['&'] = 5, ['('] = 6, [')'] = 6, [','] = 7,
+ ['.'] = 8, ["'"] = 9, ['-'] = 10, [';'] = 11, ['@'] = 12, ['['] = 13, [']'] = 13, ['^'] = 14,
+ ['_'] = 15, ['`'] = 16, ['{'] = 17, ['}'] = 17, ['~'] = 18, ['+'] = 19, ['='] = 20,
+ }
+ default_order = 21
+ else
+ symbol_order = {
+ ['`'] = 1, ['^'] = 2, ['~'] = 3, ['='] = 4, ['_'] = 5, ['-'] = 6, [','] = 7, [';'] = 8,
+ ['!'] = 9, ["'"] = 10, ['('] = 11, [')'] = 11, ['['] = 12, [']'] = 12, ['{'] = 13, ['}'] = 14,
+ ['@'] = 15, ['$'] = 16, ['*'] = 17, ['&'] = 18, ['%'] = 19, ['+'] = 20, ['.'] = 22, ['#'] = 23,
+ }
+ default_order = 21
+ end
+
+ -- Alphanumeric sorting for humans in Lua
+ -- http://notebook.kulchenko.com/algorithms/alphanumeric-natural-sorting-for-humans-in-lua
+ local function pad_number(d)
+ local dec, n = d:match('(%.?)0*(.+)')
+ return #dec > 0 and ('%.12f'):format(d) or ('%03d%s'):format(#n, n)
+ end
+
+ --- In place sorting of filenames
+ ---@param filenames string[]
+ return function(filenames)
+ local tuples = {}
+ for i, filename in ipairs(filenames) do
+ local first_char = filename:sub(1, 1)
+ local order = symbol_order[first_char] or default_order
+ local formatted = filename:lower():gsub('%.?%d+', pad_number)
+ tuples[i] = {order, formatted, filename}
+ end
+ table.sort(tuples, function(a, b)
+ if a[1] ~= b[1] then return a[1] < b[1] end
+ return a[2] == b[2] and #b[3] < #a[3] or a[2] < b[2]
+ end)
+ for i, tuple in ipairs(tuples) do filenames[i] = tuple[3] end
+ end
+end)()
+
+-- Creates in-between frames to animate value from `from` to `to` numbers.
+---@param from number
+---@param to number|fun():number
+---@param setter fun(value: number)
+---@param factor_or_callback? number|fun()
+---@param callback? fun() Called either on animation end, or when animation is killed.
+function tween(from, to, setter, factor_or_callback, callback)
+ local factor = factor_or_callback
+ if type(factor_or_callback) == 'function' then callback = factor_or_callback end
+ if type(factor) ~= 'number' then factor = 0.3 end
+
+ local current, done, timeout = from, false, nil
+ local get_to = type(to) == 'function' and to or function() return to --[[@as number]] end
+ local cutoff = math.abs(get_to() - from) * 0.01
+
+ local function finish()
+ if not done then
+ done = true
+ timeout:kill()
+ if callback then callback() end
+ end
+ end
+
+ local function tick()
+ local to = get_to()
+ current = current + ((to - current) * factor)
+ local is_end = math.abs(to - current) <= cutoff
+ setter(is_end and to or current)
+ request_render()
+ if is_end then finish()
+ else timeout:resume() end
+ end
+
+ timeout = mp.add_timeout(state.render_delay, tick)
+ tick()
+
+ return finish
+end
+
+---@param point {x: number; y: number}
+---@param rect {ax: number; ay: number; bx: number; by: number}
+function get_point_to_rectangle_proximity(point, rect)
+ local dx = math.max(rect.ax - point.x, 0, point.x - rect.bx)
+ local dy = math.max(rect.ay - point.y, 0, point.y - rect.by)
+ return math.sqrt(dx * dx + dy * dy)
+end
+
+-- Extracts the properties used by property expansion of that string.
+---@param str string
+---@param res { [string] : boolean } | nil
+---@return { [string] : boolean }
+function get_expansion_props(str, res)
+ res = res or {}
+ for str in str:gmatch('%$(%b{})') do
+ local name, str = str:match('^{[?!]?=?([^:]+):?(.*)}$')
+ if name then
+ local s = name:find('==') or nil
+ if s then name = name:sub(0, s - 1) end
+ res[name] = true
+ if str and str ~= '' then get_expansion_props(str, res) end
+ end
+ end
+ return res
+end
+
+-- Escape a string for verbatim display on the OSD.
+---@param str string
+function ass_escape(str)
+ -- There is no escape for '\' in ASS (I think?) but '\' is used verbatim if
+ -- it isn't followed by a recognized 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
+
+---@param seconds number
+---@return string
+function format_time(seconds)
+ local human = mp.format_time(seconds)
+ if options.time_precision > 0 then
+ local formatted = string.format('%.' .. options.time_precision .. 'f', math.abs(seconds) % 1)
+ human = human .. '.' .. string.sub(formatted, 3)
+ end
+ return human
+end
+
+---@param opacity number 0-1
+function opacity_to_alpha(opacity)
+ return 255 - math.ceil(255 * opacity)
+end
+
+path_separator = (function()
+ local os_separator = state.os == 'windows' and '\\' or '/'
+
+ -- Get appropriate path separator for the given path.
+ ---@param path string
+ ---@return string
+ return function(path)
+ return path:sub(1, 2) == '\\\\' and '\\' or os_separator
+ end
+end)()
+
+-- Joins paths with the OS aware path separator or UNC separator.
+---@param p1 string
+---@param p2 string
+---@return string
+function join_path(p1, p2)
+ local p1, separator = trim_trailing_separator(p1)
+ -- Prevents joining drive letters with a redundant separator (`C:\\foo`),
+ -- as `trim_trailing_separator()` doesn't trim separators from drive letters.
+ return p1:sub(#p1) == separator and p1 .. p2 or p1 .. separator.. p2
+end
+
+-- Check if path is absolute.
+---@param path string
+---@return boolean
+function is_absolute(path)
+ if path:sub(1, 2) == '\\\\' then return true
+ elseif state.os == 'windows' then return path:find('^%a+:') ~= nil
+ else return path:sub(1, 1) == '/' end
+end
+
+-- Ensure path is absolute.
+---@param path string
+---@return string
+function ensure_absolute(path)
+ if is_absolute(path) then return path end
+ return join_path(state.cwd, path)
+end
+
+-- Remove trailing slashes/backslashes.
+---@param path string
+---@return string path, string trimmed_separator_type
+function trim_trailing_separator(path)
+ local separator = path_separator(path)
+ path = trim_end(path, separator)
+ if state.os == 'windows' then
+ -- Drive letters on windows need trailing backslash
+ if path:sub(#path) == ':' then path = path .. '\\' end
+ else
+ if path == '' then path = '/' end
+ end
+ return path, separator
+end
+
+-- Ensures path is absolute, remove trailing slashes/backslashes.
+-- Lightweight version of normalize_path for performance critical parts.
+---@param path string
+---@return string
+function normalize_path_lite(path)
+ if not path or is_protocol(path) then return path end
+ path = trim_trailing_separator(ensure_absolute(path))
+ return path
+end
+
+-- Ensures path is absolute, remove trailing slashes/backslashes, normalization of path separators and deduplication.
+---@param path string
+---@return string
+function normalize_path(path)
+ if not path or is_protocol(path) then return path end
+
+ path = ensure_absolute(path)
+ local is_unc = path:sub(1, 2) == '\\\\'
+ if state.os == 'windows' or is_unc then path = path:gsub('/', '\\') end
+ path = trim_trailing_separator(path)
+
+ --Deduplication of path separators
+ if is_unc then path = path:gsub('(.\\)\\+', '%1')
+ elseif state.os == 'windows' then path = path:gsub('\\\\+', '\\')
+ else path = path:gsub('//+', '/') end
+
+ return path
+end
+
+-- Check if path is a protocol, such as `http://...`.
+---@param path string
+function is_protocol(path)
+ return type(path) == 'string' and (path:find('^%a[%a%d-_]+://') ~= nil or path:find('^%a[%a%d-_]+:\\?') ~= nil)
+end
+
+---@param path string
+---@param extensions string[] Lowercase extensions without the dot.
+function has_any_extension(path, extensions)
+ local path_last_dot_index = string_last_index_of(path, '.')
+ if not path_last_dot_index then return false end
+ local path_extension = path:sub(path_last_dot_index + 1):lower()
+ for _, extension in ipairs(extensions) do
+ if path_extension == extension then return true end
+ end
+ return false
+end
+
+---@return string
+function get_default_directory()
+ return mp.command_native({'expand-path', options.default_directory})
+end
+
+-- Serializes path into its semantic parts.
+---@param path string
+---@return nil|{path: string; is_root: boolean; dirname?: string; basename: string; filename: string; extension?: string;}
+function serialize_path(path)
+ if not path or is_protocol(path) then return end
+
+ local normal_path = normalize_path_lite(path)
+ local dirname, basename = utils.split_path(normal_path)
+ if basename == '' then basename, dirname = dirname:sub(1, #dirname - 1), nil end
+ local dot_i = string_last_index_of(basename, '.')
+
+ return {
+ path = normal_path,
+ is_root = dirname == nil,
+ dirname = dirname,
+ basename = basename,
+ filename = dot_i and basename:sub(1, dot_i - 1) or basename,
+ extension = dot_i and basename:sub(dot_i + 1) or nil,
+ }
+end
+
+-- Reads items in directory and splits it into directories and files tables.
+---@param path string
+---@param allowed_types? string[] Filter `files` table to contain only files with these extensions.
+---@return string[]|nil files
+---@return string[]|nil directories
+function read_directory(path, allowed_types)
+ local items, error = utils.readdir(path, 'all')
+
+ if not items then
+ msg.error('Reading files from "' .. path .. '" failed: ' .. error)
+ return nil, nil
+ end
+
+ local files, directories = {}, {}
+
+ for _, item in ipairs(items) do
+ if item ~= '.' and item ~= '..' then
+ local info = utils.file_info(join_path(path, item))
+ if info then
+ if info.is_file then
+ if not allowed_types or has_any_extension(item, allowed_types) then
+ files[#files + 1] = item
+ end
+ else directories[#directories + 1] = item end
+ end
+ end
+ end
+
+ return files, directories
+end
+
+-- Returns full absolute paths of files in the same directory as file_path,
+-- and index of the current file in the table.
+---@param file_path string
+---@param allowed_types? string[]
+function get_adjacent_files(file_path, allowed_types)
+ local current_file = serialize_path(file_path)
+ if not current_file then return end
+ local files = read_directory(current_file.dirname, allowed_types)
+ if not files then return end
+ sort_filenames(files)
+ local current_file_index
+ local paths = {}
+ for index, file in ipairs(files) do
+ paths[#paths + 1] = join_path(current_file.dirname, file)
+ if current_file.basename == file then current_file_index = index end
+ end
+ if not current_file_index then return end
+ return paths, current_file_index
+end
+
+-- Navigates in a list, using delta or, when `state.shuffle` is enabled,
+-- randomness to determine the next item. Loops around if `loop-playlist` is enabled.
+---@param list table
+---@param current_index number
+---@param delta number
+function decide_navigation_in_list(list, current_index, delta)
+ if #list < 2 then return #list, list[#list] end
+
+ if state.shuffle then
+ local new_index = current_index
+ math.randomseed(os.time())
+ while current_index == new_index do new_index = math.random(#list) end
+ return new_index, list[new_index]
+ end
+
+ local new_index = current_index + delta
+ if mp.get_property_native('loop-playlist') then
+ if new_index > #list then new_index = new_index % #list
+ elseif new_index < 1 then new_index = #list - new_index end
+ elseif new_index < 1 or new_index > #list then
+ return
+ end
+
+ return new_index, list[new_index]
+end
+
+---@param delta number
+function navigate_directory(delta)
+ if not state.path or is_protocol(state.path) then return false end
+ local paths, current_index = get_adjacent_files(state.path, config.media_types)
+ if paths and current_index then
+ local _, path = decide_navigation_in_list(paths, current_index, delta)
+ if path then mp.commandv('loadfile', path) return true end
+ end
+ return false
+end
+
+---@param delta number
+function navigate_playlist(delta)
+ local playlist, pos = mp.get_property_native('playlist'), mp.get_property_native('playlist-pos-1')
+ if playlist and #playlist > 1 and pos then
+ local index = decide_navigation_in_list(playlist, pos, delta)
+ if index then mp.commandv('playlist-play-index', index - 1) return true end
+ end
+ return false
+end
+
+---@param delta number
+function navigate_item(delta)
+ if state.has_playlist then return navigate_playlist(delta) else return navigate_directory(delta) end
+end
+
+-- Can't use `os.remove()` as it fails on paths with unicode characters.
+-- Returns `result, error`, result is table of:
+-- `status:number(<0=error), stdout, stderr, error_string, killed_by_us:boolean`
+---@param path string
+function delete_file(path)
+ local args = state.os == 'windows' and {'cmd', '/C', 'del', path} or {'rm', path}
+ return mp.command_native({
+ name = 'subprocess',
+ args = args,
+ playback_only = false,
+ capture_stdout = true,
+ capture_stderr = true,
+ })
+end
+
+function serialize_chapter_ranges(normalized_chapters)
+ local ranges = {}
+ local simple_ranges = {
+ {name = 'openings', patterns = {'^op ', '^op$', ' op$', 'opening$'}, requires_next_chapter = true},
+ {name = 'intros', patterns = {'^intro$'}, requires_next_chapter = true},
+ {name = 'endings', patterns = {'^ed ', '^ed$', ' ed$', 'ending$', 'closing$'}},
+ {name = 'outros', patterns = {'^outro$'}},
+ }
+ local sponsor_ranges = {}
+
+ -- Extend with alt patterns
+ for _, meta in ipairs(simple_ranges) do
+ local alt_patterns = config.chapter_ranges[meta.name] and config.chapter_ranges[meta.name].patterns
+ if alt_patterns then meta.patterns = itable_join(meta.patterns, alt_patterns) end
+ end
+
+ -- Clone chapters
+ local chapters = {}
+ for i, normalized in ipairs(normalized_chapters) do chapters[i] = table_shallow_copy(normalized) end
+
+ for i, chapter in ipairs(chapters) do
+ -- Simple ranges
+ for _, meta in ipairs(simple_ranges) do
+ if config.chapter_ranges[meta.name] then
+ local match = itable_find(meta.patterns, function(p) return chapter.lowercase_title:find(p) end)
+ if match then
+ local next_chapter = chapters[i + 1]
+ if next_chapter or not meta.requires_next_chapter then
+ ranges[#ranges + 1] = table_assign({
+ start = chapter.time,
+ ['end'] = next_chapter and next_chapter.time or infinity,
+ }, config.chapter_ranges[meta.name])
+ end
+ end
+ end
+ end
+
+ -- Sponsor blocks
+ if config.chapter_ranges.ads then
+ local id = chapter.lowercase_title:match('segment start *%(([%w]%w-)%)')
+ if id then -- ad range from sponsorblock
+ for j = i + 1, #chapters, 1 do
+ local end_chapter = chapters[j]
+ local end_match = end_chapter.lowercase_title:match('segment end *%(' .. id .. '%)')
+ if end_match then
+ local range = table_assign({
+ start_chapter = chapter, end_chapter = end_chapter,
+ start = chapter.time, ['end'] = end_chapter.time,
+ }, config.chapter_ranges.ads)
+ ranges[#ranges + 1], sponsor_ranges[#sponsor_ranges + 1] = range, range
+ end_chapter.is_end_only = true
+ break
+ end
+ end -- single chapter for ad
+ elseif not chapter.is_end_only and
+ (chapter.lowercase_title:find('%[sponsorblock%]:') or chapter.lowercase_title:find('^sponsors?')) then
+ local next_chapter = chapters[i + 1]
+ ranges[#ranges + 1] = table_assign({
+ start = chapter.time,
+ ['end'] = next_chapter and next_chapter.time or infinity,
+ }, config.chapter_ranges.ads)
+ end
+ end
+ end
+
+ -- Fix overlapping sponsor block segments
+ for index, range in ipairs(sponsor_ranges) do
+ local next_range = sponsor_ranges[index + 1]
+ if next_range then
+ local delta = next_range.start - range['end']
+ if delta < 0 then
+ local mid_point = range['end'] + delta / 2
+ range['end'], range.end_chapter.time = mid_point - 0.01, mid_point - 0.01
+ next_range.start, next_range.start_chapter.time = mid_point, mid_point
+ end
+ end
+ end
+ table.sort(chapters, function(a, b) return a.time < b.time end)
+
+ return chapters, ranges
+end
+
+-- Ensures chapters are in chronological order
+function normalize_chapters(chapters)
+ if not chapters then return {} end
+ -- Ensure chronological order
+ table.sort(chapters, function(a, b) return a.time < b.time end)
+ -- Ensure titles
+ for index, chapter in ipairs(chapters) do
+ chapter.title = chapter.title or ('Chapter ' .. index)
+ chapter.lowercase_title = chapter.title:lower()
+ end
+ return chapters
+end
+
+function serialize_chapters(chapters)
+ chapters = normalize_chapters(chapters)
+ if not chapters then return end
+ --- timeline font size isn't accessible here, so normalize to size 1 and then scale during rendering
+ local opts = {size = 1, bold = true}
+ for index, chapter in ipairs(chapters) do
+ chapter.index = index
+ chapter.title_wrapped = wrap_text(chapter.title, opts, 25)
+ chapter.title_wrapped_width = text_width(chapter.title_wrapped, opts)
+ chapter.title_wrapped = ass_escape(chapter.title_wrapped)
+ end
+ return chapters
+end
+
+--[[ RENDERING ]]
+
+function render()
+ if not display.initialized then return end
+ state.render_last_time = mp.get_time()
+
+ -- Actual rendering
+ local ass = assdraw.ass_new()
+
+ for _, element in Elements:ipairs() do
+ if element.enabled then
+ local result = element:maybe('render')
+ if result then
+ ass:new_event()
+ ass:merge(result)
+ end
+ end
+ end
+
+ -- submit
+ if osd.res_x == display.width and osd.res_y == display.height and osd.data == ass.text then
+ return
+ end
+
+ osd.res_x = display.width
+ osd.res_y = display.height
+ osd.data = ass.text
+ osd.z = 2000
+ osd:update()
+
+ update_margins()
+end
+
+-- Request that render() is called.
+-- The render is then either executed immediately, or rate-limited if it was
+-- called a small time ago.
+state.render_timer = mp.add_timeout(0, render)
+state.render_timer:kill()
+function request_render()
+ if state.render_timer:is_enabled() then return end
+ local timeout = math.max(0, state.render_delay - (mp.get_time() - state.render_last_time))
+ state.render_timer.timeout = timeout
+ state.render_timer:resume()
+end
diff --git a/.config/mpv/scripts/uosc_shared/main.lua b/.config/mpv/scripts/uosc_shared/main.lua
new file mode 100644
index 0000000..323027f
--- /dev/null
+++ b/.config/mpv/scripts/uosc_shared/main.lua
@@ -0,0 +1,5 @@
+--[[
+File required for compatibility between mpv:
+- 0.32 - doesn't support `dir/main.lua`, so we need `uosc.lua` in root
+- 0.33 - requires `main.lua` in directories
+]]