diff options
| author | trainytrain <trainytrain@rape.lol> | 2021-05-09 01:29:07 -0700 |
|---|---|---|
| committer | trainytrain <trainytrain@rape.lol> | 2021-05-09 01:29:07 -0700 |
| commit | c765e68f05bfe9f0d2e4990bdc8dfabf11cdbc87 (patch) | |
| tree | 1dd446298beeb5d1a411d5516689dee8c2ee26ee /mpv | |
init
Diffstat (limited to 'mpv')
| -rw-r--r-- | mpv/input.conf | 209 | ||||
| -rw-r--r-- | mpv/mpv.conf | 65 | ||||
| -rw-r--r-- | mpv/scripts/autoloop.lua | 53 | ||||
| -rw-r--r-- | mpv/scripts/autosave.lua | 38 | ||||
| -rw-r--r-- | mpv/scripts/autospeed.lua | 420 | ||||
| -rw-r--r-- | mpv/scripts/autosub.lua | 254 | ||||
| -rw-r--r-- | mpv/scripts/autosubsync.lua | 44 | ||||
| -rw-r--r-- | mpv/scripts/quack.lua | 44 | ||||
| -rw-r--r-- | mpv/scripts/uosc.lua | 3230 | ||||
| -rw-r--r-- | mpv/scripts/webm.lua | 2689 | ||||
| -rw-r--r-- | mpv/scripts/youtube-quality.lua | 275 |
11 files changed, 7321 insertions, 0 deletions
diff --git a/mpv/input.conf b/mpv/input.conf new file mode 100644 index 0000000..8309354 --- /dev/null +++ b/mpv/input.conf @@ -0,0 +1,209 @@ +# mpv keybindings +# +# Location of user-defined bindings: ~/.config/mpv/input.conf +# +# Lines starting with # are comments. Use SHARP to assign the # key. +# Copy this file and uncomment and edit the bindings you want to change. +# +# List of commands and further details: DOCS/man/input.rst +# List of special keys: --input-keylist +# Keybindings testing mode: mpv --input-test --force-window --idle +# +# Use 'ignore' to unbind a key fully (e.g. 'ctrl+a ignore'). +# +# Strings need to be quoted and escaped: +# KEY show-text "This is a single backslash: \\ and a quote: \" !" +# +# You can use modifier-key combinations like Shift+Left or Ctrl+Alt+x with +# the modifiers Shift, Ctrl, Alt and Meta (may not work on the terminal). +# +# The default keybindings are hardcoded into the mpv binary. +# You can disable them completely with: --no-input-default-bindings + +# Developer note: +# On compilation, this file is baked into the mpv binary, and all lines are +# uncommented (unless '#' is followed by a space) - thus this file defines the +# default key bindings. + +# If this is enabled, treat all the following bindings as default. +#default-bindings start + +#MBTN_LEFT ignore # don't do anything +#MBTN_LEFT_DBL cycle fullscreen # toggle fullscreen on/off +#MBTN_RIGHT cycle pause # toggle pause on/off + +# Mouse wheels, touchpad or other input devices that have axes +# if the input devices supports precise scrolling it will also scale the +# numeric value accordingly +#WHEEL_UP seek 10 +#WHEEL_DOWN seek -10 +#WHEEL_LEFT add volume -2 +#WHEEL_RIGHT add volume 2 + +## Seek units are in seconds, but note that these are limited by keyframes +#RIGHT seek 5 +#LEFT seek -5 +#UP seek 60 +#DOWN seek -60 +# Do smaller, always exact (non-keyframe-limited), seeks with shift. +# Don't show them on the OSD (no-osd). +#Shift+RIGHT no-osd seek 1 exact +#Shift+LEFT no-osd seek -1 exact +#Shift+UP no-osd seek 5 exact +#Shift+DOWN no-osd seek -5 exact +# Skip to previous/next subtitle (subject to some restrictions; see manpage) +#Ctrl+LEFT no-osd sub-seek -1 +#Ctrl+RIGHT no-osd sub-seek 1 +# Adjust timing to previous/next subtitle +#Ctrl+Shift+LEFT sub-step -1 +#Ctrl+Shift+RIGHT sub-step 1 +# Move video rectangle +#Alt+left add video-pan-x 0.1 +#Alt+right add video-pan-x -0.1 +#Alt+up add video-pan-y 0.1 +#Alt+down add video-pan-y -0.1 +# Zoom/unzoom video +#Alt++ add video-zoom 0.1 +#Alt+- add video-zoom -0.1 +# Reset video zoom/pan settings +#Alt+BS set video-zoom 0 ; set video-pan-x 0 ; set video-pan-y 0 +#PGUP add chapter 1 # skip to next chapter +#PGDWN add chapter -1 # skip to previous chapter +#Shift+PGUP seek 600 +#Shift+PGDWN seek -600 +#[ multiply speed 1/1.1 # scale playback speed +#] multiply speed 1.1 +#{ multiply speed 0.5 +#} multiply speed 2.0 +#BS set speed 1.0 # reset speed to normal +#Shift+BS revert-seek # undo previous (or marked) seek +#Shift+Ctrl+BS revert-seek mark # mark position for revert-seek +#q quit +#Q quit-watch-later +#q {encode} quit 4 +#ESC set fullscreen no +#ESC {encode} quit 4 +#p cycle pause # toggle pause/playback mode +#. frame-step # advance one frame and pause +#, frame-back-step # go back by one frame and pause +#SPACE cycle pause +#> playlist-next # skip to next file +#ENTER playlist-next # skip to next file +#< playlist-prev # skip to previous file +#O no-osd cycle-values osd-level 3 1 # cycle through OSD mode +#o show-progress +#P show-progress +#i script-binding stats/display-stats +#I script-binding stats/display-stats-toggle +#z add sub-delay -0.1 # subtract 100 ms delay from subs +#Z add sub-delay +0.1 # add +#x add sub-delay +0.1 # same as previous binding (discouraged) +#ctrl++ add audio-delay 0.100 # this changes audio/video sync +#ctrl+- add audio-delay -0.100 +#9 add volume -2 +#/ add volume -2 +#0 add volume 2 +#* add volume 2 +#m cycle mute +#1 add contrast -1 +#2 add contrast 1 +#3 add brightness -1 +#4 add brightness 1 +#5 add gamma -1 +#6 add gamma 1 +#7 add saturation -1 +#8 add saturation 1 +#Alt+0 set window-scale 0.5 +#Alt+1 set window-scale 1.0 +#Alt+2 set window-scale 2.0 +# toggle deinterlacer (automatically inserts or removes required filter) +#d cycle deinterlace +#r add sub-pos -1 # move subtitles up +#R add sub-pos +1 # down +#t add sub-pos +1 # same as previous binding (discouraged) +#v cycle sub-visibility +# stretch SSA/ASS subtitles with anamorphic videos to match historical +#V cycle sub-ass-vsfilter-aspect-compat +# switch between applying no style overrides to SSA/ASS subtitles, and +# overriding them almost completely with the normal subtitle style +#u cycle-values sub-ass-override "force" "no" +#j cycle sub # cycle through subtitles +#J cycle sub down # ...backwards +#SHARP cycle audio # switch audio streams +#_ cycle video +#T cycle ontop # toggle video window ontop of other windows +#f cycle fullscreen # toggle fullscreen +#s async screenshot # take a screenshot +#S async screenshot video # ...without subtitles +#Ctrl+s async screenshot window # ...with subtitles and OSD, and scaled +#Alt+s screenshot each-frame # automatically screenshot every frame +#w add panscan -0.1 # zoom out with -panscan 0 -fs +#W add panscan +0.1 # in +#e add panscan +0.1 # same as previous binding (discouraged) +# cycle video aspect ratios; "-1" is the container aspect +#A cycle-values video-aspect "16:9" "4:3" "2.35:1" "-1" +#POWER quit +#PLAY cycle pause +#PAUSE cycle pause +#PLAYPAUSE cycle pause +#STOP quit +#FORWARD seek 60 +#REWIND seek -60 +#NEXT playlist-next +#PREV playlist-prev +#VOLUME_UP add volume 2 +#VOLUME_DOWN add volume -2 +#MUTE cycle mute +#CLOSE_WIN quit +#CLOSE_WIN {encode} quit 4 +#E cycle edition # next edition +#l ab-loop # Set/clear A-B loop points +#L cycle-values loop-file "inf" "no" # toggle infinite looping +#ctrl+c quit 4 +#DEL script-binding osc/visibility # cycle OSC display +#ctrl+h cycle-values hwdec "auto" "no" # cycle hardware decoding +#F8 show_text ${playlist} # show playlist +#F9 show_text ${track-list} # show list of audio/sub streams + +# Apple Remote section +#AR_PLAY cycle pause +#AR_PLAY_HOLD quit +#AR_CENTER cycle pause +#AR_CENTER_HOLD quit +#AR_NEXT seek 10 +#AR_NEXT_HOLD seek 120 +#AR_PREV seek -10 +#AR_PREV_HOLD seek -120 +#AR_MENU show-progress +#AR_MENU_HOLD cycle mute +#AR_VUP add volume 2 +#AR_VUP_HOLD add chapter 1 +#AR_VDOWN add volume -2 +#AR_VDOWN_HOLD add chapter -1 + +# +# Legacy bindings (may or may not be removed in the future) +# +#! add chapter -1 # skip to previous chapter +#@ add chapter 1 # next + +# +# Not assigned by default +# (not an exhaustive list of unbound commands) +# + +# ? add sub-scale +0.1 # increase subtitle font size +# ? add sub-scale -0.1 # decrease subtitle font size +# ? cycle angle # switch DVD/Bluray angle +# ? cycle sub-forced-only # toggle DVD forced subs +# ? cycle program # cycle transport stream programs +# ? stop # stop playback (quit or enter idle mode) +CTRL+1 no-osd change-list glsl-shaders set "~~/shaders/Anime4K_Upscale_CNN_L_x2_Denoise.glsl:~~/shaders/Anime4K_Auto_Downscale_Pre_x4.glsl:~~/shaders/Anime4K_Upscale_CNN_M_x2_Deblur.glsl"; show-text "Anime4k: 480/720p (Faithful)" +CTRL+2 no-osd change-list glsl-shaders set "~~/shaders/Anime4K_Upscale_CNN_L_x2_Denoise.glsl:~~/shaders/Anime4K_Auto_Downscale_Pre_x4.glsl:~~/shaders/Anime4K_DarkLines_HQ.glsl:~~/shaders/Anime4K_ThinLines_HQ.glsl:~~/shaders/Anime4K_Upscale_CNN_M_x2_Deblur.glsl"; show-text "Anime4k: 480/720p (Perceptual Quality)" +CTRL+3 no-osd change-list glsl-shaders set "~~/shaders/Anime4K_Upscale_CNN_L_x2_Denoise.glsl:~~/shaders/Anime4K_Auto_Downscale_Pre_x4.glsl:~~/shaders/Anime4K_Deblur_DoG.glsl:~~/shaders/Anime4K_DarkLines_HQ.glsl:~~/shaders/Anime4K_ThinLines_HQ.glsl:~~/shaders/Anime4K_Upscale_CNN_M_x2_Deblur.glsl"; show-text "Anime4k: 480/720p (Perceptual Quality and Deblur)" +CTRL+4 no-osd change-list glsl-shaders set "~~/shaders/Anime4K_Denoise_Bilateral_Mode.glsl:~~/shaders/Anime4K_Upscale_CNN_M_x2_Deblur.glsl"; show-text "Anime4k: 1080p (Faithful)" +CTRL+5 no-osd change-list glsl-shaders set "~~/shaders/Anime4K_Denoise_Bilateral_Mode.glsl:~~/shaders/Anime4K_DarkLines_HQ.glsl:~~/shaders/Anime4K_ThinLines_HQ.glsl:~~/shaders/Anime4K_Upscale_CNN_M_x2_Deblur.glsl"; show-text "Anime4k: 1080p (Perceptual Quality)" +CTRL+6 no-osd change-list glsl-shaders set "~~/shaders/Anime4K_Denoise_Bilateral_Mode.glsl:~~/shaders/Anime4K_Deblur_DoG.glsl:~~/shaders/Anime4K_DarkLines_HQ.glsl:~~/shaders/Anime4K_ThinLines_HQ.glsl:~~/shaders/Anime4K_Upscale_CNN_M_x2_Deblur.glsl"; show-text "Anime4k: 1080p (Perceptual Quality and Deblur)" +CTRL+0 no-osd change-list glsl-shaders clr ""; show-text "GLSL shaders cleared" + +F1 vf toggle rotate=1 diff --git a/mpv/mpv.conf b/mpv/mpv.conf new file mode 100644 index 0000000..7c8a68f --- /dev/null +++ b/mpv/mpv.conf @@ -0,0 +1,65 @@ +################## +# video settings # +################## +#The default profile which sets some recommended settings +#profile=gpu-hq +#force-window=yes + +#The called API +#Use "opengl" if you have compatibility issues +#vo=gpu +#gpu-api=opengl +#fbo-format=rgba16f + +#Decoding API for 8bit h264 (or whatever your CPU supports) content +#Only should be used when you get many frame drops +#hwdec=auto + +#Don't close the player after finishing the video +keep-open=yes + +#The last position of your video is saved when quitting mpv +save-position-on-quit=yes + +#Start mpv with a % smaller resolution of your screen +#autofit=50% + +#Force seeking (if seeking doesn't work) +force-seekable=yes +# OSC +osc=no +osd-bar=no +border=no + +################## +# audio/subtitles settings # +################## + +volume=70 +# Display English subtitles if available. +slang=eng,en,en-US,enUS +blend-subtitles=yes + +# Play Finnish audio if available, fall back to English otherwise. +alang=jpn,ja,jp,en + +# Screenshots +#Output format of screenshots +screenshot-format=png + +#Same output bitdepth as the video +#Set it "no" if you want to save disc space +screenshot-high-bit-depth=yes + +#Compression of the PNG picture (1-9) +#Higher value means better compression, but takes more time +screenshot-png-compression=1 + +#Quality of JPG pictures (0-100) +#Higher value means better quality +screenshot-jpeg-quality=95 +#Output directory +screenshot-directory="~/pics/screenshots" + +#Name format you want to save the pictures +screenshot-template="%f-%wH.%wM.%wS.%wT-#%#00n" diff --git a/mpv/scripts/autoloop.lua b/mpv/scripts/autoloop.lua new file mode 100644 index 0000000..1c2794d --- /dev/null +++ b/mpv/scripts/autoloop.lua @@ -0,0 +1,53 @@ +-- mpv issue 5222 +-- Automatically set loop-file=inf for duration <= given length. Default is 5s +-- Use autoloop_duration=n in script-opts/autoloop.conf to set your preferred length +-- Alternatively use script-opts=autoloop-autoloop_duration=n in mpv.conf (takes priority) + + +require 'mp.options' + +function getOption() + -- Use recommended way to get options + local options = {autoloop_duration = 5} + read_options(options) + autoloop_duration = options.autoloop_duration + + + -- Keep old way just for compatibility (remove lines 15-27 soon) + if autoloop_duration ~= 5 then + return + end + + local opt = tonumber(mp.get_opt("autoloop-duration")) + if not opt then + return + end + print("Depracted configuration! Please use script-opts directory to set auto_loop duration") + print("Or use 'script-opts=autoloop-autoloop_duration' in mpv.conf") + autoloop_duration = opt + -- Remove lines 15-27 soon +end + +function set_loop() + local duration = mp.get_property_native("duration") + + -- Checks whether the loop status was changed for the last file + was_loop = mp.get_property_native("loop-file") + + -- Cancel operation if there is no file duration + if not duration then + return + end + + -- Loops file if was_loop is false, and file meets requirements + if not was_loop and duration <= autoloop_duration then + mp.set_property_native("loop-file", true) + -- Unloops file if was_loop is true, and file does not meet requirements + elseif was_loop and duration > autoloop_duration then + mp.set_property_native("loop-file", false) + end +end + + +getOption() +mp.register_event("file-loaded", set_loop) diff --git a/mpv/scripts/autosave.lua b/mpv/scripts/autosave.lua new file mode 100644 index 0000000..c86cdb1 --- /dev/null +++ b/mpv/scripts/autosave.lua @@ -0,0 +1,38 @@ +-- autosave.lua +-- +-- Periodically saves "watch later" data during playback, rather than only saving on quit. +-- This lets you easily recover your position in the case of an ungraceful shutdown of mpv (crash, power failure, etc.). +-- +-- You can configure the save period by creating a "lua-settings" directory inside your mpv configuration directory. +-- Inside the "lua-settings" directory, create a file named "autosave.conf". +-- The save period can be set like so: +-- +-- save_period=60 +-- +-- This will set the save period to once every 60 seconds of playback, time while paused is not counted towards the save period timer. +-- The default save period is 30 seconds. +local options = require 'mp.options' + +local o = { + save_period = 30 +} + +options.read_options(o) + +local mp = require 'mp' + +local function save() + mp.command("write-watch-later-config") +end + +local save_period_timer = mp.add_periodic_timer(o.save_period, save) + +local function pause(name, paused) + if paused then + save_period_timer:stop() + else + save_period_timer:resume() + end +end + +mp.observe_property("pause", "bool", pause)
\ No newline at end of file diff --git a/mpv/scripts/autospeed.lua b/mpv/scripts/autospeed.lua new file mode 100644 index 0000000..465ccdf --- /dev/null +++ b/mpv/scripts/autospeed.lua @@ -0,0 +1,420 @@ +--[[ + See script details on https://github.com/kevinlekiller/mpv_scripts + + Valid --script-opts are (they are all optional): + autospeed-xrandr=false true/false - Use xrandr to change display refresh rate?. + autospeed-speed=false true/false - Adjust speed of the video? + autospeed-display=HDMI1 - Use specified xrandr display, find with xrandr -q, if set to "auto", uses the primary monitor. + autospeed-exitmode=0x48 - Changes the monitor mode (refresh rate) when exiting mpv. + autospeed-exitmode=false Don't change the mode when exiting. If autospeed-exitmode is not set, this is the default. + autospeed-exitmode=auto Change the mode to the mode used when mpv started. + autospeed-exitmode=0x48 Revert to specified mode when exiting mpv. Find a mode using xrandr --verbose (it should look something like 0x123). + autospeed-interlaced=false true/false - Allow using a interlaced mode when switching refresh rates with xrandr? + autospeed-mblacklist=false - List of modes to blacklist. + Modes in this list will be ignored. + If more than one mode is specified, seperate them by semicolon. + Example: autospeed-mblacklist="0x128;0x2fa" + autospeed-minspeed=0.9 Number - Minimum allowable speed to play video at. + autospeed-maxspeed=1.1 Number - Maximum allowable speed to play video at. + autospeed-osd=true true/false - Enable OSD. + autospeed-osdtime=10 Number - How many seconds the OSD will be shown. + autospeed-osdkey=y - Key to press to show the OSD. + autospeed-estfps=false true/false - Calculate/change speed if a video has a variable fps at the cost of higher CPU usage (most videos have a fixed fps). + autospeed-spause true/false - Pause video while switching display modes. + Number - If you set this a number, it will pause for that amount of seconds. + + Example: mpv file.mkv --script-opts=autospeed-xrandr=true,autospeed-speed=false,autospeed-minspeed=0.8 +--]] +--[[ + Copyright (C) 2015-2017 kevinlekiller + + 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 2 + 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, write to the Free Software + Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + https://www.gnu.org/licenses/gpl-2.0.html +--]] +local _global = { + osd_start = mp.get_property_osd("osd-ass-cc/0"), + osd_end = mp.get_property_osd("osd-ass-cc/1"), + utils = require 'mp.utils', + modes = {}, + modeCache = {}, + lastDrr = 0, + speedCache = {}, + next = next, +} + +function round(number) + return math.floor(number + 0.5) +end + +function osdEcho() + if (_global.options["osd"] ~= true) then + return + end + setOSD() + if (_global.temp["output"] ~= nil) then + mp.osd_message(_global.temp["output"], _global.options["osdtime"]) + end +end + +function getOptions() + _global.options = { + ["xrandr"] = false, + ["speed"] = false, + ["display"] = "auto", + ["exitmode"] = "auto", + ["interlaced"] = false, + ["mblacklist"] = "false", + ["minspeed"] = 0.9, + ["maxspeed"] = 1.1, + ["osd"] = false, + ["osdtime"] = 10, + ["osdkey"] = "y", + ["estfps"] = false, + ["spause"] = false, + } + for key, value in pairs(_global.options) do + local opt = mp.get_opt("autospeed-" .. key) + if (opt ~= nil) then + if (key == "xrandr" or key == "osd" or key == "estfps" or key == "interlaced" or key == "speed") then + if (opt == "true") then + _global.options[key] = true + end + elseif (key == "minspeed" or key == "maxspeed" or key == "osdtime") then + opt = tonumber(opt) + if (opt ~= nil) then + _global.options[key] = opt + end + elseif (key == "spause") then + if (opt == "true") then + _global.options[key] = 0 + else + opt = tonumber(opt) + if (opt ~= nil) then + _global.options[key] = opt + end + end + else + _global.options[key] = opt + end + end + end + if (_global.options["mblacklist"] == "false") then + _global.options["mblacklist"] = false + else + local tmp_blacklist = {} + for blacklist in string.gmatch(_global.options["mblacklist"], '[0-9a-zA-Z]+') do + tmp_blacklist[blacklist] = true + end + _global.options["mblacklist"] = tmp_blacklist + end +end +getOptions() + +function main(name, fps) + if (fps == nil) then + return + end + _global.temp["fps"] = fps + findRefreshRate() + if (_global.options["speed"] == true) then + determineSpeed() + if (_global.temp["speed"] >= _global.options["minspeed"] and _global.temp["speed"] <= _global.options["maxspeed"]) then + mp.set_property_number("speed", _global.temp["speed"]) + else + _global.temp["speed"] = _global.confSpeed + end + else + _global.temp["speed"] = _global.confSpeed + end +end + +function setOSD() + _global.temp["output"] = (_global.osd_start .. + "{\\b1}Original monitor refresh rate{\\b0}\\h\\h" .. _global.temp["start_drr"] .. "Hz\\N" .. + "{\\b1}Current monitor refresh rate{\\b0}\\h\\h" .. _global.temp["drr"] .. "Hz\\N" .. + "{\\b1}Original video fps{\\b0}\\h\\h\\h\\h\\h\\h\\h\\h\\h\\h\\h\\h\\h\\h\\h\\h\\h\\h\\h\\h\\h" .. _global.temp["fps"] .. "fps\\N" .. + "{\\b1}Current video fps{\\b0}\\h\\h\\h\\h\\h\\h\\h\\h\\h\\h\\h\\h\\h\\h\\h\\h\\h\\h\\h\\h\\h" .. (_global.temp["fps"] * _global.temp["speed"]) .. "fps\\N" .. + "{\\b1}Original mpv speed setting{\\b0}\\h\\h\\h\\h\\h\\h" .. _global.confSpeed .. "x\\N" .. + "{\\b1}Current mpv speed setting{\\b0}\\h\\h\\h\\h\\h\\h" .. _global.temp["speed"] .. "x" .. + _global.osd_end + ) +end + +function determineSpeed() + local id = _global.temp["drr"] .. _global.temp["fps"] + if (_global.speedCache[id] ~= nil) then + _global.temp["speed"] = _global.speedCache[id] + return + end + if (_global.temp["drr"] > _global.temp["fps"]) then + local difference = (_global.temp["drr"] / _global.temp["fps"]) + if (difference >= 2) then + -- fps = 24fps, drr = 60hz + -- difference = 60hz/24fps = 3 rounded + -- 24fps * 3 = 72fps + -- 60hz / 72fps = 0.833333333333 speed + -- 72fps * 0.833333333333 = 60fps + _global.temp["speed"] = (_global.temp["drr"] / (_global.temp["fps"] * round(difference))) + else + -- fps = 50fps, drr = 60hz + -- 60hz / 50fps = 1.2 speed + -- 50fps * 1.2 speed = 60fps + + -- fps = 59.94fps, drr = 60hz + -- 60hz / 59.94fps = 1.001001001001001 speed + -- 59.94fps * 1.001001001001001 = 60fps + _global.temp["speed"] = difference + end + elseif (_global.temp["drr"] < _global.temp["fps"]) then + local difference = (_global.temp["fps"] / _global.temp["drr"]) + if (difference >= 2) then + -- fps = 120fps, drr = 25hz + -- difference = 120fps/25hz = 5 rounded + -- 120fps/5 = 24fps ; 25hz / 24fps = 1.04166666667 speed + -- 24fps * 1.04166666667 speed = 25fps + _global.temp["speed"] = (_global.temp["drr"] / (_global.temp["fps"] / round(difference))) + else + -- fps = 60fps, drr = 50hz + -- difference = 50hz / 60fps = 0.833333333333 speed + -- 60fps * 0.833333333333 speed = 50fps + + -- fps = 60fps, drr = 59.94hz + -- difference = 59.94hz / 60fps = 0.999 speed + -- 60fps * 0.999 speed = 59.94fps + _global.temp["speed"] = (_global.temp["drr"] / _global.temp["fps"]) + end + elseif (_global.temp["drr"] == _global.temp["fps"]) then + _global.temp["speed"] = 1 + end + _global.speedCache[id] = _global.temp["speed"] +end + +function findRefreshRate() + -- This is to prevent a system call if the screen refresh / video fps has not changed. + if (_global.temp["drr"] == _global.lastDrr) then + return + elseif (_global.modeCache[_global.temp["drr"]] ~= nil) then + setXrandrRate(_global.modeCache[_global.temp["drr"]]) + return + end + if (_global.options["xrandr"] ~= true or getXrandrModes() == false) then + return + end + -- If the current monitor rate is already a multiple, don't change the mode. + if (_global.temp["fps"] <= _global.temp["drr"] and _global.temp["drr"] % _global.temp["fps"] == 0) then + return + end + local round_fps = round(_global.temp["fps"]) + local iterator = 1 + if (_global.temp["maxclock"] > round_fps) then + iterator = round(_global.temp["maxclock"] / round_fps) + elseif (_global.temp["maxclock"] < round_fps) then + iterator = round(round_fps / _global.temp["maxclock"]) + else + setXrandrRate(_global.modes[_global.temp["maxclock"]]) + return + end + local smallest = 0 + local foundMode = false + for rate, mode in pairs(_global.modes) do + local min = (rate * _global.options["minspeed"]) + local max = (rate * _global.options["maxspeed"]) + for multiplier = 1, iterator do + local multiplied_fps = (multiplier * round_fps) + if (multiplied_fps >= min and multiplied_fps <= max) then + if (multiplied_fps < rate) then + local difference = (rate - multiplied_fps) + if (smallest == 0 or difference < smallest) then + smallest = difference + foundMode = mode + end + elseif (multiplied_fps > rate) then + local difference = (multiplied_fps - rate) + if (smallest == 0 or difference < smallest) then + smallest = difference + foundMode = mode + end + else + setXrandrRate(mode) + return + end + end + end + end + if (foundMode ~= false) then + setXrandrRate(foundMode) + end +end + +function setXrandrRate(mode) + if (mode == _global.temp["currentmode"]) then + return + end + local vars = {vid = nil, time_pos = nil, vdpau = (mp.get_property("options/vo") == "vdpau" or mp.get_property("options/hwdec") == "vdpau")} + if (_global.options["spause"]) then + mp.set_property("pause", "yes") + end + if (vars.vdpau) then + vars.vid = mp.get_property("vid") + vars.time_pos = mp.get_property("time-pos") + mp.set_property("vid", "no") + end + _global.utils.subprocess({["cancellable"] = false, ["args"] = {[1] = "xrandr", [2] = "--output", [3] = _global.options["display"], [4] = "--mode", [5] = mode,}}) + if (_global.options["spause"]) then + if (tonumber(_global.options["spause"]) ~= nil and _global.options["spause"] > 0) then + _global.utils.subprocess({["cancellable"] = false, ["args"] = {[1] = "sleep", [2] = _global.options["spause"]}}) + end + mp.set_property("pause", "no") + end + if (vars.vdpau) then + mp.set_property("vid", vars.vid) + if (vars.time_pos ~= nil) then + mp.commandv("seek", vars.time_pos, "absolute", "keyframes") + end + end + _global.utils.subprocess({["cancellable"] = false, ["args"] = {[1] = "sleep", [2] = "0.5"}}) + _global.temp["drr"] = mp.get_property_native("display-fps") + _global.modeCache[_global.temp["drr"]] = mode + _global.lastDrr = _global.temp["drr"] + _global.temp["currentmode"] = mode +end + +function getXrandrModes() + if (_global.next(_global.modes) ~= nil) then + return true + end + if not (_global.modes) then + return false + end + local vars = { + handle = assert(io.popen("xrandr --verbose")), + foundDisp = false, + foundRes = false, + count = 0, + resolution, + } + if (_global.options["display"] == "auto") then + vars.disp = "^%S+%sconnected%sprimary" + else + vars.disp = "^" .. string.gsub(_global.options["display"], "%-", "%%-") + end + _global.temp["maxclock"] = 0 + for line in vars.handle:lines() do + if (vars.foundDisp == false and string.match(line, vars.disp) ~= nil) then -- Check if the display name (ie HDMI1) matches the one in the config. + if (string.find(line, "disconnected") ~= nil) then + break -- Wrong display name was given. + else + local res = string.match(line, vars.disp .. "%D+([%dx]+)") -- Find current monitor resolution. + if (res ~= nil and res ~= "") then + if (_global.options["display"] == "auto") then + _global.options["display"] = string.match(line, "^%S+") + end + vars.resolution = res + vars.foundDisp = true + else + break -- Could not find display resolution. + end + end + elseif (vars.foundDisp == true) then -- We found the display name. + if (vars.foundRes == false and string.match(line, "^%s+" .. vars.resolution) ~= nil) then -- Check if mode uses current display resolution. + vars.foundRes = true + end + if (vars.foundRes == true) then -- We found a matching screen resolution. + vars.count = vars.count + 1 + if (vars.count == 1) then -- Log the mode name. + vars.mode = string.match(line, "%((.+)%)%s+[%d.]+MHz") + if (_global.temp["origmode"] == nil) then + if (string.find(line, "%*current") ~= nil) then + _global.temp["origmode"] = vars.mode + _global.temp["currentmode"] = vars.mode + end + end + vars.interlaced = false + if (string.find(line, "Interlace") ~= nil) then + vars.interlaced = true + end + elseif (vars.count == 2) then + + elseif (vars.count == 3) then + if ((_global.options["interlaced"] == false and vars.interlaced == true) or (_global.options["mblacklist"] ~= false and _global.options["mblacklist"][vars.mode] ~= nil)) then + -- ignore these modes + else + local clock = string.match(line, "total%s+%d+.+clock%s+([%d.]+)[KkHh]+z") + clock = round(clock) + if (_global.temp["maxclock"] < clock) then + _global.temp["maxclock"] = clock + end + _global.modes[clock] = vars.mode + end + vars.count = 0 -- Reset variables to look for another matching resolution. + vars.foundRes = false + end + elseif (string.match(line, "^%S") ~= nil) then + break -- We reached the next display or EOF. + end + end + end + vars.handle:close() + if (_global.next(_global.modes) == nil) then + _global.modes = false + return false + end +end + +function start() + mp.unobserve_property(start) + _global.temp = {} + _global.temp["start_drr"] = mp.get_property_native("display-fps") + if not (_global.temp["start_drr"]) then + return + end + _global.temp["drr"] = _global.temp["start_drr"] + if not (_global.confSpeed) then + _global.confSpeed = mp.get_property_native("speed") + end + local test = mp.get_property("container-fps") + if (test == nil or test == "nil property unavailable") then + if (_global.options["estfps"] ~= true) then + return + end + test = mp.get_property("estimated-vf-fps") + if (test == nil or test == "nil property unavailable") then + return + end + mp.observe_property("estimated-vf-fps", "number", main) + else + mp.observe_property("container-fps", "number", main) + end + mp.add_key_binding(_global.options["osdkey"], mp.get_script_name(), osdEcho, {repeatable=true}) + if (_global.options["xrandr"] == true and _global.options.exitmode ~= "false") then + function revertDrr() + if (_global.options["display"] ~= "auto") then + if (_global.options["exitmode"] == "auto" and _global.temp["origmode"] ~= nil) then + os.execute("xrandr --output " .. _global.options["display"] .. " --mode " .. _global.temp["origmode"] .. " &") + else + os.execute("xrandr --output " .. _global.options["display"] .. " --mode " .. _global.options["exitmode"] .. " &") + end + end + end + mp.register_event("shutdown", revertDrr) + end +end + +-- Wait until we get a video fps. +function check() + mp.observe_property("estimated-vf-fps", "string", start) +end + +mp.register_event("file-loaded", check) diff --git a/mpv/scripts/autosub.lua b/mpv/scripts/autosub.lua new file mode 100644 index 0000000..56d49fa --- /dev/null +++ b/mpv/scripts/autosub.lua @@ -0,0 +1,254 @@ +--============================================================================= +-->> SUBLIMINAL PATH: +--============================================================================= +-- This script uses Subliminal to download subtitles, +-- so make sure to specify your system's Subliminal location below: +local subliminal = '/usr/bin/subliminal' +--============================================================================= +-->> SUBTITLE LANGUAGE: +--============================================================================= +-- Specify languages in this order: +-- { 'language name', 'ISO-639-1', 'ISO-639-2' } ! +-- (See: https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) +local languages = { +-- If subtitles are found for the first language, +-- other languages will NOT be downloaded, +-- so put your preferred language first: + { 'English', 'en', 'eng' }, + { 'Dutch', 'nl', 'dut' }, +-- { 'Spanish', 'es', 'spa' }, +-- { 'French', 'fr', 'fre' }, +-- { 'German', 'de', 'ger' }, +-- { 'Italian', 'it', 'ita' }, +-- { 'Portuguese', 'pt', 'por' }, +-- { 'Polish', 'pl', 'pol' }, +-- { 'Russian', 'ru', 'rus' }, +-- { 'Chinese', 'zh', 'chi' }, +-- { 'Arabic', 'ar', 'ara' }, +} +--============================================================================= +-->> PROVIDER LOGINS: +--============================================================================= +-- These are completely optional and not required +-- for the functioning of the script! +-- If you use any of these services, simply uncomment it +-- and replace 'USERNAME' and 'PASSWORD' with your own: +local logins = { +-- { '--addic7ed', 'USERNAME', 'PASSWORD' }, +-- { '--legendastv', 'USERNAME', 'PASSWORD' }, +-- { '--opensubtitles', 'USERNAME', 'PASSWORD' }, +-- { '--subscenter', 'USERNAME', 'PASSWORD' }, +} +--============================================================================= +-->> ADDITIONAL OPTIONS: +--============================================================================= +local bools = { + auto = true, -- Automatically download subtitles, no hotkeys required + debug = false, -- Use `--debug` in subliminal command for debug output + force = true, -- Force download; will overwrite existing subtitle files + utf8 = true, -- Save all subtitle files as UTF-8 +} +local excludes = { + -- Movies with a path containing any of these strings/paths + -- will be excluded from auto-downloading subtitles. + -- Full paths are also allowed, e.g.: + -- '/home/david/Videos', + 'no-subs-dl', +} +local includes = { + -- If anything is defined here, only the movies with a path + -- containing any of these strings/paths will auto-download subtitles. + -- Full paths are also allowed, e.g.: + -- '/home/david/Videos', +} +--============================================================================= +local utils = require 'mp.utils' + + +-- Download function: download the best subtitles in most preferred language +function download_subs(language) + language = language or languages[1] + log('Searching ' .. language[1] .. ' subtitles ...', 30) + + -- Build the `subliminal` command, starting with the executable: + local table = { args = { subliminal } } + local a = table.args + + for _, login in ipairs(logins) do + a[#a + 1] = login[1] + a[#a + 1] = login[2] + a[#a + 1] = login[3] + end + if bools.debug then + -- To see `--debug` output start MPV from the terminal! + a[#a + 1] = '--debug' + end + + a[#a + 1] = 'download' + if bools.force then + a[#a + 1] = '-f' + end + if bools.utf8 then + a[#a + 1] = '-e' + a[#a + 1] = 'utf-8' + end + + a[#a + 1] = '-l' + a[#a + 1] = language[2] + a[#a + 1] = '-d' + a[#a + 1] = directory + a[#a + 1] = filename --> Subliminal command ends with the movie filename. + + local result = utils.subprocess(table) + + if string.find(result.stdout, 'Downloaded 1 subtitle') then + -- When multiple external files are present, + -- always activate the most recently downloaded: + mp.set_property('slang', language[2]) + -- Subtitles are downloaded successfully, so rescan to activate them: + mp.commandv('rescan_external_files') + log(language[1] .. ' subtitles ready!') + return true + else + log('No ' .. language[1] .. ' subtitles found\n') + return false + end +end + +-- Manually download second language subs by pressing 'n': +function download_subs2() + download_subs(languages[2]) +end + +-- Control function: only download if necessary +function control_downloads() + -- Make MPV accept external subtitle files with language specifier: + mp.set_property('sub-auto', 'fuzzy') + -- Set subtitle language preference: + mp.set_property('slang', languages[1][2]) + mp.msg.warn('Reactivate external subtitle files:') + mp.commandv('rescan_external_files') + directory, filename = utils.split_path(mp.get_property('path')) + + if not autosub_allowed() then + return + end + + sub_tracks = {} + for _, track in ipairs(mp.get_property_native('track-list')) do + if track['type'] == 'sub' then + sub_tracks[#sub_tracks + 1] = track + end + end + if bools.debug then -- Log subtitle properties to terminal: + for _, track in ipairs(sub_tracks) do + mp.msg.warn('Subtitle track', track['id'], ':\n{') + for k, v in pairs(track) do + if type(v) == 'string' then v = '"' .. v .. '"' end + mp.msg.warn(' "' .. k .. '":', v) + end + mp.msg.warn('}\n') + end + end + + for _, language in ipairs(languages) do + if should_download_subs_in(language) then + if download_subs(language) then return end -- Download successful! + else return end -- No need to download! + end + log('No subtitles were found') +end + +-- Check if subtitles should be auto-downloaded: +function autosub_allowed() + local duration = tonumber(mp.get_property('duration')) + local active_format = mp.get_property('file-format') + + if not bools.auto then + mp.msg.warn('Automatic downloading disabled!') + return false + elseif duration < 900 then + mp.msg.warn('Video is less than 15 minutes\n' .. + '=> NOT auto-downloading subtitles') + return false + elseif directory:find('^http') then + mp.msg.warn('Automatic subtitle downloading is disabled for web streaming') + return false + elseif active_format:find('^cue') then + mp.msg.warn('Automatic subtitle downloading is disabled for cue files') + return false + else + local not_allowed = {'aiff', 'ape', 'flac', 'mp3', 'ogg', 'wav', 'wv'} + + for _, file_format in pairs(not_allowed) do + if file_format == active_format then + mp.msg.warn('Automatic subtitle downloading is disabled for audio files') + return false + end + end + + for _, exclude in pairs(excludes) do + local escaped_exclude = exclude:gsub('%W','%%%0') + local excluded = directory:find(escaped_exclude) + + if excluded then + mp.msg.warn('This path is excluded from auto-downloading subs') + return false + end + end + + for i, include in ipairs(includes) do + local escaped_include = include:gsub('%W','%%%0') + local included = directory:find(escaped_include) + + if included then break + elseif i == #includes then + mp.msg.warn('This path is not included for auto-downloading subs') + return false + end + end + end + + return true +end + +-- Check if subtitles should be downloaded in this language: +function should_download_subs_in(language) + for i, track in ipairs(sub_tracks) do + local subtitles = track['external'] and + 'subtitle file' or 'embedded subtitles' + + if not track['lang'] and (track['external'] or not track['title']) + and i == #sub_tracks then + local status = track['selected'] and ' active' or ' present' + log('Unknown ' .. subtitles .. status) + mp.msg.warn('=> NOT downloading new subtitles') + return false -- Don't download if 'lang' key is absent + elseif track['lang'] == language[3] or track['lang'] == language[2] or + (track['title'] and track['title']:lower():find(language[3])) then + if not track['selected'] then + mp.set_property('sid', track['id']) + log('Enabled ' .. language[1] .. ' ' .. subtitles .. '!') + else + log(language[1] .. ' ' .. subtitles .. ' active') + end + mp.msg.warn('=> NOT downloading new subtitles') + return false -- The right subtitles are already present + end + end + mp.msg.warn('No ' .. language[1] .. ' subtitles were detected\n' .. + '=> Proceeding to download:') + return true +end + +-- Log function: log to both terminal and MPV OSD (On-Screen Display) +function log(string, secs) + secs = secs or 2.5 -- secs defaults to 2.5 when secs parameter is absent + mp.msg.warn(string) -- This logs to the terminal + mp.osd_message(string, secs) -- This logs to MPV screen +end + + +mp.add_key_binding('b', 'download_subs', download_subs) +mp.add_key_binding('n', 'download_subs2', download_subs2) +mp.register_event('file-loaded', control_downloads) diff --git a/mpv/scripts/autosubsync.lua b/mpv/scripts/autosubsync.lua new file mode 100644 index 0000000..fb3a6c3 --- /dev/null +++ b/mpv/scripts/autosubsync.lua @@ -0,0 +1,44 @@ +-- default keybinding: n +-- add the following to your input.conf to change the default keybinding: +-- keyname script_binding auto_sync_subs +local utils = require 'mp.utils' + +function display_error() + mp.msg.warn("Subtitle synchronization failed: ") + mp.osd_message("Subtitle synchronization failed") +end + +-- Courtesy of https://stackoverflow.com/questions/4990990/check-if-a-file-exists-with-lua +function file_exists(filepath) + local f=io.open(filepath,"r") + if f~=nil then io.close(f) return true else return false end +end + +function sync_sub_fn() + path = mp.get_property("path") + srt_path = string.gsub(path, "%.%w+$", ".srt") + if file_exists(srt_path)==false then + mp.msg.warn("Couldn't find",srt_path) + display_error() + do return end + end + subsync = "/home/user/.local/bin/ffsubsync" -- use 'which ffsubsync' to find the path + t = {} + t.args = {subsync, path, "-i",srt_path,"-o",srt_path} + + mp.osd_message("Sync subtitle...") + mp.msg.info("Starting ffsubsync...") + res = utils.subprocess(t) + if res.error == nil then + if mp.commandv("sub_add", srt_path) then + mp.msg.info("Subtitle updated") + mp.osd_message("Subtitle at'" .. srt_path .. "' synchronized") + else + display_error() + end + else + display_error() + end +end + +mp.add_key_binding("n", "auto_sync_subs", sync_sub_fn) diff --git a/mpv/scripts/quack.lua b/mpv/scripts/quack.lua new file mode 100644 index 0000000..ed09ce1 --- /dev/null +++ b/mpv/scripts/quack.lua @@ -0,0 +1,44 @@ +local options = require 'mp.options' + +local o = { + ducksecs = 2, -- lol + duckratio = 0.5 +} +options.read_options(o) + +local duck_progress = 0 +local duck_timer = nil +local orig_vol = nil + +function update_quack() + duck_progress = duck_progress + 1 + if duck_progress >= o.ducksecs * 10 then + duck_timer:kill() + end + mp.set_property_number("volume", math.min(orig_vol, orig_vol * o.duckratio + orig_vol * (1 - o.duckratio) * (duck_progress / (o.ducksecs * 10)))) + -- print(mp.get_property_number("volume")) +end + +function engage_ducking(name, val) + pos = mp.get_property_number("time-pos") + if val == nil or val == false then + return + end + if pos == 0 then + return + end + duck_progress = 0 + if duck_timer == nil then + duck_timer = mp.add_periodic_timer(0.1, update_quack) + orig_vol = mp.get_property_number("volume") + update_quack() -- fire for immediate effect + else + if duck_timer:is_enabled() == false then + orig_vol = mp.get_property_number("volume") + duck_timer:resume() + update_quack() + end + end +end + +mp.observe_property("seeking", "bool", engage_ducking) diff --git a/mpv/scripts/uosc.lua b/mpv/scripts/uosc.lua new file mode 100644 index 0000000..a2a5074 --- /dev/null +++ b/mpv/scripts/uosc.lua @@ -0,0 +1,3230 @@ +--[[ + +uosc 2.10.1 - 2020-Jun-20 | https://github.com/darsain/uosc + +Minimalist cursor proximity based UI for MPV player. + +uosc replaces the default osc UI, so that has to be disabled first. +Place these options into your `mpv.conf` file: + +``` +# required so that the 2 UIs don't fight each other +osc=no +# uosc provides its own seeking/volume indicators, so you also don't need this +osd-bar=no +# uosc will draw its own window controls if you disable window border +border=no +``` + +Options go in `script-opts/uosc.conf`. Defaults: + +``` +# timeline size when fully retracted, 0 will hide it completely +timeline_size_min=2 +# timeline size when fully expanded, in pixels, 0 to disable +timeline_size_max=40 +# same as ^ but when in fullscreen +timeline_size_min_fullscreen=0 +timeline_size_max_fullscreen=60 +# same thing as calling toggle-progress command once on startup +timeline_start_hidden=no +# timeline opacity +timeline_opacity=0.8 +# top (and bottom in no-border mode) border of background color to help visually +# separate elapsed bar from a video of similar color or desktop background +timeline_border=1 +# when scrolling above timeline, wheel will seek by this amount of seconds +timeline_step=5 +# display seekable buffered ranges for streaming videos, syntax `color:opacity`, +# color is an BBGGRR hex code, set to `none` to disable +timeline_cached_ranges=345433:0.5 +# floating number font scale adjustment +timeline_font_scale=1 + +# timeline chapters style: none, dots, lines, lines-top, lines-bottom +chapters=dots +chapters_opacity=0.3 + +# where to display volume controls: none, left, right +volume=right +volume_size=40 +volume_size_fullscreen=60 +volume_opacity=0.8 +volume_border=1 +volume_step=1 +volume_font_scale=1 + +# playback speed widget: mouse drag or wheel to change, click to reset +speed=no +speed_size=46 +speed_size_fullscreen=68 +speed_opacity=1 +speed_step=0.1 +speed_font_scale=1 + +# controls all menus, such as context menu, subtitle loader/selector, etc +menu_item_height=36 +menu_item_height_fullscreen=50 +menu_wasd_navigation=no +menu_hjkl_navigation=no +menu_opacity=0.8 +menu_font_scale=1 + +# top bar with window controls and media title shown only in no-border mode +top_bar_size=40 +top_bar_size_fullscreen=46 +top_bar_controls=yes +top_bar_title=yes + +# pause video on clicks shorter than this number of milliseconds, 0 to disable +pause_on_click_shorter_than=0 +# flash duration in milliseconds used by `flash-{element}` commands +flash_duration=400 +# distances in pixels below which elements are fully faded in/out +proximity_in=40 +proximity_out=120 +# BBGGRR - BLUE GREEN RED hex color codes +color_foreground=ffffff +color_foreground_text=000000 +color_background=000000 +color_background_text=ffffff +# use bold font weight throughout the whole UI +font_bold=no +# hide UI when mpv autohides the cursor +autohide=no +# can be: none, flash, static +pause_indicator=flash +# load first file when calling next on a last file in a directory and vice versa +directory_navigation_loops=no +# file types to look for when navigating media files +media_types=3gp,avi,bmp,flac,flv,gif,h264,h265,jpeg,jpg,m4a,m4v,mid,midi,mkv,mov,mp3,mp4,mp4a,mp4v,mpeg,mpg,oga,ogg,ogm,ogv,opus,png,rmvb,svg,tif,tiff,wav,weba,webm,webp,wma,wmv +# file types to look for when loading external subtitles +subtitle_types=aqt,gsub,jss,sub,ttxt,pjs,psb,rt,smi,slt,ssf,srt,ssa,ass,usf,idx,vt +# used to approximate text width +# if you are using some wide font and see a lot of right side clipping in menus, +# try bumping this up +font_height_to_letter_width_ratio=0.5 + +# `chapter_ranges` lets you transform chapter indicators into range indicators. +# +# Chapter range definition syntax: +# ``` +# start_pattern<color:opacity>end_pattern +# ``` +# +# Multiple start and end patterns can be defined by separating them with `|`: +# ``` +# p1|pN<color:opacity>p1|pN +# ``` +# +# Multiple chapter ranges can be defined by separating them with comma: +# +# chapter_ranges=range1,rangeN +# +# One of `start_pattern`s can be a custom keyword `{bof}` that will match +# beginning of file when it makes sense. +# +# One of `end_pattern`s can be a custom keyword `{eof}` that will match end of +# file when it makes sense. +# +# Patterns are lua patterns (http://lua-users.org/wiki/PatternsTutorial). +# They only need to occur in a title, not match it completely. +# Matching is case insensitive. +# +# `color` is a `bbggrr` hexadecimal color code. +# `opacity` is a float number from 0 to 1. +# +# Examples: +# +# Display anime openings and endings as ranges: +# ``` +# chapter_ranges=^op| op$|opening<968638:0.5>.*, ^ed| ed$|^end|ending$<968638:0.5>.*|{eof} +# ``` +# +# Display skippable youtube video sponsor blocks from https://github.com/po5/mpv_sponsorblock +# ``` +# chapter_ranges=sponsor start<3535a5:.5>sponsor end, segment start<3535a5:0.5>segment end +# ``` +chapter_ranges=^op| op$|opening<968638:0.5>.*, ^ed| ed$|^end|ending$<968638:0.5>.*|{eof}, sponsor start<3535a5:.5>sponsor end, segment start<3535a5:0.5>segment end +``` + +Available keybindings (place into `input.conf`): + +``` +Key script-binding uosc/peek-timeline +Key script-binding uosc/toggle-progress +Key script-binding uosc/flash-timeline +Key script-binding uosc/flash-volume +Key script-binding uosc/flash-speed +Key script-binding uosc/menu +Key script-binding uosc/load-subtitles +Key script-binding uosc/subtitles +Key script-binding uosc/audio +Key script-binding uosc/video +Key script-binding uosc/playlist +Key script-binding uosc/chapters +Key script-binding uosc/open-file +Key script-binding uosc/next +Key script-binding uosc/prev +Key script-binding uosc/first +Key script-binding uosc/last +Key script-binding uosc/next-file +Key script-binding uosc/prev-file +Key script-binding uosc/first-file +Key script-binding uosc/last-file +Key script-binding uosc/delete-file-next +Key script-binding uosc/delete-file-quit +Key script-binding uosc/show-in-directory +Key script-binding uosc/open-config-directory +``` +]] + +if mp.get_property('osc') == 'yes' then + mp.msg.info('Disabled because original osc is enabled!') + return +end + +local assdraw = require('mp.assdraw') +local opt = require('mp.options') +local utils = require('mp.utils') +local msg = require('mp.msg') +local osd = mp.create_osd_overlay('ass-events') +local infinity = 1e309 + +-- OPTIONS/CONFIG/STATE +local options = { + timeline_size_min = 2, + timeline_size_max = 40, + timeline_size_min_fullscreen = 0, + timeline_size_max_fullscreen = 60, + timeline_start_hidden = false, + timeline_opacity = 0.8, + timeline_border = 1, + timeline_step = 5, + timeline_cached_ranges = '345433:0.5', + timeline_font_scale = 1, + + chapters = 'dots', + chapters_opacity = 0.3, + + volume = 'right', + volume_size = 40, + volume_size_fullscreen = 60, + volume_opacity = 0.8, + volume_border = 1, + volume_step = 1, + volume_font_scale = 1, + + speed = false, + speed_size = 46, + speed_size_fullscreen = 68, + speed_opacity = 1, + speed_step = 0.1, + speed_font_scale = 1, + + menu_item_height = 36, + menu_item_height_fullscreen = 50, + menu_wasd_navigation = false, + menu_hjkl_navigation = false, + menu_opacity = 0.8, + menu_font_scale = 1, + + top_bar_size = 40, + top_bar_size_fullscreen = 46, + top_bar_controls = true, + top_bar_title = true, + + pause_on_click_shorter_than = 0, + flash_duration = 400, + proximity_in = 40, + proximity_out = 120, + color_foreground = 'ffffff', + color_foreground_text = '000000', + color_background = '000000', + color_background_text = 'ffffff', + font_bold = false, + autohide = false, + pause_indicator = 'flash', + directory_navigation_loops = false, + media_types = '3gp,avi,bmp,flac,flv,gif,h264,h265,jpeg,jpg,m4a,m4v,mid,midi,mkv,mov,mp3,mp4,mp4a,mp4v,mpeg,mpg,oga,ogg,ogm,ogv,opus,png,rmvb,svg,tif,tiff,wav,weba,webm,webp,wma,wmv', + subtitle_types = 'aqt,gsub,jss,sub,ttxt,pjs,psb,rt,smi,slt,ssf,srt,ssa,ass,usf,idx,vt', + font_height_to_letter_width_ratio = 0.5, + chapter_ranges = '^op| op$|opening<968638:0.5>.*, ^ed| ed$|^end|ending$<968638:0.5>.*|{eof}, sponsor start<3535a5:.5>sponsor end, segment start<3535a5:0.5>segment end', +} +opt.read_options(options, 'uosc') +local config = { + render_delay = 0.03, -- sets max rendering frequency + font = mp.get_property('options/osd-font'), + menu_parent_opacity = 0.4, + menu_min_width = 260 +} +local bold_tag = options.font_bold and '\\b1' or '' +local display = { + width = 1280, + height = 720, + aspect = 1.77778, +} +local cursor = { + hidden = true, -- true when autohidden or outside of the player window + x = 0, + y = 0, +} +local state = { + os = (function() + if os.getenv('windir') ~= nil then return 'windows' end + local homedir = os.getenv('HOME') + if homedir ~= nil and string.sub(homedir,1,6) == '/Users' then return 'macos' end + return 'linux' + end)(), + cwd = mp.get_property('working-directory'), + media_title = '', + duration = nil, + position = nil, + pause = false, + chapters = nil, + chapter_ranges = nil, + fullscreen = mp.get_property_native('fullscreen'), + maximized = mp.get_property_native('window-maximized'), + render_timer = nil, + render_last_time = 0, + volume = nil, + volume_max = nil, + mute = nil, + cursor_autohide_timer = mp.add_timeout(mp.get_property_native('cursor-autohide') / 1000, function() + if not options.autohide then return end + handle_mouse_leave() + end), + mouse_bindings_enabled = false, + cached_ranges = nil, +} +local forced_key_bindings -- defined at the bottom next to events + +-- HELPERS + +function round(number) + local modulus = number % 1 + return modulus < 0.5 and math.floor(number) or math.ceil(number) +end + +function call_me_maybe(fn, value1, value2, value3) + if fn then fn(value1, value2, value3) end +end + +function split(str, pattern) + local list = {} + local full_pattern = '(.-)' .. pattern + local last_end = 1 + local start_index, end_index, capture = str:find(full_pattern, 1) + while start_index do + list[#list +1] = capture + last_end = end_index + 1 + start_index, end_index, capture = str:find(full_pattern, last_end) + end + if last_end <= (#str + 1) then + capture = str:sub(last_end) + list[#list +1] = capture + end + return list +end + +function itable_find(haystack, needle) + local is_needle = type(needle) == 'function' and needle or function(index, value) + return value == needle + end + for index, value in ipairs(haystack) do + if is_needle(index, value) then return index, value end + end +end + +function itable_filter(haystack, needle) + local is_needle = type(needle) == 'function' and needle or function(index, value) + return value == needle + end + local filtered = {} + for index, value in ipairs(haystack) do + if is_needle(index, value) then filtered[#filtered + 1] = value end + end + return filtered +end + +function itable_remove(haystack, needle) + local should_remove = type(needle) == 'function' and needle or function(value) + return value == needle + end + local new_table = {} + for _, value in ipairs(haystack) do + if not should_remove(value) then + new_table[#new_table + 1] = value + end + end + return new_table +end + +function itable_slice(haystack, start_pos, end_pos) + start_pos = start_pos and start_pos or 1 + end_pos = end_pos and end_pos or #haystack + + if end_pos < 0 then end_pos = #haystack + end_pos + 1 end + if start_pos < 0 then start_pos = #haystack + start_pos + 1 end + + local new_table = {} + for index, value in ipairs(haystack) do + if index >= start_pos and index <= end_pos then + new_table[#new_table + 1] = value + end + end + return new_table +end + +function table_copy(table) + local new_table = {} + for key, value in pairs(table) do new_table[key] = value end + return new_table +end + +-- Sorting comparator close to (but not exactly) how file explorers sort files +local word_order_comparator = (function() + local symbol_order + local default_order + + if state.os == 'win' then + symbol_order = { + ['!'] = 1, ['#'] = 2, ['$'] = 3, ['%'] = 4, ['&'] = 5, ['('] = 6, [')'] = 6, [','] = 7, + ['.'] = 8, ["'"] = 9, ['-'] = 10, [';'] = 11, ['@'] = 12, ['['] = 13, [']'] = 13, ['^'] = 14, + ['_'] = 15, ['`'] = 16, ['{'] = 17, ['}'] = 17, ['~'] = 18, ['+'] = 19, ['='] = 20, + } + default_order = 21 + else + symbol_order = { + ['`'] = 1, ['^'] = 2, ['~'] = 3, ['='] = 4, ['_'] = 5, ['-'] = 6, [','] = 7, [';'] = 8, + ['!'] = 9, ["'"] = 10, ['('] = 11, [')'] = 11, ['['] = 12, [']'] = 12, ['{'] = 13, ['}'] = 14, + ['@'] = 15, ['$'] = 16, ['*'] = 17, ['&'] = 18, ['%'] = 19, ['+'] = 20, ['.'] = 22, ['#'] = 23, + } + default_order = 21 + end + + return function (a, b) + a = a:lower() + b = b:lower() + for i = 1, math.max(#a, #b) do + local ai = a:sub(i, i) + local bi = b:sub(i, i) + if ai == nil and bi then return true end + if bi == nil and ai then return false end + local a_order = symbol_order[ai] or default_order + local b_order = symbol_order[bi] or default_order + if a_order == b_order then + return a < b + else + return a_order < b_order + end + end + end +end)() + +-- Creates in-between frames to animate value from `from` to `to` numbers. +-- Returns function that terminates animation. +-- `to` can be a function that returns target value, useful for movable targets. +-- `speed` is an optional float between 1-instant and 0-infinite duration +-- `callback` is called either on animation end, or when animation is canceled +function tween(from, to, setter, speed, callback) + if type(speed) ~= 'number' then + callback = speed + speed = 0.3 + end + local timeout + local getTo = type(to) == 'function' and to or function() return to end + local cutoff = math.abs(getTo() - from) * 0.01 + function tick() + from = from + ((getTo() - from) * speed) + local is_end = math.abs(getTo() - from) <= cutoff + setter(is_end and getTo() or from) + request_render() + if is_end then + call_me_maybe(callback) + else + timeout:resume() + end + end + timeout = mp.add_timeout(0.016, tick) + tick() + return function() + timeout:kill() + call_me_maybe(callback) + end +end + +-- Kills ongoing animation if one is already running on this element. +-- Killed animation will not get its `on_end` called. +function tween_element(element, from, to, setter, speed, callback) + if type(speed) ~= 'number' then + callback = speed + speed = 0.3 + end + + tween_element_stop(element) + + element.stop_current_animation = tween( + from, to, + function(value) setter(element, value) end, + speed, + function() + element.stop_current_animation = nil + call_me_maybe(callback, element) + end + ) +end + +-- Stopped animation will not get its on_end called. +function tween_element_is_tweening(element) + return element and element.stop_current_animation +end + +-- Stopped animation will not get its on_end called. +function tween_element_stop(element) + call_me_maybe(element and element.stop_current_animation) +end + +-- Helper to automatically use an element property setter +function tween_element_property(element, prop, from, to, speed, callback) + tween_element(element, from, to, function(_, value) element[prop] = value end, speed, callback) +end + +function get_point_to_rectangle_proximity(point, rect) + local dx = math.max(rect.ax - point.x, 0, point.x - rect.bx + 1) + local dy = math.max(rect.ay - point.y, 0, point.y - rect.by + 1) + return math.sqrt(dx*dx + dy*dy); +end + +function text_width_estimate(letters, font_size) + return letters and letters * font_size * options.font_height_to_letter_width_ratio or 0 +end + +function opacity_to_alpha(opacity) + return 255 - math.ceil(255 * opacity) +end + +function ass_opacity(opacity, fraction) + fraction = fraction ~= nil and fraction or 1 + if type(opacity) == 'number' then + return string.format('{\\alpha&H%X&}', opacity_to_alpha(opacity * fraction)) + else + return string.format( + '{\\1a&H%X&\\2a&H%X&\\3a&H%X&\\4a&H%X&}', + opacity_to_alpha((opacity[1] or 0) * fraction), + opacity_to_alpha((opacity[2] or 0) * fraction), + opacity_to_alpha((opacity[3] or 0) * fraction), + opacity_to_alpha((opacity[4] or 0) * fraction) + ) + end +end + +-- Ensures path is absolute and normalizes slashes to the current platform +function normalize_path(path) + if not path or is_protocol(path) then return path end + + -- Ensure path is absolute + if not (path:match('^/') or path:match('^%a+:') or path:match('^\\\\')) then + path = utils.join_path(state.cwd, path) + end + + -- Use proper slashes + if state.os == 'windows' then + return path:gsub('/', '\\') + else + return path:gsub('\\', '/') + end +end + +-- Check if path is a protocol, such as `http://...` +function is_protocol(path) + return path:match('^%a[%a%d-_]+://') +end + +function get_extension(path) + local parts = split(path, '%.') + return parts and #parts > 1 and parts[#parts] or nil +end + +-- Serializes path into its semantic parts +function serialize_path(path) + if not path or is_protocol(path) then return end + path = normalize_path(path) + local parts = split(path, '[\\/]+') + if parts[#parts] == '' then table.remove(parts, #parts) end -- remove trailing separator + local basename = parts and parts[#parts] or path + local dirname = #parts > 1 and table.concat(itable_slice(parts, 1, #parts - 1), state.os == 'windows' and '\\' or '/') or nil + local dot_split = split(basename, '%.') + return { + path = path:sub(-1) == ':' and state.os == 'windows' and path..'\\' or path, + is_root = dirname == nil, + dirname = dirname, + basename = basename, + filename = #dot_split > 1 and table.concat(itable_slice(dot_split, 1, #dot_split - 1), '.') or basename, + extension = #dot_split > 1 and dot_split[#dot_split] or nil, + } +end + +function get_files_in_directory(directory, allowed_types) + local files, error = utils.readdir(directory, 'files') + + if not files then + msg.error('Retrieving files failed: '..(error or '')) + return + end + + -- Filter only requested file types + if allowed_types then + files = itable_filter(files, function(_, file) + local extension = get_extension(file) + return extension and itable_find(allowed_types, extension:lower()) + end) + end + + table.sort(files, word_order_comparator) + + return files +end + +function get_adjacent_file(file_path, direction, allowed_types) + local current_file = serialize_path(file_path) + local files = get_files_in_directory(current_file.dirname, allowed_types) + + if not files then return end + + for index, file in ipairs(files) do + if current_file.basename == file then + if direction == 'forward' then + if files[index + 1] then return utils.join_path(current_file.dirname, files[index + 1]) end + if options.directory_navigation_loops and files[1] then return utils.join_path(current_file.dirname, files[1]) end + else + if files[index - 1] then return utils.join_path(current_file.dirname, files[index - 1]) end + if options.directory_navigation_loops and files[#files] then return utils.join_path(current_file.dirname, files[#files]) end + end + + -- This is the only file in directory + return nil + end + end +end + +-- Ensures chapters are in chronological order +function get_normalized_chapters() + local chapters = mp.get_property_native('chapter-list') + + if not chapters then return end + + -- Copy table + chapters = itable_slice(chapters) + + -- Ensure chronological order of chapters + table.sort(chapters, function(a, b) return a.time < b.time end) + + return chapters +end + +-- Element +--[[ +Signature: +{ + -- enables capturing button groups for this element + captures = {mouse_buttons = true, wheel = true}, + -- element rectangle coordinates + ax = 0, ay = 0, bx = 0, by = 0, + -- cursor<>element relative proximity as a 0-1 floating number + -- where 0 = completely away, and 1 = touching/hovering + -- so it's easy to work with and throw into equations + proximity = 0, + -- raw cursor<>element proximity in pixels + proximity_raw = infinity, + -- called when element is created + ?init = function(this), + -- called manually when disposing of element + ?destroy = function(this), + -- triggered when event happens and cursor is above element + ?on_{event_name} = function(this), + -- triggered when any event happens anywhere on a page + ?on_global_{event_name} = function(this), + -- object + ?render = function(this_element), +} +]] +local Element = { + captures = nil, + ax = 0, ay = 0, bx = 0, by = 0, + proximity = 0, proximity_raw = infinity, +} +Element.__index = Element + +function Element.new(props) + local element = setmetatable(props, Element) + element._eventListeners = {} + + -- Flash timer + element._flash_out_timer = mp.add_timeout(options.flash_duration / 1000, function() + local getTo = function() return element.proximity end + element:tween_property('forced_proximity', 1, getTo, function() + element.forced_proximity = nil + end) + end) + element._flash_out_timer:kill() + + element:init() + + return element +end + +function Element:init() end +function Element:destroy() end + +-- Call method if it exists +function Element:maybe(name, ...) + if self[name] then return self[name](self, ...) end +end + +-- Tween helpers +function Element:tween(...) tween_element(self, ...) end +function Element:tween_property(...) tween_element_property(self, ...) end +function Element:tween_stop() tween_element_stop(self) end +function Element:is_tweening() tween_element_is_tweening(self) end + +-- Event listeners +function Element:on(name, handler) + if self._eventListeners[name] == nil then self._eventListeners[name] = {} end + local preexistingIndex = itable_find(self._eventListeners[name], handler) + if preexistingIndex then + return + else + self._eventListeners[name][#self._eventListeners[name] + 1] = handler + end +end +function Element:off(name, handler) + if self._eventListeners[name] == nil then return end + local index = itable_find(self._eventListeners, handler) + if index then table.remove(self._eventListeners, index) end +end +function Element:trigger(name, ...) + self:maybe('on_'..name, ...) + if self._eventListeners[name] == nil then return end + for _, handler in ipairs(self._eventListeners[name]) do handler(...) end +end + +-- Briefly flashes the element for `options.flash_duration` milliseconds. +-- Useful to visualize changes of volume and timeline when changed via hotkeys. +-- Implemented by briefly adding animated `forced_proximity` property to the element. +function Element:flash() + if options.flash_duration > 0 and (self.proximity < 1 or self._flash_out_timer:is_enabled()) then + self:tween_stop() + self.forced_proximity = 1 + self._flash_out_timer:kill() + self._flash_out_timer:resume() + end +end + +-- ELEMENTS + +local Elements = {itable = {}} +Elements.__index = Elements +local elements = setmetatable({}, Elements) + +function Elements:add(name, element) + local insert_index = #Elements.itable + 1 + + -- Replace if element already exists + if self:has(name) then + insert_index = itable_find(Elements.itable, function(_, element) + return element.name == name + end) + end + + element.name = name + Elements.itable[insert_index] = element + self[name] = element + + request_render() +end + +function Elements:remove(name, props) + Elements.itable = itable_remove(Elements.itable, self[name]) + self[name] = nil + request_render() +end + +function Elements:has(name) return self[name] ~= nil end +function Elements:ipairs() return ipairs(Elements.itable) end +function Elements:pairs(elements) return pairs(self) end + +-- MENU +--[[ +Usage: +``` +local items = { + {title = 'Foo title', hint = 'Ctrl+F', value = 'foo'}, + {title = 'Bar title', hint = 'Ctrl+B', value = 'bar'}, + { + title = 'Submenu', + items = { + {title = 'Sub item 1', value = 'sub1'}, + {title = 'Sub item 2', value = 'sub2'} + } + } +} + +function open_item(value) + value -- value from `item.value` +end + +menu:open(items, open_item) +``` +]] +local Menu = {} +Menu.__index = Menu +local menu = setmetatable({key_bindings = {}, is_closing = false}, Menu) + +function Menu:is_open(menu_type) + return elements.menu ~= nil and (not menu_type or elements.menu.type == menu_type) +end + +function Menu:open(items, open_item, opts) + opts = opts or {} + + if menu:is_open() then + if not opts.parent_menu then + menu:close(true, function() + menu:open(items, open_item, opts) + end) + return + end + else + menu:enable_key_bindings() + elements.curtain:fadein() + end + + elements:add('menu', Element.new({ + captures = {mouse_buttons = true}, + type = nil, -- menu type such as `menu`, `chapters`, ... + title = nil, + width = nil, + height = nil, + offset_x = 0, -- used to animated from/to left when submenu + item_height = nil, + item_spacing = 1, + item_content_spacing = nil, + font_size = nil, + scroll_step = nil, + scroll_height = nil, + scroll_y = 0, + opacity = 0, + relative_parent_opacity = 0.4, + items = items, + active_item = nil, + selected_item = nil, + open_item = open_item, + parent_menu = nil, + init = function(this) + -- Already initialized + if this.width ~= nil then return end + + -- Apply options + for key, value in pairs(opts) do this[key] = value end + this.selected_item = this.active_item + + -- Set initial dimensions + this:on_display_resize() + + -- Scroll to active item + this:scroll_to_item(this.active_item) + + -- Transition in animation + menu.transition = {to = 'child', target = this} + local start_offset = this.parent_menu and (this.parent_menu.width + this.width) / 2 or 0 + + tween_element(menu.transition.target, 0, 1, function(_, pos) + this:set_offset_x(round(start_offset * (1 - pos))) + this.opacity = pos + this:set_parent_opacity(1 - ((1 - config.menu_parent_opacity) * pos)) + end, function() + menu.transition = nil + update_proximities() + end) + end, + destroy = function(this) + request_render() + end, + on_display_resize = function(this) + this.item_height = (state.fullscreen or state.maximized) and options.menu_item_height_fullscreen or options.menu_item_height + this.font_size = round(this.item_height * 0.48 * options.menu_font_scale) + this.item_content_spacing = round((this.item_height - this.font_size) * 0.6) + this.scroll_step = this.item_height + this.item_spacing + + -- Estimate width of a widest item + local estimated_max_width = 0 + for _, item in ipairs(items) do + local item_text_length = ((item.title and item.title:len() or 0) + (item.hint and item.hint:len() or 0)) + local spacings_in_item = item.hint and 3 or 2 + local estimated_width = text_width_estimate(item_text_length, this.font_size) + (this.item_content_spacing * spacings_in_item) + if estimated_width > estimated_max_width then + estimated_max_width = estimated_width + end + end + + -- Also check menu title + local menu_title_length = this.title and this.title:len() or 0 + local estimated_menu_title_width = text_width_estimate(menu_title_length, this.font_size) + if estimated_menu_title_width > estimated_max_width then + estimated_max_width = estimated_menu_title_width + end + + -- Coordinates and sizes are of the scrollable area to make + -- consuming values in rendering easier. Title drawn above this, so + -- we need to account for that in max_height and ay position. + this.width = round(math.min(math.max(estimated_max_width, config.menu_min_width), display.width * 0.9)) + local title_height = this.title and this.scroll_step or 0 + local max_height = round(display.height * 0.9) - title_height + this.height = math.min(round(this.scroll_step * #items) - this.item_spacing, max_height) + this.scroll_height = math.max((this.scroll_step * #this.items) - this.height - this.item_spacing, 0) + this.ax = round((display.width - this.width) / 2) + this.offset_x + this.ay = round((display.height - this.height) / 2 + (title_height / 2)) + this.bx = round(this.ax + this.width) + this.by = round(this.ay + this.height) + + if this.parent_menu then + this.parent_menu:on_display_resize() + end + end, + set_items = function(this, items, props) + this.items = items + this.selected_item = nil + this.active_item = nil + if props then + for key, value in pairs(props) do this[key] = value end + end + this:on_display_resize() + request_render() + end, + set_offset_x = function(this, offset) + local delta = offset - this.offset_x + this.offset_x = offset + this.ax = this.ax + delta + this.bx = this.bx + delta + if this.parent_menu then + this.parent_menu:set_offset_x(offset - ((this.width + this.parent_menu.width) / 2) - this.item_spacing) + else + update_proximities() + end + end, + fadeout = function(this, callback) + this:tween(1, 0, function(this, pos) + this.opacity = pos + this:set_parent_opacity(pos * config.menu_parent_opacity) + end, callback) + end, + set_parent_opacity = function(this, opacity) + if this.parent_menu then + this.parent_menu.opacity = opacity + this.parent_menu:set_parent_opacity(opacity * config.menu_parent_opacity) + end + end, + get_item_index_below_cursor = function(this) + return math.ceil((cursor.y - this.ay + this.scroll_y) / this.scroll_step) + end, + get_first_visible_index = function(this) + return round(this.scroll_y / this.scroll_step) + 1 + end, + get_last_visible_index = function(this) + return round((this.scroll_y + this.height) / this.scroll_step) + end, + get_centermost_visible_index = function(this) + return round((this.scroll_y + (this.height / 2)) / this.scroll_step) + end, + scroll_to = function(this, pos) + this.scroll_y = math.max(math.min(pos, this.scroll_height), 0) + request_render() + end, + scroll_to_item = function(this, index) + if (index and index >= 1 and index <= #this.items) then + this:scroll_to(round((this.scroll_step * (index - 1)) - ((this.height - this.scroll_step) / 2))) + end + end, + select_index = function(this, index) + this.selected_item = (index and index >= 1 and index <= #this.items) and index or nil + request_render() + end, + select_value = function(this, value) + this:select_index(itable_find(this.items, function(_, item) return item.value == value end)) + end, + activate_index = function(this, index) + this.active_item = (index and index >= 1 and index <= #this.items) and index or nil + request_render() + end, + activate_value = function(this, value) + this:activate_index(itable_find(this.items, function(_, item) return item.value == value end)) + end, + delete_index = function(this, index) + if (index and index >= 1 and index <= #this.items) then + local previous_active_value = this.active_index and this.items[this.active_index].value or nil + table.remove(this.items, index) + this:on_display_resize() + if previous_active_value then this:activate_value(previous_active_value) end + this:scroll_to_item(this.selected_item) + end + end, + delete_value = function(this, value) + this:delete_index(itable_find(this.items, function(_, item) return item.value == value end)) + end, + prev = function(this) + local default_anchor = this.scroll_height > this.scroll_step and this:get_centermost_visible_index() or this:get_last_visible_index() + local current_index = this.selected_item or default_anchor + 1 + this.selected_item = math.max(current_index - 1, 1) + this:scroll_to_item(this.selected_item) + end, + next = function(this) + local default_anchor = this.scroll_height > this.scroll_step and this:get_centermost_visible_index() or this:get_first_visible_index() + local current_index = this.selected_item or default_anchor - 1 + this.selected_item = math.min(current_index + 1, #this.items) + this:scroll_to_item(this.selected_item) + end, + back = function(this) + if menu.transition then + local transition_target = menu.transition.target + local transition_target_type = menu.transition.target + tween_element_stop(transition_target) + if transition_target_type == 'parent' then + elements:add('menu', transition_target) + end + menu.transition = nil + transition_target:back() + return + else + menu.transition = {to = 'parent', target = this.parent_menu} + end + + if menu.transition.target == nil then + menu:close() + return + end + + local target = menu.transition.target + local to_offset = -target.offset_x + this.offset_x + + tween_element(target, 0, 1, function(_, pos) + this:set_offset_x(round(to_offset * pos)) + this.opacity = 1 - pos + this:set_parent_opacity(config.menu_parent_opacity + ((1 - config.menu_parent_opacity) * pos)) + end, function() + menu.transition = nil + elements:add('menu', target) + update_proximities() + end) + end, + open_selected_item = function(this) + -- If there is a transition active and this method got called, it + -- means we are animating from this menu to parent menu, and all + -- calls to this method should be relayed to the parent menu. + if menu.transition and menu.transition.to == 'parent' then + local target = menu.transition.target + tween_element_stop(target) + menu.transition = nil + target:open_selected_item() + return + end + + if this.selected_item then + local item = this.items[this.selected_item] + -- Is submenu + if item.items then + local opts = table_copy(opts) + opts.parent_menu = this + menu:open(item.items, this.open_item, opts) + else + menu:close(true) + this.open_item(item.value) + end + end + end, + close = function(this) + menu:close() + end, + on_global_mbtn_left_down = function(this) + if this.proximity_raw == 0 then + this.selected_item = this:get_item_index_below_cursor() + this:open_selected_item() + else + -- check if this is clicking on any parent menus + local parent_menu = this.parent_menu + repeat + if parent_menu then + if get_point_to_rectangle_proximity(cursor, parent_menu) == 0 then + this:back() + return + end + parent_menu = parent_menu.parent_menu + end + until parent_menu == nil + + menu:close() + end + end, + on_global_mouse_move = function(this) + if this.proximity_raw == 0 then + this.selected_item = this:get_item_index_below_cursor() + else + if this.selected_item then this.selected_item = nil end + end + request_render() + end, + on_wheel_up = function(this) + this.selected_item = nil + this:scroll_to(this.scroll_y - this.scroll_step) + -- Selects item below cursor + this:on_global_mouse_move() + request_render() + end, + on_wheel_down = function(this) + this.selected_item = nil + this:scroll_to(this.scroll_y + this.scroll_step) + -- Selects item below cursor + this:on_global_mouse_move() + request_render() + end, + on_pgup = function(this) + this.selected_item = nil + this:scroll_to(this.scroll_y - this.height) + end, + on_pgdwn = function(this) + this.selected_item = nil + this:scroll_to(this.scroll_y + this.height) + end, + on_home = function(this) + this.selected_item = nil + this:scroll_to(0) + end, + on_end = function(this) + this.selected_item = nil + this:scroll_to(this.scroll_height) + end, + render = render_menu, + })) + + elements.menu:maybe('on_open') +end + +function Menu:add_key_binding(key, name, fn, flags) + menu.key_bindings[#menu.key_bindings + 1] = name + mp.add_forced_key_binding(key, name, fn, flags) +end + +function Menu:enable_key_bindings() + menu.key_bindings = {} + -- The `mp.set_key_bindings()` method would be easier here, but that + -- doesn't support 'repeatable' flag, so we are stuck with this monster. + menu:add_key_binding('up', 'menu-prev', self:create_action('prev'), 'repeatable') + menu:add_key_binding('down', 'menu-next', self:create_action('next'), 'repeatable') + menu:add_key_binding('left', 'menu-back', self:create_action('back')) + menu:add_key_binding('right', 'menu-select', self:create_action('open_selected_item')) + + if options.menu_wasd_navigation then + menu:add_key_binding('w', 'menu-prev-alt', self:create_action('prev'), 'repeatable') + menu:add_key_binding('a', 'menu-back-alt', self:create_action('back')) + menu:add_key_binding('s', 'menu-next-alt', self:create_action('next'), 'repeatable') + menu:add_key_binding('d', 'menu-select-alt', self:create_action('open_selected_item')) + end + + if options.menu_hjkl_navigation then + menu:add_key_binding('h', 'menu-back-alt2', self:create_action('back')) + menu:add_key_binding('j', 'menu-next-alt2', self:create_action('next'), 'repeatable') + menu:add_key_binding('k', 'menu-prev-alt2', self:create_action('prev'), 'repeatable') + menu:add_key_binding('l', 'menu-select-alt2', self:create_action('open_selected_item')) + end + + menu:add_key_binding('mbtn_back', 'menu-back-alt3', self:create_action('back')) + menu:add_key_binding('bs', 'menu-back-alt4', self:create_action('back')) + menu:add_key_binding('enter', 'menu-select-alt3', self:create_action('open_selected_item')) + menu:add_key_binding('kp_enter', 'menu-select-alt4', self:create_action('open_selected_item')) + menu:add_key_binding('esc', 'menu-close', self:create_action('close')) + menu:add_key_binding('pgup', 'menu-page-up', self:create_action('on_pgup')) + menu:add_key_binding('pgdwn', 'menu-page-down', self:create_action('on_pgdwn')) + menu:add_key_binding('home', 'menu-home', self:create_action('on_home')) + menu:add_key_binding('end', 'menu-end', self:create_action('on_end')) +end + +function Menu:disable_key_bindings() + for _, name in ipairs(menu.key_bindings) do mp.remove_key_binding(name) end + menu.key_bindings = {} +end + +function Menu:create_action(name) + return function(...) + if elements.menu then elements.menu:maybe(name, ...) end + end +end + +function Menu:close(immediate, callback) + if type(immediate) ~= 'boolean' then callback = immediate end + + if elements:has('menu') and not menu.is_closing then + function close() + elements.menu:maybe('on_close') + elements.menu:destroy() + elements:remove('menu') + menu.is_closing = false + update_proximities() + menu:disable_key_bindings() + call_me_maybe(callback) + end + + menu.is_closing = true + elements.curtain:fadeout() + + if immediate then + close() + else + elements.menu:fadeout(close) + end + end +end + +-- ICONS +--[[ +ASS \shadN shadows are drawn also below the element, which when there is an +opacity in play, blends icon colors into ugly greys. The mess below is an +attempt to fix it by rendering shadows for icons with clipping. + +Add icons by adding functions to render them to `icons` table. + +Signature: function(pos_x, pos_y, size) => string + +Function has to return ass path coordinates to draw the icon centered at pox_x +and pos_y of passed size. +]] +local icons = {} +function icon(name, icon_x, icon_y, icon_size, shad_x, shad_y, shad_size, backdrop, opacity, clip) + local ass = assdraw.ass_new() + local icon_path = icons[name](icon_x, icon_y, icon_size) + local icon_color = options['color_'..backdrop..'_text'] + local shad_color = options['color_'..backdrop] + local use_border = (shad_x + shad_y) == 0 + local icon_border = use_border and shad_size or 0 + + -- clip can't clip out shadows, a very annoying limitation I can't work + -- around without going back to ugly default ass shadows, but atm I actually + -- don't need clipping of icons with shadows, so I'm choosing to ignore this + if not clip then + clip = '' + end + + if not use_border then + ass:new_event() + ass:append('{\\blur0\\bord0\\shad0\\1c&H'..shad_color..'\\iclip('..ass.scale..', '..icon_path..')}') + ass:append(ass_opacity(opacity)) + ass:pos(shad_x + shad_size, shad_y + shad_size) + ass:draw_start() + ass:append(icon_path) + ass:draw_stop() + end + + ass:new_event() + ass:append('{\\blur0\\bord'..icon_border..'\\shad0\\1c&H'..icon_color..'\\3c&H'..shad_color..clip..'}') + ass:append(ass_opacity(opacity)) + ass:pos(0, 0) + ass:draw_start() + ass:append(icon_path) + ass:draw_stop() + + return ass.text +end + +function icons._volume(muted, pos_x, pos_y, size) + local ass = assdraw.ass_new() + local scale = size / 200 + function x(number) return pos_x + (number * scale) end + function y(number) return pos_y + (number * scale) end + ass:move_to(x(-85), y(-35)) + ass:line_to(x(-50), y(-35)) + ass:line_to(x(-5), y(-75)) + ass:line_to(x(-5), y(75)) + ass:line_to(x(-50), y(35)) + ass:line_to(x(-85), y(35)) + if muted then + ass:move_to(x(76), y(-35)) ass:line_to(x(50), y(-9)) ass:line_to(x(24), y(-35)) + ass:line_to(x(15), y(-26)) ass:line_to(x(41), y(0)) ass:line_to(x(15), y(26)) + ass:line_to(x(24), y(35)) ass:line_to(x(50), y(9)) ass:line_to(x(76), y(35)) + ass:line_to(x(85), y(26)) ass:line_to(x(59), y(0)) ass:line_to(x(85), y(-26)) + else + ass:move_to(x(20), y(-30)) ass:line_to(x(20), y(30)) + ass:line_to(x(35), y(30)) ass:line_to(x(35), y(-30)) + + ass:move_to(x(55), y(-60)) ass:line_to(x(55), y(60)) + ass:line_to(x(70), y(60)) ass:line_to(x(70), y(-60)) + end + return ass.text +end +function icons.volume(pos_x, pos_y, size) return icons._volume(false, pos_x, pos_y, size) end +function icons.volume_muted(pos_x, pos_y, size) return icons._volume(true, pos_x, pos_y, size) end + +function icons.arrow_right(pos_x, pos_y, size) + local ass = assdraw.ass_new() + local scale = size / 200 + function x(number) return pos_x + (number * scale) end + function y(number) return pos_y + (number * scale) end + ass:move_to(x(-22), y(-80)) + ass:line_to(x(-45), y(-57)) + ass:line_to(x(12), y(0)) + ass:line_to(x(-45), y(57)) + ass:line_to(x(-22), y(80)) + ass:line_to(x(58), y(0)) + return ass.text +end + +-- STATE UPDATES + +function update_display_dimensions() + local o = mp.get_property_native('osd-dimensions') + display.width = o.w + display.height = o.h + display.aspect = o.aspect + + -- Tell elements about this + for _, element in elements:ipairs() do + if element.on_display_resize ~= nil then + element.on_display_resize(element) + end + end +end + +function update_element_cursor_proximity(element) + if cursor.hidden then + element.proximity_raw = infinity + element.proximity = 0 + else + local range = options.proximity_out - options.proximity_in + element.proximity_raw = get_point_to_rectangle_proximity(cursor, element) + element.proximity = menu:is_open() and 0 or 1 - (math.min(math.max(element.proximity_raw - options.proximity_in, 0), range) / range) + end +end + +function update_proximities() + local capture_mouse_buttons = false + local capture_wheel = false + local menu_only = menu:is_open() + local mouse_left_elements = {} + local mouse_entered_elements = {} + + -- Calculates proximities and opacities for defined elements + for _, element in elements:ipairs() do + local previous_proximity_raw = element.proximity_raw + + -- If menu is open, all other elements have to be disabled + if menu_only then + if element.name == 'menu' then + capture_mouse_buttons = true + capture_wheel = true + update_element_cursor_proximity(element) + else + element.proximity_raw = infinity + element.proximity = 0 + end + else + update_element_cursor_proximity(element) + end + + if element.proximity_raw == 0 then + -- Mouse is over element + if element.captures and element.captures.mouse_buttons then capture_mouse_buttons = true end + if element.captures and element.captures.wheel then capture_wheel = true end + + -- Mouse entered element area + if previous_proximity_raw ~= 0 then + mouse_entered_elements[#mouse_entered_elements + 1] = element + end + else + -- Mouse left element area + if previous_proximity_raw == 0 then + mouse_left_elements[#mouse_left_elements + 1] = element + end + end + end + + -- Enable key group captures elements request. + if capture_mouse_buttons then + forced_key_bindings.mouse_buttons:enable() + else + forced_key_bindings.mouse_buttons:disable() + end + if capture_wheel then + forced_key_bindings.wheel:enable() + else + forced_key_bindings.wheel:disable() + end + + -- Trigger `mouse_leave` and `mouse_enter` events + for _, element in ipairs(mouse_left_elements) do element:trigger('mouse_leave') end + for _, element in ipairs(mouse_entered_elements) do element:trigger('mouse_enter') end +end + +-- ELEMENT RENDERERS + +function render_timeline(this) + if this.size_max == 0 or state.duration == nil or state.duration == 0 or state.position == nil then return end + + local size_min = this:get_effective_size_min() + local size = this:get_effective_size() + + if size < 1 then return end + + local ass = assdraw.ass_new() + + -- Text opacity rapidly drops to 0 just before it starts overflowing, or before it reaches timeline.size_min + local hide_text_below = math.max(this.font_size * 0.7, size_min * 2) + local hide_text_ramp = hide_text_below / 2 + local text_opacity = math.max(math.min(size - hide_text_below, hide_text_ramp), 0) / hide_text_ramp + + local spacing = math.max(math.floor((this.size_max - this.font_size) / 2.5), 4) + local progress = state.position / state.duration + + -- Background bar coordinates + local bax = 0 + local bay = display.height - size - this.bottom_border - this.top_border + local bbx = display.width + local bby = display.height + + -- Foreground bar coordinates + local fax = bax + local fay = bay + this.top_border + local fbx = bbx * progress + local fby = bby - this.bottom_border + local foreground_size = bby - bay + local foreground_coordinates = fax..','..fay..','..fbx..','..fby -- for clipping + + -- Background + ass:new_event() + ass:append('{\\blur0\\bord0\\1c&H'..options.color_background..'\\iclip('..foreground_coordinates..')}') + ass:append(ass_opacity(math.max(options.timeline_opacity - 0.1, 0))) + ass:pos(0, 0) + ass:draw_start() + ass:rect_cw(bax, bay, bbx, bby) + ass:draw_stop() + + -- Foreground + ass:new_event() + ass:append('{\\blur0\\bord0\\1c&H'..options.color_foreground..'}') + ass:append(ass_opacity(options.timeline_opacity)) + ass:pos(0, 0) + ass:draw_start() + ass:rect_cw(fax, fay, fbx, fby) + ass:draw_stop() + + -- Seekable ranges + if options.timeline_cached_ranges and state.cached_ranges then + local range_height = math.max(foreground_size / 8, size_min) + local range_ay = fby - range_height + for _, range in ipairs(state.cached_ranges) do + ass:new_event() + ass:append('{\\blur0\\bord0\\1c&H'..options.timeline_cached_ranges.color..'}') + ass:append(ass_opacity(options.timeline_cached_ranges.opacity)) + ass:pos(0, 0) + ass:draw_start() + ass:rect_cw( + bbx * (range['start'] / state.duration), range_ay, + bbx * (range['end'] / state.duration), range_ay + range_height + ) + ass:draw_stop() + end + end + + -- Custom ranges + if state.chapter_ranges ~= nil then + for i, chapter_range in ipairs(state.chapter_ranges) do + for i, range in ipairs(chapter_range.ranges) do + local rax = display.width * (range['start'].time / state.duration) + local rbx = display.width * (range['end'].time / state.duration) + ass:new_event() + ass:append('{\\blur0\\bord0\\1c&H'..chapter_range.color..'}') + ass:append(ass_opacity(chapter_range.opacity)) + ass:pos(0, 0) + ass:draw_start() + -- for 1px chapter size, use the whole size of the bar including padding + if size <= 1 then + ass:rect_cw(rax, bay, rbx, bby) + else + ass:rect_cw(rax, fay, rbx, fby) + end + ass:draw_stop() + end + end + end + + -- Chapters + if options.chapters ~= 'none' and state.chapters ~= nil and #state.chapters > 0 then + local half_size = size / 2 + local dots = false + local chapter_size, chapter_y + if options.chapters == 'dots' then + dots = true + chapter_size = math.min(6, (foreground_size / 2) + 2) + chapter_y = math.min(fay + chapter_size, fay + half_size) + elseif options.chapters == 'lines' then + chapter_size = size + chapter_y = fay + (chapter_size / 2) + elseif options.chapters == 'lines-top' then + chapter_size = math.min(this.size_max / 3.5, size) + chapter_y = fay + (chapter_size / 2) + elseif options.chapters == 'lines-bottom' then + chapter_size = math.min(this.size_max / 3.5, size) + chapter_y = fay + size - (chapter_size / 2) + end + + if chapter_size ~= nil then + -- for 1px chapter size, use the whole size of the bar including padding + chapter_size = size <= 1 and foreground_size or chapter_size + local chapter_half_size = chapter_size / 2 + + for i, chapter in ipairs(state.chapters) do + local chapter_x = display.width * (chapter.time / state.duration) + local color = chapter_x > fbx and options.color_foreground or options.color_background + + ass:new_event() + ass:append('{\\blur0\\bord0\\1c&H'..color..'}') + ass:append(ass_opacity(options.chapters_opacity)) + ass:pos(0, 0) + ass:draw_start() + + if dots then + local bezier_stretch = chapter_size * 0.67 + ass:move_to(chapter_x - chapter_half_size, chapter_y) + ass:bezier_curve( + chapter_x - chapter_half_size, chapter_y - bezier_stretch, + chapter_x + chapter_half_size, chapter_y - bezier_stretch, + chapter_x + chapter_half_size, chapter_y + ) + ass:bezier_curve( + chapter_x + chapter_half_size, chapter_y + bezier_stretch, + chapter_x - chapter_half_size, chapter_y + bezier_stretch, + chapter_x - chapter_half_size, chapter_y + ) + else + ass:rect_cw(chapter_x, chapter_y - chapter_half_size, chapter_x + 1, chapter_y + chapter_half_size) + end + + ass:draw_stop() + end + end + end + + if text_opacity > 0 then + -- Elapsed time + if state.elapsed_seconds then + ass:new_event() + ass:append('{\\blur0\\bord0\\shad0\\1c&H'..options.color_foreground_text..'\\fn'..config.font..'\\fs'..this.font_size..bold_tag..'\\clip('..foreground_coordinates..')') + ass:append(ass_opacity(math.min(options.timeline_opacity + 0.1, 1), text_opacity)) + ass:pos(spacing, fay + (size / 2)) + ass:an(4) + ass:append(state.elapsed_time) + ass:new_event() + ass:append('{\\blur0\\bord0\\shad1\\1c&H'..options.color_background_text..'\\4c&H'..options.color_background..'\\fn'..config.font..'\\fs'..this.font_size..bold_tag..'\\iclip('..foreground_coordinates..')') + ass:append(ass_opacity(math.min(options.timeline_opacity + 0.1, 1), text_opacity)) + ass:pos(spacing, fay + (size / 2)) + ass:an(4) + ass:append(state.elapsed_time) + end + + -- Remaining time + if state.remaining_seconds then + ass:new_event() + ass:append('{\\blur0\\bord0\\shad0\\1c&H'..options.color_foreground_text..'\\fn'..config.font..'\\fs'..this.font_size..bold_tag..'\\clip('..foreground_coordinates..')') + ass:append(ass_opacity(math.min(options.timeline_opacity + 0.1, 1), text_opacity)) + ass:pos(display.width - spacing, fay + (size / 2)) + ass:an(6) + ass:append('-'..state.remaining_time) + ass:new_event() + ass:append('{\\blur0\\bord0\\shad1\\1c&H'..options.color_background_text..'\\4c&H'..options.color_background..'\\fn'..config.font..'\\fs'..this.font_size..bold_tag..'\\iclip('..foreground_coordinates..')') + ass:append(ass_opacity(math.min(options.timeline_opacity + 0.1, 1), text_opacity)) + ass:pos(display.width - spacing, fay + (size / 2)) + ass:an(6) + ass:append('-'..state.remaining_time) + end + end + + if (this.proximity_raw == 0 or this.pressed) and not (elements.speed and elements.speed.dragging) then + -- Hovered time + local hovered_seconds = state.duration * (cursor.x / display.width) + local box_half_width_guesstimate = (this.font_size * 4.2) / 2 + ass:new_event() + ass:append('{\\blur0\\bord1\\shad0\\1c&H'..options.color_background_text..'\\3c&H'..options.color_background..'\\fn'..config.font..'\\fs'..this.font_size..bold_tag..'') + ass:append(ass_opacity(math.min(options.timeline_opacity + 0.1, 1))) + ass:pos(math.min(math.max(cursor.x, box_half_width_guesstimate), display.width - box_half_width_guesstimate), fay) + ass:an(2) + ass:append(mp.format_time(hovered_seconds)) + + -- Cursor line + ass:new_event() + ass:append('{\\blur0\\bord0\\xshad-1\\yshad0\\1c&H'..options.color_foreground..'\\4c&H'..options.color_background..'}') + ass:append(ass_opacity(0.2)) + ass:pos(0, 0) + ass:draw_start() + ass:rect_cw(cursor.x, fay, cursor.x + 1, fby) + ass:draw_stop() + end + + return ass +end + +function render_top_bar(this) + local opacity = this:get_effective_proximity() + + if not this.enabled or opacity == 0 then return end + + local ass = assdraw.ass_new() + + if options.top_bar_controls then + -- Close button + local close = elements.window_controls_close + if close.proximity_raw == 0 then + -- Background on hover + ass:new_event() + ass:append('{\\blur0\\bord0\\1c&H2311e8}') + ass:append(ass_opacity(this.button_opacity, opacity)) + ass:pos(0, 0) + ass:draw_start() + ass:rect_cw(close.ax, close.ay, close.bx, close.by) + ass:draw_stop() + end + ass:new_event() + ass:append('{\\blur0\\bord1\\shad1\\3c&HFFFFFF\\4c&H000000}') + ass:append(ass_opacity(this.button_opacity, opacity)) + ass:pos(close.ax + (this.button_width / 2), (this.size / 2)) + ass:draw_start() + ass:move_to(-this.icon_size, this.icon_size) + ass:line_to(this.icon_size, -this.icon_size) + ass:move_to(-this.icon_size, -this.icon_size) + ass:line_to(this.icon_size, this.icon_size) + ass:draw_stop() + + -- Maximize button + local maximize = elements.window_controls_maximize + if maximize.proximity_raw == 0 then + -- Background on hover + ass:new_event() + ass:append('{\\blur0\\bord0\\1c&H222222}') + ass:append(ass_opacity(this.button_opacity, opacity)) + ass:pos(0, 0) + ass:draw_start() + ass:rect_cw(maximize.ax, maximize.ay, maximize.bx, maximize.by) + ass:draw_stop() + end + ass:new_event() + ass:append('{\\blur0\\bord2\\shad0\\1c\\3c&H000000}') + ass:append(ass_opacity({[3] = this.button_opacity}, opacity)) + ass:pos(maximize.ax + (this.button_width / 2), (this.size / 2)) + ass:draw_start() + ass:rect_cw(-this.icon_size + 1, -this.icon_size + 1, this.icon_size + 1, this.icon_size + 1) + ass:draw_stop() + ass:new_event() + ass:append('{\\blur0\\bord2\\shad0\\1c\\3c&HFFFFFF}') + ass:append(ass_opacity({[3] = this.button_opacity}, opacity)) + ass:pos(maximize.ax + (this.button_width / 2), (this.size / 2)) + ass:draw_start() + ass:rect_cw(-this.icon_size, -this.icon_size, this.icon_size, this.icon_size) + ass:draw_stop() + + -- Minimize button + local minimize = elements.window_controls_minimize + if minimize.proximity_raw == 0 then + -- Background on hover + ass:new_event() + ass:append('{\\blur0\\bord0\\1c&H222222}') + ass:append(ass_opacity(this.button_opacity, opacity)) + ass:pos(0, 0) + ass:draw_start() + ass:rect_cw(minimize.ax, minimize.ay, minimize.bx, minimize.by) + ass:draw_stop() + end + ass:new_event() + ass:append('{\\blur0\\bord1\\shad1\\3c&HFFFFFF\\4c&H000000}') + ass:append(ass_opacity(this.button_opacity, opacity)) + ass:append('{\\1a&HFF&}') + ass:pos(minimize.ax + (this.button_width / 2), (this.size / 2)) + ass:draw_start() + ass:move_to(-this.icon_size, 0) + ass:line_to(this.icon_size, 0) + ass:draw_stop() + end + + -- Window title + if options.top_bar_title and state.media_title then + local clip_coordinates = '0,0,'..(this.title_bx - this.spacing)..','..this.size + + ass:new_event() + ass:append('{\\q2\\blur0\\bord1\\shad0\\1c&HFFFFFF\\3c&H000000\\fn'..config.font..'\\fs'..this.font_size..bold_tag..'\\clip('..clip_coordinates..')') + ass:append(ass_opacity(1, opacity)) + ass:pos(0 + this.spacing, this.size / 2) + ass:an(4) + ass:append(state.media_title) + end + + return ass +end + +function render_volume(this) + local slider = elements.volume_slider + local opacity = this:get_effective_proximity() + + if this.width == 0 or opacity == 0 then return end + + local ass = assdraw.ass_new() + + if slider.height > 0 then + -- Background bar coordinates + local bax = slider.ax + local bay = slider.ay + local bbx = slider.bx + local bby = slider.by + + -- Foreground bar coordinates + local height_without_border = slider.height - (options.volume_border * 2) + local fax = slider.ax + options.volume_border + local fay = slider.ay + (height_without_border * (1 - math.min(state.volume / state.volume_max, 1))) + options.volume_border + local fbx = slider.bx - options.volume_border + local fby = slider.by - options.volume_border + + -- Path to draw a foreground bar with a 100% volume indicator, already + -- clipped by volume level. Can't just clip it with rectangle, as it itself + -- also needs to be used as a path to clip the background bar and volume + -- number. + local fpath = assdraw.ass_new() + fpath:move_to(fbx, fby) + fpath:line_to(fax, fby) + local nudge_bottom_y = slider.nudge_y + slider.nudge_size + if fay <= nudge_bottom_y and slider.draw_nudge then + fpath:line_to(fax, math.min(nudge_bottom_y)) + if fay <= slider.nudge_y then + fpath:line_to((fax + slider.nudge_size), slider.nudge_y) + local nudge_top_y = slider.nudge_y - slider.nudge_size + if fay <= nudge_top_y then + fpath:line_to(fax, nudge_top_y) + fpath:line_to(fax, fay) + fpath:line_to(fbx, fay) + fpath:line_to(fbx, nudge_top_y) + else + local triangle_side = fay - nudge_top_y + fpath:line_to((fax + triangle_side), fay) + fpath:line_to((fbx - triangle_side), fay) + end + fpath:line_to((fbx - slider.nudge_size), slider.nudge_y) + else + local triangle_side = nudge_bottom_y - fay + fpath:line_to((fax + triangle_side), fay) + fpath:line_to((fbx - triangle_side), fay) + end + fpath:line_to(fbx, nudge_bottom_y) + else + fpath:line_to(fax, fay) + fpath:line_to(fbx, fay) + end + fpath:line_to(fbx, fby) + + -- Background + ass:new_event() + ass:append('{\\blur0\\bord0\\1c&H'..options.color_background..'\\iclip('..fpath.scale..', '..fpath.text..')}') + ass:append(ass_opacity(math.max(options.volume_opacity - 0.1, 0), opacity)) + ass:pos(0, 0) + ass:draw_start() + ass:move_to(bax, bay) + ass:line_to(bbx, bay) + local half_border = options.volume_border / 2 + if slider.draw_nudge then + ass:line_to(bbx, math.max(slider.nudge_y - slider.nudge_size + half_border, bay)) + ass:line_to(bbx - slider.nudge_size + half_border, slider.nudge_y) + ass:line_to(bbx, slider.nudge_y + slider.nudge_size - half_border) + end + ass:line_to(bbx, bby) + ass:line_to(bax, bby) + if slider.draw_nudge then + ass:line_to(bax, slider.nudge_y + slider.nudge_size - half_border) + ass:line_to(bax + slider.nudge_size - half_border, slider.nudge_y) + ass:line_to(bax, math.max(slider.nudge_y - slider.nudge_size + half_border, bay)) + end + ass:line_to(bax, bay) + ass:draw_stop() + + -- Foreground + ass:new_event() + ass:append('{\\blur0\\bord0\\1c&H'..options.color_foreground..'}') + ass:append(ass_opacity(options.volume_opacity, opacity)) + ass:pos(0, 0) + ass:draw_start() + ass:append(fpath.text) + ass:draw_stop() + + -- Current volume value + local volume_string = tostring(round(state.volume * 10) / 10) + local font_size = round(((this.width * 0.6) - (#volume_string * (this.width / 20))) * options.volume_font_scale) + if fay < slider.by - slider.spacing then + ass:new_event() + ass:append('{\\blur0\\bord0\\shad0\\1c&H'..options.color_foreground_text..'\\fn'..config.font..'\\fs'..font_size..bold_tag..'\\clip('..fpath.scale..', '..fpath.text..')}') + ass:append(ass_opacity(math.min(options.volume_opacity + 0.1, 1), opacity)) + ass:pos(slider.ax + (slider.width / 2), slider.by - slider.spacing) + ass:an(2) + ass:append(volume_string) + end + if fay > slider.by - slider.spacing - font_size then + ass:new_event() + ass:append('{\\blur0\\bord0\\shad1\\1c&H'..options.color_background_text..'\\4c&H'..options.color_background..'\\fn'..config.font..'\\fs'..font_size..bold_tag..'\\iclip('..fpath.scale..', '..fpath.text..')}') + ass:append(ass_opacity(math.min(options.volume_opacity + 0.1, 1), opacity)) + ass:pos(slider.ax + (slider.width / 2), slider.by - slider.spacing) + ass:an(2) + ass:append(volume_string) + end + end + + -- Mute button + local mute = elements.volume_mute + local icon_name = state.mute and 'volume_muted' or 'volume' + ass:new_event() + ass:append(icon( + icon_name, + mute.ax + (mute.width / 2), mute.ay + (mute.height / 2), mute.width * 0.7, -- x, y, size + 0, 0, options.volume_border, -- shadow_x, shadow_y, shadow_size + 'background', options.volume_opacity * opacity -- backdrop, opacity + )) + return ass +end + +function render_speed(this) + if not this.dragging and (elements.curtain.opacity > 0) then return end + + local timeline = elements.timeline + local proximity = timeline:get_effective_proximity() + local opacity = this.forced_proximity and this.forced_proximity or (this.dragging and 1 or proximity) + + if opacity == 0 then return end + + local ass = assdraw.ass_new() + + -- Coordinates + local ax = this.ax + local ay = this.ay + timeline.size_max - timeline:get_effective_size() - timeline.top_border - timeline.bottom_border + local bx = this.bx + local by = ay + this.height + local half_width = (this.width / 2) + local half_x = ax + half_width + + -- Notches + local speed_at_center = state.speed + if this.dragging then + speed_at_center = this.dragging.start_speed + ((-this.dragging.distance / this.step_distance) * options.speed_step) + speed_at_center = math.min(math.max(speed_at_center, 0.01), 100) + end + local nearest_notch_speed = round(speed_at_center / this.notch_every) * this.notch_every + local nearest_notch_x = half_x + (((nearest_notch_speed - speed_at_center) / this.notch_every) * this.notch_spacing) + local guide_size = math.floor(this.height / 7.5) + local notch_by = by - guide_size + local notch_ay_big = ay + round(this.font_size * 1.1) + local notch_ay_medium = notch_ay_big + ((notch_by - notch_ay_big) * 0.2) + local notch_ay_small = notch_ay_big + ((notch_by - notch_ay_big) * 0.4) + local from_to_index = math.floor(this.notches / 2) + + for i = -from_to_index, from_to_index do + local notch_speed = nearest_notch_speed + (i * this.notch_every) + + if notch_speed < 0 or notch_speed > 100 then goto continue end + + local notch_x = nearest_notch_x + (i * this.notch_spacing) + local notch_thickness = 1 + local notch_ay = notch_ay_small + if (notch_speed % (this.notch_every * 10)) < 0.00000001 then + notch_ay = notch_ay_big + notch_thickness = 1 + elseif (notch_speed % (this.notch_every * 5)) < 0.00000001 then + notch_ay = notch_ay_medium + end + + ass:new_event() + ass:append('{\\blur0\\bord1\\shad0\\1c&HFFFFFF\\3c&H000000}') + ass:append(ass_opacity(math.min(1.2 - (math.abs((notch_x - ax - half_width) / half_width)), 1), opacity)) + ass:pos(0, 0) + ass:draw_start() + ass:move_to(notch_x - notch_thickness, notch_ay) + ass:line_to(notch_x + notch_thickness, notch_ay) + ass:line_to(notch_x + notch_thickness, notch_by) + ass:line_to(notch_x - notch_thickness, notch_by) + ass:draw_stop() + + ::continue:: + end + + -- Center guide + ass:new_event() + ass:append('{\\blur0\\bord1\\shad0\\1c&HFFFFFF\\3c&H000000}') + ass:append(ass_opacity(options.speed_opacity, opacity)) + ass:pos(0, 0) + ass:draw_start() + ass:move_to(half_x, by - 2 - guide_size) + ass:line_to(half_x + guide_size, by - 2) + ass:line_to(half_x - guide_size, by - 2) + ass:draw_stop() + + -- Speed value + local speed_text = (round(state.speed * 100) / 100)..'x' + ass:new_event() + ass:append('{\\blur0\\bord1\\shad0\\1c&H'..options.color_background_text..'\\3c&H'..options.color_background..'\\fn'..config.font..'\\fs'..this.font_size..bold_tag..'}') + ass:append(ass_opacity(options.speed_opacity, opacity)) + ass:pos(half_x, ay) + ass:an(8) + ass:append(speed_text) + + return ass +end + +function render_menu(this) + local ass = assdraw.ass_new() + + if this.parent_menu then + ass:merge(this.parent_menu:render()) + end + + -- Menu title + if this.title then + -- Background + ass:new_event() + ass:append('{\\blur0\\bord0\\1c&H'..options.color_background..'}') + ass:append(ass_opacity(options.menu_opacity, this.opacity)) + ass:pos(0, 0) + ass:draw_start() + ass:rect_cw(this.ax, this.ay - this.item_height, this.bx, this.ay - 1) + ass:draw_stop() + + -- Title + ass:new_event() + ass:append('{\\blur0\\bord0\\shad1\\b1\\1c&H'..options.color_background_text..'\\4c&H'..options.color_background..'\\fn'..config.font..'\\fs'..this.font_size..'\\q2\\clip('..this.ax..','..this.ay - this.item_height..','..this.bx..','..this.ay..')}') + ass:append(ass_opacity(options.menu_opacity, this.opacity)) + ass:pos(display.width / 2, this.ay - (this.item_height * 0.5)) + ass:an(5) + ass:append(this.title) + end + + local scroll_area_clip = '\\clip('..this.ax..','..this.ay..','..this.bx..','..this.by..')' + + for index, item in ipairs(this.items) do + local item_ay = this.ay - this.scroll_y + (this.item_height * (index - 1) + this.item_spacing * (index - 1)) + local item_by = item_ay + this.item_height + local item_clip = '' + + -- Clip items overflowing scroll area + if item_ay <= this.ay or item_by >= this.by then + item_clip = scroll_area_clip + end + + if item_by < this.ay or item_ay > this.by then goto continue end + + local is_active = this.active_item == index + local font_color, background_color, ass_shadow, ass_shadow_color + local icon_size = this.font_size + + if is_active then + font_color, background_color = options.color_foreground_text, options.color_foreground + ass_shadow, ass_shadow_color = '\\shad0', '' + else + font_color, background_color = options.color_background_text, options.color_background + ass_shadow, ass_shadow_color = '\\shad1', '\\4c&H'..background_color + end + + local has_submenu = item.items ~= nil + local hint_width = 0 + if item.hint then + hint_width = text_width_estimate(item.hint:len(), this.font_size) + this.item_content_spacing + elseif has_submenu then + hint_width = icon_size + this.item_content_spacing + end + + -- Background + ass:new_event() + ass:append('{\\blur0\\bord0\\1c&H'..background_color..item_clip..'}') + ass:append(ass_opacity(options.menu_opacity, this.opacity)) + ass:pos(0, 0) + ass:draw_start() + ass:rect_cw(this.ax, item_ay, this.bx, item_by) + ass:draw_stop() + + -- Selected highlight + if this.selected_item == index then + ass:new_event() + ass:append('{\\blur0\\bord0\\1c&H'..options.color_foreground..item_clip..'}') + ass:append(ass_opacity(0.1, this.opacity)) + ass:pos(0, 0) + ass:draw_start() + ass:rect_cw(this.ax, item_ay, this.bx, item_by) + ass:draw_stop() + end + + -- Title + if item.title then + item.ass_save_title = item.ass_save_title or item.title:gsub("([{}])","\\%1") + local title_clip_x = (this.bx - hint_width - this.item_content_spacing) + local title_clip = '\\clip('..this.ax..','..math.max(item_ay, this.ay)..','..title_clip_x..','..math.min(item_by, this.by)..')' + ass:new_event() + ass:append('{\\blur0\\bord0\\shad1\\1c&H'..font_color..'\\4c&H'..background_color..'\\fn'..config.font..'\\fs'..this.font_size..bold_tag..title_clip..'\\q2}') + ass:append(ass_opacity(options.menu_opacity, this.opacity)) + ass:pos(this.ax + this.item_content_spacing, item_ay + (this.item_height / 2)) + ass:an(4) + ass:append(item.ass_save_title) + end + + -- Hint + if item.hint then + item.ass_save_hint = item.ass_save_hint or item.hint:gsub("([{}])","\\%1") + ass:new_event() + ass:append('{\\blur0\\bord0'..ass_shadow..'\\1c&H'..font_color..''..ass_shadow_color..'\\fn'..config.font..'\\fs'..(this.font_size - 1)..bold_tag..item_clip..'}') + ass:append(ass_opacity(options.menu_opacity * (has_submenu and 1 or 0.5), this.opacity)) + ass:pos(this.bx - this.item_content_spacing, item_ay + (this.item_height / 2)) + ass:an(6) + ass:append(item.ass_save_hint) + elseif has_submenu then + ass:new_event() + ass:append(icon( + 'arrow_right', + this.bx - this.item_content_spacing - (icon_size / 2), -- x + item_ay + (this.item_height / 2), -- y + icon_size, -- size + 0, 0, 1, -- shadow_x, shadow_y, shadow_size + is_active and 'foreground' or 'background', this.opacity, -- backdrop, opacity + item_clip + )) + end + + ::continue:: + end + + -- Scrollbar + if this.scroll_height > 0 then + local groove_height = this.height - 2 + local thumb_height = math.max((this.height / (this.scroll_height + this.height)) * groove_height, 40) + local thumb_y = this.ay + 1 + ((this.scroll_y / this.scroll_height) * (groove_height - thumb_height)) + ass:new_event() + ass:append('{\\blur0\\bord0\\1c&H'..options.color_foreground..'}') + ass:append(ass_opacity(options.menu_opacity, this.opacity * 0.8)) + ass:pos(0, 0) + ass:draw_start() + ass:rect_cw(this.bx - 3, thumb_y, this.bx - 1, thumb_y + thumb_height) + ass:draw_stop() + end + + return ass +end + +-- MAIN RENDERING + +-- Request that render() is called. +-- The render is then either executed immediately, or rate-limited if it was +-- called a small time ago. +function request_render() + if state.render_timer == nil then + state.render_timer = mp.add_timeout(0, render) + end + + if not state.render_timer:is_enabled() then + local now = mp.get_time() + local timeout = config.render_delay - (now - state.render_last_time) + if timeout < 0 then + timeout = 0 + end + state.render_timer.timeout = timeout + state.render_timer:resume() + end +end + +function render() + state.render_last_time = mp.get_time() + + -- Actual rendering + local ass = assdraw.ass_new() + + for _, element in elements.ipairs() do + local result = element:maybe('render') + if result then + ass:new_event() + ass:merge(result) + end + end + + -- submit + if osd.res_x == display.width and osd.res_y == display.height and osd.data == ass.text then + return + end + + osd.res_x = display.width + osd.res_y = display.height + osd.data = ass.text + osd.z = 2000 + osd:update() +end + +-- STATIC ELEMENTS + +if itable_find({'flash', 'static'}, options.pause_indicator) then + elements:add('pause_indicator', Element.new({ + base_icon_opacity = options.pause_indicator == 'flash' and 1 or 0.8, + paused = false, + is_flash = options.pause_indicator == 'flash', + is_static = options.pause_indicator == 'static', + opacity = 0, + init = function(this) + local initial_call = true + mp.observe_property('pause', 'bool', function(_, paused) + if initial_call then + initial_call = false + return + end + + this.paused = paused + + if options.pause_indicator == 'flash' then + this.opacity = 1 + this:tween_property('opacity', 1, 0, 0.15) + else + this.opacity = paused and 1 or 0 + request_render() + end + + end) + end, + render = function(this) + if this.opacity == 0 then return end + + local ass = assdraw.ass_new() + + -- Background fadeout + if this.is_static then + ass:new_event() + ass:append('{\\blur0\\bord0\\1c&H'..options.color_background..'}') + ass:append(ass_opacity(0.3, this.opacity)) + ass:pos(0, 0) + ass:draw_start() + ass:rect_cw(0, 0, display.width, display.height) + ass:draw_stop() + end + + -- Icon + local size = round((math.min(display.width, display.height) * (this.is_static and 0.20 or 0.15)) / 2) + + size = size + size * (1 - this.opacity) + + if this.paused then + ass:new_event() + ass:append('{\\blur0\\bord1\\1c&H'..options.color_foreground..'\\3c&H'..options.color_background..'}') + ass:append(ass_opacity(this.base_icon_opacity, this.opacity)) + ass:pos(display.width / 2, display.height / 2) + ass:draw_start() + ass:rect_cw(-size, -size, -size / 3, size) + ass:draw_stop() + + ass:new_event() + ass:append('{\\blur0\\bord1\\1c&H'..options.color_foreground..'\\3c&H'..options.color_background..'}') + ass:append(ass_opacity(this.base_icon_opacity, this.opacity)) + ass:pos(display.width / 2, display.height / 2) + ass:draw_start() + ass:rect_cw(size / 3, -size, size, size) + ass:draw_stop() + elseif this.is_flash then + ass:new_event() + ass:append('{\\blur0\\bord1\\1c&H'..options.color_foreground..'\\3c&H'..options.color_background..'}') + ass:append(ass_opacity(this.base_icon_opacity, this.opacity)) + ass:pos(display.width / 2, display.height / 2) + ass:draw_start() + ass:move_to(-size * 0.6, -size) + ass:line_to(size, 0) + ass:line_to(-size * 0.6, size) + ass:draw_stop() + end + + return ass + end + })) +end +elements:add('timeline', Element.new({ + captures = {mouse_buttons = true, wheel = true}, + pressed = false, + size_max = 0, size_min = 0, -- set in `on_display_resize` handler based on `state.fullscreen` + size_min_override = options.timeline_start_hidden and 0 or nil, -- used for toggle-progress command + font_size = 0, -- calculated in on_display_resize + top_border = options.timeline_border, + bottom_border = 0, -- set dynamically in `border` property observer + init = function(this) + -- Toggle 1px bottom border for timeline in no-border mode + mp.observe_property('border', 'bool', function(_, border) + this.bottom_border = not border and options.timeline_border or 0 + request_render() + end) + end, + get_effective_proximity = function(this) + if (elements.volume_slider and elements.volume_slider.pressed) then return 0 end + if this.pressed then return 1 end + return this.forced_proximity and this.forced_proximity or this.proximity + end, + get_effective_size_min = function(this) + return this.size_min_override or this.size_min + end, + get_effective_size = function(this) + if elements.speed and elements.speed.dragging then return this.size_max end + local size_min = this:get_effective_size_min() + return size_min + math.ceil((this.size_max - size_min) * this:get_effective_proximity()) + end, + on_display_resize = function(this) + if state.fullscreen or state.maximized then + this.size_min = options.timeline_size_min_fullscreen + this.size_max = options.timeline_size_max_fullscreen + else + this.size_min = options.timeline_size_min + this.size_max = options.timeline_size_max + end + this.font_size = math.floor(math.min((this.size_max + 60) * 0.2, this.size_max * 0.96) * options.timeline_font_scale) + this.ax = 0 + this.ay = display.height - this.size_max - this.top_border - this.bottom_border + this.bx = display.width + this.by = display.height + end, + set_from_cursor = function(this) + mp.commandv('seek', ((cursor.x / display.width) * 100), 'absolute-percent+exact') + end, + on_mbtn_left_down = function(this) + this.pressed = true + this:set_from_cursor() + end, + on_global_mbtn_left_up = function(this) this.pressed = false end, + on_global_mouse_leave = function(this) this.pressed = false end, + on_global_mouse_move = function(this) + if this.pressed then this:set_from_cursor() end + end, + on_wheel_up = function(this) + if options.timeline_step > 0 then mp.commandv('seek', -options.timeline_step) end + end, + on_wheel_down = function(this) + if options.timeline_step > 0 then mp.commandv('seek', options.timeline_step) end + end, + render = render_timeline, +})) +if options.top_bar_controls or options.top_bar_title then + elements:add('top_bar', Element.new({ + button_opacity = 0.8, + enabled = false, + init = function(this) + mp.observe_property('border', 'bool', function(_, border) + this.enabled = not border + end) + end, + get_effective_proximity = function(this) + if (elements.volume_slider and elements.volume_slider.pressed) or elements.curtain.opacity > 0 then return 0 end + return this.forced_proximity and this.forced_proximity or this.proximity + end, + on_display_resize = function(this) + this.size = (state.fullscreen or state.maximized) and options.top_bar_size_fullscreen or options.top_bar_size + this.icon_size = round(this.size / 8) + this.spacing = math.ceil(this.size * 0.25) + this.font_size = math.floor(this.size - (this.spacing * 2)) + this.button_width = round(this.size * 1.15) + this.title_bx = display.width - (options.top_bar_controls and (this.button_width * 3) or 0) + this.ax = options.top_bar_title and 0 or this.title_bx + this.ay = 0 + this.bx = display.width + this.by = this.size + end, + render = render_top_bar, + })) +end +if options.top_bar_controls then + elements:add('window_controls_minimize', Element.new({ + captures = {mouse_buttons = true}, + on_display_resize = function(this) + this.ax = display.width - (elements.top_bar.button_width * 3) + this.ay = 0 + this.bx = this.ax + elements.top_bar.button_width + this.by = elements.top_bar.size + end, + on_mbtn_left_down = function() mp.commandv('cycle', 'window-minimized') end + })) + elements:add('window_controls_maximize', Element.new({ + captures = {mouse_buttons = true}, + on_display_resize = function(this) + this.ax = display.width - (elements.top_bar.button_width * 2) + this.ay = 0 + this.bx = this.ax + elements.top_bar.button_width + this.by = elements.top_bar.size + end, + on_mbtn_left_down = function() mp.commandv('cycle', 'window-maximized') end + })) + elements:add('window_controls_close', Element.new({ + captures = {mouse_buttons = true}, + on_display_resize = function(this) + this.ax = display.width - elements.top_bar.button_width + this.ay = 0 + this.bx = this.ax + elements.top_bar.button_width + this.by = elements.top_bar.size + end, + on_mbtn_left_down = function() mp.commandv('quit') end + })) +end +if itable_find({'left', 'right'}, options.volume) then + elements:add('volume', Element.new({ + width = nil, -- set in `on_display_resize` handler based on `state.fullscreen` + height = nil, -- set in `on_display_resize` handler based on `state.fullscreen` + margin = nil, -- set in `on_display_resize` handler based on `state.fullscreen` + get_effective_proximity = function(this) + if elements.volume_slider.pressed then return 1 end + if elements.timeline.proximity_raw == 0 or elements.curtain.opacity > 0 then return 0 end + return this.forced_proximity and this.forced_proximity or this.proximity + end, + on_display_resize = function(this) + this.width = (state.fullscreen or state.maximized) and options.volume_size_fullscreen or options.volume_size + this.height = round(math.min(this.width * 8, (elements.timeline.ay - elements.top_bar.size) * 0.8)) + -- Don't bother rendering this if too small + if this.height < (this.width * 2) then + this.height = 0 + end + this.margin = this.width / 2 + this.ax = round(options.volume == 'left' and this.margin or display.width - this.margin - this.width) + this.ay = round((display.height - this.height) / 2) + this.bx = round(this.ax + this.width) + this.by = round(this.ay + this.height) + end, + render = render_volume, + })) + elements:add('volume_mute', Element.new({ + captures = {mouse_buttons = true}, + width = 0, + height = 0, + on_display_resize = function(this) + this.width = elements.volume.width + this.height = this.width + this.ax = elements.volume.ax + this.ay = elements.volume.by - this.height + this.bx = elements.volume.bx + this.by = elements.volume.by + end, + on_mbtn_left_down = function(this) mp.commandv('cycle', 'mute') end + })) + elements:add('volume_slider', Element.new({ + captures = {mouse_buttons = true, wheel = true}, + pressed = false, + width = 0, + height = 0, + nudge_y = 0, -- vertical position where volume overflows 100 + nudge_size = nil, -- set on resize + font_size = nil, + spacing = nil, + on_display_resize = function(this) + this.ax = elements.volume.ax + this.ay = elements.volume.ay + this.bx = elements.volume.bx + this.by = elements.volume_mute.ay + this.width = this.bx - this.ax + this.height = this.by - this.ay + this.nudge_y = this.by - round(this.height * (100 / state.volume_max)) + this.nudge_size = round(elements.volume.width * 0.18) + this.draw_nudge = this.ay < this.nudge_y + this.spacing = round(this.width * 0.2) + end, + set_from_cursor = function(this) + local volume_fraction = (this.by - cursor.y - options.volume_border) / (this.height - options.volume_border) + local new_volume = math.min(math.max(volume_fraction, 0), 1) * state.volume_max + new_volume = round(new_volume / options.volume_step) * options.volume_step + if state.volume ~= new_volume then mp.commandv('set', 'volume', math.min(new_volume, state.volume_max)) end + end, + on_mbtn_left_down = function(this) + this.pressed = true + this:set_from_cursor() + end, + on_global_mbtn_left_up = function(this) this.pressed = false end, + on_global_mouse_leave = function(this) this.pressed = false end, + on_global_mouse_move = function(this) + if this.pressed then this:set_from_cursor() end + end, + on_wheel_up = function(this) + local current_rounded_volume = round(state.volume / options.volume_step) * options.volume_step + mp.commandv('set', 'volume', math.min(current_rounded_volume + options.volume_step, state.volume_max)) + end, + on_wheel_down = function(this) + local current_rounded_volume = round(state.volume / options.volume_step) * options.volume_step + mp.commandv('set', 'volume', math.min(current_rounded_volume - options.volume_step, state.volume_max)) + end, + })) +end +if options.speed then + elements:add('speed', Element.new({ + captures = {mouse_buttons = true, wheel = true}, + dragging = nil, + width = 0, + height = 0, + notches = 10, + notch_every = 0.1, + step_distance = nil, + font_size = nil, + init = function(this) + -- Fade out/in on timeline mouse enter/leave + elements.timeline:on('mouse_enter', function() + if not this.dragging then this:fadeout() end + end) + elements.timeline:on('mouse_leave', function() + if not this.dragging then this:fadein() end + end) + end, + fadeout = function(this) + this:tween_property('forced_proximity', 1, 0, function(this) + this.forced_proximity = 0 + end) + end, + fadein = function(this) + local get_current_proximity = function() return this.proximity end + this:tween_property('forced_proximity', 0, get_current_proximity, function(this) + this.forced_proximity = nil + end) + end, + on_display_resize = function(this) + this.height = (state.fullscreen or state.maximized) and options.speed_size_fullscreen or options.speed_size + this.width = round(this.height * 3.6) + this.notch_spacing = this.width / this.notches + this.step_distance = this.notch_spacing * (options.speed_step / this.notch_every) + this.ax = (display.width - this.width) / 2 + this.by = display.height - elements.timeline.size_max + this.ay = this.by - this.height + this.bx = this.ax + this.width + this.font_size = round(this.height * 0.48 * options.speed_font_scale) + end, + set_from_cursor = function(this) + local volume_fraction = (this.by - cursor.y - options.volume_border) / (this.height - options.volume_border) + local new_volume = math.min(math.max(volume_fraction, 0), 1) * state.volume_max + new_volume = round(new_volume / options.volume_step) * options.volume_step + if state.volume ~= new_volume then mp.commandv('set', 'volume', new_volume) end + end, + on_mbtn_left_down = function(this) + this:tween_stop() -- Stop and cleanup possible ongoing animations + this.dragging = { + start_time = mp.get_time(), + start_x = cursor.x, + distance = 0, + start_speed = state.speed + } + end, + on_global_mouse_move = function(this) + if not this.dragging then return end + + this.dragging.distance = cursor.x - this.dragging.start_x + local steps_dragged = round(-this.dragging.distance / this.step_distance) + local new_speed = this.dragging.start_speed + (steps_dragged * options.speed_step) + mp.set_property_native('speed', round(new_speed * 100) / 100) + end, + on_mbtn_left_up = function(this) + -- Reset speed on short clicks + if this.dragging and math.abs(this.dragging.distance) < 6 and mp.get_time() - this.dragging.start_time < 0.15 then + mp.set_property_native('speed', 1) + end + end, + on_global_mbtn_left_up = function(this) + if this.dragging and elements.timeline.proximity_raw == 0 then + this:fadeout() + end + this.dragging = nil + request_render() + end, + on_global_mouse_leave = function(this) + this.dragging = nil + request_render() + end, + on_wheel_up = function(this) + mp.set_property_native('speed', state.speed - options.speed_step) + end, + on_wheel_down = function(this) + mp.set_property_native('speed', state.speed + options.speed_step) + end, + render = render_speed, + })) +end +elements:add('curtain', Element.new({ + opacity = 0, + fadeout = function(this) + this:tween_property('opacity', this.opacity, 0); + end, + fadein = function(this) + this:tween_property('opacity', this.opacity, 1); + end, + render = function(this) + if this.opacity > 0 then + local ass = assdraw.ass_new() + ass:new_event() + ass:append('{\\blur0\\bord0\\1c&H'..options.color_background..'}') + ass:append(ass_opacity(0.4, this.opacity)) + ass:pos(0, 0) + ass:draw_start() + ass:rect_cw(0, 0, display.width, display.height) + ass:draw_stop() + return ass + end + end +})) + +-- CHAPTERS SERIALIZATION + +-- Parse `chapter_ranges` option into workable data structure +for _, definition in ipairs(split(options.chapter_ranges, ' *,+ *')) do + local start_patterns, color, opacity, end_patterns = string.match(definition, '([^<]+)<(%x%x%x%x%x%x):(%d?%.?%d*)>([^>]+)') + + -- Invalid definition + if start_patterns == nil then goto continue end + + start_patterns = start_patterns:lower() + end_patterns = end_patterns:lower() + local uses_bof = start_patterns:find('{bof}') ~= nil + local uses_eof = end_patterns:find('{eof}') ~= nil + local chapter_range = { + start_patterns = split(start_patterns, '|'), + end_patterns = split(end_patterns, '|'), + color = color, + opacity = tonumber(opacity), + ranges = {} + } + + -- Filter out special keywords so we don't use them when matching titles + if uses_bof then + chapter_range.start_patterns = itable_remove(chapter_range.start_patterns, '{bof}') + end + if uses_eof and chapter_range.end_patterns then + chapter_range.end_patterns = itable_remove(chapter_range.end_patterns, '{eof}') + end + + chapter_range['serialize'] = function (chapters) + chapter_range.ranges = {} + local current_range = nil + -- bof and eof should be used only once per timeline + -- eof is only used when last range is missing end + local bof_used = false + + function start_range(chapter) + -- If there is already a range started, should we append or overwrite? + -- I chose overwrite here. + current_range = {['start'] = chapter} + end + + function end_range(chapter) + current_range['end'] = chapter + chapter_range.ranges[#chapter_range.ranges + 1] = current_range + -- Mark both chapter objects + current_range['start']._uosc_used_as_range_point = true + current_range['end']._uosc_used_as_range_point = true + -- Clear for next range + current_range = nil + end + + for _, chapter in ipairs(chapters) do + if type(chapter.title) == 'string' then + local lowercase_title = chapter.title:lower() + local is_end = false + local is_start = false + + -- Is ending check and handling + if chapter_range.end_patterns then + for _, end_pattern in ipairs(chapter_range.end_patterns) do + is_end = is_end or lowercase_title:find(end_pattern) ~= nil + end + + if is_end then + if current_range == nil and uses_bof and not bof_used then + bof_used = true + start_range({time = 0}) + end + if current_range ~= nil then + end_range(chapter) + else + is_end = false + end + end + end + + -- Is start check and handling + for _, start_pattern in ipairs(chapter_range.start_patterns) do + is_start = is_start or lowercase_title:find(start_pattern) ~= nil + end + + if is_start then start_range(chapter) end + end + end + + -- If there is an unfinished range and range type accepts eof, use it + if current_range ~= nil and uses_eof then + end_range({time = state.duration or infinity}) + end + end + + state.chapter_ranges = state.chapter_ranges or {} + state.chapter_ranges[#state.chapter_ranges + 1] = chapter_range + + ::continue:: +end + +function parse_chapters() + -- Sometimes state.duration is not initialized yet for some reason + state.duration = mp.get_property_native('duration') + + local chapters = get_normalized_chapters() + + if not chapters or not state.duration then return end + + -- Reset custom ranges + for _, chapter_range in ipairs(state.chapter_ranges or {}) do + chapter_range.serialize(chapters) + end + + -- Filter out chapters that were used as ranges + state.chapters = itable_remove(chapters, function(chapter) + return chapter._uosc_used_as_range_point == true + end) + + request_render() +end + +-- CONTEXT MENU SERIALIZATION + +state.context_menu_items = (function() + local input_conf_path = mp.command_native({'expand-path', '~~/input.conf'}) + local input_conf_meta, meta_error = utils.file_info(input_conf_path) + + -- File doesn't exist + if not input_conf_meta or not input_conf_meta.is_file then return end + + local items = {} + local items_by_command = {} + local submenus_by_id = {} + + for line in io.lines(input_conf_path) do + local key, command, title = string.match(line, ' *([%S]+) +(.*) #! *(.*)') + if key then + local is_dummy = key:sub(1, 1) == '#' + local submenu_id = '' + local target_menu = items + local title_parts = split(title or '', ' *> *') + + for index, title_part in ipairs(#title_parts > 0 and title_parts or {''}) do + if index < #title_parts then + submenu_id = submenu_id .. title_part + + if not submenus_by_id[submenu_id] then + submenus_by_id[submenu_id] = {title = title_part, items = {}} + target_menu[#target_menu + 1] = submenus_by_id[submenu_id] + end + + target_menu = submenus_by_id[submenu_id].items + else + -- If command is already in menu, just append the key to it + if items_by_command[command] then + items_by_command[command].hint = items_by_command[command].hint..', '..key + else + items_by_command[command] = { + title = title_part, + hint = not is_dummy and key or nil, + value = command + } + target_menu[#target_menu + 1] = items_by_command[command] + end + end + end + end + end + + if #items > 0 then return items end +end)() + +-- EVENT HANDLERS + +function create_state_setter(name) + return function(_, value) + state[name] = value + dispatch_event_to_elements('prop_'..name, value) + request_render() + end +end + +function dispatch_event_to_elements(name, ...) + for _, element in pairs(elements) do + if element.proximity_raw == 0 then + element:maybe('on_'..name, ...) + end + element:maybe('on_global_'..name, ...) + end +end + +function create_event_to_elements_dispatcher(name, ...) + return function(...) dispatch_event_to_elements(name, ...) end +end + +function handle_mouse_leave() + -- Slowly fadeout elements that are currently visible + for _, element_name in ipairs({'timeline', 'volume', 'top_bar'}) do + local element = elements[element_name] + if element and element.proximity > 0 then + element:tween_property('forced_proximity', element:get_effective_proximity(), 0, function() + element.forced_proximity = nil + end) + end + end + + cursor.hidden = true + update_proximities() + dispatch_event_to_elements('mouse_leave') +end + +function handle_mouse_enter() + cursor.hidden = false + cursor.x, cursor.y = mp.get_mouse_pos() + tween_element_stop(state) + dispatch_event_to_elements('mouse_enter') +end + +function handle_mouse_move() + -- Handle case when we are in cursor hidden state but not left the actual + -- window (i.e. when autohide simulates mouse_leave). + if cursor.hidden then + handle_mouse_enter() + return + end + + cursor.x, cursor.y = mp.get_mouse_pos() + update_proximities() + dispatch_event_to_elements('mouse_move') + request_render() + + -- Restart timer that hides UI when mouse is autohidden + if options.autohide then + state.cursor_autohide_timer:kill() + state.cursor_autohide_timer:resume() + end +end + +function navigate_directory(direction) + local path = mp.get_property_native("path") + + if not path or is_protocol(path) then return end + + local next_file = get_adjacent_file(path, direction, options.media_types) + + if next_file then + mp.commandv("loadfile", utils.join_path(serialize_path(path).dirname, next_file)) + end +end + +function load_file_in_current_directory(index) + local path = mp.get_property_native("path") + + if not path or is_protocol(path) then return end + + local dirname = serialize_path(path).dirname + local files = get_files_in_directory(dirname, options.media_types) + + if not files then return end + if index < 0 then index = #files + index + 1 end + + if files[index] then + mp.commandv("loadfile", utils.join_path(dirname, files[index])) + end +end + +-- MENUS + +function create_select_tracklist_type_menu_opener(menu_title, track_type, track_prop) + return function() + if menu:is_open(track_type) then menu:close() return end + + local items = {} + local active_item = nil + + for index, track in ipairs(mp.get_property_native('track-list')) do + if track.type == track_type then + if track.selected then active_item = track.id end + + items[#items + 1] = { + title = (track.title and track.title or 'Track '..track.id), + hint = track.lang and track.lang:upper() or nil, + value = track.id + } + end + end + + -- Add option to disable a subtitle track. This works for all tracks, + -- but why would anyone want to disable audio or video? Better to not + -- let people mistakenly select what is unwanted 99.999% of the time. + -- If I'm mistaken and there is an active need for this, feel free to + -- open an issue. + if track_type == 'sub' then + active_item = active_item and active_item + 1 or 1 + table.insert(items, 1, {hint = 'disabled', value = nil}) + end + + menu:open(items, function(id) + mp.commandv('set', track_prop, id and id or 'no') + + -- If subtitle track was selected, assume user also wants to see it + if id and track_type == 'sub' then + mp.commandv('set', 'sub-visibility', 'yes') + end + + menu:close() + end, {type = track_type, title = menu_title, active_item = active_item}) + end +end + +-- `menu_options`: +-- **allowed_types** - table with file extensions to display +-- **active_path** - full path of a file to preselect +-- Rest of the options are passed to `menu:open()` +function open_file_navigation_menu(directory, handle_select, menu_options) + directory = serialize_path(directory) + local directories, error = utils.readdir(directory.path, 'dirs') + local files, error = get_files_in_directory(directory.path, menu_options.allowed_types) + + if not files or not directories then + msg.error('Retrieving files from '..directory..' failed: '..(error or '')) + return + end + + -- Files are already sorted + table.sort(directories, word_order_comparator) + + -- Pre-populate items with parent directory selector if not at root + local items = not directory.dirname and {} or { + {title = '..', hint = 'parent dir', value = directory.dirname} + } + + for _, dir in ipairs(directories) do + local serialized = serialize_path(utils.join_path(directory.path, dir)) + items[#items + 1] = {title = serialized.basename, value = serialized.path, hint = '/'} + end + + menu_options.active_item = nil + + for _, file in ipairs(files) do + local serialized = serialize_path(utils.join_path(directory.path, file)) + local item_index = #items + 1 + + items[item_index] = { + title = serialized.basename, + value = serialized.path, + } + + if menu_options.active_path == serialized.path then + menu_options.active_item = item_index + end + end + + menu_options.title = directory.basename..'/' + + menu:open(items, function(path) + local meta, error = utils.file_info(path) + + if not meta then + msg.error('Retrieving file info for '..path..' failed: '..(error or '')) + return + end + + if meta.is_dir then + open_file_navigation_menu(path, handle_select, menu_options) + else + handle_select(path) + menu:close() + end + end, menu_options) +end + +-- VALUE SERIALIZATION/NORMALIZATION + +options.proximity_out = math.max(options.proximity_out, options.proximity_in + 1) +options.chapters = itable_find({'dots', 'lines', 'lines-top', 'lines-bottom'}, options.chapters) and options.chapters or 'none' +options.media_types = split(options.media_types, ' *, *') +options.subtitle_types = split(options.subtitle_types, ' *, *') +options.timeline_cached_ranges = (function() + if options.timeline_cached_ranges == '' or options.timeline_cached_ranges == 'no' then return nil end + local parts = split(options.timeline_cached_ranges, ':') + return parts[1] and {color = parts[1], opacity = tonumber(parts[2])} or nil +end)() + +-- HOOKS +mp.register_event('file-loaded', parse_chapters) +mp.observe_property('chapter-list', 'native', parse_chapters) +mp.observe_property('duration', 'number', create_state_setter('duration')) +mp.observe_property('media-title', 'string', create_state_setter('media_title')) +mp.observe_property('fullscreen', 'bool', create_state_setter('fullscreen')) +mp.observe_property('window-maximized', 'bool', create_state_setter('maximized')) +mp.observe_property('idle-active', 'bool', create_state_setter('idle')) +mp.observe_property('speed', 'number', create_state_setter('speed')) +mp.observe_property('pause', 'bool', create_state_setter('pause')) +mp.observe_property('volume', 'number', create_state_setter('volume')) +mp.observe_property('volume-max', 'number', create_state_setter('volume_max')) +mp.observe_property('mute', 'bool', create_state_setter('mute')) +mp.observe_property('playback-time', 'number', function(name, val) + -- Ignore the initial call with nil value + if val == nil then return end + + state.position = val + state.elapsed_seconds = val + state.elapsed_time = state.elapsed_seconds and mp.format_time(state.elapsed_seconds) or nil + state.remaining_seconds = mp.get_property_native('playtime-remaining') + state.remaining_time = state.remaining_seconds and mp.format_time(state.remaining_seconds) or nil + + request_render() +end) +mp.observe_property('osd-dimensions', 'native', function(name, val) + update_display_dimensions() + request_render() +end) +mp.observe_property('demuxer-cache-state', 'native', function(prop, cache_state) + if cache_state == nil then + state.cached_ranges = nil + return + end + local cache_ranges = cache_state['seekable-ranges'] + state.cached_ranges = #cache_ranges > 0 and cache_ranges or nil +end) + +-- CONTROLS + +-- Mouse movement key binds +local base_keybinds = { + {'mouse_move', handle_mouse_move}, + {'mouse_leave', handle_mouse_leave}, + {'mouse_enter', handle_mouse_enter}, +} +if options.pause_on_click_shorter_than > 0 then + -- Cycles pause when click is shorter than `options.pause_on_click_shorter_than` + -- while filtering out double clicks. + local duration_seconds = options.pause_on_click_shorter_than / 1000 + local last_down_event; + local click_timer = mp.add_timeout(duration_seconds, function() + mp.command('cycle pause') + end); + click_timer:kill() + base_keybinds[#base_keybinds + 1] = {'mbtn_left', function() + if mp.get_time() - last_down_event < duration_seconds then + click_timer:resume() + end + end, function() + if click_timer:is_enabled() then + click_timer:kill() + last_down_event = 0 + else + last_down_event = mp.get_time() + end + end + } +end +mp.set_key_bindings(base_keybinds, 'mouse_movement', 'force') +mp.enable_key_bindings('mouse_movement', 'allow-vo-dragging+allow-hide-cursor') + +-- Context based key bind groups + +forced_key_bindings = (function() + mp.set_key_bindings({ + {'mbtn_left', create_event_to_elements_dispatcher('mbtn_left_up'), create_event_to_elements_dispatcher('mbtn_left_down')}, + {'mbtn_left_dbl', 'ignore'}, + }, 'mouse_buttons', 'force') + mp.set_key_bindings({ + {'wheel_up', create_event_to_elements_dispatcher('wheel_up')}, + {'wheel_down', create_event_to_elements_dispatcher('wheel_down')}, + }, 'wheel', 'force') + + local groups = {} + for _, group in ipairs({'mouse_buttons', 'wheel'}) do + groups[group] = { + is_enabled = false, + enable = function(this) + if this.is_enabled then return end + this.is_enabled = true + mp.enable_key_bindings(group) + end, + disable = function(this) + if not this.is_enabled then return end + this.is_enabled = false + mp.disable_key_bindings(group) + end, + } + end + return groups +end)() + +-- KEY BINDABLE FEATURES + +mp.add_key_binding(nil, 'peek-timeline', function() + if elements.timeline.proximity > 0.5 then + elements.timeline:tween_property('proximity', elements.timeline.proximity, 0) + else + elements.timeline:tween_property('proximity', elements.timeline.proximity, 1) + end +end) +mp.add_key_binding(nil, 'toggle-progress', function() + local timeline = elements.timeline + if timeline.size_min_override then + timeline:tween_property('size_min_override', timeline.size_min_override, timeline.size_min, function() + timeline.size_min_override = nil + end) + else + timeline:tween_property('size_min_override', timeline.size_min, 0) + end +end) +mp.add_key_binding(nil, 'flash-timeline', function() + elements.timeline:flash() +end) +mp.add_key_binding(nil, 'flash-volume', function() + if elements.volume then elements.volume:flash() end +end) +mp.add_key_binding(nil, 'flash-speed', function() + if elements.speed then elements.speed:flash() end +end) +mp.add_key_binding(nil, 'menu', function() + if menu:is_open('menu') then + menu:close() + elseif state.context_menu_items then + menu:open(state.context_menu_items, function(command) + mp.command(command) + end, {type = 'menu'}) + end +end) +mp.add_key_binding(nil, 'load-subtitles', function() + if menu:is_open('load-subtitles') then menu:close() return end + + local path = mp.get_property_native('path') + if path and not is_protocol(path) then + open_file_navigation_menu( + serialize_path(path).dirname, + function(path) mp.commandv('sub-add', path) end, + { + type = 'load-subtitles', + allowed_types = options.subtitle_types + } + ) + end +end) +mp.add_key_binding(nil, 'subtitles', create_select_tracklist_type_menu_opener('Subtitles', 'sub', 'sid')) +mp.add_key_binding(nil, 'audio', create_select_tracklist_type_menu_opener('Audio', 'audio', 'aid')) +mp.add_key_binding(nil, 'video', create_select_tracklist_type_menu_opener('Video', 'video', 'vid')) +mp.add_key_binding(nil, 'playlist', function() + if menu:is_open('playlist') then menu:close() return end + + function serialize_playlist() + local pos = mp.get_property_number('playlist-pos-1', 0) + local items = {} + local active_item + for index, item in ipairs(mp.get_property_native('playlist')) do + local is_url = item.filename:find('://') + items[index] = { + title = is_url and item.filename or serialize_path(item.filename).basename, + hint = tostring(index), + value = index + } + + if index == pos then active_item = index end + end + return items, active_item + end + + -- Update active index and playlist content on playlist changes + function handle_playlist_change() + if menu:is_open('playlist') then + local items, active_item = serialize_playlist() + elements.menu:set_items(items, { + active_item = active_item, + selected_item = active_item + }) + end + end + + local items, active_item = serialize_playlist() + + menu:open(items, function(index) + mp.commandv('set', 'playlist-pos-1', tostring(index)) + end, { + type = 'playlist', + title = 'Playlist', + active_item = active_item, + on_open = function() + mp.observe_property('playlist', 'native', handle_playlist_change) + mp.observe_property('playlist-pos-1', 'native', handle_playlist_change) + end, + on_close = function() + mp.unobserve_property(handle_playlist_change) + end, + }) +end) +mp.add_key_binding(nil, 'chapters', function() + if menu:is_open('chapters') then menu:close() return end + + local items = {} + local chapters = get_normalized_chapters() + + for index, chapter in ipairs(chapters) do + items[#items + 1] = { + title = chapter.title or '', + hint = mp.format_time(chapter.time), + value = chapter.time + } + end + + -- Select first chapter from the end with time lower + -- than current playing position (with 100ms leeway). + function get_selected_chapter_index() + local position = mp.get_property_native('playback-time') + if not position then return nil end + for index = #items, 1, -1 do + if position - 0.1 > items[index].value then return index end + end + end + + -- Update selected chapter in chapter navigation menu + function seek_handler() + if menu:is_open('chapters') then + elements.menu:activate_index(get_selected_chapter_index()) + end + end + + menu:open(items, function(time) + mp.commandv('seek', tostring(time), 'absolute') + end, { + type = 'chapters', + title = 'Chapters', + active_item = get_selected_chapter_index(), + on_open = function() mp.register_event('seek', seek_handler) end, + on_close = function() mp.unregister_event(seek_handler) end + }) +end) +mp.add_key_binding(nil, 'show-in-directory', function() + local path = mp.get_property_native('path') + + -- Ignore URLs + if not path or is_protocol(path) then return end + + path = normalize_path(path) + + if state.os == 'windows' then + utils.subprocess_detached({args = {'explorer', '/select,', path}, cancellable = false}) + elseif state.os == 'macos' then + utils.subprocess_detached({args = {'open', '-R', path}, cancellable = false}) + elseif state.os == 'linux' then + local result = utils.subprocess({args = {'nautilus', path}, cancellable = false}) + + -- Fallback opens the folder with xdg-open instead + if result.status ~= 0 then + utils.subprocess({args = {'xdg-open', serialize_path(path).dirname}, cancellable = false}) + end + end +end) +mp.add_key_binding(nil, 'open-file', function() + if menu:is_open('open-file') then menu:close() return end + + local path = mp.get_property_native('path') + local directory + local active_file + + if path == nil or is_protocol(path) then + local path = serialize_path(mp.command_native({'expand-path', '~/'})) + directory = path.path + active_file = nil + else + local path = serialize_path(path) + directory = path.dirname + active_file = path.path + end + + -- Update selected file in directory navigation menu + function handle_file_loaded() + if menu:is_open('open-file') then + local path = normalize_path(mp.get_property_native('path')) + elements.menu:activate_value(path) + elements.menu:select_value(path) + end + end + + open_file_navigation_menu( + directory, + function(path) mp.commandv('loadfile', path) end, + { + type = 'open-file', + allowed_types = options.media_types, + active_path = active_file, + on_open = function() mp.register_event('file-loaded', handle_file_loaded) end, + on_close = function() mp.unregister_event(handle_file_loaded) end, + } + ) +end) +mp.add_key_binding(nil, 'next', function() + if mp.get_property_native('playlist-count') > 1 then + mp.command('playlist-next') + else + navigate_directory('forward') + end +end) +mp.add_key_binding(nil, 'prev', function() + if mp.get_property_native('playlist-count') > 1 then + mp.command('playlist-prev') + else + navigate_directory('backward') + end +end) +mp.add_key_binding(nil, 'next-file', function() navigate_directory('forward') end) +mp.add_key_binding(nil, 'prev-file', function() navigate_directory('backward') end) +mp.add_key_binding(nil, 'first', function() + if mp.get_property_native('playlist-count') > 1 then + mp.commandv('set', 'playlist-pos-1', '1') + else + load_file_in_current_directory(1) + end +end) +mp.add_key_binding(nil, 'last', function() + local playlist_count = mp.get_property_native('playlist-count') + if playlist_count > 1 then + mp.commandv('set', 'playlist-pos-1', tostring(playlist_count)) + else + load_file_in_current_directory(-1) + end +end) +mp.add_key_binding(nil, 'first-file', function() load_file_in_current_directory(1) end) +mp.add_key_binding(nil, 'last-file', function() load_file_in_current_directory(-1) end) +mp.add_key_binding(nil, 'delete-file-next', function() + local path = mp.get_property_native('path') + + if not path or is_protocol(path) then return end + + path = normalize_path(path) + local playlist_count = mp.get_property_native('playlist-count') + + if playlist_count > 1 then + mp.commandv('playlist-remove', 'current') + else + local next_file = get_adjacent_file(path, 'forward', options.media_types) + + if menu:is_open('open-file') then + elements.menu:delete_value(path) + end + + if next_file then + mp.commandv('loadfile', next_file) + else + mp.commandv('stop') + end + end + + os.remove(path) +end) +mp.add_key_binding(nil, 'delete-file-quit', function() + local path = mp.get_property_native('path') + if not path or is_protocol(path) then return end + os.remove(normalize_path(path)) + mp.command('quit') +end) +mp.add_key_binding(nil, 'open-config-directory', function() + local config = serialize_path(mp.command_native({'expand-path', '~~/mpv.conf'})) + local args + + if state.os == 'windows' then + args = {'explorer', '/select,', config.path} + elseif state.os == 'macos' then + args = {'open', '-R', config.path} + elseif state.os == 'linux' then + args = {'xdg-open', config.dirname} + end + + utils.subprocess_detached({args = args, cancellable = false}) +end) diff --git a/mpv/scripts/webm.lua b/mpv/scripts/webm.lua new file mode 100644 index 0000000..edfc164 --- /dev/null +++ b/mpv/scripts/webm.lua @@ -0,0 +1,2689 @@ +local mp = require("mp") +local assdraw = require("mp.assdraw") +local msg = require("mp.msg") +local utils = require("mp.utils") +local mpopts = require("mp.options") +local options = { + -- Defaults to shift+w + keybind = "W", + -- If empty, saves on the same directory of the playing video. + -- A starting "~" will be replaced by the home dir. + -- This field is delimited by double-square-brackets - [[ and ]] - instead of + -- quotes, because Windows users might run into a issue when using + -- backslashes as a path separator. Examples of valid inputs for this field + -- would be: [[]] (the default, empty value), [[C:\Users\John]] (on Windows), + -- and [[/home/john]] (on Unix-like systems eg. Linux). + output_directory = [[]], + run_detached = false, + -- Template string for the output file + -- %f - Filename, with extension + -- %F - Filename, without extension + -- %T - Media title, if it exists, or filename, with extension (useful for some streams, such as YouTube). + -- %s, %e - Start and end time, with milliseconds + -- %S, %E - Start and end time, without milliseconds + -- %M - "-audio", if audio is enabled, empty otherwise + -- %R - "-(height)p", where height is the video's height, or scale_height, if it's enabled. + -- More specifiers are supported, see https://mpv.io/manual/master/#options-screenshot-template + -- Property expansion is supported (with %{} at top level, ${} when nested), see https://mpv.io/manual/master/#property-expansion + output_template = "%F-[%s-%e]%M", + -- Scale video to a certain height, keeping the aspect ratio. -1 disables it. + scale_height = -1, + -- Change the FPS of the output video, dropping or duplicating frames as needed. + -- -1 means the FPS will be unchanged from the source. + fps = -1, + -- Target filesize, in kB. This will be used to calculate the bitrate + -- used on the encode. If this is set to <= 0, the video bitrate will be set + -- to 0, which might enable constant quality modes, depending on the + -- video codec that's used (VP8 and VP9, for example). + target_filesize = 2500, + -- If true, will use stricter flags to ensure the resulting file doesn't + -- overshoot the target filesize. Not recommended, as constrained quality + -- mode should work well, unless you're really having trouble hitting + -- the target size. + strict_filesize_constraint = false, + strict_bitrate_multiplier = 0.95, + -- In kilobits. + strict_audio_bitrate = 64, + -- Sets the output format, from a few predefined ones. + -- Currently we have webm-vp8 (libvpx/libvorbis), webm-vp9 (libvpx-vp9/libvorbis) + -- and raw (rawvideo/pcm_s16le). + output_format = "webm-vp8", + twopass = false, + -- If set, applies the video filters currently used on the playback to the encode. + apply_current_filters = true, + -- If set, writes the video's filename to the "Title" field on the metadata. + write_filename_on_metadata = false, + -- Set the number of encoding threads, for codecs libvpx and libvpx-vp9 + libvpx_threads = 4, + additional_flags = "", + -- Constant Rate Factor (CRF). The value meaning and limits may change, + -- from codec to codec. Set to -1 to disable. + crf = 10, + -- Useful for flags that may impact output filesize, such as qmin, qmax etc + -- Won't be applied when strict_filesize_constraint is on. + non_strict_additional_flags = "", + -- Display the encode progress, in %. Requires run_detached to be disabled. + -- On Windows, it shows a cmd popup. "auto" will display progress on non-Windows platforms. + display_progress = "auto", + -- The font size used in the menu. Isn't used for the notifications (started encode, finished encode etc) + font_size = 28, + margin = 10, + message_duration = 5 +} + +mpopts.read_options(options) +local base64_chars='ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/' + +-- encoding +function base64_encode(data) + return ((data:gsub('.', function(x) + local r,b='',x:byte() + for i=8,1,-1 do r=r..(b%2^i-b%2^(i-1)>0 and '1' or '0') end + return r; + end)..'0000'):gsub('%d%d%d?%d?%d?%d?', function(x) + if (#x < 6) then return '' end + local c=0 + for i=1,6 do c=c+(x:sub(i,i)=='1' and 2^(6-i) or 0) end + return base64_chars:sub(c+1,c+1) + end)..({ '', '==', '=' })[#data%3+1]) +end + +-- decoding +function base64_decode(data) + data = string.gsub(data, '[^'..base64_chars..'=]', '') + return (data:gsub('.', function(x) + if (x == '=') then return '' end + local r,f='',(base64_chars:find(x)-1) + for i=6,1,-1 do r=r..(f%2^i-f%2^(i-1)>0 and '1' or '0') end + return r; + end):gsub('%d%d%d?%d?%d?%d?%d?%d?', function(x) + if (#x ~= 8) then return '' end + local c=0 + for i=1,8 do c=c+(x:sub(i,i)=='1' and 2^(8-i) or 0) end + return string.char(c) + end)) +end +local bold +bold = function(text) + return "{\\b1}" .. tostring(text) .. "{\\b0}" +end +local message +message = function(text, duration) + local ass = mp.get_property_osd("osd-ass-cc/0") + ass = ass .. text + return mp.osd_message(ass, duration or options.message_duration) +end +local append +append = function(a, b) + for _, val in ipairs(b) do + a[#a + 1] = val + end + return a +end +local seconds_to_time_string +seconds_to_time_string = function(seconds, no_ms, full) + if seconds < 0 then + return "unknown" + end + local ret = "" + if not (no_ms) then + ret = string.format(".%03d", seconds * 1000 % 1000) + end + ret = string.format("%02d:%02d%s", math.floor(seconds / 60) % 60, math.floor(seconds) % 60, ret) + if full or seconds > 3600 then + ret = string.format("%d:%s", math.floor(seconds / 3600), ret) + end + return ret +end +local seconds_to_path_element +seconds_to_path_element = function(seconds, no_ms, full) + local time_string = seconds_to_time_string(seconds, no_ms, full) + local _ + time_string, _ = time_string:gsub(":", ".") + return time_string +end +local file_exists +file_exists = function(name) + local info, err = utils.file_info(name) + if info ~= nil then + return true + end + return false +end +local expand_properties +expand_properties = function(text, magic) + if magic == nil then + magic = "$" + end + for prefix, raw, prop, colon, fallback, closing in text:gmatch("%" .. magic .. "{([?!]?)(=?)([^}:]*)(:?)([^}]*)(}*)}") do + local err + local prop_value + local compare_value + local original_prop = prop + local get_property = mp.get_property_osd + if raw == "=" then + get_property = mp.get_property + end + if prefix ~= "" then + for actual_prop, compare in prop:gmatch("(.-)==(.*)") do + prop = actual_prop + compare_value = compare + end + end + if colon == ":" then + prop_value, err = get_property(prop, fallback) + else + prop_value, err = get_property(prop, "(error)") + end + prop_value = tostring(prop_value) + if prefix == "?" then + if compare_value == nil then + prop_value = err == nil and fallback .. closing or "" + else + prop_value = prop_value == compare_value and fallback .. closing or "" + end + prefix = "%" .. prefix + elseif prefix == "!" then + if compare_value == nil then + prop_value = err ~= nil and fallback .. closing or "" + else + prop_value = prop_value ~= compare_value and fallback .. closing or "" + end + else + prop_value = prop_value .. closing + end + if colon == ":" then + local _ + text, _ = text:gsub("%" .. magic .. "{" .. prefix .. raw .. original_prop:gsub("%W", "%%%1") .. ":" .. fallback:gsub("%W", "%%%1") .. closing .. "}", expand_properties(prop_value)) + else + local _ + text, _ = text:gsub("%" .. magic .. "{" .. prefix .. raw .. original_prop:gsub("%W", "%%%1") .. closing .. "}", prop_value) + end + end + return text +end +local format_filename +format_filename = function(startTime, endTime, videoFormat) + local hasAudioCodec = videoFormat.audioCodec ~= "" + local replaceFirst = { + ["%%mp"] = "%%mH.%%mM.%%mS", + ["%%mP"] = "%%mH.%%mM.%%mS.%%mT", + ["%%p"] = "%%wH.%%wM.%%wS", + ["%%P"] = "%%wH.%%wM.%%wS.%%wT" + } + local replaceTable = { + ["%%wH"] = string.format("%02d", math.floor(startTime / (60 * 60))), + ["%%wh"] = string.format("%d", math.floor(startTime / (60 * 60))), + ["%%wM"] = string.format("%02d", math.floor(startTime / 60 % 60)), + ["%%wm"] = string.format("%d", math.floor(startTime / 60)), + ["%%wS"] = string.format("%02d", math.floor(startTime % 60)), + ["%%ws"] = string.format("%d", math.floor(startTime)), + ["%%wf"] = string.format("%s", startTime), + ["%%wT"] = string.sub(string.format("%.3f", startTime % 1), 3), + ["%%mH"] = string.format("%02d", math.floor(endTime / (60 * 60))), + ["%%mh"] = string.format("%d", math.floor(endTime / (60 * 60))), + ["%%mM"] = string.format("%02d", math.floor(endTime / 60 % 60)), + ["%%mm"] = string.format("%d", math.floor(endTime / 60)), + ["%%mS"] = string.format("%02d", math.floor(endTime % 60)), + ["%%ms"] = string.format("%d", math.floor(endTime)), + ["%%mf"] = string.format("%s", endTime), + ["%%mT"] = string.sub(string.format("%.3f", endTime % 1), 3), + ["%%f"] = mp.get_property("filename"), + ["%%F"] = mp.get_property("filename/no-ext"), + ["%%s"] = seconds_to_path_element(startTime), + ["%%S"] = seconds_to_path_element(startTime, true), + ["%%e"] = seconds_to_path_element(endTime), + ["%%E"] = seconds_to_path_element(endTime, true), + ["%%T"] = mp.get_property("media-title"), + ["%%M"] = (mp.get_property_native('aid') and not mp.get_property_native('mute') and hasAudioCodec) and '-audio' or '', + ["%%R"] = (options.scale_height ~= -1) and "-" .. tostring(options.scale_height) .. "p" or "-" .. tostring(mp.get_property_native('height')) .. "p", + ["%%t%%"] = "%%" + } + local filename = options.output_template + for format, value in pairs(replaceFirst) do + local _ + filename, _ = filename:gsub(format, value) + end + for format, value in pairs(replaceTable) do + local _ + filename, _ = filename:gsub(format, value) + end + if mp.get_property_bool("demuxer-via-network", false) then + local _ + filename, _ = filename:gsub("%%X{([^}]*)}", "%1") + filename, _ = filename:gsub("%%x", "") + else + local x = string.gsub(mp.get_property("stream-open-filename", ""), string.gsub(mp.get_property("filename", ""), "%W", "%%%1") .. "$", "") + local _ + filename, _ = filename:gsub("%%X{[^}]*}", x) + filename, _ = filename:gsub("%%x", x) + end + filename = expand_properties(filename, "%") + for format in filename:gmatch("%%t([aAbBcCdDeFgGhHIjmMnprRStTuUVwWxXyYzZ])") do + local _ + filename, _ = filename:gsub("%%t" .. format, os.date("%" .. format)) + end + local _ + filename, _ = filename:gsub("[<>:\"/\\|?*]", "") + return tostring(filename) .. "." .. tostring(videoFormat.outputExtension) +end +local parse_directory +parse_directory = function(dir) + local home_dir = os.getenv("HOME") + if not home_dir then + home_dir = os.getenv("USERPROFILE") + end + if not home_dir then + local drive = os.getenv("HOMEDRIVE") + local path = os.getenv("HOMEPATH") + if drive and path then + home_dir = utils.join_path(drive, path) + else + msg.warn("Couldn't find home dir.") + home_dir = "" + end + end + local _ + dir, _ = dir:gsub("^~", home_dir) + return dir +end +local is_windows = type(package) == "table" and type(package.config) == "string" and package.config:sub(1, 1) == "\\" +local trim +trim = function(s) + return s:match("^%s*(.-)%s*$") +end +local get_null_path +get_null_path = function() + if file_exists("/dev/null") then + return "/dev/null" + end + return "NUL" +end +local run_subprocess +run_subprocess = function(params) + local res = utils.subprocess(params) + msg.verbose("Command stdout: ") + msg.verbose(res.stdout) + if res.status ~= 0 then + msg.verbose("Command failed! Reason: ", res.error, " Killed by us? ", res.killed_by_us and "yes" or "no") + return false + end + return true +end +local shell_escape +shell_escape = function(args) + local ret = { } + for i, a in ipairs(args) do + local s = tostring(a) + if string.match(s, "[^A-Za-z0-9_/:=-]") then + if is_windows then + s = '"' .. string.gsub(s, '"', '"\\""') .. '"' + else + s = "'" .. string.gsub(s, "'", "'\\''") .. "'" + end + end + table.insert(ret, s) + end + local concat = table.concat(ret, " ") + if is_windows then + concat = '"' .. concat .. '"' + end + return concat +end +local run_subprocess_popen +run_subprocess_popen = function(command_line) + local command_line_string = shell_escape(command_line) + command_line_string = command_line_string .. " 2>&1" + msg.verbose("run_subprocess_popen: running " .. tostring(command_line_string)) + return io.popen(command_line_string) +end +local calculate_scale_factor +calculate_scale_factor = function() + local baseResY = 720 + local osd_w, osd_h = mp.get_osd_size() + return osd_h / baseResY +end +local should_display_progress +should_display_progress = function() + if options.display_progress == "auto" then + return not is_windows + end + return options.display_progress +end +local reverse +reverse = function(list) + local _accum_0 = { } + local _len_0 = 1 + local _max_0 = 1 + for _index_0 = #list, _max_0 < 0 and #list + _max_0 or _max_0, -1 do + local element = list[_index_0] + _accum_0[_len_0] = element + _len_0 = _len_0 + 1 + end + return _accum_0 +end +local get_pass_logfile_path +get_pass_logfile_path = function(encode_out_path) + return tostring(encode_out_path) .. "-video-pass1.log" +end +local dimensions_changed = true +local _video_dimensions = { } +local get_video_dimensions +get_video_dimensions = function() + if not (dimensions_changed) then + return _video_dimensions + end + local video_params = mp.get_property_native("video-out-params") + if not video_params then + return nil + end + dimensions_changed = false + local keep_aspect = mp.get_property_bool("keepaspect") + local w = video_params["w"] + local h = video_params["h"] + local dw = video_params["dw"] + local dh = video_params["dh"] + if mp.get_property_number("video-rotate") % 180 == 90 then + w, h = h, w + dw, dh = dh, dw + end + _video_dimensions = { + top_left = { }, + bottom_right = { }, + ratios = { } + } + local window_w, window_h = mp.get_osd_size() + if keep_aspect then + local unscaled = mp.get_property_native("video-unscaled") + local panscan = mp.get_property_number("panscan") + local fwidth = window_w + local fheight = math.floor(window_w / dw * dh) + if fheight > window_h or fheight < h then + local tmpw = math.floor(window_h / dh * dw) + if tmpw <= window_w then + fheight = window_h + fwidth = tmpw + end + end + local vo_panscan_area = window_h - fheight + local f_w = fwidth / fheight + local f_h = 1 + if vo_panscan_area == 0 then + vo_panscan_area = window_h - fwidth + f_w = 1 + f_h = fheight / fwidth + end + if unscaled or unscaled == "downscale-big" then + vo_panscan_area = 0 + if unscaled or (dw <= window_w and dh <= window_h) then + fwidth = dw + fheight = dh + end + end + local scaled_width = fwidth + math.floor(vo_panscan_area * panscan * f_w) + local scaled_height = fheight + math.floor(vo_panscan_area * panscan * f_h) + local split_scaling + split_scaling = function(dst_size, scaled_src_size, zoom, align, pan) + scaled_src_size = math.floor(scaled_src_size * 2 ^ zoom) + align = (align + 1) / 2 + local dst_start = math.floor((dst_size - scaled_src_size) * align + pan * scaled_src_size) + if dst_start < 0 then + dst_start = dst_start + 1 + end + local dst_end = dst_start + scaled_src_size + if dst_start >= dst_end then + dst_start = 0 + dst_end = 1 + end + return dst_start, dst_end + end + local zoom = mp.get_property_number("video-zoom") + local align_x = mp.get_property_number("video-align-x") + local pan_x = mp.get_property_number("video-pan-x") + _video_dimensions.top_left.x, _video_dimensions.bottom_right.x = split_scaling(window_w, scaled_width, zoom, align_x, pan_x) + local align_y = mp.get_property_number("video-align-y") + local pan_y = mp.get_property_number("video-pan-y") + _video_dimensions.top_left.y, _video_dimensions.bottom_right.y = split_scaling(window_h, scaled_height, zoom, align_y, pan_y) + else + _video_dimensions.top_left.x = 0 + _video_dimensions.bottom_right.x = window_w + _video_dimensions.top_left.y = 0 + _video_dimensions.bottom_right.y = window_h + end + _video_dimensions.ratios.w = w / (_video_dimensions.bottom_right.x - _video_dimensions.top_left.x) + _video_dimensions.ratios.h = h / (_video_dimensions.bottom_right.y - _video_dimensions.top_left.y) + return _video_dimensions +end +local set_dimensions_changed +set_dimensions_changed = function() + dimensions_changed = true +end +local monitor_dimensions +monitor_dimensions = function() + local properties = { + "keepaspect", + "video-out-params", + "video-unscaled", + "panscan", + "video-zoom", + "video-align-x", + "video-pan-x", + "video-align-y", + "video-pan-y", + "osd-width", + "osd-height" + } + for _, p in ipairs(properties) do + mp.observe_property(p, "native", set_dimensions_changed) + end +end +local clamp +clamp = function(min, val, max) + if val <= min then + return min + end + if val >= max then + return max + end + return val +end +local clamp_point +clamp_point = function(top_left, point, bottom_right) + return { + x = clamp(top_left.x, point.x, bottom_right.x), + y = clamp(top_left.y, point.y, bottom_right.y) + } +end +local VideoPoint +do + local _class_0 + local _base_0 = { + set_from_screen = function(self, sx, sy) + local d = get_video_dimensions() + local point = clamp_point(d.top_left, { + x = sx, + y = sy + }, d.bottom_right) + self.x = math.floor(d.ratios.w * (point.x - d.top_left.x) + 0.5) + self.y = math.floor(d.ratios.h * (point.y - d.top_left.y) + 0.5) + end, + to_screen = function(self) + local d = get_video_dimensions() + return { + x = math.floor(self.x / d.ratios.w + d.top_left.x + 0.5), + y = math.floor(self.y / d.ratios.h + d.top_left.y + 0.5) + } + end + } + _base_0.__index = _base_0 + _class_0 = setmetatable({ + __init = function(self) + self.x = -1 + self.y = -1 + end, + __base = _base_0, + __name = "VideoPoint" + }, { + __index = _base_0, + __call = function(cls, ...) + local _self_0 = setmetatable({}, _base_0) + cls.__init(_self_0, ...) + return _self_0 + end + }) + _base_0.__class = _class_0 + VideoPoint = _class_0 +end +local Region +do + local _class_0 + local _base_0 = { + is_valid = function(self) + return self.x > -1 and self.y > -1 and self.w > -1 and self.h > -1 + end, + set_from_points = function(self, p1, p2) + self.x = math.min(p1.x, p2.x) + self.y = math.min(p1.y, p2.y) + self.w = math.abs(p1.x - p2.x) + self.h = math.abs(p1.y - p2.y) + end + } + _base_0.__index = _base_0 + _class_0 = setmetatable({ + __init = function(self) + self.x = -1 + self.y = -1 + self.w = -1 + self.h = -1 + end, + __base = _base_0, + __name = "Region" + }, { + __index = _base_0, + __call = function(cls, ...) + local _self_0 = setmetatable({}, _base_0) + cls.__init(_self_0, ...) + return _self_0 + end + }) + _base_0.__class = _class_0 + Region = _class_0 +end +local make_fullscreen_region +make_fullscreen_region = function() + local r = Region() + local d = get_video_dimensions() + local a = VideoPoint() + local b = VideoPoint() + local xa, ya + do + local _obj_0 = d.top_left + xa, ya = _obj_0.x, _obj_0.y + end + a:set_from_screen(xa, ya) + local xb, yb + do + local _obj_0 = d.bottom_right + xb, yb = _obj_0.x, _obj_0.y + end + b:set_from_screen(xb, yb) + r:set_from_points(a, b) + return r +end +local read_double +read_double = function(bytes) + local sign = 1 + local mantissa = bytes[2] % 2 ^ 4 + for i = 3, 8 do + mantissa = mantissa * 256 + bytes[i] + end + if bytes[1] > 127 then + sign = -1 + end + local exponent = (bytes[1] % 128) * 2 ^ 4 + math.floor(bytes[2] / 2 ^ 4) + if exponent == 0 then + return 0 + end + mantissa = (math.ldexp(mantissa, -52) + 1) * sign + return math.ldexp(mantissa, exponent - 1023) +end +local write_double +write_double = function(num) + local bytes = { + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + } + if num == 0 then + return bytes + end + local anum = math.abs(num) + local mantissa, exponent = math.frexp(anum) + exponent = exponent - 1 + mantissa = mantissa * 2 - 1 + local sign = num ~= anum and 128 or 0 + exponent = exponent + 1023 + bytes[1] = sign + math.floor(exponent / 2 ^ 4) + mantissa = mantissa * 2 ^ 4 + local currentmantissa = math.floor(mantissa) + mantissa = mantissa - currentmantissa + bytes[2] = (exponent % 2 ^ 4) * 2 ^ 4 + currentmantissa + for i = 3, 8 do + mantissa = mantissa * 2 ^ 8 + currentmantissa = math.floor(mantissa) + mantissa = mantissa - currentmantissa + bytes[i] = currentmantissa + end + return bytes +end +local FirstpassStats +do + local _class_0 + local duration_multiplier, fields_before_duration, fields_after_duration + local _base_0 = { + get_duration = function(self) + local big_endian_binary_duration = reverse(self.binary_duration) + return read_double(reversed_binary_duration) / duration_multiplier + end, + set_duration = function(self, duration) + local big_endian_binary_duration = write_double(duration * duration_multiplier) + self.binary_duration = reverse(big_endian_binary_duration) + end, + _bytes_to_string = function(self, bytes) + return string.char(unpack(bytes)) + end, + as_binary_string = function(self) + local before_duration_string = self:_bytes_to_string(self.binary_data_before_duration) + local duration_string = self:_bytes_to_string(self.binary_duration) + local after_duration_string = self:_bytes_to_string(self.binary_data_after_duration) + return before_duration_string .. duration_string .. after_duration_string + end + } + _base_0.__index = _base_0 + _class_0 = setmetatable({ + __init = function(self, before_duration, duration, after_duration) + self.binary_data_before_duration = before_duration + self.binary_duration = duration + self.binary_data_after_duration = after_duration + end, + __base = _base_0, + __name = "FirstpassStats" + }, { + __index = _base_0, + __call = function(cls, ...) + local _self_0 = setmetatable({}, _base_0) + cls.__init(_self_0, ...) + return _self_0 + end + }) + _base_0.__class = _class_0 + local self = _class_0 + duration_multiplier = 10000000.0 + fields_before_duration = 16 + fields_after_duration = 1 + self.data_before_duration_size = function(self) + return fields_before_duration * 8 + end + self.data_after_duration_size = function(self) + return fields_after_duration * 8 + end + self.size = function(self) + return (fields_before_duration + 1 + fields_after_duration) * 8 + end + self.from_bytes = function(self, bytes) + local before_duration + do + local _accum_0 = { } + local _len_0 = 1 + local _max_0 = self:data_before_duration_size() + for _index_0 = 1, _max_0 < 0 and #bytes + _max_0 or _max_0 do + local b = bytes[_index_0] + _accum_0[_len_0] = b + _len_0 = _len_0 + 1 + end + before_duration = _accum_0 + end + local duration + do + local _accum_0 = { } + local _len_0 = 1 + local _max_0 = self:data_before_duration_size() + 8 + for _index_0 = self:data_before_duration_size() + 1, _max_0 < 0 and #bytes + _max_0 or _max_0 do + local b = bytes[_index_0] + _accum_0[_len_0] = b + _len_0 = _len_0 + 1 + end + duration = _accum_0 + end + local after_duration + do + local _accum_0 = { } + local _len_0 = 1 + for _index_0 = self:data_before_duration_size() + 8 + 1, #bytes do + local b = bytes[_index_0] + _accum_0[_len_0] = b + _len_0 = _len_0 + 1 + end + after_duration = _accum_0 + end + return self(before_duration, duration, after_duration) + end + FirstpassStats = _class_0 +end +local read_logfile_into_stats_array +read_logfile_into_stats_array = function(logfile_path) + local file = assert(io.open(logfile_path, "rb")) + local logfile_string = base64_decode(file:read()) + file:close() + local stats_size = FirstpassStats:size() + assert(logfile_string:len() % stats_size == 0) + local stats = { } + for offset = 1, #logfile_string, stats_size do + local bytes = { + logfile_string:byte(offset, offset + stats_size - 1) + } + assert(#bytes == stats_size) + stats[#stats + 1] = FirstpassStats:from_bytes(bytes) + end + return stats +end +local write_stats_array_to_logfile +write_stats_array_to_logfile = function(stats_array, logfile_path) + local file = assert(io.open(logfile_path, "wb")) + local logfile_string = "" + for _index_0 = 1, #stats_array do + local stat = stats_array[_index_0] + logfile_string = logfile_string .. stat:as_binary_string() + end + file:write(base64_encode(logfile_string)) + return file:close() +end +local vp8_patch_logfile +vp8_patch_logfile = function(logfile_path, encode_total_duration) + local stats_array = read_logfile_into_stats_array(logfile_path) + local average_duration = encode_total_duration / (#stats_array - 1) + for i = 1, #stats_array - 1 do + stats_array[i]:set_duration(average_duration) + end + stats_array[#stats_array]:set_duration(encode_total_duration) + return write_stats_array_to_logfile(stats_array, logfile_path) +end +local formats = { } +local Format +do + local _class_0 + local _base_0 = { + getPreFilters = function(self) + return { } + end, + getPostFilters = function(self) + return { } + end, + getFlags = function(self) + return { } + end, + getCodecFlags = function(self) + local codecs = { } + if self.videoCodec ~= "" then + codecs[#codecs + 1] = "--ovc=" .. tostring(self.videoCodec) + end + if self.audioCodec ~= "" then + codecs[#codecs + 1] = "--oac=" .. tostring(self.audioCodec) + end + return codecs + end + } + _base_0.__index = _base_0 + _class_0 = setmetatable({ + __init = function(self) + self.displayName = "Basic" + self.supportsTwopass = true + self.videoCodec = "" + self.audioCodec = "" + self.outputExtension = "" + self.acceptsBitrate = true + end, + __base = _base_0, + __name = "Format" + }, { + __index = _base_0, + __call = function(cls, ...) + local _self_0 = setmetatable({}, _base_0) + cls.__init(_self_0, ...) + return _self_0 + end + }) + _base_0.__class = _class_0 + Format = _class_0 +end +local RawVideo +do + local _class_0 + local _parent_0 = Format + local _base_0 = { + getColorspace = function(self) + local csp = mp.get_property("colormatrix") + local _exp_0 = csp + if "bt.601" == _exp_0 then + return "bt601" + elseif "bt.709" == _exp_0 then + return "bt709" + elseif "bt.2020" == _exp_0 then + return "bt2020" + elseif "smpte-240m" == _exp_0 then + return "smpte240m" + else + msg.info("Warning, unknown colorspace " .. tostring(csp) .. " detected, using bt.601.") + return "bt601" + end + end, + getPostFilters = function(self) + return { + "format=yuv444p16", + "lavfi-scale=in_color_matrix=" .. self:getColorspace(), + "format=bgr24" + } + end + } + _base_0.__index = _base_0 + setmetatable(_base_0, _parent_0.__base) + _class_0 = setmetatable({ + __init = function(self) + self.displayName = "Raw" + self.supportsTwopass = false + self.videoCodec = "rawvideo" + self.audioCodec = "pcm_s16le" + self.outputExtension = "avi" + self.acceptsBitrate = false + end, + __base = _base_0, + __name = "RawVideo", + __parent = _parent_0 + }, { + __index = function(cls, name) + local val = rawget(_base_0, name) + if val == nil then + local parent = rawget(cls, "__parent") + if parent then + return parent[name] + end + else + return val + end + end, + __call = function(cls, ...) + local _self_0 = setmetatable({}, _base_0) + cls.__init(_self_0, ...) + return _self_0 + end + }) + _base_0.__class = _class_0 + if _parent_0.__inherited then + _parent_0.__inherited(_parent_0, _class_0) + end + RawVideo = _class_0 +end +formats["raw"] = RawVideo() +local WebmVP8 +do + local _class_0 + local _parent_0 = Format + local _base_0 = { + getPreFilters = function(self) + local colormatrixFilter = { + ["bt.709"] = "bt709", + ["bt.2020"] = "bt2020", + ["smpte-240m"] = "smpte240m" + } + local ret = { } + local colormatrix = mp.get_property_native("video-params/colormatrix") + if colormatrixFilter[colormatrix] then + append(ret, { + "lavfi-colormatrix=" .. tostring(colormatrixFilter[colormatrix]) .. ":bt601" + }) + end + return ret + end, + getFlags = function(self) + return { + "--ovcopts-add=threads=" .. tostring(options.libvpx_threads) + } + end + } + _base_0.__index = _base_0 + setmetatable(_base_0, _parent_0.__base) + _class_0 = setmetatable({ + __init = function(self) + self.displayName = "WebM" + self.supportsTwopass = true + self.videoCodec = "libvpx" + self.audioCodec = "libvorbis" + self.outputExtension = "webm" + self.acceptsBitrate = true + end, + __base = _base_0, + __name = "WebmVP8", + __parent = _parent_0 + }, { + __index = function(cls, name) + local val = rawget(_base_0, name) + if val == nil then + local parent = rawget(cls, "__parent") + if parent then + return parent[name] + end + else + return val + end + end, + __call = function(cls, ...) + local _self_0 = setmetatable({}, _base_0) + cls.__init(_self_0, ...) + return _self_0 + end + }) + _base_0.__class = _class_0 + if _parent_0.__inherited then + _parent_0.__inherited(_parent_0, _class_0) + end + WebmVP8 = _class_0 +end +formats["webm-vp8"] = WebmVP8() +local WebmVP9 +do + local _class_0 + local _parent_0 = Format + local _base_0 = { + getFlags = function(self) + return { + "--ovcopts-add=threads=" .. tostring(options.libvpx_threads) + } + end + } + _base_0.__index = _base_0 + setmetatable(_base_0, _parent_0.__base) + _class_0 = setmetatable({ + __init = function(self) + self.displayName = "WebM (VP9)" + self.supportsTwopass = true + self.videoCodec = "libvpx-vp9" + self.audioCodec = "libvorbis" + self.outputExtension = "webm" + self.acceptsBitrate = true + end, + __base = _base_0, + __name = "WebmVP9", + __parent = _parent_0 + }, { + __index = function(cls, name) + local val = rawget(_base_0, name) + if val == nil then + local parent = rawget(cls, "__parent") + if parent then + return parent[name] + end + else + return val + end + end, + __call = function(cls, ...) + local _self_0 = setmetatable({}, _base_0) + cls.__init(_self_0, ...) + return _self_0 + end + }) + _base_0.__class = _class_0 + if _parent_0.__inherited then + _parent_0.__inherited(_parent_0, _class_0) + end + WebmVP9 = _class_0 +end +formats["webm-vp9"] = WebmVP9() +local MP4 +do + local _class_0 + local _parent_0 = Format + local _base_0 = { } + _base_0.__index = _base_0 + setmetatable(_base_0, _parent_0.__base) + _class_0 = setmetatable({ + __init = function(self) + self.displayName = "MP4 (h264/AAC)" + self.supportsTwopass = true + self.videoCodec = "libx264" + self.audioCodec = "aac" + self.outputExtension = "mp4" + self.acceptsBitrate = true + end, + __base = _base_0, + __name = "MP4", + __parent = _parent_0 + }, { + __index = function(cls, name) + local val = rawget(_base_0, name) + if val == nil then + local parent = rawget(cls, "__parent") + if parent then + return parent[name] + end + else + return val + end + end, + __call = function(cls, ...) + local _self_0 = setmetatable({}, _base_0) + cls.__init(_self_0, ...) + return _self_0 + end + }) + _base_0.__class = _class_0 + if _parent_0.__inherited then + _parent_0.__inherited(_parent_0, _class_0) + end + MP4 = _class_0 +end +formats["mp4"] = MP4() +local MP4NVENC +do + local _class_0 + local _parent_0 = Format + local _base_0 = { } + _base_0.__index = _base_0 + setmetatable(_base_0, _parent_0.__base) + _class_0 = setmetatable({ + __init = function(self) + self.displayName = "MP4 (h264-NVENC/AAC)" + self.supportsTwopass = true + self.videoCodec = "h264_nvenc" + self.audioCodec = "aac" + self.outputExtension = "mp4" + self.acceptsBitrate = true + end, + __base = _base_0, + __name = "MP4NVENC", + __parent = _parent_0 + }, { + __index = function(cls, name) + local val = rawget(_base_0, name) + if val == nil then + local parent = rawget(cls, "__parent") + if parent then + return parent[name] + end + else + return val + end + end, + __call = function(cls, ...) + local _self_0 = setmetatable({}, _base_0) + cls.__init(_self_0, ...) + return _self_0 + end + }) + _base_0.__class = _class_0 + if _parent_0.__inherited then + _parent_0.__inherited(_parent_0, _class_0) + end + MP4NVENC = _class_0 +end +formats["mp4-nvenc"] = MP4NVENC() +local MP3 +do + local _class_0 + local _parent_0 = Format + local _base_0 = { } + _base_0.__index = _base_0 + setmetatable(_base_0, _parent_0.__base) + _class_0 = setmetatable({ + __init = function(self) + self.displayName = "MP3 (libmp3lame)" + self.supportsTwopass = false + self.videoCodec = "" + self.audioCodec = "libmp3lame" + self.outputExtension = "mp3" + self.acceptsBitrate = true + end, + __base = _base_0, + __name = "MP3", + __parent = _parent_0 + }, { + __index = function(cls, name) + local val = rawget(_base_0, name) + if val == nil then + local parent = rawget(cls, "__parent") + if parent then + return parent[name] + end + else + return val + end + end, + __call = function(cls, ...) + local _self_0 = setmetatable({}, _base_0) + cls.__init(_self_0, ...) + return _self_0 + end + }) + _base_0.__class = _class_0 + if _parent_0.__inherited then + _parent_0.__inherited(_parent_0, _class_0) + end + MP3 = _class_0 +end +formats["mp3"] = MP3() +local GIF +do + local _class_0 + local _parent_0 = Format + local _base_0 = { } + _base_0.__index = _base_0 + setmetatable(_base_0, _parent_0.__base) + _class_0 = setmetatable({ + __init = function(self) + self.displayName = "GIF" + self.supportsTwopass = false + self.videoCodec = "gif" + self.audioCodec = "" + self.outputExtension = "gif" + self.acceptsBitrate = false + end, + __base = _base_0, + __name = "GIF", + __parent = _parent_0 + }, { + __index = function(cls, name) + local val = rawget(_base_0, name) + if val == nil then + local parent = rawget(cls, "__parent") + if parent then + return parent[name] + end + else + return val + end + end, + __call = function(cls, ...) + local _self_0 = setmetatable({}, _base_0) + cls.__init(_self_0, ...) + return _self_0 + end + }) + _base_0.__class = _class_0 + if _parent_0.__inherited then + _parent_0.__inherited(_parent_0, _class_0) + end + GIF = _class_0 +end +formats["gif"] = GIF() +local Page +do + local _class_0 + local _base_0 = { + add_keybinds = function(self) + if not self.keybinds then + return + end + for key, func in pairs(self.keybinds) do + mp.add_forced_key_binding(key, key, func, { + repeatable = true + }) + end + end, + remove_keybinds = function(self) + if not self.keybinds then + return + end + for key, _ in pairs(self.keybinds) do + mp.remove_key_binding(key) + end + end, + observe_properties = function(self) + self.sizeCallback = function() + return self:draw() + end + local properties = { + "keepaspect", + "video-out-params", + "video-unscaled", + "panscan", + "video-zoom", + "video-align-x", + "video-pan-x", + "video-align-y", + "video-pan-y", + "osd-width", + "osd-height" + } + for _index_0 = 1, #properties do + local p = properties[_index_0] + mp.observe_property(p, "native", self.sizeCallback) + end + end, + unobserve_properties = function(self) + if self.sizeCallback then + mp.unobserve_property(self.sizeCallback) + self.sizeCallback = nil + end + end, + clear = function(self) + local window_w, window_h = mp.get_osd_size() + mp.set_osd_ass(window_w, window_h, "") + return mp.osd_message("", 0) + end, + prepare = function(self) + return nil + end, + dispose = function(self) + return nil + end, + show = function(self) + if self.visible then + return + end + self.visible = true + self:observe_properties() + self:add_keybinds() + self:prepare() + self:clear() + return self:draw() + end, + hide = function(self) + if not self.visible then + return + end + self.visible = false + self:unobserve_properties() + self:remove_keybinds() + self:clear() + return self:dispose() + end, + setup_text = function(self, ass) + local scale = calculate_scale_factor() + local margin = options.margin * scale + ass:append("{\\an7}") + ass:pos(margin, margin) + return ass:append("{\\fs" .. tostring(options.font_size * scale) .. "}") + end + } + _base_0.__index = _base_0 + _class_0 = setmetatable({ + __init = function() end, + __base = _base_0, + __name = "Page" + }, { + __index = _base_0, + __call = function(cls, ...) + local _self_0 = setmetatable({}, _base_0) + cls.__init(_self_0, ...) + return _self_0 + end + }) + _base_0.__class = _class_0 + Page = _class_0 +end +local EncodeWithProgress +do + local _class_0 + local _parent_0 = Page + local _base_0 = { + draw = function(self) + local progress = 100 * ((self.currentTime - self.startTime) / self.duration) + local progressText = string.format("%d%%", progress) + local window_w, window_h = mp.get_osd_size() + local ass = assdraw.ass_new() + ass:new_event() + self:setup_text(ass) + ass:append("Encoding (" .. tostring(bold(progressText)) .. ")\\N") + return mp.set_osd_ass(window_w, window_h, ass.text) + end, + parseLine = function(self, line) + local matchTime = string.match(line, "Encode time[-]pos: ([0-9.]+)") + local matchExit = string.match(line, "Exiting... [(]([%a ]+)[)]") + if matchTime == nil and matchExit == nil then + return + end + if matchTime ~= nil and tonumber(matchTime) > self.currentTime then + self.currentTime = tonumber(matchTime) + end + if matchExit ~= nil then + self.finished = true + self.finishedReason = matchExit + end + end, + startEncode = function(self, command_line) + local copy_command_line + do + local _accum_0 = { } + local _len_0 = 1 + for _index_0 = 1, #command_line do + local arg = command_line[_index_0] + _accum_0[_len_0] = arg + _len_0 = _len_0 + 1 + end + copy_command_line = _accum_0 + end + append(copy_command_line, { + '--term-status-msg=Encode time-pos: ${=time-pos}\\n' + }) + self:show() + local processFd = run_subprocess_popen(copy_command_line) + for line in processFd:lines() do + msg.verbose(string.format('%q', line)) + self:parseLine(line) + self:draw() + end + processFd:close() + self:hide() + if self.finishedReason == "End of file" then + return true + end + return false + end + } + _base_0.__index = _base_0 + setmetatable(_base_0, _parent_0.__base) + _class_0 = setmetatable({ + __init = function(self, startTime, endTime) + self.startTime = startTime + self.endTime = endTime + self.duration = endTime - startTime + self.currentTime = startTime + end, + __base = _base_0, + __name = "EncodeWithProgress", + __parent = _parent_0 + }, { + __index = function(cls, name) + local val = rawget(_base_0, name) + if val == nil then + local parent = rawget(cls, "__parent") + if parent then + return parent[name] + end + else + return val + end + end, + __call = function(cls, ...) + local _self_0 = setmetatable({}, _base_0) + cls.__init(_self_0, ...) + return _self_0 + end + }) + _base_0.__class = _class_0 + if _parent_0.__inherited then + _parent_0.__inherited(_parent_0, _class_0) + end + EncodeWithProgress = _class_0 +end +local get_active_tracks +get_active_tracks = function() + local accepted = { + video = true, + audio = not mp.get_property_bool("mute"), + sub = mp.get_property_bool("sub-visibility") + } + local active = { + video = { }, + audio = { }, + sub = { } + } + for _, track in ipairs(mp.get_property_native("track-list")) do + if track["selected"] and accepted[track["type"]] then + local count = #active[track["type"]] + active[track["type"]][count + 1] = track + end + end + return active +end +local filter_tracks_supported_by_format +filter_tracks_supported_by_format = function(active_tracks, format) + local has_video_codec = format.videoCodec ~= "" + local has_audio_codec = format.audioCodec ~= "" + local supported = { + video = has_video_codec and active_tracks["video"] or { }, + audio = has_audio_codec and active_tracks["audio"] or { }, + sub = has_video_codec and active_tracks["sub"] or { } + } + return supported +end +local append_track +append_track = function(out, track) + local external_flag = { + ["audio"] = "audio-file", + ["sub"] = "sub-file" + } + local internal_flag = { + ["video"] = "vid", + ["audio"] = "aid", + ["sub"] = "sid" + } + if track['external'] and string.len(track['external-filename']) <= 2048 then + return append(out, { + "--" .. tostring(external_flag[track['type']]) .. "=" .. tostring(track['external-filename']) + }) + else + return append(out, { + "--" .. tostring(internal_flag[track['type']]) .. "=" .. tostring(track['id']) + }) + end +end +local append_audio_tracks +append_audio_tracks = function(out, tracks) + local internal_tracks = { } + for _index_0 = 1, #tracks do + local track = tracks[_index_0] + if track['external'] then + append_track(out, track) + else + append(internal_tracks, { + track + }) + end + end + if #internal_tracks > 1 then + local filter_string = "" + for _index_0 = 1, #internal_tracks do + local track = internal_tracks[_index_0] + filter_string = filter_string .. "[aid" .. tostring(track['id']) .. "]" + end + filter_string = filter_string .. "amix[ao]" + return append(out, { + "--lavfi-complex=" .. tostring(filter_string) + }) + else + if #internal_tracks == 1 then + return append_track(out, internal_tracks[1]) + end + end +end +local get_scale_filters +get_scale_filters = function() + if options.scale_height > 0 then + return { + "lavfi-scale=-2:" .. tostring(options.scale_height) + } + end + return { } +end +local get_fps_filters +get_fps_filters = function() + if options.fps > 0 then + return { + "fps=" .. tostring(options.fps) + } + end + return { } +end +local append_property +append_property = function(out, property_name, option_name) + option_name = option_name or property_name + local prop = mp.get_property(property_name) + if prop and prop ~= "" then + return append(out, { + "--" .. tostring(option_name) .. "=" .. tostring(prop) + }) + end +end +local append_list_options +append_list_options = function(out, property_name, option_prefix) + option_prefix = option_prefix or property_name + local prop = mp.get_property_native(property_name) + if prop then + for _index_0 = 1, #prop do + local value = prop[_index_0] + append(out, { + "--" .. tostring(option_prefix) .. "-append=" .. tostring(value) + }) + end + end +end +local get_playback_options +get_playback_options = function() + local ret = { } + append_property(ret, "sub-ass-override") + append_property(ret, "sub-ass-force-style") + append_property(ret, "sub-ass-vsfilter-aspect-compat") + append_property(ret, "sub-auto") + append_property(ret, "sub-delay") + append_property(ret, "video-rotate") + append_property(ret, "ytdl-format") + return ret +end +local get_speed_flags +get_speed_flags = function() + local ret = { } + local speed = mp.get_property_native("speed") + if speed ~= 1 then + append(ret, { + "--vf-add=setpts=PTS/" .. tostring(speed), + "--af-add=atempo=" .. tostring(speed), + "--sub-speed=1/" .. tostring(speed) + }) + end + return ret +end +local get_metadata_flags +get_metadata_flags = function() + local title = mp.get_property("filename/no-ext") + return { + "--oset-metadata=title=%" .. tostring(string.len(title)) .. "%" .. tostring(title) + } +end +local apply_current_filters +apply_current_filters = function(filters) + local vf = mp.get_property_native("vf") + msg.verbose("apply_current_filters: got " .. tostring(#vf) .. " currently applied.") + for _index_0 = 1, #vf do + local _continue_0 = false + repeat + local filter = vf[_index_0] + msg.verbose("apply_current_filters: filter name: " .. tostring(filter['name'])) + if filter["enabled"] == false then + _continue_0 = true + break + end + local str = filter["name"] + local params = filter["params"] or { } + for k, v in pairs(params) do + str = str .. ":" .. tostring(k) .. "=%" .. tostring(string.len(v)) .. "%" .. tostring(v) + end + append(filters, { + str + }) + _continue_0 = true + until true + if not _continue_0 then + break + end + end +end +local get_video_filters +get_video_filters = function(format, region) + local filters = { } + append(filters, format:getPreFilters()) + if options.apply_current_filters then + apply_current_filters(filters) + end + if region and region:is_valid() then + append(filters, { + "lavfi-crop=" .. tostring(region.w) .. ":" .. tostring(region.h) .. ":" .. tostring(region.x) .. ":" .. tostring(region.y) + }) + end + append(filters, get_scale_filters()) + append(filters, get_fps_filters()) + append(filters, format:getPostFilters()) + return filters +end +local get_video_encode_flags +get_video_encode_flags = function(format, region) + local flags = { } + append(flags, get_playback_options()) + local filters = get_video_filters(format, region) + for _index_0 = 1, #filters do + local f = filters[_index_0] + append(flags, { + "--vf-add=" .. tostring(f) + }) + end + append(flags, get_speed_flags()) + return flags +end +local calculate_bitrate +calculate_bitrate = function(active_tracks, format, length) + if format.videoCodec == "" then + return nil, options.target_filesize * 8 / length + end + local video_kilobits = options.target_filesize * 8 + local audio_kilobits = nil + local has_audio_track = #active_tracks["audio"] > 0 + if options.strict_filesize_constraint and has_audio_track then + audio_kilobits = length * options.strict_audio_bitrate + video_kilobits = video_kilobits - audio_kilobits + end + local video_bitrate = math.floor(video_kilobits / length) + local audio_bitrate = audio_kilobits and math.floor(audio_kilobits / length) or nil + return video_bitrate, audio_bitrate +end +local encode +encode = function(region, startTime, endTime) + local format = formats[options.output_format] + local path = mp.get_property("path") + if not path then + message("No file is being played") + return + end + local is_stream = not file_exists(path) + local command = { + "mpv", + path, + "--start=" .. seconds_to_time_string(startTime, false, true), + "--end=" .. seconds_to_time_string(endTime, false, true), + "--loop-file=no" + } + append(command, format:getCodecFlags()) + local active_tracks = get_active_tracks() + local supported_active_tracks = filter_tracks_supported_by_format(active_tracks, format) + for track_type, tracks in pairs(supported_active_tracks) do + if track_type == "audio" then + append_audio_tracks(command, tracks) + else + for _index_0 = 1, #tracks do + local track = tracks[_index_0] + append_track(command, track) + end + end + end + for track_type, tracks in pairs(supported_active_tracks) do + local _continue_0 = false + repeat + if #tracks > 0 then + _continue_0 = true + break + end + local _exp_0 = track_type + if "video" == _exp_0 then + append(command, { + "--vid=no" + }) + elseif "audio" == _exp_0 then + append(command, { + "--aid=no" + }) + elseif "sub" == _exp_0 then + append(command, { + "--sid=no" + }) + end + _continue_0 = true + until true + if not _continue_0 then + break + end + end + if format.videoCodec ~= "" then + append(command, get_video_encode_flags(format, region)) + end + append(command, format:getFlags()) + if options.write_filename_on_metadata then + append(command, get_metadata_flags()) + end + if format.acceptsBitrate then + if options.target_filesize > 0 then + local length = endTime - startTime + local video_bitrate, audio_bitrate = calculate_bitrate(supported_active_tracks, format, length) + if video_bitrate then + append(command, { + "--ovcopts-add=b=" .. tostring(video_bitrate) .. "k" + }) + end + if audio_bitrate then + append(command, { + "--oacopts-add=b=" .. tostring(audio_bitrate) .. "k" + }) + end + if options.strict_filesize_constraint then + local type = format.videoCodec ~= "" and "ovc" or "oac" + append(command, { + "--" .. tostring(type) .. "opts-add=minrate=" .. tostring(bitrate) .. "k", + "--" .. tostring(type) .. "opts-add=maxrate=" .. tostring(bitrate) .. "k" + }) + end + else + local type = format.videoCodec ~= "" and "ovc" or "oac" + append(command, { + "--" .. tostring(type) .. "opts-add=b=0" + }) + end + end + for token in string.gmatch(options.additional_flags, "[^%s]+") do + command[#command + 1] = token + end + if not options.strict_filesize_constraint then + for token in string.gmatch(options.non_strict_additional_flags, "[^%s]+") do + command[#command + 1] = token + end + if options.crf >= 0 then + append(command, { + "--ovcopts-add=crf=" .. tostring(options.crf) + }) + end + end + local dir = "" + if is_stream then + dir = parse_directory("~") + else + local _ + dir, _ = utils.split_path(path) + end + if options.output_directory ~= "" then + dir = parse_directory(options.output_directory) + end + local formatted_filename = format_filename(startTime, endTime, format) + local out_path = utils.join_path(dir, formatted_filename) + append(command, { + "--o=" .. tostring(out_path) + }) + if options.twopass and format.supportsTwopass and not is_stream then + local first_pass_cmdline + do + local _accum_0 = { } + local _len_0 = 1 + for _index_0 = 1, #command do + local arg = command[_index_0] + _accum_0[_len_0] = arg + _len_0 = _len_0 + 1 + end + first_pass_cmdline = _accum_0 + end + append(first_pass_cmdline, { + "--ovcopts-add=flags=+pass1" + }) + message("Starting first pass...") + msg.verbose("First-pass command line: ", table.concat(first_pass_cmdline, " ")) + local res = run_subprocess({ + args = first_pass_cmdline, + cancellable = false + }) + if not res then + message("First pass failed! Check the logs for details.") + return + end + append(command, { + "--ovcopts-add=flags=+pass2" + }) + if format.videoCodec == "libvpx" then + msg.verbose("Patching libvpx pass log file...") + vp8_patch_logfile(get_pass_logfile_path(out_path), endTime - startTime) + end + end + msg.info("Encoding to", out_path) + msg.verbose("Command line:", table.concat(command, " ")) + if options.run_detached then + message("Started encode, process was detached.") + return utils.subprocess_detached({ + args = command + }) + else + local res = false + if not should_display_progress() then + message("Started encode...") + res = run_subprocess({ + args = command, + cancellable = false + }) + else + local ewp = EncodeWithProgress(startTime, endTime) + res = ewp:startEncode(command) + end + if res then + message("Encoded successfully! Saved to\\N" .. tostring(bold(out_path))) + else + message("Encode failed! Check the logs for details.") + end + return os.remove(get_pass_logfile_path(out_path)) + end +end +local CropPage +do + local _class_0 + local _parent_0 = Page + local _base_0 = { + reset = function(self) + local dimensions = get_video_dimensions() + local xa, ya + do + local _obj_0 = dimensions.top_left + xa, ya = _obj_0.x, _obj_0.y + end + self.pointA:set_from_screen(xa, ya) + local xb, yb + do + local _obj_0 = dimensions.bottom_right + xb, yb = _obj_0.x, _obj_0.y + end + self.pointB:set_from_screen(xb, yb) + if self.visible then + return self:draw() + end + end, + setPointA = function(self) + local posX, posY = mp.get_mouse_pos() + self.pointA:set_from_screen(posX, posY) + if self.visible then + return self:draw() + end + end, + setPointB = function(self) + local posX, posY = mp.get_mouse_pos() + self.pointB:set_from_screen(posX, posY) + if self.visible then + return self:draw() + end + end, + cancel = function(self) + self:hide() + return self.callback(false, nil) + end, + finish = function(self) + local region = Region() + region:set_from_points(self.pointA, self.pointB) + self:hide() + return self.callback(true, region) + end, + draw_box = function(self, ass) + local region = Region() + region:set_from_points(self.pointA:to_screen(), self.pointB:to_screen()) + local d = get_video_dimensions() + ass:new_event() + ass:append("{\\an7}") + ass:pos(0, 0) + ass:append('{\\bord0}') + ass:append('{\\shad0}') + ass:append('{\\c&H000000&}') + ass:append('{\\alpha&H77}') + ass:draw_start() + ass:rect_cw(d.top_left.x, d.top_left.y, region.x, region.y + region.h) + ass:rect_cw(region.x, d.top_left.y, d.bottom_right.x, region.y) + ass:rect_cw(d.top_left.x, region.y + region.h, region.x + region.w, d.bottom_right.y) + ass:rect_cw(region.x + region.w, region.y, d.bottom_right.x, d.bottom_right.y) + return ass:draw_stop() + end, + draw = function(self) + local window = { } + window.w, window.h = mp.get_osd_size() + local ass = assdraw.ass_new() + self:draw_box(ass) + ass:new_event() + self:setup_text(ass) + ass:append(tostring(bold('Crop:')) .. "\\N") + ass:append(tostring(bold('1:')) .. " change point A (" .. tostring(self.pointA.x) .. ", " .. tostring(self.pointA.y) .. ")\\N") + ass:append(tostring(bold('2:')) .. " change point B (" .. tostring(self.pointB.x) .. ", " .. tostring(self.pointB.y) .. ")\\N") + ass:append(tostring(bold('r:')) .. " reset to whole screen\\N") + ass:append(tostring(bold('ESC:')) .. " cancel crop\\N") + local width, height = math.abs(self.pointA.x - self.pointB.x), math.abs(self.pointA.y - self.pointB.y) + ass:append(tostring(bold('ENTER:')) .. " confirm crop (" .. tostring(width) .. "x" .. tostring(height) .. ")\\N") + return mp.set_osd_ass(window.w, window.h, ass.text) + end + } + _base_0.__index = _base_0 + setmetatable(_base_0, _parent_0.__base) + _class_0 = setmetatable({ + __init = function(self, callback, region) + self.pointA = VideoPoint() + self.pointB = VideoPoint() + self.keybinds = { + ["1"] = (function() + local _base_1 = self + local _fn_0 = _base_1.setPointA + return function(...) + return _fn_0(_base_1, ...) + end + end)(), + ["2"] = (function() + local _base_1 = self + local _fn_0 = _base_1.setPointB + return function(...) + return _fn_0(_base_1, ...) + end + end)(), + ["r"] = (function() + local _base_1 = self + local _fn_0 = _base_1.reset + return function(...) + return _fn_0(_base_1, ...) + end + end)(), + ["ESC"] = (function() + local _base_1 = self + local _fn_0 = _base_1.cancel + return function(...) + return _fn_0(_base_1, ...) + end + end)(), + ["ENTER"] = (function() + local _base_1 = self + local _fn_0 = _base_1.finish + return function(...) + return _fn_0(_base_1, ...) + end + end)() + } + self:reset() + self.callback = callback + if region and region:is_valid() then + self.pointA.x = region.x + self.pointA.y = region.y + self.pointB.x = region.x + region.w + self.pointB.y = region.y + region.h + end + end, + __base = _base_0, + __name = "CropPage", + __parent = _parent_0 + }, { + __index = function(cls, name) + local val = rawget(_base_0, name) + if val == nil then + local parent = rawget(cls, "__parent") + if parent then + return parent[name] + end + else + return val + end + end, + __call = function(cls, ...) + local _self_0 = setmetatable({}, _base_0) + cls.__init(_self_0, ...) + return _self_0 + end + }) + _base_0.__class = _class_0 + if _parent_0.__inherited then + _parent_0.__inherited(_parent_0, _class_0) + end + CropPage = _class_0 +end +local Option +do + local _class_0 + local _base_0 = { + hasPrevious = function(self) + local _exp_0 = self.optType + if "bool" == _exp_0 then + return true + elseif "int" == _exp_0 then + if self.opts.min then + return self.value > self.opts.min + else + return true + end + elseif "list" == _exp_0 then + return self.value > 1 + end + end, + hasNext = function(self) + local _exp_0 = self.optType + if "bool" == _exp_0 then + return true + elseif "int" == _exp_0 then + if self.opts.max then + return self.value < self.opts.max + else + return true + end + elseif "list" == _exp_0 then + return self.value < #self.opts.possibleValues + end + end, + leftKey = function(self) + local _exp_0 = self.optType + if "bool" == _exp_0 then + self.value = not self.value + elseif "int" == _exp_0 then + self.value = self.value - self.opts.step + if self.opts.min and self.opts.min > self.value then + self.value = self.opts.min + end + elseif "list" == _exp_0 then + if self.value > 1 then + self.value = self.value - 1 + end + end + end, + rightKey = function(self) + local _exp_0 = self.optType + if "bool" == _exp_0 then + self.value = not self.value + elseif "int" == _exp_0 then + self.value = self.value + self.opts.step + if self.opts.max and self.opts.max < self.value then + self.value = self.opts.max + end + elseif "list" == _exp_0 then + if self.value < #self.opts.possibleValues then + self.value = self.value + 1 + end + end + end, + getValue = function(self) + local _exp_0 = self.optType + if "bool" == _exp_0 then + return self.value + elseif "int" == _exp_0 then + return self.value + elseif "list" == _exp_0 then + local value, _ + do + local _obj_0 = self.opts.possibleValues[self.value] + value, _ = _obj_0[1], _obj_0[2] + end + return value + end + end, + setValue = function(self, value) + local _exp_0 = self.optType + if "bool" == _exp_0 then + self.value = value + elseif "int" == _exp_0 then + self.value = value + elseif "list" == _exp_0 then + local set = false + for i, possiblePair in ipairs(self.opts.possibleValues) do + local possibleValue, _ + possibleValue, _ = possiblePair[1], possiblePair[2] + if possibleValue == value then + set = true + self.value = i + break + end + end + if not set then + return msg.warn("Tried to set invalid value " .. tostring(value) .. " to " .. tostring(self.displayText) .. " option.") + end + end + end, + getDisplayValue = function(self) + local _exp_0 = self.optType + if "bool" == _exp_0 then + return self.value and "yes" or "no" + elseif "int" == _exp_0 then + if self.opts.altDisplayNames and self.opts.altDisplayNames[self.value] then + return self.opts.altDisplayNames[self.value] + else + return tostring(self.value) + end + elseif "list" == _exp_0 then + local value, displayValue + do + local _obj_0 = self.opts.possibleValues[self.value] + value, displayValue = _obj_0[1], _obj_0[2] + end + return displayValue or value + end + end, + draw = function(self, ass, selected) + if selected then + ass:append(tostring(bold(self.displayText)) .. ": ") + else + ass:append(tostring(self.displayText) .. ": ") + end + if self:hasPrevious() then + ass:append("◀ ") + end + ass:append(self:getDisplayValue()) + if self:hasNext() then + ass:append(" ▶") + end + return ass:append("\\N") + end + } + _base_0.__index = _base_0 + _class_0 = setmetatable({ + __init = function(self, optType, displayText, value, opts) + self.optType = optType + self.displayText = displayText + self.opts = opts + self.value = 1 + return self:setValue(value) + end, + __base = _base_0, + __name = "Option" + }, { + __index = _base_0, + __call = function(cls, ...) + local _self_0 = setmetatable({}, _base_0) + cls.__init(_self_0, ...) + return _self_0 + end + }) + _base_0.__class = _class_0 + Option = _class_0 +end +local EncodeOptionsPage +do + local _class_0 + local _parent_0 = Page + local _base_0 = { + getCurrentOption = function(self) + return self.options[self.currentOption][2] + end, + leftKey = function(self) + (self:getCurrentOption()):leftKey() + return self:draw() + end, + rightKey = function(self) + (self:getCurrentOption()):rightKey() + return self:draw() + end, + prevOpt = function(self) + self.currentOption = math.max(1, self.currentOption - 1) + return self:draw() + end, + nextOpt = function(self) + self.currentOption = math.min(#self.options, self.currentOption + 1) + return self:draw() + end, + confirmOpts = function(self) + for _, optPair in ipairs(self.options) do + local optName, opt + optName, opt = optPair[1], optPair[2] + options[optName] = opt:getValue() + end + self:hide() + return self.callback(true) + end, + cancelOpts = function(self) + self:hide() + return self.callback(false) + end, + draw = function(self) + local window_w, window_h = mp.get_osd_size() + local ass = assdraw.ass_new() + ass:new_event() + self:setup_text(ass) + ass:append(tostring(bold('Options:')) .. "\\N\\N") + for i, optPair in ipairs(self.options) do + local opt = optPair[2] + opt:draw(ass, self.currentOption == i) + end + ass:append("\\N▲ / ▼: navigate\\N") + ass:append(tostring(bold('ENTER:')) .. " confirm options\\N") + ass:append(tostring(bold('ESC:')) .. " cancel\\N") + return mp.set_osd_ass(window_w, window_h, ass.text) + end + } + _base_0.__index = _base_0 + setmetatable(_base_0, _parent_0.__base) + _class_0 = setmetatable({ + __init = function(self, callback) + self.callback = callback + self.currentOption = 1 + local scaleHeightOpts = { + possibleValues = { + { + -1, + "no" + }, + { + 240 + }, + { + 360 + }, + { + 480 + }, + { + 720 + }, + { + 1080 + }, + { + 1440 + }, + { + 2160 + } + } + } + local filesizeOpts = { + step = 250, + min = 0, + altDisplayNames = { + [0] = "0 (constant quality)" + } + } + local crfOpts = { + step = 1, + min = -1, + altDisplayNames = { + [-1] = "disabled" + } + } + local fpsOpts = { + possibleValues = { + { + -1, + "source" + }, + { + 15 + }, + { + 24 + }, + { + 30 + }, + { + 48 + }, + { + 50 + }, + { + 60 + }, + { + 120 + }, + { + 240 + } + } + } + local formatIds = { + "webm-vp8", + "webm-vp9", + "mp4", + "mp4-nvenc", + "raw", + "mp3", + "gif" + } + local formatOpts = { + possibleValues = (function() + local _accum_0 = { } + local _len_0 = 1 + for _index_0 = 1, #formatIds do + local fId = formatIds[_index_0] + _accum_0[_len_0] = { + fId, + formats[fId].displayName + } + _len_0 = _len_0 + 1 + end + return _accum_0 + end)() + } + self.options = { + { + "output_format", + Option("list", "Output Format", options.output_format, formatOpts) + }, + { + "twopass", + Option("bool", "Two Pass", options.twopass) + }, + { + "apply_current_filters", + Option("bool", "Apply Current Video Filters", options.apply_current_filters) + }, + { + "scale_height", + Option("list", "Scale Height", options.scale_height, scaleHeightOpts) + }, + { + "strict_filesize_constraint", + Option("bool", "Strict Filesize Constraint", options.strict_filesize_constraint) + }, + { + "write_filename_on_metadata", + Option("bool", "Write Filename on Metadata", options.write_filename_on_metadata) + }, + { + "target_filesize", + Option("int", "Target Filesize", options.target_filesize, filesizeOpts) + }, + { + "crf", + Option("int", "CRF", options.crf, crfOpts) + }, + { + "fps", + Option("list", "FPS", options.fps, fpsOpts) + } + } + self.keybinds = { + ["LEFT"] = (function() + local _base_1 = self + local _fn_0 = _base_1.leftKey + return function(...) + return _fn_0(_base_1, ...) + end + end)(), + ["RIGHT"] = (function() + local _base_1 = self + local _fn_0 = _base_1.rightKey + return function(...) + return _fn_0(_base_1, ...) + end + end)(), + ["UP"] = (function() + local _base_1 = self + local _fn_0 = _base_1.prevOpt + return function(...) + return _fn_0(_base_1, ...) + end + end)(), + ["DOWN"] = (function() + local _base_1 = self + local _fn_0 = _base_1.nextOpt + return function(...) + return _fn_0(_base_1, ...) + end + end)(), + ["ENTER"] = (function() + local _base_1 = self + local _fn_0 = _base_1.confirmOpts + return function(...) + return _fn_0(_base_1, ...) + end + end)(), + ["ESC"] = (function() + local _base_1 = self + local _fn_0 = _base_1.cancelOpts + return function(...) + return _fn_0(_base_1, ...) + end + end)() + } + end, + __base = _base_0, + __name = "EncodeOptionsPage", + __parent = _parent_0 + }, { + __index = function(cls, name) + local val = rawget(_base_0, name) + if val == nil then + local parent = rawget(cls, "__parent") + if parent then + return parent[name] + end + else + return val + end + end, + __call = function(cls, ...) + local _self_0 = setmetatable({}, _base_0) + cls.__init(_self_0, ...) + return _self_0 + end + }) + _base_0.__class = _class_0 + if _parent_0.__inherited then + _parent_0.__inherited(_parent_0, _class_0) + end + EncodeOptionsPage = _class_0 +end +local PreviewPage +do + local _class_0 + local _parent_0 = Page + local _base_0 = { + prepare = function(self) + local vf = mp.get_property_native("vf") + vf[#vf + 1] = { + name = "sub" + } + if self.region:is_valid() then + vf[#vf + 1] = { + name = "crop", + params = { + w = tostring(self.region.w), + h = tostring(self.region.h), + x = tostring(self.region.x), + y = tostring(self.region.y) + } + } + end + mp.set_property_native("vf", vf) + if self.startTime > -1 and self.endTime > -1 then + mp.set_property_native("ab-loop-a", self.startTime) + mp.set_property_native("ab-loop-b", self.endTime) + mp.set_property_native("time-pos", self.startTime) + end + return mp.set_property_native("pause", false) + end, + dispose = function(self) + mp.set_property("ab-loop-a", "no") + mp.set_property("ab-loop-b", "no") + for prop, value in pairs(self.originalProperties) do + mp.set_property_native(prop, value) + end + end, + draw = function(self) + local window_w, window_h = mp.get_osd_size() + local ass = assdraw.ass_new() + ass:new_event() + self:setup_text(ass) + ass:append("Press " .. tostring(bold('ESC')) .. " to exit preview.\\N") + return mp.set_osd_ass(window_w, window_h, ass.text) + end, + cancel = function(self) + self:hide() + return self.callback() + end + } + _base_0.__index = _base_0 + setmetatable(_base_0, _parent_0.__base) + _class_0 = setmetatable({ + __init = function(self, callback, region, startTime, endTime) + self.callback = callback + self.originalProperties = { + ["vf"] = mp.get_property_native("vf"), + ["time-pos"] = mp.get_property_native("time-pos"), + ["pause"] = mp.get_property_native("pause") + } + self.keybinds = { + ["ESC"] = (function() + local _base_1 = self + local _fn_0 = _base_1.cancel + return function(...) + return _fn_0(_base_1, ...) + end + end)() + } + self.region = region + self.startTime = startTime + self.endTime = endTime + self.isLoop = false + end, + __base = _base_0, + __name = "PreviewPage", + __parent = _parent_0 + }, { + __index = function(cls, name) + local val = rawget(_base_0, name) + if val == nil then + local parent = rawget(cls, "__parent") + if parent then + return parent[name] + end + else + return val + end + end, + __call = function(cls, ...) + local _self_0 = setmetatable({}, _base_0) + cls.__init(_self_0, ...) + return _self_0 + end + }) + _base_0.__class = _class_0 + if _parent_0.__inherited then + _parent_0.__inherited(_parent_0, _class_0) + end + PreviewPage = _class_0 +end +local MainPage +do + local _class_0 + local _parent_0 = Page + local _base_0 = { + setStartTime = function(self) + self.startTime = mp.get_property_number("time-pos") + if self.visible then + self:clear() + return self:draw() + end + end, + setEndTime = function(self) + self.endTime = mp.get_property_number("time-pos") + if self.visible then + self:clear() + return self:draw() + end + end, + setupStartAndEndTimes = function(self) + if mp.get_property_native("duration") then + self.startTime = 0 + self.endTime = mp.get_property_native("duration") + else + self.startTime = -1 + self.endTime = -1 + end + if self.visible then + self:clear() + return self:draw() + end + end, + draw = function(self) + local window_w, window_h = mp.get_osd_size() + local ass = assdraw.ass_new() + ass:new_event() + self:setup_text(ass) + ass:append(tostring(bold('WebM maker')) .. "\\N\\N") + ass:append(tostring(bold('c:')) .. " crop\\N") + ass:append(tostring(bold('1:')) .. " set start time (current is " .. tostring(seconds_to_time_string(self.startTime)) .. ")\\N") + ass:append(tostring(bold('2:')) .. " set end time (current is " .. tostring(seconds_to_time_string(self.endTime)) .. ")\\N") + ass:append(tostring(bold('o:')) .. " change encode options\\N") + ass:append(tostring(bold('p:')) .. " preview\\N") + ass:append(tostring(bold('e:')) .. " encode\\N\\N") + ass:append(tostring(bold('ESC:')) .. " close\\N") + return mp.set_osd_ass(window_w, window_h, ass.text) + end, + onUpdateCropRegion = function(self, updated, newRegion) + if updated then + self.region = newRegion + end + return self:show() + end, + crop = function(self) + self:hide() + local cropPage = CropPage((function() + local _base_1 = self + local _fn_0 = _base_1.onUpdateCropRegion + return function(...) + return _fn_0(_base_1, ...) + end + end)(), self.region) + return cropPage:show() + end, + onOptionsChanged = function(self, updated) + return self:show() + end, + changeOptions = function(self) + self:hide() + local encodeOptsPage = EncodeOptionsPage((function() + local _base_1 = self + local _fn_0 = _base_1.onOptionsChanged + return function(...) + return _fn_0(_base_1, ...) + end + end)()) + return encodeOptsPage:show() + end, + onPreviewEnded = function(self) + return self:show() + end, + preview = function(self) + self:hide() + local previewPage = PreviewPage((function() + local _base_1 = self + local _fn_0 = _base_1.onPreviewEnded + return function(...) + return _fn_0(_base_1, ...) + end + end)(), self.region, self.startTime, self.endTime) + return previewPage:show() + end, + encode = function(self) + self:hide() + if self.startTime < 0 then + message("No start time, aborting") + return + end + if self.endTime < 0 then + message("No end time, aborting") + return + end + if self.startTime >= self.endTime then + message("Start time is ahead of end time, aborting") + return + end + return encode(self.region, self.startTime, self.endTime) + end + } + _base_0.__index = _base_0 + setmetatable(_base_0, _parent_0.__base) + _class_0 = setmetatable({ + __init = function(self) + self.keybinds = { + ["c"] = (function() + local _base_1 = self + local _fn_0 = _base_1.crop + return function(...) + return _fn_0(_base_1, ...) + end + end)(), + ["1"] = (function() + local _base_1 = self + local _fn_0 = _base_1.setStartTime + return function(...) + return _fn_0(_base_1, ...) + end + end)(), + ["2"] = (function() + local _base_1 = self + local _fn_0 = _base_1.setEndTime + return function(...) + return _fn_0(_base_1, ...) + end + end)(), + ["o"] = (function() + local _base_1 = self + local _fn_0 = _base_1.changeOptions + return function(...) + return _fn_0(_base_1, ...) + end + end)(), + ["p"] = (function() + local _base_1 = self + local _fn_0 = _base_1.preview + return function(...) + return _fn_0(_base_1, ...) + end + end)(), + ["e"] = (function() + local _base_1 = self + local _fn_0 = _base_1.encode + return function(...) + return _fn_0(_base_1, ...) + end + end)(), + ["ESC"] = (function() + local _base_1 = self + local _fn_0 = _base_1.hide + return function(...) + return _fn_0(_base_1, ...) + end + end)() + } + self.startTime = -1 + self.endTime = -1 + self.region = Region() + end, + __base = _base_0, + __name = "MainPage", + __parent = _parent_0 + }, { + __index = function(cls, name) + local val = rawget(_base_0, name) + if val == nil then + local parent = rawget(cls, "__parent") + if parent then + return parent[name] + end + else + return val + end + end, + __call = function(cls, ...) + local _self_0 = setmetatable({}, _base_0) + cls.__init(_self_0, ...) + return _self_0 + end + }) + _base_0.__class = _class_0 + if _parent_0.__inherited then + _parent_0.__inherited(_parent_0, _class_0) + end + MainPage = _class_0 +end +monitor_dimensions() +local mainPage = MainPage() +mp.add_key_binding(options.keybind, "display-webm-encoder", (function() + local _base_0 = mainPage + local _fn_0 = _base_0.show + return function(...) + return _fn_0(_base_0, ...) + end +end)(), { + repeatable = false +}) +return mp.register_event("file-loaded", (function() + local _base_0 = mainPage + local _fn_0 = _base_0.setupStartAndEndTimes + return function(...) + return _fn_0(_base_0, ...) + end +end)()) diff --git a/mpv/scripts/youtube-quality.lua b/mpv/scripts/youtube-quality.lua new file mode 100644 index 0000000..ba95fef --- /dev/null +++ b/mpv/scripts/youtube-quality.lua @@ -0,0 +1,275 @@ +-- youtube-quality.lua +-- +-- Change youtube video quality on the fly. +-- +-- Diplays a menu that lets you switch to different ytdl-format settings while +-- you're in the middle of a video (just like you were using the web player). +-- +-- Bound to ctrl-f by default. + +local mp = require 'mp' +local utils = require 'mp.utils' +local msg = require 'mp.msg' +local assdraw = require 'mp.assdraw' + +local opts = { + --key bindings + toggle_menu_binding = "ctrl+f", + up_binding = "UP", + down_binding = "DOWN", + select_binding = "ENTER", + + --formatting / cursors + 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=false, + + --playlist ass style overrides inside curly brackets, \keyvalue is one field, extra \ for escape in lua + --example {\\fnUbuntu\\fs10\\b0\\bord1} equals: font=Ubuntu, size=10, bold=no, border=1 + --read http://docs.aegisub.org/3.2/ASS_Tags/ for reference of tags + --undeclared tags will use default osd settings + --these styles will be used for the whole playlist. More specific styling will need to be hacked in + -- + --(a monospaced font is recommended but not required) + style_ass_tags = "{\\fnmonospace}", + + --paddings for top left corner + text_padding_x = 5, + text_padding_y = 5, + + --other + menu_timeout = 10, + + --use youtube-dl to fetch a list of available formats (overrides quality_strings) + fetch_formats = true, + + --default menu entries + 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"} + ] + ]], +} +(require 'mp.options').read_options(opts, "youtube-quality") +opts.quality_strings = utils.parse_json(opts.quality_strings) + +local destroyer = nil + + +function show_menu() + local selected = 1 + local active = 0 + local current_ytdl_format = mp.get_property("ytdl-format") + msg.verbose("current ytdl-format: "..current_ytdl_format) + local num_options = 0 + local options = {} + + + if opts.fetch_formats then + options, num_options = download_formats() + end + + if next(options) == nil then + for i,v in ipairs(opts.quality_strings) do + num_options = num_options + 1 + for k,v2 in pairs(v) do + options[i] = {label = k, format=v2} + if v2 == current_ytdl_format then + active = i + selected = active + end + end + end + end + + --set the cursor to the currently format + for i,v in ipairs(options) do + if v.format == current_ytdl_format then + active = i + selected = active + break + end + end + + function selected_move(amt) + selected = selected + amt + if selected < 1 then selected = num_options + elseif selected > num_options then selected = 1 end + timeout:kill() + timeout:resume() + draw_menu() + end + 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. + end + + function draw_menu() + local ass = assdraw.ass_new() + + ass:pos(opts.text_padding_x, opts.text_padding_y) + ass:append(opts.style_ass_tags) + + for i,v in ipairs(options) do + ass:append(choose_prefix(i)..v.label.."\\N") + end + + local w, h = mp.get_osd_size() + if opts.scale_playlist_by_window then w,h = 0, 0 end + mp.set_osd_ass(w, h, ass.text) + end + + function destroy() + timeout:kill() + mp.set_osd_ass(0,0,"") + mp.remove_key_binding("move_up") + mp.remove_key_binding("move_down") + mp.remove_key_binding("select") + mp.remove_key_binding("escape") + destroyer = nil + end + timeout = mp.add_periodic_timer(opts.menu_timeout, destroy) + destroyer = destroy + + mp.add_forced_key_binding(opts.up_binding, "move_up", function() selected_move(-1) end, {repeatable=true}) + mp.add_forced_key_binding(opts.down_binding, "move_down", function() selected_move(1) end, {repeatable=true}) + mp.add_forced_key_binding(opts.select_binding, "select", function() + destroy() + mp.set_property("ytdl-format", options[selected].format) + reload_resume() + end) + mp.add_forced_key_binding(opts.toggle_menu_binding, "escape", destroy) + + draw_menu() + return +end + +local ytdl = { + path = "youtube-dl", + searched = false, + blacklisted = {} +} + +format_cache={} +function download_formats() + local function exec(args) + local ret = utils.subprocess({args = args}) + return ret.status, ret.stdout, ret + end + + local function table_size(t) + s = 0 + for i,v in ipairs(t) do + s = s+1 + end + return s + end + + local url = mp.get_property("path") + + url = string.gsub(url, "ytdl://", "") -- Strip possible ytdl:// prefix. + + -- don't fetch the format list if we already have it + if format_cache[url] ~= nil then + local res = format_cache[url] + return res, table_size(res) + end + mp.osd_message("fetching available formats with youtube-dl...", 60) + + if not (ytdl.searched) then + local ytdl_mcd = mp.find_config_file("youtube-dl") + if not (ytdl_mcd == nil) then + msg.verbose("found youtube-dl at: " .. ytdl_mcd) + ytdl.path = ytdl_mcd + end + ytdl.searched = true + end + + local command = {ytdl.path, "--no-warnings", "--no-playlist", "-J"} + table.insert(command, url) + local es, json, result = exec(command) + + if (es < 0) or (json == nil) or (json == "") then + mp.osd_message("fetching formats failed...", 1) + msg.error("failed to get format list: " .. err) + return {}, 0 + end + + local json, err = utils.parse_json(json) + + if (json == nil) then + mp.osd_message("fetching formats failed...", 1) + msg.error("failed to parse JSON data: " .. err) + return {}, 0 + end + + res = {} + msg.verbose("youtube-dl succeeded!") + for i,v in ipairs(json.formats) do + if v.vcodec ~= "none" then + local fps = v.fps and v.fps.."fps" or "" + local resolution = string.format("%sx%s", v.width, v.height) + local l = string.format("%-9s %-5s (%-4s / %s)", resolution, fps, v.ext, v.vcodec) + local f = string.format("%s+bestaudio/best", v.format_id) + table.insert(res, {label=l, format=f, width=v.width }) + end + end + + table.sort(res, function(a, b) return a.width > b.width end) + + mp.osd_message("", 0) + format_cache[url] = res + return res, table_size(res) +end + + +-- register script message to show menu +mp.register_script_message("toggle-quality-menu", +function() + if destroyer ~= nil then + destroyer() + else + show_menu() + end +end) + +-- keybind to launch menu +mp.add_forced_key_binding(opts.toggle_menu_binding, "quality-menu", show_menu) + +-- special thanks to reload.lua (https://github.com/4e6/mpv-reload/) +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") + + mp.set_property_number("playlist-pos", playlist_pos) + + -- Tries to determine live stream vs. pre-recordered VOD. VOD has non-zero + -- duration property. When reloading VOD, to keep the current time position + -- we should provide offset from the start. Stream doesn't have fixed start. + -- Decent choice would be to reload stream from it's current 'live' positon. + -- That's the reason we don't pass the offset when reloading streams. + if reload_duration and reload_duration > 0 then + local function seeker() + mp.commandv("seek", time_pos, "absolute") + mp.unregister_event(seeker) + end + mp.register_event("file-loaded", seeker) + end +end |
