nixos-private-dots/modules/home-manager/mpv/scripts/quality-menu.lua
2024-07-31 06:18:16 +03:00

1289 lines
No EOL
44 KiB
Lua

-- 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 <url>
--This outputs unformatted JSON.
--Format it and look under "formats" to see what's available.
--
--Not all videos have all columns available.
--Be careful, misspelled columns simply won't be displayed, there is no error.
columns_video = '-resolution,frame_rate,dynamic_range|language,bitrate_total,size,-codec_video,-codec_audio',
columns_audio = 'audio_sample_rate,bitrate_total|size,language,-codec_audio',
--columns used for sorting, see "columns_video" for available columns
--comma separated list, prefix column with "-" to reverse sorting order
--Leaving this empty keeps the order from yt-dlp/youtube-dl.
--Be careful, misspelled columns won't result in an error,
--but they might influence the result.
sort_video = 'height,fps,tbr,size,format_id',
sort_audio = 'asr,tbr,size,format_id',
}
opt.read_options(opts, 'quality-menu')
---@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())