--[[ 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 . ]]-- --[[ mpv_thumbnail_script.lua 0.4.2 - commit 682becf (branch master) https://github.com/TheAMM/mpv_thumbnail_script Built on 2024-04-06 15:30:02 ]]-- 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=" to the mpv sub-call arguments -- Use "" to disable mpv_profile = "", -- Output debug logs to .log, ala //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: 97816bbef0f97cfda7abdbe560707481d5f68ccd ]]-- 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", -- slider (diamond marker), knob (circle -- marker with guide), or bar (fill) 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? seekranges = true, -- display seek ranges? visibility = "auto", -- only used at init to set visibility_mode(...) boxmaxchars = 80, -- title crop threshold for box layout } -- 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 -- 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 = {}, } 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}", } -- 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, timer = nil, cache_idle = false, idle = false, enabled = true, input_enabled = true, showhide_enabled = false, } -- -- Helperfunctions -- -- 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 -- -- 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 by tracks --(+1 -> next, -1 -> previous) function set_track(type, next) local current_track_mpv, current_track_osc if (mp.get_property(type) == "no") then current_track_osc = 0 else current_track_mpv = tonumber(mp.get_property(type)) current_track_osc = tracks_mpv[type][current_track_mpv].osc_id end local new_track_osc = (current_track_osc + next) % (#tracks_osc[type] + 1) local new_track_mpv if new_track_osc == 0 then new_track_mpv = "no" else new_track_mpv = tracks_osc[type][new_track_osc].id end mp.commandv("set", type, new_track_mpv) if (new_track_osc == 0) then show_message(nicetypes[type] .. " Track: none") else show_message(nicetypes[type] .. " Track: " .. new_track_osc .. "/" .. #tracks_osc[type] .. " [".. (tracks_osc[type][new_track_osc].lang or "unknown") .."] " .. (tracks_osc[type][new_track_osc].title or "")) end end -- get the currently selected track of , OSC-style counted function get_track(type) local track = mp.get_property(type) if track ~= "no" and track ~= nil then local tr = tracks_mpv[type][tonumber(track)] if tr then return tr.osc_id end end return 0 end -- -- Element Management -- local elements = {} function prepare_elements() -- remove elements without layout or invisble local elements2 = {} for n, element in pairs(elements) do if not (element.layout == nil) and (element.visible) then table.insert(elements2, element) end end elements = elements2 function elem_compare (a, b) return a.layout.layer < b.layout.layer end table.sort(elements, elem_compare) for _,element in pairs(elements) do local elem_geo = element.layout.geometry -- Calculate the hitbox local bX1, bY1, bX2, bY2 = get_hitbox_coords_geo(elem_geo) element.hitbox = {x1 = bX1, y1 = bY1, x2 = bX2, y2 = bY2} local style_ass = assdraw.ass_new() -- prepare static elements style_ass:append("{}") -- hack to troll new_event into inserting a \n style_ass:new_event() style_ass:pos(elem_geo.x, elem_geo.y) style_ass:an(elem_geo.an) style_ass:append(element.layout.style) element.style_ass = style_ass local static_ass = assdraw.ass_new() if (element.type == "box") then --draw box static_ass:draw_start() static_ass:round_rect_cw(0, 0, elem_geo.w, elem_geo.h, element.layout.box.radius) static_ass:draw_stop() elseif (element.type == "slider") then --draw static slider parts 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 == "slider") or (slider_lo.stype == "knob") then element.slider.min.ele_pos = elem_geo.h / 2 element.slider.max.ele_pos = elem_geo.w - (elem_geo.h / 2) elseif (slider_lo.stype == "bar") then 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 static_ass:rect_cw(0, 0, elem_geo.w, elem_geo.h); -- the "hole" static_ass:rect_ccw(slider_lo.border, slider_lo.border, elem_geo.w - slider_lo.border, elem_geo.h - slider_lo.border) -- 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) --alpha local ar = element.layout.alpha if not (state.animation == nil) then ar = {} for ai, av in pairs(element.layout.alpha) do ar[ai] = mult_alpha(av, state.animation) end end style_ass:append(string.format("{\\1a&H%X&\\2a&H%X&\\3a&H%X&\\4a&H%X&}", ar[1], ar[2], ar[3], ar[4])) 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 pos = element.slider.posF() if not (pos == nil) then local foV = slider_lo.border + slider_lo.gap local foH = 0 if (slider_lo.stype == "slider") or (slider_lo.stype == "knob") then foH = elem_geo.h / 2 elseif (slider_lo.stype == "bar") then foH = slider_lo.border + slider_lo.gap end local xp = get_slider_ele_pos_for(element, pos) -- the filling local innerH = elem_geo.h - (2*foV) if (slider_lo.stype == "bar") then elem_ass:rect_cw(foH, foV, xp, elem_geo.h - foV) elseif (slider_lo.stype == "slider") then elem_ass:move_to(xp, foV) elem_ass:line_to(xp+(innerH/2), (innerH/2)+foV) elem_ass:line_to(xp, (innerH)+foV) elem_ass:line_to(xp-(innerH/2), (innerH/2)+foV) elseif (slider_lo.stype == "knob") then elem_ass:rect_cw(xp, (9*innerH/20) + foV, elem_geo.w - foH, (11*innerH/20) + foV) elem_ass:rect_cw(foH, (3*innerH/8) + foV, xp, (5*innerH/8) + foV) elem_ass:round_rect_cw(xp - innerH/2, foV, xp + innerH/2, foV + innerH, innerH/2.0) end end -- seek ranges local seekRanges = element.slider.seekRangesF() if not (seekRanges == nil) then for _,range in pairs(seekRanges) do local pstart = get_slider_ele_pos_for(element, range["start"]) local pend = get_slider_ele_pos_for(element, range["end"]) elem_ass:rect_ccw(pstart, (elem_geo.h/2)-1, pend, (elem_geo.h/2) + 1) 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) --alpha local ar = slider_lo.alpha if not (state.animation == nil) then ar = {} for ai, av in pairs(slider_lo.alpha) do ar[ai] = mult_alpha(av, state.animation) end end elem_ass:append(string.format("{\\1a&H%X&\\2a&H%X&\\3a&H%X&\\4a&H%X&}", ar[1], ar[2], ar[3], ar[4])) 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 -- -- 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"] if lo.slider.stype == "knob" then lo.slider.border = 0 end -- -- 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 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.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 layouts["bottombar"] = function() local osc_geo = { x = -2, y = osc_param.playresy - 54 - user_opts.barmargin, an = 7, w = osc_param.playresx + 4, 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 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 osc_geo.y = osc_param.playresy - 54 - user_opts.barmargin osc_geo.w = osc_param.playresx + 4 end local line1 = osc_geo.y + 9 + padY local line2 = osc_geo.y + 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 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 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, 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, 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) lo = add_layout("seekbar") lo.geometry = geo lo.style = osc_styles.timecodes 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"] end layouts["topbar"] = function() local osc_geo = { x = -2, y = 54 + user_opts.barmargin, an = 1, w = osc_param.playresx + 4, 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 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 osc_geo.y = 54 + user_opts.barmargin osc_geo.w = osc_param.playresx + 4 end local line1 = osc_geo.y - 36 - padY local line2 = osc_geo.y - 9 - 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 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) 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 -- Playback control buttons geo = { x = osc_geo.x + padX, y = line1, 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, 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 - geo.w - padX - tcW - 10, y = geo.y, an = 4, 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 = user_opts.barmargin, an = 7, 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) 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.stype = user_opts["seekbarstyle"] lo.slider.tooltip_an = 5 -- Playlist prev/next geo = { x = osc_geo.x + padX, y = line2, 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) 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 = "box" end if user_opts.seekbarstyle ~= "slider" and user_opts.seekbarstyle ~= "bar" and user_opts.seekbarstyle ~= "knob" then msg.warn("Invalid setting \"" .. user_opts.seekbarstyle .. "\" for seekbarstyle") user_opts.seekbarstyle = "slider" 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 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 not (user_opts.seekranges) then return nil end local cache_state = mp.get_property_native("demuxer-cache-state", nil) 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"] for _, range in pairs(ranges) do range["start"] = 100 * range["start"] / duration range["end"] = 100 * range["end"] / duration end return ranges 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", "keyframes") 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 dmx_cache = mp.get_property_number("demuxer-cache-duration") local cache_used = mp.get_property_number("cache-used") local dmx_cache_state = mp.get_property_native("demuxer-cache-state", {}) local is_network = mp.get_property_native("demuxer-via-network") local show_cache = cache_used and not dmx_cache_state["eof"] if dmx_cache then dmx_cache = string.format("%3.0fs", dmx_cache) end if dmx_cache_state["fw-bytes"] then cache_used = (cache_used or 0)*1024 + dmx_cache_state["fw-bytes"] end if (is_network and dmx_cache) or show_cache then -- Only show dmx-cache-duration by itself if it's a network file. -- Cache can be forced even for local files, so always show that. return string.format("Cache: %s%s%s", (dmx_cache and dmx_cache or ""), ((dmx_cache and show_cache) and " | " or ""), (show_cache and utils.format_bytes_humanized(cache_used) or "")) else return "" end 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]() --do something with the elements prepare_elements() 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 timer_stop() render_wipe() elseif (user_opts.fadeduration > 0) then if not(state.osc_visible == false) then state.anitype = "out" control_timer() end else osc_visible(false) end end function osc_visible(visible) state.osc_visible = visible control_timer() end function pause_state(name, enabled) state.paused = enabled control_timer() end function cache_state(name, idle) state.cache_idle = idle control_timer() end function control_timer() if (state.paused) and (state.osc_visible) and ( not(state.cache_idle) or not (state.anitype == nil) ) then timer_start() else timer_stop() end end function timer_start() if not (state.timer_active) then msg.trace("timer start") if (state.timer == nil) then -- create new timer state.timer = mp.add_periodic_timer(0.03, tick) else -- resume existing one state.timer:resume() end state.timer_active = true end end function timer_stop() if (state.timer_active) then msg.trace("timer stop") if not (state.timer == nil) then -- kill timer state.timer:kill() end state.timer_active = false 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 do_enable_keybindings() --mouse input area local mouse_over_osc = false for _,cords in ipairs(osc_param.areas["input"]) do if state.osc_visible then -- activate only when OSC is actually visible set_virt_mouse_area(cords.x1, cords.y1, cords.x2, cords.y2, "input") end if state.osc_visible ~= state.input_enabled then if state.osc_visible then mp.enable_key_bindings("input") else mp.disable_key_bindings("input") end state.input_enabled = state.osc_visible end if (mouse_hit_coords(cords.x1, cords.y1, cords.x2, cords.y2)) then mouse_over_osc = true end end -- autohide if not (state.showtime == nil) and (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 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") 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 end function do_enable_keybindings() if state.enabled then if not state.showhide_enabled then mp.enable_key_bindings("showhide", "allow-vo-dragging+allow-hide-cursor") end state.showhide_enabled = true end end function enable_osc(enable) state.enabled = enable if enable then do_enable_keybindings() else hide_osc() -- acts immediately when state.enabled == false if state.showhide_enabled then mp.disable_key_bindings("showhide") end state.showhide_enabled = false end end -- 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("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("idle-active", "bool", function(name, val) state.idle = val tick() end ) mp.observe_property("pause", "bool", pause_state) mp.observe_property("cache-idle", "bool", cache_state) mp.observe_property("vo-configured", "bool", function(name, val) if val then mp.register_event("tick", tick) else mp.unregister_event(tick) end end) -- mouse show/hide bindings mp.set_key_bindings({ {"mouse_move", function(e) process_event("mouse_move", nil) end}, {"mouse_leave", mouse_leave}, }, "showhide", "force") do_enable_keybindings() --mouse input bindings mp.set_key_bindings({ {"mbtn_left", function(e) process_event("mbtn_left", "up") end, function(e) process_event("mbtn_left", "down") end}, {"shift+mbtn_left", function(e) process_event("shift+mbtn_left", "up") end, function(e) process_event("shift+mbtn_left", "down") end}, {"mbtn_right", function(e) process_event("mbtn_right", "up") end, function(e) process_event("mbtn_right", "down") end}, {"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") 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 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")