mpv: Update uosc

This repo is not including the binary 'ziggy' files. They are used for
clipboard management and subtitle downloading, which I personally do not
need.

We are including the uosc repo as a sparsely checked-out submodule
repository now, symlinking it into place.
It might be a little buggy and I am not sure how jj deals with it but
time will tell.

A concise explanation of the idea can be found here:
<https://gist.github.com/ZhuoyunZhong/2c08c8549616e03b7f508fea64130558>

WIP: Add UOSC as submodule
This commit is contained in:
Marty Oehme 2025-02-24 13:22:14 +01:00
parent df78905778
commit 1568ca0534
Signed by: Marty
GPG key ID: 4E535BC19C61886E
49 changed files with 4385 additions and 1813 deletions

3
.gitmodules vendored
View file

@ -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

View file

@ -0,0 +1 @@
uosc_repo/src/uosc

View file

@ -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

View file

@ -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"
}

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,3 @@
src/uosc/bin
release
*.zip

View file

@ -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!

View file

@ -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.

View file

@ -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

View file

@ -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=

View file

@ -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

View file

@ -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})

View file

@ -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])

View file

@ -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

View file

@ -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

View file

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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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."
}

View file

@ -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."
}

View file

@ -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 使其生效"
}

View file

@ -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

View file

@ -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

View file

@ -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 = {}

View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -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

View file

@ -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]

View file

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

View file

@ -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