commit 62fd4b81bfda095038bd532ebc23884bb018d02c
parent b5ea9e3eb4f74a04cee6bc0e832bbf7629e46a00
Author: Alex Balgavy <alex@balgavy.eu>
Date: Sat, 1 May 2021 14:12:31 +0200
mpv: a bunch of useful scripts
Diffstat:
12 files changed, 7655 insertions(+), 2 deletions(-)
diff --git a/mpv/input.conf b/mpv/input.conf
@@ -37,8 +37,8 @@
# Mouse wheels, touchpad or other input devices that have axes
# if the input devices supports precise scrolling it will also scale the
# numeric value accordingly
-#WHEEL_UP seek 10
-#WHEEL_DOWN seek -10
+WHEEL_UP add volume -2
+WHEEL_DOWN add volume 2
#WHEEL_LEFT add volume -2
#WHEEL_RIGHT add volume 2
@@ -193,3 +193,4 @@ p script-message osc-playlist # show playlist
P script-binding osc/visibility # cycle OSC display
c show_text "${chapter-metadata}${chapter}"
C show_text "${chapter-list}"
+alt+c script-message-to crop toggle-crop
diff --git a/mpv/mpv.conf b/mpv/mpv.conf
@@ -1,3 +1,4 @@
# Start at 50% volume
volume=50
osd-font-size=24
+osc=no
diff --git a/mpv/script-opts/crop.conf b/mpv/script-opts/crop.conf
@@ -0,0 +1,23 @@
+draw_crosshair=yes
+draw_text=yes
+draw_shade=yes
+# hexadecimal: 00 is opaque, FF is transparent
+shade_opacity=77
+mouse_support=yes
+
+# movement is defined in pixels in the window
+# which explains the mismatch with the text (in video space)
+coarse_movement=30
+left_coarse=LEFT
+right_coarse=RIGHT
+up_coarse=UP
+down_coarse=DOWN
+fine_movement=1
+left_fine=ALT+LEFT
+right_fine=ALT+RIGHT
+up_fine=ALT+UP
+down_fine=ALT+DOWN
+
+# these two options accept comma separated list of keys
+accept=ENTER,MOUSE_BTN0
+cancel=ESC
diff --git a/mpv/script-opts/mpv_thumbnail_script.conf b/mpv/script-opts/mpv_thumbnail_script.conf
@@ -0,0 +1 @@
+thumbnail_network=yes
diff --git a/mpv/script-opts/navigator.conf b/mpv/script-opts/navigator.conf
@@ -0,0 +1,5 @@
+key_navdown=j
+key_navup=k
+key_navback=h
+key_navforward=l
+key_navopen=o
diff --git a/mpv/scripts/crop.lua b/mpv/scripts/crop.lua
@@ -0,0 +1,419 @@
+local opts = {
+ draw_shade = true,
+ shade_opacity = "77",
+ draw_crosshair = true,
+ draw_text = true,
+ mouse_support=true,
+ coarse_movement=30,
+ left_coarse="LEFT",
+ right_coarse="RIGHT",
+ up_coarse="UP",
+ down_coarse="DOWN",
+ fine_movement=1,
+ left_fine="ALT+LEFT",
+ right_fine="ALT+RIGHT",
+ up_fine="ALT+UP",
+ down_fine="ALT+DOWN",
+ accept="ENTER,MOUSE_BTN0",
+ cancel="ESC",
+}
+(require 'mp.options').read_options(opts)
+
+function split(input)
+ local ret = {}
+ for str in string.gmatch(input, "([^,]+)") do
+ ret[#ret + 1] = str
+ end
+ return ret
+end
+opts.accept = split(opts.accept)
+opts.cancel = split(opts.cancel)
+
+local assdraw = require 'mp.assdraw'
+local msg = require 'mp.msg'
+local needs_drawing = false
+local dimensions_changed = false
+local crop_first_corner = nil -- in video space
+local crop_cursor = {
+ x = -1,
+ y = -1
+}
+
+function get_video_dimensions()
+ if not dimensions_changed then return _video_dimensions end
+ -- this function is very much ripped from video/out/aspect.c in mpv's source
+ local video_params = mp.get_property_native("video-out-params")
+ if not video_params then return nil end
+ dimensions_changed = false
+ local keep_aspect = mp.get_property_bool("keepaspect")
+ local w = video_params["w"]
+ local h = video_params["h"]
+ local dw = video_params["dw"]
+ local dh = video_params["dh"]
+ if mp.get_property_number("video-rotate") % 180 == 90 then
+ w, h = h,w
+ dw, dh = dh, dw
+ end
+ _video_dimensions = {
+ top_left = {},
+ bottom_right = {},
+ ratios = {},
+ }
+ if keep_aspect then
+ local unscaled = mp.get_property_native("video-unscaled")
+ local panscan = mp.get_property_number("panscan")
+ local window_w, window_h = mp.get_osd_size()
+
+ local fwidth = window_w
+ local fheight = math.floor(window_w / dw * dh)
+ if fheight > window_h or fheight < h then
+ local tmpw = math.floor(window_h / dh * dw)
+ if tmpw <= window_w then
+ fheight = window_h
+ fwidth = tmpw
+ end
+ end
+ local vo_panscan_area = window_h - fheight
+ local f_w = fwidth / fheight
+ local f_h = 1
+ if vo_panscan_area == 0 then
+ vo_panscan_area = window_h - fwidth
+ f_w = 1
+ f_h = fheight / fwidth
+ end
+ if unscaled or unscaled == "downscale-big" then
+ vo_panscan_area = 0
+ if unscaled or (dw <= window_w and dh <= window_h) then
+ fwidth = dw
+ fheight = dh
+ end
+ end
+
+ local scaled_width = fwidth + math.floor(vo_panscan_area * panscan * f_w)
+ local scaled_height = fheight + math.floor(vo_panscan_area * panscan * f_h)
+
+ local split_scaling = function (dst_size, scaled_src_size, zoom, align, pan)
+ scaled_src_size = math.floor(scaled_src_size * 2 ^ zoom)
+ align = (align + 1) / 2
+ local dst_start = math.floor((dst_size - scaled_src_size) * align + pan * scaled_src_size)
+ if dst_start < 0 then
+ --account for C int cast truncating as opposed to flooring
+ dst_start = dst_start + 1
+ end
+ local dst_end = dst_start + scaled_src_size;
+ if dst_start >= dst_end then
+ dst_start = 0
+ dst_end = 1
+ end
+ return dst_start, dst_end
+ end
+ local zoom = mp.get_property_number("video-zoom")
+
+ local align_x = mp.get_property_number("video-align-x")
+ local pan_x = mp.get_property_number("video-pan-x")
+ _video_dimensions.top_left.x, _video_dimensions.bottom_right.x = split_scaling(window_w, scaled_width, zoom, align_x, pan_x)
+
+ local align_y = mp.get_property_number("video-align-y")
+ local pan_y = mp.get_property_number("video-pan-y")
+ _video_dimensions.top_left.y, _video_dimensions.bottom_right.y = split_scaling(window_h, scaled_height, zoom, align_y, pan_y)
+ else
+ _video_dimensions.top_left.x = 0
+ _video_dimensions.bottom_right.x = window_w
+ _video_dimensions.top_left.y = 0
+ _video_dimensions.bottom_right.y = window_h
+ end
+ _video_dimensions.ratios.w = w / (_video_dimensions.bottom_right.x - _video_dimensions.top_left.x)
+ _video_dimensions.ratios.h = h / (_video_dimensions.bottom_right.y - _video_dimensions.top_left.y)
+ return _video_dimensions
+end
+
+function sort_corners(c1, c2)
+ local r1, r2 = {}, {}
+ if c1.x < c2.x then r1.x, r2.x = c1.x, c2.x else r1.x, r2.x = c2.x, c1.x end
+ if c1.y < c2.y then r1.y, r2.y = c1.y, c2.y else r1.y, r2.y = c2.y, c1.y end
+ return r1, r2
+end
+
+function clamp(low, value, high)
+ if value <= low then
+ return low
+ elseif value >= high then
+ return high
+ else
+ return value
+ end
+end
+
+function clamp_point(top_left, point, bottom_right)
+ return {
+ x = clamp(top_left.x, point.x, bottom_right.x),
+ y = clamp(top_left.y, point.y, bottom_right.y)
+ }
+end
+
+function screen_to_video(point, video_dim)
+ return {
+ x = math.floor(video_dim.ratios.w * (point.x - video_dim.top_left.x) + 0.5),
+ y = math.floor(video_dim.ratios.h * (point.y - video_dim.top_left.y) + 0.5)
+ }
+end
+
+function video_to_screen(point, video_dim)
+ return {
+ x = math.floor(point.x / video_dim.ratios.w + video_dim.top_left.x + 0.5),
+ y = math.floor(point.y / video_dim.ratios.h + video_dim.top_left.y + 0.5)
+ }
+end
+
+function draw_shade(ass, unshaded, video)
+ ass:new_event()
+ ass:pos(0, 0)
+ ass:append("{\\bord0}")
+ ass:append("{\\shad0}")
+ ass:append("{\\c&H000000&}")
+ ass:append("{\\1a&H" .. opts.shade_opacity .. "}")
+ ass:append("{\\2a&HFF}")
+ ass:append("{\\3a&HFF}")
+ ass:append("{\\4a&HFF}")
+ local c1, c2 = unshaded.top_left, unshaded.bottom_right
+ local v = video
+ -- c1.x c2.x
+ -- +-----+------------+
+ -- | | ur |
+ -- c1.y| ul +-------+----+
+ -- | | | |
+ -- c2.y+-----+-------+ lr |
+ -- | ll | |
+ -- +-------------+----+
+ ass:draw_start()
+ ass:rect_cw(v.top_left.x, v.top_left.y, c1.x, c2.y) -- ul
+ ass:rect_cw(c1.x, v.top_left.y, v.bottom_right.x, c1.y) -- ur
+ ass:rect_cw(v.top_left.x, c2.y, c2.x, v.bottom_right.y) -- ll
+ ass:rect_cw(c2.x, c1.y, v.bottom_right.x, v.bottom_right.y) -- lr
+ ass:draw_stop()
+ -- also possible to draw a rect over the whole video
+ -- and \iclip it in the middle, but seemingy slower
+end
+
+function draw_crosshair(ass, center, window_size)
+ ass:new_event()
+ ass:append("{\\bord0}")
+ ass:append("{\\shad0}")
+ ass:append("{\\c&HBBBBBB&}")
+ ass:append("{\\1a&H00&}")
+ ass:append("{\\2a&HFF&}")
+ ass:append("{\\3a&HFF&}")
+ ass:append("{\\4a&HFF&}")
+ ass:pos(0, 0)
+ ass:draw_start()
+ ass:rect_cw(center.x - 0.5, 0, center.x + 0.5, window_size.h)
+ ass:rect_cw(0, center.y - 0.5, window_size.w, center.y + 0.5)
+ ass:draw_stop()
+end
+
+function draw_position_text(ass, text, position, window_size, offset)
+ ass:new_event()
+ local align = 1
+ local ofx = 1
+ local ofy = -1
+ if position.x > window_size.w / 2 then
+ align = align + 2
+ ofx = -1
+ end
+ if position.y < window_size.h / 2 then
+ align = align + 6
+ ofy = 1
+ end
+ ass:append("{\\an"..align.."}")
+ ass:append("{\\fs26}")
+ ass:append("{\\bord1.5}")
+ ass:pos(ofx*offset + position.x, ofy*offset + position.y)
+ ass:append(text)
+end
+
+function draw_crop_zone()
+ if needs_drawing then
+ local video_dim = get_video_dimensions()
+ if not video_dim then
+ cancel_crop()
+ return
+ end
+
+ local window_size = {}
+ window_size.w, window_size.h = mp.get_osd_size()
+ crop_cursor = clamp_point(video_dim.top_left, crop_cursor, video_dim.bottom_right)
+ local ass = assdraw.ass_new()
+
+ if opts.draw_shade and crop_first_corner then
+ local first_corner = video_to_screen(crop_first_corner, video_dim)
+ local unshaded = {}
+ unshaded.top_left, unshaded.bottom_right = sort_corners(first_corner, crop_cursor)
+ -- don't draw shade over non-visible video parts
+ local window = {
+ top_left = { x = 0, y = 0 },
+ bottom_right = { x = window_size.w, y = window_size.h },
+ }
+ local video_visible = {
+ top_left = clamp_point(window.top_left, video_dim.top_left, window.bottom_right),
+ bottom_right = clamp_point(window.top_left, video_dim.bottom_right, window.bottom_right),
+ }
+ draw_shade(ass, unshaded, video_visible)
+ end
+
+ if opts.draw_crosshair then
+ draw_crosshair(ass, crop_cursor, window_size)
+ end
+
+ if opts.draw_text then
+ cursor_video = screen_to_video(crop_cursor, video_dim)
+ local text = string.format("%d, %d", cursor_video.x, cursor_video.y)
+ if crop_first_corner then
+ text = string.format("%s (%dx%d)", text,
+ math.abs(cursor_video.x - crop_first_corner.x),
+ math.abs(cursor_video.y - crop_first_corner.y)
+ )
+ end
+ draw_position_text(ass, text, crop_cursor, window_size, 6)
+ end
+
+ mp.set_osd_ass(window_size.w, window_size.h, ass.text)
+ needs_drawing = false
+ end
+end
+
+function crop_video(x, y, w, h)
+ local vf_table = mp.get_property_native("vf")
+ vf_table[#vf_table + 1] = {
+ name="crop",
+ params= {
+ x = tostring(x),
+ y = tostring(y),
+ w = tostring(w),
+ h = tostring(h)
+ }
+ }
+ mp.set_property_native("vf", vf_table)
+end
+
+function update_crop_zone_state()
+ local dim = get_video_dimensions()
+ if not dim then
+ cancel_crop()
+ return
+ end
+ crop_cursor = clamp_point(dim.top_left, crop_cursor, dim.bottom_right)
+ corner_video = screen_to_video(crop_cursor, dim)
+ if crop_first_corner == nil then
+ crop_first_corner = corner_video
+ needs_drawing = true
+ else
+ local c1, c2 = sort_corners(crop_first_corner, corner_video)
+ crop_video(c1.x, c1.y, c2.x - c1.x, c2.y - c1.y)
+ cancel_crop()
+ end
+end
+
+function reset_crop()
+ dimensions_changed = true
+ needs_drawing = true
+end
+
+local bindings = {}
+local bindings_repeat = {}
+
+function cancel_crop()
+ needs_drawing = false
+ crop_first_corner = nil
+ for key, _ in pairs(bindings) do
+ mp.remove_key_binding("crop-"..key)
+ end
+ for key, _ in pairs(bindings_repeat) do
+ mp.remove_key_binding("crop-"..key)
+ end
+ mp.unobserve_property(reset_crop)
+ mp.unregister_idle(draw_crop_zone)
+ mp.set_osd_ass(1280, 720, '')
+end
+
+-- bindings
+if opts.mouse_support then
+ bindings["MOUSE_MOVE"] = function() crop_cursor.x, crop_cursor.y = mp.get_mouse_pos(); needs_drawing = true end
+end
+for _, key in ipairs(opts.accept) do
+ bindings[key] = update_crop_zone_state
+end
+for _, key in ipairs(opts.cancel) do
+ bindings[key] = cancel_crop
+end
+function movement_func(move_x, move_y)
+ return function()
+ crop_cursor.x = crop_cursor.x + move_x
+ crop_cursor.y = crop_cursor.y + move_y
+ needs_drawing = true
+ end
+end
+bindings_repeat[opts.left_coarse] = movement_func(-opts.coarse_movement, 0)
+bindings_repeat[opts.right_coarse] = movement_func(opts.coarse_movement, 0)
+bindings_repeat[opts.up_coarse] = movement_func(0, -opts.coarse_movement)
+bindings_repeat[opts.down_coarse] = movement_func(0, opts.coarse_movement)
+bindings_repeat[opts.left_fine] = movement_func(-opts.fine_movement, 0)
+bindings_repeat[opts.right_fine] = movement_func(opts.fine_movement, 0)
+bindings_repeat[opts.up_fine] = movement_func(0, -opts.fine_movement)
+bindings_repeat[opts.down_fine] = movement_func(0, opts.fine_movement)
+
+local properties = {
+ "keepaspect",
+ "video-out-params",
+ "video-unscaled",
+ "panscan",
+ "video-zoom",
+ "video-align-x",
+ "video-pan-x",
+ "video-align-y",
+ "video-pan-y",
+ "osd-width",
+ "osd-height",
+}
+
+function start_crop()
+ if not mp.get_property("video-out-params", nil) then return end
+ local hwdec = mp.get_property("hwdec-current")
+ if hwdec and hwdec ~= "no" and not string.find(hwdec, "-copy$") then
+ msg.error("Cannot crop with hardware decoding active (see manual)")
+ return
+ end
+
+ crop_cursor.x, crop_cursor.y = mp.get_mouse_pos()
+ needs_drawing = true
+ dimensions_changed = true
+ for key, func in pairs(bindings) do
+ mp.add_forced_key_binding(key, "crop-"..key, func)
+ end
+ for key, func in pairs(bindings_repeat) do
+ mp.add_forced_key_binding(key, "crop-"..key, func, { repeatable = true })
+ end
+ mp.register_idle(draw_crop_zone)
+ for _, p in ipairs(properties) do
+ mp.observe_property(p, "native", reset_crop)
+ end
+end
+
+function toggle_crop()
+ local vf_table = mp.get_property_native("vf")
+ if #vf_table > 0 then
+ for i = #vf_table, 1, -1 do
+ if vf_table[i].name == "crop" then
+ for j = i, #vf_table-1 do
+ vf_table[j] = vf_table[j+1]
+ end
+ vf_table[#vf_table] = nil
+ mp.set_property_native("vf", vf_table)
+ return
+ end
+ end
+ end
+ start_crop()
+end
+
+mp.add_key_binding(nil, "start-crop", start_crop)
+mp.add_key_binding(nil, "toggle-crop", toggle_crop)
diff --git a/mpv/scripts/mpv_thumbnail_script_client_osc.lua b/mpv/scripts/mpv_thumbnail_script_client_osc.lua
@@ -0,0 +1,4117 @@
+--[[
+ Copyright (C) 2017 AMM
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+]]--
+--[[
+ mpv_thumbnail_script.lua 0.4.2 - commit b4cf490 (branch master)
+ https://github.com/TheAMM/mpv_thumbnail_script
+ Built on 2021-05-01 11:01:10
+]]--
+local assdraw = require 'mp.assdraw'
+local msg = require 'mp.msg'
+local opt = require 'mp.options'
+local utils = require 'mp.utils'
+
+-- Determine platform --
+ON_WINDOWS = (package.config:sub(1,1) ~= '/')
+
+-- Some helper functions needed to parse the options --
+function isempty(v) return (v == false) or (v == nil) or (v == "") or (v == 0) or (type(v) == "table" and next(v) == nil) end
+
+function divmod (a, b)
+ return math.floor(a / b), a % b
+end
+
+-- Better modulo
+function bmod( i, N )
+ return (i % N + N) % N
+end
+
+function join_paths(...)
+ local sep = ON_WINDOWS and "\\" or "/"
+ local result = "";
+ for i, p in pairs({...}) do
+ if p ~= "" then
+ if is_absolute_path(p) then
+ result = p
+ else
+ result = (result ~= "") and (result:gsub("[\\"..sep.."]*$", "") .. sep .. p) or p
+ end
+ end
+ end
+ return result:gsub("[\\"..sep.."]*$", "")
+end
+
+-- /some/path/file.ext -> /some/path, file.ext
+function split_path( path )
+ local sep = ON_WINDOWS and "\\" or "/"
+ local first_index, last_index = path:find('^.*' .. sep)
+
+ if last_index == nil then
+ return "", path
+ else
+ local dir = path:sub(0, last_index-1)
+ local file = path:sub(last_index+1, -1)
+
+ return dir, file
+ end
+end
+
+function is_absolute_path( path )
+ local tmp, is_win = path:gsub("^[A-Z]:\\", "")
+ local tmp, is_unix = path:gsub("^/", "")
+ return (is_win > 0) or (is_unix > 0)
+end
+
+function Set(source)
+ local set = {}
+ for _, l in ipairs(source) do set[l] = true end
+ return set
+end
+
+---------------------------
+-- More helper functions --
+---------------------------
+
+-- Removes all keys from a table, without destroying the reference to it
+function clear_table(target)
+ for key, value in pairs(target) do
+ target[key] = nil
+ end
+end
+function shallow_copy(target)
+ local copy = {}
+ for k, v in pairs(target) do
+ copy[k] = v
+ end
+ return copy
+end
+
+-- Rounds to given decimals. eg. round_dec(3.145, 0) => 3
+function round_dec(num, idp)
+ local mult = 10^(idp or 0)
+ return math.floor(num * mult + 0.5) / mult
+end
+
+function file_exists(name)
+ local f = io.open(name, "rb")
+ if f ~= nil then
+ local ok, err, code = f:read(1)
+ io.close(f)
+ return code == nil
+ else
+ return false
+ end
+end
+
+function path_exists(name)
+ local f = io.open(name, "rb")
+ if f ~= nil then
+ io.close(f)
+ return true
+ else
+ return false
+ end
+end
+
+function create_directories(path)
+ local cmd
+ if ON_WINDOWS then
+ cmd = { args = {"cmd", "/c", "mkdir", path} }
+ else
+ cmd = { args = {"mkdir", "-p", path} }
+ end
+ utils.subprocess(cmd)
+end
+
+-- Find an executable in PATH or CWD with the given name
+function find_executable(name)
+ local delim = ON_WINDOWS and ";" or ":"
+
+ local pwd = os.getenv("PWD") or utils.getcwd()
+ local path = os.getenv("PATH")
+
+ local env_path = pwd .. delim .. path -- Check CWD first
+
+ local result, filename
+ for path_dir in env_path:gmatch("[^"..delim.."]+") do
+ filename = join_paths(path_dir, name)
+ if file_exists(filename) then
+ result = filename
+ break
+ end
+ end
+
+ return result
+end
+
+local ExecutableFinder = { path_cache = {} }
+-- Searches for an executable and caches the result if any
+function ExecutableFinder:get_executable_path( name, raw_name )
+ name = ON_WINDOWS and not raw_name and (name .. ".exe") or name
+
+ if self.path_cache[name] == nil then
+ self.path_cache[name] = find_executable(name) or false
+ end
+ return self.path_cache[name]
+end
+
+-- Format seconds to HH.MM.SS.sss
+function format_time(seconds, sep, decimals)
+ decimals = decimals == nil and 3 or decimals
+ sep = sep and sep or "."
+ local s = seconds
+ local h, s = divmod(s, 60*60)
+ local m, s = divmod(s, 60)
+
+ local second_format = string.format("%%0%d.%df", 2+(decimals > 0 and decimals+1 or 0), decimals)
+
+ return string.format("%02d"..sep.."%02d"..sep..second_format, h, m, s)
+end
+
+-- Format seconds to 1h 2m 3.4s
+function format_time_hms(seconds, sep, decimals, force_full)
+ decimals = decimals == nil and 1 or decimals
+ sep = sep ~= nil and sep or " "
+
+ local s = seconds
+ local h, s = divmod(s, 60*60)
+ local m, s = divmod(s, 60)
+
+ if force_full or h > 0 then
+ return string.format("%dh"..sep.."%dm"..sep.."%." .. tostring(decimals) .. "fs", h, m, s)
+ elseif m > 0 then
+ return string.format("%dm"..sep.."%." .. tostring(decimals) .. "fs", m, s)
+ else
+ return string.format("%." .. tostring(decimals) .. "fs", s)
+ end
+end
+
+-- Writes text on OSD and console
+function log_info(txt, timeout)
+ timeout = timeout or 1.5
+ msg.info(txt)
+ mp.osd_message(txt, timeout)
+end
+
+-- Join table items, ala ({"a", "b", "c"}, "=", "-", ", ") => "=a-, =b-, =c-"
+function join_table(source, before, after, sep)
+ before = before or ""
+ after = after or ""
+ sep = sep or ", "
+ local result = ""
+ for i, v in pairs(source) do
+ if not isempty(v) then
+ local part = before .. v .. after
+ if i == 1 then
+ result = part
+ else
+ result = result .. sep .. part
+ end
+ end
+ end
+ return result
+end
+
+function wrap(s, char)
+ char = char or "'"
+ return char .. s .. char
+end
+-- Wraps given string into 'string' and escapes any 's in it
+function escape_and_wrap(s, char, replacement)
+ char = char or "'"
+ replacement = replacement or "\\" .. char
+ return wrap(string.gsub(s, char, replacement), char)
+end
+-- Escapes single quotes in a string and wraps the input in single quotes
+function escape_single_bash(s)
+ return escape_and_wrap(s, "'", "'\\''")
+end
+
+-- Returns (a .. b) if b is not empty or nil
+function joined_or_nil(a, b)
+ return not isempty(b) and (a .. b) or nil
+end
+
+-- Put items from one table into another
+function extend_table(target, source)
+ for i, v in pairs(source) do
+ table.insert(target, v)
+ end
+end
+
+-- Creates a handle and filename for a temporary random file (in current directory)
+function create_temporary_file(base, mode, suffix)
+ local handle, filename
+ suffix = suffix or ""
+ while true do
+ filename = base .. tostring(math.random(1, 5000)) .. suffix
+ handle = io.open(filename, "r")
+ if not handle then
+ handle = io.open(filename, mode)
+ break
+ end
+ io.close(handle)
+ end
+ return handle, filename
+end
+
+
+function get_processor_count()
+ local proc_count
+
+ if ON_WINDOWS then
+ proc_count = tonumber(os.getenv("NUMBER_OF_PROCESSORS"))
+ else
+ local cpuinfo_handle = io.open("/proc/cpuinfo")
+ if cpuinfo_handle ~= nil then
+ local cpuinfo_contents = cpuinfo_handle:read("*a")
+ local _, replace_count = cpuinfo_contents:gsub('processor', '')
+ proc_count = replace_count
+ end
+ end
+
+ if proc_count and proc_count > 0 then
+ return proc_count
+ else
+ return nil
+ end
+end
+
+function substitute_values(string, values)
+ local substitutor = function(match)
+ if match == "%" then
+ return "%"
+ else
+ -- nil is discarded by gsub
+ return values[match]
+ end
+ end
+
+ local substituted = string:gsub('%%(.)', substitutor)
+ return substituted
+end
+
+-- ASS HELPERS --
+function round_rect_top( ass, x0, y0, x1, y1, r )
+ local c = 0.551915024494 * r -- circle approximation
+ ass:move_to(x0 + r, y0)
+ ass:line_to(x1 - r, y0) -- top line
+ if r > 0 then
+ ass:bezier_curve(x1 - r + c, y0, x1, y0 + r - c, x1, y0 + r) -- top right corner
+ end
+ ass:line_to(x1, y1) -- right line
+ ass:line_to(x0, y1) -- bottom line
+ ass:line_to(x0, y0 + r) -- left line
+ if r > 0 then
+ ass:bezier_curve(x0, y0 + r - c, x0 + r - c, y0, x0 + r, y0) -- top left corner
+ end
+end
+
+function round_rect(ass, x0, y0, x1, y1, rtl, rtr, rbr, rbl)
+ local c = 0.551915024494
+ ass:move_to(x0 + rtl, y0)
+ ass:line_to(x1 - rtr, y0) -- top line
+ if rtr > 0 then
+ ass:bezier_curve(x1 - rtr + rtr*c, y0, x1, y0 + rtr - rtr*c, x1, y0 + rtr) -- top right corner
+ end
+ ass:line_to(x1, y1 - rbr) -- right line
+ if rbr > 0 then
+ ass:bezier_curve(x1, y1 - rbr + rbr*c, x1 - rbr + rbr*c, y1, x1 - rbr, y1) -- bottom right corner
+ end
+ ass:line_to(x0 + rbl, y1) -- bottom line
+ if rbl > 0 then
+ ass:bezier_curve(x0 + rbl - rbl*c, y1, x0, y1 - rbl + rbl*c, x0, y1 - rbl) -- bottom left corner
+ end
+ ass:line_to(x0, y0 + rtl) -- left line
+ if rtl > 0 then
+ ass:bezier_curve(x0, y0 + rtl - rtl*c, x0 + rtl - rtl*c, y0, x0 + rtl, y0) -- top left corner
+ end
+end
+-- $Revision: 1.5 $
+-- $Date: 2014-09-10 16:54:25 $
+
+-- This module was originally taken from http://cube3d.de/uploads/Main/sha1.txt.
+
+-------------------------------------------------------------------------------
+-- SHA-1 secure hash computation, and HMAC-SHA1 signature computation,
+-- in pure Lua (tested on Lua 5.1)
+-- License: MIT
+--
+-- Usage:
+-- local hashAsHex = sha1.hex(message) -- returns a hex string
+-- local hashAsData = sha1.bin(message) -- returns raw bytes
+--
+-- local hmacAsHex = sha1.hmacHex(key, message) -- hex string
+-- local hmacAsData = sha1.hmacBin(key, message) -- raw bytes
+--
+--
+-- Pass sha1.hex() a string, and it returns a hash as a 40-character hex string.
+-- For example, the call
+--
+-- local hash = sha1.hex("iNTERFACEWARE")
+--
+-- puts the 40-character string
+--
+-- "e76705ffb88a291a0d2f9710a5471936791b4819"
+--
+-- into the variable 'hash'
+--
+-- Pass sha1.hmacHex() a key and a message, and it returns the signature as a
+-- 40-byte hex string.
+--
+--
+-- The two "bin" versions do the same, but return the 20-byte string of raw
+-- data that the 40-byte hex strings represent.
+--
+-------------------------------------------------------------------------------
+--
+-- Description
+-- Due to the lack of bitwise operations in 5.1, this version uses numbers to
+-- represents the 32bit words that we combine with binary operations. The basic
+-- operations of byte based "xor", "or", "and" are all cached in a combination
+-- table (several 64k large tables are built on startup, which
+-- consumes some memory and time). The caching can be switched off through
+-- setting the local cfg_caching variable to false.
+-- For all binary operations, the 32 bit numbers are split into 8 bit values
+-- that are combined and then merged again.
+--
+-- Algorithm: http://www.itl.nist.gov/fipspubs/fip180-1.htm
+--
+-------------------------------------------------------------------------------
+
+local sha1 = (function()
+local sha1 = {}
+
+-- set this to false if you don't want to build several 64k sized tables when
+-- loading this file (takes a while but grants a boost of factor 13)
+local cfg_caching = false
+-- local storing of global functions (minor speedup)
+local floor,modf = math.floor,math.modf
+local char,format,rep = string.char,string.format,string.rep
+
+-- merge 4 bytes to an 32 bit word
+local function bytes_to_w32 (a,b,c,d) return a*0x1000000+b*0x10000+c*0x100+d end
+-- split a 32 bit word into four 8 bit numbers
+local function w32_to_bytes (i)
+ return floor(i/0x1000000)%0x100,floor(i/0x10000)%0x100,floor(i/0x100)%0x100,i%0x100
+end
+
+-- shift the bits of a 32 bit word. Don't use negative values for "bits"
+local function w32_rot (bits,a)
+ local b2 = 2^(32-bits)
+ local a,b = modf(a/b2)
+ return a+b*b2*(2^(bits))
+end
+
+-- caching function for functions that accept 2 arguments, both of values between
+-- 0 and 255. The function to be cached is passed, all values are calculated
+-- during loading and a function is returned that returns the cached values (only)
+local function cache2arg (fn)
+ if not cfg_caching then return fn end
+ local lut = {}
+ for i=0,0xffff do
+ local a,b = floor(i/0x100),i%0x100
+ lut[i] = fn(a,b)
+ end
+ return function (a,b)
+ return lut[a*0x100+b]
+ end
+end
+
+-- splits an 8-bit number into 8 bits, returning all 8 bits as booleans
+local function byte_to_bits (b)
+ local b = function (n)
+ local b = floor(b/n)
+ return b%2==1
+ end
+ return b(1),b(2),b(4),b(8),b(16),b(32),b(64),b(128)
+end
+
+-- builds an 8bit number from 8 booleans
+local function bits_to_byte (a,b,c,d,e,f,g,h)
+ local function n(b,x) return b and x or 0 end
+ return n(a,1)+n(b,2)+n(c,4)+n(d,8)+n(e,16)+n(f,32)+n(g,64)+n(h,128)
+end
+
+-- debug function for visualizing bits in a string
+local function bits_to_string (a,b,c,d,e,f,g,h)
+ local function x(b) return b and "1" or "0" end
+ return ("%s%s%s%s %s%s%s%s"):format(x(a),x(b),x(c),x(d),x(e),x(f),x(g),x(h))
+end
+
+-- debug function for converting a 8-bit number as bit string
+local function byte_to_bit_string (b)
+ return bits_to_string(byte_to_bits(b))
+end
+
+-- debug function for converting a 32 bit number as bit string
+local function w32_to_bit_string(a)
+ if type(a) == "string" then return a end
+ local aa,ab,ac,ad = w32_to_bytes(a)
+ local s = byte_to_bit_string
+ return ("%s %s %s %s"):format(s(aa):reverse(),s(ab):reverse(),s(ac):reverse(),s(ad):reverse()):reverse()
+end
+
+-- bitwise "and" function for 2 8bit number
+local band = cache2arg (function(a,b)
+ local A,B,C,D,E,F,G,H = byte_to_bits(b)
+ local a,b,c,d,e,f,g,h = byte_to_bits(a)
+ return bits_to_byte(
+ A and a, B and b, C and c, D and d,
+ E and e, F and f, G and g, H and h)
+ end)
+
+-- bitwise "or" function for 2 8bit numbers
+local bor = cache2arg(function(a,b)
+ local A,B,C,D,E,F,G,H = byte_to_bits(b)
+ local a,b,c,d,e,f,g,h = byte_to_bits(a)
+ return bits_to_byte(
+ A or a, B or b, C or c, D or d,
+ E or e, F or f, G or g, H or h)
+ end)
+
+-- bitwise "xor" function for 2 8bit numbers
+local bxor = cache2arg(function(a,b)
+ local A,B,C,D,E,F,G,H = byte_to_bits(b)
+ local a,b,c,d,e,f,g,h = byte_to_bits(a)
+ return bits_to_byte(
+ A ~= a, B ~= b, C ~= c, D ~= d,
+ E ~= e, F ~= f, G ~= g, H ~= h)
+ end)
+
+-- bitwise complement for one 8bit number
+local function bnot (x)
+ return 255-(x % 256)
+end
+
+-- creates a function to combine to 32bit numbers using an 8bit combination function
+local function w32_comb(fn)
+ return function (a,b)
+ local aa,ab,ac,ad = w32_to_bytes(a)
+ local ba,bb,bc,bd = w32_to_bytes(b)
+ return bytes_to_w32(fn(aa,ba),fn(ab,bb),fn(ac,bc),fn(ad,bd))
+ end
+end
+
+-- create functions for and, xor and or, all for 2 32bit numbers
+local w32_and = w32_comb(band)
+local w32_xor = w32_comb(bxor)
+local w32_or = w32_comb(bor)
+
+-- xor function that may receive a variable number of arguments
+local function w32_xor_n (a,...)
+ local aa,ab,ac,ad = w32_to_bytes(a)
+ for i=1,select('#',...) do
+ local ba,bb,bc,bd = w32_to_bytes(select(i,...))
+ aa,ab,ac,ad = bxor(aa,ba),bxor(ab,bb),bxor(ac,bc),bxor(ad,bd)
+ end
+ return bytes_to_w32(aa,ab,ac,ad)
+end
+
+-- combining 3 32bit numbers through binary "or" operation
+local function w32_or3 (a,b,c)
+ local aa,ab,ac,ad = w32_to_bytes(a)
+ local ba,bb,bc,bd = w32_to_bytes(b)
+ local ca,cb,cc,cd = w32_to_bytes(c)
+ return bytes_to_w32(
+ bor(aa,bor(ba,ca)), bor(ab,bor(bb,cb)), bor(ac,bor(bc,cc)), bor(ad,bor(bd,cd))
+ )
+end
+
+-- binary complement for 32bit numbers
+local function w32_not (a)
+ return 4294967295-(a % 4294967296)
+end
+
+-- adding 2 32bit numbers, cutting off the remainder on 33th bit
+local function w32_add (a,b) return (a+b) % 4294967296 end
+
+-- adding n 32bit numbers, cutting off the remainder (again)
+local function w32_add_n (a,...)
+ for i=1,select('#',...) do
+ a = (a+select(i,...)) % 4294967296
+ end
+ return a
+end
+-- converting the number to a hexadecimal string
+local function w32_to_hexstring (w) return format("%08x",w) end
+
+-- calculating the SHA1 for some text
+function sha1.hex(msg)
+ local H0,H1,H2,H3,H4 = 0x67452301,0xEFCDAB89,0x98BADCFE,0x10325476,0xC3D2E1F0
+ local msg_len_in_bits = #msg * 8
+
+ local first_append = char(0x80) -- append a '1' bit plus seven '0' bits
+
+ local non_zero_message_bytes = #msg +1 +8 -- the +1 is the appended bit 1, the +8 are for the final appended length
+ local current_mod = non_zero_message_bytes % 64
+ local second_append = current_mod>0 and rep(char(0), 64 - current_mod) or ""
+
+ -- now to append the length as a 64-bit number.
+ local B1, R1 = modf(msg_len_in_bits / 0x01000000)
+ local B2, R2 = modf( 0x01000000 * R1 / 0x00010000)
+ local B3, R3 = modf( 0x00010000 * R2 / 0x00000100)
+ local B4 = 0x00000100 * R3
+
+ local L64 = char( 0) .. char( 0) .. char( 0) .. char( 0) -- high 32 bits
+ .. char(B1) .. char(B2) .. char(B3) .. char(B4) -- low 32 bits
+
+ msg = msg .. first_append .. second_append .. L64
+
+ assert(#msg % 64 == 0)
+
+ local chunks = #msg / 64
+
+ local W = { }
+ local start, A, B, C, D, E, f, K, TEMP
+ local chunk = 0
+
+ while chunk < chunks do
+ --
+ -- break chunk up into W[0] through W[15]
+ --
+ start,chunk = chunk * 64 + 1,chunk + 1
+
+ for t = 0, 15 do
+ W[t] = bytes_to_w32(msg:byte(start, start + 3))
+ start = start + 4
+ end
+
+ --
+ -- build W[16] through W[79]
+ --
+ for t = 16, 79 do
+ -- For t = 16 to 79 let Wt = S1(Wt-3 XOR Wt-8 XOR Wt-14 XOR Wt-16).
+ W[t] = w32_rot(1, w32_xor_n(W[t-3], W[t-8], W[t-14], W[t-16]))
+ end
+
+ A,B,C,D,E = H0,H1,H2,H3,H4
+
+ for t = 0, 79 do
+ if t <= 19 then
+ -- (B AND C) OR ((NOT B) AND D)
+ f = w32_or(w32_and(B, C), w32_and(w32_not(B), D))
+ K = 0x5A827999
+ elseif t <= 39 then
+ -- B XOR C XOR D
+ f = w32_xor_n(B, C, D)
+ K = 0x6ED9EBA1
+ elseif t <= 59 then
+ -- (B AND C) OR (B AND D) OR (C AND D
+ f = w32_or3(w32_and(B, C), w32_and(B, D), w32_and(C, D))
+ K = 0x8F1BBCDC
+ else
+ -- B XOR C XOR D
+ f = w32_xor_n(B, C, D)
+ K = 0xCA62C1D6
+ end
+
+ -- TEMP = S5(A) + ft(B,C,D) + E + Wt + Kt;
+ A,B,C,D,E = w32_add_n(w32_rot(5, A), f, E, W[t], K),
+ A, w32_rot(30, B), C, D
+ end
+ -- Let H0 = H0 + A, H1 = H1 + B, H2 = H2 + C, H3 = H3 + D, H4 = H4 + E.
+ H0,H1,H2,H3,H4 = w32_add(H0, A),w32_add(H1, B),w32_add(H2, C),w32_add(H3, D),w32_add(H4, E)
+ end
+ local f = w32_to_hexstring
+ return f(H0) .. f(H1) .. f(H2) .. f(H3) .. f(H4)
+end
+
+local function hex_to_binary(hex)
+ return hex:gsub('..', function(hexval)
+ return string.char(tonumber(hexval, 16))
+ end)
+end
+
+function sha1.bin(msg)
+ return hex_to_binary(sha1.hex(msg))
+end
+
+local xor_with_0x5c = {}
+local xor_with_0x36 = {}
+-- building the lookuptables ahead of time (instead of littering the source code
+-- with precalculated values)
+for i=0,0xff do
+ xor_with_0x5c[char(i)] = char(bxor(i,0x5c))
+ xor_with_0x36[char(i)] = char(bxor(i,0x36))
+end
+
+local blocksize = 64 -- 512 bits
+
+function sha1.hmacHex(key, text)
+ assert(type(key) == 'string', "key passed to hmacHex should be a string")
+ assert(type(text) == 'string', "text passed to hmacHex should be a string")
+
+ if #key > blocksize then
+ key = sha1.bin(key)
+ end
+
+ local key_xord_with_0x36 = key:gsub('.', xor_with_0x36) .. string.rep(string.char(0x36), blocksize - #key)
+ local key_xord_with_0x5c = key:gsub('.', xor_with_0x5c) .. string.rep(string.char(0x5c), blocksize - #key)
+
+ return sha1.hex(key_xord_with_0x5c .. sha1.bin(key_xord_with_0x36 .. text))
+end
+
+function sha1.hmacBin(key, text)
+ return hex_to_binary(sha1.hmacHex(key, text))
+end
+
+return sha1
+end)()
+
+local SCRIPT_NAME = "mpv_thumbnail_script"
+
+local default_cache_base = ON_WINDOWS and os.getenv("TEMP") or "/tmp/"
+
+local thumbnailer_options = {
+ -- The thumbnail directory
+ cache_directory = join_paths(default_cache_base, "mpv_thumbs_cache"),
+
+ ------------------------
+ -- Generation options --
+ ------------------------
+
+ -- Automatically generate the thumbnails on video load, without a keypress
+ autogenerate = true,
+
+ -- Only automatically thumbnail videos shorter than this (seconds)
+ autogenerate_max_duration = 3600, -- 1 hour
+
+ -- SHA1-sum filenames over this length
+ -- It's nice to know what files the thumbnails are (hence directory names)
+ -- but long URLs may approach filesystem limits.
+ hash_filename_length = 128,
+
+ -- Use mpv to generate thumbnail even if ffmpeg is found in PATH
+ -- ffmpeg does not handle ordered chapters (MKVs which rely on other MKVs)!
+ -- mpv is a bit slower, but has better support overall (eg. subtitles in the previews)
+ prefer_mpv = true,
+
+ -- Explicitly disable subtitles on the mpv sub-calls
+ mpv_no_sub = false,
+ -- Add a "--no-config" to the mpv sub-call arguments
+ mpv_no_config = false,
+ -- Add a "--profile=<mpv_profile>" to the mpv sub-call arguments
+ -- Use "" to disable
+ mpv_profile = "",
+ -- Output debug logs to <thumbnail_path>.log, ala <cache_directory>/<video_filename>/000000.bgra.log
+ -- The logs are removed after successful encodes, unless you set mpv_keep_logs below
+ mpv_logs = true,
+ -- Keep all mpv logs, even the succesfull ones
+ mpv_keep_logs = false,
+
+ -- Disable the built-in keybind ("T") to add your own
+ disable_keybinds = false,
+
+ ---------------------
+ -- Display options --
+ ---------------------
+
+ -- Move the thumbnail up or down
+ -- For example:
+ -- topbar/bottombar: 24
+ -- rest: 0
+ vertical_offset = 24,
+
+ -- Adjust background padding
+ -- Examples:
+ -- topbar: 0, 10, 10, 10
+ -- bottombar: 10, 0, 10, 10
+ -- slimbox/box: 10, 10, 10, 10
+ pad_top = 10,
+ pad_bot = 0,
+ pad_left = 10,
+ pad_right = 10,
+
+ -- If true, pad values are screen-pixels. If false, video-pixels.
+ pad_in_screenspace = true,
+ -- Calculate pad into the offset
+ offset_by_pad = true,
+
+ -- Background color in BBGGRR
+ background_color = "000000",
+ -- Alpha: 0 - fully opaque, 255 - transparent
+ background_alpha = 80,
+
+ -- Keep thumbnail on the screen near left or right side
+ constrain_to_screen = true,
+
+ -- Do not display the thumbnailing progress
+ hide_progress = false,
+
+ -----------------------
+ -- Thumbnail options --
+ -----------------------
+
+ -- The maximum dimensions of the thumbnails (pixels)
+ thumbnail_width = 200,
+ thumbnail_height = 200,
+
+ -- The thumbnail count target
+ -- (This will result in a thumbnail every ~10 seconds for a 25 minute video)
+ thumbnail_count = 150,
+
+ -- The above target count will be adjusted by the minimum and
+ -- maximum time difference between thumbnails.
+ -- The thumbnail_count will be used to calculate a target separation,
+ -- and min/max_delta will be used to constrict it.
+
+ -- In other words, thumbnails will be:
+ -- at least min_delta seconds apart (limiting the amount)
+ -- at most max_delta seconds apart (raising the amount if needed)
+ min_delta = 5,
+ -- 120 seconds aka 2 minutes will add more thumbnails when the video is over 5 hours!
+ max_delta = 90,
+
+
+ -- Overrides for remote urls (you generally want less thumbnails!)
+ -- Thumbnailing network paths will be done with mpv
+
+ -- Allow thumbnailing network paths (naive check for "://")
+ thumbnail_network = false,
+ -- Override thumbnail count, min/max delta
+ remote_thumbnail_count = 60,
+ remote_min_delta = 15,
+ remote_max_delta = 120,
+
+ -- Try to grab the raw stream and disable ytdl for the mpv subcalls
+ -- Much faster than passing the url to ytdl again, but may cause problems with some sites
+ remote_direct_stream = true,
+}
+
+read_options(thumbnailer_options, SCRIPT_NAME)
+local Thumbnailer = {
+ cache_directory = thumbnailer_options.cache_directory,
+
+ state = {
+ ready = false,
+ available = false,
+ enabled = false,
+
+ thumbnail_template = nil,
+
+ thumbnail_delta = nil,
+ thumbnail_count = 0,
+
+ thumbnail_size = nil,
+
+ finished_thumbnails = 0,
+
+ -- List of thumbnail states (from 1 to thumbnail_count)
+ -- ready: 1
+ -- in progress: 0
+ -- not ready: -1
+ thumbnails = {},
+
+ worker_input_path = nil,
+ -- Extra options for the workers
+ worker_extra = {},
+ },
+ -- Set in register_client
+ worker_register_timeout = nil,
+ -- A timer used to wait for more workers in case we have none
+ worker_wait_timer = nil,
+ workers = {}
+}
+
+function Thumbnailer:clear_state()
+ clear_table(self.state)
+ self.state.ready = false
+ self.state.available = false
+ self.state.finished_thumbnails = 0
+ self.state.thumbnails = {}
+ self.state.worker_extra = {}
+end
+
+
+function Thumbnailer:on_file_loaded()
+ self:clear_state()
+end
+
+function Thumbnailer:on_thumb_ready(index)
+ self.state.thumbnails[index] = 1
+
+ -- Full recount instead of a naive increment (let's be safe!)
+ self.state.finished_thumbnails = 0
+ for i, v in pairs(self.state.thumbnails) do
+ if v > 0 then
+ self.state.finished_thumbnails = self.state.finished_thumbnails + 1
+ end
+ end
+end
+
+function Thumbnailer:on_thumb_progress(index)
+ self.state.thumbnails[index] = math.max(self.state.thumbnails[index], 0)
+end
+
+function Thumbnailer:on_start_file()
+ -- Clear state when a new file is being loaded
+ self:clear_state()
+end
+
+function Thumbnailer:on_video_change(params)
+ -- Gather a new state when we get proper video-dec-params and our state is empty
+ if params ~= nil then
+ if not self.state.ready then
+ self:update_state()
+ end
+ end
+end
+
+
+function Thumbnailer:update_state()
+ msg.debug("Gathering video/thumbnail state")
+
+ self.state.thumbnail_delta = self:get_delta()
+ self.state.thumbnail_count = self:get_thumbnail_count(self.state.thumbnail_delta)
+
+ -- Prefill individual thumbnail states
+ for i = 1, self.state.thumbnail_count do
+ self.state.thumbnails[i] = -1
+ end
+
+ self.state.thumbnail_template, self.state.thumbnail_directory = self:get_thumbnail_template()
+ self.state.thumbnail_size = self:get_thumbnail_size()
+
+ self.state.ready = true
+
+ local file_path = mp.get_property_native("path")
+ self.state.is_remote = file_path:find("://") ~= nil
+
+ self.state.available = false
+
+ -- Make sure the file has video (and not just albumart)
+ local track_list = mp.get_property_native("track-list")
+ local has_video = false
+ for i, track in pairs(track_list) do
+ if track.type == "video" and not track.external and not track.albumart then
+ has_video = true
+ break
+ end
+ end
+
+ if has_video and self.state.thumbnail_delta ~= nil and self.state.thumbnail_size ~= nil and self.state.thumbnail_count > 0 then
+ self.state.available = true
+ end
+
+ msg.debug("Thumbnailer.state:", utils.to_string(self.state))
+
+end
+
+
+function Thumbnailer:get_thumbnail_template()
+ local file_path = mp.get_property_native("path")
+ local is_remote = file_path:find("://") ~= nil
+
+ local filename = mp.get_property_native("filename/no-ext")
+ local filesize = mp.get_property_native("file-size", 0)
+
+ if is_remote then
+ filesize = 0
+ end
+
+ filename = filename:gsub('[^a-zA-Z0-9_.%-\' ]', '')
+ -- Hash overly long filenames (most likely URLs)
+ if #filename > thumbnailer_options.hash_filename_length then
+ filename = sha1.hex(filename)
+ end
+
+ local file_key = ("%s-%d"):format(filename, filesize)
+
+ local thumbnail_directory = join_paths(self.cache_directory, file_key)
+ local file_template = join_paths(thumbnail_directory, "%06d.bgra")
+ return file_template, thumbnail_directory
+end
+
+
+function Thumbnailer:get_thumbnail_size()
+ local video_dec_params = mp.get_property_native("video-dec-params")
+ local video_width = video_dec_params.dw
+ local video_height = video_dec_params.dh
+ if not (video_width and video_height) then
+ return nil
+ end
+
+ local w, h
+ if video_width > video_height then
+ w = thumbnailer_options.thumbnail_width
+ h = math.floor(video_height * (w / video_width))
+ else
+ h = thumbnailer_options.thumbnail_height
+ w = math.floor(video_width * (h / video_height))
+ end
+ return { w=w, h=h }
+end
+
+
+function Thumbnailer:get_delta()
+ local file_path = mp.get_property_native("path")
+ local file_duration = mp.get_property_native("duration")
+ local is_seekable = mp.get_property_native("seekable")
+
+ -- Naive url check
+ local is_remote = file_path:find("://") ~= nil
+
+ local remote_and_disallowed = is_remote
+ if is_remote and thumbnailer_options.thumbnail_network then
+ remote_and_disallowed = false
+ end
+
+ if remote_and_disallowed or not is_seekable or not file_duration then
+ -- Not a local path (or remote thumbnails allowed), not seekable or lacks duration
+ return nil
+ end
+
+ local thumbnail_count = thumbnailer_options.thumbnail_count
+ local min_delta = thumbnailer_options.min_delta
+ local max_delta = thumbnailer_options.max_delta
+
+ if is_remote then
+ thumbnail_count = thumbnailer_options.remote_thumbnail_count
+ min_delta = thumbnailer_options.remote_min_delta
+ max_delta = thumbnailer_options.remote_max_delta
+ end
+
+ local target_delta = (file_duration / thumbnail_count)
+ local delta = math.max(min_delta, math.min(max_delta, target_delta))
+
+ return delta
+end
+
+
+function Thumbnailer:get_thumbnail_count(delta)
+ if delta == nil then
+ return 0
+ end
+ local file_duration = mp.get_property_native("duration")
+
+ return math.ceil(file_duration / delta)
+end
+
+function Thumbnailer:get_closest(thumbnail_index)
+ -- Given a 1-based index, find the closest available thumbnail and return it's 1-based index
+
+ -- Check the direct thumbnail index first
+ if self.state.thumbnails[thumbnail_index] > 0 then
+ return thumbnail_index
+ end
+
+ local min_distance = self.state.thumbnail_count + 1
+ local closest = nil
+
+ -- Naive, inefficient, lazy. But functional.
+ for index, value in pairs(self.state.thumbnails) do
+ local distance = math.abs(index - thumbnail_index)
+ if distance < min_distance and value > 0 then
+ min_distance = distance
+ closest = index
+ end
+ end
+ return closest
+end
+
+function Thumbnailer:get_thumbnail_index(time_position)
+ -- Returns a 1-based thumbnail index for the given timestamp (between 1 and thumbnail_count, inclusive)
+ if self.state.thumbnail_delta and (self.state.thumbnail_count and self.state.thumbnail_count > 0) then
+ return math.min(math.floor(time_position / self.state.thumbnail_delta) + 1, self.state.thumbnail_count)
+ else
+ return nil
+ end
+end
+
+function Thumbnailer:get_thumbnail_path(time_position)
+ -- Given a timestamp, return:
+ -- the closest available thumbnail path (if any)
+ -- the 1-based thumbnail index calculated from the timestamp
+ -- the 1-based thumbnail index of the closest available (and used) thumbnail
+ -- OR nil if thumbnails are not available.
+
+ local thumbnail_index = self:get_thumbnail_index(time_position)
+ if not thumbnail_index then return nil end
+
+ local closest = self:get_closest(thumbnail_index)
+
+ if closest ~= nil then
+ return self.state.thumbnail_template:format(closest-1), thumbnail_index, closest
+ else
+ return nil, thumbnail_index, nil
+ end
+end
+
+function Thumbnailer:register_client()
+ self.worker_register_timeout = mp.get_time() + 2
+
+ mp.register_script_message("mpv_thumbnail_script-ready", function(index, path)
+ self:on_thumb_ready(tonumber(index), path)
+ end)
+ mp.register_script_message("mpv_thumbnail_script-progress", function(index, path)
+ self:on_thumb_progress(tonumber(index), path)
+ end)
+
+ mp.register_script_message("mpv_thumbnail_script-worker", function(worker_name)
+ if not self.workers[worker_name] then
+ msg.debug("Registered worker", worker_name)
+ self.workers[worker_name] = true
+ mp.commandv("script-message-to", worker_name, "mpv_thumbnail_script-slaved")
+ end
+ end)
+
+ -- Notify workers to generate thumbnails when video loads/changes
+ -- This will be executed after the on_video_change (because it's registered after it)
+ mp.observe_property("video-dec-params", "native", function()
+ local duration = mp.get_property_native("duration")
+ local max_duration = thumbnailer_options.autogenerate_max_duration
+
+ if duration ~= nil and self.state.available and thumbnailer_options.autogenerate then
+ -- Notify if autogenerate is on and video is not too long
+ if duration < max_duration or max_duration == 0 then
+ self:start_worker_jobs()
+ end
+ end
+ end)
+
+ local thumb_script_key = not thumbnailer_options.disable_keybinds and "T" or nil
+ mp.add_key_binding(thumb_script_key, "generate-thumbnails", function()
+ if self.state.available then
+ mp.osd_message("Started thumbnailer jobs")
+ self:start_worker_jobs()
+ else
+ mp.osd_message("Thumbnailing unavailabe")
+ end
+ end)
+end
+
+function Thumbnailer:_create_thumbnail_job_order()
+ -- Returns a list of 1-based thumbnail indices in a job order
+ local used_frames = {}
+ local work_frames = {}
+
+ -- Pick frames in increasing frequency.
+ -- This way we can do a quick few passes over the video and then fill in the gaps.
+ for x = 6, 0, -1 do
+ local nth = (2^x)
+
+ for thi = 1, self.state.thumbnail_count, nth do
+ if not used_frames[thi] then
+ table.insert(work_frames, thi)
+ used_frames[thi] = true
+ end
+ end
+ end
+ return work_frames
+end
+
+function Thumbnailer:prepare_source_path()
+ local file_path = mp.get_property_native("path")
+
+ if self.state.is_remote and thumbnailer_options.remote_direct_stream then
+ -- Use the direct stream (possibly) provided by ytdl
+ -- This skips ytdl on the sub-calls, making the thumbnailing faster
+ -- Works well on YouTube, rest not really tested
+ file_path = mp.get_property_native("stream-path")
+
+ -- edl:// urls can get LONG. In which case, save the path (URL)
+ -- to a temporary file and use that instead.
+ local playlist_filename = join_paths(self.state.thumbnail_directory, "playlist.txt")
+
+ if #file_path > 8000 then
+ -- Path is too long for a playlist - just pass the original URL to
+ -- workers and allow ytdl
+ self.state.worker_extra.enable_ytdl = true
+ file_path = mp.get_property_native("path")
+ msg.warn("Falling back to original URL and ytdl due to LONG source path. This will be slow.")
+
+ elseif #file_path > 1024 then
+ local playlist_file = io.open(playlist_filename, "wb")
+ if not playlist_file then
+ msg.error(("Tried to write a playlist to %s but couldn't!"):format(playlist_file))
+ return false
+ end
+
+ playlist_file:write(file_path .. "\n")
+ playlist_file:close()
+
+ file_path = "--playlist=" .. playlist_filename
+ msg.warn("Using playlist workaround due to long source path")
+ end
+ end
+
+ self.state.worker_input_path = file_path
+ return true
+end
+
+function Thumbnailer:start_worker_jobs()
+ -- Create directory for the thumbnails, if needed
+ local l, err = utils.readdir(self.state.thumbnail_directory)
+ if err then
+ msg.debug("Creating thumbnail directory", self.state.thumbnail_directory)
+ create_directories(self.state.thumbnail_directory)
+ end
+
+ -- Try to prepare the source path for workers, and bail if unable to do so
+ if not self:prepare_source_path() then
+ return
+ end
+
+ local worker_list = {}
+ for worker_name in pairs(self.workers) do table.insert(worker_list, worker_name) end
+
+ local worker_count = #worker_list
+
+ -- In case we have a worker timer created already, clear it
+ -- (For example, if the video-dec-params change in quick succession or the user pressed T, etc)
+ if self.worker_wait_timer then
+ self.worker_wait_timer:stop()
+ end
+
+ if worker_count == 0 then
+ local now = mp.get_time()
+ if mp.get_time() > self.worker_register_timeout then
+ -- Workers have had their time to register but we have none!
+ local err = "No thumbnail workers found. Make sure you are not missing a script!"
+ msg.error(err)
+ mp.osd_message(err, 3)
+
+ else
+ -- We may be too early. Delay the work start a bit to try again.
+ msg.warn("No workers found. Waiting a bit more for them.")
+ -- Wait at least half a second
+ local wait_time = math.max(self.worker_register_timeout - now, 0.5)
+ self.worker_wait_timer = mp.add_timeout(wait_time, function() self:start_worker_jobs() end)
+ end
+
+ else
+ -- We have at least one worker. This may not be all of them, but they have had
+ -- their time to register; we've done our best waiting for them.
+ self.state.enabled = true
+
+ msg.debug( ("Splitting %d thumbnails amongst %d worker(s)"):format(self.state.thumbnail_count, worker_count) )
+
+ local frame_job_order = self:_create_thumbnail_job_order()
+ local worker_jobs = {}
+ for i = 1, worker_count do worker_jobs[worker_list[i]] = {} end
+
+ -- Split frames amongst the workers
+ for i, thumbnail_index in ipairs(frame_job_order) do
+ local worker_id = worker_list[ ((i-1) % worker_count) + 1 ]
+ table.insert(worker_jobs[worker_id], thumbnail_index)
+ end
+
+ local state_json_string = utils.format_json(self.state)
+ msg.debug("Giving workers state:", state_json_string)
+
+ for worker_name, worker_frames in pairs(worker_jobs) do
+ if #worker_frames > 0 then
+ local frames_json_string = utils.format_json(worker_frames)
+ msg.debug("Assigning job to", worker_name, frames_json_string)
+ mp.commandv("script-message-to", worker_name, "mpv_thumbnail_script-job", state_json_string, frames_json_string)
+ end
+ end
+ end
+end
+
+mp.register_event("start-file", function() Thumbnailer:on_start_file() end)
+mp.observe_property("video-dec-params", "native", function(name, params) Thumbnailer:on_video_change(params) end)
+--[[
+This is mpv's original player/lua/osc.lua patched to display thumbnails
+
+Sections are denoted with -- mpv_thumbnail_script.lua --
+Current osc.lua version: 69fb2ddbc7b8ccec31348be4b7357ad9a8d48896
+]]--
+
+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 = true, -- scale the controller with the video?
+ valign = 0.8, -- vertical alignment, -1 (top) to 1 (bottom)
+ halign = 0, -- horizontal alignment, -1 (left) to 1 (right)
+ barmargin = 0, -- vertical margin of top/bottombar
+ boxalpha = 80, -- alpha of the background box,
+ -- 0 (opaque) to 255 (fully transparent)
+ hidetimeout = 500, -- duration in ms until the OSC hides if no
+ -- mouse movement. enforced non-negative for the
+ -- user, but internally negative is "always-on".
+ fadeduration = 200, -- duration of fade out in ms, 0 = no fade
+ deadzonesize = 0.5, -- 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",
+ seekbarstyle = "bar", -- bar, diamond or knob
+ seekbarhandlesize = 0.6, -- 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 = 200, -- 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
+ 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(...)
+ boxmaxchars = 80, -- title crop threshold for box layout
+ boxvideo = false, -- apply osc_param.video_margins to video
+ windowcontrols = "auto", -- whether to show window controls
+ windowcontrols_alignment = "right" -- which side to show window controls on
+}
+
+-- read_options may modify hidetimeout, so save the original default value in
+-- case the user set hidetimeout < 0 and we need the default instead.
+local hidetimeout_def = user_opts.hidetimeout
+-- read options from config and command-line
+opt.read_options(user_opts, "osc")
+if user_opts.hidetimeout < 0 then
+ user_opts.hidetimeout = hidetimeout_def
+ msg.warn("hidetimeout cannot be negative. Using " .. user_opts.hidetimeout)
+end
+
+-- validate window control options
+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
+if user_opts.windowcontrols_alignment ~= "right" and
+ user_opts.windowcontrols_alignment ~= "left" then
+ msg.warn("windowcontrols_alignment cannot be \"" ..
+ user_opts.windowcontrols_alignment .. "\". Ignoring.")
+ user_opts.windowcontrols_alignment = "right"
+end
+
+-- mpv_thumbnail_script.lua --
+
+-- Patch in msg.trace
+if not msg.trace then
+ msg.trace = function(...) return mp.log("trace", ...) end
+end
+
+-- Patch in utils.format_bytes_humanized
+if not utils.format_bytes_humanized then
+ utils.format_bytes_humanized = function(b)
+ local d = {"Bytes", "KiB", "MiB", "GiB", "TiB", "PiB"}
+ local i = 1
+ while b >= 1024 do
+ b = b / 1024
+ i = i + 1
+ end
+ return string.format("%0.2f %s", b, d[i] and d[i] or "*1024^" .. (i-1))
+ end
+end
+
+Thumbnailer:register_client()
+
+function get_thumbnail_y_offset(thumb_size, msy)
+ local layout = user_opts.layout
+ local offset = 0
+
+ if layout == "bottombar" then
+ offset = 15 --+ margin
+ elseif layout == "topbar" then
+ offset = -(thumb_size.h * msy + 15)
+ elseif layout == "box" then
+ offset = 15
+ elseif layout == "slimbox" then
+ offset = 12
+ end
+
+ return offset / msy
+end
+
+
+local osc_thumb_state = {
+ visible = false,
+ overlay_id = 1,
+
+ last_path = nil,
+ last_x = nil,
+ last_y = nil,
+}
+
+function hide_thumbnail()
+ osc_thumb_state.visible = false
+ osc_thumb_state.last_path = nil
+ mp.command_native({ "overlay-remove", osc_thumb_state.overlay_id })
+end
+
+function display_thumbnail(pos, value, ass)
+ -- If thumbnails are not available, bail
+ if not (Thumbnailer.state.enabled and Thumbnailer.state.available) then
+ return
+ end
+
+ local duration = mp.get_property_number("duration", nil)
+ if not ((duration == nil) or (value == nil)) then
+ target_position = duration * (value / 100)
+
+ local msx, msy = get_virt_scale_factor()
+ local osd_w, osd_h = mp.get_osd_size()
+
+ local thumb_size = Thumbnailer.state.thumbnail_size
+ local thumb_path, thumb_index, closest_index = Thumbnailer:get_thumbnail_path(target_position)
+
+ local thumbs_ready = Thumbnailer.state.finished_thumbnails
+ local thumbs_total = Thumbnailer.state.thumbnail_count
+ local perc = math.floor((thumbs_ready / thumbs_total) * 100)
+
+ local display_progress = thumbs_ready ~= thumbs_total and not thumbnailer_options.hide_progress
+
+ local vertical_offset = thumbnailer_options.vertical_offset
+ local padding = thumbnailer_options.background_padding
+
+ local pad = {
+ l = thumbnailer_options.pad_left, r = thumbnailer_options.pad_right,
+ t = thumbnailer_options.pad_top, b = thumbnailer_options.pad_bot
+ }
+ if thumbnailer_options.pad_in_screenspace then
+ pad.l = pad.l * msx
+ pad.r = pad.r * msx
+ pad.t = pad.t * msy
+ pad.b = pad.b * msy
+ end
+
+ if thumbnailer_options.offset_by_pad then
+ vertical_offset = vertical_offset + (user_opts.layout == "topbar" and pad.t or pad.b)
+ end
+
+ local ass_w = thumb_size.w * msx
+ local ass_h = thumb_size.h * msy
+ local y_offset = get_thumbnail_y_offset(thumb_size, 1)
+
+ -- Constrain thumbnail display to window
+ -- (ie. don't let it go off-screen left/right)
+ if thumbnailer_options.constrain_to_screen and osd_w > (ass_w + pad.l + pad.r)/msx then
+ local padded_left = (pad.l + (ass_w / 2))
+ local padded_right = (pad.r + (ass_w / 2))
+ if pos.x - padded_left < 0 then
+ pos.x = padded_left
+ elseif pos.x + padded_right > osd_w*msx then
+ pos.x = osd_w*msx - padded_right
+ end
+ end
+
+ local text_h = 30 * msy
+ local bg_h = ass_h + (display_progress and text_h or 0)
+ local bg_left = pos.x - ass_w/2
+ local framegraph_h = 10 * msy
+
+ local bg_top = nil
+ local text_top = nil
+ local framegraph_top = nil
+
+ if user_opts.layout == "topbar" then
+ bg_top = pos.y - ( y_offset + thumb_size.h ) + vertical_offset
+ text_top = bg_top + ass_h + framegraph_h
+ framegraph_top = bg_top + ass_h
+ vertical_offset = -vertical_offset
+ else
+ bg_top = pos.y - y_offset - bg_h - vertical_offset
+ text_top = bg_top
+ framegraph_top = bg_top + 20 * msy
+ end
+
+ if display_progress then
+ if user_opts.layout == "topbar" then
+ pad.b = math.max(0, pad.b - 30)
+ else
+ pad.t = math.max(0, pad.t - 30)
+ end
+ end
+
+
+
+ -- Draw background
+ ass:new_event()
+ ass:pos(bg_left, bg_top)
+ ass:append(("{\\bord0\\1c&H%s&\\1a&H%X&}"):format(thumbnailer_options.background_color, thumbnailer_options.background_alpha))
+ ass:draw_start()
+ ass:rect_cw(-pad.l, -pad.t, ass_w+pad.r, bg_h+pad.b)
+ ass:draw_stop()
+
+ if display_progress then
+
+ ass:new_event()
+ ass:pos(pos.x, text_top)
+ ass:an(8)
+ -- Scale text to correct size
+ ass:append(("{\\fs20\\bord0\\fscx%f\\fscy%f}"):format(100*msx, 100*msy))
+ ass:append(("%d%% - %d/%d"):format(perc, thumbs_ready, thumbs_total))
+
+ -- Draw the generation progress
+ local block_w = thumb_size.w * (Thumbnailer.state.thumbnail_delta / duration) * msy
+ local block_max_x = thumb_size.w * msy
+
+ -- Draw finished thumbnail blocks (white)
+ ass:new_event()
+ ass:pos(bg_left, framegraph_top)
+ ass:append(("{\\bord0\\1c&HFFFFFF&\\1a&H%X&"):format(0))
+ ass:draw_start(2)
+ for i, v in pairs(Thumbnailer.state.thumbnails) do
+ if i ~= closest_index and v > 0 then
+ ass:rect_cw((i-1)*block_w, 0, math.min(block_max_x, i*block_w), framegraph_h)
+ end
+ end
+ ass:draw_stop()
+
+ -- Draw in-progress thumbnail blocks (grayish green)
+ ass:new_event()
+ ass:pos(bg_left, framegraph_top)
+ ass:append(("{\\bord0\\1c&H44AA44&\\1a&H%X&"):format(0))
+ ass:draw_start(2)
+ for i, v in pairs(Thumbnailer.state.thumbnails) do
+ if i ~= closest_index and v == 0 then
+ ass:rect_cw((i-1)*block_w, 0, math.min(block_max_x, i*block_w), framegraph_h)
+ end
+ end
+ ass:draw_stop()
+
+ if closest_index ~= nil then
+ ass:new_event()
+ ass:pos(bg_left, framegraph_top)
+ ass:append(("{\\bord0\\1c&H4444FF&\\1a&H%X&"):format(0))
+ ass:draw_start(2)
+ ass:rect_cw((closest_index-1)*block_w, 0, math.min(block_max_x, closest_index*block_w), framegraph_h)
+ ass:draw_stop()
+ end
+ end
+
+ if thumb_path then
+ local overlay_y_offset = get_thumbnail_y_offset(thumb_size, msy)
+
+ local thumb_x = math.floor(pos.x / msx - thumb_size.w/2)
+ local thumb_y = math.floor(pos.y / msy - thumb_size.h - overlay_y_offset - vertical_offset/msy)
+
+ osc_thumb_state.visible = true
+ if not (osc_thumb_state.last_path == thumb_path and osc_thumb_state.last_x == thumb_x and osc_thumb_state.last_y == thumb_y) then
+ local overlay_add_args = {
+ "overlay-add", osc_thumb_state.overlay_id,
+ thumb_x, thumb_y,
+ thumb_path,
+ 0,
+ "bgra",
+ thumb_size.w, thumb_size.h,
+ 4 * thumb_size.w
+ }
+ mp.command_native(overlay_add_args)
+
+ osc_thumb_state.last_path = thumb_path
+ osc_thumb_state.last_x = thumb_x
+ osc_thumb_state.last_y = thumb_y
+ end
+ end
+ end
+end
+
+-- // mpv_thumbnail_script.lua // --
+
+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 = {
+ bigButtons = "{\\blur0\\bord0\\1c&HFFFFFF\\3c&HFFFFFF\\fs50\\fnmpv-osd-symbols}",
+ smallButtonsL = "{\\blur0\\bord0\\1c&HFFFFFF\\3c&HFFFFFF\\fs19\\fnmpv-osd-symbols}",
+ smallButtonsLlabel = "{\\fscx105\\fscy105\\fn" .. mp.get_property("options/osd-font") .. "}",
+ smallButtonsR = "{\\blur0\\bord0\\1c&HFFFFFF\\3c&HFFFFFF\\fs30\\fnmpv-osd-symbols}",
+ topButtons = "{\\blur0\\bord0\\1c&HFFFFFF\\3c&HFFFFFF\\fs12\\fnmpv-osd-symbols}",
+
+ elementDown = "{\\1c&H999999}",
+ timecodes = "{\\blur0\\bord0\\1c&HFFFFFF\\3c&HFFFFFF\\fs20}",
+ vidtitle = "{\\blur0\\bord0\\1c&HFFFFFF\\3c&HFFFFFF\\fs12\\q2}",
+ box = "{\\rDefault\\blur0\\bord1\\1c&H000000\\3c&HFFFFFF}",
+
+ topButtonsBar = "{\\blur0\\bord0\\1c&HFFFFFF\\3c&HFFFFFF\\fs18\\fnmpv-osd-symbols}",
+ smallButtonsBar = "{\\blur0\\bord0\\1c&HFFFFFF\\3c&HFFFFFF\\fs28\\fnmpv-osd-symbols}",
+ timecodesBar = "{\\blur0\\bord0\\1c&HFFFFFF\\3c&HFFFFFF\\fs27}",
+ timePosBar = "{\\blur0\\bord".. user_opts.tooltipborder .."\\1c&HFFFFFF\\3c&H000000\\fs30}",
+ vidtitleBar = "{\\blur0\\bord0\\1c&HFFFFFF\\3c&HFFFFFF\\fs18\\q2}",
+
+ wcButtons = "{\\1c&HFFFFFF\\fs24\\fnmpv-osd-symbols}",
+ wcTitle = "{\\1c&HFFFFFF\\fs24\\q2}",
+ wcBar = "{\\1c&H000000}",
+}
+
+-- 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?
+ last_mouseX, last_mouseY, -- last mouse position, to detect significant mouse movement
+ message_text,
+ message_timeout,
+ fullscreen = false,
+ tick_timer = nil,
+ tick_last_time = 0, -- when the last tick() was run
+ cache_state = nil,
+ idle = false,
+ enabled = true,
+ input_enabled = true,
+ showhide_enabled = false,
+ dmx_cache = 0,
+ using_video_margins = false,
+ border = true,
+ maximized = false,
+}
+
+local window_control_box_width = 80
+local tick_delay = 0.03
+
+--
+-- Helperfunctions
+--
+
+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()
+ local sx, sy = get_virt_scale_factor()
+ local x, y = mp.get_mouse_pos()
+ return x * sx, y * sy
+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
+
+-- WindowControl helpers
+function window_controls_enabled()
+ val = user_opts.windowcontrols
+ if val == "auto" then
+ return not state.border
+ else
+ return val ~= "no"
+ end
+end
+
+function window_controls_alignment()
+ return user_opts.windowcontrols_alignment
+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
+ 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) 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 > 1) then -- draw triangles
+
+ local a = slider_lo.gap / 0.5 --0.866
+
+ --top
+ if (slider_lo.nibbles_top) then
+ static_ass:move_to(s - (a/2), slider_lo.border)
+ static_ass:line_to(s + (a/2), slider_lo.border)
+ static_ass:line_to(s, foV)
+ end
+
+ --bottom
+ if (slider_lo.nibbles_bottom) then
+ static_ass:move_to(s - (a/2),
+ elem_geo.h - slider_lo.border)
+ static_ass:line_to(s,
+ elem_geo.h - foV)
+ static_ass:line_to(s + (a/2),
+ elem_geo.h - slider_lo.border)
+ end
+
+ else -- draw 2x1px nibbles
+
+ --top
+ if (slider_lo.nibbles_top) then
+ static_ass:rect_cw(s - 1, slider_lo.border,
+ s + 1, slider_lo.border + slider_lo.gap);
+ end
+
+ --bottom
+ if (slider_lo.nibbles_bottom) then
+ static_ass:rect_cw(s - 1,
+ elem_geo.h -slider_lo.border -slider_lo.gap,
+ s + 1, elem_geo.h - slider_lo.border);
+ 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
+ end
+end
+
+
+--
+-- Element Rendering
+--
+
+function render_elements(master_ass)
+
+ 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
+
+ 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)
+
+ if slider_lo.stype ~= "bar" then
+ local r = (user_opts.seekbarhandlesize * innerH) / 2
+ ass_draw_rr_h_cw(elem_ass, xp - r, foH - r,
+ xp + r, foH + r,
+ r, slider_lo.stype == "diamond")
+ 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
+
+ if (an == 2) then
+ ty = element.hitbox.y1 - slider_lo.border
+ 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)
+
+ -- mpv_thumbnail_script.lua --
+ display_thumbnail({x=get_virt_mouse_pos(), y=ty, a=an}, sliderpos, elem_ass)
+ -- // mpv_thumbnail_script.lua // --
+
+ 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
+ state.message_timeout = mp.get_time() + duration
+end
+
+function render_message(ass)
+ if not(state.message_timeout == nil) and not(state.message_text == nil)
+ and state.message_timeout > mp.get_time() 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
+ state.message_timeout = 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,
+ nibbles_top = true,
+ nibbles_bottom = true,
+ 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}
+ 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(topbar)
+ local wc_geo = {
+ x = 0,
+ y = 30 + user_opts.barmargin,
+ an = 1,
+ w = osc_param.playresx,
+ h = 30,
+ }
+
+ local alignment = window_controls_alignment()
+ 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 + 5
+
+ if alignment == "left" then
+ controlbox_left = wc_geo.x
+ titlebox_left = wc_geo.x + controlbox_w + 5
+ end
+
+ add_area("window-controls",
+ get_hitbox_coords(controlbox_left, wc_geo.y, wc_geo.an,
+ controlbox_w, wc_geo.h))
+
+ local lo
+
+ -- Background Bar
+ new_element("wcbar", "box")
+ lo = add_layout("wcbar")
+ lo.geometry = wc_geo
+ lo.layer = 10
+ lo.style = osc_styles.wcBar
+ lo.alpha[1] = user_opts.boxalpha
+
+ local button_y = wc_geo.y - (wc_geo.h / 2)
+ local first_geo =
+ {x = controlbox_left + 5, y = button_y, an = 4, w = 25, h = 25}
+ local second_geo =
+ {x = controlbox_left + 30, y = button_y, an = 4, w = 25, h = 25}
+ local third_geo =
+ {x = controlbox_left + 55, y = button_y, an = 4, w = 25, h = 25}
+
+ -- 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 = alignment == "left" and first_geo or third_geo
+ lo.style = osc_styles.wcButtons
+
+ -- Minimize: 🗕
+ ne = new_element("minimize", "button")
+ ne.content = "\238\132\146"
+ ne.eventresponder["mbtn_left_up"] =
+ function () mp.commandv("cycle", "window-minimized") end
+ lo = add_layout("minimize")
+ lo.geometry = alignment == "left" and second_geo or first_geo
+ lo.style = osc_styles.wcButtons
+
+ -- Maximize: 🗖 /🗗
+ ne = new_element("maximize", "button")
+ if state.maximized then
+ ne.content = "\238\132\148"
+ else
+ ne.content = "\238\132\147"
+ end
+ ne.eventresponder["mbtn_left_up"] =
+ function () mp.commandv("cycle", "window-maximized") end
+ lo = add_layout("maximize")
+ lo.geometry = alignment == "left" and third_geo or second_geo
+ lo.style = osc_styles.wcButtons
+
+ -- deadzone below window controls
+ local sh_area_y0, sh_area_y1
+ sh_area_y0 = user_opts.barmargin
+ sh_area_y1 = (wc_geo.y + (wc_geo.h / 2)) +
+ get_align(1 - (2 * user_opts.deadzonesize),
+ osc_param.playresy - (wc_geo.y + (wc_geo.h / 2)), 0, 0)
+ add_area("showhide_wc", wc_geo.x, sh_area_y0, wc_geo.w, sh_area_y1)
+
+ if topbar then
+ -- The title is already there as part of the top bar
+ return
+ else
+ -- Apply boxvideo margins to the control bar
+ osc_param.video_margins.t = wc_geo.h / osc_param.playresy
+ end
+
+ -- Window Title
+ ne = new_element("wctitle", "button")
+ ne.content = function ()
+ local title = mp.command_native({"expand-text", user_opts.title})
+ -- escape ASS, and strip newlines and trailing slashes
+ title = title:gsub("\\n", " "):gsub("\\$", ""):gsub("{","\\{")
+ return not (title == "") and title or "mpv"
+ end
+ lo = add_layout("wctitle")
+ lo.geometry =
+ { x = titlebox_left, y = wc_geo.y - 3, an = 1, w = titlebox_w, h = wc_geo.h }
+ lo.style = string.format("%s{\\clip(%f,%f,%f,%f)}",
+ osc_styles.wcTitle,
+ titlebox_left, wc_geo.y - wc_geo.h, titlebox_w, wc_geo.y + wc_geo.h)
+end
+
+--
+-- Layouts
+--
+
+local layouts = {}
+
+-- Classic box layout
+layouts["box"] = function ()
+
+ local osc_geo = {
+ w = 550, -- width
+ h = 138, -- height
+ r = 10, -- corner-radius
+ p = 15, -- padding
+ }
+
+ -- make sure the OSC actually fits into the video
+ if (osc_param.playresx < (osc_geo.w + (2 * osc_geo.p))) then
+ osc_param.playresy = (osc_geo.w+(2*osc_geo.p))/osc_param.display_aspect
+ osc_param.playresx = osc_param.playresy * osc_param.display_aspect
+ end
+
+ -- position of the controller according to video aspect and valignment
+ local posX = math.floor(get_align(user_opts.halign, osc_param.playresx,
+ osc_geo.w, 0))
+ local posY = math.floor(get_align(user_opts.valign, osc_param.playresy,
+ osc_geo.h, 0))
+
+ -- position offset for contents aligned at the borders of the box
+ local pos_offsetX = (osc_geo.w - (2*osc_geo.p)) / 2
+ local pos_offsetY = (osc_geo.h - (2*osc_geo.p)) / 2
+
+ osc_param.areas = {} -- delete areas
+
+ -- area for active mouse input
+ add_area("input", get_hitbox_coords(posX, posY, 5, osc_geo.w, osc_geo.h))
+
+ -- area for show/hide
+ local sh_area_y0, sh_area_y1
+ if user_opts.valign > 0 then
+ -- deadzone above OSC
+ sh_area_y0 = get_align(-1 + (2*user_opts.deadzonesize),
+ posY - (osc_geo.h / 2), 0, 0)
+ sh_area_y1 = osc_param.playresy
+ else
+ -- deadzone below OSC
+ sh_area_y0 = 0
+ sh_area_y1 = (posY + (osc_geo.h / 2)) +
+ get_align(1 - (2*user_opts.deadzonesize),
+ osc_param.playresy - (posY + (osc_geo.h / 2)), 0, 0)
+ end
+ add_area("showhide", 0, sh_area_y0, osc_param.playresx, sh_area_y1)
+
+ -- fetch values
+ local osc_w, osc_h, osc_r, osc_p =
+ osc_geo.w, osc_geo.h, osc_geo.r, osc_geo.p
+
+ local lo
+
+ --
+ -- Background box
+ --
+
+ new_element("bgbox", "box")
+ lo = add_layout("bgbox")
+
+ lo.geometry = {x = posX, y = posY, an = 5, w = osc_w, h = osc_h}
+ lo.layer = 10
+ lo.style = osc_styles.box
+ lo.alpha[1] = user_opts.boxalpha
+ lo.alpha[3] = user_opts.boxalpha
+ lo.box.radius = osc_r
+
+ --
+ -- Title row
+ --
+
+ local titlerowY = posY - pos_offsetY - 10
+
+ lo = add_layout("title")
+ lo.geometry = {x = posX, y = titlerowY, an = 8, w = 496, h = 12}
+ lo.style = osc_styles.vidtitle
+ lo.button.maxchars = user_opts.boxmaxchars
+
+ lo = add_layout("pl_prev")
+ lo.geometry =
+ {x = (posX - pos_offsetX), y = titlerowY, an = 7, w = 12, h = 12}
+ lo.style = osc_styles.topButtons
+
+ lo = add_layout("pl_next")
+ lo.geometry =
+ {x = (posX + pos_offsetX), y = titlerowY, an = 9, w = 12, h = 12}
+ lo.style = osc_styles.topButtons
+
+ --
+ -- Big buttons
+ --
+
+ local bigbtnrowY = posY - pos_offsetY + 35
+ local bigbtndist = 60
+
+ lo = add_layout("playpause")
+ lo.geometry =
+ {x = posX, y = bigbtnrowY, an = 5, w = 40, h = 40}
+ lo.style = osc_styles.bigButtons
+
+ lo = add_layout("skipback")
+ lo.geometry =
+ {x = posX - bigbtndist, y = bigbtnrowY, an = 5, w = 40, h = 40}
+ lo.style = osc_styles.bigButtons
+
+ lo = add_layout("skipfrwd")
+ lo.geometry =
+ {x = posX + bigbtndist, y = bigbtnrowY, an = 5, w = 40, h = 40}
+ lo.style = osc_styles.bigButtons
+
+ lo = add_layout("ch_prev")
+ lo.geometry =
+ {x = posX - (bigbtndist * 2), y = bigbtnrowY, an = 5, w = 40, h = 40}
+ lo.style = osc_styles.bigButtons
+
+ lo = add_layout("ch_next")
+ lo.geometry =
+ {x = posX + (bigbtndist * 2), y = bigbtnrowY, an = 5, w = 40, h = 40}
+ lo.style = osc_styles.bigButtons
+
+ lo = add_layout("cy_audio")
+ lo.geometry =
+ {x = posX - pos_offsetX, y = bigbtnrowY, an = 1, w = 70, h = 18}
+ lo.style = osc_styles.smallButtonsL
+
+ lo = add_layout("cy_sub")
+ lo.geometry =
+ {x = posX - pos_offsetX, y = bigbtnrowY, an = 7, w = 70, h = 18}
+ lo.style = osc_styles.smallButtonsL
+
+ lo = add_layout("tog_fs")
+ lo.geometry =
+ {x = posX+pos_offsetX - 25, y = bigbtnrowY, an = 4, w = 25, h = 25}
+ lo.style = osc_styles.smallButtonsR
+
+ lo = add_layout("volume")
+ lo.geometry =
+ {x = posX+pos_offsetX - (25 * 2) - osc_geo.p,
+ y = bigbtnrowY, an = 4, w = 25, h = 25}
+ lo.style = osc_styles.smallButtonsR
+
+ --
+ -- Seekbar
+ --
+
+ lo = add_layout("seekbar")
+ lo.geometry =
+ {x = posX, y = posY+pos_offsetY-22, an = 2, w = pos_offsetX*2, h = 15}
+ lo.style = osc_styles.timecodes
+ lo.slider.tooltip_style = osc_styles.vidtitle
+ lo.slider.stype = user_opts["seekbarstyle"]
+ lo.slider.rtype = user_opts["seekrangestyle"]
+
+ --
+ -- Timecodes + Cache
+ --
+
+ local bottomrowY = posY + pos_offsetY - 5
+
+ lo = add_layout("tc_left")
+ lo.geometry =
+ {x = posX - pos_offsetX, y = bottomrowY, an = 4, w = 110, h = 18}
+ lo.style = osc_styles.timecodes
+
+ lo = add_layout("tc_right")
+ lo.geometry =
+ {x = posX + pos_offsetX, y = bottomrowY, an = 6, w = 110, h = 18}
+ lo.style = osc_styles.timecodes
+
+ lo = add_layout("cache")
+ lo.geometry =
+ {x = posX, y = bottomrowY, an = 5, w = 110, h = 18}
+ lo.style = osc_styles.timecodes
+
+end
+
+-- slim box layout
+layouts["slimbox"] = function ()
+
+ local osc_geo = {
+ w = 660, -- width
+ h = 70, -- height
+ r = 10, -- corner-radius
+ }
+
+ -- make sure the OSC actually fits into the video
+ if (osc_param.playresx < (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
+
+ -- position of the controller according to video aspect and valignment
+ local posX = math.floor(get_align(user_opts.halign, osc_param.playresx,
+ osc_geo.w, 0))
+ local posY = math.floor(get_align(user_opts.valign, osc_param.playresy,
+ osc_geo.h, 0))
+
+ osc_param.areas = {} -- delete areas
+
+ -- area for active mouse input
+ add_area("input", get_hitbox_coords(posX, posY, 5, osc_geo.w, osc_geo.h))
+
+ -- area for show/hide
+ local sh_area_y0, sh_area_y1
+ if user_opts.valign > 0 then
+ -- deadzone above OSC
+ sh_area_y0 = get_align(-1 + (2*user_opts.deadzonesize),
+ posY - (osc_geo.h / 2), 0, 0)
+ sh_area_y1 = osc_param.playresy
+ else
+ -- deadzone below OSC
+ sh_area_y0 = 0
+ sh_area_y1 = (posY + (osc_geo.h / 2)) +
+ get_align(1 - (2*user_opts.deadzonesize),
+ osc_param.playresy - (posY + (osc_geo.h / 2)), 0, 0)
+ end
+ add_area("showhide", 0, sh_area_y0, osc_param.playresx, sh_area_y1)
+
+ local lo
+
+ local tc_w, ele_h, inner_w = 100, 20, osc_geo.w - 100
+
+ -- styles
+ local styles = {
+ box = "{\\rDefault\\blur0\\bord1\\1c&H000000\\3c&HFFFFFF}",
+ timecodes = "{\\1c&HFFFFFF\\3c&H000000\\fs20\\bord2\\blur1}",
+ tooltip = "{\\1c&HFFFFFF\\3c&H000000\\fs12\\bord1\\blur0.5}",
+ }
+
+
+ new_element("bgbox", "box")
+ lo = add_layout("bgbox")
+
+ lo.geometry = {x = posX, y = posY - 1, an = 2, w = inner_w, h = ele_h}
+ lo.layer = 10
+ lo.style = osc_styles.box
+ lo.alpha[1] = user_opts.boxalpha
+ lo.alpha[3] = 0
+ if not (user_opts["seekbarstyle"] == "bar") then
+ lo.box.radius = osc_geo.r
+ lo.box.hexagon = user_opts["seekbarstyle"] == "diamond"
+ end
+
+
+ lo = add_layout("seekbar")
+ lo.geometry =
+ {x = posX, y = posY - 1, an = 2, w = inner_w, h = ele_h}
+ lo.style = osc_styles.timecodes
+ lo.slider.border = 0
+ lo.slider.gap = 1.5
+ lo.slider.tooltip_style = styles.tooltip
+ lo.slider.stype = user_opts["seekbarstyle"]
+ lo.slider.rtype = user_opts["seekrangestyle"]
+ lo.slider.adjust_tooltip = false
+
+ --
+ -- Timecodes
+ --
+
+ lo = add_layout("tc_left")
+ lo.geometry =
+ {x = posX - (inner_w/2) + osc_geo.r, y = posY + 1,
+ an = 7, w = tc_w, h = ele_h}
+ lo.style = styles.timecodes
+ lo.alpha[3] = user_opts.boxalpha
+
+ lo = add_layout("tc_right")
+ lo.geometry =
+ {x = posX + (inner_w/2) - osc_geo.r, y = posY + 1,
+ an = 9, w = tc_w, h = ele_h}
+ lo.style = styles.timecodes
+ lo.alpha[3] = user_opts.boxalpha
+
+ -- Cache
+
+ lo = add_layout("cache")
+ lo.geometry =
+ {x = posX, y = posY + 1,
+ an = 8, w = tc_w, h = ele_h}
+ lo.style = styles.timecodes
+ lo.alpha[3] = user_opts.boxalpha
+
+
+end
+
+function bar_layout(direction)
+ local osc_geo = {
+ x = -2,
+ y,
+ an = (direction < 0) and 7 or 1,
+ w,
+ h = 56,
+ }
+
+ local padX = 9
+ local padY = 3
+ local buttonW = 27
+ local tcW = (state.tc_ms) and 170 or 110
+ local tsW = 90
+ local minW = (buttonW + padX)*5 + (tcW + padX)*4 + (tsW + padX)*2
+
+ -- Special topbar handling when window controls are present
+ local padwc_l
+ local padwc_r
+ if direction < 0 or not window_controls_enabled() then
+ padwc_l = 0
+ padwc_r = 0
+ elseif window_controls_alignment() == "left" then
+ padwc_l = window_control_box_width
+ padwc_r = 0
+ else
+ padwc_l = 0
+ padwc_r = window_control_box_width
+ end
+
+ if ((osc_param.display_aspect > 0) and (osc_param.playresx < minW)) then
+ osc_param.playresy = minW / osc_param.display_aspect
+ osc_param.playresx = osc_param.playresy * osc_param.display_aspect
+ end
+
+ osc_geo.y = direction * (54 + user_opts.barmargin)
+ osc_geo.w = osc_param.playresx + 4
+ if direction < 0 then
+ osc_geo.y = osc_geo.y + osc_param.playresy
+ end
+
+ local line1 = osc_geo.y - direction * (9 + padY)
+ local line2 = osc_geo.y - direction * (36 + padY)
+
+ osc_param.areas = {}
+
+ add_area("input", get_hitbox_coords(osc_geo.x, osc_geo.y, osc_geo.an,
+ osc_geo.w, osc_geo.h))
+
+ local sh_area_y0, sh_area_y1
+ if direction > 0 then
+ -- deadzone below OSC
+ sh_area_y0 = user_opts.barmargin
+ sh_area_y1 = (osc_geo.y + (osc_geo.h / 2)) +
+ get_align(1 - (2*user_opts.deadzonesize),
+ osc_param.playresy - (osc_geo.y + (osc_geo.h / 2)), 0, 0)
+ else
+ -- deadzone above OSC
+ sh_area_y0 = get_align(-1 + (2*user_opts.deadzonesize),
+ osc_geo.y - (osc_geo.h / 2), 0, 0)
+ sh_area_y1 = osc_param.playresy - user_opts.barmargin
+ end
+ add_area("showhide", 0, sh_area_y0, osc_param.playresx, sh_area_y1)
+
+ 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
+
+
+ -- Playlist prev/next
+ geo = { x = osc_geo.x + padX, y = line1,
+ an = 4, w = 18, h = 18 - padY }
+ lo = add_layout("pl_prev")
+ lo.geometry = geo
+ lo.style = osc_styles.topButtonsBar
+
+ geo = { x = geo.x + geo.w + padX, y = geo.y, an = geo.an, w = geo.w, h = geo.h }
+ lo = add_layout("pl_next")
+ lo.geometry = geo
+ lo.style = osc_styles.topButtonsBar
+
+ local t_l = geo.x + geo.w + padX
+
+ -- Cache
+ geo = { x = osc_geo.x + osc_geo.w - padX, y = geo.y,
+ an = 6, w = 150, h = geo.h }
+ lo = add_layout("cache")
+ lo.geometry = geo
+ lo.style = osc_styles.vidtitleBar
+
+ local t_r = geo.x - geo.w - padX*2
+
+ -- Title
+ geo = { x = t_l, y = geo.y, an = 4,
+ w = t_r - t_l, h = geo.h }
+ lo = add_layout("title")
+ lo.geometry = geo
+ lo.style = string.format("%s{\\clip(%f,%f,%f,%f)}",
+ osc_styles.vidtitleBar,
+ geo.x, geo.y-geo.h, geo.w, geo.y+geo.h)
+
+
+ -- Playback control buttons
+ geo = { x = osc_geo.x + padX + padwc_l, y = line2, an = 4,
+ w = buttonW, h = 36 - padY*2}
+ lo = add_layout("playpause")
+ lo.geometry = geo
+ lo.style = osc_styles.smallButtonsBar
+
+ geo = { x = geo.x + geo.w + padX, y = geo.y, an = geo.an, w = geo.w, h = geo.h }
+ lo = add_layout("ch_prev")
+ lo.geometry = geo
+ lo.style = osc_styles.smallButtonsBar
+
+ geo = { x = geo.x + geo.w + padX, y = geo.y, an = geo.an, w = geo.w, h = geo.h }
+ lo = add_layout("ch_next")
+ lo.geometry = geo
+ lo.style = osc_styles.smallButtonsBar
+
+ -- Left timecode
+ geo = { x = geo.x + geo.w + padX + tcW, y = geo.y, an = 6,
+ w = tcW, h = geo.h }
+ lo = add_layout("tc_left")
+ lo.geometry = geo
+ lo.style = osc_styles.timecodesBar
+
+ local sb_l = geo.x + padX
+
+ -- Fullscreen button
+ geo = { x = osc_geo.x + osc_geo.w - buttonW - padX - padwc_r, y = geo.y, an = 4,
+ w = buttonW, h = geo.h }
+ lo = add_layout("tog_fs")
+ lo.geometry = geo
+ lo.style = osc_styles.smallButtonsBar
+
+ -- Volume
+ geo = { x = geo.x - geo.w - padX, y = geo.y, an = geo.an, w = geo.w, h = geo.h }
+ lo = add_layout("volume")
+ lo.geometry = geo
+ lo.style = osc_styles.smallButtonsBar
+
+ -- Track selection buttons
+ geo = { x = geo.x - tsW - padX, y = geo.y, an = geo.an, w = tsW, h = geo.h }
+ lo = add_layout("cy_sub")
+ lo.geometry = geo
+ lo.style = osc_styles.smallButtonsBar
+
+ geo = { x = geo.x - geo.w - padX, y = geo.y, an = geo.an, w = geo.w, h = geo.h }
+ lo = add_layout("cy_audio")
+ lo.geometry = geo
+ lo.style = osc_styles.smallButtonsBar
+
+
+ -- Right timecode
+ geo = { x = geo.x - padX - tcW - 10, y = geo.y, an = geo.an,
+ w = tcW, h = geo.h }
+ lo = add_layout("tc_right")
+ lo.geometry = geo
+ lo.style = osc_styles.timecodesBar
+
+ local sb_r = geo.x - padX
+
+
+ -- Seekbar
+ geo = { x = sb_l, y = geo.y, an = geo.an,
+ w = math.max(0, sb_r - sb_l), h = geo.h }
+ new_element("bgbar1", "box")
+ lo = add_layout("bgbar1")
+
+ lo.geometry = geo
+ lo.layer = 15
+ lo.style = osc_styles.timecodesBar
+ lo.alpha[1] =
+ math.min(255, user_opts.boxalpha + (255 - user_opts.boxalpha)*0.8)
+ if not (user_opts["seekbarstyle"] == "bar") then
+ lo.box.radius = geo.h / 2
+ lo.box.hexagon = user_opts["seekbarstyle"] == "diamond"
+ end
+
+ lo = add_layout("seekbar")
+ lo.geometry = geo
+ lo.style = osc_styles.timecodesBar
+ lo.slider.border = 0
+ lo.slider.gap = 2
+ lo.slider.tooltip_style = osc_styles.timePosBar
+ lo.slider.tooltip_an = 5
+ lo.slider.stype = user_opts["seekbarstyle"]
+ lo.slider.rtype = user_opts["seekrangestyle"]
+
+ if direction < 0 then
+ osc_param.video_margins.b = osc_geo.h / osc_param.playresy
+ else
+ osc_param.video_margins.t = osc_geo.h / osc_param.playresy
+ end
+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
+
+
+-- 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
+
+ -- title
+ ne = new_element("title", "button")
+
+ ne.content = function ()
+ local title = mp.command_native({"expand-text", user_opts.title})
+ -- escape ASS, and strip newlines and trailing slashes
+ title = title:gsub("\\n", " "):gsub("\\$", ""):gsub("{","\\{")
+ return not (title == "") and title or "mpv"
+ end
+
+ ne.eventresponder["mbtn_left_up"] = function ()
+ local title = mp.get_property_osd("media-title")
+ if (have_pl) then
+ title = string.format("[%d/%d] %s", countone(pl_pos - 1),
+ pl_count, title)
+ end
+ show_message(title)
+ end
+
+ ne.eventresponder["mbtn_right_up"] =
+ function () show_message(mp.get_property_osd("filename")) end
+
+ -- playlist buttons
+
+ -- prev
+ ne = new_element("pl_prev", "button")
+
+ ne.content = "\238\132\144"
+ ne.enabled = (pl_pos > 1) or (loop ~= "no")
+ ne.eventresponder["mbtn_left_up"] =
+ function ()
+ mp.commandv("playlist-prev", "weak")
+ show_message(get_playlist(), 3)
+ 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 = "\238\132\129"
+ ne.enabled = (have_pl and (pl_pos < pl_count)) or (loop ~= "no")
+ ne.eventresponder["mbtn_left_up"] =
+ function ()
+ mp.commandv("playlist-next", "weak")
+ show_message(get_playlist(), 3)
+ 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 ("\238\132\129")
+ else
+ return ("\238\128\130")
+ 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 = "\238\132\132"
+ ne.eventresponder["mbtn_left_up"] =
+ function ()
+ mp.commandv("add", "chapter", -1)
+ show_message(get_chapterlist(), 3)
+ 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 = "\238\132\133"
+ ne.eventresponder["mbtn_left_up"] =
+ function ()
+ mp.commandv("add", "chapter", 1)
+ show_message(get_chapterlist(), 3)
+ 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 ("\238\132\134" .. 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 ("\238\132\135" .. 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
+
+ --tog_fs
+ ne = new_element("tog_fs", "button")
+ ne.content = function ()
+ if (state.fullscreen) then
+ return ("\238\132\137")
+ else
+ return ("\238\132\136")
+ 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)
+ 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
+ mp.commandv("seek", seekto, "absolute-percent",
+ user_opts.seekbarkeyframes and "keyframes" or "exact")
+ 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
+
+
+ -- tc_left (current pos)
+ ne = new_element("tc_left", "button")
+
+ ne.content = function ()
+ if (state.tc_ms) 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.tc_ms = not state.tc_ms
+ request_init()
+ end
+
+ -- tc_right (total/remaining time)
+ ne = new_element("tc_right", "button")
+
+ ne.visible = (mp.get_property_number("duration", 0) > 0)
+ ne.content = function ()
+ if (state.rightTC_trem) then
+ if state.tc_ms then
+ return ("-"..mp.get_property_osd("playtime-remaining/full"))
+ else
+ return ("-"..mp.get_property_osd("playtime-remaining"))
+ end
+ else
+ if state.tc_ms 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
+
+ -- 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 = mp.get_property_number("demuxer-cache-duration")
+ if dmx_cache and (dmx_cache > state.dmx_cache * 1.1 or
+ dmx_cache < state.dmx_cache * 0.9) then
+ state.dmx_cache = dmx_cache
+ else
+ dmx_cache = state.dmx_cache
+ end
+ local min = math.floor(dmx_cache / 60)
+ local sec = dmx_cache % 60
+ return "Cache: " .. (min > 0 and
+ string.format("%sm%02.0fs", min, sec) or
+ string.format("%3.0fs", dmx_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 = {"\238\132\139", "\238\132\140",
+ "\238\132\141", "\238\132\142"}
+ if volume == 0 or mute then
+ return "\238\132\138"
+ else
+ return volicon[math.min(4,math.ceil(volume / (100/3)))]
+ 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
+
+
+ -- load layout
+ layouts[user_opts.layout]()
+
+ -- load window controls
+ if window_controls_enabled() then
+ window_controls(user_opts.layout == "topbar")
+ end
+
+ --do something with the elements
+ prepare_elements()
+
+ if user_opts.boxvideo then
+ -- check whether any margin option has a non-default value
+ local margins_used = false
+
+ for _, opt in ipairs(margins_opts) do
+ if mp.get_property_number(opt[2], 0.0) ~= 0.0 then
+ margins_used = true
+ end
+ end
+
+ if not margins_used then
+ local margins = osc_param.video_margins
+ for _, opt in ipairs(margins_opts) do
+ local v = margins[opt[1]]
+ if v ~= 0 then
+ mp.set_property_number(opt[2], v)
+ state.using_video_margins = true
+ end
+ end
+ end
+ else
+ reset_margins()
+ end
+
+ 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 report margins if it's visible only temporarily. At least for
+ -- console.lua this makes no sense.
+ if (not state.osc_visible) or (user_opts.hidetimeout >= 0) then
+ margins = {l = 0, r = 0, t = 0, b = 0}
+ end
+
+ 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 user_opts.hidetimeout >= 0 then
+ hide_osc()
+ end
+ -- reset mouse position
+ state.last_mouseX, state.last_mouseY = nil, nil
+end
+
+function request_init()
+ state.initREQ = true
+end
+
+function render_wipe()
+ msg.trace("render_wipe()")
+ mp.set_osd_ass(0, 0, "{}")
+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()
+
+ 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
+
+ -- autohide
+ if not (state.showtime == nil) and (user_opts.hidetimeout >= 0)
+ and (state.showtime + (user_opts.hidetimeout/1000) < now)
+ and (state.active_element == nil) and not (mouse_over_osc) then
+
+ hide_osc()
+ end
+
+
+ -- actual rendering
+ local ass = assdraw.ass_new()
+
+ -- Messages
+ render_message(ass)
+
+ -- mpv_thumbnail_script.lua --
+ local thumb_was_visible = osc_thumb_state.visible
+ osc_thumb_state.visible = false
+ -- // mpv_thumbnail_script.lua // --
+
+ -- actual OSC
+ if state.osc_visible then
+ render_elements(ass)
+ end
+
+ -- mpv_thumbnail_script.lua --
+ if not osc_thumb_state.visible and thumb_was_visible then
+ hide_thumbnail()
+ end
+ -- // mpv_thumbnail_script.lua // --
+
+ -- submit
+ mp.set_osd_ass(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
+
+ 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
+
+-- called by mpv on every frame
+function tick()
+ 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 ass = assdraw.ass_new()
+ ass:new_event()
+ ass:pos(icon_x, icon_y)
+ ass:append("{\\rDefault\\an7\\c&H430142&\\1a&H00&\\bord0\\shad0\\p6}m 1605 828 b 1605 1175 1324 1456 977 1456 631 1456 349 1175 349 828 349 482 631 200 977 200 1324 200 1605 482 1605 828{\\p0}")
+ ass:new_event()
+ ass:pos(icon_x, icon_y)
+ ass:append("{\\rDefault\\an7\\c&HDDDBDD&\\1a&H00&\\bord0\\shad0\\p6}m 1296 910 b 1296 1131 1117 1310 897 1310 676 1310 497 1131 497 910 497 689 676 511 897 511 1117 511 1296 689 1296 910{\\p0}")
+ ass:new_event()
+ ass:pos(icon_x, icon_y)
+ ass:append("{\\rDefault\\an7\\c&H691F69&\\1a&H00&\\bord0\\shad0\\p6}m 762 1113 l 762 708 b 881 776 1000 843 1119 911 1000 978 881 1046 762 1113{\\p0}")
+ ass:new_event()
+ ass:pos(icon_x, icon_y)
+ ass:append("{\\rDefault\\an7\\c&H682167&\\1a&H00&\\bord0\\shad0\\p6}m 925 42 b 463 42 87 418 87 880 87 1343 463 1718 925 1718 1388 1718 1763 1343 1763 880 1763 418 1388 42 925 42 m 925 42 m 977 200 b 1324 200 1605 482 1605 828 1605 1175 1324 1456 977 1456 631 1456 349 1175 349 828 349 482 631 200 977 200{\\p0}")
+ ass:new_event()
+ ass:pos(icon_x, icon_y)
+ ass:append("{\\rDefault\\an7\\c&H753074&\\1a&H00&\\bord0\\shad0\\p6}m 977 198 b 630 198 348 480 348 828 348 1176 630 1458 977 1458 1325 1458 1607 1176 1607 828 1607 480 1325 198 977 198 m 977 198 m 977 202 b 1323 202 1604 483 1604 828 1604 1174 1323 1454 977 1454 632 1454 351 1174 351 828 351 483 632 202 977 202{\\p0}")
+ ass:new_event()
+ ass:pos(icon_x, icon_y)
+ ass:append("{\\rDefault\\an7\\c&HE5E5E5&\\1a&H00&\\bord0\\shad0\\p6}m 895 10 b 401 10 0 410 0 905 0 1399 401 1800 895 1800 1390 1800 1790 1399 1790 905 1790 410 1390 10 895 10 m 895 10 m 925 42 b 1388 42 1763 418 1763 880 1763 1343 1388 1718 925 1718 463 1718 87 1343 87 880 87 418 463 42 925 42{\\p0}")
+ ass:new_event()
+ ass:pos(320, icon_y+65)
+ ass:an(8)
+ ass:append("Drop files or URLs to play here.")
+ mp.set_osd_ass(640, 360, ass.text)
+
+ 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
+ mp.set_osd_ass(osc_param.playresy, osc_param.playresy, "")
+ end
+
+ state.tick_last_time = mp.get_time()
+
+ if state.anitype ~= nil then
+ request_tick()
+ 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
+
+-- mpv_thumbnail_script.lua --
+
+local builtin_osc_enabled = mp.get_property_native('osc')
+if builtin_osc_enabled then
+ local err = "You must disable the built-in OSC with osc=no in your configuration!"
+ mp.osd_message(err, 5)
+ msg.error(err)
+
+ -- This may break, but since we can, let's try to just disable the builtin OSC.
+ mp.set_property_native('osc', false)
+end
+
+-- // mpv_thumbnail_script.lua // --
+
+validate_user_opts()
+
+mp.register_event("shutdown", shutdown)
+mp.register_event("start-file", request_init)
+mp.register_event("tracks-changed", request_init)
+mp.observe_property("playlist", nil, request_init)
+
+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()
+ end
+)
+mp.observe_property("border", "bool",
+ function(name, val)
+ state.border = val
+ request_init()
+ end
+)
+mp.observe_property("window-maximized", "bool",
+ function(name, val)
+ state.maximized = val
+ request_init()
+ 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)
+
+-- 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")
+
+user_opts.hidetimeout_orig = user_opts.hidetimeout
+
+function always_on(val)
+ if val then
+ user_opts.hidetimeout = -1 -- disable autohide
+ if state.enabled then show_osc() end
+ else
+ user_opts.hidetimeout = user_opts.hidetimeout_orig
+ if state.enabled then 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.hidetimeout >= 0 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
+
+ update_margins()
+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")
diff --git a/mpv/scripts/mpv_thumbnail_script_server-thread-1.lua b/mpv/scripts/mpv_thumbnail_script_server-thread-1.lua
@@ -0,0 +1,736 @@
+--[[
+ Copyright (C) 2017 AMM
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+]]--
+--[[
+ mpv_thumbnail_script.lua 0.4.2 - commit b4cf490 (branch master)
+ https://github.com/TheAMM/mpv_thumbnail_script
+ Built on 2021-05-01 11:01:10
+]]--
+local assdraw = require 'mp.assdraw'
+local msg = require 'mp.msg'
+local opt = require 'mp.options'
+local utils = require 'mp.utils'
+
+-- Determine platform --
+ON_WINDOWS = (package.config:sub(1,1) ~= '/')
+
+-- Some helper functions needed to parse the options --
+function isempty(v) return (v == false) or (v == nil) or (v == "") or (v == 0) or (type(v) == "table" and next(v) == nil) end
+
+function divmod (a, b)
+ return math.floor(a / b), a % b
+end
+
+-- Better modulo
+function bmod( i, N )
+ return (i % N + N) % N
+end
+
+function join_paths(...)
+ local sep = ON_WINDOWS and "\\" or "/"
+ local result = "";
+ for i, p in pairs({...}) do
+ if p ~= "" then
+ if is_absolute_path(p) then
+ result = p
+ else
+ result = (result ~= "") and (result:gsub("[\\"..sep.."]*$", "") .. sep .. p) or p
+ end
+ end
+ end
+ return result:gsub("[\\"..sep.."]*$", "")
+end
+
+-- /some/path/file.ext -> /some/path, file.ext
+function split_path( path )
+ local sep = ON_WINDOWS and "\\" or "/"
+ local first_index, last_index = path:find('^.*' .. sep)
+
+ if last_index == nil then
+ return "", path
+ else
+ local dir = path:sub(0, last_index-1)
+ local file = path:sub(last_index+1, -1)
+
+ return dir, file
+ end
+end
+
+function is_absolute_path( path )
+ local tmp, is_win = path:gsub("^[A-Z]:\\", "")
+ local tmp, is_unix = path:gsub("^/", "")
+ return (is_win > 0) or (is_unix > 0)
+end
+
+function Set(source)
+ local set = {}
+ for _, l in ipairs(source) do set[l] = true end
+ return set
+end
+
+---------------------------
+-- More helper functions --
+---------------------------
+
+-- Removes all keys from a table, without destroying the reference to it
+function clear_table(target)
+ for key, value in pairs(target) do
+ target[key] = nil
+ end
+end
+function shallow_copy(target)
+ local copy = {}
+ for k, v in pairs(target) do
+ copy[k] = v
+ end
+ return copy
+end
+
+-- Rounds to given decimals. eg. round_dec(3.145, 0) => 3
+function round_dec(num, idp)
+ local mult = 10^(idp or 0)
+ return math.floor(num * mult + 0.5) / mult
+end
+
+function file_exists(name)
+ local f = io.open(name, "rb")
+ if f ~= nil then
+ local ok, err, code = f:read(1)
+ io.close(f)
+ return code == nil
+ else
+ return false
+ end
+end
+
+function path_exists(name)
+ local f = io.open(name, "rb")
+ if f ~= nil then
+ io.close(f)
+ return true
+ else
+ return false
+ end
+end
+
+function create_directories(path)
+ local cmd
+ if ON_WINDOWS then
+ cmd = { args = {"cmd", "/c", "mkdir", path} }
+ else
+ cmd = { args = {"mkdir", "-p", path} }
+ end
+ utils.subprocess(cmd)
+end
+
+-- Find an executable in PATH or CWD with the given name
+function find_executable(name)
+ local delim = ON_WINDOWS and ";" or ":"
+
+ local pwd = os.getenv("PWD") or utils.getcwd()
+ local path = os.getenv("PATH")
+
+ local env_path = pwd .. delim .. path -- Check CWD first
+
+ local result, filename
+ for path_dir in env_path:gmatch("[^"..delim.."]+") do
+ filename = join_paths(path_dir, name)
+ if file_exists(filename) then
+ result = filename
+ break
+ end
+ end
+
+ return result
+end
+
+local ExecutableFinder = { path_cache = {} }
+-- Searches for an executable and caches the result if any
+function ExecutableFinder:get_executable_path( name, raw_name )
+ name = ON_WINDOWS and not raw_name and (name .. ".exe") or name
+
+ if self.path_cache[name] == nil then
+ self.path_cache[name] = find_executable(name) or false
+ end
+ return self.path_cache[name]
+end
+
+-- Format seconds to HH.MM.SS.sss
+function format_time(seconds, sep, decimals)
+ decimals = decimals == nil and 3 or decimals
+ sep = sep and sep or "."
+ local s = seconds
+ local h, s = divmod(s, 60*60)
+ local m, s = divmod(s, 60)
+
+ local second_format = string.format("%%0%d.%df", 2+(decimals > 0 and decimals+1 or 0), decimals)
+
+ return string.format("%02d"..sep.."%02d"..sep..second_format, h, m, s)
+end
+
+-- Format seconds to 1h 2m 3.4s
+function format_time_hms(seconds, sep, decimals, force_full)
+ decimals = decimals == nil and 1 or decimals
+ sep = sep ~= nil and sep or " "
+
+ local s = seconds
+ local h, s = divmod(s, 60*60)
+ local m, s = divmod(s, 60)
+
+ if force_full or h > 0 then
+ return string.format("%dh"..sep.."%dm"..sep.."%." .. tostring(decimals) .. "fs", h, m, s)
+ elseif m > 0 then
+ return string.format("%dm"..sep.."%." .. tostring(decimals) .. "fs", m, s)
+ else
+ return string.format("%." .. tostring(decimals) .. "fs", s)
+ end
+end
+
+-- Writes text on OSD and console
+function log_info(txt, timeout)
+ timeout = timeout or 1.5
+ msg.info(txt)
+ mp.osd_message(txt, timeout)
+end
+
+-- Join table items, ala ({"a", "b", "c"}, "=", "-", ", ") => "=a-, =b-, =c-"
+function join_table(source, before, after, sep)
+ before = before or ""
+ after = after or ""
+ sep = sep or ", "
+ local result = ""
+ for i, v in pairs(source) do
+ if not isempty(v) then
+ local part = before .. v .. after
+ if i == 1 then
+ result = part
+ else
+ result = result .. sep .. part
+ end
+ end
+ end
+ return result
+end
+
+function wrap(s, char)
+ char = char or "'"
+ return char .. s .. char
+end
+-- Wraps given string into 'string' and escapes any 's in it
+function escape_and_wrap(s, char, replacement)
+ char = char or "'"
+ replacement = replacement or "\\" .. char
+ return wrap(string.gsub(s, char, replacement), char)
+end
+-- Escapes single quotes in a string and wraps the input in single quotes
+function escape_single_bash(s)
+ return escape_and_wrap(s, "'", "'\\''")
+end
+
+-- Returns (a .. b) if b is not empty or nil
+function joined_or_nil(a, b)
+ return not isempty(b) and (a .. b) or nil
+end
+
+-- Put items from one table into another
+function extend_table(target, source)
+ for i, v in pairs(source) do
+ table.insert(target, v)
+ end
+end
+
+-- Creates a handle and filename for a temporary random file (in current directory)
+function create_temporary_file(base, mode, suffix)
+ local handle, filename
+ suffix = suffix or ""
+ while true do
+ filename = base .. tostring(math.random(1, 5000)) .. suffix
+ handle = io.open(filename, "r")
+ if not handle then
+ handle = io.open(filename, mode)
+ break
+ end
+ io.close(handle)
+ end
+ return handle, filename
+end
+
+
+function get_processor_count()
+ local proc_count
+
+ if ON_WINDOWS then
+ proc_count = tonumber(os.getenv("NUMBER_OF_PROCESSORS"))
+ else
+ local cpuinfo_handle = io.open("/proc/cpuinfo")
+ if cpuinfo_handle ~= nil then
+ local cpuinfo_contents = cpuinfo_handle:read("*a")
+ local _, replace_count = cpuinfo_contents:gsub('processor', '')
+ proc_count = replace_count
+ end
+ end
+
+ if proc_count and proc_count > 0 then
+ return proc_count
+ else
+ return nil
+ end
+end
+
+function substitute_values(string, values)
+ local substitutor = function(match)
+ if match == "%" then
+ return "%"
+ else
+ -- nil is discarded by gsub
+ return values[match]
+ end
+ end
+
+ local substituted = string:gsub('%%(.)', substitutor)
+ return substituted
+end
+
+-- ASS HELPERS --
+function round_rect_top( ass, x0, y0, x1, y1, r )
+ local c = 0.551915024494 * r -- circle approximation
+ ass:move_to(x0 + r, y0)
+ ass:line_to(x1 - r, y0) -- top line
+ if r > 0 then
+ ass:bezier_curve(x1 - r + c, y0, x1, y0 + r - c, x1, y0 + r) -- top right corner
+ end
+ ass:line_to(x1, y1) -- right line
+ ass:line_to(x0, y1) -- bottom line
+ ass:line_to(x0, y0 + r) -- left line
+ if r > 0 then
+ ass:bezier_curve(x0, y0 + r - c, x0 + r - c, y0, x0 + r, y0) -- top left corner
+ end
+end
+
+function round_rect(ass, x0, y0, x1, y1, rtl, rtr, rbr, rbl)
+ local c = 0.551915024494
+ ass:move_to(x0 + rtl, y0)
+ ass:line_to(x1 - rtr, y0) -- top line
+ if rtr > 0 then
+ ass:bezier_curve(x1 - rtr + rtr*c, y0, x1, y0 + rtr - rtr*c, x1, y0 + rtr) -- top right corner
+ end
+ ass:line_to(x1, y1 - rbr) -- right line
+ if rbr > 0 then
+ ass:bezier_curve(x1, y1 - rbr + rbr*c, x1 - rbr + rbr*c, y1, x1 - rbr, y1) -- bottom right corner
+ end
+ ass:line_to(x0 + rbl, y1) -- bottom line
+ if rbl > 0 then
+ ass:bezier_curve(x0 + rbl - rbl*c, y1, x0, y1 - rbl + rbl*c, x0, y1 - rbl) -- bottom left corner
+ end
+ ass:line_to(x0, y0 + rtl) -- left line
+ if rtl > 0 then
+ ass:bezier_curve(x0, y0 + rtl - rtl*c, x0 + rtl - rtl*c, y0, x0 + rtl, y0) -- top left corner
+ end
+end
+local SCRIPT_NAME = "mpv_thumbnail_script"
+
+local default_cache_base = ON_WINDOWS and os.getenv("TEMP") or "/tmp/"
+
+local thumbnailer_options = {
+ -- The thumbnail directory
+ cache_directory = join_paths(default_cache_base, "mpv_thumbs_cache"),
+
+ ------------------------
+ -- Generation options --
+ ------------------------
+
+ -- Automatically generate the thumbnails on video load, without a keypress
+ autogenerate = true,
+
+ -- Only automatically thumbnail videos shorter than this (seconds)
+ autogenerate_max_duration = 3600, -- 1 hour
+
+ -- SHA1-sum filenames over this length
+ -- It's nice to know what files the thumbnails are (hence directory names)
+ -- but long URLs may approach filesystem limits.
+ hash_filename_length = 128,
+
+ -- Use mpv to generate thumbnail even if ffmpeg is found in PATH
+ -- ffmpeg does not handle ordered chapters (MKVs which rely on other MKVs)!
+ -- mpv is a bit slower, but has better support overall (eg. subtitles in the previews)
+ prefer_mpv = true,
+
+ -- Explicitly disable subtitles on the mpv sub-calls
+ mpv_no_sub = false,
+ -- Add a "--no-config" to the mpv sub-call arguments
+ mpv_no_config = false,
+ -- Add a "--profile=<mpv_profile>" to the mpv sub-call arguments
+ -- Use "" to disable
+ mpv_profile = "",
+ -- Output debug logs to <thumbnail_path>.log, ala <cache_directory>/<video_filename>/000000.bgra.log
+ -- The logs are removed after successful encodes, unless you set mpv_keep_logs below
+ mpv_logs = true,
+ -- Keep all mpv logs, even the succesfull ones
+ mpv_keep_logs = false,
+
+ -- Disable the built-in keybind ("T") to add your own
+ disable_keybinds = false,
+
+ ---------------------
+ -- Display options --
+ ---------------------
+
+ -- Move the thumbnail up or down
+ -- For example:
+ -- topbar/bottombar: 24
+ -- rest: 0
+ vertical_offset = 24,
+
+ -- Adjust background padding
+ -- Examples:
+ -- topbar: 0, 10, 10, 10
+ -- bottombar: 10, 0, 10, 10
+ -- slimbox/box: 10, 10, 10, 10
+ pad_top = 10,
+ pad_bot = 0,
+ pad_left = 10,
+ pad_right = 10,
+
+ -- If true, pad values are screen-pixels. If false, video-pixels.
+ pad_in_screenspace = true,
+ -- Calculate pad into the offset
+ offset_by_pad = true,
+
+ -- Background color in BBGGRR
+ background_color = "000000",
+ -- Alpha: 0 - fully opaque, 255 - transparent
+ background_alpha = 80,
+
+ -- Keep thumbnail on the screen near left or right side
+ constrain_to_screen = true,
+
+ -- Do not display the thumbnailing progress
+ hide_progress = false,
+
+ -----------------------
+ -- Thumbnail options --
+ -----------------------
+
+ -- The maximum dimensions of the thumbnails (pixels)
+ thumbnail_width = 200,
+ thumbnail_height = 200,
+
+ -- The thumbnail count target
+ -- (This will result in a thumbnail every ~10 seconds for a 25 minute video)
+ thumbnail_count = 150,
+
+ -- The above target count will be adjusted by the minimum and
+ -- maximum time difference between thumbnails.
+ -- The thumbnail_count will be used to calculate a target separation,
+ -- and min/max_delta will be used to constrict it.
+
+ -- In other words, thumbnails will be:
+ -- at least min_delta seconds apart (limiting the amount)
+ -- at most max_delta seconds apart (raising the amount if needed)
+ min_delta = 5,
+ -- 120 seconds aka 2 minutes will add more thumbnails when the video is over 5 hours!
+ max_delta = 90,
+
+
+ -- Overrides for remote urls (you generally want less thumbnails!)
+ -- Thumbnailing network paths will be done with mpv
+
+ -- Allow thumbnailing network paths (naive check for "://")
+ thumbnail_network = false,
+ -- Override thumbnail count, min/max delta
+ remote_thumbnail_count = 60,
+ remote_min_delta = 15,
+ remote_max_delta = 120,
+
+ -- Try to grab the raw stream and disable ytdl for the mpv subcalls
+ -- Much faster than passing the url to ytdl again, but may cause problems with some sites
+ remote_direct_stream = true,
+}
+
+read_options(thumbnailer_options, SCRIPT_NAME)
+function skip_nil(tbl)
+ local n = {}
+ for k, v in pairs(tbl) do
+ table.insert(n, v)
+ end
+ return n
+end
+
+function create_thumbnail_mpv(file_path, timestamp, size, output_path, options)
+ options = options or {}
+
+ local ytdl_disabled = not options.enable_ytdl and (mp.get_property_native("ytdl") == false
+ or thumbnailer_options.remote_direct_stream)
+
+ local header_fields_arg = nil
+ local header_fields = mp.get_property_native("http-header-fields")
+ if #header_fields > 0 then
+ -- We can't escape the headers, mpv won't parse "--http-header-fields='Name: value'" properly
+ header_fields_arg = "--http-header-fields=" .. table.concat(header_fields, ",")
+ end
+
+ local profile_arg = nil
+ if thumbnailer_options.mpv_profile ~= "" then
+ profile_arg = "--profile=" .. thumbnailer_options.mpv_profile
+ end
+
+ local log_arg = "--log-file=" .. output_path .. ".log"
+
+ local mpv_command = skip_nil({
+ "mpv",
+ -- Hide console output
+ "--msg-level=all=no",
+
+ -- Disable ytdl
+ (ytdl_disabled and "--no-ytdl" or nil),
+ -- Pass HTTP headers from current instance
+ header_fields_arg,
+ -- Pass User-Agent and Referer - should do no harm even with ytdl active
+ "--user-agent=" .. mp.get_property_native("user-agent"),
+ "--referrer=" .. mp.get_property_native("referrer"),
+ -- Disable hardware decoding
+ "--hwdec=no",
+
+ -- Insert --no-config, --profile=... and --log-file if enabled
+ (thumbnailer_options.mpv_no_config and "--no-config" or nil),
+ profile_arg,
+ (thumbnailer_options.mpv_logs and log_arg or nil),
+
+ file_path,
+
+ "--start=" .. tostring(timestamp),
+ "--frames=1",
+ "--hr-seek=yes",
+ "--no-audio",
+ -- Optionally disable subtitles
+ (thumbnailer_options.mpv_no_sub and "--no-sub" or nil),
+
+ ("--vf=scale=%d:%d"):format(size.w, size.h),
+ "--vf-add=format=bgra",
+ "--of=rawvideo",
+ "--ovc=rawvideo",
+ "--o=" .. output_path
+ })
+ return utils.subprocess({args=mpv_command})
+end
+
+
+function create_thumbnail_ffmpeg(file_path, timestamp, size, output_path)
+ local ffmpeg_command = {
+ "ffmpeg",
+ "-loglevel", "quiet",
+ "-noaccurate_seek",
+ "-ss", format_time(timestamp, ":"),
+ "-i", file_path,
+
+ "-frames:v", "1",
+ "-an",
+
+ "-vf", ("scale=%d:%d"):format(size.w, size.h),
+ "-c:v", "rawvideo",
+ "-pix_fmt", "bgra",
+ "-f", "rawvideo",
+
+ "-y", output_path
+ }
+ return utils.subprocess({args=ffmpeg_command})
+end
+
+
+function check_output(ret, output_path, is_mpv)
+ local log_path = output_path .. ".log"
+ local success = true
+
+ if ret.killed_by_us then
+ return nil
+ else
+ if ret.error or ret.status ~= 0 then
+ msg.error("Thumbnailing command failed!")
+ msg.error("mpv process error:", ret.error)
+ msg.error("Process stdout:", ret.stdout)
+ if is_mpv then
+ msg.error("Debug log:", log_path)
+ end
+
+ success = false
+ end
+
+ if not file_exists(output_path) then
+ msg.error("Output file missing!", output_path)
+ success = false
+ end
+ end
+
+ if is_mpv and not thumbnailer_options.mpv_keep_logs then
+ -- Remove successful debug logs
+ if success and file_exists(log_path) then
+ os.remove(log_path)
+ end
+ end
+
+ return success
+end
+
+
+function do_worker_job(state_json_string, frames_json_string)
+ msg.debug("Handling given job")
+ local thumb_state, err = utils.parse_json(state_json_string)
+ if err then
+ msg.error("Failed to parse state JSON")
+ return
+ end
+
+ local thumbnail_indexes, err = utils.parse_json(frames_json_string)
+ if err then
+ msg.error("Failed to parse thumbnail frame indexes")
+ return
+ end
+
+ local thumbnail_func = create_thumbnail_mpv
+ if not thumbnailer_options.prefer_mpv then
+ if ExecutableFinder:get_executable_path("ffmpeg") then
+ thumbnail_func = create_thumbnail_ffmpeg
+ else
+ msg.warn("Could not find ffmpeg in PATH! Falling back on mpv.")
+ end
+ end
+
+ local file_duration = mp.get_property_native("duration")
+ local file_path = thumb_state.worker_input_path
+
+ if thumb_state.is_remote then
+ if (thumbnail_func == create_thumbnail_ffmpeg) then
+ msg.warn("Thumbnailing remote path, falling back on mpv.")
+ end
+ thumbnail_func = create_thumbnail_mpv
+ end
+
+ local generate_thumbnail_for_index = function(thumbnail_index)
+ -- Given a 1-based thumbnail index, generate a thumbnail for it based on the thumbnailer state
+ local thumb_idx = thumbnail_index - 1
+ msg.debug("Starting work on thumbnail", thumb_idx)
+
+ local thumbnail_path = thumb_state.thumbnail_template:format(thumb_idx)
+ -- Grab the "middle" of the thumbnail duration instead of the very start, and leave some margin in the end
+ local timestamp = math.min(file_duration - 0.25, (thumb_idx + 0.5) * thumb_state.thumbnail_delta)
+
+ mp.commandv("script-message", "mpv_thumbnail_script-progress", tostring(thumbnail_index))
+
+ -- The expected size (raw BGRA image)
+ local thumbnail_raw_size = (thumb_state.thumbnail_size.w * thumb_state.thumbnail_size.h * 4)
+
+ local need_thumbnail_generation = false
+
+ -- Check if the thumbnail already exists and is the correct size
+ local thumbnail_file = io.open(thumbnail_path, "rb")
+ if thumbnail_file == nil then
+ need_thumbnail_generation = true
+ else
+ local existing_thumbnail_filesize = thumbnail_file:seek("end")
+ if existing_thumbnail_filesize ~= thumbnail_raw_size then
+ -- Size doesn't match, so (re)generate
+ msg.warn("Thumbnail", thumb_idx, "did not match expected size, regenerating")
+ need_thumbnail_generation = true
+ end
+ thumbnail_file:close()
+ end
+
+ if need_thumbnail_generation then
+ local ret = thumbnail_func(file_path, timestamp, thumb_state.thumbnail_size, thumbnail_path, thumb_state.worker_extra)
+ local success = check_output(ret, thumbnail_path, thumbnail_func == create_thumbnail_mpv)
+
+ if success == nil then
+ -- Killed by us, changing files, ignore
+ msg.debug("Changing files, subprocess killed")
+ return true
+ elseif not success then
+ -- Real failure
+ mp.osd_message("Thumbnailing failed, check console for details", 3.5)
+ return true
+ end
+ else
+ msg.debug("Thumbnail", thumb_idx, "already done!")
+ end
+
+ -- Verify thumbnail size
+ -- Sometimes ffmpeg will output an empty file when seeking to a "bad" section (usually the end)
+ thumbnail_file = io.open(thumbnail_path, "rb")
+
+ -- Bail if we can't read the file (it should really exist by now, we checked this in check_output!)
+ if thumbnail_file == nil then
+ msg.error("Thumbnail suddenly disappeared!")
+ return true
+ end
+
+ -- Check the size of the generated file
+ local thumbnail_file_size = thumbnail_file:seek("end")
+ thumbnail_file:close()
+
+ -- Check if the file is big enough
+ local missing_bytes = math.max(0, thumbnail_raw_size - thumbnail_file_size)
+ if missing_bytes > 0 then
+ msg.warn(("Thumbnail missing %d bytes (expected %d, had %d), padding %s"):format(
+ missing_bytes, thumbnail_raw_size, thumbnail_file_size, thumbnail_path
+ ))
+ -- Pad the file if it's missing content (eg. ffmpeg seek to file end)
+ thumbnail_file = io.open(thumbnail_path, "ab")
+ thumbnail_file:write(string.rep(string.char(0), missing_bytes))
+ thumbnail_file:close()
+ end
+
+ msg.debug("Finished work on thumbnail", thumb_idx)
+ mp.commandv("script-message", "mpv_thumbnail_script-ready", tostring(thumbnail_index), thumbnail_path)
+ end
+
+ msg.debug(("Generating %d thumbnails @ %dx%d for %q"):format(
+ #thumbnail_indexes,
+ thumb_state.thumbnail_size.w,
+ thumb_state.thumbnail_size.h,
+ file_path))
+
+ for i, thumbnail_index in ipairs(thumbnail_indexes) do
+ local bail = generate_thumbnail_for_index(thumbnail_index)
+ if bail then return end
+ end
+
+end
+
+-- Set up listeners and keybinds
+
+-- Job listener
+mp.register_script_message("mpv_thumbnail_script-job", do_worker_job)
+
+
+-- Register this worker with the master script
+local register_timer = nil
+local register_timeout = mp.get_time() + 1.5
+
+local register_function = function()
+ if mp.get_time() > register_timeout and register_timer then
+ msg.error("Thumbnail worker registering timed out")
+ register_timer:stop()
+ else
+ msg.debug("Announcing self to master...")
+ mp.commandv("script-message", "mpv_thumbnail_script-worker", mp.get_script_name())
+ end
+end
+
+register_timer = mp.add_periodic_timer(0.1, register_function)
+
+mp.register_script_message("mpv_thumbnail_script-slaved", function()
+ msg.debug("Successfully registered with master")
+ register_timer:stop()
+end)
diff --git a/mpv/scripts/mpv_thumbnail_script_server-thread-2.lua b/mpv/scripts/mpv_thumbnail_script_server-thread-2.lua
@@ -0,0 +1,736 @@
+--[[
+ Copyright (C) 2017 AMM
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+]]--
+--[[
+ mpv_thumbnail_script.lua 0.4.2 - commit b4cf490 (branch master)
+ https://github.com/TheAMM/mpv_thumbnail_script
+ Built on 2021-05-01 11:01:10
+]]--
+local assdraw = require 'mp.assdraw'
+local msg = require 'mp.msg'
+local opt = require 'mp.options'
+local utils = require 'mp.utils'
+
+-- Determine platform --
+ON_WINDOWS = (package.config:sub(1,1) ~= '/')
+
+-- Some helper functions needed to parse the options --
+function isempty(v) return (v == false) or (v == nil) or (v == "") or (v == 0) or (type(v) == "table" and next(v) == nil) end
+
+function divmod (a, b)
+ return math.floor(a / b), a % b
+end
+
+-- Better modulo
+function bmod( i, N )
+ return (i % N + N) % N
+end
+
+function join_paths(...)
+ local sep = ON_WINDOWS and "\\" or "/"
+ local result = "";
+ for i, p in pairs({...}) do
+ if p ~= "" then
+ if is_absolute_path(p) then
+ result = p
+ else
+ result = (result ~= "") and (result:gsub("[\\"..sep.."]*$", "") .. sep .. p) or p
+ end
+ end
+ end
+ return result:gsub("[\\"..sep.."]*$", "")
+end
+
+-- /some/path/file.ext -> /some/path, file.ext
+function split_path( path )
+ local sep = ON_WINDOWS and "\\" or "/"
+ local first_index, last_index = path:find('^.*' .. sep)
+
+ if last_index == nil then
+ return "", path
+ else
+ local dir = path:sub(0, last_index-1)
+ local file = path:sub(last_index+1, -1)
+
+ return dir, file
+ end
+end
+
+function is_absolute_path( path )
+ local tmp, is_win = path:gsub("^[A-Z]:\\", "")
+ local tmp, is_unix = path:gsub("^/", "")
+ return (is_win > 0) or (is_unix > 0)
+end
+
+function Set(source)
+ local set = {}
+ for _, l in ipairs(source) do set[l] = true end
+ return set
+end
+
+---------------------------
+-- More helper functions --
+---------------------------
+
+-- Removes all keys from a table, without destroying the reference to it
+function clear_table(target)
+ for key, value in pairs(target) do
+ target[key] = nil
+ end
+end
+function shallow_copy(target)
+ local copy = {}
+ for k, v in pairs(target) do
+ copy[k] = v
+ end
+ return copy
+end
+
+-- Rounds to given decimals. eg. round_dec(3.145, 0) => 3
+function round_dec(num, idp)
+ local mult = 10^(idp or 0)
+ return math.floor(num * mult + 0.5) / mult
+end
+
+function file_exists(name)
+ local f = io.open(name, "rb")
+ if f ~= nil then
+ local ok, err, code = f:read(1)
+ io.close(f)
+ return code == nil
+ else
+ return false
+ end
+end
+
+function path_exists(name)
+ local f = io.open(name, "rb")
+ if f ~= nil then
+ io.close(f)
+ return true
+ else
+ return false
+ end
+end
+
+function create_directories(path)
+ local cmd
+ if ON_WINDOWS then
+ cmd = { args = {"cmd", "/c", "mkdir", path} }
+ else
+ cmd = { args = {"mkdir", "-p", path} }
+ end
+ utils.subprocess(cmd)
+end
+
+-- Find an executable in PATH or CWD with the given name
+function find_executable(name)
+ local delim = ON_WINDOWS and ";" or ":"
+
+ local pwd = os.getenv("PWD") or utils.getcwd()
+ local path = os.getenv("PATH")
+
+ local env_path = pwd .. delim .. path -- Check CWD first
+
+ local result, filename
+ for path_dir in env_path:gmatch("[^"..delim.."]+") do
+ filename = join_paths(path_dir, name)
+ if file_exists(filename) then
+ result = filename
+ break
+ end
+ end
+
+ return result
+end
+
+local ExecutableFinder = { path_cache = {} }
+-- Searches for an executable and caches the result if any
+function ExecutableFinder:get_executable_path( name, raw_name )
+ name = ON_WINDOWS and not raw_name and (name .. ".exe") or name
+
+ if self.path_cache[name] == nil then
+ self.path_cache[name] = find_executable(name) or false
+ end
+ return self.path_cache[name]
+end
+
+-- Format seconds to HH.MM.SS.sss
+function format_time(seconds, sep, decimals)
+ decimals = decimals == nil and 3 or decimals
+ sep = sep and sep or "."
+ local s = seconds
+ local h, s = divmod(s, 60*60)
+ local m, s = divmod(s, 60)
+
+ local second_format = string.format("%%0%d.%df", 2+(decimals > 0 and decimals+1 or 0), decimals)
+
+ return string.format("%02d"..sep.."%02d"..sep..second_format, h, m, s)
+end
+
+-- Format seconds to 1h 2m 3.4s
+function format_time_hms(seconds, sep, decimals, force_full)
+ decimals = decimals == nil and 1 or decimals
+ sep = sep ~= nil and sep or " "
+
+ local s = seconds
+ local h, s = divmod(s, 60*60)
+ local m, s = divmod(s, 60)
+
+ if force_full or h > 0 then
+ return string.format("%dh"..sep.."%dm"..sep.."%." .. tostring(decimals) .. "fs", h, m, s)
+ elseif m > 0 then
+ return string.format("%dm"..sep.."%." .. tostring(decimals) .. "fs", m, s)
+ else
+ return string.format("%." .. tostring(decimals) .. "fs", s)
+ end
+end
+
+-- Writes text on OSD and console
+function log_info(txt, timeout)
+ timeout = timeout or 1.5
+ msg.info(txt)
+ mp.osd_message(txt, timeout)
+end
+
+-- Join table items, ala ({"a", "b", "c"}, "=", "-", ", ") => "=a-, =b-, =c-"
+function join_table(source, before, after, sep)
+ before = before or ""
+ after = after or ""
+ sep = sep or ", "
+ local result = ""
+ for i, v in pairs(source) do
+ if not isempty(v) then
+ local part = before .. v .. after
+ if i == 1 then
+ result = part
+ else
+ result = result .. sep .. part
+ end
+ end
+ end
+ return result
+end
+
+function wrap(s, char)
+ char = char or "'"
+ return char .. s .. char
+end
+-- Wraps given string into 'string' and escapes any 's in it
+function escape_and_wrap(s, char, replacement)
+ char = char or "'"
+ replacement = replacement or "\\" .. char
+ return wrap(string.gsub(s, char, replacement), char)
+end
+-- Escapes single quotes in a string and wraps the input in single quotes
+function escape_single_bash(s)
+ return escape_and_wrap(s, "'", "'\\''")
+end
+
+-- Returns (a .. b) if b is not empty or nil
+function joined_or_nil(a, b)
+ return not isempty(b) and (a .. b) or nil
+end
+
+-- Put items from one table into another
+function extend_table(target, source)
+ for i, v in pairs(source) do
+ table.insert(target, v)
+ end
+end
+
+-- Creates a handle and filename for a temporary random file (in current directory)
+function create_temporary_file(base, mode, suffix)
+ local handle, filename
+ suffix = suffix or ""
+ while true do
+ filename = base .. tostring(math.random(1, 5000)) .. suffix
+ handle = io.open(filename, "r")
+ if not handle then
+ handle = io.open(filename, mode)
+ break
+ end
+ io.close(handle)
+ end
+ return handle, filename
+end
+
+
+function get_processor_count()
+ local proc_count
+
+ if ON_WINDOWS then
+ proc_count = tonumber(os.getenv("NUMBER_OF_PROCESSORS"))
+ else
+ local cpuinfo_handle = io.open("/proc/cpuinfo")
+ if cpuinfo_handle ~= nil then
+ local cpuinfo_contents = cpuinfo_handle:read("*a")
+ local _, replace_count = cpuinfo_contents:gsub('processor', '')
+ proc_count = replace_count
+ end
+ end
+
+ if proc_count and proc_count > 0 then
+ return proc_count
+ else
+ return nil
+ end
+end
+
+function substitute_values(string, values)
+ local substitutor = function(match)
+ if match == "%" then
+ return "%"
+ else
+ -- nil is discarded by gsub
+ return values[match]
+ end
+ end
+
+ local substituted = string:gsub('%%(.)', substitutor)
+ return substituted
+end
+
+-- ASS HELPERS --
+function round_rect_top( ass, x0, y0, x1, y1, r )
+ local c = 0.551915024494 * r -- circle approximation
+ ass:move_to(x0 + r, y0)
+ ass:line_to(x1 - r, y0) -- top line
+ if r > 0 then
+ ass:bezier_curve(x1 - r + c, y0, x1, y0 + r - c, x1, y0 + r) -- top right corner
+ end
+ ass:line_to(x1, y1) -- right line
+ ass:line_to(x0, y1) -- bottom line
+ ass:line_to(x0, y0 + r) -- left line
+ if r > 0 then
+ ass:bezier_curve(x0, y0 + r - c, x0 + r - c, y0, x0 + r, y0) -- top left corner
+ end
+end
+
+function round_rect(ass, x0, y0, x1, y1, rtl, rtr, rbr, rbl)
+ local c = 0.551915024494
+ ass:move_to(x0 + rtl, y0)
+ ass:line_to(x1 - rtr, y0) -- top line
+ if rtr > 0 then
+ ass:bezier_curve(x1 - rtr + rtr*c, y0, x1, y0 + rtr - rtr*c, x1, y0 + rtr) -- top right corner
+ end
+ ass:line_to(x1, y1 - rbr) -- right line
+ if rbr > 0 then
+ ass:bezier_curve(x1, y1 - rbr + rbr*c, x1 - rbr + rbr*c, y1, x1 - rbr, y1) -- bottom right corner
+ end
+ ass:line_to(x0 + rbl, y1) -- bottom line
+ if rbl > 0 then
+ ass:bezier_curve(x0 + rbl - rbl*c, y1, x0, y1 - rbl + rbl*c, x0, y1 - rbl) -- bottom left corner
+ end
+ ass:line_to(x0, y0 + rtl) -- left line
+ if rtl > 0 then
+ ass:bezier_curve(x0, y0 + rtl - rtl*c, x0 + rtl - rtl*c, y0, x0 + rtl, y0) -- top left corner
+ end
+end
+local SCRIPT_NAME = "mpv_thumbnail_script"
+
+local default_cache_base = ON_WINDOWS and os.getenv("TEMP") or "/tmp/"
+
+local thumbnailer_options = {
+ -- The thumbnail directory
+ cache_directory = join_paths(default_cache_base, "mpv_thumbs_cache"),
+
+ ------------------------
+ -- Generation options --
+ ------------------------
+
+ -- Automatically generate the thumbnails on video load, without a keypress
+ autogenerate = true,
+
+ -- Only automatically thumbnail videos shorter than this (seconds)
+ autogenerate_max_duration = 3600, -- 1 hour
+
+ -- SHA1-sum filenames over this length
+ -- It's nice to know what files the thumbnails are (hence directory names)
+ -- but long URLs may approach filesystem limits.
+ hash_filename_length = 128,
+
+ -- Use mpv to generate thumbnail even if ffmpeg is found in PATH
+ -- ffmpeg does not handle ordered chapters (MKVs which rely on other MKVs)!
+ -- mpv is a bit slower, but has better support overall (eg. subtitles in the previews)
+ prefer_mpv = true,
+
+ -- Explicitly disable subtitles on the mpv sub-calls
+ mpv_no_sub = false,
+ -- Add a "--no-config" to the mpv sub-call arguments
+ mpv_no_config = false,
+ -- Add a "--profile=<mpv_profile>" to the mpv sub-call arguments
+ -- Use "" to disable
+ mpv_profile = "",
+ -- Output debug logs to <thumbnail_path>.log, ala <cache_directory>/<video_filename>/000000.bgra.log
+ -- The logs are removed after successful encodes, unless you set mpv_keep_logs below
+ mpv_logs = true,
+ -- Keep all mpv logs, even the succesfull ones
+ mpv_keep_logs = false,
+
+ -- Disable the built-in keybind ("T") to add your own
+ disable_keybinds = false,
+
+ ---------------------
+ -- Display options --
+ ---------------------
+
+ -- Move the thumbnail up or down
+ -- For example:
+ -- topbar/bottombar: 24
+ -- rest: 0
+ vertical_offset = 24,
+
+ -- Adjust background padding
+ -- Examples:
+ -- topbar: 0, 10, 10, 10
+ -- bottombar: 10, 0, 10, 10
+ -- slimbox/box: 10, 10, 10, 10
+ pad_top = 10,
+ pad_bot = 0,
+ pad_left = 10,
+ pad_right = 10,
+
+ -- If true, pad values are screen-pixels. If false, video-pixels.
+ pad_in_screenspace = true,
+ -- Calculate pad into the offset
+ offset_by_pad = true,
+
+ -- Background color in BBGGRR
+ background_color = "000000",
+ -- Alpha: 0 - fully opaque, 255 - transparent
+ background_alpha = 80,
+
+ -- Keep thumbnail on the screen near left or right side
+ constrain_to_screen = true,
+
+ -- Do not display the thumbnailing progress
+ hide_progress = false,
+
+ -----------------------
+ -- Thumbnail options --
+ -----------------------
+
+ -- The maximum dimensions of the thumbnails (pixels)
+ thumbnail_width = 200,
+ thumbnail_height = 200,
+
+ -- The thumbnail count target
+ -- (This will result in a thumbnail every ~10 seconds for a 25 minute video)
+ thumbnail_count = 150,
+
+ -- The above target count will be adjusted by the minimum and
+ -- maximum time difference between thumbnails.
+ -- The thumbnail_count will be used to calculate a target separation,
+ -- and min/max_delta will be used to constrict it.
+
+ -- In other words, thumbnails will be:
+ -- at least min_delta seconds apart (limiting the amount)
+ -- at most max_delta seconds apart (raising the amount if needed)
+ min_delta = 5,
+ -- 120 seconds aka 2 minutes will add more thumbnails when the video is over 5 hours!
+ max_delta = 90,
+
+
+ -- Overrides for remote urls (you generally want less thumbnails!)
+ -- Thumbnailing network paths will be done with mpv
+
+ -- Allow thumbnailing network paths (naive check for "://")
+ thumbnail_network = false,
+ -- Override thumbnail count, min/max delta
+ remote_thumbnail_count = 60,
+ remote_min_delta = 15,
+ remote_max_delta = 120,
+
+ -- Try to grab the raw stream and disable ytdl for the mpv subcalls
+ -- Much faster than passing the url to ytdl again, but may cause problems with some sites
+ remote_direct_stream = true,
+}
+
+read_options(thumbnailer_options, SCRIPT_NAME)
+function skip_nil(tbl)
+ local n = {}
+ for k, v in pairs(tbl) do
+ table.insert(n, v)
+ end
+ return n
+end
+
+function create_thumbnail_mpv(file_path, timestamp, size, output_path, options)
+ options = options or {}
+
+ local ytdl_disabled = not options.enable_ytdl and (mp.get_property_native("ytdl") == false
+ or thumbnailer_options.remote_direct_stream)
+
+ local header_fields_arg = nil
+ local header_fields = mp.get_property_native("http-header-fields")
+ if #header_fields > 0 then
+ -- We can't escape the headers, mpv won't parse "--http-header-fields='Name: value'" properly
+ header_fields_arg = "--http-header-fields=" .. table.concat(header_fields, ",")
+ end
+
+ local profile_arg = nil
+ if thumbnailer_options.mpv_profile ~= "" then
+ profile_arg = "--profile=" .. thumbnailer_options.mpv_profile
+ end
+
+ local log_arg = "--log-file=" .. output_path .. ".log"
+
+ local mpv_command = skip_nil({
+ "mpv",
+ -- Hide console output
+ "--msg-level=all=no",
+
+ -- Disable ytdl
+ (ytdl_disabled and "--no-ytdl" or nil),
+ -- Pass HTTP headers from current instance
+ header_fields_arg,
+ -- Pass User-Agent and Referer - should do no harm even with ytdl active
+ "--user-agent=" .. mp.get_property_native("user-agent"),
+ "--referrer=" .. mp.get_property_native("referrer"),
+ -- Disable hardware decoding
+ "--hwdec=no",
+
+ -- Insert --no-config, --profile=... and --log-file if enabled
+ (thumbnailer_options.mpv_no_config and "--no-config" or nil),
+ profile_arg,
+ (thumbnailer_options.mpv_logs and log_arg or nil),
+
+ file_path,
+
+ "--start=" .. tostring(timestamp),
+ "--frames=1",
+ "--hr-seek=yes",
+ "--no-audio",
+ -- Optionally disable subtitles
+ (thumbnailer_options.mpv_no_sub and "--no-sub" or nil),
+
+ ("--vf=scale=%d:%d"):format(size.w, size.h),
+ "--vf-add=format=bgra",
+ "--of=rawvideo",
+ "--ovc=rawvideo",
+ "--o=" .. output_path
+ })
+ return utils.subprocess({args=mpv_command})
+end
+
+
+function create_thumbnail_ffmpeg(file_path, timestamp, size, output_path)
+ local ffmpeg_command = {
+ "ffmpeg",
+ "-loglevel", "quiet",
+ "-noaccurate_seek",
+ "-ss", format_time(timestamp, ":"),
+ "-i", file_path,
+
+ "-frames:v", "1",
+ "-an",
+
+ "-vf", ("scale=%d:%d"):format(size.w, size.h),
+ "-c:v", "rawvideo",
+ "-pix_fmt", "bgra",
+ "-f", "rawvideo",
+
+ "-y", output_path
+ }
+ return utils.subprocess({args=ffmpeg_command})
+end
+
+
+function check_output(ret, output_path, is_mpv)
+ local log_path = output_path .. ".log"
+ local success = true
+
+ if ret.killed_by_us then
+ return nil
+ else
+ if ret.error or ret.status ~= 0 then
+ msg.error("Thumbnailing command failed!")
+ msg.error("mpv process error:", ret.error)
+ msg.error("Process stdout:", ret.stdout)
+ if is_mpv then
+ msg.error("Debug log:", log_path)
+ end
+
+ success = false
+ end
+
+ if not file_exists(output_path) then
+ msg.error("Output file missing!", output_path)
+ success = false
+ end
+ end
+
+ if is_mpv and not thumbnailer_options.mpv_keep_logs then
+ -- Remove successful debug logs
+ if success and file_exists(log_path) then
+ os.remove(log_path)
+ end
+ end
+
+ return success
+end
+
+
+function do_worker_job(state_json_string, frames_json_string)
+ msg.debug("Handling given job")
+ local thumb_state, err = utils.parse_json(state_json_string)
+ if err then
+ msg.error("Failed to parse state JSON")
+ return
+ end
+
+ local thumbnail_indexes, err = utils.parse_json(frames_json_string)
+ if err then
+ msg.error("Failed to parse thumbnail frame indexes")
+ return
+ end
+
+ local thumbnail_func = create_thumbnail_mpv
+ if not thumbnailer_options.prefer_mpv then
+ if ExecutableFinder:get_executable_path("ffmpeg") then
+ thumbnail_func = create_thumbnail_ffmpeg
+ else
+ msg.warn("Could not find ffmpeg in PATH! Falling back on mpv.")
+ end
+ end
+
+ local file_duration = mp.get_property_native("duration")
+ local file_path = thumb_state.worker_input_path
+
+ if thumb_state.is_remote then
+ if (thumbnail_func == create_thumbnail_ffmpeg) then
+ msg.warn("Thumbnailing remote path, falling back on mpv.")
+ end
+ thumbnail_func = create_thumbnail_mpv
+ end
+
+ local generate_thumbnail_for_index = function(thumbnail_index)
+ -- Given a 1-based thumbnail index, generate a thumbnail for it based on the thumbnailer state
+ local thumb_idx = thumbnail_index - 1
+ msg.debug("Starting work on thumbnail", thumb_idx)
+
+ local thumbnail_path = thumb_state.thumbnail_template:format(thumb_idx)
+ -- Grab the "middle" of the thumbnail duration instead of the very start, and leave some margin in the end
+ local timestamp = math.min(file_duration - 0.25, (thumb_idx + 0.5) * thumb_state.thumbnail_delta)
+
+ mp.commandv("script-message", "mpv_thumbnail_script-progress", tostring(thumbnail_index))
+
+ -- The expected size (raw BGRA image)
+ local thumbnail_raw_size = (thumb_state.thumbnail_size.w * thumb_state.thumbnail_size.h * 4)
+
+ local need_thumbnail_generation = false
+
+ -- Check if the thumbnail already exists and is the correct size
+ local thumbnail_file = io.open(thumbnail_path, "rb")
+ if thumbnail_file == nil then
+ need_thumbnail_generation = true
+ else
+ local existing_thumbnail_filesize = thumbnail_file:seek("end")
+ if existing_thumbnail_filesize ~= thumbnail_raw_size then
+ -- Size doesn't match, so (re)generate
+ msg.warn("Thumbnail", thumb_idx, "did not match expected size, regenerating")
+ need_thumbnail_generation = true
+ end
+ thumbnail_file:close()
+ end
+
+ if need_thumbnail_generation then
+ local ret = thumbnail_func(file_path, timestamp, thumb_state.thumbnail_size, thumbnail_path, thumb_state.worker_extra)
+ local success = check_output(ret, thumbnail_path, thumbnail_func == create_thumbnail_mpv)
+
+ if success == nil then
+ -- Killed by us, changing files, ignore
+ msg.debug("Changing files, subprocess killed")
+ return true
+ elseif not success then
+ -- Real failure
+ mp.osd_message("Thumbnailing failed, check console for details", 3.5)
+ return true
+ end
+ else
+ msg.debug("Thumbnail", thumb_idx, "already done!")
+ end
+
+ -- Verify thumbnail size
+ -- Sometimes ffmpeg will output an empty file when seeking to a "bad" section (usually the end)
+ thumbnail_file = io.open(thumbnail_path, "rb")
+
+ -- Bail if we can't read the file (it should really exist by now, we checked this in check_output!)
+ if thumbnail_file == nil then
+ msg.error("Thumbnail suddenly disappeared!")
+ return true
+ end
+
+ -- Check the size of the generated file
+ local thumbnail_file_size = thumbnail_file:seek("end")
+ thumbnail_file:close()
+
+ -- Check if the file is big enough
+ local missing_bytes = math.max(0, thumbnail_raw_size - thumbnail_file_size)
+ if missing_bytes > 0 then
+ msg.warn(("Thumbnail missing %d bytes (expected %d, had %d), padding %s"):format(
+ missing_bytes, thumbnail_raw_size, thumbnail_file_size, thumbnail_path
+ ))
+ -- Pad the file if it's missing content (eg. ffmpeg seek to file end)
+ thumbnail_file = io.open(thumbnail_path, "ab")
+ thumbnail_file:write(string.rep(string.char(0), missing_bytes))
+ thumbnail_file:close()
+ end
+
+ msg.debug("Finished work on thumbnail", thumb_idx)
+ mp.commandv("script-message", "mpv_thumbnail_script-ready", tostring(thumbnail_index), thumbnail_path)
+ end
+
+ msg.debug(("Generating %d thumbnails @ %dx%d for %q"):format(
+ #thumbnail_indexes,
+ thumb_state.thumbnail_size.w,
+ thumb_state.thumbnail_size.h,
+ file_path))
+
+ for i, thumbnail_index in ipairs(thumbnail_indexes) do
+ local bail = generate_thumbnail_for_index(thumbnail_index)
+ if bail then return end
+ end
+
+end
+
+-- Set up listeners and keybinds
+
+-- Job listener
+mp.register_script_message("mpv_thumbnail_script-job", do_worker_job)
+
+
+-- Register this worker with the master script
+local register_timer = nil
+local register_timeout = mp.get_time() + 1.5
+
+local register_function = function()
+ if mp.get_time() > register_timeout and register_timer then
+ msg.error("Thumbnail worker registering timed out")
+ register_timer:stop()
+ else
+ msg.debug("Announcing self to master...")
+ mp.commandv("script-message", "mpv_thumbnail_script-worker", mp.get_script_name())
+ end
+end
+
+register_timer = mp.add_periodic_timer(0.1, register_function)
+
+mp.register_script_message("mpv_thumbnail_script-slaved", function()
+ msg.debug("Successfully registered with master")
+ register_timer:stop()
+end)
diff --git a/mpv/scripts/mpv_thumbnail_script_server-thread-3.lua b/mpv/scripts/mpv_thumbnail_script_server-thread-3.lua
@@ -0,0 +1,736 @@
+--[[
+ Copyright (C) 2017 AMM
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program 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 General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+]]--
+--[[
+ mpv_thumbnail_script.lua 0.4.2 - commit b4cf490 (branch master)
+ https://github.com/TheAMM/mpv_thumbnail_script
+ Built on 2021-05-01 11:01:10
+]]--
+local assdraw = require 'mp.assdraw'
+local msg = require 'mp.msg'
+local opt = require 'mp.options'
+local utils = require 'mp.utils'
+
+-- Determine platform --
+ON_WINDOWS = (package.config:sub(1,1) ~= '/')
+
+-- Some helper functions needed to parse the options --
+function isempty(v) return (v == false) or (v == nil) or (v == "") or (v == 0) or (type(v) == "table" and next(v) == nil) end
+
+function divmod (a, b)
+ return math.floor(a / b), a % b
+end
+
+-- Better modulo
+function bmod( i, N )
+ return (i % N + N) % N
+end
+
+function join_paths(...)
+ local sep = ON_WINDOWS and "\\" or "/"
+ local result = "";
+ for i, p in pairs({...}) do
+ if p ~= "" then
+ if is_absolute_path(p) then
+ result = p
+ else
+ result = (result ~= "") and (result:gsub("[\\"..sep.."]*$", "") .. sep .. p) or p
+ end
+ end
+ end
+ return result:gsub("[\\"..sep.."]*$", "")
+end
+
+-- /some/path/file.ext -> /some/path, file.ext
+function split_path( path )
+ local sep = ON_WINDOWS and "\\" or "/"
+ local first_index, last_index = path:find('^.*' .. sep)
+
+ if last_index == nil then
+ return "", path
+ else
+ local dir = path:sub(0, last_index-1)
+ local file = path:sub(last_index+1, -1)
+
+ return dir, file
+ end
+end
+
+function is_absolute_path( path )
+ local tmp, is_win = path:gsub("^[A-Z]:\\", "")
+ local tmp, is_unix = path:gsub("^/", "")
+ return (is_win > 0) or (is_unix > 0)
+end
+
+function Set(source)
+ local set = {}
+ for _, l in ipairs(source) do set[l] = true end
+ return set
+end
+
+---------------------------
+-- More helper functions --
+---------------------------
+
+-- Removes all keys from a table, without destroying the reference to it
+function clear_table(target)
+ for key, value in pairs(target) do
+ target[key] = nil
+ end
+end
+function shallow_copy(target)
+ local copy = {}
+ for k, v in pairs(target) do
+ copy[k] = v
+ end
+ return copy
+end
+
+-- Rounds to given decimals. eg. round_dec(3.145, 0) => 3
+function round_dec(num, idp)
+ local mult = 10^(idp or 0)
+ return math.floor(num * mult + 0.5) / mult
+end
+
+function file_exists(name)
+ local f = io.open(name, "rb")
+ if f ~= nil then
+ local ok, err, code = f:read(1)
+ io.close(f)
+ return code == nil
+ else
+ return false
+ end
+end
+
+function path_exists(name)
+ local f = io.open(name, "rb")
+ if f ~= nil then
+ io.close(f)
+ return true
+ else
+ return false
+ end
+end
+
+function create_directories(path)
+ local cmd
+ if ON_WINDOWS then
+ cmd = { args = {"cmd", "/c", "mkdir", path} }
+ else
+ cmd = { args = {"mkdir", "-p", path} }
+ end
+ utils.subprocess(cmd)
+end
+
+-- Find an executable in PATH or CWD with the given name
+function find_executable(name)
+ local delim = ON_WINDOWS and ";" or ":"
+
+ local pwd = os.getenv("PWD") or utils.getcwd()
+ local path = os.getenv("PATH")
+
+ local env_path = pwd .. delim .. path -- Check CWD first
+
+ local result, filename
+ for path_dir in env_path:gmatch("[^"..delim.."]+") do
+ filename = join_paths(path_dir, name)
+ if file_exists(filename) then
+ result = filename
+ break
+ end
+ end
+
+ return result
+end
+
+local ExecutableFinder = { path_cache = {} }
+-- Searches for an executable and caches the result if any
+function ExecutableFinder:get_executable_path( name, raw_name )
+ name = ON_WINDOWS and not raw_name and (name .. ".exe") or name
+
+ if self.path_cache[name] == nil then
+ self.path_cache[name] = find_executable(name) or false
+ end
+ return self.path_cache[name]
+end
+
+-- Format seconds to HH.MM.SS.sss
+function format_time(seconds, sep, decimals)
+ decimals = decimals == nil and 3 or decimals
+ sep = sep and sep or "."
+ local s = seconds
+ local h, s = divmod(s, 60*60)
+ local m, s = divmod(s, 60)
+
+ local second_format = string.format("%%0%d.%df", 2+(decimals > 0 and decimals+1 or 0), decimals)
+
+ return string.format("%02d"..sep.."%02d"..sep..second_format, h, m, s)
+end
+
+-- Format seconds to 1h 2m 3.4s
+function format_time_hms(seconds, sep, decimals, force_full)
+ decimals = decimals == nil and 1 or decimals
+ sep = sep ~= nil and sep or " "
+
+ local s = seconds
+ local h, s = divmod(s, 60*60)
+ local m, s = divmod(s, 60)
+
+ if force_full or h > 0 then
+ return string.format("%dh"..sep.."%dm"..sep.."%." .. tostring(decimals) .. "fs", h, m, s)
+ elseif m > 0 then
+ return string.format("%dm"..sep.."%." .. tostring(decimals) .. "fs", m, s)
+ else
+ return string.format("%." .. tostring(decimals) .. "fs", s)
+ end
+end
+
+-- Writes text on OSD and console
+function log_info(txt, timeout)
+ timeout = timeout or 1.5
+ msg.info(txt)
+ mp.osd_message(txt, timeout)
+end
+
+-- Join table items, ala ({"a", "b", "c"}, "=", "-", ", ") => "=a-, =b-, =c-"
+function join_table(source, before, after, sep)
+ before = before or ""
+ after = after or ""
+ sep = sep or ", "
+ local result = ""
+ for i, v in pairs(source) do
+ if not isempty(v) then
+ local part = before .. v .. after
+ if i == 1 then
+ result = part
+ else
+ result = result .. sep .. part
+ end
+ end
+ end
+ return result
+end
+
+function wrap(s, char)
+ char = char or "'"
+ return char .. s .. char
+end
+-- Wraps given string into 'string' and escapes any 's in it
+function escape_and_wrap(s, char, replacement)
+ char = char or "'"
+ replacement = replacement or "\\" .. char
+ return wrap(string.gsub(s, char, replacement), char)
+end
+-- Escapes single quotes in a string and wraps the input in single quotes
+function escape_single_bash(s)
+ return escape_and_wrap(s, "'", "'\\''")
+end
+
+-- Returns (a .. b) if b is not empty or nil
+function joined_or_nil(a, b)
+ return not isempty(b) and (a .. b) or nil
+end
+
+-- Put items from one table into another
+function extend_table(target, source)
+ for i, v in pairs(source) do
+ table.insert(target, v)
+ end
+end
+
+-- Creates a handle and filename for a temporary random file (in current directory)
+function create_temporary_file(base, mode, suffix)
+ local handle, filename
+ suffix = suffix or ""
+ while true do
+ filename = base .. tostring(math.random(1, 5000)) .. suffix
+ handle = io.open(filename, "r")
+ if not handle then
+ handle = io.open(filename, mode)
+ break
+ end
+ io.close(handle)
+ end
+ return handle, filename
+end
+
+
+function get_processor_count()
+ local proc_count
+
+ if ON_WINDOWS then
+ proc_count = tonumber(os.getenv("NUMBER_OF_PROCESSORS"))
+ else
+ local cpuinfo_handle = io.open("/proc/cpuinfo")
+ if cpuinfo_handle ~= nil then
+ local cpuinfo_contents = cpuinfo_handle:read("*a")
+ local _, replace_count = cpuinfo_contents:gsub('processor', '')
+ proc_count = replace_count
+ end
+ end
+
+ if proc_count and proc_count > 0 then
+ return proc_count
+ else
+ return nil
+ end
+end
+
+function substitute_values(string, values)
+ local substitutor = function(match)
+ if match == "%" then
+ return "%"
+ else
+ -- nil is discarded by gsub
+ return values[match]
+ end
+ end
+
+ local substituted = string:gsub('%%(.)', substitutor)
+ return substituted
+end
+
+-- ASS HELPERS --
+function round_rect_top( ass, x0, y0, x1, y1, r )
+ local c = 0.551915024494 * r -- circle approximation
+ ass:move_to(x0 + r, y0)
+ ass:line_to(x1 - r, y0) -- top line
+ if r > 0 then
+ ass:bezier_curve(x1 - r + c, y0, x1, y0 + r - c, x1, y0 + r) -- top right corner
+ end
+ ass:line_to(x1, y1) -- right line
+ ass:line_to(x0, y1) -- bottom line
+ ass:line_to(x0, y0 + r) -- left line
+ if r > 0 then
+ ass:bezier_curve(x0, y0 + r - c, x0 + r - c, y0, x0 + r, y0) -- top left corner
+ end
+end
+
+function round_rect(ass, x0, y0, x1, y1, rtl, rtr, rbr, rbl)
+ local c = 0.551915024494
+ ass:move_to(x0 + rtl, y0)
+ ass:line_to(x1 - rtr, y0) -- top line
+ if rtr > 0 then
+ ass:bezier_curve(x1 - rtr + rtr*c, y0, x1, y0 + rtr - rtr*c, x1, y0 + rtr) -- top right corner
+ end
+ ass:line_to(x1, y1 - rbr) -- right line
+ if rbr > 0 then
+ ass:bezier_curve(x1, y1 - rbr + rbr*c, x1 - rbr + rbr*c, y1, x1 - rbr, y1) -- bottom right corner
+ end
+ ass:line_to(x0 + rbl, y1) -- bottom line
+ if rbl > 0 then
+ ass:bezier_curve(x0 + rbl - rbl*c, y1, x0, y1 - rbl + rbl*c, x0, y1 - rbl) -- bottom left corner
+ end
+ ass:line_to(x0, y0 + rtl) -- left line
+ if rtl > 0 then
+ ass:bezier_curve(x0, y0 + rtl - rtl*c, x0 + rtl - rtl*c, y0, x0 + rtl, y0) -- top left corner
+ end
+end
+local SCRIPT_NAME = "mpv_thumbnail_script"
+
+local default_cache_base = ON_WINDOWS and os.getenv("TEMP") or "/tmp/"
+
+local thumbnailer_options = {
+ -- The thumbnail directory
+ cache_directory = join_paths(default_cache_base, "mpv_thumbs_cache"),
+
+ ------------------------
+ -- Generation options --
+ ------------------------
+
+ -- Automatically generate the thumbnails on video load, without a keypress
+ autogenerate = true,
+
+ -- Only automatically thumbnail videos shorter than this (seconds)
+ autogenerate_max_duration = 3600, -- 1 hour
+
+ -- SHA1-sum filenames over this length
+ -- It's nice to know what files the thumbnails are (hence directory names)
+ -- but long URLs may approach filesystem limits.
+ hash_filename_length = 128,
+
+ -- Use mpv to generate thumbnail even if ffmpeg is found in PATH
+ -- ffmpeg does not handle ordered chapters (MKVs which rely on other MKVs)!
+ -- mpv is a bit slower, but has better support overall (eg. subtitles in the previews)
+ prefer_mpv = true,
+
+ -- Explicitly disable subtitles on the mpv sub-calls
+ mpv_no_sub = false,
+ -- Add a "--no-config" to the mpv sub-call arguments
+ mpv_no_config = false,
+ -- Add a "--profile=<mpv_profile>" to the mpv sub-call arguments
+ -- Use "" to disable
+ mpv_profile = "",
+ -- Output debug logs to <thumbnail_path>.log, ala <cache_directory>/<video_filename>/000000.bgra.log
+ -- The logs are removed after successful encodes, unless you set mpv_keep_logs below
+ mpv_logs = true,
+ -- Keep all mpv logs, even the succesfull ones
+ mpv_keep_logs = false,
+
+ -- Disable the built-in keybind ("T") to add your own
+ disable_keybinds = false,
+
+ ---------------------
+ -- Display options --
+ ---------------------
+
+ -- Move the thumbnail up or down
+ -- For example:
+ -- topbar/bottombar: 24
+ -- rest: 0
+ vertical_offset = 24,
+
+ -- Adjust background padding
+ -- Examples:
+ -- topbar: 0, 10, 10, 10
+ -- bottombar: 10, 0, 10, 10
+ -- slimbox/box: 10, 10, 10, 10
+ pad_top = 10,
+ pad_bot = 0,
+ pad_left = 10,
+ pad_right = 10,
+
+ -- If true, pad values are screen-pixels. If false, video-pixels.
+ pad_in_screenspace = true,
+ -- Calculate pad into the offset
+ offset_by_pad = true,
+
+ -- Background color in BBGGRR
+ background_color = "000000",
+ -- Alpha: 0 - fully opaque, 255 - transparent
+ background_alpha = 80,
+
+ -- Keep thumbnail on the screen near left or right side
+ constrain_to_screen = true,
+
+ -- Do not display the thumbnailing progress
+ hide_progress = false,
+
+ -----------------------
+ -- Thumbnail options --
+ -----------------------
+
+ -- The maximum dimensions of the thumbnails (pixels)
+ thumbnail_width = 200,
+ thumbnail_height = 200,
+
+ -- The thumbnail count target
+ -- (This will result in a thumbnail every ~10 seconds for a 25 minute video)
+ thumbnail_count = 150,
+
+ -- The above target count will be adjusted by the minimum and
+ -- maximum time difference between thumbnails.
+ -- The thumbnail_count will be used to calculate a target separation,
+ -- and min/max_delta will be used to constrict it.
+
+ -- In other words, thumbnails will be:
+ -- at least min_delta seconds apart (limiting the amount)
+ -- at most max_delta seconds apart (raising the amount if needed)
+ min_delta = 5,
+ -- 120 seconds aka 2 minutes will add more thumbnails when the video is over 5 hours!
+ max_delta = 90,
+
+
+ -- Overrides for remote urls (you generally want less thumbnails!)
+ -- Thumbnailing network paths will be done with mpv
+
+ -- Allow thumbnailing network paths (naive check for "://")
+ thumbnail_network = false,
+ -- Override thumbnail count, min/max delta
+ remote_thumbnail_count = 60,
+ remote_min_delta = 15,
+ remote_max_delta = 120,
+
+ -- Try to grab the raw stream and disable ytdl for the mpv subcalls
+ -- Much faster than passing the url to ytdl again, but may cause problems with some sites
+ remote_direct_stream = true,
+}
+
+read_options(thumbnailer_options, SCRIPT_NAME)
+function skip_nil(tbl)
+ local n = {}
+ for k, v in pairs(tbl) do
+ table.insert(n, v)
+ end
+ return n
+end
+
+function create_thumbnail_mpv(file_path, timestamp, size, output_path, options)
+ options = options or {}
+
+ local ytdl_disabled = not options.enable_ytdl and (mp.get_property_native("ytdl") == false
+ or thumbnailer_options.remote_direct_stream)
+
+ local header_fields_arg = nil
+ local header_fields = mp.get_property_native("http-header-fields")
+ if #header_fields > 0 then
+ -- We can't escape the headers, mpv won't parse "--http-header-fields='Name: value'" properly
+ header_fields_arg = "--http-header-fields=" .. table.concat(header_fields, ",")
+ end
+
+ local profile_arg = nil
+ if thumbnailer_options.mpv_profile ~= "" then
+ profile_arg = "--profile=" .. thumbnailer_options.mpv_profile
+ end
+
+ local log_arg = "--log-file=" .. output_path .. ".log"
+
+ local mpv_command = skip_nil({
+ "mpv",
+ -- Hide console output
+ "--msg-level=all=no",
+
+ -- Disable ytdl
+ (ytdl_disabled and "--no-ytdl" or nil),
+ -- Pass HTTP headers from current instance
+ header_fields_arg,
+ -- Pass User-Agent and Referer - should do no harm even with ytdl active
+ "--user-agent=" .. mp.get_property_native("user-agent"),
+ "--referrer=" .. mp.get_property_native("referrer"),
+ -- Disable hardware decoding
+ "--hwdec=no",
+
+ -- Insert --no-config, --profile=... and --log-file if enabled
+ (thumbnailer_options.mpv_no_config and "--no-config" or nil),
+ profile_arg,
+ (thumbnailer_options.mpv_logs and log_arg or nil),
+
+ file_path,
+
+ "--start=" .. tostring(timestamp),
+ "--frames=1",
+ "--hr-seek=yes",
+ "--no-audio",
+ -- Optionally disable subtitles
+ (thumbnailer_options.mpv_no_sub and "--no-sub" or nil),
+
+ ("--vf=scale=%d:%d"):format(size.w, size.h),
+ "--vf-add=format=bgra",
+ "--of=rawvideo",
+ "--ovc=rawvideo",
+ "--o=" .. output_path
+ })
+ return utils.subprocess({args=mpv_command})
+end
+
+
+function create_thumbnail_ffmpeg(file_path, timestamp, size, output_path)
+ local ffmpeg_command = {
+ "ffmpeg",
+ "-loglevel", "quiet",
+ "-noaccurate_seek",
+ "-ss", format_time(timestamp, ":"),
+ "-i", file_path,
+
+ "-frames:v", "1",
+ "-an",
+
+ "-vf", ("scale=%d:%d"):format(size.w, size.h),
+ "-c:v", "rawvideo",
+ "-pix_fmt", "bgra",
+ "-f", "rawvideo",
+
+ "-y", output_path
+ }
+ return utils.subprocess({args=ffmpeg_command})
+end
+
+
+function check_output(ret, output_path, is_mpv)
+ local log_path = output_path .. ".log"
+ local success = true
+
+ if ret.killed_by_us then
+ return nil
+ else
+ if ret.error or ret.status ~= 0 then
+ msg.error("Thumbnailing command failed!")
+ msg.error("mpv process error:", ret.error)
+ msg.error("Process stdout:", ret.stdout)
+ if is_mpv then
+ msg.error("Debug log:", log_path)
+ end
+
+ success = false
+ end
+
+ if not file_exists(output_path) then
+ msg.error("Output file missing!", output_path)
+ success = false
+ end
+ end
+
+ if is_mpv and not thumbnailer_options.mpv_keep_logs then
+ -- Remove successful debug logs
+ if success and file_exists(log_path) then
+ os.remove(log_path)
+ end
+ end
+
+ return success
+end
+
+
+function do_worker_job(state_json_string, frames_json_string)
+ msg.debug("Handling given job")
+ local thumb_state, err = utils.parse_json(state_json_string)
+ if err then
+ msg.error("Failed to parse state JSON")
+ return
+ end
+
+ local thumbnail_indexes, err = utils.parse_json(frames_json_string)
+ if err then
+ msg.error("Failed to parse thumbnail frame indexes")
+ return
+ end
+
+ local thumbnail_func = create_thumbnail_mpv
+ if not thumbnailer_options.prefer_mpv then
+ if ExecutableFinder:get_executable_path("ffmpeg") then
+ thumbnail_func = create_thumbnail_ffmpeg
+ else
+ msg.warn("Could not find ffmpeg in PATH! Falling back on mpv.")
+ end
+ end
+
+ local file_duration = mp.get_property_native("duration")
+ local file_path = thumb_state.worker_input_path
+
+ if thumb_state.is_remote then
+ if (thumbnail_func == create_thumbnail_ffmpeg) then
+ msg.warn("Thumbnailing remote path, falling back on mpv.")
+ end
+ thumbnail_func = create_thumbnail_mpv
+ end
+
+ local generate_thumbnail_for_index = function(thumbnail_index)
+ -- Given a 1-based thumbnail index, generate a thumbnail for it based on the thumbnailer state
+ local thumb_idx = thumbnail_index - 1
+ msg.debug("Starting work on thumbnail", thumb_idx)
+
+ local thumbnail_path = thumb_state.thumbnail_template:format(thumb_idx)
+ -- Grab the "middle" of the thumbnail duration instead of the very start, and leave some margin in the end
+ local timestamp = math.min(file_duration - 0.25, (thumb_idx + 0.5) * thumb_state.thumbnail_delta)
+
+ mp.commandv("script-message", "mpv_thumbnail_script-progress", tostring(thumbnail_index))
+
+ -- The expected size (raw BGRA image)
+ local thumbnail_raw_size = (thumb_state.thumbnail_size.w * thumb_state.thumbnail_size.h * 4)
+
+ local need_thumbnail_generation = false
+
+ -- Check if the thumbnail already exists and is the correct size
+ local thumbnail_file = io.open(thumbnail_path, "rb")
+ if thumbnail_file == nil then
+ need_thumbnail_generation = true
+ else
+ local existing_thumbnail_filesize = thumbnail_file:seek("end")
+ if existing_thumbnail_filesize ~= thumbnail_raw_size then
+ -- Size doesn't match, so (re)generate
+ msg.warn("Thumbnail", thumb_idx, "did not match expected size, regenerating")
+ need_thumbnail_generation = true
+ end
+ thumbnail_file:close()
+ end
+
+ if need_thumbnail_generation then
+ local ret = thumbnail_func(file_path, timestamp, thumb_state.thumbnail_size, thumbnail_path, thumb_state.worker_extra)
+ local success = check_output(ret, thumbnail_path, thumbnail_func == create_thumbnail_mpv)
+
+ if success == nil then
+ -- Killed by us, changing files, ignore
+ msg.debug("Changing files, subprocess killed")
+ return true
+ elseif not success then
+ -- Real failure
+ mp.osd_message("Thumbnailing failed, check console for details", 3.5)
+ return true
+ end
+ else
+ msg.debug("Thumbnail", thumb_idx, "already done!")
+ end
+
+ -- Verify thumbnail size
+ -- Sometimes ffmpeg will output an empty file when seeking to a "bad" section (usually the end)
+ thumbnail_file = io.open(thumbnail_path, "rb")
+
+ -- Bail if we can't read the file (it should really exist by now, we checked this in check_output!)
+ if thumbnail_file == nil then
+ msg.error("Thumbnail suddenly disappeared!")
+ return true
+ end
+
+ -- Check the size of the generated file
+ local thumbnail_file_size = thumbnail_file:seek("end")
+ thumbnail_file:close()
+
+ -- Check if the file is big enough
+ local missing_bytes = math.max(0, thumbnail_raw_size - thumbnail_file_size)
+ if missing_bytes > 0 then
+ msg.warn(("Thumbnail missing %d bytes (expected %d, had %d), padding %s"):format(
+ missing_bytes, thumbnail_raw_size, thumbnail_file_size, thumbnail_path
+ ))
+ -- Pad the file if it's missing content (eg. ffmpeg seek to file end)
+ thumbnail_file = io.open(thumbnail_path, "ab")
+ thumbnail_file:write(string.rep(string.char(0), missing_bytes))
+ thumbnail_file:close()
+ end
+
+ msg.debug("Finished work on thumbnail", thumb_idx)
+ mp.commandv("script-message", "mpv_thumbnail_script-ready", tostring(thumbnail_index), thumbnail_path)
+ end
+
+ msg.debug(("Generating %d thumbnails @ %dx%d for %q"):format(
+ #thumbnail_indexes,
+ thumb_state.thumbnail_size.w,
+ thumb_state.thumbnail_size.h,
+ file_path))
+
+ for i, thumbnail_index in ipairs(thumbnail_indexes) do
+ local bail = generate_thumbnail_for_index(thumbnail_index)
+ if bail then return end
+ end
+
+end
+
+-- Set up listeners and keybinds
+
+-- Job listener
+mp.register_script_message("mpv_thumbnail_script-job", do_worker_job)
+
+
+-- Register this worker with the master script
+local register_timer = nil
+local register_timeout = mp.get_time() + 1.5
+
+local register_function = function()
+ if mp.get_time() > register_timeout and register_timer then
+ msg.error("Thumbnail worker registering timed out")
+ register_timer:stop()
+ else
+ msg.debug("Announcing self to master...")
+ mp.commandv("script-message", "mpv_thumbnail_script-worker", mp.get_script_name())
+ end
+end
+
+register_timer = mp.add_periodic_timer(0.1, register_function)
+
+mp.register_script_message("mpv_thumbnail_script-slaved", function()
+ msg.debug("Successfully registered with master")
+ register_timer:stop()
+end)
diff --git a/mpv/scripts/navigator.lua b/mpv/scripts/navigator.lua
@@ -0,0 +1,483 @@
+local utils = require("mp.utils")
+local mpopts = require("mp.options")
+local assdraw = require("mp.assdraw")
+
+ON_WINDOWS = (package.config:sub(1,1) ~= "/")
+WINDOWS_ROOTDIR = false
+WINDOWS_ROOT_DESC = "Select drive"
+SEPARATOR_WINDOWS = "\\"
+
+SEPARATOR = "/"
+
+local windows_desktop = ON_WINDOWS and utils.join_path(os.getenv("USERPROFILE"), "Desktop"):gsub(SEPARATOR, SEPARATOR_WINDOWS)..SEPARATOR_WINDOWS or nil
+
+local settings = {
+ --navigation keybinds override arrowkeys and enter when activating navigation menu, false means keys are always actíve
+ dynamic_binds = true,
+ navigator_mainkey = "Alt+f", --the key to bring up navigator's menu, can be bound on input.conf aswell
+
+ --dynamic binds, should not be bound in input.conf unless dynamic binds is false
+ key_navfavorites = "f",
+ key_navup = "UP",
+ key_navdown = "DOWN",
+ key_navback = "LEFT",
+ key_navforward = "RIGHT",
+ key_navopen = "ENTER",
+ key_navclose = "ESC",
+
+ --fallback if no file is open, should be a string that points to a path in your system
+ defaultpath = windows_desktop or os.getenv("HOME") or "/",
+ forcedefault = false, --force navigation to start from defaultpath instead of currently playing file
+ --favorites in format { 'Path to directory, notice trailing /' }
+ --on windows use double backslash c:\\my\\directory\\
+ favorites = {
+ '/Volumes/HDD/Movies/',
+ '/Volumes/HDD/Destroy All Software/',
+ '/Volumes/HDD/Videos/'
+ },
+ --list of paths to ignore. the value is anything that returns true for if-statement.
+ --directory ignore entries must end with a trailing slash,
+ --but files and all symlinks (even to dirs) must be without slash!
+ --to help you with the format, simply run "ls -1p <parent folder>" in a terminal,
+ --and you will see if the file/folder to ignore is listed as "file" or "folder/" (trailing slash).
+ --you can ignore children without ignoring their parent.
+ ignorePaths = {
+ --general linux system paths (some are used by macOS too):
+ ['/bin/']='1',['/boot/']='1',['/cdrom/']='1',['/dev/']='1',['/etc/']='1',['/lib/']='1',['/lib32/']='1',['/lib64/']='1',['/tmp/']='1',
+ ['/srv/']='1',['/sys/']='1',['/snap/']='1',['/root/']='1',['/sbin/']='1',['/proc/']='1',['/opt/']='1',['/usr/']='1',['/run/']='1',
+ --useless macOS system paths (some of these standard folders are actually files (symlinks) into /private/ subpaths, hence some repetition):
+ ['/cores/']='1',['/etc']='1',['/installer.failurerequests']='1',['/net/']='1',['/private/']='1',['/tmp']='1',['/var']='1'
+ },
+ --ignore folders and files that match patterns regardless of where they exist on disk.
+ --make sure you use ^ (start of string) and $ (end of string) to catch the whole str instead of risking partial false positives.
+ --read about patterns at https://www.lua.org/pil/20.2.html or http://lua-users.org/wiki/PatternsTutorial
+ ignorePatterns = {
+ '^initrd%..*/?$', --hide files and folders folders starting with "initrd.<something>"
+ '^vmlinuz.*/?$', --hide files and folders starting with "vmlinuz<something>"
+ '^lost%+found/?$', --hide files and folders named "lost+found"
+ '^.*%.log$', --ignore files with extension .log
+ '^%$.*$', --ignore files starting with $
+ },
+
+ subtitleformats = {
+ 'srt', 'ass', 'lrc', 'ssa', 'ttml', 'sbv', 'vtt', 'txt'
+ },
+
+ navigator_menu_favkey = "f", --this key will always be bound when the menu is open, and is the key you use to cycle your favorites list!
+ menu_timeout = true, --menu timeouts and closes itself after navigator_duration seconds, else will be toggled by keybind
+ navigator_duration = 13, --osd duration before the navigator closes, if timeout is set to true
+ visible_item_count = 10, --how many menu items to show per screen
+
+ --font size scales by window, if false requires larger font and padding sizes
+ scale_by_window = true,
+ --paddings from top left corner
+ text_padding_x = 10,
+ text_padding_y = 30,
+ --ass style overrides inside curly brackets, \keyvalue is one field, extra \ for escape in lua
+ --example {\\fnUbuntu\\fs10\\b0\\bord1} equals: font=Ubuntu, size=10, bold=no, border=1
+ --read http://docs.aegisub.org/3.2/ASS_Tags/ for reference of tags
+ --undeclared tags will use default osd settings
+ --these styles will be used for the whole navigator
+ style_ass_tags = "{}",
+ --you can also use the ass tags mentioned above. For example:
+ --selection_prefix="{\\c&HFF00FF&}● " - to add a color for selected file. However, if you
+ --use ass tags you need to set them for both name and selection prefix (see https://github.com/jonniek/mpv-playlistmanager/issues/20)
+ name_prefix = "○ ",
+ selection_prefix = "● ",
+}
+
+mpopts.read_options(settings)
+
+--escape a file or directory path for use in shell arguments
+function escapepath(dir, escapechar)
+ return string.gsub(dir, escapechar, '\\'..escapechar)
+end
+
+local sub_lookup = {}
+for _, ext in ipairs(settings.subtitleformats) do
+ sub_lookup[ext] = true
+end
+
+
+--ensures directories never accidentally end in "//" due to our added slash
+function stripdoubleslash(dir)
+ if (string.sub(dir, -2) == "//") then
+ return string.sub(dir, 1, -2) --negative 2 removes the last character
+ else
+ return dir
+ end
+end
+
+function os.capture(cmd, raw)
+ local f = assert(io.popen(cmd, 'r'))
+ local s = assert(f:read('*a'))
+ f:close()
+ return string.sub(s, 0, -2)
+end
+
+dir = nil
+path = nil
+cursor = 0
+length = 0
+--osd handler that displays your navigation and information
+function handler()
+ add_keybinds()
+ timer:kill()
+ local ass = assdraw.ass_new()
+ ass:new_event()
+ ass:pos(settings.text_padding_x, settings.text_padding_y)
+ ass:append(settings.style_ass_tags)
+
+ if not path then
+ if mp.get_property('path') and not settings.forcedefault then
+ --determine path from currently playing file...
+ local workingdir = mp.get_property("working-directory")
+ local playfilename = mp.get_property("filename") --just the filename, without path
+ local playpath = mp.get_property("path") --can be relative or absolute depending on what args mpv was given
+ local firstchar = string.sub(playpath, 1, 1)
+ --first we need to remove the filename (may give us empty path if mpv was started in same dir as file)
+ path = string.sub(playpath, 1, string.len(playpath)-string.len(playfilename))
+ if (firstchar ~= "/" and not ON_WINDOWS) then --the path of the playing file wasn't absolute, so we need to add mpv's working dir to it
+ path = workingdir.."/"..path
+ end
+ --now resolve that path (to resolve things like "/home/anon/Movies/../Movies/foo.mkv")
+ path = resolvedir(path)
+ --lastly, check if the folder exists, and if not then fall back to the current mpv working dir
+ if (not isfolder(path)) then
+ if ON_WINDOWS then
+ path = workingdir..SEPARATOR_WINDOWS
+ else
+ path = workingdir
+ end
+ end
+ else path = settings.defaultpath end
+ dir,length = scandirectory(path)
+ end
+ ass:append(path.."\\N\\N")
+ local b = cursor - math.floor(settings.visible_item_count / 2)
+ if b > 0 then ass:append("...\\N") end
+ if b < 0 then b=0 end
+ for a=b,(b+settings.visible_item_count),1 do
+ if a==length then break end
+ local prefix = (a == cursor and settings.selection_prefix or settings.name_prefix)
+ ass:append(prefix..dir[a].."\\N")
+ if a == (b+settings.visible_item_count) then
+ ass:append("...")
+ end
+ end
+ local w, h = mp.get_osd_size()
+ if settings.scale_by_window then w,h = 0, 0 end
+ mp.set_osd_ass(w, h, ass.text)
+ if settings.menu_timeout then
+ timer:resume()
+ end
+end
+
+function navdown()
+ if cursor~=length-1 then
+ cursor = cursor+1
+ else
+ cursor = 0
+ end
+ handler()
+end
+
+function navup()
+ if cursor~=0 then
+ cursor = cursor-1
+ else
+ cursor = length-1
+ end
+ handler()
+end
+
+--moves into selected directory, or appends to playlist incase of file
+function childdir()
+ local item = dir[cursor]
+
+ -- windows only
+ if ON_WINDOWS then
+ if WINDOWS_ROOTDIR then
+ WINDOWS_ROOTDIR = false
+ end
+ if item then
+ local newdir = utils.join_path(path, item):gsub(SEPARATOR, SEPARATOR_WINDOWS)..SEPARATOR_WINDOWS
+ local info, error = utils.file_info(newdir)
+
+ if info and info.is_dir then
+ changepath(newdir)
+ else
+
+ if issubtitle(item) then
+ loadsubs(utils.join_path(path, item))
+ else
+ mp.commandv("loadfile", utils.join_path(path, item), "append-play")
+ mp.osd_message("Appended file to playlist: "..item)
+ end
+ handler()
+ end
+ end
+
+ return
+ end
+
+ if item then
+ if isfolder(utils.join_path(path, item)) then
+ local newdir = stripdoubleslash(utils.join_path(path, dir[cursor].."/"))
+ changepath(newdir)
+ else
+ if issubtitle(item) then
+ loadsubs(utils.join_path(path, item))
+ else
+ mp.commandv("loadfile", utils.join_path(path, item), "append-play")
+ mp.osd_message("Appended file to playlist: "..item)
+ end
+ handler()
+ end
+ end
+end
+
+function issubtitle(file)
+ local ext = file:match("^.+%.(.+)$")
+ return ext and sub_lookup[ext:lower()]
+end
+
+function loadsubs(file)
+ mp.commandv("sub_add", file)
+ mp.osd_message("Loaded subtitle: "..file)
+end
+
+--replace current playlist with directory or file
+--if directory, mpv will recursively queue all items found in the directory and its subfolders
+function opendir()
+ local item = dir[cursor]
+
+ if item then
+ remove_keybinds()
+
+ local filepath = utils.join_path(path, item)
+ if ON_WINDOWS then
+ filepath = filepath:gsub(SEPARATOR, SEPARATOR_WINDOWS)
+ end
+
+ if issubtitle(item) then
+ return loadsubs(filepath)
+ end
+
+ mp.commandv("loadfile", filepath, "replace")
+ end
+end
+
+--changes the directory to the path in argument
+function changepath(args)
+ path = args
+ if WINDOWS_ROOTDIR then
+ path = WINDOWS_ROOT_DESC
+ end
+ dir,length = scandirectory(path)
+ cursor=0
+ handler()
+end
+
+--move up to the parent directory
+function parentdir()
+ -- windows only
+ if ON_WINDOWS then
+ if path:sub(-1) == SEPARATOR_WINDOWS then
+ path = path:sub(1, -2)
+ end
+ local parent = utils.split_path(path)
+ if path == parent then
+ WINDOWS_ROOTDIR = true
+ end
+ changepath(parent)
+ return
+ end
+
+ --if path doesn't exist or can't be entered, this returns "/" (root of the drive) as the parent
+ local parent = stripdoubleslash(os.capture('cd "'..escapepath(path, '"')..'" 2>/dev/null && cd .. 2>/dev/null && pwd').."/")
+
+ changepath(parent)
+end
+
+--resolves relative paths such as "/home/foo/../foo/Music" (to "/home/foo/Music") if the folder exists!
+function resolvedir(dir)
+ local safedir = escapepath(dir, '"')
+
+ -- windows only
+ if ON_WINDOWS then
+ local resolved = stripdoubleslash(os.capture('cd /d "'..safedir..'" && cd'))
+ return resolved..SEPARATOR_WINDOWS
+ end
+
+ --if dir doesn't exist or can't be entered, this returns "/" (root of the drive) as the resolved path
+ local resolved = stripdoubleslash(os.capture('cd "'..safedir..'" 2>/dev/null && pwd').."/")
+ return resolved
+end
+
+--true if path exists and is a folder, otherwise false
+function isfolder(dir)
+ -- windows only
+ if ON_WINDOWS then
+ local info, error = utils.file_info(dir)
+ return info and info.is_dir or nil
+ end
+
+ local lua51returncode, _, lua52returncode = os.execute('test -d "'..escapepath(dir, '"')..'"')
+ return lua51returncode == 0 or lua52returncode == 0
+end
+
+function scandirectory(searchdir)
+ local directory = {}
+ --list all files, using universal utilities and flags available on both Linux and macOS
+ -- ls: -1 = list one file per line, -p = append "/" indicator to the end of directory names, -v = display in natural order
+ -- stderr messages are ignored by sending them to /dev/null
+ -- hidden files ("." prefix) are skipped, since they exist everywhere and never contain media
+ -- if we cannot list the contents (due to no permissions, etc), this returns an empty list
+
+ -- windows only
+ if ON_WINDOWS then
+ -- handle drive letters
+ if WINDOWS_ROOTDIR then
+ local popen, err = io.popen("wmic logicaldisk get caption")
+ local i = 0
+ if popen then
+ for direntry in popen:lines() do
+ -- only single letter followed by colon (:) are valid
+ if string.find(direntry, "^%a:") then
+ direntry = string.sub(direntry, 1, 2)
+ local matchedignore = false
+ for k,pattern in pairs(settings.ignorePatterns) do
+ if direntry:find(pattern) then
+ matchedignore = true
+ break --don't waste time scanning further patterns
+ end
+ end
+ if not matchedignore and not settings.ignorePaths[path..direntry] then
+ directory[i] = direntry
+ i=i+1
+ end
+ end
+ end
+ popen:close()
+ else
+ mp.msg.error("Could not scan for files :"..(err or ""))
+ end
+
+ return directory, i
+ end
+
+ local i = 0
+ local files = utils.readdir(searchdir)
+
+ if not files then
+ mp.msg.error("Could not scan for files :"..(err or ""))
+ return directory, i
+ end
+
+ for _, direntry in ipairs(files) do
+ local matchedignore = false
+ for k,pattern in pairs(settings.ignorePatterns) do
+ if direntry:find(pattern) then
+ matchedignore = true
+ break --don't waste time scanning further patterns
+ end
+ end
+ if not matchedignore and not settings.ignorePaths[path..direntry] then
+ directory[i] = direntry
+ i=i+1
+ end
+ end
+
+ return directory, i
+ end
+
+ local popen, err = io.popen('ls -1vp "'..escapepath(searchdir, '"')..'" 2>/dev/null')
+ local i = 0
+ if popen then
+ for direntry in popen:lines() do
+ local matchedignore = false
+ for k,pattern in pairs(settings.ignorePatterns) do
+ if direntry:find(pattern) then
+ matchedignore = true
+ break --don't waste time scanning further patterns
+ end
+ end
+ if not matchedignore and not settings.ignorePaths[path..direntry] then
+ directory[i] = direntry
+ i=i+1
+ end
+ end
+ popen:close()
+ else
+ mp.msg.error("Could not scan for files :"..(err or ""))
+ end
+ return directory, i
+end
+
+favcursor = 1
+function cyclefavorite()
+ local firstpath = settings.favorites[1]
+ if not firstpath then return end
+ local favpath = nil
+ local favlen = 0
+ for key, fav in pairs(settings.favorites) do
+ favlen = favlen + 1
+ if key == favcursor then favpath = fav end
+ end
+ if favpath then
+ changepath(favpath)
+ favcursor = favcursor + 1
+ else
+ changepath(firstpath)
+ favcursor = 2
+ end
+end
+
+function add_keybinds()
+ mp.add_forced_key_binding(settings.key_navdown, "navdown", navdown, "repeatable")
+ mp.add_forced_key_binding(settings.key_navup, "navup", navup, "repeatable")
+ mp.add_forced_key_binding(settings.key_navopen, "navopen", opendir)
+ mp.add_forced_key_binding(settings.key_navforward, "navforward", childdir)
+ mp.add_forced_key_binding(settings.key_navback, "navback", parentdir)
+ mp.add_forced_key_binding(settings.key_navfavorites, "navfavorites", cyclefavorite)
+ mp.add_forced_key_binding(settings.key_navclose, "navclose", remove_keybinds)
+end
+
+function remove_keybinds()
+ timer:kill()
+ mp.set_osd_ass(0, 0, "")
+ if settings.dynamic_binds then
+ mp.remove_key_binding('navdown')
+ mp.remove_key_binding('navup')
+ mp.remove_key_binding('navopen')
+ mp.remove_key_binding('navforward')
+ mp.remove_key_binding('navback')
+ mp.remove_key_binding('navfavorites')
+ mp.remove_key_binding('navclose')
+ end
+end
+
+timer = mp.add_periodic_timer(settings.navigator_duration, remove_keybinds)
+timer:kill()
+
+if not settings.dynamic_binds then
+ add_keybinds()
+end
+
+active=false
+function activate()
+ if settings.menu_timeout then
+ handler()
+ else
+ if active then
+ remove_keybinds()
+ active=false
+ else
+ handler()
+ active=true
+ end
+ end
+end
+
+mp.add_key_binding(settings.navigator_mainkey, "navigator", activate)
diff --git a/mpv/scripts/reload.lua b/mpv/scripts/reload.lua
@@ -0,0 +1,395 @@
+-- reload.lua
+--
+-- When an online video is stuck buffering or got very slow CDN
+-- source, restarting often helps. This script provides automatic
+-- reloading of videos that doesn't have buffering progress for some
+-- time while keeping the current time position. It also adds `Ctrl+r`
+-- keybinding to reload video manually.
+--
+-- SETTINGS
+--
+-- To override default setting put `lua-settings/reload.conf` file in
+-- mpv user folder, on linux it is `~/.config/mpv`. NOTE: config file
+-- name should match the name of the script.
+--
+-- Default `reload.conf` settings:
+--
+-- ```
+-- # enable automatic reload on timeout
+-- # when paused-for-cache event fired, we will wait
+-- # paused_for_cache_timer_timeout sedonds and then reload the video
+-- paused_for_cache_timer_enabled=yes
+--
+-- # checking paused_for_cache property interval in seconds,
+-- # can not be less than 0.05 (50 ms)
+-- paused_for_cache_timer_interval=1
+--
+-- # time in seconds to wait until reload
+-- paused_for_cache_timer_timeout=10
+--
+-- # enable automatic reload based on demuxer cache
+-- # if demuxer-cache-time property didn't change in demuxer_cache_timer_timeout
+-- # time interval, the video will be reloaded as soon as demuxer cache depleated
+-- demuxer_cache_timer_enabled=yes
+--
+-- # checking demuxer-cache-time property interval in seconds,
+-- # can not be less than 0.05 (50 ms)
+-- demuxer_cache_timer_interval=2
+--
+-- # if demuxer cache didn't receive any data during demuxer_cache_timer_timeout
+-- # we decide that it has no progress and will reload the stream when
+-- # paused_for_cache event happens
+-- demuxer_cache_timer_timeout=20
+--
+-- # when the end-of-file is reached, reload the stream to check
+-- # if there is more content available.
+-- reload_eof_enabled=no
+--
+-- # keybinding to reload stream from current time position
+-- # you can disable keybinding by setting it to empty value
+-- # reload_key_binding=
+-- reload_key_binding=Ctrl+r
+-- ```
+--
+-- DEBUGGING
+--
+-- Debug messages will be printed to stdout with mpv command line option
+-- `--msg-level='reload=debug'`
+
+local msg = require 'mp.msg'
+local options = require 'mp.options'
+local utils = require 'mp.utils'
+
+
+local settings = {
+ paused_for_cache_timer_enabled = true,
+ paused_for_cache_timer_interval = 1,
+ paused_for_cache_timer_timeout = 10,
+ demuxer_cache_timer_enabled = true,
+ demuxer_cache_timer_interval = 2,
+ demuxer_cache_timer_timeout = 20,
+ reload_eof_enabled = false,
+ reload_key_binding = "Ctrl+r",
+}
+
+-- global state stores properties between reloads
+local property_path = nil
+local property_time_pos = 0
+local property_keep_open = nil
+
+-- FSM managing the demuxer cache.
+--
+-- States:
+--
+-- * fetch - fetching new data
+-- * stale - unable to fetch new data for time < 'demuxer_cache_timer_timeout'
+-- * stuck - unable to fetch new data for time >= 'demuxer_cache_timer_timeout'
+--
+-- State transitions:
+--
+-- +---------------------------+
+-- v |
+-- +-------+ +-------+ +-------+
+-- + fetch +<--->+ stale +---->+ stuck |
+-- +-------+ +-------+ +-------+
+-- | ^ | ^ | ^
+-- +---+ +---+ +---+
+local demuxer_cache = {
+ timer = nil,
+
+ state = {
+ name = 'uninitialized',
+ demuxer_cache_time = 0,
+ in_state_time = 0,
+ },
+
+ events = {
+ continue_fetch = { name = 'continue_fetch', from = 'fetch', to = 'fetch' },
+ continue_stale = { name = 'continue_stale', from = 'stale', to = 'stale' },
+ continue_stuck = { name = 'continue_stuck', from = 'stuck', to = 'stuck' },
+ fetch_to_stale = { name = 'fetch_to_stale', from = 'fetch', to = 'stale' },
+ stale_to_fetch = { name = 'stale_to_fetch', from = 'stale', to = 'fetch' },
+ stale_to_stuck = { name = 'stale_to_stuck', from = 'stale', to = 'stuck' },
+ stuck_to_fetch = { name = 'stuck_to_fetch', from = 'stuck', to = 'fetch' },
+ },
+
+}
+
+-- Always start with 'fetch' state
+function demuxer_cache.reset_state()
+ demuxer_cache.state = {
+ name = demuxer_cache.events.continue_fetch.to,
+ demuxer_cache_time = 0,
+ in_state_time = 0,
+ }
+end
+
+-- Has 'demuxer_cache_time' changed
+function demuxer_cache.has_progress_since(t)
+ return demuxer_cache.state.demuxer_cache_time ~= t
+end
+
+function demuxer_cache.is_state_fetch()
+ return demuxer_cache.state.name == demuxer_cache.events.continue_fetch.to
+end
+
+function demuxer_cache.is_state_stale()
+ return demuxer_cache.state.name == demuxer_cache.events.continue_stale.to
+end
+
+function demuxer_cache.is_state_stuck()
+ return demuxer_cache.state.name == demuxer_cache.events.continue_stuck.to
+end
+
+function demuxer_cache.transition(event)
+ if demuxer_cache.state.name == event.from then
+
+ -- state setup
+ demuxer_cache.state.demuxer_cache_time = event.demuxer_cache_time
+
+ if event.name == 'continue_fetch' then
+ demuxer_cache.state.in_state_time = demuxer_cache.state.in_state_time + event.interval
+ elseif event.name == 'continue_stale' then
+ demuxer_cache.state.in_state_time = demuxer_cache.state.in_state_time + event.interval
+ elseif event.name == 'continue_stuck' then
+ demuxer_cache.state.in_state_time = demuxer_cache.state.in_state_time + event.interval
+ elseif event.name == 'fetch_to_stale' then
+ demuxer_cache.state.in_state_time = 0
+ elseif event.name == 'stale_to_fetch' then
+ demuxer_cache.state.in_state_time = 0
+ elseif event.name == 'stale_to_stuck' then
+ demuxer_cache.state.in_state_time = 0
+ elseif event.name == 'stuck_to_fetch' then
+ demuxer_cache.state.in_state_time = 0
+ end
+
+ -- state transition
+ demuxer_cache.state.name = event.to
+
+ msg.debug('demuxer_cache.transition', event.name, utils.to_string(demuxer_cache.state))
+ else
+ msg.error(
+ 'demuxer_cache.transition',
+ 'illegal transition', event.name,
+ 'from state', demuxer_cache.state.name)
+ end
+end
+
+function demuxer_cache.initialize(demuxer_cache_timer_interval)
+ demuxer_cache.reset_state()
+ demuxer_cache.timer = mp.add_periodic_timer(
+ demuxer_cache_timer_interval,
+ function()
+ demuxer_cache.demuxer_cache_timer_tick(
+ mp.get_property_native('demuxer-cache-time'),
+ demuxer_cache_timer_interval)
+ end
+ )
+end
+
+-- If there is no progress of demuxer_cache_time in
+-- settings.demuxer_cache_timer_timeout time interval switch state to
+-- 'stuck' and switch back to 'fetch' as soon as any progress is made
+function demuxer_cache.demuxer_cache_timer_tick(demuxer_cache_time, demuxer_cache_timer_interval)
+ local event = nil
+ local cache_has_progress = demuxer_cache.has_progress_since(demuxer_cache_time)
+
+ -- I miss pattern matching so much
+ if demuxer_cache.is_state_fetch() then
+ if cache_has_progress then
+ event = demuxer_cache.events.continue_fetch
+ else
+ event = demuxer_cache.events.fetch_to_stale
+ end
+ elseif demuxer_cache.is_state_stale() then
+ if cache_has_progress then
+ event = demuxer_cache.events.stale_to_fetch
+ elseif demuxer_cache.state.in_state_time < settings.demuxer_cache_timer_timeout then
+ event = demuxer_cache.events.continue_stale
+ else
+ event = demuxer_cache.events.stale_to_stuck
+ end
+ elseif demuxer_cache.is_state_stuck() then
+ if cache_has_progress then
+ event = demuxer_cache.events.stuck_to_fetch
+ else
+ event = demuxer_cache.events.continue_stuck
+ end
+ end
+
+ event.demuxer_cache_time = demuxer_cache_time
+ event.interval = demuxer_cache_timer_interval
+ demuxer_cache.transition(event)
+end
+
+
+local paused_for_cache = {
+ timer = nil,
+ time = 0,
+}
+
+function paused_for_cache.reset_timer()
+ msg.debug('paused_for_cache.reset_timer', paused_for_cache.time)
+ if paused_for_cache.timer then
+ paused_for_cache.timer:kill()
+ paused_for_cache.timer = nil
+ paused_for_cache.time = 0
+ end
+end
+
+function paused_for_cache.start_timer(interval_seconds, timeout_seconds)
+ msg.debug('paused_for_cache.start_timer', paused_for_cache.time)
+ if not paused_for_cache.timer then
+ paused_for_cache.timer = mp.add_periodic_timer(
+ interval_seconds,
+ function()
+ paused_for_cache.time = paused_for_cache.time + interval_seconds
+ if paused_for_cache.time >= timeout_seconds then
+ paused_for_cache.reset_timer()
+ reload_resume()
+ end
+ msg.debug('paused_for_cache', 'tick', paused_for_cache.time)
+ end
+ )
+ end
+end
+
+function paused_for_cache.handler(property, is_paused)
+ if is_paused then
+
+ if demuxer_cache.is_state_stuck() then
+ msg.info("demuxer cache has no progress")
+ -- reset demuxer state to avoid immediate reload if
+ -- paused_for_cache event triggered right after reload
+ demuxer_cache.reset_state()
+ reload_resume()
+ end
+
+ paused_for_cache.start_timer(
+ settings.paused_for_cache_timer_interval,
+ settings.paused_for_cache_timer_timeout)
+ else
+ paused_for_cache.reset_timer()
+ end
+end
+
+function read_settings()
+ options.read_options(settings, mp.get_script_name())
+ msg.debug(utils.to_string(settings))
+end
+
+function debug_info(event)
+ msg.debug("event =", utils.to_string(event))
+ msg.debug("path =", mp.get_property("path"))
+ msg.debug("time-pos =", mp.get_property("time-pos"))
+ msg.debug("paused-for-cache =", mp.get_property("paused-for-cache"))
+ msg.debug("stream-path =", mp.get_property("stream-path"))
+ msg.debug("stream-pos =", mp.get_property("stream-pos"))
+ msg.debug("stream-end =", mp.get_property("stream-end"))
+ msg.debug("duration =", mp.get_property("duration"))
+ msg.debug("seekable =", mp.get_property("seekable"))
+end
+
+function reload(path, time_pos)
+ msg.debug("reload", path, time_pos)
+ if time_pos == nil then
+ mp.commandv("loadfile", path, "replace")
+ else
+ mp.commandv("loadfile", path, "replace", "start=+" .. time_pos)
+ end
+end
+
+function reload_resume()
+ local path = mp.get_property("path", property_path)
+ local time_pos = mp.get_property("time-pos")
+ local reload_duration = mp.get_property_native("duration")
+
+ local playlist_count = mp.get_property_number("playlist/count")
+ local playlist_pos = mp.get_property_number("playlist-pos")
+ local playlist = {}
+ for i = 0, playlist_count-1 do
+ playlist[i] = mp.get_property("playlist/" .. i .. "/filename")
+ end
+ -- Tries to determine live stream vs. pre-recordered VOD. VOD has non-zero
+ -- duration property. When reloading VOD, to keep the current time position
+ -- we should provide offset from the start. Stream doesn't have fixed start.
+ -- Decent choice would be to reload stream from it's current 'live' positon.
+ -- That's the reason we don't pass the offset when reloading streams.
+ if reload_duration and reload_duration > 0 then
+ msg.info("reloading video from", time_pos, "second")
+ reload(path, time_pos)
+ else
+ msg.info("reloading stream")
+ reload(path, nil)
+ end
+ msg.info("file ", playlist_pos+1, " of ", playlist_count, "in playlist")
+ for i = 0, playlist_pos-1 do
+ mp.commandv("loadfile", playlist[i], "append")
+ end
+ mp.commandv("playlist-move", 0, playlist_pos+1)
+ for i = playlist_pos+1, playlist_count-1 do
+ mp.commandv("loadfile", playlist[i], "append")
+ end
+end
+
+function reload_eof(property, eof_reached)
+ msg.debug("reload_eof", property, eof_reached)
+ local time_pos = mp.get_property_number("time-pos")
+ local duration = mp.get_property_number("duration")
+
+ if eof_reached and math.floor(time_pos) == math.floor(duration) then
+ msg.debug("property_time_pos", property_time_pos, "time_pos", time_pos)
+
+ -- Check that playback time_pos made progress after the last reload. When
+ -- eof is reached we try to reload video, in case there is more content
+ -- available. If time_pos stayed the same after reload, it means that vidkk
+ -- to avoid infinite reload loop when playback ended
+ -- math.floor function rounds time_pos to a second, to avoid inane reloads
+ if math.floor(property_time_pos) == math.floor(time_pos) then
+ msg.info("eof reached, playback ended")
+ mp.set_property("keep-open", property_keep_open)
+ else
+ msg.info("eof reached, checking if more content available")
+ reload_resume()
+ mp.set_property_bool("pause", false)
+ property_time_pos = time_pos
+ end
+ end
+end
+
+-- main
+
+read_settings()
+
+if settings.reload_key_binding ~= "" then
+ mp.add_key_binding(settings.reload_key_binding, "reload_resume", reload_resume)
+end
+
+if settings.paused_for_cache_timer_enabled then
+ mp.observe_property("paused-for-cache", "bool", paused_for_cache.handler)
+end
+
+if settings.demuxer_cache_timer_enabled then
+ demuxer_cache.initialize(settings.demuxer_cache_timer_interval)
+end
+
+if settings.reload_eof_enabled then
+ -- vo-configured == video output created && its configuration went ok
+ mp.observe_property(
+ "vo-configured",
+ "bool",
+ function(name, vo_configured)
+ msg.debug(name, vo_configured)
+ if vo_configured then
+ property_path = mp.get_property("path")
+ property_keep_open = mp.get_property("keep-open")
+ mp.set_property("keep-open", "yes")
+ mp.set_property("keep-open-pause", "no")
+ end
+ end
+ )
+
+ mp.observe_property("eof-reached", "bool", reload_eof)
+end
+
+--mp.register_event("file-loaded", debug_info)