diff --git a/.gitmodules b/.gitmodules
index 4eb890c..7a54796 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -7,3 +7,6 @@
 [submodule "multimedia/.local/share/vimiv/plugins/batchmark"]
 	path = multimedia/.local/share/vimiv/plugins/batchmark
 	url = https://github.com/jcjgraf/BatchMark
+[submodule "multimedia/.config/mpv/scripts/uosc_repo"]
+	path = multimedia/.config/mpv/scripts/uosc_repo
+	url = git@github.com:tomasklaen/uosc.git
diff --git a/multimedia/.config/mpv/scripts/uosc b/multimedia/.config/mpv/scripts/uosc
new file mode 120000
index 0000000..7eb74e7
--- /dev/null
+++ b/multimedia/.config/mpv/scripts/uosc
@@ -0,0 +1 @@
+uosc_repo/src/uosc
\ No newline at end of file
diff --git a/multimedia/.config/mpv/scripts/uosc/bin/ziggy-darwin b/multimedia/.config/mpv/scripts/uosc/bin/ziggy-darwin
deleted file mode 100755
index b51c032..0000000
Binary files a/multimedia/.config/mpv/scripts/uosc/bin/ziggy-darwin and /dev/null differ
diff --git a/multimedia/.config/mpv/scripts/uosc/bin/ziggy-linux b/multimedia/.config/mpv/scripts/uosc/bin/ziggy-linux
deleted file mode 100755
index a1e0652..0000000
Binary files a/multimedia/.config/mpv/scripts/uosc/bin/ziggy-linux and /dev/null differ
diff --git a/multimedia/.config/mpv/scripts/uosc/bin/ziggy-windows.exe b/multimedia/.config/mpv/scripts/uosc/bin/ziggy-windows.exe
deleted file mode 100755
index 8391400..0000000
Binary files a/multimedia/.config/mpv/scripts/uosc/bin/ziggy-windows.exe and /dev/null differ
diff --git a/multimedia/.config/mpv/scripts/uosc/elements/Updater.lua b/multimedia/.config/mpv/scripts/uosc/elements/Updater.lua
deleted file mode 100644
index f09389c..0000000
--- a/multimedia/.config/mpv/scripts/uosc/elements/Updater.lua
+++ /dev/null
@@ -1,170 +0,0 @@
-local Element = require('elements/Element')
-local dots = {'.', '..', '...'}
-
-local function cleanup_output(output)
-	return tostring(output):gsub('%c*\n%c*', '\n'):match('^[%s%c]*(.-)[%s%c]*$')
-end
-
----@class Updater : Element
-local Updater = class(Element)
-
-function Updater:new() return Class.new(self) --[[@as Updater]] end
-function Updater:init()
-	Element.init(self, 'updater', {render_order = 1000})
-	self.output = nil
-	self.message = t('Updating uosc')
-	self.state = 'pending' -- Matches icon name
-	local config_dir = mp.command_native({'expand-path', '~~/'})
-
-	Elements:maybe('curtain', 'register', self.id)
-
-	local function handle_result(success, result, error)
-		if success and result and result.status == 0 then
-			self.state = 'done'
-			self.message = t('uosc has been installed. Restart mpv for it to take effect.')
-		else
-			self.state = 'error'
-			self.message = t('An error has occurred.') .. ' ' .. t('See above for clues.')
-		end
-
-		local output = (result.stdout or '') .. '\n' .. (error or result.stderr or '')
-		if state.platform == 'darwin' then
-			output =
-				'Self-updater is known not to work on MacOS.\nIf you know about a solution, please make an issue and share it with us!.\n' ..
-				output
-		end
-
-		self.output = ass_escape(cleanup_output(output))
-
-		request_render()
-	end
-
-	local function update(args)
-		local env = utils.get_env_list()
-		env[#env + 1] = 'MPV_CONFIG_DIR=' .. config_dir
-
-		mp.command_native_async({
-			name = 'subprocess',
-			capture_stderr = true,
-			capture_stdout = true,
-			playback_only = false,
-			args = args,
-			env = env,
-		}, handle_result)
-	end
-
-	if state.platform == 'windows' then
-		local url = 'https://raw.githubusercontent.com/tomasklaen/uosc/HEAD/installers/windows.ps1'
-		update({'powershell', '-NoProfile', '-Command', 'irm ' .. url .. ' | iex'})
-	else
-		-- Detect missing dependencies. We can't just let the process run and
-		-- report an error, as on snap packages there's no error. Everything
-		-- either exits with 0, or no helpful output/error message.
-		local missing = {}
-
-		for _, name in ipairs({'curl', 'unzip'}) do
-			local result = mp.command_native({
-				name = 'subprocess',
-				capture_stdout = true,
-				playback_only = false,
-				args = {'which', name},
-			})
-			local path = cleanup_output(result and result.stdout or '')
-			if path == '' then
-				missing[#missing + 1] = name
-			end
-		end
-
-		if #missing > 0 then
-			local stderr = 'Missing dependencies: ' .. table.concat(missing, ', ')
-			if config_dir:match('/snap/') then
-				stderr = stderr ..
-					'\nThis is a known error for mpv snap packages.\nYou can still update uosc by entering the Linux install command from uosc\'s readme into your terminal, it just can\'t be done this way.\nIf you know about a solution, please make an issue and share it with us!'
-			end
-			handle_result(false, {stderr = stderr})
-		else
-			local url = 'https://raw.githubusercontent.com/tomasklaen/uosc/HEAD/installers/unix.sh'
-			update({'/bin/bash', '-c', 'source <(curl -fsSL ' .. url .. ')'})
-		end
-	end
-end
-
-function Updater:destroy()
-	Elements:maybe('curtain', 'unregister', self.id)
-	Element.destroy(self)
-end
-
-function Updater:render()
-	local ass = assdraw.ass_new()
-
-	local text_size = math.min(20 * state.scale, display.height / 20)
-	local icon_size = text_size * 2
-	local center_x = round(display.width / 2)
-
-	local color = fg
-	if self.state == 'done' then
-		color = config.color.success
-	elseif self.state == 'error' then
-		color = config.color.error
-	end
-
-	-- Divider
-	local divider_width = round(math.min(500 * state.scale, display.width * 0.8))
-	local divider_half, divider_border_half, divider_y = divider_width / 2, round(1 * state.scale), display.height * 0.65
-	local divider_ay, divider_by = round(divider_y - divider_border_half), round(divider_y + divider_border_half)
-	ass:rect(center_x - divider_half, divider_ay, center_x - icon_size, divider_by, {
-		color = color, border = options.text_border * state.scale, border_color = bg, opacity = 0.5,
-	})
-	ass:rect(center_x + icon_size, divider_ay, center_x + divider_half, divider_by, {
-		color = color, border = options.text_border * state.scale, border_color = bg, opacity = 0.5,
-	})
-	if self.state == 'pending' then
-		ass:spinner(center_x, divider_y, icon_size, {
-			color = fg, border = options.text_border * state.scale, border_color = bg,
-		})
-	else
-		ass:icon(center_x, divider_y, icon_size * 0.8, self.state, {
-			color = color, border = options.text_border * state.scale, border_color = bg,
-		})
-	end
-
-	-- Output
-	local output = self.output or dots[math.ceil((mp.get_time() % 1) * #dots)]
-	ass:txt(center_x, divider_y - icon_size, 2, output, {
-		size = text_size, color = fg, border = options.text_border * state.scale, border_color = bg,
-	})
-
-	-- Message
-	ass:txt(center_x, divider_y + icon_size, 5, self.message, {
-		size = text_size, bold = true, color = color, border = options.text_border * state.scale, border_color = bg,
-	})
-
-	-- Button
-	if self.state ~= 'pending' then
-		-- Background
-		local button_y = divider_y + icon_size * 1.75
-		local button_rect = {
-			ax = round(center_x - icon_size / 2),
-			ay = round(button_y),
-			bx = round(center_x + icon_size / 2),
-			by = round(button_y + icon_size),
-		}
-		local is_hovered = get_point_to_rectangle_proximity(cursor, button_rect) == 0
-		ass:rect(button_rect.ax, button_rect.ay, button_rect.bx, button_rect.by, {
-			color = fg,
-			radius = state.radius,
-			opacity = is_hovered and 1 or 0.5,
-		})
-
-		-- Icon
-		local x = round(button_rect.ax + (button_rect.bx - button_rect.ax) / 2)
-		local y = round(button_rect.ay + (button_rect.by - button_rect.ay) / 2)
-		ass:icon(x, y, icon_size * 0.8, 'close', {color = bg})
-
-		cursor:zone('primary_click', button_rect, function() self:destroy() end)
-	end
-
-	return ass
-end
-
-return Updater
diff --git a/multimedia/.config/mpv/scripts/uosc/intl/es.json b/multimedia/.config/mpv/scripts/uosc/intl/es.json
deleted file mode 100644
index 425c41d..0000000
--- a/multimedia/.config/mpv/scripts/uosc/intl/es.json
+++ /dev/null
@@ -1,59 +0,0 @@
-{
-	"Aspect ratio": "Relación de aspecto",
-	"Audio": "Audio",
-	"Audio device": "Dispositivo de audio",
-	"Audio devices": "Dispositivos de audio",
-	"Audio tracks": "Pistas de audio",
-	"Autoselect device": "Selección automática",
-	"Chapter %s": "Capítulo %s",
-	"Chapters": "Capítulos",
-	"Default": "Por defecto",
-	"Default %s": "Por defecto %s",
-	"Delete file & Next": "Eliminar archivo y siguiente",
-	"Delete file & Prev": "Eliminar archivo y anterior",
-	"Delete file & Quit": "Eliminar archivo y salir",
-	"Disabled": "Desactivado",
-	"Drives": "Unidades",
-	"Edition": "Edición",
-	"Edition %s": "Edición %s",
-	"Editions": "Ediciones",
-	"Empty": "Vacío",
-	"First": "Primero",
-	"Fullscreen": "Pantalla completa",
-	"Last": "Último",
-	"Load": "Abrir",
-	"Load audio": "Añadir una pista de audio",
-	"Load subtitles": "Añadir una pista de subtítulos",
-	"Load video": "Añadir una pista de vídeo",
-	"Loop file": "Repetir archivo",
-	"Loop playlist": "Repetir lista de reproducción",
-	"Menu": "Menú",
-	"Navigation": "Navegación",
-	"Next": "Siguiente",
-	"No file": "Ningún archivo",
-	"Open config folder": "Abrir carpeta de configuración",
-	"Open file": "Abrir un archivo",
-	"Playlist": "Lista de reproducción",
-	"Playlist/Files": "Lista de reproducción / Archivos",
-	"Prev": "Anterior",
-	"Previous": "Anterior",
-	"Quit": "Salir",
-	"Screenshot": "Captura de pantalla",
-	"Show in directory": "Acceder a la carpeta",
-	"Shuffle": "Reproducción aleatoria",
-	"Stream quality": "Calidad del flujo",
-	"Subtitles": "Subtítulos",
-	"Track": "Pista",
-	"Track %s": "Pista %s",
-	"Utils": "Utilidades",
-	"Video": "Vídeo",
-	"%s channel": "%s canal",
-	"%s channels": "%s canales",
-	"default": "por defecto",
-	"drive": "unidad",
-	"external": "externo",
-	"forced": "forzado",
-	"open file": "seleccionar un archivo",
-	"parent dir": "directorio padre",
-	"playlist or file": "archivo o lista de reproducción"
-}
diff --git a/multimedia/.config/mpv/scripts/uosc/lib/menus.lua b/multimedia/.config/mpv/scripts/uosc/lib/menus.lua
deleted file mode 100644
index 3578209..0000000
--- a/multimedia/.config/mpv/scripts/uosc/lib/menus.lua
+++ /dev/null
@@ -1,831 +0,0 @@
----@param data MenuData
----@param opts? {submenu?: string; mouse_nav?: boolean; on_close?: string | string[]}
-function open_command_menu(data, opts)
-	local function run_command(command)
-		if type(command) == 'string' then
-			mp.command(command)
-		else
-			---@diagnostic disable-next-line: deprecated
-			mp.commandv(unpack(command))
-		end
-	end
-	---@type MenuOptions
-	local menu_opts = {}
-	if opts then
-		menu_opts.mouse_nav = opts.mouse_nav
-		if opts.on_close then menu_opts.on_close = function() run_command(opts.on_close) end end
-	end
-	local menu = Menu:open(data, run_command, menu_opts)
-	if opts and opts.submenu then menu:activate_submenu(opts.submenu) end
-	return menu
-end
-
----@param opts? {submenu?: string; mouse_nav?: boolean; on_close?: string | string[]}
-function toggle_menu_with_items(opts)
-	if Menu:is_open('menu') then
-		Menu:close()
-	else
-		open_command_menu({type = 'menu', items = get_menu_items(), search_submenus = true}, opts)
-	end
-end
-
----@param opts {type: string; title: string; list_prop: string; active_prop?: string; serializer: fun(list: any, active: any): MenuDataItem[]; on_select: fun(value: any); on_paste: fun(payload: string); on_move_item?: fun(from_index: integer, to_index: integer, submenu_path: integer[]); on_delete_item?: fun(index: integer, submenu_path: integer[])}
-function create_self_updating_menu_opener(opts)
-	return function()
-		if Menu:is_open(opts.type) then
-			Menu:close()
-			return
-		end
-		local list = mp.get_property_native(opts.list_prop)
-		local active = opts.active_prop and mp.get_property_native(opts.active_prop) or nil
-		local menu
-
-		local function update() menu:update_items(opts.serializer(list, active)) end
-
-		local ignore_initial_list = true
-		local function handle_list_prop_change(name, value)
-			if ignore_initial_list then
-				ignore_initial_list = false
-			else
-				list = value
-				update()
-			end
-		end
-
-		local ignore_initial_active = true
-		local function handle_active_prop_change(name, value)
-			if ignore_initial_active then
-				ignore_initial_active = false
-			else
-				active = value
-				update()
-			end
-		end
-
-		local initial_items, selected_index = opts.serializer(list, active)
-
-		-- Items and active_index are set in the handle_prop_change callback, since adding
-		-- a property observer triggers its handler immediately, we just let that initialize the items.
-		menu = Menu:open(
-			{
-				type = opts.type,
-				title = opts.title,
-				items = initial_items,
-				selected_index = selected_index,
-				on_paste = opts.on_paste,
-			},
-			opts.on_select, {
-				on_open = function()
-					mp.observe_property(opts.list_prop, 'native', handle_list_prop_change)
-					if opts.active_prop then
-						mp.observe_property(opts.active_prop, 'native', handle_active_prop_change)
-					end
-				end,
-				on_close = function()
-					mp.unobserve_property(handle_list_prop_change)
-					mp.unobserve_property(handle_active_prop_change)
-				end,
-				on_move_item = opts.on_move_item,
-				on_delete_item = opts.on_delete_item,
-			})
-	end
-end
-
-function create_select_tracklist_type_menu_opener(menu_title, track_type, track_prop, load_command, download_command)
-	local function serialize_tracklist(tracklist)
-		local items = {}
-
-		if download_command then
-			items[#items + 1] = {
-				title = t('Download'), bold = true, italic = true, hint = t('search online'), value = '{download}',
-			}
-		end
-		if load_command then
-			items[#items + 1] = {
-				title = t('Load'), bold = true, italic = true, hint = t('open file'), value = '{load}',
-			}
-		end
-		if #items > 0 then
-			items[#items].separator = true
-		end
-
-		local first_item_index = #items + 1
-		local active_index = nil
-		local disabled_item = nil
-
-		-- Add option to disable a subtitle track. This works for all tracks,
-		-- but why would anyone want to disable audio or video? Better to not
-		-- let people mistakenly select what is unwanted 99.999% of the time.
-		-- If I'm mistaken and there is an active need for this, feel free to
-		-- open an issue.
-		if track_type == 'sub' then
-			disabled_item = {title = t('Disabled'), italic = true, muted = true, hint = '—', value = nil, active = true}
-			items[#items + 1] = disabled_item
-		end
-
-		for _, track in ipairs(tracklist) do
-			if track.type == track_type then
-				local hint_values = {}
-				local function h(value) hint_values[#hint_values + 1] = value end
-
-				if track.lang then h(track.lang:upper()) end
-				if track['demux-h'] then
-					h(track['demux-w'] and (track['demux-w'] .. 'x' .. track['demux-h']) or (track['demux-h'] .. 'p'))
-				end
-				if track['demux-fps'] then h(string.format('%.5gfps', track['demux-fps'])) end
-				h(track.codec)
-				if track['audio-channels'] then
-					h(track['audio-channels'] == 1
-						and t('%s channel', track['audio-channels'])
-						or t('%s channels', track['audio-channels']))
-				end
-				if track['demux-samplerate'] then h(string.format('%.3gkHz', track['demux-samplerate'] / 1000)) end
-				if track.forced then h(t('forced')) end
-				if track.default then h(t('default')) end
-				if track.external then h(t('external')) end
-
-				items[#items + 1] = {
-					title = (track.title and track.title or t('Track %s', track.id)),
-					hint = table.concat(hint_values, ', '),
-					value = track.id,
-					active = track.selected,
-				}
-
-				if track.selected then
-					if disabled_item then disabled_item.active = false end
-					active_index = #items
-				end
-			end
-		end
-
-		return items, active_index or first_item_index
-	end
-
-	local function handle_select(value)
-		if value == '{download}' then
-			mp.command(download_command)
-		elseif value == '{load}' then
-			mp.command(load_command)
-		else
-			mp.commandv('set', track_prop, value and value or 'no')
-
-			-- If subtitle track was selected, assume the user also wants to see it
-			if value and track_type == 'sub' then
-				mp.commandv('set', 'sub-visibility', 'yes')
-			end
-		end
-	end
-
-	return create_self_updating_menu_opener({
-		title = menu_title,
-		type = track_type,
-		list_prop = 'track-list',
-		serializer = serialize_tracklist,
-		on_select = handle_select,
-		on_paste = function(path) load_track(track_type, path) end,
-	})
-end
-
----@alias NavigationMenuOptions {type: string, title?: string, allowed_types?: string[], keep_open?: boolean, active_path?: string, selected_path?: string; on_open?: fun(); on_close?: fun()}
-
--- Opens a file navigation menu with items inside `directory_path`.
----@param directory_path string
----@param handle_select fun(path: string, mods: Modifiers): nil
----@param opts NavigationMenuOptions
-function open_file_navigation_menu(directory_path, handle_select, opts)
-	directory = serialize_path(normalize_path(directory_path))
-	opts = opts or {}
-
-	if not directory then
-		msg.error('Couldn\'t serialize path "' .. directory_path .. '.')
-		return
-	end
-
-	local files, directories = read_directory(directory.path, {
-		types = opts.allowed_types,
-		hidden = options.show_hidden_files,
-	})
-	local is_root = not directory.dirname
-	local path_separator = path_separator(directory.path)
-
-	if not files or not directories then return end
-
-	sort_strings(directories)
-	sort_strings(files)
-
-	-- Pre-populate items with parent directory selector if not at root
-	-- Each item value is a serialized path table it points to.
-	local items = {}
-
-	if is_root then
-		if state.platform == 'windows' then
-			items[#items + 1] = {title = '..', hint = t('Drives'), value = '{drives}', separator = true}
-		end
-	else
-		items[#items + 1] = {title = '..', hint = t('parent dir'), value = directory.dirname, separator = true}
-	end
-
-	local back_path = items[#items] and items[#items].value
-	local selected_index = #items + 1
-
-	for _, dir in ipairs(directories) do
-		items[#items + 1] = {title = dir, value = join_path(directory.path, dir), hint = path_separator}
-	end
-
-	for _, file in ipairs(files) do
-		items[#items + 1] = {title = file, value = join_path(directory.path, file)}
-	end
-
-	for index, item in ipairs(items) do
-		if not item.value.is_to_parent and opts.active_path == item.value then
-			item.active = true
-			if not opts.selected_path then selected_index = index end
-		end
-
-		if opts.selected_path == item.value then selected_index = index end
-	end
-
-	---@type MenuCallback
-	local function open_path(path, meta)
-		local is_drives = path == '{drives}'
-		local is_to_parent = is_drives or #path < #directory_path
-		local inheritable_options = {
-			type = opts.type, title = opts.title, allowed_types = opts.allowed_types, active_path = opts.active_path,
-			keep_open = opts.keep_open,
-		}
-
-		if is_drives then
-			open_drives_menu(function(drive_path)
-				open_file_navigation_menu(drive_path, handle_select, inheritable_options)
-			end, {
-				type = inheritable_options.type,
-				title = inheritable_options.title,
-				selected_path = directory.path,
-				on_open = opts.on_open,
-				on_close = opts.on_close,
-			})
-			return
-		end
-
-		local info, error = utils.file_info(path)
-
-		if not info then
-			msg.error('Can\'t retrieve path info for "' .. path .. '". Error: ' .. (error or ''))
-			return
-		end
-
-		if info.is_dir and not meta.modifiers.alt and not meta.modifiers.ctrl then
-			--  Preselect directory we are coming from
-			if is_to_parent then
-				inheritable_options.selected_path = directory.path
-			end
-
-			open_file_navigation_menu(path, handle_select, inheritable_options)
-		else
-			handle_select(path, meta.modifiers)
-		end
-	end
-
-	local function handle_back()
-		if back_path then open_path(back_path, {modifiers = {}}) end
-	end
-
-	local menu_data = {
-		type = opts.type,
-		title = opts.title or directory.basename .. path_separator,
-		items = items,
-		keep_open = opts.keep_open,
-		selected_index = selected_index,
-	}
-	local menu_options = {on_open = opts.on_open, on_close = opts.on_close, on_back = handle_back}
-
-	return Menu:open(menu_data, open_path, menu_options)
-end
-
--- Opens a file navigation menu with Windows drives as items.
----@param handle_select fun(path: string): nil
----@param opts? NavigationMenuOptions
-function open_drives_menu(handle_select, opts)
-	opts = opts or {}
-	local process = mp.command_native({
-		name = 'subprocess',
-		capture_stdout = true,
-		playback_only = false,
-		args = {'wmic', 'logicaldisk', 'get', 'name', '/value'},
-	})
-	local items, selected_index = {}, 1
-
-	if process.status == 0 then
-		for _, value in ipairs(split(process.stdout, '\n')) do
-			local drive = string.match(value, 'Name=([A-Z]:)')
-			if drive then
-				local drive_path = normalize_path(drive)
-				items[#items + 1] = {
-					title = drive, hint = t('drive'), value = drive_path, active = opts.active_path == drive_path,
-				}
-				if opts.selected_path == drive_path then selected_index = #items end
-			end
-		end
-	else
-		msg.error(process.stderr)
-	end
-
-	return Menu:open(
-		{type = opts.type, title = opts.title or t('Drives'), items = items, selected_index = selected_index},
-		handle_select
-	)
-end
-
--- On demand menu items loading
-do
-	local items = nil
-	function get_menu_items()
-		if items then return items end
-
-		local input_conf_property = mp.get_property_native('input-conf')
-		local input_conf_iterator
-		if input_conf_property:sub(1, 9) == 'memory://' then
-			-- mpv.net v7
-			local input_conf_lines = split(input_conf_property:sub(10), '\n')
-			local i = 0
-			input_conf_iterator = function()
-				i = i + 1
-				return input_conf_lines[i]
-			end
-		else
-			local input_conf = input_conf_property == '' and '~~/input.conf' or input_conf_property
-			local input_conf_path = mp.command_native({'expand-path', input_conf})
-			local input_conf_meta, meta_error = utils.file_info(input_conf_path)
-
-			-- File doesn't exist
-			if not input_conf_meta or not input_conf_meta.is_file then
-				items = create_default_menu_items()
-				return items
-			end
-
-			input_conf_iterator = io.lines(input_conf_path)
-		end
-
-		local main_menu = {items = {}, items_by_command = {}}
-		local by_id = {}
-
-		for line in input_conf_iterator do
-			local key, command, comment = string.match(line, '%s*([%S]+)%s+(.-)%s+#%s*(.-)%s*$')
-			local title = ''
-
-			if comment then
-				local comments = split(comment, '#')
-				local titles = itable_filter(comments, function(v, i) return v:match('^!') or v:match('^menu:') end)
-				if titles and #titles > 0 then
-					title = titles[1]:match('^!%s*(.*)%s*') or titles[1]:match('^menu:%s*(.*)%s*')
-				end
-			end
-
-			if title ~= '' then
-				local is_dummy = key:sub(1, 1) == '#'
-				local submenu_id = ''
-				local target_menu = main_menu
-				local title_parts = split(title or '', ' *> *')
-
-				for index, title_part in ipairs(#title_parts > 0 and title_parts or {''}) do
-					if index < #title_parts then
-						submenu_id = submenu_id .. title_part
-
-						if not by_id[submenu_id] then
-							local items = {}
-							by_id[submenu_id] = {items = items, items_by_command = {}}
-							target_menu.items[#target_menu.items + 1] = {title = title_part, items = items}
-						end
-
-						target_menu = by_id[submenu_id]
-					else
-						if command == 'ignore' then break end
-						-- If command is already in menu, just append the key to it
-						if key ~= '#' and command ~= '' and target_menu.items_by_command[command] then
-							local hint = target_menu.items_by_command[command].hint
-							target_menu.items_by_command[command].hint = hint and hint .. ', ' .. key or key
-						else
-							-- Separator
-							if title_part:sub(1, 3) == '---' then
-								local last_item = target_menu.items[#target_menu.items]
-								if last_item then last_item.separator = true end
-							else
-								local item = {
-									title = title_part,
-									hint = not is_dummy and key or nil,
-									value = command,
-								}
-								if command == '' then
-									item.selectable = false
-									item.muted = true
-									item.italic = true
-								else
-									target_menu.items_by_command[command] = item
-								end
-								target_menu.items[#target_menu.items + 1] = item
-							end
-						end
-					end
-				end
-			end
-		end
-
-		items = #main_menu.items > 0 and main_menu.items or create_default_menu_items()
-		return items
-	end
-end
-
--- Adapted from `stats.lua`
-function get_keybinds_items()
-	local items = {}
-	local active = find_active_keybindings()
-
-	-- Convert to menu items
-	for _, bind in pairs(active) do
-		items[#items + 1] = {title = bind.cmd, hint = bind.key, value = bind.cmd}
-	end
-
-	-- Sort
-	table.sort(items, function(a, b) return a.title < b.title end)
-
-	return #items > 0 and items or {
-		{
-			title = t('%s are empty', '`input-bindings`'),
-			selectable = false,
-			align = 'center',
-			italic = true,
-			muted = true,
-		},
-	}
-end
-
-function open_stream_quality_menu()
-	if Menu:is_open('stream-quality') then
-		Menu:close()
-		return
-	end
-
-	local ytdl_format = mp.get_property_native('ytdl-format')
-	local items = {}
-
-	for _, height in ipairs(config.stream_quality_options) do
-		local format = 'bestvideo[height<=?' .. height .. ']+bestaudio/best[height<=?' .. height .. ']'
-		items[#items + 1] = {title = height .. 'p', value = format, active = format == ytdl_format}
-	end
-
-	Menu:open({type = 'stream-quality', title = t('Stream quality'), items = items}, function(format)
-		mp.set_property('ytdl-format', format)
-
-		-- Reload the video to apply new format
-		-- This is taken from https://github.com/jgreco/mpv-youtube-quality
-		-- which is in turn taken from https://github.com/4e6/mpv-reload/
-		local duration = mp.get_property_native('duration')
-		local time_pos = mp.get_property('time-pos')
-
-		mp.command('playlist-play-index current')
-
-		-- Tries to determine live stream vs. pre-recorded VOD. VOD has non-zero
-		-- duration property. When reloading VOD, to keep the current time position
-		-- we should provide offset from the start. Stream doesn't have fixed start.
-		-- Decent choice would be to reload stream from it's current 'live' position.
-		-- That's the reason we don't pass the offset when reloading streams.
-		if duration and duration > 0 then
-			local function seeker()
-				mp.commandv('seek', time_pos, 'absolute')
-				mp.unregister_event(seeker)
-			end
-			mp.register_event('file-loaded', seeker)
-		end
-	end)
-end
-
-function open_open_file_menu()
-	if Menu:is_open('open-file') then
-		Menu:close()
-		return
-	end
-
-	local directory
-	local active_file
-
-	if state.path == nil or is_protocol(state.path) then
-		local serialized = serialize_path(get_default_directory())
-		if serialized then
-			directory = serialized.path
-			active_file = nil
-		end
-	else
-		local serialized = serialize_path(state.path)
-		if serialized then
-			directory = serialized.dirname
-			active_file = serialized.path
-		end
-	end
-
-	if not directory then
-		msg.error('Couldn\'t serialize path "' .. state.path .. '".')
-		return
-	end
-
-	-- Update active file in directory navigation menu
-	local menu = nil
-	local function handle_file_loaded()
-		if menu and menu:is_alive() then
-			menu:activate_one_value(normalize_path(mp.get_property_native('path')))
-		end
-	end
-
-	menu = open_file_navigation_menu(
-		directory,
-		function(path, mods)
-			if mods.ctrl then
-				mp.commandv('loadfile', path, 'append')
-			else
-				mp.commandv('loadfile', path)
-				Menu:close()
-			end
-		end,
-		{
-			type = 'open-file',
-			allowed_types = config.types.media,
-			active_path = active_file,
-			keep_open = true,
-			on_open = function() mp.register_event('file-loaded', handle_file_loaded) end,
-			on_close = function() mp.unregister_event(handle_file_loaded) end,
-		}
-	)
-end
-
----@param opts {name: 'subtitles'|'audio'|'video'; prop: 'sub'|'audio'|'video'; allowed_types: string[]}
-function create_track_loader_menu_opener(opts)
-	local menu_type = 'load-' .. opts.name
-	local title = ({
-		subtitles = t('Load subtitles'),
-		audio = t('Load audio'),
-		video = t('Load video'),
-	})[opts.name]
-
-	return function()
-		if Menu:is_open(menu_type) then
-			Menu:close()
-			return
-		end
-
-		local path = state.path
-		if path then
-			if is_protocol(path) then
-				path = false
-			else
-				local serialized_path = serialize_path(path)
-				path = serialized_path ~= nil and serialized_path.dirname or false
-			end
-		end
-		if not path then
-			path = get_default_directory()
-		end
-
-		local function handle_select(path) load_track(opts.prop, path) end
-
-		open_file_navigation_menu(path, handle_select, {
-			type = menu_type, title = title, allowed_types = opts.allowed_types,
-		})
-	end
-end
-
-function open_subtitle_downloader()
-	local menu_type = 'download-subtitles'
-	---@type Menu
-	local menu
-
-	if Menu:is_open(menu_type) then
-		Menu:close()
-		return
-	end
-
-	local search_suggestion, file_path = '', nil
-	local destination_directory = mp.command_native({'expand-path', '~~/subtitles'})
-	local credentials = {'--api-key', config.open_subtitles_api_key, '--agent', config.open_subtitles_agent}
-
-	if state.path then
-		if is_protocol(state.path) then
-			if not is_protocol(state.title) then search_suggestion = state.title end
-		else
-			local serialized_path = serialize_path(state.path)
-			if serialized_path then
-				search_suggestion = serialized_path.filename
-				file_path = state.path
-				destination_directory = serialized_path.dirname
-			end
-		end
-	end
-
-	local handle_select, handle_search
-
-	-- Ensures response is valid, and returns its payload, or handles error reporting,
-	-- and returns `nil`, indicating the consumer should abort response handling.
-	local function ensure_response_data(success, result, error, check)
-		local data
-		if success and result and result.status == 0 then
-			data = utils.parse_json(result.stdout)
-			if not data or not check(data) then
-				data = (data and data.error == true) and data or {
-					error = true,
-					message = t('invalid response json (see console for details)'),
-					message_verbose = 'invalid response json: ' .. utils.to_string(result.stdout),
-				}
-			end
-		else
-			data = {
-				error = true,
-				message = error or t('process exited with code %s (see console for details)', result.status),
-				message_verbose = result.stdout .. result.stderr,
-			}
-		end
-
-		if data.error then
-			local message, message_verbose = data.message or t('unknown error'), data.message_verbose or data.message
-			if message_verbose then msg.error(message_verbose) end
-			menu:update_items({
-				{
-					title = message,
-					hint = t('error'),
-					muted = true,
-					italic = true,
-					selectable = false,
-				},
-			})
-			return
-		end
-
-		return data
-	end
-
-	---@param data {kind: 'file', id: number}|{kind: 'page', query: string, page: number}
-	handle_select = function(data)
-		if data.kind == 'page' then
-			handle_search(data.query, data.page)
-			return
-		end
-
-		menu = Menu:open({
-			type = menu_type .. '-result',
-			search_style = 'disabled',
-			items = {{icon = 'spinner', align = 'center', selectable = false, muted = true}},
-		}, function() end)
-
-		local args = itable_join({config.ziggy_path, 'download-subtitles'}, credentials, {
-			'--file-id', tostring(data.id),
-			'--destination', destination_directory,
-		})
-
-		mp.command_native_async({
-			name = 'subprocess',
-			capture_stderr = true,
-			capture_stdout = true,
-			playback_only = false,
-			args = args,
-		}, function(success, result, error)
-			if not menu:is_alive() then return end
-
-			local data = ensure_response_data(success, result, error, function(data)
-				return type(data.file) == 'string'
-			end)
-
-			if not data then return end
-
-			load_track('sub', data.file)
-
-			menu:update_items({
-				{
-					title = t('Subtitles loaded & enabled'),
-					bold = true,
-					icon = 'check',
-					selectable = false,
-				},
-				{
-					title = t('Remaining downloads today: %s', data.remaining .. '/' .. data.total),
-					italic = true,
-					muted = true,
-					icon = 'file_download',
-					selectable = false,
-				},
-				{
-					title = t('Resets in: %s', data.reset_time),
-					italic = true,
-					muted = true,
-					icon = 'schedule',
-					selectable = false,
-				},
-			})
-		end)
-	end
-
-	---@param query string
-	---@param page number|nil
-	handle_search = function(query, page)
-		if not menu:is_alive() then return end
-		page = math.max(1, type(page) == 'number' and round(page) or 1)
-
-		menu:update_items({{icon = 'spinner', align = 'center', selectable = false, muted = true}})
-
-		local args = itable_join({config.ziggy_path, 'search-subtitles'}, credentials)
-
-		local languages = itable_filter(get_languages(), function(lang) return lang:match('.json$') == nil end)
-		args[#args + 1] = '--languages'
-		args[#args + 1] = table.concat(table_keys(create_set(languages)), ',') -- deduplicates stuff like `en,eng,en`
-
-		args[#args + 1] = '--page'
-		args[#args + 1] = tostring(page)
-
-		if file_path then
-			args[#args + 1] = '--hash'
-			args[#args + 1] = file_path
-		end
-
-		if query and #query > 0 then
-			args[#args + 1] = '--query'
-			args[#args + 1] = query
-		end
-
-		mp.command_native_async({
-			name = 'subprocess',
-			capture_stderr = true,
-			capture_stdout = true,
-			playback_only = false,
-			args = args,
-		}, function(success, result, error)
-			if not menu:is_alive() then return end
-
-			local data = ensure_response_data(success, result, error, function(data)
-				return type(data.data) == 'table' and data.page and data.total_pages
-			end)
-
-			if not data then return end
-
-			local subs = itable_filter(data.data, function(sub)
-				return sub and sub.attributes and sub.attributes.release and type(sub.attributes.files) == 'table' and
-					#sub.attributes.files > 0
-			end)
-			local items = itable_map(subs, function(sub)
-				local hints = {sub.attributes.language}
-				if sub.attributes.foreign_parts_only then hints[#hints + 1] = t('foreign parts only') end
-				if sub.attributes.hearing_impaired then hints[#hints + 1] = t('hearing impaired') end
-				return {
-					title = sub.attributes.release,
-					hint = table.concat(hints, ', '),
-					value = {kind = 'file', id = sub.attributes.files[1].file_id},
-					keep_open = true,
-				}
-			end)
-
-			if #items == 0 then
-				items = {
-					{title = t('no results'), align = 'center', muted = true, italic = true, selectable = false},
-				}
-			end
-
-			if data.page > 1 then
-				items[#items + 1] = {
-					title = t('Previous page'),
-					align = 'center',
-					bold = true,
-					italic = true,
-					icon = 'navigate_before',
-					keep_open = true,
-					value = {kind = 'page', query = query, page = data.page - 1},
-				}
-			end
-
-			if data.page < data.total_pages then
-				items[#items + 1] = {
-					title = t('Next page'),
-					align = 'center',
-					bold = true,
-					italic = true,
-					icon = 'navigate_next',
-					keep_open = true,
-					value = {kind = 'page', query = query, page = data.page + 1},
-				}
-			end
-
-			menu:update_items(items)
-		end)
-	end
-
-	local initial_items = {
-		{title = t('%s to search', 'ctrl+enter'), align = 'center', muted = true, italic = true, selectable = false},
-	}
-
-	menu = Menu:open(
-		{
-			type = menu_type,
-			title = t('enter query'),
-			items = initial_items,
-			search_style = 'palette',
-			on_search = handle_search,
-			search_debounce = 'submit',
-			search_suggestion = search_suggestion,
-		},
-		handle_select
-	)
-end
diff --git a/multimedia/.config/mpv/scripts/uosc_repo/.editorconfig b/multimedia/.config/mpv/scripts/uosc_repo/.editorconfig
new file mode 100644
index 0000000..b7051e5
--- /dev/null
+++ b/multimedia/.config/mpv/scripts/uosc_repo/.editorconfig
@@ -0,0 +1,167 @@
+[*]
+indent_style = tab
+indent_size = 4
+end_of_line = lf
+insert_final_newline = true
+
+[*.md]
+indent_style = space
+indent_size = 2
+
+# see https://github.com/CppCXY/EmmyLuaCodeStyle
+[*.lua]
+# [basic]
+
+# optional space/tab
+indent_style = tab
+# if indent_style is space, this is valid
+indent_size = 4
+# if indent_style is tab, this is valid
+tab_width = 4
+# none/single/double
+quote_style = single
+
+continuation_indent = 4
+
+# this mean utf8 length , if this is 'unset' then the line width is no longer checked
+# this option decides when to chopdown the code
+max_line_length = 120
+
+# optional crlf/lf/cr/auto, if it is 'auto', in windows it is crlf other platforms are lf
+# in neovim the value 'auto' is not a valid option, please use 'unset'
+end_of_line = lf
+
+#  none/ comma / semicolon / only_kv_colon
+table_separator_style = comma
+
+#optional keep/never/always/smart
+trailing_table_separator = smart
+
+# keep/remove/remove_table_only/remove_string_only
+call_arg_parentheses = keep
+
+detect_end_of_line = false
+
+# this will check text end with new line
+insert_final_newline = true
+
+# [space]
+space_around_table_field_list = false
+
+space_before_attribute = false
+
+space_before_function_open_parenthesis = false
+
+space_before_function_call_open_parenthesis = false
+
+space_before_closure_open_parenthesis = false
+
+# optional always/only_string/only_table/none
+# or true/false
+space_before_function_call_single_arg = always
+
+space_before_open_square_bracket = false
+
+space_inside_function_call_parentheses = false
+
+space_inside_function_param_list_parentheses = false
+
+space_inside_square_brackets = false
+
+# like t[#t+1] = 1
+space_around_table_append_operator = false
+
+ignore_spaces_inside_function_call = false
+
+space_before_inline_comment = 1
+
+# [operator space]
+space_around_math_operator = true
+
+space_after_comma = true
+
+space_after_comma_in_for_statement = true
+
+# true/false or none/always/no_space_asym
+space_around_concat_operator = true
+
+space_around_logical_operator = true
+
+# true/false or none/always/no_space_asym
+space_around_assign_operator = true
+
+# [align]
+
+align_call_args = false
+
+align_function_params = false
+
+align_continuous_assign_statement = false
+
+align_continuous_rect_table_field = false
+
+align_continuous_line_space = 2
+
+align_if_branch = false
+
+# option none / always / contain_curly/
+align_array_table = none
+
+align_continuous_similar_call_args = false
+
+align_continuous_inline_comment = false
+
+# option none / always / only_call_stmt
+align_chain_expr = none
+
+# [indent]
+
+never_indent_before_if_condition = false
+
+never_indent_comment_on_if_branch = true
+
+keep_indents_on_empty_lines = false
+
+# [line space]
+
+# The following configuration supports four expressions
+# keep
+# fixed(n)
+# min(n)
+# max(n)
+# for eg. min(2)
+
+line_space_after_if_statement = keep
+
+line_space_after_do_statement = keep
+
+line_space_after_while_statement = keep
+
+line_space_after_repeat_statement = keep
+
+line_space_after_for_statement = keep
+
+line_space_after_local_or_assign_statement = keep
+
+line_space_after_function_statement = keep
+
+line_space_after_expression_statement = keep
+
+line_space_after_comment = keep
+
+line_space_around_block = fixed(1)
+
+# [line break]
+break_all_list_when_line_exceed = false
+
+auto_collapse_lines = false
+
+break_before_braces = false
+
+# [preference]
+ignore_space_after_colon = false
+
+remove_call_expression_list_finish_comma = false
+
+# keep / always / same_line / repalce_with_newline
+end_statement_with_semicolon = keep
diff --git a/multimedia/.config/mpv/scripts/uosc_repo/.gitignore b/multimedia/.config/mpv/scripts/uosc_repo/.gitignore
new file mode 100644
index 0000000..f392381
--- /dev/null
+++ b/multimedia/.config/mpv/scripts/uosc_repo/.gitignore
@@ -0,0 +1,3 @@
+src/uosc/bin
+release
+*.zip
diff --git a/multimedia/.config/mpv/scripts/uosc_repo/LICENSE.LGPL b/multimedia/.config/mpv/scripts/uosc_repo/LICENSE.LGPL
new file mode 100644
index 0000000..4362b49
--- /dev/null
+++ b/multimedia/.config/mpv/scripts/uosc_repo/LICENSE.LGPL
@@ -0,0 +1,502 @@
+                  GNU LESSER GENERAL PUBLIC LICENSE
+                       Version 2.1, February 1999
+
+ Copyright (C) 1991, 1999 Free Software Foundation, Inc.
+ 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+[This is the first released version of the Lesser GPL.  It also counts
+ as the successor of the GNU Library Public License, version 2, hence
+ the version number 2.1.]
+
+                            Preamble
+
+  The licenses for most software are designed to take away your
+freedom to share and change it.  By contrast, the GNU General Public
+Licenses are intended to guarantee your freedom to share and change
+free software--to make sure the software is free for all its users.
+
+  This license, the Lesser General Public License, applies to some
+specially designated software packages--typically libraries--of the
+Free Software Foundation and other authors who decide to use it.  You
+can use it too, but we suggest you first think carefully about whether
+this license or the ordinary General Public License is the better
+strategy to use in any particular case, based on the explanations below.
+
+  When we speak of free software, we are referring to freedom of use,
+not price.  Our General Public Licenses are designed to make sure that
+you have the freedom to distribute copies of free software (and charge
+for this service if you wish); that you receive source code or can get
+it if you want it; that you can change the software and use pieces of
+it in new free programs; and that you are informed that you can do
+these things.
+
+  To protect your rights, we need to make restrictions that forbid
+distributors to deny you these rights or to ask you to surrender these
+rights.  These restrictions translate to certain responsibilities for
+you if you distribute copies of the library or if you modify it.
+
+  For example, if you distribute copies of the library, whether gratis
+or for a fee, you must give the recipients all the rights that we gave
+you.  You must make sure that they, too, receive or can get the source
+code.  If you link other code with the library, you must provide
+complete object files to the recipients, so that they can relink them
+with the library after making changes to the library and recompiling
+it.  And you must show them these terms so they know their rights.
+
+  We protect your rights with a two-step method: (1) we copyright the
+library, and (2) we offer you this license, which gives you legal
+permission to copy, distribute and/or modify the library.
+
+  To protect each distributor, we want to make it very clear that
+there is no warranty for the free library.  Also, if the library is
+modified by someone else and passed on, the recipients should know
+that what they have is not the original version, so that the original
+author's reputation will not be affected by problems that might be
+introduced by others.
+
+  Finally, software patents pose a constant threat to the existence of
+any free program.  We wish to make sure that a company cannot
+effectively restrict the users of a free program by obtaining a
+restrictive license from a patent holder.  Therefore, we insist that
+any patent license obtained for a version of the library must be
+consistent with the full freedom of use specified in this license.
+
+  Most GNU software, including some libraries, is covered by the
+ordinary GNU General Public License.  This license, the GNU Lesser
+General Public License, applies to certain designated libraries, and
+is quite different from the ordinary General Public License.  We use
+this license for certain libraries in order to permit linking those
+libraries into non-free programs.
+
+  When a program is linked with a library, whether statically or using
+a shared library, the combination of the two is legally speaking a
+combined work, a derivative of the original library.  The ordinary
+General Public License therefore permits such linking only if the
+entire combination fits its criteria of freedom.  The Lesser General
+Public License permits more lax criteria for linking other code with
+the library.
+
+  We call this license the "Lesser" General Public License because it
+does Less to protect the user's freedom than the ordinary General
+Public License.  It also provides other free software developers Less
+of an advantage over competing non-free programs.  These disadvantages
+are the reason we use the ordinary General Public License for many
+libraries.  However, the Lesser license provides advantages in certain
+special circumstances.
+
+  For example, on rare occasions, there may be a special need to
+encourage the widest possible use of a certain library, so that it becomes
+a de-facto standard.  To achieve this, non-free programs must be
+allowed to use the library.  A more frequent case is that a free
+library does the same job as widely used non-free libraries.  In this
+case, there is little to gain by limiting the free library to free
+software only, so we use the Lesser General Public License.
+
+  In other cases, permission to use a particular library in non-free
+programs enables a greater number of people to use a large body of
+free software.  For example, permission to use the GNU C Library in
+non-free programs enables many more people to use the whole GNU
+operating system, as well as its variant, the GNU/Linux operating
+system.
+
+  Although the Lesser General Public License is Less protective of the
+users' freedom, it does ensure that the user of a program that is
+linked with the Library has the freedom and the wherewithal to run
+that program using a modified version of the Library.
+
+  The precise terms and conditions for copying, distribution and
+modification follow.  Pay close attention to the difference between a
+"work based on the library" and a "work that uses the library".  The
+former contains code derived from the library, whereas the latter must
+be combined with the library in order to run.
+
+                  GNU LESSER GENERAL PUBLIC LICENSE
+   TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
+
+  0. This License Agreement applies to any software library or other
+program which contains a notice placed by the copyright holder or
+other authorized party saying it may be distributed under the terms of
+this Lesser General Public License (also called "this License").
+Each licensee is addressed as "you".
+
+  A "library" means a collection of software functions and/or data
+prepared so as to be conveniently linked with application programs
+(which use some of those functions and data) to form executables.
+
+  The "Library", below, refers to any such software library or work
+which has been distributed under these terms.  A "work based on the
+Library" means either the Library or any derivative work under
+copyright law: that is to say, a work containing the Library or a
+portion of it, either verbatim or with modifications and/or translated
+straightforwardly into another language.  (Hereinafter, translation is
+included without limitation in the term "modification".)
+
+  "Source code" for a work means the preferred form of the work for
+making modifications to it.  For a library, complete source code means
+all the source code for all modules it contains, plus any associated
+interface definition files, plus the scripts used to control compilation
+and installation of the library.
+
+  Activities other than copying, distribution and modification are not
+covered by this License; they are outside its scope.  The act of
+running a program using the Library is not restricted, and output from
+such a program is covered only if its contents constitute a work based
+on the Library (independent of the use of the Library in a tool for
+writing it).  Whether that is true depends on what the Library does
+and what the program that uses the Library does.
+
+  1. You may copy and distribute verbatim copies of the Library's
+complete source code as you receive it, in any medium, provided that
+you conspicuously and appropriately publish on each copy an
+appropriate copyright notice and disclaimer of warranty; keep intact
+all the notices that refer to this License and to the absence of any
+warranty; and distribute a copy of this License along with the
+Library.
+
+  You may charge a fee for the physical act of transferring a copy,
+and you may at your option offer warranty protection in exchange for a
+fee.
+
+  2. You may modify your copy or copies of the Library or any portion
+of it, thus forming a work based on the Library, and copy and
+distribute such modifications or work under the terms of Section 1
+above, provided that you also meet all of these conditions:
+
+    a) The modified work must itself be a software library.
+
+    b) You must cause the files modified to carry prominent notices
+    stating that you changed the files and the date of any change.
+
+    c) You must cause the whole of the work to be licensed at no
+    charge to all third parties under the terms of this License.
+
+    d) If a facility in the modified Library refers to a function or a
+    table of data to be supplied by an application program that uses
+    the facility, other than as an argument passed when the facility
+    is invoked, then you must make a good faith effort to ensure that,
+    in the event an application does not supply such function or
+    table, the facility still operates, and performs whatever part of
+    its purpose remains meaningful.
+
+    (For example, a function in a library to compute square roots has
+    a purpose that is entirely well-defined independent of the
+    application.  Therefore, Subsection 2d requires that any
+    application-supplied function or table used by this function must
+    be optional: if the application does not supply it, the square
+    root function must still compute square roots.)
+
+These requirements apply to the modified work as a whole.  If
+identifiable sections of that work are not derived from the Library,
+and can be reasonably considered independent and separate works in
+themselves, then this License, and its terms, do not apply to those
+sections when you distribute them as separate works.  But when you
+distribute the same sections as part of a whole which is a work based
+on the Library, the distribution of the whole must be on the terms of
+this License, whose permissions for other licensees extend to the
+entire whole, and thus to each and every part regardless of who wrote
+it.
+
+Thus, it is not the intent of this section to claim rights or contest
+your rights to work written entirely by you; rather, the intent is to
+exercise the right to control the distribution of derivative or
+collective works based on the Library.
+
+In addition, mere aggregation of another work not based on the Library
+with the Library (or with a work based on the Library) on a volume of
+a storage or distribution medium does not bring the other work under
+the scope of this License.
+
+  3. You may opt to apply the terms of the ordinary GNU General Public
+License instead of this License to a given copy of the Library.  To do
+this, you must alter all the notices that refer to this License, so
+that they refer to the ordinary GNU General Public License, version 2,
+instead of to this License.  (If a newer version than version 2 of the
+ordinary GNU General Public License has appeared, then you can specify
+that version instead if you wish.)  Do not make any other change in
+these notices.
+
+  Once this change is made in a given copy, it is irreversible for
+that copy, so the ordinary GNU General Public License applies to all
+subsequent copies and derivative works made from that copy.
+
+  This option is useful when you wish to copy part of the code of
+the Library into a program that is not a library.
+
+  4. You may copy and distribute the Library (or a portion or
+derivative of it, under Section 2) in object code or executable form
+under the terms of Sections 1 and 2 above provided that you accompany
+it with the complete corresponding machine-readable source code, which
+must be distributed under the terms of Sections 1 and 2 above on a
+medium customarily used for software interchange.
+
+  If distribution of object code is made by offering access to copy
+from a designated place, then offering equivalent access to copy the
+source code from the same place satisfies the requirement to
+distribute the source code, even though third parties are not
+compelled to copy the source along with the object code.
+
+  5. A program that contains no derivative of any portion of the
+Library, but is designed to work with the Library by being compiled or
+linked with it, is called a "work that uses the Library".  Such a
+work, in isolation, is not a derivative work of the Library, and
+therefore falls outside the scope of this License.
+
+  However, linking a "work that uses the Library" with the Library
+creates an executable that is a derivative of the Library (because it
+contains portions of the Library), rather than a "work that uses the
+library".  The executable is therefore covered by this License.
+Section 6 states terms for distribution of such executables.
+
+  When a "work that uses the Library" uses material from a header file
+that is part of the Library, the object code for the work may be a
+derivative work of the Library even though the source code is not.
+Whether this is true is especially significant if the work can be
+linked without the Library, or if the work is itself a library.  The
+threshold for this to be true is not precisely defined by law.
+
+  If such an object file uses only numerical parameters, data
+structure layouts and accessors, and small macros and small inline
+functions (ten lines or less in length), then the use of the object
+file is unrestricted, regardless of whether it is legally a derivative
+work.  (Executables containing this object code plus portions of the
+Library will still fall under Section 6.)
+
+  Otherwise, if the work is a derivative of the Library, you may
+distribute the object code for the work under the terms of Section 6.
+Any executables containing that work also fall under Section 6,
+whether or not they are linked directly with the Library itself.
+
+  6. As an exception to the Sections above, you may also combine or
+link a "work that uses the Library" with the Library to produce a
+work containing portions of the Library, and distribute that work
+under terms of your choice, provided that the terms permit
+modification of the work for the customer's own use and reverse
+engineering for debugging such modifications.
+
+  You must give prominent notice with each copy of the work that the
+Library is used in it and that the Library and its use are covered by
+this License.  You must supply a copy of this License.  If the work
+during execution displays copyright notices, you must include the
+copyright notice for the Library among them, as well as a reference
+directing the user to the copy of this License.  Also, you must do one
+of these things:
+
+    a) Accompany the work with the complete corresponding
+    machine-readable source code for the Library including whatever
+    changes were used in the work (which must be distributed under
+    Sections 1 and 2 above); and, if the work is an executable linked
+    with the Library, with the complete machine-readable "work that
+    uses the Library", as object code and/or source code, so that the
+    user can modify the Library and then relink to produce a modified
+    executable containing the modified Library.  (It is understood
+    that the user who changes the contents of definitions files in the
+    Library will not necessarily be able to recompile the application
+    to use the modified definitions.)
+
+    b) Use a suitable shared library mechanism for linking with the
+    Library.  A suitable mechanism is one that (1) uses at run time a
+    copy of the library already present on the user's computer system,
+    rather than copying library functions into the executable, and (2)
+    will operate properly with a modified version of the library, if
+    the user installs one, as long as the modified version is
+    interface-compatible with the version that the work was made with.
+
+    c) Accompany the work with a written offer, valid for at
+    least three years, to give the same user the materials
+    specified in Subsection 6a, above, for a charge no more
+    than the cost of performing this distribution.
+
+    d) If distribution of the work is made by offering access to copy
+    from a designated place, offer equivalent access to copy the above
+    specified materials from the same place.
+
+    e) Verify that the user has already received a copy of these
+    materials or that you have already sent this user a copy.
+
+  For an executable, the required form of the "work that uses the
+Library" must include any data and utility programs needed for
+reproducing the executable from it.  However, as a special exception,
+the materials to be distributed need not include anything that is
+normally distributed (in either source or binary form) with the major
+components (compiler, kernel, and so on) of the operating system on
+which the executable runs, unless that component itself accompanies
+the executable.
+
+  It may happen that this requirement contradicts the license
+restrictions of other proprietary libraries that do not normally
+accompany the operating system.  Such a contradiction means you cannot
+use both them and the Library together in an executable that you
+distribute.
+
+  7. You may place library facilities that are a work based on the
+Library side-by-side in a single library together with other library
+facilities not covered by this License, and distribute such a combined
+library, provided that the separate distribution of the work based on
+the Library and of the other library facilities is otherwise
+permitted, and provided that you do these two things:
+
+    a) Accompany the combined library with a copy of the same work
+    based on the Library, uncombined with any other library
+    facilities.  This must be distributed under the terms of the
+    Sections above.
+
+    b) Give prominent notice with the combined library of the fact
+    that part of it is a work based on the Library, and explaining
+    where to find the accompanying uncombined form of the same work.
+
+  8. You may not copy, modify, sublicense, link with, or distribute
+the Library except as expressly provided under this License.  Any
+attempt otherwise to copy, modify, sublicense, link with, or
+distribute the Library is void, and will automatically terminate your
+rights under this License.  However, parties who have received copies,
+or rights, from you under this License will not have their licenses
+terminated so long as such parties remain in full compliance.
+
+  9. You are not required to accept this License, since you have not
+signed it.  However, nothing else grants you permission to modify or
+distribute the Library or its derivative works.  These actions are
+prohibited by law if you do not accept this License.  Therefore, by
+modifying or distributing the Library (or any work based on the
+Library), you indicate your acceptance of this License to do so, and
+all its terms and conditions for copying, distributing or modifying
+the Library or works based on it.
+
+  10. Each time you redistribute the Library (or any work based on the
+Library), the recipient automatically receives a license from the
+original licensor to copy, distribute, link with or modify the Library
+subject to these terms and conditions.  You may not impose any further
+restrictions on the recipients' exercise of the rights granted herein.
+You are not responsible for enforcing compliance by third parties with
+this License.
+
+  11. If, as a consequence of a court judgment or allegation of patent
+infringement or for any other reason (not limited to patent issues),
+conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License.  If you cannot
+distribute so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you
+may not distribute the Library at all.  For example, if a patent
+license would not permit royalty-free redistribution of the Library by
+all those who receive copies directly or indirectly through you, then
+the only way you could satisfy both it and this License would be to
+refrain entirely from distribution of the Library.
+
+If any portion of this section is held invalid or unenforceable under any
+particular circumstance, the balance of the section is intended to apply,
+and the section as a whole is intended to apply in other circumstances.
+
+It is not the purpose of this section to induce you to infringe any
+patents or other property right claims or to contest validity of any
+such claims; this section has the sole purpose of protecting the
+integrity of the free software distribution system which is
+implemented by public license practices.  Many people have made
+generous contributions to the wide range of software distributed
+through that system in reliance on consistent application of that
+system; it is up to the author/donor to decide if he or she is willing
+to distribute software through any other system and a licensee cannot
+impose that choice.
+
+This section is intended to make thoroughly clear what is believed to
+be a consequence of the rest of this License.
+
+  12. If the distribution and/or use of the Library is restricted in
+certain countries either by patents or by copyrighted interfaces, the
+original copyright holder who places the Library under this License may add
+an explicit geographical distribution limitation excluding those countries,
+so that distribution is permitted only in or among countries not thus
+excluded.  In such case, this License incorporates the limitation as if
+written in the body of this License.
+
+  13. The Free Software Foundation may publish revised and/or new
+versions of the Lesser General Public License from time to time.
+Such new versions will be similar in spirit to the present version,
+but may differ in detail to address new problems or concerns.
+
+Each version is given a distinguishing version number.  If the Library
+specifies a version number of this License which applies to it and
+"any later version", you have the option of following the terms and
+conditions either of that version or of any later version published by
+the Free Software Foundation.  If the Library does not specify a
+license version number, you may choose any version ever published by
+the Free Software Foundation.
+
+  14. If you wish to incorporate parts of the Library into other free
+programs whose distribution conditions are incompatible with these,
+write to the author to ask for permission.  For software which is
+copyrighted by the Free Software Foundation, write to the Free
+Software Foundation; we sometimes make exceptions for this.  Our
+decision will be guided by the two goals of preserving the free status
+of all derivatives of our free software and of promoting the sharing
+and reuse of software generally.
+
+                            NO WARRANTY
+
+  15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO
+WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW.
+EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR
+OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY
+KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE
+LIBRARY IS WITH YOU.  SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME
+THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+  16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN
+WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY
+AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU
+FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR
+CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE
+LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING
+RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A
+FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF
+SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH
+DAMAGES.
+
+                     END OF TERMS AND CONDITIONS
+
+           How to Apply These Terms to Your New Libraries
+
+  If you develop a new library, and you want it to be of the greatest
+possible use to the public, we recommend making it free software that
+everyone can redistribute and change.  You can do so by permitting
+redistribution under these terms (or, alternatively, under the terms of the
+ordinary General Public License).
+
+  To apply these terms, attach the following notices to the library.  It is
+safest to attach them to the start of each source file to most effectively
+convey the exclusion of warranty; and each file should have at least the
+"copyright" line and a pointer to where the full notice is found.
+
+    <one line to give the library's name and a brief idea of what it does.>
+    Copyright (C) <year>  <name of author>
+
+    This library is free software; you can redistribute it and/or
+    modify it under the terms of the GNU Lesser General Public
+    License as published by the Free Software Foundation; either
+    version 2.1 of the License, or (at your option) any later version.
+
+    This library is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+    Lesser General Public License for more details.
+
+    You should have received a copy of the GNU Lesser General Public
+    License along with this library; if not, write to the Free Software
+    Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
+
+Also add information on how to contact you by electronic and paper mail.
+
+You should also get your employer (if you work as a programmer) or your
+school, if any, to sign a "copyright disclaimer" for the library, if
+necessary.  Here is a sample; alter the names:
+
+  Yoyodyne, Inc., hereby disclaims all copyright interest in the
+  library `Frob' (a library for tweaking knobs) written by James Random Hacker.
+
+  <signature of Ty Coon>, 1 April 1990
+  Ty Coon, President of Vice
+
+That's all there is to it!
diff --git a/multimedia/.config/mpv/scripts/uosc_repo/README.md b/multimedia/.config/mpv/scripts/uosc_repo/README.md
new file mode 100644
index 0000000..84f80e9
--- /dev/null
+++ b/multimedia/.config/mpv/scripts/uosc_repo/README.md
@@ -0,0 +1,545 @@
+<div align="center">
+	<h1>uosc</h1>
+	<p>
+		Feature-rich minimalist proximity-based UI for <a href="https://mpv.io">MPV player</a>.
+	</p>
+	<br/>
+	<a href="https://user-images.githubusercontent.com/47283320/195073006-bfa72bcc-89d2-4dc7-b8dc-f3c13273910c.webm"><img src="https://github.com/tomasklaen/uosc/assets/47283320/9f99f2ae-3b65-4935-8af3-8b80c605f022" alt="Preview screenshot"></a>
+</div>
+
+Features:
+
+-   UI elements hide and show based on their proximity to cursor instead of every time mouse moves. This provides 100% control over when you see the UI and when you don't. Click on the preview above to see it in action.
+-   When timeline is unused, it can minimize itself into a small discrete progress bar.
+-   Build your own context menu with nesting support by editing your `input.conf` file.
+-   Configurable controls bar.
+-   Fast and efficient thumbnails with [thumbfast](https://github.com/po5/thumbfast) integration.
+-   UIs for:
+    -   Selecting subtitle/audio/video track.
+    -   [Downloading subtitles](#download-subtitles) from [Open Subtitles](https://www.opensubtitles.com).
+    -   Loading external subtitles.
+    -   Selecting stream quality.
+    -   Quick directory and playlist navigation.
+-   All menus are instantly searchable. Just start typing.
+-   Mouse scroll wheel does multiple things depending on what is the cursor hovering over:
+    -   Timeline: seek by `timeline_step` seconds per scroll.
+    -   Volume bar: change volume by `volume_step` per scroll.
+    -   Speed bar: change speed by `speed_step` per scroll.
+    -   Just hovering video with no UI widget below cursor: your configured wheel bindings from `input.conf`.
+-   Right click on volume or speed elements to reset them.
+-   Transforming chapters into timeline ranges (the red portion of the timeline in the preview).
+-   A lot of useful options and commands to bind keys to.
+-   [API for 3rd party scripts](https://github.com/tomasklaen/uosc/wiki) to extend, or use uosc to render their menus.
+
+[Changelog](https://github.com/tomasklaen/uosc/releases).
+
+## Install
+
+1. These commands will install or update **uosc** and place a default `uosc.conf` file into `script-opts` if it doesn't exist already.
+
+    ### Windows
+
+    _Optional, needed to run a remote script the first time if not enabled already:_
+
+    ```powershell
+    Set-ExecutionPolicy RemoteSigned -Scope CurrentUser
+    ```
+
+    Run:
+
+    ```powershell
+    irm https://raw.githubusercontent.com/tomasklaen/uosc/HEAD/installers/windows.ps1 | iex
+    ```
+
+    _**NOTE**: If this command is run in an mpv installation directory with `portable_config`, it'll install there instead of `AppData`._
+
+    _**NOTE2**: The downloaded archive might trigger false positives in some antiviruses. This is explained in [FAQ below](#why-is-the-release-reported-as-malicious-by-some-antiviruses)._
+
+    ### Linux & macOS
+
+    _Requires **curl** and **unzip**._
+
+    ```sh
+    /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/tomasklaen/uosc/HEAD/installers/unix.sh)"
+    ```
+
+    On Linux, we try to detect what package manager variant of the config location you're using, with precedent being:
+
+    ```
+    ~/.var/app/io.mpv.Mpv     (flatpak)
+    ~/snap/mpv
+    ~/snap/mpv-wayland
+    ~/.config/mpv
+    ```
+
+    To install into any of these locations, make sure the ones above it don't exist.
+
+    ### Manual
+
+    1. Download & extract [`uosc.zip`](https://github.com/tomasklaen/uosc/releases/latest/download/uosc.zip) into your mpv config directory. (_See the [documentation of mpv config locations](https://mpv.io/manual/master/#files)._)
+
+    2. If you don't have it already, download & extract [`uosc.conf`](https://github.com/tomasklaen/uosc/releases/latest/download/uosc.conf) into `script-opts` inside your mpv config directory. It contains all of uosc options along with their default values and documentation.
+
+2. **OPTIONAL**: `mpv.conf` tweaks to better integrate with **uosc**:
+
+    ```config
+    # uosc provides seeking & volume indicators (via flash-timeline and flash-volume commands)
+    # if you decide to use them, you don't need osd-bar
+    osd-bar=no
+
+    # uosc will draw its own window controls and border if you disable window border
+    border=no
+    ```
+
+3. **OPTIONAL**: To have thumbnails in timeline, install [thumbfast](https://github.com/po5/thumbfast). No other step necessary, **uosc** integrates with it seamlessly.
+
+4. **OPTIONAL**: If the UI feels sluggish/slow while playing video, you can remedy this _a bit_ by placing this in your `mpv.conf`:
+
+    ```config
+    video-sync=display-resample
+    ```
+
+    Though this does come at the cost of a little bit higher CPU/GPU load.
+
+    #### What is going on?
+
+    **uosc** places performance as one of its top priorities, but it might feel a bit sluggish because during a video playback, the UI rendering frequency is chained to its frame rate. To test this, you can pause the video which will switch refresh rate to be closer or match the frequency of your monitor, and the UI should feel smoother. This is mpv limitation, and not much we can do about it on our side.
+
+#### Build instructions
+
+To build ziggy (our utility binary) yourself, run:
+
+```
+tools/build ziggy
+```
+
+Which will run the `tools/build(.ps1)` script that builds it for each platform. It requires [go](https://go.dev/) to be installed. Source code is in `src/ziggy`.
+
+## Options
+
+All of the available **uosc** options with their default values are documented in [`uosc.conf`](https://github.com/tomasklaen/uosc/blob/HEAD/src/uosc.conf) file ([download](https://github.com/tomasklaen/uosc/releases/latest/download/uosc.conf)).
+
+To change the font, **uosc** respects the mpv's `osd-font` configuration.
+
+## Navigation
+
+These bindings are active when any **uosc** menu is open (main menu, playlist, load/select subtitles,...):
+
+-   `up`, `down` - Select previous/next item.
+-   `enter` - Activate item or submenu.
+-   `bs` (backspace) - Activate parent menu.
+-   `esc` - Close menu.
+-   `wheel_up`, `wheel_down` - Scroll menu.
+-   `pgup`, `pgdwn`, `home`, `end` - Self explanatory.
+-   `ctrl+f` or `\` - In case `menu_type_to_search` config option is disabled, these two trigger the menu search instead.
+-   `ctrl+backspace` - Delete search query by word.
+-   `shift+backspace` - Clear search query.
+-   Holding `alt` while activating an item should prevent closing the menu (this is just a guideline, not all menus behave this way).
+
+Each menu can also add its own shortcuts and bindings for special actions on items/menu, such as `del` to delete a playlist item, `ctrl+up/down/pgup/pgdwn/home/end` to move it around, etc. These are usually also exposed as item action buttons for you to find out about them that way.
+
+Click on a faded parent menu to go back to it.
+
+## Commands
+
+**uosc** provides various commands with useful features to bind your preferred keys to, or populate your menu with. These are all unbound by default.
+
+To add a keybind to one of this commands, open your `input.conf` file and add one on a new line. The command syntax is `script-binding uosc/{command-name}`.
+
+Example to bind the `tab` key to toggle the ui visibility:
+
+```
+tab  script-binding uosc/toggle-ui
+```
+
+Available commands:
+
+#### `toggle-ui`
+
+Makes the whole UI visible until you call this command again. Useful for peeking remaining time and such while watching.
+
+There's also a `toggle-elements <ids>` message you can send to toggle one or more specific elements by specifying their names separated by comma:
+
+```
+script-message-to uosc toggle-elements timeline,speed
+```
+
+Available element IDs: `timeline`, `controls`, `volume`, `top_bar`, `speed`
+
+Under the hood, `toggle-ui` is using `toggle-elements`, and that is in turn using the `set-min-visibility <visibility> [<ids>]` message. `<visibility>` is a `0-1` floating point. Leave out `<ids>` to set it for all elements.
+
+#### `toggle-progress`
+
+Toggles the timeline progress mode on/off. Progress mode is an always visible thin version of timeline with no text labels. It can be configured using the `progress*` config options.
+
+#### `toggle-title`
+
+Toggles the top bar title between main and alternative title's. This can also be done by clicking on the top bar.
+
+Only relevant if top bar is enabled, `top_bar_alt_title` is configured, and `top_bar_alt_title_place` is `toggle`.
+
+#### `flash-ui`
+
+Command(s) to briefly flash the whole UI. Elements are revealed for a second and then fade away.
+
+To flash individual elements, you can use: `flash-timeline`, `flash-progress`, `flash-top-bar`, `flash-volume`, `flash-speed`, `flash-pause-indicator`, `decide-pause-indicator`
+
+There's also a `flash-elements <ids>` message you can use to flash one or more specific elements. Example:
+
+```
+script-message-to uosc flash-elements timeline,speed
+```
+
+Available element IDs: `timeline`, `progress`, `controls`, `volume`, `top_bar`, `speed`, `pause_indicator`
+
+This is useful in combination with other commands that modify values represented by flashed elements, for example: flashing volume element when changing the volume.
+
+You can use it in your bindings like so:
+
+```
+space        cycle pause; script-binding uosc/flash-pause-indicator
+right        seek  5
+left         seek -5
+shift+right  seek  30; script-binding uosc/flash-timeline
+shift+left   seek -30; script-binding uosc/flash-timeline
+m            no-osd cycle mute; script-binding uosc/flash-volume
+up           no-osd add volume  10; script-binding uosc/flash-volume
+down         no-osd add volume -10; script-binding uosc/flash-volume
+[            no-osd add speed -0.25; script-binding uosc/flash-speed
+]            no-osd add speed  0.25; script-binding uosc/flash-speed
+\            no-osd set speed 1; script-binding uosc/flash-speed
+>            script-binding uosc/next; script-message-to uosc flash-elements top_bar,timeline
+<            script-binding uosc/prev; script-message-to uosc flash-elements top_bar,timeline
+```
+
+Case for `(flash/decide)-pause-indicator`: mpv handles frame stepping forward by briefly resuming the video, which causes pause indicator to flash, and none likes that when they are trying to compare frames. The solution is to enable manual pause indicator (`pause_indicator=manual`) and use `flash-pause-indicator` (for a brief flash) or `decide-pause-indicator` (for a static indicator) as a secondary command to appropriate bindings.
+
+#### `menu`
+
+Toggles default menu. Read [Menu](#menu-1) section below to find out how to fill it up with items you want there.
+
+Note: there's also a `menu-blurred` command that opens a menu without pre-selecting the 1st item, suitable for commands triggered with a mouse, such as control bar buttons.
+
+#### `subtitles`, `audio`, `video`
+
+Menus to select a track of a requested type.
+
+#### `load-subtitles`, `load-audio`, `load-video`
+
+Displays a file explorer with directory navigation to load a requested track type.
+
+For subtitles, the explorer only displays file types defined in `subtitle_types` option. For audio and video, the ones defined in `video_types` and `audio_types` are displayed.
+
+#### `download-subtitles`
+
+A menu to search and download subtitles from [Open Subtitles](https://www.opensubtitles.com). It can also be opened by selecting the **Download** option in `subtitles` menu.
+
+We fetch results for languages defined in *uosc**'s `languages` option, which defaults to your mpv `slang` configuration.
+
+We also hash the current file and send the hash to Open Subtitles so you can search even with empty query and if your file is known, you'll get subtitles exactly for it.
+
+Subtitles will be downloaded to the same directory as currently opened file, or `~~/subtitles` (folder in your mpv config directory) if playing a URL.
+
+Current Open Subtitles limit for unauthenticated requests is **5 download per day**, but searching is unlimited. Authentication raises downloads to 10, which doesn't feel like it's worth the effort of implementing it, so currently there's no way to authenticate. 5 downloads per day seems sufficient for most use cases anyway, as if you need more, you should probably just deal with it in the browser beforehand so you don't have to fiddle with the subtitle downloading menu every time you start playing a new file.
+
+#### `playlist`
+
+Playlist navigation.
+
+#### `chapters`
+
+Chapter navigation.
+
+#### `editions`
+
+Editions menu. Editions are different video cuts available in some mkv files.
+
+#### `stream-quality`
+
+Switch stream quality. This is just a basic re-assignment of `ytdl-format` mpv property from predefined options (configurable with `stream_quality_options`) and video reload, there is no fetching of available formats going on.
+
+#### `keybinds`
+
+Displays a command palette menu with all currently active keybindings (defined in your `input.conf` file, or registered by scripts). Useful to check what command is bound to what shortcut, or the other way around.
+
+#### `open-file`
+
+Open file menu. Browsing starts in current file directory, or user directory when file not available. The explorer only displays file types defined in the `video_types`, `audio_types`, and `image_types` options.
+
+You can use `alt+enter` or `alt+click` to load the whole directory in mpv instead of navigating its contents.
+You can also use `ctrl+enter` or `ctrl+click` to append a file or directory to the playlist.
+
+#### `items`
+
+Opens `playlist` menu when playlist exists, or `open-file` menu otherwise.
+
+#### `next`, `prev`
+
+Open next/previous item in playlist, or file in current directory when there is no playlist. Enable `loop-playlist` to loop around.
+
+#### `first`, `last`
+
+Open first/last item in playlist, or file in current directory when there is no playlist.
+
+#### `next-file`, `prev-file`
+
+Open next/prev file in current directory. Enable `loop-playlist` to loop around
+
+#### `first-file`, `last-file`
+
+Open first/last file in current directory.
+
+#### `shuffle`
+
+Toggle uosc's playlist/directory shuffle mode on or off.
+
+This simply makes the next selected playlist or directory item be random, like the shuffle function of most other players. This does not modify the actual playlist in any way, in contrast to the mpv built-in command `playlist-shuffle`.
+
+#### `delete-file-next`
+
+Delete currently playing file and start next file in playlist (if there is a playlist) or current directory.
+
+Useful when watching episodic content.
+
+#### `delete-file-quit`
+
+Delete currently playing file and quit mpv.
+
+#### `show-in-directory`
+
+Show current file in your operating systems' file explorer.
+
+#### `audio-device`
+
+Switch audio output device.
+
+#### `paste`, `paste-to-open`, `paste-to-playlist`
+
+Commands to paste path or URL in clipboard to either open immediately, or append to playlist.
+
+`paste` will add to playlist if there's any (`playlist-count > 1`), or open immediately otherwise.
+
+`paste-to-playlist` will also open the pasted file if mpv is idle (no file open).
+
+Note: there are alternative ways to open stuff from clipboard without the need to bind these commands:
+
+- When `open-file` menu is open → `ctrl+v` to open path/URL in clipboard.
+- When `playlist` menu is open → `ctrl+v` to add path/URL in clipboard to playlist.
+- When any track menu (`subtitles`, `audio`, `video`) is open → `ctrl+v` to add path/URL in clipboard as a new track.
+
+#### `copy-to-clipboard`
+
+Copy currently open path or URL to clipboard.
+
+Additionally, you can also press `ctrl+c` to copy path of a selected item in `playlist` or all directory listing menus.
+
+#### `open-config-directory`
+
+Open directory with `mpv.conf` in file explorer.
+
+#### `update`
+
+Updates uosc to the latest stable release right from the UI. Available in the "Utils" section of default menu .
+
+Supported environments:
+
+| Env | Works | Note |
+|:---|:---:|---|
+| Windows | ✔️ | _Not tested on older PowerShell versions. You might need to `Set-ExecutionPolicy` from the install instructions and install with the terminal command first._ |
+| Linux (apt) | ✔️ | |
+| Linux (flatpak) | ✔️ | |
+| Linux (snap) | ❌ | We're not allowed to access commands like `curl` even if they're installed. (Or at least this is what I think the issue is.) |
+| MacOS | ❌ | `(23) Failed writing body` error, whatever that means. |
+
+If you know about a solution to fix self-updater for any of the currently broken environments, please make an issue/PR and share it with us!
+
+**Note:** The terminal commands from install instructions still work fine everywhere, so you can use those to update instead.
+
+## Menu
+
+**uosc** provides a way to build, display, and use your own menu. By default it displays a pre-configured menu with common actions.
+
+To display the menu, add **uosc**'s `menu` command to a key of your choice. Example to bind it to **right click** and **menu** buttons:
+
+```
+mbtn_right  script-binding uosc/menu
+menu        script-binding uosc/menu
+```
+
+To display a submenu, send a `show-submenu` message to **uosc** with first parameter specifying menu ID. Example:
+
+```
+R    script-message-to uosc show-submenu "Utils > Aspect ratio"
+```
+
+Note: The **menu** key is the one nobody uses between the **win** and **right_ctrl** keys (it might not be on your keyboard).
+
+### Adding items to menu
+
+Adding items to menu is facilitated by commenting your keybinds in `input.conf` with special comment syntax. **uosc** will than parse this file and build the context menu out of it.
+
+#### Syntax
+
+Comment has to be at the end of the line with the binding.
+
+Comment has to start with `#!` (or `#menu:`).
+
+Text after `#!` is an item title.
+
+Title can be split with `>` to define nested menus. There is no limit on nesting.
+
+Use `#` instead of a key if you don't necessarily want to bind a key to a command, but still want it in the menu.
+
+If multiple menu items with the same command are defined, **uosc** will concatenate them into one item and just display all available shortcuts as that items' hint, while using the title of the first defined item.
+
+Menu items are displayed in the order they are defined in `input.conf` file.
+
+The command `ignore` does not result in a menu item, however all the folders leading up to it will still be created.
+This allows more flexible structuring of the `input.conf` file.
+
+#### Examples
+
+Adds a menu item to load subtitles:
+
+```
+alt+s  script-binding uosc/load-subtitles  #! Load subtitles
+```
+
+Adds a stay-on-top toggle with no keybind:
+
+```
+#  cycle ontop  #! Toggle on-top
+```
+
+Define and display multiple shortcuts in single items' menu hint (items with same command get concatenated):
+
+```
+esc  quit  #! Quit
+q    quit  #!
+```
+
+Define a folder without defining any of its contents:
+
+```
+#  ignore  #! Folder title >
+```
+
+Define an un-selectable, muted, and italic title item by using `#` as key, and omitting the command:
+
+```
+#    #! Title
+#    #! Section > Title
+```
+
+Define a separator between previous and next items by doing the same, but using `---` as title:
+
+```
+#    #! ---
+#    #! Section > ---
+```
+
+Example context menu:
+
+This is the default pre-configured menu if none is defined in your `input.conf`, but with added shortcuts. To both pause & move the window with left mouse button, so that you can have the menu on the right one, enable `click_threshold` in `uosc.conf` (see default `uosc.conf` for example/docs).
+
+```
+menu        script-binding uosc/menu
+mbtn_right  script-binding uosc/menu
+s           script-binding uosc/subtitles          #! Subtitles
+a           script-binding uosc/audio              #! Audio tracks
+q           script-binding uosc/stream-quality     #! Stream quality
+p           script-binding uosc/items              #! Playlist
+c           script-binding uosc/chapters           #! Chapters
+>           script-binding uosc/next               #! Navigation > Next
+<           script-binding uosc/prev               #! Navigation > Prev
+alt+>       script-binding uosc/delete-file-next   #! Navigation > Delete file & Next
+alt+<       script-binding uosc/delete-file-prev   #! Navigation > Delete file & Prev
+alt+esc     script-binding uosc/delete-file-quit   #! Navigation > Delete file & Quit
+o           script-binding uosc/open-file          #! Navigation > Open file
+#           set video-aspect-override "-1"         #! Utils > Aspect ratio > Default
+#           set video-aspect-override "16:9"       #! Utils > Aspect ratio > 16:9
+#           set video-aspect-override "4:3"        #! Utils > Aspect ratio > 4:3
+#           set video-aspect-override "2.35:1"     #! Utils > Aspect ratio > 2.35:1
+#           script-binding uosc/audio-device       #! Utils > Audio devices
+#           script-binding uosc/editions           #! Utils > Editions
+ctrl+s      async screenshot                       #! Utils > Screenshot
+alt+i       script-binding uosc/keybinds           #! Utils > Key bindings
+O           script-binding uosc/show-in-directory  #! Utils > Show in directory
+#           script-binding uosc/open-config-directory #! Utils > Open config directory
+#           script-binding uosc/update             #! Utils > Update uosc
+esc         quit #! Quit
+```
+
+To see all the commands you can bind keys or menu items to, refer to [mpv's list of input commands documentation](https://mpv.io/manual/master/#list-of-input-commands).
+
+## Messages
+
+**uosc** listens on some messages that can be sent with `script-message-to uosc` command. Example:
+
+```
+R    script-message-to uosc show-submenu "Utils > Aspect ratio"
+```
+
+### `show-submenu <menu_id>`, `show-submenu-blurred <menu_id>`
+
+Opens one of the submenus defined in `input.conf` (read on how to build those in the Menu documentation above). To prevent 1st item being preselected, use `show-submenu-blurred` instead.
+
+Parameters
+
+##### `<menu_id>`
+
+ID (title) of the submenu, including `>` subsections as defined in `input.conf`. It has to be match the title exactly.
+
+## Scripting API
+
+3rd party script developers can use our messaging API to integrate with uosc, or use it to render their menus. Documentation is available in [uosc Wiki](https://github.com/tomasklaen/uosc/wiki).
+
+## Contributing
+
+### Localization
+
+If you want to help localizing uosc by either adding a new locale or fixing one that is not up to date, start by running this while in the repository root:
+
+```
+tools/intl languagecode
+```
+
+`languagecode` can be any existing locale in `src/uosc/intl/` directory, or any [IETF language tag](https://en.wikipedia.org/wiki/IETF_language_tag). If it doesn't exist yet, the `intl` tool will create it.
+
+This will parse the codebase for localization strings and use them to either update existing locale by removing unused and setting untranslated strings to `null`, or create a new one with all `null` strings.
+
+You can then navigate to `src/uosc/intl/languagecode.json` and start translating.
+
+### Setting up binaries
+
+If you want to test or work on something that involves ziggy (our multitool binary, currently handles searching & downloading subtitles), you first need to build it with:
+
+```
+tools/build ziggy
+```
+
+This requires [`go`](https://go.dev/dl/) to be installed and in path. If you don't want to bother with installing go, and there were no changes to ziggy, you can just use the binaries from [latest release](https://github.com/tomasklaen/uosc/releases/latest/download/uosc.zip). Place folder `scripts/uosc/bin` from `uosc.zip` into `src/uosc/bin`.
+
+## FAQ
+
+#### Why is the release zip size in megabytes? Isn't this just a lua script?
+
+We are limited in what we can do in mpv's lua scripting environment. To work around this, we include a binary tool (one for each platform), that we call to handle stuff we can't do in lua. Currently this means searching & downloading subtitles, accessing clipboard data, and in future might improve self updating, and potentially other things.
+
+Other scripts usually choose to go the route of adding python scripts and requiring users to install the runtime. I don't like this as I want the installation process to be as seamless and as painless as possible. I also don't want to contribute to potential python version mismatch issues, because one tool depends on 2.7, other latest 3, and this one 3.9 only and no newer (real world scenario that happened to me), now have fun reconciling this. Depending on external runtimes can be a mess, and shipping a stable, tiny, and fast binary that users don't even have to know about is imo more preferable than having unstable external dependencies and additional installation steps that force everyone to install and manage hundreds of megabytes big runtimes in global `PATH`.
+
+#### Why don't you have `uosc-{platform}.zip` releases and only include binaries for the concerned platform in each?
+
+Then you wouldn't be able to sync your mpv config between platforms and everything _just work_.
+
+#### Why is the release reported as malicious by some antiviruses?
+
+Some antiviruses find our binaries suspicious due to the way go packages them. This is a known issue with all go binaries (https://go.dev/doc/faq#virus). I think the only way to solve that would be to sign them (not 100% sure though), but I'm not paying to work on free stuff. If anyone is bothered by this, and would be willing to donate a code signing certificate, let me know.
+
+If you want to check the binaries are safe, the code is in `src/ziggy`, and you can build them yourself by running `tools/build ziggy` in the repository root.
+
+We might eventually rewrite it in something else.
+
+#### Why _uosc_?
+
+It stood for micro osc as it used to render just a couple rectangles before it grew to what it is today. And now it means a minimalist UI design direction where everything is out of your way until needed.
diff --git a/multimedia/.config/mpv/scripts/uosc_repo/go.mod b/multimedia/.config/mpv/scripts/uosc_repo/go.mod
new file mode 100644
index 0000000..6fb8ca9
--- /dev/null
+++ b/multimedia/.config/mpv/scripts/uosc_repo/go.mod
@@ -0,0 +1,12 @@
+module uosc/bins
+
+go 1.21.3
+
+require (
+	github.com/atotto/clipboard v0.1.4
+	github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
+	golang.org/x/exp v0.0.0-20231006140011-7918f672742d
+	k8s.io/apimachinery v0.28.3
+)
+
+require golang.org/x/sys v0.28.0 // indirect
diff --git a/multimedia/.config/mpv/scripts/uosc_repo/go.sum b/multimedia/.config/mpv/scripts/uosc_repo/go.sum
new file mode 100644
index 0000000..6a0384c
--- /dev/null
+++ b/multimedia/.config/mpv/scripts/uosc_repo/go.sum
@@ -0,0 +1,11 @@
+github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
+github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
+github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
+github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
+golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
+golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
+golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
+golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+k8s.io/apimachinery v0.28.3 h1:B1wYx8txOaCQG0HmYF6nbpU8dg6HvA06x5tEffvOe7A=
+k8s.io/apimachinery v0.28.3/go.mod h1:uQTKmIqs+rAYaq+DFaoD2X7pcjLOqbQX2AOiO0nIpb8=
diff --git a/multimedia/.config/mpv/scripts/uosc/char-conv/zh.json b/multimedia/.config/mpv/scripts/uosc_repo/src/uosc/char-conv/zh.json
similarity index 100%
rename from multimedia/.config/mpv/scripts/uosc/char-conv/zh.json
rename to multimedia/.config/mpv/scripts/uosc_repo/src/uosc/char-conv/zh.json
diff --git a/multimedia/.config/mpv/scripts/uosc/elements/BufferingIndicator.lua b/multimedia/.config/mpv/scripts/uosc_repo/src/uosc/elements/BufferingIndicator.lua
similarity index 100%
rename from multimedia/.config/mpv/scripts/uosc/elements/BufferingIndicator.lua
rename to multimedia/.config/mpv/scripts/uosc_repo/src/uosc/elements/BufferingIndicator.lua
diff --git a/multimedia/.config/mpv/scripts/uosc/elements/Button.lua b/multimedia/.config/mpv/scripts/uosc_repo/src/uosc/elements/Button.lua
similarity index 82%
rename from multimedia/.config/mpv/scripts/uosc/elements/Button.lua
rename to multimedia/.config/mpv/scripts/uosc_repo/src/uosc/elements/Button.lua
index b0d29f0..6ae84a3 100644
--- a/multimedia/.config/mpv/scripts/uosc/elements/Button.lua
+++ b/multimedia/.config/mpv/scripts/uosc_repo/src/uosc/elements/Button.lua
@@ -1,6 +1,6 @@
 local Element = require('elements/Element')
 
----@alias ButtonProps {icon: string; on_click: function; anchor_id?: string; active?: boolean; badge?: string|number; foreground?: string; background?: string; tooltip?: string}
+---@alias ButtonProps {icon: string; on_click?: function; is_clickable?: boolean; anchor_id?: string; active?: boolean; badge?: string|number; foreground?: string; background?: string; tooltip?: string}
 
 ---@class Button : Element
 local Button = class(Element)
@@ -17,13 +17,15 @@ function Button:init(id, props)
 	self.badge = props.badge
 	self.foreground = props.foreground or fg
 	self.background = props.background or bg
-	---@type fun()
+	self.is_clickable = true
+	---@type fun()|nil
 	self.on_click = props.on_click
 	Element.init(self, id, props)
 end
 
 function Button:on_coordinates() self.font_size = round((self.by - self.ay) * 0.7) end
 function Button:handle_cursor_click()
+	if not self.on_click or not self.is_clickable then return end
 	-- We delay the callback to next tick, otherwise we are risking race
 	-- conditions as we are in the middle of event dispatching.
 	-- For example, handler might add a menu to the end of the element stack, and that
@@ -37,17 +39,20 @@ function Button:render()
 	cursor:zone('primary_click', self, function() self:handle_cursor_click() end)
 
 	local ass = assdraw.ass_new()
+	local is_clickable = self.is_clickable and self.on_click ~= nil
 	local is_hover = self.proximity_raw == 0
-	local is_hover_or_active = is_hover or self.active
 	local foreground = self.active and self.background or self.foreground
 	local background = self.active and self.foreground or self.background
+	local background_opacity = self.active and 1 or config.opacity.controls
+
+	if is_hover and is_clickable and background_opacity < 0.3 then background_opacity = 0.3 end
 
 	-- Background
-	if is_hover_or_active or config.opacity.controls > 0 then
+	if background_opacity > 0 then
 		ass:rect(self.ax, self.ay, self.bx, self.by, {
 			color = (self.active or not is_hover) and background or foreground,
 			radius = state.radius,
-			opacity = visibility * (self.active and 1 or (is_hover and 0.3 or config.opacity.controls)),
+			opacity = visibility * background_opacity,
 		})
 	end
 
diff --git a/multimedia/.config/mpv/scripts/uosc/elements/Controls.lua b/multimedia/.config/mpv/scripts/uosc_repo/src/uosc/elements/Controls.lua
similarity index 96%
rename from multimedia/.config/mpv/scripts/uosc/elements/Controls.lua
rename to multimedia/.config/mpv/scripts/uosc_repo/src/uosc/elements/Controls.lua
index 12c8e9c..be418e8 100644
--- a/multimedia/.config/mpv/scripts/uosc/elements/Controls.lua
+++ b/multimedia/.config/mpv/scripts/uosc_repo/src/uosc/elements/Controls.lua
@@ -1,6 +1,7 @@
 local Element = require('elements/Element')
 local Button = require('elements/Button')
 local CycleButton = require('elements/CycleButton')
+local ManagedButton = require('elements/ManagedButton')
 local Speed = require('elements/Speed')
 
 -- sizing:
@@ -54,6 +55,7 @@ function Controls:init_options()
 		['loop-playlist'] = 'cycle:repeat:loop-playlist:no/inf!?' .. t('Loop playlist'),
 		['loop-file'] = 'cycle:repeat_one:loop-file:no/inf!?' .. t('Loop file'),
 		shuffle = 'toggle:shuffle:shuffle?' .. t('Shuffle'),
+		autoload = 'toggle:hdr_auto:autoload@uosc?' .. t('Autoload'),
 		fullscreen = 'cycle:crop_free:fullscreen:no/yes=fullscreen_exit!?' .. t('Fullscreen'),
 	}
 
@@ -163,6 +165,19 @@ function Controls:init_options()
 				table_assign(control, {element = element, sizing = 'static', scale = 1, ratio = 1})
 				if badge then self:register_badge_updater(badge, element) end
 			end
+		elseif kind == 'button' then
+			if #params ~= 1 then
+				mp.error(string.format(
+					'managed button needs 1 parameter, %d received: %s', #params, table.concat(params, '/')
+				))
+			else
+				local element = ManagedButton:new('control_' .. i, {
+					name = params[1],
+					render_order = self.render_order,
+					anchor_id = 'controls',
+				})
+				table_assign(control, {element = element, sizing = 'static', scale = 1, ratio = 1})
+			end
 		elseif kind == 'speed' then
 			if not Elements.speed then
 				local element = Speed:new({anchor_id = 'controls', render_order = self.render_order})
diff --git a/multimedia/.config/mpv/scripts/uosc/elements/Curtain.lua b/multimedia/.config/mpv/scripts/uosc_repo/src/uosc/elements/Curtain.lua
similarity index 100%
rename from multimedia/.config/mpv/scripts/uosc/elements/Curtain.lua
rename to multimedia/.config/mpv/scripts/uosc_repo/src/uosc/elements/Curtain.lua
diff --git a/multimedia/.config/mpv/scripts/uosc/elements/CycleButton.lua b/multimedia/.config/mpv/scripts/uosc_repo/src/uosc/elements/CycleButton.lua
similarity index 62%
rename from multimedia/.config/mpv/scripts/uosc/elements/CycleButton.lua
rename to multimedia/.config/mpv/scripts/uosc_repo/src/uosc/elements/CycleButton.lua
index 61cd666..8aa8175 100644
--- a/multimedia/.config/mpv/scripts/uosc/elements/CycleButton.lua
+++ b/multimedia/.config/mpv/scripts/uosc_repo/src/uosc/elements/CycleButton.lua
@@ -3,6 +3,16 @@ local Button = require('elements/Button')
 ---@alias CycleState {value: any; icon: string; active?: boolean}
 ---@alias CycleButtonProps {prop: string; states: CycleState[]; anchor_id?: string; tooltip?: string}
 
+local function yes_no_to_boolean(value)
+	if type(value) ~= 'string' then return value end
+	local lowercase = trim(value):lower()
+	if lowercase == 'yes' or lowercase == 'no' then
+		return lowercase == 'yes'
+	else
+		return value
+	end
+end
+
 ---@class CycleButton : Button
 local CycleButton = class(Button)
 
@@ -24,17 +34,29 @@ function CycleButton:init(id, props)
 	self.on_click = function()
 		local new_state = self.states[self.current_state_index + 1] or self.states[1]
 		local new_value = new_state.value
-		if self.owner then
+		if self.owner == 'uosc' then
+			if type(options[self.prop]) == 'number' then
+				options[self.prop] = tonumber(new_value) or 0
+			else
+				options[self.prop] = yes_no_to_boolean(new_value)
+			end
+			handle_options({[self.prop] = options[self.prop]})
+		elseif self.owner then
 			mp.commandv('script-message-to', self.owner, 'set', self.prop, new_value)
 		elseif is_state_prop then
-			if itable_index_of({'yes', 'no'}, new_value) then new_value = new_value == 'yes' end
-			set_state(self.prop, new_value)
+			set_state(self.prop, yes_no_to_boolean(new_value))
 		else
 			mp.set_property(self.prop, new_value)
 		end
 	end
 
 	local function handle_change(name, value)
+		-- Removes unnecessary floating point digits from values like `2.00000`.
+		-- This happens when observing properties like `speed`.
+		if type(value) == 'string' and string.match(value, '^[%+%-]?%d+%.%d+$') then
+			value = tonumber(value)
+		end
+
 		value = type(value) == 'boolean' and (value and 'yes' or 'no') or tostring(value or '')
 		local index = itable_find(self.states, function(state) return state.value == value end)
 		self.current_state_index = index or 1
@@ -46,8 +68,13 @@ function CycleButton:init(id, props)
 	local prop_parts = split(self.prop, '@')
 	if #prop_parts == 2 then -- External prop with a script owner
 		self.prop, self.owner = prop_parts[1], prop_parts[2]
-		self['on_external_prop_' .. self.prop] = function(_, value) handle_change(self.prop, value) end
-		handle_change(self.prop, external[self.prop])
+		if self.owner == 'uosc' then
+			self['on_options'] = function() handle_change(self.prop, options[self.prop]) end
+			handle_change(self.prop, options[self.prop])
+		else
+			self['on_external_prop_' .. self.prop] = function(_, value) handle_change(self.prop, value) end
+			handle_change(self.prop, external[self.prop])
+		end
 	elseif is_state_prop then -- uosc's state props
 		self['on_prop_' .. self.prop] = function(self, value) handle_change(self.prop, value) end
 		handle_change(self.prop, state[self.prop])
diff --git a/multimedia/.config/mpv/scripts/uosc/elements/Element.lua b/multimedia/.config/mpv/scripts/uosc_repo/src/uosc/elements/Element.lua
similarity index 73%
rename from multimedia/.config/mpv/scripts/uosc/elements/Element.lua
rename to multimedia/.config/mpv/scripts/uosc_repo/src/uosc/elements/Element.lua
index 733d63e..3877b90 100644
--- a/multimedia/.config/mpv/scripts/uosc/elements/Element.lua
+++ b/multimedia/.config/mpv/scripts/uosc_repo/src/uosc/elements/Element.lua
@@ -27,6 +27,8 @@ function Element:init(id, props)
 	self.anchor_id = nil
 	---@type fun()[] Disposer functions called when element is destroyed.
 	self._disposers = {}
+	---@type table<string,table<string, boolean>> Namespaced active key bindings. Default namespace is `_`.
+	self._key_bindings = {}
 
 	if props then table_assign(self, props) end
 
@@ -35,7 +37,7 @@ function Element:init(id, props)
 		local function getTo() return self.proximity end
 		local function onTweenEnd() self.forced_visibility = nil end
 		if self.enabled then
-			self:tween_property('forced_visibility', 1, getTo, onTweenEnd)
+			self:tween_property('forced_visibility', self:get_visibility(), getTo, onTweenEnd)
 		else
 			onTweenEnd()
 		end
@@ -48,6 +50,7 @@ end
 function Element:destroy()
 	for _, disposer in ipairs(self._disposers) do disposer() end
 	self.destroyed = true
+	self:remove_key_bindings()
 	Elements:remove(self)
 end
 
@@ -191,4 +194,67 @@ function Element:observe_mp_property(name, type_or_callback, callback_maybe)
 	self:register_disposer(function() mp.unobserve_property(callback) end)
 end
 
+-- Adds a keybinding for the lifetime of the element, or until removed manually.
+---@param key string mpv key identifier.
+---@param fnFlags fun()|string|table<fun()|string> Callback, or `{callback, flags}` tuple. Callback can be just a method name, in which case it'll be wrapped in `create_action(callback)`.
+---@param namespace? string Keybinding namespace. Default is `_`.
+function Element:add_key_binding(key, fnFlags, namespace)
+	local name = self.id .. '-' .. key
+	local isTuple = type(fnFlags) == 'table'
+	local fn = (isTuple and fnFlags[1] or fnFlags)
+	local flags = isTuple and fnFlags[2] or nil
+	namespace = namespace or '_'
+	local names = self._key_bindings[namespace]
+	if not names then
+		names = {}
+		self._key_bindings[namespace] = names
+	end
+	names[name] = true
+	if type(fn) == 'string' then
+		fn = self:create_action(fn)
+	end
+	mp.add_forced_key_binding(key, name, fn, flags)
+end
+
+-- Remove all or only keybindings belonging to a specific namespace.
+---@param namespace? string Optional keybinding namespace to remove.
+function Element:remove_key_bindings(namespace)
+	local namespaces = namespace and {namespace} or table_keys(self._key_bindings)
+	for _, namespace in ipairs(namespaces) do
+		local names = self._key_bindings[namespace]
+		if names then
+			for name, _ in pairs(names) do
+				mp.remove_key_binding(name)
+			end
+			self._key_bindings[namespace] = nil
+		end
+	end
+end
+
+-- Checks if there are any (at all or namespaced) keybindings for this element.
+---@param namespace? string Only check this namespace.
+function Element:has_keybindings(namespace)
+	if namespace then
+		return self._key_bindings[namespace] ~= nil
+	else
+		return #table_keys(self._key_bindings) > 0
+	end
+end
+
+-- Check if element is not destroyed or otherwise disabled.
+-- Intended to be overridden by inheriting elements to add more checks.
+function Element:is_alive() return not self.destroyed end
+
+-- Wraps a function into a callback that won't run if element is destroyed or otherwise disabled.
+---@param fn fun(...)|string Function or a name of a method on this class to call.
+function Element:create_action(fn)
+	if type(fn) == 'string' then
+		local method = fn
+		fn = function(...) self[method](self, ...) end
+	end
+	return function(...)
+		if self:is_alive() then fn(...) end
+	end
+end
+
 return Element
diff --git a/multimedia/.config/mpv/scripts/uosc/elements/Elements.lua b/multimedia/.config/mpv/scripts/uosc_repo/src/uosc/elements/Elements.lua
similarity index 100%
rename from multimedia/.config/mpv/scripts/uosc/elements/Elements.lua
rename to multimedia/.config/mpv/scripts/uosc_repo/src/uosc/elements/Elements.lua
diff --git a/multimedia/.config/mpv/scripts/uosc_repo/src/uosc/elements/ManagedButton.lua b/multimedia/.config/mpv/scripts/uosc_repo/src/uosc/elements/ManagedButton.lua
new file mode 100644
index 0000000..615cde1
--- /dev/null
+++ b/multimedia/.config/mpv/scripts/uosc_repo/src/uosc/elements/ManagedButton.lua
@@ -0,0 +1,29 @@
+local Button = require('elements/Button')
+
+---@alias ManagedButtonProps {name: string; anchor_id?: string; render_order?: number}
+
+---@class ManagedButton : Button
+local ManagedButton = class(Button)
+
+---@param id string
+---@param props ManagedButtonProps
+function ManagedButton:new(id, props) return Class.new(self, id, props) --[[@as ManagedButton]] end
+---@param id string
+---@param props ManagedButtonProps
+function ManagedButton:init(id, props)
+	---@type string | table | nil
+	self.command = nil
+
+	Button.init(self, id, table_assign({}, props, {on_click = function() execute_command(self.command) end}))
+
+	self:register_disposer(buttons:subscribe(props.name, function(data) self:update(data) end))
+end
+
+function ManagedButton:update(data)
+	for _, prop in ipairs({'icon', 'active', 'badge', 'command', 'tooltip'}) do
+		self[prop] = data[prop]
+	end
+	self.is_clickable = self.command ~= nil
+end
+
+return ManagedButton
diff --git a/multimedia/.config/mpv/scripts/uosc/elements/Menu.lua b/multimedia/.config/mpv/scripts/uosc_repo/src/uosc/elements/Menu.lua
similarity index 52%
rename from multimedia/.config/mpv/scripts/uosc/elements/Menu.lua
rename to multimedia/.config/mpv/scripts/uosc_repo/src/uosc/elements/Menu.lua
index 42460c6..1471f8e 100644
--- a/multimedia/.config/mpv/scripts/uosc/elements/Menu.lua
+++ b/multimedia/.config/mpv/scripts/uosc_repo/src/uosc/elements/Menu.lua
@@ -1,21 +1,29 @@
 local Element = require('elements/Element')
 
+---@alias MenuAction {name: string; icon: string; label?: string; filter_hidden?: boolean;}
+
 -- Menu data structure accepted by `Menu:open(menu)`.
----@alias MenuData {id?: string; type?: string; title?: string; hint?: string; search_style?: 'on_demand' | 'palette' | 'disabled'; keep_open?: boolean; bold?: boolean; italic?: boolean; muted?: boolean; separator?: boolean; align?: 'left'|'center'|'right'; items?: MenuDataItem[]; selected_index?: integer; on_search?: string|string[]|fun(search_text: string); on_paste?: string|string[]|fun(search_text: string); search_debounce?: number|string; search_submenus?: boolean; search_suggestion?: string}
----@alias MenuDataItem MenuDataValue|MenuData
----@alias MenuDataValue {title?: string; hint?: string; icon?: string; value: any; active?: boolean; keep_open?: boolean; selectable?: boolean; bold?: boolean; italic?: boolean; muted?: boolean; separator?: boolean; align?: 'left'|'center'|'right'}
----@alias MenuOptions {mouse_nav?: boolean; on_open?: fun(); on_close?: fun(); on_back?: fun(); on_move_item?: fun(from_index: integer, to_index: integer, submenu_path: integer[]); on_delete_item?: fun(index: integer, submenu_path: integer[])}
+---@alias MenuData {id?: string; type?: string; title?: string; hint?: string; footnote: string; search_style?: 'on_demand' | 'palette' | 'disabled';  item_actions?: MenuAction[]; item_actions_place?: 'inside' | 'outside'; callback?: string[]; keep_open?: boolean; bold?: boolean; italic?: boolean; muted?: boolean; separator?: boolean; align?: 'left'|'center'|'right'; items?: MenuDataChild[]; selected_index?: integer; on_search?: string|string[]; on_paste?: string|string[]; on_move?: string|string[]; on_close?: string|string[]; search_debounce?: number|string; search_submenus?: boolean; search_suggestion?: string}
+---@alias MenuDataChild MenuDataItem|MenuData
+---@alias MenuDataItem {title?: string; hint?: string; icon?: string; value: any; actions?: MenuAction[]; actions_place?: 'inside' | 'outside'; active?: boolean; keep_open?: boolean; selectable?: boolean; bold?: boolean; italic?: boolean; muted?: boolean; separator?: boolean; align?: 'left'|'center'|'right'}
+---@alias MenuOptions {mouse_nav?: boolean;}
 
--- Internal data structure created from `Menu`.
----@alias MenuStack {id?: string; type?: string; title?: string; hint?: string; search_style?: 'on_demand' | 'palette' | 'disabled', selected_index?: number; keep_open?: boolean; bold?: boolean; italic?: boolean; muted?: boolean; separator?: boolean; align?: 'left'|'center'|'right'; items: MenuStackItem[]; on_search?: string|string[]|fun(search_text: string); on_paste?: string|string[]|fun(search_text: string); search_debounce?: number|string; search_submenus?: boolean; search_suggestion?: string; parent_menu?: MenuStack; submenu_path: integer[]; active?: boolean; width: number; height: number; top: number; scroll_y: number; scroll_height: number; title_width: number; hint_width: number; max_width: number; is_root?: boolean; fling?: Fling, search?: Search, ass_safe_title?: string}
----@alias MenuStackItem MenuStackValue|MenuStack
----@alias MenuStackValue {title?: string; hint?: string; icon?: string; value: any; active?: boolean; keep_open?: boolean; selectable?: boolean; bold?: boolean; italic?: boolean; muted?: boolean; separator?: boolean; align?: 'left'|'center'|'right'; title_width: number; hint_width: number}
+-- Internal data structure created from `MenuData`.
+---@alias MenuStack {id?: string; type?: string; title?: string; hint?: string; footnote: string; search_style?: 'on_demand' | 'palette' | 'disabled';  item_actions?: MenuAction[]; item_actions_place?: 'inside' | 'outside'; callback?: string[]; selected_index?: number; action_index?: number; keep_open?: boolean; bold?: boolean; italic?: boolean; muted?: boolean; separator?: boolean; align?: 'left'|'center'|'right'; items: MenuStackChild[]; on_search?: string|string[]; on_paste?: string|string[]; on_move?: string|string[]; on_close?: string|string[]; search_debounce?: number|string; search_submenus?: boolean; search_suggestion?: string; parent_menu?: MenuStack; submenu_path: integer[]; active?: boolean; width: number; height: number; top: number; scroll_y: number; scroll_height: number; title_width: number; hint_width: number; max_width: number; is_root?: boolean; fling?: Fling, search?: Search, ass_safe_title?: string}
+---@alias MenuStackChild MenuStackItem|MenuStack
+---@alias MenuStackItem {title?: string; hint?: string; icon?: string; value: any; actions?: MenuAction[]; actions_place?: 'inside' | 'outside'; active?: boolean; keep_open?: boolean; selectable?: boolean; bold?: boolean; italic?: boolean; muted?: boolean; separator?: boolean; align?: 'left'|'center'|'right'; title_width: number; hint_width: number; ass_safe_hint?: string}
 ---@alias Fling {y: number, distance: number, time: number, easing: fun(x: number), duration: number, update_cursor?: boolean}
----@alias Search {query: string; timeout: unknown; min_top: number; max_width: number; source: {width: number; top: number; scroll_y: number; selected_index?: integer; items?: MenuDataItem[]}}
+---@alias Search {query: string; timeout: unknown; min_top: number; max_width: number; source: {width: number; top: number; scroll_y: number; selected_index?: integer; items?: MenuStackChild[]}}
 
----@alias Modifiers {shift?: boolean, ctrl?: boolean, alt?: boolean}
----@alias MenuCallbackMeta {modifiers: Modifiers}
----@alias MenuCallback fun(value: any, meta: MenuCallbackMeta)
+---@alias MenuEventActivate {type: 'activate'; index: number; value: any; action?: string; modifiers?: string; alt: boolean; ctrl: boolean; shift: boolean; is_pointer: boolean; keep_open?: boolean; menu_id: string;}
+---@alias MenuEventMove {type: 'move'; from_index: number; to_index: number; menu_id: string;}
+---@alias MenuEventSearch {type: 'search'; query: string; menu_id: string;}
+---@alias MenuEventKey {type: 'key'; id: string; key: string; modifiers?: string; alt: boolean; ctrl: boolean; shift: boolean; menu_id: string; selected_item?: {index: number; value: any; action?: string;}}
+---@alias MenuEventPaste {type: 'paste'; value: string; menu_id: string; selected_item?: {index: number; value: any; action?: string;}}
+---@alias MenuEventBack {type: 'back';}
+---@alias MenuEventClose {type: 'close';}
+---@alias MenuEvent MenuEventActivate | MenuEventMove | MenuEventSearch | MenuEventKey | MenuEventPaste | MenuEventBack | MenuEventClose
+---@alias MenuCallback fun(data: MenuEvent)
 
 ---@class Menu : Element
 local Menu = class(Element)
@@ -24,7 +32,7 @@ local Menu = class(Element)
 ---@param callback MenuCallback
 ---@param opts? MenuOptions
 function Menu:open(data, callback, opts)
-	local open_menu = self:is_open()
+	local open_menu = Menu:is_open()
 	if open_menu then
 		open_menu.is_being_replaced = true
 		open_menu:close(true)
@@ -46,6 +54,10 @@ function Menu:close(immediate, callback)
 
 	local menu = self == Menu and Elements.menu or self
 
+	if state.ime_active == false and mp.get_property_bool('input-ime') then
+		mp.set_property_bool('input-ime', false)
+	end
+
 	if menu and not menu.destroyed then
 		if menu.is_closing then
 			menu:tween_stop()
@@ -53,12 +65,20 @@ function Menu:close(immediate, callback)
 		end
 
 		local function close()
-			Elements:remove('menu')
-			menu.is_closing, menu.stack, menu.current, menu.all, menu.by_id = false, nil, nil, {}, {}
-			menu:disable_key_bindings()
+			local on_close = menu.root.on_close -- removed in menu:destroy()
+			Elements:remove('menu') -- calls menu:destroy() under the hood
 			Elements:update_proximities()
 			cursor:queue_autohide()
+
+			-- Call :close() callback
 			if callback then callback() end
+
+			-- Call callbacks/events defined on menu config
+			local close_event = {type = 'close'}
+			if not on_close or menu:command_or_event(on_close, {}, close_event) ~= 'event' then
+				menu.callback(close_event)
+			end
+
 			request_render()
 		end
 
@@ -88,8 +108,6 @@ function Menu:init(data, callback, opts)
 	self.opts = opts or {}
 	self.offset_x = 0 -- Used for submenu transition animation.
 	self.mouse_nav = self.opts.mouse_nav -- Stops pre-selecting items
-	---@type Modifiers
-	self.modifiers = {}
 	self.item_height = nil
 	self.min_width = nil
 	self.item_spacing = 1
@@ -111,11 +129,9 @@ function Menu:init(data, callback, opts)
 	self.all = nil
 	---@type table<string, MenuStack> Map of submenus by their ids, such as `'Tools > Aspect ratio'`.
 	self.by_id = {}
-	self.key_bindings = {}
-	self.key_bindings_search = {} -- temporary key bindings for search
 	self.type_to_search = options.menu_type_to_search
 	self.is_being_replaced = false
-	self.is_closing, self.is_closed = false, false
+	self.is_closing = false
 	self.drag_last_y = nil
 	self.is_dragging = false
 
@@ -125,25 +141,22 @@ function Menu:init(data, callback, opts)
 	mp.set_property_native('user-data/uosc/menu/type', self.type or 'undefined')
 	self:update(data)
 
-	for _, menu in ipairs(self.all) do self:scroll_to_index(menu.selected_index, menu) end
+	for _, menu in ipairs(self.all) do self:scroll_to_index(menu.selected_index, menu.id) end
 	if self.mouse_nav then self.current.selected_index = nil end
 
 	self:tween_property('opacity', 0, 1)
 	self:enable_key_bindings()
 	Elements:maybe('curtain', 'register', self.id)
-	if self.opts.on_open then self.opts.on_open() end
 end
 
 function Menu:destroy()
 	Element.destroy(self)
-	self:disable_key_bindings()
-	self.is_closed = true
+	self.is_closing = false
 	if not self.is_being_replaced then Elements:maybe('curtain', 'unregister', self.id) end
 	if utils.shared_script_property_set then
 		utils.shared_script_property_set('uosc-menu-type', nil)
 	end
 	mp.set_property_native('user-data/uosc/menu/type', nil)
-	if self.opts.on_close then self.opts.on_close() end
 end
 
 ---@param data MenuData
@@ -154,14 +167,10 @@ function Menu:update(data)
 	local new_by_id = {}
 	local menus_to_serialize = {{new_root, data}}
 	local old_current_id = self.current and self.current.id
-	local menu_props_to_copy = {
-		'title', 'hint', 'keep_open', 'search_style', 'search_submenus', 'search_suggestion', 'on_search', 'on_paste',
-	}
-	local item_props_to_copy = itable_join(menu_props_to_copy, {
-		'icon', 'active', 'bold', 'italic', 'muted', 'value', 'separator', 'selectable', 'align',
-	})
+	local menu_state_props = {'selected_index', 'action_index', 'scroll_y', 'fling', 'search'}
+	local internal_props_set = create_set(itable_append({'is_root', 'submenu_path', 'id', 'items'}, menu_state_props))
 
-	table_assign_props(new_root, data, itable_join({'type'}, menu_props_to_copy))
+	table_assign_exclude(new_root, data, internal_props_set)
 
 	local i = 0
 	while i < #menus_to_serialize do
@@ -173,13 +182,13 @@ function Menu:update(data)
 		elseif not menu.is_root then
 			menu.id = (parent_id and parent_id .. ' > ' or '') .. (menu_data.title or i)
 		else
-			menu.id = 'main'
+			menu.id = '{root}'
 		end
 		menu.icon = 'chevron_right'
 
 		-- Normalize `search_debounce`
 		if type(menu_data.search_debounce) == 'number' then
-			menu.search_debounce = math.max(0, menu_data.search_debounce)
+			menu.search_debounce = math.max(0, menu_data.search_debounce --[[@as number]])
 		elseif menu_data.search_debounce == 'submit' then
 			menu.search_debounce = 'submit'
 		else
@@ -196,7 +205,7 @@ function Menu:update(data)
 			if item_data.active and not first_active_index then first_active_index = i end
 
 			local item = {}
-			table_assign_props(item, item_data, item_props_to_copy)
+			table_assign_exclude(item, item_data, internal_props_set)
 			if item.keep_open == nil then item.keep_open = menu.keep_open end
 
 			-- Submenu
@@ -214,7 +223,7 @@ function Menu:update(data)
 		-- Retain old state
 		local old_menu = self.by_id[menu.id]
 		if old_menu then
-			table_assign_props(menu, old_menu, {'selected_index', 'scroll_y', 'fling', 'search'})
+			table_assign_props(menu, old_menu, menu_state_props)
 		else
 			new_menus[#new_menus + 1] = menu
 		end
@@ -235,7 +244,7 @@ function Menu:update(data)
 		local is_palette = menu.search_style == 'palette'
 		if not menu.search and (is_palette or (menu.search_suggestion and itable_index_of(new_menus, menu))) then
 			update_dimensions_again = true
-			self:search_init(menu)
+			self:search_init(menu.id)
 		elseif not is_palette and menu.search and menu.search.query == '' then
 			update_dimensions_again = true
 			menu.search = nil
@@ -256,7 +265,7 @@ function Menu:update(data)
 			-- the menu items are new objects and the search needs to contain those
 			menu.search.source.items = not menu.on_search and menu.items or nil
 			-- Only internal searches are immediately submitted
-			if not menu.on_search then self:search_internal(menu, true) end
+			if not menu.on_search then self:search_internal(menu.id, true) end
 		end
 
 		if menu.selected_index then self:select_by_offset(0, menu) end
@@ -265,7 +274,7 @@ function Menu:update(data)
 	self:search_ensure_key_bindings()
 end
 
----@param items MenuDataItem[]
+---@param items MenuDataChild[]
 function Menu:update_items(items)
 	local data = table_assign({}, self.root)
 	data.items = items
@@ -315,14 +324,17 @@ function Menu:update_dimensions()
 	-- This is a debt from an era where we had different cursor event handling,
 	-- and dumb titles with no search inputs. It could use a refactor.
 	local margin = round(self.item_height / 2)
-	local width_available, height_available = display.width - margin * 2, display.height - margin * 2
+	local external_buttons_reserve = display.width / self.item_height > 14 and self.scroll_step * 6 - margin * 2 or 0
+	local width_available = display.width - margin * 2 - external_buttons_reserve
+	local height_available = display.height - margin * 2
 	local min_width = math.min(self.min_width, width_available)
 
 	for _, menu in ipairs(self.all) do
 		local width = math.max(menu.search and menu.search.max_width or 0, menu.max_width)
 		menu.width = round(clamp(min_width, width, width_available))
 		local title_height = (menu.is_root and menu.title or menu.search) and self.scroll_step + self.padding or 0
-		local max_height = height_available - title_height
+		local footnote_height = self.font_size * 1.5
+		local max_height = height_available - title_height - footnote_height
 		local content_height = self.scroll_step * #menu.items
 		menu.height = math.min(content_height - self.item_spacing, max_height)
 		menu.top = clamp(
@@ -335,7 +347,7 @@ function Menu:update_dimensions()
 			menu.search.max_width = math.max(menu.search.max_width, menu.width)
 		end
 		menu.scroll_height = math.max(content_height - menu.height - self.item_spacing, 0)
-		self:set_scroll_to(menu.scroll_y, menu) -- clamps scroll_y to scroll limits
+		self:set_scroll_to(menu.scroll_y, menu.id) -- clamps scroll_y to scroll limits
 	end
 
 	self:update_coordinates()
@@ -379,33 +391,42 @@ end
 
 function Menu:fadeout(callback) self:tween_property('opacity', 1, 0, callback) end
 
-function Menu:get_first_active_index(menu)
-	menu = menu or self.current
-	for index, item in ipairs(self.current.items) do
+-- If `menu_id` is provided, will return menu with that id or `nil`. If `menu_id` is `nil`, will return current menu.
+---@param menu_id? string
+---@return MenuStack | nil
+function Menu:get_menu(menu_id) return menu_id == nil and self.current or self.by_id[menu_id] end
+
+function Menu:get_first_active_index(menu_id)
+	local menu = self:get_menu(menu_id)
+	if not menu then return end
+	for index, item in ipairs(menu.items) do
 		if item.active then return index end
 	end
 end
 
 ---@param pos? number
----@param menu? MenuStack
-function Menu:set_scroll_to(pos, menu)
-	menu = menu or self.current
+---@param menu_id? string
+function Menu:set_scroll_to(pos, menu_id)
+	local menu = self:get_menu(menu_id)
+	if not menu then return end
 	menu.scroll_y = clamp(0, pos or 0, menu.scroll_height)
 	request_render()
 end
 
 ---@param delta? number
----@param menu? MenuStack
-function Menu:set_scroll_by(delta, menu)
-	menu = menu or self.current
-	self:set_scroll_to(menu.scroll_y + delta, menu)
+---@param menu_id? string
+function Menu:set_scroll_by(delta, menu_id)
+	local menu = self:get_menu(menu_id)
+	if not menu then return end
+	self:set_scroll_to(menu.scroll_y + delta, menu_id)
 end
 
 ---@param pos? number
----@param menu? MenuStack
+---@param menu_id? string
 ---@param fling_options? table
-function Menu:scroll_to(pos, menu, fling_options)
-	menu = menu or self.current
+function Menu:scroll_to(pos, menu_id, fling_options)
+	local menu = self:get_menu(menu_id)
+	if not menu then return end
 	menu.fling = {
 		y = menu.scroll_y,
 		distance = clamp(-menu.scroll_y, pos - menu.scroll_y, menu.scroll_height - menu.scroll_y),
@@ -418,202 +439,235 @@ function Menu:scroll_to(pos, menu, fling_options)
 end
 
 ---@param delta? number
----@param menu? MenuStack
+---@param menu_id? string
 ---@param fling_options? Fling
-function Menu:scroll_by(delta, menu, fling_options)
-	menu = menu or self.current
-	self:scroll_to((menu.fling and (menu.fling.y + menu.fling.distance) or menu.scroll_y) + delta, menu, fling_options)
+function Menu:scroll_by(delta, menu_id, fling_options)
+	local menu = self:get_menu(menu_id)
+	if not menu then return end
+	self:scroll_to((menu.fling and (menu.fling.y + menu.fling.distance) or menu.scroll_y) + delta, menu_id, fling_options)
 end
 
 ---@param index? integer
----@param menu? MenuStack
+---@param menu_id? string
 ---@param immediate? boolean
-function Menu:scroll_to_index(index, menu, immediate)
-	menu = menu or self.current
+function Menu:scroll_to_index(index, menu_id, immediate)
+	local menu = self:get_menu(menu_id)
+	if not menu then return end
 	if (index and index >= 1 and index <= #menu.items) then
 		local position = round((self.scroll_step * (index - 1)) - ((menu.height - self.scroll_step) / 2))
 		if immediate then
-			self:set_scroll_to(position, menu)
+			self:set_scroll_to(position, menu_id)
 		else
-			self:scroll_to(position, menu)
+			self:scroll_to(position, menu_id)
 		end
 	end
 end
 
 ---@param index? integer
----@param menu? MenuStack
-function Menu:select_index(index, menu)
-	menu = menu or self.current
+---@param menu_id? string
+function Menu:select_index(index, menu_id)
+	local menu = self:get_menu(menu_id)
+	if not menu then return end
 	menu.selected_index = (index and index >= 1 and index <= #menu.items) and index or nil
+	self:select_action(menu.action_index, menu_id) -- normalize selected action index
 	request_render()
 end
 
+---@param index? integer
+---@param menu_id? string
+function Menu:select_action(index, menu_id)
+	local menu = self:get_menu(menu_id)
+	if not menu then return end
+	local actions = menu.items[menu.selected_index] and menu.items[menu.selected_index].actions or menu.item_actions
+	if not index or not actions or type(actions) ~= 'table' or index < 1 or index > #actions then
+		menu.action_index = nil
+		return
+	end
+	menu.action_index = index
+	request_render()
+end
+
+---@param delta? integer
+---@param menu_id? string
+function Menu:navigate_action(delta, menu_id)
+	local menu = self:get_menu(menu_id)
+	if not menu then return end
+	local actions = menu.items[menu.selected_index] and menu.items[menu.selected_index].actions or menu.item_actions
+	if actions and delta ~= 0 then
+		-- Circular navigation where zero gets converted to nil
+		local index = (menu.action_index or (delta > 0 and 0 or #actions + 1)) + delta
+		self:select_action(index <= #actions and index > 0 and (index - 1) % #actions + 1 or nil, menu_id)
+	else
+		self:select_action(nil, menu_id)
+	end
+	request_render()
+end
+
+function Menu:next_action() self:navigate_action(1) end
+function Menu:prev_action() self:navigate_action(-1) end
+
 ---@param value? any
----@param menu? MenuStack
-function Menu:select_value(value, menu)
-	menu = menu or self.current
+---@param menu_id? string
+function Menu:select_value(value, menu_id)
+	local menu = self:get_menu(menu_id)
+	if not menu then return end
 	local index = itable_find(menu.items, function(item) return item.value == value end)
 	self:select_index(index)
 end
 
----@param menu? MenuStack
-function Menu:deactivate_items(menu)
-	menu = menu or self.current
+---@param menu_id? string
+function Menu:deactivate_items(menu_id)
+	local menu = self:get_menu(menu_id)
+	if not menu then return end
 	for _, item in ipairs(menu.items) do item.active = false end
 	request_render()
 end
 
 ---@param index? integer
----@param menu? MenuStack
-function Menu:activate_index(index, menu)
-	menu = menu or self.current
+---@param menu_id? string
+function Menu:activate_index(index, menu_id)
+	local menu = self:get_menu(menu_id)
+	if not menu then return end
 	if index and index >= 1 and index <= #menu.items then menu.items[index].active = true end
 	request_render()
 end
 
----@param index? integer
----@param menu? MenuStack
-function Menu:activate_one_index(index, menu)
-	self:deactivate_items(menu)
-	self:activate_index(index, menu)
+---@param value? any
+---@param menu_id? string
+function Menu:activate_value(value, menu_id)
+	local menu = self:get_menu(menu_id)
+	if not menu then return end
+	local index = itable_find(menu.items, function(item) return item.value == value end)
+	self:activate_index(index, menu_id)
 end
 
 ---@param value? any
----@param menu? MenuStack
-function Menu:activate_value(value, menu)
-	menu = menu or self.current
+---@param menu_id? string
+function Menu:activate_one_value(value, menu_id)
+	local menu = self:get_menu(menu_id)
+	if not menu then return end
 	local index = itable_find(menu.items, function(item) return item.value == value end)
-	self:activate_index(index, menu)
+	self:activate_index(index, menu_id)
 end
 
----@param value? any
----@param menu? MenuStack
-function Menu:activate_one_value(value, menu)
-	menu = menu or self.current
-	local index = itable_find(menu.items, function(item) return item.value == value end)
-	self:activate_one_index(index, menu)
-end
-
----@param menu MenuStack One of menus in `self.all`.
-function Menu:activate_menu(menu)
-	if itable_index_of(self.all, menu) then
+---@param id string One of menus in `self.all`.
+function Menu:activate_menu(id)
+	local menu = self:get_menu(id)
+	if menu then
 		self.current = menu
 		self:update_coordinates()
 		self:reset_navigation()
 		self:search_ensure_key_bindings()
-		request_render()
-	else
-		msg.error('Attempt to open a menu not in `self.all` list.')
-	end
-end
-
----@param id string
-function Menu:activate_submenu(id)
-	local submenu = self.by_id[id]
-	if submenu then
-		self:activate_menu(submenu)
-		local menu = self.current
 		local parent = menu.parent_menu
 		while parent do
 			parent.selected_index = itable_index_of(parent.items, menu)
 			self:scroll_to_index(parent.selected_index, parent)
 			menu, parent = parent, parent.parent_menu
 		end
-	else
-		msg.error(string.format('Requested submenu id "%s" doesn\'t exist', id))
+		request_render()
 	end
 end
 
 ---@param index? integer
----@param menu? MenuStack
-function Menu:delete_index(index, menu)
-	menu = menu or self.current
+---@param menu_id? string
+function Menu:delete_index(index, menu_id)
+	local menu = self:get_menu(menu_id)
+	if not menu then return end
 	if (index and index >= 1 and index <= #menu.items) then
 		table.remove(menu.items, index)
 		self:update_content_dimensions()
-		self:scroll_to_index(menu.selected_index, menu)
+		self:scroll_to_index(menu.selected_index, menu_id)
 	end
 end
 
 ---@param value? any
----@param menu? MenuStack
-function Menu:delete_value(value, menu)
-	menu = menu or self.current
+---@param menu_id? string
+function Menu:delete_value(value, menu_id)
+	local menu = self:get_menu(menu_id)
+	if not menu then return end
 	local index = itable_find(menu.items, function(item) return item.value == value end)
 	self:delete_index(index)
 end
 
----@param menu MenuStack One of menus in `self.all`.
+---@param id string Menu id.
 ---@param x number `x` coordinate to slide from.
-function Menu:slide_in_menu(menu, x)
-	local current = self.current
-	current.selected_index = nil
-	self:activate_menu(menu)
+function Menu:slide_in_menu(id, x)
+	local menu = self:get_menu(id)
+	if not menu then return end
+	self:activate_menu(id)
 	self:tween(-(display.width / 2 - menu.width / 2 - x), 0, function(offset) self:set_offset_x(offset) end)
 	self.opacity = 1 -- in case tween above canceled fade in animation
 end
 
 function Menu:back()
-	if self.opts.on_back then
-		self.opts.on_back()
-		if self.is_closed then return end
-	end
+	if not self:is_alive() then return end
 
 	local current = self.current
 	local parent = current.parent_menu
 
 	if parent then
-		self:slide_in_menu(parent, display.width / 2 - current.width / 2 - parent.width / 2 + self.offset_x)
+		self:slide_in_menu(parent.id, display.width / 2 - current.width / 2 - parent.width / 2 + self.offset_x)
 	else
-		self:close()
+		self.callback({type = 'back'})
 	end
 end
 
----@param opts? {keep_open?: boolean, preselect_first_item?: boolean}
-function Menu:open_selected_item(opts)
-	opts = opts or {}
+---@param shortcut? Shortcut
+---@param is_pointer? boolean Whether this was called by a pointer.
+function Menu:activate_selected_item(shortcut, is_pointer)
 	local menu = self.current
-	if menu.selected_index then
-		local item = menu.items[menu.selected_index]
+	local item = menu.items[menu.selected_index]
+	if item then
 		-- Is submenu
 		if item.items then
-			if opts.preselect_first_item then
-				item.selected_index = #item.items > 0 and 1 or nil
+			if not self.mouse_nav then
+				self:select_index(1, item.id)
 			end
-			self:activate_menu(item)
+			self:activate_menu(item.id)
 			self:tween(self.offset_x + menu.width / 2, 0, function(offset) self:set_offset_x(offset) end)
 			self.opacity = 1 -- in case tween above canceled fade in animation
 		else
-			self.callback(item.value, {modifiers = self.modifiers or {}})
-			if not item.keep_open and not opts.keep_open then self:close() end
+			local actions = item.actions or menu.item_actions
+			local action = actions and actions[menu.action_index]
+			self.callback({
+				type = 'activate',
+				index = menu.selected_index,
+				value = item.value,
+				is_pointer = is_pointer == true,
+				action = action and action.name,
+				keep_open = item.keep_open or menu.keep_open,
+				modifiers = shortcut and shortcut.modifiers or nil,
+				alt = shortcut and shortcut.alt or false,
+				ctrl = shortcut and shortcut.ctrl or false,
+				shift = shortcut and shortcut.shift or false,
+				menu_id = menu.id,
+			})
 		end
 	end
 end
 
-function Menu:open_selected_item_soft() self:open_selected_item({keep_open = true}) end
-function Menu:open_selected_item_preselect() self:open_selected_item({preselect_first_item = true}) end
-
 ---@param index integer
 function Menu:move_selected_item_to(index)
-	local from, callback = self.current.selected_index, self.opts.on_move_item
-	if callback and from and from ~= index and index >= 1 and index <= #self.current.items then
-		callback(from, index, self.current.submenu_path)
-		self.current.selected_index = index
-		self:scroll_to_index(index, self.current, true)
+	if self.current.search then return end -- Moving filtered items is an undefined behavior
+	local callback = self.current.on_move
+	local from, items_count = self.current.selected_index, self.current.items and #self.current.items or 0
+	if callback and from and from ~= index and index >= 1 and index <= items_count then
+		local event = {type = 'move', from_index = from, to_index = index, menu_id = self.current.id}
+		self:command_or_event(callback, {from, index, self.current.id}, event)
+		self:select_index(index, self.current.id)
+		self:scroll_to_index(index, self.current.id, true)
 	end
 end
 
-function Menu:move_selected_item_up()
-	if self.current.selected_index then self:move_selected_item_to(self.current.selected_index - 1) end
-end
-
-function Menu:move_selected_item_down()
-	if self.current.selected_index then self:move_selected_item_to(self.current.selected_index + 1) end
-end
-
-function Menu:delete_selected_item()
-	local index, callback = self.current.selected_index, self.opts.on_delete_item
-	if callback and index then callback(index, self.current.submenu_path) end
+---@param delta number
+function Menu:move_selected_item_by(delta)
+	local current_index, items_count = self.current.selected_index, self.current.items and #self.current.items or 0
+	if current_index and items_count > 1 then
+		local new_index = clamp(1, current_index + delta, items_count)
+		if current_index ~= new_index then
+			self:move_selected_item_to(new_index)
+		end
+	end
 end
 
 function Menu:on_display() self:update_dimensions() end
@@ -629,9 +683,10 @@ function Menu:handle_cursor_down()
 	end
 end
 
-function Menu:handle_cursor_up()
+---@param shortcut? Shortcut
+function Menu:handle_cursor_up(shortcut)
 	if self.proximity_raw == 0 and self.drag_last_y and not self.is_dragging then
-		self:open_selected_item({preselect_first_item = false, keep_open = self.modifiers and self.modifiers.shift})
+		self:activate_selected_item(shortcut, true)
 	end
 	if self.is_dragging then
 		local distance = cursor:get_velocity().y / -3
@@ -669,20 +724,30 @@ function Menu:handle_wheel_down() self:scroll_by(self.scroll_step * 3, nil, {upd
 ---@param menu? MenuStack
 function Menu:select_by_offset(offset, menu)
 	menu = menu or self.current
-	local index = clamp(1, (menu.selected_index or offset >= 0 and 0 or #menu.items + 1) + offset, #menu.items)
-	local prev_index = itable_find(menu.items, function(item) return item.selectable ~= false end, index, 1)
-	local next_index = itable_find(menu.items, function(item) return item.selectable ~= false end, index)
-	if prev_index and next_index then
-		if offset == 0 then
-			menu.selected_index = index - prev_index <= next_index - index and prev_index or next_index
-		elseif offset > 0 then
-			menu.selected_index = next_index
-		else
-			menu.selected_index = prev_index
-		end
+
+	-- Blur selected_index when navigating off bounds and submittable search is active.
+	-- Blurred selected_index is an implied focused input, so enter can submit it.
+	if menu.search and menu.search_debounce == 'submit' and (
+			(menu.selected_index == 1 and offset < 0) or (menu.selected_index == #menu.items and offset > 0)
+		) then
+		self:select_index(nil, menu.id)
 	else
-		menu.selected_index = prev_index or next_index or nil
+		local index = clamp(1, (menu.selected_index or offset >= 0 and 0 or #menu.items + 1) + offset, #menu.items)
+		local prev_index = itable_find(menu.items, function(item) return item.selectable ~= false end, index, 1)
+		local next_index = itable_find(menu.items, function(item) return item.selectable ~= false end, index)
+		if prev_index and next_index then
+			if offset == 0 then
+				self:select_index(index - prev_index <= next_index - index and prev_index or next_index, menu.id)
+			elseif offset > 0 then
+				self:select_index(next_index, menu.id)
+			else
+				self:select_index(prev_index, menu.id)
+			end
+		else
+			self:select_index(prev_index or next_index or nil, menu.id)
+		end
 	end
+
 	request_render()
 end
 
@@ -690,61 +755,40 @@ end
 ---@param immediate? boolean
 function Menu:navigate_by_offset(offset, immediate)
 	self:select_by_offset(offset)
-	if self.current.selected_index then self:scroll_to_index(self.current.selected_index, self.current, immediate) end
-end
-
-function Menu:prev()
-	self:navigate_by_offset(-1, true)
-end
-
-function Menu:next()
-	self:navigate_by_offset(1, true)
-end
-
-function Menu:on_pgup()
-	local items_per_page = round((self.current.height / self.scroll_step) * 0.4)
-	self:navigate_by_offset(-items_per_page)
-end
-
-function Menu:on_pgdwn()
-	local items_per_page = round((self.current.height / self.scroll_step) * 0.4)
-	self:navigate_by_offset(items_per_page)
-end
-
-function Menu:on_home()
-	self:navigate_by_offset(-math.huge)
-end
-
-function Menu:on_end()
-	self:navigate_by_offset(math.huge)
+	if self.current.selected_index then
+		self:scroll_to_index(self.current.selected_index, self.current.id, immediate)
+	end
 end
 
 function Menu:paste()
 	local menu = self.current
 	local payload = get_clipboard()
 	if not payload then return end
-	if menu.search then
+	if menu.on_paste then
+		local selected_item = menu.items and menu.selected_index and menu.items[menu.selected_index]
+		local actions = selected_item and selected_item.actions or menu.item_actions
+		local selected_action = actions and menu.action_index and actions[menu.action_index]
+		self:command_or_event(menu.on_paste, {payload, menu.id}, {
+			type = 'paste',
+			value = payload,
+			menu_id = menu.id,
+			selected_item = selected_item and {
+				index = menu.selected_index, value = selected_item.value, action = selected_action,
+			},
+		})
+	elseif menu.search then
 		self:search_query_update(menu.search.query .. payload)
-	elseif menu.on_paste then
-		local paste_type = type(menu.on_paste)
-		if paste_type == 'string' then
-			mp.command(menu.on_paste .. ' ' .. payload)
-		elseif paste_type == 'table' then
-			local command = itable_join({}, menu.on_paste)
-			command[#command + 1] = payload
-			mp.command_native(command)
-		else
-			menu.on_paste(payload)
-		end
 	elseif menu.search_style ~= 'disabled' then
-		self:search_start(menu)
-		self:search_query_update(payload, menu)
+		self:search_start(menu.id)
+		self:search_query_update(payload, menu.id)
 	end
 end
 
----@param menu MenuStack
+---@param menu_id string
 ---@param no_select_first? boolean
-function Menu:search_internal(menu, no_select_first)
+function Menu:search_internal(menu_id, no_select_first)
+	local menu = self:get_menu(menu_id)
+	if not menu then return end
 	local query = menu.search.query:lower()
 	if query == '' then
 		-- Reset menu state to what it was before search
@@ -759,17 +803,17 @@ function Menu:search_internal(menu, no_select_first)
 		-- Select 1st item in search results
 		if not no_select_first then
 			menu.scroll_y = 0
-			self:select_index(1, menu)
+			self:select_index(1, menu_id)
 		end
 	end
 	self:update_content_dimensions()
 end
 
----@param items MenuStackItem[]
+---@param items MenuStackChild[]
 ---@param query string
 ---@param recursive? boolean
 ---@param prefix? string
----@return MenuStackItem[]
+---@return MenuStackChild[]
 function search_items(items, query, recursive, prefix)
 	local result = {}
 	local concat = table.concat
@@ -781,7 +825,7 @@ function search_items(items, query, recursive, prefix)
 			else
 				local title = item.title and item.title:lower()
 				local hint = item.hint and item.hint:lower()
-				local initials_title = title and concat(initials(title))
+				local initials_title = title and concat(initials(title)) --[[@as string]]
 				local romanization = need_romanization()
 				if romanization then
 					ligature_conv_title = title and char_conv(title, true)
@@ -804,38 +848,36 @@ function search_items(items, query, recursive, prefix)
 	return result
 end
 
----@param menu? MenuStack
-function Menu:search_submit(menu)
-	menu = menu or self.current
-	if not menu.search then return end
-	if menu.on_search then
-		local search_type = type(menu.on_search)
-		if search_type == 'string' then
-			mp.command(menu.on_search .. ' ' .. menu.search.query)
-		elseif search_type == 'table' then
-			local command = itable_join({}, menu.on_search)
-			command[#command + 1] = menu.search.query
-			mp.command_native(command)
-		else
-			menu.on_search(menu.search.query)
-		end
+---@param menu_id? string
+function Menu:search_submit(menu_id)
+	local menu = self:get_menu(menu_id)
+	if not menu or not menu.search then return end
+	local callback, query = menu.on_search, menu.search.query
+	if callback then
+		self:command_or_event(callback, {query, menu.id}, {type = 'search', query = query, menu_id = menu.id})
 	else
-		self:search_internal(menu)
+		self:search_internal(menu.id)
 	end
 end
 
 ---@param query string
----@param menu? MenuStack
-function Menu:search_query_update(query, menu)
-	menu = menu or self.current
+---@param menu_id? string
+---@param immediate? boolean
+function Menu:search_query_update(query, menu_id, immediate)
+	local menu = self:get_menu(menu_id)
+	if not menu or not menu.search then return end
 	menu.search.query = query
 	if menu.search_debounce ~= 'submit' then
-		if menu.search.timeout then
-			menu.search.timeout:kill()
+		if menu.search.timeout then menu.search.timeout:kill() end
+		if menu.search.timeout and not immediate then
 			menu.search.timeout:resume()
 		else
-			self:search_submit(menu)
+			self:search_submit(menu_id)
 		end
+	else
+		-- `search_debounce='submit'` behavior: We blur selected item when query
+		-- changes to let [enter] key submit searches instead of activating items.
+		self:select_index(nil, menu.id)
 	end
 	request_render()
 end
@@ -845,7 +887,7 @@ end
 function Menu:search_backspace(event, word_mode)
 	local pos, old_query = #self.current.search.query, self.current.search.query
 	local is_palette = self.current.search_style == 'palette'
-	if word_mode then
+	if word_mode and #old_query > 1 then
 		local word_pat, other_pat = '[^%c%s%p]+$', '[%c%s%p]+$'
 		local init_pat = old_query:sub(#old_query):match(word_pat) and word_pat or other_pat
 		-- First we match all same type consecutive chars at the end
@@ -866,7 +908,7 @@ function Menu:search_backspace(event, word_mode)
 	if new_query ~= old_query and (is_palette or not self.type_to_search or pos > 0) then
 		self:search_query_update(new_query)
 	elseif not is_palette and self.type_to_search then
-		self:search_stop()
+		self:search_cancel()
 	elseif is_palette and event ~= 'repeat' then
 		self:back()
 	end
@@ -888,24 +930,35 @@ function Menu:search_text_input(info)
 	end
 end
 
----@param menu? MenuStack
-function Menu:search_stop(menu)
-	menu = menu or self.current
-	self:search_query_update('', menu)
+---@param menu_id? string
+function Menu:search_cancel(menu_id)
+	local menu = self:get_menu(menu_id)
+	if not menu or not menu.search or menu.search_style == 'palette' then
+		self:search_query_update('', menu_id)
+		return
+	end
+	if state.ime_active == false then
+		mp.set_property_bool('input-ime', false)
+	end
+	self:search_query_update('', menu_id, true)
 	menu.search = nil
 	self:search_ensure_key_bindings()
 	self:update_dimensions()
 	self:reset_navigation()
 end
 
----@param menu? MenuStack
-function Menu:search_init(menu)
-	menu = menu or self.current
+---@param menu_id? string
+function Menu:search_init(menu_id)
+	local menu = self:get_menu(menu_id)
+	if not menu then return end
 	if menu.search then return end
+	if state.ime_active == false then
+		mp.set_property_bool('input-ime', true)
+	end
 	local timeout
 	if menu.search_debounce ~= 'submit' and menu.search_debounce > 0 then
 		timeout = mp.add_timeout(menu.search_debounce / 1000, self:create_action(function()
-			self:search_submit(menu)
+			self:search_submit(menu.id)
 		end))
 		timeout:kill()
 	end
@@ -924,197 +977,210 @@ function Menu:search_init(menu)
 	}
 end
 
----@param menu? MenuStack
-function Menu:search_start(menu)
-	if (menu or self.current).search_style == 'disabled' then return end
-	self:search_init(menu)
+---@param menu_id? string
+function Menu:search_start(menu_id)
+	local menu = self:get_menu(menu_id)
+	if not menu or menu.search_style == 'disabled' then return end
+	self:search_init(menu_id)
 	self:search_ensure_key_bindings()
 	self:update_dimensions()
 end
 
----@param menu? MenuStack
-function Menu:search_clear_query(menu)
-	menu = menu or self.current
+---@param menu_id? string
+function Menu:search_clear_query(menu_id)
+	local menu = self:get_menu(menu_id)
+	if not menu then return end
 	if not self.current.search_style == 'palette' and self.type_to_search then
-		self:search_stop(menu)
+		self:search_cancel(menu_id)
 	else
-		self:search_query_update('', menu)
-	end
-end
-
-function Menu:key_bs(info)
-	if info.event ~= 'up' then
-		if self.current.search then
-			if self.modifiers.shift then
-				self:search_clear_query()
-			else
-				self:search_backspace(info.event, self.modifiers.ctrl)
-			end
-		elseif info.event ~= 'repeat' then
-			self:back()
-		end
-	end
-end
-
-function Menu:key_ctrl_enter()
-	if self.current.search then
-		self:search_submit()
-	else
-		self:open_selected_item_preselect()
-	end
-end
-
-function Menu:key_left()
-	if self.current.search then -- control cursor when it's implemented
-	else
-		self:back()
-	end
-end
-
-function Menu:key_right()
-	if self.current.search then -- control cursor when it's implemented
-	else
-		self:open_selected_item_preselect()
+		self:search_query_update('', menu_id)
 	end
 end
 
 function Menu:search_enable_key_bindings()
-	if #self.key_bindings_search ~= 0 then return end
+	if self:has_keybindings('search') then return end
 	local flags = {repeatable = true, complex = true}
-	local add_key_binding = self.type_to_search and self.add_key_binding or self.search_add_key_binding
-	add_key_binding(self, 'any_unicode', 'menu-search', self:create_key_action('search_text_input'), flags)
+	self:add_key_binding('any_unicode', {self:create_key_handler('search_text_input'), flags}, 'search')
 	-- KP0 to KP9 and KP_DEC are not included in any_unicode
 	-- despite typically producing characters, they don't have a info.key_text
-	add_key_binding(self, 'kp_dec', 'menu-search-kp-dec', self:create_key_action('search_text_input'), flags)
+	self:add_key_binding('kp_dec', {self:create_key_handler('search_text_input'), flags}, 'search')
 	for i = 0, 9 do
-		add_key_binding(self, 'kp' .. i, 'menu-search-kp' .. i, self:create_key_action('search_text_input'), flags)
+		self:add_key_binding('kp' .. i, {self:create_key_handler('search_text_input'), flags}, 'search')
 	end
 end
 
 function Menu:search_ensure_key_bindings()
-	if self.type_to_search then return end
-	if self.current.search then
+	if self.current.search or (self.type_to_search and self.current.search_style ~= 'disabled') then
 		self:search_enable_key_bindings()
 	else
-		self:search_disable_key_bindings()
+		self:remove_key_bindings('search')
 	end
 end
 
-function Menu:search_disable_key_bindings()
-	for _, name in ipairs(self.key_bindings_search) do mp.remove_key_binding(name) end
-	self.key_bindings_search = {}
-end
-
-function Menu:search_add_key_binding(key, name, fn, flags)
-	self.key_bindings_search[#self.key_bindings_search + 1] = name
-	mp.add_forced_key_binding(key, name, fn, flags)
-end
-
-function Menu:add_key_binding(key, name, fn, flags)
-	self.key_bindings[#self.key_bindings + 1] = name
-	mp.add_forced_key_binding(key, name, fn, flags)
-end
-
 function Menu:enable_key_bindings()
-	-- The `mp.set_key_bindings()` method would be easier here, but that
-	-- doesn't support 'repeatable' flag, so we are stuck with this monster.
-	self:add_key_binding('up', 'menu-prev1', self:create_key_action('prev'), 'repeatable')
-	self:add_key_binding('down', 'menu-next1', self:create_key_action('next'), 'repeatable')
-	self:add_key_binding('ctrl+up', 'menu-move-up', self:create_key_action('move_selected_item_up'), 'repeatable')
-	self:add_key_binding('ctrl+down', 'menu-move-down', self:create_key_action('move_selected_item_down'), 'repeatable')
-	self:add_key_binding('left', 'menu-back1', self:create_key_action('key_left'))
-	self:add_key_binding('right', 'menu-select1', self:create_key_action('key_right'))
-	self:add_key_binding('shift+right', 'menu-select-soft1',
-		self:create_key_action('open_selected_item_soft', {shift = true}))
-	self:add_key_binding('shift+mbtn_left', 'menu-select3', self:create_modified_mbtn_left_handler({shift = true}))
-	self:add_key_binding('ctrl+mbtn_left', 'menu-select4', self:create_modified_mbtn_left_handler({ctrl = true}))
-	self:add_key_binding('alt+mbtn_left', 'menu-select5', self:create_modified_mbtn_left_handler({alt = true}))
-	self:add_key_binding('mbtn_back', 'menu-back-alt3', self:create_key_action('back'))
-	self:add_key_binding('bs', 'menu-back-alt4', self:create_key_action('key_bs'), {repeatable = true, complex = true})
-	self:add_key_binding('shift+bs', 'menu-clear-query', self:create_key_action('key_bs', {shift = true}),
-		{repeatable = true, complex = true})
-	self:add_key_binding('ctrl+bs', 'menu-delete-word', self:create_key_action('key_bs', {ctrl = true}),
-		{repeatable = true, complex = true})
-	self:add_key_binding('enter', 'menu-select-alt3', self:create_key_action('open_selected_item_preselect'))
-	self:add_key_binding('kp_enter', 'menu-select-alt4', self:create_key_action('open_selected_item_preselect'))
-	self:add_key_binding('ctrl+enter', 'menu-select-ctrl1', self:create_key_action('key_ctrl_enter', {ctrl = true}))
-	self:add_key_binding('alt+enter', 'menu-select-alt1',
-		self:create_key_action('open_selected_item_preselect', {alt = true}))
-	self:add_key_binding('ctrl+kp_enter', 'menu-select-ctrl2',
-		self:create_key_action('open_selected_item_preselect', {ctrl = true}))
-	self:add_key_binding('alt+kp_enter', 'menu-select-alt2',
-		self:create_key_action('open_selected_item_preselect', {alt = true}))
-	self:add_key_binding('shift+enter', 'menu-select-alt5',
-		self:create_key_action('open_selected_item_soft', {shift = true}))
-	self:add_key_binding('shift+kp_enter', 'menu-select-alt6',
-		self:create_key_action('open_selected_item_soft', {shift = true}))
-	self:add_key_binding('esc', 'menu-close', self:create_key_action('close'))
-	self:add_key_binding('pgup', 'menu-page-up', self:create_key_action('on_pgup'), 'repeatable')
-	self:add_key_binding('pgdwn', 'menu-page-down', self:create_key_action('on_pgdwn'), 'repeatable')
-	self:add_key_binding('home', 'menu-home', self:create_key_action('on_home'))
-	self:add_key_binding('end', 'menu-end', self:create_key_action('on_end'))
-	self:add_key_binding('del', 'menu-delete-item', self:create_key_action('delete_selected_item'))
-	self:add_key_binding('ctrl+v', 'menu-paste', self:create_key_action('paste'))
-	if self.type_to_search then
-		self:search_enable_key_bindings()
-	else
-		self:add_key_binding('/', 'menu-search1', self:create_key_action('search_start'))
-		self:add_key_binding('ctrl+f', 'menu-search2', self:create_key_action('search_start'))
+	-- `+` at the end enables `repeatable` flag
+	local standalone_keys = {
+		'f1', 'f2', 'f3', 'f4', 'f5', 'f6', 'f7', 'f8', 'f9', 'f10', 'f11', 'f12', '/', 'kp_divide', 'mbtn_back',
+		{'f', 'ctrl'}, {'v', 'ctrl'}, {'c', 'ctrl'},
+	}
+	local modifiable_keys = {'up+', 'down+', 'left', 'right', 'enter', 'kp_enter', 'bs', 'tab', 'esc', 'pgup+',
+		'pgdwn+', 'home', 'end', 'del'}
+	local modifiers = {nil, 'alt', 'alt+ctrl', 'alt+shift', 'alt+ctrl+shift', 'ctrl', 'ctrl+shift', 'shift'}
+	local normalized = {kp_enter = 'enter'}
+
+	local function bind(key, modifier, flags)
+		local binding = modifier and modifier .. '+' .. key or key
+		local shortcut = create_shortcut(normalized[key] or key, modifier)
+		local handler = self:create_action(function(info) self:handle_shortcut(shortcut, info) end)
+		self:add_key_binding(binding, {handler, flags})
 	end
+
+	for i, key_mods in ipairs(standalone_keys) do
+		local is_table = type(key_mods) == 'table'
+		local key, mods = is_table and key_mods[1] or key_mods, is_table and key_mods[2] or nil
+		bind(key, mods, {repeatable = false, complex = true})
+	end
+
+	for i, key in ipairs(modifiable_keys) do
+		local flags = {repeatable = false, complex = true}
+
+		if key:sub(-1) == '+' then
+			key = key:sub(1, -2)
+			flags.repeatable = true
+		end
+
+		for j = 1, #modifiers do
+			bind(key, modifiers[j], flags)
+		end
+	end
+
+	self:search_ensure_key_bindings()
 end
 
-function Menu:disable_key_bindings()
-	self:search_disable_key_bindings()
-	for _, name in ipairs(self.key_bindings) do mp.remove_key_binding(name) end
-	self.key_bindings = {}
+-- Handles all key and mouse button shortcuts, except unicode inputs.
+---@param shortcut Shortcut
+---@param info ComplexBindingInfo
+function Menu:handle_shortcut(shortcut, info)
+	if not self:is_alive() then return end
+
+	self.mouse_nav = info.is_mouse
+	local menu, id, key, modifiers = self.current, shortcut.id, shortcut.key, shortcut.modifiers
+	local selected_index = menu.selected_index
+	local selected_item = menu and selected_index and menu.items[selected_index]
+	local is_submenu = selected_item and selected_item.items ~= nil
+	local actions = selected_item and selected_item.actions or menu.item_actions
+	local selected_action = actions and menu.action_index and actions[menu.action_index]
+
+	if info.event == 'up' then return end
+
+	if (key == 'enter' and selected_item) or (id == 'right' and is_submenu) then
+		self:activate_selected_item(shortcut)
+	elseif id == 'enter' and menu.search and menu.search_debounce == 'submit' then
+		self:search_submit()
+	elseif id == 'up' or id == 'down' then
+		self:navigate_by_offset(id == 'up' and -1 or 1, true)
+	elseif id == 'pgup' or id == 'pgdwn' then
+		local items_per_page = round((menu.height / self.scroll_step) * 0.4)
+		self:navigate_by_offset(items_per_page * (id == 'pgup' and -1 or 1))
+	elseif id == 'home' or id == 'end' then
+		self:navigate_by_offset(id == 'home' and -math.huge or math.huge)
+	elseif id == 'shift+tab' then
+		self:prev_action()
+	elseif id == 'tab' then
+		self:next_action()
+	elseif id == 'ctrl+up' then
+		self:move_selected_item_by(-1)
+	elseif id == 'ctrl+down' then
+		self:move_selected_item_by(1)
+	elseif id == 'ctrl+pgup' then
+		self:move_selected_item_by(-round((menu.height / self.scroll_step) * 0.4))
+	elseif id == 'ctrl+pgdwn' then
+		self:move_selected_item_by(round((menu.height / self.scroll_step) * 0.4))
+	elseif id == 'ctrl+home' then
+		self:move_selected_item_by(-math.huge)
+	elseif id == 'ctrl+end' then
+		self:move_selected_item_by(math.huge)
+	elseif id == '/' or id == 'kp_divide' or id == 'ctrl+f' then
+		self:search_start()
+	elseif key == 'esc' then
+		if menu.search and menu.search_style ~= 'palette' then
+			self:search_cancel()
+		else
+			self:close()
+		end
+	elseif id == 'left' and menu.parent_menu then
+		self:back()
+	elseif key == 'bs' then
+		if menu.search then
+			if modifiers == 'shift' then
+				self:search_clear_query()
+			elseif not modifiers or modifiers == 'ctrl' then
+				self:search_backspace(info.event, modifiers == 'ctrl')
+			end
+		elseif not modifiers and info.event ~= 'repeat' then
+			self:back()
+		end
+	elseif key == 'mbtn_back' then
+		self:back()
+	elseif id == 'ctrl+v' then
+		self:paste()
+	else
+		self.callback(table_assign({}, shortcut, {
+			type = 'key',
+			menu_id = menu.id,
+			selected_item = selected_item and {
+				index = selected_index, value = selected_item.value, action = selected_action,
+			},
+		}))
+	end
 end
 
 -- Check if menu is not closed or closing.
-function Menu:is_alive() return not self.is_closing and not self.is_closed end
-
--- Wraps a function so that it won't run if menu is closing or closed.
----@param fn function()
-function Menu:create_action(fn)
-	return function(...)
-		if self:is_alive() then fn(...) end
-	end
-end
-
----@param modifiers Modifiers
-function Menu:create_modified_mbtn_left_handler(modifiers)
-	return self:create_action(function()
-		self.mouse_nav = true
-		self.modifiers = modifiers or {}
-		self:handle_cursor_down()
-		self:handle_cursor_up()
-		self.modifiers = {}
-	end)
-end
+function Menu:is_alive() return not self.is_closing and not self.destroyed end
 
 ---@param name string
----@param modifiers? Modifiers
-function Menu:create_key_action(name, modifiers)
+function Menu:create_key_handler(name)
 	return self:create_action(function(...)
 		self.mouse_nav = false
-		self.modifiers = modifiers or {}
 		self:maybe(name, ...)
-		self.modifiers = {}
 	end)
 end
 
+-- Sends command with params, or triggers a callback event if `command == 'callback'`.
+-- Intended to handle `on_{event}: 'callback' | string | string[]` events.
+-- Returns what happened.
+---@param command string|number|string[]|number[]
+---@param params string[]|number[]
+---@param event MenuEvent
+---@return 'event' | 'command' | nil
+function Menu:command_or_event(command, params, event)
+	if command == 'callback' then
+		self.callback(event)
+		return 'event'
+	elseif type(command) == 'table' then
+		---@diagnostic disable-next-line: deprecated
+		mp.command_native(itable_join(command, params))
+		return 'command'
+	elseif type(command) == 'string' then
+		mp.command(command .. ' ' .. table.concat(params, ' '))
+		return 'command'
+	end
+	return nil
+end
+
 function Menu:render()
 	for _, menu in ipairs(self.all) do
 		if menu.fling then
 			local time_delta = state.render_last_time - menu.fling.time
 			local progress = menu.fling.easing(math.min(time_delta / menu.fling.duration, 1))
-			self:set_scroll_to(round(menu.fling.y + menu.fling.distance * progress), menu)
+			self:set_scroll_to(round(menu.fling.y + menu.fling.distance * progress), menu.id)
 			if progress < 1 then request_render() else menu.fling = nil end
 		end
 	end
 
 	local display_rect = {ax = 0, ay = 0, bx = display.width, by = display.height}
 	cursor:zone('primary_down', display_rect, self:create_action(function() self:handle_cursor_down() end))
-	cursor:zone('primary_up', display_rect, self:create_action(function() self:handle_cursor_up() end))
+	cursor:zone('primary_up', display_rect, self:create_action(function(shortcut) self:handle_cursor_up(shortcut) end))
 	cursor:zone('wheel_down', self, function() self:handle_wheel_down() end)
 	cursor:zone('wheel_up', self, function() self:handle_wheel_up() end)
 
@@ -1139,7 +1205,7 @@ function Menu:render()
 			bx = bx,
 			by = by + self.padding,
 		}
-		local blur_selected_index = is_current and self.mouse_nav
+		local blur_action_index = self.mouse_nav and menu.action_index ~= nil
 
 		-- Background
 		ass:rect(menu_rect.ax, menu_rect.ay, menu_rect.bx, menu_rect.by, {
@@ -1149,47 +1215,60 @@ function Menu:render()
 		})
 
 		if is_parent then
-			cursor:zone('primary_down', menu_rect, self:create_action(function() self:slide_in_menu(menu, x) end))
+			cursor:zone('primary_down', menu_rect, self:create_action(function() self:slide_in_menu(menu.id, x) end))
+		end
+
+		-- Scrollbar
+		if menu.scroll_height > 0 then
+			local groove_height = menu.height - 2
+			local thumb_height = math.max((menu.height / (menu.scroll_height + menu.height)) * groove_height, 40)
+			local thumb_y = ay + 1 + ((menu.scroll_y / menu.scroll_height) * (groove_height - thumb_height))
+			local sax = bx - round(self.scrollbar_size / 2)
+			local sbx = sax + self.scrollbar_size
+			ass:rect(sax, thumb_y, sbx, thumb_y + thumb_height, {color = fg, opacity = menu_opacity * 0.8})
 		end
 
 		-- Draw submenu if selected
 		local submenu_rect, current_item = nil, is_current and menu.selected_index and menu.items[menu.selected_index]
 		local submenu_is_hovered = false
 		if current_item and current_item.items then
-			submenu_rect = draw_menu(current_item, menu_rect.bx + self.gap, 1)
-			cursor:zone('primary_down', submenu_rect, self:create_action(function()
-				self:open_selected_item({preselect_first_item = false})
+			submenu_rect = draw_menu(current_item --[[@as MenuStack]], menu_rect.bx + self.gap, 1)
+			cursor:zone('primary_down', submenu_rect, self:create_action(function(shortcut)
+				self:activate_selected_item(shortcut, true)
 			end))
 		end
 
+		---@type MenuAction|nil
+		local selected_action
 		for index = start_index, end_index, 1 do
 			local item = menu.items[index]
 
 			if not item then break end
 
+			local item_ax = menu_rect.ax + self.padding
+			local item_bx = menu_rect.bx - self.padding
 			local item_ay = ay - menu.scroll_y + self.scroll_step * (index - 1)
 			local item_by = item_ay + self.item_height
 			local item_center_y = item_ay + (self.item_height / 2)
 			local item_clip = (item_ay < ay or item_by > by) and scroll_clip or nil
 			local content_ax, content_bx = ax + self.padding + spacing, bx - self.padding - spacing
 			local is_selected = menu.selected_index == index
+			local item_rect_hitbox = {
+				ax = item_ax,
+				ay = math.max(item_ay, menu_rect.ay),
+				bx = menu_rect.bx + (item.items and self.gap or -self.padding), -- to bridge the gap with cursor
+				by = math.min(item_ay + self.scroll_step, menu_rect.by),
+			}
 
 			-- Select hovered item
-			if is_current and self.mouse_nav and item.selectable ~= false then
-				if submenu_rect and cursor:direction_to_rectangle_distance(submenu_rect) then
-					blur_selected_index = false
-				else
-					local item_rect_hitbox = {
-						ax = menu_rect.ax + self.padding,
-						ay = item_ay,
-						bx = menu_rect.bx + (item.items and self.gap or -self.padding), -- to bridge the gap with cursor
-						by = item_by,
-					}
-					if submenu_is_hovered or get_point_to_rectangle_proximity(cursor, item_rect_hitbox) == 0 then
-						blur_selected_index = false
-						menu.selected_index = index
-						if not is_selected then request_render() end
-					end
+			if is_current and self.mouse_nav and item.selectable ~= false
+				-- Do not select items if cursor is moving towards a submenu
+				and (not submenu_rect or not cursor:direction_to_rectangle_distance(submenu_rect))
+				and (submenu_is_hovered or get_point_to_rectangle_proximity(cursor, item_rect_hitbox) == 0) then
+				menu.selected_index = index
+				if not is_selected then
+					is_selected = true
+					request_render()
 				end
 			end
 
@@ -1198,6 +1277,10 @@ function Menu:render()
 			local next_is_active = next_item and next_item.active
 			local next_has_background = menu.selected_index == index + 1 or next_is_active
 			local font_color = item.active and fgt or bgt
+			local actions = is_selected and (item.actions or menu.item_actions) -- not nil = actions are visible
+			local action = actions and actions[menu.action_index] -- not nil = action is selected
+
+			if action then selected_action = action end
 
 			-- Separator
 			if item_by < by and ((not has_background and not next_has_background) or item.separator) then
@@ -1214,7 +1297,7 @@ function Menu:render()
 
 			-- Background
 			local highlight_opacity = 0 + (item.active and 0.8 or 0) + (is_selected and 0.15 or 0)
-			if not is_submenu and highlight_opacity > 0 then
+			if highlight_opacity > 0 then
 				ass:rect(ax + self.padding, item_ay, bx - self.padding, item_by, {
 					radius = state.radius,
 					color = fg,
@@ -1223,22 +1306,103 @@ function Menu:render()
 				})
 			end
 
-			-- Icon
-			if item.icon then
-				local x = (not item.title and not item.hint and item.align == 'center')
-					and menu_rect.ax + (menu_rect.bx - menu_rect.ax) / 2
-					or content_bx - (icon_size / 2)
-				if item.icon == 'spinner' then
-					ass:spinner(x, item_center_y, icon_size * 1.5, {color = font_color, opacity = menu_opacity * 0.8})
-				else
-					ass:icon(x, item_center_y, icon_size * 1.5, item.icon, {
-						color = font_color, opacity = menu_opacity, clip = item_clip,
-					})
+			local title_clip_bx = content_bx
+
+			-- Actions
+			local actions_rect
+			if is_selected and actions and #actions > 0 and not item.items then
+				local place = item.actions_place or menu.item_actions_place
+				local margin = self.gap * 2
+				local size = item_by - item_ay - margin * 2
+				local rect_width = size * #actions + margin * (#actions - 1)
+
+				-- Place actions outside of menu when requested and there's enough space for it
+				actions_rect = {
+					ay = item_ay + margin,
+					by = item_by - margin,
+					is_outside = place == 'outside' and display.width - menu_rect.bx + margin * 2 > rect_width,
+				}
+				actions_rect.bx = actions_rect.is_outside and menu_rect.bx + margin + rect_width or item_bx - margin
+				actions_rect.ax = actions_rect.bx
+
+				for i = 1, #actions, 1 do
+					local action_index = #actions - (i - 1)
+					local action = actions[action_index]
+
+					-- Hide when the action shouldn't be displayed when the item is a result of a search/filter
+					if not (action.filter_hidden and menu.search) then
+						local is_active = action_index == menu.action_index
+						local bx = actions_rect.ax - (i == 1 and 0 or margin)
+						local rect = {
+							ay = actions_rect.ay,
+							by = actions_rect.by,
+							ax = bx - size,
+							bx = bx,
+						}
+						actions_rect.ax = rect.ax
+
+						ass:rect(rect.ax, rect.ay, rect.bx, rect.by, {
+							radius = state.radius > 2 and state.radius - 1 or state.radius,
+							color = is_active and fg or bg,
+							border = is_active and self.gap or nil,
+							border_color = bg,
+							opacity = menu_opacity,
+							clip = item_clip,
+						})
+						ass:icon(rect.ax + size / 2, rect.ay + size / 2, size * 0.66, action.icon, {
+							color = is_active and bg or fg, opacity = menu_opacity, clip = item_clip,
+						})
+
+						-- Re-use rect as a hitbox by growing it so it bridges gaps to prevent flickering
+						rect.ay, rect.by, rect.bx = item_ay, item_ay + self.scroll_step, rect.bx + margin
+
+						-- Select action on cursor hover
+						if self.mouse_nav and get_point_to_rectangle_proximity(cursor, rect) == 0 then
+							cursor:zone('primary_click', rect, self:create_action(function(shortcut)
+								self:activate_selected_item(shortcut, true)
+							end))
+							blur_action_index = false
+							if not is_active then
+								menu.action_index = action_index
+								selected_action = actions[action_index]
+								request_render()
+							end
+						end
+					end
 				end
-				content_bx = content_bx - icon_size - spacing
+
+				title_clip_bx = actions_rect.ax - self.gap * 2
 			end
 
-			local title_cut_x = content_bx
+			-- Selected item indicator line
+			if is_selected and not selected_action then
+				local size = round(2 * state.scale)
+				local v_padding = math.min(state.radius, math.ceil(self.item_height / 3))
+				ass:rect(ax + self.padding - size - 1, item_ay + v_padding, ax + self.padding - 1,
+					item_by - v_padding, {
+						radius = 1 * state.scale, color = fg, opacity = menu_opacity, clip = item_clip,
+					})
+			end
+
+			-- Icon
+			if item.icon then
+				if not actions_rect or actions_rect.is_outside then
+					local x = (not item.title and not item.hint and item.align == 'center')
+						and menu_rect.ax + (menu_rect.bx - menu_rect.ax) / 2
+						or content_bx - (icon_size / 2)
+					if item.icon == 'spinner' then
+						ass:spinner(x, item_center_y, icon_size * 1.5, {color = font_color, opacity = menu_opacity * 0.8})
+					else
+						ass:icon(x, item_center_y, icon_size * 1.5, item.icon, {
+							color = font_color, opacity = menu_opacity, clip = item_clip,
+						})
+					end
+				end
+				content_bx = content_bx - icon_size - spacing
+				title_clip_bx = math.min(content_bx, title_clip_bx)
+			end
+
+			local hint_clip_bx = title_clip_bx
 			if item.hint_width > 0 then
 				-- controls title & hint clipping proportional to the ratio of their widths
 				-- both title and hint get at least 50% of the width, unless they are smaller then that
@@ -1246,16 +1410,23 @@ function Menu:render()
 				local title_min = math.min(item.title_width, width * 0.5)
 				local hint_min = math.min(item.hint_width, width * 0.5)
 				local title_ratio = item.title_width / (item.title_width + item.hint_width)
-				title_cut_x = round(content_ax + clamp(title_min, width * title_ratio, width - hint_min))
+				title_clip_bx = math.min(
+					title_clip_bx,
+					round(content_ax + clamp(title_min, width * title_ratio, width - hint_min))
+				)
 			end
 
 			-- Hint
 			if item.hint then
 				item.ass_safe_hint = item.ass_safe_hint or ass_escape(item.hint)
-				local clip = '\\clip(' .. title_cut_x + spacing .. ',' ..
-					math.max(item_ay, ay) .. ',' .. bx .. ',' .. math.min(item_by, by) .. ')'
+				local clip = '\\clip(' .. title_clip_bx + spacing .. ',' ..
+					math.max(item_ay, ay) .. ',' .. hint_clip_bx .. ',' .. math.min(item_by, by) .. ')'
 				ass:txt(content_bx, item_center_y, 6, item.ass_safe_hint, {
-					size = self.font_size_hint, color = font_color, wrap = 2, opacity = 0.5 * menu_opacity, clip = clip,
+					size = self.font_size_hint,
+					color = font_color,
+					wrap = 2,
+					opacity = 0.5 * menu_opacity,
+					clip = clip,
 				})
 			end
 
@@ -1263,12 +1434,12 @@ function Menu:render()
 			if item.title then
 				item.ass_safe_title = item.ass_safe_title or ass_escape(item.title)
 				local clip = '\\clip(' .. ax .. ',' .. math.max(item_ay, ay) .. ','
-					.. title_cut_x .. ',' .. math.min(item_by, by) .. ')'
+					.. title_clip_bx .. ',' .. math.min(item_by, by) .. ')'
 				local title_x, align = content_ax, 4
 				if item.align == 'right' then
-					title_x, align = title_cut_x, 6
+					title_x, align = title_clip_bx, 6
 				elseif item.align == 'center' then
-					title_x, align = content_ax + (title_cut_x - content_ax) / 2, 5
+					title_x, align = content_ax + (title_clip_bx - content_ax) / 2, 5
 				end
 				ass:txt(title_x, item_center_y, align, item.ass_safe_title, {
 					size = self.font_size,
@@ -1282,16 +1453,46 @@ function Menu:render()
 			end
 		end
 
+		-- Footnote / Selected action label
+		if is_current and (menu.footnote or selected_action) then
+			local height_half = self.font_size
+			local icon_x, icon_y = menu_rect.ax + self.padding + self.font_size / 2, menu_rect.by + height_half
+			local is_icon_hovered = false
+			local icon_hitbox = {
+				ax = icon_x - height_half,
+				ay = icon_y - height_half,
+				bx = icon_x + height_half,
+				by = icon_y + height_half,
+			}
+			is_icon_hovered = get_point_to_rectangle_proximity(cursor, icon_hitbox) == 0
+			local text = selected_action and selected_action.label or is_icon_hovered and menu.footnote
+			local opacity = (is_icon_hovered and 1 or 0.5) * menu_opacity
+			ass:icon(icon_x, icon_y, self.font_size, is_icon_hovered and 'help' or 'help_outline', {
+				color = fg, border = state.scale, border_color = bg, opacity = opacity,
+			})
+			if text then
+				ass:txt(icon_x + self.font_size * 0.75, icon_y, 4, text, {
+					size = self.font_size,
+					color = fg,
+					border = state.scale,
+					border_color = bg,
+					opacity = menu_opacity,
+					italic = true,
+				})
+			end
+		end
+
 		-- Menu title
 		if draw_title then
 			local requires_submit = menu.search_debounce == 'submit'
 			local rect = {
-				ax = ax + spacing / 2 + self.padding,
+				ax = round(ax + spacing / 2 + self.padding),
 				ay = ay - self.scroll_step - self.padding * 2,
-				bx = bx - spacing / 2 - self.padding,
+				bx = round(bx - spacing / 2 - self.padding),
 				by = math.min(by, ay - self.padding),
 			}
-			rect.cx, rect.cy = rect.ax + (rect.bx - rect.ax) / 2, rect.ay + (rect.by - rect.ay) / 2 -- centers
+			-- centers
+			rect.cx, rect.cy = round(rect.ax + (rect.bx - rect.ax) / 2), round(rect.ay + (rect.by - rect.ay) / 2)
 
 			if menu.title and not menu.ass_safe_title then
 				menu.ass_safe_title = ass_escape(menu.title)
@@ -1350,12 +1551,20 @@ function Menu:render()
 					})
 				end
 
+				-- Selected input indicator for submittable searches.
+				-- (input is selected when `selected_index` is `nil`)
+				if menu.search_debounce == 'submit' and not menu.selected_index then
+					local size_half = round(1 * state.scale)
+					ass:rect(ax, rect.by - size_half, bx, rect.by + size_half, {color = fg, opacity = menu_opacity})
+				end
+				local input_is_blurred = menu.search_debounce == 'submit' and menu.selected_index
+
 				-- Cursor
-				local font_size_half, cursor_thickness = round(self.font_size / 2), round(self.font_size / 14)
+				local cursor_height_half, cursor_thickness = round(self.font_size * 0.6), round(self.font_size / 12)
 				local cursor_ax, cursor_bx = rect.bx + 1, rect.bx + 1 + cursor_thickness
-				ass:rect(cursor_ax, rect.cy - font_size_half, cursor_bx, rect.cy + font_size_half, {
+				ass:rect(cursor_ax, rect.cy - cursor_height_half, cursor_bx, rect.cy + cursor_height_half, {
 					color = fg,
-					opacity = menu_opacity * 0.5,
+					opacity = menu_opacity * (input_is_blurred and 0.5 or 1),
 					clip = '\\clip(' .. cursor_ax .. ',' .. rect.ay .. ',' .. cursor_bx .. ',' .. rect.by .. ')',
 				})
 			else
@@ -1370,25 +1579,15 @@ function Menu:render()
 			end
 		end
 
-		-- Scrollbar
-		if menu.scroll_height > 0 then
-			local groove_height = menu.height - 2
-			local thumb_height = math.max((menu.height / (menu.scroll_height + menu.height)) * groove_height, 40)
-			local thumb_y = ay + 1 + ((menu.scroll_y / menu.scroll_height) * (groove_height - thumb_height))
-			local sax = bx - round(self.scrollbar_size / 2)
-			local sbx = sax + self.scrollbar_size
-			ass:rect(sax, thumb_y, sbx, thumb_y + thumb_height, {color = fg, opacity = menu_opacity * 0.8})
-		end
-
-		-- We are in mouse nav and cursor isn't hovering any item
-		if blur_selected_index then
-			menu.selected_index = nil
+		if blur_action_index then
+			menu.action_index = nil
+			request_render()
 		end
 
 		return menu_rect
 	end
 
-	-- Main menu
+	-- Active menu
 	draw_menu(self.current, self.ax, 0)
 
 	-- Parent menus
diff --git a/multimedia/.config/mpv/scripts/uosc/elements/PauseIndicator.lua b/multimedia/.config/mpv/scripts/uosc_repo/src/uosc/elements/PauseIndicator.lua
similarity index 100%
rename from multimedia/.config/mpv/scripts/uosc/elements/PauseIndicator.lua
rename to multimedia/.config/mpv/scripts/uosc_repo/src/uosc/elements/PauseIndicator.lua
diff --git a/multimedia/.config/mpv/scripts/uosc/elements/Speed.lua b/multimedia/.config/mpv/scripts/uosc_repo/src/uosc/elements/Speed.lua
similarity index 97%
rename from multimedia/.config/mpv/scripts/uosc/elements/Speed.lua
rename to multimedia/.config/mpv/scripts/uosc_repo/src/uosc/elements/Speed.lua
index 216d0d6..f994e69 100644
--- a/multimedia/.config/mpv/scripts/uosc/elements/Speed.lua
+++ b/multimedia/.config/mpv/scripts/uosc_repo/src/uosc/elements/Speed.lua
@@ -22,6 +22,10 @@ function Speed:init(props)
 	self.dragging = nil
 end
 
+function Speed:get_visibility()
+	return Elements:maybe('timeline', 'get_is_hovered') and -1 or Element.get_visibility(self)
+end
+
 function Speed:on_coordinates()
 	self.height, self.width = self.by - self.ay, self.bx - self.ax
 	self.notch_spacing = self.width / (self.notches + 1)
diff --git a/multimedia/.config/mpv/scripts/uosc/elements/Timeline.lua b/multimedia/.config/mpv/scripts/uosc_repo/src/uosc/elements/Timeline.lua
similarity index 95%
rename from multimedia/.config/mpv/scripts/uosc/elements/Timeline.lua
rename to multimedia/.config/mpv/scripts/uosc_repo/src/uosc/elements/Timeline.lua
index 2ed40d2..608275f 100644
--- a/multimedia/.config/mpv/scripts/uosc/elements/Timeline.lua
+++ b/multimedia/.config/mpv/scripts/uosc_repo/src/uosc/elements/Timeline.lua
@@ -163,8 +163,6 @@ function Timeline:on_global_mouse_move()
 		end
 	end
 end
-function Timeline:handle_wheel_up() mp.commandv('seek', options.timeline_step) end
-function Timeline:handle_wheel_down() mp.commandv('seek', -options.timeline_step) end
 
 function Timeline:render()
 	if self.size == 0 then return end
@@ -186,8 +184,14 @@ function Timeline:render()
 			self:handle_cursor_down()
 			cursor:once('primary_up', function() self:handle_cursor_up() end)
 		end)
-		cursor:zone('wheel_down', self, function() self:handle_wheel_down() end)
-		cursor:zone('wheel_up', self, function() self:handle_wheel_up() end)
+		if config.timeline_step ~= 0 then
+			cursor:zone('wheel_down', self, function()
+				mp.commandv('seek', -config.timeline_step, config.timeline_step_flag)
+			end)
+			cursor:zone('wheel_up', self, function()
+				mp.commandv('seek', config.timeline_step, config.timeline_step_flag)
+			end)
+		end
 	end
 
 	local ass = assdraw.ass_new()
@@ -251,15 +255,11 @@ function Timeline:render()
 	ass:rect(fax, fay, fbx, fby, {opacity = config.opacity.position})
 
 	-- Uncached ranges
-	local buffered_playtime = nil
 	if state.uncached_ranges then
 		local opts = {size = 80, anchor_y = fby}
 		local texture_char = visibility > 0 and 'b' or 'a'
 		local offset = opts.size / (visibility > 0 and 24 or 28)
 		for _, range in ipairs(state.uncached_ranges) do
-			if not buffered_playtime and (range[1] > state.time or range[2] > state.time) then
-				buffered_playtime = (range[1] - state.time) / (state.speed or 1)
-			end
 			if options.timeline_cache then
 				local ax = range[1] < 0.5 and bax or math.floor(t2x(range[1]))
 				local bx = range[2] > state.duration - 0.5 and bbx or math.ceil(t2x(range[2]))
@@ -321,7 +321,7 @@ function Timeline:render()
 					if chapter ~= hovered_chapter then draw_chapter(chapter.time, diamond_radius) end
 					local circle = {point = {x = t2x(chapter.time), y = fay - 1}, r = diamond_radius_hovered}
 					if visibility > 0 then
-						cursor:zone('primary_down', circle, function()
+						cursor:zone('primary_click', circle, function()
 							mp.commandv('seek', chapter.time, 'absolute+exact')
 						end)
 					end
@@ -377,14 +377,15 @@ function Timeline:render()
 	if text_opacity > 0 then
 		local time_opts = {size = self.font_size, opacity = text_opacity, border = 2 * state.scale}
 		-- Upcoming cache time
-		if buffered_playtime and options.buffered_time_threshold > 0
-			and buffered_playtime < options.buffered_time_threshold then
+		local cache_duration = state.cache_duration and state.cache_duration / state.speed or nil
+		if cache_duration and options.buffered_time_threshold > 0
+			and cache_duration < options.buffered_time_threshold then
 			local margin = 5 * state.scale
 			local x, align = fbx + margin, 4
 			local cache_opts = {
 				size = self.font_size * 0.8, opacity = text_opacity * 0.6, border = options.text_border * state.scale,
 			}
-			local human = round(math.max(buffered_playtime, 0)) .. 's'
+			local human = round(cache_duration) .. 's'
 			local width = text_width(human, cache_opts)
 			local time_width = timestamp_width(state.time_human, time_opts)
 			local time_width_end = timestamp_width(state.destination_time_human, time_opts)
@@ -449,13 +450,14 @@ function Timeline:render()
 				border_color = fg,
 				radius = state.radius,
 			})
-			mp.commandv('script-message-to', 'thumbfast', 'thumb', hovered_seconds, thumb_x, thumb_y)
+			local thumb_seconds = (state.rebase_start_time == false and state.start_time) and (hovered_seconds - state.start_time) or hovered_seconds
+			mp.commandv('script-message-to', 'thumbfast', 'thumb', thumb_seconds, thumb_x, thumb_y)
 			self.has_thumbnail, rendered_thumbnail = true, true
 			tooltip_anchor.ay = ay
 		end
 
 		-- Chapter title
-		if #state.chapters > 0 then
+		if config.opacity.chapters > 0 and #state.chapters > 0 then
 			local _, chapter = itable_find(state.chapters, function(c) return hovered_seconds >= c.time end,
 				#state.chapters, 1)
 			if chapter and not chapter.is_end_only then
diff --git a/multimedia/.config/mpv/scripts/uosc/elements/TopBar.lua b/multimedia/.config/mpv/scripts/uosc_repo/src/uosc/elements/TopBar.lua
similarity index 58%
rename from multimedia/.config/mpv/scripts/uosc/elements/TopBar.lua
rename to multimedia/.config/mpv/scripts/uosc_repo/src/uosc/elements/TopBar.lua
index 8b943ae..32466a1 100644
--- a/multimedia/.config/mpv/scripts/uosc/elements/TopBar.lua
+++ b/multimedia/.config/mpv/scripts/uosc_repo/src/uosc/elements/TopBar.lua
@@ -1,46 +1,6 @@
 local Element = require('elements/Element')
 
----@alias TopBarButtonProps {icon: string; background: string; anchor_id?: string; command: string|fun()}
-
----@class TopBarButton : Element
-local TopBarButton = class(Element)
-
----@param id string
----@param props TopBarButtonProps
-function TopBarButton:new(id, props) return Class.new(self, id, props) --[[@as TopBarButton]] end
-function TopBarButton:init(id, props)
-	Element.init(self, id, props)
-	self.anchor_id = 'top_bar'
-	self.icon = props.icon
-	self.background = props.background
-	self.command = props.command
-end
-
-function TopBarButton:handle_click()
-	mp.command(type(self.command) == 'function' and self.command() or self.command)
-end
-
-function TopBarButton:render()
-	local visibility = self:get_visibility()
-	if visibility <= 0 then return end
-	local ass = assdraw.ass_new()
-
-	-- Background on hover
-	if self.proximity_raw == 0 then
-		ass:rect(self.ax, self.ay, self.bx, self.by, {color = self.background, opacity = visibility})
-	end
-	cursor:zone('primary_click', self, function() self:handle_click() end)
-
-	local width, height = self.bx - self.ax, self.by - self.ay
-	local icon_size = math.min(width, height) * 0.5
-	ass:icon(self.ax + width / 2, self.ay + height / 2, icon_size, self.icon, {
-		opacity = visibility, border = options.text_border * state.scale,
-	})
-
-	return ass
-end
-
---[[ TopBar ]]
+---@alias TopBarButtonProps {icon: string; hover_fg?: string; hover_bg?: string; command: (fun():string)}
 
 ---@class TopBar : Element
 local TopBar = class(Element)
@@ -49,48 +9,30 @@ function TopBar:new() return Class.new(self) --[[@as TopBar]] end
 function TopBar:init()
 	Element.init(self, 'top_bar', {render_order = 4})
 	self.size = 0
-	self.icon_size, self.spacing, self.font_size, self.title_bx, self.title_by = 1, 1, 1, 1, 1
+	self.icon_size, self.font_size, self.title_by = 1, 1, 1
 	self.show_alt_title = false
 	self.main_title, self.alt_title = nil, nil
 
-	local function get_maximized_command()
+	local function maximized_command()
 		if state.platform == 'windows' then
-			return state.border
+			mp.command(state.border
 				and (state.fullscreen and 'set fullscreen no;cycle window-maximized' or 'cycle window-maximized')
-				or 'set window-maximized no;cycle fullscreen'
+				or 'set window-maximized no;cycle fullscreen')
+		else
+			mp.command(state.fullormaxed and 'set fullscreen no;set window-maximized no' or 'set window-maximized yes')
 		end
-		return state.fullormaxed and 'set fullscreen no;set window-maximized no' or 'set window-maximized yes'
 	end
 
-	-- Order aligns from right to left
-	self.buttons = {
-		TopBarButton:new('tb_close', {
-			icon = 'close', background = '2311e8', command = 'quit', render_order = self.render_order,
-		}),
-		TopBarButton:new('tb_max', {
-			icon = 'crop_square',
-			background = '222222',
-			command = get_maximized_command,
-			render_order = self.render_order,
-		}),
-		TopBarButton:new('tb_min', {
-			icon = 'minimize',
-			background = '222222',
-			command = 'cycle window-minimized',
-			render_order = self.render_order,
-		}),
-	}
+	local close = {icon = 'close', hover_bg = '2311e8', hover_fg = 'ffffff', command = function() mp.command('quit') end}
+	local max = {icon = 'crop_square', command = maximized_command}
+	local min = {icon = 'minimize', command = function() mp.command('cycle window-minimized') end}
+	self.buttons = options.top_bar_controls == 'left' and {close, max, min} or {min, max, close}
 
 	self:decide_titles()
 	self:decide_enabled()
 	self:update_dimensions()
 end
 
-function TopBar:destroy()
-	for _, button in ipairs(self.buttons) do button:destroy() end
-	Element.destroy(self)
-end
-
 function TopBar:decide_enabled()
 	if options.top_bar == 'no-border' then
 		self.enabled = not state.border or state.title_bar == false or state.fullscreen
@@ -98,9 +40,6 @@ function TopBar:decide_enabled()
 		self.enabled = options.top_bar == 'always'
 	end
 	self.enabled = self.enabled and (options.top_bar_controls or options.top_bar_title ~= 'no' or state.has_playlist)
-	for _, element in ipairs(self.buttons) do
-		element.enabled = self.enabled and options.top_bar_controls
-	end
 end
 
 function TopBar:decide_titles()
@@ -126,7 +65,7 @@ function TopBar:decide_titles()
 			longer_title, shorter_title = self.main_title, self.alt_title
 		end
 
-		local escaped_shorter_title = string.gsub(shorter_title --[[@as string]], '[%(%)%.%+%-%*%?%[%]%^%$%%]', '%%%1')
+		local escaped_shorter_title = regexp_escape(shorter_title --[[@as string]])
 		if string.match(longer_title --[[@as string]], escaped_shorter_title) then
 			self.main_title, self.alt_title = longer_title, nil
 		end
@@ -136,27 +75,18 @@ end
 function TopBar:update_dimensions()
 	self.size = round(options.top_bar_size * state.scale)
 	self.icon_size = round(self.size * 0.5)
-	self.spacing = math.ceil(self.size * 0.25)
-	self.font_size = math.floor((self.size - (self.spacing * 2)) * options.font_scale)
-	self.button_width = round(self.size * 1.15)
+	self.font_size = math.floor((self.size - (math.ceil(self.size * 0.25) * 2)) * options.font_scale)
 	local window_border_size = Elements:v('window_border', 'size', 0)
+	self.ax = window_border_size
 	self.ay = window_border_size
 	self.bx = display.width - window_border_size
 	self.by = self.size + window_border_size
-	self.title_bx = self.bx - (options.top_bar_controls and (self.button_width * 3) or 0)
-	self.ax = (options.top_bar_title ~= 'no' or state.has_playlist) and window_border_size or self.title_bx
-
-	local button_bx = self.bx
-	for _, element in pairs(self.buttons) do
-		element.ax, element.bx = button_bx - self.button_width, button_bx
-		element.ay, element.by = self.ay, self.by
-		button_bx = button_bx - self.button_width
-	end
 end
 
 function TopBar:toggle_title()
 	if options.top_bar_alt_title_place ~= 'toggle' then return end
 	self.show_alt_title = not self.show_alt_title
+	request_render()
 end
 
 function TopBar:on_prop_title() self:decide_titles() end
@@ -198,15 +128,54 @@ function TopBar:render()
 	local visibility = self:get_visibility()
 	if visibility <= 0 then return end
 	local ass = assdraw.ass_new()
+	local ax, bx = self.ax, self.bx
+	local margin = math.floor((self.size - self.font_size) / 4)
+
+	-- Window controls
+	if options.top_bar_controls then
+		local is_left, button_ax = options.top_bar_controls == 'left', 0
+		if is_left then
+			button_ax = ax
+			ax = self.size * #self.buttons
+		else
+			button_ax = bx - self.size * #self.buttons
+			bx = button_ax
+		end
+
+		for _, button in ipairs(self.buttons) do
+			local rect = {ax = button_ax, ay = self.ay, bx = button_ax + self.size, by = self.by}
+			local is_hover = get_point_to_rectangle_proximity(cursor, rect) == 0
+			local opacity = is_hover and 1 or config.opacity.controls
+			local button_fg = is_hover and (button.hover_fg or bg) or fg
+			local button_bg = is_hover and (button.hover_bg or fg) or bg
+
+			cursor:zone('primary_click', rect, button.command)
+
+			local bg_size = self.size - margin
+			local bg_ax, bg_ay = rect.ax + (is_left and margin or 0), rect.ay + margin
+			local bg_bx, bg_by = bg_ax + bg_size, bg_ay + bg_size
+
+			ass:rect(bg_ax, bg_ay, bg_bx, bg_by, {
+				color = button_bg, opacity = visibility * opacity, radius = state.radius,
+			})
+
+			ass:icon(bg_ax + bg_size / 2, bg_ay + bg_size / 2, bg_size * 0.5, button.icon, {
+				color = button_fg,
+				border_color = button_bg,
+				opacity = visibility,
+				border = options.text_border * state.scale,
+			})
+
+			button_ax = button_ax + self.size
+		end
+	end
 
 	-- Window title
 	if state.title or state.has_playlist then
-		local bg_margin = math.floor((self.size - self.font_size) / 4)
 		local padding = self.font_size / 2
 		local spacing = 1
-		local title_ax = self.ax + bg_margin
-		local title_ay = self.ay + bg_margin
-		local max_bx = self.title_bx - self.spacing
+		local left_aligned = options.top_bar_controls == 'left'
+		local title_ax, title_bx, title_ay = ax + margin, bx - margin, self.ay + margin
 
 		-- Playlist position
 		if state.has_playlist then
@@ -214,11 +183,13 @@ function TopBar:render()
 			local formatted_text = '{\\b1}' .. state.playlist_pos .. '{\\b0\\fs' .. self.font_size * 0.9 .. '}/'
 				.. state.playlist_count
 			local opts = {size = self.font_size, wrap = 2, color = fgt, opacity = visibility}
+			local rect_width = round(text_width(text, opts) + padding * 2)
+			local ax = left_aligned and title_bx - rect_width or title_ax
 			local rect = {
-				ax = title_ax,
+				ax = ax,
 				ay = title_ay,
-				bx = round(title_ax + text_width(text, opts) + padding * 2),
-				by = self.by - bg_margin,
+				bx = ax + rect_width,
+				by = self.by - margin,
 			}
 			local opacity = get_point_to_rectangle_proximity(cursor, rect) == 0
 				and 1 or config.opacity.playlist_position
@@ -228,14 +199,14 @@ function TopBar:render()
 				})
 			end
 			ass:txt(rect.ax + (rect.bx - rect.ax) / 2, rect.ay + (rect.by - rect.ay) / 2, 5, formatted_text, opts)
-			title_ax = rect.bx + bg_margin
+			if left_aligned then title_bx = rect.ax - margin else title_ax = rect.bx + margin end
 
 			-- Click action
 			cursor:zone('primary_click', rect, function() mp.command('script-binding uosc/playlist') end)
 		end
 
 		-- Skip rendering titles if there's not enough horizontal space
-		if max_bx - title_ax > self.font_size * 3 and options.top_bar_title ~= 'no' then
+		if title_bx - title_ax > self.font_size * 3 and options.top_bar_title ~= 'no' then
 			-- Main title
 			local main_title = self.show_alt_title and self.alt_title or self.main_title
 			if main_title then
@@ -246,11 +217,13 @@ function TopBar:render()
 					opacity = visibility,
 					border = options.text_border * state.scale,
 					border_color = bg,
-					clip = string.format('\\clip(%d, %d, %d, %d)', self.ax, self.ay, max_bx, self.by),
+					clip = string.format('\\clip(%d, %d, %d, %d)', self.ax, self.ay, title_bx, self.by),
 				}
-				local bx = round(math.min(max_bx, title_ax + text_width(main_title, opts) + padding * 2))
-				local by = self.by - bg_margin
-				local title_rect = {ax = title_ax, ay = title_ay, bx = bx, by = by}
+				local rect_ideal_width = round(text_width(main_title, opts) + padding * 2)
+				local rect_width = math.min(rect_ideal_width, title_bx - title_ax)
+				local ax = left_aligned and title_bx - rect_width or title_ax
+				local by = self.by - margin
+				local title_rect = {ax = ax, ay = title_ay, bx = ax + rect_width, by = by}
 
 				if options.top_bar_alt_title_place == 'toggle' then
 					cursor:zone('primary_click', title_rect, function() self:toggle_title() end)
@@ -259,7 +232,9 @@ function TopBar:render()
 				ass:rect(title_rect.ax, title_rect.ay, title_rect.bx, title_rect.by, {
 					color = bg, opacity = visibility * config.opacity.title, radius = state.radius,
 				})
-				ass:txt(title_ax + padding, self.ay + (self.size / 2), 4, main_title, opts)
+				local align = left_aligned and rect_ideal_width == rect_width and 6 or 4
+				local x = align == 6 and title_rect.bx - padding or ax + padding
+				ass:txt(x, self.ay + (self.size / 2), align, main_title, opts)
 				title_ay = by + spacing
 			end
 
@@ -276,12 +251,17 @@ function TopBar:render()
 					border_color = bg,
 					opacity = visibility,
 				}
-				local bx = round(math.min(max_bx, title_ax + text_width(self.alt_title, opts) + padding * 2))
+				local rect_ideal_width = round(text_width(self.alt_title, opts) + padding * 2)
+				local rect_width = math.min(rect_ideal_width, title_bx - title_ax)
+				local ax = left_aligned and title_bx - rect_width or title_ax
+				local bx = ax + rect_width
 				opts.clip = string.format('\\clip(%d, %d, %d, %d)', title_ax, title_ay, bx, by)
-				ass:rect(title_ax, title_ay, bx, by, {
+				ass:rect(ax, title_ay, bx, by, {
 					color = bg, opacity = visibility * config.opacity.title, radius = state.radius,
 				})
-				ass:txt(title_ax + padding, title_ay + height / 2, 4, self.alt_title, opts)
+				local align = left_aligned and rect_ideal_width == rect_width and 6 or 4
+				local x = align == 6 and bx - padding or ax + padding
+				ass:txt(x, title_ay + height / 2, align, self.alt_title, opts)
 				title_ay = by + spacing
 			end
 
@@ -290,10 +270,12 @@ function TopBar:render()
 				local padding_half = round(padding / 2)
 				local font_size = self.font_size * 0.8
 				local height = font_size * 1.3
-				local text = '└ ' .. state.current_chapter.index .. ': ' .. state.current_chapter.title
+				local prefix, postfix = left_aligned and '' or '└ ', left_aligned and ' ┘' or ''
+				local text = prefix .. state.current_chapter.index .. ': ' .. state.current_chapter.title .. postfix
 				local next_chapter = state.chapters[state.current_chapter.index + 1]
 				local chapter_end = next_chapter and next_chapter.time or state.duration or 0
-				local remaining_time = (state.time and state.time or 0) - chapter_end
+				local remaining_time = ((state.time or 0) - chapter_end) /
+					(options.destination_time == 'time-remaining' and 1 or state.speed)
 				local remaining_human = format_time(remaining_time, math.abs(remaining_time))
 				local opts = {
 					size = font_size,
@@ -308,32 +290,36 @@ function TopBar:render()
 				local remaining_box_width = remaining_width + padding_half * 2
 
 				-- Title
+				local max_bx = title_bx - remaining_box_width - spacing
+				local rect_ideal_width = round(text_width(text, opts) + padding * 2)
+				local rect_width = math.min(rect_ideal_width, max_bx - title_ax)
+				local ax = left_aligned and title_bx - rect_width or title_ax
 				local rect = {
-					ax = title_ax,
+					ax = ax,
 					ay = title_ay,
-					bx = round(math.min(
-						max_bx - remaining_box_width - spacing,
-						title_ax + text_width(text, opts) + padding * 2
-					)),
+					bx = ax + rect_width,
 					by = title_ay + height,
 				}
 				opts.clip = string.format('\\clip(%d, %d, %d, %d)', title_ax, title_ay, rect.bx, rect.by)
 				ass:rect(rect.ax, rect.ay, rect.bx, rect.by, {
 					color = bg, opacity = visibility * config.opacity.title, radius = state.radius,
 				})
-				ass:txt(rect.ax + padding, rect.ay + height / 2, 4, text, opts)
-
-				-- Click action
-				cursor:zone('primary_click', rect, function() mp.command('script-binding uosc/chapters') end)
+				local align = left_aligned and rect_ideal_width == rect_width and 6 or 4
+				local x = align == 6 and rect.bx - padding or rect.ax + padding
+				ass:txt(x, rect.ay + height / 2, align, text, opts)
 
 				-- Time
-				rect.ax = rect.bx + spacing
-				rect.bx = rect.ax + remaining_box_width
+				local time_ax = left_aligned and rect.ax - spacing - remaining_box_width or rect.bx + spacing
+				local time_bx = time_ax + remaining_box_width
 				opts.clip = nil
-				ass:rect(rect.ax, rect.ay, rect.bx, rect.by, {
+				ass:rect(time_ax, rect.ay, time_bx, rect.by, {
 					color = bg, opacity = visibility * config.opacity.title, radius = state.radius,
 				})
-				ass:txt(rect.ax + padding_half, rect.ay + height / 2, 4, remaining_human, opts)
+				ass:txt(time_ax + padding_half, rect.ay + height / 2, 4, remaining_human, opts)
+
+				-- Click action
+				rect.bx = time_bx
+				cursor:zone('primary_click', rect, function() mp.command('script-binding uosc/chapters') end)
 
 				title_ay = rect.by + spacing
 			end
diff --git a/multimedia/.config/mpv/scripts/uosc_repo/src/uosc/elements/Updater.lua b/multimedia/.config/mpv/scripts/uosc_repo/src/uosc/elements/Updater.lua
new file mode 100644
index 0000000..b957937
--- /dev/null
+++ b/multimedia/.config/mpv/scripts/uosc_repo/src/uosc/elements/Updater.lua
@@ -0,0 +1,316 @@
+local Element = require('elements/Element')
+local dots = {'.', '..', '...'}
+
+local function cleanup_output(output)
+	return tostring(output):gsub('%c*\n%c*', '\n'):match('^[%s%c]*(.-)[%s%c]*$')
+end
+
+---@class Updater : Element
+local Updater = class(Element)
+
+function Updater:new() return Class.new(self) --[[@as Updater]] end
+function Updater:init()
+	Element.init(self, 'updater', {render_order = 1000})
+	self.output = nil
+	self.title = ''
+	self.state = 'circle' -- Also used as an icon name. 'pending' maps to 'spinner'.
+	self.update_available = false
+
+	-- Buttons
+	self.check_button = {method = 'check', title = t('Check for updates')}
+	self.update_button = {method = 'update', title = t('Update uosc'), color = config.color.success}
+	self.changelog_button = {method = 'open_changelog', title = t('Open changelog')}
+	self.close_button = {method = 'destroy', title = t('Close') .. ' (Esc)', color = config.color.error}
+	self.quit_button = {method = 'quit', title = t('Quit')}
+	self.buttons = {self.check_button, self.close_button}
+	self.selected_button_index = 1
+
+	-- Key bindings
+	self:add_key_binding('right', 'select_next_button')
+	self:add_key_binding('tab', 'select_next_button')
+	self:add_key_binding('left', 'select_prev_button')
+	self:add_key_binding('shift+tab', 'select_prev_button')
+	self:add_key_binding('enter', 'activate_selected_button')
+	self:add_key_binding('kp_enter', 'activate_selected_button')
+	self:add_key_binding('esc', 'destroy')
+
+	Elements:maybe('curtain', 'register', self.id)
+	self:check()
+end
+
+function Updater:destroy()
+	Elements:maybe('curtain', 'unregister', self.id)
+	Element.destroy(self)
+end
+
+function Updater:quit()
+	mp.command('quit')
+end
+
+function Updater:select_prev_button()
+	self.selected_button_index = self.selected_button_index - 1
+	if self.selected_button_index < 1 then self.selected_button_index = #self.buttons end
+	request_render()
+end
+
+function Updater:select_next_button()
+	self.selected_button_index = self.selected_button_index + 1
+	if self.selected_button_index > #self.buttons then self.selected_button_index = 1 end
+	request_render()
+end
+
+function Updater:activate_selected_button()
+	local button = self.buttons[self.selected_button_index]
+	if button then self[button.method](self) end
+end
+
+---@param msg string
+function Updater:append_output(msg)
+	self.output = (self.output or '') .. ass_escape('\n' .. cleanup_output(msg))
+	request_render()
+end
+
+---@param msg string
+function Updater:display_error(msg)
+	self.state = 'error'
+	self.title = t('An error has occurred.') .. ' ' .. t('See console for details.')
+	self:append_output(msg)
+	print(msg)
+end
+
+function Updater:open_changelog()
+	if self.state == 'pending' then return end
+
+	local url = 'https://github.com/tomasklaen/uosc/releases'
+
+	self:append_output('Opening URL: ' .. url)
+
+	call_ziggy_async({'open', url}, function(error)
+		if error then
+			self:display_error(error)
+			return
+		end
+	end)
+end
+
+function Updater:check()
+	if self.state == 'pending' then return end
+	self.state = 'pending'
+	self.title = t('Checking for updates') .. '...'
+
+	local url = 'https://api.github.com/repos/tomasklaen/uosc/releases/latest'
+	local headers = utils.format_json({
+		Accept = 'application/vnd.github+json',
+	})
+	local args = {'http-get', '--headers', headers, url}
+
+	self:append_output('Fetching: ' .. url)
+
+	call_ziggy_async(args, function(error, response)
+		if error then
+			self:display_error(error)
+			return
+		end
+
+		release = utils.parse_json(type(response.body) == 'string' and response.body or '')
+		if response.status == 200 and type(release) == 'table' and type(release.tag_name) == 'string' then
+			self.update_available = config.version ~= release.tag_name
+			self:append_output('Response: 200 OK')
+			self:append_output('Current version: ' .. config.version)
+			self:append_output('Latest version: ' .. release.tag_name)
+			if self.update_available then
+				self.state = 'upgrade'
+				self.title = t('Update available')
+				self.buttons = {self.update_button, self.changelog_button, self.close_button}
+				self.selected_button_index = 1
+			else
+				self.state = 'done'
+				self.title = t('Up to date')
+			end
+		else
+			self:display_error('Response couldn\'t be parsed, is invalid, or not-OK status code.\nStatus: ' ..
+				response.status .. '\nBody: ' .. response.body)
+		end
+
+		request_render()
+	end)
+end
+
+function Updater:update()
+	if self.state == 'pending' then return end
+	self.state = 'pending'
+	self.title = t('Updating uosc')
+	self.output = nil
+	request_render()
+
+	local config_dir = mp.command_native({'expand-path', '~~/'})
+
+	local function handle_result(success, result, error)
+		if success and result and result.status == 0 then
+			self.state = 'done'
+			self.title = t('uosc has been installed. Restart mpv for it to take effect.')
+			self.buttons = {self.quit_button, self.close_button}
+			self.selected_button_index = 1
+		else
+			self.state = 'error'
+			self.title = t('An error has occurred.') .. ' ' .. t('See above for clues.')
+		end
+
+		local output = (result.stdout or '') .. '\n' .. (error or result.stderr or '')
+		if state.platform == 'darwin' then
+			output =
+				'Self-updater is known not to work on MacOS.\nIf you know about a solution, please make an issue and share it with us!.\n' ..
+				output
+		end
+		self:append_output(output)
+	end
+
+	local function update(args)
+		local env = utils.get_env_list()
+		env[#env + 1] = 'MPV_CONFIG_DIR=' .. config_dir
+
+		mp.command_native_async({
+			name = 'subprocess',
+			capture_stderr = true,
+			capture_stdout = true,
+			playback_only = false,
+			args = args,
+			env = env,
+		}, handle_result)
+	end
+
+	if state.platform == 'windows' then
+		local url = 'https://raw.githubusercontent.com/tomasklaen/uosc/HEAD/installers/windows.ps1'
+		update({'powershell', '-NoProfile', '-Command', 'irm ' .. url .. ' | iex'})
+	else
+		-- Detect missing dependencies. We can't just let the process run and
+		-- report an error, as on snap packages there's no error. Everything
+		-- either exits with 0, or no helpful output/error message.
+		local missing = {}
+
+		for _, name in ipairs({'curl', 'unzip'}) do
+			local result = mp.command_native({
+				name = 'subprocess',
+				capture_stdout = true,
+				playback_only = false,
+				args = {'which', name},
+			})
+			local path = cleanup_output(result and result.stdout or '')
+			if path == '' then
+				missing[#missing + 1] = name
+			end
+		end
+
+		if #missing > 0 then
+			local stderr = 'Missing dependencies: ' .. table.concat(missing, ', ')
+			if config_dir:match('/snap/') then
+				stderr = stderr ..
+					'\nThis is a known error for mpv snap packages.\nYou can still update uosc by entering the Linux install command from uosc\'s readme into your terminal, it just can\'t be done this way.\nIf you know about a solution, please make an issue and share it with us!'
+			end
+			handle_result(false, {stderr = stderr})
+		else
+			local url = 'https://raw.githubusercontent.com/tomasklaen/uosc/HEAD/installers/unix.sh'
+			update({'/bin/bash', '-c', 'source <(curl -fsSL ' .. url .. ')'})
+		end
+	end
+end
+
+function Updater:render()
+	local ass = assdraw.ass_new()
+
+	local text_size = math.min(20 * state.scale, display.height / 20)
+	local icon_size = text_size * 2
+	local center_x = round(display.width / 2)
+
+	local color = fg
+	if self.state == 'done' or self.update_available then
+		color = config.color.success
+	elseif self.state == 'error' then
+		color = config.color.error
+	end
+
+	-- Divider
+	local divider_width = round(math.min(500 * state.scale, display.width * 0.8))
+	local divider_half, divider_border_half, divider_y = divider_width / 2, round(1 * state.scale), display.height * 0.65
+	local divider_ay, divider_by = round(divider_y - divider_border_half), round(divider_y + divider_border_half)
+	ass:rect(center_x - divider_half, divider_ay, center_x - icon_size, divider_by, {
+		color = color, border = options.text_border * state.scale, border_color = bg, opacity = 0.5,
+	})
+	ass:rect(center_x + icon_size, divider_ay, center_x + divider_half, divider_by, {
+		color = color, border = options.text_border * state.scale, border_color = bg, opacity = 0.5,
+	})
+	if self.state == 'pending' then
+		ass:spinner(center_x, divider_y, icon_size, {
+			color = fg, border = options.text_border * state.scale, border_color = bg,
+		})
+	else
+		ass:icon(center_x, divider_y, icon_size * 0.8, self.state, {
+			color = color, border = options.text_border * state.scale, border_color = bg,
+		})
+	end
+
+	-- Output
+	local output = self.output or dots[math.ceil((mp.get_time() % 1) * #dots)]
+	ass:txt(center_x, divider_y - icon_size, 2, output, {
+		size = text_size, color = fg, border = options.text_border * state.scale, border_color = bg,
+	})
+
+	-- Title
+	ass:txt(center_x, divider_y + icon_size, 5, self.title, {
+		size = text_size, bold = true, color = color, border = options.text_border * state.scale, border_color = bg,
+	})
+
+	-- Buttons
+	local outline = round(1 * state.scale)
+	local spacing = outline * 9
+	local padding = round(text_size * 0.5)
+
+	local text_opts = {size = text_size, bold = true}
+
+	-- Calculate button text widths
+	local total_width = (#self.buttons - 1) * spacing
+	for _, button in ipairs(self.buttons) do
+		button.width = text_width(button.title, text_opts) + padding * 2
+		total_width = total_width + button.width
+	end
+
+	-- Render buttons
+	local ay = round(divider_y + icon_size * 1.8)
+	local ax = round(display.width / 2 - total_width / 2)
+	local height = text_size + padding * 2
+	for index, button in ipairs(self.buttons) do
+		local rect = {
+			ax = ax,
+			ay = ay,
+			bx = ax + button.width,
+			by = ay + height,
+		}
+		ax = rect.bx + spacing
+		local is_hovered = get_point_to_rectangle_proximity(cursor, rect) == 0
+
+		-- Background
+		ass:rect(rect.ax, rect.ay, rect.bx, rect.by, {
+			color = button.color or fg,
+			radius = state.radius,
+			opacity = is_hovered and 1 or 0.8,
+		})
+		-- Selected outline
+		if index == self.selected_button_index then
+			ass:rect(rect.ax - outline * 4, rect.ay - outline * 4, rect.bx + outline * 4, rect.by + outline * 4, {
+				border = outline,
+				border_color = button.color or fg,
+				radius = state.radius + outline * 4,
+				opacity = {primary = 0, border = 0.5},
+			})
+		end
+		-- Text
+		local x, y = rect.ax + (rect.bx - rect.ax) / 2, rect.ay + (rect.by - rect.ay) / 2
+		ass:txt(x, y, 5, button.title, {size = text_size, bold = true, color = fgt})
+
+		cursor:zone('primary_click', rect, self:create_action(button.method))
+	end
+
+	return ass
+end
+
+return Updater
diff --git a/multimedia/.config/mpv/scripts/uosc/elements/Volume.lua b/multimedia/.config/mpv/scripts/uosc_repo/src/uosc/elements/Volume.lua
similarity index 100%
rename from multimedia/.config/mpv/scripts/uosc/elements/Volume.lua
rename to multimedia/.config/mpv/scripts/uosc_repo/src/uosc/elements/Volume.lua
diff --git a/multimedia/.config/mpv/scripts/uosc/elements/WindowBorder.lua b/multimedia/.config/mpv/scripts/uosc_repo/src/uosc/elements/WindowBorder.lua
similarity index 100%
rename from multimedia/.config/mpv/scripts/uosc/elements/WindowBorder.lua
rename to multimedia/.config/mpv/scripts/uosc_repo/src/uosc/elements/WindowBorder.lua
diff --git a/multimedia/.config/mpv/scripts/uosc/intl/de.json b/multimedia/.config/mpv/scripts/uosc_repo/src/uosc/intl/de.json
similarity index 100%
rename from multimedia/.config/mpv/scripts/uosc/intl/de.json
rename to multimedia/.config/mpv/scripts/uosc_repo/src/uosc/intl/de.json
diff --git a/multimedia/.config/mpv/scripts/uosc_repo/src/uosc/intl/es.json b/multimedia/.config/mpv/scripts/uosc_repo/src/uosc/intl/es.json
new file mode 100644
index 0000000..4679541
--- /dev/null
+++ b/multimedia/.config/mpv/scripts/uosc_repo/src/uosc/intl/es.json
@@ -0,0 +1,99 @@
+{
+	"%s are empty": "%s están vacíos",
+	"%s channel": "%s canal",
+	"%s channels": "%s canales",
+	"%s to delete": "%s para eliminar",
+	"%s to go up in tree.": "%s para subir en el árbol",
+	"%s to reorder.": "%s para reordenar",
+	"%s to search": "%s para buscar",
+	"Add to playlist": "Añadir a lista de reproducción",
+	"Added to playlist": "Añadido a lista de reproducción",
+	"An error has occurred.": "Un error ha ocurrido.",
+	"Aspect ratio": "Relación de aspecto",
+	"Audio": "Audio",
+	"Audio device": "Dispositivo de audio",
+	"Audio devices": "Dispositivos de audio",
+	"Audio tracks": "Pistas de audio",
+	"Chapter %s": "Capítulo %s",
+	"Chapters": "Capítulos",
+	"Copied to clipboard": "Copiado al portapapeles",
+	"Default": "Por defecto",
+	"Default %s": "Por defecto %s",
+	"Delete": "Eliminar",
+	"Delete file & Next": "Eliminar archivo y siguiente",
+	"Delete file & Prev": "Eliminar archivo y anterior",
+	"Delete file & Quit": "Eliminar archivo y salir",
+	"Drives": "Unidades",
+	"Drop files or URLs to play here": "Soltar archivos o URLs aquí para reproducirlas",
+	"Edition %s": "Edición %s",
+	"Editions": "Ediciones",
+	"Empty": "Vacío",
+	"First": "Primero",
+	"Fullscreen": "Pantalla completa",
+	"Key bindings": "Atajos de teclas",
+	"Last": "Último",
+	"Load": "Abrir",
+	"Load audio": "Añadir una pista de audio",
+	"Load subtitles": "Añadir una pista de subtítulos",
+	"Load video": "Añadir una pista de vídeo",
+	"Loaded audio": "Audio cargado",
+	"Loaded subtitles": "Subtítulos cargados",
+	"Loaded video": "Vídeos cargados",
+	"Loop file": "Repetir archivo",
+	"Loop playlist": "Repetir lista de reproducción",
+	"Menu": "Menú",
+	"Move down": "Moverse abajo",
+	"Move up": "Moverse arriba",
+	"Navigation": "Navegación",
+	"Next": "Siguiente",
+	"Next page": "Página siguiente",
+	"No file": "Ningún archivo",
+	"Open config folder": "Abrir carpeta de configuración",
+	"Open file": "Abrir archivo",
+	"Open in browser": "Abrir en navegador",
+	"Open in mpv": "Abrir en mpv",
+	"Paste path or url to add.": "Pegar ruta o url a añadir.",
+	"Paste path or url to open.": "Pegar ruta o url a abrir.",
+	"Play/Pause": "Reproducir/Pausa",
+	"Playlist": "Lista de reproducción",
+	"Playlist/Files": "Lista de reproducción/Archivos",
+	"Prev": "Anterior",
+	"Previous": "Anterior",
+	"Previous page": "Página anterior",
+	"Quit": "Salir",
+	"Reload": "Recargar",
+	"Remaining downloads today: %s": "Descargas restantes por hoy: %s",
+	"Remove": "Eliminar",
+	"Resets in: %s": "Restablecer en: %s",
+	"Screenshot": "Captura de pantalla",
+	"Search online": "Buscar en línea",
+	"See above for clues.": "Vea arriba para más pistas",
+	"See console for details.": "Vea la consola para más detalles",
+	"Show in directory": "Mostrar en la carpeta",
+	"Shuffle": "Reproducción aleatoria",
+	"Something went wrong.": "Algo malió sal",
+	"Stream quality": "Calidad de la transmisión",
+	"Subtitles": "Subtítulos",
+	"Subtitles loaded & enabled": "Subtítulos cargados y habilitados",
+	"Toggle to disable.": "Alternar para deshabilitar",
+	"Track %s": "Pista %s",
+	"Update uosc": "Actualizar uosc",
+	"Updating uosc": "Actualizando uosc",
+	"Use as secondary": "Utilizar como secundario",
+	"Utils": "Utilidades",
+	"Video": "Vídeo",
+	"default": "por defecto",
+	"drive": "unidad",
+	"enter query": "ingresar consulta",
+	"external": "externo",
+	"forced": "forzado",
+	"foreign parts only": "solo partes extranjeras",
+	"hearing impaired": "discapacidad auditiva",
+	"no results": "sin resultados",
+	"open file": "abrir archivo",
+	"parent dir": "directorio padre",
+	"playlist or file": "archivo o lista de reproducción",
+	"type & ctrl+enter to search": "escriba y presione ctrl+enter para buscar",
+	"type to search": "escriba para buscar",
+	"uosc has been installed. Restart mpv for it to take effect.": "uosc ha sido instalado, Reinicie mpv para que tome efecto."
+}
diff --git a/multimedia/.config/mpv/scripts/uosc/intl/fr.json b/multimedia/.config/mpv/scripts/uosc_repo/src/uosc/intl/fr.json
similarity index 100%
rename from multimedia/.config/mpv/scripts/uosc/intl/fr.json
rename to multimedia/.config/mpv/scripts/uosc_repo/src/uosc/intl/fr.json
diff --git a/multimedia/.config/mpv/scripts/uosc/intl/ro.json b/multimedia/.config/mpv/scripts/uosc_repo/src/uosc/intl/ro.json
similarity index 100%
rename from multimedia/.config/mpv/scripts/uosc/intl/ro.json
rename to multimedia/.config/mpv/scripts/uosc_repo/src/uosc/intl/ro.json
diff --git a/multimedia/.config/mpv/scripts/uosc/intl/ru.json b/multimedia/.config/mpv/scripts/uosc_repo/src/uosc/intl/ru.json
similarity index 100%
rename from multimedia/.config/mpv/scripts/uosc/intl/ru.json
rename to multimedia/.config/mpv/scripts/uosc_repo/src/uosc/intl/ru.json
diff --git a/multimedia/.config/mpv/scripts/uosc_repo/src/uosc/intl/tr.json b/multimedia/.config/mpv/scripts/uosc_repo/src/uosc/intl/tr.json
new file mode 100644
index 0000000..4928c2f
--- /dev/null
+++ b/multimedia/.config/mpv/scripts/uosc_repo/src/uosc/intl/tr.json
@@ -0,0 +1,107 @@
+{
+	"%s are empty": "%s boş",
+	"%s channel": "%s kanal",
+	"%s channels": "%s kanallar",
+	"%s to delete": "%s silmek için",
+	"%s to go up in tree.": "%s yukarı gitmek için.",
+	"%s to reorder.": "%s yeniden sıralamak için.",
+	"%s to search": "%s aramak için",
+	"Add to playlist": "Çalma listesine ekle",
+	"Added to playlist": "Çalma listesine eklendi",
+	"An error has occurred.": "Bir hata oluştu.",
+	"Aspect ratio": "En-boy oranı",
+	"Audio": "Ses",
+	"Audio device": "Ses cihazı",
+	"Audio devices": "Ses cihazları",
+	"Audio tracks": "Ses parçaları",
+	"Autoload": "Otomatik yükleme",
+	"Chapter %s": "Bölüm %s",
+	"Chapters": "Bölümler",
+	"Check for updates": "Güncellemeleri kontrol et",
+	"Checking for updates": "Güncellemeler kontrol ediliyor",
+	"Close": "Kapat",
+	"Copied to clipboard": "Panoya kopyalandı",
+	"Default": "Varsayılan",
+	"Default %s": "Varsayılan %s",
+	"Delete": "Sil",
+	"Delete file & Next": "Dosyayı sil & Sonraki",
+	"Delete file & Prev": "Dosyayı sil & Önceki",
+	"Delete file & Quit": "Dosyayı sil & Çık",
+	"Drives": "Sürücüler",
+	"Drop files or URLs to play here": "Dosyaları veya URL'leri buraya bırakın",
+	"Edition %s": "Sürüm %s",
+	"Editions": "Sürümler",
+	"Empty": "Boş",
+	"First": "İlk",
+	"Fullscreen": "Tam ekran",
+	"Key bindings": "Tuş atamaları",
+	"Last": "Son",
+	"Load": "Yükle",
+	"Load audio": "Ses yükle",
+	"Load subtitles": "Altyazı yükle",
+	"Load video": "Video yükle",
+	"Loaded audio": "Ses yüklendi",
+	"Loaded subtitles": "Altyazı yüklendi",
+	"Loaded video": "Video yüklendi",
+	"Loop file": "Dosyayı döngüye al",
+	"Loop playlist": "Çalma listesini döngüye al",
+	"Menu": "Menü",
+	"Move down": "Aşağı taşı",
+	"Move up": "Yukarı taşı",
+	"Navigation": "Gezinme",
+	"Next": "Sonraki",
+	"Next page": "Sonraki sayfa",
+	"No file": "Dosya yok",
+	"Nothing to copy": "Kopyalanacak bir şey yok",
+	"Open changelog": "Değişiklik günlüğünü aç",
+	"Open config folder": "Yapılandırma klasörünü aç",
+	"Open file": "Dosya aç",
+	"Open in browser": "Tarayıcıda aç",
+	"Open in mpv": "mpv'de aç",
+	"Paste path or url to add.": "Eklemek için yolu veya URL'yi yapıştırın.",
+	"Paste path or url to open.": "Açmak için yolu veya URL'yi yapıştırın.",
+	"Play/Pause": "Oynat/Duraklat",
+	"Playlist": "Çalma listesi",
+	"Playlist/Files": "Çalma listesi/Dosyalar",
+	"Prev": "Önceki",
+	"Previous": "Önceki",
+	"Previous page": "Önceki sayfa",
+	"Quit": "Çıkış",
+	"Reload": "Yeniden yükle",
+	"Remaining downloads today: %s": "Bugünkü kalan indirmeler: %s",
+	"Remove": "Kaldır",
+	"Resets in: %s": "%s içinde sıfırlanacak",
+	"Screenshot": "Ekran görüntüsü",
+	"Search online": "Çevrimiçi ara",
+	"See above for clues.": "İpuçları için yukarıya bakın.",
+	"See console for details.": "Ayrıntılar için konsola bakın.",
+	"Show in directory": "Dizinde göster",
+	"Shuffle": "Karıştır",
+	"Something went wrong.": "Bir şeyler ters gitti.",
+	"Stream quality": "Yayın kalitesi",
+	"Subtitles": "Altyazılar",
+	"Subtitles loaded & enabled": "Altyazılar yüklendi ve etkinleştirildi",
+	"Toggle to disable.": "Devre dışı bırakmak için değiştir.",
+	"Track %s": "Parça %s",
+	"Up to date": "Güncel",
+	"Update available": "Güncelleme mevcut",
+	"Update uosc": "uosc güncelle",
+	"Updating uosc": "uosc güncelleniyor",
+	"Use as secondary": "İkincil olarak kullan",
+	"Utils": "Araçlar",
+	"Video": "Video",
+	"default": "varsayılan",
+	"drive": "sürücü",
+	"enter query": "Sorgu gir",
+	"external": "harici",
+	"forced": "zorunlu",
+	"foreign parts only": "sadece yabancı bölümler",
+	"hearing impaired": "işitme engelli",
+	"no results": "Sonuç yok",
+	"open file": "Dosya aç",
+	"parent dir": "üst dizin",
+	"playlist or file": "çalma listesi veya dosya",
+	"type & ctrl+enter to search": "Yaz & aramak için Ctrl+Enter'a bas",
+	"type to search": "Aramak için yaz",
+	"uosc has been installed. Restart mpv for it to take effect.": "uosc yüklendi. Etkin olması için mpv'yi yeniden başlatın."
+}
diff --git a/multimedia/.config/mpv/scripts/uosc/intl/uk.json b/multimedia/.config/mpv/scripts/uosc_repo/src/uosc/intl/uk.json
similarity index 100%
rename from multimedia/.config/mpv/scripts/uosc/intl/uk.json
rename to multimedia/.config/mpv/scripts/uosc_repo/src/uosc/intl/uk.json
diff --git a/multimedia/.config/mpv/scripts/uosc/intl/zh-hans.json b/multimedia/.config/mpv/scripts/uosc_repo/src/uosc/intl/zh-hans.json
similarity index 66%
rename from multimedia/.config/mpv/scripts/uosc/intl/zh-hans.json
rename to multimedia/.config/mpv/scripts/uosc_repo/src/uosc/intl/zh-hans.json
index 7ed3f07..5411c65 100644
--- a/multimedia/.config/mpv/scripts/uosc/intl/zh-hans.json
+++ b/multimedia/.config/mpv/scripts/uosc_repo/src/uosc/intl/zh-hans.json
@@ -2,7 +2,12 @@
 	"%s are empty": "%s 为空",
 	"%s channel": "%s 声道",
 	"%s channels": "%s 声道",
-	"%s to search": "%s 进行搜索",
+	"%s to delete": "使用 %s 进行删除",
+	"%s to go up in tree.": "使用 %s 返回上一级",
+	"%s to reorder.": "使用 %s 重新排序",
+	"%s to search": "使用 %s 进行搜索",
+	"Add to playlist": "添加到播放列表",
+	"Added to playlist": "已添加到播放列表",
 	"An error has occurred.": "出现错误",
 	"Aspect ratio": "纵横比",
 	"Audio": "音频",
@@ -11,13 +16,13 @@
 	"Audio tracks": "音频轨道",
 	"Chapter %s": "第 %s 章",
 	"Chapters": "章节",
+	"Copied to clipboard": "已复制到剪贴板",
 	"Default": "默认",
 	"Default %s": "默认 %s",
+	"Delete": "删除",
 	"Delete file & Next": "删除文件并播放下一个",
 	"Delete file & Prev": "删除文件并播放上一个",
 	"Delete file & Quit": "删除文件并退出",
-	"Disabled": "禁用",
-	"Download": "下载",
 	"Drives": "驱动器",
 	"Drop files or URLs to play here": "拖放文件或 URLs 到此处进行播放",
 	"Edition %s": "版本 %s",
@@ -28,56 +33,67 @@
 	"Key bindings": "键位绑定",
 	"Last": "最后一个",
 	"Load": "加载",
-	"Load audio": "加载音频",
+	"Load audio": "加载音轨",
 	"Load subtitles": "加载字幕",
-	"Load video": "加载视频",
+	"Load video": "加载视频轨",
+	"Loaded audio": "已加载音轨",
+	"Loaded subtitles": "已加载字幕",
+	"Loaded video": "已加载视频轨",
 	"Loop file": "单个循环",
 	"Loop playlist": "列表循环",
 	"Menu": "菜单",
+	"Move down": "下移",
+	"Move up": "上移",
 	"Navigation": "导航",
 	"Next": "下一个",
 	"Next page": "下一页",
 	"No file": "无文件",
-	"Open config folder": "打开设置文件夹",
+	"Open config folder": "打开配置文件夹",
 	"Open file": "打开文件",
+	"Open in browser": "在浏览器中打开",
+	"Open in mpv": "在 mpv 中打开",
+	"Paste path or url to add.": "粘贴路径或网址以添加",
+	"Paste path or url to open.": "粘贴路径或网址以打开",
 	"Play/Pause": "播放/暂停",
 	"Playlist": "播放列表",
-	"Playlist/Files": "播放/文件列表",
+	"Playlist/Files": "播放列表/文件列表",
 	"Prev": "上一个",
 	"Previous": "上一个",
 	"Previous page": "上一页",
 	"Quit": "退出",
+	"Reload": "重载",
 	"Remaining downloads today: %s": "今天的剩余下载量: %s",
+	"Remove": "移除",
 	"Resets in: %s": "重置: %s",
 	"Screenshot": "截图",
+	"Search online": "在线搜索",
 	"See above for clues.": "线索见上文",
+	"See console for details.": "参阅控制台了解详细信息",
 	"Show in directory": "打开所在文件夹",
 	"Shuffle": "乱序",
+	"Something went wrong.": "出错了",
 	"Stream quality": "流媒体品质",
 	"Subtitles": "字幕",
 	"Subtitles loaded & enabled": "字幕已加载并启用",
+	"Toggle to disable.": "点击切换禁用状态",
 	"Track %s": "轨道 %s",
 	"Update uosc": "更新 uosc",
 	"Updating uosc": "正在更新 uosc",
+	"Use as secondary": "设置为次字幕",
 	"Utils": "工具",
 	"Video": "视频",
 	"default": "默认",
 	"drive": "磁盘",
 	"enter query": "输入查询",
-	"error": "错误",
 	"external": "外置",
 	"forced": "强制",
 	"foreign parts only": "仅限外语部分",
 	"hearing impaired": "听力障碍",
-	"invalid response json (see console for details)": "无效的响应 json (请参阅控制台了解详细信息)",
 	"no results": "没有结果",
 	"open file": "打开文件",
 	"parent dir": "父文件夹",
 	"playlist or file": "播放列表或文件",
-	"process exited with code %s (see console for details)": "进程以代码 %s 退出 (请参阅控制台了解详细信息)",
-	"search online": "在线搜索",
 	"type & ctrl+enter to search": "输入并按 ctrl+enter 进行搜索",
 	"type to search": "输入搜索内容",
-	"unknown error": "未知错误",
 	"uosc has been installed. Restart mpv for it to take effect.": "uosc 已经安装,重新启动 mpv 使其生效"
 }
diff --git a/multimedia/.config/mpv/scripts/uosc/lib/ass.lua b/multimedia/.config/mpv/scripts/uosc_repo/src/uosc/lib/ass.lua
similarity index 80%
rename from multimedia/.config/mpv/scripts/uosc/lib/ass.lua
rename to multimedia/.config/mpv/scripts/uosc_repo/src/uosc/lib/ass.lua
index aff5e22..c20349f 100644
--- a/multimedia/.config/mpv/scripts/uosc/lib/ass.lua
+++ b/multimedia/.config/mpv/scripts/uosc_repo/src/uosc/lib/ass.lua
@@ -85,30 +85,56 @@ end
 -- Tooltip.
 ---@param element Rect
 ---@param value string|number
----@param opts? {size?: number; offset?: number; bold?: boolean; italic?: boolean; width_overwrite?: number, margin?: number; responsive?: boolean; lines?: integer, timestamp?: boolean}
+---@param opts? {size?: number; align?: number; offset?: number; bold?: boolean; italic?: boolean; width_overwrite?: number, margin?: number; responsive?: boolean; lines?: integer, timestamp?: boolean; invert_colors?: boolean}
 function ass_mt:tooltip(element, value, opts)
 	if value == '' then return end
 	opts = opts or {}
 	opts.size = opts.size or round(16 * state.scale)
 	opts.border = options.text_border * state.scale
-	opts.border_color = bg
+	opts.border_color = opts.invert_colors and fg or bg
 	opts.margin = opts.margin or round(10 * state.scale)
 	opts.lines = opts.lines or 1
+	opts.color = opts.invert_colors and bg or fg
+	local offset = opts.offset or 2
 	local padding_y = round(opts.size / 6)
 	local padding_x = round(opts.size / 3)
-	local offset = opts.offset or 2
-	local align_top = opts.responsive == false or element.ay - offset > opts.size * 2
-	local x = element.ax + (element.bx - element.ax) / 2
-	local y = align_top and element.ay - offset or element.by + offset
-	local width_half = (opts.width_overwrite or text_width(value, opts)) / 2 + padding_x
-	local min_edge_distance = width_half + opts.margin + Elements:v('window_border', 'size', 0)
-	x = clamp(min_edge_distance, x, display.width - min_edge_distance)
-	local ax, bx = round(x - width_half), round(x + width_half)
-	local ay = (align_top and y - opts.size * opts.lines - 2 * padding_y or y)
-	local by = (align_top and y or y + opts.size * opts.lines + 2 * padding_y)
-	self:rect(ax, ay, bx, by, {color = bg, opacity = config.opacity.tooltip, radius = state.radius})
+	local width = (opts.width_overwrite or text_width(value, opts)) + padding_x * 2
+	local height = opts.size * opts.lines + 2 * padding_y
+	local width_half, height_half = width / 2, height / 2
+	local margin = opts.margin + Elements:v('window_border', 'size', 0)
+	local align = opts.align or 8
+
+	local x, y = 0, 0 -- center of tooltip
+
+	-- Flip alignment to other side when not enough space
+	if opts.responsive ~= false then
+		if align == 8 then
+			if element.ay - offset - height < margin then align = 2 end
+		elseif align == 2 then
+			if element.by + offset + height > display.height - margin then align = 8 end
+		elseif align == 6 then
+			if element.bx + offset + width > display.width - margin then align = 4 end
+		elseif align == 4 then
+			if element.ax - offset - width < margin then align = 6 end
+		end
+	end
+
+	-- Calculate tooltip center based on alignment
+	if align == 8 or align == 2 then
+		x = clamp(margin + width_half, element.ax + (element.bx - element.ax) / 2, display.width - margin - width_half)
+		y = align == 8 and element.ay - offset - height_half or element.by + offset + height_half
+	else
+		x = align == 6 and element.bx + offset + width_half or element.ax - offset - width_half
+		y = clamp(margin + height_half, element.ay + (element.by - element.ay) / 2, display.height - margin - height_half)
+	end
+
+	-- Draw
+	local ax, ay, bx, by = round(x - width_half), round(y - height_half), round(x + width_half), round(y + height_half)
+	self:rect(ax, ay, bx, by, {
+		color = opts.invert_colors and fg or bg, opacity = config.opacity.tooltip, radius = state.radius
+	})
 	local func = opts.timestamp and self.timestamp or self.txt
-	func(self, x, align_top and y - padding_y or y + padding_y, align_top and 2 or 8, tostring(value), opts)
+	func(self, x, y, 5, tostring(value), opts)
 	return {ax = element.ax, ay = ay, bx = element.bx, by = by}
 end
 
diff --git a/multimedia/.config/mpv/scripts/uosc_repo/src/uosc/lib/buttons.lua b/multimedia/.config/mpv/scripts/uosc_repo/src/uosc/lib/buttons.lua
new file mode 100644
index 0000000..b5c086a
--- /dev/null
+++ b/multimedia/.config/mpv/scripts/uosc_repo/src/uosc/lib/buttons.lua
@@ -0,0 +1,69 @@
+---@alias ButtonData {icon: string; active?: boolean; badge?: string; command?: string | string[]; tooltip?: string;}
+---@alias ButtonSubscriber fun(data: ButtonData)
+
+local buttons = {
+	---@type ButtonData[]
+	data = {},
+	---@type table<string, ButtonSubscriber[]>
+	subscribers = {},
+}
+
+---@param name string
+---@param callback fun(data: ButtonData)
+function buttons:subscribe(name, callback)
+	local pool = self.subscribers[name]
+	if not pool then
+		pool = {}
+		self.subscribers[name] = pool
+	end
+	pool[#pool + 1] = callback
+	self:trigger(name)
+	return function() buttons:unsubscribe(name, callback) end
+end
+
+---@param name string
+---@param callback? ButtonSubscriber
+function buttons:unsubscribe(name, callback)
+	if self.subscribers[name] then
+		if callback == nil then
+			self.subscribers[name] = {}
+		else
+			itable_delete_value(self.subscribers[name], callback)
+		end
+	end
+end
+
+---@param name string
+function buttons:trigger(name)
+	local pool = self.subscribers[name]
+	local data = self.data[name] or {icon = 'help_center', tooltip = 'Uninitialized button "' .. name .. '"'}
+	if pool then
+		for _, callback in ipairs(pool) do callback(data) end
+	end
+end
+
+---@param name string
+---@param data ButtonData
+function buttons:set(name, data)
+	buttons.data[name] = data
+	buttons:trigger(name)
+	request_render()
+end
+
+mp.register_script_message('set-button', function(name, data)
+	if type(name) ~= 'string' then
+		msg.error('Invalid set-button message parameter: 1st parameter (name) has to be a string.')
+		return
+	end
+	if type(data) ~= 'string' then
+		msg.error('Invalid set-button message parameter: 2nd parameter (data) has to be a string.')
+		return
+	end
+
+	local data = utils.parse_json(data)
+	if type(data) == 'table' and type(data.icon) == 'string' then
+		buttons:set(name, data)
+	end
+end)
+
+return buttons
diff --git a/multimedia/.config/mpv/scripts/uosc/lib/char_conv.lua b/multimedia/.config/mpv/scripts/uosc_repo/src/uosc/lib/char_conv.lua
similarity index 89%
rename from multimedia/.config/mpv/scripts/uosc/lib/char_conv.lua
rename to multimedia/.config/mpv/scripts/uosc_repo/src/uosc/lib/char_conv.lua
index 8e5183c..4823ce0 100644
--- a/multimedia/.config/mpv/scripts/uosc/lib/char_conv.lua
+++ b/multimedia/.config/mpv/scripts/uosc_repo/src/uosc/lib/char_conv.lua
@@ -4,13 +4,8 @@ local char_dir = mp.get_script_directory() .. '/char-conv/'
 local data = {}
 
 local languages = get_languages()
-for i = #languages, 1, -1 do
-	lang = languages[i]
-	if (lang == 'en') then
-		data = {}
-	else
-		table_assign(data, get_locale_from_json(char_dir .. lang:lower() .. '.json'))
-	end
+for _, lang in ipairs(languages) do
+	table_assign(data, get_locale_from_json(char_dir .. lang:lower() .. '.json'))
 end
 
 local romanization = {}
diff --git a/multimedia/.config/mpv/scripts/uosc/lib/cursor.lua b/multimedia/.config/mpv/scripts/uosc_repo/src/uosc/lib/cursor.lua
similarity index 81%
rename from multimedia/.config/mpv/scripts/uosc/lib/cursor.lua
rename to multimedia/.config/mpv/scripts/uosc_repo/src/uosc/lib/cursor.lua
index 0bd3e02..5752593 100644
--- a/multimedia/.config/mpv/scripts/uosc/lib/cursor.lua
+++ b/multimedia/.config/mpv/scripts/uosc_repo/src/uosc/lib/cursor.lua
@@ -1,10 +1,13 @@
+---@alias CursorEventHandler fun(shortcut: Shortcut)
+
 local cursor = {
 	x = math.huge,
 	y = math.huge,
 	hidden = true,
-	hover_raw = false,
+	distance = 0, -- Distance traveled during current move. Reset by `cursor.distance_reset_timer`.
+	last_hover = false, -- Stores `mouse.hover` boolean of the last mouse event for enter/leave detection.
 	-- Event handlers that are only fired on zones defined during render loop.
-	---@type {event: string, hitbox: Hitbox; handler: fun(...)}[]
+	---@type {event: string, hitbox: Hitbox; handler: CursorEventHandler}[]
 	zones = {},
 	handlers = {
 		primary_down = {},
@@ -68,6 +71,12 @@ mp.observe_property('cursor-autohide', 'number', function(_, val)
 	cursor.autohide_timer.timeout = (val or 1000) / 1000
 end)
 
+cursor.distance_reset_timer = mp.add_timeout(0.2, function()
+	cursor.distance = 0
+	request_render()
+end)
+cursor.distance_reset_timer:kill()
+
 -- Called at the beginning of each render
 function cursor:clear_zones()
 	itable_clear(self.zones)
@@ -105,7 +114,7 @@ end
 -- - `move` event zones are ignored due to it being a high frequency event that is currently not needed as a zone.
 ---@param event string
 ---@param hitbox Hitbox
----@param callback fun(...)
+---@param callback CursorEventHandler
 function cursor:zone(event, hitbox, callback)
 	self.zones[#self.zones + 1] = {event = event, hitbox = hitbox, handler = callback}
 end
@@ -113,6 +122,7 @@ end
 -- Binds a permanent cursor event handler active until manually unbound using `cursor:off()`.
 -- `_click` events are not available as permanent global events, only as zones.
 ---@param event string
+---@param callback CursorEventHandler
 ---@return fun() disposer Unbinds the event.
 function cursor:on(event, callback)
 	if self.handlers[event] and not itable_index_of(self.handlers[event], callback) then
@@ -146,7 +156,8 @@ end
 
 -- Trigger the event.
 ---@param event string
-function cursor:trigger(event, ...)
+---@param shortcut? Shortcut
+function cursor:trigger(event, shortcut)
 	local forward = true
 
 	-- Call raw event handlers.
@@ -154,8 +165,8 @@ function cursor:trigger(event, ...)
 	local callbacks = self.handlers[event]
 	if zone or #callbacks > 0 then
 		forward = false
-		if zone then zone.handler(...) end
-		for _, callback in ipairs(callbacks) do callback(...) end
+		if zone and shortcut then zone.handler(shortcut) end
+		for _, callback in ipairs(callbacks) do callback(shortcut) end
 	end
 
 	-- Call compound/parent (click) event handlers if both start and end events are within `parent_zone.hitbox`.
@@ -166,8 +177,8 @@ function cursor:trigger(event, ...)
 			forward = false -- Canceled here so we don't forward down events if they can lead to a click.
 			if parent.is_end then
 				local last_start_event = self.last_event[parent.start_event]
-				if last_start_event and point_collides_with(last_start_event, parent_zone.hitbox) then
-					parent_zone.handler(...)
+				if last_start_event and point_collides_with(last_start_event, parent_zone.hitbox) and shortcut then
+					parent_zone.handler(create_shortcut('primary_click', shortcut.modifiers))
 				end
 			end
 		end
@@ -179,7 +190,7 @@ function cursor:trigger(event, ...)
 		if forward_name then
 			-- Forward events if there was no handler.
 			local active = find_active_keybindings(forward_name)
-			if active then
+			if active and active.cmd then
 				local is_wheel = event:find('wheel', 1, true)
 				local is_up = event:sub(-3) == '_up'
 				if active.owner then
@@ -255,7 +266,7 @@ function cursor:_find_history_sample()
 	return self.history:tail()
 end
 
--- Returns a table with current velocities in in pixels per second.
+-- Returns the current velocity vector in pixels per second.
 ---@return Point
 function cursor:get_velocity()
 	local snap = self:_find_history_sample()
@@ -319,6 +330,16 @@ function cursor:move(x, y)
 				Elements:trigger('global_mouse_enter')
 			end
 
+			-- Update current move travel distance
+			-- `mp.get_time() - last.time < 0.5` check is there to ignore first event after long inactivity to
+			-- filter out big jumps due to window being repositioned/rescaled (e.g. opening a different file).
+			local last = self.last_event.move
+			if last and last.x < math.huge and last.y < math.huge and mp.get_time() - last.time < 0.5 then
+				self.distance = self.distance + get_point_to_point_proximity(cursor, last)
+				cursor.distance_reset_timer:kill()
+				cursor.distance_reset_timer:resume()
+			end
+
 			Elements:update_proximities()
 			-- Update history
 			self.history:insert({x = self.x, y = self.y, time = mp.get_time()})
@@ -367,44 +388,56 @@ function cursor:direction_to_rectangle_distance(rect)
 	return get_ray_to_rectangle_distance(self.x, self.y, end_x, end_y, rect)
 end
 
-function cursor:create_handler(event, cb)
-	return function(...)
-		call_maybe(cb, ...)
-		self:trigger(event, ...)
+---@param event string
+---@param shortcut Shortcut
+---@param cb? fun(shortcut: Shortcut)
+function cursor:create_handler(event, shortcut, cb)
+	return function()
+		if cb then cb(shortcut) end
+		self:trigger(event, shortcut)
 	end
 end
 
 -- Movement
 function handle_mouse_pos(_, mouse)
 	if not mouse then return end
-	if cursor.hover_raw and not mouse.hover then
+	if cursor.last_hover and not mouse.hover then
 		cursor:leave()
-	else
+	elseif not (cursor.last_hover == false and mouse.hover == false) then -- filters out duplicate mouse out events
 		cursor:move(mouse.x, mouse.y)
 	end
-	cursor.hover_raw = mouse.hover
+	cursor.last_hover = mouse.hover
 end
 mp.observe_property('mouse-pos', 'native', handle_mouse_pos)
 
 -- Key binding groups
-mp.set_key_bindings({
-	{
-		'mbtn_left',
-		cursor:create_handler('primary_up'),
-		cursor:create_handler('primary_down', function(...)
+local modifiers = {nil, 'alt', 'alt+ctrl', 'alt+shift', 'alt+ctrl+shift', 'ctrl', 'ctrl+shift', 'shift'}
+local primary_bindings = {}
+for i = 1, #modifiers do
+	local mods = modifiers[i]
+	local mp_name = (mods and mods .. '+' or '') .. 'mbtn_left'
+	primary_bindings[#primary_bindings + 1] = {
+		mp_name,
+		cursor:create_handler('primary_up', create_shortcut('primary_up', mods)),
+		cursor:create_handler('primary_down', create_shortcut('primary_down', mods), function(...)
 			handle_mouse_pos(nil, mp.get_property_native('mouse-pos'))
 		end),
-	},
-}, 'mbtn_left', 'force')
+	}
+end
+mp.set_key_bindings(primary_bindings, 'mbtn_left', 'force')
 mp.set_key_bindings({
 	{'mbtn_left_dbl', 'ignore'},
 }, 'mbtn_left_dbl', 'force')
 mp.set_key_bindings({
-	{'mbtn_right', cursor:create_handler('secondary_up'), cursor:create_handler('secondary_down')},
+	{
+		'mbtn_right',
+		cursor:create_handler('secondary_up', create_shortcut('secondary_up')),
+		cursor:create_handler('secondary_down', create_shortcut('secondary_down')),
+	},
 }, 'mbtn_right', 'force')
 mp.set_key_bindings({
-	{'wheel_up', cursor:create_handler('wheel_up')},
-	{'wheel_down', cursor:create_handler('wheel_down')},
+	{'wheel_up', cursor:create_handler('wheel_up', create_shortcut('wheel_up'))},
+	{'wheel_down', cursor:create_handler('wheel_down', create_shortcut('wheel_down'))},
 }, 'wheel', 'force')
 
 return cursor
diff --git a/multimedia/.config/mpv/scripts/uosc/lib/intl.lua b/multimedia/.config/mpv/scripts/uosc_repo/src/uosc/lib/intl.lua
similarity index 100%
rename from multimedia/.config/mpv/scripts/uosc/lib/intl.lua
rename to multimedia/.config/mpv/scripts/uosc_repo/src/uosc/lib/intl.lua
diff --git a/multimedia/.config/mpv/scripts/uosc_repo/src/uosc/lib/menus.lua b/multimedia/.config/mpv/scripts/uosc_repo/src/uosc/lib/menus.lua
new file mode 100644
index 0000000..d224e4f
--- /dev/null
+++ b/multimedia/.config/mpv/scripts/uosc_repo/src/uosc/lib/menus.lua
@@ -0,0 +1,1135 @@
+---@alias OpenCommandMenuOptions {submenu?: string; mouse_nav?: boolean; on_close?: string | string[]}
+---@param data MenuData
+---@param opts? OpenCommandMenuOptions
+function open_command_menu(data, opts)
+	opts = opts or {}
+	local menu
+
+	local function run_command(command)
+		if type(command) == 'table' then
+			---@diagnostic disable-next-line: deprecated
+			mp.commandv(unpack(command))
+		else
+			mp.command(tostring(command))
+		end
+	end
+
+	local function callback(event)
+		if type(menu.root.callback) == 'table' then
+			---@diagnostic disable-next-line: deprecated
+			mp.commandv(unpack(itable_join({'script-message-to'}, menu.root.callback, {utils.format_json(event)})))
+		elseif event.type == 'activate' then
+			-- Modifiers and actions are not available on basic non-callback mode menus.
+			-- `alt` modifier should activate without closing the menu.
+			if (event.modifiers == 'alt' or not event.modifiers) and not event.action then
+				run_command(event.value)
+			end
+			-- Convention: Only pure item activations should close the menu.
+			-- Using modifiers or triggering item actions should not.
+			if not event.keep_open and not event.modifiers and not event.action then
+				menu:close()
+			end
+		end
+	end
+
+	---@type MenuOptions
+	local menu_opts = table_assign_props({}, opts, {'mouse_nav'})
+	menu = Menu:open(data, callback, menu_opts)
+	if opts.submenu then menu:activate_menu(opts.submenu) end
+	return menu
+end
+
+---@param opts? OpenCommandMenuOptions
+function toggle_menu_with_items(opts)
+	if Menu:is_open('menu') then
+		Menu:close()
+	else
+		open_command_menu({type = 'menu', items = get_menu_items(), search_submenus = true}, opts)
+	end
+end
+
+---@alias TrackEventRemove {type: 'remove' | 'delete', index: number; value: any;}
+---@alias TrackEventReload {type: 'reload', index: number; value: any;}
+---@param opts {type: string; title: string; list_prop: string; active_prop?: string; footnote?: string; serializer: fun(list: any, active: any): MenuDataItem[]; actions?: MenuAction[]; actions_place?: 'inside'|'outside'; on_paste: fun(event: MenuEventPaste); on_move?: fun(event: MenuEventMove); on_activate?: fun(event: MenuEventActivate); on_remove?: fun(event: TrackEventRemove); on_delete?: fun(event: TrackEventRemove); on_reload?: fun(event: TrackEventReload); on_key?: fun(event: MenuEventKey, close: fun())}
+function create_self_updating_menu_opener(opts)
+	return function()
+		if Menu:is_open(opts.type) then
+			Menu:close()
+			return
+		end
+		local list = mp.get_property_native(opts.list_prop)
+		local active = opts.active_prop and mp.get_property_native(opts.active_prop) or nil
+		local menu
+
+		local function update() menu:update_items(opts.serializer(list, active)) end
+
+		local ignore_initial_list = true
+		local function handle_list_prop_change(name, value)
+			if ignore_initial_list then
+				ignore_initial_list = false
+			else
+				list = value
+				update()
+			end
+		end
+
+		local ignore_initial_active = true
+		local function handle_active_prop_change(name, value)
+			if ignore_initial_active then
+				ignore_initial_active = false
+			else
+				active = value
+				update()
+			end
+		end
+
+		local function cleanup_and_close()
+			mp.unobserve_property(handle_list_prop_change)
+			mp.unobserve_property(handle_active_prop_change)
+			menu:close()
+		end
+
+		local initial_items, selected_index = opts.serializer(list, active)
+
+		---@type MenuAction[]
+		local actions = opts.actions or {}
+		if opts.on_move then
+			actions[#actions + 1] = {
+				name = 'move_up',
+				icon = 'arrow_upward',
+				label = t('Move up') .. ' (ctrl+up/pgup/home)',
+				filter_hidden = true,
+			}
+			actions[#actions + 1] = {
+				name = 'move_down',
+				icon = 'arrow_downward',
+				label = t('Move down') .. ' (ctrl+down/pgdwn/end)',
+				filter_hidden = true,
+			}
+		end
+		if opts.on_reload then
+			actions[#actions + 1] = {name = 'reload', icon = 'refresh', label = t('Reload') .. ' (f5)'}
+		end
+		if opts.on_remove or opts.on_delete then
+			local label = (opts.on_remove and t('Remove') or t('Delete')) .. ' (del)'
+			if opts.on_remove and opts.on_delete then
+				label = t('Remove') .. ' (' .. t('%s to delete', 'del, ctrl+del') .. ')'
+			end
+			actions[#actions + 1] = {name = 'remove', icon = 'delete', label = label}
+		end
+
+		function remove_or_delete(index, value, menu_id, modifiers)
+			if opts.on_remove and opts.on_delete then
+				local method = modifiers == 'ctrl' and 'delete' or 'remove'
+				local handler = method == 'delete' and opts.on_delete or opts.on_remove
+				if handler then
+					handler({type = method, value = value, index = index})
+				end
+			elseif opts.on_remove or opts.on_delete then
+				local method = opts.on_delete and 'delete' or 'remove'
+				local handler = opts.on_delete or opts.on_remove
+				if handler then
+					handler({type = method, value = value, index = index})
+				end
+			end
+		end
+
+		-- Items and active_index are set in the handle_prop_change callback, since adding
+		-- a property observer triggers its handler immediately, we just let that initialize the items.
+		menu = Menu:open({
+			type = opts.type,
+			title = opts.title,
+			footnote = opts.footnote,
+			items = initial_items,
+			item_actions = actions,
+			item_actions_place = opts.actions_place,
+			selected_index = selected_index,
+			on_move = opts.on_move and 'callback' or nil,
+			on_paste = opts.on_paste and 'callback' or nil,
+		}, function(event)
+			if event.type == 'activate' then
+				if (event.action == 'move_up' or event.action == 'move_down') and opts.on_move then
+					local to_index = event.index + (event.action == 'move_up' and -1 or 1)
+					if to_index >= 1 and to_index <= #menu.current.items then
+						opts.on_move({
+							type = 'move',
+							from_index = event.index,
+							to_index = to_index,
+							menu_id = menu.current.id,
+						})
+						menu:select_index(to_index)
+						if not event.is_pointer then
+							menu:scroll_to_index(to_index, nil, true)
+						end
+					end
+				elseif event.action == 'reload' and opts.on_reload then
+					opts.on_reload({type = 'reload', index = event.index, value = event.value})
+				elseif event.action == 'remove' and (opts.on_remove or opts.on_delete) then
+					remove_or_delete(event.index, event.value, event.menu_id, event.modifiers)
+				else
+					opts.on_activate(event --[[@as MenuEventActivate]])
+					if not event.modifiers and not event.action then cleanup_and_close() end
+				end
+			elseif event.type == 'key' then
+				local item = event.selected_item
+				if event.id == 'enter' then
+					-- We get here when there's no selectable item in menu and user presses enter.
+					cleanup_and_close()
+				elseif event.key == 'f5' and opts.on_reload and item then
+					opts.on_reload({type = 'reload', index = item.index, value = item.value})
+				elseif event.key == 'del' and (opts.on_remove or opts.on_delete) and item then
+					if itable_has({nil, 'ctrl'}, event.modifiers) then
+						remove_or_delete(item.index, item.value, event.menu_id, event.modifiers)
+					end
+				elseif opts.on_key then
+					opts.on_key(event --[[@as MenuEventKey]], cleanup_and_close)
+				end
+			elseif event.type == 'paste' and opts.on_paste then
+				opts.on_paste(event --[[@as MenuEventPaste]])
+			elseif event.type == 'close' then
+				cleanup_and_close()
+			elseif event.type == 'move' and opts.on_move then
+				opts.on_move(event --[[@as MenuEventMove]])
+			elseif event.type == 'remove' and opts.on_move then
+			end
+		end)
+
+		mp.observe_property(opts.list_prop, 'native', handle_list_prop_change)
+		if opts.active_prop then
+			mp.observe_property(opts.active_prop, 'native', handle_active_prop_change)
+		end
+	end
+end
+
+---@param opts {title: string; type: string; prop: string; enable_prop?: string; secondary?: {prop: string; icon: string; enable_prop?: string}; load_command: string; download_command?: string}
+function create_select_tracklist_type_menu_opener(opts)
+	local snd = opts.secondary
+	local function get_props()
+		return tonumber(mp.get_property(opts.prop)), snd and tonumber(mp.get_property(snd.prop)) or nil
+	end
+
+	local function serialize_tracklist(tracklist)
+		local items = {}
+
+		if opts.load_command then
+			items[#items + 1] = {
+				title = t('Load'),
+				bold = true,
+				italic = true,
+				hint = t('open file'),
+				value = '{load}',
+				actions = opts.download_command
+					and {{name = 'download', icon = 'language', label = t('Search online')}}
+					or nil,
+			}
+		end
+		if #items > 0 then
+			items[#items].separator = true
+		end
+
+		local track_prop_index, snd_prop_index = get_props()
+		local filename = mp.get_property_native('filename/no-ext')
+		local escaped_filename = filename and regexp_escape(filename)
+		local first_item_index = #items + 1
+		local active_index = nil
+		local disabled_item = nil
+		local track_actions = nil
+		local track_external_actions = {}
+
+		if snd then
+			local action = {
+				name = 'as_secondary', icon = snd.icon, label = t('Use as secondary') .. ' (shift+enter/click)',
+			}
+			track_actions = {action}
+			table.insert(track_external_actions, action)
+		end
+		table.insert(track_external_actions, {name = 'reload', icon = 'refresh', label = t('Reload') .. ' (f5)'})
+		table.insert(track_external_actions, {name = 'remove', icon = 'delete', label = t('Remove') .. ' (del)'})
+
+		for _, track in ipairs(tracklist) do
+			if track.type == opts.type then
+				local hint_values = {}
+				local track_selected = track.selected and track.id == track_prop_index
+				local snd_selected = snd and track.id == snd_prop_index
+				local function h(value)
+					value = trim(value)
+					if #value > 0 then hint_values[#hint_values + 1] = value end
+				end
+
+				if track.lang then h(track.lang) end
+				if track['demux-h'] then
+					h(track['demux-w'] and (track['demux-w'] .. 'x' .. track['demux-h']) or (track['demux-h'] .. 'p'))
+				end
+				if track['demux-fps'] then h(string.format('%.5gfps', track['demux-fps'])) end
+				h(track.codec)
+				if track['audio-channels'] then
+					h(track['audio-channels'] == 1
+						and t('%s channel', track['audio-channels'])
+						or t('%s channels', track['audio-channels']))
+				end
+				if track['demux-samplerate'] then h(string.format('%.3gkHz', track['demux-samplerate'] / 1000)) end
+				if track.forced then h(t('forced')) end
+				if track.default then h(t('default')) end
+				if track.external then
+					local extension = track.title:match('%.([^%.]+)$')
+					if track.title and escaped_filename and extension then
+						track.title = trim(track.title:gsub(escaped_filename .. '%.?', ''):gsub('%.?([^%.]+)$', ''))
+						if track.title == '' or track.lang and track.title:lower() == track.lang:lower() then
+							track.title = nil
+						end
+					end
+					h(t('external'))
+				end
+
+				items[#items + 1] = {
+					title = (track.title and track.title or t('Track %s', track.id)),
+					hint = table.concat(hint_values, ', '),
+					value = track.id,
+					active = track_selected or snd_selected,
+					italic = snd_selected,
+					icon = snd and snd_selected and snd.icon or nil,
+					actions = track.external and track_external_actions or track_actions,
+				}
+
+				if track_selected then
+					if disabled_item then disabled_item.active = false end
+					active_index = #items
+				end
+			end
+		end
+
+		return items, active_index or first_item_index
+	end
+
+	local function reload(id)
+		if id then mp.commandv(opts.type .. '-reload', id) end
+	end
+	local function remove(id)
+		if id then mp.commandv(opts.type .. '-remove', id) end
+	end
+
+	---@param event MenuEventActivate
+	local function handle_activate(event)
+		if event.value == '{load}' then
+			mp.command(event.action == 'download' and opts.download_command or opts.load_command)
+		else
+			if snd and (event.action == 'as_secondary' or event.modifiers == 'shift') then
+				local _, snd_track_index = get_props()
+				mp.commandv('set', snd.prop, event.value == snd_track_index and 'no' or event.value)
+				if snd.enable_prop then
+					mp.commandv('set', snd.enable_prop, 'yes')
+				end
+			elseif event.action == 'reload' then
+				reload(event.value)
+			elseif event.action == 'remove' then
+				remove(event.value)
+			elseif not event.modifiers or event.modifiers == 'alt' then
+				mp.commandv('set', opts.prop, event.value == get_props() and 'no' or event.value)
+				if opts.enable_prop then
+					mp.commandv('set', opts.enable_prop, 'yes')
+				end
+			end
+		end
+	end
+
+	---@param event MenuEventKey
+	local function handle_key(event)
+		if event.selected_item then
+			if event.id == 'f5' then
+				reload(event.selected_item.value)
+			elseif event.id == 'del' then
+				remove(event.selected_item.value)
+			end
+		end
+	end
+
+	return create_self_updating_menu_opener({
+		title = opts.title,
+		footnote = t('Toggle to disable.') .. ' ' .. t('Paste path or url to add.'),
+		type = opts.type,
+		list_prop = 'track-list',
+		serializer = serialize_tracklist,
+		on_activate = handle_activate,
+		on_key = handle_key,
+		actions_place = 'outside',
+		on_paste = function(event) load_track(opts.type, event.value) end,
+	})
+end
+
+---@alias NavigationMenuOptions {type: string, title?: string, allowed_types?: string[], file_actions?: MenuAction[], directory_actions?: MenuAction[], active_path?: string, selected_path?: string; on_close?: fun()}
+
+-- Opens a file navigation menu with items inside `directory_path`.
+---@param directory_path string
+---@param handle_activate fun(event: MenuEventActivate)
+---@param opts NavigationMenuOptions
+function open_file_navigation_menu(directory_path, handle_activate, opts)
+	if directory_path == '{drives}' then
+		if state.platform ~= 'windows' then directory_path = '/' end
+	else
+		directory_path = normalize_path(mp.command_native({'expand-path', directory_path}))
+	end
+
+	opts = opts or {}
+	---@type string|nil
+	local current_directory = nil
+	---@type Menu
+	local menu
+	---@type string | nil
+	local back_path
+	local separator = path_separator(directory_path)
+
+	---@param path string Can be path to a directory, or special string `'{drives}'` to get windows drives items.
+	---@param selected_path? string Marks item with this path as active.
+	---@return MenuStackChild[] menu_items
+	---@return number selected_index
+	---@return string|nil error
+	local function serialize_items(path, selected_path)
+		if path == '{drives}' then
+			local process = mp.command_native({
+				name = 'subprocess',
+				capture_stdout = true,
+				playback_only = false,
+				args = {'fsutil', 'fsinfo', 'drives'},
+			})
+			local items, selected_index = {}, 1
+
+			if process.status == 0 then
+				for drive in process.stdout:gmatch("(%a:)\\") do
+					if drive then
+						local drive_path = normalize_path(drive)
+						items[#items + 1] = {
+							title = drive, hint = t('drive'), value = drive_path, active = opts.active_path == drive_path,
+						}
+						if selected_path == drive_path then selected_index = #items end
+					end
+				end
+			else
+				return {}, 1, 'Couldn\'t open drives. Error: ' .. utils.to_string(process.stderr)
+			end
+			return items, selected_index
+		end
+
+		local serialized = serialize_path(path)
+		if not serialized then
+			return {}, 0, 'Couldn\'t serialize path "' .. path .. '.'
+		end
+		local files, directories, error = read_directory(serialized.path, {
+			types = opts.allowed_types,
+			hidden = options.show_hidden_files,
+		})
+		if error then
+			return {}, 1, error
+		end
+		local is_root = not serialized.dirname
+
+		if not files or not directories then return {}, 0 end
+
+		sort_strings(directories)
+		sort_strings(files)
+
+		-- Pre-populate items with parent directory selector if not at root
+		-- Each item value is a serialized path table it points to.
+		local items = {}
+
+		if is_root then
+			if state.platform == 'windows' then
+				items[#items + 1] = {title = '..', hint = t('Drives'), value = '{drives}', separator = true, is_to_parent = true}
+			end
+		else
+			items[#items + 1] = {title = '..', hint = t('parent dir'), value = serialized.dirname, separator = true, is_to_parent = true}
+		end
+
+		back_path = items[#items] and items[#items].value
+		local selected_index = #items + 1
+
+		for _, dir in ipairs(directories) do
+			items[#items + 1] = {
+				title = dir .. ' ' .. separator,
+				value = join_path(path, dir),
+				bold = true,
+				actions = opts
+					.directory_actions,
+			}
+		end
+
+		for _, file in ipairs(files) do
+			items[#items + 1] = {title = file, value = join_path(path, file), actions = opts.file_actions}
+		end
+
+		for index, item in ipairs(items) do
+			if not item.is_to_parent then
+				if opts.active_path == item.value then
+					item.active = true
+					if not selected_path then selected_index = index end
+				end
+
+				if selected_path == item.value then selected_index = index end
+			end
+		end
+
+		return items, selected_index
+	end
+
+	local menu_data = {
+		type = opts.type,
+		title = opts.title or '',
+		footnote = t('%s to go up in tree.', 'alt+up') .. ' ' .. t('Paste path or url to open.'),
+		items = {},
+		on_paste = 'callback',
+	}
+
+	---@param path string
+	local function open_directory(path)
+		local items, selected_index, error = serialize_items(path, current_directory)
+		if error then
+			msg.error(error)
+			items = {{title = 'Something went wrong. See console for errors.', selectable = false, muted = true}}
+		end
+
+		local title = opts.title
+		if not title then
+			if path == '{drives}' then
+				title = 'Drives'
+			else
+				local serialized = serialize_path(path)
+				title = serialized and serialized.basename .. separator or '??'
+			end
+		end
+
+		current_directory = path
+		menu_data.title = title
+		menu_data.items = items
+		menu:search_cancel()
+		menu:update(menu_data)
+		menu:select_index(selected_index)
+		menu:scroll_to_index(selected_index, nil, true)
+	end
+
+	local function close()
+		menu:close()
+		if opts.on_close then opts.on_close() end
+	end
+
+	---@param event MenuEventActivate
+	---@param only_if_dir? boolean Activate item only if it's a directory.
+	local function activate(event, only_if_dir)
+		local path = event.value
+		local is_drives = path == '{drives}'
+
+		if is_drives then
+			open_directory(path)
+			return
+		end
+
+		local info, error = utils.file_info(path)
+
+		if not info then
+			msg.error('Can\'t retrieve path info for "' .. path .. '". Error: ' .. (error or ''))
+			return
+		end
+
+		if info.is_dir and not event.modifiers and not event.action then
+			open_directory(path)
+		elseif not only_if_dir then
+			handle_activate(event)
+		end
+	end
+
+	menu = Menu:open(menu_data, function(event)
+		if event.type == 'activate' then
+			activate(event --[[@as MenuEventActivate]])
+		elseif event.type == 'back' or event.type == 'key' and itable_has({'alt+up', 'left'}, event.id) then
+			if back_path then open_directory(back_path) end
+		elseif event.type == 'paste' then
+			handle_activate({type = 'activate', value = event.value})
+		elseif event.type == 'key' then
+			if event.id == 'right' then
+				local selected_item = event.selected_item
+				if selected_item then
+					activate(table_assign({}, selected_item, {type = 'activate'}), true)
+				end
+			elseif event.id == 'ctrl+c' and event.selected_item then
+				set_clipboard(event.selected_item.value)
+			end
+		elseif event.type == 'close' then
+			close()
+		end
+	end)
+
+	open_directory(directory_path)
+
+	return menu
+end
+
+-- On demand menu items loading
+do
+	---@type {key: string; cmd: string; comment: string; is_menu_item: boolean}[]|nil
+	local all_user_bindings = nil
+	---@type MenuStackItem[]|nil
+	local menu_items = nil
+
+	local function is_uosc_menu_comment(v) return v:match('^!') or v:match('^menu:') end
+
+	-- Returns all relevant bindings from `input.conf`, even if they are overwritten
+	-- (same key bound to something else later) or have no keys (uosc menu items).
+	function get_all_user_bindings()
+		if all_user_bindings then return all_user_bindings end
+		all_user_bindings = {}
+
+		local input_conf_property = mp.get_property_native('input-conf')
+		local input_conf_iterator
+		if input_conf_property:sub(1, 9) == 'memory://' then
+			-- mpv.net v7
+			local input_conf_lines = split(input_conf_property:sub(10), '\n')
+			local i = 0
+			input_conf_iterator = function()
+				i = i + 1
+				return input_conf_lines[i]
+			end
+		else
+			local input_conf = input_conf_property == '' and '~~/input.conf' or input_conf_property
+			local input_conf_path = mp.command_native({'expand-path', input_conf})
+			local input_conf_meta, meta_error = utils.file_info(input_conf_path)
+
+			-- File doesn't exist
+			if not input_conf_meta or not input_conf_meta.is_file then
+				menu_items = create_default_menu_items()
+				return menu_items, all_user_bindings
+			end
+
+			input_conf_iterator = io.lines(input_conf_path)
+		end
+
+		for line in input_conf_iterator do
+			local key, command, comment = string.match(line, '%s*([%S]+)%s+([^#]*)%s*(.-)%s*$')
+			local is_commented_out = key and key:sub(1, 1) == '#'
+
+			if comment and #comment > 0 then comment = comment:sub(2) end
+			if command then command = trim(command) end
+
+			local is_menu_item = comment and is_uosc_menu_comment(comment)
+
+			if key
+				-- Filter out stuff like `#F2`, which is clearly intended to be disabled
+				and not (is_commented_out and #key > 1)
+				-- Filter out comments that are not uosc menu items
+				and (not is_commented_out or is_menu_item) then
+				all_user_bindings[#all_user_bindings + 1] = {
+					key = key,
+					cmd = command,
+					comment = comment or '',
+					is_menu_item = is_menu_item,
+				}
+			end
+		end
+
+		return all_user_bindings
+	end
+
+	function get_menu_items()
+		if menu_items then return menu_items end
+
+		local all_user_bindings = get_all_user_bindings()
+		local main_menu = {items = {}, items_by_command = {}}
+		local by_id = {}
+
+		for _, bind in ipairs(all_user_bindings) do
+			local key, command, comment = bind.key, bind.cmd, bind.comment
+			local title = ''
+
+			if comment then
+				local comments = split(comment, '#')
+				local titles = itable_filter(comments, is_uosc_menu_comment)
+				if titles and #titles > 0 then
+					title = titles[1]:match('^!%s*(.*)%s*') or titles[1]:match('^menu:%s*(.*)%s*')
+				end
+			end
+
+			if title ~= '' then
+				local is_dummy = key:sub(1, 1) == '#'
+				local submenu_id = ''
+				local target_menu = main_menu
+				local title_parts = split(title or '', ' *> *')
+
+				for index, title_part in ipairs(#title_parts > 0 and title_parts or {''}) do
+					if index < #title_parts then
+						submenu_id = submenu_id .. title_part
+
+						if not by_id[submenu_id] then
+							local items = {}
+							by_id[submenu_id] = {items = items, items_by_command = {}}
+							target_menu.items[#target_menu.items + 1] = {title = title_part, items = items}
+						end
+
+						target_menu = by_id[submenu_id]
+					else
+						-- If command is already in menu, just append the key to it
+						if key ~= '#' and command ~= '' and target_menu.items_by_command[command] then
+							local hint = target_menu.items_by_command[command].hint
+							target_menu.items_by_command[command].hint = hint and hint .. ', ' .. key or key
+						else
+							-- Separator
+							if title_part:sub(1, 3) == '---' then
+								local last_item = target_menu.items[#target_menu.items]
+								if last_item then last_item.separator = true end
+							elseif command ~= 'ignore' then
+								local item = {
+									title = title_part,
+									hint = not is_dummy and key or nil,
+									value = command,
+								}
+								if command == '' then
+									item.selectable = false
+									item.muted = true
+									item.italic = true
+								else
+									target_menu.items_by_command[command] = item
+								end
+								target_menu.items[#target_menu.items + 1] = item
+							end
+						end
+					end
+				end
+			end
+		end
+
+		menu_items = #main_menu.items > 0 and main_menu.items or create_default_menu_items()
+		return menu_items
+	end
+end
+
+-- Adapted from `stats.lua`
+function get_keybinds_items()
+	local items = {}
+	-- uosc and mpv-menu-plugin binds with no keys
+	local no_key_menu_binds = itable_filter(
+		get_all_user_bindings(),
+		function(b) return b.is_menu_item and b.cmd and b.cmd ~= '' and (b.key == '#' or b.key == '_') end
+	)
+	local binds_dump = itable_join(find_active_keybindings(), no_key_menu_binds)
+	local ids = {}
+
+	-- Convert to menu items
+	for _, bind in pairs(binds_dump) do
+		local id = bind.key .. '<>' .. bind.cmd
+		if not ids[id] then
+			ids[id] = true
+			items[#items + 1] = {title = bind.cmd, hint = bind.key, value = bind.cmd}
+		end
+	end
+
+	-- Sort
+	table.sort(items, function(a, b) return a.title < b.title end)
+
+	return #items > 0 and items or {
+		{
+			title = t('%s are empty', '`input-bindings`'),
+			selectable = false,
+			align = 'center',
+			italic = true,
+			muted = true,
+		},
+	}
+end
+
+function open_stream_quality_menu()
+	if Menu:is_open('stream-quality') then
+		Menu:close()
+		return
+	end
+
+	local ytdl_format = mp.get_property_native('ytdl-format')
+	local items = {}
+	---@type Menu
+	local menu
+
+	for _, height in ipairs(config.stream_quality_options) do
+		local format = 'bestvideo[height<=?' .. height .. ']+bestaudio/best[height<=?' .. height .. ']'
+		items[#items + 1] = {title = height .. 'p', value = format, active = format == ytdl_format}
+	end
+
+	menu = Menu:open({type = 'stream-quality', title = t('Stream quality'), items = items}, function(event)
+		if event.type == 'activate' then
+			mp.set_property('ytdl-format', event.value)
+
+			-- Reload the video to apply new format
+			-- This is taken from https://github.com/jgreco/mpv-youtube-quality
+			-- which is in turn taken from https://github.com/4e6/mpv-reload/
+			local duration = mp.get_property_native('duration')
+			local time_pos = mp.get_property('time-pos')
+
+			mp.command('playlist-play-index current')
+
+			-- Tries to determine live stream vs. pre-recorded VOD. VOD has non-zero
+			-- duration property. When reloading VOD, to keep the current time position
+			-- we should provide offset from the start. Stream doesn't have fixed start.
+			-- Decent choice would be to reload stream from it's current 'live' position.
+			-- That's the reason we don't pass the offset when reloading streams.
+			if duration and duration > 0 then
+				local function seeker()
+					mp.commandv('seek', time_pos, 'absolute')
+					mp.unregister_event(seeker)
+				end
+				mp.register_event('file-loaded', seeker)
+			end
+
+			if not event.alt then menu:close() end
+		end
+	end)
+end
+
+function open_open_file_menu()
+	if Menu:is_open('open-file') then
+		Menu:close()
+		return
+	end
+
+	---@type Menu | nil
+	local menu
+	local directory
+	local active_file
+
+	if state.path == nil or is_protocol(state.path) then
+		directory = options.default_directory
+		active_file = nil
+	else
+		local serialized = serialize_path(state.path)
+		if serialized then
+			directory = serialized.dirname
+			active_file = serialized.path
+		end
+	end
+
+	if not directory then
+		msg.error('Couldn\'t serialize path "' .. state.path .. '".')
+		return
+	end
+
+	-- Update active file in directory navigation menu
+	local function handle_file_loaded()
+		if menu and menu:is_alive() then
+			menu:activate_one_value(normalize_path(mp.get_property_native('path')))
+		end
+	end
+
+	menu = open_file_navigation_menu(
+		directory,
+		function(event)
+			if not menu then return end
+			local command = has_any_extension(event.value, config.types.playlist) and 'loadlist' or 'loadfile'
+			if event.modifiers == 'shift' or event.action == 'add_to_playlist' then
+				mp.commandv(command, event.value, 'append')
+				local serialized = serialize_path(event.value)
+				local filename = serialized and serialized.basename or event.value
+				mp.commandv('show-text', t('Added to playlist') .. ': ' .. filename, 3000)
+			elseif itable_has({nil, 'ctrl', 'alt', 'alt+ctrl'}, event.modifiers) and itable_has({nil, 'force_open'}, event.action) then
+				mp.commandv(command, event.value)
+				if not event.alt then menu:close() end
+			end
+		end,
+		{
+			type = 'open-file',
+			allowed_types = config.types.media,
+			active_path = active_file,
+			directory_actions = {
+				{name = 'add_to_playlist', icon = 'playlist_add', label = t('Add to playlist') .. ' (shift+enter/click)'},
+				{name = 'force_open', icon = 'play_circle_outline', label = t('Open in mpv') .. ' (ctrl+enter/click)'},
+			},
+			file_actions = {
+				{name = 'add_to_playlist', icon = 'playlist_add', label = t('Add to playlist') .. ' (shift+enter/click)'},
+			},
+			keep_open = true,
+			on_close = function() mp.unregister_event(handle_file_loaded) end,
+		}
+	)
+	if menu then mp.register_event('file-loaded', handle_file_loaded) end
+end
+
+---@param opts {prop: 'sub'|'audio'|'video'; title: string; loaded_message: string; allowed_types: string[]}
+function create_track_loader_menu_opener(opts)
+	local menu_type = 'load-' .. opts.prop
+	return function()
+		if Menu:is_open(menu_type) then
+			Menu:close()
+			return
+		end
+
+		---@type Menu
+		local menu
+		local path = state.path
+		if path then
+			if is_protocol(path) then
+				path = false
+			else
+				local serialized_path = serialize_path(path)
+				path = serialized_path ~= nil and serialized_path.dirname or false
+			end
+		end
+		if not path then
+			path = options.default_directory
+		end
+
+		local function handle_activate(event)
+			load_track(opts.prop, event.value)
+			local serialized = serialize_path(event.value)
+			local filename = serialized and serialized.basename or event.value
+			mp.commandv('show-text', opts.loaded_message .. ': ' .. filename, 3000)
+			if not event.alt then menu:close() end
+		end
+
+		menu = open_file_navigation_menu(path, handle_activate, {
+			type = menu_type, title = opts.title, allowed_types = opts.allowed_types,
+		})
+	end
+end
+
+function open_subtitle_downloader()
+	local menu_type = 'download-subtitles'
+	---@type Menu
+	local menu
+
+	if Menu:is_open(menu_type) then
+		Menu:close()
+		return
+	end
+
+	local search_suggestion, file_path, destination_directory = '', nil, nil
+	local credentials = {'--api-key', config.open_subtitles_api_key, '--agent', config.open_subtitles_agent}
+
+	if state.path then
+		if is_protocol(state.path) then
+			if not is_protocol(state.title) then search_suggestion = state.title end
+		else
+			local serialized_path = serialize_path(state.path)
+			if serialized_path then
+				search_suggestion = serialized_path.filename
+				file_path = state.path
+				destination_directory = serialized_path.dirname
+			end
+		end
+	end
+
+	local force_destination = options.subtitles_directory:sub(1, 1) == '!'
+	if force_destination or not destination_directory then
+		local subtitles_directory = options.subtitles_directory:sub(force_destination and 2 or 1)
+		destination_directory = mp.command_native({'expand-path', subtitles_directory})
+	end
+
+	local handle_download, handle_search
+
+	-- Checks if there an error, or data is invalid. If true, reports the error,
+	-- updates menu to inform about it, and returns true.
+	---@param error string|nil
+	---@param data any
+	---@param check_is_valid? fun(data: any):boolean
+	---@return boolean abort Whether the further response handling should be aborted.
+	local function should_abort(error, data, check_is_valid)
+		if error or not data or (not check_is_valid or not check_is_valid(data)) then
+			menu:update_items({
+				{
+					title = t('Something went wrong.'),
+					align = 'center',
+					muted = true,
+					italic = true,
+					selectable = false,
+				},
+				{
+					title = t('See console for details.'),
+					align = 'center',
+					muted = true,
+					italic = true,
+					selectable = false,
+				},
+			})
+			msg.error(error or ('Invalid response: ' .. (utils.format_json(data) or tostring(data))))
+			return true
+		end
+		return false
+	end
+
+	---@param data {kind: 'file', id: number}|{kind: 'page', query: string, page: number}
+	handle_download = function(data)
+		if data.kind == 'page' then
+			handle_search(data.query, data.page)
+			return
+		end
+
+		menu = Menu:open({
+			type = menu_type .. '-result',
+			search_style = 'disabled',
+			items = {{icon = 'spinner', align = 'center', selectable = false, muted = true}},
+		}, function(event)
+			if event.type == 'key' and event.key == 'enter' then
+				menu:close()
+			end
+		end)
+
+		local args = itable_join({'download-subtitles'}, credentials, {
+			'--file-id', tostring(data.id),
+			'--destination', destination_directory,
+		})
+
+		call_ziggy_async(args, function(error, data)
+			if not menu:is_alive() then return end
+			if should_abort(error, data, function(data) return type(data.file) == 'string' end) then return end
+
+			load_track('sub', data.file)
+
+			menu:update_items({
+				{
+					title = t('Subtitles loaded & enabled'),
+					bold = true,
+					icon = 'check',
+					selectable = false,
+				},
+				{
+					title = t('Remaining downloads today: %s', data.remaining .. '/' .. data.total),
+					italic = true,
+					muted = true,
+					icon = 'file_download',
+					selectable = false,
+				},
+				{
+					title = t('Resets in: %s', data.reset_time),
+					italic = true,
+					muted = true,
+					icon = 'schedule',
+					selectable = false,
+				},
+			})
+		end)
+	end
+
+	---@param query string
+	---@param page number|nil
+	handle_search = function(query, page)
+		if not menu:is_alive() then return end
+		page = math.max(1, type(page) == 'number' and round(page) or 1)
+
+		menu:update_items({{icon = 'spinner', align = 'center', selectable = false, muted = true}})
+
+		local args = itable_join({'search-subtitles'}, credentials)
+
+		local languages = itable_filter(get_languages(), function(lang) return lang:match('.json$') == nil end)
+		args[#args + 1] = '--languages'
+		args[#args + 1] = table.concat(table_keys(create_set(languages)), ',') -- deduplicates stuff like `en,eng,en`
+
+		args[#args + 1] = '--page'
+		args[#args + 1] = tostring(page)
+
+		if file_path then
+			args[#args + 1] = '--hash'
+			args[#args + 1] = file_path
+		end
+
+		if query and #query > 0 then
+			args[#args + 1] = '--query'
+			args[#args + 1] = query
+		end
+
+		call_ziggy_async(args, function(error, data)
+			if not menu:is_alive() then return end
+
+			local function check_is_valid(data)
+				return type(data.data) == 'table' and data.page and data.total_pages
+			end
+
+			if should_abort(error, data, check_is_valid) then return end
+
+			local subs = itable_filter(data.data, function(sub)
+				return sub and sub.attributes and sub.attributes.release and type(sub.attributes.files) == 'table' and
+					#sub.attributes.files > 0
+			end)
+			local items = itable_map(subs, function(sub)
+				local hints = {sub.attributes.language}
+				if sub.attributes.foreign_parts_only then hints[#hints + 1] = t('foreign parts only') end
+				if sub.attributes.hearing_impaired then hints[#hints + 1] = t('hearing impaired') end
+				local url = sub.attributes.url
+				return {
+					title = sub.attributes.release,
+					hint = table.concat(hints, ', '),
+					value = {kind = 'file', id = sub.attributes.files[1].file_id, url = url},
+					keep_open = true,
+					actions = url and
+						{{name = 'open_in_browser', icon = 'open_in_new', label = t('Open in browser') .. ' (shift)'}},
+				}
+			end)
+
+			if #items == 0 then
+				items = {
+					{title = t('no results'), align = 'center', muted = true, italic = true, selectable = false},
+				}
+			end
+
+			if data.page > 1 then
+				items[#items + 1] = {
+					title = t('Previous page'),
+					align = 'center',
+					bold = true,
+					italic = true,
+					icon = 'navigate_before',
+					keep_open = true,
+					value = {kind = 'page', query = query, page = data.page - 1},
+				}
+			end
+
+			if data.page < data.total_pages then
+				items[#items + 1] = {
+					title = t('Next page'),
+					align = 'center',
+					bold = true,
+					italic = true,
+					icon = 'navigate_next',
+					keep_open = true,
+					value = {kind = 'page', query = query, page = data.page + 1},
+				}
+			end
+
+			menu:update_items(items)
+		end)
+	end
+
+	local initial_items = {
+		{title = t('%s to search', 'enter'), align = 'center', muted = true, italic = true, selectable = false},
+	}
+
+	menu = Menu:open(
+		{
+			type = menu_type,
+			title = t('enter query'),
+			items = initial_items,
+			search_style = 'palette',
+			on_search = 'callback',
+			search_debounce = 'submit',
+			search_suggestion = search_suggestion,
+		},
+		function(event)
+			if event.type == 'activate' then
+				if event.action == 'open_in_browser' or event.modifiers == 'shift' then
+					local command = ({
+						windows = 'explorer',
+						linux = 'xdg-open',
+						darwin = 'open',
+					})[state.platform]
+					local url = event.value.url
+					mp.command_native_async({
+						name = 'subprocess',
+						capture_stderr = true,
+						capture_stdout = true,
+						playback_only = false,
+						args = {command, url},
+					}, function(success, result, error)
+						if not success then
+							local err_str = utils.to_string(error or result.stderr)
+							msg.error('Error trying to open url "' .. url .. '" in browser: ' .. err_str)
+						end
+					end)
+				elseif not event.action then
+					handle_download(event.value)
+				end
+			elseif event.type == 'search' then
+				handle_search(event.query)
+			end
+		end
+	)
+end
diff --git a/multimedia/.config/mpv/scripts/uosc/lib/std.lua b/multimedia/.config/mpv/scripts/uosc_repo/src/uosc/lib/std.lua
similarity index 84%
rename from multimedia/.config/mpv/scripts/uosc/lib/std.lua
rename to multimedia/.config/mpv/scripts/uosc_repo/src/uosc/lib/std.lua
index 0797a49..9a7b877 100644
--- a/multimedia/.config/mpv/scripts/uosc/lib/std.lua
+++ b/multimedia/.config/mpv/scripts/uosc_repo/src/uosc/lib/std.lua
@@ -1,5 +1,7 @@
 --[[ Stateless utilities missing in lua standard library ]]
 
+---@alias Shortcut {id: string; key: string; modifiers?: string; alt: boolean; ctrl: boolean; shift: boolean}
+
 ---@param number number
 function round(number) return math.floor(number + 0.5) end
 
@@ -17,6 +19,11 @@ function serialize_rgba(rgba)
 	}
 end
 
+-- Trim any white space from the start and end of the string.
+---@param str string
+---@return string
+function trim(str) return str:match('^%s*(.-)%s*$') end
+
 -- Trim any `char` from the end of the string.
 ---@param str string
 ---@param char string
@@ -76,12 +83,18 @@ function string_last_index_of(str, sub)
 	end
 end
 
+-- Escapes a string to be used in a matching expression.
+---@param value string
+function regexp_escape(value)
+	return string.gsub(value, '[%(%)%.%+%-%*%?%[%]%^%$%%]', '%%%1')
+end
+
 ---@param itable table
 ---@param value any
 ---@return integer|nil
 function itable_index_of(itable, value)
-	for index, item in ipairs(itable) do
-		if item == value then return index end
+	for index = 1, #itable do
+		if itable[index] == value then return index end
 	end
 end
 
@@ -217,6 +230,19 @@ function table_assign_props(target, source, props)
 	return target
 end
 
+-- Assign props from `source` to `target` that are not in `props` set.
+---@generic T: table<any, any>
+---@param target T
+---@param source T
+---@param props table<string, boolean>
+---@return T
+function table_assign_exclude(target, source, props)
+	for key, value in pairs(source) do
+		if not props[key] then target[key] = value end
+	end
+	return target
+end
+
 -- `table_assign({}, input)` without loosing types :(
 ---@generic T: table<any, any>
 ---@param input T
@@ -244,6 +270,26 @@ function serialize_key_value_list(input, value_sanitizer)
 	return result
 end
 
+---@param key string
+---@param modifiers? string
+---@return Shortcut
+function create_shortcut(key, modifiers)
+	key = key:lower()
+
+	local id_parts, modifiers_set
+	if modifiers then
+		id_parts = split(modifiers:lower(), '+')
+		table.sort(id_parts, function(a, b) return a < b end)
+		modifiers_set = create_set(id_parts)
+		modifiers = table.concat(id_parts, '+')
+	else
+		id_parts, modifiers, modifiers_set = {}, nil, {}
+	end
+	id_parts[#id_parts + 1] = key
+
+	return table_assign({id = table.concat(id_parts, '+'), key = key, modifiers = modifiers}, modifiers_set)
+end
+
 --[[ EASING FUNCTIONS ]]
 
 function ease_out_quart(x) return 1 - ((1 - x) ^ 4) end
diff --git a/multimedia/.config/mpv/scripts/uosc/lib/text.lua b/multimedia/.config/mpv/scripts/uosc_repo/src/uosc/lib/text.lua
similarity index 99%
rename from multimedia/.config/mpv/scripts/uosc/lib/text.lua
rename to multimedia/.config/mpv/scripts/uosc_repo/src/uosc/lib/text.lua
index 3f5d7c5..f90eab6 100644
--- a/multimedia/.config/mpv/scripts/uosc/lib/text.lua
+++ b/multimedia/.config/mpv/scripts/uosc_repo/src/uosc/lib/text.lua
@@ -382,7 +382,7 @@ do
 		---@type boolean, boolean
 		local bold, italic = opts.bold or options.font_bold, opts.italic or false
 
-		if config.refine.text_width then
+		if not config.refine.text_width then
 			---@type {[string|number]: {[1]: number, [2]: integer}}
 			local text_width = get_cache_stage(width_cache, bold)
 			local width_px = text_width[text]
diff --git a/multimedia/.config/mpv/scripts/uosc/lib/utils.lua b/multimedia/.config/mpv/scripts/uosc_repo/src/uosc/lib/utils.lua
similarity index 87%
rename from multimedia/.config/mpv/scripts/uosc/lib/utils.lua
rename to multimedia/.config/mpv/scripts/uosc_repo/src/uosc/lib/utils.lua
index 4199912..82e6272 100644
--- a/multimedia/.config/mpv/scripts/uosc/lib/utils.lua
+++ b/multimedia/.config/mpv/scripts/uosc_repo/src/uosc/lib/utils.lua
@@ -4,9 +4,7 @@
 ---@alias Rect {ax: number, ay: number, bx: number, by: number, window_drag?: boolean}
 ---@alias Circle {point: Point, r: number, window_drag?: boolean}
 ---@alias Hitbox Rect|Circle
-
---- In place sorting of filenames
----@param filenames string[]
+---@alias ComplexBindingInfo {event: 'down' | 'repeat' | 'up' | 'press'; is_mouse: boolean; canceled: boolean; key_name?: string; key_text?: string;}
 
 -- String sorting
 do
@@ -223,11 +221,6 @@ function get_ray_to_rectangle_distance(ax, ay, bx, by, rect)
 	return closest
 end
 
--- Call function with args if it exists
-function call_maybe(fn, ...)
-	if type(fn) == 'function' then fn(...) end
-end
-
 -- Extracts the properties used by property expansion of that string.
 ---@param str string
 ---@param res { [string] : boolean } | nil
@@ -398,9 +391,20 @@ function has_any_extension(path, extensions)
 	return false
 end
 
----@return string
-function get_default_directory()
-	return mp.command_native({'expand-path', options.default_directory})
+-- Executes mp command defined as a string or an itable, or does nothing if command is any other value.
+-- Returns boolean specifying if command was executed or not.
+---@param command string | string[] | nil | any
+---@return boolean executed `true` if command was executed.
+function execute_command(command)
+	local command_type = type(command)
+	if command_type == 'string' then
+		mp.command(command)
+		return true
+	elseif command_type == 'table' and #command > 0 then
+		mp.command_native(command)
+		return true
+	end
+	return false
 end
 
 -- Serializes path into its semantic parts.
@@ -427,19 +431,18 @@ end
 -- Reads items in directory and splits it into directories and files tables.
 ---@param path string
 ---@param opts? {types?: string[], hidden?: boolean}
----@return string[]|nil files
----@return string[]|nil directories
+---@return string[] files
+---@return string[] directories
+---@return string|nil error
 function read_directory(path, opts)
 	opts = opts or {}
 	local items, error = utils.readdir(path, 'all')
+	local files, directories = {}, {}
 
 	if not items then
-		msg.error('Reading files from "' .. path .. '" failed: ' .. error)
-		return nil, nil
+		return files, directories, 'Reading directory "' .. path .. '" failed. Error: ' .. utils.to_string(error)
 	end
 
-	local files, directories = {}, {}
-
 	for _, item in ipairs(items) do
 		if item ~= '.' and item ~= '..' and (opts.hidden or item:sub(1, 1) ~= '.') then
 			local info = utils.file_info(join_path(path, item))
@@ -467,8 +470,11 @@ function get_adjacent_files(file_path, opts)
 	opts = opts or {}
 	local current_meta = serialize_path(file_path)
 	if not current_meta then return end
-	local files = read_directory(current_meta.dirname, {hidden = opts.hidden})
-	if not files then return end
+	local files, _dirs, error = read_directory(current_meta.dirname, {hidden = opts.hidden})
+	if error then
+		msg.error(error)
+		return
+	end
 	sort_strings(files)
 	local current_file_index
 	local paths = {}
@@ -546,7 +552,7 @@ end
 function navigate_directory(delta)
 	if not state.path or is_protocol(state.path) then return false end
 	local paths, current_index = get_adjacent_files(state.path, {
-		types = config.types.autoload,
+		types = config.types.load,
 		hidden = options.show_hidden_files,
 	})
 	if paths and current_index then
@@ -631,7 +637,7 @@ function delete_file_navigate(delta)
 		if Menu:is_open('open-file') then
 			Elements:maybe('menu', 'delete_value', path)
 		end
-		delete_file(path)
+		if path then delete_file(path) end
 	end
 end
 
@@ -782,18 +788,20 @@ end
 ---@return {[string]: table}|table
 function find_active_keybindings(key)
 	local bindings = mp.get_property_native('input-bindings', {})
-	local active = {} -- map: key-name -> bind-info
+	local active_map = {} -- map: key-name -> bind-info
+	local active_table = {}
 	for _, bind in pairs(bindings) do
 		if bind.owner ~= 'uosc' and bind.priority >= 0 and (not key or bind.key == key) and (
-				not active[bind.key]
-				or (active[bind.key].is_weak and not bind.is_weak)
-				or (bind.is_weak == active[bind.key].is_weak and bind.priority > active[bind.key].priority)
+				not active_map[bind.key]
+				or (active_map[bind.key].is_weak and not bind.is_weak)
+				or (bind.is_weak == active_map[bind.key].is_weak and bind.priority > active_map[bind.key].priority)
 			)
 		then
-			active[bind.key] = bind
+			active_table[#active_table + 1] = bind
+			active_map[bind.key] = bind
 		end
 	end
-	return not key and active or active[key]
+	return key and active_map[key] or active_table
 end
 
 ---@param type 'sub'|'audio'|'video'
@@ -806,32 +814,90 @@ function load_track(type, path)
 	end
 end
 
----@return string|nil
-function get_clipboard()
+---@param args (string|number)[]
+---@return string|nil error
+---@return table data
+function call_ziggy(args)
 	local result = mp.command_native({
 		name = 'subprocess',
 		capture_stderr = true,
 		capture_stdout = true,
 		playback_only = false,
-		args = {config.ziggy_path, 'get-clipboard'},
+		args = itable_join({config.ziggy_path}, args),
 	})
 
-	local function print_error(message)
-		msg.error('Getting clipboard data failed. Error: ' .. message)
+	if result.status ~= 0 then
+		return 'Calling ziggy failed. Exit code ' .. result.status .. ': ' .. result.stdout .. result.stderr, {}
 	end
 
-	if result.status == 0 then
-		local data = utils.parse_json(result.stdout)
-		if data and data.payload then
-			return data.payload
-		else
-			print_error(data and (data.error and data.message or 'unknown error') or 'couldn\'t parse json')
-		end
+	local data = utils.parse_json(result.stdout)
+	if not data then
+		return 'Ziggy response error. Couldn\'t parse json: ' .. result.stdout, {}
+	elseif data.error then
+		return 'Ziggy error: ' .. data.message, {}
 	else
-		print_error('exit code ' .. result.status .. ': ' .. result.stdout .. result.stderr)
+		return nil, data
 	end
 end
 
+---@param args (string|number)[]
+---@param callback fun(error: string|nil, data: table)
+---@return fun() abort Function to abort the request.
+function call_ziggy_async(args, callback)
+	local abort_signal = mp.command_native_async({
+		name = 'subprocess',
+		capture_stderr = true,
+		capture_stdout = true,
+		playback_only = false,
+		args = itable_join({config.ziggy_path}, args),
+	}, function(success, result, error)
+		if not success or not result or result.status ~= 0 then
+			local exit_code = (result and result.status or 'unknown')
+			local message = error or (result and result.stdout .. result.stderr) or ''
+			callback('Calling ziggy failed. Exit code: ' .. exit_code .. ' Error: ' .. message, {})
+			return
+		end
+
+		local json = result and type(result.stdout) == 'string' and result.stdout or ''
+		local data = utils.parse_json(json)
+		if not data then
+			callback('Ziggy response error. Couldn\'t parse json: ' .. json, {})
+		elseif data.error then
+			callback('Ziggy error: ' .. data.message, {})
+		else
+			return callback(nil, data)
+		end
+	end)
+
+	return function()
+		mp.abort_async_command(abort_signal)
+	end
+end
+
+---@return string|nil
+function get_clipboard()
+	local err, data = call_ziggy({'get-clipboard'})
+	if err then
+		mp.commandv('show-text', 'Get clipboard error. See console for details.')
+		msg.error(err)
+	end
+	return data and data.payload
+end
+
+---@param payload any
+---@return string|nil payload String that was copied to clipboard.
+function set_clipboard(payload)
+	payload = tostring(payload)
+	local err, data = call_ziggy({'set-clipboard', payload})
+	if err then
+		mp.commandv('show-text', 'Set clipboard error. See console for details.')
+		msg.error(err)
+	else
+		mp.commandv('show-text', t('Copied to clipboard') .. ': ' .. payload, 3000)
+	end
+	return data and data.payload
+end
+
 --[[ RENDERING ]]
 
 function render()
diff --git a/multimedia/.config/mpv/scripts/uosc/main.lua b/multimedia/.config/mpv/scripts/uosc_repo/src/uosc/main.lua
similarity index 82%
rename from multimedia/.config/mpv/scripts/uosc/main.lua
rename to multimedia/.config/mpv/scripts/uosc_repo/src/uosc/main.lua
index c63a8d1..6178bf6 100644
--- a/multimedia/.config/mpv/scripts/uosc/main.lua
+++ b/multimedia/.config/mpv/scripts/uosc_repo/src/uosc/main.lua
@@ -1,8 +1,10 @@
 --[[ uosc | https://github.com/tomasklaen/uosc ]]
-local uosc_version = '5.2.0'
+local uosc_version = '5.8.0'
 
 mp.commandv('script-message', 'uosc-version', uosc_version)
 
+mp.set_property('osc', 'no')
+
 assdraw = require('mp.assdraw')
 opt = require('mp.options')
 utils = require('mp.utils')
@@ -23,7 +25,7 @@ defaults = {
 	progress_line_width = 20,
 	timeline_persistency = '',
 	timeline_border = 1,
-	timeline_step = 5,
+	timeline_step = '5',
 	timeline_cache = true,
 
 	controls =
@@ -51,7 +53,7 @@ defaults = {
 	top_bar = 'no-border',
 	top_bar_size = 40,
 	top_bar_persistency = '',
-	top_bar_controls = true,
+	top_bar_controls = 'right',
 	top_bar_title = 'yes',
 	top_bar_alt_title = '',
 	top_bar_alt_title_place = 'below',
@@ -60,7 +62,6 @@ defaults = {
 	window_border_size = 1,
 
 	autoload = false,
-	autoload_types = 'video,audio,image',
 	shuffle = false,
 
 	scale = 1,
@@ -92,6 +93,8 @@ defaults = {
 	'aac,ac3,aiff,ape,au,cue,dsf,dts,flac,m4a,mid,midi,mka,mp3,mp4a,oga,ogg,opus,spx,tak,tta,wav,weba,wma,wv',
 	image_types = 'apng,avif,bmp,gif,j2k,jp2,jfif,jpeg,jpg,jxl,mj2,png,svg,tga,tif,tiff,webp',
 	subtitle_types = 'aqt,ass,gsub,idx,jss,lrc,mks,pgs,pjs,psb,rt,sbv,slt,smi,sub,sup,srt,ssa,ssf,ttxt,txt,usf,vt,vtt',
+	playlist_types = 'm3u,m3u8,pls,url,cue',
+	load_types = 'video,audio,image',
 	default_directory = '~/',
 	show_hidden_files = false,
 	use_trash = false,
@@ -99,10 +102,11 @@ defaults = {
 	chapter_ranges = 'openings:30abf964,endings:30abf964,ads:c54e4e80',
 	chapter_range_patterns = 'openings:オープニング;endings:エンディング',
 	languages = 'slang,en',
+	subtitles_directory = '~~/subtitles',
 	disable_elements = '',
 }
 options = table_copy(defaults)
-opt.read_options(options, 'uosc', function(changed_options)
+function handle_options(changed_options)
 	if changed_options.time_precision then
 		timestamp_zero_rep_clear_cache()
 	end
@@ -112,7 +116,8 @@ opt.read_options(options, 'uosc', function(changed_options)
 	Elements:trigger('options')
 	Elements:update_proximities()
 	request_render()
-end)
+end
+opt.read_options(options, 'uosc', handle_options)
 -- Normalize values
 options.proximity_out = math.max(options.proximity_out, options.proximity_in + 1)
 if options.chapter_ranges:sub(1, 4) == '^op|' then options.chapter_ranges = defaults.chapter_ranges end
@@ -126,8 +131,9 @@ if options.total_time and options.destination_time == 'playtime-remaining' then
 elseif not itable_index_of({'total', 'playtime-remaining', 'time-remaining'}, options.destination_time) then
 	options.destination_time = 'playtime-remaining'
 end
--- Ensure required environment configuration
-if options.autoload then mp.commandv('set', 'keep-open-pause', 'no') end
+if not itable_index_of({'left', 'right'}, options.top_bar_controls) then
+	options.top_bar_controls = options.top_bar_controls == 'yes' and 'right' or nil
+end
 
 --[[ INTERNATIONALIZATION ]]
 local intl = require('lib/intl')
@@ -184,16 +190,12 @@ config = {
 		audio = comma_split(options.audio_types),
 		image = comma_split(options.image_types),
 		subtitle = comma_split(options.subtitle_types),
-		media = comma_split(options.video_types .. ',' .. options.audio_types .. ',' .. options.image_types),
-		autoload = (function()
-			---@type string[]
-			local option_values = {}
-			for _, name in ipairs(comma_split(options.autoload_types)) do
-				local value = options[name .. '_types']
-				if type(value) == 'string' then option_values[#option_values + 1] = value end
-			end
-			return comma_split(table.concat(option_values, ','))
-		end)(),
+		playlist = comma_split(options.playlist_types),
+		media = comma_split(options.video_types
+			.. ',' .. options.audio_types
+			.. ',' .. options.image_types
+			.. ',' .. options.playlist_types),
+		load = {}, -- populated by update_load_types() below
 	},
 	stream_quality_options = comma_split(options.stream_quality_options),
 	top_bar_flash_on = comma_split(options.top_bar_flash_on),
@@ -228,10 +230,39 @@ config = {
 	color = table_copy(config_defaults.color),
 	opacity = table_copy(config_defaults.opacity),
 	cursor_leave_fadeout_elements = {'timeline', 'volume', 'top_bar', 'controls'},
+	timeline_step = 5,
+	timeline_step_flag = '',
 }
 
+function update_load_types()
+	local extensions = {}
+	local types = create_set(comma_split(options.load_types:lower()))
+
+	if types.same then
+		types.same = nil
+		if state and state.type then types[state.type] = true end
+	end
+
+	for _, name in ipairs(table_keys(types)) do
+		local type_extensions = config.types[name]
+		if type(type_extensions) == 'table' then
+			itable_append(extensions, type_extensions)
+		else
+			msg.warn('Unknown load type: ' .. name)
+		end
+	end
+
+	config.types.load = extensions
+end
+
 -- Updates config with values dependent on options
 function update_config()
+	-- Required environment config
+	if options.autoload then
+		mp.commandv('set', 'keep-open', 'yes')
+		mp.commandv('set', 'keep-open-pause', 'no')
+	end
+
 	-- Adds `{element}_persistency` config properties with forced visibility states (e.g.: `{paused = true}`)
 	for _, name in ipairs({'timeline', 'controls', 'volume', 'top_bar', 'speed'}) do
 		local option_name = name .. '_persistency'
@@ -257,6 +288,16 @@ function update_config()
 	-- Global color shorthands
 	fg, bg = config.color.foreground, config.color.background
 	fgt, bgt = config.color.foreground_text, config.color.background_text
+
+	-- Timeline step
+	do
+		local is_exact = options.timeline_step:sub(-1) == '!'
+		config.timeline_step = tonumber(is_exact and options.timeline_step:sub(1, -2) or options.timeline_step)
+		config.timeline_step_flag = is_exact and 'exact' or ''
+	end
+
+	-- Other
+	update_load_types()
 end
 update_config()
 
@@ -337,11 +378,14 @@ state = {
 	alt_title = nil,
 	time = nil, -- current media playback time
 	speed = 1,
+	---@type number|nil
 	duration = nil, -- current media duration
 	time_human = nil, -- current playback time in human format
 	destination_time_human = nil, -- depends on options.destination_time
 	pause = mp.get_property_native('pause'),
+	ime_active = mp.get_property_native("input-ime"),
 	chapters = {},
+	---@type {index: number; title: string}|nil
 	current_chapter = nil,
 	chapter_ranges = {},
 	border = mp.get_property_native('border'),
@@ -354,6 +398,7 @@ state = {
 	volume = nil,
 	volume_max = nil,
 	mute = nil,
+	type = nil, -- video,image,audio
 	is_idle = false,
 	is_video = false,
 	is_audio = false, -- true if file is audio only (mp3, etc)
@@ -373,6 +418,7 @@ state = {
 	cache = nil,
 	cache_buffering = 100,
 	cache_underrun = false,
+	cache_duration = nil,
 	core_idle = false,
 	eof_reached = false,
 	render_delay = config.render_delay,
@@ -386,6 +432,7 @@ state = {
 	scale = 1,
 	radius = 0,
 }
+buttons = require('lib/buttons')
 thumbnail = {width = 0, height = 0, disabled = false}
 external = {} -- Properties set by external scripts
 key_binding_overwrites = {} -- Table of key_binding:mpv_command
@@ -429,23 +476,32 @@ function update_fullormaxed()
 	cursor:leave()
 end
 
+function update_duration()
+	local duration = state._duration and ((state.rebase_start_time == false and state.start_time)
+		and (state._duration + state.start_time) or state._duration)
+	set_state('duration', duration)
+	update_human_times()
+end
+
 function update_human_times()
+	state.speed = state.speed or 1
 	if state.time then
-		state.time_human = format_time(state.time, state.duration)
+		local max_seconds = state.duration
 		if state.duration then
-			local speed = state.speed or 1
 			if options.destination_time == 'playtime-remaining' then
-				state.destination_time_human = format_time((state.time - state.duration) / speed, state.duration)
+				max_seconds = state.speed >= 1 and state.duration or state.duration / state.speed
+				state.destination_time_human = format_time((state.time - state.duration) / state.speed, max_seconds)
 			elseif options.destination_time == 'total' then
-				state.destination_time_human = format_time(state.duration, state.duration)
+				state.destination_time_human = format_time(state.duration, max_seconds)
 			else
-				state.destination_time_human = format_time(state.time - state.duration, state.duration)
+				state.destination_time_human = format_time(state.time - state.duration, max_seconds)
 			end
 		else
 			state.destination_time_human = nil
 		end
+		state.time_human = format_time(state.time, max_seconds)
 	else
-		state.time_human = nil
+		state.time_human, state.destination_time_human = nil, nil
 	end
 end
 
@@ -516,7 +572,8 @@ end
 
 function set_state(name, value)
 	state[name] = value
-	call_maybe(state['on_' .. name], value)
+	local state_event = state['on_' .. name]
+	if state_event then state_event(value) end
 	Elements:trigger('prop_' .. name, value)
 end
 
@@ -540,12 +597,16 @@ function load_file_index_in_current_directory(index)
 
 	local serialized = serialize_path(state.path)
 	if serialized and serialized.dirname then
-		local files = read_directory(serialized.dirname, {
-			types = config.types.autoload,
+		local files, _dirs, error = read_directory(serialized.dirname, {
+			types = config.types.load,
 			hidden = options.show_hidden_files,
 		})
 
-		if not files then return end
+		if error then
+			msg.error(error)
+			return
+		end
+
 		sort_strings(files)
 		if index < 0 then index = #files + index + 1 end
 
@@ -568,11 +629,18 @@ function observe_display_fps(name, fps)
 end
 
 function select_current_chapter()
+	local current_chapter_index = state.current_chapter and state.current_chapter.index
 	local current_chapter
 	if state.time and state.chapters then
 		_, current_chapter = itable_find(state.chapters, function(c) return state.time >= c.time end, #state.chapters, 1)
 	end
-	set_state('current_chapter', current_chapter)
+	local new_chapter_index = current_chapter and current_chapter.index
+	if current_chapter_index ~= new_chapter_index then
+		set_state('current_chapter', current_chapter)
+		if itable_has(config.top_bar_flash_on, 'chapter') then
+			Elements:flash({'top_bar'})
+		end
+	end
 end
 
 --[[ STATE HOOKS ]]
@@ -602,7 +670,6 @@ if options.click_threshold > 0 then
 	end
 end
 
-mp.observe_property('osc', 'bool', function(name, value) if value == true then mp.set_property('osc', 'no') end end)
 mp.register_event('file-loaded', function()
 	local path = normalize_path(mp.get_property_native('path'))
 	itable_delete_value(state.history, path)
@@ -688,7 +755,9 @@ mp.observe_property('playback-time', 'number', create_state_setter('time', funct
 	update_human_times()
 	select_current_chapter()
 end))
-mp.observe_property('duration', 'number', create_state_setter('duration', update_human_times))
+mp.observe_property('rebase-start-time', 'bool', create_state_setter('rebase_start_time', update_duration))
+mp.observe_property('demuxer-start-time', 'number', create_state_setter('start_time', update_duration))
+mp.observe_property('duration', 'number', create_state_setter('_duration', update_duration))
 mp.observe_property('speed', 'number', create_state_setter('speed', update_human_times))
 mp.observe_property('track-list', 'native', function(name, value)
 	-- checks the file dispositions
@@ -713,6 +782,8 @@ mp.observe_property('track-list', 'native', function(name, value)
 	set_state('has_many_sub', types.sub > 1)
 	set_state('is_video', types.video > 0)
 	set_state('has_many_video', types.video > 1)
+	set_state('type', state.is_video and 'video' or state.is_audio and 'audio' or state.is_image and 'image' or nil)
+	update_load_types()
 	Elements:trigger('dispositions')
 end)
 mp.observe_property('editions', 'number', function(_, editions)
@@ -764,6 +835,7 @@ mp.observe_property('demuxer-cache-state', 'native', function(prop, cache_state)
 	if cache_state then
 		cached_ranges, bof, eof = cache_state['seekable-ranges'], cache_state['bof-cached'], cache_state['eof-cached']
 		set_state('cache_underrun', cache_state['underrun'])
+		set_state('cache_duration', not cache_state.eof and cache_state['cache-duration'] or nil)
 	else
 		cached_ranges = {}
 	end
@@ -771,6 +843,7 @@ mp.observe_property('demuxer-cache-state', 'native', function(prop, cache_state)
 	if not (state.duration and (#cached_ranges > 0 or state.cache == 'yes' or
 			(state.cache == 'auto' and state.is_stream))) then
 		if state.uncached_ranges then set_state('uncached_ranges', nil) end
+		set_state('cache_duration', nil)
 		return
 	end
 
@@ -779,7 +852,7 @@ mp.observe_property('demuxer-cache-state', 'native', function(prop, cache_state)
 	for _, range in ipairs(cached_ranges) do
 		ranges[#ranges + 1] = {
 			math.max(range['start'] or 0, 0),
-			math.min(range['end'] or state.duration, state.duration),
+			math.min(range['end'] or state.duration --[[@as number]], state.duration),
 		}
 	end
 	table.sort(ranges, function(a, b) return a[1] < b[1] end)
@@ -849,34 +922,51 @@ bind_command('keybinds', function()
 end)
 bind_command('download-subtitles', open_subtitle_downloader)
 bind_command('load-subtitles', create_track_loader_menu_opener({
-	name = 'subtitles', prop = 'sub', allowed_types = itable_join(config.types.video, config.types.subtitle),
+	prop = 'sub',
+	title = t('Load subtitles'),
+	loaded_message = t('Loaded subtitles'),
+	allowed_types = itable_join(config.types.video, config.types.subtitle),
 }))
 bind_command('load-audio', create_track_loader_menu_opener({
-	name = 'audio', prop = 'audio', allowed_types = itable_join(config.types.video, config.types.audio),
+	prop = 'audio',
+	title = t('Load audio'),
+	loaded_message = t('Loaded audio'),
+	allowed_types = itable_join(config.types.video, config.types.audio),
 }))
 bind_command('load-video', create_track_loader_menu_opener({
-	name = 'video', prop = 'video', allowed_types = config.types.video,
+	prop = 'video',
+	title = t('Load video'),
+	loaded_message = t('Loaded video'),
+	allowed_types = config.types.video,
+}))
+bind_command('subtitles', create_select_tracklist_type_menu_opener({
+	title = t('Subtitles'),
+	type = 'sub',
+	prop = 'sid',
+	enable_prop = 'sub-visibility',
+	secondary = {prop = 'secondary-sid', icon = 'vertical_align_top', enable_prop = 'secondary-sub-visibility'},
+	load_command = 'script-binding uosc/load-subtitles',
+	download_command = 'script-binding uosc/download-subtitles',
+}))
+bind_command('audio', create_select_tracklist_type_menu_opener({
+	title = t('Audio'), type = 'audio', prop = 'aid', load_command = 'script-binding uosc/load-audio',
+}))
+bind_command('video', create_select_tracklist_type_menu_opener({
+	title = t('Video'), type = 'video', prop = 'vid', load_command = 'script-binding uosc/load-video',
 }))
-bind_command('subtitles', create_select_tracklist_type_menu_opener(
-	t('Subtitles'), 'sub', 'sid', 'script-binding uosc/load-subtitles', 'script-binding uosc/download-subtitles'
-))
-bind_command('audio', create_select_tracklist_type_menu_opener(
-	t('Audio'), 'audio', 'aid', 'script-binding uosc/load-audio'
-))
-bind_command('video', create_select_tracklist_type_menu_opener(
-	t('Video'), 'video', 'vid', 'script-binding uosc/load-video'
-))
 bind_command('playlist', create_self_updating_menu_opener({
 	title = t('Playlist'),
 	type = 'playlist',
 	list_prop = 'playlist',
+	footnote = t('Paste path or url to add.') .. ' ' .. t('%s to reorder.', 'ctrl+up/down/pgup/pgdn/home/end'),
 	serializer = function(playlist)
 		local items = {}
+		local force_filename = mp.get_property_native('osd-playlist-entry') == 'filename'
 		for index, item in ipairs(playlist) do
-			local is_url = is_protocol(item.filename)
-			local item_title = type(item.title) == 'string' and #item.title > 0 and item.title or false
+			local title = type(item.title) == 'string' and #item.title > 0 and item.title or false
 			items[index] = {
-				title = item_title or (is_url and item.filename or serialize_path(item.filename).basename),
+				title = (not force_filename and title) and title
+					or (is_protocol(item.filename) and item.filename or serialize_path(item.filename).basename),
 				hint = tostring(index),
 				active = item.current,
 				value = index,
@@ -884,11 +974,19 @@ bind_command('playlist', create_self_updating_menu_opener({
 		end
 		return items
 	end,
-	on_select = function(index) mp.commandv('set', 'playlist-pos-1', tostring(index)) end,
-	on_move_item = function(from, to)
-		mp.commandv('playlist-move', tostring(math.max(from, to) - 1), tostring(math.min(from, to) - 1))
+	on_activate = function(event) mp.commandv('set', 'playlist-pos-1', tostring(event.value)) end,
+	on_paste = function(event) mp.commandv('loadfile', tostring(event.value), 'append') end,
+	on_key = function(event)
+		if event.id == 'ctrl+c' and event.selected_item then
+			local payload = mp.get_property_native('playlist/' .. (event.selected_item.value - 1) .. '/filename')
+			set_clipboard(payload)
+		end
 	end,
-	on_delete_item = function(index) mp.commandv('playlist-remove', tostring(index - 1)) end,
+	on_move = function(event)
+		local from, to = event.from_index, event.to_index
+		mp.commandv('playlist-move', tostring(from - 1), tostring(to - (to > from and 0 or 1)))
+	end,
+	on_remove = function(event) mp.commandv('playlist-remove', tostring(event.value - 1)) end,
 }))
 bind_command('chapters', create_self_updating_menu_opener({
 	title = t('Chapters'),
@@ -908,7 +1006,7 @@ bind_command('chapters', create_self_updating_menu_opener({
 		end
 		return items
 	end,
-	on_select = function(index) mp.commandv('set', 'chapter', tostring(index - 1)) end,
+	on_activate = function(event) mp.commandv('set', 'chapter', tostring(event.value - 1)) end,
 }))
 bind_command('editions', create_self_updating_menu_opener({
 	title = t('Editions'),
@@ -928,7 +1026,7 @@ bind_command('editions', create_self_updating_menu_opener({
 		end
 		return items
 	end,
-	on_select = function(id) mp.commandv('set', 'edition', id) end,
+	on_activate = function(event) mp.commandv('set', 'edition', event.value) end,
 }))
 bind_command('show-in-directory', function()
 	-- Ignore URLs
@@ -1009,8 +1107,35 @@ bind_command('audio-device', create_self_updating_menu_opener({
 		end
 		return items
 	end,
-	on_select = function(name) mp.commandv('set', 'audio-device', name) end,
+	on_activate = function(event) mp.commandv('set', 'audio-device', event.value) end,
 }))
+bind_command('paste', function()
+	local has_playlist = mp.get_property_native('playlist-count') > 1
+	mp.commandv('script-binding', 'uosc/paste-to-' .. (has_playlist and 'playlist' or 'open'))
+end)
+bind_command('paste-to-open', function()
+	local payload = get_clipboard()
+	if payload then mp.commandv('loadfile', payload) end
+end)
+bind_command('paste-to-playlist', function()
+	-- If there's no file loaded, we use `paste-to-open`, which both opens and adds to playlist
+	if state.is_idle then
+		mp.commandv('script-binding', 'uosc/paste-to-open')
+	else
+		local payload = get_clipboard()
+		if payload then
+			mp.commandv('loadfile', payload, 'append')
+			mp.commandv('show-text', t('Added to playlist') .. ': ' .. payload, 3000)
+		end
+	end
+end)
+bind_command('copy-to-clipboard', function()
+	if state.path then
+		set_clipboard(state.path)
+	else
+		mp.commandv('show-text', t('Nothing to copy'), 3000)
+	end
+end)
 bind_command('open-config-directory', function()
 	local config_path = mp.command_native({'expand-path', '~~/mpv.conf'})
 	local config = serialize_path(normalize_path(config_path))
@@ -1058,9 +1183,30 @@ mp.register_script_message('update-menu', function(json)
 		if menu then menu:update(data) end
 	end
 end)
+mp.register_script_message('select-menu-item', function(type, item_index, menu_id)
+	local menu = Menu:is_open(type)
+	local index = tonumber(item_index)
+	if menu and index and not menu.mouse_nav then
+		index = round(index)
+		if index > 0 and index <= #menu.current.items then
+			menu:select_index(index, menu_id)
+			menu:scroll_to_index(index, menu_id, true)
+		end
+	end
+end)
 mp.register_script_message('close-menu', function(type)
 	if Menu:is_open(type) then Menu:close() end
 end)
+mp.register_script_message('menu-action', function(name, ...)
+	local menu = Menu:is_open()
+	if menu then
+		local method = ({
+			['search-cancel'] = 'search_cancel',
+			['search-query-update'] = 'search_query_update',
+		})[name]
+		if method then menu[method](menu, ...) end
+	end
+end)
 mp.register_script_message('thumbfast-info', function(json)
 	local data = utils.parse_json(json)
 	if type(data) ~= 'table' or not data.width or not data.height then
@@ -1117,6 +1263,7 @@ Manager = {
 ---@param element_ids string|string[]|nil `foo,bar` or `{'foo', 'bar'}`.
 function Manager:disable(client, element_ids)
 	self._disabled_by[client] = comma_split(element_ids)
+	---@diagnostic disable-next-line: deprecated
 	self.disabled = create_set(itable_join(unpack(table_values(self._disabled_by))))
 	self:_commit()
 end