summaryrefslogtreecommitdiffstats
path: root/.config/mpv/scripts/uosc.lua
diff options
context:
space:
mode:
authorJoe <rrbo@proton.me>2023-01-31 14:03:59 +0100
committerJoe <rrbo@proton.me>2023-01-31 14:03:59 +0100
commitefa2957045fe9ea4421ea4a0c546f62a8a27fb58 (patch)
tree816b267a9d431e28744db41429358516ab0c461b /.config/mpv/scripts/uosc.lua
parentup (diff)
downloaddotfiles-bsd-efa2957045fe9ea4421ea4a0c546f62a8a27fb58.tar.gz
dotfiles-bsd-efa2957045fe9ea4421ea4a0c546f62a8a27fb58.tar.bz2
dotfiles-bsd-efa2957045fe9ea4421ea4a0c546f62a8a27fb58.tar.xz
dotfiles-bsd-efa2957045fe9ea4421ea4a0c546f62a8a27fb58.tar.zst
dotfiles-bsd-efa2957045fe9ea4421ea4a0c546f62a8a27fb58.zip
update
Diffstat (limited to '')
-rw-r--r--.config/mpv/scripts/uosc.lua1081
1 files changed, 1081 insertions, 0 deletions
diff --git a/.config/mpv/scripts/uosc.lua b/.config/mpv/scripts/uosc.lua
new file mode 100644
index 0000000..4c9abdb
--- /dev/null
+++ b/.config/mpv/scripts/uosc.lua
@@ -0,0 +1,1081 @@
+--[[ uosc 4.5.0 - 2022-Dec-07 | https://github.com/tomasklaen/uosc ]]
+local uosc_version = '4.5.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 = true,
+ top_bar_title_opacity = 0.8,
+
+ window_border_size = 1,
+ window_border_opacity = 0.8,
+
+ autoload = false,
+ 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,
+ 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',
+ media_types = '3g2,3gp,aac,aiff,ape,apng,asf,au,avi,avif,bmp,dsf,f4v,flac,flv,gif,h264,h265,j2k,jp2,jfif,jpeg,jpg,jxl,m2ts,m4a,m4v,mid,midi,mj2,mka,mkv,mov,mp3,mp4,mp4a,mp4v,mpeg,mpg,oga,ogg,ogm,ogv,opus,png,rm,rmvb,spx,svg,tak,tga,tta,tif,tiff,ts,vob,wav,weba,webm,webp,wma,wmv,wv,y4m',
+ 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 = '~/',
+ 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
+-- 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'),
+ media_types = split(options.media_types, ' *, *'),
+ subtitle_types = split(options.subtitle_types, ' *, *'),
+ 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 = {hidden = true, x = 0, y = 0}
+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,
+ time = nil, -- current media playback time
+ speed = 1,
+ duration = nil, -- current media duration
+ time_human = nil, -- current playback time in human format
+ duration_or_remaining_time_human = nil, -- depends on options.total_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
+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)
+end
+
+function update_human_times()
+ if state.time then
+ state.time_human = format_time(state.time)
+ if state.duration then
+ local speed = state.speed or 1
+ state.duration_or_remaining_time_human = format_time(
+ options.total_time and state.duration or ((state.time - state.duration) / speed)
+ )
+ else
+ state.duration_or_remaining_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()
+ -- 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))
+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.media_types)
+
+ 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, ignore_hover)
+ if ignore_hover or mouse.hover then
+ if cursor.hidden then handle_mouse_enter(mouse.x, mouse.y) end
+ handle_mouse_move(mouse.x, mouse.y)
+ else handle_mouse_leave() end
+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')))
+end)
+mp.register_event('end-file', function(event)
+ if event.reason == 'eof' then
+ file_end_timer:kill()
+ handle_file_end()
+ end
+end)
+do
+ local template = nil
+ function update_title()
+ if template:sub(-6) == ' - mpv' then template = template:sub(1, -7) end
+ -- escape ASS, and strip newlines and trailing slashes and trim whitespace
+ local t = mp.command_native({'expand-text', template}):gsub('\\n', ' '):gsub('[\\%s]+$', ''):gsub('^%s+', '')
+ set_state('title', ass_escape(t))
+ end
+ mp.observe_property('title', 'string', function(_, title)
+ mp.unobserve_property(update_title)
+ template = title
+ local props = get_expansion_props(title)
+ for prop, _ in pairs(props) do
+ mp.observe_property(prop, 'native', update_title)
+ end
+ if not next(props) then update_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 ]]
+
+mp.add_key_binding(nil, 'toggle-ui', function() Elements:toggle({'timeline', 'controls', 'volume', 'top_bar'}) end)
+mp.add_key_binding(nil, 'flash-ui', function() Elements:flash({'timeline', 'controls', 'volume', 'top_bar'}) end)
+mp.add_key_binding(nil, 'flash-timeline', function() Elements:flash({'timeline'}) end)
+mp.add_key_binding(nil, 'flash-top-bar', function() Elements:flash({'top_bar'}) end)
+mp.add_key_binding(nil, 'flash-volume', function() Elements:flash({'volume'}) end)
+mp.add_key_binding(nil, 'flash-speed', function() Elements:flash({'speed'}) end)
+mp.add_key_binding(nil, 'flash-pause-indicator', function() Elements:flash({'pause_indicator'}) end)
+mp.add_key_binding(nil, '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)
+mp.add_key_binding(nil, 'decide-pause-indicator', function() Elements.pause_indicator:decide() end)
+mp.add_key_binding(nil, 'menu', function() toggle_menu_with_items() end)
+mp.add_key_binding(nil, 'menu-blurred', function() toggle_menu_with_items({mouse_nav = true}) end)
+local track_loaders = {
+ {name = 'subtitles', prop = 'sub', allowed_types = config.subtitle_types},
+ {name = 'audio', prop = 'audio', allowed_types = config.media_types},
+ {name = 'video', prop = 'video', allowed_types = config.media_types},
+}
+for _, loader in ipairs(track_loaders) do
+ local menu_type = 'load-' .. loader.name
+ mp.add_key_binding(nil, 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
+mp.add_key_binding(nil, 'subtitles', create_select_tracklist_type_menu_opener(
+ 'Subtitles', 'sub', 'sid', 'script-binding uosc/load-subtitles'
+))
+mp.add_key_binding(nil, 'audio', create_select_tracklist_type_menu_opener(
+ 'Audio', 'audio', 'aid', 'script-binding uosc/load-audio'
+))
+mp.add_key_binding(nil, 'video', create_select_tracklist_type_menu_opener(
+ 'Video', 'video', 'vid', 'script-binding uosc/load-video'
+))
+mp.add_key_binding(nil, '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,
+}))
+mp.add_key_binding(nil, '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 = mp.format_time(chapter.time),
+ value = index,
+ active = index - 1 == current_chapter,
+ }
+ end
+ return items
+ end,
+ on_select = function(index) mp.commandv('set', 'chapter', tostring(index - 1)) end,
+}))
+mp.add_key_binding(nil, '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,
+}))
+mp.add_key_binding(nil, '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)
+mp.add_key_binding(nil, '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)
+mp.add_key_binding(nil, '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.media_types,
+ 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)
+mp.add_key_binding(nil, 'shuffle', function() set_state('shuffle', not state.shuffle) end)
+mp.add_key_binding(nil, 'items', function()
+ if state.has_playlist then
+ mp.command('script-binding uosc/playlist')
+ else
+ mp.command('script-binding uosc/open-file')
+ end
+end)
+mp.add_key_binding(nil, 'next', function() navigate_item(1) end)
+mp.add_key_binding(nil, 'prev', function() navigate_item(-1) end)
+mp.add_key_binding(nil, 'next-file', function() navigate_directory(1) end)
+mp.add_key_binding(nil, 'prev-file', function() navigate_directory(-1) end)
+mp.add_key_binding(nil, 'first', function()
+ if state.has_playlist then
+ mp.commandv('set', 'playlist-pos-1', '1')
+ else
+ load_file_index_in_current_directory(1)
+ end
+end)
+mp.add_key_binding(nil, '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)
+mp.add_key_binding(nil, 'first-file', function() load_file_index_in_current_directory(1) end)
+mp.add_key_binding(nil, 'last-file', function() load_file_index_in_current_directory(-1) end)
+mp.add_key_binding(nil, '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.media_types)
+ 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)
+mp.add_key_binding(nil, '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)
+mp.add_key_binding(nil, '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,
+}))
+mp.add_key_binding(nil, '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('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_id = submenu_id}) 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)
+
+--[[ 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()