diff options
| author | ilotterytea <ilotterytea@proton.me> | 2024-05-26 20:10:26 +0500 |
|---|---|---|
| committer | ilotterytea <ilotterytea@proton.me> | 2024-05-26 20:10:26 +0500 |
| commit | 036c889c4a4f7f59d1e1a592586b54c5c5e93005 (patch) | |
| tree | aa76d678790abc79f24edf83c17a564eb2c6f65d /mpv/scripts | |
Diffstat (limited to 'mpv/scripts')
| -rw-r--r-- | mpv/scripts/auto-audio-device.lua | 36 | ||||
| -rw-r--r-- | mpv/scripts/auto-profiles.lua | 198 | ||||
| -rw-r--r-- | mpv/scripts/auto-save-state.lua | 55 | ||||
| -rw-r--r-- | mpv/scripts/autodeint.lua | 158 | ||||
| -rw-r--r-- | mpv/scripts/betterchapters.lua | 21 | ||||
| -rw-r--r-- | mpv/scripts/blacklist-extensions.lua | 80 | ||||
| -rw-r--r-- | mpv/scripts/cycle-profiles.lua | 103 | ||||
| -rw-r--r-- | mpv/scripts/music-mode.lua | 192 | ||||
| -rw-r--r-- | mpv/scripts/user-input.lua | 664 | ||||
| -rw-r--r-- | mpv/scripts/youtube-quality.lua | 275 | ||||
| -rw-r--r-- | mpv/scripts/youtube-serarch.lua | 149 |
11 files changed, 1931 insertions, 0 deletions
diff --git a/mpv/scripts/auto-audio-device.lua b/mpv/scripts/auto-audio-device.lua new file mode 100644 index 0000000..92fcdf8 --- /dev/null +++ b/mpv/scripts/auto-audio-device.lua @@ -0,0 +1,36 @@ +local mp = require 'mp' + + +local auto_change = true +local av_map = { + ["default"] = "auto", + ["DELL U2312HM"] = "coreaudio/AppleUSBAudioEngine:FiiO:DigiHug USB Audio:1a160000:3", -- FiiO DAC + ["SONY TV *00"] = "coreaudio/AppleGFXHDAEngineOutputDP:0:{D94D-8204-01010101}", -- HDMI + ["Color LCD"] = "coreaudio/AppleHDAEngineOutput:1B,0,1,1:0", -- Built-in +} + +function set_audio_device(obs_display) + if obs_display ~= nil and not auto_change then + return + end + + local display = obs_display or mp.get_property_native("display-names") + if not display or not display[1] then + --print("Invalid display return value: " .. tostring(display)) + return + end + + local new_adev = av_map[display[1]] or av_map["default"] + local current_adev = mp.get_property("audio-device", av_map["default"]) + if new_adev ~= current_adev then + mp.osd_message("Audio device: " .. new_adev) + mp.set_property("audio-device", new_adev) + end +end + +mp.observe_property("display-names", "native", function(name, value) set_audio_device(value) end) +mp.add_key_binding("", "set-audio-device", function() set_audio_device(nil) end) +mp.add_key_binding("", "toggle-switching", function() + auto_change = not auto_change + mp.osd_message("Audio device switching: " .. tostring(auto_change)) +end) diff --git a/mpv/scripts/auto-profiles.lua b/mpv/scripts/auto-profiles.lua new file mode 100644 index 0000000..32e1809 --- /dev/null +++ b/mpv/scripts/auto-profiles.lua @@ -0,0 +1,198 @@ +--[[ + +Automatically apply profiles based on runtime conditions. +At least mpv 0.21.0 is required. + +This script queries the list of loaded config profiles, and checks the +"profile-desc" field of each profile. If it starts with "cond:", the script +parses the string as Lua expression, and evaluates it. If the expression +returns true, the profile is applied, if it returns false, it is ignored. + +Expressions can reference properties by accessing "p". For example, "p.pause" +would return the current pause status. If the variable name contains any "_" +characters, they are turned into "-". For example, "playback_time" would +return the property "playback-time". (Although you can also just write +p["playback-time"].) + +Note that if a property is not available, it will return nil, which can cause +errors if used in expressions. These are printed and ignored, and the +expression is considered to be false. You can also write e.g. +get("playback-time", 0) instead of p.playback_time to default to 0. + +Whenever a property referenced by a profile condition changes, the condition +is re-evaluated. If the return value of the condition changes from false or +error to true, the profile is applied. + +Note that profiles cannot be "unapplied", so you may have to define inverse +profiles with inverse conditions do undo a profile. + +Using profile-desc is just a hack - maybe it will be changed later. + +Supported --script-opts: + + auto-profiles: if set to "no", the script disables itself (but will still + listen to property notifications etc. - if you set it to + "yes" again, it will re-evaluate the current state) + +Example profiles: + +# the profile names aren't used (except for logging), but must not clash with +# other profiles +[test] +profile-desc=cond:p.playback_time>10 +video-zoom=2 + +# you would need this to actually "unapply" the "test" profile +[test-revert] +profile-desc=cond:p.playback_time<=10 +video-zoom=0 + +--]] + +local lua_modules = mp.find_config_file('lua-modules') +if lua_modules then + package.path = package.path .. ';' .. lua_modules .. '/?.lua' +end + +local f = require 'auto-profiles-functions' +local utils = require 'mp.utils' +local msg = require 'mp.msg' + +local profiles = {} +local watched_properties = {} -- indexed by property name (used as a set) +local cached_properties = {} -- property name -> last known raw value +local properties_to_profiles = {} -- property name -> set of profiles using it +local have_dirty_profiles = false -- at least one profile is marked dirty + +-- Used during evaluation of the profile condition, and should contain the +-- profile the condition is evaluated for. +local current_profile = nil + +local function evaluate(profile) + msg.verbose("Re-evaluate auto profile " .. profile.name) + + current_profile = profile + local status, res = pcall(profile.cond) + current_profile = nil + + if not status then + -- errors can be "normal", e.g. in case properties are unavailable + msg.info("Error evaluating: " .. res) + res = false + elseif type(res) ~= "boolean" then + msg.error("Profile '" .. profile.name .. "' did not return a boolean.") + res = false + end + if res ~= profile.status and res == true then + msg.info("Applying profile " .. profile.name) + mp.commandv("apply-profile", profile.name) + end + profile.status = res + profile.dirty = false +end + +local function on_property_change(name, val) + cached_properties[name] = val + -- Mark all profiles reading this property as dirty, so they get re-evaluated + -- the next time the script goes back to sleep. + local dependent_profiles = properties_to_profiles[name] + if dependent_profiles then + for profile, _ in pairs(dependent_profiles) do + assert(profile.cond) -- must be a profile table + profile.dirty = true + have_dirty_profiles = true + end + end +end + +local function on_idle() + if mp.get_opt("auto-profiles") == "no" then + return + end + + -- When events and property notifications stop, re-evaluate all dirty profiles. + if have_dirty_profiles then + for _, profile in ipairs(profiles) do + if profile.dirty then + evaluate(profile) + end + end + end + have_dirty_profiles = false +end + +mp.register_idle(on_idle) + +local evil_meta_magic = { + __index = function(table, key) + -- interpret everything as property, unless it already exists as + -- a non-nil global value + local v = _G[key] + if type(v) ~= "nil" then + return v + end + -- Lua identifiers can't contain "-", so in order to match with mpv + -- property conventions, replace "_" to "-" + key = string.gsub(key, "_", "-") + -- Normally, we use the cached value only (to reduce CPU usage I guess?) + if not watched_properties[key] then + watched_properties[key] = true + mp.observe_property(key, "native", on_property_change) + cached_properties[key] = mp.get_property_native(key) + end + -- The first time the property is read we need add it to the + -- properties_to_profiles table, which will be used to mark the profile + -- dirty if a property referenced by it changes. + if current_profile then + local map = properties_to_profiles[key] + if not map then + map = {} + properties_to_profiles[key] = map + end + map[current_profile] = true + end + return cached_properties[key] + end, +} + +local evil_magic = {} +setmetatable(evil_magic, evil_meta_magic) + +local function compile_cond(name, s) + chunk, err = loadstring("return " .. s, "profile " .. name .. " condition") + if not chunk then + msg.error("Profile '" .. name .. "' condition: " .. err) + return function() return false end + end + return chunk +end + +for i, v in ipairs(mp.get_property_native("profile-list")) do + local desc = v["profile-desc"] + if desc and desc:sub(1, 5) == "cond:" then + local profile = { + name = v.name, + cond = compile_cond(v.name, desc:sub(6)), + properties = {}, + status = nil, + dirty = true, -- need re-evaluate + } + profiles[#profiles + 1] = profile + have_dirty_profiles = true + end +end + +-- these definitions are for use by the condition expressions + +p = evil_magic + +function get(property_name, default) + local val = p[property_name] + if val == nil then + val = default + end + return val +end + +-- re-evaluate all profiles immediately +on_idle() diff --git a/mpv/scripts/auto-save-state.lua b/mpv/scripts/auto-save-state.lua new file mode 100644 index 0000000..a4ec3ec --- /dev/null +++ b/mpv/scripts/auto-save-state.lua @@ -0,0 +1,55 @@ +-- Save watch-later conditionally. +-- a) Always for playlists (so mpv remembers the position within this playlist) +-- b) Never for files shorter than `min_length` seconds +-- c) When the current playback position is > `thresh_start` and < `thresh_end` + + +local opts = require 'mp.options' +local o = { + min_length = 600, + thresh_end = 180, + thresh_start = 60, +} +opts.read_options(o) + + +-- Return true when multiple files are being played +function check_playlist() + local pcount, err = mp.get_property_number("playlist-count") + if not pcount then + print("error: " .. err) + pcount = 1 + end + + return pcount > 1 +end + + +-- Return true when the current playback time is not too close to the start or end +-- Always return false for short files, no matter the playback time +function check_time() + local duration = mp.get_property_number("duration", 9999) + if duration < o.min_length then + return false + end + + local remaining, err = mp.get_property_number("time-remaining") + if not remaining then + print("error: " .. err) + remaining = -math.huge + end + local pos, err = mp.get_property_number("time-pos") + if not pos then + print("error: " .. err) + pos = -math.huge + end + + return pos > o.thresh_start and remaining > o.thresh_end +end + + +mp.add_key_binding("q", "quit-watch-later-conditional", + function() + mp.set_property_bool("options/save-position-on-quit", check_playlist() or check_time()) + mp.command("quit") + end) diff --git a/mpv/scripts/autodeint.lua b/mpv/scripts/autodeint.lua new file mode 100644 index 0000000..16f56ea --- /dev/null +++ b/mpv/scripts/autodeint.lua @@ -0,0 +1,158 @@ +-- From: https://github.com/mpv-player/mpv/tree/master/TOOLS/lua +-- (with slight modifications) +-- +-- This script uses the lavfi idet filter to automatically insert the +-- appropriate deinterlacing filter based on a short section of the +-- currently playing video. +-- +-- It registers the key-binding ctrl+d, which when pressed, inserts the filters +-- ``vf=lavfi=idet,pullup,vf=lavfi=idet``. After 4 seconds, it removes these +-- filters and decides whether the content is progressive, interlaced, or +-- telecined and the interlacing field dominance. +-- +-- Based on this information, it may set mpv's ``deinterlace`` property (which +-- usually inserts the yadif filter), or insert the ``pullup`` filter if the +-- content is telecined. It also sets mpv's ``field-dominance`` property. +-- +-- OPTIONS: +-- The default detection time may be overridden by adding +-- +-- --script-opts=autodeint.detect_seconds=<number of seconds> +-- +-- to mpv's arguments. This may be desirable to allow idet more +-- time to collect data. +-- +-- To see counts of the various types of frames for each detection phase, +-- the verbosity can be increased with +-- +-- --msg-level autodeint=v +-- +-- This script requires a recent version of ffmpeg for which the idet +-- filter adds the required metadata. + +require "mp.msg" + +script_name = mp.get_script_name() +detect_label = string.format("%s-detect", script_name) +pullup_label = string.format("%s", script_name) +ivtc_detect_label = string.format("%s-ivtc-detect", script_name) + +-- number of seconds to gather cropdetect data +detect_seconds = tonumber(mp.get_opt(string.format("%s.detect_seconds", script_name))) +if not detect_seconds then + detect_seconds = 4 +end + +function del_filter_if_present(label) + -- necessary because mp.command('vf del @label:filter') raises an + -- error if the filter doesn't exist + local vfs = mp.get_property_native("vf") + + for i,vf in pairs(vfs) do + if vf["label"] == label then + table.remove(vfs, i) + mp.set_property_native("vf", vfs) + return true + end + end + return false +end + +function start_detect() + -- exit if detection is already in progress + if timer then + mp.msg.warn("already detecting!") + mp.osd_message("autodeint: already detecting!") + return + end + + mp.set_property("deinterlace","no") + del_filter_if_present(pullup_label) + + -- insert the detection filter + local cmd = string.format('vf add @%s:lavfi=graph="idet",@%s:pullup,@%s:lavfi=graph="idet"', + detect_label, pullup_label, ivtc_detect_label) + if not mp.command(cmd) then + mp.msg.error("failed to insert detection filters") + mp.osd_message("autodeint: failed to insert detection filters") + return + end + + -- wait to gather data + mp.osd_message("autodeint: starting detection") + timer = mp.add_timeout(detect_seconds, select_filter) +end + +function stop_detect() + del_filter_if_present(detect_label) + del_filter_if_present(ivtc_detect_label) + timer = nil +end + +progressive, interlaced_tff, interlaced_bff, interlaced = 0, 1, 2, 3, 4 + +function judge(label) + -- get the metadata + local result = mp.get_property_native(string.format("vf-metadata/%s", label)) + -- filter might have been removed by third party + if not result or next(result) == nil then + return progressive + end + num_tff = tonumber(result["lavfi.idet.multiple.tff"]) + num_bff = tonumber(result["lavfi.idet.multiple.bff"]) + num_progressive = tonumber(result["lavfi.idet.multiple.progressive"]) + num_undetermined = tonumber(result["lavfi.idet.multiple.undetermined"]) + num_interlaced = num_tff + num_bff + num_determined = num_interlaced + num_progressive + + mp.msg.verbose(label .. " progressive = "..num_progressive) + mp.msg.verbose(label .. " interlaced-tff = "..num_tff) + mp.msg.verbose(label .. " interlaced-bff = "..num_bff) + mp.msg.verbose(label .. " undetermined = "..num_undetermined) + + if num_determined < num_undetermined then + mp.msg.warn("majority undetermined frames") + end + if num_progressive > 20*num_interlaced then + return progressive + elseif num_tff > 10*num_bff then + return interlaced_tff + elseif num_bff > 10*num_tff then + return interlaced_bff + else + return interlaced + end +end + +function select_filter() + -- handle the first detection filter results + verdict = judge(detect_label) + if verdict == progressive then + mp.msg.info("progressive: doing nothing") + mp.osd_message("autodeint: no deinterlacing required") + stop_detect() + return + elseif verdict == interlaced_tff then + mp.set_property("field-dominance", "top") + elseif verdict == interlaced_bff then + mp.set_property("field-dominance", "bottom") + elseif verdict == interlaced then + mp.set_property("field-dominance", "auto") + end + + -- handle the ivtc detection filter results + verdict = judge(ivtc_detect_label) + if verdict == progressive then + mp.msg.info(string.format("telecinied with %s field dominance: using pullup", mp.get_property("field-dominance"))) + mp.osd_message("autodeint: using pullup") + stop_detect() + else + mp.msg.info(string.format("interlaced with %s field dominance: setting deinterlace property", mp.get_property("field-dominance"))) + del_filter_if_present(pullup_label) + mp.osd_message(string.format("autodeint: setting deinterlace property (%s)", mp.get_property("field-dominance"))) + mp.set_property("deinterlace", "yes") + stop_detect() + end +end + +mp.add_key_binding("ctrl+d", script_name, start_detect) diff --git a/mpv/scripts/betterchapters.lua b/mpv/scripts/betterchapters.lua new file mode 100644 index 0000000..4b871c8 --- /dev/null +++ b/mpv/scripts/betterchapters.lua @@ -0,0 +1,21 @@ +-- From: https://github.com/mpv-player/mpv/issues/4738#issuecomment-321298846 + +function chapter_seek(direction) + local chapters = mp.get_property_number("chapters") + if chapters == nil then chapters = 0 end + local chapter = mp.get_property_number("chapter") + if chapter == nil then chapter = 0 end + if chapter+direction < 0 then + mp.command("playlist_prev") + mp.commandv("script-message", "osc-playlist") + elseif chapter+direction >= chapters then + mp.command("playlist_next") + mp.commandv("script-message", "osc-playlist") + else + mp.commandv("add", "chapter", direction) + mp.commandv("script-message", "osc-chapterlist") + end +end + +mp.add_key_binding(nil, "chapterplaylist-next", function() chapter_seek(1) end) +mp.add_key_binding(nil, "chapterplaylist-prev", function() chapter_seek(-1) end)
\ No newline at end of file diff --git a/mpv/scripts/blacklist-extensions.lua b/mpv/scripts/blacklist-extensions.lua new file mode 100644 index 0000000..a8ec638 --- /dev/null +++ b/mpv/scripts/blacklist-extensions.lua @@ -0,0 +1,80 @@ +-- From: https://github.com/occivink/mpv-scripts + +opts = { + blacklist="", + whitelist="", + remove_files_without_extension = false, + oneshot = true, +} +(require 'mp.options').read_options(opts) +local msg = require 'mp.msg' + +function split(input) + local ret = {} + for str in string.gmatch(input, "([^,]+)") do + ret[#ret + 1] = str + end + return ret +end + +opts.blacklist = split(opts.blacklist) +opts.whitelist = split(opts.whitelist) + +local exclude +if #opts.whitelist > 0 then + exclude = function(extension) + for _, ext in pairs(opts.whitelist) do + if extension == ext then + return false + end + end + return true + end +elseif #opts.blacklist > 0 then + exclude = function(extension) + for _, ext in pairs(opts.blacklist) do + if extension == ext then + return true + end + end + return false + end +else + return +end + +function should_remove(filename) + if string.find(filename, "://") then + return false + end + local extension = string.match(filename, "%.([^./]+)$") + if not extension and opts.remove_file_without_extension then + return true + end + if extension and exclude(string.lower(extension)) then + return true + end + return false +end + +function process(playlist_count) + if playlist_count < 2 then return end + if opts.oneshot then + mp.unobserve_property(observe) + end + local playlist = mp.get_property_native("playlist") + local removed = 0 + for i = #playlist, 1, -1 do + if should_remove(playlist[i].filename) then + mp.commandv("playlist-remove", i-1) + removed = removed + 1 + end + end + if removed == #playlist then + msg.warn("Removed eveything from the playlist") + end +end + +function observe(k,v) process(v) end + +mp.observe_property("playlist-count", "number", observe) diff --git a/mpv/scripts/cycle-profiles.lua b/mpv/scripts/cycle-profiles.lua new file mode 100644 index 0000000..da846c5 --- /dev/null +++ b/mpv/scripts/cycle-profiles.lua @@ -0,0 +1,103 @@ +--[[ + script to cycle profiles with a keybind, accomplished through script messages + available at: https://github.com/CogentRedTester/mpv-scripts + syntax: + script-message cycle-profiles "profile1;profile2;profile3" + You must use semicolons to separate the profiles, do not include any spaces that are not part of the profile name. + The script will print the profile description to the screen when switching, if there is no profile description, then it just prints the name +]]-- + +--change this to change what character separates the profile names +seperator = ";" + +msg = require 'mp.msg' + +--splits the profiles string into an array of profile names +--function taken from: https://stackoverflow.com/questions/1426954/split-string-in-lua/7615129#7615129 +function mysplit (inputstr, sep) + if sep == nil then + sep = "%s" + end + local t={} + for str in string.gmatch(inputstr, "([^"..sep.."]+)") do + table.insert(t, str) + end + return t +end + +--table of all available profiles and options +profileList = mp.get_property_native('profile-list') + +--keeps track of current profile for every unique cycle +iterator = {} + +--stores descriptions for profiles +--once requested a description is stored here so it does not need to be found again +profilesDescs = {} + +--if trying to cycle to an unknown profile this function is run to find a description to print +function findDesc(profile) + msg.verbose('unknown profile ' .. profile .. ', searching for description') + + for i = 1, #profileList, 1 do + if profileList[i]['name'] == profile then + msg.verbose('profile found') + local desc = profileList[i]['profile-desc'] + + if desc ~= nil then + msg.verbose('description found') + profilesDescs[profile] = desc + else + msg.verbose('no description, will use name') + profilesDescs[profile] = profile + end + return + end + end + + msg.verbose('profile not found') + profilesDescs[profile] = "no profile '" .. profile .. "'" +end + +--prints the profile description to the OSD +--if the profile has not been requested before during the session then it runs findDesc() +function printProfileDesc(profile) + local desc = profilesDescs[profile] + if desc == nil then + findDesc(profile) + desc = profilesDescs[profile] + end + + msg.verbose('profile description: ' .. desc) + mp.osd_message(desc) +end + +function main(profileStr) + --if there is not already an iterator for this cycle then it creates one + if iterator[profileStr] == nil then + msg.verbose('unknown cycle, creating new iterator') + iterator[profileStr] = 1 + end + local i = iterator[profileStr] + + --converts the string into an array of profile names + local profiles = mysplit(profileStr, seperator) + msg.verbose('cycling ' .. tostring(profiles)) + msg.verbose("number of profiles: " .. tostring(#profiles)) + + --sends the command to apply the profile + msg.info("applying profile " .. profiles[i]) + mp.commandv('apply-profile', profiles[i]) + + --prints the profile description to the OSD + printProfileDesc(profiles[i]) + + --moves the iterator + iterator[profileStr] = iterator[profileStr] + 1 + if iterator[profileStr] > #profiles then + msg.verbose('reached end of profiles, wrapping back to start') + iterator[profileStr] = 1 + end +end + +mp.register_script_message('cycle-profiles', main) diff --git a/mpv/scripts/music-mode.lua b/mpv/scripts/music-mode.lua new file mode 100644 index 0000000..4c57456 --- /dev/null +++ b/mpv/scripts/music-mode.lua @@ -0,0 +1,192 @@ +local mp = require 'mp' +local msg = require 'mp.msg' +local opt = require 'mp.options' + +--script options, set these in script-opts +local o = { + --change to disable automatic mode switching + auto = true, + + --profile to call when valid extension is found + profile = "music", + + --runs this profile when in music mode and a non-audio file is loaded + --you should essentially put all your defaults that the music profile changed in here + undo_profile = "", + + --start playback in music mode. This setting is only applied when the player is initially started, + --changing this option during runtime does nothing. + --probably only useful if auto is disabled + enable = false, + + --the script will also enable the following input section when music mode is enabled + --see the mpv manual for details on sections + input_section = "music", + + --dispays the metadata of the track on the osd when music mode is on + --there is also a script message to enable this seperately + show_metadata = false +} + +opt.read_options(o, 'musicmode', function() msg.verbose('options updated') end) + +--a music file is one where mpv returns an audio stream or coverart as the first track +local function is_audio_file() + local track_list = mp.get_property_native("track-list") + + local has_audio = false + for _, track in ipairs(track_list) do + if track.type == "audio" then has_audio = true + elseif not track.albumart and (track["demux-fps"] or 2) > 1 then return false end + end + return has_audio +end + +local metadata = mp.create_osd_overlay('ass-events') +metadata.hidden = not o.show_metadata + +local function update_metadata() + metadata.data = mp.get_property_osd('filtered-metadata') + metadata:update() +end + +local function enable_metadata() + metadata.hidden = false + metadata:update() +end + +local function disable_metadata() + metadata.hidden = true + metadata:remove() +end + +--changes visibility of metadata +local function show_metadata(command) + if command == "on" or command == nil then + enable_metadata() + elseif command == "off" then + disable_metadata() + elseif command == "toggle" then + if metadata.hidden then + enable_metadata() + else + disable_metadata() + end + else + msg.warn('unknown command "' .. command .. '"') + end +end + +--to prevent superfluous loading of profiles the script keeps track of when music mode is enabled +local musicMode = false + +--enables music mode +local function activate() + mp.commandv('apply-profile', o.profile) + mp.commandv('enable-section', o.input_section, "allow-vo-dragging+allow-hide-cursor") + mp.osd_message('Music Mode enabled') + + if o.show_metadata then + show_metadata("on") + end + + musicMode = true +end + +--disables music mode +local function deactivate() + mp.commandv('apply-profile', o.undo_profile) + mp.commandv('disable-section', o.input_section) + mp.osd_message('Music Mode disabled') + + if o.show_metadata then + show_metadata('off') + end + + musicMode = false +end + +local function main() + --if the file is an audio file then the music profile is loaded + if is_audio_file() then + msg.verbose('audio file, applying profile "' .. o.profile .. '"') + if not musicMode then + activate() + end + elseif o.undo_profile ~= "" and musicMode then + msg.verbose('video file, applying undo profile "' .. o.undo_profile .. '"') + deactivate() + end +end + +--sets music mode from script-message +local function script_message(command) + if command == "on" or command == nil then + activate() + elseif command == "off" then + deactivate() + elseif command == "toggle" then + if musicMode then + deactivate() + else + activate() + end + else + msg.warn('unknown command "' .. command .. '"') + end +end + +local function lock() + o.auto = false + msg.info('Music Mode locked') + mp.osd_message('Music Mode locked') +end + +local function unLock() + o.auto = true + msg.info('Music Mode unlocked') + mp.osd_message('Music Mode unlocked') +end + +--toggles lock +local function lock_script_message(command) + if command == "on" or command == nil then + lock() + elseif command == "off" then + unLock() + elseif command == "toggle" then + if o.auto then + lock() + else + unLock() + end + else + msg.warn('unknown command "' .. command .. '"') + end +end + +--runs when the file is loaded, if script is locked it will do nothing +local function file_loaded() + if o.auto then + main() + end +end + +if o.enable then + activate() +end + +--sets music mode +--accepts arguments: 'on', 'off', 'toggle' +mp.register_script_message('music-mode', script_message) + +--stops the script from switching modes on file loads +----accepts arguments: 'on', 'off', 'toggle' +mp.register_script_message('music-mode-lock', lock_script_message) + +--shows file metadata on osc +--accepts arguments: 'on' 'off' 'toggle' +mp.register_script_message('show-metadata', show_metadata) + +mp.add_hook('on_preloaded', 40, file_loaded) +mp.observe_property('filtered-metadata', 'string', update_metadata) diff --git a/mpv/scripts/user-input.lua b/mpv/scripts/user-input.lua new file mode 100644 index 0000000..689e073 --- /dev/null +++ b/mpv/scripts/user-input.lua @@ -0,0 +1,664 @@ +local mp = require 'mp' +local msg = require 'mp.msg' +local utils = require 'mp.utils' +local options = require 'mp.options' + +-- Default options +local opts = { + -- All drawing is scaled by this value, including the text borders and the + -- cursor. Change it if you have a high-DPI display. + scale = 1, + -- Set the font used for the REPL and the console. This probably doesn't + -- have to be a monospaced font. + font = "", + -- Set the font size used for the REPL and the console. This will be + -- multiplied by "scale." + font_size = 16, +} + +options.read_options(opts, "user_input") + +local queue = { + queue = {}, + active_ids = {} +} +local histories = {} +local request = nil + +local line = '' + +--[[ + sends a response to the original script in the form of a json string + it is expected that all requests get a response, if the input is nil then err should say why + current error codes are: + exitted the user closed the input instead of pressing Enter + already_queued a request with the specified id was already in the queue + replaced the request was replaced with a newer request + cancelled a script cancelled the request +]]-- +local function send_response(send_line, err, override_response) + mp.commandv("script-message", override_response or request.response, send_line and line or "", err or "") +end + + +--[[ + The below code is a modified implementation of text input from mpv's console.lua: + https://github.com/mpv-player/mpv/blob/7ca14d646c7e405f3fb1e44600e2a67fc4607238/player/lua/console.lua + + Modifications: + removed support for log messages, sending commands, tab complete, help commands + removed update timer + Changed esc key to call handle_esc function + handle_esc and handle_enter now call the send_response() function + all functions that send responses now call queue:pop() + made history specific to request ids + localised all functions - reordered some to fit + keybindings use new names +]]-- + +------------------------------START ORIGINAL MPV CODE----------------------------------- +---------------------------------------------------------------------------------------- +---------------------------------------------------------------------------------------- +---------------------------------------------------------------------------------------- +---------------------------------------------------------------------------------------- + +-- Copyright (C) 2019 the mpv developers +-- +-- Permission to use, copy, modify, and/or distribute this software for any +-- purpose with or without fee is hereby granted, provided that the above +-- copyright notice and this permission notice appear in all copies. +-- +-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +-- WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +-- MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +-- SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +-- WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION +-- OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +-- CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +local assdraw = require 'mp.assdraw' + +local function detect_platform() + local o = {} + -- Kind of a dumb way of detecting the platform but whatever + if mp.get_property_native('options/vo-mmcss-profile', o) ~= o then + return 'windows' + elseif mp.get_property_native('options/macos-force-dedicated-gpu', o) ~= o then + return 'macos' + elseif os.getenv('WAYLAND_DISPLAY') then + return 'wayland' + end + return 'x11' +end + +-- Pick a better default font for Windows and macOS +local platform = detect_platform() +if platform == 'windows' then + opts.font = 'Consolas' +elseif platform == 'macos' then + opts.font = 'Menlo' +else + opts.font = 'monospace' +end + +local repl_active = false +local insert_mode = false +local cursor = 1 +local key_bindings = {} +local global_margin_y = 0 + +-- Escape a string for verbatim display on the OSD +local function ass_escape(str) + -- There is no escape for '\' in ASS (I think?) but '\' is used verbatim if + -- it isn't followed by a recognised character, so add a zero-width + -- non-breaking space + str = str:gsub('\\', '\\\239\187\191') + str = str:gsub('{', '\\{') + str = str:gsub('}', '\\}') + -- Precede newlines with a ZWNBSP to prevent ASS's weird collapsing of + -- consecutive newlines + str = str:gsub('\n', '\239\187\191\\N') + -- Turn leading spaces into hard spaces to prevent ASS from stripping them + str = str:gsub('\\N ', '\\N\\h') + str = str:gsub('^ ', '\\h') + return str +end + +-- Render the REPL and console as an ASS OSD +local function update() + local dpi_scale = mp.get_property_native("display-hidpi-scale", 1.0) + + dpi_scale = dpi_scale * opts.scale + + local screenx, screeny, aspect = mp.get_osd_size() + screenx = screenx / dpi_scale + screeny = screeny / dpi_scale + + -- Clear the OSD if the REPL is not active + if not repl_active then + mp.set_osd_ass(screenx, screeny, '') + return + end + + local ass = assdraw.ass_new() + local style = '{\\r' .. + '\\1a&H00&\\3a&H00&\\4a&H99&' .. + '\\1c&Heeeeee&\\3c&H111111&\\4c&H000000&' .. + '\\fn' .. opts.font .. '\\fs' .. opts.font_size .. + '\\bord1\\xshad0\\yshad1\\fsp0\\q1}' + -- Create the cursor glyph as an ASS drawing. ASS will draw the cursor + -- inline with the surrounding text, but it sets the advance to the width + -- of the drawing. So the cursor doesn't affect layout too much, make it as + -- thin as possible and make it appear to be 1px wide by giving it 0.5px + -- horizontal borders. + local cheight = opts.font_size * 8 + local cglyph = '{\\r' .. + '\\1a&H44&\\3a&H44&\\4a&H99&' .. + '\\1c&Heeeeee&\\3c&Heeeeee&\\4c&H000000&' .. + '\\xbord0.5\\ybord0\\xshad0\\yshad1\\p4\\pbo24}' .. + 'm 0 0 l 1 0 l 1 ' .. cheight .. ' l 0 ' .. cheight .. + '{\\p0}' + local before_cur = ass_escape(line:sub(1, cursor - 1)) + local after_cur = ass_escape(line:sub(cursor)) + + ass:new_event() + ass:an(1) + ass:pos(2, screeny - 2 - global_margin_y * screeny) + ass:append(style .. request.text .. '\\N') + ass:append('> ' .. before_cur) + ass:append(cglyph) + ass:append(style .. after_cur) + + -- Redraw the cursor with the REPL text invisible. This will make the + -- cursor appear in front of the text. + ass:new_event() + ass:an(1) + ass:pos(2, screeny - 2) + ass:append(style .. '{\\alpha&HFF&}> ' .. before_cur) + ass:append(cglyph) + ass:append(style .. '{\\alpha&HFF&}' .. after_cur) + + mp.set_osd_ass(screenx, screeny, ass.text) +end + +-- Naive helper function to find the next UTF-8 character in 'str' after 'pos' +-- by skipping continuation bytes. Assumes 'str' contains valid UTF-8. +local function next_utf8(str, pos) + if pos > str:len() then return pos end + repeat + pos = pos + 1 + until pos > str:len() or str:byte(pos) < 0x80 or str:byte(pos) > 0xbf + return pos +end + +-- As above, but finds the previous UTF-8 charcter in 'str' before 'pos' +local function prev_utf8(str, pos) + if pos <= 1 then return pos end + repeat + pos = pos - 1 + until pos <= 1 or str:byte(pos) < 0x80 or str:byte(pos) > 0xbf + return pos +end + +-- Insert a character at the current cursor position (any_unicode, Shift+Enter) +local function handle_char_input(c) + if insert_mode then + line = line:sub(1, cursor - 1) .. c .. line:sub(next_utf8(line, cursor)) + else + line = line:sub(1, cursor - 1) .. c .. line:sub(cursor) + end + cursor = cursor + #c + update() +end + +-- Remove the character behind the cursor (Backspace) +local function handle_backspace() + if cursor <= 1 then return end + local prev = prev_utf8(line, cursor) + line = line:sub(1, prev - 1) .. line:sub(cursor) + cursor = prev + update() +end + +-- Remove the character in front of the cursor (Del) +local function handle_del() + if cursor > line:len() then return end + line = line:sub(1, cursor - 1) .. line:sub(next_utf8(line, cursor)) + update() +end + +-- Toggle insert mode (Ins) +local function handle_ins() + insert_mode = not insert_mode +end + +-- Move the cursor to the next character (Right) +local function next_char(amount) + cursor = next_utf8(line, cursor) + update() +end + +-- Move the cursor to the previous character (Left) +local function prev_char(amount) + cursor = prev_utf8(line, cursor) + update() +end + +-- Clear the current line (Ctrl+C) +local function clear() + line = '' + cursor = 1 + insert_mode = false + request.history.pos = #request.history.list + 1 + update() +end + +-- Close the REPL if the current line is empty, otherwise do nothing (Ctrl+D) +local function maybe_exit() + if line == '' then + send_response(false, "exitted") + queue:pop() + end +end + +local function handle_esc() + send_response(false, "exitted") + queue:pop() +end + +-- Run the current command and clear the line (Enter) +local function handle_enter() + if request.history.list[#request.history.list] ~= line and line ~= "" then + request.history.list[#request.history.list + 1] = line + end + send_response(true) + queue:pop() +end + +-- Go to the specified position in the command history +local function go_history(new_pos) + local old_pos = request.history.pos + request.history.pos = new_pos + + -- Restrict the position to a legal value + if request.history.pos > #request.history.list + 1 then + request.history.pos = #request.history.list + 1 + elseif request.history.pos < 1 then + request.history.pos = 1 + end + + -- Do nothing if the history position didn't actually change + if request.history.pos == old_pos then + return + end + + -- If the user was editing a non-history line, save it as the last history + -- entry. This makes it much less frustrating to accidentally hit Up/Down + -- while editing a line. + if old_pos == #request.history.list + 1 and line ~= '' and request.history.list[#request.history.list] ~= line then + request.history.list[#request.history.list + 1] = line + end + + -- Now show the history line (or a blank line for #history + 1) + if request.history.pos <= #request.history.list then + line = request.history.list[request.history.pos] + else + line = '' + end + cursor = line:len() + 1 + insert_mode = false + update() +end + +-- Go to the specified relative position in the command history (Up, Down) +local function move_history(amount) + go_history(request.history.pos + amount) +end + +-- Go to the first command in the command history (PgUp) +local function handle_pgup() + go_history(1) +end + +-- Stop browsing history and start editing a blank line (PgDown) +local function handle_pgdown() + go_history(#request.history.list + 1) +end + +-- Move to the start of the current word, or if already at the start, the start +-- of the previous word. (Ctrl+Left) +local function prev_word() + -- This is basically the same as next_word() but backwards, so reverse the + -- string in order to do a "backwards" find. This wouldn't be as annoying + -- to do if Lua didn't insist on 1-based indexing. + cursor = line:len() - select(2, line:reverse():find('%s*[^%s]*', line:len() - cursor + 2)) + 1 + update() +end + +-- Move to the end of the current word, or if already at the end, the end of +-- the next word. (Ctrl+Right) +local function next_word() + cursor = select(2, line:find('%s*[^%s]*', cursor)) + 1 + update() +end + +-- Move the cursor to the beginning of the line (HOME) +local function go_home() + cursor = 1 + update() +end + +-- Move the cursor to the end of the line (END) +local function go_end() + cursor = line:len() + 1 + update() +end + +-- Delete from the cursor to the end of the word (Ctrl+W) +local function del_word() + local before_cur = line:sub(1, cursor - 1) + local after_cur = line:sub(cursor) + + before_cur = before_cur:gsub('[^%s]+%s*$', '', 1) + line = before_cur .. after_cur + cursor = before_cur:len() + 1 + update() +end + +-- Delete from the cursor to the end of the line (Ctrl+K) +local function del_to_eol() + line = line:sub(1, cursor - 1) + update() +end + +-- Delete from the cursor back to the start of the line (Ctrl+U) +local function del_to_start() + line = line:sub(cursor) + cursor = 1 + update() +end + +-- Returns a string of UTF-8 text from the clipboard (or the primary selection) +local function get_clipboard(clip) + if platform == 'x11' then + local res = utils.subprocess({ + args = { 'xclip', '-selection', clip and 'clipboard' or 'primary', '-out' }, + playback_only = false, + }) + if not res.error then + return res.stdout + end + elseif platform == 'wayland' then + local res = utils.subprocess({ + args = { 'wl-paste', clip and '-n' or '-np' }, + playback_only = false, + }) + if not res.error then + return res.stdout + end + elseif platform == 'windows' then + local res = utils.subprocess({ + args = { 'powershell', '-NoProfile', '-Command', [[& { + Trap { + Write-Error -ErrorRecord $_ + Exit 1 + } + + $clip = "" + if (Get-Command "Get-Clipboard" -errorAction SilentlyContinue) { + $clip = Get-Clipboard -Raw -Format Text -TextFormatType UnicodeText + } else { + Add-Type -AssemblyName PresentationCore + $clip = [Windows.Clipboard]::GetText() + } + + $clip = $clip -Replace "`r","" + $u8clip = [System.Text.Encoding]::UTF8.GetBytes($clip) + [Console]::OpenStandardOutput().Write($u8clip, 0, $u8clip.Length) + }]] }, + playback_only = false, + }) + if not res.error then + return res.stdout + end + elseif platform == 'macos' then + local res = utils.subprocess({ + args = { 'pbpaste' }, + playback_only = false, + }) + if not res.error then + return res.stdout + end + end + return '' +end + +-- Paste text from the window-system's clipboard. 'clip' determines whether the +-- clipboard or the primary selection buffer is used (on X11 and Wayland only.) +local function paste(clip) + local text = get_clipboard(clip) + local before_cur = line:sub(1, cursor - 1) + local after_cur = line:sub(cursor) + line = before_cur .. text .. after_cur + cursor = cursor + text:len() + update() +end + +-- List of input bindings. This is a weird mashup between common GUI text-input +-- bindings and readline bindings. +local function get_bindings() + local bindings = { + { 'esc', handle_esc }, + { 'enter', handle_enter }, + { 'kp_enter', handle_enter }, + { 'shift+enter', function() handle_char_input('\n') end }, + { 'bs', handle_backspace }, + { 'shift+bs', handle_backspace }, + { 'del', handle_del }, + { 'shift+del', handle_del }, + { 'ins', handle_ins }, + { 'shift+ins', function() paste(false) end }, + { 'mbtn_mid', function() paste(false) end }, + { 'left', function() prev_char() end }, + { 'right', function() next_char() end }, + { 'up', function() move_history(-1) end }, + { 'wheel_up', function() move_history(-1) end }, + { 'down', function() move_history(1) end }, + { 'wheel_down', function() move_history(1) end }, + { 'wheel_left', function() end }, + { 'wheel_right', function() end }, + { 'ctrl+left', prev_word }, + { 'ctrl+right', next_word }, + { 'home', go_home }, + { 'end', go_end }, + { 'pgup', handle_pgup }, + { 'pgdwn', handle_pgdown }, + { 'ctrl+c', clear }, + { 'ctrl+d', maybe_exit }, + { 'ctrl+k', del_to_eol }, + { 'ctrl+u', del_to_start }, + { 'ctrl+v', function() paste(true) end }, + { 'meta+v', function() paste(true) end }, + { 'ctrl+w', del_word }, + { 'kp_dec', function() handle_char_input('.') end }, + } + + for i = 0, 9 do + bindings[#bindings + 1] = + {'kp' .. i, function() handle_char_input('' .. i) end} + end + + return bindings +end + +local function text_input(info) + if info.key_text and (info.event == "press" or info.event == "down" + or info.event == "repeat") + then + handle_char_input(info.key_text) + end +end + +local function define_key_bindings() + if #key_bindings > 0 then + return + end + for _, bind in ipairs(get_bindings()) do + -- Generate arbitrary name for removing the bindings later. + local name = "_userinput_" .. bind[1] + key_bindings[#key_bindings + 1] = name + mp.add_forced_key_binding(bind[1], name, bind[2], {repeatable = true}) + end + mp.add_forced_key_binding("any_unicode", "_userinput_text", text_input, + {repeatable = true, complex = true}) + key_bindings[#key_bindings + 1] = "_userinput_text" +end + +local function undefine_key_bindings() + for _, name in ipairs(key_bindings) do + mp.remove_key_binding(name) + end + key_bindings = {} +end + +-- Set the REPL visibility ("enable", Esc) +local function set_active(active) + if active == repl_active then return end + if active then + repl_active = true + insert_mode = false + define_key_bindings() + else + clear() + repl_active = false + undefine_key_bindings() + collectgarbage() + end + update() +end + + +utils.shared_script_property_observe("osc-margins", function(_, val) + if val then + -- formatted as "%f,%f,%f,%f" with left, right, top, bottom, each + -- value being the border size as ratio of the window size (0.0-1.0) + local vals = {} + for v in string.gmatch(val, "[^,]+") do + vals[#vals + 1] = tonumber(v) + end + global_margin_y = vals[4] -- bottom + else + global_margin_y = 0 + end + update() +end) + +-- Redraw the REPL when the OSD size changes. This is needed because the +-- PlayRes of the OSD will need to be adjusted. +mp.observe_property('osd-width', 'native', update) +mp.observe_property('osd-height', 'native', update) +mp.observe_property('display-hidpi-scale', 'native', update) + +---------------------------------------------------------------------------------------- +---------------------------------------------------------------------------------------- +---------------------------------------------------------------------------------------- +-------------------------------END ORIGINAL MPV CODE------------------------------------ + +-- push new request onto the queue +-- if a request with the same id already exists and the queueable flag is not enabled then +-- a nil result will be returned to the function +function queue:push(req) + if self.active_ids[req.id] then + + -- replace an existing request with the new one + if req.replace then + for i = 1, #self.queue do + if self.queue[i].id == req.id then + send_response(false, "replaced", self.queue[i].response) + self.queue[i] = req + if i == 1 then request = req ; update() end + return + end + end + end + + --cancel the new request if it is not queueable + if not req.queueable then send_response(false, "already_queued", req.response) ; return end + end + + table.insert(self.queue, req) + self.active_ids[req.id] = (self.active_ids[req.id] or 0) + 1 + if #self.queue == 1 then return self:start_queue() end +end + +-- removes the first item in the queue and either continues or stops the queue +function queue:pop() + self:remove(1) + clear() + + if #self.queue < 1 then return self:stop_queue() + else return self:continue_queue() end +end + +-- safely removes an item from the queue and updates the set of active requests +function queue:remove(index) + local req = table.remove(self.queue, index) + self.active_ids[req.id] = self.active_ids[req.id] ~= 1 and self.active_ids[req.id] - 1 or nil +end + +function queue:start_queue() + request = self.queue[1] + line = request.default_input + set_active(true) +end + +function queue:continue_queue() + request = self.queue[1] + line = request.default_input + update() +end + +function queue:stop_queue() + set_active(false) +end + +-- removes all requests with the specified id from the queue +mp.register_script_message("cancel-user-input", function(id) + local i = 2 + while i <= #queue.queue do + if queue.queue[i].id == id then + send_response(false, "cancelled", queue.queue[i].response) + queue:remove(i) + else + i = i + 1 + end + end + + if queue.queue[1] and queue.queue[1].id == id then + send_response(false, "cancelled") + queue:pop() + end +end) + +-- script message to recieve input requests, get-user-input.lua acts as an interface to call this script message +-- requests are recieved as json objects +mp.register_script_message("request-user-input", function(response, id, request_text, default_input, queueable, replace) + local req = {} + + if not response then msg.error("input requests require a response string") ; return end + if not id then msg.error("input requests require an id string") ; return end + req.response = response + req.text = ass_escape(request_text or "") + req.default_input = default_input + req.id = id or "mpv" + req.queueable = (queueable == "1") + req.replace = (replace == "1") + + if not histories[id] then histories[id] = {pos = 1, list = {}} end + req.history = histories[id] + + queue:push(req) +end) + +--temporary keybind for debugging purposes +mp.add_key_binding("Ctrl+i", "user-input", function() set_active(true) end) diff --git a/mpv/scripts/youtube-quality.lua b/mpv/scripts/youtube-quality.lua new file mode 100644 index 0000000..b587f37 --- /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_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 diff --git a/mpv/scripts/youtube-serarch.lua b/mpv/scripts/youtube-serarch.lua new file mode 100644 index 0000000..dfe04c6 --- /dev/null +++ b/mpv/scripts/youtube-serarch.lua @@ -0,0 +1,149 @@ +--[[ + This script allows users to search and open youtube results from within mpv. + Available at: https://github.com/CogentRedTester/mpv-scripts + Users can open the search page with Y, and use Y again to open a search. + Alternatively, Ctrl+y can be used at any time to open a search. + Esc can be used to close the page. + Enter will open the selected item, Shift+Enter will append the item to the playlist. + This script requires that my other scripts `scroll-list` and `user-input` be installed. + scroll-list.lua and user-input-module.lua must be in the ~~/script-modules/ directory, + while user-input.lua should be loaded by mpv normally. + https://github.com/CogentRedTester/mpv-scroll-list + https://github.com/CogentRedTester/mpv-user-input + This script also requires a youtube API key to be entered. + The API key must be passed to the `API_key` script-opt. + A personal API key is free and can be created from: + https://console.developers.google.com/apis/api/youtube.googleapis.com/ + The script also requires that curl be in the system path. +]]-- + +local mp = require "mp" +local msg = require "mp.msg" +local utils = require "mp.utils" +local opts = require "mp.options" + +package.path = mp.command_native({"expand-path", "~~/lua-modules/?.lua;"}) .. package.path +local ui = require "user-input-module" +local list = require "scroll-list" + +list.header = "Youtube Search Results\\N-----------------------------" +list.num_entries = 18 +list.list_style = [[{\fs10}\N{\q2\fs25\c&Hffffff&}]] + +local o = { + API_key = "AIzaSyB3IuyFp8C5SYEgJ0BSGgKZZjfUtuVGl44", + num_results = 40 +} + +opts.read_options(o) + +local ass_escape = list.ass_escape + +--taken from: https://gist.github.com/liukun/f9ce7d6d14fa45fe9b924a3eed5c3d99 +local function urlencode(url) + if type(url) ~= "string" then return url end + url = url:gsub("\n", "\r\n") + url = url:gsub("([^%w ])", function (c) string.format("%%%02X", string.byte(c)) end) + url = url:gsub(" ", "+") + return url +end + +--sends an API request +local function send_request(type, queries) + local url = "https://www.googleapis.com/youtube/v3/"..type + + url = url.."?key="..o.API_key + + for key, value in pairs(queries) do + url = url.."&"..key.."="..urlencode(value) + end + + + local request = mp.command_native({ + name = "subprocess", + capture_stdout = true, + capture_stderr = true, + playback_only = false, + args = {"curl", url} + }) + + local response = utils.parse_json(request.stdout) + if request.status ~= 0 then msg.error(request.stderr) ; return nil end + return response +end + +local function insert_video(item) + list:insert({ + ass = ("%s {\\c&aaaaaa&}%s"):format(ass_escape(item.snippet.title), ass_escape(item.snippet.channelTitle)), + url = "https://www.youtube.com/watch?v="..item.id.videoId + }) +end + +local function insert_playlist(item) + list:insert({ + ass = ("🖿 %s {\\c&aaaaaa&}%s"):format(ass_escape(item.snippet.title), ass_escape(item.snippet.channelTitle)), + url = "https://www.youtube.com/playlist?list="..item.id.playlistId + }) +end + +local function insert_channel(item) + list:insert({ + ass = ("👤 %s"):format(ass_escape(item.snippet.title)), + url = "https://www.youtube.com/channel/"..item.id.channelId + }) +end + +local function reset_list() + list.selected = 1 + list:clear() +end + +local function search(query) + local response = send_request("search", { + q = query, + part = "id,snippet", + maxResults = o.num_results + }) + + if not response then return end + reset_list() + + for _, item in ipairs(response.items) do + if item.id.kind == "youtube#video" then + insert_video(item) + elseif item.id.kind == "youtube#playlist" then + insert_playlist(item) + elseif item.id.kind == "youtube#channel" then + insert_channel(item) + end + end + list.header = "Youtube Search: "..ass_escape(query).."\\N-------------------------------------------------" + list:update() + list:open() +end + +local function play_result(flag) + if not list[list.selected] then return end + if flag == "new_window" then mp.commandv("run", "mpv", list[list.selected].url) ; return end + + mp.commandv("loadfile", list[list.selected].url, flag) + if flag == "replace" then list:close() end +end + +table.insert(list.keybinds, {"ENTER", "play", function() play_result("replace") end, {}}) +table.insert(list.keybinds, {"Shift+ENTER", "play_append", function() play_result("append-play") end, {}}) +table.insert(list.keybinds, {"Ctrl+ENTER", "play_new_window", function() play_result("new_window") end, {}}) + +local function open_search_input() + ui.get_user_input(function(input) + if not input then return end + search( input ) + end) +end + +mp.add_key_binding("Ctrl+y", "yt", open_search_input) + +mp.add_key_binding("Y", "youtube-search", function() + if not list.hidden then open_search_input() + else list:open() end +end) |
