3655 lines
132 KiB
Lua
3655 lines
132 KiB
Lua
--[[
|
|
|
|
uosc 2.9.0 - 2020-May-11 | https://github.com/darsain/uosc
|
|
|
|
Minimalist cursor proximity based UI for MPV player.
|
|
|
|
uosc replaces the default osc UI, so that has to be disabled first.
|
|
Place these options into your `mpv.conf` file:
|
|
|
|
```
|
|
# required so that the 2 UIs don't fight each other
|
|
osc=no
|
|
# uosc provides its own seeking/volume indicators, so you also don't need this
|
|
osd-bar=no
|
|
# uosc will draw its own window controls if you disable window border
|
|
border=no
|
|
```
|
|
|
|
Options go in `script-opts/uosc.conf`. Defaults:
|
|
|
|
```
|
|
# timeline size when fully retracted, 0 will hide it completely
|
|
timeline_size_min=2
|
|
# timeline size when fully expanded, in pixels, 0 to disable
|
|
timeline_size_max=40
|
|
# same as ^ but when in fullscreen
|
|
timeline_size_min_fullscreen=0
|
|
timeline_size_max_fullscreen=60
|
|
# same thing as calling toggle-progress command once on startup
|
|
timeline_start_hidden=no
|
|
# timeline opacity
|
|
timeline_opacity=0.8
|
|
# top (and bottom in no-border mode) border of background color to help visually
|
|
# separate elapsed bar from a video of similar color or desktop background
|
|
timeline_border=1
|
|
# when scrolling above timeline, wheel will seek by this amount of seconds
|
|
timeline_step=5
|
|
# display seekable buffered ranges for streaming videos, syntax `color:opacity`,
|
|
# color is an BBGGRR hex code, set to `none` to disable
|
|
timeline_cached_ranges=345433:0.5
|
|
# floating number font scale adjustment
|
|
timeline_font_scale=1
|
|
# briefly show timeline on external changes (e.g. seeking with a hotkey)
|
|
timeline_flash=yes
|
|
|
|
# timeline chapters style: none, dots, lines, lines-top, lines-bottom
|
|
chapters=dots
|
|
chapters_opacity=0.3
|
|
|
|
# where to display volume controls: none, left, right
|
|
volume=right
|
|
volume_size=40
|
|
volume_size_fullscreen=60
|
|
volume_opacity=0.8
|
|
volume_border=1
|
|
volume_step=1
|
|
volume_font_scale=1
|
|
volume_flash=yes
|
|
|
|
# playback speed widget: mouse drag or wheel to change, click to reset
|
|
speed=no
|
|
speed_size=46
|
|
speed_size_fullscreen=68
|
|
speed_opacity=1
|
|
speed_step=0.1
|
|
speed_font_scale=1
|
|
speed_flash=yes
|
|
|
|
# controls all menus, such as context menu, subtitle loader/selector, etc
|
|
menu_item_height=36
|
|
menu_item_height_fullscreen=50
|
|
menu_wasd_navigation=no
|
|
menu_hjkl_navigation=no
|
|
menu_opacity=0.8
|
|
menu_font_scale=1
|
|
|
|
# top bar with window controls and media title shown only in no-border mode
|
|
top_bar_size=40
|
|
top_bar_size_fullscreen=46
|
|
top_bar_controls=yes
|
|
top_bar_title=yes
|
|
|
|
# pause video on clicks shorter than this number of milliseconds, 0 to disable
|
|
pause_on_click_shorter_than=0
|
|
# for how long in milliseconds to show elements they're it's being flashed
|
|
flash_duration=400
|
|
# distances in pixels below which elements are fully faded in/out
|
|
proximity_in=40
|
|
proximity_out=120
|
|
# BBGGRR - BLUE GREEN RED hex color codes
|
|
color_foreground=ffffff
|
|
color_foreground_text=000000
|
|
color_background=000000
|
|
color_background_text=ffffff
|
|
# use bold font weight throughout the whole UI
|
|
font_bold=no
|
|
# hide UI when mpv autohides the cursor
|
|
autohide=no
|
|
# can be: none, flash, static
|
|
pause_indicator=flash
|
|
# load first file when calling next on a last file in a directory and vice versa
|
|
directory_navigation_loops=no
|
|
# file types to look for when navigating media files
|
|
media_types=3gp,avi,bmp,flac,flv,gif,h264,h265,jpeg,jpg,m4a,m4v,mid,midi,mkv,mov,mp3,mp4,mp4a,mp4v,mpeg,mpg,oga,ogg,ogm,ogv,opus,png,rmvb,svg,tif,tiff,wav,weba,webm,webp,wma,wmv
|
|
# file types to look for when loading external subtitles
|
|
subtitle_types=aqt,gsub,jss,sub,ttxt,pjs,psb,rt,smi,slt,ssf,srt,ssa,ass,usf,idx,vt
|
|
# used to approximate text width
|
|
# if you are using some wide font and see a lot of right side clipping in menus,
|
|
# try bumping this up
|
|
font_height_to_letter_width_ratio=0.5
|
|
|
|
# `chapter_ranges` lets you transform chapter indicators into range indicators.
|
|
#
|
|
# Chapter range definition syntax:
|
|
# ```
|
|
# start_pattern<color:opacity>end_pattern
|
|
# ```
|
|
#
|
|
# Multiple start and end patterns can be defined by separating them with `|`:
|
|
# ```
|
|
# p1|pN<color:opacity>p1|pN
|
|
# ```
|
|
#
|
|
# Multiple chapter ranges can be defined by separating them with comma:
|
|
#
|
|
# chapter_ranges=range1,rangeN
|
|
#
|
|
# One of `start_pattern`s can be a custom keyword `{bof}` that will match
|
|
# beginning of file when it makes sense.
|
|
#
|
|
# One of `end_pattern`s can be a custom keyword `{eof}` that will match end of
|
|
# file when it makes sense.
|
|
#
|
|
# Patterns are lua patterns (http://lua-users.org/wiki/PatternsTutorial).
|
|
# They only need to occur in a title, not match it completely.
|
|
# Matching is case insensitive.
|
|
#
|
|
# `color` is a `bbggrr` hexadecimal color code.
|
|
# `opacity` is a float number from 0 to 1.
|
|
#
|
|
# Examples:
|
|
#
|
|
# Display skippable youtube video sponsor blocks from https://github.com/po5/mpv_sponsorblock
|
|
# ```
|
|
# chapter_ranges=sponsor start<3535a5:0.5>sponsor end
|
|
# ```
|
|
#
|
|
# Display anime openings and endings as ranges:
|
|
# ```
|
|
# chapter_ranges=^op| op$|opening<968638:0.5>.*, ^ed| ed$|^end|ending$<968638:0.5>.*|{eof}
|
|
# ```
|
|
chapter_ranges=^op| op$|opening<968638:0.5>.*, ^ed| ed$|^end|ending$<968638:0.5>.*|{eof}, sponsor start<3535a5:.5>sponsor end
|
|
```
|
|
|
|
Available keybindings (place into `input.conf`):
|
|
|
|
```
|
|
Key script-binding uosc/peek-timeline
|
|
Key script-binding uosc/toggle-progress
|
|
Key script-binding uosc/menu
|
|
Key script-binding uosc/load-subtitles
|
|
Key script-binding uosc/subtitles
|
|
Key script-binding uosc/audio
|
|
Key script-binding uosc/video
|
|
Key script-binding uosc/playlist
|
|
Key script-binding uosc/chapters
|
|
Key script-binding uosc/open-file
|
|
Key script-binding uosc/next
|
|
Key script-binding uosc/prev
|
|
Key script-binding uosc/first
|
|
Key script-binding uosc/last
|
|
Key script-binding uosc/next-file
|
|
Key script-binding uosc/prev-file
|
|
Key script-binding uosc/first-file
|
|
Key script-binding uosc/last-file
|
|
Key script-binding uosc/delete-file-next
|
|
Key script-binding uosc/delete-file-quit
|
|
Key script-binding uosc/show-in-directory
|
|
Key script-binding uosc/open-config-directory
|
|
```
|
|
]] if mp.get_property('osc') == 'yes' then
|
|
mp.msg.info('Disabled because original osc is enabled!')
|
|
return
|
|
end
|
|
|
|
local assdraw = require('mp.assdraw')
|
|
local opt = require('mp.options')
|
|
local utils = require('mp.utils')
|
|
local msg = require('mp.msg')
|
|
local osd = mp.create_osd_overlay('ass-events')
|
|
local infinity = 1e309
|
|
|
|
-- OPTIONS/CONFIG/STATE
|
|
local options = {
|
|
timeline_size_min = 2,
|
|
timeline_size_max = 40,
|
|
timeline_size_min_fullscreen = 0,
|
|
timeline_size_max_fullscreen = 60,
|
|
timeline_start_hidden = false,
|
|
timeline_opacity = 0.8,
|
|
timeline_border = 1,
|
|
timeline_step = 5,
|
|
timeline_cached_ranges = '345433:0.5',
|
|
timeline_font_scale = 1,
|
|
timeline_flash = true,
|
|
|
|
chapters = 'dots',
|
|
chapters_opacity = 0.3,
|
|
|
|
volume = 'right',
|
|
volume_size = 40,
|
|
volume_size_fullscreen = 60,
|
|
volume_opacity = 0.8,
|
|
volume_border = 1,
|
|
volume_step = 1,
|
|
volume_font_scale = 1,
|
|
volume_flash = true,
|
|
|
|
speed = false,
|
|
speed_size = 46,
|
|
speed_size_fullscreen = 68,
|
|
speed_opacity = 1,
|
|
speed_step = 0.1,
|
|
speed_font_scale = 1,
|
|
speed_flash = true,
|
|
|
|
menu_item_height = 36,
|
|
menu_item_height_fullscreen = 50,
|
|
menu_wasd_navigation = false,
|
|
menu_hjkl_navigation = false,
|
|
menu_opacity = 0.8,
|
|
menu_font_scale = 1,
|
|
|
|
top_bar_size = 40,
|
|
top_bar_size_fullscreen = 46,
|
|
top_bar_controls = true,
|
|
top_bar_title = true,
|
|
|
|
pause_on_click_shorter_than = 0,
|
|
flash_duration = 400,
|
|
proximity_in = 40,
|
|
proximity_out = 120,
|
|
color_foreground = 'ffffff',
|
|
color_foreground_text = '000000',
|
|
color_background = '000000',
|
|
color_background_text = 'ffffff',
|
|
font_bold = false,
|
|
autohide = false,
|
|
pause_indicator = 'flash',
|
|
directory_navigation_loops = false,
|
|
media_types = '3gp,avi,bmp,flac,flv,gif,h264,h265,jpeg,jpg,m4a,m4v,mid,midi,mkv,mov,mp3,mp4,mp4a,mp4v,mpeg,mpg,oga,ogg,ogm,ogv,opus,png,rmvb,svg,tif,tiff,wav,weba,webm,webp,wma,wmv',
|
|
subtitle_types = 'aqt,gsub,jss,sub,ttxt,pjs,psb,rt,smi,slt,ssf,srt,ssa,ass,usf,idx,vt',
|
|
font_height_to_letter_width_ratio = 0.5,
|
|
chapter_ranges = '^op| op$|opening<968638:0.5>.*, ^ed| ed$|^end|ending$<968638:0.5>.*|{eof}, sponsor start<3535a5:.5>sponsor end'
|
|
}
|
|
opt.read_options(options, 'uosc')
|
|
local config = {
|
|
render_delay = 0.03, -- sets max rendering frequency
|
|
font = mp.get_property('options/osd-font'),
|
|
menu_parent_opacity = 0.4,
|
|
menu_min_width = 260
|
|
}
|
|
local bold_tag = options.font_bold and '\\b1' or ''
|
|
local display = {width = 1280, height = 720, aspect = 1.77778}
|
|
local cursor = {
|
|
hidden = true, -- true when autohidden or outside of the player window
|
|
x = 0,
|
|
y = 0
|
|
}
|
|
local 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'),
|
|
media_title = '',
|
|
duration = nil,
|
|
position = nil,
|
|
pause = false,
|
|
chapters = nil,
|
|
chapter_ranges = nil,
|
|
fullscreen = mp.get_property_native('fullscreen'),
|
|
maximized = mp.get_property_native('window-maximized'),
|
|
render_timer = nil,
|
|
render_last_time = 0,
|
|
volume = nil,
|
|
volume_max = nil,
|
|
mute = nil,
|
|
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,
|
|
cached_ranges = nil
|
|
}
|
|
local forced_key_bindings -- defined at the bottom next to events
|
|
|
|
-- HELPERS
|
|
|
|
function round(number)
|
|
local modulus = number % 1
|
|
return modulus < 0.5 and math.floor(number) or math.ceil(number)
|
|
end
|
|
|
|
function call_me_maybe(fn, value1, value2, value3)
|
|
if fn then fn(value1, value2, value3) end
|
|
end
|
|
|
|
function split(str, pattern)
|
|
local list = {}
|
|
local full_pattern = '(.-)' .. pattern
|
|
local last_end = 1
|
|
local start_index, end_index, capture = str:find(full_pattern, 1)
|
|
while start_index do
|
|
list[#list + 1] = capture
|
|
last_end = end_index + 1
|
|
start_index, end_index, capture = str:find(full_pattern, last_end)
|
|
end
|
|
if last_end <= (#str + 1) then
|
|
capture = str:sub(last_end)
|
|
list[#list + 1] = capture
|
|
end
|
|
return list
|
|
end
|
|
|
|
function itable_find(haystack, needle)
|
|
local is_needle = type(needle) == 'function' and needle or
|
|
function(index, value) return value == needle end
|
|
for index, value in ipairs(haystack) do
|
|
if is_needle(index, value) then return index, value end
|
|
end
|
|
end
|
|
|
|
function itable_filter(haystack, needle)
|
|
local is_needle = type(needle) == 'function' and needle or
|
|
function(index, value) return value == needle end
|
|
local filtered = {}
|
|
for index, value in ipairs(haystack) do
|
|
if is_needle(index, value) then filtered[#filtered + 1] = value end
|
|
end
|
|
return filtered
|
|
end
|
|
|
|
function itable_remove(haystack, needle)
|
|
local should_remove = type(needle) == 'function' and needle or
|
|
function(value) return value == needle end
|
|
local new_table = {}
|
|
for _, value in ipairs(haystack) do
|
|
if not should_remove(value) then
|
|
new_table[#new_table + 1] = value
|
|
end
|
|
end
|
|
return new_table
|
|
end
|
|
|
|
function itable_slice(haystack, start_pos, end_pos)
|
|
start_pos = start_pos and start_pos or 1
|
|
end_pos = end_pos and end_pos or #haystack
|
|
|
|
if end_pos < 0 then end_pos = #haystack + end_pos + 1 end
|
|
if start_pos < 0 then start_pos = #haystack + start_pos + 1 end
|
|
|
|
local new_table = {}
|
|
for index, value in ipairs(haystack) do
|
|
if index >= start_pos and index <= end_pos then
|
|
new_table[#new_table + 1] = value
|
|
end
|
|
end
|
|
return new_table
|
|
end
|
|
|
|
function table_copy(table)
|
|
local new_table = {}
|
|
for key, value in pairs(table) do new_table[key] = value end
|
|
return new_table
|
|
end
|
|
|
|
-- Sorting comparator close to (but not exactly) how file explorers sort files
|
|
local word_order_comparator = (function()
|
|
local symbol_order
|
|
local default_order
|
|
|
|
if state.os == 'win' then
|
|
symbol_order = {
|
|
['!'] = 1,
|
|
['#'] = 2,
|
|
['$'] = 3,
|
|
['%'] = 4,
|
|
['&'] = 5,
|
|
['('] = 6,
|
|
[')'] = 6,
|
|
[','] = 7,
|
|
['.'] = 8,
|
|
["'"] = 9,
|
|
['-'] = 10,
|
|
[';'] = 11,
|
|
['@'] = 12,
|
|
['['] = 13,
|
|
[']'] = 13,
|
|
['^'] = 14,
|
|
['_'] = 15,
|
|
['`'] = 16,
|
|
['{'] = 17,
|
|
['}'] = 17,
|
|
['~'] = 18,
|
|
['+'] = 19,
|
|
['='] = 20
|
|
}
|
|
default_order = 21
|
|
else
|
|
symbol_order = {
|
|
['`'] = 1,
|
|
['^'] = 2,
|
|
['~'] = 3,
|
|
['='] = 4,
|
|
['_'] = 5,
|
|
['-'] = 6,
|
|
[','] = 7,
|
|
[';'] = 8,
|
|
['!'] = 9,
|
|
["'"] = 10,
|
|
['('] = 11,
|
|
[')'] = 11,
|
|
['['] = 12,
|
|
[']'] = 12,
|
|
['{'] = 13,
|
|
['}'] = 14,
|
|
['@'] = 15,
|
|
['$'] = 16,
|
|
['*'] = 17,
|
|
['&'] = 18,
|
|
['%'] = 19,
|
|
['+'] = 20,
|
|
['.'] = 22,
|
|
['#'] = 23
|
|
}
|
|
default_order = 21
|
|
end
|
|
|
|
return function(a, b)
|
|
a = a:lower()
|
|
b = b:lower()
|
|
for i = 1, math.max(#a, #b) do
|
|
local ai = a:sub(i, i)
|
|
local bi = b:sub(i, i)
|
|
if ai == nil and bi then return true end
|
|
if bi == nil and ai then return false end
|
|
local a_order = symbol_order[ai] or default_order
|
|
local b_order = symbol_order[bi] or default_order
|
|
if a_order == b_order then
|
|
return a < b
|
|
else
|
|
return a_order < b_order
|
|
end
|
|
end
|
|
end
|
|
end)()
|
|
|
|
-- Creates in-between frames to animate value from `from` to `to` numbers.
|
|
-- Returns function that terminates animation.
|
|
-- `to` can be a function that returns target value, useful for movable targets.
|
|
-- `speed` is an optional float between 1-instant and 0-infinite duration
|
|
-- `callback` is called either on animation end, or when animation is canceled
|
|
function tween(from, to, setter, speed, callback)
|
|
if type(speed) ~= 'number' then
|
|
callback = speed
|
|
speed = 0.3
|
|
end
|
|
local timeout
|
|
local getTo = type(to) == 'function' and to or function() return to end
|
|
local cutoff = math.abs(getTo() - from) * 0.01
|
|
function tick()
|
|
from = from + ((getTo() - from) * speed)
|
|
local is_end = math.abs(getTo() - from) <= cutoff
|
|
setter(is_end and getTo() or from)
|
|
request_render()
|
|
if is_end then
|
|
call_me_maybe(callback)
|
|
else
|
|
timeout:resume()
|
|
end
|
|
end
|
|
timeout = mp.add_timeout(0.016, tick)
|
|
tick()
|
|
return function()
|
|
timeout:kill()
|
|
call_me_maybe(callback)
|
|
end
|
|
end
|
|
|
|
-- Kills ongoing animation if one is already running on this element.
|
|
-- Killed animation will not get its `on_end` called.
|
|
function tween_element(element, from, to, setter, speed, callback)
|
|
if type(speed) ~= 'number' then
|
|
callback = speed
|
|
speed = 0.3
|
|
end
|
|
|
|
tween_element_stop(element)
|
|
|
|
element.stop_current_animation = tween(from, to, function(value)
|
|
setter(element, value)
|
|
end, speed, function()
|
|
element.stop_current_animation = nil
|
|
call_me_maybe(callback, element)
|
|
end)
|
|
end
|
|
|
|
-- Stopped animation will not get its on_end called.
|
|
function tween_element_is_tweening(element)
|
|
return element and element.stop_current_animation
|
|
end
|
|
|
|
-- Stopped animation will not get its on_end called.
|
|
function tween_element_stop(element)
|
|
call_me_maybe(element and element.stop_current_animation)
|
|
end
|
|
|
|
-- Helper to automatically use an element property setter
|
|
function tween_element_property(element, prop, from, to, speed, callback)
|
|
tween_element(element, from, to,
|
|
function(_, value) element[prop] = value end, speed, callback)
|
|
end
|
|
|
|
function get_point_to_rectangle_proximity(point, rect)
|
|
local dx = math.max(rect.ax - point.x, 0, point.x - rect.bx + 1)
|
|
local dy = math.max(rect.ay - point.y, 0, point.y - rect.by + 1)
|
|
return math.sqrt(dx * dx + dy * dy);
|
|
end
|
|
|
|
function text_width_estimate(letters, font_size)
|
|
return letters and letters * font_size *
|
|
options.font_height_to_letter_width_ratio or 0
|
|
end
|
|
|
|
function opacity_to_alpha(opacity) return 255 - math.ceil(255 * opacity) end
|
|
|
|
function ass_opacity(opacity, fraction)
|
|
fraction = fraction ~= nil and fraction or 1
|
|
if type(opacity) == 'number' then
|
|
return string.format('{\\alpha&H%X&}',
|
|
opacity_to_alpha(opacity * fraction))
|
|
else
|
|
return string.format('{\\1a&H%X&\\2a&H%X&\\3a&H%X&\\4a&H%X&}',
|
|
opacity_to_alpha((opacity[1] or 0) * fraction),
|
|
opacity_to_alpha((opacity[2] or 0) * fraction),
|
|
opacity_to_alpha((opacity[3] or 0) * fraction),
|
|
opacity_to_alpha((opacity[4] or 0) * fraction))
|
|
end
|
|
end
|
|
|
|
-- Ensures path is absolute and normalizes slashes to the current platform
|
|
function normalize_path(path)
|
|
if not path or is_protocol(path) then return path end
|
|
|
|
-- Ensure path is absolute
|
|
if not (path:match('^/') or path:match('^%a+:') or path:match('^\\\\')) then
|
|
path = utils.join_path(state.cwd, path)
|
|
end
|
|
|
|
-- Use proper slashes
|
|
if state.os == 'windows' then
|
|
return path:gsub('/', '\\')
|
|
else
|
|
return path:gsub('\\', '/')
|
|
end
|
|
end
|
|
|
|
-- Check if path is a protocol, such as `http://...`
|
|
function is_protocol(path) return path:match('^%a[%a%d-_]+://') end
|
|
|
|
function get_extension(path)
|
|
local parts = split(path, '%.')
|
|
return parts and #parts > 1 and parts[#parts] or nil
|
|
end
|
|
|
|
-- Serializes path into its semantic parts
|
|
function serialize_path(path)
|
|
if not path or is_protocol(path) then return end
|
|
path = normalize_path(path)
|
|
local parts = split(path, '[\\/]+')
|
|
if parts[#parts] == '' then table.remove(parts, #parts) end -- remove trailing separator
|
|
local basename = parts and parts[#parts] or path
|
|
local dirname = #parts > 1 and
|
|
table.concat(itable_slice(parts, 1, #parts - 1),
|
|
state.os == 'windows' and '\\' or '/') or
|
|
nil
|
|
local dot_split = split(basename, '%.')
|
|
return {
|
|
path = path:sub(-1) == ':' and state.os == 'windows' and path .. '\\' or
|
|
path,
|
|
is_root = dirname == nil,
|
|
dirname = dirname,
|
|
basename = basename,
|
|
filename = #dot_split > 1 and
|
|
table.concat(itable_slice(dot_split, 1, #dot_split - 1), '.') or
|
|
basename,
|
|
extension = #dot_split > 1 and dot_split[#dot_split] or nil
|
|
}
|
|
end
|
|
|
|
function get_files_in_directory(directory, allowed_types)
|
|
local files, error = utils.readdir(directory, 'files')
|
|
|
|
if not files then
|
|
msg.error('Retrieving files failed: ' .. (error or ''))
|
|
return
|
|
end
|
|
|
|
-- Filter only requested file types
|
|
if allowed_types then
|
|
files = itable_filter(files, function(_, file)
|
|
local extension = get_extension(file)
|
|
return extension and itable_find(allowed_types, extension:lower())
|
|
end)
|
|
end
|
|
|
|
table.sort(files, word_order_comparator)
|
|
|
|
return files
|
|
end
|
|
|
|
function get_adjacent_file(file_path, direction, allowed_types)
|
|
local current_file = serialize_path(file_path)
|
|
local files = get_files_in_directory(current_file.dirname, allowed_types)
|
|
|
|
if not files then return end
|
|
|
|
for index, file in ipairs(files) do
|
|
if current_file.basename == file then
|
|
if direction == 'forward' then
|
|
if files[index + 1] then
|
|
return utils.join_path(current_file.dirname,
|
|
files[index + 1])
|
|
end
|
|
if options.directory_navigation_loops and files[1] then
|
|
return utils.join_path(current_file.dirname, files[1])
|
|
end
|
|
else
|
|
if files[index - 1] then
|
|
return utils.join_path(current_file.dirname,
|
|
files[index - 1])
|
|
end
|
|
if options.directory_navigation_loops and files[#files] then
|
|
return utils.join_path(current_file.dirname, files[#files])
|
|
end
|
|
end
|
|
|
|
-- This is the only file in directory
|
|
return nil
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Ensures chapters are in chronological order
|
|
function get_normalized_chapters()
|
|
local chapters = mp.get_property_native('chapter-list')
|
|
|
|
if not chapters then return end
|
|
|
|
-- Copy table
|
|
chapters = itable_slice(chapters)
|
|
|
|
-- Ensure chronological order of chapters
|
|
table.sort(chapters, function(a, b) return a.time < b.time end)
|
|
|
|
return chapters
|
|
end
|
|
|
|
-- Element
|
|
--[[
|
|
Signature:
|
|
{
|
|
-- enables capturing button groups for this element
|
|
captures = {mouse_buttons = true, wheel = true},
|
|
-- element rectangle coordinates
|
|
ax = 0, ay = 0, bx = 0, by = 0,
|
|
-- cursor<>element relative proximity as a 0-1 floating number
|
|
-- where 0 = completely away, and 1 = touching/hovering
|
|
-- so it's easy to work with and throw into equations
|
|
proximity = 0,
|
|
-- raw cursor<>element proximity in pixels
|
|
proximity_raw = infinity,
|
|
-- called when element is created
|
|
?init = function(this),
|
|
-- called manually when disposing of element
|
|
?destroy = function(this),
|
|
-- triggered when event happens and cursor is above element
|
|
?on_{event_name} = function(this),
|
|
-- triggered when any event happens anywhere on a page
|
|
?on_global_{event_name} = function(this),
|
|
-- object
|
|
?render = function(this_element),
|
|
}
|
|
]]
|
|
local Element = {
|
|
captures = nil,
|
|
ax = 0,
|
|
ay = 0,
|
|
bx = 0,
|
|
by = 0,
|
|
proximity = 0,
|
|
proximity_raw = infinity
|
|
}
|
|
Element.__index = Element
|
|
|
|
function Element.new(props)
|
|
local element = setmetatable(props, Element)
|
|
element._eventListeners = {}
|
|
|
|
-- Flash timer
|
|
element._flash_out_timer = mp.add_timeout(options.flash_duration / 1000,
|
|
function()
|
|
local getTo = function() return element.proximity end
|
|
element:tween_property('forced_proximity', 1, getTo,
|
|
function() element.forced_proximity = nil end)
|
|
end)
|
|
element._flash_out_timer:kill()
|
|
|
|
element:init()
|
|
|
|
return element
|
|
end
|
|
|
|
function Element:init() end
|
|
function Element:destroy() end
|
|
|
|
-- Call method if it exists
|
|
function Element:maybe(name, ...)
|
|
if self[name] then return self[name](self, ...) end
|
|
end
|
|
|
|
-- Tween helpers
|
|
function Element:tween(...) tween_element(self, ...) end
|
|
function Element:tween_property(...) tween_element_property(self, ...) end
|
|
function Element:tween_stop() tween_element_stop(self) end
|
|
function Element:is_tweening() tween_element_is_tweening(self) end
|
|
|
|
-- Event listeners
|
|
function Element:on(name, handler)
|
|
if self._eventListeners[name] == nil then self._eventListeners[name] = {} end
|
|
local preexistingIndex = itable_find(self._eventListeners[name], handler)
|
|
if preexistingIndex then
|
|
return
|
|
else
|
|
self._eventListeners[name][#self._eventListeners[name] + 1] = handler
|
|
end
|
|
end
|
|
function Element:off(name, handler)
|
|
if self._eventListeners[name] == nil then return end
|
|
local index = itable_find(self._eventListeners, handler)
|
|
if index then table.remove(self._eventListeners, index) end
|
|
end
|
|
function Element:trigger(name, ...)
|
|
self:maybe('on_' .. name, ...)
|
|
if self._eventListeners[name] == nil then return end
|
|
for _, handler in ipairs(self._eventListeners[name]) do handler(...) end
|
|
end
|
|
|
|
-- Briefly flashes the element for `options.flash_duration` milliseconds.
|
|
-- Useful to visualize changes of volume and timeline when changed via hotkeys.
|
|
-- Implemented by briefly adding animated `forced_proximity` property to the element.
|
|
function Element:flash()
|
|
if options.flash_duration > 0 and
|
|
(self.proximity < 1 or self._flash_out_timer:is_enabled()) then
|
|
self:tween_stop()
|
|
self.forced_proximity = 1
|
|
self._flash_out_timer:kill()
|
|
self._flash_out_timer:resume()
|
|
end
|
|
end
|
|
|
|
-- ELEMENTS
|
|
|
|
local Elements = {itable = {}}
|
|
Elements.__index = Elements
|
|
local elements = setmetatable({}, Elements)
|
|
|
|
function Elements:add(name, element)
|
|
local insert_index = #Elements.itable + 1
|
|
|
|
-- Replace if element already exists
|
|
if self:has(name) then
|
|
insert_index = itable_find(Elements.itable, function(_, element)
|
|
return element.name == name
|
|
end)
|
|
end
|
|
|
|
element.name = name
|
|
Elements.itable[insert_index] = element
|
|
self[name] = element
|
|
|
|
request_render()
|
|
end
|
|
|
|
function Elements:remove(name, props)
|
|
Elements.itable = itable_remove(Elements.itable, self[name])
|
|
self[name] = nil
|
|
request_render()
|
|
end
|
|
|
|
function Elements:has(name) return self[name] ~= nil end
|
|
function Elements:ipairs() return ipairs(Elements.itable) end
|
|
function Elements:pairs(elements) return pairs(self) end
|
|
|
|
-- MENU
|
|
--[[
|
|
Usage:
|
|
```
|
|
local items = {
|
|
{title = 'Foo title', hint = 'Ctrl+F', value = 'foo'},
|
|
{title = 'Bar title', hint = 'Ctrl+B', value = 'bar'},
|
|
{
|
|
title = 'Submenu',
|
|
items = {
|
|
{title = 'Sub item 1', value = 'sub1'},
|
|
{title = 'Sub item 2', value = 'sub2'}
|
|
}
|
|
}
|
|
}
|
|
|
|
function open_item(value)
|
|
value -- value from `item.value`
|
|
end
|
|
|
|
menu:open(items, open_item)
|
|
```
|
|
]]
|
|
local Menu = {}
|
|
Menu.__index = Menu
|
|
local menu = setmetatable({key_bindings = {}, is_closing = false}, Menu)
|
|
|
|
function Menu:is_open(menu_type)
|
|
return elements.menu ~= nil and
|
|
(not menu_type or elements.menu.type == menu_type)
|
|
end
|
|
|
|
function Menu:open(items, open_item, opts)
|
|
opts = opts or {}
|
|
|
|
if menu:is_open() then
|
|
if not opts.parent_menu then
|
|
menu:close(true, function()
|
|
menu:open(items, open_item, opts)
|
|
end)
|
|
return
|
|
end
|
|
else
|
|
menu:enable_key_bindings()
|
|
elements.curtain:fadein()
|
|
end
|
|
|
|
elements:add('menu', Element.new({
|
|
captures = {mouse_buttons = true},
|
|
type = nil, -- menu type such as `menu`, `chapters`, ...
|
|
title = nil,
|
|
width = nil,
|
|
height = nil,
|
|
offset_x = 0, -- used to animated from/to left when submenu
|
|
item_height = nil,
|
|
item_spacing = 1,
|
|
item_content_spacing = nil,
|
|
font_size = nil,
|
|
scroll_step = nil,
|
|
scroll_height = nil,
|
|
scroll_y = 0,
|
|
opacity = 0,
|
|
relative_parent_opacity = 0.4,
|
|
items = items,
|
|
active_item = nil,
|
|
selected_item = nil,
|
|
open_item = open_item,
|
|
parent_menu = nil,
|
|
init = function(this)
|
|
-- Already initialized
|
|
if this.width ~= nil then return end
|
|
|
|
-- Apply options
|
|
for key, value in pairs(opts) do this[key] = value end
|
|
this.selected_item = this.active_item
|
|
|
|
-- Set initial dimensions
|
|
this:on_display_resize()
|
|
|
|
-- Scroll to active item
|
|
this:scroll_to_item(this.active_item)
|
|
|
|
-- Transition in animation
|
|
menu.transition = {to = 'child', target = this}
|
|
local start_offset = this.parent_menu and
|
|
(this.parent_menu.width + this.width) / 2 or
|
|
0
|
|
|
|
tween_element(menu.transition.target, 0, 1, function(_, pos)
|
|
this:set_offset_x(round(start_offset * (1 - pos)))
|
|
this.opacity = pos
|
|
this:set_parent_opacity(1 -
|
|
((1 - config.menu_parent_opacity) *
|
|
pos))
|
|
end, function()
|
|
menu.transition = nil
|
|
update_proximities()
|
|
end)
|
|
end,
|
|
destroy = function(this) request_render() end,
|
|
on_display_resize = function(this)
|
|
this.item_height = (state.fullscreen or state.maximized) and
|
|
options.menu_item_height_fullscreen or
|
|
options.menu_item_height
|
|
this.font_size = round(this.item_height * 0.48 *
|
|
options.menu_font_scale)
|
|
this.item_content_spacing = round(
|
|
(this.item_height - this.font_size) *
|
|
0.6)
|
|
this.scroll_step = this.item_height + this.item_spacing
|
|
|
|
-- Estimate width of a widest item
|
|
local estimated_max_width = 0
|
|
for _, item in ipairs(items) do
|
|
local item_text_length =
|
|
((item.title and item.title:len() or 0) +
|
|
(item.hint and item.hint:len() or 0))
|
|
local spacings_in_item = item.hint and 3 or 2
|
|
local estimated_width = text_width_estimate(item_text_length,
|
|
this.font_size) +
|
|
(this.item_content_spacing *
|
|
spacings_in_item)
|
|
if estimated_width > estimated_max_width then
|
|
estimated_max_width = estimated_width
|
|
end
|
|
end
|
|
|
|
-- Also check menu title
|
|
local menu_title_length = this.title and this.title:len() or 0
|
|
local estimated_menu_title_width =
|
|
text_width_estimate(menu_title_length, this.font_size)
|
|
if estimated_menu_title_width > estimated_max_width then
|
|
estimated_max_width = estimated_menu_title_width
|
|
end
|
|
|
|
-- Coordinates and sizes are of the scrollable area to make
|
|
-- consuming values in rendering easier. Title drawn above this, so
|
|
-- we need to account for that in max_height and ay position.
|
|
this.width = round(math.min(math.max(estimated_max_width,
|
|
config.menu_min_width),
|
|
display.width * 0.9))
|
|
local title_height = this.title and this.scroll_step or 0
|
|
local max_height = round(display.height * 0.9) - title_height
|
|
this.height = math.min(round(this.scroll_step * #items) -
|
|
this.item_spacing, max_height)
|
|
this.scroll_height = math.max(
|
|
(this.scroll_step * #this.items) -
|
|
this.height - this.item_spacing, 0)
|
|
this.ax = round((display.width - this.width) / 2) + this.offset_x
|
|
this.ay = round((display.height - this.height) / 2 +
|
|
(title_height / 2))
|
|
this.bx = round(this.ax + this.width)
|
|
this.by = round(this.ay + this.height)
|
|
|
|
if this.parent_menu then
|
|
this.parent_menu:on_display_resize()
|
|
end
|
|
end,
|
|
set_items = function(this, items, props)
|
|
this.items = items
|
|
this.selected_item = nil
|
|
this.active_item = nil
|
|
if props then
|
|
for key, value in pairs(props) do
|
|
this[key] = value
|
|
end
|
|
end
|
|
this:on_display_resize()
|
|
request_render()
|
|
end,
|
|
set_offset_x = function(this, offset)
|
|
local delta = offset - this.offset_x
|
|
this.offset_x = offset
|
|
this.ax = this.ax + delta
|
|
this.bx = this.bx + delta
|
|
if this.parent_menu then
|
|
this.parent_menu:set_offset_x(
|
|
offset - ((this.width + this.parent_menu.width) / 2) -
|
|
this.item_spacing)
|
|
else
|
|
update_proximities()
|
|
end
|
|
end,
|
|
fadeout = function(this, callback)
|
|
this:tween(1, 0, function(this, pos)
|
|
this.opacity = pos
|
|
this:set_parent_opacity(pos * config.menu_parent_opacity)
|
|
end, callback)
|
|
end,
|
|
set_parent_opacity = function(this, opacity)
|
|
if this.parent_menu then
|
|
this.parent_menu.opacity = opacity
|
|
this.parent_menu:set_parent_opacity(
|
|
opacity * config.menu_parent_opacity)
|
|
end
|
|
end,
|
|
get_item_index_below_cursor = function(this)
|
|
return math.ceil((cursor.y - this.ay + this.scroll_y) /
|
|
this.scroll_step)
|
|
end,
|
|
get_first_visible_index = function(this)
|
|
return round(this.scroll_y / this.scroll_step) + 1
|
|
end,
|
|
get_last_visible_index = function(this)
|
|
return round((this.scroll_y + this.height) / this.scroll_step)
|
|
end,
|
|
get_centermost_visible_index = function(this)
|
|
return round((this.scroll_y + (this.height / 2)) / this.scroll_step)
|
|
end,
|
|
scroll_to = function(this, pos)
|
|
this.scroll_y = math.max(math.min(pos, this.scroll_height), 0)
|
|
request_render()
|
|
end,
|
|
scroll_to_item = function(this, index)
|
|
if (index and index >= 1 and index <= #this.items) then
|
|
this:scroll_to(round((this.scroll_step * (index - 1)) -
|
|
((this.height - this.scroll_step) / 2)))
|
|
end
|
|
end,
|
|
select_index = function(this, index)
|
|
this.selected_item =
|
|
(index and index >= 1 and index <= #this.items) and index or nil
|
|
request_render()
|
|
end,
|
|
select_value = function(this, value)
|
|
this:select_index(itable_find(this.items, function(_, item)
|
|
return item.value == value
|
|
end))
|
|
end,
|
|
activate_index = function(this, index)
|
|
this.active_item =
|
|
(index and index >= 1 and index <= #this.items) and index or nil
|
|
request_render()
|
|
end,
|
|
activate_value = function(this, value)
|
|
this:activate_index(itable_find(this.items, function(_, item)
|
|
return item.value == value
|
|
end))
|
|
end,
|
|
delete_index = function(this, index)
|
|
if (index and index >= 1 and index <= #this.items) then
|
|
local previous_active_value =
|
|
this.active_index and this.items[this.active_index].value or
|
|
nil
|
|
table.remove(this.items, index)
|
|
this:on_display_resize()
|
|
if previous_active_value then
|
|
this:activate_value(previous_active_value)
|
|
end
|
|
this:scroll_to_item(this.selected_item)
|
|
end
|
|
end,
|
|
delete_value = function(this, value)
|
|
this:delete_index(itable_find(this.items, function(_, item)
|
|
return item.value == value
|
|
end))
|
|
end,
|
|
prev = function(this)
|
|
local default_anchor = this.scroll_height > this.scroll_step and
|
|
this:get_centermost_visible_index() or
|
|
this:get_last_visible_index()
|
|
local current_index = this.selected_item or default_anchor + 1
|
|
this.selected_item = math.max(current_index - 1, 1)
|
|
this:scroll_to_item(this.selected_item)
|
|
end,
|
|
next = function(this)
|
|
local default_anchor = this.scroll_height > this.scroll_step and
|
|
this:get_centermost_visible_index() or
|
|
this:get_first_visible_index()
|
|
local current_index = this.selected_item or default_anchor - 1
|
|
this.selected_item = math.min(current_index + 1, #this.items)
|
|
this:scroll_to_item(this.selected_item)
|
|
end,
|
|
back = function(this)
|
|
if menu.transition then
|
|
local transition_target = menu.transition.target
|
|
local transition_target_type = menu.transition.target
|
|
tween_element_stop(transition_target)
|
|
if transition_target_type == 'parent' then
|
|
elements:add('menu', transition_target)
|
|
end
|
|
menu.transition = nil
|
|
transition_target:back()
|
|
return
|
|
else
|
|
menu.transition = {to = 'parent', target = this.parent_menu}
|
|
end
|
|
|
|
if menu.transition.target == nil then
|
|
menu:close()
|
|
return
|
|
end
|
|
|
|
local target = menu.transition.target
|
|
local to_offset = -target.offset_x + this.offset_x
|
|
|
|
tween_element(target, 0, 1, function(_, pos)
|
|
this:set_offset_x(round(to_offset * pos))
|
|
this.opacity = 1 - pos
|
|
this:set_parent_opacity(config.menu_parent_opacity +
|
|
((1 - config.menu_parent_opacity) *
|
|
pos))
|
|
end, function()
|
|
menu.transition = nil
|
|
elements:add('menu', target)
|
|
update_proximities()
|
|
end)
|
|
end,
|
|
open_selected_item = function(this)
|
|
-- If there is a transition active and this method got called, it
|
|
-- means we are animating from this menu to parent menu, and all
|
|
-- calls to this method should be relayed to the parent menu.
|
|
if menu.transition and menu.transition.to == 'parent' then
|
|
local target = menu.transition.target
|
|
tween_element_stop(target)
|
|
menu.transition = nil
|
|
target:open_selected_item()
|
|
return
|
|
end
|
|
|
|
if this.selected_item then
|
|
local item = this.items[this.selected_item]
|
|
-- Is submenu
|
|
if item.items then
|
|
local opts = table_copy(opts)
|
|
opts.parent_menu = this
|
|
menu:open(item.items, this.open_item, opts)
|
|
else
|
|
menu:close(true)
|
|
this.open_item(item.value)
|
|
end
|
|
end
|
|
end,
|
|
close = function(this) menu:close() end,
|
|
on_global_mbtn_left_down = function(this)
|
|
if this.proximity_raw == 0 then
|
|
this.selected_item = this:get_item_index_below_cursor()
|
|
this:open_selected_item()
|
|
else
|
|
-- check if this is clicking on any parent menus
|
|
local parent_menu = this.parent_menu
|
|
repeat
|
|
if parent_menu then
|
|
if get_point_to_rectangle_proximity(cursor, parent_menu) ==
|
|
0 then
|
|
this:back()
|
|
return
|
|
end
|
|
parent_menu = parent_menu.parent_menu
|
|
end
|
|
until parent_menu == nil
|
|
|
|
menu:close()
|
|
end
|
|
end,
|
|
on_global_mouse_move = function(this)
|
|
if this.proximity_raw == 0 then
|
|
this.selected_item = this:get_item_index_below_cursor()
|
|
else
|
|
if this.selected_item then
|
|
this.selected_item = nil
|
|
end
|
|
end
|
|
request_render()
|
|
end,
|
|
on_wheel_up = function(this)
|
|
this.selected_item = nil
|
|
this:scroll_to(this.scroll_y - this.scroll_step)
|
|
-- Selects item below cursor
|
|
this:on_global_mouse_move()
|
|
request_render()
|
|
end,
|
|
on_wheel_down = function(this)
|
|
this.selected_item = nil
|
|
this:scroll_to(this.scroll_y + this.scroll_step)
|
|
-- Selects item below cursor
|
|
this:on_global_mouse_move()
|
|
request_render()
|
|
end,
|
|
on_pgup = function(this)
|
|
this.selected_item = nil
|
|
this:scroll_to(this.scroll_y - this.height)
|
|
end,
|
|
on_pgdwn = function(this)
|
|
this.selected_item = nil
|
|
this:scroll_to(this.scroll_y + this.height)
|
|
end,
|
|
on_home = function(this)
|
|
this.selected_item = nil
|
|
this:scroll_to(0)
|
|
end,
|
|
on_end = function(this)
|
|
this.selected_item = nil
|
|
this:scroll_to(this.scroll_height)
|
|
end,
|
|
render = render_menu
|
|
}))
|
|
|
|
elements.menu:maybe('on_open')
|
|
end
|
|
|
|
function Menu:add_key_binding(key, name, fn, flags)
|
|
menu.key_bindings[#menu.key_bindings + 1] = name
|
|
mp.add_forced_key_binding(key, name, fn, flags)
|
|
end
|
|
|
|
function Menu:enable_key_bindings()
|
|
menu.key_bindings = {}
|
|
-- The `mp.set_key_bindings()` method would be easier here, but that
|
|
-- doesn't support 'repeatable' flag, so we are stuck with this monster.
|
|
menu:add_key_binding('up', 'menu-prev', self:create_action('prev'),
|
|
'repeatable')
|
|
menu:add_key_binding('down', 'menu-next', self:create_action('next'),
|
|
'repeatable')
|
|
menu:add_key_binding('left', 'menu-back', self:create_action('back'))
|
|
menu:add_key_binding('right', 'menu-select',
|
|
self:create_action('open_selected_item'))
|
|
|
|
if options.menu_wasd_navigation then
|
|
menu:add_key_binding('w', 'menu-prev-alt', self:create_action('prev'),
|
|
'repeatable')
|
|
menu:add_key_binding('a', 'menu-back-alt', self:create_action('back'))
|
|
menu:add_key_binding('s', 'menu-next-alt', self:create_action('next'),
|
|
'repeatable')
|
|
menu:add_key_binding('d', 'menu-select-alt',
|
|
self:create_action('open_selected_item'))
|
|
end
|
|
|
|
if options.menu_hjkl_navigation then
|
|
menu:add_key_binding('h', 'menu-back-alt2', self:create_action('back'))
|
|
menu:add_key_binding('j', 'menu-next-alt2', self:create_action('next'),
|
|
'repeatable')
|
|
menu:add_key_binding('k', 'menu-prev-alt2', self:create_action('prev'),
|
|
'repeatable')
|
|
menu:add_key_binding('l', 'menu-select-alt2',
|
|
self:create_action('open_selected_item'))
|
|
end
|
|
|
|
menu:add_key_binding('mbtn_back', 'menu-back-alt3',
|
|
self:create_action('back'))
|
|
menu:add_key_binding('bs', 'menu-back-alt4', self:create_action('back'))
|
|
menu:add_key_binding('enter', 'menu-select-alt3',
|
|
self:create_action('open_selected_item'))
|
|
menu:add_key_binding('kp_enter', 'menu-select-alt4',
|
|
self:create_action('open_selected_item'))
|
|
menu:add_key_binding('esc', 'menu-close', self:create_action('close'))
|
|
menu:add_key_binding('pgup', 'menu-page-up', self:create_action('on_pgup'))
|
|
menu:add_key_binding('pgdwn', 'menu-page-down',
|
|
self:create_action('on_pgdwn'))
|
|
menu:add_key_binding('home', 'menu-home', self:create_action('on_home'))
|
|
menu:add_key_binding('end', 'menu-end', self:create_action('on_end'))
|
|
end
|
|
|
|
function Menu:disable_key_bindings()
|
|
for _, name in ipairs(menu.key_bindings) do mp.remove_key_binding(name) end
|
|
menu.key_bindings = {}
|
|
end
|
|
|
|
function Menu:create_action(name)
|
|
return function(...)
|
|
if elements.menu then elements.menu:maybe(name, ...) end
|
|
end
|
|
end
|
|
|
|
function Menu:close(immediate, callback)
|
|
if type(immediate) ~= 'boolean' then callback = immediate end
|
|
|
|
if elements:has('menu') and not menu.is_closing then
|
|
function close()
|
|
elements.menu:maybe('on_close')
|
|
elements.menu:destroy()
|
|
elements:remove('menu')
|
|
menu.is_closing = false
|
|
update_proximities()
|
|
menu:disable_key_bindings()
|
|
call_me_maybe(callback)
|
|
end
|
|
|
|
menu.is_closing = true
|
|
elements.curtain:fadeout()
|
|
|
|
if immediate then
|
|
close()
|
|
else
|
|
elements.menu:fadeout(close)
|
|
end
|
|
end
|
|
end
|
|
|
|
-- ICONS
|
|
--[[
|
|
ASS \shadN shadows are drawn also below the element, which when there is an
|
|
opacity in play, blends icon colors into ugly greys. The mess below is an
|
|
attempt to fix it by rendering shadows for icons with clipping.
|
|
|
|
Add icons by adding functions to render them to `icons` table.
|
|
|
|
Signature: function(pos_x, pos_y, size) => string
|
|
|
|
Function has to return ass path coordinates to draw the icon centered at pox_x
|
|
and pos_y of passed size.
|
|
]]
|
|
local icons = {}
|
|
function icon(name, icon_x, icon_y, icon_size, shad_x, shad_y, shad_size,
|
|
backdrop, opacity, clip)
|
|
local ass = assdraw.ass_new()
|
|
local icon_path = icons[name](icon_x, icon_y, icon_size)
|
|
local icon_color = options['color_' .. backdrop .. '_text']
|
|
local shad_color = options['color_' .. backdrop]
|
|
local use_border = (shad_x + shad_y) == 0
|
|
local icon_border = use_border and shad_size or 0
|
|
|
|
-- clip can't clip out shadows, a very annoying limitation I can't work
|
|
-- around without going back to ugly default ass shadows, but atm I actually
|
|
-- don't need clipping of icons with shadows, so I'm choosing to ignore this
|
|
if not clip then clip = '' end
|
|
|
|
if not use_border then
|
|
ass:new_event()
|
|
ass:append('{\\blur0\\bord0\\shad0\\1c&H' .. shad_color .. '\\iclip(' ..
|
|
ass.scale .. ', ' .. icon_path .. ')}')
|
|
ass:append(ass_opacity(opacity))
|
|
ass:pos(shad_x + shad_size, shad_y + shad_size)
|
|
ass:draw_start()
|
|
ass:append(icon_path)
|
|
ass:draw_stop()
|
|
end
|
|
|
|
ass:new_event()
|
|
ass:append(
|
|
'{\\blur0\\bord' .. icon_border .. '\\shad0\\1c&H' .. icon_color ..
|
|
'\\3c&H' .. shad_color .. clip .. '}')
|
|
ass:append(ass_opacity(opacity))
|
|
ass:pos(0, 0)
|
|
ass:draw_start()
|
|
ass:append(icon_path)
|
|
ass:draw_stop()
|
|
|
|
return ass.text
|
|
end
|
|
|
|
function icons._volume(muted, pos_x, pos_y, size)
|
|
local ass = assdraw.ass_new()
|
|
local scale = size / 200
|
|
function x(number) return pos_x + (number * scale) end
|
|
function y(number) return pos_y + (number * scale) end
|
|
ass:move_to(x(-85), y(-35))
|
|
ass:line_to(x(-50), y(-35))
|
|
ass:line_to(x(-5), y(-75))
|
|
ass:line_to(x(-5), y(75))
|
|
ass:line_to(x(-50), y(35))
|
|
ass:line_to(x(-85), y(35))
|
|
if muted then
|
|
ass:move_to(x(76), y(-35))
|
|
ass:line_to(x(50), y(-9))
|
|
ass:line_to(x(24), y(-35))
|
|
ass:line_to(x(15), y(-26))
|
|
ass:line_to(x(41), y(0))
|
|
ass:line_to(x(15), y(26))
|
|
ass:line_to(x(24), y(35))
|
|
ass:line_to(x(50), y(9))
|
|
ass:line_to(x(76), y(35))
|
|
ass:line_to(x(85), y(26))
|
|
ass:line_to(x(59), y(0))
|
|
ass:line_to(x(85), y(-26))
|
|
else
|
|
ass:move_to(x(20), y(-30))
|
|
ass:line_to(x(20), y(30))
|
|
ass:line_to(x(35), y(30))
|
|
ass:line_to(x(35), y(-30))
|
|
|
|
ass:move_to(x(55), y(-60))
|
|
ass:line_to(x(55), y(60))
|
|
ass:line_to(x(70), y(60))
|
|
ass:line_to(x(70), y(-60))
|
|
end
|
|
return ass.text
|
|
end
|
|
function icons.volume(pos_x, pos_y, size)
|
|
return icons._volume(false, pos_x, pos_y, size)
|
|
end
|
|
function icons.volume_muted(pos_x, pos_y, size)
|
|
return icons._volume(true, pos_x, pos_y, size)
|
|
end
|
|
|
|
function icons.arrow_right(pos_x, pos_y, size)
|
|
local ass = assdraw.ass_new()
|
|
local scale = size / 200
|
|
function x(number) return pos_x + (number * scale) end
|
|
function y(number) return pos_y + (number * scale) end
|
|
ass:move_to(x(-22), y(-80))
|
|
ass:line_to(x(-45), y(-57))
|
|
ass:line_to(x(12), y(0))
|
|
ass:line_to(x(-45), y(57))
|
|
ass:line_to(x(-22), y(80))
|
|
ass:line_to(x(58), y(0))
|
|
return ass.text
|
|
end
|
|
|
|
-- STATE UPDATES
|
|
|
|
function update_display_dimensions()
|
|
local o = mp.get_property_native('osd-dimensions')
|
|
display.width = o.w
|
|
display.height = o.h
|
|
display.aspect = o.aspect
|
|
|
|
-- Tell elements about this
|
|
for _, element in elements:ipairs() do
|
|
if element.on_display_resize ~= nil then
|
|
element.on_display_resize(element)
|
|
end
|
|
end
|
|
end
|
|
|
|
function update_element_cursor_proximity(element)
|
|
if cursor.hidden then
|
|
element.proximity_raw = infinity
|
|
element.proximity = 0
|
|
else
|
|
local range = options.proximity_out - options.proximity_in
|
|
element.proximity_raw =
|
|
get_point_to_rectangle_proximity(cursor, element)
|
|
element.proximity = menu:is_open() and 0 or 1 -
|
|
(math.min(
|
|
math.max(
|
|
element.proximity_raw -
|
|
options.proximity_in, 0), range) /
|
|
range)
|
|
end
|
|
end
|
|
|
|
function update_proximities()
|
|
local capture_mouse_buttons = false
|
|
local capture_wheel = false
|
|
local menu_only = menu:is_open()
|
|
local mouse_left_elements = {}
|
|
local mouse_entered_elements = {}
|
|
|
|
-- Calculates proximities and opacities for defined elements
|
|
for _, element in elements:ipairs() do
|
|
local previous_proximity_raw = element.proximity_raw
|
|
|
|
-- If menu is open, all other elements have to be disabled
|
|
if menu_only then
|
|
if element.name == 'menu' then
|
|
capture_mouse_buttons = true
|
|
capture_wheel = true
|
|
update_element_cursor_proximity(element)
|
|
else
|
|
element.proximity_raw = infinity
|
|
element.proximity = 0
|
|
end
|
|
else
|
|
update_element_cursor_proximity(element)
|
|
end
|
|
|
|
if element.proximity_raw == 0 then
|
|
-- Mouse is over element
|
|
if element.captures and element.captures.mouse_buttons then
|
|
capture_mouse_buttons = true
|
|
end
|
|
if element.captures and element.captures.wheel then
|
|
capture_wheel = true
|
|
end
|
|
|
|
-- Mouse entered element area
|
|
if previous_proximity_raw ~= 0 then
|
|
mouse_entered_elements[#mouse_entered_elements + 1] = element
|
|
end
|
|
else
|
|
-- Mouse left element area
|
|
if previous_proximity_raw == 0 then
|
|
mouse_left_elements[#mouse_left_elements + 1] = element
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Enable key group captures elements request.
|
|
if capture_mouse_buttons then
|
|
forced_key_bindings.mouse_buttons:enable()
|
|
else
|
|
forced_key_bindings.mouse_buttons:disable()
|
|
end
|
|
if capture_wheel then
|
|
forced_key_bindings.wheel:enable()
|
|
else
|
|
forced_key_bindings.wheel:disable()
|
|
end
|
|
|
|
-- Trigger `mouse_leave` and `mouse_enter` events
|
|
for _, element in ipairs(mouse_left_elements) do
|
|
element:trigger('mouse_leave')
|
|
end
|
|
for _, element in ipairs(mouse_entered_elements) do
|
|
element:trigger('mouse_enter')
|
|
end
|
|
end
|
|
|
|
-- ELEMENT RENDERERS
|
|
|
|
function render_timeline(this)
|
|
if this.size_max == 0 or state.duration == nil or state.position == nil then
|
|
return
|
|
end
|
|
|
|
local size_min = this:get_effective_size_min()
|
|
local size = this:get_effective_size()
|
|
|
|
if size < 1 then return end
|
|
|
|
local ass = assdraw.ass_new()
|
|
|
|
-- Text opacity rapidly drops to 0 just before it starts overflowing, or before it reaches timeline.size_min
|
|
local hide_text_below = math.max(this.font_size * 0.7, size_min * 2)
|
|
local hide_text_ramp = hide_text_below / 2
|
|
local text_opacity = math.max(math.min(size - hide_text_below,
|
|
hide_text_ramp), 0) / hide_text_ramp
|
|
|
|
local spacing = math.max(math.floor((this.size_max - this.font_size) / 2.5),
|
|
4)
|
|
local progress = state.position / state.duration
|
|
|
|
-- Background bar coordinates
|
|
local bax = 0
|
|
local bay = display.height - size - this.bottom_border - this.top_border
|
|
local bbx = display.width
|
|
local bby = display.height
|
|
|
|
-- Foreground bar coordinates
|
|
local fax = bax
|
|
local fay = bay + this.top_border
|
|
local fbx = bbx * progress
|
|
local fby = bby - this.bottom_border
|
|
local foreground_size = bby - bay
|
|
local foreground_coordinates = fax .. ',' .. fay .. ',' .. fbx .. ',' .. fby -- for clipping
|
|
|
|
-- Background
|
|
ass:new_event()
|
|
ass:append(
|
|
'{\\blur0\\bord0\\1c&H' .. options.color_background .. '\\iclip(' ..
|
|
foreground_coordinates .. ')}')
|
|
ass:append(ass_opacity(math.max(options.timeline_opacity - 0.1, 0)))
|
|
ass:pos(0, 0)
|
|
ass:draw_start()
|
|
ass:rect_cw(bax, bay, bbx, bby)
|
|
ass:draw_stop()
|
|
|
|
-- Foreground
|
|
ass:new_event()
|
|
ass:append('{\\blur0\\bord0\\1c&H' .. options.color_foreground .. '}')
|
|
ass:append(ass_opacity(options.timeline_opacity))
|
|
ass:pos(0, 0)
|
|
ass:draw_start()
|
|
ass:rect_cw(fax, fay, fbx, fby)
|
|
ass:draw_stop()
|
|
|
|
-- Seekable ranges
|
|
if options.timeline_cached_ranges and state.cached_ranges then
|
|
local range_height = math.max(foreground_size / 8, size_min)
|
|
local range_ay = fby - range_height
|
|
for _, range in ipairs(state.cached_ranges) do
|
|
ass:new_event()
|
|
ass:append('{\\blur0\\bord0\\1c&H' ..
|
|
options.timeline_cached_ranges.color .. '}')
|
|
ass:append(ass_opacity(options.timeline_cached_ranges.opacity))
|
|
ass:pos(0, 0)
|
|
ass:draw_start()
|
|
ass:rect_cw(bbx * (range['start'] / state.duration), range_ay,
|
|
bbx * (range['end'] / state.duration),
|
|
range_ay + range_height)
|
|
ass:draw_stop()
|
|
end
|
|
end
|
|
|
|
-- Custom ranges
|
|
if state.chapter_ranges ~= nil then
|
|
for i, chapter_range in ipairs(state.chapter_ranges) do
|
|
for i, range in ipairs(chapter_range.ranges) do
|
|
local rax = display.width *
|
|
(range['start'].time / state.duration)
|
|
local rbx = display.width * (range['end'].time / state.duration)
|
|
ass:new_event()
|
|
ass:append('{\\blur0\\bord0\\1c&H' .. chapter_range.color .. '}')
|
|
ass:append(ass_opacity(chapter_range.opacity))
|
|
ass:pos(0, 0)
|
|
ass:draw_start()
|
|
-- for 1px chapter size, use the whole size of the bar including padding
|
|
if size <= 1 then
|
|
ass:rect_cw(rax, bay, rbx, bby)
|
|
else
|
|
ass:rect_cw(rax, fay, rbx, fby)
|
|
end
|
|
ass:draw_stop()
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Chapters
|
|
if options.chapters ~= 'none' and state.chapters ~= nil and #state.chapters >
|
|
0 then
|
|
local half_size = size / 2
|
|
local dots = false
|
|
local chapter_size, chapter_y
|
|
if options.chapters == 'dots' then
|
|
dots = true
|
|
chapter_size = math.min(6, (foreground_size / 2) + 2)
|
|
chapter_y = math.min(fay + chapter_size, fay + half_size)
|
|
elseif options.chapters == 'lines' then
|
|
chapter_size = size
|
|
chapter_y = fay + (chapter_size / 2)
|
|
elseif options.chapters == 'lines-top' then
|
|
chapter_size = math.min(this.size_max / 3.5, size)
|
|
chapter_y = fay + (chapter_size / 2)
|
|
elseif options.chapters == 'lines-bottom' then
|
|
chapter_size = math.min(this.size_max / 3.5, size)
|
|
chapter_y = fay + size - (chapter_size / 2)
|
|
end
|
|
|
|
if chapter_size ~= nil then
|
|
-- for 1px chapter size, use the whole size of the bar including padding
|
|
chapter_size = size <= 1 and foreground_size or chapter_size
|
|
local chapter_half_size = chapter_size / 2
|
|
|
|
for i, chapter in ipairs(state.chapters) do
|
|
local chapter_x = display.width *
|
|
(chapter.time / state.duration)
|
|
local color = chapter_x > fbx and options.color_foreground or
|
|
options.color_background
|
|
|
|
ass:new_event()
|
|
ass:append('{\\blur0\\bord0\\1c&H' .. color .. '}')
|
|
ass:append(ass_opacity(options.chapters_opacity))
|
|
ass:pos(0, 0)
|
|
ass:draw_start()
|
|
|
|
if dots then
|
|
local bezier_stretch = chapter_size * 0.67
|
|
ass:move_to(chapter_x - chapter_half_size, chapter_y)
|
|
ass:bezier_curve(chapter_x - chapter_half_size,
|
|
chapter_y - bezier_stretch,
|
|
chapter_x + chapter_half_size,
|
|
chapter_y - bezier_stretch,
|
|
chapter_x + chapter_half_size, chapter_y)
|
|
ass:bezier_curve(chapter_x + chapter_half_size,
|
|
chapter_y + bezier_stretch,
|
|
chapter_x - chapter_half_size,
|
|
chapter_y + bezier_stretch,
|
|
chapter_x - chapter_half_size, chapter_y)
|
|
else
|
|
ass:rect_cw(chapter_x, chapter_y - chapter_half_size,
|
|
chapter_x + 1, chapter_y + chapter_half_size)
|
|
end
|
|
|
|
ass:draw_stop()
|
|
end
|
|
end
|
|
end
|
|
|
|
if text_opacity > 0 then
|
|
-- Elapsed time
|
|
if state.elapsed_seconds then
|
|
ass:new_event()
|
|
ass:append('{\\blur0\\bord0\\shad0\\1c&H' ..
|
|
options.color_foreground_text .. '\\fn' ..
|
|
config.font .. '\\fs' .. this.font_size .. bold_tag ..
|
|
'\\clip(' .. foreground_coordinates .. ')')
|
|
ass:append(ass_opacity(math.min(options.timeline_opacity + 0.1, 1),
|
|
text_opacity))
|
|
ass:pos(spacing, fay + (size / 2))
|
|
ass:an(4)
|
|
ass:append(state.elapsed_time)
|
|
ass:new_event()
|
|
ass:append('{\\blur0\\bord0\\shad1\\1c&H' ..
|
|
options.color_background_text .. '\\4c&H' ..
|
|
options.color_background .. '\\fn' .. config.font ..
|
|
'\\fs' .. this.font_size .. bold_tag .. '\\iclip(' ..
|
|
foreground_coordinates .. ')')
|
|
ass:append(ass_opacity(math.min(options.timeline_opacity + 0.1, 1),
|
|
text_opacity))
|
|
ass:pos(spacing, fay + (size / 2))
|
|
ass:an(4)
|
|
ass:append(state.elapsed_time)
|
|
end
|
|
|
|
-- Remaining time
|
|
if state.remaining_seconds then
|
|
ass:new_event()
|
|
ass:append('{\\blur0\\bord0\\shad0\\1c&H' ..
|
|
options.color_foreground_text .. '\\fn' ..
|
|
config.font .. '\\fs' .. this.font_size .. bold_tag ..
|
|
'\\clip(' .. foreground_coordinates .. ')')
|
|
ass:append(ass_opacity(math.min(options.timeline_opacity + 0.1, 1),
|
|
text_opacity))
|
|
ass:pos(display.width - spacing, fay + (size / 2))
|
|
ass:an(6)
|
|
ass:append('-' .. state.remaining_time)
|
|
ass:new_event()
|
|
ass:append('{\\blur0\\bord0\\shad1\\1c&H' ..
|
|
options.color_background_text .. '\\4c&H' ..
|
|
options.color_background .. '\\fn' .. config.font ..
|
|
'\\fs' .. this.font_size .. bold_tag .. '\\iclip(' ..
|
|
foreground_coordinates .. ')')
|
|
ass:append(ass_opacity(math.min(options.timeline_opacity + 0.1, 1),
|
|
text_opacity))
|
|
ass:pos(display.width - spacing, fay + (size / 2))
|
|
ass:an(6)
|
|
ass:append('-' .. state.remaining_time)
|
|
end
|
|
end
|
|
|
|
if (this.proximity_raw == 0 or this.pressed) and
|
|
not (elements.speed and elements.speed.dragging) then
|
|
-- Hovered time
|
|
local hovered_seconds = state.duration * (cursor.x / display.width)
|
|
local box_half_width_guesstimate = (this.font_size * 4.2) / 2
|
|
ass:new_event()
|
|
ass:append('{\\blur0\\bord1\\shad0\\1c&H' ..
|
|
options.color_background_text .. '\\3c&H' ..
|
|
options.color_background .. '\\fn' .. config.font ..
|
|
'\\fs' .. this.font_size .. bold_tag .. '')
|
|
ass:append(ass_opacity(math.min(options.timeline_opacity + 0.1, 1)))
|
|
ass:pos(math.min(math.max(cursor.x, box_half_width_guesstimate),
|
|
display.width - box_half_width_guesstimate), fay)
|
|
ass:an(2)
|
|
ass:append(mp.format_time(hovered_seconds))
|
|
|
|
-- Cursor line
|
|
ass:new_event()
|
|
ass:append('{\\blur0\\bord0\\xshad-1\\yshad0\\1c&H' ..
|
|
options.color_foreground .. '\\4c&H' ..
|
|
options.color_background .. '}')
|
|
ass:append(ass_opacity(0.2))
|
|
ass:pos(0, 0)
|
|
ass:draw_start()
|
|
ass:rect_cw(cursor.x, fay, cursor.x + 1, fby)
|
|
ass:draw_stop()
|
|
end
|
|
|
|
return ass
|
|
end
|
|
|
|
function render_top_bar(this)
|
|
local opacity = this:get_effective_proximity()
|
|
|
|
if not this.enabled or opacity == 0 then return end
|
|
|
|
local ass = assdraw.ass_new()
|
|
|
|
if options.top_bar_controls then
|
|
-- Close button
|
|
local close = elements.window_controls_close
|
|
if close.proximity_raw == 0 then
|
|
-- Background on hover
|
|
ass:new_event()
|
|
ass:append('{\\blur0\\bord0\\1c&H2311e8}')
|
|
ass:append(ass_opacity(this.button_opacity, opacity))
|
|
ass:pos(0, 0)
|
|
ass:draw_start()
|
|
ass:rect_cw(close.ax, close.ay, close.bx, close.by)
|
|
ass:draw_stop()
|
|
end
|
|
ass:new_event()
|
|
ass:append('{\\blur0\\bord1\\shad1\\3c&HFFFFFF\\4c&H000000}')
|
|
ass:append(ass_opacity(this.button_opacity, opacity))
|
|
ass:pos(close.ax + (this.button_width / 2), (this.size / 2))
|
|
ass:draw_start()
|
|
ass:move_to(-this.icon_size, this.icon_size)
|
|
ass:line_to(this.icon_size, -this.icon_size)
|
|
ass:move_to(-this.icon_size, -this.icon_size)
|
|
ass:line_to(this.icon_size, this.icon_size)
|
|
ass:draw_stop()
|
|
|
|
-- Maximize button
|
|
local maximize = elements.window_controls_maximize
|
|
if maximize.proximity_raw == 0 then
|
|
-- Background on hover
|
|
ass:new_event()
|
|
ass:append('{\\blur0\\bord0\\1c&H222222}')
|
|
ass:append(ass_opacity(this.button_opacity, opacity))
|
|
ass:pos(0, 0)
|
|
ass:draw_start()
|
|
ass:rect_cw(maximize.ax, maximize.ay, maximize.bx, maximize.by)
|
|
ass:draw_stop()
|
|
end
|
|
ass:new_event()
|
|
ass:append('{\\blur0\\bord2\\shad0\\1c\\3c&H000000}')
|
|
ass:append(ass_opacity({[3] = this.button_opacity}, opacity))
|
|
ass:pos(maximize.ax + (this.button_width / 2), (this.size / 2))
|
|
ass:draw_start()
|
|
ass:rect_cw(-this.icon_size + 1, -this.icon_size + 1,
|
|
this.icon_size + 1, this.icon_size + 1)
|
|
ass:draw_stop()
|
|
ass:new_event()
|
|
ass:append('{\\blur0\\bord2\\shad0\\1c\\3c&HFFFFFF}')
|
|
ass:append(ass_opacity({[3] = this.button_opacity}, opacity))
|
|
ass:pos(maximize.ax + (this.button_width / 2), (this.size / 2))
|
|
ass:draw_start()
|
|
ass:rect_cw(-this.icon_size, -this.icon_size, this.icon_size,
|
|
this.icon_size)
|
|
ass:draw_stop()
|
|
|
|
-- Minimize button
|
|
local minimize = elements.window_controls_minimize
|
|
if minimize.proximity_raw == 0 then
|
|
-- Background on hover
|
|
ass:new_event()
|
|
ass:append('{\\blur0\\bord0\\1c&H222222}')
|
|
ass:append(ass_opacity(this.button_opacity, opacity))
|
|
ass:pos(0, 0)
|
|
ass:draw_start()
|
|
ass:rect_cw(minimize.ax, minimize.ay, minimize.bx, minimize.by)
|
|
ass:draw_stop()
|
|
end
|
|
ass:new_event()
|
|
ass:append('{\\blur0\\bord1\\shad1\\3c&HFFFFFF\\4c&H000000}')
|
|
ass:append(ass_opacity(this.button_opacity, opacity))
|
|
ass:append('{\\1a&HFF&}')
|
|
ass:pos(minimize.ax + (this.button_width / 2), (this.size / 2))
|
|
ass:draw_start()
|
|
ass:move_to(-this.icon_size, 0)
|
|
ass:line_to(this.icon_size, 0)
|
|
ass:draw_stop()
|
|
end
|
|
|
|
-- Window title
|
|
if options.top_bar_title and state.media_title then
|
|
local clip_coordinates =
|
|
'0,0,' .. (this.title_bx - this.spacing) .. ',' .. this.size
|
|
|
|
ass:new_event()
|
|
ass:append('{\\q2\\blur0\\bord1\\shad0\\1c&HFFFFFF\\3c&H000000\\fn' ..
|
|
config.font .. '\\fs' .. this.font_size .. bold_tag ..
|
|
'\\clip(' .. clip_coordinates .. ')')
|
|
ass:append(ass_opacity(1, opacity))
|
|
ass:pos(0 + this.spacing, this.size / 2)
|
|
ass:an(4)
|
|
ass:append(state.media_title)
|
|
end
|
|
|
|
return ass
|
|
end
|
|
|
|
function render_volume(this)
|
|
local slider = elements.volume_slider
|
|
local opacity = this:get_effective_proximity()
|
|
|
|
if this.width == 0 or opacity == 0 then return end
|
|
|
|
local ass = assdraw.ass_new()
|
|
|
|
if slider.height > 0 then
|
|
-- Background bar coordinates
|
|
local bax = slider.ax
|
|
local bay = slider.ay
|
|
local bbx = slider.bx
|
|
local bby = slider.by
|
|
|
|
-- Foreground bar coordinates
|
|
local height_without_border = slider.height -
|
|
(options.volume_border * 2)
|
|
local fax = slider.ax + options.volume_border
|
|
local fay = slider.ay +
|
|
(height_without_border *
|
|
(1 - math.min(state.volume / state.volume_max, 1))) +
|
|
options.volume_border
|
|
local fbx = slider.bx - options.volume_border
|
|
local fby = slider.by - options.volume_border
|
|
|
|
-- Path to draw a foreground bar with a 100% volume indicator, already
|
|
-- clipped by volume level. Can't just clip it with rectangle, as it itself
|
|
-- also needs to be used as a path to clip the background bar and volume
|
|
-- number.
|
|
local fpath = assdraw.ass_new()
|
|
fpath:move_to(fbx, fby)
|
|
fpath:line_to(fax, fby)
|
|
local nudge_bottom_y = slider.nudge_y + slider.nudge_size
|
|
if fay <= nudge_bottom_y and slider.draw_nudge then
|
|
fpath:line_to(fax, math.min(nudge_bottom_y))
|
|
if fay <= slider.nudge_y then
|
|
fpath:line_to((fax + slider.nudge_size), slider.nudge_y)
|
|
local nudge_top_y = slider.nudge_y - slider.nudge_size
|
|
if fay <= nudge_top_y then
|
|
fpath:line_to(fax, nudge_top_y)
|
|
fpath:line_to(fax, fay)
|
|
fpath:line_to(fbx, fay)
|
|
fpath:line_to(fbx, nudge_top_y)
|
|
else
|
|
local triangle_side = fay - nudge_top_y
|
|
fpath:line_to((fax + triangle_side), fay)
|
|
fpath:line_to((fbx - triangle_side), fay)
|
|
end
|
|
fpath:line_to((fbx - slider.nudge_size), slider.nudge_y)
|
|
else
|
|
local triangle_side = nudge_bottom_y - fay
|
|
fpath:line_to((fax + triangle_side), fay)
|
|
fpath:line_to((fbx - triangle_side), fay)
|
|
end
|
|
fpath:line_to(fbx, nudge_bottom_y)
|
|
else
|
|
fpath:line_to(fax, fay)
|
|
fpath:line_to(fbx, fay)
|
|
end
|
|
fpath:line_to(fbx, fby)
|
|
|
|
-- Background
|
|
ass:new_event()
|
|
ass:append('{\\blur0\\bord0\\1c&H' .. options.color_background ..
|
|
'\\iclip(' .. fpath.scale .. ', ' .. fpath.text .. ')}')
|
|
ass:append(ass_opacity(math.max(options.volume_opacity - 0.1, 0),
|
|
opacity))
|
|
ass:pos(0, 0)
|
|
ass:draw_start()
|
|
ass:move_to(bax, bay)
|
|
ass:line_to(bbx, bay)
|
|
local half_border = options.volume_border / 2
|
|
if slider.draw_nudge then
|
|
ass:line_to(bbx, math.max(slider.nudge_y - slider.nudge_size +
|
|
half_border, bay))
|
|
ass:line_to(bbx - slider.nudge_size + half_border, slider.nudge_y)
|
|
ass:line_to(bbx, slider.nudge_y + slider.nudge_size - half_border)
|
|
end
|
|
ass:line_to(bbx, bby)
|
|
ass:line_to(bax, bby)
|
|
if slider.draw_nudge then
|
|
ass:line_to(bax, slider.nudge_y + slider.nudge_size - half_border)
|
|
ass:line_to(bax + slider.nudge_size - half_border, slider.nudge_y)
|
|
ass:line_to(bax, math.max(slider.nudge_y - slider.nudge_size +
|
|
half_border, bay))
|
|
end
|
|
ass:line_to(bax, bay)
|
|
ass:draw_stop()
|
|
|
|
-- Foreground
|
|
ass:new_event()
|
|
ass:append('{\\blur0\\bord0\\1c&H' .. options.color_foreground .. '}')
|
|
ass:append(ass_opacity(options.volume_opacity, opacity))
|
|
ass:pos(0, 0)
|
|
ass:draw_start()
|
|
ass:append(fpath.text)
|
|
ass:draw_stop()
|
|
|
|
-- Current volume value
|
|
local volume_string = tostring(round(state.volume * 10) / 10)
|
|
local font_size = round(((this.width * 0.6) -
|
|
(#volume_string * (this.width / 20))) *
|
|
options.volume_font_scale)
|
|
if fay < slider.by - slider.spacing then
|
|
ass:new_event()
|
|
ass:append('{\\blur0\\bord0\\shad0\\1c&H' ..
|
|
options.color_foreground_text .. '\\fn' ..
|
|
config.font .. '\\fs' .. font_size .. bold_tag ..
|
|
'\\clip(' .. fpath.scale .. ', ' .. fpath.text ..
|
|
')}')
|
|
ass:append(ass_opacity(math.min(options.volume_opacity + 0.1, 1),
|
|
opacity))
|
|
ass:pos(slider.ax + (slider.width / 2), slider.by - slider.spacing)
|
|
ass:an(2)
|
|
ass:append(volume_string)
|
|
end
|
|
if fay > slider.by - slider.spacing - font_size then
|
|
ass:new_event()
|
|
ass:append('{\\blur0\\bord0\\shad1\\1c&H' ..
|
|
options.color_background_text .. '\\4c&H' ..
|
|
options.color_background .. '\\fn' .. config.font ..
|
|
'\\fs' .. font_size .. bold_tag .. '\\iclip(' ..
|
|
fpath.scale .. ', ' .. fpath.text .. ')}')
|
|
ass:append(ass_opacity(math.min(options.volume_opacity + 0.1, 1),
|
|
opacity))
|
|
ass:pos(slider.ax + (slider.width / 2), slider.by - slider.spacing)
|
|
ass:an(2)
|
|
ass:append(volume_string)
|
|
end
|
|
end
|
|
|
|
-- Mute button
|
|
local mute = elements.volume_mute
|
|
local icon_name = state.mute and 'volume_muted' or 'volume'
|
|
ass:new_event()
|
|
ass:append(icon(icon_name, mute.ax + (mute.width / 2),
|
|
mute.ay + (mute.height / 2), mute.width * 0.7, -- x, y, size
|
|
0, 0, options.volume_border, -- shadow_x, shadow_y, shadow_size
|
|
'background', options.volume_opacity * opacity -- backdrop, opacity
|
|
))
|
|
return ass
|
|
end
|
|
|
|
function render_speed(this)
|
|
if not this.dragging and (elements.curtain.opacity > 0) then return end
|
|
|
|
local timeline = elements.timeline
|
|
local proximity = timeline:get_effective_proximity()
|
|
local opacity = this.forced_proximity and this.forced_proximity or
|
|
(this.dragging and 1 or proximity)
|
|
|
|
if opacity == 0 then return end
|
|
|
|
local ass = assdraw.ass_new()
|
|
|
|
-- Coordinates
|
|
local ax = this.ax
|
|
local ay = this.ay + timeline.size_max - timeline:get_effective_size() -
|
|
timeline.top_border - timeline.bottom_border
|
|
local bx = this.bx
|
|
local by = ay + this.height
|
|
local half_width = (this.width / 2)
|
|
local half_x = ax + half_width
|
|
|
|
-- Notches
|
|
local speed_at_center = state.speed
|
|
if this.dragging then
|
|
speed_at_center = this.dragging.start_speed +
|
|
((-this.dragging.distance / this.step_distance) *
|
|
options.speed_step)
|
|
speed_at_center = math.min(math.max(speed_at_center, 0.01), 100)
|
|
end
|
|
local nearest_notch_speed = round(speed_at_center / this.notch_every) *
|
|
this.notch_every
|
|
local nearest_notch_x = half_x +
|
|
(((nearest_notch_speed - speed_at_center) /
|
|
this.notch_every) * this.notch_spacing)
|
|
local guide_size = math.floor(this.height / 7.5)
|
|
local notch_by = by - guide_size
|
|
local notch_ay_big = ay + round(this.font_size * 1.1)
|
|
local notch_ay_medium = notch_ay_big + ((notch_by - notch_ay_big) * 0.2)
|
|
local notch_ay_small = notch_ay_big + ((notch_by - notch_ay_big) * 0.4)
|
|
local from_to_index = math.floor(this.notches / 2)
|
|
|
|
for i = -from_to_index, from_to_index do
|
|
local notch_speed = nearest_notch_speed + (i * this.notch_every)
|
|
|
|
if notch_speed < 0 or notch_speed > 100 then goto continue end
|
|
|
|
local notch_x = nearest_notch_x + (i * this.notch_spacing)
|
|
local notch_thickness = 1
|
|
local notch_ay = notch_ay_small
|
|
if (notch_speed % (this.notch_every * 10)) < 0.00000001 then
|
|
notch_ay = notch_ay_big
|
|
notch_thickness = 1
|
|
elseif (notch_speed % (this.notch_every * 5)) < 0.00000001 then
|
|
notch_ay = notch_ay_medium
|
|
end
|
|
|
|
ass:new_event()
|
|
ass:append('{\\blur0\\bord1\\shad0\\1c&HFFFFFF\\3c&H000000}')
|
|
ass:append(ass_opacity(math.min(1.2 -
|
|
(math.abs(
|
|
(notch_x - ax - half_width) /
|
|
half_width)), 1), opacity))
|
|
ass:pos(0, 0)
|
|
ass:draw_start()
|
|
ass:move_to(notch_x - notch_thickness, notch_ay)
|
|
ass:line_to(notch_x + notch_thickness, notch_ay)
|
|
ass:line_to(notch_x + notch_thickness, notch_by)
|
|
ass:line_to(notch_x - notch_thickness, notch_by)
|
|
ass:draw_stop()
|
|
|
|
::continue::
|
|
end
|
|
|
|
-- Center guide
|
|
ass:new_event()
|
|
ass:append('{\\blur0\\bord1\\shad0\\1c&HFFFFFF\\3c&H000000}')
|
|
ass:append(ass_opacity(options.speed_opacity, opacity))
|
|
ass:pos(0, 0)
|
|
ass:draw_start()
|
|
ass:move_to(half_x, by - 2 - guide_size)
|
|
ass:line_to(half_x + guide_size, by - 2)
|
|
ass:line_to(half_x - guide_size, by - 2)
|
|
ass:draw_stop()
|
|
|
|
-- Speed value
|
|
local speed_text = (round(state.speed * 100) / 100) .. 'x'
|
|
ass:new_event()
|
|
ass:append(
|
|
'{\\blur0\\bord1\\shad0\\1c&H' .. options.color_background_text ..
|
|
'\\3c&H' .. options.color_background .. '\\fn' .. config.font ..
|
|
'\\fs' .. this.font_size .. bold_tag .. '}')
|
|
ass:append(ass_opacity(options.speed_opacity, opacity))
|
|
ass:pos(half_x, ay)
|
|
ass:an(8)
|
|
ass:append(speed_text)
|
|
|
|
return ass
|
|
end
|
|
|
|
function render_menu(this)
|
|
local ass = assdraw.ass_new()
|
|
|
|
if this.parent_menu then ass:merge(this.parent_menu:render()) end
|
|
|
|
-- Menu title
|
|
if this.title then
|
|
-- Background
|
|
ass:new_event()
|
|
ass:append('{\\blur0\\bord0\\1c&H' .. options.color_background .. '}')
|
|
ass:append(ass_opacity(options.menu_opacity, this.opacity))
|
|
ass:pos(0, 0)
|
|
ass:draw_start()
|
|
ass:rect_cw(this.ax, this.ay - this.item_height, this.bx, this.ay - 1)
|
|
ass:draw_stop()
|
|
|
|
-- Title
|
|
ass:new_event()
|
|
ass:append('{\\blur0\\bord0\\shad1\\b1\\1c&H' ..
|
|
options.color_background_text .. '\\4c&H' ..
|
|
options.color_background .. '\\fn' .. config.font ..
|
|
'\\fs' .. this.font_size .. '\\q2\\clip(' .. this.ax ..
|
|
',' .. this.ay - this.item_height .. ',' .. this.bx ..
|
|
',' .. this.ay .. ')}')
|
|
ass:append(ass_opacity(options.menu_opacity, this.opacity))
|
|
ass:pos(display.width / 2, this.ay - (this.item_height * 0.5))
|
|
ass:an(5)
|
|
ass:append(this.title)
|
|
end
|
|
|
|
local scroll_area_clip = '\\clip(' .. this.ax .. ',' .. this.ay .. ',' ..
|
|
this.bx .. ',' .. this.by .. ')'
|
|
|
|
for index, item in ipairs(this.items) do
|
|
local item_ay = this.ay - this.scroll_y +
|
|
(this.item_height * (index - 1) + this.item_spacing *
|
|
(index - 1))
|
|
local item_by = item_ay + this.item_height
|
|
local item_clip = ''
|
|
|
|
-- Clip items overflowing scroll area
|
|
if item_ay <= this.ay or item_by >= this.by then
|
|
item_clip = scroll_area_clip
|
|
end
|
|
|
|
if item_by < this.ay or item_ay > this.by then goto continue end
|
|
|
|
local is_active = this.active_item == index
|
|
local font_color, background_color, ass_shadow, ass_shadow_color
|
|
local icon_size = this.font_size
|
|
|
|
if is_active then
|
|
font_color, background_color = options.color_foreground_text,
|
|
options.color_foreground
|
|
ass_shadow, ass_shadow_color = '\\shad0', ''
|
|
else
|
|
font_color, background_color = options.color_background_text,
|
|
options.color_background
|
|
ass_shadow, ass_shadow_color = '\\shad1',
|
|
'\\4c&H' .. background_color
|
|
end
|
|
|
|
local has_submenu = item.items ~= nil
|
|
local hint_width = 0
|
|
if item.hint then
|
|
hint_width = text_width_estimate(item.hint:len(), this.font_size) +
|
|
this.item_content_spacing
|
|
elseif has_submenu then
|
|
hint_width = icon_size + this.item_content_spacing
|
|
end
|
|
|
|
-- Background
|
|
ass:new_event()
|
|
ass:append('{\\blur0\\bord0\\1c&H' .. background_color .. item_clip ..
|
|
'}')
|
|
ass:append(ass_opacity(options.menu_opacity, this.opacity))
|
|
ass:pos(0, 0)
|
|
ass:draw_start()
|
|
ass:rect_cw(this.ax, item_ay, this.bx, item_by)
|
|
ass:draw_stop()
|
|
|
|
-- Selected highlight
|
|
if this.selected_item == index then
|
|
ass:new_event()
|
|
ass:append('{\\blur0\\bord0\\1c&H' .. options.color_foreground ..
|
|
item_clip .. '}')
|
|
ass:append(ass_opacity(0.1, this.opacity))
|
|
ass:pos(0, 0)
|
|
ass:draw_start()
|
|
ass:rect_cw(this.ax, item_ay, this.bx, item_by)
|
|
ass:draw_stop()
|
|
end
|
|
|
|
-- Title
|
|
if item.title then
|
|
item.ass_save_title = item.ass_save_title or
|
|
item.title:gsub("([{}])", "\\%1")
|
|
local title_clip_x = (this.bx - hint_width -
|
|
this.item_content_spacing)
|
|
local title_clip = '\\clip(' .. this.ax .. ',' ..
|
|
math.max(item_ay, this.ay) .. ',' ..
|
|
title_clip_x .. ',' ..
|
|
math.min(item_by, this.by) .. ')'
|
|
ass:new_event()
|
|
ass:append(
|
|
'{\\blur0\\bord0\\shad1\\1c&H' .. font_color .. '\\4c&H' ..
|
|
background_color .. '\\fn' .. config.font .. '\\fs' ..
|
|
this.font_size .. bold_tag .. title_clip .. '\\q2}')
|
|
ass:append(ass_opacity(options.menu_opacity, this.opacity))
|
|
ass:pos(this.ax + this.item_content_spacing,
|
|
item_ay + (this.item_height / 2))
|
|
ass:an(4)
|
|
ass:append(item.ass_save_title)
|
|
end
|
|
|
|
-- Hint
|
|
if item.hint then
|
|
item.ass_save_hint = item.ass_save_hint or
|
|
item.hint:gsub("([{}])", "\\%1")
|
|
ass:new_event()
|
|
ass:append(
|
|
'{\\blur0\\bord0' .. ass_shadow .. '\\1c&H' .. font_color .. '' ..
|
|
ass_shadow_color .. '\\fn' .. config.font .. '\\fs' ..
|
|
(this.font_size - 1) .. bold_tag .. item_clip .. '}')
|
|
ass:append(ass_opacity(options.menu_opacity *
|
|
(has_submenu and 1 or 0.5), this.opacity))
|
|
ass:pos(this.bx - this.item_content_spacing,
|
|
item_ay + (this.item_height / 2))
|
|
ass:an(6)
|
|
ass:append(item.ass_save_hint)
|
|
elseif has_submenu then
|
|
ass:new_event()
|
|
ass:append(icon('arrow_right',
|
|
this.bx - this.item_content_spacing -
|
|
(icon_size / 2), -- x
|
|
item_ay + (this.item_height / 2), -- y
|
|
icon_size, -- size
|
|
0, 0, 1, -- shadow_x, shadow_y, shadow_size
|
|
is_active and 'foreground' or 'background', this.opacity, -- backdrop, opacity
|
|
item_clip))
|
|
end
|
|
|
|
::continue::
|
|
end
|
|
|
|
-- Scrollbar
|
|
if this.scroll_height > 0 then
|
|
local groove_height = this.height - 2
|
|
local thumb_height = math.max((this.height /
|
|
(this.scroll_height + this.height)) *
|
|
groove_height, 40)
|
|
local thumb_y = this.ay + 1 +
|
|
((this.scroll_y / this.scroll_height) *
|
|
(groove_height - thumb_height))
|
|
ass:new_event()
|
|
ass:append('{\\blur0\\bord0\\1c&H' .. options.color_foreground .. '}')
|
|
ass:append(ass_opacity(options.menu_opacity, this.opacity * 0.8))
|
|
ass:pos(0, 0)
|
|
ass:draw_start()
|
|
ass:rect_cw(this.bx - 3, thumb_y, this.bx - 1, thumb_y + thumb_height)
|
|
ass:draw_stop()
|
|
end
|
|
|
|
return ass
|
|
end
|
|
|
|
-- MAIN RENDERING
|
|
|
|
-- Request that render() is called.
|
|
-- The render is then either executed immediately, or rate-limited if it was
|
|
-- called a small time ago.
|
|
function request_render()
|
|
if state.render_timer == nil then
|
|
state.render_timer = mp.add_timeout(0, render)
|
|
end
|
|
|
|
if not state.render_timer:is_enabled() then
|
|
local now = mp.get_time()
|
|
local timeout = config.render_delay - (now - state.render_last_time)
|
|
if timeout < 0 then timeout = 0 end
|
|
state.render_timer.timeout = timeout
|
|
state.render_timer:resume()
|
|
end
|
|
end
|
|
|
|
function render()
|
|
state.render_last_time = mp.get_time()
|
|
|
|
-- Actual rendering
|
|
local ass = assdraw.ass_new()
|
|
|
|
for _, element in elements.ipairs() do
|
|
local result = element:maybe('render')
|
|
if result then
|
|
ass:new_event()
|
|
ass:merge(result)
|
|
end
|
|
end
|
|
|
|
-- submit
|
|
if osd.res_x == display.width and osd.res_y == display.height and osd.data ==
|
|
ass.text then return end
|
|
|
|
osd.res_x = display.width
|
|
osd.res_y = display.height
|
|
osd.data = ass.text
|
|
osd.z = 2000
|
|
osd:update()
|
|
end
|
|
|
|
-- STATIC ELEMENTS
|
|
|
|
if itable_find({'flash', 'static'}, options.pause_indicator) then
|
|
elements:add('pause_indicator', Element.new(
|
|
{
|
|
base_icon_opacity = options.pause_indicator == 'flash' and 1 or 0.8,
|
|
paused = false,
|
|
is_flash = options.pause_indicator == 'flash',
|
|
is_static = options.pause_indicator == 'static',
|
|
opacity = 0,
|
|
init = function(this)
|
|
local initial_call = true
|
|
mp.observe_property('pause', 'bool', function(_, paused)
|
|
if initial_call then
|
|
initial_call = false
|
|
return
|
|
end
|
|
|
|
this.paused = paused
|
|
|
|
if options.pause_indicator == 'flash' then
|
|
this.opacity = 1
|
|
this:tween_property('opacity', 1, 0, 0.15)
|
|
else
|
|
this.opacity = paused and 1 or 0
|
|
request_render()
|
|
end
|
|
|
|
end)
|
|
end,
|
|
render = function(this)
|
|
if this.opacity == 0 then return end
|
|
|
|
local ass = assdraw.ass_new()
|
|
|
|
-- Background fadeout
|
|
if this.is_static then
|
|
ass:new_event()
|
|
ass:append('{\\blur0\\bord0\\1c&H' ..
|
|
options.color_background .. '}')
|
|
ass:append(ass_opacity(0.3, this.opacity))
|
|
ass:pos(0, 0)
|
|
ass:draw_start()
|
|
ass:rect_cw(0, 0, display.width, display.height)
|
|
ass:draw_stop()
|
|
end
|
|
|
|
-- Icon
|
|
local size = round((math.min(display.width, display.height) *
|
|
(this.is_static and 0.20 or 0.15)) / 2)
|
|
|
|
size = size + size * (1 - this.opacity)
|
|
|
|
if this.paused then
|
|
ass:new_event()
|
|
ass:append('{\\blur0\\bord1\\1c&H' ..
|
|
options.color_foreground .. '\\3c&H' ..
|
|
options.color_background .. '}')
|
|
ass:append(ass_opacity(this.base_icon_opacity, this.opacity))
|
|
ass:pos(display.width / 2, display.height / 2)
|
|
ass:draw_start()
|
|
ass:rect_cw(-size, -size, -size / 3, size)
|
|
ass:draw_stop()
|
|
|
|
ass:new_event()
|
|
ass:append('{\\blur0\\bord1\\1c&H' ..
|
|
options.color_foreground .. '\\3c&H' ..
|
|
options.color_background .. '}')
|
|
ass:append(ass_opacity(this.base_icon_opacity, this.opacity))
|
|
ass:pos(display.width / 2, display.height / 2)
|
|
ass:draw_start()
|
|
ass:rect_cw(size / 3, -size, size, size)
|
|
ass:draw_stop()
|
|
elseif this.is_flash then
|
|
ass:new_event()
|
|
ass:append('{\\blur0\\bord1\\1c&H' ..
|
|
options.color_foreground .. '\\3c&H' ..
|
|
options.color_background .. '}')
|
|
ass:append(ass_opacity(this.base_icon_opacity, this.opacity))
|
|
ass:pos(display.width / 2, display.height / 2)
|
|
ass:draw_start()
|
|
ass:move_to(-size * 0.6, -size)
|
|
ass:line_to(size, 0)
|
|
ass:line_to(-size * 0.6, size)
|
|
ass:draw_stop()
|
|
end
|
|
|
|
return ass
|
|
end
|
|
}))
|
|
end
|
|
elements:add('timeline', Element.new({
|
|
captures = {mouse_buttons = true, wheel = true},
|
|
pressed = false,
|
|
size_max = 0,
|
|
size_min = 0, -- set in `on_display_resize` handler based on `state.fullscreen`
|
|
size_min_override = options.timeline_start_hidden and 0 or nil, -- used for toggle-progress command
|
|
font_size = 0, -- calculated in on_display_resize
|
|
top_border = options.timeline_border,
|
|
bottom_border = 0, -- set dynamically in `border` property observer
|
|
init = function(this)
|
|
-- Toggle 1px bottom border for timeline in no-border mode
|
|
mp.observe_property('border', 'bool', function(_, border)
|
|
this.bottom_border = not border and options.timeline_border or 0
|
|
request_render()
|
|
end)
|
|
|
|
-- Flash on external changes
|
|
if options.timeline_flash then
|
|
mp.register_event('seek', function()
|
|
local position = mp.get_property_native('playback-time')
|
|
if position and state.position then
|
|
local seek_length = math.abs(position - state.position)
|
|
-- Don't flash on video looping (seek to 0) or tiny seeks (frame-step)
|
|
if position > 0.5 and seek_length > 0.5 then
|
|
this:flash()
|
|
end
|
|
end
|
|
end)
|
|
end
|
|
end,
|
|
get_effective_proximity = function(this)
|
|
if (elements.volume_slider and elements.volume_slider.pressed) then
|
|
return 0
|
|
end
|
|
if this.pressed then return 1 end
|
|
return this.forced_proximity and this.forced_proximity or this.proximity
|
|
end,
|
|
get_effective_size_min = function(this)
|
|
return this.size_min_override or this.size_min
|
|
end,
|
|
get_effective_size = function(this)
|
|
if elements.speed and elements.speed.dragging then
|
|
return this.size_max
|
|
end
|
|
local size_min = this:get_effective_size_min()
|
|
return size_min +
|
|
math.ceil((this.size_max - size_min) *
|
|
this:get_effective_proximity())
|
|
end,
|
|
on_display_resize = function(this)
|
|
if state.fullscreen or state.maximized then
|
|
this.size_min = options.timeline_size_min_fullscreen
|
|
this.size_max = options.timeline_size_max_fullscreen
|
|
else
|
|
this.size_min = options.timeline_size_min
|
|
this.size_max = options.timeline_size_max
|
|
end
|
|
this.font_size = math.floor(math.min((this.size_max + 60) * 0.2,
|
|
this.size_max * 0.96) *
|
|
options.timeline_font_scale)
|
|
this.ax = 0
|
|
this.ay = display.height - this.size_max - this.top_border -
|
|
this.bottom_border
|
|
this.bx = display.width
|
|
this.by = display.height
|
|
end,
|
|
set_from_cursor = function(this)
|
|
mp.commandv('seek', ((cursor.x / display.width) * 100),
|
|
'absolute-percent+exact')
|
|
end,
|
|
on_mbtn_left_down = function(this)
|
|
this.pressed = true
|
|
this:set_from_cursor()
|
|
end,
|
|
on_global_mbtn_left_up = function(this) this.pressed = false end,
|
|
on_global_mouse_leave = function(this) this.pressed = false end,
|
|
on_global_mouse_move = function(this)
|
|
if this.pressed then this:set_from_cursor() end
|
|
end,
|
|
on_wheel_up = function(this)
|
|
if options.timeline_step > 0 then
|
|
mp.commandv('seek', -options.timeline_step)
|
|
end
|
|
end,
|
|
on_wheel_down = function(this)
|
|
if options.timeline_step > 0 then
|
|
mp.commandv('seek', options.timeline_step)
|
|
end
|
|
end,
|
|
render = render_timeline
|
|
}))
|
|
if options.top_bar_controls or options.top_bar_title then
|
|
elements:add('top_bar', Element.new({
|
|
button_opacity = 0.8,
|
|
enabled = false,
|
|
init = function(this)
|
|
mp.observe_property('border', 'bool', function(_, border)
|
|
this.enabled = not border
|
|
end)
|
|
end,
|
|
get_effective_proximity = function(this)
|
|
if (elements.volume_slider and elements.volume_slider.pressed) or
|
|
elements.curtain.opacity > 0 then return 0 end
|
|
return this.forced_proximity and this.forced_proximity or
|
|
this.proximity
|
|
end,
|
|
on_display_resize = function(this)
|
|
this.size = (state.fullscreen or state.maximized) and
|
|
options.top_bar_size_fullscreen or
|
|
options.top_bar_size
|
|
this.icon_size = round(this.size / 8)
|
|
this.spacing = math.ceil(this.size * 0.25)
|
|
this.font_size = math.floor(this.size - (this.spacing * 2))
|
|
this.button_width = round(this.size * 1.15)
|
|
this.title_bx = display.width -
|
|
(options.top_bar_controls and
|
|
(this.button_width * 3) or 0)
|
|
this.ax = options.top_bar_title and 0 or this.title_bx
|
|
this.ay = 0
|
|
this.bx = display.width
|
|
this.by = this.size
|
|
end,
|
|
render = render_top_bar
|
|
}))
|
|
end
|
|
if options.top_bar_controls then
|
|
elements:add('window_controls_minimize', Element.new(
|
|
{
|
|
captures = {mouse_buttons = true},
|
|
on_display_resize = function(this)
|
|
this.ax = display.width - (elements.top_bar.button_width * 3)
|
|
this.ay = 0
|
|
this.bx = this.ax + elements.top_bar.button_width
|
|
this.by = elements.top_bar.size
|
|
end,
|
|
on_mbtn_left_down = function()
|
|
mp.commandv('cycle', 'window-minimized')
|
|
end
|
|
}))
|
|
elements:add('window_controls_maximize', Element.new(
|
|
{
|
|
captures = {mouse_buttons = true},
|
|
on_display_resize = function(this)
|
|
this.ax = display.width - (elements.top_bar.button_width * 2)
|
|
this.ay = 0
|
|
this.bx = this.ax + elements.top_bar.button_width
|
|
this.by = elements.top_bar.size
|
|
end,
|
|
on_mbtn_left_down = function()
|
|
mp.commandv('cycle', 'window-maximized')
|
|
end
|
|
}))
|
|
elements:add('window_controls_close', Element.new(
|
|
{
|
|
captures = {mouse_buttons = true},
|
|
on_display_resize = function(this)
|
|
this.ax = display.width - elements.top_bar.button_width
|
|
this.ay = 0
|
|
this.bx = this.ax + elements.top_bar.button_width
|
|
this.by = elements.top_bar.size
|
|
end,
|
|
on_mbtn_left_down = function() mp.commandv('quit') end
|
|
}))
|
|
end
|
|
if itable_find({'left', 'right'}, options.volume) then
|
|
elements:add('volume', Element.new({
|
|
width = nil, -- set in `on_display_resize` handler based on `state.fullscreen`
|
|
height = nil, -- set in `on_display_resize` handler based on `state.fullscreen`
|
|
margin = nil, -- set in `on_display_resize` handler based on `state.fullscreen`
|
|
init = function(this)
|
|
-- FLash on external changes
|
|
if options.volume_flash then
|
|
local is_initial_volume_call = true
|
|
mp.observe_property('volume', 'number', function(_, value)
|
|
if not is_initial_volume_call then
|
|
this:flash()
|
|
end
|
|
is_initial_volume_call = false
|
|
end)
|
|
local is_initial_mute_call = true
|
|
mp.observe_property('mute', 'bool', function(_, value)
|
|
if not is_initial_mute_call then
|
|
this:flash()
|
|
end
|
|
is_initial_mute_call = false
|
|
end)
|
|
end
|
|
end,
|
|
get_effective_proximity = function(this)
|
|
if elements.volume_slider.pressed then return 1 end
|
|
if elements.timeline.proximity_raw == 0 or elements.curtain.opacity >
|
|
0 then return 0 end
|
|
return this.forced_proximity and this.forced_proximity or
|
|
this.proximity
|
|
end,
|
|
on_display_resize = function(this)
|
|
this.width = (state.fullscreen or state.maximized) and
|
|
options.volume_size_fullscreen or
|
|
options.volume_size
|
|
this.height = round(math.min(this.width * 8, (elements.timeline.ay -
|
|
elements.top_bar.size) * 0.8))
|
|
-- Don't bother rendering this if too small
|
|
if this.height < (this.width * 2) then this.height = 0 end
|
|
this.margin = this.width / 2
|
|
this.ax = round(options.volume == 'left' and this.margin or
|
|
display.width - this.margin - this.width)
|
|
this.ay = round((display.height - this.height) / 2)
|
|
this.bx = round(this.ax + this.width)
|
|
this.by = round(this.ay + this.height)
|
|
end,
|
|
render = render_volume
|
|
}))
|
|
elements:add('volume_mute', Element.new(
|
|
{
|
|
captures = {mouse_buttons = true},
|
|
width = 0,
|
|
height = 0,
|
|
on_display_resize = function(this)
|
|
this.width = elements.volume.width
|
|
this.height = this.width
|
|
this.ax = elements.volume.ax
|
|
this.ay = elements.volume.by - this.height
|
|
this.bx = elements.volume.bx
|
|
this.by = elements.volume.by
|
|
end,
|
|
on_mbtn_left_down = function(this)
|
|
mp.commandv('cycle', 'mute')
|
|
end
|
|
}))
|
|
elements:add('volume_slider', Element.new(
|
|
{
|
|
captures = {mouse_buttons = true, wheel = true},
|
|
pressed = false,
|
|
width = 0,
|
|
height = 0,
|
|
nudge_y = 0, -- vertical position where volume overflows 100
|
|
nudge_size = nil, -- set on resize
|
|
font_size = nil,
|
|
spacing = nil,
|
|
on_display_resize = function(this)
|
|
this.ax = elements.volume.ax
|
|
this.ay = elements.volume.ay
|
|
this.bx = elements.volume.bx
|
|
this.by = elements.volume_mute.ay
|
|
this.width = this.bx - this.ax
|
|
this.height = this.by - this.ay
|
|
this.nudge_y = this.by -
|
|
round(this.height * (100 / state.volume_max))
|
|
this.nudge_size = round(elements.volume.width * 0.18)
|
|
this.draw_nudge = this.ay < this.nudge_y
|
|
this.spacing = round(this.width * 0.2)
|
|
end,
|
|
set_from_cursor = function(this)
|
|
local volume_fraction = (this.by - cursor.y -
|
|
options.volume_border) /
|
|
(this.height - options.volume_border)
|
|
local new_volume = math.min(math.max(volume_fraction, 0), 1) *
|
|
state.volume_max
|
|
new_volume = round(new_volume / options.volume_step) *
|
|
options.volume_step
|
|
if state.volume ~= new_volume then
|
|
mp.commandv('set', 'volume',
|
|
math.min(new_volume, state.volume_max))
|
|
end
|
|
end,
|
|
on_mbtn_left_down = function(this)
|
|
this.pressed = true
|
|
this:set_from_cursor()
|
|
end,
|
|
on_global_mbtn_left_up = function(this)
|
|
this.pressed = false
|
|
end,
|
|
on_global_mouse_leave = function(this)
|
|
this.pressed = false
|
|
end,
|
|
on_global_mouse_move = function(this)
|
|
if this.pressed then this:set_from_cursor() end
|
|
end,
|
|
on_wheel_up = function(this)
|
|
local current_rounded_volume =
|
|
round(state.volume / options.volume_step) *
|
|
options.volume_step
|
|
mp.commandv('set', 'volume', math.min(
|
|
current_rounded_volume + options.volume_step,
|
|
state.volume_max))
|
|
end,
|
|
on_wheel_down = function(this)
|
|
local current_rounded_volume =
|
|
round(state.volume / options.volume_step) *
|
|
options.volume_step
|
|
mp.commandv('set', 'volume', math.min(
|
|
current_rounded_volume - options.volume_step,
|
|
state.volume_max))
|
|
end
|
|
}))
|
|
end
|
|
if options.speed then
|
|
elements:add('speed', Element.new({
|
|
captures = {mouse_buttons = true, wheel = true},
|
|
dragging = nil,
|
|
width = 0,
|
|
height = 0,
|
|
notches = 10,
|
|
notch_every = 0.1,
|
|
step_distance = nil,
|
|
font_size = nil,
|
|
init = function(this)
|
|
-- Fade out/in on timeline mouse enter/leave
|
|
elements.timeline:on('mouse_enter', function()
|
|
if not this.dragging then this:fadeout() end
|
|
end)
|
|
elements.timeline:on('mouse_leave', function()
|
|
if not this.dragging then this:fadein() end
|
|
end)
|
|
|
|
-- Flash on external changes
|
|
if options.speed_flash then
|
|
local initial_call = true
|
|
mp.observe_property('speed', 'number', function()
|
|
if not initial_call and not this.dragging then
|
|
this:flash()
|
|
end
|
|
initial_call = false
|
|
end)
|
|
end
|
|
end,
|
|
fadeout = function(this)
|
|
this:tween_property('forced_proximity', 1, 0,
|
|
function(this)
|
|
this.forced_proximity = 0
|
|
end)
|
|
end,
|
|
fadein = function(this)
|
|
local get_current_proximity =
|
|
function() return this.proximity end
|
|
this:tween_property('forced_proximity', 0, get_current_proximity,
|
|
function(this)
|
|
this.forced_proximity = nil
|
|
end)
|
|
end,
|
|
on_display_resize = function(this)
|
|
this.height = (state.fullscreen or state.maximized) and
|
|
options.speed_size_fullscreen or
|
|
options.speed_size
|
|
this.width = round(this.height * 3.6)
|
|
this.notch_spacing = this.width / this.notches
|
|
this.step_distance = this.notch_spacing *
|
|
(options.speed_step / this.notch_every)
|
|
this.ax = (display.width - this.width) / 2
|
|
this.by = display.height - elements.timeline.size_max
|
|
this.ay = this.by - this.height
|
|
this.bx = this.ax + this.width
|
|
this.font_size =
|
|
round(this.height * 0.48 * options.speed_font_scale)
|
|
end,
|
|
set_from_cursor = function(this)
|
|
local volume_fraction =
|
|
(this.by - cursor.y - options.volume_border) /
|
|
(this.height - options.volume_border)
|
|
local new_volume = math.min(math.max(volume_fraction, 0), 1) *
|
|
state.volume_max
|
|
new_volume = round(new_volume / options.volume_step) *
|
|
options.volume_step
|
|
if state.volume ~= new_volume then
|
|
mp.commandv('set', 'volume', new_volume)
|
|
end
|
|
end,
|
|
on_mbtn_left_down = function(this)
|
|
this:tween_stop() -- Stop and cleanup possible ongoing animations
|
|
this.dragging = {
|
|
start_time = mp.get_time(),
|
|
start_x = cursor.x,
|
|
distance = 0,
|
|
start_speed = state.speed
|
|
}
|
|
end,
|
|
on_global_mouse_move = function(this)
|
|
if not this.dragging then return end
|
|
|
|
this.dragging.distance = cursor.x - this.dragging.start_x
|
|
local steps_dragged = round(-this.dragging.distance /
|
|
this.step_distance)
|
|
local new_speed = this.dragging.start_speed +
|
|
(steps_dragged * options.speed_step)
|
|
mp.set_property_native('speed', round(new_speed * 100) / 100)
|
|
end,
|
|
on_mbtn_left_up = function(this)
|
|
-- Reset speed on short clicks
|
|
if this.dragging and math.abs(this.dragging.distance) < 6 and
|
|
mp.get_time() - this.dragging.start_time < 0.15 then
|
|
mp.set_property_native('speed', 1)
|
|
end
|
|
end,
|
|
on_global_mbtn_left_up = function(this)
|
|
if this.dragging and elements.timeline.proximity_raw == 0 then
|
|
this:fadeout()
|
|
end
|
|
this.dragging = nil
|
|
request_render()
|
|
end,
|
|
on_global_mouse_leave = function(this)
|
|
this.dragging = nil
|
|
request_render()
|
|
end,
|
|
on_wheel_up = function(this)
|
|
mp.set_property_native('speed', state.speed - options.speed_step)
|
|
end,
|
|
on_wheel_down = function(this)
|
|
mp.set_property_native('speed', state.speed + options.speed_step)
|
|
end,
|
|
render = render_speed
|
|
}))
|
|
end
|
|
elements:add('curtain', Element.new({
|
|
opacity = 0,
|
|
fadeout = function(this) this:tween_property('opacity', this.opacity, 0); end,
|
|
fadein = function(this) this:tween_property('opacity', this.opacity, 1); end,
|
|
render = function(this)
|
|
if this.opacity > 0 then
|
|
local ass = assdraw.ass_new()
|
|
ass:new_event()
|
|
ass:append('{\\blur0\\bord0\\1c&H' .. options.color_background ..
|
|
'}')
|
|
ass:append(ass_opacity(0.4, this.opacity))
|
|
ass:pos(0, 0)
|
|
ass:draw_start()
|
|
ass:rect_cw(0, 0, display.width, display.height)
|
|
ass:draw_stop()
|
|
return ass
|
|
end
|
|
end
|
|
}))
|
|
|
|
-- CHAPTERS SERIALIZATION
|
|
|
|
-- Parse `chapter_ranges` option into workable data structure
|
|
for _, definition in ipairs(split(options.chapter_ranges, ' *,+ *')) do
|
|
local start_patterns, color, opacity, end_patterns =
|
|
string.match(definition, '([^<]+)<(%x%x%x%x%x%x):(%d?%.?%d*)>([^>]+)')
|
|
|
|
-- Invalid definition
|
|
if start_patterns == nil then goto continue end
|
|
|
|
start_patterns = start_patterns:lower()
|
|
end_patterns = end_patterns:lower()
|
|
local uses_bof = start_patterns:find('{bof}') ~= nil
|
|
local uses_eof = end_patterns:find('{eof}') ~= nil
|
|
local chapter_range = {
|
|
start_patterns = split(start_patterns, '|'),
|
|
end_patterns = split(end_patterns, '|'),
|
|
color = color,
|
|
opacity = tonumber(opacity),
|
|
ranges = {}
|
|
}
|
|
|
|
-- Filter out special keywords so we don't use them when matching titles
|
|
if uses_bof then
|
|
chapter_range.start_patterns = itable_remove(
|
|
chapter_range.start_patterns, '{bof}')
|
|
end
|
|
if uses_eof and chapter_range.end_patterns then
|
|
chapter_range.end_patterns = itable_remove(chapter_range.end_patterns,
|
|
'{eof}')
|
|
end
|
|
|
|
chapter_range['serialize'] = function(chapters)
|
|
chapter_range.ranges = {}
|
|
local current_range = nil
|
|
-- bof and eof should be used only once per timeline
|
|
-- eof is only used when last range is missing end
|
|
local bof_used = false
|
|
|
|
function start_range(chapter)
|
|
-- If there is already a range started, should we append or overwrite?
|
|
-- I chose overwrite here.
|
|
current_range = {['start'] = chapter}
|
|
end
|
|
|
|
function end_range(chapter)
|
|
current_range['end'] = chapter
|
|
chapter_range.ranges[#chapter_range.ranges + 1] = current_range
|
|
-- Mark both chapter objects
|
|
current_range['start']._uosc_used_as_range_point = true
|
|
current_range['end']._uosc_used_as_range_point = true
|
|
-- Clear for next range
|
|
current_range = nil
|
|
end
|
|
|
|
for _, chapter in ipairs(chapters) do
|
|
if type(chapter.title) == 'string' then
|
|
local lowercase_title = chapter.title:lower()
|
|
local is_end = false
|
|
local is_start = false
|
|
|
|
-- Is ending check and handling
|
|
if chapter_range.end_patterns then
|
|
for _, end_pattern in ipairs(chapter_range.end_patterns) do
|
|
is_end = is_end or lowercase_title:find(end_pattern) ~=
|
|
nil
|
|
end
|
|
|
|
if is_end then
|
|
if current_range == nil and uses_bof and not bof_used then
|
|
bof_used = true
|
|
start_range({time = 0})
|
|
end
|
|
if current_range ~= nil then
|
|
end_range(chapter)
|
|
else
|
|
is_end = false
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Is start check and handling
|
|
for _, start_pattern in ipairs(chapter_range.start_patterns) do
|
|
is_start =
|
|
is_start or lowercase_title:find(start_pattern) ~= nil
|
|
end
|
|
|
|
if is_start then start_range(chapter) end
|
|
end
|
|
end
|
|
|
|
-- If there is an unfinished range and range type accepts eof, use it
|
|
if current_range ~= nil and uses_eof then
|
|
end_range({time = state.duration or infinity})
|
|
end
|
|
end
|
|
|
|
state.chapter_ranges = state.chapter_ranges or {}
|
|
state.chapter_ranges[#state.chapter_ranges + 1] = chapter_range
|
|
|
|
::continue::
|
|
end
|
|
|
|
function parse_chapters()
|
|
-- Sometimes state.duration is not initialized yet for some reason
|
|
state.duration = mp.get_property_native('duration')
|
|
|
|
local chapters = get_normalized_chapters()
|
|
|
|
if not chapters or not state.duration then return end
|
|
|
|
-- Reset custom ranges
|
|
for _, chapter_range in ipairs(state.chapter_ranges or {}) do
|
|
chapter_range.serialize(chapters)
|
|
end
|
|
|
|
-- Filter out chapters that were used as ranges
|
|
state.chapters = itable_remove(chapters, function(chapter)
|
|
return chapter._uosc_used_as_range_point == true
|
|
end)
|
|
|
|
request_render()
|
|
end
|
|
|
|
-- CONTEXT MENU SERIALIZATION
|
|
|
|
state.context_menu_items = (function()
|
|
local input_conf_path = mp.command_native({'expand-path', '~~/input.conf'})
|
|
local input_conf_meta, meta_error = utils.file_info(input_conf_path)
|
|
|
|
-- File doesn't exist
|
|
if not input_conf_meta or not input_conf_meta.is_file then return end
|
|
|
|
local items = {}
|
|
local items_by_command = {}
|
|
local submenus_by_id = {}
|
|
|
|
for line in io.lines(input_conf_path) do
|
|
local key, command, title = string.match(line,
|
|
' *([%S]+) +(.*) #! *(.*)')
|
|
if key then
|
|
local is_dummy = key:sub(1, 1) == '#'
|
|
local submenu_id = ''
|
|
local target_menu = items
|
|
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 submenus_by_id[submenu_id] then
|
|
submenus_by_id[submenu_id] =
|
|
{title = title_part, items = {}}
|
|
target_menu[#target_menu + 1] =
|
|
submenus_by_id[submenu_id]
|
|
end
|
|
|
|
target_menu = submenus_by_id[submenu_id].items
|
|
else
|
|
-- If command is already in menu, just append the key to it
|
|
if items_by_command[command] then
|
|
items_by_command[command].hint =
|
|
items_by_command[command].hint .. ', ' .. key
|
|
else
|
|
items_by_command[command] =
|
|
{
|
|
title = title_part,
|
|
hint = not is_dummy and key or nil,
|
|
value = command
|
|
}
|
|
target_menu[#target_menu + 1] =
|
|
items_by_command[command]
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
if #items > 0 then return items end
|
|
end)()
|
|
|
|
-- EVENT HANDLERS
|
|
|
|
function create_state_setter(name)
|
|
return function(_, value)
|
|
state[name] = value
|
|
dispatch_event_to_elements('prop_' .. name, value)
|
|
request_render()
|
|
end
|
|
end
|
|
|
|
function dispatch_event_to_elements(name, ...)
|
|
for _, element in pairs(elements) do
|
|
if element.proximity_raw == 0 then
|
|
element:maybe('on_' .. name, ...)
|
|
end
|
|
element:maybe('on_global_' .. name, ...)
|
|
end
|
|
end
|
|
|
|
function create_event_to_elements_dispatcher(name, ...)
|
|
return function(...) dispatch_event_to_elements(name, ...) end
|
|
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_proximity',
|
|
element:get_effective_proximity(), 0,
|
|
function()
|
|
element.forced_proximity = nil
|
|
end)
|
|
end
|
|
end
|
|
|
|
cursor.hidden = true
|
|
update_proximities()
|
|
dispatch_event_to_elements('mouse_leave')
|
|
end
|
|
|
|
function handle_mouse_enter()
|
|
cursor.hidden = false
|
|
cursor.x, cursor.y = mp.get_mouse_pos()
|
|
tween_element_stop(state)
|
|
dispatch_event_to_elements('mouse_enter')
|
|
end
|
|
|
|
function handle_mouse_move()
|
|
-- Handle case when we are in cursor hidden state but not left the actual
|
|
-- window (i.e. when autohide simulates mouse_leave).
|
|
if cursor.hidden then
|
|
handle_mouse_enter()
|
|
return
|
|
end
|
|
|
|
cursor.x, cursor.y = mp.get_mouse_pos()
|
|
update_proximities()
|
|
dispatch_event_to_elements('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 navigate_directory(direction)
|
|
local path = mp.get_property_native("path")
|
|
|
|
if not path or is_protocol(path) then return end
|
|
|
|
local next_file = get_adjacent_file(path, direction, options.media_types)
|
|
|
|
if next_file then
|
|
mp.commandv("loadfile",
|
|
utils.join_path(serialize_path(path).dirname, next_file))
|
|
end
|
|
end
|
|
|
|
function load_file_in_current_directory(index)
|
|
local path = mp.get_property_native("path")
|
|
|
|
if not path or is_protocol(path) then return end
|
|
|
|
local dirname = serialize_path(path).dirname
|
|
local files = get_files_in_directory(dirname, options.media_types)
|
|
|
|
if not files then return end
|
|
if index < 0 then index = #files + index + 1 end
|
|
|
|
if files[index] then
|
|
mp.commandv("loadfile", utils.join_path(dirname, files[index]))
|
|
end
|
|
end
|
|
|
|
-- MENUS
|
|
|
|
function create_select_tracklist_type_menu_opener(menu_title, track_type,
|
|
track_prop)
|
|
return function()
|
|
if menu:is_open(track_type) then
|
|
menu:close()
|
|
return
|
|
end
|
|
|
|
local items = {}
|
|
local active_item = nil
|
|
|
|
for index, track in ipairs(mp.get_property_native('track-list')) do
|
|
if track.type == track_type then
|
|
if track.selected then active_item = track.id end
|
|
|
|
items[#items + 1] = {
|
|
title = (track.title and track.title or 'Track ' .. track.id),
|
|
hint = track.lang and track.lang:upper() or nil,
|
|
value = track.id
|
|
}
|
|
end
|
|
end
|
|
|
|
-- Add option to disable a subtitle track. This works for all tracks,
|
|
-- but why would anyone want to disable audio or video? Better to not
|
|
-- let people mistakenly select what is unwanted 99.999% of the time.
|
|
-- If I'm mistaken and there is an active need for this, feel free to
|
|
-- open an issue.
|
|
if track_type == 'sub' then
|
|
active_item = active_item and active_item + 1 or 1
|
|
table.insert(items, 1, {hint = 'disabled', value = nil})
|
|
end
|
|
|
|
menu:open(items, function(id)
|
|
mp.commandv('set', track_prop, id and id or 'no')
|
|
|
|
-- If subtitle track was selected, assume user also wants to see it
|
|
if id and track_type == 'sub' then
|
|
mp.commandv('set', 'sub-visibility', 'yes')
|
|
end
|
|
|
|
menu:close()
|
|
end, {type = track_type, title = menu_title, active_item = active_item})
|
|
end
|
|
end
|
|
|
|
-- `menu_options`:
|
|
-- **allowed_types** - table with file extensions to display
|
|
-- **active_path** - full path of a file to preselect
|
|
-- Rest of the options are passed to `menu:open()`
|
|
function open_file_navigation_menu(directory, handle_select, menu_options)
|
|
directory = serialize_path(directory)
|
|
local directories, error = utils.readdir(directory.path, 'dirs')
|
|
local files, error = get_files_in_directory(directory.path,
|
|
menu_options.allowed_types)
|
|
|
|
if not files or not directories then
|
|
msg.error('Retrieving files from ' .. directory .. ' failed: ' ..
|
|
(error or ''))
|
|
return
|
|
end
|
|
|
|
-- Files are already sorted
|
|
table.sort(directories, word_order_comparator)
|
|
|
|
-- Pre-populate items with parent directory selector if not at root
|
|
local items = not directory.dirname and {} or
|
|
{
|
|
{title = '..', hint = 'parent dir', value = directory.dirname}
|
|
}
|
|
|
|
for _, dir in ipairs(directories) do
|
|
local serialized = serialize_path(utils.join_path(directory.path, dir))
|
|
items[#items + 1] = {
|
|
title = serialized.basename,
|
|
value = serialized.path,
|
|
hint = '/'
|
|
}
|
|
end
|
|
|
|
menu_options.active_item = nil
|
|
|
|
for _, file in ipairs(files) do
|
|
local serialized = serialize_path(utils.join_path(directory.path, file))
|
|
local item_index = #items + 1
|
|
|
|
items[item_index] = {
|
|
title = serialized.basename,
|
|
value = serialized.path
|
|
}
|
|
|
|
if menu_options.active_path == serialized.path then
|
|
menu_options.active_item = item_index
|
|
end
|
|
end
|
|
|
|
menu_options.title = directory.basename .. '/'
|
|
|
|
menu:open(items, function(path)
|
|
local meta, error = utils.file_info(path)
|
|
|
|
if not meta then
|
|
msg.error('Retrieving file info for ' .. path .. ' failed: ' ..
|
|
(error or ''))
|
|
return
|
|
end
|
|
|
|
if meta.is_dir then
|
|
open_file_navigation_menu(path, handle_select, menu_options)
|
|
else
|
|
handle_select(path)
|
|
menu:close()
|
|
end
|
|
end, menu_options)
|
|
end
|
|
|
|
-- VALUE SERIALIZATION/NORMALIZATION
|
|
|
|
options.proximity_out =
|
|
math.max(options.proximity_out, options.proximity_in + 1)
|
|
options.chapters = itable_find({'dots', 'lines', 'lines-top', 'lines-bottom'},
|
|
options.chapters) and options.chapters or 'none'
|
|
options.media_types = split(options.media_types, ' *, *')
|
|
options.subtitle_types = split(options.subtitle_types, ' *, *')
|
|
options.timeline_cached_ranges = (function()
|
|
if options.timeline_cached_ranges == '' or options.timeline_cached_ranges ==
|
|
'no' then return nil end
|
|
local parts = split(options.timeline_cached_ranges, ':')
|
|
return parts[1] and {color = parts[1], opacity = tonumber(parts[2])} or nil
|
|
end)()
|
|
|
|
-- HOOKS
|
|
mp.register_event('file-loaded', parse_chapters)
|
|
mp.observe_property('chapter-list', 'native', parse_chapters)
|
|
mp.observe_property('duration', 'number', create_state_setter('duration'))
|
|
mp.observe_property('media-title', 'string', create_state_setter('media_title'))
|
|
mp.observe_property('fullscreen', 'bool', create_state_setter('fullscreen'))
|
|
mp.observe_property('window-maximized', 'bool', create_state_setter('maximized'))
|
|
mp.observe_property('idle-active', 'bool', create_state_setter('idle'))
|
|
mp.observe_property('speed', 'number', create_state_setter('speed'))
|
|
mp.observe_property('pause', 'bool', create_state_setter('pause'))
|
|
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('playback-time', 'number', function(name, val)
|
|
-- Ignore the initial call with nil value
|
|
if val == nil then return end
|
|
|
|
state.position = val
|
|
state.elapsed_seconds = val
|
|
state.elapsed_time = state.elapsed_seconds and
|
|
mp.format_time(state.elapsed_seconds) or nil
|
|
state.remaining_seconds = mp.get_property_native('playtime-remaining')
|
|
state.remaining_time = state.remaining_seconds and
|
|
mp.format_time(state.remaining_seconds) or nil
|
|
|
|
request_render()
|
|
end)
|
|
mp.observe_property('osd-dimensions', 'native', function(name, val)
|
|
update_display_dimensions()
|
|
request_render()
|
|
end)
|
|
mp.observe_property('demuxer-cache-state', 'native', function(prop, cache_state)
|
|
if cache_state == nil then
|
|
state.cached_ranges = nil
|
|
return
|
|
end
|
|
local cache_ranges = cache_state['seekable-ranges']
|
|
state.cached_ranges = #cache_ranges > 0 and cache_ranges or nil
|
|
end)
|
|
|
|
-- CONTROLS
|
|
|
|
-- Mouse movement key binds
|
|
local base_keybinds = {
|
|
{'mouse_move', handle_mouse_move}, {'mouse_leave', handle_mouse_leave},
|
|
{'mouse_enter', handle_mouse_enter}
|
|
}
|
|
if options.pause_on_click_shorter_than > 0 then
|
|
-- Cycles pause when click is shorter than `options.pause_on_click_shorter_than`
|
|
-- while filtering out double clicks.
|
|
local duration_seconds = options.pause_on_click_shorter_than / 1000
|
|
local last_down_event;
|
|
local click_timer = mp.add_timeout(duration_seconds,
|
|
function() mp.command('cycle pause') end);
|
|
click_timer:kill()
|
|
base_keybinds[#base_keybinds + 1] = {
|
|
'mbtn_left', function()
|
|
if mp.get_time() - last_down_event < duration_seconds then
|
|
click_timer:resume()
|
|
end
|
|
end, function()
|
|
if click_timer:is_enabled() then
|
|
click_timer:kill()
|
|
last_down_event = 0
|
|
else
|
|
last_down_event = mp.get_time()
|
|
end
|
|
end
|
|
}
|
|
end
|
|
mp.set_key_bindings(base_keybinds, 'mouse_movement', 'force')
|
|
mp.enable_key_bindings('mouse_movement', 'allow-vo-dragging+allow-hide-cursor')
|
|
|
|
-- Context based key bind groups
|
|
|
|
forced_key_bindings = (function()
|
|
mp.set_key_bindings({
|
|
{
|
|
'mbtn_left', create_event_to_elements_dispatcher('mbtn_left_up'),
|
|
create_event_to_elements_dispatcher('mbtn_left_down')
|
|
}, {'mbtn_left_dbl', 'ignore'}
|
|
}, 'mouse_buttons', 'force')
|
|
mp.set_key_bindings({
|
|
{'wheel_up', create_event_to_elements_dispatcher('wheel_up')},
|
|
{'wheel_down', create_event_to_elements_dispatcher('wheel_down')}
|
|
}, 'wheel', 'force')
|
|
|
|
local groups = {}
|
|
for _, group in ipairs({'mouse_buttons', 'wheel'}) do
|
|
groups[group] = {
|
|
is_enabled = false,
|
|
enable = function(this)
|
|
if this.is_enabled then return end
|
|
this.is_enabled = true
|
|
mp.enable_key_bindings(group)
|
|
end,
|
|
disable = function(this)
|
|
if not this.is_enabled then return end
|
|
this.is_enabled = false
|
|
mp.disable_key_bindings(group)
|
|
end
|
|
}
|
|
end
|
|
return groups
|
|
end)()
|
|
|
|
-- KEY BINDABLE FEATURES
|
|
|
|
mp.add_key_binding(nil, 'peek-timeline', function()
|
|
if elements.timeline.proximity > 0.5 then
|
|
elements.timeline:tween_property('proximity',
|
|
elements.timeline.proximity, 0)
|
|
else
|
|
elements.timeline:tween_property('proximity',
|
|
elements.timeline.proximity, 1)
|
|
end
|
|
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, 'menu', function()
|
|
if menu:is_open('menu') then
|
|
menu:close()
|
|
elseif state.context_menu_items then
|
|
menu:open(state.context_menu_items,
|
|
function(command) mp.command(command) end, {type = 'menu'})
|
|
end
|
|
end)
|
|
mp.add_key_binding(nil, 'load-subtitles', function()
|
|
if menu:is_open('load-subtitles') then
|
|
menu:close()
|
|
return
|
|
end
|
|
|
|
local path = mp.get_property_native('path')
|
|
if path and not is_protocol(path) then
|
|
open_file_navigation_menu(serialize_path(path).dirname, function(path)
|
|
mp.commandv('sub-add', path)
|
|
end, {type = 'load-subtitles', allowed_types = options.subtitle_types})
|
|
end
|
|
end)
|
|
mp.add_key_binding(nil, 'subtitles', create_select_tracklist_type_menu_opener(
|
|
'Subtitles', 'sub', 'sid'))
|
|
mp.add_key_binding(nil, 'audio', create_select_tracklist_type_menu_opener(
|
|
'Audio', 'audio', 'aid'))
|
|
mp.add_key_binding(nil, 'video', create_select_tracklist_type_menu_opener(
|
|
'Video', 'video', 'vid'))
|
|
mp.add_key_binding(nil, 'playlist', function()
|
|
if menu:is_open('playlist') then
|
|
menu:close()
|
|
return
|
|
end
|
|
|
|
function serialize_playlist()
|
|
local pos = mp.get_property_number('playlist-pos-1', 0)
|
|
local items = {}
|
|
local active_item
|
|
for index, item in ipairs(mp.get_property_native('playlist')) do
|
|
local is_url = item.filename:find('://')
|
|
items[index] = {
|
|
title = is_url and item.filename or
|
|
serialize_path(item.filename).basename,
|
|
hint = tostring(index),
|
|
value = index
|
|
}
|
|
|
|
if index == pos then active_item = index end
|
|
end
|
|
return items, active_item
|
|
end
|
|
|
|
-- Update active index and playlist content on playlist changes
|
|
function handle_playlist_change()
|
|
if menu:is_open('playlist') then
|
|
local items, active_item = serialize_playlist()
|
|
elements.menu:set_items(items, {
|
|
active_item = active_item,
|
|
selected_item = active_item
|
|
})
|
|
end
|
|
end
|
|
|
|
local items, active_item = serialize_playlist()
|
|
|
|
menu:open(items, function(index)
|
|
mp.commandv('set', 'playlist-pos-1', tostring(index))
|
|
end, {
|
|
type = 'playlist',
|
|
title = 'Playlist',
|
|
active_item = active_item,
|
|
on_open = function()
|
|
mp.observe_property('playlist', 'native', handle_playlist_change)
|
|
mp.observe_property('playlist-pos-1', 'native',
|
|
handle_playlist_change)
|
|
end,
|
|
on_close = function()
|
|
mp.unobserve_property(handle_playlist_change)
|
|
end
|
|
})
|
|
end)
|
|
mp.add_key_binding(nil, 'chapters', function()
|
|
if menu:is_open('chapters') then
|
|
menu:close()
|
|
return
|
|
end
|
|
|
|
local items = {}
|
|
local chapters = get_normalized_chapters()
|
|
|
|
for index, chapter in ipairs(chapters) do
|
|
items[#items + 1] = {
|
|
title = chapter.title or '',
|
|
hint = mp.format_time(chapter.time),
|
|
value = chapter.time
|
|
}
|
|
end
|
|
|
|
-- Select first chapter from the end with time lower
|
|
-- than current playing position (with 100ms leeway).
|
|
function get_selected_chapter_index()
|
|
local position = mp.get_property_native('playback-time')
|
|
if not position then return nil end
|
|
for index = #items, 1, -1 do
|
|
if position - 0.1 > items[index].value then return index end
|
|
end
|
|
end
|
|
|
|
-- Update selected chapter in chapter navigation menu
|
|
function seek_handler()
|
|
if menu:is_open('chapters') then
|
|
elements.menu:activate_index(get_selected_chapter_index())
|
|
end
|
|
end
|
|
|
|
menu:open(items, function(time)
|
|
mp.commandv('seek', tostring(time), 'absolute')
|
|
end, {
|
|
type = 'chapters',
|
|
title = 'Chapters',
|
|
active_item = get_selected_chapter_index(),
|
|
on_open = function() mp.register_event('seek', seek_handler) end,
|
|
on_close = function() mp.unregister_event(seek_handler) end
|
|
})
|
|
end)
|
|
mp.add_key_binding(nil, 'show-in-directory', function()
|
|
local path = mp.get_property_native('path')
|
|
|
|
-- Ignore URLs
|
|
if not path or is_protocol(path) then return end
|
|
|
|
path = normalize_path(path)
|
|
|
|
if state.os == 'windows' then
|
|
utils.subprocess_detached({
|
|
args = {'explorer', '/select,', path},
|
|
cancellable = false
|
|
})
|
|
elseif state.os == 'macos' then
|
|
utils.subprocess_detached({
|
|
args = {'open', '-R', path},
|
|
cancellable = false
|
|
})
|
|
elseif state.os == 'linux' then
|
|
local result = utils.subprocess({
|
|
args = {'nautilus', path},
|
|
cancellable = false
|
|
})
|
|
|
|
-- Fallback opens the folder with xdg-open instead
|
|
if result.status ~= 0 then
|
|
utils.subprocess({
|
|
args = {'xdg-open', serialize_path(path).dirname},
|
|
cancellable = false
|
|
})
|
|
end
|
|
end
|
|
end)
|
|
mp.add_key_binding(nil, 'open-file', function()
|
|
if menu:is_open('open-file') then
|
|
menu:close()
|
|
return
|
|
end
|
|
|
|
local path = mp.get_property_native('path')
|
|
local directory
|
|
local active_file
|
|
|
|
if path == nil or is_protocol(path) then
|
|
local path = serialize_path(mp.command_native({'expand-path', '~/'}))
|
|
directory = path.path
|
|
active_file = nil
|
|
else
|
|
local path = serialize_path(path)
|
|
directory = path.dirname
|
|
active_file = path.path
|
|
end
|
|
|
|
-- Update selected file in directory navigation menu
|
|
function handle_file_loaded()
|
|
if menu:is_open('open-file') then
|
|
local path = normalize_path(mp.get_property_native('path'))
|
|
elements.menu:activate_value(path)
|
|
elements.menu:select_value(path)
|
|
end
|
|
end
|
|
|
|
open_file_navigation_menu(directory,
|
|
function(path) mp.commandv('loadfile', path) end,
|
|
{
|
|
type = 'open-file',
|
|
allowed_types = options.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, 'next', function()
|
|
if mp.get_property_native('playlist-count') > 1 then
|
|
mp.command('playlist-next')
|
|
else
|
|
navigate_directory('forward')
|
|
end
|
|
end)
|
|
mp.add_key_binding(nil, 'prev', function()
|
|
if mp.get_property_native('playlist-count') > 1 then
|
|
mp.command('playlist-prev')
|
|
else
|
|
navigate_directory('backward')
|
|
end
|
|
end)
|
|
mp.add_key_binding(nil, 'next-file',
|
|
function() navigate_directory('forward') end)
|
|
mp.add_key_binding(nil, 'prev-file',
|
|
function() navigate_directory('backward') end)
|
|
mp.add_key_binding(nil, 'first', function()
|
|
if mp.get_property_native('playlist-count') > 1 then
|
|
mp.commandv('set', 'playlist-pos-1', '1')
|
|
else
|
|
load_file_in_current_directory(1)
|
|
end
|
|
end)
|
|
mp.add_key_binding(nil, 'last', function()
|
|
local playlist_count = mp.get_property_native('playlist-count')
|
|
if playlist_count > 1 then
|
|
mp.commandv('set', 'playlist-pos-1', tostring(playlist_count))
|
|
else
|
|
load_file_in_current_directory(-1)
|
|
end
|
|
end)
|
|
mp.add_key_binding(nil, 'first-file',
|
|
function() load_file_in_current_directory(1) end)
|
|
mp.add_key_binding(nil, 'last-file',
|
|
function() load_file_in_current_directory(-1) end)
|
|
mp.add_key_binding(nil, 'delete-file-next', function()
|
|
local path = mp.get_property_native('path')
|
|
|
|
if not path or is_protocol(path) then return end
|
|
|
|
path = normalize_path(path)
|
|
local playlist_count = mp.get_property_native('playlist-count')
|
|
|
|
if playlist_count > 1 then
|
|
mp.commandv('playlist-remove', 'current')
|
|
else
|
|
local next_file =
|
|
get_adjacent_file(path, 'forward', options.media_types)
|
|
|
|
if menu:is_open('open-file') then
|
|
elements.menu:delete_value(path)
|
|
end
|
|
|
|
if next_file then
|
|
mp.commandv('loadfile', next_file)
|
|
else
|
|
mp.commandv('stop')
|
|
end
|
|
end
|
|
|
|
os.remove(path)
|
|
end)
|
|
mp.add_key_binding(nil, 'delete-file-quit', function()
|
|
local path = mp.get_property_native('path')
|
|
if not path or is_protocol(path) then return end
|
|
os.remove(normalize_path(path))
|
|
mp.command('quit')
|
|
end)
|
|
mp.add_key_binding(nil, 'open-config-directory', function()
|
|
local config = serialize_path(mp.command_native(
|
|
{'expand-path', '~~/mpv.conf'}))
|
|
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})
|
|
end)
|