local settings = {

	-- #### FUNCTIONALITY SETTINGS

	-- navigation keybindings force override only while playlist is visible
	-- if "no" then you can display the playlist by any of the navigation keys
	dynamic_binds = true,

	-- to bind multiple keys separate them by a space
	key_moveup = "UP",
	key_movedown = "DOWN",
	key_selectfile = "RIGHT LEFT",
	key_unselectfile = "",
	key_playfile = "ENTER",
	key_removefile = "BS",
	key_closeplaylist = "ESC",

	-- replaces matches on filenames based on extension, put as empty string to not replace anything
	-- replace rules are executed in provided order
	-- replace rule key is the pattern and value is the replace value
	-- uses :gsub('pattern', 'replace'), read more http://lua-users.org/wiki/StringLibraryTutorial
	-- 'all' will match any extension or protocol if it has one
	-- uses json and parses it into a lua table to be able to support .conf file

	filename_replace = "",

	--[=====[ START OF SAMPLE REPLACE, to use remove start and end line
  --Sample replace: replaces underscore to space on all files
  --for mp4 and webm; remove extension, remove brackets and surrounding whitespace, change dot between alphanumeric to space
  filename_replace = [[
    [
      {
        "ext": { "all": true},
        "rules": [
          { "_" : " " }
        ]
      },{
        "ext": { "mp4": true, "mkv": true },
        "rules": [
          { "^(.+)%..+$": "%1" },
          { "%s*[%[%(].-[%]%)]%s*": "" },
          { "(%w)%.(%w)": "%1 %2" }
        ]
      },{
        "protocol": { "http": true, "https": true },
        "rules": [
          { "^%a+://w*%.?": "" }
        ]
      }
    ]
  ]],
--END OF SAMPLE REPLACE ]=====]

	-- json array of filetypes to search from directory
	loadfiles_filetypes = [[
    [
      "jpg", "jpeg", "png", "tif", "tiff", "gif", "webp", "svg", "bmp",
      "mp3", "wav", "ogm", "flac", "m4a", "wma", "ogg", "opus",
      "mkv", "avi", "mp4", "ogv", "webm", "rmvb", "flv", "wmv", "mpeg", "mpg", "m4v", "3gp"
    ]
  ]],

	-- loadfiles at startup if there is 0 or 1 items in playlist, if 0 uses worḱing dir for files
	loadfiles_on_start = false,

	-- sort playlist on mpv start
	sortplaylist_on_start = false,

	-- sort playlist when files are added to playlist
	sortplaylist_on_file_add = false,

	-- use alphanumerical sort
	alphanumsort = true,

	-- "linux | windows | auto"
	system = "auto",

	-- Use ~ for home directory. Leave as empty to use mpv/playlists
	playlist_savepath = "",

	-- show playlist or filename every time a new file is loaded
	-- 2 shows playlist, 1 shows current file(filename strip applied) as osd text, 0 shows nothing
	-- instead of using this you can also call script-message playlistmanager show playlist/filename
	-- ex. KEY playlist-next ; script-message playlistmanager show playlist
	show_playlist_on_fileload = 0,

	-- sync cursor when file is loaded from outside reasons(file-ending, playlist-next shortcut etc.)
	-- has the sideeffect of moving cursor if file happens to change when navigating
	-- good side is cursor always following current file when going back and forth files with playlist-next/prev
	sync_cursor_on_load = true,

	-- playlist open key will toggle visibility instead of refresh, best used with long timeout
	open_toggles = true,

	-- allow the playlist cursor to loop from end to start and vice versa
	loop_cursor = true,

	-- ####  VISUAL SETTINGS

	-- prefer to display titles for following files: "all", "url", "none". Sorting still uses filename.
	prefer_titles = "url",

	-- call youtube-dl to resolve the titles of urls in the playlist
	resolve_titles = false,

	-- osd timeout on inactivity, with high value on this open_toggles is good to be true
	playlist_display_timeout = 5,

	-- amount of entries to show before slicing. Optimal value depends on font/video size etc.
	showamount = 16,

	-- font size scales by window, if false requires larger font and padding sizes
	scale_playlist_by_window = true,
	-- playlist ass style overrides inside curly brackets, \keyvalue is one field, extra \ for escape in lua
	-- example {\\fnUbuntu\\fs10\\b0\\bord1} equals: font=Ubuntu, size=10, bold=no, border=1
	-- read http://docs.aegisub.org/3.2/ASS_Tags/ for reference of tags
	-- undeclared tags will use default osd settings
	-- these styles will be used for the whole playlist
	style_ass_tags = "{}",
	-- paddings from top left corner
	text_padding_x = 10,
	text_padding_y = 30,

	-- set title of window with stripped name
	set_title_stripped = false,
	title_prefix = "",
	title_suffix = " - mpv",

	-- slice long filenames, and how many chars to show
	slice_longfilenames = false,
	slice_longfilenames_amount = 70,

	-- Playlist header template
	-- %mediatitle or %filename = title or name of playing file
	-- %pos = position of playing file
	-- %cursor = position of navigation
	-- %plen = playlist length
	-- %N = newline
	playlist_header = "[%cursor/%plen]",

	-- Playlist file templates
	-- %pos = position of file with leading zeros
	-- %name = title or name of file
	-- %N = newline
	-- you can also use the ass tags mentioned above. For example:
	--  selected_file="{\\c&HFF00FF&}➔ %name"   | to add a color for selected file. However, if you
	--  use ass tags you need to reset them for every line (see https://github.com/jonniek/mpv-playlistmanager/issues/20)
	normal_file = "○ %name",
	hovered_file = "● %name",
	selected_file = "➔ %name",
	playing_file = "▷ %name",
	playing_hovered_file = "▶ %name",
	playing_selected_file = "➤ %name",

	-- what to show when playlist is truncated
	playlist_sliced_prefix = "...",
	playlist_sliced_suffix = "...",
}
local opts = require("mp.options")
opts.read_options(settings, "playlistmanager", function(list)
	update_opts(list)
end)

local utils = require("mp.utils")
local msg = require("mp.msg")
local assdraw = require("mp.assdraw")

-- check os
if settings.system == "auto" then
	local o = {}
	if mp.get_property_native("options/vo-mmcss-profile", o) ~= o then
		settings.system = "windows"
	else
		settings.system = "linux"
	end
end

-- global variables
local playlist_visible = false
local strippedname = nil
local path = nil
local directory = nil
local filename = nil
local pos = 0
local plen = 0
local cursor = 0
-- table for saved media titles for later if we prefer them
local url_table = {}
-- table for urls that we have request to be resolved to titles
local requested_urls = {}
-- state for if we sort on playlist size change
local sort_watching = false

local filetype_lookup = {}

function update_opts(changelog)
	msg.verbose("updating options")

	-- parse filename json
	if changelog.filename_replace then
		if settings.filename_replace ~= "" then
			settings.filename_replace = utils.parse_json(settings.filename_replace)
		else
			settings.filename_replace = false
		end
	end

	-- parse loadfiles json
	if changelog.loadfiles_filetypes then
		settings.loadfiles_filetypes = utils.parse_json(settings.loadfiles_filetypes)

		filetype_lookup = {}
		-- create loadfiles set
		for _, ext in ipairs(settings.loadfiles_filetypes) do
			filetype_lookup[ext] = true
		end
	end

	if changelog.resolve_titles then
		resolve_titles()
	end

	if changelog.playlist_display_timeout then
		keybindstimer = mp.add_periodic_timer(settings.playlist_display_timeout, remove_keybinds)
		keybindstimer:kill()
	end

	if playlist_visible then
		showplaylist()
	end
end

update_opts({ filename_replace = true, loadfiles_filetypes = true })

function on_loaded()
	filename = mp.get_property("filename")
	path = mp.get_property("path")
	-- if not a url then join path with working directory
	if not path:match("^%a%a+:%/%/") then
		path = utils.join_path(mp.get_property("working-directory"), path)
		directory = utils.split_path(path)
	else
		directory = nil
	end

	refresh_globals()
	if settings.sync_cursor_on_load then
		cursor = pos
		-- refresh playlist if cursor moved
		if playlist_visible then
			draw_playlist()
		end
	end

	local media_title = mp.get_property("media-title")
	if path:match("^https?://") and not url_table[path] and path ~= media_title then
		url_table[path] = media_title
	end

	strippedname = stripfilename(mp.get_property("media-title"))
	if settings.show_playlist_on_fileload == 2 then
		showplaylist()
	elseif settings.show_playlist_on_fileload == 1 then
		mp.commandv("show-text", strippedname)
	end
	if settings.set_title_stripped then
		mp.set_property("title", settings.title_prefix .. strippedname .. settings.title_suffix)
	end

	local didload = false
	if settings.loadfiles_on_start and plen == 1 then
		didload = true -- save reference for sorting
		msg.info("Loading files from playing files directory")
		playlist()
	end

	-- if we promised to sort files on launch do it
	if promised_sort then
		promised_sort = false
		msg.info("Your playlist is sorted before starting playback")
		if didload then
			sortplaylist()
		else
			sortplaylist(true)
		end
	end

	-- if we promised to listen and sort on playlist size increase do it
	if promised_sort_watch then
		promised_sort_watch = false
		sort_watching = true
		msg.info("Added files will be automatically sorted")
		mp.observe_property("playlist-count", "number", autosort)
	end
end

function on_closed()
	strippedname = nil
	path = nil
	directory = nil
	filename = nil
	if playlist_visible then
		showplaylist()
	end
end

function refresh_globals()
	pos = mp.get_property_number("playlist-pos", 0)
	plen = mp.get_property_number("playlist-count", 0)
end

function escapepath(dir, escapechar)
	return string.gsub(dir, escapechar, "\\" .. escapechar)
end

-- strip a filename based on its extension or protocol according to rules in settings
function stripfilename(pathfile, media_title)
	if pathfile == nil then
		return ""
	end
	local ext = pathfile:match("^.+%.(.+)$")
	local protocol = pathfile:match("^(%a%a+)://")
	if not ext then
		ext = ""
	end
	local tmp = pathfile
	if settings.filename_replace and not media_title then
		for k, v in ipairs(settings.filename_replace) do
			if
				(v["ext"] and (v["ext"][ext] or (ext and not protocol and v["ext"]["all"])))
				or (v["protocol"] and (v["protocol"][protocol] or (protocol and not ext and v["protocol"]["all"])))
			then
				for ruleindex, indexrules in ipairs(v["rules"]) do
					for rule, override in pairs(indexrules) do
						tmp = tmp:gsub(rule, override)
					end
				end
			end
		end
	end
	if settings.slice_longfilenames and tmp:len() > settings.slice_longfilenames_amount + 5 then
		tmp = tmp:sub(1, settings.slice_longfilenames_amount) .. " ..."
	end
	return tmp
end

-- gets a nicename of playlist entry at 0-based position i
function get_name_from_index(i, notitle)
	refresh_globals()
	if plen <= i then
		msg.error("no index in playlist", i, "length", plen)
		return nil
	end
	local _, name = nil
	local title = mp.get_property("playlist/" .. i .. "/title")
	local name = mp.get_property("playlist/" .. i .. "/filename")

	local should_use_title = settings.prefer_titles == "all"
		or name:match("^https?://") and settings.prefer_titles == "url"
	-- check if file has a media title stored or as property
	if not title and should_use_title then
		local mtitle = mp.get_property("media-title")
		if i == pos and mp.get_property("filename") ~= mtitle then
			if not url_table[name] then
				url_table[name] = mtitle
			end
			title = mtitle
		elseif url_table[name] then
			title = url_table[name]
		end
	end

	-- if we have media title use a more conservative strip
	if title and not notitle and should_use_title then
		return stripfilename(title, true)
	end

	-- remove paths if they exist, keeping protocols for stripping
	if string.sub(name, 1, 1) == "/" or name:match("^%a:[/\\]") then
		_, name = utils.split_path(name)
	end
	return stripfilename(name)
end

function parse_header(string)
	local esc_title = stripfilename(mp.get_property("media-title"), true):gsub("%%", "%%%%")
	local esc_file = stripfilename(mp.get_property("filename")):gsub("%%", "%%%%")
	return string
		:gsub("%%N", "\\N")
		:gsub("%%pos", mp.get_property_number("playlist-pos", 0) + 1)
		:gsub("%%plen", mp.get_property("playlist-count"))
		:gsub("%%cursor", cursor + 1)
		:gsub("%%mediatitle", esc_title)
		:gsub("%%filename", esc_file) -- undo name escape
		:gsub("%%%%", "%%")
end

function parse_filename(string, name, index)
	local base = tostring(plen):len()
	local esc_name = stripfilename(name):gsub("%%", "%%%%")
	return string
		:gsub("%%N", "\\N")
		:gsub("%%pos", string.format("%0" .. base .. "d", index + 1))
		:gsub("%%name", esc_name) -- undo name escape
		:gsub("%%%%", "%%")
end

function parse_filename_by_index(index)
	local template = settings.normal_file

	local is_idle = mp.get_property_native("idle-active")
	local position = is_idle and -1 or pos

	if index == position then
		if index == cursor then
			if selection then
				template = settings.playing_selected_file
			else
				template = settings.playing_hovered_file
			end
		else
			template = settings.playing_file
		end
	elseif index == cursor then
		if selection then
			template = settings.selected_file
		else
			template = settings.hovered_file
		end
	end

	return parse_filename(template, get_name_from_index(index), index)
end

function draw_playlist()
	refresh_globals()
	local ass = assdraw.ass_new()
	ass:new_event()
	ass:pos(settings.text_padding_x, settings.text_padding_y)
	ass:append(settings.style_ass_tags)

	if settings.playlist_header ~= "" then
		ass:append(parse_header(settings.playlist_header) .. "\\N")
	end
	local start = cursor - math.floor(settings.showamount / 2)
	local showall = false
	local showrest = false
	if start < 0 then
		start = 0
	end
	if plen <= settings.showamount then
		start = 0
		showall = true
	end
	if start > math.max(plen - settings.showamount - 1, 0) then
		start = plen - settings.showamount
		showrest = true
	end
	if start > 0 and not showall then
		ass:append(settings.playlist_sliced_prefix .. "\\N")
	end
	for index = start, start + settings.showamount - 1, 1 do
		if index == plen then
			break
		end

		ass:append(parse_filename_by_index(index) .. "\\N")
		if index == start + settings.showamount - 1 and not showall and not showrest then
			ass:append(settings.playlist_sliced_suffix)
		end
	end
	local w, h = mp.get_osd_size()
	if settings.scale_playlist_by_window then
		w, h = 0, 0
	end
	mp.set_osd_ass(w, h, ass.text)
end

function toggle_playlist()
	if settings.open_toggles then
		if playlist_visible then
			remove_keybinds()
			return
		end
	end
	showplaylist()
end

function showplaylist(duration)
	refresh_globals()
	if plen == 0 then
		return
	end
	playlist_visible = true
	add_keybinds()

	draw_playlist()
	keybindstimer:kill()
	if duration then
		keybindstimer = mp.add_periodic_timer(duration, remove_keybinds)
	else
		keybindstimer:resume()
	end
end

selection = nil
function selectfile()
	refresh_globals()
	if plen == 0 then
		return
	end
	if not selection then
		selection = cursor
	else
		selection = nil
	end
	showplaylist()
end

function unselectfile()
	selection = nil
	showplaylist()
end

function removefile()
	refresh_globals()
	if plen == 0 then
		return
	end
	selection = nil
	if cursor == pos then
		mp.command('script-message unseenplaylist mark true "playlistmanager avoid conflict when removing file"')
	end
	mp.commandv("playlist-remove", cursor)
	if cursor == plen - 1 then
		cursor = cursor - 1
	end
	showplaylist()
end

function moveup()
	refresh_globals()
	if plen == 0 then
		return
	end
	if cursor ~= 0 then
		if selection then
			mp.commandv("playlist-move", cursor, cursor - 1)
		end
		cursor = cursor - 1
	elseif settings.loop_cursor then
		if selection then
			mp.commandv("playlist-move", cursor, plen)
		end
		cursor = plen - 1
	end
	showplaylist()
end

function movedown()
	refresh_globals()
	if plen == 0 then
		return
	end
	if cursor ~= plen - 1 then
		if selection then
			mp.commandv("playlist-move", cursor, cursor + 2)
		end
		cursor = cursor + 1
	elseif settings.loop_cursor then
		if selection then
			mp.commandv("playlist-move", cursor, 0)
		end
		cursor = 0
	end
	showplaylist()
end

function Watch_later()
	if mp.get_property_bool("save-position-on-quit") then
		mp.command("write-watch-later-config")
	end
end

function playfile()
	refresh_globals()
	if plen == 0 then
		return
	end
	selection = nil
	local is_idle = mp.get_property_native("idle-active")
	if cursor ~= pos or is_idle then
		mp.set_property("playlist-pos", cursor)
	else
		if cursor ~= plen - 1 then
			cursor = cursor + 1
		end
		Watch_later()
		mp.commandv("playlist-next", "weak")
	end
	if settings.show_playlist_on_fileload ~= 2 then
		remove_keybinds()
	end
end

function get_files_windows(dir)
	local args = {
		"powershell",
		"-NoProfile",
		"-Command",
		[[& {
          Trap {
              Write-Error -ErrorRecord $_
              Exit 1
          }
          $path = "]]
			.. dir
			.. [["
          $escapedPath = [WildcardPattern]::Escape($path)
          cd $escapedPath

          $list = (Get-ChildItem -File | Sort-Object { [regex]::Replace($_.Name, '\d+', { $args[0].Value.PadLeft(20) }) }).Name
          $string = ($list -join "/")
          $u8list = [System.Text.Encoding]::UTF8.GetBytes($string)
          [Console]::OpenStandardOutput().Write($u8list, 0, $u8list.Length)
      }]],
	}
	local process = utils.subprocess({ args = args, cancellable = false })
	return parse_files(process, "%/")
end

function get_files_linux(dir)
	local args = { "ls", "-1pv", dir }
	local process = utils.subprocess({ args = args, cancellable = false })
	return parse_files(process, "\n")
end

function parse_files(res, delimiter)
	if not res.error and res.status == 0 then
		local valid_files = {}
		for line in res.stdout:gmatch("[^" .. delimiter .. "]+") do
			local ext = line:match("^.+%.(.+)$")
			if ext and filetype_lookup[ext:lower()] then
				table.insert(valid_files, line)
			end
		end
		return valid_files, nil
	else
		return nil, res.error
	end
end

-- Creates a playlist of all files in directory, will keep the order and position
-- For exaple, Folder has 12 files, you open the 5th file and run this, the remaining 7 are added behind the 5th file and prior 4 files before it
function playlist(force_dir)
	refresh_globals()
	if not directory and plen > 0 then
		return
	end
	local hasfile = true
	if plen == 0 then
		hasfile = false
		dir = mp.get_property("working-directory")
	else
		dir = directory
	end
	if force_dir then
		dir = force_dir
	end

	local files, error
	if settings.system == "linux" then
		files, error = get_files_linux(dir)
	else
		files, error = get_files_windows(dir)
	end

	local c, c2 = 0, 0
	if files then
		local cur = false
		local filename = mp.get_property("filename")
		for _, file in ipairs(files) do
			local appendstr = "append"
			if not hasfile then
				cur = true
				appendstr = "append-play"
				hasfile = true
			end
			if cur == true then
				mp.commandv("loadfile", utils.join_path(dir, file), appendstr)
				msg.info("Appended to playlist: " .. file)
				c2 = c2 + 1
			elseif file ~= filename then
				mp.commandv("loadfile", utils.join_path(dir, file), appendstr)
				msg.info("Prepended to playlist: " .. file)
				mp.commandv("playlist-move", mp.get_property_number("playlist-count", 1) - 1, c)
				c = c + 1
			else
				cur = true
			end
		end
		if c2 > 0 or c > 0 then
			mp.osd_message("Added " .. c + c2 .. " files to playlist")
		else
			mp.osd_message("No additional files found")
		end
		cursor = mp.get_property_number("playlist-pos", 1)
	else
		msg.error("Could not scan for files: " .. (error or ""))
	end
	if sort_watching then
		msg.info("Ignoring directory structure and using playlist sort")
		sortplaylist()
	end
	refresh_globals()
	if playlist_visible then
		showplaylist()
	end
	return c + c2
end

function parse_home(path)
	if not path:find("^~") then
		return path
	end
	local home_dir = os.getenv("HOME") or os.getenv("USERPROFILE")
	if not home_dir then
		local drive = os.getenv("HOMEDRIVE")
		local path = os.getenv("HOMEPATH")
		if drive and path then
			home_dir = utils.join_path(drive, path)
		else
			msg.error("Couldn't find home dir.")
			return nil
		end
	end
	local result = path:gsub("^~", home_dir)
	return result
end

-- saves the current playlist into a m3u file
function save_playlist()
	local length = mp.get_property_number("playlist-count", 0)
	if length == 0 then
		return
	end

	-- get playlist save path
	local savepath
	if settings.playlist_savepath == nil or settings.playlist_savepath == "" then
		savepath = mp.command_native({ "expand-path", "~~home/" }) .. "/playlists"
	else
		savepath = parse_home(settings.playlist_savepath)
		if savepath == nil then
			return
		end
	end

	-- create savepath if it doesn't exist
	if utils.readdir(savepath) == nil then
		local windows_args = {
			"powershell",
			"-NoProfile",
			"-Command",
			"mkdir",
			savepath,
		}
		local unix_args = { "mkdir", savepath }
		local args = settings.system == "windows" and windows_args or unix_args
		local res = utils.subprocess({ args = args, cancellable = false })
		if res.status ~= 0 then
			msg.error(
				"Failed to create playlist save directory " .. savepath .. ". Error: " .. (res.error or "unknown")
			)
			return
		end
	end

	local date = os.date("*t")
	local datestring = ("%02d-%02d-%02d_%02d-%02d-%02d"):format(
		date.year,
		date.month,
		date.day,
		date.hour,
		date.min,
		date.sec
	)

	local savepath = utils.join_path(savepath, datestring .. "_playlist-size_" .. length .. ".m3u")
	local file, err = io.open(savepath, "w")
	if not file then
		msg.error("Error in creating playlist file, check permissions. Error: " .. (err or "unknown"))
	else
		local i = 0
		while i < length do
			local pwd = mp.get_property("working-directory")
			local filename = mp.get_property("playlist/" .. i .. "/filename")
			local fullpath = filename
			if not filename:match("^%a%a+:%/%/") then
				fullpath = utils.join_path(pwd, filename)
			end
			local title = mp.get_property("playlist/" .. i .. "/title")
			if title then
				file:write("#EXTINF:," .. title .. "\n")
			end
			file:write(fullpath, "\n")
			i = i + 1
		end
		msg.info("Playlist written to: " .. savepath)
		file:close()
	end
end

function alphanumsort(a, b)
	local function padnum(d)
		local dec, n = string.match(d, "(%.?)0*(.+)")
		return #dec > 0 and ("%.12f"):format(d) or ("%s%03d%s"):format(dec, #n, n)
	end
	return tostring(a):lower():gsub("%.?%d+", padnum) .. ("%3d"):format(#b)
		< tostring(b):lower():gsub("%.?%d+", padnum) .. ("%3d"):format(#a)
end

function dosort(a, b)
	if settings.alphanumsort then
		return alphanumsort(a, b)
	else
		return a < b
	end
end

function sortplaylist(startover)
	local length = mp.get_property_number("playlist-count", 0)
	if length < 2 then
		return
	end
	-- use insertion sort on playlist to make it easy to order files with playlist-move
	for outer = 1, length - 1, 1 do
		local outerfile = get_name_from_index(outer, true)
		local inner = outer - 1
		while inner >= 0 and dosort(outerfile, get_name_from_index(inner, true)) do
			inner = inner - 1
		end
		inner = inner + 1
		if outer ~= inner then
			mp.commandv("playlist-move", outer, inner)
		end
	end
	cursor = mp.get_property_number("playlist-pos", 0)
	if startover then
		mp.set_property("playlist-pos", 0)
	end
	if playlist_visible then
		showplaylist()
	end
end

function autosort(name, param)
	if param == 0 then
		return
	end
	if plen < param then
		msg.info("Playlistmanager autosorting playlist")
		refresh_globals()
		sortplaylist()
	end
end

function reverseplaylist()
	local length = mp.get_property_number("playlist-count", 0)
	if length < 2 then
		return
	end
	for outer = 1, length - 1, 1 do
		mp.commandv("playlist-move", outer, 0)
	end
	if playlist_visible then
		showplaylist()
	end
end

function shuffleplaylist()
	refresh_globals()
	if plen < 2 then
		return
	end
	mp.command("playlist-shuffle")
	math.randomseed(os.time())
	mp.commandv("playlist-move", pos, math.random(0, plen - 1))
	mp.set_property("playlist-pos", 0)
	refresh_globals()
	if playlist_visible then
		showplaylist()
	end
end

function bind_keys(keys, name, func, opts)
	if not keys then
		mp.add_forced_key_binding(keys, name, func, opts)
		return
	end
	local i = 1
	for key in keys:gmatch("[^%s]+") do
		local prefix = i == 1 and "" or i
		mp.add_forced_key_binding(key, name .. prefix, func, opts)
		i = i + 1
	end
end

function unbind_keys(keys, name)
	if not keys then
		mp.remove_key_binding(name)
		return
	end
	local i = 1
	for key in keys:gmatch("[^%s]+") do
		local prefix = i == 1 and "" or i
		mp.remove_key_binding(name .. prefix)
		i = i + 1
	end
end

function add_keybinds()
	bind_keys(settings.key_moveup, "moveup", moveup, "repeatable")
	bind_keys(settings.key_movedown, "movedown", movedown, "repeatable")
	bind_keys(settings.key_selectfile, "selectfile", selectfile)
	bind_keys(settings.key_unselectfile, "unselectfile", unselectfile)
	bind_keys(settings.key_playfile, "playfile", playfile)
	bind_keys(settings.key_removefile, "removefile", removefile, "repeatable")
	bind_keys(settings.key_closeplaylist, "closeplaylist", remove_keybinds)
end

function remove_keybinds()
	keybindstimer:kill()
	keybindstimer = mp.add_periodic_timer(settings.playlist_display_timeout, remove_keybinds)
	keybindstimer:kill()
	mp.set_osd_ass(0, 0, "")
	playlist_visible = false
	if settings.dynamic_binds then
		unbind_keys(settings.key_moveup, "moveup")
		unbind_keys(settings.key_movedown, "movedown")
		unbind_keys(settings.key_selectfile, "selectfile")
		unbind_keys(settings.key_unselectfile, "unselectfile")
		unbind_keys(settings.key_playfile, "playfile")
		unbind_keys(settings.key_removefile, "removefile")
		unbind_keys(settings.key_closeplaylist, "closeplaylist")
	end
end

keybindstimer = mp.add_periodic_timer(settings.playlist_display_timeout, remove_keybinds)
keybindstimer:kill()

if not settings.dynamic_binds then
	add_keybinds()
end

if settings.loadfiles_on_start and mp.get_property_number("playlist-count", 0) == 0 then
	playlist()
end

promised_sort_watch = false
if settings.sortplaylist_on_file_add then
	promised_sort_watch = true
end

promised_sort = false
if settings.sortplaylist_on_start then
	promised_sort = true
end

mp.observe_property("playlist-count", "number", function()
	if playlist_visible then
		showplaylist()
	end
	if settings.prefer_titles == "none" then
		return
	end
	-- resolve titles
	resolve_titles()
end)

-- resolves url titles by calling youtube-dl
function resolve_titles()
	if not settings.resolve_titles then
		return
	end
	local length = mp.get_property_number("playlist-count", 0)
	if length < 2 then
		return
	end
	local i = 0
	-- loop all items in playlist because we can't predict how it has changed
	while i < length do
		local filename = mp.get_property("playlist/" .. i .. "/filename")
		local title = mp.get_property("playlist/" .. i .. "/title")
		if
			i ~= pos
			and filename
			and filename:match("^https?://")
			and not title
			and not url_table[filename]
			and not requested_urls[filename]
		then
			requested_urls[filename] = true

			local args = {
				"youtube-dl",
				"--no-playlist",
				"--flat-playlist",
				"-sJ",
				filename,
			}
			local req = mp.command_native_async({
				name = "subprocess",
				args = args,
				playback_only = false,
				capture_stdout = true,
			}, function(success, res)
				if res.killed_by_us then
					msg.verbose("Request to resolve url title " .. filename .. " timed out")
					return
				end
				if res.status == 0 then
					local json, err = utils.parse_json(res.stdout)
					if not err then
						local is_playlist = json["_type"] and json["_type"] == "playlist"
						local title = (is_playlist and "[playlist]: " or "") .. json["title"]
						msg.verbose(filename .. " resolved to '" .. title .. "'")
						url_table[filename] = title
						refresh_globals()
						if playlist_visible then
							showplaylist()
						end
						return
					else
						msg.error("Failed parsing json, reason: " .. (err or "unknown"))
					end
				else
					msg.error("Failed to resolve url title " .. filename .. " Error: " .. (res.error or "unknown"))
				end
			end)

			mp.add_timeout(5, function()
				mp.abort_async_command(req)
			end)
		end
		i = i + 1
	end
end

-- script message handler
function handlemessage(msg, value, value2)
	if msg == "show" and value == "playlist" then
		if value2 ~= "toggle" then
			showplaylist(value2)
			return
		else
			toggle_playlist()
			return
		end
	end
	if msg == "show" and value == "filename" and strippedname and value2 then
		mp.commandv("show-text", strippedname, tonumber(value2) * 1000)
		return
	end
	if msg == "show" and value == "filename" and strippedname then
		mp.commandv("show-text", strippedname)
		return
	end
	if msg == "sort" then
		sortplaylist(value)
		return
	end
	if msg == "shuffle" then
		shuffleplaylist()
		return
	end
	if msg == "reverse" then
		reverseplaylist()
		return
	end
	if msg == "loadfiles" then
		playlist(value)
		return
	end
	if msg == "save" then
		save_playlist()
		return
	end
end

mp.register_script_message("playlistmanager", handlemessage)

mp.add_key_binding("CTRL+p", "sortplaylist", sortplaylist)
mp.add_key_binding("CTRL+P", "shuffleplaylist", shuffleplaylist)
mp.add_key_binding("CTRL+R", "reverseplaylist", reverseplaylist)
mp.add_key_binding("P", "loadfiles", playlist)
mp.add_key_binding("p", "saveplaylist", save_playlist)
mp.add_key_binding("SHIFT+ENTER", "showplaylist", toggle_playlist)

mp.register_event("file-loaded", on_loaded)
mp.register_event("end-file", on_closed)
