--[[ This file is part of mfpbar. mfpbar is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. mfpbar is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with mfpbar. If not, see . ]] local msg = require('mp.msg') local utils = require('mp.utils') local mpopt = require('mp.options') -- poor man's enum local pbar_uninit = 0 local pbar_hidden = 1 local pbar_minimized = 2 local pbar_active = 3 -- globals local state = { osd = nil, dpy_w = 0, dpy_h = 0, pbar = pbar_uninit, mouse = nil, mouse_prev = nil, cached_ranges = nil, duration = nil, chapters = nil, timeout = nil, time_observed = false, press_bounded = false, fullscreen = false, thumbfast = { width = 0, height = 0, disabled = true, available = false }, } local opt = { pbar_height = 2, pbar_minimized_height = 0.5, pbar_color = "CCCCCC", pbar_bg_alpha = "3F", pbar_fullscreen_hide = true, cachebar_height = 0.24, cachebar_color = "1C6C89", cachebar_uncached_color = "CC3A2A", cachebar_uncached_alpha = "70", -- TODO: allow selecting "duration" as well ? timeline_rhs = "time-remaining", hover_bar_color = "BDAE93", font_size = 16, font_pad = 4, -- TODO: rename this to hpad ? -- TODO: add a configurable vpad as well ? font_border_width = 2, font_border_color = "000000", proximity = 40, preview_border_width = 2, preview_border_color = "BDAE93", chapter_marker_size = 3, chapter_marker_color = "BDAE93", chapter_marker_border_width = 1, chapter_marker_border_color = "161616", minimize_timeout = 3, debug = false, } local zassert = function() end -- function implementation -- ASS uses BBGGRR format, which fucking sucks local function rgb_to_ass(color) if (not string.len(color) == 6) then msg.error("Invalid color: " .. color) return "FFFFFF" end local r = string.sub(color, 1, 2) local g = string.sub(color, 3, 4) local b = string.sub(color, 5, 6) return string.upper(b .. g .. r) end local function grab_chapter_name_at(sec) zassert(state.chapters) local name = nil local psec = -1 for _, c in ipairs(state.chapters) do if (sec > c.time) then name = c.title end zassert(psec <= c.time) psec = c.time end return name end local function format_time(t) local h = math.floor(t / (60 * 60)) t = t - (h * 60 * 60) local m = math.floor(t / 60) local s = t - (m * 60) return string.format("%.2d:%.2d:%.2d", h, m, s) end local function round(n) zassert(n >= 0) return math.floor(n + 0.5) end local function clamp(n, min, max) return math.min(math.max(n, min), max) end local function hover_to_sec(mx, dw, duration) zassert(duration) local n = duration * ((mx + 0.5) / dw) return clamp(n, 0, duration) end local function render() state.osd:update() state.osd.data = nil end local function draw_append(text) if (state.osd.data == nil) then state.osd.data = text else state.osd.data = state.osd.data .. '\n' .. text end end local function draw_rect_point(x0, y0, x1, y1, x2, y2, x3, y3, color, opt) local s = '{\\pos(0, 0)}' opt = opt or {} s = s .. '{\\1c&' .. color .. '&}' s = s .. '{\\1a&' .. (opt.alpha or "00") .. '&}' s = s .. '{\\bord' .. (opt.bw or '0') .. '}' s = s .. '{\\3c&' .. (opt.bcolor or "000000") .. '&}' s = s .. string.format( '{\\p1}m %d %d l %d %d %d %d %d %d{\\p0}', x0, y0, x1, y1, x2, y2, x3, y3 ) draw_append(s) end local function draw_rect(x, y, w, h, color, opt) draw_rect_point( x, y, x + w, y, x + w, y + h, x, y + h, color, opt ) end local function draw_text(x, y, size, text, opt) local s = string.format('{\\pos(%d, %d)}{\\fs%d}', x, y, size) opt = opt or {} s = s .. '{\\bord' .. (opt.bw or '0') .. '}' s = s .. '{\\3c&' .. (opt.bcolor or "000000") .. '&}' s = s .. text draw_append(s) end -- TODO: make this less janky. local function pbar_draw() local dpy_w = state.dpy_w local dpy_h = state.dpy_h local ypos = 0 local play_pos = mp.get_property_native("percent-pos") local duration = state.duration local clist = state.chapters zassert(state.pbar == pbar_minimized or state.pbar == pbar_active) if (play_pos == nil or dpy_w == 0 or dpy_h == 0) then return end -- L0: playback cursor local pb_h = state.pbar == pbar_minimized and opt.pbar_minimized_height or opt.pbar_height zassert(pb_h > 0) pb_h = dpy_h * (pb_h / 100) pb_h = math.max(round(pb_h), 4) local pb_w = dpy_w * (play_pos/100.0) local pb_y = dpy_h - (pb_h + ypos) draw_rect(0, pb_y, pb_w, pb_h, opt.pbar_color) draw_rect(pb_w, pb_y, dpy_w - pb_w, pb_h, "000000", { alpha = opt.pbar_bg_alpha }) ypos = ypos + pb_h if (duration) then -- L1: cache cusor if (state.cached_ranges and opt.cachebar_height > 0) then zassert(#state.cached_ranges > 0) local ch = dpy_h * (opt.cachebar_height / 100) ch = math.max(round(ch), 2) draw_rect( 0, dpy_h - (ch + ypos), dpy_w, ch, opt.cachebar_uncached_color, { alpha = opt.cachebar_uncached_alpha } ) for _, range in ipairs(state.cached_ranges) do local s = range['start'] local e = range['end'] local sp = dpy_w * (s / duration) local ep = (dpy_w * (e / duration)) - sp draw_rect(sp, dpy_h - (ch + ypos), ep, ch, opt.cachebar_color) end ypos = ypos + ch end -- L0-???: chapters if (clist and opt.chapter_marker_size > 0) then zassert(#clist > 0) local bw = opt.chapter_marker_border_width local tw = opt.chapter_marker_size local miny = tw + bw + 1 -- +1 for pad local y = dpy_h - math.max(pb_h / 2, miny) for _, c in ipairs(clist) do local x = dpy_w * (c.time / duration) draw_rect_point( x - tw, y, x, y - tw, x + tw, y, x, y + tw, opt.chapter_marker_color, { bw = bw, bcolor = opt.chapter_marker_border_color } ) end ypos = math.max(ypos, dpy_h - (y + tw + bw)) end end if (state.pbar == pbar_active) then local fs = opt.font_size local pad = opt.font_pad local fopt = { bw = opt.font_border_width, bcolor = opt.font_border_color } -- L2: timeline -- LHS: current playback position local time = mp.get_property_osd("time-pos", "00:00:00") draw_text(pad, dpy_h - (ypos + fs), fs, time, fopt) -- RHS: time/playback remaining local rem = "-" .. mp.get_property_osd(opt.timeline_rhs, "99:99:99") draw_text(dpy_w - pad, dpy_h - (ypos + fs), fs, "{\\an9}" .. rem, fopt) ypos = ypos + fs + (fopt.bw * 2) if (duration) then zassert(state.mouse) -- L0-2: hovered timeline local hover_sec = hover_to_sec(state.mouse.x, dpy_w, duration) local hover_text = format_time(hover_sec) draw_rect( math.max(state.mouse.x - 1, 0), dpy_h - ypos, 2, ypos, opt.hover_bar_color ) local fw = fs * 2 -- guesstimate ¯\_(ツ)_/¯ local x = clamp(state.mouse.x, pad + fw, dpy_w - (pad + fw)) draw_text( x, dpy_h - (ypos + fs), fs, "{\\an8}" .. hover_text, fopt ) ypos = ypos + fs + (fopt.bw * 2) -- L3: chapter name local cname = clist and grab_chapter_name_at(hover_sec) or nil if cname then zassert(cname) local fw = string.len(cname) * fs * 0.28 -- guesstimate again local x = clamp(state.mouse.x, pad + fw, dpy_w - (pad + fw)) draw_text( x, dpy_h - (ypos + fs), fs, "{\\an8}" .. cname, fopt ) ypos = ypos + fs + (fopt.bw * 2) end -- L4: preview thumbnail if not state.thumbfast.disabled then local pw = opt.preview_border_width local hpad = 4 + pw local tw = state.thumbfast.width local th = state.thumbfast.height local y = dpy_h - (ypos + th + pw) local x = state.mouse.x - (tw / 2) x = clamp(x, hpad, dpy_w - (hpad + tw)) mp.commandv( "script-message-to", "thumbfast", "thumb", hover_sec, x, y ) ypos = ypos + th + pw -- L4: preview border if pw > 0 then local c = opt.preview_border_color draw_rect( x, y, tw, th, "161616", { alpha = "7F", bw = pw, bcolor = c } ) ypos = ypos + pw end end end end render() end local function pbar_pressed() zassert(state.mouse.hover) zassert(state.pbar == pbar_active) if (state.duration) then mp.set_property("time-pos", hover_to_sec( state.mouse.x, state.dpy_w, state.duration )); end end local function pbar_update(next_state) local dpy_w = state.dpy_w local dpy_h = state.dpy_h if (dpy_w == 0 or dpy_h == 0 or state.pbar == next_state or state.pbar == pbar_uninit) then return end zassert(dpy_w > 0) zassert(dpy_h > 0) local statestr = { [pbar_uninit] = "uninit", [pbar_active] = "active", [pbar_minimized] = "minimized", [pbar_hidden] = "hidden" } msg.debug('[UPDATE]: ', statestr[state.pbar], '=> ', statestr[next_state]); -- TODO: reduce latency when pbar is active if (next_state == pbar_active) then state.pbar = pbar_active pbar_draw() if (not state.press_bounded) then mp.add_forced_key_binding('mbtn_left', 'pbar_pressed', pbar_pressed) state.press_bounded = true end if (not state.time_observed) then mp.observe_property("time-pos", nil, pbar_draw) state.time_observed = true end else if (next_state == pbar_minimized) then zassert(opt.pbar_minimized_height > 0) state.pbar = pbar_minimized pbar_draw() if (not state.time_observed) then mp.observe_property("time-pos", nil, pbar_draw) state.time_observed = true end elseif (next_state == pbar_hidden) then zassert(state.pbar ~= pbar_hidden) state.pbar = pbar_hidden state.osd.data = '' -- clear everything render() zassert(state.time_observed) mp.unobserve_property(pbar_draw) state.time_observed = false else zassert(false, "unreachable") end if (state.press_bounded) then mp.remove_key_binding('pbar_pressed') state.press_bounded = false end state.mouse = nil if (state.thumbfast.available) then mp.commandv("script-message-to", "thumbfast", "clear") end end end local function pbar_minimize_or_hide() msg.debug("[MIN-HIDE]") if (opt.pbar_minimized_height > 0 and not (opt.pbar_fullscreen_hide and state.fullscreen)) then pbar_update(pbar_minimized) else pbar_update(pbar_hidden) end end local function mouse_isactive(m) return m.hover and math.abs(m.y - state.dpy_h) < opt.proximity end local function update_mouse_pos(kind, mouse) zassert(kind == "mouse-pos") state.mouse_prev = state.mouse or { hover = false } state.mouse = mouse msg.debug('[MOUSE] hover = ', mouse.hover, ' x = ', mouse.x, ' y = ', mouse.y) local dpy_w = state.dpy_w local dpy_h = state.dpy_h if (dpy_w == 0 or dpy_h == 0) then return end zassert(dpy_w > 0) zassert(dpy_h > 0) zassert(mouse) -- TODO: ensure there's enough height to draw our stuff ? if (mouse_isactive(state.mouse_prev) and mouse_isactive(mouse)) then -- TODO: a better way to do this without killing/resuming a -- timer on each mouse update? state.timeout:kill() state.timeout:resume() pbar_update(pbar_active) else state.timeout:kill() pbar_minimize_or_hide() end end local function update_fullscreen(kind, fs) zassert(kind == "fullscreen") state.fullscreen = fs msg.debug('[FULLSCREEN] fs = ', fs) pbar_minimize_or_hide() end local function update_focus(kind, foc) zassert(kind == "focused") msg.debug('[FOCUS] focus = ', foc) state.mouse_prev = { hover = false, x = 0, y = 0 } end local function set_dpy_size(kind, osd) zassert(kind == "osd-dimensions") state.dpy_w = osd.w state.osd.res_x = osd.w state.dpy_h = osd.h state.osd.res_y = osd.h msg.debug('[DPY] w = ', osd.w, ' h = ', osd.h) -- HACK: ensure we don't obstruct the console (excluding the preview and hovered timeline) -- the shared_script_property_* functions are documented as undocumented :) -- and users are discouraged to use them, but whatever... local b = (opt.font_size + (opt.font_border_width * 2) + 8) / state.dpy_h -- +8 padding b = b + ((opt.pbar_minimized_height + opt.cachebar_height) / 100.0) utils.shared_script_property_set( 'osc-margins', string.format('%f,%f,%f,%f', 0, 0, 0, b) ) end local function set_cache_state(kind, c) zassert(kind == "demuxer-cache-state") if (c == nil) then state.cached_ranges = nil else local r = c['seekable-ranges'] if #r > 0 then state.cached_ranges = r else state.cached_ranges = nil end end end local function set_duration(kind, d) zassert(kind == "duration") state.duration = d end local function set_chapter_list(kind, c) zassert(kind == "chapter-list") if (c and #c > 0) then state.chapters = c table.sort(state.chapters, function(a, b) return a.time < b.time end) else state.chapters = nil end end local function set_thumbfast(json) local data = utils.parse_json(json) if (type(data) ~= "table" or not data.width or not data.height) then msg.error("thumbfast-info: received json didn't produce a table with thumbnail information") else state.thumbfast = data end end local function pbar_init(kind, thing) zassert(kind == 'vo-configured') msg.debug("[VO-CONFIGURED]", thing, state.pbar) if thing then zassert(state.pbar == pbar_uninit) state.pbar = pbar_hidden if (opt.pbar_minimized_height > 0) then pbar_update(pbar_minimized) end mp.unobserve_property(pbar_init) end end local function init() mpopt.read_options(opt, "mfpbar") for k,v in pairs(opt) do if string.find(k, "_color$") then opt[k] = rgb_to_ass(v) end end if opt.debug then msg.debug("[ASSERTIONS] enabled") zassert = assert else zassert(false) end state.osd = mp.create_osd_overlay("ass-events") mp.observe_property("osd-dimensions", "native", set_dpy_size) mp.observe_property('demuxer-cache-state', 'native', set_cache_state) mp.observe_property('duration', 'native', set_duration) mp.observe_property('chapter-list', 'native', set_chapter_list) mp.register_script_message("thumbfast-info", set_thumbfast) mp.observe_property('fullscreen', 'native', update_fullscreen) mp.observe_property('focused', 'native', update_focus) -- NOTE: mouse-pos doesn't work mpv versions older than v33 mp.observe_property("mouse-pos", "native", update_mouse_pos) if (opt.minimize_timeout > 0) then state.timeout = mp.add_timeout(opt.minimize_timeout, pbar_minimize_or_hide) state.timeout:kill() -- update_mouse_pos() will kill/resume this as needed else state.timeout = { kill = function() end, resume = function() end } end -- HACK: mpv doesn't open the window instantly by default. -- so wait for 'vo-configured' as a hook for when the window opens. mp.observe_property('vo-configured', 'native', pbar_init) end init()