utils.lua 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907
  1. --[[ UI specific utilities that might or might not depend on its state or options ]]
  2. ---@alias Point {x: number; y: number}
  3. ---@alias Rect {ax: number, ay: number, bx: number, by: number, window_drag?: boolean}
  4. ---@alias Circle {point: Point, r: number, window_drag?: boolean}
  5. ---@alias Hitbox Rect|Circle
  6. --- In place sorting of filenames
  7. ---@param filenames string[]
  8. -- String sorting
  9. do
  10. ----- winapi start -----
  11. -- in windows system, we can use the sorting function provided by the win32 API
  12. -- see https://learn.microsoft.com/en-us/windows/win32/api/shlwapi/nf-shlwapi-strcmplogicalw
  13. -- this function was taken from https://github.com/mpvnet-player/mpv.net/issues/575#issuecomment-1817413401
  14. local winapi = nil
  15. if state.platform == 'windows' and config.refine.sorting then
  16. -- is_ffi_loaded is false usually means the mpv builds without luajit
  17. local is_ffi_loaded, ffi = pcall(require, 'ffi')
  18. if is_ffi_loaded then
  19. winapi = {
  20. ffi = ffi,
  21. C = ffi.C,
  22. CP_UTF8 = 65001,
  23. shlwapi = ffi.load('shlwapi'),
  24. }
  25. -- ffi code from https://github.com/po5/thumbfast, Mozilla Public License Version 2.0
  26. ffi.cdef [[
  27. int __stdcall MultiByteToWideChar(unsigned int CodePage, unsigned long dwFlags, const char *lpMultiByteStr,
  28. int cbMultiByte, wchar_t *lpWideCharStr, int cchWideChar);
  29. int __stdcall StrCmpLogicalW(wchar_t *psz1, wchar_t *psz2);
  30. ]]
  31. winapi.utf8_to_wide = function(utf8_str)
  32. if utf8_str then
  33. local utf16_len = winapi.C.MultiByteToWideChar(winapi.CP_UTF8, 0, utf8_str, -1, nil, 0)
  34. if utf16_len > 0 then
  35. local utf16_str = winapi.ffi.new('wchar_t[?]', utf16_len)
  36. if winapi.C.MultiByteToWideChar(winapi.CP_UTF8, 0, utf8_str, -1, utf16_str, utf16_len) > 0 then
  37. return utf16_str
  38. end
  39. end
  40. end
  41. return ''
  42. end
  43. end
  44. end
  45. ----- winapi end -----
  46. -- alphanum sorting for humans in Lua
  47. -- http://notebook.kulchenko.com/algorithms/alphanumeric-natural-sorting-for-humans-in-lua
  48. local function padnum(n, d)
  49. return #d > 0 and ('%03d%s%.12f'):format(#n, n, tonumber(d) / (10 ^ #d))
  50. or ('%03d%s'):format(#n, n)
  51. end
  52. local function sort_lua(strings)
  53. local tuples = {}
  54. for i, f in ipairs(strings) do
  55. tuples[i] = {f:lower():gsub('0*(%d+)%.?(%d*)', padnum), f}
  56. end
  57. table.sort(tuples, function(a, b)
  58. return a[1] == b[1] and #b[2] < #a[2] or a[1] < b[1]
  59. end)
  60. for i, tuple in ipairs(tuples) do strings[i] = tuple[2] end
  61. return strings
  62. end
  63. ---@param strings string[]
  64. function sort_strings(strings)
  65. if winapi then
  66. table.sort(strings, function(a, b)
  67. return winapi.shlwapi.StrCmpLogicalW(winapi.utf8_to_wide(a), winapi.utf8_to_wide(b)) == -1
  68. end)
  69. else
  70. sort_lua(strings)
  71. end
  72. end
  73. end
  74. -- Creates in-between frames to animate value from `from` to `to` numbers.
  75. ---@param from number
  76. ---@param to number|fun():number
  77. ---@param setter fun(value: number)
  78. ---@param duration_or_callback? number|fun() Duration in milliseconds or a callback function.
  79. ---@param callback? fun() Called either on animation end, or when animation is killed.
  80. function tween(from, to, setter, duration_or_callback, callback)
  81. local duration = duration_or_callback
  82. if type(duration_or_callback) == 'function' then callback = duration_or_callback end
  83. if type(duration) ~= 'number' then duration = options.animation_duration end
  84. local current, done, timeout = from, false, nil
  85. local get_to = type(to) == 'function' and to or function() return to --[[@as number]] end
  86. local distance = math.abs(get_to() - current)
  87. local cutoff = distance * 0.01
  88. local target_ticks = (math.max(duration, 1) / (state.render_delay * 1000))
  89. local decay = 1 - ((cutoff / distance) ^ (1 / target_ticks))
  90. local function finish()
  91. if not done then
  92. setter(get_to())
  93. done = true
  94. timeout:kill()
  95. if callback then callback() end
  96. request_render()
  97. end
  98. end
  99. local function tick()
  100. local to = get_to()
  101. current = current + ((to - current) * decay)
  102. local is_end = math.abs(to - current) <= cutoff
  103. if is_end then
  104. finish()
  105. else
  106. setter(current)
  107. timeout:resume()
  108. request_render()
  109. end
  110. end
  111. timeout = mp.add_timeout(state.render_delay, tick)
  112. if cutoff > 0 then tick() else finish() end
  113. return finish
  114. end
  115. ---@param point Point
  116. ---@param rect Rect
  117. function get_point_to_rectangle_proximity(point, rect)
  118. local dx = math.max(rect.ax - point.x, 0, point.x - rect.bx)
  119. local dy = math.max(rect.ay - point.y, 0, point.y - rect.by)
  120. return math.sqrt(dx * dx + dy * dy)
  121. end
  122. ---@param point_a Point
  123. ---@param point_b Point
  124. function get_point_to_point_proximity(point_a, point_b)
  125. local dx, dy = point_a.x - point_b.x, point_a.y - point_b.y
  126. return math.sqrt(dx * dx + dy * dy)
  127. end
  128. ---@param point Point
  129. ---@param hitbox Hitbox
  130. function point_collides_with(point, hitbox)
  131. return (hitbox.r and get_point_to_point_proximity(point, hitbox.point) <= hitbox.r) or
  132. (not hitbox.r and get_point_to_rectangle_proximity(point, hitbox --[[@as Rect]]) == 0)
  133. end
  134. ---@param lax number
  135. ---@param lay number
  136. ---@param lbx number
  137. ---@param lby number
  138. ---@param max number
  139. ---@param may number
  140. ---@param mbx number
  141. ---@param mby number
  142. function get_line_to_line_intersection(lax, lay, lbx, lby, max, may, mbx, mby)
  143. -- Calculate the direction of the lines
  144. local uA = ((mbx - max) * (lay - may) - (mby - may) * (lax - max)) /
  145. ((mby - may) * (lbx - lax) - (mbx - max) * (lby - lay))
  146. local uB = ((lbx - lax) * (lay - may) - (lby - lay) * (lax - max)) /
  147. ((mby - may) * (lbx - lax) - (mbx - max) * (lby - lay))
  148. -- If uA and uB are between 0-1, lines are colliding
  149. if uA >= 0 and uA <= 1 and uB >= 0 and uB <= 1 then
  150. return lax + (uA * (lbx - lax)), lay + (uA * (lby - lay))
  151. end
  152. return nil, nil
  153. end
  154. -- Returns distance from the start of a finite ray assumed to be at (rax, ray)
  155. -- coordinates to a line.
  156. ---@param rax number
  157. ---@param ray number
  158. ---@param rbx number
  159. ---@param rby number
  160. ---@param lax number
  161. ---@param lay number
  162. ---@param lbx number
  163. ---@param lby number
  164. function get_ray_to_line_distance(rax, ray, rbx, rby, lax, lay, lbx, lby)
  165. local x, y = get_line_to_line_intersection(rax, ray, rbx, rby, lax, lay, lbx, lby)
  166. if x then
  167. return math.sqrt((rax - x) ^ 2 + (ray - y) ^ 2)
  168. end
  169. return nil
  170. end
  171. -- Returns distance from the start of a finite ray assumed to be at (ax, ay)
  172. -- coordinates to a rectangle. Returns `0` if ray originates inside rectangle.
  173. ---@param ax number
  174. ---@param ay number
  175. ---@param bx number
  176. ---@param by number
  177. ---@param rect Rect
  178. ---@return number|nil
  179. function get_ray_to_rectangle_distance(ax, ay, bx, by, rect)
  180. -- Is inside
  181. if ax >= rect.ax and ax <= rect.bx and ay >= rect.ay and ay <= rect.by then
  182. return 0
  183. end
  184. local closest = nil
  185. local function updateDistance(distance)
  186. if distance and (not closest or distance < closest) then closest = distance end
  187. end
  188. updateDistance(get_ray_to_line_distance(ax, ay, bx, by, rect.ax, rect.ay, rect.bx, rect.ay))
  189. updateDistance(get_ray_to_line_distance(ax, ay, bx, by, rect.bx, rect.ay, rect.bx, rect.by))
  190. updateDistance(get_ray_to_line_distance(ax, ay, bx, by, rect.ax, rect.by, rect.bx, rect.by))
  191. updateDistance(get_ray_to_line_distance(ax, ay, bx, by, rect.ax, rect.ay, rect.ax, rect.by))
  192. return closest
  193. end
  194. -- Call function with args if it exists
  195. function call_maybe(fn, ...)
  196. if type(fn) == 'function' then fn(...) end
  197. end
  198. -- Extracts the properties used by property expansion of that string.
  199. ---@param str string
  200. ---@param res { [string] : boolean } | nil
  201. ---@return { [string] : boolean }
  202. function get_expansion_props(str, res)
  203. res = res or {}
  204. for str in str:gmatch('%$(%b{})') do
  205. local name, str = str:match('^{[?!]?=?([^:]+):?(.*)}$')
  206. if name then
  207. local s = name:find('==') or nil
  208. if s then name = name:sub(0, s - 1) end
  209. res[name] = true
  210. if str and str ~= '' then get_expansion_props(str, res) end
  211. end
  212. end
  213. return res
  214. end
  215. -- Escape a string for verbatim display on the OSD.
  216. ---@param str string
  217. function ass_escape(str)
  218. -- There is no escape for '\' in ASS (I think?) but '\' is used verbatim if
  219. -- it isn't followed by a recognized character, so add a zero-width
  220. -- non-breaking space
  221. str = str:gsub('\\', '\\\239\187\191')
  222. str = str:gsub('{', '\\{')
  223. str = str:gsub('}', '\\}')
  224. -- Precede newlines with a ZWNBSP to prevent ASS's weird collapsing of
  225. -- consecutive newlines
  226. str = str:gsub('\n', '\239\187\191\\N')
  227. -- Turn leading spaces into hard spaces to prevent ASS from stripping them
  228. str = str:gsub('\\N ', '\\N\\h')
  229. str = str:gsub('^ ', '\\h')
  230. return str
  231. end
  232. ---@param seconds number
  233. ---@param max_seconds number|nil Trims unnecessary `00:` if time is not expected to reach it.
  234. ---@return string
  235. function format_time(seconds, max_seconds)
  236. local human = mp.format_time(seconds)
  237. if options.time_precision > 0 then
  238. local formatted = string.format('%.' .. options.time_precision .. 'f', math.abs(seconds) % 1)
  239. human = human .. '.' .. string.sub(formatted, 3)
  240. end
  241. if max_seconds then
  242. local trim_length = (max_seconds < 60 and 7 or (max_seconds < 3600 and 4 or 0))
  243. if trim_length > 0 then
  244. local has_minus = seconds < 0
  245. human = string.sub(human, trim_length + (has_minus and 1 or 0))
  246. if has_minus then human = '-' .. human end
  247. end
  248. end
  249. return human
  250. end
  251. ---@param opacity number 0-1
  252. function opacity_to_alpha(opacity)
  253. return 255 - math.ceil(255 * opacity)
  254. end
  255. path_separator = (function()
  256. local os_separator = state.platform == 'windows' and '\\' or '/'
  257. -- Get appropriate path separator for the given path.
  258. ---@param path string
  259. ---@return string
  260. return function(path)
  261. return path:sub(1, 2) == '\\\\' and '\\' or os_separator
  262. end
  263. end)()
  264. -- Joins paths with the OS aware path separator or UNC separator.
  265. ---@param p1 string
  266. ---@param p2 string
  267. ---@return string
  268. function join_path(p1, p2)
  269. local p1, separator = trim_trailing_separator(p1)
  270. -- Prevents joining drive letters with a redundant separator (`C:\\foo`),
  271. -- as `trim_trailing_separator()` doesn't trim separators from drive letters.
  272. return p1:sub(#p1) == separator and p1 .. p2 or p1 .. separator .. p2
  273. end
  274. -- Check if path is absolute.
  275. ---@param path string
  276. ---@return boolean
  277. function is_absolute(path)
  278. if path:sub(1, 2) == '\\\\' then
  279. return true
  280. elseif state.platform == 'windows' then
  281. return path:find('^%a+:') ~= nil
  282. else
  283. return path:sub(1, 1) == '/'
  284. end
  285. end
  286. -- Ensure path is absolute.
  287. ---@param path string
  288. ---@return string
  289. function ensure_absolute(path)
  290. if is_absolute(path) then return path end
  291. return join_path(state.cwd, path)
  292. end
  293. -- Remove trailing slashes/backslashes.
  294. ---@param path string
  295. ---@return string path, string trimmed_separator_type
  296. function trim_trailing_separator(path)
  297. local separator = path_separator(path)
  298. path = trim_end(path, separator)
  299. if state.platform == 'windows' then
  300. -- Drive letters on windows need trailing backslash
  301. if path:sub(#path) == ':' then path = path .. '\\' end
  302. else
  303. if path == '' then path = '/' end
  304. end
  305. return path, separator
  306. end
  307. -- Ensures path is absolute, remove trailing slashes/backslashes.
  308. -- Lightweight version of normalize_path for performance critical parts.
  309. ---@param path string
  310. ---@return string
  311. function normalize_path_lite(path)
  312. if not path or is_protocol(path) then return path end
  313. path = trim_trailing_separator(ensure_absolute(path))
  314. return path
  315. end
  316. -- Ensures path is absolute, remove trailing slashes/backslashes, normalization of path separators and deduplication.
  317. ---@param path string
  318. ---@return string
  319. function normalize_path(path)
  320. if not path or is_protocol(path) then return path end
  321. path = ensure_absolute(path)
  322. local is_unc = path:sub(1, 2) == '\\\\'
  323. if state.platform == 'windows' or is_unc then path = path:gsub('/', '\\') end
  324. path = trim_trailing_separator(path)
  325. --Deduplication of path separators
  326. if is_unc then
  327. path = path:gsub('(.\\)\\+', '%1')
  328. elseif state.platform == 'windows' then
  329. path = path:gsub('\\\\+', '\\')
  330. else
  331. path = path:gsub('//+', '/')
  332. end
  333. return path
  334. end
  335. -- Check if path is a protocol, such as `http://...`.
  336. ---@param path string
  337. function is_protocol(path)
  338. return type(path) == 'string' and (path:find('^%a[%w.+-]-://') ~= nil or path:find('^%a[%w.+-]-:%?') ~= nil)
  339. end
  340. ---@param path string
  341. ---@param extensions string[] Lowercase extensions without the dot.
  342. function has_any_extension(path, extensions)
  343. local path_last_dot_index = string_last_index_of(path, '.')
  344. if not path_last_dot_index then return false end
  345. local path_extension = path:sub(path_last_dot_index + 1):lower()
  346. for _, extension in ipairs(extensions) do
  347. if path_extension == extension then return true end
  348. end
  349. return false
  350. end
  351. ---@return string
  352. function get_default_directory()
  353. return mp.command_native({'expand-path', options.default_directory})
  354. end
  355. -- Serializes path into its semantic parts.
  356. ---@param path string
  357. ---@return nil|{path: string; is_root: boolean; dirname?: string; basename: string; filename: string; extension?: string;}
  358. function serialize_path(path)
  359. if not path or is_protocol(path) then return end
  360. local normal_path = normalize_path_lite(path)
  361. local dirname, basename = utils.split_path(normal_path)
  362. if basename == '' then basename, dirname = dirname:sub(1, #dirname - 1), nil end
  363. local dot_i = string_last_index_of(basename, '.')
  364. return {
  365. path = normal_path,
  366. is_root = dirname == nil,
  367. dirname = dirname,
  368. basename = basename,
  369. filename = dot_i and basename:sub(1, dot_i - 1) or basename,
  370. extension = dot_i and basename:sub(dot_i + 1) or nil,
  371. }
  372. end
  373. -- Reads items in directory and splits it into directories and files tables.
  374. ---@param path string
  375. ---@param opts? {types?: string[], hidden?: boolean}
  376. ---@return string[]|nil files
  377. ---@return string[]|nil directories
  378. function read_directory(path, opts)
  379. opts = opts or {}
  380. local items, error = utils.readdir(path, 'all')
  381. if not items then
  382. msg.error('Reading files from "' .. path .. '" failed: ' .. error)
  383. return nil, nil
  384. end
  385. local files, directories = {}, {}
  386. for _, item in ipairs(items) do
  387. if item ~= '.' and item ~= '..' and (opts.hidden or item:sub(1, 1) ~= '.') then
  388. local info = utils.file_info(join_path(path, item))
  389. if info then
  390. if info.is_file then
  391. if not opts.types or has_any_extension(item, opts.types) then
  392. files[#files + 1] = item
  393. end
  394. else
  395. directories[#directories + 1] = item
  396. end
  397. end
  398. end
  399. end
  400. return files, directories
  401. end
  402. -- Returns full absolute paths of files in the same directory as `file_path`,
  403. -- and index of the current file in the table.
  404. -- Returned table will always contain `file_path`, regardless of `allowed_types`.
  405. ---@param file_path string
  406. ---@param opts? {types?: string[], hidden?: boolean}
  407. function get_adjacent_files(file_path, opts)
  408. opts = opts or {}
  409. local current_meta = serialize_path(file_path)
  410. if not current_meta then return end
  411. local files = read_directory(current_meta.dirname, {hidden = opts.hidden})
  412. if not files then return end
  413. sort_strings(files)
  414. local current_file_index
  415. local paths = {}
  416. for _, file in ipairs(files) do
  417. local is_current_file = current_meta.basename == file
  418. if is_current_file or not opts.types or has_any_extension(file, opts.types) then
  419. paths[#paths + 1] = join_path(current_meta.dirname, file)
  420. if is_current_file then current_file_index = #paths end
  421. end
  422. end
  423. if not current_file_index then return end
  424. return paths, current_file_index
  425. end
  426. -- Navigates in a list, using delta or, when `state.shuffle` is enabled,
  427. -- randomness to determine the next item. Loops around if `loop-playlist` is enabled.
  428. ---@param paths table
  429. ---@param current_index number
  430. ---@param delta number 1 or -1 for forward or backward
  431. function decide_navigation_in_list(paths, current_index, delta)
  432. if #paths < 2 then return end
  433. delta = delta < 0 and -1 or 1
  434. -- Shuffle looks at the played files history trimmed to 80% length of the paths
  435. -- and removes all paths in it from the potential shuffle pool. This guarantees
  436. -- no path repetition until at least 80% of the playlist has been exhausted.
  437. if state.shuffle then
  438. state.shuffle_history = state.shuffle_history or {
  439. pos = #state.history,
  440. paths = itable_slice(state.history),
  441. }
  442. state.shuffle_history.pos = state.shuffle_history.pos + delta
  443. local history_path = state.shuffle_history.paths[state.shuffle_history.pos]
  444. local next_index = history_path and itable_index_of(paths, history_path)
  445. if next_index then
  446. return next_index, history_path
  447. end
  448. if delta < 0 then
  449. state.shuffle_history.pos = state.shuffle_history.pos - delta
  450. else
  451. state.shuffle_history.pos = math.min(state.shuffle_history.pos, #state.shuffle_history.paths + 1)
  452. end
  453. local trimmed_history = itable_slice(state.history, -math.floor(#paths * 0.8))
  454. local shuffle_pool = {}
  455. for index, value in ipairs(paths) do
  456. if not itable_has(trimmed_history, value) then
  457. shuffle_pool[#shuffle_pool + 1] = index
  458. end
  459. end
  460. math.randomseed(os.time())
  461. local next_index = shuffle_pool[math.random(#shuffle_pool)]
  462. local next_path = paths[next_index]
  463. table.insert(state.shuffle_history.paths, state.shuffle_history.pos, next_path)
  464. return next_index, next_path
  465. end
  466. local new_index = current_index + delta
  467. if mp.get_property_native('loop-playlist') then
  468. if new_index > #paths then
  469. new_index = new_index % #paths
  470. elseif new_index < 1 then
  471. new_index = #paths - new_index
  472. end
  473. elseif new_index < 1 or new_index > #paths then
  474. return
  475. end
  476. return new_index, paths[new_index]
  477. end
  478. ---@param delta number
  479. function navigate_directory(delta)
  480. if not state.path or is_protocol(state.path) then return false end
  481. local paths, current_index = get_adjacent_files(state.path, {
  482. types = config.types.autoload,
  483. hidden = options.show_hidden_files,
  484. })
  485. if paths and current_index then
  486. local _, path = decide_navigation_in_list(paths, current_index, delta)
  487. if path then
  488. mp.commandv('loadfile', path)
  489. return true
  490. end
  491. end
  492. return false
  493. end
  494. ---@param delta number
  495. function navigate_playlist(delta)
  496. local playlist, pos = mp.get_property_native('playlist'), mp.get_property_native('playlist-pos-1')
  497. if playlist and #playlist > 1 and pos then
  498. local paths = itable_map(playlist, function(item) return normalize_path(item.filename) end)
  499. local index = decide_navigation_in_list(paths, pos, delta)
  500. if index then
  501. mp.commandv('playlist-play-index', index - 1)
  502. return true
  503. end
  504. end
  505. return false
  506. end
  507. ---@param delta number
  508. function navigate_item(delta)
  509. if state.has_playlist then return navigate_playlist(delta) else return navigate_directory(delta) end
  510. end
  511. -- Can't use `os.remove()` as it fails on paths with unicode characters.
  512. -- Returns `result, error`, result is table of:
  513. -- `status:number(<0=error), stdout, stderr, error_string, killed_by_us:boolean`
  514. ---@param path string
  515. function delete_file(path)
  516. if state.platform == 'windows' then
  517. if options.use_trash then
  518. local ps_code = [[
  519. Add-Type -AssemblyName Microsoft.VisualBasic
  520. [Microsoft.VisualBasic.FileIO.FileSystem]::DeleteFile('__path__', 'OnlyErrorDialogs', 'SendToRecycleBin')
  521. ]]
  522. local escaped_path = string.gsub(path, "'", "''")
  523. escaped_path = string.gsub(escaped_path, '’', '’’')
  524. escaped_path = string.gsub(escaped_path, '%%', '%%%%')
  525. ps_code = string.gsub(ps_code, '__path__', escaped_path)
  526. args = {'powershell', '-NoProfile', '-Command', ps_code}
  527. else
  528. args = {'cmd', '/C', 'del', path}
  529. end
  530. else
  531. if options.use_trash then
  532. --On Linux and Macos the app trash-cli/trash must be installed first.
  533. args = {'trash', path}
  534. else
  535. args = {'rm', path}
  536. end
  537. end
  538. return mp.command_native({
  539. name = 'subprocess',
  540. args = args,
  541. playback_only = false,
  542. capture_stdout = true,
  543. capture_stderr = true,
  544. })
  545. end
  546. function delete_file_navigate(delta)
  547. local path, playlist_pos = state.path, state.playlist_pos
  548. local is_local_file = path and not is_protocol(path)
  549. if navigate_item(delta) then
  550. if state.has_playlist then
  551. mp.commandv('playlist-remove', playlist_pos - 1)
  552. end
  553. else
  554. mp.command('stop')
  555. end
  556. if is_local_file then
  557. if Menu:is_open('open-file') then
  558. Elements:maybe('menu', 'delete_value', path)
  559. end
  560. delete_file(path)
  561. end
  562. end
  563. function serialize_chapter_ranges(normalized_chapters)
  564. local ranges = {}
  565. local simple_ranges = {
  566. {
  567. name = 'openings',
  568. patterns = {
  569. '^op ', '^op$', ' op$',
  570. '^opening$', ' opening$',
  571. },
  572. requires_next_chapter = true,
  573. },
  574. {
  575. name = 'intros',
  576. patterns = {
  577. '^intro$', ' intro$',
  578. '^avant$', '^prologue$',
  579. },
  580. requires_next_chapter = true,
  581. },
  582. {
  583. name = 'endings',
  584. patterns = {
  585. '^ed ', '^ed$', ' ed$',
  586. '^ending ', '^ending$', ' ending$',
  587. },
  588. },
  589. {
  590. name = 'outros',
  591. patterns = {
  592. '^outro$', ' outro$',
  593. '^closing$', '^closing ',
  594. '^preview$', '^pv$',
  595. },
  596. },
  597. }
  598. local sponsor_ranges = {}
  599. -- Extend with alt patterns
  600. for _, meta in ipairs(simple_ranges) do
  601. local alt_patterns = config.chapter_ranges[meta.name] and config.chapter_ranges[meta.name].patterns
  602. if alt_patterns then meta.patterns = itable_join(meta.patterns, alt_patterns) end
  603. end
  604. -- Clone chapters
  605. local chapters = {}
  606. for i, normalized in ipairs(normalized_chapters) do chapters[i] = table_assign({}, normalized) end
  607. for i, chapter in ipairs(chapters) do
  608. -- Simple ranges
  609. for _, meta in ipairs(simple_ranges) do
  610. if config.chapter_ranges[meta.name] then
  611. local match = itable_find(meta.patterns, function(p) return chapter.lowercase_title:find(p) end)
  612. if match then
  613. local next_chapter = chapters[i + 1]
  614. if next_chapter or not meta.requires_next_chapter then
  615. ranges[#ranges + 1] = table_assign({
  616. start = chapter.time,
  617. ['end'] = next_chapter and next_chapter.time or math.huge,
  618. }, config.chapter_ranges[meta.name])
  619. end
  620. end
  621. end
  622. end
  623. -- Sponsor blocks
  624. if config.chapter_ranges.ads then
  625. local id = chapter.lowercase_title:match('segment start *%(([%w]%w-)%)')
  626. if id then -- ad range from sponsorblock
  627. for j = i + 1, #chapters, 1 do
  628. local end_chapter = chapters[j]
  629. local end_match = end_chapter.lowercase_title:match('segment end *%(' .. id .. '%)')
  630. if end_match then
  631. local range = table_assign({
  632. start_chapter = chapter,
  633. end_chapter = end_chapter,
  634. start = chapter.time,
  635. ['end'] = end_chapter.time,
  636. }, config.chapter_ranges.ads)
  637. ranges[#ranges + 1], sponsor_ranges[#sponsor_ranges + 1] = range, range
  638. end_chapter.is_end_only = true
  639. break
  640. end
  641. end -- single chapter for ad
  642. elseif not chapter.is_end_only and
  643. (chapter.lowercase_title:find('%[sponsorblock%]:') or chapter.lowercase_title:find('^sponsors?')) then
  644. local next_chapter = chapters[i + 1]
  645. ranges[#ranges + 1] = table_assign({
  646. start = chapter.time,
  647. ['end'] = next_chapter and next_chapter.time or math.huge,
  648. }, config.chapter_ranges.ads)
  649. end
  650. end
  651. end
  652. -- Fix overlapping sponsor block segments
  653. for index, range in ipairs(sponsor_ranges) do
  654. local next_range = sponsor_ranges[index + 1]
  655. if next_range then
  656. local delta = next_range.start - range['end']
  657. if delta < 0 then
  658. local mid_point = range['end'] + delta / 2
  659. range['end'], range.end_chapter.time = mid_point - 0.01, mid_point - 0.01
  660. next_range.start, next_range.start_chapter.time = mid_point, mid_point
  661. end
  662. end
  663. end
  664. table.sort(chapters, function(a, b) return a.time < b.time end)
  665. return chapters, ranges
  666. end
  667. -- Ensures chapters are in chronological order
  668. function normalize_chapters(chapters)
  669. if not chapters then return {} end
  670. -- Ensure chronological order
  671. table.sort(chapters, function(a, b) return a.time < b.time end)
  672. -- Ensure titles
  673. for index, chapter in ipairs(chapters) do
  674. local chapter_number = chapter.title and string.match(chapter.title, '^Chapter (%d+)$')
  675. if chapter_number then
  676. chapter.title = t('Chapter %s', tonumber(chapter_number))
  677. end
  678. chapter.title = chapter.title ~= '(unnamed)' and chapter.title ~= '' and chapter.title or t('Chapter %s', index)
  679. chapter.lowercase_title = chapter.title:lower()
  680. end
  681. return chapters
  682. end
  683. function serialize_chapters(chapters)
  684. chapters = normalize_chapters(chapters)
  685. if not chapters then return end
  686. --- timeline font size isn't accessible here, so normalize to size 1 and then scale during rendering
  687. local opts = {size = 1, bold = true}
  688. for index, chapter in ipairs(chapters) do
  689. chapter.index = index
  690. chapter.title_wrapped, chapter.title_lines = wrap_text(chapter.title, opts, 25)
  691. chapter.title_wrapped_width = text_width(chapter.title_wrapped, opts)
  692. chapter.title_wrapped = ass_escape(chapter.title_wrapped)
  693. end
  694. return chapters
  695. end
  696. ---Find all active key bindings or the active key binding for key
  697. ---@param key string|nil
  698. ---@return {[string]: table}|table
  699. function find_active_keybindings(key)
  700. local bindings = mp.get_property_native('input-bindings', {})
  701. local active = {} -- map: key-name -> bind-info
  702. for _, bind in pairs(bindings) do
  703. if bind.owner ~= 'uosc' and bind.priority >= 0 and (not key or bind.key == key) and (
  704. not active[bind.key]
  705. or (active[bind.key].is_weak and not bind.is_weak)
  706. or (bind.is_weak == active[bind.key].is_weak and bind.priority > active[bind.key].priority)
  707. )
  708. then
  709. active[bind.key] = bind
  710. end
  711. end
  712. return not key and active or active[key]
  713. end
  714. ---@param type 'sub'|'audio'|'video'
  715. ---@param path string
  716. function load_track(type, path)
  717. mp.commandv(type .. '-add', path, 'cached')
  718. -- If subtitle track was loaded, assume the user also wants to see it
  719. if type == 'sub' then
  720. mp.commandv('set', 'sub-visibility', 'yes')
  721. end
  722. end
  723. ---@return string|nil
  724. function get_clipboard()
  725. local result = mp.command_native({
  726. name = 'subprocess',
  727. capture_stderr = true,
  728. capture_stdout = true,
  729. playback_only = false,
  730. args = {config.ziggy_path, 'get-clipboard'},
  731. })
  732. local function print_error(message)
  733. msg.error('Getting clipboard data failed. Error: ' .. message)
  734. end
  735. if result.status == 0 then
  736. local data = utils.parse_json(result.stdout)
  737. if data and data.payload then
  738. return data.payload
  739. else
  740. print_error(data and (data.error and data.message or 'unknown error') or 'couldn\'t parse json')
  741. end
  742. else
  743. print_error('exit code ' .. result.status .. ': ' .. result.stdout .. result.stderr)
  744. end
  745. end
  746. --[[ RENDERING ]]
  747. function render()
  748. if not display.initialized then return end
  749. state.render_last_time = mp.get_time()
  750. cursor:clear_zones()
  751. -- Click on empty area detection
  752. if setup_click_detection then setup_click_detection() end
  753. -- Actual rendering
  754. local ass = assdraw.ass_new()
  755. -- Idle indicator
  756. if state.is_idle and not Manager.disabled.idle_indicator then
  757. local smaller_side = math.min(display.width, display.height)
  758. local center_x, center_y, icon_size = display.width / 2, display.height / 2, math.max(smaller_side / 4, 56)
  759. ass:icon(center_x, center_y - icon_size / 4, icon_size, 'not_started', {
  760. color = fg, opacity = config.opacity.idle_indicator,
  761. })
  762. ass:txt(center_x, center_y + icon_size / 2, 8, t('Drop files or URLs to play here'), {
  763. size = icon_size / 4, color = fg, opacity = config.opacity.idle_indicator,
  764. })
  765. end
  766. -- Audio indicator
  767. if state.is_audio and not state.has_image and not Manager.disabled.audio_indicator
  768. and not (state.pause and options.pause_indicator == 'static') then
  769. local smaller_side = math.min(display.width, display.height)
  770. ass:icon(display.width / 2, display.height / 2, smaller_side / 4, 'graphic_eq', {
  771. color = fg, opacity = config.opacity.audio_indicator,
  772. })
  773. end
  774. -- Elements
  775. for _, element in Elements:ipairs() do
  776. if element.enabled then
  777. local result = element:maybe('render')
  778. if result then
  779. ass:new_event()
  780. ass:merge(result)
  781. end
  782. end
  783. end
  784. cursor:decide_keybinds()
  785. -- submit
  786. if osd.res_x == display.width and osd.res_y == display.height and osd.data == ass.text then
  787. return
  788. end
  789. osd.res_x = display.width
  790. osd.res_y = display.height
  791. osd.data = ass.text
  792. osd.z = 2000
  793. osd:update()
  794. update_margins()
  795. end
  796. -- Request that render() is called.
  797. -- The render is then either executed immediately, or rate-limited if it was
  798. -- called a small time ago.
  799. state.render_timer = mp.add_timeout(0, render)
  800. state.render_timer:kill()
  801. function request_render()
  802. if state.render_timer:is_enabled() then return end
  803. local timeout = math.max(0, state.render_delay - (mp.get_time() - state.render_last_time))
  804. state.render_timer.timeout = timeout
  805. state.render_timer:resume()
  806. end