Compare commits

...

2 Commits

Author SHA1 Message Date
ksyasuda
1844d00653
updates 2023-08-17 01:48:19 -07:00
ksyasuda
b42fdf40f1
add youtube search, scroll list, user input 2023-08-17 01:48:10 -07:00
7 changed files with 1546 additions and 4 deletions

@ -1 +1 @@
Subproject commit fa2014acd681c35a0027e8b13ae67ba27d24aca1 Subproject commit 84a860f596eefc699a63a692b890471063beacc9

View File

@ -163,7 +163,8 @@ osc=no
interpolation interpolation
tscale=oversample tscale=oversample
hwdec=auto-copy # hwdec=auto-copy
hwdec=auto
ytdl-raw-options=ignore-config=,sub-lang=en,write-auto-sub= ytdl-raw-options=ignore-config=,sub-lang=en,write-auto-sub=
ytdl-format=bestvideo+bestaudio/best ytdl-format=bestvideo+bestaudio/best

View File

@ -0,0 +1,293 @@
local mp = require 'mp'
local scroll_list = {
global_style = [[]],
header_style = [[{\q2\fs35\c&00ccff&}]],
list_style = [[{\q2\fs25\c&Hffffff&}]],
wrapper_style = [[{\c&00ccff&\fs16}]],
cursor_style = [[{\c&00ccff&}]],
selected_style = [[{\c&Hfce788&}]],
cursor = [[➤\h]],
indent = [[\h\h\h\h]],
num_entries = 16,
wrap = false,
empty_text = "no entries"
}
--formats strings for ass handling
--this function is based on a similar function from https://github.com/mpv-player/mpv/blob/master/player/lua/console.lua#L110
function scroll_list.ass_escape(str, replace_newline)
if replace_newline == true then replace_newline = "\\\239\187\191n" end
--escape the invalid single characters
str = str:gsub('[\\{}\n]', {
-- 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
['\\'] = '\\\239\187\191',
['{'] = '\\{',
['}'] = '\\}',
-- Precede newlines with a ZWNBSP to prevent ASS's weird collapsing of
-- consecutive newlines
['\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')
if replace_newline then
str = str:gsub("\\N", replace_newline)
end
return str
end
--format and return the header string
function scroll_list:format_header_string(str)
return str
end
--appends the entered text to the overlay
function scroll_list:append(text)
if text == nil then return end
self.ass.data = self.ass.data .. text
end
--appends a newline character to the osd
function scroll_list:newline()
self.ass.data = self.ass.data .. '\\N'
end
--re-parses the list into an ass string
--if the list is closed then it flags an update on the next open
function scroll_list:update()
if self.hidden then self.flag_update = true
else self:update_ass() end
end
--prints the header to the overlay
function scroll_list:format_header()
self:append(self.header_style)
self:append(self:format_header_string(self.header))
self:newline()
end
--formats each line of the list and prints it to the overlay
function scroll_list:format_line(index, item)
self:append(self.list_style)
if index == self.selected then self:append(self.cursor_style..self.cursor..self.selected_style)
else self:append(self.indent) end
self:append(item.style)
self:append(item.ass)
self:newline()
end
--refreshes the ass text using the contents of the list
function scroll_list:update_ass()
self.ass.data = self.global_style
self:format_header()
if #self.list < 1 then
self:append(self.empty_text)
self.ass:update()
return
end
local start = 1
local finish = start+self.num_entries-1
--handling cursor positioning
local mid = math.ceil(self.num_entries/2)+1
if self.selected+mid > finish then
local offset = self.selected - finish + mid
--if we've overshot the end of the list then undo some of the offset
if finish + offset > #self.list then
offset = offset - ((finish+offset) - #self.list)
end
start = start + offset
finish = finish + offset
end
--making sure that we don't overstep the boundaries
if start < 1 then start = 1 end
local overflow = finish < #self.list
--this is necessary when the number of items in the dir is less than the max
if not overflow then finish = #self.list end
--adding a header to show there are items above in the list
if start > 1 then self:append(self.wrapper_style..(start-1)..' item(s) above\\N\\N') end
for i=start, finish do
self:format_line(i, self.list[i])
end
if overflow then self:append('\\N'..self.wrapper_style..#self.list-finish..' item(s) remaining') end
self.ass:update()
end
--moves the selector down the list
function scroll_list:scroll_down()
if self.selected < #self.list then
self.selected = self.selected + 1
self:update_ass()
elseif self.wrap then
self.selected = 1
self:update_ass()
end
end
--moves the selector up the list
function scroll_list:scroll_up()
if self.selected > 1 then
self.selected = self.selected - 1
self:update_ass()
elseif self.wrap then
self.selected = #self.list
self:update_ass()
end
end
--moves the selector to the list next page
function scroll_list:move_pagedown()
if #self.list > self.num_entries then
self.selected = self.selected + self.num_entries
if self.selected > #self.list then self.selected = #self.list end
self:update_ass()
end
end
--moves the selector to the list previous page
function scroll_list:move_pageup()
if #self.list > self.num_entries then
self.selected = self.selected - self.num_entries
if self.selected < 1 then self.selected = 1 end
self:update_ass()
end
end
--moves the selector to the list begin
function scroll_list:move_begin()
if #self.list > 1 then
self.selected = 1
self:update_ass()
end
end
--moves the selector to the list end
function scroll_list:move_end()
if #self.list > 1 then
self.selected = #self.list
self:update_ass()
end
end
--adds the forced keybinds
function scroll_list:add_keybinds()
for _,v in ipairs(self.keybinds) do
mp.add_forced_key_binding(v[1], 'dynamic/'..self.ass.id..'/'..v[2], v[3], v[4])
end
end
--removes the forced keybinds
function scroll_list:remove_keybinds()
for _,v in ipairs(self.keybinds) do
mp.remove_key_binding('dynamic/'..self.ass.id..'/'..v[2])
end
end
--opens the list and sets the hidden flag
function scroll_list:open_list()
self.hidden = false
if not self.flag_update then self.ass:update()
else self.flag_update = false ; self:update_ass() end
end
--closes the list and sets the hidden flag
function scroll_list:close_list()
self.hidden = true
self.ass:remove()
end
--modifiable function that opens the list
function scroll_list:open()
if self.hidden then self:add_keybinds() end
self:open_list()
end
--modifiable function that closes the list
function scroll_list:close()
self:remove_keybinds()
self:close_list()
end
--toggles the list
function scroll_list:toggle()
if self.hidden then self:open()
else self:close() end
end
--clears the list in-place
function scroll_list:clear()
local i = 1
while self.list[i] do
self.list[i] = nil
i = i + 1
end
end
--added alias for ipairs(list.list) for lua 5.1
function scroll_list:ipairs()
return ipairs(self.list)
end
--append item to the end of the list
function scroll_list:insert(item)
self.list[#self.list + 1] = item
end
local metatable = {
__index = function(t, key)
if scroll_list[key] ~= nil then return scroll_list[key]
elseif key == "__current" then return t.list[t.selected]
elseif type(key) == "number" then return t.list[key] end
end,
__newindex = function(t, key, value)
if type(key) == "number" then rawset(t.list, key, value)
else rawset(t, key, value) end
end,
__scroll_list = scroll_list,
__len = function(t) return #t.list end,
__ipairs = function(t) return ipairs(t.list) end
}
--creates a new list object
function scroll_list:new()
local vars
vars = {
ass = mp.create_osd_overlay('ass-events'),
hidden = true,
flag_update = true,
header = "header \\N ----------------------------------------------",
list = {},
selected = 1,
keybinds = {
{'DOWN', 'scroll_down', function() vars:scroll_down() end, {repeatable = true}},
{'UP', 'scroll_up', function() vars:scroll_up() end, {repeatable = true}},
{'PGDWN', 'move_pagedown', function() vars:move_pagedown() end, {}},
{'PGUP', 'move_pageup', function() vars:move_pageup() end, {}},
{'HOME', 'move_begin', function() vars:move_begin() end, {}},
{'END', 'move_end', function() vars:move_end() end, {}},
{'ESC', 'close_browser', function() vars:close() end, {}}
}
}
return setmetatable(vars, metatable)
end
return scroll_list:new()

View File

@ -0,0 +1,126 @@
--[[
This is a module designed to interface with mpv-user-input
https://github.com/CogentRedTester/mpv-user-input
Loading this script as a module will return a table with two functions to format
requests to get and cancel user-input requests. See the README for details.
Alternatively, developers can just paste these functions directly into their script,
however this is not recommended as there is no guarantee that the formatting of
these requests will remain the same for future versions of user-input.
]]
local API_VERSION = "0.1.0"
local mp = require 'mp'
local msg = require "mp.msg"
local utils = require 'mp.utils'
local mod = {}
local name = mp.get_script_name()
local counter = 1
local function pack(...)
local t = {...}
t.n = select("#", ...)
return t
end
local request_mt = {}
-- ensures the option tables are correctly formatted based on the input
local function format_options(options, response_string)
return {
response = response_string,
version = API_VERSION,
id = name..'/'..(options.id or ""),
source = name,
request_text = ("[%s] %s"):format(options.source or name, options.request_text or options.text or "requesting user input:"),
default_input = options.default_input,
cursor_pos = tonumber(options.cursor_pos),
queueable = options.queueable and true,
replace = options.replace and true
}
end
-- cancels the request
function request_mt:cancel()
assert(self.uid, "request object missing UID")
mp.commandv("script-message-to", "user_input", "cancel-user-input/uid", self.uid)
end
-- updates the options for the request
function request_mt:update(options)
assert(self.uid, "request object missing UID")
options = utils.format_json( format_options(options) )
mp.commandv("script-message-to", "user_input", "update-user-input/uid", self.uid, options)
end
-- sends a request to ask the user for input using formatted options provided
-- creates a script message to recieve the response and call fn
function mod.get_user_input(fn, options, ...)
options = options or {}
local response_string = name.."/__user_input_request/"..counter
counter = counter + 1
local request = {
uid = response_string,
passthrough_args = pack(...),
callback = fn,
pending = true
}
-- create a callback for user-input to respond to
mp.register_script_message(response_string, function(response)
mp.unregister_script_message(response_string)
request.pending = false
response = utils.parse_json(response)
request.callback(response.line, response.err, unpack(request.passthrough_args, 1, request.passthrough_args.n))
end)
-- send the input command
options = utils.format_json( format_options(options, response_string) )
mp.commandv("script-message-to", "user_input", "request-user-input", options)
return setmetatable(request, { __index = request_mt })
end
-- runs the request synchronously using coroutines
-- takes the option table and an optional coroutine resume function
function mod.get_user_input_co(options, co_resume)
local co, main = coroutine.running()
assert(not main and co, "get_user_input_co must be run from within a coroutine")
local uid = {}
local request = mod.get_user_input(function(line, err)
if co_resume then
co_resume(uid, line, err)
else
local success, er = coroutine.resume(co, uid, line, err)
if not success then
msg.warn(debug.traceback(co))
msg.error(er)
end
end
end, options)
-- if the uid was not sent then the coroutine was resumed by the user.
-- we will treat this as a cancellation request
local success, line, err = coroutine.yield(request)
if success ~= uid then
request:cancel()
request.callback = function() end
return nil, "cancelled"
end
return line, err
end
-- sends a request to cancel all input requests with the given id
function mod.cancel_user_input(id)
id = name .. '/' .. (id or "")
mp.commandv("script-message-to", "user_input", "cancel-user-input/id", id)
end
return mod

View File

@ -6,8 +6,8 @@ thumbnail=
# Maximum thumbnail size in pixels (scaled down to fit) # Maximum thumbnail size in pixels (scaled down to fit)
# Values are scaled when hidpi is enabled # Values are scaled when hidpi is enabled
max_height=500 max_height=400
max_width=500 max_width=400
# Overlay id # Overlay id
overlay_id=42 overlay_id=42

749
scripts/user-input.lua Normal file
View File

@ -0,0 +1,749 @@
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 API_VERSION = "0.1.0"
local API_MAJOR_MINOR = API_VERSION:match("%d+%.%d+")
local co = nil
local queue = {}
local active_ids = {}
local histories = {}
local request = nil
local line = ''
--[[
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 resume the main coroutine with a response table
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}'
local queue_style = '{\\r' .. '\\1a&H00&\\3a&H00&\\4a&H99&' ..
'\\1c&Heeeeee&\\3c&H111111&\\4c&H000000&' .. '\\fn' ..
opts.font .. '\\fs' .. opts.font_size ..
'\\c&H66ccff&' ..
'\\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)
if (#queue == 2) then
ass:append(queue_style ..
string.format("There is 1 more request queued\\N"))
elseif (#queue > 2) then
ass:append(queue_style ..
string.format("There are %d more requests queued\\N",
#queue - 1))
end
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)
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
else
handle_del()
end
end
local function handle_esc() coroutine.resume(co, { line = nil, err = "exited" }) 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
coroutine.resume(co, { line = line })
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 beginning of the word (Ctrl+Backspace)
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 word (Ctrl+Del)
local function del_next_word()
if cursor > line:len() then return end
local before_cur = line:sub(1, cursor - 1)
local after_cur = line:sub(cursor)
after_cur = after_cur:gsub('^%s*[^%s]+', '', 1)
line = before_cur .. after_cur
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 },
{ 'ctrl+j', handle_enter }, { 'ctrl+m', handle_enter },
{ 'bs', handle_backspace }, { 'shift+bs', handle_backspace },
{ 'ctrl+h', 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 },
{ 'ctrl+b', function() prev_char() end },
{ 'right', function() next_char() end },
{ 'ctrl+f', function() next_char() end },
{ 'up', function() move_history(-1) end },
{ 'ctrl+p', function() move_history(-1) end },
{ 'wheel_up', function() move_history(-1) end },
{ 'down', function() move_history(1) end },
{ 'ctrl+n', function() move_history(1) end },
{ 'wheel_down', function() move_history(1) end },
{ 'wheel_left', function() end }, { 'wheel_right', function() end },
{ 'ctrl+left', prev_word }, { 'alt+b', prev_word },
{ 'ctrl+right', next_word }, { 'alt+f', next_word }, { 'ctrl+a', go_home },
{ 'home', go_home }, { 'ctrl+e', go_end }, { '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+bs', del_word },
{ 'ctrl+w', del_word }, { 'ctrl+del', del_next_word },
{ 'alt+d', del_next_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------------------------------------
--[[
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:
exited the user closed the input instead of pressing Enter
already_queued a request with the specified id was already in the queue
cancelled a script cancelled the request
replace replaced by another request
]]
local function send_response(res)
if res.source then
mp.commandv("script-message-to", res.source, res.response,
(utils.format_json(res)))
else
mp.commandv("script-message", res.response, (utils.format_json(res)))
end
end
-- 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 push_request(req)
if active_ids[req.id] then
if req.replace then
for i, q_req in ipairs(queue) do
if q_req.id == req.id then
send_response {
err = "replaced",
response = q_req.response,
source = q_req.source
}
queue[i] = req
if i == 1 then request = req end
end
end
update()
return
end
if not req.queueable then
send_response {
err = "already_queued",
response = req.response,
source = req.source
}
return
end
end
table.insert(queue, req)
active_ids[req.id] = (active_ids[req.id] or 0) + 1
if #queue == 1 then coroutine.resume(co) end
update()
end
-- safely removes an item from the queue and updates the set of active requests
function remove_request(index)
local req = table.remove(queue, index)
active_ids[req.id] = active_ids[req.id] - 1
if active_ids[req.id] == 0 then active_ids[req.id] = nil end
return req
end
-- an infinite loop that moves through the request queue
-- uses a coroutine to handle asynchronous operations
local function driver()
while (true) do
while queue[1] do
request = queue[1]
line = request.default_input
cursor = request.cursor_pos
if repl_active then
update()
else
set_active(true)
end
res = coroutine.yield()
if res then
res.source, res.response = request.source, request.response
send_response(res)
remove_request(1)
end
end
set_active(false)
coroutine.yield()
end
end
co = coroutine.create(driver)
-- cancels any input request that returns true for the given predicate function
local function cancel_input_request(pred)
for i = #queue, 1, -1 do
if pred(i) then
req = remove_request(i)
send_response {
err = "cancelled",
response = req.response,
source = req.source
}
-- if we're removing the first item then that means the coroutine is waiting for a response
-- we will need to tell the coroutine to resume, upon which it will move to the next request
-- if there is something in the buffer then save it to the history before erasing it
if i == 1 then
local old_line = line
if old_line ~= "" then
table.insert(histories[req.id].list, old_line)
end
clear()
coroutine.resume(co)
end
end
end
end
mp.register_script_message("cancel-user-input/uid", function(uid)
cancel_input_request(function(i) return queue[i].response == uid end)
end)
-- removes all requests with the specified id from the queue
mp.register_script_message("cancel-user-input/id", function(id)
cancel_input_request(function(i) return queue[i].id == id end)
end)
-- ensures a request has the correct fields and is correctly formatted
local function format_request_fields(req)
assert(req.version, "input requests require an API version string")
if not string.find(req.version, API_MAJOR_MINOR, 1, true) then
error(
("input request has invalid version: expected %s.x, got %s"):format(
API_MAJOR_MINOR, req.version))
end
assert(req.response, "input requests require a response string")
assert(req.id, "input requests require an id string")
req.text = ass_escape(req.request_text or "")
req.default_input = req.default_input or ""
req.cursor_pos = tonumber(req.cursor_pos) or 1
req.id = req.id or "mpv"
if req.cursor_pos ~= 1 then
if req.cursor_pos < 1 then
req.cursor_pos = 1
elseif req.cursor_pos > #req.default_input then
req.cursor_pos = #req.default_input + 1
end
end
if not histories[req.id] then histories[req.id] = { pos = 1, list = {} } end
req.history = histories[req.id]
return req
end
-- updates the fields of a specific request
mp.register_script_message("update-user-input/uid", function(uid, req_opts)
req_opts = utils.parse_json(req_opts)
req_opts.response = uid
for i, req in ipairs(queue) do
if req.response == uid then
local success, result = pcall(format_request_fields, req_opts)
if not success then return msg.error(result) end
queue[i] = result
if i == 1 then request = queue[1] end
update()
return
end
end
end)
-- the function that parses the input requests
local function input_request(req)
req = format_request_fields(req)
push_request(req)
end
-- script message to recieve input requests, get-user-input.lua acts as an interface to call this script message
mp.register_script_message("request-user-input", function(req)
msg.debug(req)
req = utils.parse_json(req)
local success, err = pcall(input_request, req)
if not success then
send_response { err = err, response = req.response, source = req.source }
msg.error(err)
end
end)

373
scripts/youtube-search.lua Normal file
View File

@ -0,0 +1,373 @@
--[[
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.
An alternative to using the official youtube API is to use Invidious.
This script has experimental support for Invidious searches using the 'invidious',
'API_path', and 'frontend' options. API_path refers to the url of the API the
script uses, Invidious API paths are usually in the form:
https://domain.name/api/v1/
The frontend option is the url to actualy try to load videos from. This
can probably be the same as the above url:
https://domain.name
Since the url syntax seems to be identical between Youtube and Invidious,
it should be possible to mix these options, a.k.a. using the Google
API to get videos from an Invidious frontend, or to use an Invidious
API to get videos from Youtube.
The 'invidious' option tells the script that the API_path is for an
Invidious path. This is to support other possible API options in the future.
]]
--
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", "~~/script-modules/?.lua;" }) ..
package.path
local ui = require "user-input-module"
local list = require "scroll-list"
local o = {
API_key = "AIzaSyAiXDAueA9Kn_hbqxb5lN38vFI3IviA3gg",
-- number of search results to show in the list
num_results = 40,
-- the url to send API calls to
API_path = "https://www.googleapis.com/youtube/v3/",
-- attempt this API if the default fails
fallback_API_path = "",
-- the url to load videos from
frontend = "https://www.youtube.com",
-- use invidious API calls
invidious = false,
-- whether the fallback uses invidious as well
fallback_invidious = false
}
opts.read_options(o)
-- ensure the URL options are properly formatted
local function format_options()
if o.API_path:sub(-1) ~= "/" then o.API_path = o.API_path .. "/" end
if o.fallback_API_path:sub(-1) ~= "/" then
o.fallback_API_path = o.fallback_API_path .. "/"
end
if o.frontend:sub(-1) == "/" then o.frontend = o.frontend:sub(1, -2) end
end
format_options()
list.header =
("%s Search: \\N-------------------------------------------------"):format(
o.invidious and "Invidious" or "Youtube")
list.num_entries = 17
list.list_style = [[{\fs10}\N{\q2\fs25\c&Hffffff&}]]
list.empty_text = "enter search query"
local ass_escape = list.ass_escape
-- encodes a string so that it uses url percent encoding
-- this function is based on code taken from here: https://rosettacode.org/wiki/URL_encoding#Lua
local function encode_string(str)
if type(str) ~= "string" then return str end
local output, t = str:gsub("[^%w]", function(char)
return string.format("%%%X", string.byte(char))
end)
return output
end
-- convert HTML character codes to the correct characters
local function html_decode(str)
if type(str) ~= "string" then return str end
return str:gsub("&(#?)(%w-);", function(is_ascii, code)
if is_ascii == "#" then return string.char(tonumber(code)) end
if code == "amp" then return "&" end
if code == "quot" then return '"' end
if code == "apos" then return "'" end
if code == "lt" then return "<" end
if code == "gt" then return ">" end
return nil
end)
end
-- creates a formatted results table from an invidious API call
function format_invidious_results(response)
if not response then return nil end
local results = {}
for i, item in ipairs(response) do
if i > o.num_results then break end
local t = {}
table.insert(results, t)
t.title = html_decode(item.title)
t.channelTitle = html_decode(item.author)
if item.type == "video" then
t.type = "video"
t.id = item.videoId
elseif item.type == "playlist" then
t.type = "playlist"
t.id = item.playlistId
elseif item.type == "channel" then
t.type = "channel"
t.id = item.authorId
t.title = t.channelTitle
end
end
return results
end
-- creates a formatted results table from a youtube API call
function format_youtube_results(response)
if not response or not response.items then return nil end
local results = {}
for _, item in ipairs(response.items) do
local t = {}
table.insert(results, t)
t.title = html_decode(item.snippet.title)
t.channelTitle = html_decode(item.snippet.channelTitle)
if item.id.kind == "youtube#video" then
t.type = "video"
t.id = item.id.videoId
elseif item.id.kind == "youtube#playlist" then
t.type = "playlist"
t.id = item.id.playlistId
elseif item.id.kind == "youtube#channel" then
t.type = "channel"
t.id = item.id.channelId
end
end
return results
end
-- sends an API request
local function send_request(type, queries, API_path)
local url = (API_path or o.API_path) .. type
url = url .. "?"
for key, value in pairs(queries) do
msg.verbose(key, value)
url = url .. "&" .. key .. "=" .. encode_string(value)
end
msg.debug(url)
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)
msg.trace(utils.to_string(request))
if request.status ~= 0 then
msg.error(request.stderr)
return nil
end
if not response then
msg.error("Could not parse response:")
msg.error(request.stdout)
return nil
end
if response.error then
msg.error(request.stdout)
return nil
end
return response
end
-- sends a search API request - handles Google/Invidious API differences
local function search_request(queries, API_path, invidious)
list.header =
("%s Search: %s\\N-------------------------------------------------"):format(
invidious and "Invidious" or "Youtube", ass_escape(queries.q, true))
list.list = {}
list.empty_text = "~"
list:update()
local results = {}
-- we need to modify the returned results so that the rest of the script can read it
if invidious then
-- Invidious searches are done with pages rather than a max result number
local page = 1
while #results < o.num_results do
queries.page = page
local response = send_request("search", queries, API_path)
response = format_invidious_results(response)
if not response then
msg.warn("Search did not return a results list");
return
end
if #response == 0 then break end
for _, item in ipairs(response) do
table.insert(results, item)
end
page = page + 1
end
else
local response = send_request("search", queries, API_path)
results = format_youtube_results(response)
end
-- print error messages to console if the API request fails
if not results then
msg.warn("Search did not return a results list")
return
end
list.empty_text = "no results"
return results
end
local function insert_video(item)
list:insert({
ass = ("%s {\\c&aaaaaa&}%s"):format(ass_escape(item.title),
ass_escape(item.channelTitle)),
url = ("%s/watch?v=%s"):format(o.frontend, item.id)
})
end
local function insert_playlist(item)
list:insert({
ass = ("🖿 %s {\\c&aaaaaa&}%s"):format(ass_escape(item.title),
ass_escape(item.channelTitle)),
url = ("%s/playlist?list=%s"):format(o.frontend, item.id)
})
end
local function insert_channel(item)
list:insert({
ass = ("👤 %s"):format(ass_escape(item.title)),
url = ("%s/channel/%s"):format(o.frontend, item.id)
})
end
local function reset_list()
list.selected = 1
list:clear()
end
-- creates the search request queries depending on what API we're using
local function get_search_queries(query, invidious)
if invidious then
return { q = query, type = "all", page = 1 }
else
return {
key = o.API_key,
q = query,
part = "id,snippet",
maxResults = o.num_results
}
end
end
local function search(query)
local response = search_request(get_search_queries(query, o.invidious),
o.API_path, o.invidious)
if not response and o.fallback_API_path ~= "/" then
msg.info("search failed - attempting fallback")
response = search_request(
get_search_queries(query, o.fallback_invidious),
o.fallback_API_path, o.fallback_invidious)
end
if not response then return end
reset_list()
for _, item in ipairs(response) do
if item.type == "video" then
insert_video(item)
elseif item.type == "playlist" then
insert_playlist(item)
elseif item.type == "channel" then
insert_channel(item)
end
end
list:update()
list:open()
end
local function play_result(flag)
if not list[list.selected] then return end
local url = list[list.selected].url
mp.msg.info("URL: " .. url)
mp.msg.info("Flag: " .. flag)
if flag == "new_window" then
mp.commandv("run", "mpv", url);
return
end
mp.commandv("loadfile", url, flag)
if flag == "replace" then
list:close()
else
mp.commandv("script-message", "add_to_youtube_queue",
list[list.selected].url, 1)
end
end
table.insert(list.keybinds,
{ "ENTER", "play", function() play_result("replace") end, {} })
table.insert(list.keybinds, {
"Ctrl+ENTER", "play_append", function() play_result("append-play") end, {}
})
table.insert(list.keybinds, {
"Ctrl+Shift+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, { request_text = "Enter Query:" })
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()
if #list.list == 0 then open_search_input() end
end
end)