Files
uosc/scripts/uosc.lua
Tomas Klaen f635df18f7 refactor: cursor events consolidated into one interface (#483)
`mbtn_left` and `wheel` events are now only fired on a `cursor.on_{event}` object, which resets at the beginning of each frame, so elements need to bind these listeners in their render functions. These properties are overwritable which allows elements to take over cursor event handling from their parents if necessary.
2023-03-29 09:08:18 +02:00

1212 lines
43 KiB
Lua

--[[ uosc 4.6.0 - 2023-Feb-15 | https://github.com/tomasklaen/uosc ]]
local uosc_version = '4.6.0'
assdraw = require('mp.assdraw')
opt = require('mp.options')
utils = require('mp.utils')
msg = require('mp.msg')
osd = mp.create_osd_overlay('ass-events')
INFINITY = 1e309
QUARTER_PI_SIN = math.sin(math.pi / 4)
-- Enables relative requires from `scripts` directory
package.path = package.path .. ';' .. mp.find_config_file('scripts') .. '/?.lua'
require('uosc_shared/lib/std')
--[[ OPTIONS ]]
defaults = {
timeline_style = 'line',
timeline_line_width = 2,
timeline_line_width_fullscreen = 3,
timeline_line_width_minimized_scale = 10,
timeline_size_min = 2,
timeline_size_max = 40,
timeline_size_min_fullscreen = 0,
timeline_size_max_fullscreen = 60,
timeline_start_hidden = false,
timeline_persistency = 'paused',
timeline_opacity = 0.9,
timeline_border = 1,
timeline_step = 5,
timeline_chapters_opacity = 0.8,
timeline_cache = true,
controls = 'menu,gap,subtitles,<has_many_audio>audio,<has_many_video>video,<has_many_edition>editions,<stream>stream-quality,gap,space,speed,space,shuffle,loop-playlist,loop-file,gap,prev,items,next,gap,fullscreen',
controls_size = 32,
controls_size_fullscreen = 40,
controls_margin = 8,
controls_spacing = 2,
controls_persistency = '',
volume = 'right',
volume_size = 40,
volume_size_fullscreen = 52,
volume_persistency = '',
volume_opacity = 0.9,
volume_border = 1,
volume_step = 1,
speed_persistency = '',
speed_opacity = 0.6,
speed_step = 0.1,
speed_step_is_factor = false,
menu_item_height = 36,
menu_item_height_fullscreen = 50,
menu_min_width = 260,
menu_min_width_fullscreen = 360,
menu_opacity = 1,
menu_parent_opacity = 0.4,
top_bar = 'no-border',
top_bar_size = 40,
top_bar_size_fullscreen = 46,
top_bar_persistency = '',
top_bar_controls = true,
top_bar_title = 'yes',
top_bar_alt_title = '',
top_bar_alt_title_place = 'below',
top_bar_title_opacity = 0.8,
window_border_size = 1,
window_border_opacity = 0.8,
autoload = false,
autoload_types = 'video,audio,image',
shuffle = false,
ui_scale = 1,
font_scale = 1,
text_border = 1.2,
text_width_estimation = true,
pause_on_click_shorter_than = 0, -- deprecated by below
click_threshold = 0,
click_command = 'cycle pause; script-binding uosc/flash-pause-indicator',
flash_duration = 1000,
proximity_in = 40,
proximity_out = 120,
foreground = 'ffffff',
foreground_text = '000000',
background = '000000',
background_text = 'ffffff',
total_time = false, -- deprecated by below
destination_time = 'playtime-remaining',
time_precision = 0,
font_bold = false,
autohide = false,
buffered_time_threshold = 60,
pause_indicator = 'flash',
curtain_opacity = 0.5,
stream_quality_options = '4320,2160,1440,1080,720,480,360,240,144',
video_types= '3g2,3gp,asf,avi,f4v,flv,h264,h265,m2ts,m4v,mkv,mov,mp4,mp4v,mpeg,mpg,ogm,ogv,rm,rmvb,ts,vob,webm,wmv,y4m',
audio_types= 'aac,ac3,aiff,ape,au,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,slt,smi,sub,sup,srt,ssa,ssf,ttxt,txt,usf,vt,vtt',
default_directory = '~/',
use_trash = false,
chapter_ranges = 'openings:30abf964,endings:30abf964,ads:c54e4e80',
chapter_range_patterns = 'openings:オープニング;endings:エンディング',
}
options = table_shallow_copy(defaults)
opt.read_options(options, 'uosc')
-- 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
if options.pause_on_click_shorter_than > 0 and options.click_threshold == 0 then
msg.warn('`pause_on_click_shorter_than` is deprecated. Use `click_threshold` and `click_command` instead.')
options.click_threshold = options.pause_on_click_shorter_than
end
if options.total_time and options.destination_time == 'playtime-remaining' then
msg.warn('`total_time` is deprecated. Use `destination_time` instead.')
options.destination_time = 'total'
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
-- Color shorthands
fg, bg = serialize_rgba(options.foreground).color, serialize_rgba(options.background).color
fgt, bgt = serialize_rgba(options.foreground_text).color, serialize_rgba(options.background_text).color
--[[ CONFIG ]]
function create_default_menu()
return {
{title = 'Subtitles', value = 'script-binding uosc/subtitles'},
{title = 'Audio tracks', value = 'script-binding uosc/audio'},
{title = 'Stream quality', value = 'script-binding uosc/stream-quality'},
{title = 'Playlist', value = 'script-binding uosc/items'},
{title = 'Chapters', value = 'script-binding uosc/chapters'},
{title = 'Navigation', items = {
{title = 'Next', hint = 'playlist or file', value = 'script-binding uosc/next'},
{title = 'Prev', hint = 'playlist or file', value = 'script-binding uosc/prev'},
{title = 'Delete file & Next', value = 'script-binding uosc/delete-file-next'},
{title = 'Delete file & Prev', value = 'script-binding uosc/delete-file-prev'},
{title = 'Delete file & Quit', value = 'script-binding uosc/delete-file-quit'},
{title = 'Open file', value = 'script-binding uosc/open-file'},
},},
{title = 'Utils', items = {
{title = 'Aspect ratio', items = {
{title = 'Default', value = 'set video-aspect-override "-1"'},
{title = '16:9', value = 'set video-aspect-override "16:9"'},
{title = '4:3', value = 'set video-aspect-override "4:3"'},
{title = '2.35:1', value = 'set video-aspect-override "2.35:1"'},
},},
{title = 'Audio devices', value = 'script-binding uosc/audio-device'},
{title = 'Editions', value = 'script-binding uosc/editions'},
{title = 'Screenshot', value = 'async screenshot'},
{title = 'Show in directory', value = 'script-binding uosc/show-in-directory'},
{title = 'Open config folder', value = 'script-binding uosc/open-config-directory'},
},},
{title = 'Quit', value = 'quit'},
}
end
config = {
version = uosc_version,
-- sets max rendering frequency in case the
-- native rendering frequency could not be detected
render_delay = 1 / 60,
font = mp.get_property('options/osd-font'),
types = {
video = split(options.video_types, ' *, *'),
audio = split(options.audio_types, ' *, *'),
image = split(options.image_types, ' *, *'),
subtitle = split(options.subtitle_types, ' *, *'),
media = split(options.video_types .. ',' .. options.audio_types .. ',' .. options.image_types, ' *, *'),
autoload = (function()
---@type string[]
local option_values = {}
for _, name in ipairs(split(options.autoload_types, ' *, *')) do
local value = options[name .. '_types']
if type(value) == 'string' then option_values[#option_values + 1] = value end
end
return split(table.concat(option_values, ','), ' *, *')
end)(),
},
stream_quality_options = split(options.stream_quality_options, ' *, *'),
menu_items = (function()
local input_conf_property = mp.get_property_native('input-conf')
local input_conf_path = mp.command_native({
'expand-path', input_conf_property == '' and '~~/input.conf' or input_conf_property,
})
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 return create_default_menu() end
local main_menu = {items = {}, items_by_command = {}}
local by_id = {}
for line in io.lines(input_conf_path) 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 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
local item = {
title = title_part,
hint = not is_dummy and key or nil,
value = command,
}
target_menu.items_by_command[command] = item
target_menu.items[#target_menu.items + 1] = item
end
end
end
end
end
if #main_menu.items > 0 then
return main_menu.items
else
-- Default context menu
return create_default_menu()
end
end)(),
chapter_ranges = (function()
---@type table<string, string[]> Alternative patterns.
local alt_patterns = {}
if options.chapter_range_patterns and options.chapter_range_patterns ~= '' then
for _, definition in ipairs(split(options.chapter_range_patterns, ';+ *')) do
local name_patterns = split(definition, ' *:')
local name, patterns = name_patterns[1], name_patterns[2]
if name and patterns then alt_patterns[name] = split(patterns, ',') end
end
end
---@type table<string, {color: string; opacity: number; patterns?: string[]}>
local ranges = {}
if options.chapter_ranges and options.chapter_ranges ~= '' then
for _, definition in ipairs(split(options.chapter_ranges, ' *,+ *')) do
local name_color = split(definition, ' *:+ *')
local name, color = name_color[1], name_color[2]
if name and color
and name:match('^[a-zA-Z0-9_]+$') and color:match('^[a-fA-F0-9]+$')
and (#color == 6 or #color == 8) then
local range = serialize_rgba(name_color[2])
range.patterns = alt_patterns[name]
ranges[name_color[1]] = range
end
end
end
return ranges
end)(),
}
-- Adds `{element}_persistency` property with table of flags when the element should be visible (`{paused = true}`)
for _, name in ipairs({'timeline', 'controls', 'volume', 'top_bar', 'speed'}) do
local option_name = name .. '_persistency'
local value, flags = options[option_name], {}
if type(value) == 'string' then
for _, state in ipairs(split(value, ' *, *')) do flags[state] = true end
end
config[option_name] = flags
end
--[[ STATE ]]
display = {width = 1280, height = 720, scale_x = 1, scale_y = 1, initialized = false}
cursor = {
x = 0,
y = 0,
hidden = true,
hover_raw = false,
-- Event handlers that are only fired on cursor, bound during render loop. Guidelines:
-- - element activations (clicks) go to `mbtn_left_down` handler
-- - `mbtn_button_up` is only for clearing dragging/swiping
on_primary_down = nil,
on_primary_up = nil,
on_wheel_down = nil,
on_wheel_up = nil,
-- Called at the beginning of each render
reset_handlers = function()
cursor.on_primary_down, cursor.on_primary_up = nil, nil
cursor.on_wheel_down, cursor.on_wheel_up = nil, nil
end,
-- Enables pointer key group captures needed by handlers (called at the end of each render)
decide_keybinds = function()
local mbtn_left_decision = (cursor.on_primary_down or cursor.on_primary_up) and 'enable' or 'disable'
local wheel_decision = (cursor.on_wheel_down or cursor.on_wheel_up) and 'enable' or 'disable'
mp[mbtn_left_decision .. '_key_bindings']('mbtn_left')
mp[wheel_decision .. '_key_bindings']('wheel')
end
}
state = {
os = (function()
if os.getenv('windir') ~= nil then return 'windows' end
local homedir = os.getenv('HOME')
if homedir ~= nil and string.sub(homedir, 1, 6) == '/Users' then return 'macos' end
return 'linux'
end)(),
cwd = mp.get_property('working-directory'),
path = nil, -- current file path or URL
title = nil,
alt_title = nil,
time = nil, -- current media playback time
speed = 1,
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'),
chapters = {},
current_chapter = nil,
chapter_ranges = {},
border = mp.get_property_native('border'),
fullscreen = mp.get_property_native('fullscreen'),
maximized = mp.get_property_native('window-maximized'),
fullormaxed = mp.get_property_native('fullscreen') or mp.get_property_native('window-maximized'),
render_timer = nil,
render_last_time = 0,
volume = nil,
volume_max = nil,
mute = nil,
is_idle = false,
is_video = false,
is_audio = false, -- true if file is audio only (mp3, etc)
is_image = false,
is_stream = false,
has_audio = false,
has_sub = false,
has_chapter = false,
has_playlist = false,
shuffle = options.shuffle,
cursor_autohide_timer = mp.add_timeout(mp.get_property_native('cursor-autohide') / 1000, function()
if not options.autohide then return end
handle_mouse_leave()
end),
mouse_bindings_enabled = false,
uncached_ranges = nil,
cache = nil,
cache_buffering = 100,
cache_underrun = false,
core_idle = false,
eof_reached = false,
render_delay = config.render_delay,
first_real_mouse_move_received = false,
playlist_count = 0,
playlist_pos = 0,
margin_top = 0,
margin_bottom = 0,
hidpi_scale = 1,
}
thumbnail = {width = 0, height = 0, disabled = false}
external = {} -- Properties set by external scripts
key_binding_overwrites = {} -- Table of key_binding:mpv_command
Elements = require('uosc_shared/elements/Elements')
Menu = require('uosc_shared/elements/Menu')
-- State dependent utilities
require('uosc_shared/lib/utils')
require('uosc_shared/lib/text')
require('uosc_shared/lib/ass')
require('uosc_shared/lib/menus')
--[[ STATE UPDATERS ]]
function update_display_dimensions()
local scale = (state.hidpi_scale or 1) * options.ui_scale
local real_width, real_height = mp.get_osd_size()
if real_width <= 0 then return end
local scaled_width, scaled_height = round(real_width / scale), round(real_height / scale)
display.width, display.height = scaled_width, scaled_height
display.scale_x, display.scale_y = real_width / scaled_width, real_height / scaled_height
display.initialized = true
-- Tell elements about this
Elements:trigger('display')
-- Some elements probably changed their rectangles as a reaction to `display`
Elements:update_proximities()
request_render()
end
function update_fullormaxed()
state.fullormaxed = state.fullscreen or state.maximized
update_display_dimensions()
Elements:trigger('prop_fullormaxed', state.fullormaxed)
handle_mouse_move(INFINITY, INFINITY)
end
function update_human_times()
if state.time then
state.time_human = format_time(state.time, 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)
elseif options.destination_time == 'total' then
state.destination_time_human = format_time(state.duration, state.duration)
else
state.destination_time_human = format_time(state.time - state.duration, state.duration)
end
else
state.destination_time_human = nil
end
else
state.time_human = nil
end
end
-- Notifies other scripts such as console about where the unoccupied parts of the screen are.
function update_margins()
if display.height == 0 then return end
-- margins are normalized to window size
local timeline, top_bar, controls = Elements.timeline, Elements.top_bar, Elements.controls
local bottom_y = controls and controls.enabled and controls.ay or timeline.ay
local top, bottom = 0, (display.height - bottom_y) / display.height
if top_bar.enabled and top_bar:get_visibility() > 0 then
top = (top_bar.size or 0) / display.height
end
if top == state.margin_top and bottom == state.margin_bottom then return end
state.margin_top = top
state.margin_bottom = bottom
utils.shared_script_property_set('osc-margins', string.format('%f,%f,%f,%f', 0, 0, top, bottom))
mp.set_property_native("user-data/osc/margins", { l = 0, r = 0, t = top, b = bottom })
end
function create_state_setter(name, callback)
return function(_, value)
set_state(name, value)
if callback then callback() end
request_render()
end
end
function set_state(name, value)
state[name] = value
Elements:trigger('prop_' .. name, value)
end
function update_cursor_position(x, y)
-- mpv reports initial mouse position on linux as (0, 0), which always
-- displays the top bar, so we hardcode cursor position as infinity until
-- we receive a first real mouse move event with coordinates other than 0,0.
if not state.first_real_mouse_move_received then
if x > 0 and y > 0 then state.first_real_mouse_move_received = true
else x, y = INFINITY, INFINITY end
end
-- add 0.5 to be in the middle of the pixel
cursor.x, cursor.y = (x + 0.5) / display.scale_x, (y + 0.5) / display.scale_y
Elements:update_proximities()
request_render()
end
function handle_mouse_leave()
-- Slowly fadeout elements that are currently visible
for _, element_name in ipairs({'timeline', 'volume', 'top_bar'}) do
local element = Elements[element_name]
if element and element.proximity > 0 then
element:tween_property('forced_visibility', element:get_visibility(), 0, function()
element.forced_visibility = nil
end)
end
end
cursor.hidden = true
Elements:update_proximities()
Elements:trigger('global_mouse_leave')
end
function handle_mouse_enter(x, y)
cursor.hidden = false
update_cursor_position(x, y)
Elements:trigger('global_mouse_enter')
end
function handle_mouse_move(x, y)
update_cursor_position(x, y)
Elements:proximity_trigger('mouse_move')
request_render()
-- Restart timer that hides UI when mouse is autohidden
if options.autohide then
state.cursor_autohide_timer:kill()
state.cursor_autohide_timer:resume()
end
end
function handle_file_end()
local resume = false
if not state.loop_file then
if state.has_playlist then resume = state.shuffle and navigate_playlist(1)
else resume = options.autoload and navigate_directory(1) end
end
-- Resume only when navigation happened
if resume then mp.command('set pause no') end
end
local file_end_timer = mp.add_timeout(1, handle_file_end)
file_end_timer:kill()
function load_file_index_in_current_directory(index)
if not state.path or is_protocol(state.path) then return end
local serialized = serialize_path(state.path)
if serialized and serialized.dirname then
local files = read_directory(serialized.dirname, config.types.autoload)
if not files then return end
sort_filenames(files)
if index < 0 then index = #files + index + 1 end
if files[index] then
mp.commandv('loadfile', join_path(serialized.dirname, files[index]))
end
end
end
function update_render_delay(name, fps)
if fps then state.render_delay = 1 / fps end
end
function observe_display_fps(name, fps)
if fps then
mp.unobserve_property(update_render_delay)
mp.unobserve_property(observe_display_fps)
mp.observe_property('display-fps', 'native', update_render_delay)
end
end
function select_current_chapter()
local current_chapter
if state.time and state.chapters then
_, current_chapter = itable_find(state.chapters, function(c) return state.time >= c.time end, true)
end
set_state('current_chapter', current_chapter)
end
--[[ STATE HOOKS ]]
-- Click detection
if options.click_threshold > 0 then
-- Executes custom command for clicks shorter than `options.click_threshold`
-- while filtering out double clicks.
local click_time = options.click_threshold / 1000
local doubleclick_time = mp.get_property_native('input-doubleclick-time') / 1000
local last_down, last_up = 0, 0
local click_timer = mp.add_timeout(math.max(click_time, doubleclick_time), function()
local delta = last_up - last_down
if delta > 0 and delta < click_time and delta > 0.02 then mp.command(options.click_command) end
end)
click_timer:kill()
mp.set_key_bindings({{'mbtn_left',
function() last_up = mp.get_time() end,
function()
last_down = mp.get_time()
if click_timer:is_enabled() then click_timer:kill() else click_timer:resume() end
end,
},}, 'mouse_movement', 'force')
mp.enable_key_bindings('mouse_movement', 'allow-vo-dragging+allow-hide-cursor')
end
function update_mouse_pos(_, mouse)
if not mouse then return end
if cursor.hover_raw and not mouse.hover then
handle_mouse_leave()
else
if cursor.hidden then handle_mouse_enter(mouse.x, mouse.y) end
handle_mouse_move(mouse.x, mouse.y)
end
cursor.hover_raw = mouse.hover
end
mp.observe_property('mouse-pos', 'native', update_mouse_pos)
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()
set_state('path', normalize_path(mp.get_property_native('path')))
Elements:flash({'top_bar'})
end)
mp.register_event('end-file', function(event)
if event.reason == 'eof' then
file_end_timer:kill()
handle_file_end()
end
end)
-- Top bar titles
do
local function update_state_with_template(prop, template)
-- escape ASS, and strip newlines and trailing slashes and trim whitespace
local tmp = mp.command_native({'expand-text', template}):gsub('\\n', ' '):gsub('[\\%s]+$', ''):gsub('^%s+', '')
set_state(prop, ass_escape(tmp))
end
local function add_template_listener(template, callback)
local props = get_expansion_props(template)
for prop, _ in pairs(props) do
mp.observe_property(prop, 'native', callback)
end
if not next(props) then callback() end
end
local function remove_template_listener(callback) mp.unobserve_property(callback) end
-- Main title
if #options.top_bar_title > 0 and options.top_bar_title ~= 'no' then
if options.top_bar_title == 'yes' then
local template = nil
local function update_title() update_state_with_template('title', template) end
mp.observe_property('title', 'string', function(_, title)
remove_template_listener(update_title)
template = title
if template then
if template:sub(-6) == ' - mpv' then template = template:sub(1, -7) end
add_template_listener(template, update_title)
end
end)
elseif type(options.top_bar_title) == 'string' then
add_template_listener(options.top_bar_title, function()
update_state_with_template('title', options.top_bar_title)
end)
end
end
-- Alt title
if #options.top_bar_alt_title > 0 and options.top_bar_alt_title ~= 'no' then
add_template_listener(options.top_bar_alt_title, function()
update_state_with_template('alt_title', options.top_bar_alt_title)
end)
end
end
mp.observe_property('playback-time', 'number', create_state_setter('time', function()
-- Create a file-end event that triggers right before file ends
file_end_timer:kill()
if state.duration and state.time and not state.pause then
local remaining = (state.duration - state.time) / state.speed
if remaining < 5 then
local timeout = remaining - 0.02
if timeout > 0 then
file_end_timer.timeout = timeout
file_end_timer:resume()
else handle_file_end() end
end
end
update_human_times()
select_current_chapter()
end))
mp.observe_property('duration', 'number', create_state_setter('duration', update_human_times))
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
local is_image = false
local types = {sub = 0, audio = 0, video = 0}
for _, track in ipairs(value) do
if track.type == 'video' then
is_image = track.image
if not is_image and not track.albumart then types.video = types.video + 1 end
elseif types[track.type] then types[track.type] = types[track.type] + 1 end
end
set_state('is_audio', types.video == 0 and types.audio > 0)
set_state('is_image', is_image)
set_state('has_audio', types.audio > 0)
set_state('has_many_audio', types.audio > 1)
set_state('has_sub', types.sub > 0)
set_state('has_many_sub', types.sub > 1)
set_state('is_video', types.video > 0)
set_state('has_many_video', types.video > 1)
Elements:trigger('dispositions')
end)
mp.observe_property('editions', 'number', function(_, editions)
if editions then set_state('has_many_edition', editions > 1) end
Elements:trigger('dispositions')
end)
mp.observe_property('chapter-list', 'native', function(_, chapters)
local chapters, chapter_ranges = serialize_chapters(chapters), {}
if chapters then chapters, chapter_ranges = serialize_chapter_ranges(chapters) end
set_state('chapters', chapters)
set_state('chapter_ranges', chapter_ranges)
set_state('has_chapter', #chapters > 0)
select_current_chapter()
Elements:trigger('dispositions')
end)
mp.observe_property('border', 'bool', create_state_setter('border'))
mp.observe_property('loop-file', 'native', create_state_setter('loop_file'))
mp.observe_property('ab-loop-a', 'number', create_state_setter('ab_loop_a'))
mp.observe_property('ab-loop-b', 'number', create_state_setter('ab_loop_b'))
mp.observe_property('playlist-pos-1', 'number', create_state_setter('playlist_pos'))
mp.observe_property('playlist-count', 'number', function(_, value)
set_state('playlist_count', value)
set_state('has_playlist', value > 1)
Elements:trigger('dispositions')
end)
mp.observe_property('fullscreen', 'bool', create_state_setter('fullscreen', update_fullormaxed))
mp.observe_property('window-maximized', 'bool', create_state_setter('maximized', update_fullormaxed))
mp.observe_property('idle-active', 'bool', function(_, idle)
set_state('is_idle', idle)
Elements:trigger('dispositions')
end)
mp.observe_property('pause', 'bool', create_state_setter('pause', function() file_end_timer:kill() end))
mp.observe_property('volume', 'number', create_state_setter('volume'))
mp.observe_property('volume-max', 'number', create_state_setter('volume_max'))
mp.observe_property('mute', 'bool', create_state_setter('mute'))
mp.observe_property('osd-dimensions', 'native', function(name, val)
update_display_dimensions()
request_render()
end)
mp.observe_property('display-hidpi-scale', 'native', create_state_setter('hidpi_scale', update_display_dimensions))
mp.observe_property('cache', 'string', create_state_setter('cache'))
mp.observe_property('cache-buffering-state', 'number', create_state_setter('cache_buffering'))
mp.observe_property('demuxer-via-network', 'native', create_state_setter('is_stream', function()
Elements:trigger('dispositions')
end))
mp.observe_property('demuxer-cache-state', 'native', function(prop, cache_state)
local cached_ranges, bof, eof, uncached_ranges = nil, nil, nil, nil
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'])
else cached_ranges = {} end
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
return
end
-- Normalize
local ranges = {}
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),
}
end
table.sort(ranges, function(a, b) return a[1] < b[1] end)
if bof then ranges[1][1] = 0 end
if eof then ranges[#ranges][2] = state.duration end
-- Invert cached ranges into uncached ranges, as that's what we're rendering
local inverted_ranges = {{0, state.duration}}
for _, cached in pairs(ranges) do
inverted_ranges[#inverted_ranges][2] = cached[1]
inverted_ranges[#inverted_ranges + 1] = {cached[2], state.duration}
end
uncached_ranges = {}
local last_range = nil
for _, range in ipairs(inverted_ranges) do
if last_range and last_range[2] + 0.5 > range[1] then -- fuse ranges
last_range[2] = range[2]
else
if range[2] - range[1] > 0.5 then -- skip short ranges
uncached_ranges[#uncached_ranges + 1] = range
last_range = range
end
end
end
set_state('uncached_ranges', uncached_ranges)
end)
mp.observe_property('display-fps', 'native', observe_display_fps)
mp.observe_property('estimated-display-fps', 'native', update_render_delay)
mp.observe_property('eof-reached', 'native', create_state_setter('eof_reached'))
mp.observe_property('core-idle', 'native', create_state_setter('core_idle'))
--[[ KEY BINDS ]]
-- Pointer related binding groups
mp.set_key_bindings({
{
'mbtn_left',
function(...) call_maybe(cursor.on_primary_up, ...) end,
function(...)
update_mouse_pos(nil, mp.get_property_native('mouse-pos'))
call_maybe(cursor.on_primary_down, ...)
end,
},
{'mbtn_left_dbl', 'ignore'},
}, 'mbtn_left', 'force')
mp.set_key_bindings({
{'wheel_up', function(...) call_maybe(cursor.on_wheel_up, ...) end},
{'wheel_down', function(...) call_maybe(cursor.on_wheel_down, ...) end},
}, 'wheel', 'force')
-- Adds a key binding that respects rerouting set by `key_binding_overwrites` table.
---@param name string
---@param callback fun(event: table)
---@param flags nil|string
function bind_command(name, callback, flags)
mp.add_key_binding(nil, name, function(...)
if key_binding_overwrites[name] then mp.command(key_binding_overwrites[name])
else callback(...) end
end, flags)
end
bind_command('toggle-ui', function() Elements:toggle({'timeline', 'controls', 'volume', 'top_bar'}) end)
bind_command('flash-ui', function() Elements:flash({'timeline', 'controls', 'volume', 'top_bar'}) end)
bind_command('flash-timeline', function() Elements:flash({'timeline'}) end)
bind_command('flash-top-bar', function() Elements:flash({'top_bar'}) end)
bind_command('flash-volume', function() Elements:flash({'volume'}) end)
bind_command('flash-speed', function() Elements:flash({'speed'}) end)
bind_command('flash-pause-indicator', function() Elements:flash({'pause_indicator'}) end)
bind_command('toggle-progress', function()
local timeline = Elements.timeline
if timeline.size_min_override then
timeline:tween_property('size_min_override', timeline.size_min_override, timeline.size_min, function()
timeline.size_min_override = nil
end)
else
timeline:tween_property('size_min_override', timeline.size_min, 0)
end
end)
bind_command('toggle-title', function() Elements.top_bar:toggle_title() end)
bind_command('decide-pause-indicator', function() Elements.pause_indicator:decide() end)
bind_command('menu', function() toggle_menu_with_items() end)
bind_command('menu-blurred', function() toggle_menu_with_items({mouse_nav = true}) end)
local track_loaders = {
{name = 'subtitles', prop = 'sub', allowed_types = itable_join(config.types.video, config.types.subtitle)},
{name = 'audio', prop = 'audio', allowed_types = itable_join(config.types.video, config.types.audio)},
{name = 'video', prop = 'video', allowed_types = config.types.video},
}
for _, loader in ipairs(track_loaders) do
local menu_type = 'load-' .. loader.name
bind_command(menu_type, 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
open_file_navigation_menu(
path,
function(path) mp.commandv(loader.prop .. '-add', path) end,
{type = menu_type, title = 'Load ' .. loader.name, allowed_types = loader.allowed_types}
)
end)
end
bind_command('subtitles', create_select_tracklist_type_menu_opener(
'Subtitles', 'sub', 'sid', 'script-binding uosc/load-subtitles'
))
bind_command('audio', create_select_tracklist_type_menu_opener(
'Audio', 'audio', 'aid', 'script-binding uosc/load-audio'
))
bind_command('video', create_select_tracklist_type_menu_opener(
'Video', 'video', 'vid', 'script-binding uosc/load-video'
))
bind_command('playlist', create_self_updating_menu_opener({
title = 'Playlist',
type = 'playlist',
list_prop = 'playlist',
serializer = function(playlist)
local items = {}
for index, item in ipairs(playlist) do
local is_url = item.filename:find('://')
local item_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),
hint = tostring(index),
active = item.current,
value = index,
}
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))
end,
on_delete_item = function(index) mp.commandv('playlist-remove', tostring(index - 1)) end,
}))
bind_command('chapters', create_self_updating_menu_opener({
title = 'Chapters',
type = 'chapters',
list_prop = 'chapter-list',
active_prop = 'chapter',
serializer = function(chapters, current_chapter)
local items = {}
chapters = normalize_chapters(chapters)
for index, chapter in ipairs(chapters) do
items[index] = {
title = chapter.title or '',
hint = format_time(chapter.time, state.duration),
value = index,
active = index - 1 == current_chapter,
}
end
return items
end,
on_select = function(index) mp.commandv('set', 'chapter', tostring(index - 1)) end,
}))
bind_command('editions', create_self_updating_menu_opener({
title = 'Editions',
type = 'editions',
list_prop = 'edition-list',
active_prop = 'current-edition',
serializer = function(editions, current_id)
local items = {}
for _, edition in ipairs(editions or {}) do
items[#items + 1] = {
title = edition.title or 'Edition',
hint = tostring(edition.id + 1),
value = edition.id,
active = edition.id == current_id,
}
end
return items
end,
on_select = function(id) mp.commandv('set', 'edition', id) end,
}))
bind_command('show-in-directory', function()
-- Ignore URLs
if not state.path or is_protocol(state.path) then return end
if state.os == 'windows' then
utils.subprocess_detached({args = {'explorer', '/select,', state.path}, cancellable = false})
elseif state.os == 'macos' then
utils.subprocess_detached({args = {'open', '-R', state.path}, cancellable = false})
elseif state.os == 'linux' then
local result = utils.subprocess({args = {'nautilus', state.path}, cancellable = false})
-- Fallback opens the folder with xdg-open instead
if result.status ~= 0 then
utils.subprocess({args = {'xdg-open', serialize_path(state.path).dirname}, cancellable = false})
end
end
end)
bind_command('stream-quality', function()
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 = '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/
-- Dunno if playlist_pos shenanigans below are necessary.
local playlist_pos = mp.get_property_number('playlist-pos')
local duration = mp.get_property_native('duration')
local time_pos = mp.get_property('time-pos')
mp.set_property_number('playlist-pos', playlist_pos)
-- 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)
bind_command('open-file', function()
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 function handle_file_loaded()
if Menu:is_open('open-file') then
Elements.menu:activate_one_value(normalize_path(mp.get_property_native('path')))
end
end
open_file_navigation_menu(
directory,
function(path) mp.commandv('loadfile', path) end,
{
type = 'open-file',
allowed_types = config.types.media,
active_path = active_file,
on_open = function() mp.register_event('file-loaded', handle_file_loaded) end,
on_close = function() mp.unregister_event(handle_file_loaded) end,
}
)
end)
bind_command('shuffle', function() set_state('shuffle', not state.shuffle) end)
bind_command('items', function()
if state.has_playlist then
mp.command('script-binding uosc/playlist')
else
mp.command('script-binding uosc/open-file')
end
end)
bind_command('next', function() navigate_item(1) end)
bind_command('prev', function() navigate_item(-1) end)
bind_command('next-file', function() navigate_directory(1) end)
bind_command('prev-file', function() navigate_directory(-1) end)
bind_command('first', function()
if state.has_playlist then
mp.commandv('set', 'playlist-pos-1', '1')
else
load_file_index_in_current_directory(1)
end
end)
bind_command('last', function()
if state.has_playlist then
mp.commandv('set', 'playlist-pos-1', tostring(state.playlist_count))
else
load_file_index_in_current_directory(-1)
end
end)
bind_command('first-file', function() load_file_index_in_current_directory(1) end)
bind_command('last-file', function() load_file_index_in_current_directory(-1) end)
bind_command('delete-file-next', function()
local next_file = nil
local is_local_file = state.path and not is_protocol(state.path)
if is_local_file then
if Menu:is_open('open-file') then Elements.menu:delete_value(state.path) end
end
if state.has_playlist then
mp.commandv('playlist-remove', 'current')
else
if is_local_file then
local paths, current_index = get_adjacent_files(state.path, config.types.autoload)
if paths and current_index then
local index, path = decide_navigation_in_list(paths, current_index, 1)
if path then next_file = path end
end
end
if next_file then mp.commandv('loadfile', next_file)
else mp.commandv('stop') end
end
if is_local_file then delete_file(state.path) end
end)
bind_command('delete-file-quit', function()
mp.command('stop')
if state.path and not is_protocol(state.path) then delete_file(state.path) end
mp.command('quit')
end)
bind_command('audio-device', create_self_updating_menu_opener({
title = 'Audio devices',
type = 'audio-device-list',
list_prop = 'audio-device-list',
active_prop = 'audio-device',
serializer = function(audio_device_list, current_device)
current_device = current_device or 'auto'
local ao = mp.get_property('current-ao') or ''
local items = {}
for _, device in ipairs(audio_device_list) do
if device.name == 'auto' or string.match(device.name, '^' .. ao) then
local hint = string.match(device.name, ao .. '/(.+)')
if not hint then hint = device.name end
items[#items + 1] = {
title = device.description,
hint = hint,
active = device.name == current_device,
value = device.name,
}
end
end
return items
end,
on_select = function(name) mp.commandv('set', 'audio-device', name) 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))
if config then
local args
if state.os == 'windows' then
args = {'explorer', '/select,', config.path}
elseif state.os == 'macos' then
args = {'open', '-R', config.path}
elseif state.os == 'linux' then
args = {'xdg-open', config.dirname}
end
utils.subprocess_detached({args = args, cancellable = false})
else
msg.error('Couldn\'t serialize config path "' .. config_path .. '".')
end
end)
--[[ MESSAGE HANDLERS ]]
mp.register_script_message('show-submenu', function(id) toggle_menu_with_items({submenu = id}) end)
mp.register_script_message('show-submenu-blurred', function(id)
toggle_menu_with_items({submenu = id, mouse_nav = true})
end)
mp.register_script_message('get-version', function(script)
mp.commandv('script-message-to', script, 'uosc-version', config.version)
end)
mp.register_script_message('open-menu', function(json, submenu_id)
local data = utils.parse_json(json)
if type(data) ~= 'table' or type(data.items) ~= 'table' then
msg.error('open-menu: received json didn\'t produce a table with menu configuration')
else
if data.type and Menu:is_open(data.type) then Menu:close()
else open_command_menu(data, {submenu = submenu_id, on_close = data.on_close}) end
end
end)
mp.register_script_message('update-menu', function(json)
local data = utils.parse_json(json)
if type(data) ~= 'table' or type(data.items) ~= 'table' then
msg.error('update-menu: received json didn\'t produce a table with menu configuration')
else
local menu = data.type and Menu:is_open(data.type)
if menu then menu:update(data)
else open_command_menu(data) 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
thumbnail.disabled = true
msg.error('thumbfast-info: received json didn\'t produce a table with thumbnail information')
else
thumbnail = data
request_render()
end
end)
mp.register_script_message('set', function(name, value)
external[name] = value
Elements:trigger('external_prop_' .. name, value)
end)
mp.register_script_message('toggle-elements', function(elements) Elements:toggle(split(elements, ' *, *')) end)
mp.register_script_message('set-min-visibility', function(visibility, elements)
local fraction = tonumber(visibility)
local ids = split(elements and elements ~= '' and elements or 'timeline,controls,volume,top_bar', ' *, *')
if fraction then Elements:set_min_visibility(clamp(0, fraction, 1), ids) end
end)
mp.register_script_message('flash-elements', function(elements) Elements:flash(split(elements, ' *, *')) end)
mp.register_script_message('overwrite-binding', function(name, command) key_binding_overwrites[name] = command end)
--[[ ELEMENTS ]]
require('uosc_shared/elements/WindowBorder'):new()
require('uosc_shared/elements/BufferingIndicator'):new()
require('uosc_shared/elements/PauseIndicator'):new()
require('uosc_shared/elements/TopBar'):new()
require('uosc_shared/elements/Timeline'):new()
if options.controls and options.controls ~= 'never' then require('uosc_shared/elements/Controls'):new() end
if itable_index_of({'left', 'right'}, options.volume) then require('uosc_shared/elements/Volume'):new() end
require('uosc_shared/elements/Curtain'):new()