diff options
author | Joe <rrbo@proton.me> | 2023-01-31 14:03:59 +0100 |
---|---|---|
committer | Joe <rrbo@proton.me> | 2023-01-31 14:03:59 +0100 |
commit | efa2957045fe9ea4421ea4a0c546f62a8a27fb58 (patch) | |
tree | 816b267a9d431e28744db41429358516ab0c461b /.config/mpv/scripts/uosc.lua | |
parent | up (diff) | |
download | dotfiles-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.lua | 1081 |
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() |