Something went wrong. Try again.
🎥 Command line media player
Something went wrong. Try again.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174-- 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 msg = require "mp.msg"local utils = require "mp.utils"local assdraw = require "mp.assdraw"
local function detect_platform() local platform = mp.get_property_native("platform") if platform == "darwin" or platform == "windows" then return platform elseif os.getenv("WAYLAND_DISPLAY") or os.getenv("WAYLAND_SOCKET") then return "wayland" end return "x11"end
local platform = detect_platform()
-- Default optionslocal script_opts = { monospace_font = "", font_size = 24, border_size = 1.65, background_alpha = 80, gap = 0.2, padding = 10, menu_outline_size = 0, menu_outline_color = "#FFFFFF", corner_radius = 8, margin_x = -1, margin_y = -1, scale_with_window = "auto", focused_color = "#222222", focused_back_color = "#FFFFFF", match_color = "#0088FF", exact_match = false, case_sensitive = false, history_dedup = true, font_hw_ratio = "auto",}local opts = script_opts
local styles = { error = "{\\1c&H7a77f2&}", completion = "{\\1c&Hcc99cc&}", inactive = "{\\alpha&H66&}",}for key, style in pairs(styles) do styles[key] = style .. "{\\3c&H111111&}"end
local terminal_styles = { error = "\027[31m", selected_completion = "\027[7m", default_item = "\027[1m", disabled = "\027[38;5;8m", matched_position = "\027[34m", match_end = "\027[39m",}
local open = falselocal osd_msg_active = falselocal insert_mode = falselocal pending_update = falselocal ime_active = falselocal line = ""local cursor = 1local promptlocal id
local overlay = mp.create_osd_overlay("ass-events")local width_overlay = mp.create_osd_overlay("ass-events")width_overlay.compute_bounds = truewidth_overlay.hidden = true
local histories = {}local history = {}local history_pos = 1local searching_history = falselocal history_paths = {}local histories_to_save = {}
local MAX_LOG_LINES = 10000local log_buffers = {}local log_offset = 0local key_bindings = {}local global_margins = { t = 0, b = 0 }local input_callerlocal input_caller_handlerlocal keep_open = false
local completion_buffer = {}local dim_completions = falselocal selected_completion_indexlocal completion_poslocal completion_appendlocal autoselect_completionlocal has_completions
local selectable_itemslocal selectable_items_lowerlocal matches = {}local matches_query = { needle = "" }local focused_match = 1local first_match_to_print = 1local default_itemlocal item_positions = {}local max_item_width = 0local horizontal_offset = 0
local completelocal cycle_through_completionslocal set_activelocal render
local property_cache = {}
local completion_timer = mp.add_timeout(0.1, function() dim_completions = true render()end, true)
local function get_property_cached(name, def) if property_cache[name] ~= nil then return property_cache[name] end return defend
local function get_font() if not has_completions then return end
if opts.monospace_font ~= "" then return opts.monospace_font end
-- Pick a better default font for Windows and macOS if platform == "windows" then return "Consolas" end
if platform == "darwin" then return "Menlo" end
return "monospace"end
local function get_margin_x() return opts.margin_x > -1 and opts.margin_x or mp.get_property_native("osd-margin-x")end
local function get_margin_y() return opts.margin_y > -1 and opts.margin_y or mp.get_property_native("osd-margin-y")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 posend
-- As above, but finds the previous UTF-8 character 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 posend
local function len_utf8(str) local len = 0 local pos = 1 while pos <= str:len() do pos = next_utf8(str, pos) len = len + 1 end return lenend
local function utf8_positions(str) local pos = 1 local positions = {true}
while pos <= str:len() do pos = next_utf8(str, pos) positions[pos] = true end
return positionsend
-- Converts values to strings, using JSON formatting for tables.-- JSON is used instead of utils.to_string as that is the format the-- values are received in from script-messages.local function to_string(v) if type(v) == "table" then return utils.format_json(v) else return tostring(v) endend
-- Functions to calculate the font width.local width_length_ratio = 0.5local osd_width, osd_height = 0, 0local text_osd = mp.create_osd_overlay("ass-events")text_osd.compute_bounds, text_osd.hidden = true, true
local function measure_bounds(ass_text) text_osd.res_x, text_osd.res_y = osd_width, osd_height text_osd.data = ass_text local res = text_osd:update() return res.x0, res.y0, res.x1, res.y1end
---Measure text width and normalize to a font size of 1---text has to be ass safelocal function normalized_text_width(text, size, horizontal) local align, rotation = horizontal and 7 or 1, horizontal and 0 or -90 local template = "{\\pos(0,0)\\rDefault\\blur0\\bord0\\shad0\\q2\\an%s\\fs%s\\fn%s\\frz%s}%s" size = size / 0.8 local width -- Limit to 5 iterations local repetitions_left = 5 for i = 1, repetitions_left do size = size * 0.8 local ass = assdraw.ass_new() ass.text = template:format(align, size, get_font(), rotation, text) local _, _, x1, y1 = measure_bounds(ass.text) -- Check if nothing got clipped if x1 and x1 < osd_width and y1 < osd_height then width = horizontal and x1 or y1 break end if i == repetitions_left then width = 0 end end return width / size, horizontal and osd_width or osd_heightend
local function fit_on_osd(text) local estimated_width = #text * width_length_ratio if osd_width >= osd_height then -- Fill the osd as much as possible, bigger is more accurate. return math.min(osd_width / estimated_width, osd_height), true else return math.min(osd_height / estimated_width, osd_width), false endend
local measured_font_hw_ratio = nillocal function get_font_hw_ratio() local font_hw_ratio = tonumber(opts.font_hw_ratio) if font_hw_ratio then return font_hw_ratio end if not measured_font_hw_ratio then local alphabet = "abcdefghijklmnopqrstuvwxyz" local text = alphabet:rep(3) local size, horizontal = fit_on_osd(text) local normalized_width = normalized_text_width(text, size * 0.9, horizontal) measured_font_hw_ratio = #text / normalized_width * 0.95 end return measured_font_hw_ratioend
-- Escape a string for verbatim display on the OSDlocal function ass_escape(str) return mp.command_native({"escape-ass", str})end
local function should_scale() return opts.scale_with_window == "yes" or (opts.scale_with_window == "auto" and mp.get_property_native("osd-scale-by-window"))end
local function scale_factor() if should_scale() and osd_height > 0 then return osd_height / 720 end
return get_property_cached("display-hidpi-scale", 1)end
local function terminal_output() -- Unlike vo-configured, current-vo doesn't become falsy while switching VO, -- which would print the log to the OSD. return not mp.get_property("current-vo") or not mp.get_property_native("video-osd")end
local function get_scaled_osd_dimensions() local scale = scale_factor()
return osd_width / scale, osd_height / scaleend
local function get_line_height() if selectable_items then return opts.font_size * (1 + opts.gap) end
return opts.font_sizeend
local function calculate_max_lines() if terminal_output() then -- Subtract 1 for the input line and for each line in the status line. -- This does not detect wrapped lines. return mp.get_property_native("term-size/h", 24) - 2 - select(2, mp.get_property("term-status-msg"):gsub("\\n", "")) end
return math.floor((select(2, get_scaled_osd_dimensions()) * (1 - global_margins.t - global_margins.b) - get_margin_y() - (selectable_items and opts.padding * 2 or 0)) / get_line_height() -- Subtract 1 for the input line and 0.5 for the empty -- line between the log and the input line. - 1.5)end
local function calculate_max_item_width() if not selectable_items or terminal_output() then return end
local longest_item = prompt .. ("a"):rep(9) if #selectable_items > calculate_max_lines() then local digits = math.floor(math.log(#selectable_items, 10)) + 1 longest_item = longest_item .. ("0"):rep(digits) .. "/" .. ("0"):rep(digits) end local longest_item_width = utils.terminal_display_width(longest_item) for _, item in pairs(selectable_items) do local item_width = utils.terminal_display_width(item) if item_width > longest_item_width then longest_item = item longest_item_width = item_width end end
local osd_w, osd_h = get_scaled_osd_dimensions() local font = get_font() width_overlay.res_x = osd_w width_overlay.res_y = osd_h width_overlay.data = "{\\fs" .. opts.font_size .. (font and "\\fn" .. font or "") .. "\\q2}" .. ass_escape(longest_item) local result = width_overlay:update() if result.x0 then max_item_width = math.min(result.x1 - result.x0, osd_w - get_margin_x() * 2 - opts.padding * 2) endend
local function should_highlight_completion(i) return i == selected_completion_index or (i == 1 and selected_completion_index == 0 and autoselect_completion)end
local function mpv_color_to_ass(color) return color:sub(8,9) .. color:sub(6,7) .. color:sub(4,5), string.format("%x", 255 - tonumber("0x" .. color:sub(2,3)))end
local function color_option_to_ass(color) return color:sub(6,7) .. color:sub(4,5) .. color:sub(2,3)end
local function get_selected_ass() local color, alpha = mpv_color_to_ass(mp.get_property("osd-selected-color")) local outline_color, outline_alpha = mpv_color_to_ass(mp.get_property("osd-selected-outline-color")) return "{\\b1\\1c&H" .. color .. "&\\1a&H" .. alpha .. "&\\3c&H" .. outline_color .. "&\\3a&H" .. outline_alpha .. "&}"end
-- Takes a list of strings, a max width in characters and-- optionally a max row count.-- The result contains at least one column.-- Rows are cut off from the top if rows_max is specified.-- returns a string containing the formatted table and the row countlocal function format_grid(list, width_max, rows_max) if #list == 0 then return "", 0 end
local spaces_min = 2 local spaces_max = 8 local list_size = #list local column_count = 1 local row_count = list_size local column_widths -- total width without spacing local width_total = 0
local list_widths = {} for i, item in ipairs(list) do list_widths[i] = len_utf8(item) end
-- use as many columns as possible for columns = 2, list_size do local rows_lower_bound = math.min(rows_max, math.ceil(list_size / columns)) local rows_upper_bound = math.min(rows_max, list_size, math.ceil(list_size / (columns - 1) - 1)) for rows = rows_upper_bound, rows_lower_bound, -1 do local cw = {} width_total = 0
-- find out width of each column for column = 1, columns do local width = 0 for row = 1, rows do local i = row + (column - 1) * rows local item_width = list_widths[i] if not item_width then break end if width < item_width then width = item_width end end cw[column] = width width_total = width_total + width if width_total + (columns - 1) * spaces_min > width_max then break end end
if width_total + (columns - 1) * spaces_min <= width_max then row_count = rows column_count = columns column_widths = cw else break end end if width_total + (columns - 1) * spaces_min > width_max then break end end
local spaces = math.floor((width_max - width_total) / (column_count - 1)) spaces = math.max(spaces_min, math.min(spaces_max, spaces)) local spacing = column_count > 1 and ass_escape(string.format("%" .. spaces .. "s", " ")) or ""
local rows = {} for row = 1, row_count do local columns = {} for column = 1, column_count do local i = row + (column - 1) * row_count if i > #list then break end -- more then 99 leads to "invalid format (width or precision too long)" local format_string = column == column_count and "%s" or "%-" .. math.min(column_widths[column], 99) .. "s" columns[column] = ass_escape(string.format(format_string, list[i]))
if should_highlight_completion(i) then local style_override = dim_completions and styles.inactive or "" columns[column] = get_selected_ass() .. style_override .. columns[column] .. "{\\b\\1a&\\3a&}" .. styles.completion .. style_override end end -- first row is at the bottom rows[row_count - row + 1] = table.concat(columns, spacing) end return table.concat(rows, ass_escape("\n")), row_countend
local fzy = require "mp.fzy"
-- fzy scores are sums of the score constants in fzy.lua, which are all-- multiples of 0.005, so 200 * score is an integer. This allows packing a-- match's score and its position in the list into a single number. This makes-- sort infinitely faster, because it no longer has to call lua comparator.-- Positions are packed into the low bits, so this supports lists of up to-- sort_ranks items.local sort_ranks = 2 ^ 30local score_infinity = fzy.get_score_ceiling() * 200 + 1
-- Stable sort the indices in `indices` by their `scores`.local function sort_by_score(indices, scores) local keys = {} for i = 1, #indices do local score = scores[i] * 200 if score > score_infinity then score = score_infinity elseif score < -score_infinity then score = -score_infinity else score = math.floor(score + 0.5) end keys[i] = (i - 1) - score * sort_ranks end
table.sort(keys)
local sorted = {} for i = 1, #keys do sorted[i] = indices[keys[i] % sort_ranks + 1] end
return sortedend
local function fuzzy_find(needle, haystacks) local indices = {} local scores = {} fzy.filter_range(needle, haystacks, 1, #haystacks, indices, scores)
return sort_by_score(indices, scores)end
local function get_match_positions(index) local needle = matches_query.needle
if needle == "" then return {} end
local text = selectable_items[index]
if not matches_query.exact then return (fzy.positions(needle, text)) end
if not matches_query.case_sensitive then text = selectable_items_lower[index] end
local positions = {} for _, word in ipairs(matches_query.words) do local start_pos, end_pos = text:find(word, 1, true)
if start_pos then for i = start_pos, end_pos do positions[#positions + 1] = i end end end table.sort(positions)
return positionsend
local function get_matches_to_print(terminal) if not selectable_items or focused_match == 0 then return {} end
local items = {} local max_lines = calculate_max_lines() local escape = terminal and function (str) return str end or ass_escape local highlight = terminal and terminal_styles.matched_position or "{\\1c&H" .. color_option_to_ass(opts.match_color) .. "}"
if focused_match < first_match_to_print then first_match_to_print = focused_match elseif focused_match > first_match_to_print + max_lines - 1 then first_match_to_print = focused_match - max_lines + 1 end
local last_match_to_print = math.min(first_match_to_print + max_lines - 1, #matches)
if last_match_to_print - first_match_to_print + 1 < math.min(max_lines, #matches) and last_match_to_print >= math.min(max_lines, #matches) then first_match_to_print = last_match_to_print - math.min(max_lines, #matches) + 1 end
for i = first_match_to_print, last_match_to_print do local item = "" local end_highlight = terminal and terminal_styles.match_end or "{\\1c}" local text = selectable_items[matches[i]] local positions = get_match_positions(matches[i])
if terminal then if matches[i] == default_item then item = terminal_styles.default_item end if i == focused_match then item = item .. terminal_styles.selected_completion end else if i == focused_match then if searching_history and mp.get_property("osd-border-style") == "outline-and-shadow" then item = get_selected_ass() else item = "{\\1c&H" .. color_option_to_ass(opts.focused_color) .. "&}" end end_highlight = item end end
local char_positions = utf8_positions(text) local start_of_last_match = positions[1]
if not start_of_last_match then item = item .. escape(text) elseif start_of_last_match > 1 then item = item .. escape(text:sub(1, start_of_last_match - 1)) end
for j, pos in ipairs(positions) do local last_pos = positions[j - 1]
if last_pos and pos > last_pos + 1 then if char_positions[start_of_last_match] and char_positions[last_pos + 1] then item = item .. highlight .. escape(text:sub(start_of_last_match, last_pos)) .. end_highlight .. escape(text:sub(last_pos + 1, pos - 1)) else item = item .. escape(text:sub(start_of_last_match, pos - 1)) end
start_of_last_match = pos end end
if start_of_last_match then local last_pos = positions[#positions]
if char_positions[start_of_last_match] and char_positions[last_pos + 1] then item = item .. highlight .. escape(text:sub(start_of_last_match, last_pos)) .. end_highlight .. escape(text:sub(last_pos + 1)) else item = item .. escape(text:sub(start_of_last_match)) end end
items[#items + 1] = item end
return itemsend
local function update_overlay(data, res_x, res_y, z) if overlay.data == data and overlay.res_x == res_x and overlay.res_y == res_y and overlay.z == z then return end
overlay.data = data overlay.res_x = res_x overlay.res_y = res_y overlay.z = z overlay:update()end
local function print_to_terminal() -- Clear the log after closing the console. if not open then if osd_msg_active then mp.osd_message("") end osd_msg_active = false return end
local log = "" local counter = "" if selectable_items then if #selectable_items > calculate_max_lines() then local digits = math.floor(math.log(#selectable_items, 10)) + 1 counter = terminal_styles.disabled .. "[" .. string.format("%0" .. digits .. "d", focused_match) .. "/" .. string.format("%0" .. digits .. "d", #matches) .. "]\027[0m " end
local clip = mp.get_property("term-clip-cc") for _, item in ipairs(get_matches_to_print(true)) do log = log .. clip .. item .. "\027[0m\n" end else for _, log_line in ipairs(log_buffers[id]) do log = log .. log_line.terminal_style .. log_line.text .. "\027[0m\n" end
if not dim_completions then local term_size = mp.get_property_native("term-size", { w = 80, h = 24 }) local max_width = term_size.w * (term_size.h - 2 - select(2, (prompt .. mp.get_property("term-status-msg")):gsub("\\n", ""))) local completions = ""
for i, completion in ipairs(completion_buffer) do if should_highlight_completion(i) then completions = completions .. terminal_styles.selected_completion .. completion .. "\027[0m" else completions = completions .. completion end completions = completions .. (i < #completion_buffer and "\t" or "\n")
if utils.terminal_display_width(completions) > max_width then completions = completions .. "\n" break end end
log = log .. completions end end
local before_cur = line:sub(1, cursor - 1) local after_cur = line:sub(cursor) -- Ensure there is a character with inverted colors to print. if after_cur == "" then after_cur = " " end
mp.osd_message(log .. counter .. prompt .. " " .. before_cur .. "\027[7m" .. after_cur:sub(1, 1) .. "\027[0m" .. after_cur:sub(2), 999) osd_msg_active = trueend
render = function() pending_update = false
if terminal_output() then print_to_terminal() return end
-- Clear the OSD if the console was being printed to the terminal if osd_msg_active then mp.osd_message("") osd_msg_active = false end
-- Clear the OSD after closing the console if not open then update_overlay("", 0, 0, 0) return end
local ass = assdraw.ass_new() local osd_w, osd_h = get_scaled_osd_dimensions() local line_height = get_line_height() local max_lines = calculate_max_lines()
local x, y, alignment, clipping_coordinates if selectable_items and not searching_history then x = (osd_w - max_item_width) / 2 y = osd_h * (global_margins.t + (1 - global_margins.t - global_margins.b) / 2) - (math.min(#selectable_items, max_lines) + 1.5) * line_height / 2 alignment = 7 clipping_coordinates = x .. ",0," .. x + max_item_width .. "," .. osd_h else x = get_margin_x() y = osd_h * (1 - global_margins.b) - get_margin_y() alignment = 1 -- Avoid drawing below topbar OSC when there are wrapped lines. local coordinate_top = math.floor(global_margins.t * osd_h + 0.5) clipping_coordinates = "0," .. coordinate_top .. "," .. osd_w .. "," .. osd_h end
local font = get_font() -- Use the same blur value as the rest of the OSD. 288 is the OSD's -- PlayResY. local blur = mp.get_property_native("osd-blur") * osd_h / 288 local border_style = mp.get_property("osd-border-style")
local style = "{\\r" .. (font and "\\fn" .. font or "") .. "\\fs" .. opts.font_size .. "\\bord" .. opts.border_size .. "\\fsp0" .. "\\blur" .. blur .. (selectable_items and "\\q2" or "\\q1") .. "\\clip(" .. clipping_coordinates .. ")}"
-- 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 color, alpha = mpv_color_to_ass(mp.get_property("osd-color")) local cheight = opts.font_size * 8 local cglyph = "{\\r\\blur0" .. (get_property_cached("focused") == false and "\\alpha&HFF&" or "\\3a&H" .. alpha .. "&") .. "\\3c&H" .. color .. "&" .. "\\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))
local log_ass = "" local log_buffer = log_buffers[id] or {} log_offset = math.max(math.min(log_offset, #log_buffer - 1), 0) local last = #log_buffer - log_offset local first = math.max(last - math.min(max_lines, #log_buffer) + 1, 1) for i = first, last do log_ass = log_ass .. style .. log_buffer[i].style .. ass_escape(log_buffer[i].text) .. "\\N" end
local completion_ass = "" if completion_buffer[1] and not selectable_items then -- Estimate how many characters fit in one line -- Even with bottom-left anchoring, -- libass/ass_render.c:ass_render_event() subtracts --osd-margin-x from -- the maximum text width twice. -- TODO: --osd-margin-x should scale with osd-width and PlayResX to make -- the calculation accurate. local width_max = math.floor( (osd_w - x - mp.get_property_native("osd-margin-x") * 2) / opts.font_size * get_font_hw_ratio())
local completions, rows = format_grid(completion_buffer, width_max, max_lines) max_lines = max_lines - rows completion_ass = style .. styles.completion .. (dim_completions and styles.inactive or "") .. completions .. "\\N" end
-- Background if selectable_items and (not searching_history or border_style == "background-box") then style = style .. "{\\bord0\\blur0\\4a&Hff&}" local back_color, back_alpha = mpv_color_to_ass(mp.get_property( border_style == "background-box" and "osd-back-color" or "osd-outline-color")) if not searching_history then back_alpha = string.format("%x", opts.background_alpha) end
ass:new_event() ass:an(alignment) ass:pos(x, y) ass:append("{\\1c&H" .. back_color .. "&\\1a&H" .. back_alpha .. "&\\bord" .. opts.menu_outline_size .. "\\3c&H" .. color_option_to_ass(opts.menu_outline_color) .. "\\blur0&}") if border_style == "background-box" then ass:append("{\\4a&Hff&}") end ass:draw_start() ass:round_rect_cw(-opts.padding, opts.padding * (alignment == 7 and -1 or 1), max_item_width + opts.padding, (1.5 + math.min(#matches, max_lines)) * line_height + opts.padding * (alignment == 7 and 1 or 2), opts.corner_radius, opts.corner_radius) ass:draw_stop() end
local items = get_matches_to_print() item_positions = {} for i, item in ipairs(items) do local item_y = alignment == 7 and y + (1 + i) * line_height or y - (1.5 + #items - i) * line_height
if (first_match_to_print - 1 + i == focused_match or matches[first_match_to_print - 1 + i] == default_item) and (not searching_history or border_style == "background-box") then ass:new_event() ass:an(4) ass:pos(x, item_y) ass:append("{\\blur0\\bord0\\4aH&ff&\\1c&H" .. color_option_to_ass(opts.focused_back_color) .. "&}") if first_match_to_print - 1 + i ~= focused_match then ass:append("{\\1aH&cc&}") end ass:draw_start() ass:rect_cw(-opts.padding, 0, max_item_width + opts.padding, line_height) ass:draw_stop() end
ass:new_event() ass:an(4) ass:pos(x - horizontal_offset, item_y) ass:append(style .. item)
item_positions[#item_positions + 1] = { item_y - line_height / 2, item_y + line_height / 2 } end
-- Scrollbar if selectable_items and #matches > max_lines then ass:new_event() ass:an(alignment + 2) ass:pos(x + max_item_width, y) ass:append(style) if not searching_history or border_style == "background-box" then ass:append("{\\bord0\\4a&Hff&\\blur0}") end ass:append(focused_match .. "/" .. #matches)
local start_percentage = (first_match_to_print - 1) / #matches local end_percentage = (first_match_to_print - 1 + max_lines) / #matches if end_percentage - start_percentage < 0.04 then local diff = 0.04 - (end_percentage - start_percentage) start_percentage = start_percentage * (1 - diff) end_percentage = end_percentage + diff * (1 - end_percentage) end
local max_height = max_lines * line_height local bar_y = alignment == 7 and y + 1.5 * line_height + start_percentage * max_height or y - 1.5 * line_height - max_height * (1 - end_percentage) local height = max_height * (end_percentage - start_percentage)
ass:new_event() ass:an(alignment) ass:append("{\\blur0\\4a&Hff&\\bord1}") ass:pos(x + max_item_width + opts.padding - 1, bar_y) ass:draw_start() ass:rect_cw(0, 0, -opts.padding / 2, height) ass:draw_stop() end
ass:new_event() ass:an(alignment) ass:pos(x, y) if not selectable_items then ass:append(log_ass .. "\\N" .. completion_ass) end ass:append(style .. ass_escape(prompt) .. " " .. before_cur) ass:append(cglyph) ass:append(style .. after_cur)
-- Redraw the cursor with the input text invisible. This will make the -- cursor appear in front of the text. ass:new_event() ass:an(alignment) ass:pos(x, y) ass:append(style .. "{\\alpha&HFF&}" .. ass_escape(prompt) .. " " .. before_cur) ass:append(cglyph) ass:append(style .. "{\\alpha&HFF&}" .. after_cur)
-- z with selectable_items needs to be greater than the OSC's. update_overlay(ass.text, osd_w, osd_h, selectable_items and 2000 or 0)end
local update_timer = nilupdate_timer = mp.add_periodic_timer(0.05, function() if pending_update then render() else update_timer:kill() endend)update_timer:kill()
-- Add a line to the history and deduplicatelocal function history_add(text) if history[#history] == text or text == "" then return end
if opts.history_dedup then -- More recent entries are more likely to be repeated for i = #history, 1, -1 do if history[i] == text then table.remove(history, i) break end end end
history[#history + 1] = text
if history_paths[id] then histories_to_save[id] = histories_to_save[id] .. text .. "\n" endend
local function handle_cursor_move() -- Don't show completions after a command is entered because they move its -- output up, and allow clearing completions by emptying the line. if line == "" then completion_buffer = {} render() else complete() endend
-- Filtering hundreds of thousands of items takes long enough to make typing-- sluggish, so it is done in time slices of a coroutine which yields to the-- event loop, letting pending keypresses be handled in between.local filter_slice_duration = 0.005-- The elapsed time is only checked once per this many items.local filter_chunk_size = 1000
local filter_co
local function filter_items() local items = selectable_items local item_count = #items local needle = line local exact = opts.exact_match or needle:sub(1, 1) == "'" local case_sensitive = exact and opts.case_sensitive local words
if exact then if not opts.exact_match then needle = needle:sub(2) end if not case_sensitive then needle = needle:lower() end
words = {} for word in needle:gmatch("%S+") do words[#words + 1] = word end end
local indices = {} local scores = not exact and needle ~= "" and {} or nil local lowered = selectable_items_lower local deadline = mp.get_time() + filter_slice_duration
local first = 1 while first <= item_count do local last = math.min(first + filter_chunk_size - 1, item_count)
if needle == "" then for i = first, last do indices[i] = i end else if not case_sensitive then for i = first, last do if not lowered[i] then lowered[i] = items[i]:lower() end end end
if exact then for i = first, last do local haystack = case_sensitive and items[i] or lowered[i] local match = true for w = 1, #words do if not haystack:find(words[w], 1, true) then match = false break end end if match then indices[#indices + 1] = i end end else fzy.filter_range(needle, items, first, last, indices, scores, false, lowered) end end
first = last + 1
if first <= item_count and mp.get_time() >= deadline then coroutine.yield() deadline = mp.get_time() + filter_slice_duration end end
if scores then indices = sort_by_score(indices, scores) end
matches = indices matches_query = { needle = needle, exact = exact, words = words, case_sensitive = case_sensitive }
if line == "" and default_item then focused_match = default_item
local max_lines = calculate_max_lines() first_match_to_print = math.max(1, focused_match + 1 - math.ceil(max_lines / 2)) if first_match_to_print > #selectable_items - max_lines + 1 then first_match_to_print = math.max(1, #selectable_items - max_lines + 1) end else focused_match = 1 end
render()end
local filter_timer
local function step_filter() local ok, err = coroutine.resume(filter_co) if coroutine.status(filter_co) == "dead" then filter_timer:kill() filter_co = nil if not ok then error(err) end endend
filter_timer = mp.add_periodic_timer(0, step_filter, true)
local function cancel_filter() filter_timer:kill() filter_co = nilend
local function start_filter() cancel_filter() filter_co = coroutine.create(filter_items)
-- Run the first slice synchronously so any smaller list is filtered -- instantly, and only huge workloads are deferred. step_filter() if filter_co then filter_timer:resume() endend
local function handle_edit() if not selectable_items then cancel_filter() handle_cursor_move() mp.commandv("script-message-to", input_caller, input_caller_handler, "edited", utils.format_json({line})) return end
start_filter()
-- Render new user input with previous matches, the matches will be updated -- once available. if filter_co then render() endend
-- 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 handle_edit()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 handle_edit()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)) handle_edit()end
-- Toggle insert mode (Ins)local function handle_ins() insert_mode = not insert_modeend
-- Move the cursor to the next character (Right)local function next_char() cursor = next_utf8(line, cursor) handle_cursor_move()end
-- Move the cursor to the previous character (Left)local function prev_char() cursor = prev_utf8(line, cursor) handle_cursor_move()end
-- Clear the current line (Ctrl+C)local function clear() line = "" cursor = 1 insert_mode = false history_pos = #history + 1 handle_edit()end
-- Close the console if the current line is empty, otherwise delete the next-- character (Ctrl+D)local function maybe_exit() if line == "" then set_active(false) else handle_del() endend
local function unbind_mouse() mp.remove_key_binding("_console_mouse_move") mp.remove_key_binding("_console_mbtn_left")end
local function after_cur_matches_completion() local token = line:sub(completion_pos)
for _, completion in pairs(completion_buffer) do if completion == token:sub(1, #completion) then return true end endend
-- Run the current command or select the current itemlocal function submit() if searching_history then searching_history = false line = #matches > 0 and selectable_items[matches[focused_match]] or "" selectable_items = nil selectable_items_lower = nil cursor = #line + 1 handle_edit() unbind_mouse() return end
if selectable_items then if #matches > 0 then mp.commandv("script-message-to", input_caller, input_caller_handler, "submit", utils.format_json({matches[focused_match]})) end else if selected_completion_index == 0 and autoselect_completion and not after_cur_matches_completion() then cycle_through_completions() end
mp.commandv("script-message-to", input_caller, input_caller_handler, "submit", utils.format_json({line}))
history_add(line) end
if not keep_open then set_active(false) elseif not selectable_items then clear() endend
local function determine_hovered_item() local osd_w, _ = get_scaled_osd_dimensions() local scale = scale_factor() local mouse_pos = mp.get_property_native("mouse-pos") local mouse_x = mouse_pos.x / scale local mouse_y = mouse_pos.y / scale local item_x0 = (searching_history and get_margin_x() or (osd_w - max_item_width) / 2) - opts.padding
if mouse_x < item_x0 or mouse_x > item_x0 + max_item_width + opts.padding * 2 then return end
for i, positions in ipairs(item_positions) do if mouse_y >= positions[1] and mouse_y <= positions[2] then return first_match_to_print - 1 + i end endend
local function bind_mouse() mp.add_forced_key_binding("MOUSE_MOVE", "_console_mouse_move", function() local item = determine_hovered_item() if item and item ~= focused_match then focused_match = item render() end end)
mp.add_forced_key_binding("MBTN_LEFT", "_console_mbtn_left", function() local item = determine_hovered_item() if item then focused_match = item submit() else set_active(false) end end)end
-- Go to the specified position in the command historylocal function go_history(new_pos) local old_pos = history_pos history_pos = new_pos
-- Restrict the position to a legal value if history_pos > #history + 1 then history_pos = #history + 1 elseif history_pos < 1 then history_pos = 1 end
-- Do nothing if the history position didn't actually change if 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 == #history + 1 then history_add(line) end
-- Now show the history line (or a blank line for #history + 1) if history_pos <= #history then line = history[history_pos] else line = "" end cursor = line:len() + 1 insert_mode = false handle_edit()end
-- Go to the specified relative position in the command history (Up, Down)local function move_history(amount, is_wheel) if not selectable_items then go_history(history_pos + amount) return end
if is_wheel then local max_lines = calculate_max_lines()
-- Update focused_match only if it's the first or last printed item and -- there are hidden items. if (amount > 0 and focused_match == first_match_to_print and first_match_to_print - 1 + max_lines < #matches) or (amount < 0 and focused_match == first_match_to_print - 1 + max_lines and first_match_to_print > 1) then focused_match = focused_match + amount end
if amount > 0 and first_match_to_print < #matches - max_lines + 1 or amount < 0 and first_match_to_print > 1 then -- math.min and math.max would only be needed with amounts other than -- 1 and -1. first_match_to_print = math.min( math.max(first_match_to_print + amount, 1), #matches - max_lines + 1) end
local item = determine_hovered_item() if item then focused_match = item end
render() return end
focused_match = focused_match + amount if focused_match > #matches then focused_match = 1 elseif focused_match < 1 then focused_match = #matches end render()end
local function horizontal_scroll(amount) horizontal_offset = math.max(horizontal_offset + amount, 0) render()end
-- Go to the first command in the command history (PgUp)local function handle_pgup() if selectable_items then focused_match = math.max(focused_match - calculate_max_lines() + 1, 1) render() return end
go_history(1)end
-- Stop browsing history and start editing a blank line (PgDown)local function handle_pgdown() if selectable_items then focused_match = math.min(focused_match + calculate_max_lines() - 1, #matches) render() return end
go_history(#history + 1)end
local function search_history() if selectable_items or #history == 0 then return end
searching_history = true selectable_items = {} selectable_items_lower = {} horizontal_offset = 0
for i = 1, #history do selectable_items[i] = history[#history + 1 - i] end
calculate_max_item_width() handle_edit() bind_mouse()end
local function page_up_or_prev_char() if selectable_items then handle_pgup() else prev_char() endend
local function page_down_or_next_char() if selectable_items then handle_pgdown() else next_char() endend
-- 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 handle_cursor_move()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 handle_cursor_move()end
-- Move the cursor to the beginning of the line (HOME)local function go_home() cursor = 1 handle_cursor_move()end
-- Move the cursor to the end of the line (END)local function go_end() cursor = line:len() + 1 handle_cursor_move()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 handle_edit()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 handle_edit()end
-- Delete from the cursor to the end of the line (Ctrl+K)local function del_to_eol() line = line:sub(1, cursor - 1) handle_edit()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 handle_edit()end
-- Empty the log buffer of all messages (Ctrl+L)local function clear_log_buffer() if not selectable_items then log_buffers[id] = {} end render()end
local function scroll_log(amount) if selectable_items then return end
log_offset = log_offset + amount render()end
-- Returns a string of UTF-8 text from the clipboard (or the primary selection)local function get_clipboard(clip) mp.commandv("update-clipboard", clip and "text" or "text-primary") if platform == "x11" then local property = clip and "clipboard/text" or "clipboard/text-primary" return mp.get_property(property, "") elseif platform == "wayland" then if mp.get_property("current-clipboard-backend") == "wayland" then local property = clip and "clipboard/text" or "clipboard/text-primary" return mp.get_property(property, "") end -- Wayland VO clipboard is only updated on window focus if clip and get_property_cached("focused") then return mp.get_property("clipboard/text", "") end 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" or platform == "darwin" then return mp.get_property("clipboard/text", "") 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) if selectable_items then text = text:gsub("\r?\n", " ") end local before_cur = line:sub(1, cursor - 1) local after_cur = line:sub(cursor) line = before_cur .. text .. after_cur cursor = cursor + text:len() handle_edit()end
local function copy() if not selectable_items then mp.set_property("clipboard/text", line) mp.msg.info("Input line copied") elseif matches[1] then mp.set_property("clipboard/text", selectable_items[matches[focused_match]])
if terminal_output() then mp.msg.info("Item copied") else mp.osd_message("Item copied") end endend
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) endend
local function common_prefix_length(s1, s2) local common_count = 0 for i = 1, #s1 do if s1:byte(i) ~= s2:byte(i) then break end common_count = common_count + 1 end return common_countend
local function max_overlap_length(s1, s2) for s1_offset = 0, #s1 - 1 do local match = true for i = 1, #s1 - s1_offset do if s1:byte(s1_offset + i) ~= s2:byte(i) then match = false break end end if match then return #s1 - s1_offset end end return 0end
-- If str starts with the first or last characters of prefix, strip them.local function strip_common_characters(str, prefix) return str:sub(1 + math.max( common_prefix_length(prefix, str), max_overlap_length(prefix, str)))end
cycle_through_completions = function (backwards) -- Disable tab completions while waiting for completion responses if completion_timer:is_enabled() or dim_completions then return end
if #completion_buffer == 0 then -- Allow Tab completion of commands before typing anything. if line == "" then complete() end
return end
selected_completion_index = selected_completion_index + (backwards and -1 or 1)
if selected_completion_index > #completion_buffer then selected_completion_index = 1 elseif selected_completion_index < 1 then selected_completion_index = #completion_buffer end
local before_cur = line:sub(1, completion_pos - 1) .. completion_buffer[selected_completion_index] .. completion_append line = before_cur .. strip_common_characters(line:sub(cursor), completion_buffer[selected_completion_index] .. completion_append) cursor = before_cur:len() + 1 render()end
local function enable_completions() dim_completions = false completion_timer:kill()end
-- Show autocompletions.complete = function () completion_timer:resume() mp.commandv("script-message-to", input_caller, input_caller_handler, "complete", utils.format_json({line:sub(1, cursor - 1)})) render()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", function() set_active(false) end }, { "ctrl+[", function() set_active(false) end }, { "enter", submit }, { "kp_enter", submit }, { "shift+enter", function() if not selectable_items then handle_char_input("\n") end end }, { "ctrl+j", submit }, { "ctrl+m", submit }, { "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() page_up_or_prev_char() end }, { "right", function() next_char() end }, { "ctrl+f", function() page_down_or_next_char() end}, { "up", function() move_history(-1) end }, { "ctrl+p", function() move_history(-1) end }, { "wheel_up", function() move_history(-1, true) end }, { "down", function() move_history(1) end }, { "ctrl+n", function() move_history(1) end }, { "wheel_down", function() move_history(1, true) end }, { "shift+up", function() scroll_log(1) end }, { "shift+down", function() scroll_log(-1) end }, { "wheel_left", function() end }, { "wheel_right", function() end }, { "shift+left", function() horizontal_scroll(-25) end }, { "shift+right", function() horizontal_scroll( 25) end }, { "wheel_left", function() horizontal_scroll(-25) end }, { "wheel_right", function() horizontal_scroll( 25) end }, { "shift+wheel_up", function() horizontal_scroll(-25) end }, { "shift+wheel_down", function() horizontal_scroll( 25) end }, { "ctrl+left", prev_word }, { "alt+b", prev_word }, { "ctrl+right", next_word }, { "alt+f", next_word }, { "tab", cycle_through_completions }, { "ctrl+i", cycle_through_completions }, { "shift+tab", function() cycle_through_completions(true) end }, { "ctrl+a", go_home }, { "home", go_home }, { "ctrl+e", go_end }, { "end", go_end }, { "pgup", handle_pgup }, { "pgdwn", handle_pgdown }, { "ctrl+r", search_history }, { "ctrl+c", clear }, { "ctrl+d", maybe_exit }, { "ctrl+k", del_to_eol }, { "ctrl+l", clear_log_buffer }, { "ctrl+u", del_to_start }, { "ctrl+y", copy, }, { "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 }, { "kp_add", function() handle_char_input("+") end }, { "kp_subtract", function() handle_char_input("-") end }, { "kp_multiply", function() handle_char_input("*") end }, { "kp_divide", function() handle_char_input("/") end }, }
for i = 0, 9 do bindings[#bindings + 1] = {"kp" .. i, function() handle_char_input("" .. i) end} end
return bindingsend
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 = "_console_" .. (#key_bindings + 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", "_console_text", text_input, {repeatable = true, complex = true}) key_bindings[#key_bindings + 1] = "_console_text"end
local function undefine_key_bindings() for _, name in ipairs(key_bindings) do mp.remove_key_binding(name) end key_bindings = {}end
local function read_history() if not history_paths[id] or history[1] then return end
local history_file = io.open(mp.command_native({"expand-path", history_paths[id]}))
if history_file == nil then return end
if opts.history_dedup then local unfiltered_history = {} for command in history_file:lines() do unfiltered_history[#unfiltered_history + 1] = command end
local history_map = {} for i = #unfiltered_history, 1, -1 do local command = unfiltered_history[i] if not history_map[command] then history[#history + 1] = command history_map[command] = true end end
for i = 1, #history / 2, 1 do history[i], history[#history - i + 1] = history[#history - i + 1], history[i] end else for command in history_file:lines() do history[#history + 1] = command end end
history_file:close()end
-- Open or close the consoleset_active = function (active) if active == open then return end
if active then open = true insert_mode = false define_key_bindings() mp.set_property_bool("user-data/mpv/console/open", true) ime_active = mp.get_property_bool("input-ime") mp.set_property_bool("input-ime", true) elseif searching_history then searching_history = false cancel_filter() selectable_items = nil selectable_items_lower = nil unbind_mouse() else open = false undefine_key_bindings() unbind_mouse() completion_timer:kill() cancel_filter() mp.set_property_bool("user-data/mpv/console/open", false) mp.set_property_bool("input-ime", ime_active) mp.commandv("script-message-to", input_caller, input_caller_handler, "closed", utils.format_json({line, cursor}))
-- these tables may be extremely large, reset them for garbage collection selectable_items = nil selectable_items_lower = nil matches = {} matches_query = { needle = "" } item_positions = {} completion_buffer = {} fzy.gc()
collectgarbage() end render()end
mp.register_script_message("disable", function(message) message = utils.parse_json(message or "")
if not message or message.client_name == input_caller then set_active(false) endend)
mp.register_script_message("get-input", function (args) args = utils.parse_json(args) if type(args) ~= "table" then return msg.error("Input request aborted - " .. "get-input must be passed a JSON formatted table.") end
if not args.client_name or not args.handler_id then return msg.error("Input request aborted - " .. "get-input must be passed a 'client_name' and 'handler_id'") end
if open then mp.commandv("script-message-to", input_caller, input_caller_handler, "closed", utils.format_json({line, cursor})) end
input_caller = args.client_name input_caller_handler = args.handler_id prompt = to_string(args.prompt or "") line = to_string(args.default_text or "") cursor = math.floor(tonumber(args.cursor_position) or line:len() + 1) keep_open = args.keep_open default_item = args.default_item has_completions = args.has_completions searching_history = false if cursor < 1 then cursor = 1 elseif cursor > line:len() + 1 then cursor = line:len() + 1 end
-- Allows clients to override script-opts, but first ensures the overrides are the same type. if type(args.console_opt_overrides) == "table" then for k, v in pairs(args.console_opt_overrides) do if type(v) ~= type(script_opts[k]) then args.console_opt_overrides[k] = nil msg.warn(("mp.input (%s): ignoring console_opt_overrides.%s - must be of type %s" ):format(input_caller, k, type(script_opts[k]))) end end opts = setmetatable(args.console_opt_overrides, {__index = script_opts}) else opts = script_opts end
enable_completions()
if args.items then if type(args.items) ~= "table" then msg.warn(("input.select (%s): 'items' must be a table, instead received a %s" ):format(input_caller, type(args.items))) -- set as empty to avoid errors args.items = {} end
selectable_items = {} selectable_items_lower = {} horizontal_offset = 0
-- Limit the number of characters to prevent libass from freezing mpv. -- Not important for terminal output. local limit if osd_width == 0 or terminal_output() then limit = 5000 else limit = 5 * osd_width / opts.font_size end
for i, item in ipairs(args.items) do item = to_string(item) local last = next_utf8(item, limit) - 1 selectable_items[i] = item:gsub("[\r\n].*", "…"):sub(1, last) .. (last < #item and "…" or "") end
calculate_max_item_width() handle_edit() bind_mouse() else cancel_filter() selectable_items = nil selectable_items_lower = nil unbind_mouse() id = args.id or input_caller .. args.prompt log_offset = 0 completion_buffer = {} autoselect_completion = args.autoselect_completion
if histories[id] == nil then histories[id] = {} log_buffers[id] = {} histories_to_save[id] = "" end history = histories[id]
-- We probably do not want to be converting accidental non-string values to filepaths. history_paths[id] = type(args.history_path) == "string" and args.history_path or nil read_history() history_pos = #history + 1
handle_cursor_move() end
set_active(true)
-- We want to ensure the keybindings have been set before sending the "opened" event -- in case scripts want to override our bindings. mp.flush_keybindings() mp.commandv("script-message-to", input_caller, input_caller_handler, "opened")end)
-- Add a line to the log buffermp.register_script_message("log", function (message) message = utils.parse_json(message or "") if type(message) ~= "table" or not message.log_id then return msg.error("Line not appended to log buffer - " .. "log must be passed a JSON formatted table which contains a 'log_id'.") end
local log_buffer = log_buffers[message.log_id] if not log_buffer then return end
log_buffer[#log_buffer + 1] = { text = to_string(message.text or ""), style = message.error and styles.error or to_string(message.style or ""), terminal_style = message.error and terminal_styles.error or to_string(message.terminal_style or ""), }
if #log_buffer > MAX_LOG_LINES then table.remove(log_buffer, 1) end
if not open or message.log_id ~= id then return end
if log_offset > 0 then log_offset = log_offset + 1 end
if not update_timer:is_enabled() then render() update_timer:resume() else pending_update = true endend)
mp.register_script_message("set-log", function (log_id, log) log = utils.parse_json(log) if not log_id then return msg.error("Log buffer not overwritten - " .. "set-log must be passed a 'log_id' as the first argument.") end
-- This error message provides extra details as mp.input passes the `log` table to -- console.lua unchanged, meaning that this guard may be triggered by Lua and Js clients. if type(log) ~= "table" then return msg.error("Log buffer not overwritten - " .. "set-log must be passed a table as second argument, " .. ("instead received a %s."):format(type(log))) end
log_buffers[log_id] = {}
for i = 1, #log do if type(log[i]) == "table" then log[i].text = to_string(log[i].text or "") log[i].style = to_string(log[i].style or "") log[i].terminal_style = to_string(log[i].terminal_style or "") log_buffers[id][i] = log[i] else log_buffers[id][i] = { text = to_string(log[i] or ""), style = "", terminal_style = "", } end end
if log_id == id then render() endend)
mp.register_script_message("complete", function (message) message = utils.parse_json(message) if type(message) ~= "table" then return msg.error("Completions not received - " .. "complete must be passed a JSON formatted table.") end
if message.client_name ~= input_caller or message.handler_id ~= input_caller_handler then return end
completion_buffer = {} selected_completion_index = 0 local completions = message.list
if type(completions) ~= "table" then msg.warn(("Completions invalid (%s) - " .. "completions must be a table, instead received a %s" ):format(input_caller, type(completions))) completions = {} else for i = 1, #completions do completions[i] = to_string(completions[i]) end end
table.sort(completions) completion_pos = math.floor(tonumber(message.start_pos) or 1) completion_append = to_string(message.append or "") if completion_pos < 1 then completion_pos = 1 end
for i, match_index in ipairs(fuzzy_find(line:sub(completion_pos, cursor - 1), completions)) do completion_buffer[i] = completions[match_index] end
-- Rendering outdated completions makes the completions appear more -- responsive, but we only want allow selecting up-to-date completions. if line:sub(1, cursor - 1) == message.original_line then enable_completions() end render()end)
local function resize() calculate_max_item_width() render()end
mp.observe_property("osd-dimensions", "native", function (_, value) if value.w == osd_width and value.h == osd_height then return end osd_width, osd_height = value.w, value.h resize()end)
mp.observe_property("display-hidpi-scale", "native", function (name, value) property_cache[name] = value resize()end)
mp.observe_property("focused", "native", function (name, value) property_cache[name] = value render()end)
mp.observe_property("user-data/osc/margins", "native", function(_, val) if type(val) == "table" and type(val.t) == "number" and type(val.b) == "number" then global_margins = val else global_margins = { t = 0, b = 0 } end render()end)
mp.register_event("shutdown", function () mp.del_property("user-data/mpv/console")
for history_id, history_path in pairs(history_paths) do history_path = mp.command_native({"expand-path", history_path}) local history_file, error_message = io.open(history_path, "ab")
if history_file then history_file:write(histories_to_save[history_id]) history_file:close() else mp.msg.error("Failed to write history: " .. error_message) end endend)
-- These are for backwards compatibility only.mp.add_key_binding(nil, "enable", function () mp.command("script-message-to commands open")end)
mp.register_script_message("type", function (...) mp.commandv("script-message-to", "commands", "type", ...)end)
require "mp.options".read_options(script_opts, nil, render)
collectgarbage()