-- mpv-osc-morden by maoiscat -- email:valarmor@163.com -- https://github.com/maoiscat/mpv-osc-morden -- fork by cyl0 -- https://github.com/cyl0/mpv-osc-morden-x 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 -- may change them in osc.conf local user_opts = { showwindowed = true, -- show OSC when windowed? showfullscreen = true, -- show OSC when fullscreen? scalewindowed = 1.0, -- scaling of the controller when windowed scalefullscreen = 1.0, -- scaling of the controller when fullscreen scaleforcedwindow = 1.0, -- scaling when rendered on a forced window vidscale = false, -- scale the controller with the video? hidetimeout = 1500, -- duration in ms until the OSC hides if no -- mouse movement. enforced non-negative for the -- user, but internally negative is 'always-on'. fadeduration = 250, -- duration of fade out in ms, 0 = no fade minmousemove = 1, -- 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) font = 'mpv-osd-symbols', -- default osc font seekbarhandlesize = 1.0, -- size ratio of the slider handle, range 0 ~ 1 seekrange = true, -- show seekrange overlay seekrangealpha = 64, -- transparency of seekranges seekbarkeyframes = true, -- use keyframes when dragging the seekbar showjump = true, -- show "jump forward/backward 5 seconds" buttons -- shift+left-click to step 1 frame and -- right-click to jump 1 minute jumpamount = 5, -- change the jump amount (in seconds by default) jumpiconnumber = true, -- show different icon when jumpamount is 5, 10, or 30 jumpmode = 'exact', -- seek mode for jump buttons. e.g. -- 'exact', 'relative+keyframes', etc. title = '${media-title}', -- string compatible with property-expansion -- to be shown as OSC title showtitle = true, -- show title in OSC showonpause = true, -- whether to disable the hide timeout on pause timetotal = true, -- display total time instead of remaining time? timems = false, -- Display time down to millliseconds by default visibility = 'auto', -- only used at init to set visibility_mode(...) windowcontrols = 'no', -- whether to show window controls language = 'eng', -- eng=English, chs=Chinese keyboardnavigation = false, -- enable directional keyboard navigation chapter_fmt = "Chapter: %s", -- chapter print format for seekbar-hover. "no" to disable } -- Icons for jump button depending on jumpamount local jumpicons = { [5] = {'\xEF\x8E\xB1', '\xEF\x8E\xA3'}, [10] = {'\xEF\x8E\xAF', '\xEF\x8E\xA1'}, [30] = {'\xEF\x8E\xB0', '\xEF\x8E\xA2'}, default = {'\xEF\x8E\xB2', '\xEF\x8E\xB2'}, -- second icon is mirrored in layout() } -- Localization local language = { ['eng'] = { welcome = '{\\fs24\\1c&H0&\\1c&HFFFFFF&}Drop files or URLs to play here.', -- this text appears when mpv starts off = 'OFF', na = 'n/a', none = 'none', video = 'Video', audio = 'Audio', subtitle = 'Subtitle', available = 'Available ', track = ' Tracks:', playlist = 'Playlist', nolist = 'Empty playlist.', chapter = 'Chapter', nochapter = 'No chapters.', }, ['chs'] = { welcome = '{\\1c&H00\\bord0\\fs30\\fn微软雅黑 light\\fscx125}MPV{\\fscx100} 播放器', -- this text appears when mpv starts off = '关闭', na = 'n/a', none = '无', video = '视频', audio = '音频', subtitle = '字幕', available = '可选', track = ':', playlist = '播放列表', nolist = '无列表信息', chapter = '章节', nochapter = '无章节信息', } } -- read options from config and command-line opt.read_options(user_opts, 'osc', function(list) update_options(list) end) -- apply lang opts local texts = language[user_opts.language] local osc_param = { -- calculated by osc_init() playresy = 0, -- canvas size Y playresx = 0, -- canvas size X display_aspect = 1, unscaled_y = 0, areas = {}, } local osc_styles = { TransBg = '{\\blur100\\bord150\\1c&H000000&\\3c&H000000&}', SeekbarBg = '{\\blur0\\bord0\\1c&HFFFFFF&}', SeekbarFg = '{\\blur1\\bord1\\1c&HE39C42&}', Ctrl1 = '{\\blur0\\bord0\\1c&HFFFFFF&\\3c&HFFFFFF&\\fs36\\fnmaterial-design-iconic-font}', Ctrl2 = '{\\blur0\\bord0\\1c&HFFFFFF&\\3c&HFFFFFF&\\fs30\\fnmaterial-design-iconic-font}', Ctrl2Flip = '{\\blur0\\bord0\\1c&HFFFFFF&\\3c&HFFFFFF&\\fs30\\fnmaterial-design-iconic-font\\fry180', Ctrl3 = '{\\blur0\\bord0\\1c&HFFFFFF&\\3c&HFFFFFF&\\fs30\\fnmaterial-design-iconic-font}', Time = '{\\blur0\\bord0\\1c&HFFFFFF&\\3c&H000000&\\fs20\\fn' .. user_opts.font .. '}', Tooltip = '{\\blur1\\bord0.5\\1c&HFFFFFF&\\3c&H000000&\\fs18\\fn' .. user_opts.font .. '}', Title = '{\\blur1\\bord0.5\\1c&HFFFFFF&\\3c&H0\\fs32\\q2\\fn' .. user_opts.font .. '}', WinCtrl = '{\\blur1\\bord0.5\\1c&HFFFFFF&\\3c&H0\\fs20\\fnmpv-osd-symbols}', elementDown = '{\\1c&H999999&}', elementHighlight = '{\\blur1\\bord1\\1c&HFFC033&}', } -- 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 mp_screen_sizeX, mp_screen_sizeY, -- last screen-resolution, to detect resolution changes to issue reINITs initREQ = false, -- is a re-init request 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, border = true, maximized = false, osd = mp.create_osd_overlay('ass-events'), lastvisibility = user_opts.visibility, -- save last visibility on pause if showonpause fulltime = user_opts.timems, highlight_element = 'cy_audio', chapter_list = {}, -- sorted by time } local window_control_box_width = 138 local tick_delay = 0.03 --- Automatically disable OSC local builtin_osc_enabled = mp.get_property_native('osc') if builtin_osc_enabled then mp.set_property_native('osc', false) end -- -- WindowControl helpers function window_controls_enabled() val = user_opts.windowcontrols if val == 'auto' then return (not state.border) or state.fullscreen else return val ~= 'no' end end function build_keyboard_controls() -- prepare the main button row local bottom_button_line = {} table.insert(bottom_button_line, 'cy_audio') table.insert(bottom_button_line, 'cy_sub') table.insert(bottom_button_line, 'pl_prev') table.insert(bottom_button_line, 'skipback') if user_opts.showjump then table.insert(bottom_button_line, 'jumpback') end table.insert(bottom_button_line, 'playpause') if user_opts.showjump then table.insert(bottom_button_line, 'jumpfrwd') end table.insert(bottom_button_line, 'skipfrwd') table.insert(bottom_button_line, 'pl_next') table.insert(bottom_button_line, 'tog_info') table.insert(bottom_button_line, 'tog_fs') -- build up the main mapping object local mapping = {} if window_controls_enabled() then table.insert(mapping, { 'minimize', 'maximize', 'close' }) end table.insert(mapping, { 'seekbar' }) table.insert(mapping, bottom_button_line) return mapping end -- -- Helperfunctions -- 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 -- 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 -- 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_cir_cw(ass, x, y, r) ass:round_rect_cw(x-r, y-r, x+r, y+r, r) 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 = texts.video, audio = texts.audio, sub = texts.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 = texts.available .. nicetypes[type] .. texts.track if #tracks_osc[type] == 0 then msg = msg .. texts.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 by 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 , 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 slider_lo = element.layout.slider -- calculate positions of min and max points element.slider.min.ele_pos = user_opts.seekbarhandlesize * elem_geo.h / 2 element.slider.max.ele_pos = elem_geo.w - element.slider.min.ele_pos 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() -- a hack which prepares the whole slider area to allow center placements such like an=5 static_ass:rect_cw(0, 0, elem_geo.w, elem_geo.h) static_ass:rect_ccw(0, 0, elem_geo.w, elem_geo.h) -- marker nibbles if not (element.slider.markerF == nil) and (slider_lo.gap > 0) 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) if (slider_lo.gap > 5) then -- draw triangles --top if (slider_lo.nibbles_top) then static_ass:move_to(s - 3, slider_lo.gap - 5) static_ass:line_to(s + 3, slider_lo.gap - 5) static_ass:line_to(s, slider_lo.gap - 1) end --bottom if (slider_lo.nibbles_bottom) then static_ass:move_to(s - 3, elem_geo.h - slider_lo.gap + 5) static_ass:line_to(s, elem_geo.h - slider_lo.gap + 1) static_ass:line_to(s + 3, elem_geo.h - slider_lo.gap + 5) end else -- draw 2x1px nibbles --top if (slider_lo.nibbles_top) then static_ass:rect_cw(s - 1, 0, s + 1, slider_lo.gap); end --bottom if (slider_lo.nibbles_bottom) then static_ass:rect_cw(s - 1, elem_geo.h-slider_lo.gap, s + 1, elem_geo.h); end end 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] = 136 element.eventresponder = nil end -- gray out the element if it is toggled off if (element.off) then element.layout.alpha[1] = 136 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 state.forced_title = 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 if user_opts.keyboardnavigation and state.highlight_element == element.name then style_ass:append(osc_styles.elementHighlight) 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 pos = element.slider.posF() local seekRanges = element.slider.seekRangesF() local rh = user_opts.seekbarhandlesize * elem_geo.h / 2 -- Handle radius local xp if pos then xp = get_slider_ele_pos_for(element, pos) ass_draw_cir_cw(elem_ass, xp, elem_geo.h/2, rh) elem_ass:rect_cw(0, slider_lo.gap, xp, elem_geo.h - slider_lo.gap) end if seekRanges 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) 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']) elem_ass:rect_cw(pstart - rh, slider_lo.gap, pend + rh, elem_geo.h - slider_lo.gap) 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 if (an == 2) then ty = element.hitbox.y1 else ty = element.hitbox.y1 + elem_geo.h/2 end local tx = get_virt_mouse_pos() if (slider_lo.adjust_tooltip) then if (an == 2) then if (sliderpos < (s_min + 3)) then an = an - 1 elseif (sliderpos > (s_max - 3)) then an = an + 1 end elseif (sliderpos > (s_max-s_min)/2) then an = an + 1 tx = tx - 5 else an = an - 1 tx = tx + 10 end end -- tooltip label elem_ass:new_event() elem_ass:pos(tx, ty) elem_ass:an(an) elem_ass:append(slider_lo.tooltip_style) ass_append_alpha(elem_ass, slider_lo.alpha, 0) elem_ass:append(tooltiplabel) 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 buttontext = buttontext:gsub(':%((.?.?.?)%) unknown ', ':%(%1%)') --gsub('%) unknown %(\'', '') local maxchars = element.layout.button.maxchars -- 认为1个中文字符约等于1.5个英文字符 -- local charcount = buttontext:len()- (buttontext:len()-select(2, buttontext:gsub('[^\128-\193]', '')))/1.5 local charcount = (buttontext:len() + select(2, buttontext:gsub('[^\128-\193]', ''))*2) / 3 if not (maxchars == nil) and (charcount > maxchars) then local limit = math.max(0, maxchars - 3) if (charcount > limit) then while (charcount > limit) do buttontext = buttontext:gsub('.[\128-\191]*$', '') charcount = (buttontext:len() + select(2, buttontext:gsub('[^\128-\193]', ''))*2) / 3 end buttontext = buttontext .. '...' end end elem_ass:append(buttontext) -- add tooltip if not (element.tooltipF == nil) and element.enabled then if mouse_hit(element) then local tooltiplabel = element.tooltipF local an = 1 local ty = element.hitbox.y1 local tx = get_virt_mouse_pos() if ty < osc_param.playresy / 2 then ty = element.hitbox.y2 an = 7 end -- tooltip label if type(element.tooltipF) == 'function' then tooltiplabel = element.tooltipF() else tooltiplabel = element.tooltipF end elem_ass:new_event() elem_ass:pos(tx, ty) elem_ass:an(an) elem_ass:append(element.tooltip_style) elem_ass:append(tooltiplabel) end end 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 texts.nolist end local message = string.format(texts.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 texts.nochapter end local message = string.format(texts.chapter.. ' [%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(texts.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 elements[name].name = name -- 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, nibbles_top = true, nibbles_bottom = true, 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 -- Window Controls function window_controls() local wc_geo = { x = 0, y = 32, an = 1, w = osc_param.playresx, h = 32, } local controlbox_w = window_control_box_width local titlebox_w = wc_geo.w - controlbox_w -- Default alignment is 'right' local controlbox_left = wc_geo.w - controlbox_w local titlebox_left = wc_geo.x local titlebox_right = wc_geo.w - controlbox_w add_area('window-controls', get_hitbox_coords(controlbox_left, wc_geo.y, wc_geo.an, controlbox_w, wc_geo.h)) local lo local button_y = wc_geo.y - (wc_geo.h / 2) local first_geo = {x = controlbox_left + 27, y = button_y, an = 5, w = 40, h = wc_geo.h} local second_geo = {x = controlbox_left + 69, y = button_y, an = 5, w = 40, h = wc_geo.h} local third_geo = {x = controlbox_left + 115, y = button_y, an = 5, w = 40, h = wc_geo.h} -- Window control buttons use symbols in the custom mpv osd font -- because the official unicode codepoints are sufficiently -- exotic that a system might lack an installed font with them, -- and libass will complain that they are not present in the -- default font, even if another font with them is available. -- Close: ?? ne = new_element('close', 'button') ne.content = '\238\132\149' ne.eventresponder['mbtn_left_up'] = function () mp.commandv('quit') end lo = add_layout('close') lo.geometry = third_geo lo.style = osc_styles.WinCtrl lo.alpha[3] = 0 -- Minimize: ?? ne = new_element('minimize', 'button') ne.content = '\\n\238\132\146' ne.eventresponder['mbtn_left_up'] = function () mp.commandv('cycle', 'window-minimized') end lo = add_layout('minimize') lo.geometry = first_geo lo.style = osc_styles.WinCtrl lo.alpha[3] = 0 -- Maximize: ?? /?? ne = new_element('maximize', 'button') if state.maximized or state.fullscreen then ne.content = '\238\132\148' else ne.content = '\238\132\147' end ne.eventresponder['mbtn_left_up'] = function () if state.fullscreen then mp.commandv('cycle', 'fullscreen') else mp.commandv('cycle', 'window-maximized') end end lo = add_layout('maximize') lo.geometry = second_geo lo.style = osc_styles.WinCtrl lo.alpha[3] = 0 end -- -- Layouts -- local layouts = {} -- Default layout layouts = function () local osc_geo = {w, h} osc_geo.w = osc_param.playresx osc_geo.h = 180 -- origin of the controllers, left/bottom corner local posX = 0 local posY = osc_param.playresy osc_param.areas = {} -- delete areas -- area for active mouse input add_area('input', get_hitbox_coords(posX, posY, 1, osc_geo.w, 113)) -- area for show/hide add_area('showhide', 0, 0, osc_param.playresx, osc_param.playresy) -- fetch values local osc_w, osc_h= osc_geo.w, osc_geo.h -- -- Controller Background -- local lo new_element('TransBg', 'box') lo = add_layout('TransBg') lo.geometry = {x = posX, y = posY, an = 7, w = osc_w, h = 1} lo.style = osc_styles.TransBg lo.layer = 10 lo.alpha[3] = 0 -- -- Alignment -- local refX = osc_w / 2 local refY = posY local geo -- -- Seekbar -- new_element('bgbar1', 'box') lo = add_layout('bgbar1') lo.geometry = {x = refX , y = refY - 105 , an = 5, w = osc_geo.w - 50, h = 4} lo.layer = 13 lo.style = osc_styles.SeekbarBg lo.alpha[1] = 128 lo.alpha[3] = 128 lo = add_layout('seekbar') lo.geometry = {x = refX, y = refY - 105 , an = 5, w = osc_geo.w - 50, h = 20} lo.style = osc_styles.SeekbarFg lo.slider.gap = 7 lo.slider.tooltip_style = osc_styles.Tooltip lo.slider.tooltip_an = 2 local showjump = user_opts.showjump local offset = showjump and 60 or 0 -- buttons lo = add_layout('pl_prev') lo.geometry = {x = refX - 120 - offset, y = refY - 40 , an = 5, w = 30, h = 24} lo.style = osc_styles.Ctrl2 lo = add_layout('skipback') lo.geometry = {x = refX - 60 - offset, y = refY - 40 , an = 5, w = 30, h = 24} lo.style = osc_styles.Ctrl2 if showjump then lo = add_layout('jumpback') lo.geometry = {x = refX - 60, y = refY - 40 , an = 5, w = 30, h = 24} lo.style = osc_styles.Ctrl2 end lo = add_layout('playpause') lo.geometry = {x = refX, y = refY - 40 , an = 5, w = 45, h = 45} lo.style = osc_styles.Ctrl1 if showjump then lo = add_layout('jumpfrwd') lo.geometry = {x = refX + 60, y = refY - 40 , an = 5, w = 30, h = 24} -- HACK: jumpfrwd's icon must be mirrored for nonstandard # of seconds -- as the font only has an icon without a number for rewinding lo.style = (user_opts.jumpiconnumber and jumpicons[user_opts.jumpamount] ~= nil) and osc_styles.Ctrl2 or osc_styles.Ctrl2Flip end lo = add_layout('skipfrwd') lo.geometry = {x = refX + 60 + offset, y = refY - 40 , an = 5, w = 30, h = 24} lo.style = osc_styles.Ctrl2 lo = add_layout('pl_next') lo.geometry = {x = refX + 120 + offset, y = refY - 40 , an = 5, w = 30, h = 24} lo.style = osc_styles.Ctrl2 -- Time lo = add_layout('tc_left') lo.geometry = {x = 25, y = refY - 84, an = 7, w = 64, h = 20} lo.style = osc_styles.Time lo = add_layout('tc_right') lo.geometry = {x = osc_geo.w - 25 , y = refY -84, an = 9, w = 64, h = 20} lo.style = osc_styles.Time lo = add_layout('cy_audio') lo.geometry = {x = 37, y = refY - 40, an = 5, w = 24, h = 24} lo.style = osc_styles.Ctrl3 lo = add_layout('cy_sub') lo.geometry = {x = 87, y = refY - 40, an = 5, w = 24, h = 24} lo.style = osc_styles.Ctrl3 lo = add_layout('tog_fs') lo.geometry = {x = osc_geo.w - 37, y = refY - 40, an = 5, w = 24, h = 24} lo.style = osc_styles.Ctrl3 lo = add_layout('tog_info') lo.geometry = {x = osc_geo.w - 87, y = refY - 40, an = 5, w = 24, h = 24} lo.style = osc_styles.Ctrl3 geo = { x = 25, y = refY - 132, an = 1, w = osc_geo.w - 50, h = 48 } lo = add_layout('title') lo.geometry = geo lo.style = string.format('%s{\\clip(%f,%f,%f,%f)}', osc_styles.Title, geo.x, geo.y - geo.h, geo.x + geo.w , geo.y) lo.alpha[3] = 0 end -- Validate string type user options function validate_user_opts() if user_opts.windowcontrols ~= 'auto' and user_opts.windowcontrols ~= 'yes' and user_opts.windowcontrols ~= 'no' then msg.warn('windowcontrols cannot be \'' .. user_opts.windowcontrols .. '\'. Ignoring.') user_opts.windowcontrols = 'auto' 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 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 = '\xEF\x8E\xB5' ne.enabled = (pl_pos > 1) or (loop ~= 'no') ne.eventresponder['mbtn_left_up'] = function () mp.commandv('playlist-prev', 'weak') end ne.eventresponder['mbtn_right_up'] = function () show_message(get_playlist()) end --next ne = new_element('pl_next', 'button') ne.content = '\xEF\x8E\xB4' ne.enabled = (have_pl and (pl_pos < pl_count)) or (loop ~= 'no') ne.eventresponder['mbtn_left_up'] = function () mp.commandv('playlist-next', 'weak') end ne.eventresponder['mbtn_right_up'] = function () show_message(get_playlist()) end --play control buttons --playpause ne = new_element('playpause', 'button') ne.content = function () if mp.get_property('pause') == 'yes' then return ('\xEF\x8E\xAA') else return ('\xEF\x8E\xA7') end end ne.eventresponder['mbtn_left_up'] = function () mp.commandv('cycle', 'pause') end --ne.eventresponder['mbtn_right_up'] = -- function () mp.commandv('script-binding', 'open-file-dialog') end if user_opts.showjump then local jumpamount = user_opts.jumpamount local jumpmode = user_opts.jumpmode local icons = jumpicons.default if user_opts.jumpiconnumber then icons = jumpicons[jumpamount] or jumpicons.default end --jumpback ne = new_element('jumpback', 'button') ne.softrepeat = true ne.content = icons[1] ne.eventresponder['mbtn_left_down'] = --function () mp.command('seek -5') end function () mp.commandv('seek', -jumpamount, jumpmode) end ne.eventresponder['shift+mbtn_left_down'] = function () mp.commandv('frame-back-step') end ne.eventresponder['mbtn_right_down'] = --function () mp.command('seek -60') end function () mp.commandv('seek', -60, jumpmode) end ne.eventresponder['enter'] = --function () mp.command('seek -5') end function () mp.commandv('seek', -jumpamount, jumpmode) end --jumpfrwd ne = new_element('jumpfrwd', 'button') ne.softrepeat = true ne.content = icons[2] ne.eventresponder['mbtn_left_down'] = --function () mp.command('seek +5') end function () mp.commandv('seek', jumpamount, jumpmode) end ne.eventresponder['shift+mbtn_left_down'] = function () mp.commandv('frame-step') end ne.eventresponder['mbtn_right_down'] = --function () mp.command('seek +60') end function () mp.commandv('seek', 60, jumpmode) end ne.eventresponder['enter'] = --function () mp.command('seek +5') end function () mp.commandv('seek', jumpamount, jumpmode) end end --skipback ne = new_element('skipback', 'button') ne.softrepeat = true ne.content = '\xEF\x8E\xA0' ne.enabled = (have_ch) -- disables button when no chapters available. ne.eventresponder['mbtn_left_down'] = --function () mp.command('seek -5') end --function () mp.commandv('seek', -5, 'relative', 'keyframes') end function () mp.commandv("add", "chapter", -1) end --ne.eventresponder['shift+mbtn_left_down'] = --function () mp.commandv('frame-back-step') end ne.eventresponder['mbtn_right_down'] = function () show_message(get_chapterlist()) end --function () mp.command('seek -60') end --function () mp.commandv('seek', -60, 'relative', 'keyframes') end ne.eventresponder['enter'] = --function () mp.command('seek -5') end --function () mp.commandv('seek', -5, 'relative', 'keyframes') end function () mp.commandv("add", "chapter", -1) end --skipfrwd ne = new_element('skipfrwd', 'button') ne.softrepeat = true ne.content = '\xEF\x8E\x9F' ne.enabled = (have_ch) -- disables button when no chapters available. ne.eventresponder['mbtn_left_down'] = --function () mp.command('seek +5') end --function () mp.commandv('seek', 5, 'relative', 'keyframes') end function () mp.commandv("add", "chapter", 1) end --ne.eventresponder['shift+mbtn_left_down'] = --function () mp.commandv('frame-step') end ne.eventresponder['mbtn_right_down'] = function () show_message(get_chapterlist()) end --function () mp.command('seek +60') end --function () mp.commandv('seek', 60, 'relative', 'keyframes') end ne.eventresponder['enter'] = --function () mp.command('seek +5') end --function () mp.commandv('seek', 5, 'relative', 'keyframes') end function () mp.commandv("add", "chapter", 1) end -- update_tracklist() --cy_audio ne = new_element('cy_audio', 'button') ne.enabled = (#tracks_osc.audio > 0) ne.off = (get_track('audio') == 0) ne.visible = (osc_param.playresx >= 540) ne.content = '\xEF\x8E\xB7' ne.tooltip_style = osc_styles.Tooltip ne.tooltipF = function () local msg = texts.off if not (get_track('audio') == 0) then msg = (texts.audio .. ' [' .. get_track('audio') .. ' ∕ ' .. #tracks_osc.audio .. '] ') local prop = mp.get_property('current-tracks/audio/title') --('current-tracks/audio/lang') if not prop then prop = texts.na end msg = msg .. '[' .. prop .. ']' prop = mp.get_property('current-tracks/audio/lang') --('current-tracks/audio/title') if prop then msg = msg .. ' ' .. prop end return msg end return msg 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['mbtn_mid_up'] = function () show_message(get_tracklist('audio')) end ne.eventresponder['enter'] = function () set_track('audio', 1); show_message(get_tracklist('audio')) end --cy_sub ne = new_element('cy_sub', 'button') ne.enabled = (#tracks_osc.sub > 0) ne.off = (get_track('sub') == 0) ne.visible = (osc_param.playresx >= 600) ne.content = '\xEF\x8F\x93' ne.tooltip_style = osc_styles.Tooltip ne.tooltipF = function () local msg = texts.off if not (get_track('sub') == 0) then msg = (texts.subtitle .. ' [' .. get_track('sub') .. ' ∕ ' .. #tracks_osc.sub .. '] ') local prop = mp.get_property('current-tracks/sub/lang') if not prop then prop = texts.na end msg = msg .. '[' .. prop .. ']' prop = mp.get_property('current-tracks/sub/title') if prop then msg = msg .. ' ' .. prop end return msg end return msg 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['mbtn_mid_up'] = function () show_message(get_tracklist('sub')) end ne.eventresponder['enter'] = function () set_track('sub', 1); show_message(get_tracklist('sub')) end --tog_fs ne = new_element('tog_fs', 'button') ne.content = function () if (state.fullscreen) then return ('\xEF\x85\xAC') else return ('\xEF\x85\xAD') end end ne.visible = (osc_param.playresx >= 540) ne.eventresponder['mbtn_left_up'] = function () mp.commandv("cycle", "fullscreen") end --tog_info ne = new_element('tog_info', 'button') ne.content = '' ne.visible = (osc_param.playresx >= 600) ne.eventresponder['mbtn_left_up'] = function () mp.commandv('script-binding', 'stats/display-stats-toggle') end -- title ne = new_element('title', 'button') ne.content = function () local title = state.forced_title or mp.command_native({"expand-text", user_opts.title}) if state.paused then title = title:gsub('\\n', ' '):gsub('\\$', ''):gsub('{','\\{') else title = title:gsub('\\n', ' '):gsub('\\$', ''):gsub('{','\\{') --title = ' ' end return not (title == '') and title or ' ' end ne.visible = osc_param.playresy >= 320 and user_opts.showtitle --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 not user_opts.seekrange 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) if not element.state.mbtnleft then return end -- allow drag for mbtnleft only! -- 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') element.state.mbtnleft = true end ne.eventresponder['mbtn_left_up'] = function (element) element.state.mbtnleft = false end ne.eventresponder['mbtn_right_down'] = --seeks to chapter start function (element) local duration = mp.get_property_number('duration', nil) if not (duration == nil) then local chapters = mp.get_property_native('chapter-list', {}) if #chapters > 0 then local pos = get_slider_value(element) local ch = #chapters for n = 1, ch do if chapters[n].time / duration * 100 >= pos then ch = n - 1 break end end mp.commandv('set', 'chapter', ch - 1) --if chapters[ch].title then show_message(chapters[ch].time) end end end end ne.eventresponder['reset'] = function (element) element.state.lastseek = nil end -- tc_left (current pos) ne = new_element('tc_left', 'button') ne.content = function () if (state.fulltime) then return (mp.get_property_osd('playback-time/full')) else return (mp.get_property_osd('playback-time')) end end ne.eventresponder["mbtn_left_up"] = function () state.fulltime = not state.fulltime request_init() end -- tc_right (total/remaining time) ne = new_element('tc_right', 'button') ne.content = function () if (mp.get_property_number('duration', 0) <= 0) then return '--:--:--' end if (state.rightTC_trem) then if (state.fulltime) then return ('-'..mp.get_property_osd('playtime-remaining/full')) else return ('-'..mp.get_property_osd('playtime-remaining')) end else if (state.fulltime) then return (mp.get_property_osd('duration/full')) else return (mp.get_property_osd('duration')) end end end ne.eventresponder['mbtn_left_up'] = function () state.rightTC_trem = not state.rightTC_trem end -- load layout layouts() -- load window controls if window_controls_enabled() then window_controls() end --do something with the elements prepare_elements() end function shutdown() 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.keyboardnavigation == true then osc_enable_key_bindings() end 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() if user_opts.keyboardnavigation == true then osc_disable_key_bindings() end 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 end request_tick() end function pause_state(name, enabled) state.paused = enabled mp.add_timeout(0.1, function() state.osd:update() end) if user_opts.showonpause then if enabled then state.lastvisibility = user_opts.visibility visibility_mode("always", true) show_osc() else visibility_mode(state.lastvisibility, true) end end 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: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.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 state.anistart = nil state.animation = nil state.anitype = nil end else state.anistart = nil state.animation = nil state.anitype = nil end --mouse show/hide area for k,cords in pairs(osc_param.areas['showhide']) do set_virt_mouse_area(cords.x1, cords.y1, cords.x2, cords.y2, 'showhide') end if osc_param.areas['showhide_wc'] then for k,cords in pairs(osc_param.areas['showhide_wc']) do set_virt_mouse_area(cords.x1, cords.y1, cords.x2, cords.y2, 'showhide_wc') end else set_virt_mouse_area(0, 0, 0, 0, 'showhide_wc') 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 if osc_param.areas['window-controls'] then for _,cords in ipairs(osc_param.areas['window-controls']) 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, 'window-controls') mp.enable_key_bindings('window-controls') else mp.disable_key_bindings('window-controls') end if (mouse_hit_coords(cords.x1, cords.y1, cords.x2, cords.y2)) then mouse_over_osc = true end end end if osc_param.areas['window-controls-title'] then for _,cords in ipairs(osc_param.areas['window-controls-title']) do if (mouse_hit_coords(cords.x1, cords.y1, cords.x2, cords.y2)) then mouse_over_osc = true end 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 request_tick() end end function show_logo() local osd_w, osd_h = 640, 360 local logo_x, logo_y = osd_w/2, osd_h/2-20 local ass = assdraw.ass_new() ass:new_event() ass:pos(logo_x, logo_y) ass:append('{\\1c&H8E348D&\\3c&H0&\\3a&H60&\\blur1\\bord0.5}') ass:draw_start() ass_draw_cir_cw(ass, 0, 0, 100) ass:draw_stop() ass:new_event() ass:pos(logo_x, logo_y) ass:append('{\\1c&H632462&\\bord0}') ass:draw_start() ass_draw_cir_cw(ass, 6, -6, 75) ass:draw_stop() ass:new_event() ass:pos(logo_x, logo_y) ass:append('{\\1c&HFFFFFF&\\bord0}') ass:draw_start() ass_draw_cir_cw(ass, -4, 4, 50) ass:draw_stop() ass:new_event() ass:pos(logo_x, logo_y) ass:append('{\\1c&H632462&\\bord&}') ass:draw_start() ass:move_to(-20, -20) ass:line_to(23.3, 5) ass:line_to(-20, 30) ass:draw_stop() ass:new_event() ass:pos(logo_x, logo_y+110) ass:an(8) ass:append(texts.welcome) set_osd(osd_w, osd_h, ass.text) end -- called by mpv on every frame function tick() if (not state.enabled) then return end if (state.idle) then --show_logo() -- render idle message msg.trace('idle message') if state.showhide_enabled then mp.disable_key_bindings('showhide') mp.disable_key_bindings('showhide_wc') 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 set_osd(osc_param.playresy, osc_param.playresy, '') 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') mp.enable_key_bindings('showhide_wc', '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') mp.disable_key_bindings('showhide_wc') 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 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') mp.set_key_bindings({ {'mouse_move', function(e) process_event('mouse_move', nil) end}, {'mouse_leave', mouse_leave}, }, 'showhide_wc', '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') mp.set_key_bindings({ {'mbtn_left', function(e) process_event('mbtn_left', 'up') end, function(e) process_event('mbtn_left', 'down') end}, }, 'window-controls', 'force') mp.enable_key_bindings('window-controls') 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 == '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 user_opts.visibility = mode 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') mp.disable_key_bindings('window-controls') state.input_enabled = false request_tick() end -- KeyboardControl -- local osc_key_bindings = {} function osc_kb_control_up() visibility_mode('always', true) local keyboard_controls = build_keyboard_controls() local rows = {} local active_row_index = 0 local active_row_name = nil local row_index = -1 for row_name, row_controls in pairs(keyboard_controls) do row_index = row_index + 1 rows[row_index] = row_name for i, control in pairs(row_controls) do if control == state.highlight_element then active_row_index = row_index active_row_name = row_name end end end if active_row_index - 1 < 0 then return end local next_row_index = active_row_index - 1 local new_active_row_name = rows[next_row_index] local new_active_row = keyboard_controls[new_active_row_name] for i, control in pairs(new_active_row) do state.highlight_element = control return end end function osc_kb_control_down() visibility_mode('always', true) local keyboard_controls = build_keyboard_controls() local rows = {} local active_row_index = 0 local active_row_name = nil local row_index = -1 for row_name, row_controls in pairs(keyboard_controls) do row_index = row_index + 1 rows[row_index] = row_name for i, control in pairs(row_controls) do if control == state.highlight_element then active_row_index = row_index active_row_name = row_name end end end if active_row_index + 1 > #rows then return end local next_row_index = active_row_index + 1 local new_active_row_name = rows[next_row_index] local new_active_row = keyboard_controls[new_active_row_name] for i, control in pairs(new_active_row) do state.highlight_element = control return end end function osc_kb_control_left() visibility_mode('always', true) local keyboard_controls = build_keyboard_controls() local active_control_name = nil for row_name, row_controls in pairs(keyboard_controls) do local controls = {} local controls_index = -1 for i, control in pairs(row_controls) do controls_index = controls_index + 1 controls[controls_index] = control if control == state.highlight_element then active_control_index = controls_index active_control_name = control end end if active_control_name == 'seekbar' then mp.commandv('seek', -5, 'exact', 'keyframes') return end if active_control_name then if active_control_index - 1 < 0 then return end local next_control_index = active_control_index - 1 state.highlight_element = controls[next_control_index] return end end end function osc_kb_control_right() visibility_mode('always', true) local keyboard_controls = build_keyboard_controls() local active_control_name = nil for row_name, row_controls in pairs(keyboard_controls) do local controls = {} local controls_index = -1 for i, control in pairs(row_controls) do controls_index = controls_index + 1 controls[controls_index] = control if control == state.highlight_element then active_control_index = controls_index active_control_name = control end end if active_control_name == 'seekbar' then mp.commandv('seek', 5, 'exact', 'keyframes') return end if active_control_name then if active_control_index + 1 > #controls then return end local next_control_index = active_control_index + 1 state.highlight_element = controls[next_control_index] return end end end function osc_kb_control_back() visibility_mode('auto', true) end function osc_kb_control_enter() visibility_mode('always', true) for n = 1, #elements do if elements[n].name == state.highlight_element then local action = 'enter' if element_has_action(elements[n], action) then elements[n].eventresponder[action](elements[n]) return end local action = 'mbtn_left_up' if element_has_action(elements[n], action) then elements[n].eventresponder[action](elements[n]) return end end end end function osc_add_key_binding(key, name, fn, flags) osc_key_bindings[#osc_key_bindings + 1] = name mp.add_forced_key_binding(key, name, fn, flags) end -- This is based on code from https://github.com/darsain/uosc function osc_enable_key_bindings() osc_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. osc_add_key_binding('up', 'osc-kb-control-prev1', osc_kb_control_up, 'repeatable') osc_add_key_binding('down', 'osc-kb-control-next1', osc_kb_control_down, 'repeatable') osc_add_key_binding('left', 'osc-kb-control-left1', osc_kb_control_left, 'repeatable') osc_add_key_binding('right', 'osc-kb-control-right1', osc_kb_control_right, 'repeatable') osc_add_key_binding('enter', 'osc-kb-control-select-alt3', osc_kb_control_enter, 'repeatable') osc_add_key_binding('esc', 'osc-kb-control-close', osc_kb_control_back, 'repeatable') end function osc_disable_key_bindings() for _, name in ipairs(osc_key_bindings) do mp.remove_key_binding(name) end osc_key_bindings = {} 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') set_virt_mouse_area(0, 0, 0, 0, 'window-controls')