--[[
    modernx.lua v0.4.4 by zydezu
    (https://github.com/zydezu/ModernX)

    This script is a result of the original mpv-osc-modern by maoiscat
    and it's subsequent forks:
    * cyl0/ModernX
    * dexeonify/ModernX
    * Samillion/ModernZ

    Based on the osc.lua from mpv
--]]

mp.assdraw = require("mp.assdraw")
mp.msg = require("mp.msg")
mp.utils = require("mp.utils")

-- ====================
-- declarations
-- ====================

local function update_tracklist() end
local function get_tracklist() end
local function set_track() end
local function get_track() end
local function window_controls_enabled() end
local function get_chapter() end
local function render_elements() end
local function render_persistent_progressbar() end
local function limited_list() end
local function check_title() end
local function shuffle_playlist() end
local function normalize_date(date) end
local function exec_async() end
local function is_url() end
local function check_path_url() end
local function check_comments() end
local function load_set_of_comments() end
local function process_filesize() end
local function split_utf8_strings(str, maxLength) end
local function process_vid_stats() end
local function add_commas_to_number() end
local function add_like_count_to_title() end
local function get_playlist(shuffled) end
local function get_chapterlist() end
local function show_message(text, duration) end
local function bind_keys() end
local function unbind_keys() end
local function destroy_scrolling_keys() end
local function check_description() end
local function show_description(text) end
local function reset_desc_timer() end
local function render_message() end
local function window_controls() end
local function validate_user_opts() end
local function update_options(list) end
local function show_osc() end
local function hide_osc() end
local function osc_visible(visible) end
local function adjust_subtitles(visible) end
local function pause_state() end
local function cache_state() end
local function process_event() end
local function tick() end
local function reset_timeout() end
local function visibility_mode(mode) end

-- ====================
-- Parameters
-- default user option values
-- change them using modernx.conf
-- ====================

local user_opts = {
    -- Language and display --
    language = "en",                        -- en:English - .json translations need implementing
    font = "mpv-osd-symbols",               -- font for the OSC (default: mpv-osd-symbols or the one set in mpv.conf)
    layout_option = "original",             -- use the original/reduced layout
    idle_screen = true,                     -- show mpv logo when idle
    key_bindings = true,                    -- register additional key bindings, such as chapter scrubbing, pinning the window
    window_top_bar = "no",                -- show OSC window top bar: "auto", "yes", or "no" (borderless/fullscreen)
    show_windowed = true,                   -- show OSC when windowed
    show_fullscreen = true,                 -- show OSC when fullscreen
    show_on_pause = true,                   -- show OSC when paused
    keep_on_pause = false,                  -- disable OSC hide timeout when paused
    green_and_grumpy = false,               -- disable the Santa hat in December
    visibility = "auto",                    -- only used at init to set visibility_mode(...)

    -- OSC behaviour and scaling
    hide_timeout = 1500,                    -- time (in ms) before OSC hides if no mouse movement
    seek_resets_hide_timeout = true,        -- if seeking should reset the hide_timeout
    fade_duration = 150,                    -- fade-out duration (in ms), set to 0 for no fade
    min_mouse_move = 0,                     -- minimum mouse movement (in pixels) required to show OSC
    bottom_hover = true,                    -- show OSC only when hovering at the bottom
    bottom_hover_zone = 200,                -- height of hover zone for bottom_hover (in pixels)
    osc_on_seek = false,                    -- show OSC when seeking
    osc_keep_with_cursor = false,           -- keep OSC visible if mouse cursor is within OSC boundaries
    mouse_seek_pause = true,                -- pause video while seeking with mouse move (on button hold)

    vid_scale = false,                      -- scale osc with the video
    scale_windowed = 1.0,                   -- osc scale factor when windowed
    scale_fullscreen = 1.0,                 -- osc scale factor when fullscreen
    scale_forced_window = 1.0,              -- osc scale factor when forced (no video, like music files)

    -- Time, title and description display
    show_title = true,                      -- show title in the OSC (above seekbar)
    title = "${media-title}",               -- title above seekbar format: "${media-title}" or "${filename}"
    title_font_size = 28,                   -- font size of the title text (above seekbar)
    dynamic_title = true,                   -- change title if {media-title} and {filename} differ (eg: when playing URLs or audio)

    show_chapter_title = true,              -- show chapter title alongside timestamp (below seekbar)
    chapter_fmt = "%s",                     -- format for chapter display on seekbar hover (set to "no" to disable)
    show_chapter_markers = true,            -- show chapter markers on the seekbar
    show_top_mark = true,                   -- show the top part of the chapter marker
    show_bottom_mark = false,               -- show the bottom part of the chapter marker

    time_total = true,                      -- show total time instead of remaining time
    time_ms = false,                        -- show timecodes with milliseconds
    unicode_minus = false,                  -- use the Unicode minus sign in remaining time
    time_format = "dynamic",                -- "dynamic" or "fixed" - dynamic shows MM:SS when possible, fixed always shows HH:MM:SS
    time_font_size = 18,                    -- font size of the time display

    show_description = true,                -- show video description - description on web videos or metadata/stats on local video
    show_file_size = true,                  -- show the current file's size in the description
    description_font_size = 19,             -- font size of the description text (below title)
    description_alpha = 100,                -- alpha of the description background box
    scrolling_speed = 40,                   -- the speed of scrolling text in description/comment menus

    date_format = "%Y-%m-%d",               -- how dates should be formatted, when read from metadata (uses standard lua date formatting)

    -- Title bar settings
    window_title = true,                    -- show window title in borderless/fullscreen mode
    window_controls = true,                 -- show window controls (close, minimize, maximize) in borderless/fullscreen
    window_controls_title = "${media-title}", -- same as title but for window_controls

    -- Subtitle display settings
    raise_subtitles = true,                 -- whether to raise subtitles above the osc when it's shown
    raise_subtitle_amount = 160,            -- how much subtitles rise when the osc is shown

    -- Buttons display and functionality
    compact_mode = true,                    -- replace the jump buttons with the seek/chapter buttons

    jump_buttons = true,                    -- show the jump backward and forward buttons
    jump_amount = 10,                       -- change the jump amount in seconds
    jump_more_amount = 60,                  -- change the jump amount in seconds when right-clicking jump buttons and shift-clicking chapter skip buttons
    jump_icon_number = true,                -- show different icon when jump_amount is set to 5, 10, or 30
    jump_mode = "relative",                 -- seek mode for jump buttons
    jump_softrepeat = true,                 -- enable continuous jumping when holding down seek buttons
    chapter_skip_buttons = true,            -- show the chapter skip backward and forward buttons
    chapter_softrepeat = false,             -- enable continuous skipping when holding down chapter skip buttons
    track_nextprev_buttons = true,          -- show next/previous playlist track buttons

    volume_control = true,                  -- show mute button and volume slider
    volume_control_type = "linear",         -- volume scale type: "linear" or "logarithmic"

    info_button = false,                    -- show info button
    ontop_button = true,                    -- show window on top button
    screenshot_button = false,              -- show screenshot button
    screenshot_flag = "subtitles",          -- flag for screenshot button: "subtitles", "video", "window", "each-frame"
                                            -- https://mpv.io/manual/master/#command-interface-screenshot-%3Cflags%3E

    download_button = true,                 -- show download button on web videos (requires yt-dlp and ffmpeg)
    download_path = "~/Pictures/mpv/downloads", -- default download directory for videos (https://mpv.io/manual/master/#paths)

    loop_button = false,                    -- show loop button
    loop_in_pause = true,                   -- enable looping by right-clicking pause

    playpause_size = 30,                    -- icon size for the play/pause button
    midbuttons_size = 24,                   -- icon size for the middle buttons
    sidebuttons_size = 24,                  -- icon size for the side buttons

    -- Colors and style
    osc_color = "#000000",                  -- accent color of the OSC and title bar
    window_title_color = "#FFFFFF",         -- color of the title in borderless/fullscreen mode
    window_controls_color = "#FFFFFF",      -- color of the window controls (close, minimize, maximize) in borderless/fullscreen mode
    window_controls_close_hover = "#E81123", -- color of close window control on hover
    window_controls_minmax_hover = "#53A4FC", -- color of min/max window controls on hover
    title_color = "#FFFFFF",                -- color of the title (above seekbar)
    seekbarfg_color = "#1D96F5",            -- color of the seekbar progress and handle, in Hex color format
    seekbarbg_color = "#FFFFFF",            -- color of the remaining seekbar, in Hex color format
    seekbar_cache_color = "#1D96F5",        -- color of the cache ranges on the seekbar
    volumebar_match_seek_color = false,     -- match volume bar color with seekbar color (ignores side_buttons_color)
    time_color = "#FFFFFF",                 -- color of the timestamps (below seekbar)
    chapter_title_color = "#FFFFFF",        -- color of the chapter title next to timestamp (below seekbar)
    side_buttons_color = "#FFFFFF",         -- color of the side buttons (audio, subtitles, playlist, etc.)
    middle_buttons_color = "#FFFFFF",       -- color of the middle buttons (skip, jump, chapter, etc.)
    playpause_color = "#FFFFFF",            -- color of the play/pause button
    held_element_color = "#999999",         -- color of the element when held down (pressed)
    hover_effect_color = "#FFFFFF",         -- color of a hovered button when hover_effect includes "color"
    thumbnail_border_color = "#FFFFFF",     -- color of the border for thumbnails (with thumbfast)
    thumbnail_border_outline = "#000000",   -- color of the border outline for thumbnails

    fade_alpha = 100,                       -- alpha of the title bar background box
    fade_blur_strength = 75,                -- blur strength for the OSC alpha fade - caution: high values can take a lot of CPU time to render
    fade_transparency_strength = 0,         -- use with "fade_blur_strength = 0" to create a transparency box
    window_fade_alpha = 100,                -- alpha of the window title bar
    window_fade_blur_strength = 75,         -- blur strength for the window title bar. caution: high values can take a lot of CPU time to render
    window_fade_transparency_strength = 0,  -- use with "window_fade_blur_strength = 0" to create a transparency box
    thumbnail_border = 3,                   -- width of the thumbnail border (for thumbfast)
    thumbnail_border_radius = 3,            -- rounded corner radius for thumbnail border (0 to disable)

    -- Button hover effects
    hover_effect = "size,glow,color",       -- active button hover effects: "glow", "size", "color"; can use multiple separated by commas
    hover_button_size = 115,                -- relative size of a hovered button if "size" effect is active
    button_glow_amount = 5,                 -- glow intensity when "glow" hover effect is active
    hover_effect_for_sliders = false,       -- apply hover effects to slider handles

    -- Progress bar settings
    seek_handle_size = 0.8,                 -- size ratio of the seekbar handle (range: 0 ~ 1)
    seekbar_between_timers = false,         -- moves the seekbar and progress bar between the timers
    seekbar_height = 2,                     -- height of the seekbar
    progress_bar_height = 16,               -- height of the progress bar
    seek_range = true,                      -- show seek range overlay
    seek_range_alpha = 175,                 -- transparency of the seek range
    seekbar_keyframes = false,              -- use keyframes when dragging the seekbar

    automatic_keyframe_mode = true,         -- automatically set keyframes for the seekbar based on video length
    automatic_keyframe_limit = 600,         -- videos longer than this (in seconds) will have keyframes on the seekbar

    persistent_progress_default = false,    -- always show a small progress line at the bottom of the screen
    persistent_progress_height = 17,        -- height of the persistent_progress bar
    persistent_buffer = false,              -- show the buffer on the persistent progress line
    persistent_progress_toggle = true,      -- enable toggling the persistent_progress bar

    -- Web videos
    title_youtube_stats = true,             -- update the window/OSC title bar with YouTube video stats (views, comments, likes)
    ytdl_format = "",                       -- optional parameteres for yt-dlp downloading, eg: '-f bestvideo+bestaudio/best'

    -- sponsorblock features need https://github.com/zydezu/mpvconfig/blob/main/scripts/sponsorblock.lua to work!
    show_sponsorblock_segments = true,      -- show sponsorblock segments on the progress bar
    add_sponsorblock_chapters = false,      -- add sponsorblock chapters to the chapter list
    sponsorblock_seek_range_alpha = 75,     -- transparency of sponsorblock segments
    sponsor_types = {                       -- what categories to show in the progress bar
        "sponsor",                          -- all categories: sponsor, intro, outro,
        "intro",                            -- interaction, selfpromo, preview, music_offtopic, filler
        "outro",
        "interaction",
        "selfpromo",
        "preview",
        "music_offtopic",
        "filler"
    },
    sponsorblock_sponsor_color = "#00D400", -- color for sponsors
    sponsorblock_intro_color = "#00FFFF",   -- color for intermission/intro animations
    sponsorblock_outro_color = "#0202ED",   -- color for endcards/credits
    sponsorblock_interaction_color = "#CC00FF", -- color for interaction reminders (reminders to subscribe)
    sponsorblock_selfpromo_color = "#FFFF00", -- color for unpaid/self promotion
    sponsorblock_preview_color = "#008FD6", -- color for unpaid/self promotion
    sponsorblock_music_offtopic_color = "#FF9900", -- color for unpaid/self promotion
    sponsorblock_filler_color = "#7300FF",  -- color for filler tangent/jokes

    -- Experimental
    show_youtube_comments = false,          -- EXPERIMENTAL - show youtube comments
    comments_download_path = "~~Pictures/mpv/downloads/comments", -- EXPERIMENTAL - the download path for the comment JSON file
    FORCE_fix_not_ontop = true,             -- EXPERIMENTAL - try and mitigate https://github.com/zydezu/ModernX/issues/30, https://github.com/akiirui/mpv-handler/issues/48
}
-- read options from config and command-line
require("mp.options").read_options(user_opts, 'modernx', function(list) update_options(list) end)

mp.observe_property("osc", "bool", function(name, value) if value == true then mp.set_property("osc", "no") end end)

local osc_param = {                         -- calculated by osc_init()
    playresy = 0,                           -- canvas size Y
    playresx = 0,                           -- canvas size X
    display_aspect = 1,
    unscaled_y = 0,
    areas = {},
}

local icons = {
    play = "\238\166\143",
    pause = "\238\163\140",
    replay = "\238\189\191",
    previous = "\239\152\167",
    next = "\239\149\168",
    rewind = "\238\168\158",
    forward = "\238\152\135",

    audio = "\238\175\139",
    subtitle = "\238\175\141",
    volume_mute = "\238\173\138",
    volume_quiet = "\238\172\184",
    volume_low = "\238\172\189",
    volume_high = "\238\173\130",

    download = "\239\133\144",
    downloading = "\239\140\174",
    loop_off = "\239\133\178",
    loop_on = "\239\133\181",
    info = "\239\146\164",
    ontop_on = "\238\165\190",
    ontop_off = "\238\166\129",
    screenshot = "\239\154\142",
    fullscreen = "\239\133\160",
    fullscreen_exit = "\239\133\166",

    jumpicons = {
        [5] = {"\238\171\186", "\238\171\187"},
        [10] = {"\238\171\188", "\238\172\129"},
        [30] = {"\238\172\133", "\238\172\134"},
        default = {"\238\172\138", "\238\172\138"}, -- second icon is mirrored in layout()
    },

    emoticon = {
        view = "👁️",
        comment = "💬",
        like = "👍"
    },

    playlist = "\238\161\159", -- unused rn
}

-- Localization
local language = {
    ['en'] = {
        welcome = 'Drop files or URLs here to play',  -- this text appears when mpv starts
        off = 'OFF',
        na = 'Not available',
        none = 'None available',
        video = 'Video',
        audio = 'Audio',
        subtitle = 'Subtitle',
        nosub = 'No subtitles available',
        noaudio = 'No audio tracks available',
        track = ' tracks:',
        playlist = 'Playlist',
        playlistshuffled = 'Shuffled playlist',
        nolist = 'Playlist is empty',
        chapter = 'Chapter',
        nochapter = 'No chapters available',
        ontop = 'Pin window',
        ontopdisable = 'Unpin window',
        loopenable = 'Looping enabled',
        loopdisable = 'Looping disabled',
        screenshot = "Screenshot",
        statsinfo = "Information",
        download = "Download",
        download_in_progress = "Download in progress",
        downloading = "Downloading",
        downloaded = "Already downloaded",
    }
}

-- apply lang opts
local texts = language[user_opts.language] or language["en"]

local function contains(list, item)
    local t = {}
    if type(list) ~= "table" then
        for str in string.gmatch(list, '([^,]+)') do
            str = str:gsub("%s+", "")
            table.insert(t, str)
        end
    else
        t = list
    end
    for _, v in ipairs(t) do
        if v == item then
            return true
        end
    end
    return false
end

-- debug function
local function dumptable(o)
    if type(o) == 'table' then
       local s = '{ '
       for k,v in pairs(o) do
          if type(k) ~= 'number' then k = '"'..k..'"' end
          s = s .. '['..k..'] = ' .. dumptable(v) .. ','
       end
       return s .. '} '
    else
       return tostring(o)
    end
end

local thumbfast = {
    width = 0,
    height = 0,
    disabled = true,
    available = false
}

local sponsorblock_color_map = {
    sponsor = user_opts.sponsorblock_sponsor_color,
    intro = user_opts.sponsorblock_intro_color,
    outro = user_opts.sponsorblock_outro_color,
    interaction = user_opts.sponsorblock_interaction_color,
    selfpromo = user_opts.sponsorblock_selfpromo_color,
    preview = user_opts.sponsorblock_preview_color,
    music_offtopic = user_opts.sponsorblock_music_offtopic_color,
    filler = user_opts.sponsorblock_filler_color
}

local tick_delay = 1 / 60 -- Fallback
local audio_track_count = 0 -- TODO: implement
local sub_track_count = 0 -- TODO: implement
local window_control_box_width = 138
local max_descsize = 200
local comments_per_page = 25
local is_december = os.date("*t").month == 12
local UNICODE_MINUS = string.char(0xe2, 0x88, 0x92)  -- UTF-8 for U+2212 MINUS SIGN
local iconfont = 'fluent-system-icons'

local function osc_color_convert(color)
    return color:sub(6,7) .. color:sub(4,5) ..  color:sub(2,3)
end

local playpause_size = user_opts.playpause_size or 30
local midbuttons_size = user_opts.midbuttons_size or 24
local sidebuttons_size = user_opts.sidebuttons_size or 24
local osc_styles = {
    osc_fade_bg = "{\\blur" .. user_opts.fade_blur_strength .. "\\bord" .. user_opts.fade_alpha .. "\\1c&H0&\\3c&H" .. osc_color_convert(user_opts.osc_color) .. "&}",
    window_fade_bg = "{\\blur" .. user_opts.window_fade_blur_strength .. "\\bord" .. user_opts.window_fade_alpha .. "\\1c&H0&\\3c&H" .. osc_color_convert(user_opts.osc_color) .. "&}",
    chapter_title = "{\\blur0\\bord0\\1c&H" .. osc_color_convert(user_opts.chapter_title_color) .. "&\\3c&H000000&\\fs" .. user_opts.time_font_size .. "\\fn" .. user_opts.font .. "}",
    control_1 = "{\\blur0\\bord0\\1c&H" .. osc_color_convert(user_opts.playpause_color) .. "&\\3c&HFFFFFF&\\fs" .. playpause_size .. "\\fn" .. iconfont .. "}",
    control_2 = "{\\blur0\\bord0\\1c&H" .. osc_color_convert(user_opts.middle_buttons_color) .. "&\\3c&HFFFFFF&\\fs" .. midbuttons_size .. "\\fn" .. iconfont .. "}",
    control_2_flip = "{\\blur0\\bord0\\1c&H" .. osc_color_convert(user_opts.middle_buttons_color) .. "&\\3c&HFFFFFF&\\fs" .. midbuttons_size .. "\\fn" .. iconfont .. "\\fry180}",
    control_3 = "{\\blur0\\bord0\\1c&H" .. osc_color_convert(user_opts.side_buttons_color) .. "&\\3c&HFFFFFF&\\fs" .. sidebuttons_size .. "\\fn" .. iconfont .. "}",
    element_down = "{\\1c&H" .. osc_color_convert(user_opts.held_element_color) .. "&}",
    element_hover = "{" .. (contains(user_opts.hover_effect, "color") and "\\1c&H" .. osc_color_convert(user_opts.hover_effect_color) .. "&" or "") .."\\2c&HFFFFFF&" .. (contains(user_opts.hover_effect, "size") and string.format("\\fscx%s\\fscy%s", user_opts.hover_button_size, user_opts.hover_button_size) or "") .. "}",
    seekbar_bg = "{\\blur0\\bord0\\1c&H" .. osc_color_convert(user_opts.seekbarbg_color) .. "&}",
    seekbar_fg = "{\\blur1\\bord1\\1c&H" .. osc_color_convert(user_opts.seekbarfg_color) .. "&}",
    thumbnail = "{\\blur0\\bord1\\1c&H" .. osc_color_convert(user_opts.thumbnail_border_color) .. "&\\3c&H" .. osc_color_convert(user_opts.thumbnail_border_outline) .. "&}",
    time = "{\\blur0\\bord0\\1c&H" .. osc_color_convert(user_opts.time_color) .. "&\\3c&H000000&\\fs" .. user_opts.time_font_size .. "\\fn" .. user_opts.font .. "}",
    title = "{\\blur1\\bord0.5\\1c&H" .. osc_color_convert(user_opts.title_color) .. "&\\3c&H0&\\fs".. user_opts.title_font_size .."\\q2\\fn" .. user_opts.font .. "}",
    tooltip = "{\\blur1\\bord0.5\\1c&HFFFFFF&\\3c&H000000&\\fs" .. user_opts.time_font_size .. "\\fn" .. user_opts.font .. "}",
    volumebar_bg = "{\\blur0\\bord0\\1c&H999999&}",
    volumebar_fg = "{\\blur1\\bord1\\1c&H" .. osc_color_convert(user_opts.side_buttons_color) .. "&}",
    window_control = "{\\blur1\\bord0.5\\1c&H" .. osc_color_convert(user_opts.window_controls_color) .. "&\\3c&H0&\\fs18\\fnmpv-osd-symbols}",
    window_title = "{\\blur1\\bord0.5\\1c&H" .. osc_color_convert(user_opts.window_title_color) .. "&\\3c&H0&\\fs19\\q2\\fn" .. user_opts.font .. "}",
    description = '{\\blur1\\bord0.5\\1c&HFFFFFF&\\3c&H000000&\\fs'.. user_opts.description_font_size ..'\\q2\\fn' .. user_opts.font .. '}',
}

-- internal states, do not touch
local state = {
    showtime = nil,                         -- time of last invocation (last mouse move)
    osc_visible = false,
    anistart = nil,                         -- time when the animation started
    anitype = nil,                          -- current type of animation
    animation = nil,                        -- current animation alpha
    mouse_down_counter = 0,                 -- used for softrepeat
    active_element = nil,                   -- nil = none, 0 = background, 1+ = see elements[]
    active_event_source = nil,              -- the "button" that issued the current event
    tc_right_rem = not user_opts.time_total, -- if the right timecode should display total or remaining time
    fulltime = user_opts.time_ms,
    mp_screen_sizeX = nil, mp_screen_sizeY = nil, -- last screen-resolution, to detect resolution changes to issue reINITs
    initREQ = false,                        -- is a re-init request pending?
    last_mouseX = nil, last_mouseY = nil,   -- last mouse position, to detect significant mouse movement
    mouse_in_window = false,
    fullscreen = false,
    tick_timer = nil,
    tick_last_time = 0,                     -- when the last tick() was run
    hide_timer = nil,
    cache_state = nil,
    buffering = false,
    idle = false,
    enabled = true,
    input_enabled = true,
    showhide_enabled = false,

    border = true,
    title_bar = true,
    maximized = false,
    osd = mp.create_osd_overlay('ass-events'),
    new_file_flag = false,                  -- flag to detect new file starts
    chapter_list = {},                      -- sorted by time
    mute = false,
    looping = false,
    sliderpos = 0,
    touchingprogressbar = false,            -- if the mouse is touching the progress bar
    initialborder = mp.get_property('border'),
    playingWhilstSeeking = false,
    playingWhilstSeekingWaitingForEnd = false,
    persistent_progresstoggle = user_opts.persistent_progress_default,

    downloaded_once = false,
    downloading = false,
    file_size_bytes = 0,
    file_size_normalized = "Approximating size...",
    is_URL = false,
    URL_path = "",                          -- used for yt-dlp downloading
    videoCantBeDownloaded = false, -- TODO: needs to be removed

    localDescription = nil,
    localDescriptionClick = nil,
    localDescriptionIsClickable = false,
    videoDescription = "",                  -- use if it is a YouTube video
    descriptionLoaded = false,
    showingDescription = false,
    scrolledlines = 25,
    youtubeuploader = "",
    jsoncomments= {},
    youtubecomments = {},
    commentsParsed = false,
    currentCommentIndex = 0,
    commentsPage = 0,
    maxCommentPages = 0,
    commentsAdditionalText = "",
    is_live = false,

    sponsor_segments = {},

    message_text = nil, -- TODO: needs to be removed
    message_hide_timer = nil, -- TODO: needs to be removed
}

local logo_lines = {
    -- White border
    "{\\c&HE5E5E5&\\p6}m 895 10 b 401 10 0 410 0 905 0 1399 401 1800 895 1800 1390 1800 1790 1399 1790 905 1790 410 1390 10 895 10 {\\p0}",
    -- Purple fill
    "{\\c&H682167&\\p6}m 925 42 b 463 42 87 418 87 880 87 1343 463 1718 925 1718 1388 1718 1763 1343 1763 880 1763 418 1388 42 925 42{\\p0}",
    -- Darker fill
    "{\\c&H430142&\\p6}m 1605 828 b 1605 1175 1324 1456 977 1456 631 1456 349 1175 349 828 349 482 631 200 977 200 1324 200 1605 482 1605 828{\\p0}",
    -- White fill
    "{\\c&HDDDBDD&\\p6}m 1296 910 b 1296 1131 1117 1310 897 1310 676 1310 497 1131 497 910 497 689 676 511 897 511 1117 511 1296 689 1296 910{\\p0}",
    -- Triangle
    "{\\c&H691F69&\\p6}m 762 1113 l 762 708 b 881 776 1000 843 1119 911 1000 978 881 1046 762 1113{\\p0}",
}

local santa_hat_lines = {
    -- Pompoms
    "{\\c&HC0C0C0&\\p6}m 500 -323 b 491 -322 481 -318 475 -311 465 -312 456 -319 446 -318 434 -314 427 -304 417 -297 410 -290 404 -282 395 -278 390 -274 387 -267 381 -265 377 -261 379 -254 384 -253 397 -244 409 -232 425 -228 437 -228 446 -218 457 -217 462 -216 466 -213 468 -209 471 -205 477 -203 482 -206 491 -211 499 -217 508 -222 532 -235 556 -249 576 -267 584 -272 584 -284 578 -290 569 -305 550 -312 533 -309 523 -310 515 -316 507 -321 505 -323 503 -323 500 -323{\\p0}",
    "{\\c&HE0E0E0&\\p6}m 315 -260 b 286 -258 259 -240 246 -215 235 -210 222 -215 211 -211 204 -188 177 -176 172 -151 170 -139 163 -128 154 -121 143 -103 141 -81 143 -60 139 -46 125 -34 129 -17 132 -1 134 16 142 30 145 56 161 80 181 96 196 114 210 133 231 144 266 153 303 138 328 115 373 79 401 28 423 -24 446 -73 465 -123 483 -174 487 -199 467 -225 442 -227 421 -232 402 -242 384 -254 364 -259 342 -250 322 -260 320 -260 317 -261 315 -260{\\p0}",
    -- Main cap
    "{\\c&H0000F0&\\p6}m 1151 -523 b 1016 -516 891 -458 769 -406 693 -369 624 -319 561 -262 526 -252 465 -235 479 -187 502 -147 551 -135 588 -111 1115 165 1379 232 1909 761 1926 800 1952 834 1987 858 2020 883 2053 912 2065 952 2088 1000 2146 962 2139 919 2162 836 2156 747 2143 662 2131 615 2116 567 2122 517 2120 410 2090 306 2089 199 2092 147 2071 99 2034 64 1987 5 1928 -41 1869 -86 1777 -157 1712 -256 1629 -337 1578 -389 1521 -436 1461 -476 1407 -509 1343 -507 1284 -515 1240 -519 1195 -521 1151 -523{\\p0}",
    -- Cap shadow
    "{\\c&H0000AA&\\p6}m 1657 248 b 1658 254 1659 261 1660 267 1669 276 1680 284 1689 293 1695 302 1700 311 1707 320 1716 325 1726 330 1735 335 1744 347 1752 360 1761 371 1753 352 1754 331 1753 311 1751 237 1751 163 1751 90 1752 64 1752 37 1767 14 1778 -3 1785 -24 1786 -45 1786 -60 1786 -77 1774 -87 1760 -96 1750 -78 1751 -65 1748 -37 1750 -8 1750 20 1734 78 1715 134 1699 192 1694 211 1689 231 1676 246 1671 251 1661 255 1657 248 m 1909 541 b 1914 542 1922 549 1917 539 1919 520 1921 502 1919 483 1918 458 1917 433 1915 407 1930 373 1942 338 1947 301 1952 270 1954 238 1951 207 1946 214 1947 229 1945 239 1939 278 1936 318 1924 356 1923 362 1913 382 1912 364 1906 301 1904 237 1891 175 1887 150 1892 126 1892 101 1892 68 1893 35 1888 2 1884 -9 1871 -20 1859 -14 1851 -6 1854 9 1854 20 1855 58 1864 95 1873 132 1883 179 1894 225 1899 273 1908 362 1910 451 1909 541{\\p0}",
    -- Brim and tip pompom
    "{\\c&HF8F8F8&\\p6}m 626 -191 b 565 -155 486 -196 428 -151 387 -115 327 -101 304 -47 273 2 267 59 249 113 219 157 217 213 215 265 217 309 260 302 285 283 373 264 465 264 555 257 608 252 655 292 709 287 759 294 816 276 863 298 903 340 972 324 1012 367 1061 394 1125 382 1167 424 1213 462 1268 482 1322 506 1385 546 1427 610 1479 662 1510 690 1534 725 1566 752 1611 796 1664 830 1703 880 1740 918 1747 986 1805 1005 1863 991 1897 932 1916 880 1914 823 1945 777 1961 725 1979 673 1957 622 1938 575 1912 534 1862 515 1836 473 1790 417 1755 351 1697 305 1658 266 1633 216 1593 176 1574 138 1539 116 1497 110 1448 101 1402 77 1371 37 1346 -16 1295 15 1254 6 1211 -27 1170 -62 1121 -86 1072 -104 1027 -128 976 -133 914 -130 851 -137 794 -162 740 -181 679 -168 626 -191 m 2051 917 b 1971 932 1929 1017 1919 1091 1912 1149 1923 1214 1970 1254 2000 1279 2027 1314 2066 1325 2139 1338 2212 1295 2254 1238 2281 1203 2287 1158 2282 1116 2292 1061 2273 1006 2229 970 2206 941 2167 938 2138 918{\\p0}",
}

--
-- Helper functions
--

local function kill_animation()
    state.anistart = nil
    state.animation = nil
    state.anitype =  nil
end

local function set_osd(res_x, res_y, text, z)
    if state.osd.res_x == res_x and
       state.osd.res_y == res_y and
       state.osd.data == text then
        return
    end
    state.osd.res_x = res_x
    state.osd.res_y = res_y
    state.osd.data = text
    state.osd.z = z
    state.osd:update()
end

-- scale factor for translating between real and virtual ASS coordinates
local function get_virt_scale_factor()
    local w, h = mp.get_osd_size()
    if w <= 0 or h <= 0 then
        return 0, 0
    end
    return osc_param.playresx / w, osc_param.playresy / h
end

-- return mouse position in virtual ASS coordinates (playresx/y)
local function get_virt_mouse_pos()
    if state.mouse_in_window then
        local sx, sy = get_virt_scale_factor()
        local x, y = mp.get_mouse_pos()
        return x * sx, y * sy
    else
        return -1, -1
    end
end

local function set_virt_mouse_area(x0, y0, x1, y1, name)
    local sx, sy = get_virt_scale_factor()
    mp.set_mouse_area(x0 / sx, y0 / sy, x1 / sx, y1 / sy, name)
end

local function scale_value(x0, x1, y0, y1, val)
    local m = (y1 - y0) / (x1 - x0)
    local b = y0 - (m * x0)
    return (m * val) + b
end

-- returns hitbox spanning coordinates (top left, bottom right corner)
-- according to alignment
local function get_hitbox_coords(x, y, an, w, h)
    local alignments = {
      [1] = function () return x, y-h, x+w, y end,
      [2] = function () return x-(w/2), y-h, x+(w/2), y end,
      [3] = function () return x-w, y-h, x, y end,

      [4] = function () return x, y-(h/2), x+w, y+(h/2) end,
      [5] = function () return x-(w/2), y-(h/2), x+(w/2), y+(h/2) end,
      [6] = function () return x-w, y-(h/2), x, y+(h/2) end,

      [7] = function () return x, y, x+w, y+h end,
      [8] = function () return x-(w/2), y, x+(w/2), y+h end,
      [9] = function () return x-w, y, x, y+h end,
    }

    return alignments[an]()
end

local function get_hitbox_coords_geo(geometry)
    return get_hitbox_coords(geometry.x, geometry.y, geometry.an,
        geometry.w, geometry.h)
end

local function get_element_hitbox(element)
    return element.hitbox.x1, element.hitbox.y1,
        element.hitbox.x2, element.hitbox.y2
end

local function mouse_hit_coords(bX1, bY1, bX2, bY2)
    local mX, mY = get_virt_mouse_pos()
    return (mX >= bX1 and mX <= bX2 and mY >= bY1 and mY <= bY2)
end

local function mouse_hit(element)
    return mouse_hit_coords(get_element_hitbox(element))
end

local function limit_range(min, max, val)
    if val > max then
        val = max
    elseif val < min then
        val = min
    end
    return val
end

-- translate value into element coordinates
local function get_slider_ele_pos_for(element, val)
    local ele_pos = scale_value(
        element.slider.min.value, element.slider.max.value,
        element.slider.min.ele_pos, element.slider.max.ele_pos,
        val)

    return limit_range(
        element.slider.min.ele_pos, element.slider.max.ele_pos,
        ele_pos)
end

-- translates global (mouse) coordinates to value
local function get_slider_value_at(element, glob_pos)
    if element then
        local val = scale_value(
            element.slider.min.glob_pos, element.slider.max.glob_pos,
            element.slider.min.value, element.slider.max.value,
            glob_pos)

        return limit_range(
            element.slider.min.value, element.slider.max.value,
            val)
    end
    -- fall back incase of loading errors
    return 0
end

-- get value at current mouse position
local function get_slider_value(element)
    return get_slider_value_at(element, get_virt_mouse_pos())
end

-- multiplies two alpha values, formular can probably be improved
local function mult_alpha(alphaA, alphaB)
    return 255 - (((1-(alphaA/255)) * (1-(alphaB/255))) * 255)
end

local function add_area(name, x1, y1, x2, y2)
    -- create area if needed
    if osc_param.areas[name] == nil then
        osc_param.areas[name] = {}
    end
    table.insert(osc_param.areas[name], {x1=x1, y1=y1, x2=x2, y2=y2})
end

local function ass_append_alpha(ass, alpha, modifier, inverse)
    local ar = {}

    for ai, av in pairs(alpha) do
        av = mult_alpha(av, modifier)
        if state.animation then
            local animpos = state.animation
            if inverse then
                animpos = 255 - animpos
            end
            av = mult_alpha(av, animpos)
        end
        ar[ai] = av
    end

    ass:append(string.format("{\\1a&H%X&\\2a&H%X&\\3a&H%X&\\4a&H%X&}",
               ar[1], ar[2], ar[3], ar[4]))
end

local function ass_draw_cir_cw(ass, x, y, r)
    ass:round_rect_cw(x-r, y-r, x+r, y+r, r)
end

local function ass_draw_rr_h_cw(ass, x0, y0, x1, y1, r1, hexagon, r2)
    if hexagon then
        ass:hexagon_cw(x0, y0, x1, y1, r1, r2)
    else
        ass:round_rect_cw(x0, y0, x1, y1, r1, r2)
    end
end

local function get_hide_timeout()
    if user_opts.visibility == "always" then
        return -1 -- disable autohide
    end
    return user_opts.hide_timeout
end

-- Request that tick() is called (which typically re-renders the OSC).
-- The tick is then either executed immediately, or rate-limited if it was
-- called a small time ago.
local function request_tick()
    if state.tick_timer == nil then
        state.tick_timer = mp.add_timeout(0, tick)
    end

    if not state.tick_timer:is_enabled() then
        local now = mp.get_time()
        local timeout = tick_delay - (now - state.tick_last_time)
        if timeout < 0 then
            timeout = 0
        end
        state.tick_timer.timeout = timeout
        state.tick_timer:resume()
    end
end

local function request_init()
    state.initREQ = true
    request_tick()
end

-- Like request_init(), but also request an immediate update
local function request_init_resize()
    request_init()
    -- ensure immediate update
    state.tick_timer:kill()
    state.tick_timer.timeout = 0
    state.tick_timer:resume()
end

local function render_wipe()
    mp.msg.trace('render_wipe()')
    state.osd.data = "" -- allows set_osd to immediately update on enable
    state.osd:remove()
end

--
-- Tracklist Management
--

local valid_types = {video = texts.video, audio = texts.audio, sub = texts.subtitle}
local tracks_osc, tracks_mpv

-- updates the OSC internal playlists, should be run each time the track-layout changes
function update_tracklist()
    local tracktable = mp.get_property_native('track-list', {})

    -- by osc_id
    tracks_osc = {}
    tracks_osc.video, tracks_osc.audio, tracks_osc.sub = {}, {}, {}
    -- by mpv_id
    tracks_mpv = {}
    tracks_mpv.video, tracks_mpv.audio, tracks_mpv.sub = {}, {}, {}
    for n = 1, #tracktable do
        if not (tracktable[n].type == 'unknown') then
            local type = tracktable[n].type
            local mpv_id = tonumber(tracktable[n].id)

            -- by osc_id
            table.insert(tracks_osc[type], tracktable[n])

            -- by mpv_id
            tracks_mpv[type][mpv_id] = tracktable[n]
            tracks_mpv[type][mpv_id].osc_id = #tracks_osc[type]
        end
    end
end

-- return a nice list of tracks of the given type (video, audio, sub)
function get_tracklist(type)
    update_tracklist()

    local message = valid_types[type] .. texts.track
    if not tracks_osc or #tracks_osc[type] == 0 then
        message = texts.none
    else
        for n = 1, #tracks_osc[type] do
            local track = tracks_osc[type][n]
            local lang, title, selected = 'unknown', '', '○'
            if not(track.lang == nil) then lang = track.lang end
            if not(track.title == nil) then title = track.title end
            if (track.id == tonumber(mp.get_property(type))) then
                selected = '●'
            end
            message = message..'\n'..selected..' '..n..': ['..lang..'] '..title
        end
    end
    return message
end

-- relatively change the track of given <type> by <next> tracks
    --(+1 -> next, -1 -> previous)
function set_track(type, next)
    local current_track_mpv, current_track_osc
    current_track_osc = 0
    if (mp.get_property(type) == 'no') then
        current_track_osc = 0
    else
        current_track_mpv = tonumber(mp.get_property(type))
        if (tracks_mpv[type][current_track_mpv]) then
            current_track_osc = tracks_mpv[type][current_track_mpv].osc_id
        end
    end
    local new_track_osc = (current_track_osc + next) % (#tracks_osc[type] + 1)
    local new_track_mpv
    if new_track_osc == 0 then
        new_track_mpv = 'no'
    else
        new_track_mpv = tracks_osc[type][new_track_osc].id
    end

    mp.commandv('set', type, new_track_mpv)
end

-- get the currently selected track of <type>, OSC-style counted
function get_track(type)
    local track = mp.get_property(type)
    if track ~= 'no' and track ~= nil then
        local tr = tracks_mpv[type][tonumber(track)]
        if tr then
            return tr.osc_id
        end
    end
    return 0
end

-- convert slider_pos to logarithmic depending on volume_control user_opts
local function set_volume(slider_pos)
    local volume = slider_pos
    if user_opts.volume_control_type == "logarithmic" then
        volume = slider_pos^2 / 100
    end
    return math.floor(volume)
end

-- WindowControl helpers
function window_controls_enabled()
    local val = user_opts.window_top_bar
    if val == 'auto' then
        return (not state.border) or (not state.title_bar) or state.fullscreen
    else
        return val ~= 'no'
    end
end

--
-- Element Management
--
local elements = {}

local function prepare_elements()
    -- remove elements without layout or invisible
    local elements2 = {}
    for _, element in pairs(elements) do
        if element.layout ~= nil and element.visible then
            table.insert(elements2, element)
        end
    end
    elements = elements2

    local function elem_compare (a, b)
        return a.layout.layer < b.layout.layer
    end

    table.sort(elements, elem_compare)

    for _,element in pairs(elements) do

        local elem_geo = element.layout.geometry

        -- Calculate the hitbox
        local bX1, bY1, bX2, bY2 = get_hitbox_coords_geo(elem_geo)
        element.hitbox = {x1 = bX1, y1 = bY1, x2 = bX2, y2 = bY2}

        local style_ass = mp.assdraw.ass_new()

        -- prepare static elements
        style_ass:append("{}") -- hack to troll new_event into inserting a \n
        style_ass:new_event()
        style_ass:pos(elem_geo.x, elem_geo.y)
        style_ass:an(elem_geo.an)
        style_ass:append(element.layout.style)

        element.style_ass = style_ass

        local static_ass = mp.assdraw.ass_new()

        if element.type == "box" then
            --draw box
            static_ass:draw_start()
            ass_draw_rr_h_cw(static_ass, 0, 0, elem_geo.w, elem_geo.h,
                             element.layout.box.radius, element.layout.box.hexagon)
            static_ass:draw_stop()

        elseif element.type == "slider" then
            --draw static slider parts
            local slider_lo = element.layout.slider
            -- calculate positions of min and max points
            element.slider.min.ele_pos = user_opts.seek_handle_size * elem_geo.h / 2
            element.slider.max.ele_pos = elem_geo.w - element.slider.min.ele_pos
            element.slider.min.glob_pos = element.hitbox.x1 + element.slider.min.ele_pos
            element.slider.max.glob_pos = element.hitbox.x1 + element.slider.max.ele_pos

            static_ass:draw_start()

            -- a hack which prepares the whole slider area to allow center placements such like an=5
            static_ass:rect_cw(0, 0, elem_geo.w, elem_geo.h)
            static_ass:rect_ccw(0, 0, elem_geo.w, elem_geo.h)
            -- chapter marker nibbles
            if user_opts.show_chapter_markers and element.slider.markerF ~= nil and slider_lo.gap > 0 then
                local markers = element.slider.markerF()
                for _, marker in pairs(markers) do
                    if marker >= element.slider.min.value and marker <= element.slider.max.value then
                        local s = get_slider_ele_pos_for(element, marker)
                        if slider_lo.gap > 5 then -- draw triangles
                            --top
                            if slider_lo.nibbles_top then
                                static_ass:move_to(s - 3, slider_lo.gap - 5)
                                static_ass:line_to(s + 3, slider_lo.gap - 5)
                                static_ass:line_to(s, slider_lo.gap - 1)
                            end
                            --bottom
                            if slider_lo.nibbles_bottom then
                                static_ass:move_to(s - 3, elem_geo.h - slider_lo.gap + 5)
                                static_ass:line_to(s, elem_geo.h - slider_lo.gap + 1)
                                static_ass:line_to(s + 3, elem_geo.h - slider_lo.gap + 5)
                            end
                        else -- draw 2x1px nibbles
                            --top
                            if slider_lo.nibbles_top then
                                static_ass:rect_cw(s - 1, 0, s + 1, slider_lo.gap);
                            end
                            --bottom
                            if slider_lo.nibbles_bottom then
                                static_ass:rect_cw(s - 1, elem_geo.h - slider_lo.gap, s + 1, elem_geo.h);
                            end
                        end
                    end
                end
            end
        end

        element.static_ass = static_ass

        -- if the element is supposed to be disabled,
        -- style it accordingly and kill the eventresponders
        if not element.enabled then
            element.layout.alpha[1] = 215
            if not (element.name == "sub_track" or element.name == "audio_track" or element.name == "tog_playlist") then -- keep these to display tooltips
                element.eventresponder = nil
            end
        end

        -- gray out the element if it is toggled off
        if element.off then
            element.layout.alpha[1] = 100
        end
    end
end

--
-- Element Rendering
--

-- returns nil or a chapter element from the native property chapter-list
function get_chapter(possec)
    local cl = state.chapter_list  -- sorted, get latest before possec, if any

    for n=#cl,1,-1 do
        if possec >= cl[n].time then
            return cl[n]
        end
    end
end

local function draw_seekbar_handle(element, elem_ass, override_alpha)
    local pos = element.slider.posF()
    if not pos then
        return 0, 0
    end
    local display_handle = user_opts.seek_handle_size > 0
    local elem_geo = element.layout.geometry
    local rh = display_handle and (user_opts.seek_handle_size * elem_geo.h / 2) or 0 -- handle radius
    local xp = get_slider_ele_pos_for(element, pos) -- handle position
    local handle_hovered = mouse_hit_coords(element.hitbox.x1 + xp - rh, element.hitbox.y1 + elem_geo.h / 2 - rh, element.hitbox.x1 + xp + rh, element.hitbox.y1 + elem_geo.h / 2 + rh) and element.enabled

    if display_handle then
        -- Apply size hover_effect only if hovering over the handle
        if handle_hovered and user_opts.hover_effect_for_sliders then
            if contains(user_opts.hover_effect, "size") then
                rh = rh * (user_opts.hover_button_size / 100)
            end
        end

        ass_draw_cir_cw(elem_ass, xp, elem_geo.h / 2, rh)

        if user_opts.hover_effect_for_sliders then
            elem_ass:draw_stop()
            elem_ass:merge(element.style_ass)
            ass_append_alpha(elem_ass, element.layout.alpha, override_alpha or 0)
            elem_ass:merge(element.static_ass)
        end

        return xp, rh
    end
    return xp, 0
end

-- Draw seekbar progress more accurately
local function draw_seekbar_progress(element, elem_ass)
    local pos = element.slider.posF()
    if not pos then
        return
    end
    local xp = get_slider_ele_pos_for(element, pos)
    local slider_lo = element.layout.slider
    local elem_geo = element.layout.geometry
    elem_ass:rect_cw(0, slider_lo.gap, xp, elem_geo.h - slider_lo.gap)
end

-- Draws seekbar ranges according to user_opts
local function draw_seekbar_ranges(element, elem_ass, xp, rh, override_alpha)
    local handle = xp and rh
    xp = xp or 0
    rh = rh or 0
    local slider_lo = element.layout.slider
    local elem_geo = element.layout.geometry
    local seekRanges = element.slider.seek_rangesF()
    if not seekRanges then
        return
    end
    elem_ass:draw_stop()
    elem_ass:merge(element.style_ass)
    ass_append_alpha(elem_ass, element.layout.alpha, override_alpha or user_opts.seek_range_alpha)
    elem_ass:append("{\\1cH&" .. osc_color_convert(user_opts.seekbar_cache_color) .. "&}")
    elem_ass:merge(element.static_ass)

    for _, range in pairs(seekRanges) do
        local pstart = math.max(0, get_slider_ele_pos_for(element, range["start"]) - slider_lo.gap)
        local pend = math.min(elem_geo.w, get_slider_ele_pos_for(element, range["end"]) + slider_lo.gap)

        if handle and (pstart < xp + rh and pend > xp - rh) then
            if pstart < xp - rh then
                elem_ass:rect_cw(pstart, slider_lo.gap, xp - rh, elem_geo.h - slider_lo.gap)
            end
            pstart = xp + rh
        end

        if pend > pstart then
            elem_ass:rect_cw(pstart, slider_lo.gap, pend, elem_geo.h - slider_lo.gap)
        end
    end
end

local function draw_sponsorblock_ranges(element, elem_ass, xp, rh)
    local function set_draw_color(color, value, slider_lo, elem_geo)
        elem_ass:draw_stop()
        elem_ass:merge(element.style_ass)
        ass_append_alpha(elem_ass, element.layout.alpha, user_opts.sponsorblock_seek_range_alpha)
        elem_ass:append("{\\1cH&" .. osc_color_convert(color) .. "&}")
        elem_ass:merge(element.static_ass)

        for _, range in pairs(value) do
            local pstart = get_slider_ele_pos_for(element, range["start"])
            local pend = get_slider_ele_pos_for(element, range["end"])
            elem_ass:rect_cw(pstart - rh, slider_lo.gap, pend + rh, elem_geo.h - slider_lo.gap)
        end
    end

    if not user_opts.show_sponsorblock_segments then
        return
    end

    local handle = xp and rh
    xp = xp or 0
    rh = rh or 0
    local slider_lo = element.layout.slider
    local elem_geo = element.layout.geometry

    local temp = elem_ass

    for key, value in pairs(state.sponsor_segments) do
        elem_ass = temp

        local color = sponsorblock_color_map[key]
        if color then
            set_draw_color(color, value, slider_lo, elem_geo)
        end
    end
end

function render_elements(master_ass)
    -- when the slider is dragged or hovered and we have a target chapter name
    -- then we use it instead of the normal title. we calculate it before the
    -- render iterations because the title may be rendered before the slider.
    state.forced_title = nil

    -- disable displaying chapter name in title when thumbfast is available
    -- because thumbfast will render it above the thumbnail instead
    if thumbfast.disabled then
        if user_opts.chapter_fmt ~= "no" and state.touchingprogressbar then
            local dur = mp.get_property_number("duration", 0)
            if dur > 0 then
                local ch = get_chapter(state.sliderpos * dur / 100)
                if ch and ch.title and ch.title ~= "" then
                    state.forced_title = string.format(user_opts.chapter_fmt, ch.title)
                end
            end
        end
    end
    state.touchingprogressbar = false

    for n=1, #elements do
        local element = elements[n]
        local style_ass = mp.assdraw.ass_new()
        style_ass:merge(element.style_ass)
        ass_append_alpha(style_ass, element.layout.alpha, 0)

        if element.eventresponder and (state.active_element == n) then
            -- run render event functions
            if not (element.eventresponder.render == nil) then
                element.eventresponder.render(element)
            end
            if mouse_hit(element) then
                -- mouse down styling
                if (element.styledown) then
                    style_ass:append(osc_styles.element_down)
                end
                if (element.softrepeat) and (state.mouse_down_counter >= 15
                    and state.mouse_down_counter % 5 == 0) then

                    element.eventresponder[state.active_event_source..'_down'](element)
                end
                state.mouse_down_counter = state.mouse_down_counter + 1
            end
        end

        local elem_ass = mp.assdraw.ass_new()
        elem_ass:merge(style_ass)

        if not (element.type == 'button') then
            elem_ass:merge(element.static_ass)
        end

        if element.type == "slider" then
            if element.name ~= "persistentseekbar" then
                local slider_lo = element.layout.slider
                local elem_geo = element.layout.geometry
                local s_min = element.slider.min.value
                local s_max = element.slider.max.value

                local xp, rh = draw_seekbar_handle(element, elem_ass) -- handle posistion, handle radius
                draw_seekbar_progress(element, elem_ass)
                if element.name == "seekbar" then
                    draw_seekbar_ranges(element, elem_ass, xp, rh)
                    draw_sponsorblock_ranges(element, elem_ass, xp, rh)
                end

                elem_ass:draw_stop()

                -- add tooltip
                if element.slider.tooltipF ~= nil and element.enabled then
                    if mouse_hit(element) then
                        local sliderpos = get_slider_value(element)
                        local tooltiplabel = element.slider.tooltipF(sliderpos)
                        local an = slider_lo.tooltip_an
                        local ty
                        if (an == 2) then
                            ty = element.hitbox.y1
                        else
                            ty = element.hitbox.y1 + elem_geo.h/2
                        end

                        local tx = get_virt_mouse_pos()
                        if (slider_lo.adjust_tooltip) then
                            if an == 2 then
                                if sliderpos < (s_min + 3) then
                                    an = an - 1
                                elseif sliderpos > (s_max - 3) then
                                    an = an + 1
                                end
                            elseif (sliderpos > (s_max+s_min)/2) then
                                an = an + 1
                                tx = tx - 5
                            else
                                an = an - 1
                                tx = tx + 10
                            end
                        end

                        if element.name == "seekbar" then
                            state.sliderpos = sliderpos
                        end

                        -- thumbfast
                        if element.thumbnailable and not thumbfast.disabled then
                            local osd_w = mp.get_property_number("osd-width")
                            local r_w, r_h = get_virt_scale_factor()

                            if osd_w then
                                local hover_sec = 0
                                if mp.get_property_number("duration") then hover_sec = mp.get_property_number("duration") * sliderpos / 100 end
                                local thumbPad = user_opts.thumbnail_border
                                local thumbMarginX = 18 / r_w
                                local thumbMarginY = user_opts.time_font_size + thumbPad + 2 / r_h
                                local thumbX = math.min(osd_w - thumbfast.width - thumbMarginX, math.max(thumbMarginX, tx / r_w - thumbfast.width / 2))
                                local thumbY = (ty - thumbMarginY) / r_h - thumbfast.height

                                thumbX = math.floor(thumbX + 0.5)
                                thumbY = math.floor(thumbY + 0.5)

                                if state.anitype == nil then
                                    elem_ass:new_event()
                                    elem_ass:append("{\\rDefault}")
                                    elem_ass:pos(thumbX * r_w, ty - thumbMarginY - thumbfast.height * r_h)
                                    elem_ass:an(7)
                                    elem_ass:append(osc_styles.thumbnail)
                                    elem_ass:draw_start()
                                    if user_opts.thumbnail_border_radius and user_opts.thumbnail_border_radius > 0 then
                                        elem_ass:round_rect_cw(-thumbPad * r_w, -thumbPad * r_h, (thumbfast.width + thumbPad) * r_w, (thumbfast.height + thumbPad) * r_h, user_opts.thumbnail_border_radius)
                                    else
                                        elem_ass:rect_cw(-thumbPad * r_w, -thumbPad * r_h, (thumbfast.width + thumbPad) * r_w, (thumbfast.height + thumbPad) * r_h)
                                    end
                                    elem_ass:draw_stop()

                                    -- force tooltip to be centered on the thumb, even at far left/right of screen
                                    tx = (thumbX + thumbfast.width / 2) * r_w
                                    an = 2

                                    mp.commandv("script-message-to", "thumbfast", "thumb", hover_sec, thumbX, thumbY)
                                end


                                -- chapter title
                                if user_opts.chapter_fmt ~= "no" and state.touchingprogressbar then
                                    local dur = mp.get_property_number("duration", 0)
                                    if dur > 0 then
                                        local ch = get_chapter(state.sliderpos * dur / 100)
                                        if ch and ch.title and ch.title ~= "" then
                                            elem_ass:new_event()
                                            elem_ass:pos((thumbX + thumbfast.width / 2) * r_w, thumbY * r_h - user_opts.time_font_size / 2)
                                            elem_ass:an(an)
                                            elem_ass:append(slider_lo.tooltip_style)
                                            ass_append_alpha(elem_ass, slider_lo.alpha, 0)
                                            elem_ass:append(string.format(user_opts.chapter_fmt, ch.title))
                                        end
                                    end
                                end
                            end
                        end

                        -- tooltip label
                        elem_ass:new_event()
                        elem_ass:pos(tx, ty)
                        elem_ass:an(an)
                        elem_ass:append(slider_lo.tooltip_style)
                        ass_append_alpha(elem_ass, slider_lo.alpha, 0)
                        elem_ass:append(tooltiplabel)
                    elseif element.thumbnailable and thumbfast.available then
                        mp.commandv("script-message-to", "thumbfast", "clear")
                    end
                end
            end

        elseif (element.type == "button") then
            local buttontext
            if type(element.content) == "function" then
                buttontext = element.content() -- function objects
            elseif element.content ~= nil then
                buttontext = element.content -- text objects
            end
            buttontext = buttontext:gsub(":%((.?.?.?)%) unknown ", ":%(%1%)")  --gsub('%) unknown %(\'', '')

            local maxchars = element.layout.button.maxchars
            if not (maxchars == nil) and (#buttontext > maxchars) then
                local max_ratio = 1.25  -- up to 25% more chars while shrinking
                local limit = math.max(0, math.floor(maxchars * max_ratio) - 3)
                if (#buttontext > limit) then
                    while (#buttontext > limit) do
                        buttontext = buttontext:gsub(".[\128-\191]*$", "")
                    end
                    buttontext = buttontext .. "..."
                end
                local _, nchars2 = buttontext:gsub(".[\128-\191]*", "")
                local stretch = (maxchars/#buttontext)*100
                buttontext = string.format("{\\fscx%f}",
                    (maxchars/#buttontext)*100) .. buttontext
            end

            -- add hover effects
            local button_lo = element.layout.button
            local is_clickable = element.eventresponder and (
                element.eventresponder["mbtn_left_down"] ~= nil or
                element.eventresponder["mbtn_left_up"] ~= nil
            )
            local hovered = mouse_hit(element) and is_clickable and element.enabled and state.mouse_down_counter == 0
            local hoverstyle = button_lo.hoverstyle
            if hovered and (contains(user_opts.hover_effect, "size") or contains(user_opts.hover_effect, "color")) then
                -- remove font scale tags for these elements, it looks out of place
                if element.name == "title" or element.name == "description" or element.name == "tc_left" or element.name == "tc_right" or element.name == "chapter_title" then
                    hoverstyle = hoverstyle:gsub("\\fscx%d+\\fscy%d+", "")
                end
                elem_ass:append(hoverstyle .. buttontext)
            else
                elem_ass:append(buttontext)
            end

            -- apply blur effect if "glow" is in hover effects
            if hovered and contains(user_opts.hover_effect, "glow") then
                local shadow_ass = mp.assdraw.ass_new()
                shadow_ass:merge(style_ass)
                shadow_ass:append("{\\blur" .. user_opts.button_glow_amount .. "}" .. hoverstyle .. buttontext)
                elem_ass:merge(shadow_ass)
            end

            -- add tooltip for audio and subtitle tracks
            if not (element.tooltipF == nil) then
                if mouse_hit(element) then
                    local tooltiplabel = element.tooltipF
                    local an = 1
                    local ty = element.hitbox.y1
                    local tx = get_virt_mouse_pos()

                    if ty < osc_param.playresy / 2 then
                        ty = element.hitbox.y2
                        an = 7
                    end

                    -- tooltip label
                    if element.enabled then
                        if type(element.tooltipF) == 'function' then
                            tooltiplabel = element.tooltipF()
                        else
                            tooltiplabel = element.tooltipF
                        end
                    else
                        tooltiplabel = element.nothingavailable
                    end

                    if tx > osc_param.playresx / 2 then --move tooltip to left side of mouse cursor
                        tx = tx - string.len(tooltiplabel) * 8
                    end

                    elem_ass:new_event()
                    elem_ass:pos(tx, ty)
                    elem_ass:an(an)
                    elem_ass:append(element.tooltip_style)
                    elem_ass:append(tooltiplabel)
                end
            end
        end

        master_ass:merge(elem_ass)
    end
end

function render_persistent_progressbar(master_ass)
    for n=1, #elements do
        local element = elements[n]
        if element.name == "persistentseekbar" then
            local style_ass = mp.assdraw.ass_new()
            style_ass:merge(element.style_ass)
            if state.animation or not state.osc_visible then
                ass_append_alpha(style_ass, element.layout.alpha, 0, true)

                local elem_ass = mp.assdraw.ass_new()
                elem_ass:merge(style_ass)
                if element.type ~= "button" then
                    elem_ass:merge(element.static_ass)
                end

                -- draw pos marker
                draw_seekbar_progress(element, elem_ass)

                if user_opts.persistent_buffer then
                    draw_seekbar_ranges(element, elem_ass, nil, nil)
                end

                elem_ass:draw_stop()
                master_ass:merge(elem_ass)
            end
        end
    end
end

--
-- Message display
--

-- pos is 1 based
function limited_list(prop, pos)
    local proplist = mp.get_property_native(prop, {})
    local count = #proplist
    if count == 0 then
        return count, proplist
    end

    local fs = tonumber(mp.get_property('options/osd-font-size'))
    local max = math.ceil(osc_param.unscaled_y * 1.25 / fs)
    if max % 2 == 0 then
        max = max - 1
    end
    local delta = math.ceil(max / 2) - 1
    local begi = math.max(math.min(pos - delta, count - max + 1), 1)
    local endi = math.min(begi + max - 1, count)

    local reslist = {}
    for i=begi, endi do
        local item = proplist[i]
        item.current = (i == pos) and true or nil
        table.insert(reslist, item)
    end
    return count, reslist
end

local function set_tick_delay(_, display_fps)
    -- may be nil if unavailable or 0 fps is reported
    if not display_fps then
        return
    end
    tick_delay = 1 / display_fps
end

local function newfilereset()
    request_init()
    state.downloaded_once = false
    state.videoDescription = "Loading description..."
    state.file_size_normalized = "Approximating size..."
    state.localDescription = "Loading..."
    state.localDescriptionIsClickable = false
    state.localDescriptionClick = "Loading..."
    if is_url(mp.get_property("path")) then
        mp.set_property("title", "Loading...")
    end
end

local function startupevents()
    state.new_file_flag = true
    set_tick_delay("display_fps", mp.get_property_number("display_fps"))
    state.videoDescription = "Loading description..."
    state.file_size_normalized = "Approximating size..."
    check_path_url()
    check_title()
    if user_opts.automatic_keyframe_mode then
        if mp.get_property_number("duration", 0) > user_opts.automatic_keyframe_limit then
            user_opts.seekbar_keyframes = true
        else
            user_opts.seekbar_keyframes = false
        end
     end
    destroy_scrolling_keys() -- close description

    if user_opts.FORCE_fix_not_ontop and state.is_URL then
        mp.commandv("cycle", "ontop")
        mp.commandv("cycle", "ontop")
    end

    mp.set_property_bool("auto-window-resize", false)
end

function check_title()
    local mediatitle = mp.get_property("media-title")
    mp.set_property("title", mediatitle or "")

    if (mp.get_property("filename") ~= mediatitle) and user_opts.dynamic_title then
        user_opts.title = "${media-title}"
    end

    -- fake description using metadata
    state.localDescription = nil
    state.localDescriptionClick = nil

    local title = mp.get_property("media-title")
    local artist = mp.get_property("filtered-metadata/by-key/Album_Artist") or mp.get_property("filtered-metadata/by-key/Artist") or mp.get_property("filtered-metadata/by-key/Uploader")
    local tempartistclicktext = "Contributing artists: " .. (artist or "")

    if (mp.get_property("filtered-metadata/by-key/Album_Artist") and mp.get_property("filtered-metadata/by-key/Artist")) then
        if (mp.get_property("filtered-metadata/by-key/Album_Artist") ~= mp.get_property("filtered-metadata/by-key/Artist")) then
            artist = mp.get_property("filtered-metadata/by-key/Artist") .. ", " .. mp.get_property("filtered-metadata/by-key/Album_Artist")
            tempartistclicktext = "Contributing artists: " .. mp.get_property("filtered-metadata/by-key/Artist") .. "\\NAlbum artist: " .. mp.get_property("filtered-metadata/by-key/Album_Artist")
        end
    end
    local album = mp.get_property("filtered-metadata/by-key/Album")
    local description = mp.get_property("filtered-metadata/by-key/Description")
    local date = mp.get_property("filtered-metadata/by-key/Date")

    state.ytdescription = ""
    state.youtubeuploader = artist

    local metadata = mp.get_property_native('metadata')

    if metadata then
        state.ytdescription = metadata.ytdl_description or description or ""
        state.ytdescription = state.ytdescription:gsub('\r', '\\N'):gsub('\n', '\\N'):gsub("%%", "%%")

        state.is_live = metadata.ytdl_is_live
    else
        print("Failed to load metadata")
    end

    if user_opts.show_description then
        if (title) then
            if (#state.ytdescription > 1) then
                state.localDescriptionClick = title .. "\\N────────────────────\\N" .. state.ytdescription .. "\\N────────────────────\\N"

                local utf8split, lastchar = split_utf8_strings(state.ytdescription, max_descsize)

                if #utf8split ~= #state.ytdescription then
                    local tmp = utf8split:gsub("[,%.%s]+$", "")
                    utf8split = tmp .. "..."
                end
                utf8split = utf8split:match("^(.-)%s*$")
                local artisttext = state.is_URL and "By: " or "Uploaded by: "
                if artist then
                    utf8split = artisttext .. artist .. " | " .. utf8split
                    state.localDescriptionClick = state.localDescriptionClick ..  artisttext .. artist
                end
                state.descriptionLoaded = true
                state.videoDescription = utf8split:gsub("\r", ""):gsub("\n", " ")
                state.localDescription = state.videoDescription
            else
                state.localDescriptionClick = title .. "\\N────────────────────\\N"
            end
        end
        if (artist ~= nil) then
            if (state.localDescription == nil) then
                state.localDescription = artist
                state.localDescriptionClick = state.localDescriptionClick .. tempartistclicktext
                state.localDescriptionIsClickable = true
            end
        end
        if (album ~= nil) then
            if (state.localDescription == nil) then -- only metadata
                state.localDescription = "Album: " .. album
                state.localDescriptionClick = state.localDescriptionClick .. state.localDescription
                state.localDescriptionIsClickable = true
            else -- append to other metadata
                if (state.localDescriptionClick ~= nil) then
                    state.localDescriptionClick = state.localDescriptionClick .. "\\NAlbum: " .. album .. "\\N"
                else
                    state.localDescriptionClick = album
                    state.localDescriptionIsClickable = true
                end
                state.localDescription = state.localDescription .. " (" .. album .. ")"
            end
        end
        if (date ~= nil) then
            local datenormal = normalize_date(date)
            local datetext = "Year"
            if (#datenormal > 4) then datetext = "Date" end
            if (state.localDescription == nil) then -- only metadata
                state.localDescription = datetext .. ": " .. datenormal
                state.localDescriptionClick = state.localDescriptionClick .. state.localDescription
                state.localDescriptionIsClickable = true
            else -- append to other metadata
                if (state.localDescriptionClick ~= nil) then
                    state.localDescriptionClick = state.localDescriptionClick .. "\\N" .. datetext .. ": " .. datenormal
                else
                    state.localDescriptionClick = datenormal
                    state.localDescriptionIsClickable = true
                end
                if (artist ~= nil and datetext == "Year") then
                    state.localDescription = state.localDescription .. " (" .. datenormal .. ")"
                end
                -- state.localDescription = state.localDescription .. " | " ..  datetext .. ": " .. datenormal
            end
        end

        if (user_opts.show_file_size) then
            local file_size = mp.get_property_native("file-size")
            local resinfo = ""
            if (file_size ~= nil) then
                file_size = mp.utils.format_bytes_humanized(file_size)
                if (state.localDescription == nil) then -- only metadata
                    state.localDescription = "Size: " .. file_size .. " " .. resinfo
                    state.localDescriptionClick = state.localDescriptionClick .. state.localDescription
                    state.localDescriptionIsClickable = true
                else
                    state.localDescriptionClick = state.localDescriptionClick .. "\\NSize: " .. file_size .. " " .. resinfo
                end
            end
        end
    end
end

function shuffle_playlist()
    mp.commandv("playlist-shuffle")
    show_message(get_playlist(true))
end

function normalize_date(date)
    date = string.gsub(date:gsub("/", ""), "-", "")
    local date_table
    if string.find(date:sub(1,8), ":") then
        return date
    end
    if (#date > 8) then -- YYYYMMDD HHMMSS (plus a time)
        date_table = {year = date:sub(1,4), month = date:sub(5,6), day = date:sub(7,8)}
        return os.date(user_opts.date_format, os.time(date_table)) .. date:sub(9)
    elseif (#date > 4) then -- YYYYMMDD
        date_table = {year = date:sub(1,4), month = date:sub(5,6), day = date:sub(7,8)}
        return os.date(user_opts.date_format, os.time(date_table))
    else -- YYYY
        return date
    end
end

function exec_async(args, callback)
    local ret = mp.command_native_async({
        name = "subprocess",
        args = args,
        capture_stdout = true,
        capture_stderr = true
    }, callback)

    return ret and ret.status or nil
end

function is_url(s)
    if not s then
        user_opts.download_button = false
        return false
    end

    local url_pattern = "^[%w]+://[%w%.%-_]+%.[%a]+[-%w%.%-%_/?&=]*"
    return string.match(s, url_pattern) ~= nil
end

function check_path_url()
    state.is_URL = false
    state.downloading = false

    state.youtubecomments = {}
    state.commentsParsed = false
    state.currentCommentIndex = 0
    state.commentsPage = 0
    state.maxCommentPages = 0

    local path = mp.get_property("path")
    if not path then return nil end

    if string.find(path, "https://") then
        path = string.gsub(path, "ytdl://", "") -- Remove "ytdl://" prefix
    else
        path = string.gsub(path, "ytdl://", "https://") -- Replace "ytdl://" with "https://"
    end

    if is_url(path) and path or nil then
        state.is_URL = true
        state.url_path = path
        mp.msg.info("URL detected.")

        if not (path:match("https?://(www%.youtube%.com/watch%?v=.+)") or path:match("https?://youtu%.be/.+")) then
            user_opts.download_button = false
            user_opts.show_youtube_comments = false
            user_opts.is_youtube = false
        else
            user_opts.is_youtube = true
        end

        if user_opts.download_button then
            mp.msg.info("Fetching file size...")
            local command = {
                "yt-dlp",
                "--no-download",
                "-O",
                "%(filesize,filesize_approx)s", -- Fetch file size or approximate size
                path
            }
            exec_async(command, process_filesize)
        end

        if user_opts.show_description then
            if not user_opts.is_youtube then
                local file_size = mp.get_property_native("file-size")
                file_size = mp.utils.format_bytes_humanized(file_size)
                state.videoDescription = "Size: " .. file_size
                state.descriptionLoaded = true
                return
            end

            mp.msg.info("[WEB] Loading video description...")
            local command = {
                "yt-dlp",
                "--no-download",
                "-O Views: %(view_count)s\nComments: %(comment_count)s\nLikes: %(like_count)s",
                state.url_path
            }
            exec_async(command, process_vid_stats)
        end

        if user_opts.show_youtube_comments then
            mp.msg.info("[WEB] Downloading comments...")
            check_comments()
        end
    end
end

function check_comments()
    local function file_exists(file)
        local f = io.open(file, "rb")
        if f then f:close() end
        return f ~= nil
    end

    local function lines_from(file)
        if not file_exists(file) then return {} end
        local lines = {}
        for line in io.lines(file) do
            lines[#lines + 1] = line
        end
        return lines
    end

    mp.command_native_async({
        name = "subprocess",
        args = {
            "yt-dlp",
            "--skip-download",
            "--write-comments",
            "-o%(id)s",
            "-P " .. mp.command_native({"expand-path", user_opts.comments_download_path}),
            state.url_path
        },
        capture_stdout = true,
        capture_stderr = true
    }, function(success, result, error)
        if not success then
            print("[WEB] Couldn't write youtube comments: " .. error)
            return
        end

        local filename = ""
        local file_prop = mp.get_property("filename")
        local comments_path = user_opts.comments_download_path or ""

        if file_prop then            
            mp.msg.info("[WEB] Downloaded comments")

            -- clean file name
            local clean_name = file_prop:gsub("watch%?v=", "")
            clean_name = clean_name:match("^[^%?&]+") or clean_name

            -- create the file path
            local base_path = mp.command_native({"expand-path", comments_path .. '/'}) or ""
            filename = base_path .. clean_name .. ".info.json"        else
            mp.msg.info("[WEB] Comments failed to download...")
            return
        end

        if file_exists(filename) then
            mp.msg.info("[WEB] Reading comments file...")
            local lines = lines_from(filename)
            state.jsoncomments = mp.utils.parse_json(lines[1]).comments
        else
            mp.msg.info("[WEB] Error opening comments file")
            return
        end
        state.maxCommentPages = math.ceil(#state.jsoncomments / comments_per_page)
        if (#state.jsoncomments > 0) then
            state.commentsParsed = true
        else
            user_opts.show_youtube_comments = false -- prevent crash when viewing comments
        end
        if state.showingDescription then
            show_description(state.localDescriptionClick)
        end
        mp.msg.info("[WEB] Read and parsed comments")
    end )
end

function load_set_of_comments(startIndex)
    if (#state.jsoncomments < 1) then
        return
    end

    state.commentDescription = ""
    for i=startIndex, #state.jsoncomments do
        if i > startIndex + (comments_per_page - 1) then
            state.currentCommentIndex = i
            break
        end

        local comment = state.jsoncomments[i]
        local commentconstruction = comment.author

        local linebreak = ''
        if (i ~= startIndex) then
            linebreak = '\\N'
        end
        if (comment.parent ~= "root") then
            commentconstruction = linebreak .. "\\N | " .. commentconstruction  .. " (Replying) | "
        else
            if (linebreak == '\\N') then
                commentconstruction = linebreak .. '-----\\N' .. commentconstruction .. ' | '
            else
                commentconstruction = '\\N' .. commentconstruction .. ' | '
            end
        end

        if (comment._time_text) then
            commentconstruction = commentconstruction .. comment._time_text
        end
        if (comment.is_favorited) then
            commentconstruction = commentconstruction .. (comment.is_favorited and ' | Favorited ♡\\N')
        end
        if (comment.is_pinned) then
            commentconstruction = commentconstruction .. (comment.is_pinned and ' | Pinned 📌\\N')
        else
            commentconstruction = commentconstruction .. '\\N'
        end

        local replyPad = ""
        if (comment.parent ~= "root") then
            replyPad = " | "
            commentconstruction = commentconstruction .. replyPad .. comment.text:gsub('\n', '\\N' .. replyPad)
        else
            commentconstruction = commentconstruction .. comment.text
        end

        if (comment.like_count) then
            local likeText = " likes"
            if (comment.like_count == 1) then
                likeText = " like"
            end
            commentconstruction = commentconstruction .. '\\N' .. replyPad .. comment.like_count .. likeText
        else
            commentconstruction = commentconstruction ..  '\\N' .. replyPad ..  "0 likes"
        end
        state.youtubecomments[i] = commentconstruction
        state.commentDescription = state.commentDescription .. commentconstruction
    end
end

function process_filesize(success, result, error)
    if not success then
        print("[WEB] Couldn't fetch video filesize: " .. error)
        return
    end

    local fileSizeString = result.stdout
    state.file_size_bytes = tonumber(fileSizeString)

    if state.file_size_bytes then
        state.file_size_normalized = mp.utils.format_bytes_humanized(state.file_size_bytes)
        mp.msg.info("[WEB] Download size: " .. state.file_size_normalized)
    else
        local fs_prop = mp.get_property_osd("file-size")
        if fs_prop and fs_prop ~= "" then
            state.file_size_normalized = fs_prop
            mp.msg.info(fs_prop)
        else
            state.file_size_normalized = "Unknown"
            mp.msg.info("[WEB] Unable to retrieve file size")
        end
    end

    request_tick()
end

local function download_done(success, result, error)
    if success then
        show_message("{\\an9}[WEB] Download saved to " .. mp.command_native({"expand-path", user_opts.download_path}))
        state.downloaded_once = true
        mp.msg.info("[WEB] Download completed")
    else
        show_message("{\\an9}[WEB] Download failed - " .. (error or "Unknown error"))
        mp.msg.info("[WEB] Download failed")
    end
    state.downloading = false
end

function split_utf8_strings(str, maxLength)
    local result = {}
    local currentIndex = 1
    local length = #str
    local byteCount = 0
    local charCount = 0

    while currentIndex <= length do
        -- Check if the next characters are a \N escape sequence
        local nextTwo = string.sub(str, currentIndex, currentIndex + 1)
        if nextTwo == "\\N" then
            table.insert(result, nextTwo)
            currentIndex = currentIndex + 2
            -- \N does NOT count toward byte length
            goto continue
        end

        local byte = string.byte(str, currentIndex)
        local charLength

        if byte >= 0 and byte <= 127 then
            charLength = 1
        elseif byte >= 192 and byte <= 223 then
            charLength = 2
        elseif byte >= 224 and byte <= 239 then
            charLength = 3
        elseif byte >= 240 and byte <= 247 then
            charLength = 4
        else
            break -- invalid byte
        end

        local currentChar = string.sub(str, currentIndex, currentIndex + charLength - 1)

        if byteCount + charLength > maxLength then
            break
        end

        table.insert(result, currentChar)
        byteCount = byteCount + charLength
        currentIndex = currentIndex + charLength
        charCount = charCount + 1

        ::continue::
    end

    return table.concat(result), charCount
end

function process_vid_stats(success, result, error)
    if not success then
        print("[WEB] Couldn't fetch video stats: " .. error)
        return
    end

    add_like_count_to_title()

    if (state.localDescriptionClick:match('Views: (%d+)')) then
        state.localDescriptionClick = state.localDescriptionClick:gsub(state.localDescriptionClick:match('Views: (%d+)'), add_commas_to_number(state.localDescriptionClick:match('Views: (%d+)')))
    end
    if (state.localDescriptionClick:match('Likes: (%d+)')) then
        state.localDescriptionClick = state.localDescriptionClick:gsub(state.localDescriptionClick:match('Likes: (%d+)'), add_commas_to_number(state.localDescriptionClick:match('Likes: (%d+)')))
    end
    if (state.localDescriptionClick:match('Comments: (%d+)')) then
        state.localDescriptionClick = state.localDescriptionClick:gsub(state.localDescriptionClick:match('Comments: (%d+)'), add_commas_to_number(state.localDescriptionClick:match('Comments: (%d+)')))
    end

    state.localDescriptionClick = state.localDescriptionClick:gsub("Uploaded by: NA\\N", "")
    state.localDescriptionClick = state.localDescriptionClick:gsub("Uploaded: NA\\N", "")
    state.localDescriptionClick = state.localDescriptionClick:gsub("Views: NA\\N", "")
    state.localDescriptionClick = state.localDescriptionClick:gsub("Comments: NA\\N", "")
    state.localDescriptionClick = state.localDescriptionClick:gsub("Likes: NA\\N", "")
    state.localDescriptionClick = state.localDescriptionClick:gsub("Likes: NA", "")

    if false then
        state.localDescriptionClick = state.localDescriptionClick:gsub("Views:", icons.emoticon.view):gsub("Comments:", icons.emoticon.comment):gsub("Likes:", icons.emoticon.like)  -- replace with icons
    end

    if not state.ytdescription or #state.ytdescription < 5 then
        if mp.get_property_number("estimated-vf-fps") then
            state.videoDescription = mp.get_property("width") .. "x" .. mp.get_property("height") .. " | FPS: " ..
            (math.floor(mp.get_property_number("estimated-vf-fps") + 0.5) or "") -- can't get a normal description, display something else
        end
    end

    state.descriptionLoaded = true
    if state.showingDescription then
        show_description(state.localDescriptionClick)
    end
    mp.msg.info("[WEB] Loaded video description")
end

function add_commas_to_number(number)
    if number == nil then return '' end

    return tostring(number) -- Make sure the "number" is a string
       :reverse() -- Reverse the string
       :gsub('%d%d%d', '%0,') -- insert one comma after every 3 numbers
       :gsub(',$', '') -- Remove a trailing comma if present
       :reverse() -- Reverse the string again
       :sub(1) -- a little hack to get rid of the second return value
 end

function add_like_count_to_title()
    if (user_opts.show_description and user_opts.title_youtube_stats) then
        state.viewcount = add_commas_to_number(state.localDescriptionClick:match('Views: (%d+)'))
        state.likecount = add_commas_to_number(state.localDescriptionClick:match('Likes: (%d+)'))
        if (state.viewcount ~= '' and state.likecount ~= '') then
            mp.set_property("title", mp.get_property("media-title") ..
            " | " .. icons.emoticon.view .. state.viewcount ..
            " | " .. icons.emoticon.like .. state.likecount)
        end
    end
end

-- playlist and chapters --
function get_playlist(shuffled)
    local pos = mp.get_property_number('playlist-pos', 0) + 1
    local count, limlist = limited_list('playlist', pos)
    if count == 0 then
        return texts.nolist
    end

    local playlist_label = shuffled and (texts.playlistshuffled or texts.playlist .. " (shuffled)") or texts.playlist
    local message = string.format(playlist_label .. ' [%d/%d]\n', pos, count)
    for i, v in ipairs(limlist) do
        local title = v.title
        local _, filename = mp.utils.split_path(v.filename)
        if title == nil then
            title = filename
        end
        message = string.format('%s %s %s\n', message,
            (v.current and '●' or '○'), title)
    end
    return message
end

function get_chapterlist()
    local pos = mp.get_property_number('chapter', 0) + 1
    local count, limlist = limited_list('chapter-list', pos)
    if count == 0 then
        return texts.nochapter
    end

    local message = string.format(texts.chapter.. ' [%d/%d]:\n', pos, count)
    for i, v in ipairs(limlist) do
        local time = mp.format_time(v.time)
        local title = v.title
        if title == nil then
            title = string.format(texts.chapter .. ' %02d', i)
        end
        message = string.format('%s[%s] %s %s\n', message, time,
            (v.current and '●' or '○'), title)
    end
    return message
end

local function make_sponsorblock_segments()
    if not user_opts.show_sponsorblock_segments then return end
    if not state.is_URL then return end

    local sponsor_types = user_opts.sponsor_types

    state.sponsor_segments = {}
    local temp_segment = {}
    local is_start_added = false
    local current_category = ""

    local temp_chapters = mp.get_property_native("chapter-list")
    local duration = mp.get_property_number('duration', nil)

    if duration then
        for _, chapter in ipairs(temp_chapters) do
            if chapter.title then
                for _, value in ipairs(sponsor_types) do
                    if string.find(string.lower(chapter.title), value) then
                        current_category = value

                        if not temp_segment[current_category] then
                            temp_segment[current_category] = {}
                        end
                        if not state.sponsor_segments[current_category] then
                            state.sponsor_segments[current_category] = {}
                        end
                    end
                end
                if string.find(chapter.title, ("start"):gsub("[%[%]]", "%%%1")) then
                    if not is_start_added then
                        temp_segment[current_category]["start"] = chapter.time / duration * 100
                        temp_segment[current_category]["is_start_added"] = true
                    end
                end
                if string.find(chapter.title, ("end"):gsub("[%[%]]", "%%%1")) then
                    if temp_segment[current_category]["is_start_added"] then
                        temp_segment[current_category]["end"] = chapter.time / duration * 100
                        if state.sponsor_segments ~= 2 then
                            temp_segment[current_category]["is_start_added"] = nil
                            -- table.sort(temp_segment[current_category], function(a, b) return a.time < b.time end)
                            table.insert(state.sponsor_segments[current_category], temp_segment[current_category])
                        end
                        temp_segment[current_category] = {}
                        is_start_added = false
                    end
                end
            end
        end
    end

    if not user_opts.add_sponsorblock_chapters then
        -- remove [SponsorBlock] chapters
        local updated_chapters = {}
        for _, chapter in ipairs(temp_chapters) do
            if not string.find(chapter.title, "%[SponsorBlock%]") then
                table.insert(updated_chapters, chapter)
            end
        end

        -- updated chapter list
        state.chapter_list = updated_chapters
        if #updated_chapters > 0 then
            mp.set_property_native("chapter-list", updated_chapters)
        end
    end

    print("Added SponsorBlock segments")
end

function show_message(text, duration)
    if state.showingDescription then
        destroy_scrolling_keys()
    end
    if duration == nil then
        duration = tonumber(mp.get_property('options/osd-duration')) / 1000
    elseif not type(duration) == 'number' then
        print('duration: ' .. duration)
    end

    -- cut the text short, otherwise the following functions
    -- may slow down massively on huge input
    text = string.sub(text, 0, 4000)

    -- replace actual linebreaks with ASS linebreaks
    text = string.gsub(text, '\n', '\\N')
    text = "\\N" .. text
    state.message_text = text

    if not state.message_hide_timer then
        state.message_hide_timer = mp.add_timeout(0, request_tick)
    end
    state.message_hide_timer:kill()
    state.message_hide_timer.timeout = duration
    state.message_hide_timer:resume()
    request_tick()
end

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

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

function destroy_scrolling_keys()
    state.showingDescription = false
    state.scrolledlines = 25
    show_message("", 0.01) -- clear text
    unbind_keys("UP WHEEL_UP", "move_up")
    unbind_keys("DOWN WHEEL_DOWN", "move_down")
    unbind_keys("ENTER MBTN_LEFT", "select")
    unbind_keys("ESC MBTN_RIGHT", "close")
    unbind_keys("LEFT", "comments_left")
    unbind_keys("RIGHT", "comments_right")
end

function check_description()
    if not user_opts.show_description then return end
    if state.descriptionLoaded or state.localDescriptionIsClickable then
        if state.showingDescription then
            state.showingDescription = false
            destroy_scrolling_keys()
        else
            state.showingDescription = true
            if state.is_URL then
                show_description(state.localDescriptionClick)
            else
                if state.localDescriptionClick == nil then
                    show_description(state.localDescription)
                else
                    show_description(state.localDescriptionClick)
                end
            end
        end
    end
end

function show_description(text)
    if state.is_URL and user_opts.show_youtube_comments then
        if state.commentsParsed and user_opts.show_youtube_comments then
            local pageText = "pages"
            if state.maxCommentPages == 1 then
                pageText = "page"
            end
            state.commentsAdditionalText = '\\N────────────────────\\NPress LEFT/RIGHT to view comments\\N' .. state.maxCommentPages .. ' ' .. pageText .. ' (' .. #state.jsoncomments .. ' comments)'
            text = text .. state.commentsAdditionalText
        else
            text = text .. '\\N────────────────────\\NComments loading...'
        end
    end
    text = string.gsub(text, '\n', '\\N')

    -- enable scrolling of menu --
    bind_keys("UP WHEEL_UP", "move_up", function()
        state.scrolledlines = state.scrolledlines + user_opts.scrolling_speed
        if (state.scrolledlines > 25) then
            state.scrolledlines = 25
        end
        reset_desc_timer()
        request_tick()
    end, { repeatable = true })
    bind_keys("DOWN WHEEL_DOWN", "move_down", function()
        state.scrolledlines = state.scrolledlines - user_opts.scrolling_speed
        reset_desc_timer()
        request_tick()
    end, { repeatable = true })
    bind_keys("ENTER", "select", destroy_scrolling_keys)
    bind_keys("ESC", "close", function()
        if (state.commentsPage > 0) then
            state.commentsPage = 0
            state.message_text = state.localDescriptionClick .. state.commentsAdditionalText
            reset_desc_timer()
            request_tick()
            state.scrolledlines = 25
        else
            destroy_scrolling_keys()
        end
    end) -- close menu using ESC

    local function returnMessageText()
        local totalCommentCount = #state.jsoncomments
        local firstCommentCount = (state.commentsPage - 1) * comments_per_page + 1
        local lastCommentCount = (state.commentsPage) * comments_per_page
        if lastCommentCount > totalCommentCount then
            lastCommentCount = totalCommentCount
        end
        load_set_of_comments(firstCommentCount)
        return 'Comments\\NPage ' .. state.commentsPage .. '/' .. state.maxCommentPages .. ' (' .. firstCommentCount .. '/' .. #state.jsoncomments .. ')\\N────────────────────\\N' .. state.commentDescription:gsub('\n', '\\N') ..  '\\N────────────────────\\NEnd of page\\NPage ' .. state.commentsPage .. '/' .. state.maxCommentPages .. ' (' .. lastCommentCount .. '/' .. totalCommentCount .. ')'
    end

    state.commentsPage = 0
    if (state.is_URL and user_opts.show_youtube_comments) then
        bind_keys("LEFT", "comments_left", function()
            if (state.commentsParsed) then
                state.commentsPage = state.commentsPage - 1
                if (state.commentsPage == 0) then
                    state.message_text = state.localDescriptionClick .. state.commentsAdditionalText
                elseif (state.commentsPage > 0) then
                    state.message_text = returnMessageText()
                else
                    state.commentsPage = state.maxCommentPages
                    state.message_text = returnMessageText()
                end
                state.scrolledlines = 25
            end
            reset_desc_timer()
            request_tick()
        end)
        bind_keys("RIGHT", "comments_right", function()
            if (state.commentsParsed) then
                state.commentsPage = state.commentsPage + 1
                if (state.commentsPage > state.maxCommentPages) then
                    state.commentsPage = 0
                    state.message_text = state.localDescriptionClick .. state.commentsAdditionalText
                else
                    state.message_text = returnMessageText()
                end
                state.scrolledlines = 25
            end
            reset_desc_timer()
            request_tick()
        end)
    end

    text = "\\N" .. text
    state.message_text = text

    if not state.message_hide_timer then
        state.message_hide_timer = mp.add_timeout(0, request_tick)
    end
    reset_desc_timer()
    request_tick()
end

function reset_desc_timer()
    state.message_hide_timer:kill()
    state.message_hide_timer.timeout = 10
    state.message_hide_timer:resume()
end

function render_message(ass)
    if state.message_hide_timer and state.message_hide_timer:is_enabled() and state.message_text then
        local _, lines = string.gsub(state.message_text, "\\N", "")

        local fontsize = tonumber(mp.get_property('options/osd-font-size'))
        local outline = tonumber(mp.get_property('options/osd-border-size'))
        local maxlines = math.ceil(osc_param.unscaled_y*0.75 / fontsize)
        local counterscale = osc_param.playresy / osc_param.unscaled_y

        if state.showingDescription then
            fontsize = fontsize * 0.85
            outline = outline * 0.85
        else
            fontsize = fontsize * counterscale / math.max(0.5 + math.min(lines/maxlines, 1), 1)
            outline = outline * counterscale / math.max(0.5 + math.min(lines/maxlines, 1)/2, 1)
        end

        if state.showingDescription then
            ass.text = string.format('{\\pos(0,0)\\an7\\1c&H000000&\\alpha&H%X&}', user_opts.description_alpha)
            ass:draw_start()
            ass:rect_cw(0, 0, osc_param.playresx, osc_param.playresy)
            ass:draw_stop()
            ass:new_event()
        end

        local style = '{\\bord' .. outline .. '\\fs' .. fontsize .. '}'

        ass:new_event()
        ass:append(style .. state.message_text)

        if state.showingDescription then
            ass:pos(20, state.scrolledlines)
        end
    else
        state.message_text = nil
        if state.showingDescription then destroy_scrolling_keys() end
    end
end

--
-- Initialisation and Layout
--

local function new_element(name, type)
    elements[name] = {}
    elements[name].type = type
    elements[name].name = name

    -- add default stuff
    elements[name].eventresponder = {}
    elements[name].visible = true
    elements[name].enabled = true
    elements[name].softrepeat = false
    elements[name].styledown = (type == "button")
    elements[name].state = {}

    if type == "slider" then
        elements[name].slider = {min = {value = 0}, max = {value = 100}}
        elements[name].thumbnailable = false
    end

    return elements[name]
end

local function add_layout(name)
    if elements[name] ~= nil then
        -- new layout
        elements[name].layout = {}

        -- set layout defaults
        elements[name].layout.layer = 50
        elements[name].layout.alpha = {[1] = 0, [2] = 255, [3] = 255, [4] = 255}

        if elements[name].type == "button" then
            elements[name].layout.button = {
                maxchars = nil,
                hoverstyle = osc_styles.element_hover,
            }
        elseif elements[name].type == "slider" then
            -- slider defaults
            elements[name].layout.slider = {
                border = 1,
                gap = 1,
                nibbles_top = user_opts.show_top_mark,
                nibbles_bottom = user_opts.show_bottom_mark,
                adjust_tooltip = true,
                tooltip_style = "",
                tooltip_an = 2,
                alpha = {[1] = 0, [2] = 255, [3] = 88, [4] = 255},
                hoverstyle = osc_styles.element_hover:gsub("\\fscx%d+\\fscy%d+", ""), -- font scales messes with handle positions in werid ways
            }
        elseif elements[name].type == "box" then
            elements[name].layout.box = {radius = 0, hexagon = false}
        end

        return elements[name].layout
    else
        mp.msg.error("Can't add_layout to element '"..name.."', doesn't exist.")
    end
end

-- Window Controls
function window_controls()
    local wc_geo = {
        x = 0,
        y = 30,
        an = 1,
        w = osc_param.playresx,
        h = 30
    }

    local controlbox_w = window_control_box_width

    -- Default alignment is 'right'
    local controlbox_left = wc_geo.w - controlbox_w

    add_area('window-controls',
             get_hitbox_coords(controlbox_left, wc_geo.y, wc_geo.an,
                               controlbox_w, wc_geo.h))

    local lo, ne

    local button_y = wc_geo.y - (wc_geo.h / 2)
    local first_geo =
        {x = controlbox_left + 30, y = button_y, an = 5, w = 40, h = wc_geo.h}
    local second_geo =
        {x = controlbox_left + 74, y = button_y, an = 5, w = 40, h = wc_geo.h}
    local third_geo =
        {x = controlbox_left + 118, y = button_y, an = 5, w = 40, h = wc_geo.h}

    -- Window control buttons use symbols in the custom mpv osd font
    -- because the official unicode codepoints are sufficiently
    -- exotic that a system might lack an installed font with them,
    -- and libass will complain that they are not present in the
    -- default font, even if another font with them is available.

    if user_opts.window_controls then
        -- Close: 🗙
        ne = new_element('close', 'button')
        ne.content = '\238\132\149'
        ne.eventresponder['mbtn_left_up'] =
            function () mp.commandv('quit') end
        lo = add_layout('close')
        lo.geometry = third_geo
        lo.style = osc_styles.window_control
        lo.button.hoverstyle = "{\\c&H" .. osc_color_convert(user_opts.window_controls_close_hover) .. "&}"

        -- Minimize: 🗕
        ne = new_element('minimize', 'button')
        ne.content = '\238\132\146'
        ne.eventresponder['mbtn_left_up'] =
            function () mp.commandv('cycle', 'window-minimized') end
        lo = add_layout('minimize')
        lo.geometry = first_geo
        lo.style = osc_styles.window_control
        lo.button.hoverstyle = "{\\c&H" .. osc_color_convert(user_opts.window_controls_minmax_hover) .. "&}"

        -- Maximize: 🗖/🗗
        ne = new_element('maximize', 'button')
        if state.maximized or state.fullscreen then
            ne.content = '\238\132\148'
        else
            ne.content = '\238\132\147'
        end
        ne.eventresponder['mbtn_left_up'] =
            function ()
                if state.fullscreen then
                    mp.commandv('cycle', 'fullscreen')
                else
                    mp.commandv('cycle', 'window-maximized')
                end
            end
        lo = add_layout('maximize')
        lo.geometry = second_geo
        lo.style = osc_styles.window_control
        lo.button.hoverstyle = "{\\c&H" .. osc_color_convert(user_opts.window_controls_minmax_hover) .. "&}"
    end

    -- Window Title
    if user_opts.window_title then
        ne = new_element("window_title", "button")
        ne.content = function ()
            local title = mp.command_native({"expand-text", user_opts.window_controls_title})
            -- escape ASS, and strip newlines and trailing slashes
            title = title:gsub("\\n", " "):gsub("\\$", ""):gsub("{","\\{")
            local titleval = not (title == "") and title or "mpv video"
            if (mp.get_property('ontop') == 'yes') then return "📌 " .. titleval end
            return titleval
        end
        lo = add_layout('window_title')

        local geo = {x = 12, y = button_y + 9, an = 1, w = osc_param.playresx - 150, h = wc_geo.h}

        lo.geometry = geo
        lo.style = string.format("%s{\\clip(0,%f,%f,%f)}", osc_styles.window_title,
                    geo.y - geo.h, geo.x + geo.w, geo.y + geo.h)

        -- lo.button.maxchars = geo.w / 10
    end
end

--
-- ModernX Layout
--

local layouts = {}

-- Default layout
layouts["original"] = function ()
    local osc_geo = {
        w = osc_param.playresx,
        h = 180
    }

    -- origin of the controllers, left/bottom corner
    local posX = 0
    local posY = osc_param.playresy

    osc_param.areas = {} -- delete areas

    -- area for active mouse input
    add_area('input', get_hitbox_coords(posX, posY, 1, osc_geo.w, osc_geo.h))

    -- area for show/hide
    add_area('showhide', 0, 0, osc_param.playresx, osc_param.playresy)

    -- fetch values
    local osc_w, osc_h = osc_geo.w, osc_geo.h

    -- Controller Background
    local lo, geo

    new_element('osc_fade_bg', 'box')
    lo = add_layout('osc_fade_bg')
    lo.geometry = {x = posX, y = posY, an = 7, w = osc_w, h = 1}
    lo.style = osc_styles.osc_fade_bg
    lo.layer = 10
    lo.alpha[3] = user_opts.fade_transparency_strength

    local top_titlebar = window_controls_enabled() and (user_opts.window_title or user_opts.window_controls)

    if (user_opts.window_top_bar == "yes" or (not state.border) or (not state.title_bar) or state.fullscreen) and top_titlebar then
        new_element("window_bar_alpha_bg", "box")
        lo = add_layout("window_bar_alpha_bg")
        lo.geometry = {x = posX, y = -70, an = 7, w = osc_w, h = -1}
        lo.style = osc_styles.window_fade_bg
        lo.layer = 10
        lo.alpha[3] = user_opts.window_fade_transparency_strength
    end

    -- Alignment
    local refX = osc_w / 2
    local refY = posY

    -- Seekbar
    new_element('seekbarbg', 'box')
    lo = add_layout('seekbarbg')
    if user_opts.seekbar_between_timers then
        lo.geometry = {x = refX , y = refY - 75, an = 5, w = osc_geo.w - 200, h = user_opts.seekbar_height}
    else
        lo.geometry = {x = refX , y = refY - 100, an = 5, w = osc_geo.w - 50, h = user_opts.seekbar_height}
    end
    lo.layer = 13
    lo.style = osc_styles.seekbar_bg
    lo.alpha[1] = 128
    lo.alpha[3] = 128

    lo = add_layout('seekbar')
    if user_opts.seekbar_between_timers then
        lo.geometry = {x = refX, y = refY - 75, an = 5, w = osc_geo.w - 200, h = user_opts.progress_bar_height}
    else
        lo.geometry = {x = refX, y = refY - 100, an = 5, w = osc_geo.w - 50, h = user_opts.progress_bar_height}
    end
    lo.style = osc_styles.seekbar_fg
    lo.slider.gap = 7
    lo.slider.tooltip_style = osc_styles.tooltip
    lo.slider.tooltip_an = 2
    lo.layer = 100

    if (user_opts.persistent_progress_default or user_opts.persistent_progress_toggle) then
        lo = add_layout('persistentseekbar')
        lo.geometry = {x = refX, y = refY, an = 5, w = osc_geo.w, h = user_opts.persistent_progress_height}
        lo.style = osc_styles.seekbar_fg
        lo.slider.gap = 7
        lo.slider.tooltip_an = 0
    end

    local jump_buttons = user_opts.jump_buttons
    local chapter_skip_buttons = user_opts.chapter_skip_buttons
    local track_nextprev_buttons = user_opts.track_nextprev_buttons

    local loop_button = user_opts.loop_button
    local info_button = user_opts.info_button
    local ontop_button = user_opts.ontop_button
    local screenshot_button = user_opts.screenshot_button

    if user_opts.compact_mode then
        user_opts.jump_buttons = false
        jump_buttons = false
    end
    local offset = jump_buttons and 60 or 0
    local outeroffset = (chapter_skip_buttons and 0 or 100) + (jump_buttons and 0 or 100)

    -- Title
    geo = {x = 25, y = refY - 117 + (((state.localDescription ~= nil or state.is_URL) and user_opts.show_description) and -20 or 0) + (user_opts.seekbar_between_timers and 25 or 0), an = 1, w = osc_geo.w - 50, h = 35}
    lo = add_layout("title")
    lo.geometry = geo
    lo.style = string.format("%s{\\clip(0,%f,%f,%f)}", osc_styles.title,
                             geo.y - geo.h, geo.x + geo.w, geo.y + geo.h)
    lo.alpha[3] = 0
    -- lo.button.maxchars = geo.w / 11

    -- Description
    if (state.localDescription ~= nil or state.is_URL) and user_opts.show_description then
        geo = {x = 25, y = refY - 117 + (user_opts.seekbar_between_timers and 25 or 0), an = 1, w = osc_geo.w - 50, h = 19}
        lo = add_layout("description")
        lo.geometry = geo

        lo.style = string.format("%s{\\clip(0,%f,%f,%f)}", osc_styles.description,
        geo.y - geo.h, geo.x + geo.w, geo.y + geo.h)

        lo.alpha[3] = 0
        -- lo.button.maxchars = geo.w / 7
    end

    -- Volumebar
    if user_opts.volume_control then
        lo = new_element("volumebarbg", "box")
        lo.visible = (osc_param.playresx >= 900 - outeroffset) and user_opts.volume_control
        lo = add_layout("volumebarbg")
        lo.geometry = {x = 155, y = refY - 40, an = 4, w = 80, h = 2}
        lo.layer = 13
        lo.alpha[1] = 128
        lo.style = user_opts.volumebar_match_seek_color and osc_styles.seekbar_bg or osc_styles.volumebar_bg

        lo = add_layout("volumebar")
        lo.geometry = {x = 155, y = refY - 40, an = 4, w = 80, h = 8}
        lo.style = user_opts.volumebar_match_seek_color and osc_styles.seekbar_fg or osc_styles.volumebar_fg
        lo.slider.gap = 3
        lo.slider.tooltip_style = osc_styles.tooltip
        lo.slider.tooltip_an = 2
    end

    -- buttons
    if track_nextprev_buttons then
        lo = add_layout('pl_prev')
        lo.geometry = {x = refX - (60 + (chapter_skip_buttons and 60 or 0)) - offset, y = refY - 40 , an = 5, w = 30, h = 24}
        lo.style = osc_styles.control_2
    end

    if chapter_skip_buttons then
        lo = add_layout('skipback')
        lo.geometry = {x = refX - 60 - offset, y = refY - 40 , an = 5, w = 30, h = 24}
        lo.style = osc_styles.control_2
    end

    if jump_buttons then
        lo = add_layout('jumpback')
        lo.geometry = {x = refX - 60, y = refY - 40 , an = 5, w = 30, h = 24}
        lo.style = osc_styles.control_2
    end

    lo = add_layout("play_pause")
    lo.geometry = {x = refX, y = refY - 40 , an = 5, w = 45, h = 45}
    lo.style = osc_styles.control_1

    if jump_buttons then
        lo = add_layout('jumpfrwd')
        lo.geometry = {x = refX + 60, y = refY - 40 , an = 5, w = 30, h = 24}
        -- HACK: jumpfrwd's icon must be mirrored for nonstandard # of seconds
        -- as the font only has an icon without a number for rewinding
        lo.style = (user_opts.jump_icon_number and icons.jumpicons[user_opts.jump_amount] ~= nil) and osc_styles.control_2 or osc_styles.control_2_flip
    end

    if chapter_skip_buttons then
        lo = add_layout('skipfrwd')
        lo.geometry = {x = refX + 60 + offset, y = refY - 40 , an = 5, w = 30, h = 24}
        lo.style = osc_styles.control_2
    end

    if track_nextprev_buttons then
        lo = add_layout('pl_next')
        lo.geometry = {x = refX + (60 + (chapter_skip_buttons and 60 or 0)) + offset, y = refY - 40 , an = 5, w = 30, h = 24}
        lo.style = osc_styles.control_2
    end

    -- Time
    local remsec = mp.get_property_number("playtime-remaining", 0)
    local possec = mp.get_property_number("playback-time", 0)
    local dur = mp.get_property_number("duration", 0)

    local show_hours = possec >= 3600 or user_opts.time_format ~= "dynamic"
    lo = add_layout("tc_left")
    lo.geometry = {x = 25, y = refY - 84, an = 7, w = 35 + (state.tc_ms and 30 or 0) + (show_hours and 20 or 0), h = 20}
    lo.style = osc_styles.time

    local show_remhours = (state.tc_right_rem and remsec >= 3600) or (not state.tc_right_rem and dur >= 3600) or user_opts.time_format ~= "dynamic"
    lo = add_layout("tc_right")
    lo.geometry = {x = osc_geo.w - 25 , y = refY -84, an = 9, w = 35 + (state.tc_ms and 30 or 0) + (show_remhours and 25 or 0), h = 20}
    lo.style = osc_styles.time

    -- Chapter Title (next to timestamp)
    if user_opts.show_chapter_title then
        lo = add_layout("separator")
        lo.geometry = {x = 65 + (state.tc_ms and 25 or 0) + (show_hours and 16 or 0), y = refY - 84, an = 7, w = 30, h = 20}
        lo.style = osc_styles.time

        lo = add_layout("chapter_title")
        lo.geometry = {x = 77 + (state.tc_ms and 25 or 0) + (show_hours and 16 or 0), y = refY - 84, an = 7, w = osc_geo.w - 200 - ((show_hours or state.tc_ms) and 60 or 0), h = 20}
        lo.style = osc_styles.chapter_title
    end

    -- Audio/Subtitle
    lo = add_layout('cy_audio')
    lo.geometry = {x = 37, y = refY - 40, an = 5, w = 24, h = 24}
    lo.style = osc_styles.control_3
    lo.visible = (osc_param.playresx >= 500 - outeroffset)

    lo = add_layout('cy_sub')
    lo.geometry = {x = 82, y = refY - 40, an = 5, w = 24, h = 24}
    lo.style = osc_styles.control_3
    lo.visible = (osc_param.playresx >= 600 - outeroffset)

    lo = add_layout('vol_ctrl')
    lo.geometry = {x = 127, y = refY - 40, an = 5, w = 24, h = 24}
    lo.style = osc_styles.control_3
    lo.visible = (osc_param.playresx >= 700 - outeroffset)

    -- Fullscreen/Loop/Info
    lo = add_layout('tog_fs')
    lo.geometry = {x = osc_geo.w - 37, y = refY - 40, an = 5, w = 24, h = 24}
    lo.style = osc_styles.control_3
    lo.visible = (osc_param.playresx >= 250 - outeroffset)

    if ontop_button then
        lo = add_layout('tog_ontop')
        lo.geometry = {x = osc_geo.w - 127 + (loop_button and 0 or 45), y = refY - 40, an = 5, w = 24, h = 24}
        lo.style = osc_styles.control_3
        lo.visible = (osc_param.playresx >= 700 - outeroffset)
    end

    if loop_button then
        lo = add_layout('tog_loop')
        lo.geometry = {x = osc_geo.w - 82, y = refY - 40, an = 5, w = 24, h = 24}
        lo.style = osc_styles.control_3
        lo.visible = (osc_param.playresx >= 600 - outeroffset)
    end

    if info_button then
        lo = add_layout('tog_info')
        lo.geometry = {x = osc_geo.w - 172 + (loop_button and 0 or 45) + (ontop_button and 0 or 45), y = refY - 40, an = 5, w = 24, h = 24}
        lo.style = osc_styles.control_3
        lo.visible = (osc_param.playresx >= 500 - outeroffset)
    end

    if screenshot_button then
        lo = add_layout('screenshot')
        lo.geometry = {x = osc_geo.w - 217 + (loop_button and 0 or 45) + (ontop_button and 0 or 45) + (info_button and 0 or 45), y = refY - 40, an = 5, w = 24, h = 24}
        lo.style = osc_styles.control_3
        lo.visible = (osc_param.playresx >= 300 - outeroffset)
    end

    if user_opts.download_button then
        lo = add_layout('download')
        lo.geometry = {x = osc_geo.w - 262 + (loop_button and 0 or 45) + (ontop_button and 0 or 45) + (info_button and 0 or 45) + (screenshot_button and 0 or 45), y = refY - 40, an = 5, w = 24, h = 24}
        lo.style = osc_styles.control_3
        lo.visible = (osc_param.playresx >= 400 - outeroffset)
    end
end

-- Reduced occupation layout
layouts["reduced"] = function ()
    local osc_geo = {
        w = osc_param.playresx,
        h = 180
    }

    -- origin of the controllers, left/bottom corner
    local posX = 0
    local posY = osc_param.playresy

    osc_param.areas = {} -- delete areas

    -- area for active mouse input
    add_area('input', get_hitbox_coords(posX, posY, 1, osc_geo.w, osc_geo.h))

    -- area for show/hide
    add_area('showhide', 0, 0, osc_param.playresx, osc_param.playresy)

    -- fetch values
    local osc_w, osc_h = osc_geo.w, osc_geo.h

    -- Controller Background
    local lo, geo

    new_element('osc_fade_bg', 'box')
    lo = add_layout('osc_fade_bg')
    lo.geometry = {x = posX, y = posY, an = 7, w = osc_w, h = 1}
    lo.style = osc_styles.osc_fade_bg
    lo.layer = 10
    lo.alpha[3] = user_opts.fade_transparency_strength

    local top_titlebar = window_controls_enabled() and (user_opts.window_title or user_opts.window_controls)

    if (user_opts.window_top_bar == "yes" or (not state.border) or (not state.title_bar) or state.fullscreen) and top_titlebar then
        new_element("window_bar_alpha_bg", "box")
        lo = add_layout("window_bar_alpha_bg")
        lo.geometry = {x = posX, y = -70, an = 7, w = osc_w, h = -1}
        lo.style = osc_styles.window_fade_bg
        lo.layer = 10
        lo.alpha[3] = user_opts.window_fade_transparency_strength
    end

    -- Alignment
    local refX = osc_w / 2
    local refY = posY

    -- Seekbar
    new_element('seekbarbg', 'box')
    lo = add_layout('seekbarbg')
    lo.geometry = {x = refX , y = refY - 75, an = 5, w = osc_geo.w - 200, h = 2}
    lo.layer = 13
    lo.style = osc_styles.seekbar_bg
    lo.alpha[1] = 128
    lo.alpha[3] = 128

    lo = add_layout('seekbar')
    lo.geometry = {x = refX, y = refY - 75, an = 5, w = osc_geo.w - 200, h = 16}
    lo.style = osc_styles.seekbar_fg
    lo.slider.gap = 7
    lo.slider.tooltip_style = osc_styles.tooltip
    lo.slider.tooltip_an = 2

    if (user_opts.persistent_progress or user_opts.persistent_progresstoggle) then
        lo = add_layout('persistentseekbar')
        lo.geometry = {x = refX, y = refY, an = 5, w = osc_geo.w, h = user_opts.persistent_progress_height}
        lo.style = osc_styles.seekbar_fg
        lo.slider.gap = 7
        lo.slider.tooltip_an = 0
    end

    local jump_buttons = user_opts.jump_buttons
    local chapter_skip_buttons = user_opts.chapter_skip_buttons
    local track_nextprev_buttons = user_opts.track_nextprev_buttons

    local loop_button = user_opts.loop_button
    local info_button = user_opts.info_button
    local ontop_button = user_opts.ontop_button
    local screenshot_button = user_opts.screenshot_button

    if user_opts.compact_mode then
        user_opts.jump_buttons = false
        jump_buttons = false
    end
    local offset = jump_buttons and 60 or 0
    local outeroffset = (chapter_skip_buttons and 0 or 100) + (jump_buttons and 0 or 100)

    -- Title
    geo = {x = 25, y = refY - 97, an = 1, w = osc_geo.w - 170, h = 35}
    lo = add_layout("title")
    lo.geometry = geo
    lo.style = string.format("%s{\\clip(0,%f,%f,%f)}", osc_styles.title,
                             geo.y - geo.h, geo.x + osc_geo.w - 170, geo.y + geo.h)
    lo.alpha[3] = 0
    lo.button.maxchars = geo.w / 5

    -- Description
    if (state.localDescription ~= nil or state.is_URL) and user_opts.show_description then
        geo = {x = osc_geo.w - 25, y = refY - 115, an = 9, w = 120, h = 19}
        lo = add_layout("description")
        lo.geometry = geo
        lo.style = string.format("%s{\\clip(0,%f,%f,%f)}", osc_styles.description,
        geo.y - geo.h, geo.x + geo.w, geo.y + geo.h)
        lo.alpha[3] = 0
        -- lo.button.maxchars = geo.w / 11
    end

    -- Volumebar
    if user_opts.volume_control then
        lo = new_element("volumebarbg", "box")
        lo.visible = (osc_param.playresx >= 900 - outeroffset) and user_opts.volume_control
        lo = add_layout("volumebarbg")
        lo.geometry = {x = 155, y = refY - 40, an = 4, w = 80, h = 2}
        lo.layer = 13
        lo.alpha[1] = 128
        lo.style = user_opts.volumebar_match_seek_color and osc_styles.seekbar_bg or osc_styles.volumebar_bg

        lo = add_layout("volumebar")
        lo.geometry = {x = 155, y = refY - 40, an = 4, w = 80, h = 8}
        lo.style = user_opts.volumebar_match_seek_color and osc_styles.seekbar_fg or osc_styles.volumebar_fg
        lo.slider.gap = 3
        lo.slider.tooltip_style = osc_styles.tooltip
        lo.slider.tooltip_an = 2
    end

    -- buttons
    if track_nextprev_buttons then
        lo = add_layout('pl_prev')
        lo.geometry = {x = refX - (60 + (chapter_skip_buttons and 60 or 0)) - offset, y = refY - 40 , an = 5, w = 30, h = 24}
        lo.style = osc_styles.control_2
    end

    if chapter_skip_buttons then
        lo = add_layout('skipback')
        lo.geometry = {x = refX - 60 - offset, y = refY - 40 , an = 5, w = 30, h = 24}
        lo.style = osc_styles.control_2
    end

    if jump_buttons then
        lo = add_layout('jumpback')
        lo.geometry = {x = refX - 60, y = refY - 40 , an = 5, w = 30, h = 24}
        lo.style = osc_styles.control_2
    end

    lo = add_layout("play_pause")
    lo.geometry = {x = refX, y = refY - 40 , an = 5, w = 45, h = 45}
    lo.style = osc_styles.control_1

    if jump_buttons then
        lo = add_layout('jumpfrwd')
        lo.geometry = {x = refX + 60, y = refY - 40 , an = 5, w = 30, h = 24}
        -- HACK: jumpfrwd's icon must be mirrored for nonstandard # of seconds
        -- as the font only has an icon without a number for rewinding
        lo.style = (user_opts.jump_icon_number and icons.jumpicons[user_opts.jump_amount] ~= nil) and osc_styles.control_2 or osc_styles.control_2_flip
    end

    if chapter_skip_buttons then
        lo = add_layout('skipfrwd')
        lo.geometry = {x = refX + 60 + offset, y = refY - 40 , an = 5, w = 30, h = 24}
        lo.style = osc_styles.control_2
    end

    if track_nextprev_buttons then
        lo = add_layout('pl_next')
        lo.geometry = {x = refX + (60 + (chapter_skip_buttons and 60 or 0)) + offset, y = refY - 40 , an = 5, w = 30, h = 24}
        lo.style = osc_styles.control_2
    end

    -- Time
    local remsec = mp.get_property_number("playtime-remaining", 0)
    local possec = mp.get_property_number("playback-time", 0)
    local dur = mp.get_property_number("duration", 0)

    local show_hours = possec >= 3600 or user_opts.time_format ~= "dynamic"
    lo = add_layout("tc_left")
    lo.geometry = {x = 25, y = refY - 84, an = 7, w = 35 + (state.tc_ms and 30 or 0) + (show_hours and 20 or 0), h = 20}
    lo.style = osc_styles.time

    local show_remhours = (state.tc_right_rem and remsec >= 3600) or (not state.tc_right_rem and dur >= 3600) or user_opts.time_format ~= "dynamic"
    lo = add_layout("tc_right")
    lo.geometry = {x = osc_geo.w - 25 , y = refY -84, an = 9, w = 35 + (state.tc_ms and 30 or 0) + (show_remhours and 25 or 0), h = 20}
    lo.style = osc_styles.time

    -- Chapter Title (next to timestamp)
    if user_opts.show_chapter_title then
        lo = add_layout("chapter_title")
        lo.geometry = {x = 25, y = refY - 125, an = 1, w = 120, h = 19}
        lo.style = osc_styles.chapter_title
    end

    -- Audio/Subtitle
    lo = add_layout('cy_audio')
    lo.geometry = {x = 37, y = refY - 40, an = 5, w = 24, h = 24}
    lo.style = osc_styles.control_3
    lo.visible = (osc_param.playresx >= 500 - outeroffset)

    lo = add_layout('cy_sub')
    lo.geometry = {x = 82, y = refY - 40, an = 5, w = 24, h = 24}
    lo.style = osc_styles.control_3
    lo.visible = (osc_param.playresx >= 600 - outeroffset)

    lo = add_layout('vol_ctrl')
    lo.geometry = {x = 127, y = refY - 40, an = 5, w = 24, h = 24}
    lo.style = osc_styles.control_3
    lo.visible = (osc_param.playresx >= 700 - outeroffset)

    -- Fullscreen/Loop/Info
    lo = add_layout('tog_fs')
    lo.geometry = {x = osc_geo.w - 37, y = refY - 40, an = 5, w = 24, h = 24}
    lo.style = osc_styles.control_3
    lo.visible = (osc_param.playresx >= 250 - outeroffset)

    if ontop_button then
        lo = add_layout('tog_ontop')
        lo.geometry = {x = osc_geo.w - 127 + (loop_button and 0 or 45), y = refY - 40, an = 5, w = 24, h = 24}
        lo.style = osc_styles.control_3
        lo.visible = (osc_param.playresx >= 700 - outeroffset)
    end

    if loop_button then
        lo = add_layout('tog_loop')
        lo.geometry = {x = osc_geo.w - 82, y = refY - 40, an = 5, w = 24, h = 24}
        lo.style = osc_styles.control_3
        lo.visible = (osc_param.playresx >= 600 - outeroffset)
    end

    if info_button then
        lo = add_layout('tog_info')
        lo.geometry = {x = osc_geo.w - 172 + (loop_button and 0 or 45) + (ontop_button and 0 or 45), y = refY - 40, an = 5, w = 24, h = 24}
        lo.style = osc_styles.control_3
        lo.visible = (osc_param.playresx >= 500 - outeroffset)
    end

    if screenshot_button then
        lo = add_layout('screenshot')
        lo.geometry = {x = osc_geo.w - 217 + (loop_button and 0 or 45) + (ontop_button and 0 or 45) + (info_button and 0 or 45), y = refY - 40, an = 5, w = 24, h = 24}
        lo.style = osc_styles.control_3
        lo.visible = (osc_param.playresx >= 300 - outeroffset)
    end

    if user_opts.download_button then
        lo = add_layout('download')
        lo.geometry = {x = osc_geo.w - 262 + (loop_button and 0 or 45) + (ontop_button and 0 or 45) + (info_button and 0 or 45) + (screenshot_button and 0 or 45), y = refY - 40, an = 5, w = 24, h = 24}
        lo.style = osc_styles.control_3
        lo.visible = (osc_param.playresx >= 400 - outeroffset)
    end
end

-- Validate string type user options
function validate_user_opts()
    if user_opts.window_top_bar ~= "auto" and
       user_opts.window_top_bar ~= "yes" and
       user_opts.window_top_bar ~= "no" then
        mp.msg.warn("window_top_bar cannot be '" .. user_opts.window_top_bar .. "'. Ignoring.")
        user_opts.window_top_bar = "auto"
    end

    if user_opts.volume_control_type ~= "linear" and
    user_opts.volume_control_type ~= "logarithmic" then
        mp.msg.warn("volume_control_type cannot be '" .. user_opts.volume_control_type .. "'. Ignoring.")
        user_opts.volume_control_type = "linear"
    end
end

function update_options(list)
    validate_user_opts()
    request_tick()
    visibility_mode("auto")
    request_init()
end

-- OSC INIT
local function osc_init()
    mp.msg.debug("osc_init")

    -- set canvas resolution according to display aspect and scaling setting
    local baseResY = 720
    local _, display_h, display_aspect = mp.get_osd_size()
    local scale

    if (mp.get_property("video") == "no") then -- dummy/forced window
        scale = user_opts.scale_forced_window
    elseif state.fullscreen then
        scale = user_opts.scale_fullscreen
    else
        scale = user_opts.scale_windowed
    end

    if user_opts.vid_scale then
        osc_param.unscaled_y = baseResY
    else
        osc_param.unscaled_y = display_h
    end
    osc_param.playresy = osc_param.unscaled_y / scale
    if display_aspect > 0 then
        osc_param.display_aspect = display_aspect
    end
    osc_param.playresx = osc_param.playresy * osc_param.display_aspect

    -- stop seeking with the slider to prevent skipping files
    state.active_element = nil

    elements = {}

    local pl_count = mp.get_property_number("playlist-count", 0)
    local have_pl = pl_count > 1
    local pl_pos = mp.get_property_number("playlist-pos", 0) + 1
    local have_ch = mp.get_property_number("chapters", 0) > 0
    local loop = mp.get_property("loop-playlist", "no")

    local nojumpoffset = user_opts.jump_buttons and 0 or 100
    local noskipoffset = user_opts.chapter_skip_buttons and 0 or 100

    local compact_mode = user_opts.compact_mode
    if compact_mode then nojumpoffset = 100 end
    local outeroffset = (user_opts.chapter_skip_buttons and 0 or 140) + (user_opts.jump_buttons and 0 or 140)
    if compact_mode then outeroffset = 140 end

    local ne

    -- title
    ne = new_element("title", "button")
    ne.visible = user_opts.show_title
    ne.content = function ()
        local title = state.forced_title or
                      mp.command_native({"expand-text", user_opts.title})
        -- escape ASS, and strip newlines and trailing slashes
        title = title:gsub("\\n", " "):gsub("\\$", ""):gsub("{","\\{")
        return not (title == "") and title or "mpv video"
    end
    ne.eventresponder["mbtn_left_up"] = function ()
        local title = mp.get_property_osd("media-title")
        show_message(title)
    end
    ne.eventresponder["mbtn_right_up"] =
        function () show_message(mp.get_property_osd("filename")) end

    -- description
    ne = new_element('description', 'button')
    ne.visible = (state.localDescription ~= nil or state.is_URL) and user_opts.show_description
    ne.content = function ()
        if #state.videoDescription > 25 and user_opts.layout_option == "reduced" then
            return "View description"
        end

        if state.is_URL then
            local title = "Loading description..."
            if state.descriptionLoaded then
                title = state.videoDescription:sub(1, 300)

            end
            -- get rid of new lines
            title = string.gsub(title, '\\N', ' ')
            return not (title == "") and title or "error"
        else
            if (state.localDescription == nil) then
                return ""
            else
                return string.gsub(state.localDescription, '\\N', ' ')
            end
        end
    end
    ne.eventresponder['mbtn_left_up'] =
        function ()
            check_description()
        end

    -- playlist buttons
    -- prev
    ne = new_element('pl_prev', 'button')
    ne.visible = (osc_param.playresx >= 500 - nojumpoffset - noskipoffset*(nojumpoffset == 0 and 1 or 10))
    ne.content = icons.previous
    ne.enabled = (pl_pos > 1) or (loop ~= 'no')
    ne.eventresponder['mbtn_left_up'] =
        function ()
            mp.commandv('playlist-prev', 'weak')
            destroy_scrolling_keys()
        end
    ne.eventresponder['enter'] =
        function ()
            mp.commandv('playlist-prev', 'weak')
            destroy_scrolling_keys()
            show_message(get_playlist(false))
        end
    ne.eventresponder['mbtn_right_up'] =
        function () show_message(get_playlist(false)) end
    ne.eventresponder['shift+mbtn_left_down'] =
        function () show_message(get_playlist(false)) end

    --next
    ne = new_element('pl_next', 'button')
    ne.visible = (osc_param.playresx >= 500 - nojumpoffset - noskipoffset*(nojumpoffset == 0 and 1 or 10))
    ne.content = icons.next
    ne.enabled = (have_pl and (pl_pos < pl_count)) or (loop ~= 'no')
    ne.eventresponder['mbtn_left_up'] =
        function ()
            mp.commandv('playlist-next', 'weak')
            destroy_scrolling_keys()
        end
    ne.eventresponder['enter'] =
        function ()
            mp.commandv('playlist-next', 'weak')
            destroy_scrolling_keys()
            show_message(get_playlist(false))
        end
    ne.eventresponder['mbtn_right_up'] =
        function () show_message(get_playlist(false)) end
    ne.eventresponder['shift+mbtn_left_down'] =
        function () show_message(get_playlist(false)) end

    --play control buttons
    --playpause
    ne = new_element("play_pause", "button")
    ne.content = function ()
        if mp.get_property("eof-reached") == "yes" then
            return icons.replay
        elseif mp.get_property("pause") == "yes" and not state.playingWhilstSeeking then
            return icons.play
        else
            return icons.pause
        end
    end
    ne.eventresponder["mbtn_left_up"] = function ()
        if mp.get_property("eof-reached") == "yes" then
            mp.commandv("seek", 0, "absolute-percent")
            mp.commandv("set", "pause", "no")
        else
            mp.commandv("cycle", "pause")
        end
    end
    ne.eventresponder["shift+mbtn_left_down"] = function ()
        if user_opts.loop_in_pause then
            show_message((state.looping and texts.loopdisable or texts.loopenable))
            state.looping = not state.looping
            mp.set_property_native("loop-file", state.looping)
        end
    end
    ne.eventresponder["mbtn_right_down"] = function ()
        shuffle_playlist()
    end

    --skipback
    local jump_amount = user_opts.jump_amount
    local jump_more_amount = user_opts.jump_more_amount
    local jump_mode = user_opts.jump_mode
    local tempicons = icons.jumpicons.default

    ne = new_element('skipback', 'button')
    ne.visible = (osc_param.playresx >= 400 - nojumpoffset*10)
    ne.softrepeat = user_opts.chapter_softrepeat == true
    ne.content = icons.rewind
    ne.enabled = (have_ch) or compact_mode -- disables button when no chapters available.
    ne.eventresponder['mbtn_left_down'] =
        function ()
            if compact_mode then
                mp.commandv('seek', -jump_amount, jump_mode)
            else
                mp.commandv("add", "chapter", -1)
            end
        end
    ne.eventresponder['mbtn_right_down'] =
        function ()
            if compact_mode then
                mp.commandv("add", "chapter", -1)
                show_message(get_chapterlist())
                show_message(get_chapterlist()) -- run twice as it might show the wrong chapter without another function
            else
                show_message(get_chapterlist())
            end
        end
    ne.eventresponder['shift+mbtn_left_down'] =
        function ()
            mp.commandv('seek', -jump_more_amount, jump_mode)
        end
    ne.eventresponder['shift+mbtn_right_down'] =
        function () show_message(get_chapterlist()) end


    --skipfrwd
    ne = new_element('skipfrwd', 'button')
    ne.visible = (osc_param.playresx >= 400 - nojumpoffset*10)
    ne.softrepeat = user_opts.chapter_softrepeat == true
    ne.content = icons.forward
    ne.enabled = (have_ch) or compact_mode -- disables button when no chapters available.
    ne.eventresponder['mbtn_left_down'] =
        function ()
            if compact_mode then
                mp.commandv('seek', jump_amount, jump_mode)
            else
                mp.commandv("add", "chapter", 1)
            end
        end
    ne.eventresponder['mbtn_right_down'] =
        function ()
            if compact_mode then
                mp.commandv("add", "chapter", 1)
                show_message(get_chapterlist())
                show_message(get_chapterlist()) -- run twice as it might show the wrong chapter without another function
            else
                show_message(get_chapterlist())
            end
        end
    ne.eventresponder['shift+mbtn_left_down'] =
        function ()
            mp.commandv('seek', jump_more_amount, jump_mode)
        end
    ne.eventresponder['shift+mbtn_right_down'] =
        function () show_message(get_chapterlist()) end

    if user_opts.jump_buttons then
        if user_opts.jump_icon_number then
            tempicons = icons.jumpicons[jump_amount] or icons.jumpicons.default
        end

        --jumpback
        ne = new_element('jumpback', 'button')

        ne.softrepeat = user_opts.jump_softrepeat == true
        ne.content = tempicons[1]
        ne.eventresponder['mbtn_left_down'] =
            function () mp.commandv('seek', -jump_amount, jump_mode) end
        ne.eventresponder['mbtn_right_down'] =
            function () mp.commandv('seek', -jump_more_amount, jump_mode) end
        ne.eventresponder['shift+mbtn_left_down'] =
            function () mp.commandv('frame-back-step') end


        --jumpfrwd
        ne = new_element('jumpfrwd', 'button')

        ne.softrepeat = user_opts.jump_softrepeat == true
        ne.content = tempicons[2]
        ne.eventresponder['mbtn_left_down'] =
            function () mp.commandv('seek', jump_amount, jump_mode) end
        ne.eventresponder['mbtn_right_down'] =
            function () mp.commandv('seek', jump_more_amount, jump_mode) end
        ne.eventresponder['shift+mbtn_left_down'] =
            function () mp.commandv('frame-step') end
    end

    --
    update_tracklist()

    --cy_audio
    ne = new_element('cy_audio', 'button')
    ne.enabled = (#tracks_osc.audio > 0)
    ne.off = (get_track('audio') == 0)
    ne.visible = (osc_param.playresx >= 500 - outeroffset)
    ne.content = icons.audio
    ne.tooltip_style = osc_styles.tooltip
    ne.tooltipF = function ()
        local track_id = get_track("audio")
        local message = texts.off
        if track_id ~= 0 then
            local total = #tracks_osc.audio
            message = string.format("%s [%d ∕ %d]", texts.audio, track_id, total)
            local prop = mp.get_property("current-tracks/audio/title")
                or mp.get_property("current-tracks/audio/lang")

            if prop and prop ~= texts.na then
                message = string.format("%s [%s]", message, prop)
            end
        else
            if not ne.enabled then
                message = "No audio tracks"
            end
        end
        return message
    end
    ne.nothingavailable = texts.noaudio
    ne.eventresponder['mbtn_left_up'] =
    function () mp.set_property("lavfi-complex", "") set_track('audio', 1) show_message(get_tracklist('audio')) end
    ne.eventresponder['enter'] =
        function ()
            mp.set_property("lavfi-complex", "")
            set_track('audio', 1)
            show_message(get_tracklist('audio'))
        end
    ne.eventresponder['mbtn_right_up'] =
        function () mp.set_property("lavfi-complex", "") set_track('audio', -1) show_message(get_tracklist('audio')) end
    ne.eventresponder['shift+mbtn_left_down'] =
        function () mp.commandv("script-binding", "loadaudiotracks/ask_for_audio_track") end
    ne.eventresponder['shift+mbtn_right_down'] =
        function () show_message(get_tracklist('audio')) end

    --cy_sub
    ne = new_element('cy_sub', 'button')
    ne.enabled = #tracks_osc.sub > 0
    ne.off = get_track('sub') == 0
    ne.visible = (osc_param.playresx >= 600 - outeroffset)
    ne.content = icons.subtitle
    ne.tooltip_style = osc_styles.tooltip
    ne.tooltipF = function ()
        local sub_id = get_track("sub")
        local message = texts.off
        if sub_id ~= 0 then
            message = string.format("%s [%d ∕ %d]", texts.subtitle, sub_id, #tracks_osc.sub)
            local lang = mp.get_property("current-tracks/sub/lang")
            local title = mp.get_property("current-tracks/sub/title")

            if lang and lang ~= texts.na then
                message = string.format("%s [%s]", message, lang)
            end

            if title then
                message = message .. " " .. title
            end
        end
        return message
    end
    ne.nothingavailable = texts.nosub
    ne.eventresponder['mbtn_left_up'] =
        function ()
            mp.set_property_number("secondary-sid", 0)
            set_track('sub', 1)
            show_message(get_tracklist("sub"))
        end
    ne.eventresponder['enter'] =
        function ()
            mp.set_property_number("secondary-sid", 0)
            set_track('sub', 1)
            show_message(get_tracklist('sub'))
        end
    ne.eventresponder['mbtn_right_up'] =
        function ()
            mp.set_property_number("secondary-sid", 0)
            set_track('sub', -1)
            show_message(get_tracklist('sub'))
        end
    ne.eventresponder['shift+mbtn_left_down'] =
    function ()
        mp.set_property_number("secondary-sid", 0)
        set_track('sub', 1)
    end
    ne.eventresponder['shift+mbtn_right_down'] =
        function () show_message(get_tracklist('sub')) end

    -- vol_ctrl
    ne = new_element("vol_ctrl", "button")
    ne.enabled = get_track("audio") > 0
    ne.off = get_track("audio") == 0
    ne.visible = (osc_param.playresx >= 700 - outeroffset) and user_opts.volume_control
    ne.content = function ()
        local volume = mp.get_property_number("volume", 0)
        if state.mute then
            return icons.volume_mute
        else
            if volume >= 75 then
                return icons.volume_high
            elseif volume >= 25 then
                return icons.volume_low
            else
                return icons.volume_quiet
            end
        end
    end
    ne.eventresponder['mbtn_left_up'] =
        function ()
            mp.commandv('cycle', 'mute')
        end
    ne.eventresponder["wheel_up_press"] =
        function ()
            if (state.mute) then mp.commandv('cycle', 'mute') end
            mp.commandv("osd-auto", "add", "volume", 5)
        end
    ne.eventresponder["wheel_down_press"] =
        function ()
            if (state.mute) then mp.commandv('cycle', 'mute') end
            mp.commandv("osd-auto", "add", "volume", -5)
        end

    --tog_fs
    ne = new_element('tog_fs', 'button')
    ne.content = function () return state.fullscreen and icons.fullscreen_exit or icons.fullscreen end
    ne.visible = (osc_param.playresx >= 250)
    ne.eventresponder['mbtn_left_up'] =
        function () mp.commandv('cycle', 'fullscreen') end

    --tog_loop
    ne = new_element('tog_loop', 'button')
    ne.content = function ()
        if (state.looping) then
            return (icons.loop_on)
        else
            return (icons.loop_off)
        end
    end
    ne.visible = (osc_param.playresx >= 600 - outeroffset)
    ne.tooltip_style = osc_styles.tooltip
    ne.tooltipF = function ()
        local message = texts.loopenable
        if state.looping then
            message = texts.loopdisable
        end
        return message
    end
    ne.eventresponder['mbtn_left_up'] =
        function ()
            state.looping = not state.looping
            mp.set_property_native("loop-file", state.looping)
        end

    --download
    ne = new_element("download", "button")
    ne.content = function () return state.downloading and icons.downloading or icons.download end
    ne.visible = (osc_param.playresx >= 1100 - outeroffset - (user_opts.loop_button and 0 or 100) - (user_opts.ontop_button and 0 or 100) - (user_opts.info_button and 0 or 100) - (user_opts.screenshot_button and 0 or 100)) and state.is_URL
    ne.tooltip_style = osc_styles.tooltip
    ne.tooltipF = function () return state.downloading and (texts.downloading .. "...") or (texts.download .. " (" .. state.file_size_normalized .. ")") end
    ne.eventresponder["mbtn_left_up"] = function ()
        if not state.videoCantBeDownloaded then
            local localpath = mp.command_native({"expand-path", user_opts.download_path})

            if state.downloaded_once then
                show_message("{\\an9}" .. texts.downloaded .. "...")
            elseif state.downloading then
                show_message("{\\an9}" .. texts.download_in_progress .. "...")
            else
                show_message("{\\an9}" .. texts.downloading .. "...")
                state.downloading = true

                -- use current or default ytdl-format
                local mpv_ytdl = (user_opts.ytdl_format and user_opts.ytdl_format ~= "") and user_opts.ytdl_format or  mp.get_property("file-local-options/ytdl-format") or mp.get_property("ytdl-format") or ""

                local command = {
                    "yt-dlp",
                    mpv_ytdl,
                    "--remux", "mp4",
                    "--add-metadata",
                    "--embed-subs",
                    "-o", "%(title)s.%(ext)s",
                    "-P", localpath,
                    state.url_path
                }

                exec_async(command, download_done)
            end
        else
            show_message("{\\an9}Can't be downloaded")
        end
    end

    --screenshot
    ne = new_element("screenshot", "button")
    ne.content = icons.screenshot
    ne.tooltip_style = osc_styles.tooltip
    ne.tooltipF = texts.screenshot
    ne.visible = (osc_param.playresx >= 900 - outeroffset - (user_opts.loop_button and 0 or 100) - (user_opts.ontop_button and 0 or 100) - (user_opts.info_button and 0 or 100))
    ne.eventresponder["mbtn_left_up"] = function ()
        local temp_sub_pos = mp.get_property("sub-pos")
        if user_opts.screenshot_flag == "subtitles" or user_opts.screenshot_flag == "subtitles+each-frame" then
            mp.commandv("set", "sub-pos", 100)
        end
        mp.commandv("osd-msg", "screenshot", user_opts.screenshot_flag)
        mp.commandv("set", "sub-pos", temp_sub_pos)
    end

    --tog_info
    ne = new_element('tog_info', 'button')
    ne.content = icons.info
    ne.visible = (osc_param.playresx >= 800 - outeroffset - (user_opts.loop_button and 0 or 100) - (user_opts.ontop_button and 0 or 100))
    ne.tooltip_style = osc_styles.tooltip
    ne.tooltipF = texts.statsinfo
    ne.eventresponder['mbtn_left_up'] =
        function () mp.commandv('script-binding', 'stats/display-stats-toggle') end

    --tog_ontop
    ne = new_element('tog_ontop', 'button')
    ne.content = function ()
        if mp.get_property('ontop') == 'no' then
            return (icons.ontop_on)
        else
            return (icons.ontop_off)
        end
    end
    ne.tooltip_style = osc_styles.tooltip
    ne.tooltipF = function ()
        local message = texts.ontopdisable
        if mp.get_property('ontop') == 'no' then
            message = texts.ontop
        end
        return message
    end
    ne.visible = (osc_param.playresx >= 700 - outeroffset - (user_opts.loop_button and 0 or 100))
    ne.eventresponder['mbtn_left_up'] =
        function ()
            mp.commandv("cycle", "ontop")
            if (state.initialborder == 'yes') then
                if (mp.get_property('ontop') == 'yes') then
                    mp.commandv('set', 'border', "no")
                else
                    mp.commandv('set', 'border', "yes")
                end
            end
        end

    ne.eventresponder['mbtn_right_up'] =
        function ()
            mp.commandv("cycle", "ontop")
        end

    --seekbar
    ne = new_element('seekbar', 'slider')
    ne.enabled = not (mp.get_property('percent-pos') == nil)
    ne.thumbnailable = true
    state.slider_element = ne.enabled and ne or nil  -- used for forced_title
    ne.slider.markerF = function ()
        local duration = mp.get_property_number('duration', nil)
        if duration then
            local chapters = mp.get_property_native("chapter-list", {})
            local markers = {}
            for n = 1, #chapters do
                markers[n] = (chapters[n].time / duration * 100)
            end
            return markers
        else
            return {}
        end
    end
    ne.slider.posF = function ()
            if mp.get_property_bool("eof-reached") then return 100 end
            return mp.get_property_number("percent-pos")
        end
    ne.slider.tooltipF = function (pos)
        state.touchingprogressbar = true
        local duration = mp.get_property_number("duration")
        if duration ~= nil and pos ~= nil then
            local possec = duration * (pos / 100)
            local time = mp.format_time(possec)
            -- If video is less than 1 hour, and the time format is not fixed, strip the "00:" prefix
            if possec < 3600 and user_opts.time_format ~= "fixed" then
                time = time:gsub("^00:", "")
            end
            return time
        else
            return ""
        end

    end
    ne.slider.seek_rangesF = function()
        if not user_opts.seek_range then
            return nil
        end
        local cache_state = state.cache_state
        if not cache_state then
            return nil
        end
        local duration = mp.get_property_number("duration")
        if (duration == nil) or duration <= 0 then
            return nil
        end
        local ranges = cache_state["seekable-ranges"]
        if #ranges == 0 then
            return nil
        end
        local nranges = {}
        for _, range in pairs(ranges) do
            nranges[#nranges + 1] = {
                ['start'] = 100 * range['start'] / duration,
                ['end'] = 100 * range['end'] / duration,
            }
        end
        return nranges
    end
    ne.eventresponder["mouse_move"] = --keyframe seeking when mouse is dragged
        function (element)
            if not element.state.mbtnleft then return end -- allow drag for mbtnleft only!
            -- mouse move events may pile up during seeking and may still get
            -- sent when the user is done seeking, so we need to throw away
            -- identical seeks
            if mp.get_property("pause") == "no" and user_opts.mouse_seek_pause then
                state.playingWhilstSeeking = true
                mp.commandv("cycle", "pause")
            end
            local seekto = get_slider_value(element)
            if element.state.lastseek == nil or element.state.lastseek ~= seekto then
                local flags = "absolute-percent"
                if not user_opts.seekbar_keyframes then
                    flags = flags .. "+exact"
                end
                mp.commandv("seek", seekto, flags)
                element.state.lastseek = seekto
            end

        end
    ne.eventresponder['mbtn_left_down'] = -- exact seeks on left click
        function (element)
            element.state.mbtnleft = true
            if user_opts.seekbar_keyframes then
                mp.commandv("seek", get_slider_value(element), "absolute-percent")
            else
                mp.commandv("seek", get_slider_value(element), "absolute-percent", "exact")
            end
        end
    ne.eventresponder["shift+mbtn_left_down"] = -- keyframe seeks on shift+left click
        function (element)
            element.state.mbtnleft = true
            if user_opts.seekbar_keyframes then
                mp.commandv("seek", get_slider_value(element), "absolute-percent", "exact")
            else
                mp.commandv("seek", get_slider_value(element), "absolute-percent")
            end        
        end
    ne.eventresponder["mbtn_left_up"] =
        function (element)
            element.state.mbtnleft = false
        end
    ne.eventresponder["mbtn_right_down"] = function (element)
        if (mp.get_property_native("chapter-list/count") > 0) then
            local chapter
            local pos = get_slider_value(element)
            local diff = math.huge

            for i, marker in ipairs(element.slider.markerF()) do
                if math.abs(pos - marker) < diff then
                    diff = math.abs(pos - marker)
                    chapter = i
                end
            end

            if chapter then
                mp.set_property("chapter", chapter - 1)
            end
        end
    end
    ne.eventresponder["reset"] =
        function (element)
            element.state.lastseek = nil
            if (state.playingWhilstSeeking) then
                if mp.get_property("eof-reached") == "no" then
                    mp.commandv("cycle", "pause")
                end
                state.playingWhilstSeeking = false
            end
        end

    --volumebar
    if user_opts.volume_control then
        local volume_max = mp.get_property_number("volume-max") > 0 and mp.get_property_number("volume-max") or 100
        ne = new_element("volumebar", "slider")
        ne.visible = (osc_param.playresx >= 900 - outeroffset) and user_opts.volume_control
        ne.enabled = get_track('audio') > 0
        ne.slider = {min = {value = 0}, max = {value = volume_max}}
        ne.slider.markerF = function () return {} end
        ne.slider.seek_rangesF = function() return nil end
        ne.slider.posF = function ()
            local volume = mp.get_property_number("volume")
            if user_opts.volume_control_type == "logarithmic" then
                return math.sqrt(volume * 100)
            else
                return volume
            end
        end
        ne.slider.tooltipF = function (pos) return (get_track('audio') > 0) and set_volume(pos) or "" end
        ne.eventresponder["mouse_move"] = function (element)
            local pos = get_slider_value(element)
            local setvol = set_volume(pos)
            if element.state.lastseek == nil or element.state.lastseek ~= setvol then
                mp.commandv("osd-msg", "set", "volume", setvol)
                element.state.lastseek = setvol
            end
        end
        ne.eventresponder["mbtn_left_down"] = function (element)
            local pos = get_slider_value(element)
            mp.commandv("osd-msg", "set", "volume", set_volume(pos))
        end
        ne.eventresponder["reset"] = function (element) element.state.lastseek = nil end
        ne.eventresponder["wheel_up_press"] = function () mp.commandv("osd-msg", "add", "volume", 5) end
        ne.eventresponder["wheel_down_press"] = function () mp.commandv("osd-msg", "add", "volume", -5) end
    end

    --persistent seekbar
    if (user_opts.persistent_progress_default or user_opts.persistent_progress_toggle) then
        ne = new_element('persistentseekbar', 'slider')
        ne.enabled = not (mp.get_property('percent-pos') == nil)
        state.slider_element = ne.enabled and ne or nil  -- used for forced_title
        ne.slider.markerF = function ()
            return {}
        end
        ne.slider.posF = function ()
            if mp.get_property_bool("eof-reached") then return 100 end
            return mp.get_property_number('percent-pos', nil)
        end
        ne.slider.tooltipF = function()
            return ""
        end
        ne.slider.seek_rangesF = function()
            if user_opts.persistent_buffer then
                if not user_opts.seek_range then
                    return nil
                end
                local cache_state = state.cache_state
                if not cache_state then
                    return nil
                end
                local duration = mp.get_property_number('duration', nil)
                if (duration == nil) or duration <= 0 then
                    return nil
                end
                local ranges = cache_state['seekable-ranges']
                if #ranges == 0 then
                    return nil
                end
                local nranges = {}
                for _, range in pairs(ranges) do
                    nranges[#nranges + 1] = {
                        ['start'] = 100 * range['start'] / duration,
                        ['end'] = 100 * range['end'] / duration,
                    }
                end
                return nranges
            end
            return nil
        end
    end

    -- Helper function to format time
    local function format_time(seconds)
        if not seconds then return "--:--" end

        local hours = math.floor(seconds / 3600)
        local minutes = math.floor((seconds % 3600) / 60)
        local whole_seconds = math.floor(seconds % 60)
        local milliseconds = state.tc_ms and math.floor((seconds % 1) * 1000) or nil

        -- Always show HH:MM:SS if user_opts.time_format is "fixed"
        local force_hours = user_opts.time_format == "fixed"

        -- Format string templates
        local format_with_ms = (hours > 0 or force_hours) and "%02d:%02d:%02d.%03d" or "%02d:%02d.%03d"
        local format_without_ms = (hours > 0 or force_hours) and "%02d:%02d:%02d" or "%02d:%02d"

        if state.tc_ms then
            return string.format(format_with_ms,
                (hours > 0 or force_hours) and hours or minutes,
                (hours > 0 or force_hours) and minutes or whole_seconds,
                (hours > 0 or force_hours) and whole_seconds or milliseconds,
                (hours > 0 or force_hours) and milliseconds or nil)
        else
            return string.format(format_without_ms,
                (hours > 0 or force_hours) and hours or minutes,
                (hours > 0 or force_hours) and minutes or whole_seconds,
                (hours > 0 or force_hours) and whole_seconds or nil)
        end
    end

    -- Current position time display
    ne = new_element("tc_left", "button")
    ne.content = function()
        local playback_time = mp.get_property_number("playback-time", 0)
        return format_time(playback_time)
    end
    ne.eventresponder["mbtn_left_up"] = function()
        state.tc_ms = not state.tc_ms
        request_init()
    end

    -- Chapter title (below seekbar)
    local chapter_index = mp.get_property_number("chapter", -1)
    ne = new_element("separator", "button")
    ne.visible = true
    ne.content = function()
        if chapter_index >= 0 or state.buffering then
            return " • "
        else
            return ""
        end
    end

    ne = new_element("chapter_title", "button")
    ne.visible = true
    ne.content = function()
        if state.buffering ~= nil and state.buffering then
            return "Buffering..." .. " " .. (mp.get_property("cache-buffering-state") or "0") .. "%"
        else
            if user_opts.chapter_fmt ~= "no" and chapter_index >= 0 then
                request_init()
                local chapters = mp.get_property_native("chapter-list", {})
                local chapter_title = (chapters[chapter_index + 1] and chapters[chapter_index + 1].title ~= "") and chapters[chapter_index + 1].title
                    or chapter_index + 1 .. "/" .. #chapters
                chapter_title = mp.command_native({"escape-ass", chapter_title})
                return string.format(user_opts.chapter_fmt, chapter_title)
            end
        end
        return "" -- fallback
    end
    ne.eventresponder["mbtn_left_up"] = function() show_message(get_chapterlist()) end

    -- Total/remaining time display
    ne = new_element("tc_right", "button")
    ne.visible = (mp.get_property_number("duration", 0) > 0)
    ne.content = function()
        local duration = mp.get_property_number("duration", 0)
        if duration <= 0 then return "--:--" end

        local time_to_display = state.tc_right_rem and mp.get_property_number("playtime-remaining", 0) or duration
        if time_to_display < 0 then time_to_display = 0 end
        local prefix = state.tc_right_rem and (user_opts.unicode_minus and UNICODE_MINUS or "-") or ""

        return prefix .. format_time(time_to_display) .. (state.is_live and " • LIVE" or "")
    end
    ne.eventresponder["mbtn_left_up"] = function()
        state.tc_right_rem = not state.tc_right_rem
    end

    -- load layout
    layouts[user_opts.layout_option]()

    -- load window controls
    if window_controls_enabled() then
        window_controls()
    end

    --do something with the elements
    prepare_elements()
end

--
-- Other important stuff
--
function show_osc()
    -- show when disabled can happen (e.g. mouse_move) due to async/delayed unbinding
    if not state.enabled then return end

    mp.msg.trace('show_osc')
    --remember last time of invocation (mouse move)
    state.showtime = mp.get_time()

    osc_visible(true)

    if (user_opts.fade_duration > 0) then
        state.anitype = nil
    end
end

function hide_osc()
    mp.msg.trace('hide_osc')
    if not state.enabled then
        -- typically hide happens at render() from tick(), but now tick() is
        -- no-op and won't render again to remove the osc, so do that manually.
        state.osc_visible = false
        adjust_subtitles(false)
        render_wipe()
    elseif (user_opts.fade_duration > 0) then
        if not(state.osc_visible == false) then
            state.anitype = 'out'
            request_tick()
        end
    else
        osc_visible(false)
    end
    if thumbfast.available then
        mp.commandv("script-message-to", "thumbfast", "clear")
    end
end

function osc_visible(visible)
    if state.osc_visible ~= visible then
        state.osc_visible = visible
        adjust_subtitles(true)  -- raise subtitles
    end
    request_tick()
end

function adjust_subtitles(visible)
    if visible and user_opts.raise_subtitles and state.osc_visible == true and (state.fullscreen == false or user_opts.show_fullscreen) then
        local _, h = mp.get_osd_size()
        if h > 0 then
            local subpos = math.floor((osc_param.playresy - (user_opts.raise_subtitle_amount - (user_opts.seekbar_between_timers and 25 or 0)))/osc_param.playresy*100)
            if subpos < 0 then
                subpos = 100 -- out of screen, default to original position
            end
            mp.commandv('set', 'sub-pos', subpos) -- percentage
        end
    elseif user_opts.raise_subtitles then
        mp.commandv('set', 'sub-pos', 100)
    end
end

function pause_state(name, enabled)
    -- fix OSC instantly hiding after scrubbing (initiates a 'fake' pause to stop issues when scrubbing to the end of files)
    if (state.playingWhilstSeeking) then state.playingWhilstSeekingWaitingForEnd = true return end
    if (state.playingWhilstSeekingWaitingForEnd) then state.playingWhilstSeekingWaitingForEnd = false return end
    state.paused = enabled
    if user_opts.show_on_pause then
        if enabled then
            visibility_mode("auto")
            show_osc()
        else
            visibility_mode("auto")
        end
    end
    request_tick()
end

function cache_state(name, st)
    state.cache_state = st
    request_tick()
end


local function mouse_leave()
    if get_hide_timeout() >= 0 then
        hide_osc()
    end
    -- reset mouse position
    state.last_mouseX, state.last_mouseY = nil, nil
    state.mouse_in_window = false
end

local function do_enable_key_bindings()
    if state.enabled then
        if not state.showhide_enabled then
            mp.enable_key_bindings('showhide', 'allow-vo-dragging+allow-hide-cursor')
            mp.enable_key_bindings('showhide_wc', 'allow-vo-dragging+allow-hide-cursor')
        end
        state.showhide_enabled = true
    end
end

local function enable_osc(enable)
    state.enabled = enable
    if enable then
        do_enable_key_bindings()
    else
        hide_osc() -- acts immediately when state.enabled == false
        if state.showhide_enabled then
            mp.disable_key_bindings('showhide')
            mp.disable_key_bindings('showhide_wc')
        end
        state.showhide_enabled = false
    end
end

local function render()
    mp.msg.trace('rendering')
    local current_screen_sizeX, current_screen_sizeY, aspect = mp.get_osd_size()
    local mouseX, mouseY = get_virt_mouse_pos()
    local now = mp.get_time()

    -- check if display changed, if so request reinit
    if not (state.mp_screen_sizeX == current_screen_sizeX
        and state.mp_screen_sizeY == current_screen_sizeY) then

        request_init_resize()

        state.mp_screen_sizeX = current_screen_sizeX
        state.mp_screen_sizeY = current_screen_sizeY
    end

    -- init management
    if state.active_element then
        -- mouse is held down on some element - keep ticking and igore initReq
        -- till it's released, or else the mouse-up (click) will misbehave or
        -- get ignored. that's because osc_init() recreates the osc elements,
        -- but mouse handling depends on the elements staying unmodified
        -- between mouse-down and mouse-up (using the index active_element).
        request_tick()
    elseif state.initREQ then
        osc_init()
        state.initREQ = false

        -- store initial mouse position
        if (state.last_mouseX == nil or state.last_mouseY == nil)
            and not (mouseX == nil or mouseY == nil) then

            state.last_mouseX, state.last_mouseY = mouseX, mouseY
        end
    end


    -- fade animation
    if not(state.anitype == nil) then

        if (state.anistart == nil) then
            state.anistart = now
        end

        if (now < state.anistart + (user_opts.fade_duration/1000)) then

            if (state.anitype == 'in') then --fade in
                osc_visible(true)
                state.animation = scale_value(state.anistart,
                    (state.anistart + (user_opts.fade_duration/1000)),
                    255, 0, now)
            elseif (state.anitype == 'out') then --fade out
                state.animation = scale_value(state.anistart,
                    (state.anistart + (user_opts.fade_duration/1000)),
                    0, 255, now)
            end

        else
            if (state.anitype == 'out') then
                osc_visible(false)
            end
            kill_animation()
        end
    else
        kill_animation()
    end

    -- mouse show/hide area
    for _,cords in pairs(osc_param.areas['showhide']) do
        set_virt_mouse_area(cords.x1, cords.y1, cords.x2, cords.y2, 'showhide')
    end
    if osc_param.areas['showhide_wc'] then
        for _,cords in pairs(osc_param.areas['showhide_wc']) do
            set_virt_mouse_area(cords.x1, cords.y1, cords.x2, cords.y2, 'showhide_wc')
        end
    else
        set_virt_mouse_area(0, 0, 0, 0, 'showhide_wc')
    end
    do_enable_key_bindings()

    -- mouse input area
    local mouse_over_osc = false

    for _,cords in ipairs(osc_param.areas['input']) do
        if state.osc_visible then -- activate only when OSC is actually visible
            set_virt_mouse_area(cords.x1, cords.y1, cords.x2, cords.y2, 'input')
        end
        if state.osc_visible ~= state.input_enabled then
            if state.osc_visible then
                mp.enable_key_bindings('input')
            else
                mp.disable_key_bindings('input')
            end
            state.input_enabled = state.osc_visible
        end

        if (mouse_hit_coords(cords.x1, cords.y1, cords.x2, cords.y2) and user_opts.osc_keep_with_cursor) then
            mouse_over_osc = true
        end
    end

    if osc_param.areas['window-controls'] then
        for _,cords in ipairs(osc_param.areas['window-controls']) do
            if state.osc_visible then -- activate only when OSC is actually visible
                set_virt_mouse_area(cords.x1, cords.y1, cords.x2, cords.y2, 'window-controls')
                mp.enable_key_bindings('window-controls')
            else
                mp.disable_key_bindings('window-controls')
            end

            if (mouse_hit_coords(cords.x1, cords.y1, cords.x2, cords.y2) and user_opts.osc_keep_with_cursor) then
                mouse_over_osc = true
            end
        end
    end

    -- autohide
    if not (state.showtime == nil) and (get_hide_timeout() >= 0) then
        local timeout = state.showtime + (get_hide_timeout()/1000) - now
        if timeout <= 0 then
            if (state.active_element == nil) and (user_opts.bottom_hover or not (mouse_over_osc)) then
                if (not (state.paused and user_opts.keep_on_pause)) then
                    hide_osc()
                end
            end
        else
            -- the timer is only used to recheck the state and to possibly run
            -- the code above again
            if not state.hide_timer then
                state.hide_timer = mp.add_timeout(0, tick)
            end
            state.hide_timer.timeout = timeout
            -- re-arm
            state.hide_timer:kill()
            state.hide_timer:resume()
        end
    end


    -- actual rendering
    local ass = mp.assdraw.ass_new()

    -- Messages
    render_message(ass)

    -- actual OSC
    if state.osc_visible then
        render_elements(ass)
    end

    if state.persistent_progresstoggle then
        render_persistent_progressbar(ass)
    end

    -- submit
    set_osd(osc_param.playresy * osc_param.display_aspect, osc_param.playresy, ass.text)
end

--
-- Event handling
--

local function element_has_action(element, action)
    return element and element.eventresponder and
        element.eventresponder[action]
end

function process_event(source, what)
    local action = string.format('%s%s', source,
        what and ('_' .. what) or '')

    if what == 'down' or what == 'press' then

        reset_timeout() -- clicking resets the hideosc timer

        for n = 1, #elements do

            if mouse_hit(elements[n]) and
                elements[n].eventresponder and
                (elements[n].eventresponder[source .. '_up'] or
                    elements[n].eventresponder[action]) then

                if what == 'down' then
                    state.active_element = n
                    state.active_event_source = source
                end
                -- fire the down or press event if the element has one
                if element_has_action(elements[n], action) then
                    elements[n].eventresponder[action](elements[n])
                end

            end
        end

    elseif what == 'up' then

        if elements[state.active_element] then
            local n = state.active_element

            if n == 0 then
                --click on background (does not work)
            elseif element_has_action(elements[n], action) and
                mouse_hit(elements[n]) then

                elements[n].eventresponder[action](elements[n])
            end

            --reset active element
            if element_has_action(elements[n], 'reset') then
                elements[n].eventresponder['reset'](elements[n])
            end

        end
        state.active_element = nil
        state.mouse_down_counter = 0

    elseif source == 'mouse_move' then
        state.mouse_in_window = true

        local mouseX, mouseY = get_virt_mouse_pos()
        if (user_opts.min_mouse_move == 0) or
            (not ((state.last_mouseX == nil) or (state.last_mouseY == nil)) and
                ((math.abs(mouseX - state.last_mouseX) >= user_opts.min_mouse_move)
                    or (math.abs(mouseY - state.last_mouseY) >= user_opts.min_mouse_move)
                )
            ) then
                if user_opts.bottom_hover then -- if enabled, only show osc if mouse is hovering at the bottom of the screen (where the UI elements are)
                    local top_hover = window_controls_enabled() and (user_opts.window_title or user_opts.window_top_bar)
                    if mouseY > osc_param.playresy - (user_opts.bottom_hover_zone or 200) or
                        (user_opts.window_top_bar == "yes" or (not state.border) or (not state.title_bar) or state.fullscreen) and (mouseY < 40 and top_hover) then
                        show_osc()
                    else
                        hide_osc()
                    end
                else
                    show_osc()
                end
        end
        state.last_mouseX, state.last_mouseY = mouseX, mouseY

        local n = state.active_element
        if element_has_action(elements[n], action) then
            elements[n].eventresponder[action](elements[n])
        end
    end

    -- ensure rendering after any (mouse) event - icons could change etc
    request_tick()
end

-- called by mpv on every frame
function tick()
    if not state.enabled then return end

    if state.idle then -- this is the screen mpv opens to (not playing a file directly), or if you quit a video (idle=yes in mpv.conf)

        -- render idle message
        mp.msg.trace('idle message')
        local _, _, display_aspect = mp.get_osd_size()
        if display_aspect == 0 then
            return
        end
        local display_h = 360
        local display_w = display_h * display_aspect
        -- logo is rendered at 2^(6-1) = 32 times resolution with size 1800x1800
        local icon_x, icon_y = (display_w - 1800 / 32) / 2, 140
        local line_prefix = ('{\\rDefault\\an7\\1a&H00&\\bord0\\shad0\\pos(%f,%f)}'):format(icon_x, icon_y)

        local ass = mp.assdraw.ass_new()

        -- mpv logo
        if user_opts.idle_screen then
            for i, line in ipairs(logo_lines) do
                ass:new_event()
                ass:append(line_prefix .. line)
            end
        end

        -- Santa hat
        if is_december and user_opts.idle_screen and not user_opts.green_and_grumpy then
            for i, line in ipairs(santa_hat_lines) do
                ass:new_event()
                ass:append(line_prefix .. line)
            end
        end

        if user_opts.idle_screen then
            ass:new_event()
            ass:pos(display_w / 2, icon_y + 65)
            ass:an(8)
            ass:append("{\\fs24\\1c&H0&\\1c&HFFFFFF&}" .. texts.welcome)
        end
        set_osd(display_w, display_h, ass.text)

        if state.showhide_enabled then
            mp.disable_key_bindings('showhide')
            mp.disable_key_bindings('showhide_wc')
            state.showhide_enabled = false
        end

    elseif (state.fullscreen and user_opts.show_fullscreen)
        or (not state.fullscreen and user_opts.show_windowed) then

        -- render the OSC
        render()
    else
        -- Flush OSD
        render_wipe()
    end

    state.tick_last_time = mp.get_time()

    if state.anitype ~= nil then
        -- state.anistart can be nil - animation should now start, or it can
        -- be a timestamp when it started. state.idle has no animation.
        if not state.idle and
           (not state.anistart or
            mp.get_time() < 1 + state.anistart + user_opts.fade_duration/1000)
        then
            -- animating or starting, or still within 1s past the deadline
            request_tick()
        else
            kill_animation()
        end
    end
end

mp.register_event('start-file', newfilereset)
mp.register_event("file-loaded", startupevents)
mp.observe_property('track-list', nil, request_init)
mp.observe_property('playlist', nil, request_init)
mp.observe_property("chapter-list", "native", function(_, list) -- chapter list changes
    list = list or {}  -- safety, shouldn't return nil
    table.sort(list, function(a, b) return a.time < b.time end)
    state.chapter_list = list
    -- make_sponsorblock_segments()
    request_init()
end)
mp.observe_property('seeking', nil, function()
    if user_opts.seek_resets_hide_timeout then
        reset_timeout()
    end
    if user_opts.osc_on_seek and not state.new_file_flag then
        show_osc()
    elseif state.new_file_flag then
        state.new_file_flag = false
    end
end)

if user_opts.key_bindings then
    local function change_chapter(number)
        mp.commandv("add", "chapter", number)
        reset_timeout()
        show_message(get_chapterlist())
    end

    -- chapter scrubbing
    mp.add_key_binding("ctrl+left", "prevfile", function()
        mp.commandv('playlist-prev', 'weak')
        destroy_scrolling_keys()
    end);
    mp.add_key_binding("ctrl+right", "nextfile", function()
        mp.commandv('playlist-next', 'weak')
        destroy_scrolling_keys()
    end);
    mp.add_key_binding("shift+left", "prevchapter", function()
        change_chapter(-1)
    end);
    mp.add_key_binding("shift+right", "nextchapter", function()
        change_chapter(1)
    end);

    -- extra key bindings
    mp.add_key_binding("z", "cycleaudiotracks", function()
        mp.set_property_number("secondary-sid", 0)
        set_track("audio", 1)
        show_message(get_tracklist("audio"))
    end);

    mp.add_key_binding("c", "cyclecaptions", function()
        mp.set_property_number("secondary-sid", 0)
        set_track("sub", 1)
        show_message(get_tracklist("sub"))
    end);

    if user_opts.persistent_progress_toggle then
        mp.add_key_binding("b", "persistenttoggle", function()
            if user_opts.persistent_progress_toggle then
                state.persistent_progresstoggle = not state.persistent_progresstoggle
                tick()
            end
        end);
    end

    if user_opts.show_description then
        mp.add_key_binding("d", "show_description", check_description);
    end

    mp.add_key_binding("tab", 'get_chapterlist', function() show_message(get_chapterlist()) end)

    mp.add_key_binding("p", "pinwindow", function()
        mp.commandv("cycle", "ontop")
        if state.initialborder == 'yes' then
            if mp.get_property('ontop') == 'yes' then
                show_message("Pinned window")
                mp.commandv('set', 'border', "no")
            else
                show_message("Unpinned window")
                mp.commandv('set', 'border', "yes")
            end
        end
    end);

    mp.add_key_binding("ctrl+s", "shuffle_playlist", shuffle_playlist);

    mp.add_key_binding(nil, 'show_osc', function() show_osc() end)
end

mp.observe_property('fullscreen', 'bool',
    function(name, val)
        state.fullscreen = val
        request_init_resize()
    end
)
mp.observe_property('mute', 'bool',
    function(name, val)
        state.mute = val
    end
)
mp.observe_property('paused-for-cache', 'bool',
    function(name, val)
        state.buffering = val
    end
)
mp.observe_property('loop-file', 'bool',
    function(name, val) -- ensure compatibility with auto looping scripts (eg: a script that sets videos under 2 seconds to loop by default)
        if (val == nil) then
            state.looping = true;
        else
            state.looping = false
        end
    end
)
mp.observe_property('border', 'bool',
    function(name, val)
        state.border = val
        request_init_resize()
    end
)
mp.observe_property('title-bar', 'bool',
    function(name, val)
        state.title_bar = val
        request_init_resize()
    end
)
mp.observe_property('window-maximized', 'bool',
    function(name, val)
        state.maximized = val
        request_init_resize()
    end
)
mp.observe_property('idle-active', 'bool',
    function(name, val)
        state.idle = val
        request_tick()
    end
)
mp.observe_property('pause', 'bool', pause_state)
mp.observe_property('demuxer-cache-state', 'native', cache_state)
mp.observe_property('vo-configured', 'bool', function(name, val)
    request_tick()
end)
mp.observe_property('playback-time', 'number', function(name, val)
    request_tick()
end)
mp.observe_property('osd-dimensions', 'native', function(name, val)
    -- (we could use the value instead of re-querying it all the time, but then
    --  we might have to worry about property update ordering)
    request_init_resize()
end)
mp.observe_property("display-fps", "number", set_tick_delay)
-- mouse show/hide bindings
mp.set_key_bindings({
    {'mouse_move',              function(e) process_event('mouse_move', nil) end},
    {'mouse_leave',             mouse_leave},
}, 'showhide', 'force')
mp.set_key_bindings({
    {'mouse_move',              function(e) process_event('mouse_move', nil) end},
    {'mouse_leave',             mouse_leave},
}, 'showhide_wc', 'force')
do_enable_key_bindings()

--mouse input bindings
mp.set_key_bindings({
    {"mbtn_left",           function(e) process_event("mbtn_left", "up") end,
                            function(e) process_event("mbtn_left", "down")  end},
    {"shift+mbtn_left",     function(e) process_event("shift+mbtn_left", "up") end,
                            function(e) process_event("shift+mbtn_left", "down")  end},
    {"shift+mbtn_right",    function(e) process_event("shift+mbtn_right", "up") end,
                            function(e) process_event("shift+mbtn_right", "down")  end},
    {"mbtn_right",          function(e) process_event("mbtn_right", "up") end,
                            function(e) process_event("mbtn_right", "down")  end},
    {"mbtn_mid",            function(e) process_event("shift+mbtn_left", "up") end,
                            function(e) process_event("shift+mbtn_left", "down")  end},
    {"wheel_up",            function(e) process_event("wheel_up", "press") end},
    {"wheel_down",          function(e) process_event("wheel_down", "press") end},
    {"mbtn_left_dbl",       "ignore"},
    {"shift+mbtn_left_dbl", "ignore"},
    {"mbtn_right_dbl",      "ignore"},
}, "input", "force")
mp.enable_key_bindings('input')

mp.set_key_bindings({
    {'mbtn_left',           function(e) process_event('mbtn_left', 'up') end,
                            function(e) process_event('mbtn_left', 'down')  end},
}, 'window-controls', 'force')
mp.enable_key_bindings('window-controls')

function reset_timeout()
    state.showtime = mp.get_time()
end

-- mode can be auto/always/never/cycle
-- the modes only affect internal variables and not stored on its own.
function visibility_mode(mode)
    enable_osc(true)

    mp.set_property_native("user-data/osc/visibility", mode)

    -- Reset the input state on a mode change. The input state will be
    -- recalcuated on the next render cycle, except in 'never' mode where it
    -- will just stay disabled.
    mp.disable_key_bindings("input")
    mp.disable_key_bindings("window-controls")
    state.input_enabled = false
    request_tick()
end

mp.register_script_message("thumbfast-info", function(json)
    local data = mp.utils.parse_json(json)
    if type(data) ~= "table" or not data.width or not data.height then
        mp.msg.error("thumbfast-info: received json didn't produce a table with thumbnail information")
    else
        thumbfast = data
    end
end)

mp.register_script_message("sponsorblock-done", make_sponsorblock_segments)

set_virt_mouse_area(0, 0, 0, 0, 'input')
set_virt_mouse_area(0, 0, 0, 0, 'window-controls')
mp.set_property("title", "mpv")
