summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorJoe <rbo@gmx.us>2024-04-22 23:35:06 +0200
committerJoe <rbo@gmx.us>2024-04-22 23:35:06 +0200
commit528c35de2a67b8e87ff9bb6d7f7de692cb4a31bc (patch)
tree47ea5d822d948e3e5119619c019fe9b62bc07135
parentup (diff)
downloaddotfiles-bsd-528c35de2a67b8e87ff9bb6d7f7de692cb4a31bc.tar.gz
dotfiles-bsd-528c35de2a67b8e87ff9bb6d7f7de692cb4a31bc.tar.bz2
dotfiles-bsd-528c35de2a67b8e87ff9bb6d7f7de692cb4a31bc.tar.xz
dotfiles-bsd-528c35de2a67b8e87ff9bb6d7f7de692cb4a31bc.tar.zst
dotfiles-bsd-528c35de2a67b8e87ff9bb6d7f7de692cb4a31bc.zip
up
-rw-r--r--.config/mpv/input.conf2
-rw-r--r--.config/mpv/mpv.conf2
-rw-r--r--.config/mpv/script-opts/mpv_thumbnail_script.conf143
-rw-r--r--.config/mpv/script-opts/quality-menu.conf (renamed from .config/mpv/quality-menu.conf)17
-rw-r--r--.config/mpv/scripts/mpv_thumbnail_script_server-1.lua815
-rw-r--r--.config/mpv/scripts/quality-menu.lua1357
-rw-r--r--.config/mpv/scripts/reload.lua19
7 files changed, 1840 insertions, 515 deletions
diff --git a/.config/mpv/input.conf b/.config/mpv/input.conf
index 01d010b..ac8da77 100644
--- a/.config/mpv/input.conf
+++ b/.config/mpv/input.conf
@@ -5,3 +5,5 @@ k seek 60
S cycle sub
Ctrl+f script-binding quality_menu/video_formats_toggle #! Stream Quality > Video
Alt+f script-binding quality_menu/audio_formats_toggle #! Stream Quality > Audio
+Ctrl+r script-binding reload/reload
+Ctrl+t script-binding generate-thumbnails
diff --git a/.config/mpv/mpv.conf b/.config/mpv/mpv.conf
index eeeec5b..ad9a188 100644
--- a/.config/mpv/mpv.conf
+++ b/.config/mpv/mpv.conf
@@ -1,2 +1,2 @@
video-sync=display-resample
-osc=no
+osc=yes
diff --git a/.config/mpv/script-opts/mpv_thumbnail_script.conf b/.config/mpv/script-opts/mpv_thumbnail_script.conf
new file mode 100644
index 0000000..80a3c22
--- /dev/null
+++ b/.config/mpv/script-opts/mpv_thumbnail_script.conf
@@ -0,0 +1,143 @@
+# The thumbnail cache directory.
+# On Windows this defaults to %TEMP%\mpv_thumbs_cache,
+# and on other platforms to ${TEMP} or ${XDG_CACHE_HOME} or /tmp in the subfolder mpv_thumbs_cache
+# The directory will be created automatically, but must be writeable!
+# Use absolute paths, and take note that environment variables like %TEMP% are unsupported (despite the default)!
+cache_directory=/tmp/mpv_thumbs_cache
+
+# Whether to generate thumbnails automatically on video load, without a keypress
+# Defaults to yes
+autogenerate=yes
+
+# Only automatically thumbnail videos shorter than this (in seconds)
+# You will have to press T (or your own keybind) to enable the thumbnail previews
+# Set to 0 to disable the check, ie. thumbnail videos no matter how long they are
+# Defaults to 3600 (one hour)
+autogenerate_max_duration=3600
+
+# Use mpv to generate thumbnail even if ffmpeg is found in PATH
+# ffmpeg is slightly faster than mpv but lacks support for ordered chapters in MKVs,
+# which can break the resulting thumbnails. You have been warned.
+# Defaults to yes (don't use ffmpeg)
+prefer_mpv=yes
+
+# Explicitly disable subtitles on the mpv sub-calls
+# mpv can and will by default render subtitles into the thumbnails.
+# If this is not what you wish, set mpv_no_sub to yes
+# Defaults to no
+mpv_no_sub=no
+
+# Enable to disable the built-in keybind ("T") to add your own, see after the block
+# Defaults to no
+disable_keybinds=yes
+
+# The maximum dimensions of the thumbnails, in pixels
+# Defaults to 200 and 200
+thumbnail_width=200
+thumbnail_height=200
+
+# The thumbnail count target
+# (This will result in a thumbnail every ~10 seconds for a 25 minute video)
+# Defaults to 150
+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)
+# Defaults to 5 and 90, values are seconds
+min_delta=5
+max_delta=90
+# 120 seconds aka 2 minutes will add more thumbnails only when the video is over 5 hours long!
+
+# Parameter that mpv should use for hardware decoding
+# If properly configured can really improve thumbnail generation speed and cpu load
+# Default to no, see https://mpv.io/manual/master/#options-hwdec for the values
+mpv_hwdec=no
+
+# Parameter that mpv should use for seeking
+# yes extracts the exact frame
+# no extracts the closest keyframe, faster but less precise
+# Default to yes
+mpv_hr_seek=yes
+
+
+# Remote options
+
+
+# Below are overrides for remote urls (you generally want less thumbnails, because it's slow!)
+# Thumbnailing network paths will be done with mpv (leveraging youtube-dl)
+
+# Allow thumbnailing network paths (naive check for "://")
+# Defaults to no
+thumbnail_network=no
+
+# Same as autogenerate_max_duration but for remote videos
+# Defaults to 1200 (20 minutes)
+remote_autogenerate_max_duration=1200
+# Override thumbnail count, min/max delta, as above
+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
+# Defaults to yes
+remote_direct_stream=yes
+
+# Enable storyboards (requires yt-dlp in PATH). Currently only supports YouTube and Twitch VoDs
+# Defaults to yes
+storyboard_enable=yes
+# Max thumbnails for storyboards. It only skips processing some of the downloaded thumbnails and doesn't make it much faster
+# Defaults to 800
+storyboard_max_thumbnail_count=800
+# Most storyboard thumbnails are 160x90. Enabling this allows upscaling them up to thumbnail_height
+# Defaults to no
+storyboard_upscale=no
+
+
+# Display options
+
+
+# Move the thumbnail up or down
+# For example:
+# topbar/bottombar: 24 (default)
+# rest: 0
+vertical_offset=24
+
+# Adjust background padding
+# Examples:
+# topbar: 0, 10, 10, 10
+# bottombar 10, 0, 10, 10 (default)
+# slimbox/box: 10, 10, 10, 10
+pad_top=10
+pad_bot=0
+pad_left=10
+pad_right=10
+
+# If enabled pad values are screen-pixels, else video-pixels.
+# Defaults to yes
+pad_in_screenspace=yes
+
+# Calculate pad into the offset
+# Defaults to yes
+offset_by_pad=yes
+
+# Background color in BBGGRR
+background_color=000000
+
+# Alpha: 0 - fully opaque, 255 - transparent
+# Defaults to 80
+background_alpha=80
+
+# Keep thumbnail on the screen near left or right side
+# Defaults to yes
+constrain_to_screen=yes
+
+# Do not display the thumbnailing progress
+# Defaults to no
+hide_progress=no
diff --git a/.config/mpv/quality-menu.conf b/.config/mpv/script-opts/quality-menu.conf
index a5654f8..8b34f7e 100644
--- a/.config/mpv/quality-menu.conf
+++ b/.config/mpv/script-opts/quality-menu.conf
@@ -7,7 +7,7 @@ down_binding=DOWN WHEEL_DOWN
# select menu entry
select_binding=ENTER MBTN_LEFT
# close menu
-close_menu_binding=ESC MBTN_RIGHT F Alt+f
+close_menu_binding=ESC MBTN_RIGHT
# youtube-dl version(could be youtube-dl or yt-dlp, or something else)
ytdl_ver=yt-dlp
@@ -49,11 +49,8 @@ menu_timeout=6
fetch_formats=yes
# list of ytdl-format strings to choose from
-quality_strings=[ {"4320p" : "bestvideo[height<=?4320p]+bestaudio/best"}, {"2160p" : "bestvideo[height<=?2160]+bestaudio/best"}, {"1440p" : "bestvideo[height<=?1440]+bestaudio/best"}, {"1080p" : "bestvideo[height<=?1080]+bestaudio/best"}, {"720p" : "bestvideo[height<=?720]+bestaudio/best"}, {"480p" : "bestvideo[height<=?480]+bestaudio/best"}, {"360p" : "bestvideo[height<=?360]+bestaudio/best"}, {"240p" : "bestvideo[height<=?240]+bestaudio/best"}, {"144p" : "bestvideo[height<=?144]+bestaudio/best"} ]
-
-# reset youtube-dl format to the original format string when changing files (e.g. going to the next playlist entry)
-# if file was opened previously, reset to previously selected format
-reset_format=yes
+quality_strings_video=[ {"4320p" : "bestvideo[height<=?4320p]"}, {"2160p" : "bestvideo[height<=?2160]"}, {"1440p" : "bestvideo[height<=?1440]"}, {"1080p" : "bestvideo[height<=?1080]"}, {"720p" : "bestvideo[height<=?720]"}, {"480p" : "bestvideo[height<=?480]"}, {"360p" : "bestvideo[height<=?360]"}, {"240p" : "bestvideo[height<=?240]"}, {"144p" : "bestvideo[height<=?144]"} ]
+quality_strings_audio=[ {"default" : "bestaudio"} ]
# automatically fetch available formats when opening an url
fetch_on_start=yes
@@ -74,6 +71,10 @@ hide_identical_columns=yes
# which columns are shown in which order
# comma separated list, prefix column with "-" to align left
#
+# for the uosc integration it is possible to split the text up into a title and a hint
+# this is done by separating two columns with a "|" instead of a comma
+# column order in the hint is reversed
+#
# columns that might be useful are:
# resolution, width, height, fps, dynamic_range, tbr, vbr, abr, asr,
# filesize, filesize_approx, vcodec, acodec, ext, video_ext, audio_ext,
@@ -90,8 +91,8 @@ hide_identical_columns=yes
#
# Not all videos have all columns available.
# Be careful, misspelled columns simply won't be displayed, there is no error.
-columns_video=-resolution,frame_rate,dynamic_range,language,bitrate_total,size,-codec_video,-codec_audio
-columns_audio=audio_sample_rate,bitrate_total,size,language,-codec_audio
+columns_video=-resolution,frame_rate,dynamic_range|language,bitrate_total,size,-codec_video,-codec_audio
+columns_audio=audio_sample_rate,bitrate_total|size,language,-codec_audio
# columns used for sorting, see "columns_video" for available columns
# comma separated list, prefix column with "-" to reverse sorting order
diff --git a/.config/mpv/scripts/mpv_thumbnail_script_server-1.lua b/.config/mpv/scripts/mpv_thumbnail_script_server-1.lua
new file mode 100644
index 0000000..a91db76
--- /dev/null
+++ b/.config/mpv/scripts/mpv_thumbnail_script_server-1.lua
@@ -0,0 +1,815 @@
+--[[
+ Copyright (C) 2017 AMM
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
+]]--
+--[[
+ mpv_thumbnail_script.lua 0.5.3 - commit 6b42232 (branch master)
+ https://github.com/TheAMM/mpv_thumbnail_script
+ Built on 2023-10-19 11:12:04
+]]--
+local assdraw = require 'mp.assdraw'
+local msg = require 'mp.msg'
+local opt = require 'mp.options'
+local utils = require 'mp.utils'
+
+-- Determine if the platform is Windows --
+ON_WINDOWS = (package.config:sub(1,1) ~= '/')
+
+-- Determine if the platform is MacOS --
+local uname = io.popen("uname -s"):read("*l")
+ON_MAC = not ON_WINDOWS and (uname == "Mac" or uname == "Darwin")
+
+-- Some helper functions needed to parse the options --
+function isempty(v) return not v or (v == "") or (v == 0) or (type(v) == "table" and not next(v)) 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 not last_index 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 then
+ local ok, err, code = f:read(1)
+ io.close(f)
+ return not code
+ else
+ return false
+ end
+end
+
+function path_exists(name)
+ local f = io.open(name, "rb")
+ if f 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 not self.path_cache[name] 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 or 3
+ sep = 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 or 1
+ sep = 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 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
+ end
+end
+
+function substitute_values(string, values)
+ local substitutor = function(match)
+ if match == "%" then
+ return "%"
+ else
+ -- nil is discarded by gsub
+ return values[match]
+ end
+ end
+
+ local substituted = string:gsub('%%(.)', substitutor)
+ return substituted
+end
+
+-- ASS HELPERS --
+function round_rect_top( ass, x0, y0, x1, y1, r )
+ local c = 0.551915024494 * r -- circle approximation
+ ass:move_to(x0 + r, y0)
+ ass:line_to(x1 - r, y0) -- top line
+ if r > 0 then
+ ass:bezier_curve(x1 - r + c, y0, x1, y0 + r - c, x1, y0 + r) -- top right corner
+ end
+ ass:line_to(x1, y1) -- right line
+ ass:line_to(x0, y1) -- bottom line
+ ass:line_to(x0, y0 + r) -- left line
+ if r > 0 then
+ ass:bezier_curve(x0, y0 + r - c, x0 + r - c, y0, x0 + r, y0) -- top left corner
+ end
+end
+
+function round_rect(ass, x0, y0, x1, y1, rtl, rtr, rbr, rbl)
+ local c = 0.551915024494
+ ass:move_to(x0 + rtl, y0)
+ ass:line_to(x1 - rtr, y0) -- top line
+ if rtr > 0 then
+ ass:bezier_curve(x1 - rtr + rtr*c, y0, x1, y0 + rtr - rtr*c, x1, y0 + rtr) -- top right corner
+ end
+ ass:line_to(x1, y1 - rbr) -- right line
+ if rbr > 0 then
+ ass:bezier_curve(x1, y1 - rbr + rbr*c, x1 - rbr + rbr*c, y1, x1 - rbr, y1) -- bottom right corner
+ end
+ ass:line_to(x0 + rbl, y1) -- bottom line
+ if rbl > 0 then
+ ass:bezier_curve(x0 + rbl - rbl*c, y1, x0, y1 - rbl + rbl*c, x0, y1 - rbl) -- bottom left corner
+ end
+ ass:line_to(x0, y0 + rtl) -- left line
+ if rtl > 0 then
+ ass:bezier_curve(x0, y0 + rtl - rtl*c, x0 + rtl - rtl*c, y0, x0 + rtl, y0) -- top left corner
+ end
+end
+local SCRIPT_NAME = "mpv_thumbnail_script"
+
+local default_cache_base = ON_WINDOWS and os.getenv("TEMP") or (os.getenv("XDG_CACHE_HOME") or "/tmp/")
+
+local thumbnailer_options = {
+ -- The thumbnail directory
+ cache_directory = join_paths(default_cache_base, "mpv_thumbs_cache"),
+
+ ------------------------
+ -- Generation options --
+ ------------------------
+
+ -- Automatically generate the thumbnails on video load, without a keypress
+ autogenerate = true,
+
+ -- Only automatically thumbnail videos shorter than this (seconds)
+ autogenerate_max_duration = 3600, -- 1 hour
+
+ -- SHA1-sum filenames over this length
+ -- It's nice to know what files the thumbnails are (hence directory names)
+ -- but long URLs may approach filesystem limits.
+ hash_filename_length = 128,
+
+ -- Use mpv to generate thumbnail even if ffmpeg is found in PATH
+ -- ffmpeg does not handle ordered chapters (MKVs which rely on other MKVs)!
+ -- mpv is a bit slower, but has better support overall (eg. subtitles in the previews)
+ prefer_mpv = true,
+
+ -- Explicitly disable subtitles on the mpv sub-calls
+ mpv_no_sub = false,
+ -- Add a "--no-config" to the mpv sub-call arguments
+ mpv_no_config = false,
+ -- Add a "--profile=<mpv_profile>" to the mpv sub-call arguments
+ -- Use "" to disable
+ mpv_profile = "",
+ -- Hardware decoding
+ mpv_hwdec = "no",
+ -- High precision seek
+ mpv_hr_seek = "yes",
+ -- Output debug logs to <thumbnail_path>.log, ala <cache_directory>/<video_filename>/000000.bgra.log
+ -- The logs are removed after successful encodes, unless you set mpv_keep_logs below
+ mpv_logs = true,
+ -- Keep all mpv logs, even the succesfull ones
+ mpv_keep_logs = false,
+
+ -- Disable the built-in keybind ("T") to add your own
+ disable_keybinds = false,
+
+ ---------------------
+ -- Display options --
+ ---------------------
+
+ -- Move the thumbnail up or down
+ -- For example:
+ -- topbar/bottombar: 24
+ -- rest: 0
+ vertical_offset = 24,
+
+ -- Adjust background padding
+ -- Examples:
+ -- topbar: 0, 10, 10, 10
+ -- bottombar: 10, 0, 10, 10
+ -- slimbox/box: 10, 10, 10, 10
+ pad_top = 10,
+ pad_bot = 0,
+ pad_left = 10,
+ pad_right = 10,
+
+ -- If true, pad values are screen-pixels. If false, video-pixels.
+ pad_in_screenspace = true,
+ -- Calculate pad into the offset
+ offset_by_pad = true,
+
+ -- Background color in BBGGRR
+ background_color = "000000",
+ -- Alpha: 0 - fully opaque, 255 - transparent
+ background_alpha = 80,
+
+ -- Keep thumbnail on the screen near left or right side
+ constrain_to_screen = true,
+
+ -- Do not display the thumbnailing progress
+ hide_progress = false,
+
+ -----------------------
+ -- Thumbnail options --
+ -----------------------
+
+ -- The maximum dimensions of the thumbnails (pixels)
+ thumbnail_width = 200,
+ thumbnail_height = 200,
+
+ -- The thumbnail count target
+ -- (This will result in a thumbnail every ~10 seconds for a 25 minute video)
+ thumbnail_count = 150,
+
+ -- The above target count will be adjusted by the minimum and
+ -- maximum time difference between thumbnails.
+ -- The thumbnail_count will be used to calculate a target separation,
+ -- and min/max_delta will be used to constrict it.
+
+ -- In other words, thumbnails will be:
+ -- at least min_delta seconds apart (limiting the amount)
+ -- at most max_delta seconds apart (raising the amount if needed)
+ min_delta = 5,
+ -- 120 seconds aka 2 minutes will add more thumbnails when the video is over 5 hours!
+ max_delta = 90,
+
+
+ -- Overrides for remote urls (you generally want less thumbnails!)
+ -- Thumbnailing network paths will be done with mpv
+
+ -- Allow thumbnailing network paths (naive check for "://")
+ thumbnail_network = false,
+ -- Same as autogenerate_max_duration but for remote videos
+ remote_autogenerate_max_duration = 1200, -- 20 min
+ -- 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,
+
+ -- Enable storyboards (requires yt-dlp in PATH). Currently only supports YouTube and Twitch VoDs
+ storyboard_enable = true,
+ -- Max thumbnails for storyboards. It only skips processing some of the downloaded thumbnails and doesn't make it much faster
+ storyboard_max_thumbnail_count = 800,
+ -- Most storyboard thumbnails are 160x90. Enabling this allows upscaling them up to thumbnail_height
+ storyboard_upscale = false,
+}
+
+read_options(thumbnailer_options, SCRIPT_NAME)
+function skip_nil(tbl)
+ local n = {}
+ for k, v in pairs(tbl) do
+ table.insert(n, v)
+ end
+ return n
+end
+
+function create_thumbnail_mpv(file_path, timestamp, size, output_path, options)
+ options = options or {}
+
+ local ytdl_disabled = not options.enable_ytdl and (mp.get_property_native("ytdl") == false
+ or thumbnailer_options.remote_direct_stream)
+
+ local header_fields_arg = nil
+ local header_fields = mp.get_property_native("http-header-fields")
+ if #header_fields > 0 then
+ -- We can't escape the headers, mpv won't parse "--http-header-fields='Name: value'" properly
+ header_fields_arg = "--http-header-fields=" .. table.concat(header_fields, ",")
+ end
+
+ local profile_arg = nil
+ if thumbnailer_options.mpv_profile ~= "" then
+ profile_arg = "--profile=" .. thumbnailer_options.mpv_profile
+ end
+
+ local log_arg = "--log-file=" .. output_path .. ".log"
+
+ local mpv_path = ON_MAC and "/opt/homebrew/bin/mpv" or "mpv"
+
+ local mpv_command = skip_nil{
+ mpv_path,
+ -- Hide console output
+ "--msg-level=all=no",
+
+ -- Disable ytdl
+ (ytdl_disabled and "--no-ytdl" or nil),
+ -- Pass HTTP headers from current instance
+ header_fields_arg,
+ -- Pass User-Agent and Referer - should do no harm even with ytdl active
+ "--user-agent=" .. mp.get_property_native("user-agent"),
+ "--referrer=" .. mp.get_property_native("referrer"),
+ -- User set hardware decoding
+ "--hwdec=" .. thumbnailer_options.mpv_hwdec,
+
+ -- Insert --no-config, --profile=... and --log-file if enabled
+ (thumbnailer_options.mpv_no_config and "--no-config" or nil),
+ profile_arg,
+ (thumbnailer_options.mpv_logs and log_arg or nil),
+
+ "--start=" .. tostring(timestamp),
+ "--frames=1",
+ "--hr-seek=" .. thumbnailer_options.mpv_hr_seek,
+ "--no-audio",
+ -- Optionally disable subtitles
+ (thumbnailer_options.mpv_no_sub and "--no-sub" or nil),
+
+ (options.relative_scale
+ and ("--vf=scale=iw*%d:ih*%d"):format(size.w, size.h)
+ or ("--vf=scale=%d:%d"):format(size.w, size.h)),
+
+ "--vf-add=format=bgra",
+ "--of=rawvideo",
+ "--ovc=rawvideo",
+ ("--o=%s"):format(output_path),
+
+ "--",
+
+ file_path,
+ }
+ return mp.command_native{name="subprocess", args=mpv_command}
+end
+
+
+function create_thumbnail_ffmpeg(file_path, timestamp, size, output_path, options)
+ options = options or {}
+
+ local ffmpeg_path = ON_MAC and "/opt/homebrew/bin/ffmpeg" or "ffmpeg"
+
+ local ffmpeg_command = {
+ ffmpeg_path,
+ "-loglevel", "quiet",
+ "-noaccurate_seek",
+ "-ss", format_time(timestamp, ":"),
+ "-i", file_path,
+
+ "-frames:v", "1",
+ "-an",
+
+ "-vf",
+ (options.relative_scale
+ and ("scale=iw*%d:ih*%d"):format(size.w, size.h)
+ or ("scale=%d:%d"):format(size.w, size.h)),
+
+ "-c:v", "rawvideo",
+ "-pix_fmt", "bgra",
+ "-f", "rawvideo",
+
+ "-y", output_path,
+ }
+ return mp.command_native{name="subprocess", args=ffmpeg_command}
+end
+
+
+function check_output(ret, output_path, is_mpv)
+ local log_path = output_path .. ".log"
+ local success = true
+
+ if ret.killed_by_us then
+ return nil
+ else
+ if ret.error or ret.status ~= 0 then
+ msg.error("Thumbnailing command failed!")
+ msg.error("mpv process error:", ret.error)
+ msg.error("Process stdout:", ret.stdout)
+ if is_mpv then
+ msg.error("Debug log:", log_path)
+ end
+
+ success = false
+ end
+
+ if not file_exists(output_path) then
+ msg.error("Output file missing!", output_path)
+ success = false
+ end
+ end
+
+ if is_mpv and not thumbnailer_options.mpv_keep_logs then
+ -- Remove successful debug logs
+ if success and file_exists(log_path) then
+ os.remove(log_path)
+ end
+ end
+
+ return success
+end
+
+-- split cols x N atlas in BGRA format into many thumbnail files
+function split_atlas(atlas_path, cols, thumbnail_size, output_name)
+ local atlas = io.open(atlas_path, "rb")
+ local atlas_filesize = atlas:seek("end")
+ local atlas_pictures = math.floor(atlas_filesize / (4 * thumbnail_size.w * thumbnail_size.h))
+ local stride = 4 * thumbnail_size.w * math.min(cols, atlas_pictures)
+ for pic = 0, atlas_pictures-1 do
+ local x_start = (pic % cols) * thumbnail_size.w
+ local y_start = math.floor(pic / cols) * thumbnail_size.h
+ local filename = output_name(pic)
+ if filename then
+ local thumb_file = io.open(filename, "wb")
+ for line = 0, thumbnail_size.h - 1 do
+ atlas:seek("set", 4 * x_start + (y_start + line) * stride)
+ local data = atlas:read(thumbnail_size.w * 4)
+ if data then
+ thumb_file:write(data)
+ end
+ end
+ thumb_file:close()
+ end
+ end
+ atlas:close()
+end
+
+function do_worker_job(state_json_string, frames_json_string)
+ msg.debug("Handling given job")
+ local thumb_state, err = utils.parse_json(state_json_string)
+ if err then
+ msg.error("Failed to parse state JSON")
+ return
+ end
+
+ local thumbnail_indexes, err = utils.parse_json(frames_json_string)
+ if err then
+ msg.error("Failed to parse thumbnail frame indexes")
+ return
+ end
+
+ local thumbnail_func = create_thumbnail_mpv
+ if not thumbnailer_options.prefer_mpv then
+ if ExecutableFinder:get_executable_path("ffmpeg") then
+ thumbnail_func = create_thumbnail_ffmpeg
+ else
+ msg.warn("Could not find ffmpeg in PATH! Falling back on mpv.")
+ end
+ end
+
+ local file_duration = mp.get_property_native("duration")
+ local file_path = thumb_state.worker_input_path
+
+ if thumb_state.is_remote and not thumb_state.storyboard then
+ if (thumbnail_func == create_thumbnail_ffmpeg) then
+ msg.warn("Thumbnailing remote path, falling back on mpv.")
+ end
+ thumbnail_func = create_thumbnail_mpv
+ end
+
+ local generate_thumbnail_for_index = function(thumbnail_index)
+ -- Given a 1-based thumbnail index, generate a thumbnail for it based on the thumbnailer state
+ local thumb_idx = thumbnail_index - 1
+ msg.debug("Starting work on thumbnail", thumb_idx)
+
+ local thumbnail_path = thumb_state.thumbnail_template:format(thumb_idx)
+ -- Grab the "middle" of the thumbnail duration instead of the very start, and leave some margin in the end
+ local timestamp = math.min(file_duration - 0.25, (thumb_idx + 0.5) * thumb_state.thumbnail_delta)
+
+ mp.commandv("script-message", "mpv_thumbnail_script-progress", tostring(thumbnail_index))
+
+ -- The expected size (raw BGRA image)
+ local thumbnail_raw_size = (thumb_state.thumbnail_size.w * thumb_state.thumbnail_size.h * 4)
+
+ local need_thumbnail_generation = false
+
+ -- Check if the thumbnail already exists and is the correct size
+ local thumbnail_file = io.open(thumbnail_path, "rb")
+ if not thumbnail_file then
+ need_thumbnail_generation = true
+ else
+ local existing_thumbnail_filesize = thumbnail_file:seek("end")
+ if existing_thumbnail_filesize ~= thumbnail_raw_size then
+ -- Size doesn't match, so (re)generate
+ msg.warn("Thumbnail", thumb_idx, "did not match expected size, regenerating")
+ need_thumbnail_generation = true
+ end
+ thumbnail_file:close()
+ end
+
+ if need_thumbnail_generation then
+ local success
+ if thumb_state.storyboard then
+ -- get atlas and then split it into thumbnails
+ local rows = thumb_state.storyboard.rows
+ local cols = thumb_state.storyboard.cols
+ local div = thumb_state.storyboard.divisor
+ local atlas_idx = math.floor(thumb_idx * div /(cols*rows))
+ local atlas_path = thumb_state.thumbnail_template:format(atlas_idx) .. ".atlas"
+ local url = thumb_state.storyboard.fragments[atlas_idx+1].url
+ if not url then
+ url = thumb_state.storyboard.fragment_base_url .. "/" .. thumb_state.storyboard.fragments[atlas_idx+1].path
+ end
+ local ret = thumbnail_func(url, 0, { w=thumb_state.storyboard.scale, h=thumb_state.storyboard.scale }, atlas_path, { relative_scale=true })
+ success = check_output(ret, atlas_path, thumbnail_func == create_thumbnail_mpv)
+ if success then
+ split_atlas(atlas_path, cols, thumb_state.thumbnail_size, function(idx)
+ if (atlas_idx * cols * rows + idx) % div ~= 0 then
+ return nil
+ end
+ return thumb_state.thumbnail_template:format(math.floor((atlas_idx * cols * rows + idx) / div))
+ end)
+ os.remove(atlas_path)
+ end
+ else
+ local ret = thumbnail_func(file_path, timestamp, thumb_state.thumbnail_size, thumbnail_path, thumb_state.worker_extra)
+ success = check_output(ret, thumbnail_path, thumbnail_func == create_thumbnail_mpv)
+ end
+
+ if not success then
+ -- Killed by us, changing files, ignore
+ msg.debug("Changing files, subprocess killed")
+ return true
+ elseif not success then
+ -- Real failure
+ mp.osd_message("Thumbnailing failed, check console for details", 3.5)
+ return true
+ end
+ else
+ msg.debug("Thumbnail", thumb_idx, "already done!")
+ end
+
+ -- Verify thumbnail size
+ -- Sometimes ffmpeg will output an empty file when seeking to a "bad" section (usually the end)
+ thumbnail_file = io.open(thumbnail_path, "rb")
+
+ -- Bail if we can't read the file (it should really exist by now, we checked this in check_output!)
+ if not thumbnail_file then
+ msg.error("Thumbnail suddenly disappeared!")
+ return true
+ end
+
+ -- Check the size of the generated file
+ local thumbnail_file_size = thumbnail_file:seek("end")
+ thumbnail_file:close()
+
+ -- Check if the file is big enough
+ local missing_bytes = math.max(0, thumbnail_raw_size - thumbnail_file_size)
+ if missing_bytes > 0 then
+ msg.warn(("Thumbnail missing %d bytes (expected %d, had %d), padding %s"):format(
+ missing_bytes, thumbnail_raw_size, thumbnail_file_size, thumbnail_path
+ ))
+ -- Pad the file if it's missing content (eg. ffmpeg seek to file end)
+ thumbnail_file = io.open(thumbnail_path, "ab")
+ thumbnail_file:write(string.rep(string.char(0), missing_bytes))
+ thumbnail_file:close()
+ end
+
+ msg.debug("Finished work on thumbnail", thumb_idx)
+ mp.commandv("script-message", "mpv_thumbnail_script-ready", tostring(thumbnail_index), thumbnail_path)
+ end
+
+ msg.debug(("Generating %d thumbnails @ %dx%d for %q"):format(
+ #thumbnail_indexes,
+ thumb_state.thumbnail_size.w,
+ thumb_state.thumbnail_size.h,
+ file_path))
+
+ for i, thumbnail_index in ipairs(thumbnail_indexes) do
+ local bail = generate_thumbnail_for_index(thumbnail_index)
+ if bail then return end
+ end
+
+end
+
+-- Set up listeners and keybinds
+
+-- Job listener
+mp.register_script_message("mpv_thumbnail_script-job", do_worker_job)
+
+
+-- Register this worker with the master script
+local register_timer = nil
+local register_timeout = mp.get_time() + 1.5
+
+local register_function = function()
+ if mp.get_time() > register_timeout and register_timer then
+ msg.error("Thumbnail worker registering timed out")
+ register_timer:stop()
+ else
+ msg.debug("Announcing self to master...")
+ mp.commandv("script-message", "mpv_thumbnail_script-worker", mp.get_script_name())
+ end
+end
+
+register_timer = mp.add_periodic_timer(0.1, register_function)
+
+mp.register_script_message("mpv_thumbnail_script-slaved", function()
+ msg.debug("Successfully registered with master")
+ register_timer:stop()
+end)
diff --git a/.config/mpv/scripts/quality-menu.lua b/.config/mpv/scripts/quality-menu.lua
index 35949d5..c0e91ac 100644
--- a/.config/mpv/scripts/quality-menu.lua
+++ b/.config/mpv/scripts/quality-menu.lua
@@ -1,4 +1,4 @@
--- quality-menu 3.0.2 - 2023-Jan-10
+-- quality-menu 4.1.1 - 2023-Oct-22
-- https://github.com/christoph-heinrich/mpv-quality-menu
--
-- Change the stream video and audio quality on the fly.
@@ -13,22 +13,23 @@ local utils = require 'mp.utils'
local msg = require 'mp.msg'
local assdraw = require 'mp.assdraw'
local opt = require('mp.options')
+local script_name = mp.get_script_name()
local opts = {
--key bindings
- up_binding = "UP WHEEL_UP",
- down_binding = "DOWN WHEEL_DOWN",
- select_binding = "ENTER MBTN_LEFT",
- close_menu_binding = "ESC MBTN_RIGHT F Alt+f",
+ up_binding = 'UP WHEEL_UP',
+ down_binding = 'DOWN WHEEL_DOWN',
+ select_binding = 'ENTER MBTN_LEFT',
+ close_menu_binding = 'ESC MBTN_RIGHT',
--youtube-dl version(could be youtube-dl or yt-dlp, or something else)
- ytdl_ver = "yt-dlp",
+ ytdl_ver = 'yt-dlp',
--formatting / cursors
- selected_and_active = "▶ - ",
- selected_and_inactive = "● - ",
- unselected_and_active = "▷ - ",
- unselected_and_inactive = "○ - ",
+ selected_and_active = '▶ - ',
+ selected_and_inactive = '● - ',
+ unselected_and_active = '▷ - ',
+ unselected_and_inactive = '○ - ',
--font size scales by window, if false requires larger font and padding sizes
scale_playlist_by_window = true,
@@ -40,7 +41,7 @@ local opts = {
--these styles will be used for the whole playlist. More specific styling will need to be hacked in
--
--(a monospaced font is recommended but not required)
- style_ass_tags = "{\\fnmonospace\\fs25\\bord1}",
+ style_ass_tags = '{\\fnmonospace\\fs25\\bord1}',
-- Shift drawing coordinates. Required for mpv.net compatiblity
shift_x = 0,
@@ -60,24 +61,25 @@ local opts = {
--use youtube-dl to fetch a list of available formats (overrides quality_strings)
fetch_formats = true,
- --default menu entries
- quality_strings = [[
+ --list of ytdl-format strings to choose from
+ quality_strings_video = [[
[
- {"4320p" : "bestvideo[height<=?4320p]+bestaudio/best"},
- {"2160p" : "bestvideo[height<=?2160]+bestaudio/best"},
- {"1440p" : "bestvideo[height<=?1440]+bestaudio/best"},
- {"1080p" : "bestvideo[height<=?1080]+bestaudio/best"},
- {"720p" : "bestvideo[height<=?720]+bestaudio/best"},
- {"480p" : "bestvideo[height<=?480]+bestaudio/best"},
- {"360p" : "bestvideo[height<=?360]+bestaudio/best"},
- {"240p" : "bestvideo[height<=?240]+bestaudio/best"},
- {"144p" : "bestvideo[height<=?144]+bestaudio/best"}
+ {"4320p" : "bestvideo[height<=?4320p]"},
+ {"2160p" : "bestvideo[height<=?2160]"},
+ {"1440p" : "bestvideo[height<=?1440]"},
+ {"1080p" : "bestvideo[height<=?1080]"},
+ {"720p" : "bestvideo[height<=?720]"},
+ {"480p" : "bestvideo[height<=?480]"},
+ {"360p" : "bestvideo[height<=?360]"},
+ {"240p" : "bestvideo[height<=?240]"},
+ {"144p" : "bestvideo[height<=?144]"}
+ ]
+ ]],
+ quality_strings_audio = [[
+ [
+ {"default" : "bestaudio/best"}
]
]],
-
- --reset ytdl-format to the original format string when changing files (e.g. going to the next playlist entry)
- --if file was opened previously, reset to previously selected format
- reset_format = true,
--automatically fetch available formats when opening an url
fetch_on_start = true,
@@ -98,6 +100,10 @@ local opts = {
--which columns are shown in which order
--comma separated list, prefix column with "-" to align left
--
+ --for the uosc integration it is possible to split the text up into a title and a hint
+ --this is done by separating two columns with a "|" instead of a comma
+ --column order in the hint is reversed
+ --
--columns that might be useful are:
--resolution, width, height, fps, dynamic_range, tbr, vbr, abr, asr,
--filesize, filesize_approx, vcodec, acodec, ext, video_ext, audio_ext,
@@ -114,8 +120,8 @@ local opts = {
--
--Not all videos have all columns available.
--Be careful, misspelled columns simply won't be displayed, there is no error.
- columns_video = '-resolution,frame_rate,dynamic_range,language,bitrate_total,size,-codec_video,-codec_audio',
- columns_audio = 'audio_sample_rate,bitrate_total,size,language,-codec_audio',
+ columns_video = '-resolution,frame_rate,dynamic_range|language,bitrate_total,size,-codec_video,-codec_audio',
+ columns_audio = 'audio_sample_rate,bitrate_total|size,language,-codec_audio',
--columns used for sorting, see "columns_video" for available columns
--comma separated list, prefix column with "-" to reverse sorting order
@@ -125,19 +131,116 @@ local opts = {
sort_video = 'height,fps,tbr,size,format_id',
sort_audio = 'asr,tbr,size,format_id',
}
-opt.read_options(opts, "quality-menu")
-opts.quality_strings = utils.parse_json(opts.quality_strings)
+opt.read_options(opts, 'quality-menu')
+
+---@alias Format { properties: {[string]: string}, id: string, label?: string, title?: string, hint?: string }
+-- *_active_id == nil means unknown, *_active_id == '' means disabled
+---@alias Data { video_formats: Format[], audio_formats: Format[], video_active_id?: string, audio_active_id?: string }
+---@alias UIState { type: string, type_capitalized: string, name: string , to_other_type: UIState, to_fetching: UIState, to_menu: UIState, is_video: boolean }
+
+do
+ ---@param option_string string
+ ---@param option_name string
+ ---@return Format[]
+ local function parse_predefined(option_string, option_name)
+ ---@type {[string]: string}[]
+ local json, error = utils.parse_json(option_string)
+ if error then
+ msg.error('Error while parsing JSON of option ' .. option_name .. ': ' .. error)
+ return {}
+ end
+ ---@type Format[]
+ local formats = {}
+ for i, format in ipairs(json) do
+ local label, format_string = next(format)
+ formats[i] = {
+ label = label,
+ title = label,
+ id = format_string,
+ }
+ end
+ return formats
+ end
+
+ ---@type Data
+ opts.predefined_data = {
+ video_formats = parse_predefined(opts.quality_strings_video, 'quality_strings_video'),
+ audio_formats = parse_predefined(opts.quality_strings_audio, 'quality_strings_audio'),
+ video_active_id = nil,
+ audio_active_id = nil,
+ }
+end
opts.font_size = tonumber(opts.style_ass_tags:match('\\fs(%d+%.?%d*)')) or mp.get_property_number('osd-font-size') or 25
opts.curtain_opacity = math.max(math.min(opts.curtain_opacity, 1), 0)
+---@param input string
+---@param separator string
+---@return string[]
+local function string_split(input, separator)
+ if separator == nil then
+ separator = '%s'
+ end
+ local t = {}
+ for str in string.gmatch(input, '([^' .. separator .. ']+)') do
+ table.insert(t, str)
+ end
+ return t
+end
+
+---@param strings string[]
+---@return string[], boolean[]
+local function strip_minus(strings)
+ local stripped_list = {}
+ local had_minus = {}
+ for i, val in ipairs(strings) do
+ if string.sub(val, 1, 1) == '-' then
+ val = string.sub(val, 2)
+ had_minus[val] = true
+ end
+ stripped_list[i] = val
+ end
+ return stripped_list, had_minus
+end
+
+do
+ ---@param column_definition string
+ ---@return { all: string[], all_align_left: boolean[], title: string[], title_align_left: boolean[], hint?: string[] }
+ local function parse_columns(column_definition)
+ local columns, columns_align_left = strip_minus(string_split(column_definition, '|,'))
+ local title_hint = string_split(column_definition, '|')
+ local title, title_align_left = strip_minus(string_split(title_hint[1], ','))
+
+ local hint = nil
+ if title_hint[2] then
+ hint = strip_minus(string_split(title_hint[2], ','))
+ -- reverse column order
+ local n = #hint
+ for i = 1, n / 2 do
+ hint[i], hint[n - i + 1] = hint[n - i + 1], hint[i]
+ end
+ end
+ return {
+ all = columns, all_align_left = columns_align_left,
+ title = title, title_align_left = title_align_left,
+ hint = hint
+ }
+ end
+
+ ---@type { all: string[], all_align_left: boolean[], title: string[], title_align_left: boolean[], hint?: string[] }
+ ---@diagnostic disable-next-line: param-type-mismatch
+ opts.columns_video = parse_columns(opts.columns_video)
+ ---@type { all: string[], all_align_left: boolean[], title: string[], title_align_left: boolean[], hint?: string[] }
+ ---@diagnostic disable-next-line: param-type-mismatch
+ opts.columns_audio = parse_columns(opts.columns_audio)
+end
+
-- special thanks to reload.lua (https://github.com/4e6/mpv-reload/)
local function reload_resume()
- local playlist_pos = mp.get_property_number("playlist-pos")
- local reload_duration = mp.get_property_native("duration")
- local time_pos = mp.get_property("time-pos")
+ local reload_duration = mp.get_property_native('duration')
+ local time_pos = mp.get_property('time-pos')
- mp.set_property_number("playlist-pos", playlist_pos)
+ mp.command('playlist-play-index current')
-- Tries to determine live stream vs. pre-recorded VOD. VOD has non-zero
-- duration property. When reloading VOD, to keep the current time position
@@ -146,39 +249,107 @@ local function reload_resume()
-- That's the reason we don't pass the offset when reloading streams.
if reload_duration and reload_duration > 0 then
local function seeker()
- mp.commandv("seek", time_pos, "absolute")
+ mp.commandv('seek', time_pos, 'absolute+exact')
mp.unregister_event(seeker)
end
- mp.register_event("file-loaded", seeker)
+ mp.register_event('file-loaded', seeker)
end
end
+---@type { video_menu: UIState, audio_menu: UIState, video_fetching: UIState, audio_fetching: UIState }
+local states = {
+ video_menu = { type = 'video', type_capitalized = 'Video', name = 'video_menu', is_video = true },
+ audio_menu = { type = 'audio', type_capitalized = 'Audio', name = 'audio_menu', is_video = false },
+ video_fetching = { type = 'video', type_capitalized = 'Video', name = 'video_fetching', is_video = true },
+ audio_fetching = { type = 'audio', type_capitalized = 'Audio', name = 'audio_fetching', is_video = false },
+}
+states.video_menu.to_fetching = states.video_fetching
+states.video_menu.to_menu = states.video_menu
+states.video_menu.to_other_type = states.audio_menu
+states.audio_menu.to_fetching = states.audio_fetching
+states.audio_menu.to_menu = states.audio_menu
+states.audio_menu.to_other_type = states.video_menu
+states.video_fetching.to_fetching = states.video_fetching
+states.video_fetching.to_menu = states.video_menu
+states.video_fetching.to_other_type = states.audio_fetching
+states.audio_fetching.to_fetching = states.audio_fetching
+states.audio_fetching.to_menu = states.audio_menu
+states.audio_fetching.to_other_type = states.video_fetching
+
+---@type UIState | nil
+local open_menu_state = nil
+---@type string | nil
+local current_url = nil
+---@type {[string]: table}
+local currently_fetching = {}
+local destructor = nil
+
local ytdl = {
path = opts.ytdl_ver,
searched = false,
blacklisted = {}
}
+local menu_open
+local menu_close
+local video_formats_toggle
+local audio_formats_toggle
+
+local osd = mp.create_osd_overlay('ass-events')
+
+local function hide_osd()
+ -- workaround mpv bug, setting to hidden does not cause a redraw
+ -- https://github.com/mpv-player/mpv/issues/10227
+ osd.data = ''
+ osd:update()
+ osd.hidden = true
+ osd:update()
+end
+
+local osd_timer = mp.add_timeout(1, function() menu_close() end)
+osd_timer:kill()
+
+---@param message string
+---@param time number
+local function osd_message(message, time)
+ osd.res_x = 1280
+ osd.res_y = 720
+ osd.hidden = false
+ osd.data = message
+ osd:update()
+ osd_timer.timeout = time
+ osd_timer:kill()
+ osd_timer:resume()
+end
+
+---@alias FormatRaw {format_id: string, vcodec?: string, acodec?: string, filesize: integer?, filesize_approx?: integer, fps?: number, tbr?: number, vbr?: number, abr?: number, asr?: number}
+
+---@param json {formats: FormatRaw[], requested_formats: FormatRaw, requested_downloads: FormatRaw}
+---@return Data
local function process_json(json)
+ ---@param format FormatRaw
+ ---@return boolean
local function is_video(format)
- -- "none" means it is not a video
+ -- 'none' means it is not a video
-- nil means it is unknown
- return (opts.include_unknown or format.vcodec) and format.vcodec ~= "none"
+ return (opts.include_unknown or format.vcodec) and format.vcodec ~= 'none' or false
end
+ ---@param format FormatRaw
+ ---@return boolean
local function is_audio(format)
- return (opts.include_unknown or format.acodec) and format.acodec ~= "none"
+ return (opts.include_unknown or format.acodec) and format.acodec ~= 'none' or false
end
- local vfmt = nil
- local afmt = nil
- local requested_formats = json["requested_formats"] or json["requested_downloads"]
+ local requested_video = nil
+ local requested_audio = nil
+ local requested_formats = json.requested_formats or json.requested_downloads or {}
for _, format in ipairs(requested_formats) do
if is_video(format) then
- vfmt = format["format_id"]
+ requested_video = format.format_id
elseif is_audio(format) then
- afmt = format["format_id"]
+ requested_audio = format.format_id
end
end
@@ -196,6 +367,7 @@ local function process_json(json)
end
end
+ ---@param format FormatRaw
local function populate_special_fields(format)
format.size = format.filesize or format.filesize_approx
format.frame_rate = format.fps
@@ -211,33 +383,12 @@ local function process_json(json)
populate_special_fields(format)
end
- local function strip_minus(list)
- local stripped_list = {}
- local had_minus = {}
- for i, val in ipairs(list) do
- if string.sub(val, 1, 1) == "-" then
- val = string.sub(val, 2)
- had_minus[val] = true
- end
- stripped_list[i] = val
- end
- return stripped_list, had_minus
- end
-
- local function string_split(inputstr, sep)
- if sep == nil then
- sep = "%s"
- end
- local t = {}
- for str in string.gmatch(inputstr, "([^" .. sep .. "]+)") do
- table.insert(t, str)
- end
- return t
- end
-
local sort_video, reverse_video = strip_minus(string_split(opts.sort_video, ','))
local sort_audio, reverse_audio = strip_minus(string_split(opts.sort_audio, ','))
+ ---@param properties string[]
+ ---@param reverse {[string]: boolean}
+ ---@return fun(a: FormatRaw, b: FormatRaw): boolean
local function comp(properties, reverse)
return function(a, b)
for _, prop in ipairs(properties) do
@@ -262,11 +413,12 @@ local function process_json(json)
table.sort(audio_formats, comp(sort_audio, reverse_audio))
end
+ ---@param size integer
+ ---@return string
local function scale_filesize(size)
if size == nil then
- return ""
+ return ''
end
- size = tonumber(size)
local counter = 0
while size > 1024 do
@@ -274,424 +426,303 @@ local function process_json(json)
counter = counter + 1
end
- if counter >= 3 then return string.format("%.1fGiB", size)
- elseif counter >= 2 then return string.format("%.1fMiB", size)
- elseif counter >= 1 then return string.format("%.1fKiB", size)
- else return string.format("%.1fB ", size)
+ if counter >= 3 then return string.format('%.1fGiB', size)
+ elseif counter >= 2 then return string.format('%.1fMiB', size)
+ elseif counter >= 1 then return string.format('%.1fKiB', size)
+ else return string.format('%.1fB ', size)
end
end
- local function scale_bitrate(br)
- if br == nil then
- return ""
+ ---@param bitrate integer
+ ---@return string
+ local function scale_bitrate(bitrate)
+ if bitrate == nil then
+ return ''
end
- br = tonumber(br)
local counter = 0
- while br > 1000 do
- br = br / 1000
+ while bitrate > 1000 do
+ bitrate = bitrate / 1000
counter = counter + 1
end
- if counter >= 2 then return string.format("%.1fGbps", br)
- elseif counter >= 1 then return string.format("%.1fMbps", br)
- else return string.format("%.1fKbps", br)
+ if counter >= 2 then return string.format('%.1fGbps', bitrate)
+ elseif counter >= 1 then return string.format('%.1fMbps', bitrate)
+ else return string.format('%.1fKbps', bitrate)
end
end
+ ---@param format FormatRaw
local function format_special_fields(format)
- local size_prefix = not format.filesize and format.filesize_approx and "~" or ""
+ local size_prefix = not format.filesize and format.filesize_approx and '~' or ''
+ ---@diagnostic disable-next-line: param-type-mismatch
format.size = (size_prefix) .. scale_filesize(format.size)
- format.frame_rate = format.fps and format.fps .. "fps" or ""
+ format.frame_rate = format.fps and format.fps .. 'fps' or ''
format.bitrate_total = scale_bitrate(format.tbr)
format.bitrate_video = scale_bitrate(format.vbr)
format.bitrate_audio = scale_bitrate(format.abr)
- format.codec_video = format.vcodec == nil and "unknown" or format.vcodec == "none" and "" or format.vcodec
- format.codec_audio = format.acodec == nil and "unknown" or format.acodec == "none" and "" or format.acodec
- format.audio_sample_rate = format.asr and tostring(format.asr) .. "Hz" or ""
+ format.codec_video = format.vcodec == nil and 'unknown' or format.vcodec == 'none' and '' or format.vcodec
+ format.codec_audio = format.acodec == nil and 'unknown' or format.acodec == 'none' and '' or format.acodec
+ format.audio_sample_rate = format.asr and tostring(format.asr) .. 'Hz' or ''
end
for _, format in ipairs(all_formats) do
format_special_fields(format)
end
- local function format_table(formats, columns)
- local function calc_shown_columns()
- local display_col = {}
- local column_widths = {}
- local column_values = {}
- local columns, column_align_left = strip_minus(columns)
-
- for _, format in pairs(formats) do
- for col, prop in ipairs(columns) do
- local label = tostring(format[prop] or "")
- format[prop] = label
-
- if not column_widths[col] or column_widths[col] < label:len() then
- column_widths[col] = label:len()
- end
-
- column_values[col] = column_values[col] or label
- display_col[col] = display_col[col] or (column_values[col] ~= label)
- end
- end
-
- local show_columns = {}
- for i, width in ipairs(column_widths) do
- if width > 0 and not opts.hide_identical_columns or display_col[i] then
- local prop = columns[i]
- show_columns[#show_columns + 1] = {
- prop = prop,
- width = width,
- align_left = column_align_left[prop]
- }
- end
- end
- return show_columns
- end
-
- local show_columns = calc_shown_columns()
-
- local spacing = 2
- local res = {}
- for _, f in ipairs(formats) do
- local row = ''
- for i, column in ipairs(show_columns) do
- -- lua errors out with width > 99 ("invalid conversion specification")
- local width = math.min(column.width * (column.align_left and -1 or 1), 99)
- row = row .. (i > 1 and string.format('%' .. spacing .. 's', '') or '')
- .. string.format('%' .. width .. 's', f[column.prop] or "")
+ ---@param raw_formats { [string]: any }
+ ---@param properties string[]
+ ---@return Format[]
+ local function convert_to_format(raw_formats, properties)
+ ---@type Format[]
+ local formats = {}
+ for i, format in ipairs(raw_formats) do
+ local props = {}
+ for _, prop in ipairs(properties) do
+ props[prop] = tostring(format[prop] or '')
end
- res[#res + 1] = { label = row:gsub('%s+$', ''), format = f.format_id }
+ formats[i] = { properties = props, id = format.format_id }
end
- return res
+ return formats
end
- local columns_video = string_split(opts.columns_video, ',')
- local columns_audio = string_split(opts.columns_audio, ',')
- local vres = format_table(video_formats, columns_video)
- local ares = format_table(audio_formats, columns_audio)
- return vres, ares, vfmt, afmt
+ return {
+ video_formats = convert_to_format(video_formats, opts.columns_video.all),
+ audio_formats = convert_to_format(audio_formats, opts.columns_audio.all),
+ video_active_id = requested_video,
+ audio_active_id = requested_audio,
+ }
end
+---@return string | nil
local function get_url()
- local path = mp.get_property("path")
+ local path = mp.get_property('path')
if not path then return nil end
- path = string.gsub(path, "ytdl://", "") -- Strip possible ytdl:// prefix.
+ path = path:gsub('ytdl://', '') -- Strip possible ytdl:// prefix.
- local function is_url(s)
+ ---@param str string
+ ---@return boolean
+ local function is_url(str)
-- adapted the regex from
-- https://stackoverflow.com/questions/3809401/what-is-a-good-regular-expression-to-match-a-url
return nil ~=
- string.match(path,
- "^[%w]-://[-a-zA-Z0-9@:%._\\+~#=]+%." ..
- "[a-zA-Z0-9()][a-zA-Z0-9()]?[a-zA-Z0-9()]?[a-zA-Z0-9()]?[a-zA-Z0-9()]?[a-zA-Z0-9()]?" ..
- "[-a-zA-Z0-9()@:%_\\+.~#?&/=]*")
+ str:match(
+ '^[%w]-://[-a-zA-Z0-9@:%._\\+~#=]+%.' ..
+ '[a-zA-Z0-9()][a-zA-Z0-9()]?[a-zA-Z0-9()]?[a-zA-Z0-9()]?[a-zA-Z0-9()]?[a-zA-Z0-9()]?' ..
+ '[-a-zA-Z0-9()@:%_\\+.~#?&/=]*')
end
return is_url(path) and path or nil
end
-local uosc = false
+local uosc_available = false
+---@type { [string]: Data }
local url_data = {}
-local function uosc_set_format_counts()
- if not uosc then return end
- local new_path = get_url()
- if not new_path then return end
+local function uosc_set_format_counts()
+ if not uosc_available then return end
- local data = url_data[new_path]
+ local data = url_data[current_url]
if data then
- mp.commandv('script-message-to', 'uosc', 'set', 'vformats', #data.voptions)
- mp.commandv('script-message-to', 'uosc', 'set', 'aformats', #data.aoptions)
+ mp.commandv('script-message-to', 'uosc', 'set', 'vformats', #data.video_formats)
+ mp.commandv('script-message-to', 'uosc', 'set', 'aformats', #data.audio_formats)
else
mp.commandv('script-message-to', 'uosc', 'set', 'vformats', 0)
mp.commandv('script-message-to', 'uosc', 'set', 'aformats', 0)
end
end
-local function process_json_string(url, json)
- local json, err = utils.parse_json(json)
+---@param json string
+---@return Data | nil
+local function process_json_string(json)
+ local json_table, err = utils.parse_json(json)
- if (json == nil) then
- mp.osd_message("fetching formats failed...", 2)
- if err == nil then err = "unexpected error occurred" end
- msg.error("failed to parse JSON data: " .. err)
+ if (json_table == nil) then
+ osd_message('fetching formats failed...', 2)
+ if err == nil then err = 'unexpected error occurred' end
+ msg.error('failed to parse JSON data: ' .. err)
return
end
- if json.formats == nil then
+ if json_table.formats == nil then
return
end
- local vres, ares, vfmt, afmt = process_json(json)
- url_data[url] = { voptions = vres, aoptions = ares, vfmt = vfmt, afmt = afmt }
- uosc_set_format_counts()
- return vres, ares, vfmt, afmt
+ return process_json(json_table)
end
+---@param url string
local function download_formats(url)
+ if currently_fetching[url] then return end
- if opts.fetch_on_start and not opts.start_with_menu then
- msg.info("fetching available formats with youtube-dl...")
- else
- mp.osd_message("fetching available formats with youtube-dl...", 60)
- end
+ msg.info('fetching available formats...')
if not (ytdl.searched) then
local ytdl_mcd = mp.find_config_file(opts.ytdl_ver)
if not (ytdl_mcd == nil) then
- msg.verbose("found youtube-dl at: " .. ytdl_mcd)
+ msg.verbose('found ytdl at: ' .. ytdl_mcd)
ytdl.path = ytdl_mcd
end
ytdl.searched = true
end
- local function exec(args)
- msg.debug("Running: " .. table.concat(args, " "))
- local ret = mp.command_native({
- name = "subprocess",
- args = args,
- capture_stdout = true,
- capture_stderr = true
- })
- return ret.status, ret.stdout, ret, ret.killed_by_us
+ local ytdl_format = mp.get_property('ytdl-format')
+ local raw_options = mp.get_property_native('ytdl-raw-options')
+ local command = { ytdl.path, '--no-warnings', '--no-playlist', '-J' }
+ if ytdl_format and #ytdl_format > 0 then
+ command[#command + 1] = '-f'
+ command[#command + 1] = ytdl_format
end
-
- local function check_version(ytdl_path)
- local command = {
- name = "subprocess",
- capture_stdout = true,
- args = { ytdl_path, "--version" }
- }
- local version_string = mp.command_native(command).stdout
- local year, month, day = string.match(version_string, "(%d+).(%d+).(%d+)")
-
- -- sanity check
- if (tonumber(year) < 2000) or (tonumber(month) > 12) or
- (tonumber(day) > 31) then
- return
+ for param, arg in pairs(raw_options) do
+ command[#command + 1] = '--' .. param
+ if #arg > 0 then
+ command[#command + 1] = arg
end
- local version_ts = os.time { year = year, month = month, day = day }
- if (os.difftime(os.time(), version_ts) > 60 * 60 * 24 * 90) then
- msg.warn("It appears that your youtube-dl version is severely out of date.")
- end
- end
-
- local ytdl_format = mp.get_property("ytdl-format")
- local command = nil
- if (ytdl_format == nil or ytdl_format == "") then
- command = { ytdl.path, "--no-warnings", "--no-playlist", "-J", url }
- else
- command = { ytdl.path, "--no-warnings", "--no-playlist", "-J", "-f", ytdl_format, url }
- end
-
- msg.verbose("calling youtube-dl with command: " .. table.concat(command, " "))
-
- local es, json, result, aborted = exec(command)
-
- if aborted then
- return
- end
-
- if (es ~= 0) or (json == "") then
- json = nil
end
+ if opts.ytdl_ver == 'yt-dlp' then command[#command + 1] = '--no-match-filter' end
+ command[#command + 1] = '--'
+ command[#command + 1] = url
+
+ msg.verbose('calling ytdl with command: ' .. table.concat(command, ' '))
+
+ --- result.status is exit status
+ --- result.error_string can be empty string, 'killed' or 'init'
+ ---@param success boolean
+ ---@param result { status: integer, stdout: string, stderr: string, error_string: string , killed_by_us: boolean }
+ ---@param error string | nil
+ local function callback(success, result, error)
+ currently_fetching[url] = nil
+ if result.killed_by_us then return end
+ if result.status < 0 or result.stdout == '' or result.error_string ~= '' then
+ osd_message('fetching formats failed...', 2)
+ msg.verbose('status:', result.status)
+ msg.verbose('reason:', result.error_string)
+ msg.verbose('stdout:', result.stdout)
+ msg.verbose('stderr:', result.stderr)
+
+ -- trim our stderr to avoid spurious newlines
+ local ytdl_err = result.stderr:gsub('^%s*(.-)%s*$', '%1')
+ msg.error(ytdl_err)
+ local err = 'ytdl failed: '
+ if result.error_string and result.error_string == 'init' then
+ err = err .. 'not found or not enough permissions'
+ elseif not result.killed_by_us then
+ err = err .. 'unexpected error occurred'
+ else
+ err = string.format('%s returned "%d"', err, result.status)
+ end
+ msg.error(err)
+ if string.find(ytdl_err, 'yt%-dl%.org/bug') then
+ -- check version
+ local version_command = {
+ name = 'subprocess',
+ capture_stdout = true,
+ args = { ytdl.path, '--version' }
+ }
+ local version_string = mp.command_native(version_command).stdout
+ local year, month, day = string.match(version_string, '(%d+).(%d+).(%d+)')
- if (json == nil) then
- mp.osd_message("fetching formats failed...", 2)
- msg.verbose("status:", es)
- msg.verbose("reason:", result.error_string)
- msg.verbose("stdout:", result.stdout)
- msg.verbose("stderr:", result.stderr)
-
- -- trim our stderr to avoid spurious newlines
- local ytdl_err = result.stderr:gsub("^%s*(.-)%s*$", "%1")
- msg.error(ytdl_err)
- local err = "youtube-dl failed: "
- if result.error_string and result.error_string == "init" then
- err = err .. "not found or not enough permissions"
- elseif not result.killed_by_us then
- err = err .. "unexpected error occurred"
- else
- err = string.format("%s returned '%d'", err, es)
- end
- msg.error(err)
- if string.find(ytdl_err, "yt%-dl%.org/bug") then
- check_version(ytdl.path)
+ -- sanity check
+ if (tonumber(year) < 2000) or (tonumber(month) > 12) or
+ (tonumber(day) > 31) then
+ return
+ end
+ local version_ts = os.time { year = year, month = month, day = day }
+ if (os.difftime(os.time(), version_ts) > 60 * 60 * 24 * 90) then
+ msg.warn('It appears that your ytdl version is severely out of date.')
+ end
+ end
+ return
end
- return
- end
-
- msg.verbose("youtube-dl succeeded!")
- mp.osd_message("", 0)
-
- local vres, ares, vfmt, afmt = process_json_string(url, json)
- return vres, ares, vfmt, afmt
-end
-local function send_formats_to(type, url, script_name, options, format_id)
- mp.commandv('script-message-to', script_name, type .. '_formats',
- url, utils.format_json(options or {}), format_id or '')
-end
-
-local queue_callback_video = {}
-local queue_callback_audio = {}
-local function get_formats()
-
- local url = get_url()
- if url == nil then
- return
- end
-
- if url_data[url] then
- local data = url_data[url]
- return data.voptions, data.aoptions, data.vfmt, data.afmt, url
- end
+ msg.verbose('ytdl succeeded!')
+ local data = process_json_string(result.stdout)
+ url_data[url] = data
+ uosc_set_format_counts()
- if opts.fetch_formats == false then
- local vres = {}
- for i, v in ipairs(opts.quality_strings) do
- for k, v2 in pairs(v) do
- vres[i] = { label = k, format = v2 }
- end
+ if not data then return end
+ if open_menu_state and open_menu_state == open_menu_state.to_fetching and url == current_url then
+ menu_open(open_menu_state)
end
- url_data[url] = { voptions = vres, aoptions = {}, vfmt = nil, afmt = nil }
- return vres, {}, nil, nil, url
end
- local vres, ares, vfmt, afmt = download_formats(url)
-
- for _, script_name in ipairs(queue_callback_video[url] or {}) do
- send_formats_to('video', url, script_name, vres, vfmt)
- end
- for _, script_name in ipairs(queue_callback_audio[url] or {}) do
- send_formats_to('audio', url, script_name, ares, afmt)
- end
+ currently_fetching[url] = mp.command_native_async({
+ name = 'subprocess',
+ args = command,
+ capture_stdout = true,
+ capture_stderr = true
+ }, callback)
+end
- queue_callback_video[url] = nil
- queue_callback_audio[url] = nil
- return vres, ares, vfmt, afmt, url
+---Unknown format falls back on highest ranked format if possible
+---@param id string | nil
+---@param formats Format[]
+---@return string
+local function sanitize_format_id(id, formats)
+ return id or (formats[1] or {}).id or ''
end
-local function format_string(vfmt, afmt)
- if vfmt and afmt then
- return vfmt .. "+" .. afmt
- elseif vfmt then
- return vfmt
- elseif afmt then
- return afmt
+---@param video_id string
+---@param audio_id string
+---@return string
+local function format_string(video_id, audio_id)
+ if #video_id > 0 and #audio_id > 0 then
+ return video_id .. '+' .. audio_id
+ elseif #video_id > 0 then
+ return video_id
+ elseif #audio_id > 0 then
+ return audio_id
else
- return ""
+ return ''
end
end
-local function set_format(url, vfmt, afmt)
- if (url_data[url].vfmt ~= vfmt or url_data[url].afmt ~= afmt) then
- url_data[url].afmt = afmt
- url_data[url].vfmt = vfmt
- if url == mp.get_property("path") then
- mp.set_property("ytdl-format", format_string(vfmt, afmt))
- reload_resume()
- end
+---@param url string
+---@param video_format string
+---@param audio_format string
+local function set_format(url, video_format, audio_format)
+ if (url_data[url].video_active_id ~= video_format or url_data[url].audio_active_id ~= audio_format) then
+ url_data[url].video_active_id = video_format
+ url_data[url].audio_active_id = audio_format
+ if url == mp.get_property('path') then reload_resume() end
end
end
-local destroyer = nil
-local function show_menu(isvideo)
-
- if destroyer then
- destroyer()
- end
-
- local voptions, aoptions, vfmt, afmt, url = get_formats()
-
- local options
- local fmt
- if isvideo then
- options = voptions
- fmt = vfmt
- else
- options = aoptions
- fmt = afmt
- end
-
- if options == nil then
- if uosc then
- if isvideo then
- mp.commandv('script-binding', 'uosc/video')
- else
- mp.commandv('script-binding', 'uosc/audio')
- end
- end
-
- return
- end
-
- msg.verbose("current ytdl-format: " .. format_string(vfmt, afmt))
-
+---@param formats Format[]
+---@param active_format string | nil
+---@param menu_type UIState
+local function text_menu_open(formats, active_format, menu_type)
local active = 0
local selected = 1
--set the cursor to the current format
- if fmt then
- for i, v in ipairs(options) do
- if v.format == fmt then
- active = i
- selected = active
- break
- end
+ for i, format in ipairs(formats) do
+ if format.id == active_format then
+ active = i
+ selected = active
+ break
end
- else
- active = #options + 1
- selected = active
end
-
- if uosc then
- local menu = {
- title = isvideo and 'Video Formats' or 'Audio Formats',
- items = {},
- type = (isvideo and 'video' or 'audio') .. '_formats',
- }
- for i, option in ipairs(options) do
- menu.items[i] = {
- title = option.label,
- active = i == active,
- value = {
- 'script-message-to',
- 'quality_menu',
- (isvideo and 'video' or 'audio') .. '-format-set',
- url,
- option.format
- }
- }
- end
- menu.items[#menu.items + 1] = {
- title = 'None',
- value = {
- 'script-message-to',
- 'quality_menu',
- (isvideo and 'video' or 'audio') .. '-format-set',
- url
- }
- }
- local json = utils.format_json(menu)
- mp.commandv('script-message-to', 'uosc', 'open-menu', json)
- return
+ if active_format == '' then
+ active = #formats + 1
+ selected = active
end
+ ---@param i integer
+ ---@return string
local function choose_prefix(i)
if i == selected and i == active then return opts.selected_and_active
elseif i == selected then return opts.selected_and_inactive end
if i ~= selected and i == active then return opts.unselected_and_active
elseif i ~= selected then return opts.unselected_and_inactive end
- return "> " --shouldn't get here.
+ return '> ' --shouldn't get here.
end
local width, height
local margin_top, margin_bottom = 0, 0
- local num_options = #options + 1
+ local num_options = #formats > 0 and #formats + 2 or 1
+ ---@return integer
local function get_scrolled_lines()
local output_height = height - opts.text_padding_y * 2 - margin_top * height - margin_bottom * height
local screen_lines = math.max(math.floor(output_height / opts.font_size), 1)
@@ -717,228 +748,542 @@ local function show_menu(isvideo)
local clip_top = math.floor(margin_top * height + 0.5)
local clip_bottom = math.floor((1 - margin_bottom) * height + 0.5)
local clipping_coordinates = '0,' .. clip_top .. ',' .. width .. ',' .. clip_bottom
- ass:append(opts.style_ass_tags .. '{\\q2\\clip(' .. clipping_coordinates .. ')}')
+ ass:append('{\\rDefault\\q2\\clip(' .. clipping_coordinates .. ')}' .. opts.style_ass_tags)
- if #options > 0 then
- for i, v in ipairs(options) do
- ass:append(choose_prefix(i) .. v.label .. "\\N")
+ if #formats > 0 then
+ for i, format in ipairs(formats) do
+ ass:append(choose_prefix(i) .. format.label .. '\\N')
end
- ass:append(choose_prefix(#options + 1) .. "None")
+ ass:append(choose_prefix(#formats + 1) .. 'Disabled\\N')
+ ass:append(choose_prefix(#formats + 2) .. menu_type.to_other_type.type_capitalized .. ' menu')
else
- ass:append("no formats found")
+ ass:append('no formats found\\N')
+ ass:append(opts.selected_and_inactive .. menu_type.to_other_type.type_capitalized .. ' menu')
end
- mp.set_osd_ass(width, height, ass.text)
+ osd.data = ass.text
+ osd:update()
end
local function update_dimensions()
local _, h, aspect = mp.get_osd_size()
if opts.scale_playlist_by_window then h = 720 end
height = h
- width = h * aspect
+ width = height * aspect
+ osd.res_y = height
+ osd.res_x = width
draw_menu()
end
- local function update_margins()
- local shared_props = mp.get_property_native('shared-script-properties')
- local val = shared_props['osc-margins']
- if val then
- -- formatted as "%f,%f,%f,%f" with left, right, top, bottom, each
- -- value being the border size as ratio of the window size (0.0-1.0)
- local vals = {}
- for v in string.gmatch(val, "[^,]+") do
- vals[#vals + 1] = tonumber(v)
+ local update_margins;
+ if utils.shared_script_property_set then
+ update_margins = function()
+ local shared_props = mp.get_property_native('shared-script-properties')
+ local val = shared_props['osc-margins']
+ if val then
+ -- formatted as '%f,%f,%f,%f' with left, right, top, bottom, each
+ -- value being the border size as ratio of the window size (0.0-1.0)
+ local vals = {}
+ for v in string.gmatch(val, '[^,]+') do
+ vals[#vals + 1] = tonumber(v)
+ end
+ margin_top = vals[3] -- top
+ margin_bottom = vals[4] -- bottom
+ else
+ margin_top = 0
+ margin_bottom = 0
end
- margin_top = vals[3] -- top
- margin_bottom = vals[4] -- bottom
- else
- margin_top = 0
- margin_bottom = 0
+ draw_menu()
end
- draw_menu()
+ mp.observe_property('shared-script-properties', 'native', update_margins)
+ else
+ update_margins = function(_, val)
+ if not val then
+ val = mp.get_property_native('user-data/osc/margins')
+ end
+ if val then
+ margin_top = val.t
+ margin_bottom = val.b
+ else
+ margin_top = 0
+ margin_bottom = 0
+ end
+ draw_menu()
+ end
+ mp.observe_property('user-data/osc/margins', 'native', update_margins)
end
update_dimensions()
update_margins()
mp.observe_property('osd-dimensions', 'native', update_dimensions)
- mp.observe_property('shared-script-properties', 'native', update_margins)
-
- local timeout = nil
- local function selected_move(amt)
- selected = selected + amt
+ ---@param amount integer
+ local function selected_move(amount)
+ selected = selected + amount
if selected < 1 then selected = num_options
elseif selected > num_options then selected = 1 end
- if timeout then
- timeout:kill()
- timeout:resume()
+ if osd_timer then
+ osd_timer:kill()
+ osd_timer:resume()
end
draw_menu()
end
+ ---@param keys string | nil
+ ---@param name string
+ ---@param func function
+ ---@param opts table | nil
local function bind_keys(keys, name, func, opts)
if not keys then
mp.add_forced_key_binding(keys, name, func, opts)
return
end
local i = 1
- for key in keys:gmatch("[^%s]+") do
+ for key in keys:gmatch('[^%s]+') do
local prefix = i == 1 and '' or i
mp.add_forced_key_binding(key, name .. prefix, func, opts)
i = i + 1
end
end
+ ---@param keys string | nil
+ ---@param name string
local function unbind_keys(keys, name)
if not keys then
mp.remove_key_binding(name)
return
end
local i = 1
- for key in keys:gmatch("[^%s]+") do
+ for key in keys:gmatch('[^%s]+') do
local prefix = i == 1 and '' or i
mp.remove_key_binding(name .. prefix)
i = i + 1
end
end
- local function destroy()
- if timeout then
- timeout:kill()
- end
- mp.set_osd_ass(0, 0, "")
- unbind_keys(opts.up_binding, "move_up")
- unbind_keys(opts.down_binding, "move_down")
- unbind_keys(opts.select_binding, "select")
- unbind_keys(opts.close_menu_binding, "close")
+ -- make sure observers are cleaned up
+ if open_menu_state and open_menu_state == open_menu_state.to_menu and destructor then destructor() end
+ destructor = function()
+ unbind_keys(opts.up_binding, 'move_up')
+ unbind_keys(opts.down_binding, 'move_down')
+ unbind_keys(opts.select_binding, 'select')
+ unbind_keys(opts.close_menu_binding, 'close')
mp.unobserve_property(update_dimensions)
mp.unobserve_property(update_margins)
- destroyer = nil
end
+ osd_timer:kill()
if opts.menu_timeout > 0 then
- timeout = mp.add_periodic_timer(opts.menu_timeout, destroy)
+ osd_timer.timeout = opts.menu_timeout
+ osd_timer:resume()
end
- destroyer = destroy
- bind_keys(opts.up_binding, "move_up", function() selected_move(-1) end, { repeatable = true })
- bind_keys(opts.down_binding, "move_down", function() selected_move(1) end, { repeatable = true })
- if #options > 0 then
- bind_keys(opts.select_binding, "select", function()
- destroy()
- if selected == active then return end
+ bind_keys(opts.up_binding, 'move_up', function() selected_move( -1) end, { repeatable = true })
+ bind_keys(opts.down_binding, 'move_down', function() selected_move(1) end, { repeatable = true })
+ bind_keys(opts.close_menu_binding, 'close', menu_close)
+ bind_keys(opts.select_binding, 'select', function()
+ if selected == num_options then
+ mp.unobserve_property(update_dimensions)
+ mp.unobserve_property(update_margins)
+ if menu_type.is_video then audio_formats_toggle()
+ else video_formats_toggle() end
+ return
+ end
+ menu_close()
+ if selected == active then return end
+ if current_url == nil then return end
+
+ local video_id, audio_id
+ local id = formats[selected] and formats[selected].id or ''
+ local data = url_data[current_url]
+ if menu_type.is_video then
+ video_id = id
+ audio_id = sanitize_format_id(data.audio_active_id, data.audio_formats)
+ else
+ video_id = sanitize_format_id(data.video_active_id, data.video_formats)
+ audio_id = id
+ end
+ set_format(current_url, video_id, audio_id)
+ end)
- fmt = options[selected] and options[selected].format or nil
- if isvideo then
- vfmt = fmt
- else
- afmt = fmt
+ osd.hidden = false
+ draw_menu()
+end
+
+---@param menu table
+---@param menu_type UIState
+local function uosc_show_menu(menu, menu_type)
+ local json = utils.format_json(menu)
+ -- always using update wouldn't work, because it doesn't support the on_close command
+ -- therefore opening a different kind requires `open-menu`
+ -- while updating the same kind requires `update-menu`
+ if open_menu_state == menu_type then mp.commandv('script-message-to', 'uosc', 'update-menu', json)
+ else mp.commandv('script-message-to', 'uosc', 'open-menu', json) end
+end
+
+---@param formats Format[]
+---@param active_format string | nil
+---@param menu_type UIState
+local function uosc_menu_open(formats, active_format, menu_type)
+ local menu = {
+ title = menu_type.type_capitalized .. ' Formats',
+ items = {},
+ type = 'quality-menu-' .. menu_type.name,
+ keep_open = true,
+ on_close = {
+ 'script-message-to',
+ script_name,
+ 'uosc-menu-closed',
+ menu_type.name,
+ }
+ }
+
+ menu.items[#menu.items + 1] = {
+ title = menu_type.to_other_type.type_capitalized,
+ italic = true,
+ bold = true,
+ hint = 'open menu',
+ value = {
+ 'script-message-to',
+ script_name,
+ menu_type.to_other_type.type .. '_formats_toggle',
+ },
+ }
+ menu.items[#menu.items + 1] = {
+ title = 'Disabled',
+ italic = true,
+ muted = true,
+ hint = '—',
+ active = active_format == '',
+ value = {
+ 'script-message-to',
+ script_name,
+ menu_type.type .. '-format-set',
+ current_url,
+ '',
+ }
+ }
+
+ for _, format in ipairs(formats) do
+ menu.items[#menu.items + 1] = {
+ title = format.title,
+ hint = format.hint,
+ active = format.id == active_format,
+ value = {
+ 'script-message-to',
+ script_name,
+ menu_type.type .. '-format-set',
+ current_url,
+ format.id,
+ }
+ }
+ end
+
+ uosc_show_menu(menu, menu_type)
+ destructor = function()
+ mp.commandv('script-message-to', 'uosc', 'close-menu', menu.type)
+ end
+end
+
+---Check if property is same for all formats
+---@param formats Format[]
+---@param properties string[]
+---@return { [string]: boolean }
+local function identical_for_all(formats, properties)
+ ---@param formats Format[]
+ ---@param prop string
+ ---@return boolean
+ local function all_formats_same_value(formats, prop)
+ local first_value = nil
+ for _, format in ipairs(formats) do
+ first_value = first_value or format.properties[prop]
+ if format.properties[prop] ~= first_value then return false end
+ end
+ return true
+ end
+
+ local identical_props = {}
+ for _, prop in ipairs(properties) do
+ identical_props[prop] = all_formats_same_value(formats, prop)
+ end
+ return identical_props
+end
+
+---@param formats Format[]
+---@param columns string[]
+---@param column_align_left boolean[]
+---@return string[]
+local function format_table(formats, columns, column_align_left)
+ local column_widths = {}
+ for _, format in pairs(formats) do
+ for col, prop in ipairs(columns) do
+ local width = format.properties[prop]:len()
+ if not column_widths[col] or column_widths[col] < width then
+ column_widths[col] = width
end
- set_format(url, vfmt, afmt)
- end)
+ end
end
- bind_keys(opts.close_menu_binding, "close", destroy) --close menu using ESC
- mp.osd_message("", 0)
- draw_menu()
+
+ local identical_columns = identical_for_all(formats, columns)
+
+ local show_columns = {}
+ for i, width in ipairs(column_widths) do
+ local prop = columns[i]
+ if width > 0 and not (opts.hide_identical_columns and identical_columns[prop]) then
+ show_columns[#show_columns + 1] = {
+ prop = prop,
+ width = width,
+ align_left = column_align_left[prop]
+ }
+ end
+ end
+
+ local spacing = 2
+ ---@type string[]
+ local rows = {}
+ for i, format in ipairs(formats) do
+ local row = {}
+ for j, column in ipairs(show_columns) do
+ -- lua errors out with width > 99 ("invalid conversion specification")
+ local width = math.min(column.width * (column.align_left and -1 or 1), 99)
+ row[j] = string.format('%' .. width .. 's', format.properties[column.prop] or '')
+ end
+ rows[i] = table.concat(row, string.format('%' .. spacing .. 's', '')):gsub('%s+$', '')
+ end
+ return rows
end
-local ui_callback = {}
+---@param formats Format[]
+---@param columns string[]
+---@return string[]
+local function format_csv(formats, columns)
+ local identical_props = identical_for_all(formats, columns)
+ local hints = {}
+ for i, format in ipairs(formats) do
+ local row = {}
+ for _, prop in ipairs(columns) do
+ local val = format.properties[prop]
+ if #val > 0 and not (opts.hide_identical_columns and identical_props[prop]) then
+ row[#row + 1] = val
+ end
+ end
+ hints[i] = table.concat(row, ', ')
+ end
+ return hints
+end
+
+---@param formats Format[]
+---@param menu_type UIState
+local function ensure_menu_data_filled(formats, menu_type)
+ if uosc_available then
+ if formats[1] and formats[1].title == nil then
+ local columns = menu_type.is_video and opts.columns_video or opts.columns_audio
+ local titles = format_table(formats, columns.title, columns.title_align_left)
+
+ local hints = {}
+ if columns.hint then
+ hints = format_csv(formats, columns.hint)
+ end
-local function video_formats_toggle()
- if #ui_callback > 0 then
- for _, name in ipairs(ui_callback) do
- mp.commandv('script-message-to', name, 'video-formats-menu')
+ for i, format in ipairs(formats) do
+ format.title = titles[i]
+ format.hint = hints[i]
+ end
end
else
- show_menu(true)
+ if formats[1] and formats[1].label == nil then
+ local columns = menu_type.is_video and opts.columns_video or opts.columns_audio
+ local labels = format_table(formats, columns.all, columns.all_align_left)
+ for i, format in ipairs(formats) do format.label = labels[i] end
+ end
end
end
-local function audio_formats_toggle()
- if #ui_callback > 0 then
- for _, name in ipairs(ui_callback) do
- mp.commandv('script-message-to', name, 'audio-formats-menu')
+---@param menu_type UIState
+local function loading_message(menu_type)
+ menu_type = menu_type.to_fetching
+ if uosc_available then
+ if open_menu_state and open_menu_state == menu_type then return end
+ local menu = {
+ title = menu_type.type_capitalized .. ' Formats',
+ items = { { icon = 'spinner', selectable = false, value = 'ignore' } },
+ type = 'quality-menu-' .. menu_type.name,
+ keep_open = true,
+ on_close = {
+ 'script-message-to',
+ script_name,
+ 'uosc-menu-closed',
+ menu_type.name
+ }
+ }
+ uosc_show_menu(menu, menu_type)
+ destructor = function()
+ mp.commandv('script-message-to', 'uosc', 'close-menu', menu.type)
end
else
- show_menu(false)
+ osd_message('fetching available ' .. menu_type.type .. ' formats...', 60)
end
+ open_menu_state = menu_type
end
--- keybind to launch menu
-mp.add_key_binding(nil, "video_formats_toggle", video_formats_toggle)
-mp.add_key_binding(nil, "audio_formats_toggle", audio_formats_toggle)
-mp.add_key_binding(nil, "reload", reload_resume)
+---@param menu_type UIState
+function menu_open(menu_type)
+ if not current_url then return end
+ menu_type = menu_type.to_menu
-local original_format = mp.get_property("ytdl-format")
-local path = nil
-local function file_start()
- uosc_set_format_counts()
+ local data = url_data[current_url]
+ if not data then
+ if opts.fetch_formats then
+ loading_message(menu_type)
+ download_formats(current_url)
+ return
+ end
- local new_path = get_url()
- if not new_path then return end
+ -- shallow clone so that each url has it's own active format ids
+ data = {}
+ for k, v in pairs(opts.predefined_data) do
+ data[k] = v
+ end
+ url_data[current_url] = data
+ end
+ local formats = menu_type.is_video and data.video_formats or data.audio_formats
+ local active_format
+ if menu_type.is_video then active_format = data.video_active_id
+ else active_format = data.audio_active_id end
- local data = url_data[new_path]
+ msg.verbose('current ytdl-format: ' .. mp.get_property('ytdl-format', ''))
- if opts.reset_format and path and new_path ~= path then
- if data then
- msg.verbose("setting previously set format")
- mp.set_property("ytdl-format", format_string(data.vfmt, data.afmt))
- else
- msg.verbose("setting original format")
- mp.set_property("ytdl-format", original_format)
- end
+ ensure_menu_data_filled(formats, menu_type)
+ if uosc_available then uosc_menu_open(formats, active_format, menu_type)
+ else text_menu_open(formats, active_format, menu_type) end
+ open_menu_state = menu_type
+end
+
+function menu_close()
+ if destructor then
+ destructor()
+ destructor = nil
+ end
+ if not osd.hidden then hide_osd() end
+ open_menu_state = nil
+end
+
+---@param menu_type UIState
+local function toggle_menu(menu_type)
+ if open_menu_state and open_menu_state.type == menu_type.type then
+ menu_close()
+ return
end
- if opts.start_with_menu and new_path ~= path then
- video_formats_toggle()
- elseif opts.fetch_on_start and not data then
- download_formats(new_path)
+
+ if current_url == nil then
+ if uosc_available then
+ if menu_type.is_video then
+ mp.commandv('script-binding', 'uosc/video')
+ else
+ mp.commandv('script-binding', 'uosc/audio')
+ end
+ end
+ return
end
- path = new_path
+
+ menu_open(menu_type)
end
-mp.register_event("start-file", file_start)
+function video_formats_toggle() toggle_menu(states.video_menu) end
+function audio_formats_toggle() toggle_menu(states.audio_menu) end
-mp.register_script_message('video-formats-get', function(url, script_name)
- local data = url_data[url]
- if data then
- send_formats_to('video', url, script_name, data.voptions, data.vfmt)
- else
- local queue = queue_callback_video[url] or {}
- queue[#queue + 1] = script_name
- queue_callback_video[url] = queue
- get_formats()
+-- keybind to launch menu
+mp.add_key_binding(nil, 'video_formats_toggle', video_formats_toggle)
+mp.add_key_binding(nil, 'audio_formats_toggle', audio_formats_toggle)
+mp.add_key_binding(nil, 'reload', reload_resume)
+
+mp.register_event('start-file', function()
+ local new_url = get_url()
+ local url_changed = current_url ~= new_url
+ current_url = new_url
+ uosc_set_format_counts()
+
+ -- new path isn't an url
+ if not new_url then return menu_close() end
+
+ -- open or update menu
+ if opts.start_with_menu and url_changed or open_menu_state then
+ menu_open(open_menu_state or states.video_menu)
end
end)
-mp.register_script_message('audio-formats-get', function(url, script_name)
- local data = url_data[url]
- if data then
- send_formats_to('audio', url, script_name, data.aoptions, data.afmt)
- else
- local queue = queue_callback_audio[url] or {}
- queue[#queue + 1] = script_name
- queue_callback_audio[url] = queue
- get_formats()
- end
+mp.register_event('file-loaded', function()
+ if not (opts.fetch_formats and opts.fetch_on_start) then return end
+ if not current_url or url_data[current_url] then return end
+ download_formats(current_url)
end)
-mp.register_script_message('video-format-set', function(url, format_id)
- set_format(url, format_id, url_data[url].afmt)
+-- run before ytdl_hook, which uses a priority of 10
+mp.add_hook('on_load', 9, function()
+ local path = mp.get_property('path')
+ local data = url_data[path]
+ if not (data and data.video_active_id and data.audio_active_id) then return end
+ local format = format_string(data.video_active_id, data.audio_active_id)
+ msg.verbose('setting ytdl-format: ' .. format)
+ mp.set_property('file-local-options/ytdl-format', format)
end)
-mp.register_script_message('audio-format-set', function(url, format_id)
- set_format(url, url_data[url].vfmt, format_id)
+---@param url string
+---@param format_id string
+mp.register_script_message('video-format-set', function(url, format_id)
+ menu_close()
+ local data = url_data[url]
+ set_format(url, format_id, sanitize_format_id(data.audio_active_id, data.audio_formats))
end)
-mp.register_script_message('register-ui', function(script_name)
- ui_callback[#ui_callback + 1] = script_name
+---@param url string
+---@param format_id string
+mp.register_script_message('audio-format-set', function(url, format_id)
+ menu_close()
+ local data = url_data[url]
+ set_format(url, sanitize_format_id(data.video_active_id, data.video_formats), format_id)
end)
--- check if uosc is running
+--- check if uosc is running
+---@param version string
mp.register_script_message('uosc-version', function(version)
- version = tonumber((version:gsub('%.', '')))
- ---@diagnostic disable-next-line: cast-local-type
- uosc = version and version >= 400
+ ---Like the comperator for table.sort, this returns v1 < v2
+ ---Assumes two valid semver strings
+ ---@param v1 string
+ ---@param v2 string
+ ---@return boolean
+ local function semver_comp(v1, v2)
+ local v1_iterator = v1:gmatch('%d+')
+ local v2_iterator = v2:gmatch('%d+')
+ for v2_num_str in v2_iterator do
+ local v1_num_str = v1_iterator()
+ if not v1_num_str then return true end
+ local v1_num = tonumber(v1_num_str)
+ local v2_num = tonumber(v2_num_str)
+ if v1_num < v2_num then return true end
+ if v1_num > v2_num then return false end
+ end
+ return false
+ end
+
+ local min_version = '4.6.0'
+ uosc_available = not semver_comp(version, min_version)
+ if not uosc_available then return end
uosc_set_format_counts()
+ mp.commandv(
+ 'script-message-to',
+ 'uosc',
+ 'overwrite-binding',
+ 'stream-quality',
+ 'script-binding ' .. script_name .. '/video_formats_toggle'
+ )
+ ---@param name string
+ mp.register_script_message('uosc-menu-closed', function(name)
+ -- got closed from the uosc side
+ if open_menu_state and open_menu_state.name == name then
+ destructor = nil
+ menu_close()
+ end
+ end)
end)
mp.commandv('script-message-to', 'uosc', 'get-version', mp.get_script_name())
diff --git a/.config/mpv/scripts/reload.lua b/.config/mpv/scripts/reload.lua
new file mode 100644
index 0000000..d07ab08
--- /dev/null
+++ b/.config/mpv/scripts/reload.lua
@@ -0,0 +1,19 @@
+--[[
+ reload / by sibwaf / https://github.com/sibwaf/mpv-scripts
+
+ Reopens the current playing file and seeks to the same timestamp on a button press ([Shift+R] by default).
+ Useful for situations when you are watching YouTube/streams and your connection breaks for some reason.
+
+ MIT license - do whatever you want, but I'm not responsible for any possible problems.
+ Please keep the URL to the original repository. Thanks!
+]]
+
+function reload()
+ local path = mp.get_property("path")
+ if path ~= nil then
+ local time = mp.get_property_number("time-pos")
+ mp.commandv("loadfile", path, "replace", "start=" .. time)
+ end
+end
+
+mp.add_key_binding("R", "reload", reload)