-- quality-menu 4.1.1 - 2023-Oct-22 -- 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 script_name = mp.get_script_name() 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', --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, --list of ytdl-format strings to choose from quality_strings_video = [[ [ {"4320p" : "bestvideo[height<=?4320p]"}, {"2160p" : "bestvideo[height<=?2160]"}, {"1440p" : "bestvideo[height<=?1440]"}, {"1080p" : "bestvideo[height<=?1080]"}, {"720p" : "bestvideo[height<=?720]"}, {"480p" : "bestvideo[height<=?480]"}, {"360p" : "bestvideo[height<=?360]"}, {"240p" : "bestvideo[height<=?240]"}, {"144p" : "bestvideo[height<=?144]"} ] ]], quality_strings_audio = [[ [ {"default" : "bestaudio/best"} ] ]], --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 -- --for the uosc integration it is possible to split the text up into a title and a hint --this is done by separating two columns with a "|" instead of a comma --column order in the hint is reversed -- --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 --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') ---@alias Format { properties: {[string]: string}, id: string, label?: string, title?: string, hint?: string } -- *_active_id == nil means unknown, *_active_id == '' means disabled ---@alias Data { video_formats: Format[], audio_formats: Format[], video_active_id?: string, audio_active_id?: string } ---@alias UIState { type: string, type_capitalized: string, name: string , to_other_type: UIState, to_fetching: UIState, to_menu: UIState, is_video: boolean } do ---@param option_string string ---@param option_name string ---@return Format[] local function parse_predefined(option_string, option_name) ---@type {[string]: string}[] local json, error = utils.parse_json(option_string) if error then msg.error('Error while parsing JSON of option ' .. option_name .. ': ' .. error) return {} end ---@type Format[] local formats = {} for i, format in ipairs(json) do local label, format_string = next(format) formats[i] = { label = label, title = label, id = format_string, } end return formats end ---@type Data opts.predefined_data = { video_formats = parse_predefined(opts.quality_strings_video, 'quality_strings_video'), audio_formats = parse_predefined(opts.quality_strings_audio, 'quality_strings_audio'), video_active_id = nil, audio_active_id = nil, } end 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) ---@param input string ---@param separator string ---@return string[] local function string_split(input, separator) if separator == nil then separator = '%s' end local t = {} for str in string.gmatch(input, '([^' .. separator .. ']+)') do table.insert(t, str) end return t end ---@param strings string[] ---@return string[], boolean[] local function strip_minus(strings) local stripped_list = {} local had_minus = {} for i, val in ipairs(strings) 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 do ---@param column_definition string ---@return { all: string[], all_align_left: boolean[], title: string[], title_align_left: boolean[], hint?: string[] } local function parse_columns(column_definition) local columns, columns_align_left = strip_minus(string_split(column_definition, '|,')) local title_hint = string_split(column_definition, '|') local title, title_align_left = strip_minus(string_split(title_hint[1], ',')) local hint = nil if title_hint[2] then hint = strip_minus(string_split(title_hint[2], ',')) -- reverse column order local n = #hint for i = 1, n / 2 do hint[i], hint[n - i + 1] = hint[n - i + 1], hint[i] end end return { all = columns, all_align_left = columns_align_left, title = title, title_align_left = title_align_left, hint = hint } end ---@type { all: string[], all_align_left: boolean[], title: string[], title_align_left: boolean[], hint?: string[] } ---@diagnostic disable-next-line: param-type-mismatch opts.columns_video = parse_columns(opts.columns_video) ---@type { all: string[], all_align_left: boolean[], title: string[], title_align_left: boolean[], hint?: string[] } ---@diagnostic disable-next-line: param-type-mismatch opts.columns_audio = parse_columns(opts.columns_audio) end -- special thanks to reload.lua (https://github.com/4e6/mpv-reload/) local function reload_resume() local reload_duration = mp.get_property_native('duration') local time_pos = mp.get_property('time-pos') mp.command('playlist-play-index current') -- 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+exact') mp.unregister_event(seeker) end mp.register_event('file-loaded', seeker) end end ---@type { video_menu: UIState, audio_menu: UIState, video_fetching: UIState, audio_fetching: UIState } local states = { video_menu = { type = 'video', type_capitalized = 'Video', name = 'video_menu', is_video = true }, audio_menu = { type = 'audio', type_capitalized = 'Audio', name = 'audio_menu', is_video = false }, video_fetching = { type = 'video', type_capitalized = 'Video', name = 'video_fetching', is_video = true }, audio_fetching = { type = 'audio', type_capitalized = 'Audio', name = 'audio_fetching', is_video = false }, } states.video_menu.to_fetching = states.video_fetching states.video_menu.to_menu = states.video_menu states.video_menu.to_other_type = states.audio_menu states.audio_menu.to_fetching = states.audio_fetching states.audio_menu.to_menu = states.audio_menu states.audio_menu.to_other_type = states.video_menu states.video_fetching.to_fetching = states.video_fetching states.video_fetching.to_menu = states.video_menu states.video_fetching.to_other_type = states.audio_fetching states.audio_fetching.to_fetching = states.audio_fetching states.audio_fetching.to_menu = states.audio_menu states.audio_fetching.to_other_type = states.video_fetching ---@type UIState | nil local open_menu_state = nil ---@type string | nil local current_url = nil ---@type {[string]: table} local currently_fetching = {} local destructor = nil local ytdl = { path = opts.ytdl_ver, searched = false, blacklisted = {} } local menu_open local menu_close local video_formats_toggle local audio_formats_toggle local osd = mp.create_osd_overlay('ass-events') local function hide_osd() -- workaround mpv bug, setting to hidden does not cause a redraw -- https://github.com/mpv-player/mpv/issues/10227 osd.data = '' osd:update() osd.hidden = true osd:update() end local osd_timer = mp.add_timeout(1, function() menu_close() end) osd_timer:kill() ---@param message string ---@param time number local function osd_message(message, time) osd.res_x = 1280 osd.res_y = 720 osd.hidden = false osd.data = message osd:update() osd_timer.timeout = time osd_timer:kill() osd_timer:resume() end ---@alias FormatRaw {format_id: string, vcodec?: string, acodec?: string, filesize: integer?, filesize_approx?: integer, fps?: number, tbr?: number, vbr?: number, abr?: number, asr?: number} ---@param json {formats: FormatRaw[], requested_formats: FormatRaw, requested_downloads: FormatRaw} ---@return Data local function process_json(json) ---@param format FormatRaw ---@return boolean 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' or false end ---@param format FormatRaw ---@return boolean local function is_audio(format) return (opts.include_unknown or format.acodec) and format.acodec ~= 'none' or false end local requested_video = nil local requested_audio = nil local requested_formats = json.requested_formats or json.requested_downloads or {} for _, format in ipairs(requested_formats) do if is_video(format) then requested_video = format.format_id elseif is_audio(format) then requested_audio = 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 ---@param format FormatRaw 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 sort_video, reverse_video = strip_minus(string_split(opts.sort_video, ',')) local sort_audio, reverse_audio = strip_minus(string_split(opts.sort_audio, ',')) ---@param properties string[] ---@param reverse {[string]: boolean} ---@return fun(a: FormatRaw, b: FormatRaw): boolean 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 ---@param size integer ---@return string local function scale_filesize(size) if size == nil then return '' end 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 ---@param bitrate integer ---@return string local function scale_bitrate(bitrate) if bitrate == nil then return '' end local counter = 0 while bitrate > 1000 do bitrate = bitrate / 1000 counter = counter + 1 end if counter >= 2 then return string.format('%.1fGbps', bitrate) elseif counter >= 1 then return string.format('%.1fMbps', bitrate) else return string.format('%.1fKbps', bitrate) end end ---@param format FormatRaw local function format_special_fields(format) local size_prefix = not format.filesize and format.filesize_approx and '~' or '' ---@diagnostic disable-next-line: param-type-mismatch 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 ---@param raw_formats { [string]: any } ---@param properties string[] ---@return Format[] local function convert_to_format(raw_formats, properties) ---@type Format[] local formats = {} for i, format in ipairs(raw_formats) do local props = {} for _, prop in ipairs(properties) do props[prop] = tostring(format[prop] or '') end formats[i] = { properties = props, id = format.format_id } end return formats end return { video_formats = convert_to_format(video_formats, opts.columns_video.all), audio_formats = convert_to_format(audio_formats, opts.columns_audio.all), video_active_id = requested_video, audio_active_id = requested_audio, } end ---@return string | nil local function get_url() local path = mp.get_property('path') if not path then return nil end path = path:gsub('ytdl://', '') -- Strip possible ytdl:// prefix. ---@param str string ---@return boolean local function is_url(str) -- adapted the regex from -- https://stackoverflow.com/questions/3809401/what-is-a-good-regular-expression-to-match-a-url return nil ~= str:match( '^[%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_available = false ---@type { [string]: Data } local url_data = {} local function uosc_set_format_counts() if not uosc_available then return end local data = url_data[current_url] if data then mp.commandv('script-message-to', 'uosc', 'set', 'vformats', #data.video_formats) mp.commandv('script-message-to', 'uosc', 'set', 'aformats', #data.audio_formats) else mp.commandv('script-message-to', 'uosc', 'set', 'vformats', 0) mp.commandv('script-message-to', 'uosc', 'set', 'aformats', 0) end end ---@param json string ---@return Data | nil local function process_json_string(json) local json_table, err = utils.parse_json(json) if (json_table == nil) then 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_table.formats == nil then return end return process_json(json_table) end ---@param url string local function download_formats(url) if currently_fetching[url] then return end msg.info('fetching available formats...') if not (ytdl.searched) then local ytdl_mcd = mp.find_config_file(opts.ytdl_ver) if not (ytdl_mcd == nil) then msg.verbose('found ytdl at: ' .. ytdl_mcd) ytdl.path = ytdl_mcd end ytdl.searched = true end local ytdl_format = mp.get_property('ytdl-format') local raw_options = mp.get_property_native('ytdl-raw-options') local command = { ytdl.path, '--no-warnings', '--no-playlist', '-J' } if ytdl_format and #ytdl_format > 0 then command[#command + 1] = '-f' command[#command + 1] = ytdl_format end for param, arg in pairs(raw_options) do command[#command + 1] = '--' .. param if #arg > 0 then command[#command + 1] = arg end end if opts.ytdl_ver == 'yt-dlp' then command[#command + 1] = '--no-match-filter' end command[#command + 1] = '--' command[#command + 1] = url msg.verbose('calling ytdl with command: ' .. table.concat(command, ' ')) --- result.status is exit status --- result.error_string can be empty string, 'killed' or 'init' ---@param success boolean ---@param result { status: integer, stdout: string, stderr: string, error_string: string , killed_by_us: boolean } ---@param error string | nil local function callback(success, result, error) currently_fetching[url] = nil if result.killed_by_us then return end if result.status < 0 or result.stdout == '' or result.error_string ~= '' then osd_message('fetching formats failed...', 2) msg.verbose('status:', result.status) msg.verbose('reason:', result.error_string) msg.verbose('stdout:', result.stdout) msg.verbose('stderr:', result.stderr) -- trim our stderr to avoid spurious newlines local ytdl_err = result.stderr:gsub('^%s*(.-)%s*$', '%1') msg.error(ytdl_err) local err = 'ytdl 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, result.status) end msg.error(err) if string.find(ytdl_err, 'yt%-dl%.org/bug') then -- check version local version_command = { name = 'subprocess', capture_stdout = true, args = { ytdl.path, '--version' } } local version_string = mp.command_native(version_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 ytdl version is severely out of date.') end end return end msg.verbose('ytdl succeeded!') local data = process_json_string(result.stdout) url_data[url] = data uosc_set_format_counts() if not data then return end if open_menu_state and open_menu_state == open_menu_state.to_fetching and url == current_url then menu_open(open_menu_state) end end currently_fetching[url] = mp.command_native_async({ name = 'subprocess', args = command, capture_stdout = true, capture_stderr = true }, callback) end ---Unknown format falls back on highest ranked format if possible ---@param id string | nil ---@param formats Format[] ---@return string local function sanitize_format_id(id, formats) return id or (formats[1] or {}).id or '' end ---@param video_id string ---@param audio_id string ---@return string local function format_string(video_id, audio_id) if #video_id > 0 and #audio_id > 0 then return video_id .. '+' .. audio_id elseif #video_id > 0 then return video_id elseif #audio_id > 0 then return audio_id else return '' end end ---@param url string ---@param video_format string ---@param audio_format string local function set_format(url, video_format, audio_format) if (url_data[url].video_active_id ~= video_format or url_data[url].audio_active_id ~= audio_format) then url_data[url].video_active_id = video_format url_data[url].audio_active_id = audio_format if url == mp.get_property('path') then reload_resume() end end end ---@param formats Format[] ---@param active_format string | nil ---@param menu_type UIState local function text_menu_open(formats, active_format, menu_type) local active = 0 local selected = 1 --set the cursor to the current format for i, format in ipairs(formats) do if format.id == active_format then active = i selected = active break end end if active_format == '' then active = #formats + 1 selected = active end ---@param i integer ---@return string 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 = #formats > 0 and #formats + 2 or 1 ---@return integer 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('{\\rDefault\\q2\\clip(' .. clipping_coordinates .. ')}' .. opts.style_ass_tags) if #formats > 0 then for i, format in ipairs(formats) do ass:append(choose_prefix(i) .. format.label .. '\\N') end ass:append(choose_prefix(#formats + 1) .. 'Disabled\\N') ass:append(choose_prefix(#formats + 2) .. menu_type.to_other_type.type_capitalized .. ' menu') else ass:append('no formats found\\N') ass:append(opts.selected_and_inactive .. menu_type.to_other_type.type_capitalized .. ' menu') end osd.data = ass.text osd:update() 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 = height * aspect osd.res_y = height osd.res_x = width draw_menu() end local update_margins; if utils.shared_script_property_set then update_margins = function() 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 mp.observe_property('shared-script-properties', 'native', update_margins) else update_margins = function(_, val) if not val then val = mp.get_property_native('user-data/osc/margins') end if val then margin_top = val.t margin_bottom = val.b else margin_top = 0 margin_bottom = 0 end draw_menu() end mp.observe_property('user-data/osc/margins', 'native', update_margins) end update_dimensions() update_margins() mp.observe_property('osd-dimensions', 'native', update_dimensions) ---@param amount integer local function selected_move(amount) selected = selected + amount if selected < 1 then selected = num_options elseif selected > num_options then selected = 1 end if osd_timer then osd_timer:kill() osd_timer:resume() end draw_menu() end ---@param keys string | nil ---@param name string ---@param func function ---@param opts table | nil 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 ---@param keys string | nil ---@param name string 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 -- make sure observers are cleaned up if open_menu_state and open_menu_state == open_menu_state.to_menu and destructor then destructor() end destructor = function() 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) end osd_timer:kill() if opts.menu_timeout > 0 then osd_timer.timeout = opts.menu_timeout osd_timer:resume() end 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 }) bind_keys(opts.close_menu_binding, 'close', menu_close) bind_keys(opts.select_binding, 'select', function() if selected == num_options then mp.unobserve_property(update_dimensions) mp.unobserve_property(update_margins) if menu_type.is_video then audio_formats_toggle() else video_formats_toggle() end return end menu_close() if selected == active then return end if current_url == nil then return end local video_id, audio_id local id = formats[selected] and formats[selected].id or '' local data = url_data[current_url] if menu_type.is_video then video_id = id audio_id = sanitize_format_id(data.audio_active_id, data.audio_formats) else video_id = sanitize_format_id(data.video_active_id, data.video_formats) audio_id = id end set_format(current_url, video_id, audio_id) end) osd.hidden = false draw_menu() end ---@param menu table ---@param menu_type UIState local function uosc_show_menu(menu, menu_type) local json = utils.format_json(menu) -- always using update wouldn't work, because it doesn't support the on_close command -- therefore opening a different kind requires `open-menu` -- while updating the same kind requires `update-menu` if open_menu_state == menu_type then mp.commandv('script-message-to', 'uosc', 'update-menu', json) else mp.commandv('script-message-to', 'uosc', 'open-menu', json) end end ---@param formats Format[] ---@param active_format string | nil ---@param menu_type UIState local function uosc_menu_open(formats, active_format, menu_type) local menu = { title = menu_type.type_capitalized .. ' Formats', items = {}, type = 'quality-menu-' .. menu_type.name, keep_open = true, on_close = { 'script-message-to', script_name, 'uosc-menu-closed', menu_type.name, } } menu.items[#menu.items + 1] = { title = menu_type.to_other_type.type_capitalized, italic = true, bold = true, hint = 'open menu', value = { 'script-message-to', script_name, menu_type.to_other_type.type .. '_formats_toggle', }, } menu.items[#menu.items + 1] = { title = 'Disabled', italic = true, muted = true, hint = '—', active = active_format == '', value = { 'script-message-to', script_name, menu_type.type .. '-format-set', current_url, '', } } for _, format in ipairs(formats) do menu.items[#menu.items + 1] = { title = format.title, hint = format.hint, active = format.id == active_format, value = { 'script-message-to', script_name, menu_type.type .. '-format-set', current_url, format.id, } } end uosc_show_menu(menu, menu_type) destructor = function() mp.commandv('script-message-to', 'uosc', 'close-menu', menu.type) end end ---Check if property is same for all formats ---@param formats Format[] ---@param properties string[] ---@return { [string]: boolean } local function identical_for_all(formats, properties) ---@param formats Format[] ---@param prop string ---@return boolean local function all_formats_same_value(formats, prop) local first_value = nil for _, format in ipairs(formats) do first_value = first_value or format.properties[prop] if format.properties[prop] ~= first_value then return false end end return true end local identical_props = {} for _, prop in ipairs(properties) do identical_props[prop] = all_formats_same_value(formats, prop) end return identical_props end ---@param formats Format[] ---@param columns string[] ---@param column_align_left boolean[] ---@return string[] local function format_table(formats, columns, column_align_left) local column_widths = {} for _, format in pairs(formats) do for col, prop in ipairs(columns) do local width = format.properties[prop]:len() if not column_widths[col] or column_widths[col] < width then column_widths[col] = width end end end local identical_columns = identical_for_all(formats, columns) local show_columns = {} for i, width in ipairs(column_widths) do local prop = columns[i] if width > 0 and not (opts.hide_identical_columns and identical_columns[prop]) then show_columns[#show_columns + 1] = { prop = prop, width = width, align_left = column_align_left[prop] } end end local spacing = 2 ---@type string[] local rows = {} for i, format in ipairs(formats) do local row = {} for j, 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[j] = string.format('%' .. width .. 's', format.properties[column.prop] or '') end rows[i] = table.concat(row, string.format('%' .. spacing .. 's', '')):gsub('%s+$', '') end return rows end ---@param formats Format[] ---@param columns string[] ---@return string[] local function format_csv(formats, columns) local identical_props = identical_for_all(formats, columns) local hints = {} for i, format in ipairs(formats) do local row = {} for _, prop in ipairs(columns) do local val = format.properties[prop] if #val > 0 and not (opts.hide_identical_columns and identical_props[prop]) then row[#row + 1] = val end end hints[i] = table.concat(row, ', ') end return hints end ---@param formats Format[] ---@param menu_type UIState local function ensure_menu_data_filled(formats, menu_type) if uosc_available then if formats[1] and formats[1].title == nil then local columns = menu_type.is_video and opts.columns_video or opts.columns_audio local titles = format_table(formats, columns.title, columns.title_align_left) local hints = {} if columns.hint then hints = format_csv(formats, columns.hint) end for i, format in ipairs(formats) do format.title = titles[i] format.hint = hints[i] end end else if formats[1] and formats[1].label == nil then local columns = menu_type.is_video and opts.columns_video or opts.columns_audio local labels = format_table(formats, columns.all, columns.all_align_left) for i, format in ipairs(formats) do format.label = labels[i] end end end end ---@param menu_type UIState local function loading_message(menu_type) menu_type = menu_type.to_fetching if uosc_available then if open_menu_state and open_menu_state == menu_type then return end local menu = { title = menu_type.type_capitalized .. ' Formats', items = { { icon = 'spinner', selectable = false, value = 'ignore' } }, type = 'quality-menu-' .. menu_type.name, keep_open = true, on_close = { 'script-message-to', script_name, 'uosc-menu-closed', menu_type.name } } uosc_show_menu(menu, menu_type) destructor = function() mp.commandv('script-message-to', 'uosc', 'close-menu', menu.type) end else osd_message('fetching available ' .. menu_type.type .. ' formats...', 60) end open_menu_state = menu_type end ---@param menu_type UIState function menu_open(menu_type) if not current_url then return end menu_type = menu_type.to_menu local data = url_data[current_url] if not data then if opts.fetch_formats then loading_message(menu_type) download_formats(current_url) return end -- shallow clone so that each url has it's own active format ids data = {} for k, v in pairs(opts.predefined_data) do data[k] = v end url_data[current_url] = data end local formats = menu_type.is_video and data.video_formats or data.audio_formats local active_format if menu_type.is_video then active_format = data.video_active_id else active_format = data.audio_active_id end msg.verbose('current ytdl-format: ' .. mp.get_property('ytdl-format', '')) ensure_menu_data_filled(formats, menu_type) if uosc_available then uosc_menu_open(formats, active_format, menu_type) else text_menu_open(formats, active_format, menu_type) end open_menu_state = menu_type end function menu_close() if destructor then destructor() destructor = nil end if not osd.hidden then hide_osd() end open_menu_state = nil end ---@param menu_type UIState local function toggle_menu(menu_type) if open_menu_state and open_menu_state.type == menu_type.type then menu_close() return end if current_url == nil then if uosc_available then if menu_type.is_video then mp.commandv('script-binding', 'uosc/video') else mp.commandv('script-binding', 'uosc/audio') end end return end menu_open(menu_type) end function video_formats_toggle() toggle_menu(states.video_menu) end function audio_formats_toggle() toggle_menu(states.audio_menu) 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) mp.register_event('start-file', function() local new_url = get_url() local url_changed = current_url ~= new_url current_url = new_url uosc_set_format_counts() -- new path isn't an url if not new_url then return menu_close() end -- open or update menu if opts.start_with_menu and url_changed or open_menu_state then menu_open(open_menu_state or states.video_menu) end end) mp.register_event('file-loaded', function() if not (opts.fetch_formats and opts.fetch_on_start) then return end if not current_url or url_data[current_url] then return end download_formats(current_url) end) -- run before ytdl_hook, which uses a priority of 10 mp.add_hook('on_load', 9, function() local path = mp.get_property('path') local data = url_data[path] if not (data and data.video_active_id and data.audio_active_id) then return end local format = format_string(data.video_active_id, data.audio_active_id) msg.verbose('setting ytdl-format: ' .. format) mp.set_property('file-local-options/ytdl-format', format) end) ---@param url string ---@param format_id string mp.register_script_message('video-format-set', function(url, format_id) menu_close() local data = url_data[url] set_format(url, format_id, sanitize_format_id(data.audio_active_id, data.audio_formats)) end) ---@param url string ---@param format_id string mp.register_script_message('audio-format-set', function(url, format_id) menu_close() local data = url_data[url] set_format(url, sanitize_format_id(data.video_active_id, data.video_formats), format_id) end) --- check if uosc is running ---@param version string mp.register_script_message('uosc-version', function(version) ---Like the comperator for table.sort, this returns v1 < v2 ---Assumes two valid semver strings ---@param v1 string ---@param v2 string ---@return boolean local function semver_comp(v1, v2) local v1_iterator = v1:gmatch('%d+') local v2_iterator = v2:gmatch('%d+') for v2_num_str in v2_iterator do local v1_num_str = v1_iterator() if not v1_num_str then return true end local v1_num = tonumber(v1_num_str) local v2_num = tonumber(v2_num_str) if v1_num < v2_num then return true end if v1_num > v2_num then return false end end return false end local min_version = '4.6.0' uosc_available = not semver_comp(version, min_version) if not uosc_available then return end uosc_set_format_counts() mp.commandv( 'script-message-to', 'uosc', 'overwrite-binding', 'stream-quality', 'script-binding ' .. script_name .. '/video_formats_toggle' ) ---@param name string mp.register_script_message('uosc-menu-closed', function(name) -- got closed from the uosc side if open_menu_state and open_menu_state.name == name then destructor = nil menu_close() end end) end) mp.commandv('script-message-to', 'uosc', 'get-version', mp.get_script_name())