1-- syncplayintf.lua -- An interface for communication between mpv and Syncplay
2-- Author: Etoh, utilising repl.lua code by James Ross-Gowan (see below)
3-- Thanks: RiCON, James Ross-Gowan, Argon-, wm4, uau
4
5-- Includes code copied/adapted from repl.lua -- A graphical REPL for mpv input commands
6--
7-- c 2016, James Ross-Gowan
8--
9-- Permission to use, copy, modify, and/or distribute this software for any
10-- purpose with or without fee is hereby granted, provided that the above
11-- copyright notice and this permission notice appear in all copies.
12--
13-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
14-- WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
15-- MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
16-- SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
17-- WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION
18-- OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN
19-- CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
20
21-- See https://github.com/rossy/mpv-repl for a copy of repl.lua
22
23local CANVAS_WIDTH = 1920
24local CANVAS_HEIGHT = 1080
25local ROW_HEIGHT = 100
26local chat_format = "{\\fs50}{\an1}"
27local max_scrolling_rows = 100
28local MOVEMENT_PER_SECOND = 200
29local TICK_INTERVAL = 0.01
30local CHAT_MODE_CHATROOM = "Chatroom"
31local CHAT_MODE_SUBTITLE = "Subtitle"
32local CHAT_MODE_SCROLLING = "Scrolling"
33local last_chat_time = 0
34local use_alpha_rows_for_chat = true
35local MOOD_NEUTRAL = 0
36local MOOD_BAD = 1
37local MOOD_GOOD = 2
38local WORDWRAPIFY_MAGICWORD = "{\\\\fscx0}  {\\\\fscx100}"
39local SCROLLING_ADDITIONAL_BOTTOM_MARGIN = 75
40local default_oscvisibility_state = "never"
41
42local ALPHA_WARNING_TEXT_COLOUR = "FF00FF" -- RBG
43local HINT_TEXT_COLOUR = "AAAAAA" -- RBG
44local NEUTRAL_ALERT_TEXT_COLOUR = "FFFFFF" -- RBG
45local BAD_ALERT_TEXT_COLOUR = "0000FF"  -- RBG
46local GOOD_ALERT_TEXT_COLOUR = "00FF00" -- RBG
47local NOTIFICATION_TEXT_COLOUR = "FFFF00" -- RBG
48
49local FONT_SIZE_MULTIPLIER = 2
50
51local chat_log = {}
52
53local assdraw = require "mp.assdraw"
54
55local opt = require 'mp.options'
56
57local repl_active = false
58local insert_mode = false
59local line = ''
60local cursor = 1
61local key_hints_enabled = false
62
63non_us_chars = {
64	'А','а',
65    'ą','ć','ę','ł','ń','ś','ź','ż','Ą','Ć','Ę','Ł','Ń','Ś','Ź','Ż',
66    'à','è','ì','ò','ù','À','È','Ì','Ò','Ù',
67    'á', 'é', 'í', 'ó', 'ú', 'ý', 'Á', 'É', 'Í', 'Ó', 'Ú', 'Ý',
68    'â', 'ê', 'î', 'ô', 'û', 'Â', 'Ê', 'Î', 'Ô', 'Û',
69    'ã', 'ñ', 'õ', 'Ã', 'Ñ', 'Õ',
70    'ä', 'ë', 'ï', 'ö', 'ü', 'ÿ', 'Ä', 'Ë', 'Ï', 'Ö', 'Ü', 'Ÿ',
71    'å', 'Å','æ','Æ','œ','Œ','ç','Ç','ð','Ð','ø','Ø','¿','¡','ß',
72    '¤','†','×','÷','‡','±','—','–','¶','§','ˆ','˜','«','»','¦','‰','©','®','™',
73    'ž','Ž',
74    'ª','Þ','þ','ƒ','µ','°','º','•','„','“','…','¬','¥','£','€','¢','¹','²','³','½','¼','¾',
75    '·','Ĉ','ĉ','Ĝ','ĝ','Ĥ','ĥ','Ĵ','ĵ','Ŝ','ŝ','Ŭ','ŭ',
76    'Б','б','В','в','Г','г','Д','д','Е','е','Ё','ё','Ж','ж','З','з',
77    'И','и','Й','й','К','к','Л','л','М','м','Н','н','О','о','П','п',
78    'Р','р','С','с','Т','т','У','у','Ф','ф','Х','х','Ц','ц','Ч','ч',
79    'Ш','ш','Щ','щ','Ъ','ъ','Ы','ы','Ь','ь','Э','э','Ю','ю','Я','я',
80    '≥','≠'
81}
82
83function format_scrolling(xpos, ypos, text)
84	local chat_message = "\n"..chat_format .. "{\\pos("..xpos..","..ypos..")\\q2}"..text.."\\N\\n"
85    return string.format(chat_message)
86end
87
88function format_chatroom(text)
89    local chat_message = chat_format .. text .."\\N\\n"
90    return string.format(chat_message)
91end
92
93function clear_chat()
94	chat_log = {}
95end
96
97local alert_osd = ""
98local last_alert_osd_time = nil
99local alert_osd_mood = MOOD_NEUTRAL
100
101local notification_osd = ""
102local last_notification_osd_time = nil
103local notification_osd_mood = MOOD_NEUTRAL
104
105function set_alert_osd(osd_message, mood)
106    alert_osd = osd_message
107    last_alert_osd_time = mp.get_time()
108    alert_osd_mood = mood
109end
110
111function set_notification_osd(osd_message, mood)
112    notification_osd = osd_message
113    last_notification_osd_time = mp.get_time()
114    notification_osd_mood = mood
115end
116
117function add_chat(chat_message, mood)
118    last_chat_time = mp.get_time()
119	local entry = #chat_log+1
120	for i = 1, #chat_log do
121		if chat_log[i].text == '' then
122			entry = i
123			break
124		end
125    end
126	local row = ((entry-1) % max_scrolling_rows)+1
127    if opts['chatOutputMode'] == CHAT_MODE_CHATROOM then
128		if entry > opts['chatMaxLines'] then
129             table.remove(chat_log, 1)
130             entry = entry - 1
131        end
132    end
133	chat_log[entry] = { xpos=CANVAS_WIDTH, timecreated=mp.get_time(), text=tostring(chat_message), row=row }
134end
135
136function chat_update()
137    local ass = assdraw.ass_new()
138	local chat_ass = ''
139    local rowsAdded = 0
140    local to_add = ''
141    local incrementRow = 0
142    if opts['chatOutputMode'] == CHAT_MODE_CHATROOM and chat_log ~= {} then
143        local timedelta = mp.get_time() - last_chat_time
144		if timedelta >= opts['chatTimeout'] then
145            clear_chat()
146        end
147    end
148    rowsAdded,to_add = process_alert_osd()
149    if to_add ~= nil and to_add ~= "" then
150        chat_ass = to_add
151    end
152    incrementRow,to_add = process_notification_osd(rowsAdded)
153    rowsAdded = rowsAdded + incrementRow
154    if to_add ~= nil and to_add ~= "" then
155        chat_ass = chat_ass .. to_add
156    end
157
158	if #chat_log > 0 then
159		for i = 1, #chat_log do
160			local to_add = process_chat_item(i,rowsAdded)
161			if to_add ~= nil and to_add ~= "" then
162				chat_ass = chat_ass .. to_add
163			end
164		end
165    end
166
167    local xpos = opts['chatLeftMargin']
168    local ypos = opts['chatTopMargin']
169    chat_ass = "\n".."{\\pos("..xpos..","..ypos..")}".. chat_ass
170
171    if use_alpha_rows_for_chat == false and opts['chatDirectInput'] == true then
172        local alphawarning_ass = assdraw.ass_new()
173        alphawarning_ass = "{\\a6}{\\1c&H"..ALPHA_WARNING_TEXT_COLOUR.."}"..opts['alphakey-mode-warning-first-line'].."\n{\\a6}{\\1c&H"..ALPHA_WARNING_TEXT_COLOUR.."}"..opts['alphakey-mode-warning-second-line']
174        ass:append(alphawarning_ass)
175    elseif opts['chatOutputMode'] == CHAT_MODE_CHATROOM and opts['chatInputPosition'] == "Top" then
176        ass:append(chat_ass)
177        ass:append(input_ass())
178    else
179        ass:append(input_ass())
180        ass:append(chat_ass)
181    end
182	mp.set_osd_ass(CANVAS_WIDTH,CANVAS_HEIGHT, ass.text)
183end
184
185function process_alert_osd()
186    local rowsCreated = 0
187    local stringToAdd = ""
188    if alert_osd ~= "" and mp.get_time() - last_alert_osd_time < opts['alertTimeout'] and last_alert_osd_time ~= nil then
189        local messageColour
190        if alert_osd_mood == MOOD_NEUTRAL then
191            messageColour = "{\\1c&H"..NEUTRAL_ALERT_TEXT_COLOUR.."}"
192        elseif alert_osd_mood == MOOD_BAD then
193            messageColour = "{\\1c&H"..BAD_ALERT_TEXT_COLOUR.."}"
194        elseif alert_osd_mood == MOOD_GOOD then
195            messageColour = "{\\1c&H"..GOOD_ALERT_TEXT_COLOUR.."}"
196        end
197        local messageString = wordwrapify_string(alert_osd)
198        local startRow = 0
199        if messageString ~= '' and messageString ~= nil then
200            local toDisplay
201            rowsCreated = rowsCreated + 1
202            messageString = messageColour..messageString
203			if stringToAdd ~= "" then
204            	stringToAdd = stringToAdd .. format_chatroom(messageString)
205			else
206				stringToAdd = format_chatroom(messageString)
207			end
208        end
209    end
210    return rowsCreated, stringToAdd
211end
212
213function process_notification_osd(startRow)
214    local rowsCreated = 0
215    local startRow = startRow
216    local stringToAdd = ""
217    if notification_osd ~= "" and mp.get_time() - last_notification_osd_time < opts['alertTimeout'] and last_notification_osd_time ~= nil then
218        local messageColour
219        messageColour = "{\\1c&H"..NOTIFICATION_TEXT_COLOUR.."}"
220        local messageString
221        messageString = wordwrapify_string(notification_osd)
222        messageString = messageColour..messageString
223        messageString = format_chatroom(messageString)
224        stringToAdd = messageString
225        rowsCreated = 1
226	end
227    return rowsCreated, stringToAdd
228end
229
230
231function process_chat_item(i, rowsAdded)
232    if opts['chatOutputMode'] == CHAT_MODE_CHATROOM then
233		return process_chat_item_chatroom(i, rowsAdded)
234	elseif opts['chatOutputMode'] == CHAT_MODE_SCROLLING then
235		return process_chat_item_scrolling(i)
236	end
237end
238
239function process_chat_item_scrolling(i)
240	local timecreated = chat_log[i].timecreated
241	local timedelta = mp.get_time() - timecreated
242	local xpos = CANVAS_WIDTH - (timedelta*MOVEMENT_PER_SECOND)
243	local text = chat_log[i].text
244	if text ~= '' then
245		local roughlen = string.len(text) * (opts['chatOutputRelativeFontSize']*FONT_SIZE_MULTIPLIER) * 1.5
246		if xpos > (-1*roughlen) then
247			local row = chat_log[i].row-1+opts['scrollingFirstRowOffset']
248			local ypos = opts['chatTopMargin']+(row * (opts['chatOutputRelativeFontSize']*FONT_SIZE_MULTIPLIER))
249			return format_scrolling(xpos,ypos,text)
250		else
251			chat_log[i].text = ''
252		end
253	end
254end
255
256function process_chat_item_chatroom(i, startRow)
257    local text = chat_log[i].text
258	if text ~= '' then
259        local text = wordwrapify_string(text)
260        local rowNumber = i+startRow-1
261        return(format_chatroom(text))
262	end
263end
264
265function process_chat_item_subtitle(i)
266    local timecreated = chat_log[i].timecreated
267	local timedelta = mp.get_time() - timecreated
268	local xpos = CANVAS_WIDTH - (timedelta*MOVEMENT_PER_SECOND)
269	local text = chat_log[i].text
270	if text ~= '' then
271		local roughlen = string.len(text) * (opts['chatOutputRelativeFontSize']*FONT_SIZE_MULTIPLIER)
272		if xpos > (-1*roughlen) then
273			local row = chat_log[i].row
274			local ypos = row * (opts['chatOutputRelativeFontSize']*FONT_SIZE_MULTIPLIER)
275            return(format_scrolling(xpos,ypos,text))
276		else
277			chat_log[i].text = ''
278		end
279	end
280end
281
282chat_timer=mp.add_periodic_timer(TICK_INTERVAL, chat_update)
283
284mp.register_script_message('chat', function(e)
285	add_chat(e)
286end)
287
288-- Chat OSD
289
290mp.register_script_message('chat-osd-neutral', function(e)
291	add_chat(e,MOOD_NEUTRAL)
292end)
293
294mp.register_script_message('chat-osd-bad', function(e)
295	add_chat(e,MOOD_BAD)
296end)
297
298mp.register_script_message('chat-osd-good', function(e)
299	add_chat(e,MOOD_GOOD)
300end)
301
302-- Alert OSD
303
304mp.register_script_message('alert-osd-neutral', function(e)
305	set_alert_osd(e,MOOD_NEUTRAL)
306end)
307
308mp.register_script_message('alert-osd-bad', function(e)
309	set_alert_osd(e,MOOD_BAD)
310end)
311
312mp.register_script_message('alert-osd-good', function(e)
313	set_alert_osd(e,MOOD_GOOD)
314end)
315
316-- Notification OSD
317
318mp.register_script_message('notification-osd-neutral', function(e)
319	set_notification_osd(e,MOOD_NEUTRAL)
320end)
321
322mp.register_script_message('notification-osd-bad', function(e)
323	set_notification_osd(e,MOOD_BAD)
324end)
325
326mp.register_script_message('notification-osd-good', function(e)
327	set_notification_osd(e,MOOD_GOOD)
328end)
329
330--
331
332mp.register_script_message('set_syncplayintf_options', function(e)
333	set_syncplayintf_options(e)
334end)
335
336-- Default options
337local utils = require 'mp.utils'
338local options = require 'mp.options'
339opts = {
340
341	-- All drawing is scaled by this value, including the text borders and the
342	-- cursor. Change it if you have a high-DPI display.
343	scale = 1,
344	-- Set the font used for the REPL and the console. This probably doesn't
345	-- have to be a monospaced font.
346	['chatInputFontFamily'] = 'monospace',
347	-- Enable/Disable
348	['chatInputEnabled'] = true,
349	['chatOutputEnabled'] = true,
350    ['OscVisibilityChangeCompatible'] = false,
351	-- Set the font size used for the REPL and the console. This will be
352	-- multiplied by "scale."
353	['chatInputRelativeFontSize'] = 14,
354	['chatInputFontWeight'] = 1,
355	['chatInputFontUnderline'] = false,
356	['chatInputFontColor'] = "#000000",
357	['chatInputPosition'] = "Top",
358	['MaxChatMessageLength'] = 500,
359	['chatOutputFontFamily'] = "sans serif",
360	['chatOutputFontSize'] = 50,
361	['chatOutputFontWeight'] = 1,
362	['chatOutputFontUnderline'] = false,
363	['chatOutputFontColor'] = "#FFFFFF",
364	['chatOutputMode'] = "Chatroom",
365    ['scrollingFirstRowOffset'] = 2,
366	-- Can be "Chatroom", "Subtitle" or "Scrolling" style
367    ['chatMaxLines'] = 7,
368    ['chatTopMargin'] = 25,
369    ['chatLeftMargin'] = 20,
370    ['chatDirectInput'] = true,
371    --
372    ['notificationTimeout'] = 3,
373    ['alertTimeout'] = 5,
374    ['chatTimeout'] = 7,
375	--
376	['inputPromptStartCharacter'] = ">",
377    ['inputPromptEndCharacter'] = "<",
378    ['backslashSubstituteCharacter'] = "|",
379    --Lang:
380    ['mpv-key-tab-hint'] = "[TAB] to toggle access to alphabet row key shortcuts.",
381    ['mpv-key-hint'] = "[ENTER] to send message. [ESC] to escape chat mode.",
382    ['alphakey-mode-warning-first-line'] = "You can temporarily use old mpv bindings with a-z keys.",
383    ['alphakey-mode-warning-second-line'] = "Press [TAB] to return to Syncplay chat mode.",
384}
385
386function detect_platform()
387	local o = {}
388	-- Kind of a dumb way of detecting the platform but whatever
389	if mp.get_property_native('options/vo-mmcss-profile', o) ~= o then
390		return 'windows'
391	elseif mp.get_property_native('options/input-app-events', o) ~= o then
392		return 'macos'
393	end
394	return 'linux'
395end
396
397-- Pick a better default font for Windows and macOS
398local platform = detect_platform()
399if platform == 'windows' then
400	opts.font = 'Consolas'
401elseif platform == 'macos' then
402	opts.font = 'Menlo'
403end
404
405-- Apply user-set options
406options.read_options(opts)
407
408-- Escape a string for verbatim display on the OSD
409function ass_escape(str)
410	-- There is no escape for '\' in ASS (I think?) but '\' is used verbatim if
411	-- it isn't followed by a recognised character, so add a zero-width
412	-- non-breaking space
413	str = str:gsub('\\', '\\\239\187\191')
414	str = str:gsub('{', '\\{')
415	str = str:gsub('}', '\\}')
416	-- Precede newlines with a ZWNBSP to prevent ASS's weird collapsing of
417	-- consecutive newlines
418	str = str:gsub('\n', '\239\187\191\n')
419	return str
420end
421
422function update()
423	return
424end
425
426function input_ass()
427	if not repl_active then
428		return ""
429    end
430    last_chat_time = mp.get_time() -- to keep chat messages showing while entering input
431	local bold
432	if opts['chatInputFontWeight'] < 75 then
433		bold = 0
434	else
435		bold = 1
436	end
437	local underline = opts['chatInputFontUnderline'] and 1 or 0
438	local red = string.sub(opts['chatInputFontColor'],2,3)
439	local green = string.sub(opts['chatInputFontColor'],4,5)
440	local blue = string.sub(opts['chatInputFontColor'],6,7)
441	local fontColor = blue .. green .. red
442	local style = '{\\r' ..
443	               '\\1a&H00&\\3a&H00&\\4a&H99&' ..
444	               '\\1c&H'..fontColor..'&\\3c&H111111&\\4c&H000000&' ..
445	               '\\fn' .. opts['chatInputFontFamily'] .. '\\fs' .. (opts['chatInputRelativeFontSize']*FONT_SIZE_MULTIPLIER) .. '\\b' .. bold ..
446	               '\\bord2\\xshad0\\yshad1\\fsp0\\q1}'
447
448	local after_style = '{\\u'  .. underline .. '}'
449	local cheight = opts['chatInputRelativeFontSize'] * FONT_SIZE_MULTIPLIER * 8
450	local cglyph = '_'
451	local before_cur = wordwrapify_string(ass_escape(line:sub(1, cursor - 1)))
452	local after_cur = wordwrapify_string(ass_escape(line:sub(cursor)))
453        local secondary_pos = "10,"..tostring(10+(opts['chatInputRelativeFontSize']*FONT_SIZE_MULTIPLIER))
454
455	local alignment = 7
456	local position = "5,5"
457	local start_marker = opts['inputPromptStartCharacter']
458	local end_marker = ""
459	if opts['chatInputPosition'] == "Middle" then
460		alignment = 5
461		position = tostring(CANVAS_WIDTH/2)..","..tostring(CANVAS_HEIGHT/2)
462        secondary_pos = tostring(CANVAS_WIDTH/2)..","..tostring((CANVAS_HEIGHT/2)+20+(opts['chatInputRelativeFontSize']*FONT_SIZE_MULTIPLIER))
463		end_marker = "{\\u0}"..opts['inputPromptEndCharacter']
464	elseif opts['chatInputPosition'] == "Bottom" then
465		alignment = 1
466		position = tostring(5)..","..tostring(CANVAS_HEIGHT-5)
467        secondary_pos = "10,"..tostring(CANVAS_HEIGHT-(20+(opts['chatInputRelativeFontSize']*FONT_SIZE_MULTIPLIER)))
468    end
469
470	local osd_help_message = opts['mpv-key-hint']
471    if opts['chatDirectInput'] then
472        osd_help_message = opts['mpv-key-tab-hint'] .. " " .. osd_help_message
473    end
474	local help_prompt = '\\N\\n{\\an'..alignment..'\\pos('..secondary_pos..')\\fn' .. opts['chatOutputFontFamily'] .. '\\fs' .. ((opts['chatInputRelativeFontSize']*FONT_SIZE_MULTIPLIER)/1.25) .. '\\1c&H'..HINT_TEXT_COLOUR..'}' .. osd_help_message
475
476	local firststyle = "{\\an"..alignment.."}{\\pos("..position..")}"
477	if opts['chatOutputEnabled'] and opts['chatOutputMode'] == CHAT_MODE_CHATROOM and opts['chatInputPosition'] == "Top" then
478		firststyle = get_output_style().."{'\\1c&H'"..fontColor.."}"
479        before_cur = before_cur .. firststyle
480        after_cur =  after_cur .. firststyle
481		help_prompt = '\\N\\n'..firststyle..'{\\1c&H'..HINT_TEXT_COLOUR..'}' .. osd_help_message .. '\\N\\n'
482	end
483	if key_hints_enabled == false then help_prompt = "" end
484
485	return firststyle..style..start_marker.." "..after_style..before_cur..style..cglyph..style..after_style..after_cur..end_marker..help_prompt
486
487end
488
489function get_output_style()
490	local bold
491	if opts['chatOutputFontWeight'] < 75 then
492		bold = 0
493	else
494		bold = 1
495	end
496	local underline = opts['chatOutputFontUnderline'] and 1 or 0
497	local red = string.sub(opts['chatOutputFontColor'],2,3)
498	local green = string.sub(opts['chatOutputFontColor'],4,5)
499	local blue = string.sub(opts['chatOutputFontColor'],6,7)
500	local fontColor = blue .. green .. red
501	local style = '{\\r' ..
502	               '\\1a&H00&\\3a&H00&\\4a&H99&' ..
503	               '\\1c&H'..fontColor..'&\\3c&H111111&\\4c&H000000&' ..
504	               '\\fn' .. opts['chatOutputFontFamily'] .. '\\fs' .. (opts['chatOutputRelativeFontSize']*FONT_SIZE_MULTIPLIER) .. '\\b' .. bold ..
505	               '\\u'  .. underline .. '\\a5\\MarginV=500' .. '}'
506
507	--mp.osd_message("",0)
508	return style
509
510end
511
512function escape()
513	set_active(false)
514	clear()
515end
516
517-- Set the REPL visibility (`, Esc)
518function set_active(active)
519    if use_alpha_rows_for_chat == false then active = false end
520	if active == repl_active then return end
521	if active then
522		repl_active = true
523		insert_mode = false
524		mp.enable_key_bindings('repl-input', 'allow-hide-cursor+allow-vo-dragging')
525	else
526		repl_active = false
527		mp.disable_key_bindings('repl-input')
528    end
529    if default_oscvisibility_state ~= "never" and opts['OscVisibilityChangeCompatible'] == true then
530        if active then
531            mp.commandv("script-message", "osc-visibility","never", "no-osd")
532        else
533            mp.commandv("script-message", "osc-visibility",default_oscvisibility_state, "no-osd")
534        end
535    end
536end
537
538-- Show the repl if hidden and replace its contents with 'text'
539-- (script-message-to repl type)
540function show_and_type(text)
541	text = text or ''
542
543	line = text
544	cursor = line:len() + 1
545	insert_mode = false
546	if repl_active then
547		update()
548	else
549		set_active(true)
550	end
551end
552
553-- Naive helper function to find the next UTF-8 character in 'str' after 'pos'
554-- by skipping continuation bytes. Assumes 'str' contains valid UTF-8.
555function next_utf8(str, pos)
556	if pos > str:len() then return pos end
557	repeat
558		pos = pos + 1
559	until pos > str:len() or str:byte(pos) < 0x80 or str:byte(pos) > 0xbf
560	return pos
561end
562
563-- Naive helper function to find the next UTF-8 character in 'str' after 'pos'
564-- by skipping continuation bytes. Assumes 'str' contains valid UTF-8.
565
566
567-- As above, but finds the previous UTF-8 charcter in 'str' before 'pos'
568function prev_utf8(str, pos)
569	if pos <= 1 then return pos end
570	repeat
571		pos = pos - 1
572	until pos <= 1 or str:byte(pos) < 0x80 or str:byte(pos) > 0xbf
573	return pos
574end
575
576function trim_string(line,maxCharacters)
577-- Naive helper function to find the next UTF-8 character in 'str' after 'pos'
578-- by skipping continuation bytes. Assumes 'str' contains valid UTF-8.
579
580	local str = line
581	if str == nil or str == "" or str:len() <= maxCharacters then
582		return str, ""
583	end
584	local pos = 0
585	local oldPos = -1
586	local chars = 0
587
588	repeat
589		oldPos = pos
590		pos = next_utf8(str, pos)
591		chars = chars + 1
592	until pos == oldPos or chars > maxCharacters
593	return str:sub(1,pos-1), str:sub(pos)
594end
595
596function wordwrapify_string(line)
597-- Used to ensure characters wrap on a per-character rather than per-word basis
598-- to avoid issues with long filenames, etc.
599
600	local str = line
601	if str == nil or str == "" then
602		return ""
603    end
604    local newstr = ""
605	local currentChar = 0
606	local nextChar = 0
607	local chars = 0
608    local maxChars = str:len()
609
610	repeat
611		nextChar = next_utf8(str, currentChar)
612        if nextChar == currentChar then
613            return newstr
614		end
615		local charToTest = str:sub(currentChar,nextChar-1)
616		if charToTest ~= "\\" and charToTest ~= "{"  and charToTest ~= "}" and charToTest ~= "%" then
617			newstr = newstr .. WORDWRAPIFY_MAGICWORD .. str:sub(currentChar,nextChar-1)
618        else
619			newstr = newstr .. str:sub(currentChar,nextChar-1)
620		end
621        currentChar = nextChar
622	until currentChar > maxChars
623    newstr = string.gsub(newstr,opts['backslashSubstituteCharacter'], '\\\239\187\191') -- Workaround for \ escape issues
624	return newstr
625end
626
627
628function trim_input()
629-- Naive helper function to find the next UTF-8 character in 'str' after 'pos'
630-- by skipping continuation bytes. Assumes 'str' contains valid UTF-8.
631
632	local str = line
633	if str == nil or str == "" or str:len() <= opts['MaxChatMessageLength'] then
634		return
635	end
636	local pos = 0
637	local oldPos = -1
638	local chars = 0
639
640	repeat
641		oldPos = pos
642		pos = next_utf8(str, pos)
643		chars = chars + 1
644	until pos == oldPos or chars > opts['MaxChatMessageLength']
645	line = line:sub(1,pos-1)
646	if cursor > pos then
647		cursor = pos
648	end
649	return
650end
651
652-- Insert a character at the current cursor position (' '-'~', Shift+Enter)
653function handle_char_input(c)
654    if c == nil then return end
655    if c == "\\" then c = opts['backslashSubstituteCharacter'] end
656	if key_hints_enabled and (string.len(line) > 0 or opts['chatDirectInput'] == false) then
657        key_hints_enabled = false
658    end
659    set_active(true)
660	if insert_mode then
661		line = line:sub(1, cursor - 1) .. c .. line:sub(next_utf8(line, cursor))
662	else
663		line = line:sub(1, cursor - 1) .. c .. line:sub(cursor)
664	end
665	cursor = cursor + c:len()
666    trim_input()
667	update()
668end
669
670-- Remove the character behind the cursor (Backspace)
671function handle_backspace()
672	if cursor <= 1 then return end
673	local prev = prev_utf8(line, cursor)
674	line = line:sub(1, prev - 1) .. line:sub(cursor)
675	cursor = prev
676	update()
677end
678
679-- Remove the character in front of the cursor (Del)
680function handle_del()
681	if cursor > line:len() then return end
682	line = line:sub(1, cursor - 1) .. line:sub(next_utf8(line, cursor))
683	update()
684end
685
686-- Toggle insert mode (Ins)
687function handle_ins()
688	insert_mode = not insert_mode
689end
690
691--local was_active_before_tab = false
692
693function handle_tab()
694    use_alpha_rows_for_chat = not use_alpha_rows_for_chat
695    if use_alpha_rows_for_chat then
696        mp.enable_key_bindings('repl-alpha-input')
697        --set_active(was_active_before_tab)
698    else
699        mp.disable_key_bindings('repl-alpha-input')
700        --was_active_before_tab = repl_active
701        --set_active(false)
702        escape()
703    end
704end
705
706-- Move the cursor to the next character (Right)
707function next_char(amount)
708	cursor = next_utf8(line, cursor)
709	update()
710end
711
712-- Move the cursor to the previous character (Left)
713function prev_char(amount)
714	cursor = prev_utf8(line, cursor)
715	update()
716end
717
718-- Clear the current line (Ctrl+C)
719function clear()
720	line = ''
721	cursor = 1
722	insert_mode = false
723	update()
724end
725
726-- Close the REPL if the current line is empty, otherwise do nothing (Ctrl+D)
727function maybe_exit()
728	if line == '' then
729		set_active(false)
730	end
731end
732
733-- Run the current command and clear the line (Enter)
734function handle_enter()
735	if not repl_active then
736		set_active(true)
737		return
738	end
739	set_active(false)
740
741	if line == '' then
742		return
743    end
744    key_hints_enabled = false
745	line = string.gsub(line,"\\", "\\\\")
746	line = string.gsub(line,"\"", "\\\"")
747	mp.command('print-text "<chat>'..line..'</chat>"')
748	clear()
749end
750
751-- Move the cursor to the beginning of the line (HOME)
752function go_home()
753	cursor = 1
754	update()
755end
756
757-- Move the cursor to the end of the line (END)
758function go_end()
759	cursor = line:len() + 1
760	update()
761end
762
763-- Delete from the cursor to the end of the line (Ctrl+K)
764function del_to_eol()
765	line = line:sub(1, cursor - 1)
766	update()
767end
768
769-- Delete from the cursor back to the start of the line (Ctrl+U)
770function del_to_start()
771	line = line:sub(cursor)
772	cursor = 1
773	update()
774end
775
776-- Returns a string of UTF-8 text from the clipboard (or the primary selection)
777function get_clipboard(clip)
778	if platform == 'linux' then
779		local res = utils.subprocess({ args = {
780			'xclip', '-selection', clip and 'clipboard' or 'primary', '-out'
781		} })
782		if not res.error then
783			return res.stdout
784		end
785	elseif platform == 'windows' then
786		local res = utils.subprocess({ args = {
787			'powershell', '-NoProfile', '-Command', [[& {
788				Trap {
789					Write-Error -ErrorRecord $_
790					Exit 1
791				}
792
793				$clip = ""
794				if (Get-Command "Get-Clipboard" -errorAction SilentlyContinue) {
795					$clip = Get-Clipboard -Raw -Format Text -TextFormatType UnicodeText
796				} else {
797					Add-Type -AssemblyName PresentationCore
798					$clip = [Windows.Clipboard]::GetText()
799				}
800
801				$clip = $clip -Replace "`r",""
802				$u8clip = [System.Text.Encoding]::UTF8.GetBytes($clip)
803				[Console]::OpenStandardOutput().Write($u8clip, 0, $u8clip.Length)
804			}]]
805		} })
806		if not res.error then
807			return res.stdout
808		end
809	elseif platform == 'macos' then
810		local res = utils.subprocess({ args = { 'pbpaste' } })
811		if not res.error then
812			return res.stdout
813		end
814	end
815	return ''
816end
817
818-- Paste text from the window-system's clipboard. 'clip' determines whether the
819-- clipboard or the primary selection buffer is used (on X11 only.)
820function paste(clip)
821	local text = get_clipboard(clip)
822	local before_cur = line:sub(1, cursor - 1)
823	local after_cur = line:sub(cursor)
824	line = before_cur .. text .. after_cur
825	cursor = cursor + text:len()
826    trim_input()
827	update()
828end
829
830-- The REPL has pretty specific requirements for key bindings that aren't
831-- really satisified by any of mpv's helper methods, since they must be in
832-- their own input section, but they must also raise events on key-repeat.
833-- Hence, this function manually creates an input section and puts a list of
834-- bindings in it.
835function add_repl_bindings(bindings)
836	local cfg = ''
837	for i, binding in ipairs(bindings) do
838		local key = binding[1]
839		local fn = binding[2]
840		local name = '__repl_binding_' .. i
841		mp.add_forced_key_binding(nil, name, fn, 'repeatable')
842		cfg = cfg .. key .. ' script-binding ' .. mp.script_name .. '/' ..
843		      name .. '\n'
844	end
845	mp.commandv('define-section', 'repl-input', cfg, 'force')
846end
847
848function add_repl_alpharow_bindings(bindings)
849	local cfg = ''
850	for i, binding in ipairs(bindings) do
851		local key = binding[1]
852		local fn = binding[2]
853		local name = '__repl_alpha_binding_' .. i
854		mp.add_forced_key_binding(nil, name, fn, 'repeatable')
855		cfg = cfg .. key .. ' script-binding ' .. mp.script_name .. '/' ..
856		      name .. '\n'
857	end
858	mp.commandv('define-section', 'repl-alpha-input', cfg, 'force')
859    mp.enable_key_bindings('repl-alpha-input')
860end
861
862-- Mapping from characters to mpv key names
863local binding_name_map = {
864	[' '] = 'SPACE',
865	['#'] = 'SHARP',
866}
867
868-- List of input bindings. This is a weird mashup between common GUI text-input
869-- bindings and readline bindings.
870local bindings = {
871	{ 'esc',         function() escape() end       },
872	{ 'bs',          handle_backspace                       },
873	{ 'shift+bs',    handle_backspace                       },
874	{ 'del',         handle_del                             },
875	{ 'shift+del',   handle_del                             },
876	{ 'ins',         handle_ins                             },
877	{ 'left',        function() prev_char() end             },
878	{ 'right',       function() next_char() end             },
879	{ 'up',          function() clear() end        },
880	{ 'home',        go_home                                },
881	{ 'end',         go_end                                 },
882	{ 'ctrl+c',      clear                                  },
883	{ 'ctrl+d',      maybe_exit                             },
884	{ 'ctrl+k',      del_to_eol                             },
885	{ 'ctrl+l',      clear_log_buffer                       },
886	{ 'ctrl+u',      del_to_start                           },
887	{ 'ctrl+v',      function() paste(true) end             },
888	{ 'meta+v',      function() paste(true) end             },
889}
890local alpharowbindings = {}
891-- Add bindings for all the printable US-ASCII characters from ' ' to '~'
892-- inclusive. Note, this is a pretty hacky way to do text input. mpv's input
893-- system was designed for single-key key bindings rather than text input, so
894-- things like dead-keys and non-ASCII input won't work. This is probably okay
895-- though, since all mpv's commands and properties can be represented in ASCII.
896for b = (' '):byte(), ('~'):byte() do
897	local c = string.char(b)
898	local binding = binding_name_map[c] or c
899	bindings[#bindings + 1] = {binding, function() handle_char_input(c) end}
900end
901
902function add_alpharowbinding(firstchar,lastchar)
903    for b = (firstchar):byte(), (lastchar):byte() do
904	    local c = string.char(b)
905	    local alphabinding = binding_name_map[c] or c
906	    alpharowbindings[#alpharowbindings + 1] = {alphabinding, function() handle_char_input(c) end}
907    end
908end
909
910function add_specialalphabindings(charinput)
911    local alphabindingarray = charinput
912	for i, alphabinding in ipairs(alphabindingarray) do
913    	alpharowbindings[#alpharowbindings + 1] = {alphabinding, function() handle_char_input(alphabinding) end }
914	    bindings[#bindings + 1] = {alphabinding, function() handle_char_input(alphabinding) end}
915	end
916end
917
918add_alpharowbinding('a','z')
919add_alpharowbinding('A','Z')
920add_alpharowbinding('/','/')
921add_alpharowbinding(':',':')
922add_alpharowbinding('(',')')
923add_alpharowbinding('{','}')
924add_alpharowbinding(':',';')
925add_alpharowbinding('<','>')
926add_alpharowbinding(',','.')
927add_alpharowbinding('|','|')
928add_alpharowbinding('\\','\\')
929add_alpharowbinding('?','?')
930add_alpharowbinding('[',']')
931add_alpharowbinding('#','#')
932add_alpharowbinding('~','~')
933add_alpharowbinding('\'','\'')
934add_alpharowbinding('@','@')
935
936add_specialalphabindings(non_us_chars)
937add_repl_bindings(bindings)
938
939-- Add a script-message to show the REPL and fill it with the provided text
940mp.register_script_message('type', function(text)
941	show_and_type(text)
942end)
943
944local syncplayintfSet = false
945mp.command('print-text "<get_syncplayintf_options>"')
946
947function readyMpvAfterSettingsKnown()
948	if syncplayintfSet == false then
949        local vertical_output_area = CANVAS_HEIGHT-(opts['chatTopMargin']+opts['chatBottomMargin']+((opts['chatOutputRelativeFontSize']*FONT_SIZE_MULTIPLIER)*opts['scrollingFirstRowOffset'])+SCROLLING_ADDITIONAL_BOTTOM_MARGIN)
950		max_scrolling_rows = math.floor(vertical_output_area/(opts['chatOutputRelativeFontSize']*FONT_SIZE_MULTIPLIER))
951		local user_opts = { visibility = "auto", }
952		opt.read_options(user_opts, "osc")
953		default_oscvisibility_state = user_opts.visibility
954		if opts['chatInputEnabled'] == true then
955			key_hints_enabled = true
956			mp.add_forced_key_binding('enter', handle_enter)
957			mp.add_forced_key_binding('kp_enter', handle_enter)
958			if opts['chatDirectInput'] == true then
959				add_repl_alpharow_bindings(alpharowbindings)
960				mp.add_forced_key_binding('tab', handle_tab)
961			end
962        end
963        syncplayintfSet = true
964    end
965end
966
967function set_syncplayintf_options(input)
968	--mp.command('print-text "<chat>...'..input..'</chat>"')
969	for option, value in string.gmatch(input, "([^ ,=]+)=([^,]+)") do
970		local valueType = type(opts[option])
971		if valueType == "number" then
972			value = tonumber(value)
973		elseif valueType == "boolean" then
974			if value == "True" then
975				value = true
976			else
977				value = false
978			end
979		end
980		opts[option] = value
981		--mp.command('print-text "<chat>'..option.."="..tostring(value).." - "..valueType..'</chat>"')
982	end
983	chat_format = get_output_style()
984	readyMpvAfterSettingsKnown()
985end