summaryrefslogtreecommitdiffstats
path: root/.config/mpv/scripts/quality-menu.lua
diff options
context:
space:
mode:
authorJoe <rbo@gmx.us>2024-04-22 23:35:06 +0200
committerJoe <rbo@gmx.us>2024-04-22 23:35:06 +0200
commit528c35de2a67b8e87ff9bb6d7f7de692cb4a31bc (patch)
tree47ea5d822d948e3e5119619c019fe9b62bc07135 /.config/mpv/scripts/quality-menu.lua
parentup (diff)
downloaddotfiles-bsd-528c35de2a67b8e87ff9bb6d7f7de692cb4a31bc.tar.gz
dotfiles-bsd-528c35de2a67b8e87ff9bb6d7f7de692cb4a31bc.tar.bz2
dotfiles-bsd-528c35de2a67b8e87ff9bb6d7f7de692cb4a31bc.tar.xz
dotfiles-bsd-528c35de2a67b8e87ff9bb6d7f7de692cb4a31bc.tar.zst
dotfiles-bsd-528c35de2a67b8e87ff9bb6d7f7de692cb4a31bc.zip
up
Diffstat (limited to '.config/mpv/scripts/quality-menu.lua')
-rw-r--r--.config/mpv/scripts/quality-menu.lua1357
1 files changed, 851 insertions, 506 deletions
diff --git a/.config/mpv/scripts/quality-menu.lua b/.config/mpv/scripts/quality-menu.lua
index 35949d5..c0e91ac 100644
--- a/.config/mpv/scripts/quality-menu.lua
+++ b/.config/mpv/scripts/quality-menu.lua
@@ -1,4 +1,4 @@
--- quality-menu 3.0.2 - 2023-Jan-10
+-- 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.
@@ -13,22 +13,23 @@ 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 F Alt+f",
+ 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",
+ ytdl_ver = 'yt-dlp',
--formatting / cursors
- selected_and_active = "▶ - ",
- selected_and_inactive = "● - ",
- unselected_and_active = "▷ - ",
- unselected_and_inactive = "○ - ",
+ 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,
@@ -40,7 +41,7 @@ local opts = {
--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}",
+ style_ass_tags = '{\\fnmonospace\\fs25\\bord1}',
-- Shift drawing coordinates. Required for mpv.net compatiblity
shift_x = 0,
@@ -60,24 +61,25 @@ local opts = {
--use youtube-dl to fetch a list of available formats (overrides quality_strings)
fetch_formats = true,
- --default menu entries
- quality_strings = [[
+ --list of ytdl-format strings to choose from
+ quality_strings_video = [[
[
- {"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"}
+ {"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"}
]
]],
-
- --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,
@@ -98,6 +100,10 @@ local opts = {
--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,
@@ -114,8 +120,8 @@ local opts = {
--
--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_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
@@ -125,19 +131,116 @@ local opts = {
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)
+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 playlist_pos = mp.get_property_number("playlist-pos")
- local reload_duration = mp.get_property_native("duration")
- local time_pos = mp.get_property("time-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)
+ 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
@@ -146,39 +249,107 @@ local function reload_resume()
-- 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.commandv('seek', time_pos, 'absolute+exact')
mp.unregister_event(seeker)
end
- mp.register_event("file-loaded", seeker)
+ 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
+ -- 'none' means it is not a video
-- nil means it is unknown
- return (opts.include_unknown or format.vcodec) and format.vcodec ~= "none"
+ 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"
+ return (opts.include_unknown or format.acodec) and format.acodec ~= 'none' or false
end
- local vfmt = nil
- local afmt = nil
- local requested_formats = json["requested_formats"] or json["requested_downloads"]
+ 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
- vfmt = format["format_id"]
+ requested_video = format.format_id
elseif is_audio(format) then
- afmt = format["format_id"]
+ requested_audio = format.format_id
end
end
@@ -196,6 +367,7 @@ local function process_json(json)
end
end
+ ---@param format FormatRaw
local function populate_special_fields(format)
format.size = format.filesize or format.filesize_approx
format.frame_rate = format.fps
@@ -211,33 +383,12 @@ local function process_json(json)
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, ','))
+ ---@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
@@ -262,11 +413,12 @@ local function process_json(json)
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 ""
+ return ''
end
- size = tonumber(size)
local counter = 0
while size > 1024 do
@@ -274,424 +426,303 @@ local function process_json(json)
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)
+ 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 ""
+ ---@param bitrate integer
+ ---@return string
+ local function scale_bitrate(bitrate)
+ if bitrate == nil then
+ return ''
end
- br = tonumber(br)
local counter = 0
- while br > 1000 do
- br = br / 1000
+ while bitrate > 1000 do
+ bitrate = bitrate / 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)
+ 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 ""
+ 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.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 ""
+ 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 "")
+ ---@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
- res[#res + 1] = { label = row:gsub('%s+$', ''), format = f.format_id }
+ formats[i] = { properties = props, id = format.format_id }
end
- return res
+ return formats
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
+ 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")
+ local path = mp.get_property('path')
if not path then return nil end
- path = string.gsub(path, "ytdl://", "") -- Strip possible ytdl:// prefix.
+ path = path:gsub('ytdl://', '') -- Strip possible ytdl:// prefix.
- local function is_url(s)
+ ---@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 ~=
- 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()@:%_\\+.~#?&/=]*")
+ 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 = false
+local uosc_available = false
+---@type { [string]: Data }
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 function uosc_set_format_counts()
+ if not uosc_available then return end
- local data = url_data[new_path]
+ local data = url_data[current_url]
if data then
- mp.commandv('script-message-to', 'uosc', 'set', 'vformats', #data.voptions)
- mp.commandv('script-message-to', 'uosc', 'set', 'aformats', #data.aoptions)
+ 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
-local function process_json_string(url, json)
- local json, err = utils.parse_json(json)
+---@param json string
+---@return Data | nil
+local function process_json_string(json)
+ local json_table, 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)
+ 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.formats == nil then
+ if json_table.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
+ return process_json(json_table)
end
+---@param url string
local function download_formats(url)
+ if currently_fetching[url] then return end
- 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
+ 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 youtube-dl at: " .. ytdl_mcd)
+ msg.verbose('found ytdl 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
+ 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
-
- 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
+ for param, arg in pairs(raw_options) do
+ command[#command + 1] = '--' .. param
+ if #arg > 0 then
+ command[#command + 1] = arg
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 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+)')
- 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)
+ -- 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
- 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
+ msg.verbose('ytdl succeeded!')
+ local data = process_json_string(result.stdout)
+ url_data[url] = data
+ uosc_set_format_counts()
- 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
+ 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
- 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
+ currently_fetching[url] = mp.command_native_async({
+ name = 'subprocess',
+ args = command,
+ capture_stdout = true,
+ capture_stderr = true
+ }, callback)
+end
- queue_callback_video[url] = nil
- queue_callback_audio[url] = nil
- return vres, ares, vfmt, afmt, url
+---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
-local function format_string(vfmt, afmt)
- if vfmt and afmt then
- return vfmt .. "+" .. afmt
- elseif vfmt then
- return vfmt
- elseif afmt then
- return afmt
+---@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 ""
+ 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
+---@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
-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))
-
+---@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
- if fmt then
- for i, v in ipairs(options) do
- if v.format == fmt then
- active = i
- selected = active
- break
- end
+ for i, format in ipairs(formats) do
+ if format.id == active_format then
+ active = i
+ selected = active
+ break
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
+ 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.
+ return '> ' --shouldn't get here.
end
local width, height
local margin_top, margin_bottom = 0, 0
- local num_options = #options + 1
+ 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)
@@ -717,228 +748,542 @@ local function show_menu(isvideo)
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 .. ')}')
+ ass:append('{\\rDefault\\q2\\clip(' .. clipping_coordinates .. ')}' .. opts.style_ass_tags)
- if #options > 0 then
- for i, v in ipairs(options) do
- ass:append(choose_prefix(i) .. v.label .. "\\N")
+ if #formats > 0 then
+ for i, format in ipairs(formats) do
+ ass:append(choose_prefix(i) .. format.label .. '\\N')
end
- ass:append(choose_prefix(#options + 1) .. "None")
+ 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")
+ ass:append('no formats found\\N')
+ ass:append(opts.selected_and_inactive .. menu_type.to_other_type.type_capitalized .. ' menu')
end
- mp.set_osd_ass(width, height, ass.text)
+ 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 = h * aspect
+ width = height * aspect
+ osd.res_y = height
+ osd.res_x = width
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)
+ 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
- margin_top = vals[3] -- top
- margin_bottom = vals[4] -- bottom
- else
- margin_top = 0
- margin_bottom = 0
+ draw_menu()
end
- draw_menu()
+ 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)
- mp.observe_property('shared-script-properties', 'native', update_margins)
-
- local timeout = nil
- local function selected_move(amt)
- selected = selected + amt
+ ---@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 timeout then
- timeout:kill()
- timeout:resume()
+ 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
+ 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
+ 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")
+ -- 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)
- destroyer = nil
end
+ osd_timer:kill()
if opts.menu_timeout > 0 then
- timeout = mp.add_periodic_timer(opts.menu_timeout, destroy)
+ osd_timer.timeout = opts.menu_timeout
+ osd_timer:resume()
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
+ 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)
- fmt = options[selected] and options[selected].format or nil
- if isvideo then
- vfmt = fmt
- else
- afmt = fmt
+ 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
- set_format(url, vfmt, afmt)
- end)
+ end
end
- bind_keys(opts.close_menu_binding, "close", destroy) --close menu using ESC
- mp.osd_message("", 0)
- draw_menu()
+
+ 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
-local ui_callback = {}
+---@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
-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')
+ for i, format in ipairs(formats) do
+ format.title = titles[i]
+ format.hint = hints[i]
+ end
end
else
- show_menu(true)
+ 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
-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')
+---@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
- show_menu(false)
+ osd_message('fetching available ' .. menu_type.type .. ' formats...', 60)
end
+ open_menu_state = menu_type
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)
+---@param menu_type UIState
+function menu_open(menu_type)
+ if not current_url then return end
+ menu_type = menu_type.to_menu
-local original_format = mp.get_property("ytdl-format")
-local path = nil
-local function file_start()
- uosc_set_format_counts()
+ 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
- local new_path = get_url()
- if not new_path then 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
- local data = url_data[new_path]
+ msg.verbose('current ytdl-format: ' .. mp.get_property('ytdl-format', ''))
- 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
+ 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 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)
+
+ 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
- path = new_path
+
+ menu_open(menu_type)
end
-mp.register_event("start-file", file_start)
+function video_formats_toggle() toggle_menu(states.video_menu) end
+function audio_formats_toggle() toggle_menu(states.audio_menu) end
-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()
+-- 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_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
+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)
-mp.register_script_message('video-format-set', function(url, format_id)
- set_format(url, format_id, url_data[url].afmt)
+-- 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)
-mp.register_script_message('audio-format-set', function(url, format_id)
- set_format(url, url_data[url].vfmt, format_id)
+---@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)
-mp.register_script_message('register-ui', function(script_name)
- ui_callback[#ui_callback + 1] = script_name
+---@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
+--- check if uosc is running
+---@param version string
mp.register_script_message('uosc-version', function(version)
- version = tonumber((version:gsub('%.', '')))
- ---@diagnostic disable-next-line: cast-local-type
- uosc = version and version >= 400
+ ---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())