dots/.config/mpv/scripts/oscc.lua

2498 lines
79 KiB
Lua
Raw Normal View History

2022-09-24 12:25:22 +02:00
-- Based on https://github.com/mpv-player/mpv/blob/master/player/lua/osc.lua
local assdraw = require 'mp.assdraw'
local msg = require 'mp.msg'
local opt = require 'mp.options'
local utils = require 'mp.utils'
--
-- Parameters
--
-- default user option values
-- do not touch, change them in osc.conf
local user_opts = {
showwindowed = true, -- show OSC when windowed?
showfullscreen = true, -- show OSC when fullscreen?
scalewindowed = 1, -- scaling of the controller when windowed
scalefullscreen = 1, -- scaling of the controller when fullscreen
scaleforcedwindow = 2, -- scaling when rendered on a forced window
vidscale = false, -- scale the controller with the video?
barmargin = 0, -- vertical margin of top/bottombar
boxalpha = 0, -- alpha of the background box,
-- 0 (opaque) to 255 (fully transparent)
seekbaralpha = 255,
disabledbtnsalpha = 128,
hidetimeout = 3500, -- duration in ms until the OSC hides if no
-- mouse movement. enforced non-negative for the
-- user, but internally negative is "always-on".
fadeduration = 0, -- duration of fade out in ms, 0 = no fade
deadzonesize = 0, -- size of deadzone
minmousemove = 0, -- minimum amount of pixels the mouse has to
-- move between ticks to make the OSC show up
iamaprogrammer = false, -- use native mpv values and disable OSC
-- internal track list management (and some
-- functions that depend on it)
layout = "bottombar",
oscboxheight = 80,
oscboxwidth = 768,
seekbarstyle = "bar", -- bar, knob
pbseekbarheight = 8, -- playback seekbar, height of the box and handle
pbseekbarhandlesize = 8,
volseekbar = true, -- volume seekbar visibility, true/nil
volseekbarstyle = "bar", -- volknob, bar
volboxheight = 2, -- volume seekbar height
volboxwidth = 64,
volhandlesize = 8,
handlesize = 1, -- size ratio of the diamond and knob handle
seekrangestyle = "inverted",-- bar, line, slider, inverted or none
seekrangeseparate = true, -- wether the seekranges overlay on the bar-style seekbar
seekrangealpha = 210, -- transparency of seekranges
seekbarkeyframes = true, -- use keyframes when dragging the seekbar
title = "${media-title}", -- string compatible with property-expansion
-- to be shown as OSC title
tooltipborder = 1, -- border of tooltip in bottom/topbar
tooltipfontsize = 17.5,
tooltipseekbar = false, -- following cursor bar over the playback seekbar
tooltipseekbaralpha = 230,
timetotal = false, -- display total time instead of remaining time?
timems = false, -- display timecodes with milliseconds?
visibility = "auto", -- only used at init to set visibility_mode(...)
livemarkers = true, -- update seekbar chapter markers on duration change
chapters_osd = true, -- whether to show chapters OSD on next/prev
playlist_osd = true, -- whether to show playlist OSD on next/prev
chapter_fmt = "Chapter: %s", -- chapter print format for seekbar-hover. "no" to disable
}
-- read options from config and command-line
opt.read_options(user_opts, "osc", function(list) update_options(list) end)
local osc_param = { -- calculated by osc_init()
playresy = 0, -- canvas size Y
playresx = 0, -- canvas size X
display_aspect = 1,
unscaled_y = 0,
areas = {},
video_margins = {
l = 0, r = 0, t = 0, b = 0, -- left/right/top/bottom
},
}
local osc_styles = {
smallButtonsLlabel = "{\\fs13\\fn" .. mp.get_property("options/osd-font") .. "}",
elementDown = "{\\1c&H9E9E9E}",
box = "{\\rDefault\\blur0\\bord0\\1c&H101010\\3c&HEFEFEF}",
seekbar = "{\\1c&HEFEFEF}",
--buttons
buttonsLeft = "{\\blur0\\bord0\\1c&HEFEFEF\\3c&HEFEFEF\\fs17.5\\fnoscc}",
buttonsRight = "{\\blur0\\bord0\\1c&HEFEFEF\\3c&HEFEFEF\\fs17.5\\fnoscc}",
--times
timecodesBar = "{\\blur0\\bord0\\1c&HEFEFEF\\3c&HEFEFEF\\fs17.5}",
timePosBar = "{\\blur2.4\\bord".. user_opts.tooltipborder .."\\1c&HEFEFEF\\3c&H000000\\fs" .. user_opts.tooltipfontsize .."}",
}
-- internal states, do not touch
local state = {
showtime, -- time of last invocation (last mouse move)
osc_visible = false,
anistart, -- time when the animation started
anitype, -- current type of animation
animation, -- current animation alpha
mouse_down_counter = 0, -- used for softrepeat
active_element = nil, -- nil = none, 0 = background, 1+ = see elements[]
active_event_source = nil, -- the "button" that issued the current event
rightTC_trem = not user_opts.timetotal, -- if the right timecode should display total or remaining time
tc_ms = user_opts.timems, -- Should the timecodes display their time with milliseconds
mp_screen_sizeX, mp_screen_sizeY, -- last screen-resolution, to detect resolution changes to issue reINITs
initREQ = false, -- is a re-init request pending?
marginsREQ = false, -- is a margins update pending?
last_mouseX, last_mouseY, -- last mouse position, to detect significant mouse movement
mouse_in_window = false,
message_text,
message_hide_timer,
fullscreen = false,
tick_timer = nil,
tick_last_time = 0, -- when the last tick() was run
hide_timer = nil,
cache_state = nil,
idle = false,
enabled = true,
input_enabled = true,
showhide_enabled = false,
dmx_cache = 0,
using_video_margins = false,
border = true,
maximized = false,
osd = mp.create_osd_overlay("ass-events"),
chapter_list = {}, -- sorted by time
}
local window_control_box_width = 80
local tick_delay = 0.03
local is_december = os.date("*t").month == 12
--
-- Helperfunctions
--
function kill_animation()
state.anistart = nil
state.animation = nil
state.anitype = nil
end
function set_osd(res_x, res_y, text)
if state.osd.res_x == res_x and
state.osd.res_y == res_y and
state.osd.data == text then
return
end
state.osd.res_x = res_x
state.osd.res_y = res_y
state.osd.data = text
state.osd.z = 1000
state.osd:update()
end
local margins_opts = {
{"l", "video-margin-ratio-left"},
{"r", "video-margin-ratio-right"},
{"t", "video-margin-ratio-top"},
{"b", "video-margin-ratio-bottom"},
}
-- scale factor for translating between real and virtual ASS coordinates
function get_virt_scale_factor()
local w, h = mp.get_osd_size()
if w <= 0 or h <= 0 then
return 0, 0
end
return osc_param.playresx / w, osc_param.playresy / h
end
-- return mouse position in virtual ASS coordinates (playresx/y)
function get_virt_mouse_pos()
if state.mouse_in_window then
local sx, sy = get_virt_scale_factor()
local x, y = mp.get_mouse_pos()
return x * sx, y * sy
else
return -1, -1
end
end
function set_virt_mouse_area(x0, y0, x1, y1, name)
local sx, sy = get_virt_scale_factor()
mp.set_mouse_area(x0 / sx, y0 / sy, x1 / sx, y1 / sy, name)
end
function scale_value(x0, x1, y0, y1, val)
local m = (y1 - y0) / (x1 - x0)
local b = y0 - (m * x0)
return (m * val) + b
end
-- returns hitbox spanning coordinates (top left, bottom right corner)
-- according to alignment
function get_hitbox_coords(x, y, an, w, h)
local alignments = {
[1] = function () return x, y-h, x+w, y end,
[2] = function () return x-(w/2), y-h, x+(w/2), y end,
[3] = function () return x-w, y-h, x, y end,
[4] = function () return x, y-(h/2), x+w, y+(h/2) end,
[5] = function () return x-(w/2), y-(h/2), x+(w/2), y+(h/2) end,
[6] = function () return x-w, y-(h/2), x, y+(h/2) end,
[7] = function () return x, y, x+w, y+h end,
[8] = function () return x-(w/2), y, x+(w/2), y+h end,
[9] = function () return x-w, y, x, y+h end,
}
return alignments[an]()
end
function get_hitbox_coords_geo(geometry)
return get_hitbox_coords(geometry.x, geometry.y, geometry.an,
geometry.w, geometry.h)
end
function get_element_hitbox(element)
return element.hitbox.x1, element.hitbox.y1,
element.hitbox.x2, element.hitbox.y2
end
function mouse_hit(element)
return mouse_hit_coords(get_element_hitbox(element))
end
function mouse_hit_coords(bX1, bY1, bX2, bY2)
local mX, mY = get_virt_mouse_pos()
return (mX >= bX1 and mX <= bX2 and mY >= bY1 and mY <= bY2)
end
function limit_range(min, max, val)
if val > max then
val = max
elseif val < min then
val = min
end
return val
end
-- translate value into element coordinates
function get_slider_ele_pos_for(element, val)
local ele_pos = scale_value(
element.slider.min.value, element.slider.max.value,
element.slider.min.ele_pos, element.slider.max.ele_pos,
val)
return limit_range(
element.slider.min.ele_pos, element.slider.max.ele_pos,
ele_pos)
end
-- translates global (mouse) coordinates to value
function get_slider_value_at(element, glob_pos)
local val = scale_value(
element.slider.min.glob_pos, element.slider.max.glob_pos,
element.slider.min.value, element.slider.max.value, glob_pos)
return limit_range(
element.slider.min.value, element.slider.max.value, val)
end
-- get value at current mouse position
function get_slider_value(element)
return get_slider_value_at(element, get_virt_mouse_pos())
end
function countone(val)
if not (user_opts.iamaprogrammer) then
val = val + 1
end
return val
end
-- align: -1 .. +1
-- frame: size of the containing area
-- obj: size of the object that should be positioned inside the area
-- margin: min. distance from object to frame (as long as -1 <= align <= +1)
function get_align(align, frame, obj, margin)
return (frame / 2) + (((frame / 2) - margin - (obj / 2)) * align)
end
-- multiplies two alpha values, formular can probably be improved
function mult_alpha(alphaA, alphaB)
return 255 - (((1-(alphaA/255)) * (1-(alphaB/255))) * 255)
end
function add_area(name, x1, y1, x2, y2)
-- create area if needed
if (osc_param.areas[name] == nil) then
osc_param.areas[name] = {}
end
table.insert(osc_param.areas[name], {x1=x1, y1=y1, x2=x2, y2=y2})
end
function ass_append_alpha(ass, alpha, modifier)
local ar = {}
for ai, av in pairs(alpha) do
av = mult_alpha(av, modifier)
if state.animation then
av = mult_alpha(av, state.animation)
end
ar[ai] = av
end
ass:append(string.format("{\\1a&H%X&\\2a&H%X&\\3a&H%X&\\4a&H%X&}",
ar[1], ar[2], ar[3], ar[4]))
end
function ass_draw_rr_h_cw(ass, x0, y0, x1, y1, r1, hexagon, r2)
if hexagon then
ass:hexagon_cw(x0, y0, x1, y1, r1, r2)
else
ass:round_rect_cw(x0, y0, x1, y1, r1, r2)
end
end
function ass_draw_rr_h_ccw(ass, x0, y0, x1, y1, r1, hexagon, r2)
if hexagon then
ass:hexagon_ccw(x0, y0, x1, y1, r1, r2)
else
ass:round_rect_ccw(x0, y0, x1, y1, r1, r2)
end
end
--
-- Tracklist Management
--
local nicetypes = {video = "Video", audio = "Audio", sub = "Subtitle"}
-- updates the OSC internal playlists, should be run each time the track-layout changes
function update_tracklist()
local tracktable = mp.get_property_native("track-list", {})
-- by osc_id
tracks_osc = {}
tracks_osc.video, tracks_osc.audio, tracks_osc.sub = {}, {}, {}
-- by mpv_id
tracks_mpv = {}
tracks_mpv.video, tracks_mpv.audio, tracks_mpv.sub = {}, {}, {}
for n = 1, #tracktable do
if not (tracktable[n].type == "unknown") then
local type = tracktable[n].type
local mpv_id = tonumber(tracktable[n].id)
-- by osc_id
table.insert(tracks_osc[type], tracktable[n])
-- by mpv_id
tracks_mpv[type][mpv_id] = tracktable[n]
tracks_mpv[type][mpv_id].osc_id = #tracks_osc[type]
end
end
end
-- return a nice list of tracks of the given type (video, audio, sub)
function get_tracklist(type)
local msg = "Available " .. nicetypes[type] .. " Tracks: "
if #tracks_osc[type] == 0 then
msg = msg .. "none"
else
for n = 1, #tracks_osc[type] do
local track = tracks_osc[type][n]
local lang, title, selected = "unknown", "", ""
if not(track.lang == nil) then lang = track.lang end
if not(track.title == nil) then title = track.title end
if (track.id == tonumber(mp.get_property(type))) then
selected = ""
end
msg = msg.."\n"..selected.." "..n..": ["..lang.."] "..title
end
end
return msg
end
-- relatively change the track of given <type> by <next> tracks
--(+1 -> next, -1 -> previous)
function set_track(type, next)
local current_track_mpv, current_track_osc
if (mp.get_property(type) == "no") then
current_track_osc = 0
else
current_track_mpv = tonumber(mp.get_property(type))
current_track_osc = tracks_mpv[type][current_track_mpv].osc_id
end
local new_track_osc = (current_track_osc + next) % (#tracks_osc[type] + 1)
local new_track_mpv
if new_track_osc == 0 then
new_track_mpv = "no"
else
new_track_mpv = tracks_osc[type][new_track_osc].id
end
mp.commandv("set", type, new_track_mpv)
if (new_track_osc == 0) then
show_message(nicetypes[type] .. " Track: none")
else
show_message(nicetypes[type] .. " Track: "
.. new_track_osc .. "/" .. #tracks_osc[type]
.. " [".. (tracks_osc[type][new_track_osc].lang or "unknown") .."] "
.. (tracks_osc[type][new_track_osc].title or ""))
end
end
-- get the currently selected track of <type>, OSC-style counted
function get_track(type)
local track = mp.get_property(type)
if track ~= "no" and track ~= nil then
local tr = tracks_mpv[type][tonumber(track)]
if tr then
return tr.osc_id
end
end
return 0
end
-- Element Management
--
local elements = {}
function prepare_elements()
-- remove elements without layout or invisble
local elements2 = {}
for n, element in pairs(elements) do
if not (element.layout == nil) and (element.visible) then
table.insert(elements2, element)
end
end
elements = elements2
function elem_compare (a, b)
return a.layout.layer < b.layout.layer
end
table.sort(elements, elem_compare)
for _,element in pairs(elements) do
local elem_geo = element.layout.geometry
-- Calculate the hitbox
local bX1, bY1, bX2, bY2 = get_hitbox_coords_geo(elem_geo)
element.hitbox = {x1 = bX1, y1 = bY1, x2 = bX2, y2 = bY2}
local style_ass = assdraw.ass_new()
-- prepare static elements
style_ass:append("{}") -- hack to troll new_event into inserting a \n
style_ass:new_event()
style_ass:pos(elem_geo.x, elem_geo.y)
style_ass:an(elem_geo.an)
style_ass:append(element.layout.style)
element.style_ass = style_ass
local static_ass = assdraw.ass_new()
if (element.type == "box") then
--draw box
static_ass:draw_start()
ass_draw_rr_h_cw(static_ass, 0, 0, elem_geo.w, elem_geo.h,
element.layout.box.radius, element.layout.box.hexagon)
static_ass:draw_stop()
elseif (element.type == "slider") then
--draw static slider parts
local r1 = 0
local r2 = 0
local slider_lo = element.layout.slider
-- offset between element outline and drag-area
local foV = slider_lo.border + slider_lo.gap
-- calculate positions of min and max points
if (slider_lo.stype ~= "bar") then
r1 = elem_geo.h / 2
element.slider.min.ele_pos = elem_geo.h / 2
element.slider.max.ele_pos = elem_geo.w - (elem_geo.h / 2)
if (slider_lo.stype == "diamond") then
r2 = (elem_geo.h - 2 * slider_lo.border) / 2
elseif (slider_lo.stype == "knob") then
r2 = r1
elseif (slider_lo.stype == "volknob") then
r2 = r1
end
else
element.slider.min.ele_pos =
slider_lo.border + slider_lo.gap
element.slider.max.ele_pos =
elem_geo.w - (slider_lo.border + slider_lo.gap)
end
element.slider.min.glob_pos =
element.hitbox.x1 + element.slider.min.ele_pos
element.slider.max.glob_pos =
element.hitbox.x1 + element.slider.max.ele_pos
-- -- --
static_ass:draw_start()
-- the box
ass_draw_rr_h_cw(static_ass, 0, 0, elem_geo.w, elem_geo.h, r1, slider_lo.stype == "diamond")
-- the "hole"
ass_draw_rr_h_ccw(static_ass, slider_lo.border, slider_lo.border,
elem_geo.w - slider_lo.border, elem_geo.h - slider_lo.border,
r2, slider_lo.stype == "diamond")
-- marker nibbles
if not (element.slider.markerF == nil) then
local markers = element.slider.markerF()
for _,marker in pairs(markers) do
if (marker > element.slider.min.value) and
(marker < element.slider.max.value) then
local s = get_slider_ele_pos_for(element, marker)
local a = user_opts.pbseekbarheight / 2
local b = user_opts.pbseekbarhandlesize / 2
static_ass:rect_cw(s - 1.25, a + b,
s + 1.25, b - a);
end
end
end
end
element.static_ass = static_ass
-- if the element is supposed to be disabled,
-- style it accordingly and kill the eventresponders
if not (element.enabled) then
element.layout.alpha[1] = user_opts.disabledbtnsalpha
element.eventresponder = nil
end
end
end
--
-- Element Rendering
--
-- returns nil or a chapter element from the native property chapter-list
function get_chapter(possec)
local cl = state.chapter_list -- sorted, get latest before possec, if any
for n=#cl,1,-1 do
if possec >= cl[n].time then
return cl[n]
end
end
end
function render_elements(master_ass)
-- when the slider is dragged or hovered and we have a target chapter name
-- then we use it instead of the normal title. we calculate it before the
-- render iterations because the title may be rendered before the slider.
state.forced_title = nil
local se, ae = state.slider_element, elements[state.active_element]
if user_opts.chapter_fmt ~= "no" and se and (ae == se or (not ae and mouse_hit(se))) then
local dur = mp.get_property_number("duration", 0)
if dur > 0 then
local possec = get_slider_value(se) * dur / 100 -- of mouse pos
local ch = get_chapter(possec)
if ch and ch.title and ch.title ~= "" then
show_message(string.format(user_opts.chapter_fmt, ch.title))
end
end
end
for n=1, #elements do
local element = elements[n]
local style_ass = assdraw.ass_new()
style_ass:merge(element.style_ass)
ass_append_alpha(style_ass, element.layout.alpha, 0)
if element.eventresponder and (state.active_element == n) then
-- run render event functions
if not (element.eventresponder.render == nil) then
element.eventresponder.render(element)
end
if mouse_hit(element) then
-- mouse down styling
if (element.styledown) then
style_ass:append(osc_styles.elementDown)
end
if (element.softrepeat) and (state.mouse_down_counter >= 15
and state.mouse_down_counter % 5 == 0) then
element.eventresponder[state.active_event_source.."_down"](element)
end
state.mouse_down_counter = state.mouse_down_counter + 1
end
end
local elem_ass = assdraw.ass_new()
elem_ass:merge(style_ass)
if not (element.type == "button") then
elem_ass:merge(element.static_ass)
end
if (element.type == "slider") then
local slider_lo = element.layout.slider
local elem_geo = element.layout.geometry
local s_min = element.slider.min.value
local s_max = element.slider.max.value
-- draw pos marker
local foH, xp
local pos = element.slider.posF()
local foV = slider_lo.border + slider_lo.gap
local innerH = elem_geo.h - (2 * foV)
local seekRanges = element.slider.seekRangesF()
local seekRangeLineHeight = innerH / 5
local handlesize = user_opts.handlesize
if slider_lo.stype ~= "bar" then
foH = elem_geo.h / 2
else
foH = slider_lo.border + slider_lo.gap
end
if pos then
xp = get_slider_ele_pos_for(element, pos)
local r = (handlesize * innerH) / 2
local h = elem_geo.h / 2
local pbh = user_opts.pbseekbarheight / 2
local vbh = user_opts.volboxheight / 2
if slider_lo.stype == "knob" then
ass_draw_rr_h_cw(elem_ass, xp - r, foH - r, xp + r, foH + r,r)
ass_draw_rr_h_cw(elem_ass, 0, h - pbh, xp, h + pbh, 0)
elseif slider_lo.stype == "volknob" then
ass_draw_rr_h_cw(elem_ass, xp - r, foH - r, xp + r, foH + r,r)
ass_draw_rr_h_cw(elem_ass, 0, h - vbh, xp, h + vbh, 0)
else
local h = 0
if seekRanges and user_opts.seekrangeseparate and slider_lo.rtype ~= "inverted" then
h = seekRangeLineHeight
end
elem_ass:rect_cw(foH, foV, xp, elem_geo.h - foV - h)
if seekRanges and not user_opts.seekrangeseparate and slider_lo.rtype ~= "inverted" then
-- Punch holes for the seekRanges to be drawn later
for _,range in pairs(seekRanges) do
if range["start"] < pos then
local pstart = get_slider_ele_pos_for(element, range["start"])
local pend = xp
if pos > range["end"] then
pend = get_slider_ele_pos_for(element, range["end"])
end
elem_ass:rect_ccw(pstart, elem_geo.h - foV - seekRangeLineHeight, pend, elem_geo.h - foV)
end
end
end
end
if slider_lo.rtype == "slider" then
ass_draw_rr_h_cw(elem_ass, foH - innerH / 6, foH - innerH / 6,
xp, foH + innerH / 6,
innerH / 6, slider_lo.stype == "diamond", 0)
ass_draw_rr_h_cw(elem_ass, xp, foH - innerH / 15,
elem_geo.w - foH + innerH / 15, foH + innerH / 15,
0, slider_lo.stype == "diamond", innerH / 15)
for _,range in pairs(seekRanges or {}) do
local pstart = get_slider_ele_pos_for(element, range["start"])
local pend = get_slider_ele_pos_for(element, range["end"])
ass_draw_rr_h_ccw(elem_ass, pstart, foH - innerH / 21,
pend, foH + innerH / 21,
innerH / 21, slider_lo.stype == "diamond")
end
end
end
if seekRanges then
if slider_lo.rtype ~= "inverted" then
elem_ass:draw_stop()
elem_ass:merge(element.style_ass)
ass_append_alpha(elem_ass, element.layout.alpha, user_opts.seekrangealpha)
elem_ass:merge(element.static_ass)
end
for _,range in pairs(seekRanges) do
local pstart = get_slider_ele_pos_for(element, range["start"])
local pend = get_slider_ele_pos_for(element, range["end"])
if slider_lo.rtype == "slider" then
ass_draw_rr_h_cw(elem_ass, pstart, foH - innerH / 21,
pend, foH + innerH / 21,
innerH / 21, slider_lo.stype == "diamond")
elseif slider_lo.rtype == "line" then
if slider_lo.stype == "bar" then
elem_ass:rect_cw(pstart, elem_geo.h - foV - seekRangeLineHeight, pend, elem_geo.h - foV)
else
ass_draw_rr_h_cw(elem_ass, pstart - innerH / 8, foH - innerH / 8,
pend + innerH / 8, foH + innerH / 8,
innerH / 8, slider_lo.stype == "diamond")
end
elseif slider_lo.rtype == "bar" then
if slider_lo.stype ~= "bar" then
ass_draw_rr_h_cw(elem_ass, pstart - innerH / 2, foV,
pend + innerH / 2, foV + innerH,
innerH / 2, slider_lo.stype == "diamond")
elseif range["end"] >= (pos or 0) then
elem_ass:rect_cw(pstart, foV, pend, elem_geo.h - foV)
else
elem_ass:rect_cw(pstart, elem_geo.h - foV - seekRangeLineHeight, pend, elem_geo.h - foV)
end
elseif slider_lo.rtype == "inverted" then
if slider_lo.stype ~= "bar" then
ass_draw_rr_h_ccw(elem_ass, pstart, (elem_geo.h / 2) - 1, pend,
(elem_geo.h / 2) + 1,
1, slider_lo.stype == "diamond")
else
elem_ass:rect_ccw(pstart, (elem_geo.h / 2) - 1, pend, (elem_geo.h / 2) + 1)
end
end
end
end
elem_ass:draw_stop()
-- add tooltip
if not (element.slider.tooltipF == nil) then
if mouse_hit(element) then
local sliderpos = get_slider_value(element)
local tooltiplabel = element.slider.tooltipF(sliderpos)
local an = slider_lo.tooltip_an
local ty, tx
tx = get_virt_mouse_pos()
ty = element.hitbox.y1 - 8
if user_opts.layout == "topbar" then
ty = element.hitbox.y2 + user_opts.tooltipfontsize + 8
end
local ta = 55 --left margin
local tb = element.hitbox.x2 - ta --right margin
local playersx = osc_param.playresx
local oscboxwidth = user_opts.oscboxwidth
local sum = s_min + ta
if tx > sum then
ta = tx
if (tx > element.hitbox.x2 - 55) then
ta = tb
end
end
if playersx == oscboxwidth then
tx = ta
end
-- tooltip label
elem_ass:new_event()
elem_ass:pos(tx, ty)
elem_ass:an(an)
elem_ass:append(slider_lo.tooltip_style)
elem_ass:append(tooltiplabel)
-- tooltip seekbar
if user_opts.tooltipseekbar then
local ww = (playersx - oscboxwidth) / 2
elem_ass:merge(style_ass)
ass_append_alpha(elem_ass, element.layout.alpha, user_opts.tooltipseekbaralpha)
elem_ass:merge(element.static_ass)
elem_ass:rect_ccw(0, 0, tx - ww, elem_geo.h)
end
end
end
elseif (element.type == "button") then
local buttontext
if type(element.content) == "function" then
buttontext = element.content() -- function objects
elseif not (element.content == nil) then
buttontext = element.content -- text objects
end
local maxchars = element.layout.button.maxchars
if not (maxchars == nil) and (#buttontext > maxchars) then
local max_ratio = 1.25 -- up to 25% more chars while shrinking
local limit = math.max(0, math.floor(maxchars * max_ratio) - 3)
if (#buttontext > limit) then
while (#buttontext > limit) do
buttontext = buttontext:gsub(".[\128-\191]*$", "")
end
buttontext = buttontext .. "..."
end
local _, nchars2 = buttontext:gsub(".[\128-\191]*", "")
local stretch = (maxchars/#buttontext)*100
buttontext = string.format("{\\fscx%f}",
(maxchars/#buttontext)*100) .. buttontext
end
elem_ass:append(buttontext)
end
master_ass:merge(elem_ass)
end
end
--
-- Message display
--
-- pos is 1 based
function limited_list(prop, pos)
local proplist = mp.get_property_native(prop, {})
local count = #proplist
if count == 0 then
return count, proplist
end
local fs = tonumber(mp.get_property('options/osd-font-size'))
local max = math.ceil(osc_param.unscaled_y*0.75 / fs)
if max % 2 == 0 then
max = max - 1
end
local delta = math.ceil(max / 2) - 1
local begi = math.max(math.min(pos - delta, count - max + 1), 1)
local endi = math.min(begi + max - 1, count)
local reslist = {}
for i=begi, endi do
local item = proplist[i]
item.current = (i == pos) and true or nil
table.insert(reslist, item)
end
return count, reslist
end
function get_playlist()
local pos = mp.get_property_number('playlist-pos', 0) + 1
local count, limlist = limited_list('playlist', pos)
if count == 0 then
return 'Empty playlist.'
end
local message = string.format('Playlist %d/%d:\n', pos, count)
for i, v in ipairs(limlist) do
local title = v.title
local _, filename = utils.split_path(v.filename)
if title == nil then
title = filename
end
message = string.format('%s %s %s\n', message,
(v.current and '' or ''), title)
end
return message
end
function get_chapterlist()
local pos = mp.get_property_number('chapter', 0) + 1
local count, limlist = limited_list('chapter-list', pos)
if count == 0 then
return 'No chapters.'
end
local message = string.format('Chapters %d/%d:\n', pos, count)
for i, v in ipairs(limlist) do
local time = mp.format_time(v.time)
local title = v.title
if title == nil then
title = string.format('Chapter %02d', i)
end
message = string.format('%s%s %s %s\n', message, time,
(v.current and '' or ''), title)
end
return message
end
function show_message(text, duration)
--print("text: "..text.." duration: " .. duration)
if duration == nil then
duration = tonumber(mp.get_property("options/osd-duration")) / 1000
elseif not type(duration) == "number" then
print("duration: " .. duration)
end
-- cut the text short, otherwise the following functions
-- may slow down massively on huge input
text = string.sub(text, 0, 4000)
-- replace actual linebreaks with ASS linebreaks
text = string.gsub(text, "\n", "\\N")
state.message_text = text
if not state.message_hide_timer then
state.message_hide_timer = mp.add_timeout(0, request_tick)
end
state.message_hide_timer:kill()
state.message_hide_timer.timeout = duration
state.message_hide_timer:resume()
request_tick()
end
function render_message(ass)
if state.message_hide_timer and state.message_hide_timer:is_enabled() and
state.message_text
then
local _, lines = string.gsub(state.message_text, "\\N", "")
local fontsize = tonumber(mp.get_property("options/osd-font-size"))
local outline = tonumber(mp.get_property("options/osd-border-size"))
local maxlines = math.ceil(osc_param.unscaled_y*0.75 / fontsize)
local counterscale = osc_param.playresy / osc_param.unscaled_y
fontsize = fontsize * counterscale / math.max(0.65 + math.min(lines/maxlines, 1), 1)
outline = outline * counterscale / math.max(0.75 + math.min(lines/maxlines, 1)/2, 1)
local style = "{\\bord" .. outline .. "\\fs" .. fontsize .. "}"
ass:new_event()
ass:append(style .. state.message_text)
else
state.message_text = nil
end
end
--
-- Initialisation and Layout
--
function new_element(name, type)
elements[name] = {}
elements[name].type = type
-- add default stuff
elements[name].eventresponder = {}
elements[name].visible = true
elements[name].enabled = true
elements[name].softrepeat = false
elements[name].styledown = (type == "button")
elements[name].state = {}
if (type == "slider") then
elements[name].slider = {min = {value = 0}, max = {value = 100}}
end
return elements[name]
end
function add_layout(name)
if not (elements[name] == nil) then
-- new layout
elements[name].layout = {}
-- set layout defaults
elements[name].layout.layer = 50
elements[name].layout.alpha = {[1] = 0, [2] = 255, [3] = 255, [4] = 255}
if (elements[name].type == "button") then
elements[name].layout.button = {
maxchars = nil,
}
elseif (elements[name].type == "slider") then
-- slider defaults
elements[name].layout.slider = {
border = 1,
gap = 1,
stype = "slider",
adjust_tooltip = true,
tooltip_style = "",
tooltip_an = 2,
alpha = {[1] = 0, [2] = 255, [3] = 88, [4] = 255},
}
elseif (elements[name].type == "box") then
elements[name].layout.box = {radius = 0, hexagon = false}
end
return elements[name].layout
else
msg.error("Can't add_layout to element \""..name.."\", doesn't exist.")
end
end
local layouts = {}
function bar_layout(direction)
local osc_geo = {
x,
y,
an = 4,
w = user_opts.oscboxwidth,
h = user_opts.oscboxheight,
}
local btnw_left = string.match(osc_styles.buttonsLeft, "fs(.%d)")
local btnw_right = string.match(osc_styles.buttonsRight, "fs(.%d)")
local btnw_rightlbl = btnw_right + string.match(osc_styles.smallButtonsLlabel, "fs(.%d)") + 12
local btnwtime = string.match(osc_styles.timecodesBar, "fs(.%d)")
local timew = btnwtime * 8
local timewfull = btnwtime * 11.5
local padd = 30
local marg_b = 32
local marg_s = 12
local marg_r = 22
local osc_paramsx = osc_param.playresx
if (osc_paramsx < osc_geo.w) then
osc_param.playresy = osc_geo.w / osc_param.display_aspect
osc_param.playresx = osc_param.playresy * osc_param.display_aspect
end
--bottom margin osc
local hlf = osc_geo.h / 2
local hlfw = osc_geo.w / 2
osc_geo.x = osc_param.playresx / 2 - hlfw
osc_geo.y = user_opts.barmargin - hlf
if user_opts.layout == "topbar" then
osc_geo.y = hlf - user_opts.barmargin
end
if direction < 0 then
osc_geo.y = osc_geo.y + osc_param.playresy
end
local sh = user_opts.pbseekbarheight / 2
local shh = sh - 1
local line1 = osc_geo.y + direction * (osc_geo.h / 2 - sh)
local line2
line2 = osc_geo.y - direction + shh
if user_opts.layout == "topbar" then
line2 = osc_geo.y - direction - shh
end
add_area("input", get_hitbox_coords(osc_geo.x, osc_geo.y, osc_geo.an, osc_geo.w, osc_geo.h))
local lo, geo
-- Background bar
new_element("bgbox", "box")
lo = add_layout("bgbox")
lo.geometry = osc_geo
lo.layer = 10
lo.style = osc_styles.box
lo.alpha[1] = user_opts.boxalpha
-- Playback control buttons
geo = { x = osc_geo.x + padd, y = line2, an = 4,
w = btnw_left, h = btnw_left}
local bw = 8
local bw2 = bw * 2
-- Left
-- Play/Pause
lo = add_layout("playpause")
lo.geometry = geo
lo.style = osc_styles.buttonsLeft
-- Chapter prev
geo = { x = geo.x + bw + marg_b, y = geo.y, an = geo.an, w = geo.w, h = geo.h }
lo = add_layout("ch_prev")
lo.geometry = geo
lo.style = osc_styles.buttonsLeft
-- Playlist prev
geo = { x = geo.x + bw2 + marg_s, y = geo.y, an = geo.an, w = geo.w, h = geo.h }
lo = add_layout("pl_prev")
lo.geometry = geo
lo.style = osc_styles.buttonsLeft
-- Playlist next
geo = { x = geo.x + marg_s + bw, y = geo.y, an = geo.an, w = geo.w, h = geo.h }
lo = add_layout("pl_next")
lo.geometry = geo
lo.style = osc_styles.buttonsLeft
-- Chapter next
geo = { x = geo.x + bw + marg_s, y = geo.y, an = geo.an, w = geo.w, h = geo.h }
lo = add_layout("ch_next")
lo.geometry = geo
lo.style = osc_styles.buttonsLeft
-- Left timecode
geo = { x = geo.x + bw2 + marg_b, y = geo.y, an = geo.an, w = timew, h = btnw_right }
if state.tc_ms then
geo["w"] = timewfull
end
lo = add_layout("tc_left")
lo.geometry = geo
lo.style = osc_styles.timecodesBar
-- Cache
geo = { x = geo.x + geo.w + marg_s, y = geo.y,
an = geo.an, w = 0, h = 0 }
lo = add_layout("cache")
lo.geometry = geo
lo.style = osc_styles.timecodesBar
-- Right
-- Seekbarvol
if user_opts.volseekbar then
local volboxheight = user_opts.volboxheight
local volboxwidth = user_opts.volboxwidth
local volhandlesize = user_opts.volhandlesize
geo = { x = osc_geo.x + osc_geo.w - volboxwidth - padd, y = geo.y, an = geo.an, w = volboxwidth, h = volboxheight }
new_element("bgbarvol", "box")
lo = add_layout("bgbarvol")
lo.geometry = geo
lo.layer = 15
lo.style = osc_styles.timecodesBar
lo.alpha[1] = user_opts.seekbaralpha
if not (user_opts["seekbarstyle"] == "bar") then
lo.box.radius = geo.h / 2
lo.box.hexagon = user_opts["seekbarstyle"] == "diamond"
end
lo = add_layout("volseekbar")
lo.geometry = { x = geo.x, y = geo.y, an = geo.an,
w = geo.w, h = volhandlesize }
lo.style = osc_styles.timecodesBar
lo.slider.border = 1
lo.slider.gap = 0
lo.slider.stype = user_opts["volseekbarstyle"]
end
-- Volume
if user_opts.volseekbar then
geo = { x = geo.x - bw2 - marg_s, y = geo.y, an = geo.an, w = btnw_right, h = btnw_right }
else
geo = { x = osc_geo.x + osc_geo.w - bw2 - padd, y = geo.y, an = geo.an, w = btnw_right, h = btnw_right }
end
lo = add_layout("volume")
lo.geometry = geo
lo.style = osc_styles.buttonsRight
-- Right
local bw3 = bw * 4.7
local bw4 = bw * 5.3
-- Shuffle
geo = { x = geo.x - bw2 - marg_b, y = geo.y, an = geo.an, w = geo.w, h = geo.h }
lo = add_layout("cy_shuf")
lo.geometry = geo
lo.style = osc_styles.buttonsRight
-- Loop
geo = { x = geo.x - bw3 - marg_r, y = geo.y, an = geo.an, w = btnw_rightlbl, h = geo.h }
lo = add_layout("cy_loop")
lo.geometry = geo
lo.style = osc_styles.buttonsRight
-- Ontop
geo = { x = geo.x - bw4 - marg_r, y = geo.y, an = geo.an, w = geo.w, h = geo.h }
lo = add_layout("cy_ontop")
lo.geometry = geo
lo.style = osc_styles.buttonsRight
-- Closed Captures selection buttons
geo = { x = geo.x - bw4 - marg_r, y = geo.y, an = geo.an, w = geo.w, h = geo.h }
lo = add_layout("cy_sub")
lo.geometry = geo
lo.style = osc_styles.buttonsRight
-- Track selection buttons
geo = { x = geo.x - bw4 - marg_r, y = geo.y, an = geo.an, w = geo.w, h = geo.h}
lo = add_layout("cy_audio")
lo.geometry = geo
lo.style = osc_styles.buttonsRight
-- Seekbar
local heightbar = user_opts.pbseekbarheight
local heighthandle = user_opts.pbseekbarhandlesize
new_element("bgbar1", "box")
lo = add_layout("bgbar1")
lo.geometry = { x = osc_geo.x, y = line1, an = geo.an,
w = osc_geo.w, h = heightbar }
lo.layer = 15
lo.style = osc_styles.timecodesBar
lo.alpha[1] = user_opts.seekbaralpha
if not (user_opts["seekbarstyle"] == "bar") then
lo.box.radius = 0
lo.box.hexagon = user_opts["seekbarstyle"] == "diamond"
end
lo = add_layout("seekbar")
lo.geometry = { x = osc_geo.x, y = line1, an = geo.an,
w = osc_geo.w, h = heighthandle }
lo.style = osc_styles.seekbar
lo.slider.border = 1
lo.slider.gap = 0
lo.slider.tooltip_style = osc_styles.timePosBar
lo.slider.tooltip_an = 2
lo.slider.stype = user_opts["seekbarstyle"]
lo.slider.rtype = user_opts["seekrangestyle"]
end
layouts["bottombar"] = function()
bar_layout(-1)
end
layouts["topbar"] = function()
bar_layout(1)
end
-- Validate string type user options
function validate_user_opts()
if layouts[user_opts.layout] == nil then
msg.warn("Invalid setting \""..user_opts.layout.."\" for layout")
user_opts.layout = "bottombar"
end
if user_opts.seekbarstyle ~= "bar" and
user_opts.seekbarstyle ~= "diamond" and
user_opts.seekbarstyle ~= "knob" then
msg.warn("Invalid setting \"" .. user_opts.seekbarstyle
.. "\" for seekbarstyle")
user_opts.seekbarstyle = "bar"
end
if user_opts.seekrangestyle ~= "bar" and
user_opts.seekrangestyle ~= "line" and
user_opts.seekrangestyle ~= "slider" and
user_opts.seekrangestyle ~= "inverted" and
user_opts.seekrangestyle ~= "none" then
msg.warn("Invalid setting \"" .. user_opts.seekrangestyle
.. "\" for seekrangestyle")
user_opts.seekrangestyle = "inverted"
end
if user_opts.seekrangestyle == "slider" and
user_opts.seekbarstyle == "bar" then
msg.warn("Using \"slider\" seekrangestyle together with \"bar\" seekbarstyle is not supported")
user_opts.seekrangestyle = "inverted"
end
end
function update_options(list)
validate_user_opts()
request_tick()
visibility_mode(user_opts.visibility, true)
update_duration_watch()
request_init()
end
-- OSC INIT
function osc_init()
msg.debug("osc_init")
-- set canvas resolution according to display aspect and scaling setting
local baseResY = 720
local display_w, display_h, display_aspect = mp.get_osd_size()
local scale = 1
if (mp.get_property("video") == "no") then -- dummy/forced window
scale = user_opts.scaleforcedwindow
elseif state.fullscreen then
scale = user_opts.scalefullscreen
else
scale = user_opts.scalewindowed
end
if user_opts.vidscale then
osc_param.unscaled_y = baseResY
else
osc_param.unscaled_y = display_h
end
osc_param.playresy = osc_param.unscaled_y / scale
if (display_aspect > 0) then
osc_param.display_aspect = display_aspect
end
osc_param.playresx = osc_param.playresy * osc_param.display_aspect
-- stop seeking with the slider to prevent skipping files
state.active_element = nil
osc_param.video_margins = {l = 0, r = 0, t = 0, b = 0}
elements = {}
-- some often needed stuff
local pl_count = mp.get_property_number("playlist-count", 0)
local have_pl = (pl_count > 1)
local pl_pos = mp.get_property_number("playlist-pos", 0) + 1
local have_ch = (mp.get_property_number("chapters", 0) > 0)
local loop = mp.get_property("loop-playlist", "no")
local ne
-- playlist buttons
-- prev
ne = new_element("pl_prev", "button")
ne.content = "\xee\x80\x83"
ne.enabled = (pl_pos > 1) or (loop ~= "no")
ne.eventresponder["mbtn_left_up"] =
function ()
mp.commandv("playlist-prev", "weak")
if user_opts.playlist_osd then
show_message(get_playlist(), 3)
end
end
ne.eventresponder["shift+mbtn_left_up"] =
function () show_message(get_playlist(), 3) end
ne.eventresponder["mbtn_right_up"] =
function () show_message(get_playlist(), 3) end
--next
ne = new_element("pl_next", "button")
ne.content = "\xee\x80\x84"
ne.enabled = (have_pl and (pl_pos < pl_count)) or (loop ~= "no")
ne.eventresponder["mbtn_left_up"] =
function ()
mp.commandv("playlist-next", "weak")
if user_opts.playlist_osd then
show_message(get_playlist(), 3)
end
end
ne.eventresponder["shift+mbtn_left_up"] =
function () show_message(get_playlist(), 3) end
ne.eventresponder["mbtn_right_up"] =
function () show_message(get_playlist(), 3) end
-- big buttons
--playpause
ne = new_element("playpause", "button")
ne.content = function ()
if mp.get_property("pause") == "yes" then
return ('\xee\x80\x81')
else
return ('\xee\x80\x80')
end
end
ne.eventresponder["mbtn_left_up"] =
function () mp.commandv("cycle", "pause") end
--skipback
ne = new_element("skipback", "button")
ne.softrepeat = true
ne.content = "\238\128\132"
ne.eventresponder["mbtn_left_down"] =
function () mp.commandv("seek", -5, "relative", "keyframes") end
ne.eventresponder["shift+mbtn_left_down"] =
function () mp.commandv("frame-back-step") end
ne.eventresponder["mbtn_right_down"] =
function () mp.commandv("seek", -30, "relative", "keyframes") end
--skipfrwd
ne = new_element("skipfrwd", "button")
ne.softrepeat = true
ne.content = "\238\128\133"
ne.eventresponder["mbtn_left_down"] =
function () mp.commandv("seek", 10, "relative", "keyframes") end
ne.eventresponder["shift+mbtn_left_down"] =
function () mp.commandv("frame-step") end
ne.eventresponder["mbtn_right_down"] =
function () mp.commandv("seek", 60, "relative", "keyframes") end
--ch_prev
ne = new_element("ch_prev", "button")
ne.enabled = have_ch
ne.content = "\xee\x80\x82"
ne.eventresponder["mbtn_left_up"] =
function ()
mp.commandv("add", "chapter", -1)
if user_opts.chapters_osd then
show_message(get_chapterlist(), 3)
end
end
ne.eventresponder["shift+mbtn_left_up"] =
function () show_message(get_chapterlist(), 3) end
ne.eventresponder["mbtn_right_up"] =
function () show_message(get_chapterlist(), 3) end
--ch_next
ne = new_element("ch_next", "button")
ne.enabled = have_ch
ne.content = "\xee\x80\x85"
ne.eventresponder["mbtn_left_up"] =
function ()
mp.commandv("add", "chapter", 1)
if user_opts.chapters_osd then
show_message(get_chapterlist(), 3)
end
end
ne.eventresponder["shift+mbtn_left_up"] =
function () show_message(get_chapterlist(), 3) end
ne.eventresponder["mbtn_right_up"] =
function () show_message(get_chapterlist(), 3) end
--
update_tracklist()
--cy_audio
ne = new_element("cy_audio", "button")
ne.enabled = (#tracks_osc.audio > 0)
ne.content = function ()
local aid = ""
if not (get_track("audio") == 0) then
aid = get_track("audio")
end
return ("\xee\x80\x86" .. osc_styles.smallButtonsLlabel
.. " " .. aid .. "/" .. #tracks_osc.audio)
end
ne.eventresponder["mbtn_left_up"] =
function () set_track("audio", 1) end
ne.eventresponder["mbtn_right_up"] =
function () set_track("audio", -1) end
ne.eventresponder["shift+mbtn_left_down"] =
function () show_message(get_tracklist("audio"), 2) end
--cy_sub
ne = new_element("cy_sub", "button")
ne.enabled = (#tracks_osc.sub > 0)
ne.content = function ()
local sid = ""
if not (get_track("sub") == 0) then
sid = get_track("sub")
end
return ("\xee\x80\x87" .. osc_styles.smallButtonsLlabel
.. " " .. sid .. "/" .. #tracks_osc.sub)
end
ne.eventresponder["mbtn_left_up"] =
function () set_track("sub", 1) end
ne.eventresponder["mbtn_right_up"] =
function () set_track("sub", -1) end
ne.eventresponder["shift+mbtn_left_down"] =
function () show_message(get_tracklist("sub"), 2) end
--cy_loop
ne = new_element("cy_loop", "button")
local lf = "Loop current file: "
local lp = "Loop current playlist: "
local ico = "\xee\x80\x88" .. osc_styles.smallButtonsLlabel
local y = "yes"
local n = "no"
ne.content = function ()
if (mp.get_property("loop") == n) then
return (ico .. " No")
else
return (ico .. " Yes")
end
end
ne.eventresponder["mbtn_left_up"] =
function ()
if (mp.get_property("loop") == n) then
mp.set_property("loop", y)
show_message(lf .. y)
else
mp.set_property("loop", n)
show_message(lf .. n)
end
end
ne.eventresponder["shift+mbtn_left_up"] =
function ()
if (mp.get_property("loop-playlist") == n) then
mp.set_property("loop-playlist", y)
show_message(lp .. y)
else
mp.set_property("loop-playlist", n)
show_message(lp .. n)
end
end
--cy_shuf
ne = new_element("cy_shuf", "button")
ne.content = "\xee\x80\x89"
ne.enabled = have_pl
ne.eventresponder["mbtn_left_up"] =
function ()
mp.commandv("playlist-shuffle")
show_message("Shuffled")
end
ne.eventresponder["shift+mbtn_left_up"] =
function () show_message(get_playlist(), 3) end
--cy_ontop
ne = new_element("cy_ontop", "button")
local t = "Stay on top: "
local b = "Border: "
local ico = "\xee\x80\x8f" .. osc_styles.smallButtonsLlabel
local y = "yes"
local n = "no"
ne.content = function ()
if (mp.get_property("ontop") == "no") then
return (ico .. " No")
else
return (ico .. " Yes")
end
end
ne.eventresponder["mbtn_left_up"] =
function ()
if (mp.get_property("ontop") == n) then
mp.set_property("ontop", y)
show_message(t .. y)
else
mp.set_property("ontop", n)
show_message(t .. n)
end
end
ne.eventresponder["shift+mbtn_left_up"] =
function ()
if (mp.get_property("border") == n) then
mp.set_property("border", y)
show_message(b .. y)
else
mp.set_property("border", n)
show_message(b .. n)
end
end
--tog_fs
ne = new_element("tog_fs", "button")
ne.content = function ()
if (state.fullscreen) then
return ('\xee\x80\x8e')
else
return ('\xee\x80\x8d')
end
end
ne.eventresponder["mbtn_left_up"] =
function () mp.commandv("cycle", "fullscreen") end
--seekbar
ne = new_element("seekbar", "slider")
ne.enabled = not (mp.get_property("percent-pos") == nil)
state.slider_element = ne.enabled and ne or nil -- used for forced_title
ne.slider.markerF = function ()
local duration = mp.get_property_number("duration", nil)
if not (duration == nil) then
local chapters = mp.get_property_native("chapter-list", {})
local markers = {}
for n = 1, #chapters do
markers[n] = (chapters[n].time / duration * 100)
end
return markers
else
return {}
end
end
ne.slider.posF =
function () return mp.get_property_number("percent-pos", nil) end
ne.slider.tooltipF = function (pos)
local duration = mp.get_property_number("duration", nil)
if not ((duration == nil) or (pos == nil)) then
possec = duration * (pos / 100)
return mp.format_time(possec)
else
return ""
end
end
ne.slider.seekRangesF = function()
if user_opts.seekrangestyle == "none" then
return nil
end
local cache_state = state.cache_state
if not cache_state then
return nil
end
local duration = mp.get_property_number("duration", nil)
if (duration == nil) or duration <= 0 then
return nil
end
local ranges = cache_state["seekable-ranges"]
if #ranges == 0 then
return nil
end
local nranges = {}
for _, range in pairs(ranges) do
nranges[#nranges + 1] = {
["start"] = 100 * range["start"] / duration,
["end"] = 100 * range["end"] / duration,
}
end
return nranges
end
ne.eventresponder["mouse_move"] = --keyframe seeking when mouse is dragged
function (element)
-- mouse move events may pile up during seeking and may still get
-- sent when the user is done seeking, so we need to throw away
-- identical seeks
local seekto = get_slider_value(element)
if (element.state.lastseek == nil) or
(not (element.state.lastseek == seekto)) then
local flags = "absolute-percent"
if not user_opts.seekbarkeyframes then
flags = flags .. "+exact"
end
mp.commandv("seek", seekto, flags)
element.state.lastseek = seekto
end
end
ne.eventresponder["mbtn_left_down"] = --exact seeks on single clicks
function (element) mp.commandv("seek", get_slider_value(element),
"absolute-percent", "exact") end
ne.eventresponder["reset"] =
function (element) element.state.lastseek = nil end
--volseekbar
if user_opts.volseekbar then
ne = new_element("volseekbar", "slider")
ne.slider.posF =
function () return mp.get_property_osd("volume") end
ne.slider.seekRangesF = function()
return nil end
ne.eventresponder["mouse_move"] =
function (element)
local seekto = get_slider_value(element)
mp.commandv("set", "volume", seekto)
end
ne.eventresponder["mbtn_left_down"] = --exact seeks on single clicks
function (element)
mp.commandv("set", "volume", get_slider_value(element))
end
end
-- tc_left (current pos)
ne = new_element("tc_left", "button")
ne.content = function ()
if (state.tc_ms) then
if (state.rightTC_trem) then
return (mp.get_property_osd("playback-time/full")) .. " / " .. (mp.get_property_osd("playtime-remaining/full"))
else
return (mp.get_property_osd("playback-time/full")) .. " / " .. (mp.get_property_osd("duration/full"))
end
else
if (state.rightTC_trem) then
return (mp.get_property_osd("playback-time")) .. " / " .. (mp.get_property_osd("playtime-remaining"))
else
return (mp.get_property_osd("playback-time")) .. " / " .. (mp.get_property_osd("duration"))
end
end
end
ne.eventresponder["mbtn_left_up"] = function ()
state.tc_ms = not state.tc_ms
request_init()
end
ne.eventresponder["shift+mbtn_left_up"] = function ()
state.rightTC_trem = not state.rightTC_trem
end
--cache
ne = new_element("cache", "button")
ne.content = function ()
local cache_state = state.cache_state
if not (cache_state and cache_state["seekable-ranges"] and
#cache_state["seekable-ranges"] > 0) then
-- probably not a network stream
return ""
end
local dmx_cache = cache_state and cache_state["cache-duration"]
local thresh = math.min(state.dmx_cache * 0.05, 5) -- 5% or 5s
if dmx_cache and math.abs(dmx_cache - state.dmx_cache) >= thresh then
state.dmx_cache = dmx_cache
else
dmx_cache = state.dmx_cache
end
local min = math.floor(dmx_cache / 60)
local sec = math.floor(dmx_cache % 60) -- don't round e.g. 59.9 to 60
return (min > 0 and
string.format(" %sm%02.0fs", min, sec) or
string.format(" %3.0fs", sec))
end
ne.eventresponder["mbtn_left_up"] =
function () show_message("Cache") end
-- volume
ne = new_element("volume", "button")
ne.content = function()
local volume = mp.get_property_number("volume", 0)
local mute = mp.get_property_native("mute")
local volicon = {"\xee\x80\x8a", "\xee\x80\x8b",}
if volume == 0 or mute then
return "\xee\x80\x8c"
else
return volicon[math.min(2,math.ceil(volume / (100/2)))]
end
end
ne.eventresponder["mbtn_left_up"] =
function () mp.commandv("cycle", "mute") end
ne.eventresponder["wheel_up_press"] =
function () mp.commandv("osd-auto", "add", "volume", 5) end
ne.eventresponder["wheel_down_press"] =
function () mp.commandv("osd-auto", "add", "volume", -5) end
ne.eventresponder["shift+mbtn_left_down"] =
function () show_message("Volume: " .. mp.get_property_osd("volume") .. "%") end
-- load layout
layouts[user_opts.layout]()
--do something with the elements
prepare_elements()
update_margins()
end
function reset_margins()
if state.using_video_margins then
for _, opt in ipairs(margins_opts) do
mp.set_property_number(opt[2], 0.0)
end
state.using_video_margins = false
end
end
function update_margins()
local margins = osc_param.video_margins
-- Don't use margins if it's visible only temporarily.
if (not state.osc_visible) or (get_hidetimeout() >= 0) or
(state.fullscreen and not user_opts.showfullscreen) or
(not state.fullscreen and not user_opts.showwindowed)
then
margins = {l = 0, r = 0, t = 0, b = 0}
end
reset_margins()
utils.shared_script_property_set("osc-margins",
string.format("%f,%f,%f,%f", margins.l, margins.r, margins.t, margins.b))
end
function shutdown()
reset_margins()
utils.shared_script_property_set("osc-margins", nil)
end
--
-- Other important stuff
--
function show_osc()
-- show when disabled can happen (e.g. mouse_move) due to async/delayed unbinding
if not state.enabled then return end
msg.trace("show_osc")
--remember last time of invocation (mouse move)
state.showtime = mp.get_time()
osc_visible(true)
if (user_opts.fadeduration > 0) then
state.anitype = nil
end
end
function hide_osc()
msg.trace("hide_osc")
if not state.enabled then
-- typically hide happens at render() from tick(), but now tick() is
-- no-op and won't render again to remove the osc, so do that manually.
state.osc_visible = false
render_wipe()
elseif (user_opts.fadeduration > 0) then
if not(state.osc_visible == false) then
state.anitype = "out"
request_tick()
end
else
osc_visible(false)
end
end
function osc_visible(visible)
if state.osc_visible ~= visible then
state.osc_visible = visible
update_margins()
end
request_tick()
end
function pause_state(name, enabled)
state.paused = enabled
request_tick()
end
function cache_state(name, st)
state.cache_state = st
request_tick()
end
-- Request that tick() is called (which typically re-renders the OSC).
-- The tick is then either executed immediately, or rate-limited if it was
-- called a small time ago.
function request_tick()
if state.tick_timer == nil then
state.tick_timer = mp.add_timeout(0, tick)
end
if not state.tick_timer:is_enabled() then
local now = mp.get_time()
local timeout = tick_delay - (now - state.tick_last_time)
if timeout < 0 then
timeout = 0
end
state.tick_timer.timeout = timeout
state.tick_timer:resume()
end
end
function mouse_leave()
if get_hidetimeout() >= 0 then
hide_osc()
end
-- reset mouse position
state.last_mouseX, state.last_mouseY = nil, nil
state.mouse_in_window = false
end
function request_init()
state.initREQ = true
request_tick()
end
-- Like request_init(), but also request an immediate update
function request_init_resize()
request_init()
-- ensure immediate update
state.tick_timer:kill()
state.tick_timer.timeout = 0
state.tick_timer:resume()
end
function render_wipe()
msg.trace("render_wipe()")
state.osd.data = "" -- allows set_osd to immediately update on enable
state.osd:remove()
end
function render()
msg.trace("rendering")
local current_screen_sizeX, current_screen_sizeY, aspect = mp.get_osd_size()
local mouseX, mouseY = get_virt_mouse_pos()
local now = mp.get_time()
-- check if display changed, if so request reinit
if not (state.mp_screen_sizeX == current_screen_sizeX
and state.mp_screen_sizeY == current_screen_sizeY) then
request_init_resize()
state.mp_screen_sizeX = current_screen_sizeX
state.mp_screen_sizeY = current_screen_sizeY
end
-- init management
if state.active_element then
-- mouse is held down on some element - keep ticking and igore initReq
-- till it's released, or else the mouse-up (click) will misbehave or
-- get ignored. that's because osc_init() recreates the osc elements,
-- but mouse handling depends on the elements staying unmodified
-- between mouse-down and mouse-up (using the index active_element).
request_tick()
elseif state.initREQ then
osc_init()
state.initREQ = false
-- store initial mouse position
if (state.last_mouseX == nil or state.last_mouseY == nil)
and not (mouseX == nil or mouseY == nil) then
state.last_mouseX, state.last_mouseY = mouseX, mouseY
end
end
-- fade animation
if not(state.anitype == nil) then
if (state.anistart == nil) then
state.anistart = now
end
if (now < state.anistart + (user_opts.fadeduration/1000)) then
if (state.anitype == "in") then --fade in
osc_visible(true)
state.animation = scale_value(state.anistart,
(state.anistart + (user_opts.fadeduration/1000)),
255, 0, now)
elseif (state.anitype == "out") then --fade out
state.animation = scale_value(state.anistart,
(state.anistart + (user_opts.fadeduration/1000)),
0, 255, now)
end
else
if (state.anitype == "out") then
osc_visible(false)
end
kill_animation()
end
else
kill_animation()
end
do_enable_keybindings()
-- --mouse input area
local mouse_over_osc = false
for _,cords in ipairs(osc_param.areas["input"]) do
if state.osc_visible then -- activate only when OSC is actually visible
set_virt_mouse_area(cords.x1, cords.y1, cords.x2, cords.y2, "input")
end
if state.osc_visible ~= state.input_enabled then
if state.osc_visible then
mp.enable_key_bindings("input")
else
mp.disable_key_bindings("input")
end
state.input_enabled = state.osc_visible
end
if (mouse_hit_coords(cords.x1, cords.y1, cords.x2, cords.y2)) then
mouse_over_osc = true
end
end
-- autohide
if not (state.showtime == nil) and (get_hidetimeout() >= 0) then
local timeout = state.showtime + (get_hidetimeout()/1000) - now
if timeout <= 0 then
if (state.active_element == nil) and not (mouse_over_osc) then
hide_osc()
end
else
-- the timer is only used to recheck the state and to possibly run
-- the code above again
if not state.hide_timer then
state.hide_timer = mp.add_timeout(0, tick)
end
state.hide_timer.timeout = timeout
-- re-arm
state.hide_timer:kill()
state.hide_timer:resume()
end
end
-- actual rendering
local ass = assdraw.ass_new()
-- Messages
render_message(ass)
-- actual OSC
if state.osc_visible then
render_elements(ass)
end
-- submit
set_osd(osc_param.playresy * osc_param.display_aspect,
osc_param.playresy, ass.text)
end
--
-- Eventhandling
--
local function element_has_action(element, action)
return element and element.eventresponder and
element.eventresponder[action]
end
function process_event(source, what)
local action = string.format("%s%s", source,
what and ("_" .. what) or "")
if what == "down" or what == "press" then
for n = 1, #elements do
if mouse_hit(elements[n]) and
elements[n].eventresponder and
(elements[n].eventresponder[source .. "_up"] or
elements[n].eventresponder[action]) then
if what == "down" then
state.active_element = n
state.active_event_source = source
end
-- fire the down or press event if the element has one
if element_has_action(elements[n], action) then
elements[n].eventresponder[action](elements[n])
end
end
end
elseif what == "up" then
if elements[state.active_element] then
local n = state.active_element
if n == 0 then
--click on background (does not work)
elseif element_has_action(elements[n], action) and
mouse_hit(elements[n]) then
elements[n].eventresponder[action](elements[n])
end
--reset active element
if element_has_action(elements[n], "reset") then
elements[n].eventresponder["reset"](elements[n])
end
end
state.active_element = nil
state.mouse_down_counter = 0
elseif source == "mouse_move" then
state.mouse_in_window = true
local mouseX, mouseY = get_virt_mouse_pos()
if (user_opts.minmousemove == 0) or
(not ((state.last_mouseX == nil) or (state.last_mouseY == nil)) and
((math.abs(mouseX - state.last_mouseX) >= user_opts.minmousemove)
or (math.abs(mouseY - state.last_mouseY) >= user_opts.minmousemove)
)
) then
show_osc()
end
state.last_mouseX, state.last_mouseY = mouseX, mouseY
local n = state.active_element
if element_has_action(elements[n], action) then
elements[n].eventresponder[action](elements[n])
end
end
-- ensure rendering after any (mouse) event - icons could change etc
request_tick()
end
-- called by mpv on every frame
function tick()
if state.marginsREQ == true then
update_margins()
state.marginsREQ = false
end
if (not state.enabled) then return end
if (state.idle) then
-- render idle message
msg.trace("idle message")
local icon_x, icon_y = 320 - 26, 140
local line_prefix = ("{\\rDefault\\an7\\1a&H00&\\bord0\\shad0\\pos(%f,%f)}"):format(icon_x, icon_y)
local ass = assdraw.ass_new()
ass:new_event()
ass:pos(320, icon_y+65)
ass:an(8)
ass:append("Drop files or URLs to play here.")
set_osd(640, 360, ass.text)
if state.showhide_enabled then
mp.disable_key_bindings("showhide")
state.showhide_enabled = false
end
elseif (state.fullscreen and user_opts.showfullscreen)
or (not state.fullscreen and user_opts.showwindowed) then
-- render the OSC
render()
else
-- Flush OSD
render_wipe()
end
state.tick_last_time = mp.get_time()
if state.anitype ~= nil then
-- state.anistart can be nil - animation should now start, or it can
-- be a timestamp when it started. state.idle has no animation.
if not state.idle and
(not state.anistart or
mp.get_time() < 1 + state.anistart + user_opts.fadeduration/1000)
then
-- animating or starting, or still within 1s past the deadline
request_tick()
else
kill_animation()
end
end
end
function do_enable_keybindings()
if state.enabled then
if not state.showhide_enabled then
mp.enable_key_bindings("showhide", "allow-vo-dragging+allow-hide-cursor")
end
state.showhide_enabled = true
end
end
function enable_osc(enable)
state.enabled = enable
if enable then
do_enable_keybindings()
else
hide_osc() -- acts immediately when state.enabled == false
if state.showhide_enabled then
mp.disable_key_bindings("showhide")
end
state.showhide_enabled = false
end
end
-- duration is observed for the sole purpose of updating chapter markers
-- positions. live streams with chapters are very rare, and the update is also
-- expensive (with request_init), so it's only observed when we have chapters
-- and the user didn't disable the livemarkers option (update_duration_watch).
function on_duration() request_init() end
local duration_watched = false
function update_duration_watch()
local want_watch = user_opts.livemarkers and
(mp.get_property_number("chapters", 0) or 0) > 0 and
true or false -- ensure it's a boolean
if (want_watch ~= duration_watched) then
if want_watch then
mp.observe_property("duration", nil, on_duration)
else
mp.unobserve_property(on_duration)
end
duration_watched = want_watch
end
end
validate_user_opts()
update_duration_watch()
mp.register_event("shutdown", shutdown)
mp.register_event("start-file", request_init)
mp.observe_property("track-list", nil, request_init)
mp.observe_property("playlist", nil, request_init)
mp.observe_property("chapter-list", "native", function(_, list)
list = list or {} -- safety, shouldn't return nil
table.sort(list, function(a, b) return a.time < b.time end)
state.chapter_list = list
update_duration_watch()
request_init()
end)
mp.register_script_message("osc-message", show_message)
mp.register_script_message("osc-chapterlist", function(dur)
show_message(get_chapterlist(), dur)
end)
mp.register_script_message("osc-playlist", function(dur)
show_message(get_playlist(), dur)
end)
mp.register_script_message("osc-tracklist", function(dur)
local msg = {}
for k,v in pairs(nicetypes) do
table.insert(msg, get_tracklist(k))
end
show_message(table.concat(msg, '\n\n'), dur)
end)
mp.observe_property("fullscreen", "bool",
function(name, val)
state.fullscreen = val
state.marginsREQ = true
request_init_resize()
end
)
mp.observe_property("border", "bool",
function(name, val)
state.border = val
request_init_resize()
end
)
mp.observe_property("window-maximized", "bool",
function(name, val)
state.maximized = val
request_init_resize()
end
)
mp.observe_property("idle-active", "bool",
function(name, val)
state.idle = val
request_tick()
end
)
mp.observe_property("pause", "bool", pause_state)
mp.observe_property("demuxer-cache-state", "native", cache_state)
mp.observe_property("vo-configured", "bool", function(name, val)
request_tick()
end)
mp.observe_property("playback-time", "number", function(name, val)
request_tick()
end)
mp.observe_property("osd-dimensions", "native", function(name, val)
-- (we could use the value instead of re-querying it all the time, but then
-- we might have to worry about property update ordering)
request_init_resize()
end)
-- mouse show/hide bindings
mp.set_key_bindings({
{"mouse_move", function(e) process_event("mouse_move", nil) end},
{"mouse_leave", mouse_leave},
}, "showhide", "force")
do_enable_keybindings()
--mouse input bindings
mp.set_key_bindings({
{"mbtn_left", function(e) process_event("mbtn_left", "up") end,
function(e) process_event("mbtn_left", "down") end},
{"shift+mbtn_left", function(e) process_event("shift+mbtn_left", "up") end,
function(e) process_event("shift+mbtn_left", "down") end},
{"mbtn_right", function(e) process_event("mbtn_right", "up") end,
function(e) process_event("mbtn_right", "down") end},
-- alias to shift_mbtn_left for single-handed mouse use
{"mbtn_mid", function(e) process_event("shift+mbtn_left", "up") end,
function(e) process_event("shift+mbtn_left", "down") end},
{"wheel_up", function(e) process_event("wheel_up", "press") end},
{"wheel_down", function(e) process_event("wheel_down", "press") end},
{"mbtn_left_dbl", "ignore"},
{"shift+mbtn_left_dbl", "ignore"},
{"mbtn_right_dbl", "ignore"},
}, "input", "force")
mp.enable_key_bindings("input")
function get_hidetimeout()
if user_opts.visibility == "always" then
return -1 -- disable autohide
end
return user_opts.hidetimeout
end
function always_on(val)
if state.enabled then
if val then
show_osc()
else
hide_osc()
end
end
end
-- mode can be auto/always/never/cycle
-- the modes only affect internal variables and not stored on its own.
function visibility_mode(mode, no_osd)
if mode == "cycle" then
if not state.enabled then
mode = "auto"
elseif user_opts.visibility ~= "always" then
mode = "always"
else
mode = "never"
end
end
if mode == "auto" then
always_on(false)
enable_osc(true)
elseif mode == "always" then
enable_osc(true)
always_on(true)
elseif mode == "never" then
enable_osc(false)
else
msg.warn("Ignoring unknown visibility mode '" .. mode .. "'")
return
end
if not no_osd and tonumber(mp.get_property("osd-level")) >= 1 then
mp.osd_message("OSC visibility: " .. mode)
end
-- Reset the input state on a mode change. The input state will be
-- recalcuated on the next render cycle, except in 'never' mode where it
-- will just stay disabled.
mp.disable_key_bindings("input")
state.input_enabled = false
update_margins()
request_tick()
end
visibility_mode(user_opts.visibility, true)
mp.register_script_message("osc-visibility", visibility_mode)
mp.add_key_binding(nil, "visibility", function() visibility_mode("cycle") end)
set_virt_mouse_area(0, 0, 0, 0, "input")