-- 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())