├── .gitmodules ├── README.md ├── concat.json ├── concat_tool.py ├── src ├── license_blurb.lua ├── main.lua └── version.tmpl.lua ├── version.txt └── youtube-search.lua /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "lib"] 2 | path = lib 3 | url = https://github.com/TheAMM/mpv_script_libs 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # !!! VERY BETA !!! 2 | 3 | # youtube-search 4 | A userscript for MPV that allows you to search for videos on youtube. Search results are placed into the playlist. Right now it loads 50 search results, in the future this will be configurable. 5 | 6 | Bound to / In the future this will be configurable. 7 | Triggers [mpv-gallery-view](https://github.com/occivink/mpv-gallery-view), if available after the search results are loaded. 8 | 9 | ## Plans For Future Enhancement 10 | - [ ] Many. 11 | 12 | ## Credit 13 | - This was made possible by TheAMM's [mpv_scripts_libs](https://github.com/TheAMM/mpv_script_libs/blob/master/input_tools.lua) which implements the text entry dialog 14 | -------------------------------------------------------------------------------- /concat.json: -------------------------------------------------------------------------------- 1 | { 2 | "output": "youtube-search.lua", 3 | "files": [ 4 | "src/license_blurb.lua", 5 | "", 6 | "lib/helpers.lua", 7 | "lib/text_measurer.lua", 8 | "lib/input_tools.lua", 9 | "src/main.lua"], 10 | 11 | "version_file" : "version.txt", 12 | "version_template_file" : "src/version.tmpl.lua", 13 | 14 | "header_prefix" : "--[ ", 15 | "header_suffix" : " ]--"} 16 | -------------------------------------------------------------------------------- /concat_tool.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import re 3 | import os 4 | import time 5 | import json 6 | import binascii 7 | 8 | import argparse 9 | import hashlib 10 | import subprocess 11 | import datetime 12 | 13 | 14 | parser = argparse.ArgumentParser(description="Concatenate files to a target file, optionally writing target-changes back to source files") 15 | 16 | parser.add_argument('config_file', metavar='CONFIG_FILE', help='Configuration file for concat') 17 | parser.add_argument('-o', '--output', metavar='OUTPUT', help='Override output filename') 18 | parser.add_argument('-w', '--watch', action='store_true', help='Watch files for any changes and map them between the target and source files') 19 | parser.add_argument('-r', '--release', action='store_true', help='Build a version without the section dividers') 20 | 21 | 22 | class FileWatcher(object): 23 | def __init__(self, file_list=[]): 24 | self.file_list = file_list 25 | self._mtimes = self._get_mtimes() 26 | 27 | def _get_mtimes(self): 28 | return { filename : os.path.getmtime(filename) for filename in self.file_list if os.path.exists(filename) } 29 | 30 | def get_changes(self): 31 | mtimes = self._get_mtimes() 32 | changes = [ filename for filename in self.file_list if self._mtimes.get(filename, 0) < mtimes.get(filename, 0) ] 33 | self._mtimes.update(mtimes) 34 | return changes 35 | 36 | 37 | class FileSection(object): 38 | def __init__(self, filename, content, modified): 39 | self.filename = filename 40 | self.content = content 41 | self.modified = modified 42 | self.hash = None 43 | 44 | self.old_hash = None 45 | 46 | if self.content: 47 | self.recalculate_hash() 48 | 49 | def __repr__(self): 50 | hash_part = binascii.hexlify(self.hash).decode()[:7] 51 | if self.old_hash: 52 | hash_part += ' (' + binascii.hexlify(self.old_hash).decode()[:7] + ')' 53 | 54 | return '<{} \'{}\' {}b {}>'.format(self.__class__.__name__, self.filename, len(self.content), hash_part) 55 | 56 | def recalculate_hash(self): 57 | self.hash = hashlib.sha256(self.content.encode('utf-8')).digest() 58 | return self.hash 59 | 60 | @classmethod 61 | def from_file(cls, filename): 62 | modified_time = os.path.getmtime(filename) 63 | with open(filename, 'r') as in_file: 64 | content = in_file.read() 65 | 66 | return cls(filename=filename, 67 | content=content, 68 | modified=modified_time) 69 | # hash=hash) 70 | 71 | 72 | class Concatter(object): 73 | SECTION_REGEX_BASE = r'FileConcat-([SE]) (.+?) HASH:(.+?)' 74 | SECTION_HEADER_FORMAT_BASE = 'FileConcat-{kind} {filename} HASH:{hash}' 75 | def __init__(self, config, working_directory=''): 76 | self.output_filename = config.get('output', 'output.txt') 77 | self.file_list = config.get('files', []) 78 | self.section_header_prefix = config.get('header_prefix', '') 79 | self.section_header_suffix = config.get('header_suffix', '') 80 | 81 | self.prefixes_and_suffixes = {} 82 | for i, file_data in enumerate(self.file_list): 83 | if isinstance(file_data, (list, tuple)): 84 | prefix, filename, suffix = file_data 85 | if not suffix.endswith('\n'): 86 | suffix += '\n' 87 | 88 | self.prefixes_and_suffixes[filename] = (prefix, suffix) 89 | self.file_list[i] = file_data[1] 90 | 91 | self.working_directory = working_directory 92 | 93 | self.version_metafile = None 94 | 95 | self.newline = '\n' 96 | 97 | self.section_header_format = self.section_header_prefix + self.SECTION_HEADER_FORMAT_BASE + self.section_header_suffix 98 | self.section_header_regex = re.compile(r'^' + re.escape(self.section_header_prefix) + self.SECTION_REGEX_BASE + re.escape(self.section_header_suffix) + r'$') 99 | 100 | def split_output_file(self): 101 | file_sections = [] 102 | if not os.path.exists(self.output_filename): 103 | return file_sections 104 | 105 | modified_time = os.path.getmtime(self.output_filename) 106 | with open(self.output_filename, 'r') as in_file: 107 | current_section = None 108 | section_lines = [] 109 | 110 | for line in in_file: 111 | header_match = self.section_header_regex.match(line) 112 | if header_match: 113 | is_start = header_match.group(1) == 'S' 114 | section_filename = header_match.group(2) 115 | section_hash = binascii.unhexlify(header_match.group(3)) 116 | 117 | if is_start and current_section is None: 118 | current_section = FileSection(section_filename, None, modified_time) 119 | elif not is_start and current_section: 120 | section_content = ''.join(section_lines) 121 | 122 | if section_filename in self.prefixes_and_suffixes: 123 | prefix, suffix = self.prefixes_and_suffixes[section_filename] 124 | if section_content.startswith(prefix) and section_content.endswith(suffix): 125 | section_content = section_content[len(prefix):len(section_content)-len(suffix)] 126 | else: 127 | print("!!! Ignoring section '{}' in target file with bad prefix and/or suffix".format(section_filename)) 128 | current_section = None 129 | 130 | if current_section: 131 | current_section.content = section_content 132 | current_section.recalculate_hash() 133 | current_section.old_hash = section_hash 134 | 135 | file_sections.append(current_section) 136 | 137 | current_section = None 138 | section_lines = [] 139 | else: 140 | section_lines.append(line) 141 | 142 | if current_section is not None: 143 | raise Exception('Missing file end marker! For ' + current_section.filename) 144 | 145 | return file_sections 146 | 147 | 148 | def read_source_files(self): 149 | file_sections = [] 150 | 151 | for filename in self.file_list: 152 | # The version metafile 153 | if filename == '': 154 | file_section = self.version_metafile 155 | else: 156 | file_path = os.path.join(self.working_directory, filename) 157 | if not os.path.exists(file_path): 158 | raise Exception("File '{}' is missing!".format(file_path)) 159 | 160 | file_section = FileSection.from_file(file_path) 161 | file_section.filename = filename 162 | 163 | if not file_section.content.endswith(self.newline): 164 | file_section.content += self.newline 165 | file_section.recalculate_hash() 166 | 167 | file_sections.append(file_section) 168 | return file_sections 169 | 170 | 171 | def concatenate_file_sections(self, file_sections, insert_section_headers=True): 172 | with open(self.output_filename, 'w', newline='\n') as out_file: 173 | for file_section in file_sections: 174 | content = file_section.content 175 | if file_section.filename in self.prefixes_and_suffixes: 176 | prefix, suffix = self.prefixes_and_suffixes[file_section.filename] 177 | content = prefix + content + suffix 178 | if insert_section_headers: 179 | section_hash = binascii.hexlify(file_section.recalculate_hash()).decode() 180 | 181 | out_file.write(self.section_header_format.format(kind='S', filename=file_section.filename, hash=section_hash) + self.newline) 182 | out_file.write(content) 183 | out_file.write(self.section_header_format.format(kind='E', filename=file_section.filename, hash=section_hash) + self.newline) 184 | else: 185 | out_file.write(content) 186 | 187 | 188 | def write_file_sections_back(self, file_sections): 189 | for file_section in file_sections: 190 | # Skip version metafile 191 | if file_section is self.version_metafile: continue 192 | 193 | file_path = os.path.join(self.working_directory, file_section.filename) 194 | 195 | # Backup target file if it exists 196 | if os.path.exists(file_path): 197 | bak_filename = file_path + '.bak' 198 | if os.path.exists(bak_filename): 199 | os.remove(bak_filename) 200 | os.rename(file_path, bak_filename) 201 | 202 | # Write contents 203 | with open(file_path, 'w', newline='\n') as out_file: 204 | out_file.write(file_section.content) 205 | 206 | 207 | def _map_sections(self, source_sections, target_sections): 208 | target_map = {s.filename: s for s in target_sections} 209 | 210 | source_to_target = [] 211 | target_to_source = [] 212 | 213 | for source_section in source_sections: 214 | if source_section is self.version_metafile: continue 215 | 216 | target_section = target_map.get(source_section.filename) 217 | 218 | if not target_section: 219 | # Target doesn't have this section at all (or is completely empty) 220 | source_to_target.append(source_section) # Write section to target file 221 | else: 222 | source_section.old_hash = target_section.old_hash # Used to check changes on rewrite 223 | source_newer = source_section.modified > target_section.modified 224 | 225 | # Target and source differ 226 | if source_section.hash != target_section.hash: 227 | if source_newer: 228 | # If source file is newer than target, use it 229 | source_to_target.append(source_section) 230 | else: 231 | # Use target section to rewrite target section AND source file 232 | target_section.old_hash = target_section.hash # Hack to skip target rewrite 233 | source_to_target.append(target_section) 234 | target_to_source.append(target_section) 235 | else: 236 | # No change in files so just use the source file 237 | source_to_target.append(source_section) 238 | 239 | return source_to_target, target_to_source 240 | 241 | 242 | def process_changes_in_files(self): 243 | source_sections = self.read_source_files() 244 | target_sections = self.split_output_file() 245 | 246 | source_to_target, target_to_source = self._map_sections(source_sections, target_sections) 247 | 248 | changed_sections = [s for s in source_to_target if s.hash != s.old_hash] 249 | if changed_sections: 250 | self.concatenate_file_sections(source_to_target) 251 | 252 | if target_to_source: 253 | self.write_file_sections_back(target_to_source) 254 | 255 | return changed_sections, target_to_source 256 | 257 | def plain_concat(self): 258 | source_sections = self.read_source_files() 259 | self.concatenate_file_sections(source_sections, False) 260 | 261 | 262 | def _create_version_metafile(config, config_dirname): 263 | repo_dir = os.path.join(config_dirname, config.get('repo_dir', '')) 264 | try: 265 | git_branch = subprocess.check_output(['git', '-C', repo_dir, 'symbolic-ref', '--short', '-q', 'HEAD'], stderr=subprocess.DEVNULL).decode().strip() 266 | git_commit = subprocess.check_output(['git', '-C', repo_dir, 'rev-parse', '--short', '-q', 'HEAD'], stderr=subprocess.DEVNULL).decode().strip() 267 | except: 268 | git_branch = None 269 | git_commit = None 270 | 271 | if not git_branch: 272 | git_branch = 'unknown' 273 | 274 | if git_commit: 275 | git_commit_short = git_commit[:7] 276 | else: 277 | git_commit = git_commit_short = 'unknown' 278 | 279 | project_version_file = config.get('version_file') 280 | if project_version_file: 281 | with open(project_version_file, 'r') as in_file: 282 | project_version = in_file.read().strip() 283 | else: 284 | project_version = 'unknown' 285 | 286 | template_data = { 287 | 'version' : project_version, 288 | 289 | 'branch' : git_branch, 290 | 'commit' : git_commit, 291 | 'commit_short' : git_commit_short, 292 | 293 | 'now' : datetime.datetime.now(), 294 | 'utc_now' : datetime.datetime.utcnow(), 295 | } 296 | 297 | version_template_file = config.get('version_template_file') 298 | if version_template_file: 299 | with open(os.path.join(config_dirname, version_template_file), 'r') as in_file: 300 | version_template = in_file.read() 301 | else: 302 | version_template = '' 303 | 304 | version_metafile = FileSection('', version_template.format(**template_data), 0) 305 | return version_metafile 306 | 307 | 308 | def _print_change_writes(source_to_target, target_to_source): 309 | if source_to_target: 310 | print('SOURCE -> TARGET') 311 | print(source_to_target) 312 | if target_to_source: 313 | print('TARGET -> SOURCE') 314 | print(target_to_source) 315 | if not source_to_target and not target_to_source: 316 | print('No changes.') 317 | 318 | 319 | if __name__ == '__main__': 320 | args = parser.parse_args() 321 | 322 | if not os.path.exists(args.config_file): 323 | print('Unable to find given configuration file \'{}\''.format(args.config_file)) 324 | exit(1) 325 | 326 | try: 327 | with open(args.config_file, 'r') as in_file: 328 | config = json.load(in_file) 329 | except Exception as e: 330 | print('Unable to read given configuration file \'{}\':'.format(args.config_file)) 331 | print(e) 332 | exit(1) 333 | 334 | config_dirname = os.path.dirname(args.config_file) 335 | if args.output: 336 | config['output'] = args.output 337 | else: 338 | # Make output be relative to config file 339 | config['output'] = os.path.join(config_dirname, config['output']) 340 | 341 | concatter = Concatter(config, config_dirname) 342 | concatter.version_metafile = _create_version_metafile(config, config_dirname) 343 | 344 | if not concatter.file_list: 345 | print('No files listed in configuration!') 346 | exit(1) 347 | 348 | if not args.watch: 349 | if args.release: 350 | concatter.plain_concat() 351 | print("Concatenated source files to '{}'".format(concatter.output_filename)) 352 | else: 353 | s2t, t2s = concatter.process_changes_in_files() 354 | _print_change_writes(s2t, t2s) 355 | else: 356 | tracked_files_list = list(concatter.file_list) 357 | tracked_files_list.append(concatter.output_filename) 358 | 359 | file_watcher = FileWatcher(tracked_files_list) 360 | first_concat_done = False 361 | 362 | print('Watching changes for', len(tracked_files_list), 'files...') 363 | while True: 364 | changes = file_watcher.get_changes() 365 | 366 | if changes or not first_concat_done: 367 | print("------------------------", changes) 368 | if args.release: 369 | concatter.plain_concat() 370 | print("Concatenated source files to '{}'".format(concatter.output_filename)) 371 | else: 372 | s2t, t2s = concatter.process_changes_in_files() 373 | _print_change_writes(s2t, t2s) 374 | # Grab new mtimes 375 | file_watcher.get_changes() 376 | 377 | first_concat_done = True 378 | time.sleep(0.25) 379 | -------------------------------------------------------------------------------- /src/license_blurb.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | Copyright (C) 2018 3 | 4 | This program is free software: you can redistribute it and/or modify 5 | it under the terms of the GNU General Public License as published by 6 | the Free Software Foundation, either version 3 of the License, or 7 | (at your option) any later version. 8 | 9 | This program is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU General Public License for more details. 13 | 14 | You should have received a copy of the GNU General Public License 15 | along with this program. If not, see . 16 | ]]-- 17 | -------------------------------------------------------------------------------- /src/main.lua: -------------------------------------------------------------------------------- 1 | local mp = require 'mp' 2 | local msg = require 'mp.msg' 3 | 4 | 5 | local function search() 6 | local search_dialog = NumberInputter() 7 | local screen_w,screen_h,_ = mp.get_osd_size() 8 | 9 | local function tick_callback() 10 | local ass=assdraw.ass_new() 11 | ass:append(search_dialog:get_ass(screen_w,screen_h).text) 12 | mp.set_osd_ass(screen_w,screen_h,ass.text) 13 | end 14 | mp.register_event("tick", tick_callback) 15 | 16 | local function cancel_callback() 17 | search_dialog:stop() 18 | mp.set_osd_ass(screen_w,screen_h,"") 19 | mp.unregister_event(tick_callback) 20 | end 21 | local function callback(e,v) 22 | cancel_callback() 23 | msg.verbose("searching for: "..v) 24 | mp.commandv("loadfile", "ytdl://ytsearch50:"..v) 25 | 26 | local function trigger_gallery(prop,count) 27 | if count > 1 then 28 | msg.verbose("triggering gallery-view") 29 | mp.unobserve_property(trigger_gallery) 30 | mp.commandv("script-message", "gallery-view", "true") 31 | end 32 | end 33 | mp.observe_property("playlist-count", "number", trigger_gallery) 34 | end 35 | 36 | search_dialog:start({{"search","Search Youtube:",nil,"text"}}, callback, cancel_callback) 37 | end 38 | 39 | mp.add_forced_key_binding("/", "youtube-search", search) 40 | -------------------------------------------------------------------------------- /src/version.tmpl.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | mpv_sort_script.lua {version} - commit {commit_short} (branch {branch}) 3 | Built on {utc_now:%Y-%m-%d %H:%M:%S} 4 | ]]-- 5 | -------------------------------------------------------------------------------- /version.txt: -------------------------------------------------------------------------------- 1 | 0.0.1 2 | -------------------------------------------------------------------------------- /youtube-search.lua: -------------------------------------------------------------------------------- 1 | --[ FileConcat-S src/license_blurb.lua HASH:9dcbc85dd77b54786ebc0ef0b7145b6183e4fffc2d527c2832ee9bc51f0f61eb ]-- 2 | --[[ 3 | Copyright (C) 2018 4 | 5 | This program is free software: you can redistribute it and/or modify 6 | it under the terms of the GNU General Public License as published by 7 | the Free Software Foundation, either version 3 of the License, or 8 | (at your option) any later version. 9 | 10 | This program is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU General Public License for more details. 14 | 15 | You should have received a copy of the GNU General Public License 16 | along with this program. If not, see . 17 | ]]-- 18 | --[ FileConcat-E src/license_blurb.lua HASH:9dcbc85dd77b54786ebc0ef0b7145b6183e4fffc2d527c2832ee9bc51f0f61eb ]-- 19 | --[ FileConcat-S lib/helpers.lua HASH:66f7cd869de2dda2fcc45a6af21529b84297f9001b7a26c8654ad096298a54a7 ]-- 20 | --[[ 21 | Assorted helper functions, from checking falsey values to path utils 22 | to escaping and wrapping strings. 23 | 24 | Does not depend on other libs. 25 | ]]-- 26 | 27 | local assdraw = require 'mp.assdraw' 28 | local msg = require 'mp.msg' 29 | local utils = require 'mp.utils' 30 | 31 | -- Determine platform -- 32 | ON_WINDOWS = (package.config:sub(1,1) ~= '/') 33 | 34 | -- Some helper functions needed to parse the options -- 35 | function isempty(v) return (v == false) or (v == nil) or (v == "") or (v == 0) or (type(v) == "table" and next(v) == nil) end 36 | 37 | function divmod (a, b) 38 | return math.floor(a / b), a % b 39 | end 40 | 41 | -- Better modulo 42 | function bmod( i, N ) 43 | return (i % N + N) % N 44 | end 45 | 46 | 47 | -- Path utils 48 | local path_utils = { 49 | abspath = true, 50 | split = true, 51 | dirname = true, 52 | basename = true, 53 | 54 | isabs = true, 55 | normcase = true, 56 | splitdrive = true, 57 | join = true, 58 | normpath = true, 59 | relpath = true, 60 | } 61 | 62 | -- Helpers 63 | path_utils._split_parts = function(path, sep) 64 | local path_parts = {} 65 | for c in path:gmatch('[^' .. sep .. ']+') do table.insert(path_parts, c) end 66 | return path_parts 67 | end 68 | 69 | -- Common functions 70 | path_utils.abspath = function(path) 71 | if not path_utils.isabs(path) then 72 | local cwd = os.getenv("PWD") or utils.getcwd() 73 | path = path_utils.join(cwd, path) 74 | end 75 | return path_utils.normpath(path) 76 | end 77 | 78 | path_utils.split = function(path) 79 | local drive, path = path_utils.splitdrive(path) 80 | -- Technically unix path could contain a \, but meh 81 | local first_index, last_index = path:find('^.*[/\\]') 82 | 83 | if last_index == nil then 84 | return drive .. '', path 85 | else 86 | local head = path:sub(0, last_index-1) 87 | local tail = path:sub(last_index+1) 88 | if head == '' then head = sep end 89 | return drive .. head, tail 90 | end 91 | end 92 | 93 | path_utils.dirname = function(path) 94 | local head, tail = path_utils.split(path) 95 | return head 96 | end 97 | 98 | path_utils.basename = function(path) 99 | local head, tail = path_utils.split(path) 100 | return tail 101 | end 102 | 103 | path_utils.expanduser = function(path) 104 | -- Expands the following from the start of the path: 105 | -- ~ to HOME 106 | -- ~~ to mpv config directory (first result of mp.find_config_file('.')) 107 | -- ~~desktop to Windows desktop, otherwise HOME 108 | -- ~~temp to Windows temp or /tmp/ 109 | 110 | local first_index, last_index = path:find('^.-[/\\]') 111 | local head = path 112 | local tail = '' 113 | 114 | local sep = '' 115 | 116 | if last_index then 117 | head = path:sub(0, last_index-1) 118 | tail = path:sub(last_index+1) 119 | sep = path:sub(last_index, last_index) 120 | end 121 | 122 | if head == "~~desktop" then 123 | head = ON_WINDOWS and path_utils.join(os.getenv('USERPROFILE'), 'Desktop') or os.getenv('HOME') 124 | elseif head == "~~temp" then 125 | head = ON_WINDOWS and os.getenv('TEMP') or (os.getenv('TMP') or '/tmp/') 126 | elseif head == "~~" then 127 | local mpv_config_dir = mp.find_config_file('.') 128 | if mpv_config_dir then 129 | head = path_utils.dirname(mpv_config_dir) 130 | else 131 | msg.warn('Could not find mpv config directory (using mp.find_config_file), using temp instead') 132 | head = ON_WINDOWS and os.getenv('TEMP') or (os.getenv('TMP') or '/tmp/') 133 | end 134 | elseif head == "~" then 135 | head = ON_WINDOWS and os.getenv('USERPROFILE') or os.getenv('HOME') 136 | end 137 | 138 | return path_utils.normpath(path_utils.join(head .. sep, tail)) 139 | end 140 | 141 | 142 | if ON_WINDOWS then 143 | local sep = '\\' 144 | local altsep = '/' 145 | local curdir = '.' 146 | local pardir = '..' 147 | local colon = ':' 148 | 149 | local either_sep = function(c) return c == sep or c == altsep end 150 | 151 | path_utils.isabs = function(path) 152 | local prefix, path = path_utils.splitdrive(path) 153 | return either_sep(path:sub(1,1)) 154 | end 155 | 156 | path_utils.normcase = function(path) 157 | return path:gsub(altsep, sep):lower() 158 | end 159 | 160 | path_utils.splitdrive = function(path) 161 | if #path >= 2 then 162 | local norm = path:gsub(altsep, sep) 163 | if (norm:sub(1, 2) == (sep..sep)) and (norm:sub(3,3) ~= sep) then 164 | -- UNC path 165 | local index = norm:find(sep, 3) 166 | if not index then 167 | return '', path 168 | end 169 | 170 | local index2 = norm:find(sep, index + 1) 171 | if index2 == index + 1 then 172 | return '', path 173 | elseif not index2 then 174 | index2 = path:len() 175 | end 176 | 177 | return path:sub(1, index2-1), path:sub(index2) 178 | elseif norm:sub(2,2) == colon then 179 | return path:sub(1, 2), path:sub(3) 180 | end 181 | end 182 | return '', path 183 | end 184 | 185 | path_utils.join = function(path, ...) 186 | local paths = {...} 187 | 188 | local result_drive, result_path = path_utils.splitdrive(path) 189 | 190 | function inner(p) 191 | local p_drive, p_path = path_utils.splitdrive(p) 192 | if either_sep(p_path:sub(1,1)) then 193 | -- Path is absolute 194 | if p_drive ~= '' or result_drive == '' then 195 | result_drive = p_drive 196 | end 197 | result_path = p_path 198 | return 199 | elseif p_drive ~= '' and p_drive ~= result_drive then 200 | if p_drive:lower() ~= result_drive:lower() then 201 | -- Different paths, ignore first 202 | result_drive = p_drive 203 | result_path = p_path 204 | return 205 | end 206 | end 207 | 208 | if result_path ~= '' and not either_sep(result_path:sub(-1)) then 209 | result_path = result_path .. sep 210 | end 211 | result_path = result_path .. p_path 212 | end 213 | 214 | for i, p in ipairs(paths) do inner(p) end 215 | 216 | -- add separator between UNC and non-absolute path 217 | if result_path ~= '' and not either_sep(result_path:sub(1,1)) and 218 | result_drive ~= '' and result_drive:sub(-1) ~= colon then 219 | return result_drive .. sep .. result_path 220 | end 221 | return result_drive .. result_path 222 | end 223 | 224 | path_utils.normpath = function(path) 225 | if path:find('\\\\.\\', nil, true) == 1 or path:find('\\\\?\\', nil, true) == 1 then 226 | -- Device names and literal paths - return as-is 227 | return path 228 | end 229 | 230 | path = path:gsub(altsep, sep) 231 | local prefix, path = path_utils.splitdrive(path) 232 | 233 | if path:find(sep) == 1 then 234 | prefix = prefix .. sep 235 | path = path:gsub('^[\\]+', '') 236 | end 237 | 238 | local comps = path_utils._split_parts(path, sep) 239 | 240 | local i = 1 241 | while i <= #comps do 242 | if comps[i] == curdir then 243 | table.remove(comps, i) 244 | elseif comps[i] == pardir then 245 | if i > 1 and comps[i-1] ~= pardir then 246 | table.remove(comps, i) 247 | table.remove(comps, i-1) 248 | i = i - 1 249 | elseif i == 1 and prefix:match('\\$') then 250 | table.remove(comps, i) 251 | else 252 | i = i + 1 253 | end 254 | else 255 | i = i + 1 256 | end 257 | end 258 | 259 | if prefix == '' and #comps == 0 then 260 | comps[1] = curdir 261 | end 262 | 263 | return prefix .. table.concat(comps, sep) 264 | end 265 | 266 | path_utils.relpath = function(path, start) 267 | start = start or curdir 268 | 269 | local start_abs = path_utils.abspath(path_utils.normpath(start)) 270 | local path_abs = path_utils.abspath(path_utils.normpath(path)) 271 | 272 | local start_drive, start_rest = path_utils.splitdrive(start_abs) 273 | local path_drive, path_rest = path_utils.splitdrive(path_abs) 274 | 275 | if path_utils.normcase(start_drive) ~= path_utils.normcase(path_drive) then 276 | -- Different drives 277 | return nil 278 | end 279 | 280 | local start_list = path_utils._split_parts(start_rest, sep) 281 | local path_list = path_utils._split_parts(path_rest, sep) 282 | 283 | local i = 1 284 | for j = 1, math.min(#start_list, #path_list) do 285 | if path_utils.normcase(start_list[j]) ~= path_utils.normcase(path_list[j]) then 286 | break 287 | end 288 | i = j + 1 289 | end 290 | 291 | local rel_list = {} 292 | for j = 1, (#start_list - i + 1) do rel_list[j] = pardir end 293 | for j = i, #path_list do table.insert(rel_list, path_list[j]) end 294 | 295 | if #rel_list == 0 then 296 | return curdir 297 | end 298 | 299 | return path_utils.join(unpack(rel_list)) 300 | end 301 | 302 | else 303 | -- LINUX 304 | local sep = '/' 305 | local curdir = '.' 306 | local pardir = '..' 307 | 308 | path_utils.isabs = function(path) return path:sub(1,1) == '/' end 309 | path_utils.normcase = function(path) return path end 310 | path_utils.splitdrive = function(path) return '', path end 311 | 312 | path_utils.join = function(path, ...) 313 | local paths = {...} 314 | 315 | for i, p in ipairs(paths) do 316 | if p:sub(1,1) == sep then 317 | path = p 318 | elseif path == '' or path:sub(-1) == sep then 319 | path = path .. p 320 | else 321 | path = path .. sep .. p 322 | end 323 | end 324 | 325 | return path 326 | end 327 | 328 | path_utils.normpath = function(path) 329 | if path == '' then return curdir end 330 | 331 | local initial_slashes = (path:sub(1,1) == sep) and 1 332 | if initial_slashes and path:sub(2,2) == sep and path:sub(3,3) ~= sep then 333 | initial_slashes = 2 334 | end 335 | 336 | local comps = path_utils._split_parts(path, sep) 337 | local new_comps = {} 338 | 339 | for i, comp in ipairs(comps) do 340 | if comp == '' or comp == curdir then 341 | -- pass 342 | elseif (comp ~= pardir or (not initial_slashes and #new_comps == 0) or 343 | (#new_comps > 0 and new_comps[#new_comps] == pardir)) then 344 | table.insert(new_comps, comp) 345 | elseif #new_comps > 0 then 346 | table.remove(new_comps) 347 | end 348 | end 349 | 350 | comps = new_comps 351 | path = table.concat(comps, sep) 352 | if initial_slashes then 353 | path = sep:rep(initial_slashes) .. path 354 | end 355 | 356 | return (path ~= '') and path or curdir 357 | end 358 | 359 | path_utils.relpath = function(path, start) 360 | start = start or curdir 361 | 362 | local start_abs = path_utils.abspath(path_utils.normpath(start)) 363 | local path_abs = path_utils.abspath(path_utils.normpath(path)) 364 | 365 | local start_list = path_utils._split_parts(start_abs, sep) 366 | local path_list = path_utils._split_parts(path_abs, sep) 367 | 368 | local i = 1 369 | for j = 1, math.min(#start_list, #path_list) do 370 | if start_list[j] ~= path_list[j] then break 371 | end 372 | i = j + 1 373 | end 374 | 375 | local rel_list = {} 376 | for j = 1, (#start_list - i + 1) do rel_list[j] = pardir end 377 | for j = i, #path_list do table.insert(rel_list, path_list[j]) end 378 | 379 | if #rel_list == 0 then 380 | return curdir 381 | end 382 | 383 | return path_utils.join(unpack(rel_list)) 384 | end 385 | 386 | end 387 | -- Path utils end 388 | 389 | -- Check if path is local (by looking if it's prefixed by a proto://) 390 | local path_is_local = function(path) 391 | local proto = path:match('(..-)://') 392 | return proto == nil 393 | end 394 | 395 | 396 | function Set(source) 397 | local set = {} 398 | for _, l in ipairs(source) do set[l] = true end 399 | return set 400 | end 401 | 402 | --------------------------- 403 | -- More helper functions -- 404 | --------------------------- 405 | 406 | function busy_wait(seconds) 407 | local target = mp.get_time() + seconds 408 | local cycles = 0 409 | while target > mp.get_time() do 410 | cycles = cycles + 1 411 | end 412 | return cycles 413 | end 414 | 415 | -- Removes all keys from a table, without destroying the reference to it 416 | function clear_table(target) 417 | for key, value in pairs(target) do 418 | target[key] = nil 419 | end 420 | end 421 | function shallow_copy(target) 422 | if type(target) == "table" then 423 | local copy = {} 424 | for k, v in pairs(target) do 425 | copy[k] = v 426 | end 427 | return copy 428 | else 429 | return target 430 | end 431 | end 432 | 433 | function deep_copy(target) 434 | local copy = {} 435 | for k, v in pairs(target) do 436 | if type(v) == "table" then 437 | copy[k] = deep_copy(v) 438 | else 439 | copy[k] = v 440 | end 441 | end 442 | return copy 443 | end 444 | 445 | -- Rounds to given decimals. eg. round_dec(3.145, 0) => 3 446 | function round_dec(num, idp) 447 | local mult = 10^(idp or 0) 448 | return math.floor(num * mult + 0.5) / mult 449 | end 450 | 451 | function file_exists(name) 452 | local f = io.open(name, "rb") 453 | if f ~= nil then 454 | local ok, err, code = f:read(1) 455 | io.close(f) 456 | return code == nil 457 | else 458 | return false 459 | end 460 | end 461 | 462 | function path_exists(name) 463 | local f = io.open(name, "rb") 464 | if f ~= nil then 465 | io.close(f) 466 | return true 467 | else 468 | return false 469 | end 470 | end 471 | 472 | function create_directories(path) 473 | local cmd 474 | if ON_WINDOWS then 475 | cmd = { args = {'cmd', '/c', 'mkdir', path} } 476 | else 477 | cmd = { args = {'mkdir', '-p', path} } 478 | end 479 | utils.subprocess(cmd) 480 | end 481 | 482 | function move_file(source_path, target_path) 483 | local cmd 484 | if ON_WINDOWS then 485 | cmd = { cancellable=false, args = {'cmd', '/c', 'move', '/Y', source_path, target_path } } 486 | utils.subprocess(cmd) 487 | else 488 | -- cmd = { cancellable=false, args = {'mv', source_path, target_path } } 489 | os.rename(source_path, target_path) 490 | end 491 | end 492 | 493 | function check_pid(pid) 494 | -- Checks if a PID exists and returns true if so 495 | local cmd, r 496 | if ON_WINDOWS then 497 | cmd = { cancellable=false, args = { 498 | 'tasklist', '/FI', ('PID eq %d'):format(pid) 499 | }} 500 | r = utils.subprocess(cmd) 501 | return r.stdout:sub(1,1) == '\13' 502 | else 503 | cmd = { cancellable=false, args = { 504 | 'sh', '-c', ('kill -0 %d 2>/dev/null'):format(pid) 505 | }} 506 | r = utils.subprocess(cmd) 507 | return r.status == 0 508 | end 509 | end 510 | 511 | function kill_pid(pid) 512 | local cmd, r 513 | if ON_WINDOWS then 514 | cmd = { cancellable=false, args = {'taskkill', '/F', '/PID', tostring(pid) } } 515 | else 516 | cmd = { cancellable=false, args = {'kill', tostring(pid) } } 517 | end 518 | r = utils.subprocess(cmd) 519 | return r.status == 0, r 520 | end 521 | 522 | 523 | -- Find an executable in PATH or CWD with the given name 524 | function find_executable(name) 525 | local delim = ON_WINDOWS and ";" or ":" 526 | 527 | local pwd = os.getenv("PWD") or utils.getcwd() 528 | local path = os.getenv("PATH") 529 | 530 | local env_path = pwd .. delim .. path -- Check CWD first 531 | 532 | local result, filename 533 | for path_dir in env_path:gmatch("[^"..delim.."]+") do 534 | filename = path_utils.join(path_dir, name) 535 | if file_exists(filename) then 536 | result = filename 537 | break 538 | end 539 | end 540 | 541 | return result 542 | end 543 | 544 | local ExecutableFinder = { path_cache = {} } 545 | -- Searches for an executable and caches the result if any 546 | function ExecutableFinder:get_executable_path( name, raw_name ) 547 | name = ON_WINDOWS and not raw_name and (name .. ".exe") or name 548 | 549 | if self.path_cache[name] == nil then 550 | self.path_cache[name] = find_executable(name) or false 551 | end 552 | return self.path_cache[name] 553 | end 554 | 555 | -- Format seconds to HH.MM.SS.sss 556 | function format_time(seconds, sep, decimals) 557 | decimals = decimals == nil and 3 or decimals 558 | sep = sep and sep or ":" 559 | local s = seconds 560 | local h, s = divmod(s, 60*60) 561 | local m, s = divmod(s, 60) 562 | 563 | local second_format = string.format("%%0%d.%df", 2+(decimals > 0 and decimals+1 or 0), decimals) 564 | 565 | return string.format("%02d"..sep.."%02d"..sep..second_format, h, m, s) 566 | end 567 | 568 | -- Format seconds to 1h 2m 3.4s 569 | function format_time_hms(seconds, sep, decimals, force_full) 570 | decimals = decimals == nil and 1 or decimals 571 | sep = sep ~= nil and sep or " " 572 | 573 | local s = seconds 574 | local h, s = divmod(s, 60*60) 575 | local m, s = divmod(s, 60) 576 | 577 | if force_full or h > 0 then 578 | return string.format("%dh"..sep.."%dm"..sep.."%." .. tostring(decimals) .. "fs", h, m, s) 579 | elseif m > 0 then 580 | return string.format("%dm"..sep.."%." .. tostring(decimals) .. "fs", m, s) 581 | else 582 | return string.format("%." .. tostring(decimals) .. "fs", s) 583 | end 584 | end 585 | 586 | -- Writes text on OSD and console 587 | function log_info(txt, timeout) 588 | timeout = timeout or 1.5 589 | msg.info(txt) 590 | mp.osd_message(txt, timeout) 591 | end 592 | 593 | -- Join table items, ala ({"a", "b", "c"}, "=", "-", ", ") => "=a-, =b-, =c-" 594 | function join_table(source, before, after, sep) 595 | before = before or "" 596 | after = after or "" 597 | sep = sep or ", " 598 | local result = "" 599 | for i, v in pairs(source) do 600 | if not isempty(v) then 601 | local part = before .. v .. after 602 | if i == 1 then 603 | result = part 604 | else 605 | result = result .. sep .. part 606 | end 607 | end 608 | end 609 | return result 610 | end 611 | 612 | function wrap(s, char) 613 | char = char or "'" 614 | return char .. s .. char 615 | end 616 | -- Wraps given string into 'string' and escapes any 's in it 617 | function escape_and_wrap(s, char, replacement) 618 | char = char or "'" 619 | replacement = replacement or "\\" .. char 620 | return wrap(string.gsub(s, char, replacement), char) 621 | end 622 | -- Escapes single quotes in a string and wraps the input in single quotes 623 | function escape_single_bash(s) 624 | return escape_and_wrap(s, "'", "'\\''") 625 | end 626 | 627 | -- Returns (a .. b) if b is not empty or nil 628 | function joined_or_nil(a, b) 629 | return not isempty(b) and (a .. b) or nil 630 | end 631 | 632 | -- Put items from one table into another 633 | function extend_table(target, source) 634 | for i, v in pairs(source) do 635 | table.insert(target, v) 636 | end 637 | end 638 | 639 | -- Creates a handle and filename for a temporary random file (in current directory) 640 | function create_temporary_file(base, mode, suffix) 641 | local handle, filename 642 | suffix = suffix or "" 643 | while true do 644 | filename = base .. tostring(math.random(1, 5000)) .. suffix 645 | handle = io.open(filename, "r") 646 | if not handle then 647 | handle = io.open(filename, mode) 648 | break 649 | end 650 | io.close(handle) 651 | end 652 | return handle, filename 653 | end 654 | 655 | 656 | function get_processor_count() 657 | local proc_count 658 | 659 | if ON_WINDOWS then 660 | proc_count = tonumber(os.getenv("NUMBER_OF_PROCESSORS")) 661 | else 662 | local cpuinfo_handle = io.open("/proc/cpuinfo") 663 | if cpuinfo_handle ~= nil then 664 | local cpuinfo_contents = cpuinfo_handle:read("*a") 665 | local _, replace_count = cpuinfo_contents:gsub('processor', '') 666 | proc_count = replace_count 667 | end 668 | end 669 | 670 | if proc_count and proc_count > 0 then 671 | return proc_count 672 | else 673 | return nil 674 | end 675 | end 676 | 677 | function substitute_values(string, values) 678 | local substitutor = function(match) 679 | if match == "%" then 680 | return "%" 681 | else 682 | -- nil is discarded by gsub 683 | return values[match] 684 | end 685 | end 686 | 687 | local substituted = string:gsub('%%(.)', substitutor) 688 | return substituted 689 | end 690 | 691 | -- ASS HELPERS -- 692 | function round_rect_top( ass, x0, y0, x1, y1, r ) 693 | local c = 0.551915024494 * r -- circle approximation 694 | ass:move_to(x0 + r, y0) 695 | ass:line_to(x1 - r, y0) -- top line 696 | if r > 0 then 697 | ass:bezier_curve(x1 - r + c, y0, x1, y0 + r - c, x1, y0 + r) -- top right corner 698 | end 699 | ass:line_to(x1, y1) -- right line 700 | ass:line_to(x0, y1) -- bottom line 701 | ass:line_to(x0, y0 + r) -- left line 702 | if r > 0 then 703 | ass:bezier_curve(x0, y0 + r - c, x0 + r - c, y0, x0 + r, y0) -- top left corner 704 | end 705 | end 706 | 707 | function round_rect(ass, x0, y0, x1, y1, rtl, rtr, rbr, rbl) 708 | local c = 0.551915024494 709 | ass:move_to(x0 + rtl, y0) 710 | ass:line_to(x1 - rtr, y0) -- top line 711 | if rtr > 0 then 712 | ass:bezier_curve(x1 - rtr + rtr*c, y0, x1, y0 + rtr - rtr*c, x1, y0 + rtr) -- top right corner 713 | end 714 | ass:line_to(x1, y1 - rbr) -- right line 715 | if rbr > 0 then 716 | ass:bezier_curve(x1, y1 - rbr + rbr*c, x1 - rbr + rbr*c, y1, x1 - rbr, y1) -- bottom right corner 717 | end 718 | ass:line_to(x0 + rbl, y1) -- bottom line 719 | if rbl > 0 then 720 | ass:bezier_curve(x0 + rbl - rbl*c, y1, x0, y1 - rbl + rbl*c, x0, y1 - rbl) -- bottom left corner 721 | end 722 | ass:line_to(x0, y0 + rtl) -- left line 723 | if rtl > 0 then 724 | ass:bezier_curve(x0, y0 + rtl - rtl*c, x0 + rtl - rtl*c, y0, x0 + rtl, y0) -- top left corner 725 | end 726 | end 727 | --[ FileConcat-E lib/helpers.lua HASH:66f7cd869de2dda2fcc45a6af21529b84297f9001b7a26c8654ad096298a54a7 ]-- 728 | --[ FileConcat-S lib/text_measurer.lua HASH:972853e2bee899f877edb68959d39072af9afad10ebb3c0ff9eaa6b3863dcdba ]-- 729 | --[[ 730 | TextMeasurer can calculate character and text width with medium accuracy, 731 | and wrap/truncate strings by the information. 732 | It works by creating an ASS subtitle, rendering it with a subprocessed mpv 733 | and then counting pixels to find the bounding boxes for individual characters. 734 | ]]-- 735 | 736 | local TextMeasurer = { 737 | FONT_HEIGHT = 16 * 5, 738 | FONT_MARGIN = 5, 739 | BASE_X = 10, 740 | 741 | IMAGE_WIDTH = 256, 742 | 743 | FONT_NAME = 'sans-serif', 744 | 745 | CHARACTERS = { 746 | '', 'M ', -- For measuring, removed later 747 | 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', 748 | 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 749 | '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 750 | '!', '"', '#', '$', '%', '&', "'", '(', ')', '*', '+', ',', '-', '.', '/', ':', ';', '<', '=', '>', '?', '@', '[', '\\', ']', '^', '_', '`', '{', '|', '}', '~', 751 | '\195\161', '\195\129', '\195\160', '\195\128', '\195\162', '\195\130', '\195\164', '\195\132', '\195\163', '\195\131', '\195\165', '\195\133', '\195\166', 752 | '\195\134', '\195\167', '\195\135', '\195\169', '\195\137', '\195\168', '\195\136', '\195\170', '\195\138', '\195\171', '\195\139', '\195\173', '\195\141', 753 | '\195\172', '\195\140', '\195\174', '\195\142', '\195\175', '\195\143', '\195\177', '\195\145', '\195\179', '\195\147', '\195\178', '\195\146', '\195\180', 754 | '\195\148', '\195\182', '\195\150', '\195\181', '\195\149', '\195\184', '\195\152', '\197\147', '\197\146', '\195\159', '\195\186', '\195\154', '\195\185', 755 | '\195\153', '\195\187', '\195\155', '\195\188', '\195\156' 756 | }, 757 | 758 | WIDTH_MAP = nil, 759 | 760 | ASS_HEADER = [[[Script Info] 761 | Title: Temporary file 762 | ScriptType: v4.00+ 763 | PlayResX: %d 764 | PlayResY: %d 765 | 766 | [V4+ Styles] 767 | Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding 768 | Style: Default,%s,80,&H00FFFFFF,&H00FFFFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,7,0,0,0,1 769 | 770 | [Events] 771 | Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text 772 | ]] 773 | } 774 | 775 | TextMeasurer.LINE_HEIGHT = (TextMeasurer.FONT_HEIGHT + TextMeasurer.FONT_MARGIN) 776 | TextMeasurer.TOTAL_HEIGHT = TextMeasurer.LINE_HEIGHT * #TextMeasurer.CHARACTERS 777 | 778 | 779 | function TextMeasurer:create_ass_track() 780 | local ass_lines = { self.ASS_HEADER:format(self.IMAGE_WIDTH, self.TOTAL_HEIGHT, self.FONT_NAME) } 781 | 782 | for i, character in ipairs(self.CHARACTERS) do 783 | local ass_line = 'Dialogue: 0,0:00:00.00,0:00:05.00,Default,,0,0,0,,' .. ('{\\pos(%d, %d)}%sM'):format(self.BASE_X, (i-1) * self.LINE_HEIGHT, character) 784 | table.insert(ass_lines, ass_line) 785 | end 786 | 787 | return table.concat(ass_lines, '\n') 788 | end 789 | 790 | 791 | function TextMeasurer:render_ass_track(ass_sub_data) 792 | -- Round up to divisible by 2 793 | local target_height = self.TOTAL_HEIGHT + (self.TOTAL_HEIGHT + 2) % 2 794 | 795 | local mpv_args = { 796 | 'mpv', 797 | '--msg-level=all=no', 798 | 799 | '--sub-file=memory://' .. ass_sub_data, 800 | ('av://lavfi:color=color=black:size=%dx%d:duration=1'):format(self.IMAGE_WIDTH, target_height), 801 | '--frames=1', 802 | 803 | -- Byte for each pixel 804 | '--vf-add=format=gray', 805 | '--of=rawvideo', 806 | '--ovc=rawvideo', 807 | 808 | -- Write to stdout 809 | '-o=-' 810 | } 811 | 812 | local ret = utils.subprocess({args=mpv_args, cancellable=false}) 813 | return ret.stdout 814 | end 815 | 816 | 817 | function TextMeasurer:get_bounds(image_data, offset) 818 | local w = self.IMAGE_WIDTH 819 | local h = self.LINE_HEIGHT 820 | 821 | local left_edge = nil 822 | local right_edge = nil 823 | 824 | -- Approach from left 825 | for x = 0, w-1 do 826 | for y = 0, h-1 do 827 | local p = image_data:byte(x + y*w + 1 + offset) 828 | if p > 0 then 829 | left_edge = x 830 | break 831 | end 832 | end 833 | if left_edge then break end 834 | end 835 | 836 | -- Approach from right 837 | for x = w-1, 0, -1 do 838 | for y = 0, h-1 do 839 | local p = image_data:byte(x + y*w + 1 + offset) 840 | if p > 0 then 841 | right_edge = x 842 | break 843 | end 844 | end 845 | if right_edge then break end 846 | end 847 | 848 | if left_edge and right_edge then 849 | return left_edge, right_edge 850 | end 851 | end 852 | 853 | 854 | function TextMeasurer:parse_characters(image_data) 855 | local sub_image_size = self.IMAGE_WIDTH * self.LINE_HEIGHT 856 | 857 | if #image_data < self.IMAGE_WIDTH * self.TOTAL_HEIGHT then 858 | -- Not enough bytes for all rows 859 | return nil 860 | end 861 | 862 | local edge_map = {} 863 | 864 | for i, character in ipairs(self.CHARACTERS) do 865 | local left, right = self:get_bounds(image_data, (i-1) * sub_image_size) 866 | edge_map[character] = {left, right} 867 | end 868 | 869 | local em_bound = edge_map[''] 870 | local em_space_em_bound = edge_map['M '] 871 | 872 | local em_w = (em_bound[2] - em_bound[1]) + (em_bound[1] - self.BASE_X) 873 | 874 | -- Remove measurement characters from map 875 | edge_map[''] = nil 876 | edge_map['M '] = nil 877 | 878 | for character, edges in pairs(edge_map) do 879 | edge_map[character] = (edges[2] - self.BASE_X - em_w) 880 | end 881 | 882 | -- Space 883 | edge_map[' '] = (em_space_em_bound[2] - em_space_em_bound[1]) - (em_w * 2) 884 | 885 | return edge_map 886 | end 887 | 888 | 889 | function TextMeasurer:create_character_map() 890 | if not self.WIDTH_MAP then 891 | local ass_sub_data = TextMeasurer:create_ass_track() 892 | local image_data = TextMeasurer:render_ass_track(ass_sub_data) 893 | self.WIDTH_MAP = TextMeasurer:parse_characters(image_data) 894 | if not self.WIDTH_MAP then 895 | msg.error("Failed to parse character widths!") 896 | end 897 | end 898 | return self.WIDTH_MAP 899 | end 900 | 901 | -- String functions 902 | 903 | function TextMeasurer:_utf8_iter(text) 904 | iter = text:gmatch('([%z\1-\127\194-\244][\128-\191]*)') 905 | return function() return iter() end 906 | end 907 | 908 | function TextMeasurer:calculate_width(text, font_size) 909 | local total_width = 0 910 | local width_map = self:create_character_map() 911 | local default_width = width_map['M'] 912 | 913 | for char in self:_utf8_iter(text) do 914 | local char_width = width_map[char] or default_width 915 | total_width = total_width + char_width 916 | end 917 | 918 | return total_width * (font_size / self.FONT_HEIGHT) 919 | end 920 | 921 | function TextMeasurer:trim_to_width(text, font_size, max_width, suffix) 922 | suffix = suffix or "..." 923 | max_width = max_width * (self.FONT_HEIGHT / font_size) - self:calculate_width(suffix, font_size) 924 | 925 | local width_map = self:create_character_map() 926 | local default_width = width_map['M'] 927 | 928 | local total_width = 0 929 | local characters = {} 930 | for char in self:_utf8_iter(text) do 931 | local char_width = width_map[char] or default_width 932 | total_width = total_width + char_width 933 | 934 | if total_width > max_width then break end 935 | table.insert(characters, char) 936 | end 937 | 938 | if total_width > max_width then 939 | return table.concat(characters, '') .. suffix 940 | else 941 | return text 942 | end 943 | end 944 | 945 | function TextMeasurer:wrap_to_width(text, font_size, max_width) 946 | local lines = {} 947 | local line_widths = {} 948 | 949 | local current_line = '' 950 | local current_width = 0 951 | 952 | for word in text:gmatch("( *[%S]*\n?)") do 953 | if word ~= '' then 954 | local is_newline = word:sub(-1) == '\n' 955 | word = word:gsub('%s*$', '') 956 | 957 | if word ~= '' then 958 | local part_width = TextMeasurer:calculate_width(word, font_size) 959 | 960 | if (current_width + part_width) > max_width then 961 | table.insert(lines, current_line) 962 | table.insert(line_widths, current_width) 963 | current_line = word:gsub('^%s*', '') 964 | current_width = part_width 965 | else 966 | current_line = current_line .. word 967 | current_width = current_width + part_width 968 | end 969 | end 970 | 971 | if is_newline then 972 | table.insert(lines, current_line) 973 | table.insert(line_widths, current_width) 974 | current_line = '' 975 | current_width = 0 976 | end 977 | end 978 | end 979 | table.insert(lines, current_line) 980 | table.insert(line_widths, current_width) 981 | 982 | return lines, line_widths 983 | end 984 | 985 | 986 | function TextMeasurer:load_or_create(file_path) 987 | local cache_file = io.open(file_path, 'r') 988 | if cache_file then 989 | local map_json = cache_file:read('*a') 990 | local width_map = utils.parse_json(map_json) 991 | self.WIDTH_MAP = width_map 992 | cache_file:close() 993 | else 994 | cache_file = io.open(file_path, 'w') 995 | msg.warn("Generating OSD font character measurements, this may take a second...") 996 | local width_map = self:create_character_map() 997 | local map_json = utils.format_json(width_map) 998 | cache_file:write(map_json) 999 | cache_file:close() 1000 | msg.info("Text measurements created and saved to", file_path) 1001 | end 1002 | end 1003 | --[ FileConcat-E lib/text_measurer.lua HASH:972853e2bee899f877edb68959d39072af9afad10ebb3c0ff9eaa6b3863dcdba ]-- 1004 | --[ FileConcat-S lib/input_tools.lua HASH:7c5b9b73f8d67119a3db9898eea7c260857f4af803a52e8dcc0f0df575683b3b ]-- 1005 | --[[ 1006 | Collection of tools to gather user input. 1007 | NumberInputter can do more than the name says. 1008 | It's a dialog for integer, float, text and even timestamp input. 1009 | 1010 | ChoicePicker allows one to choose an item from a list. 1011 | 1012 | Depends on TextMeasurer and helpers.lua (round_rect) 1013 | ]]-- 1014 | 1015 | local NumberInputter = {} 1016 | NumberInputter.__index = NumberInputter 1017 | 1018 | setmetatable(NumberInputter, { 1019 | __call = function (cls, ...) return cls.new(...) end 1020 | }) 1021 | 1022 | NumberInputter.validators = { 1023 | integer = { 1024 | live = function(new_value, old_value) 1025 | if new_value:match("^%d*$") then return new_value 1026 | else return old_value end 1027 | end, 1028 | submit = function(value) 1029 | if value:match("^%d+$") then return tonumber(value) 1030 | elseif value ~= "" then return nil, value end 1031 | end 1032 | }, 1033 | 1034 | signed_integer = { 1035 | live = function(new_value, old_value) 1036 | if new_value:match("^[-]?%d*$") then return new_value 1037 | else return old_value end 1038 | end, 1039 | submit = function(value) 1040 | if value:match("^[-]?%d+$") then return tonumber(value) 1041 | elseif value ~= "" then return nil, value end 1042 | end 1043 | }, 1044 | 1045 | float = { 1046 | live = function(new_value, old_value) 1047 | if new_value:match("^%d*$") or new_value:match("^%d+%.%d*$") then return new_value 1048 | else return old_value end 1049 | end, 1050 | submit = function(value) 1051 | if value:match("^%d+$") or value:match("^%d+%.%d+$") then 1052 | return tonumber(value) 1053 | elseif value:match("^%d%.$") then 1054 | return nil, value:sub(1, -2) 1055 | elseif value ~= "" then 1056 | return nil, value 1057 | end 1058 | end 1059 | }, 1060 | 1061 | signed_float = { 1062 | live = function(new_value, old_value) 1063 | if new_value:match("^[-]?%d*$") or new_value:match("^[-]?%d+%.%d*$") then return new_value 1064 | else return old_value end 1065 | end, 1066 | submit = function(value) 1067 | if value:match("^[-]?%d+$") or value:match("^[-]?%d+%.%d+$") then 1068 | return tonumber(value) 1069 | elseif value:match("^[-]?%d%.$") then 1070 | return nil, value:sub(1, -2) 1071 | elseif value ~= "" then 1072 | return nil, value 1073 | end 1074 | end 1075 | }, 1076 | 1077 | text = { 1078 | live = function(new_value, old_value) 1079 | return new_value:match("^%s*(.*)") 1080 | end, 1081 | submit = function(value) 1082 | if value:match("%s+$") then 1083 | return nil, value:match("^(.-)%s+$") 1084 | elseif value ~= "" then 1085 | return value 1086 | end 1087 | end 1088 | }, 1089 | 1090 | filename = { 1091 | live = function(new_value, old_value) 1092 | return new_value:match("^%s*(.*)"):gsub('[^a-zA-Z0-9 !#$%&\'()+%-,.;=@[%]_ {}]', '') 1093 | end, 1094 | submit = function(value) 1095 | if value:match("%s+$") then 1096 | return nil, value:match("^(.-)%s+$") 1097 | elseif value ~= "" then 1098 | return value 1099 | end 1100 | end 1101 | }, 1102 | 1103 | timestamp = { 1104 | initial_parser = function(v) 1105 | v = math.min(99*3600 + 59*60 + 59.999, math.max(0, v)) 1106 | 1107 | local ms = round_dec((v - math.floor(v)) * 1000) 1108 | if (ms >= 1000) then 1109 | v = v + 1 1110 | ms = ms - 1000 1111 | end 1112 | 1113 | return ("%02d%02d%02d%03d"):format( 1114 | math.floor(v / 3600), 1115 | math.floor((v % 3600) / 60), 1116 | math.floor(v % 60), 1117 | ms 1118 | ) 1119 | end, 1120 | live = function(new_value, old_value) 1121 | if new_value:match("^%d*$") then return new_value, true 1122 | else return old_value, false end 1123 | end, 1124 | submit = function(value) 1125 | local v = tonumber(value:sub(1,2)) * 3600 + tonumber(value:sub(3,4)) * 60 + tonumber(value:sub(5,9)) / 1000 1126 | v = math.min(99*3600 + 59*60 + 59.999, math.max(0, v)) 1127 | 1128 | local ms = round_dec((v - math.floor(v)) * 1000) 1129 | if (ms >= 1000) then 1130 | v = v + 1 1131 | ms = ms - 1000 1132 | end 1133 | 1134 | local fv = ("%02d%02d%02d%03d"):format( 1135 | math.floor(v / 3600), 1136 | math.floor((v % 3600) / 60), 1137 | math.floor(v % 60), 1138 | ms 1139 | ) 1140 | 1141 | -- Check if formatting matches, if not, return fixed value for resubmit 1142 | if fv == value then return v 1143 | else return nil, fv end 1144 | end 1145 | } 1146 | } 1147 | 1148 | function NumberInputter.new() 1149 | local self = setmetatable({}, NumberInputter) 1150 | 1151 | self.active = false 1152 | 1153 | self.option_index = 1 1154 | self.options = {} -- {name, hint, value, type_string} 1155 | 1156 | self.scale = 1 1157 | 1158 | self.cursor = 1 1159 | self.last_move = 0 1160 | self.replace_mode = false 1161 | 1162 | self._input_characters = {} 1163 | 1164 | local input_char_string = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" .. 1165 | "!\"#$%&'()*+,-./:;<=>?@[\\]^_{|}~#" 1166 | local keys = { 1167 | "ENTER", "ESC", "TAB", 1168 | "BS", "DEL", 1169 | "LEFT", "RIGHT", "HOME", "END", 1170 | 1171 | -- Extra input characters 1172 | "SPACE", "SHARP", 1173 | } 1174 | local repeatable_keys = Set{"BS", "DEL", "LEFT", "RIGHT"} 1175 | 1176 | for c in input_char_string:gmatch('.') do 1177 | self._input_characters[c] = c 1178 | table.insert(keys, c) 1179 | end 1180 | self._input_characters["SPACE"] = " " 1181 | self._input_characters["SHARP"] = "#" 1182 | 1183 | self._keys_bound = false 1184 | self._key_binds = {} 1185 | 1186 | for i,k in pairs(keys) do 1187 | local listener = function() self:_on_key(k) end 1188 | local do_repeat = (repeatable_keys[k] or self._input_characters[k]) 1189 | local flags = do_repeat and {repeatable=true} or nil 1190 | 1191 | table.insert(self._key_binds, {k, "_input_" .. i, listener, flags}) 1192 | end 1193 | 1194 | return self 1195 | end 1196 | 1197 | function NumberInputter:escape_ass(text) 1198 | return text:gsub('\\', '\\\226\129\160'):gsub('{', '\\{') 1199 | end 1200 | 1201 | function NumberInputter:cycle_options() 1202 | self.option_index = (self.option_index) % #self.options + 1 1203 | 1204 | local initial_value = self.options[self.option_index][3] 1205 | local parser = self.validators[self.options[self.option_index][4]].initial_parser or tostring 1206 | 1207 | if type(initial_value) == "function" then initial_value = initial_value() end 1208 | self.current_value = initial_value and parser(initial_value) or "" 1209 | self.cursor = 1 1210 | 1211 | self.replace_mode = (self.options[self.option_index][4] == "timestamp") 1212 | end 1213 | 1214 | function NumberInputter:enable_key_bindings() 1215 | if not self._keys_bound then 1216 | for k, v in pairs(self._key_binds) do 1217 | mp.add_forced_key_binding(unpack(v)) 1218 | end 1219 | self._keys_bound = true 1220 | end 1221 | end 1222 | 1223 | function NumberInputter:disable_key_bindings() 1224 | for k, v in pairs(self._key_binds) do 1225 | mp.remove_key_binding(v[2]) -- remove by name 1226 | end 1227 | self._keys_bound = false 1228 | end 1229 | 1230 | function NumberInputter:start(options, on_enter, on_cancel) 1231 | self.active = true 1232 | self.was_paused = mp.get_property_native('pause') 1233 | if not self.was_paused then 1234 | mp.set_property_native('pause', true) 1235 | mp.osd_message("Paused playback for input") 1236 | end 1237 | 1238 | self.options = options 1239 | 1240 | self.option_index = 0 1241 | self:cycle_options() -- Will move index to 1 1242 | 1243 | self.enter_callback = on_enter 1244 | self.cancel_callback = on_cancel 1245 | 1246 | self:enable_key_bindings() 1247 | end 1248 | function NumberInputter:stop() 1249 | self.active = false 1250 | self.current_value = "" 1251 | if not self.was_paused then 1252 | mp.set_property_native('pause', false) 1253 | mp.osd_message("Resumed playback") 1254 | end 1255 | 1256 | self:disable_key_bindings() 1257 | end 1258 | 1259 | function NumberInputter:_append( part ) 1260 | local l = self.current_value:len() 1261 | local validator_data = self.validators[self.options[self.option_index][4]] 1262 | 1263 | if self.replace_mode then 1264 | if self.cursor > 1 then 1265 | 1266 | local new_value = self.current_value:sub(1, l - self.cursor + 1) .. part .. self.current_value:sub(l - self.cursor + 3) 1267 | self.current_value, changed = validator_data.live(new_value, self.current_value, self) 1268 | if changed then 1269 | self.cursor = math.max(1, self.cursor - 1) 1270 | end 1271 | end 1272 | 1273 | else 1274 | local new_value = self.current_value:sub(1, l - self.cursor + 1) .. part .. self.current_value:sub(l - self.cursor + 2) 1275 | 1276 | self.current_value = validator_data.live(new_value, self.current_value, self) 1277 | end 1278 | end 1279 | 1280 | function NumberInputter:_on_key( key ) 1281 | 1282 | if key == "ESC" then 1283 | self:stop() 1284 | if self.cancel_callback then 1285 | self.cancel_callback() 1286 | end 1287 | 1288 | elseif key == "ENTER" then 1289 | local opt = self.options[self.option_index] 1290 | local extra_validation = opt[5] 1291 | 1292 | local value, repl = self.validators[opt[4]].submit(self.current_value) 1293 | if value and extra_validation then 1294 | local number_formats = Set{"integer", "float", "signed_float", "signed_integer", "timestamp"} 1295 | if number_formats[opt[4]] then 1296 | if extra_validation.min and value < extra_validation.min then repl = tostring(extra_validation.min) end 1297 | if extra_validation.max and value > extra_validation.max then repl = tostring(extra_validation.max) end 1298 | end 1299 | end 1300 | 1301 | if repl then 1302 | self.current_value = repl 1303 | else 1304 | 1305 | self:stop() 1306 | if self.enter_callback then 1307 | self.enter_callback(opt[1], value) 1308 | end 1309 | end 1310 | 1311 | elseif key == "TAB" then 1312 | self:cycle_options() 1313 | 1314 | elseif key == "BS" then 1315 | -- Remove character to the left 1316 | local l = self.current_value:len() 1317 | local c = self.cursor 1318 | 1319 | if not self.replace_mode and c <= l then 1320 | self.current_value = self.current_value:sub(1, l - c) .. self.current_value:sub(l - c + 2) 1321 | self.cursor = math.min(c, self.current_value:len() + 1) 1322 | 1323 | elseif self.replace_mode then 1324 | if c <= l then 1325 | self.current_value = self.current_value:sub(1, l - c) .. '0' .. self.current_value:sub(l - c + 2) 1326 | self.cursor = math.min(l + 1, c + 1) 1327 | end 1328 | end 1329 | 1330 | elseif key == "DEL" then 1331 | -- Remove character to the right 1332 | local l = self.current_value:len() 1333 | local c = self.cursor 1334 | 1335 | if not self.replace_mode and c > 1 then 1336 | self.current_value = self.current_value:sub(1, l - c + 1) .. self.current_value:sub(l - c + 3) 1337 | self.cursor = math.min(math.max(1, c - 1), self.current_value:len() + 1) 1338 | 1339 | elseif self.replace_mode then 1340 | if c > 1 then 1341 | self.current_value = self.current_value:sub(1, l - c + 1) .. '0' .. self.current_value:sub(l - c + 3) 1342 | self.cursor = math.max(1, c - 1) 1343 | end 1344 | end 1345 | 1346 | elseif key == "LEFT" then 1347 | self.cursor = math.min(self.cursor + 1, self.current_value:len() + 1) 1348 | elseif key == "RIGHT" then 1349 | self.cursor = math.max(self.cursor - 1, 1) 1350 | elseif key == "HOME" then 1351 | self.cursor = self.current_value:len() + 1 1352 | elseif key == "END" then 1353 | self.cursor = 1 1354 | 1355 | elseif self._input_characters[key] then 1356 | self:_append(self._input_characters[key]) 1357 | 1358 | end 1359 | 1360 | self.last_move = mp.get_time() 1361 | end 1362 | 1363 | function NumberInputter:get_ass( w, h ) 1364 | local ass = assdraw.ass_new() 1365 | 1366 | -- Center 1367 | local cx = w / 2 1368 | local cy = h / 2 1369 | local multiple_options = #self.options > 1 1370 | 1371 | local scaled = function(v) return v * self.scale end 1372 | 1373 | 1374 | -- Dialog size 1375 | local b_w = scaled(190) 1376 | local b_h = scaled(multiple_options and 80 or 64) 1377 | local m = scaled(4) -- Margin 1378 | 1379 | local txt_fmt = "{\\fs%d\\an%d\\bord2}" 1380 | local bgc = 16 1381 | local background_style = string.format("{\\bord0\\1a&H%02X&\\1c&H%02X%02X%02X&}", 96, bgc, bgc, bgc) 1382 | 1383 | local small_font_size = scaled(14) 1384 | local main_font_size = scaled(18) 1385 | 1386 | local value_width = TextMeasurer:calculate_width(self.current_value, main_font_size) 1387 | local cursor_width = TextMeasurer:calculate_width("|", main_font_size) 1388 | 1389 | b_w = math.max(b_w, value_width + scaled(20)) 1390 | 1391 | ass:new_event() 1392 | ass:pos(0,0) 1393 | ass:draw_start() 1394 | ass:append(background_style) 1395 | ass:round_rect_cw(cx-b_w/2, cy-b_h/2, cx+b_w/2, cy+b_h/2, scaled(7)) 1396 | ass:draw_stop() 1397 | 1398 | ass:new_event() 1399 | ass:pos(cx-b_w/2 + m, cy+b_h/2 - m) 1400 | ass:append( string.format(txt_fmt, small_font_size, 1) ) 1401 | ass:append("[ESC] Cancel") 1402 | 1403 | ass:new_event() 1404 | ass:pos(cx+b_w/2 - m, cy+b_h/2 - m) 1405 | ass:append( string.format(txt_fmt, small_font_size, 3) ) 1406 | ass:append("Accept [ENTER]") 1407 | 1408 | if multiple_options then 1409 | ass:new_event() 1410 | ass:pos(cx-b_w/2 + m, cy-b_h/2 + m) 1411 | ass:append( string.format(txt_fmt, small_font_size, 7) ) 1412 | ass:append("[TAB] Cycle") 1413 | end 1414 | 1415 | ass:new_event() 1416 | ass:pos(cx, cy-b_h/2 + m + scaled(multiple_options and 15 or 0)) 1417 | ass:append( string.format(txt_fmt, main_font_size, 8) ) 1418 | ass:append(self.options[self.option_index][2]) 1419 | 1420 | local value = self.current_value 1421 | local cursor = self.cursor 1422 | if self.options[self.option_index][4] == "timestamp" then 1423 | value = value:sub(1, 2) .. ":" .. value:sub(3, 4) .. ":" .. value:sub(5, 6) .. "." .. value:sub(7, 9) 1424 | cursor = cursor + (cursor > 4 and 1 or 0) + (cursor > 6 and 1 or 0) + (cursor > 8 and 1 or 0) 1425 | end 1426 | 1427 | local safe_text = self:escape_ass(value) 1428 | 1429 | local text_x, text_y = (cx - value_width/2), (cy + scaled(multiple_options and 7 or 0)) 1430 | ass:new_event() 1431 | ass:pos(text_x, text_y) 1432 | ass:append( string.format(txt_fmt, main_font_size, 4) ) 1433 | ass:append(safe_text) 1434 | 1435 | -- Blink the cursor 1436 | local cur_style = (math.floor( (mp.get_time() - self.last_move) * 1.5 ) % 2 == 0) and "{\\alpha&H00&}" or "{\\alpha&HFF&}" 1437 | 1438 | ass:new_event() 1439 | ass:pos(text_x - (cursor > 1 and cursor_width or 0)/2, text_y) 1440 | ass:append( string.format(txt_fmt, main_font_size, 4) ) 1441 | ass:append("{\\alpha&HFF&}" .. self:escape_ass(value:sub(1, value:len() - cursor + 1)) .. cur_style .. "{\\bord1}|" ) 1442 | 1443 | return ass 1444 | end 1445 | 1446 | -- -- -- -- 1447 | 1448 | local ChoicePicker = {} 1449 | ChoicePicker.__index = ChoicePicker 1450 | 1451 | setmetatable(ChoicePicker, { 1452 | __call = function (cls, ...) return cls.new(...) end 1453 | }) 1454 | 1455 | function ChoicePicker.new() 1456 | local self = setmetatable({}, ChoicePicker) 1457 | 1458 | self.active = false 1459 | 1460 | self.choice_index = 1 1461 | self.choices = {} -- { { name = "Visible name", value = "some_value" }, ... } 1462 | 1463 | self.scale = 1 1464 | 1465 | local keys = { 1466 | "UP", "DOWN", "PGUP", "PGDWN", 1467 | "ENTER", "ESC" 1468 | } 1469 | local repeatable_keys = Set{"UP", "DOWN"} 1470 | 1471 | self._keys_bound = false 1472 | self._key_binds = {} 1473 | 1474 | for i,k in pairs(keys) do 1475 | local listener = function() self:_on_key(k) end 1476 | local do_repeat = repeatable_keys[k] 1477 | local flags = do_repeat and {repeatable=true} or nil 1478 | 1479 | table.insert(self._key_binds, {k, "_picker_key_" .. k, listener, flags}) 1480 | end 1481 | 1482 | return self 1483 | end 1484 | 1485 | function ChoicePicker:shift_selection(offset, no_wrap) 1486 | local n = #self.choices 1487 | 1488 | if n == 0 then 1489 | return 0 1490 | end 1491 | 1492 | local target_index = self.choice_index - 1 + offset 1493 | if no_wrap then 1494 | target_index = math.max(0, math.min(n - 1, target_index)) 1495 | end 1496 | 1497 | self.choice_index = (target_index % n) + 1 1498 | end 1499 | 1500 | 1501 | function ChoicePicker:enable_key_bindings() 1502 | if not self._keys_bound then 1503 | for k, v in pairs(self._key_binds) do 1504 | mp.add_forced_key_binding(unpack(v)) 1505 | end 1506 | self._keys_bound = true 1507 | end 1508 | end 1509 | 1510 | function ChoicePicker:disable_key_bindings() 1511 | for k, v in pairs(self._key_binds) do 1512 | mp.remove_key_binding(v[2]) -- remove by name 1513 | end 1514 | self._keys_bound = false 1515 | end 1516 | 1517 | function ChoicePicker:start(choices, on_enter, on_cancel) 1518 | self.active = true 1519 | 1520 | self.choices = choices 1521 | 1522 | self.choice_index = 1 1523 | -- self:cycle_options() -- Will move index to 1 1524 | 1525 | self.enter_callback = on_enter 1526 | self.cancel_callback = on_cancel 1527 | 1528 | self:enable_key_bindings() 1529 | end 1530 | function ChoicePicker:stop() 1531 | self.active = false 1532 | 1533 | self:disable_key_bindings() 1534 | end 1535 | 1536 | function ChoicePicker:_on_key( key ) 1537 | 1538 | if key == "UP" then 1539 | self:shift_selection(-1) 1540 | 1541 | elseif key == "DOWN" then 1542 | self:shift_selection(1) 1543 | 1544 | elseif key == "PGUP" then 1545 | self.choice_index = 1 1546 | 1547 | elseif key == "PGDWN" then 1548 | self.choice_index = #self.choices 1549 | 1550 | elseif key == "ESC" then 1551 | self:stop() 1552 | if self.cancel_callback then 1553 | self.cancel_callback() 1554 | end 1555 | 1556 | elseif key == "ENTER" then 1557 | self:stop() 1558 | if self.enter_callback then 1559 | self.enter_callback(self.choices[self.choice_index].value) 1560 | end 1561 | 1562 | end 1563 | end 1564 | 1565 | function ChoicePicker:get_ass( w, h ) 1566 | local ass = assdraw.ass_new() 1567 | 1568 | -- Center 1569 | local cx = w / 2 1570 | local cy = h / 2 1571 | local choice_count = #self.choices 1572 | 1573 | local s = function(v) return v * self.scale end 1574 | 1575 | -- Dialog size 1576 | local b_w = s(220) 1577 | local b_h = s(20 + 20 + (choice_count * 20) + 10) 1578 | local m = s(5) -- Margin 1579 | 1580 | local small_font_size = s(14) 1581 | local main_font_size = s(18) 1582 | 1583 | for j, choice in pairs(self.choices) do 1584 | local name_width = TextMeasurer:calculate_width(choice.name, main_font_size) 1585 | b_w = math.max(b_w, name_width + s(20)) 1586 | end 1587 | 1588 | local e_l = cx - b_w/2 1589 | local e_r = cx + b_w/2 1590 | local e_t = cy - b_h/2 1591 | local e_b = cy + b_h/2 1592 | 1593 | local txt_fmt = "{\\fs%d\\an%d\\bord2}" 1594 | local bgc = 16 1595 | local background_style = string.format("{\\bord0\\1a&H%02X&\\1c&H%02X%02X%02X&}", 96, bgc, bgc, bgc) 1596 | 1597 | local line_h = s(20) 1598 | local line_h2 = s(22) 1599 | local corner_radius = s(7) 1600 | 1601 | ass:new_event() 1602 | ass:pos(0,0) 1603 | ass:draw_start() 1604 | ass:append(background_style) 1605 | -- Main BG 1606 | ass:round_rect_cw(e_l, e_t, e_r, e_b, corner_radius) 1607 | -- Options title 1608 | round_rect(ass, e_l + line_h*2, e_t-line_h2, e_r - line_h*2, e_t, corner_radius, corner_radius, 0, 0) 1609 | ass:draw_stop() 1610 | 1611 | ass:new_event() 1612 | ass:pos(cx, e_t - line_h2/2) 1613 | ass:append( string.format(txt_fmt, main_font_size, 5) ) 1614 | ass:append("Choose") 1615 | 1616 | ass:new_event() 1617 | ass:pos(e_r - m, e_b - m) 1618 | ass:append( string.format(txt_fmt, small_font_size, 3) ) 1619 | ass:append("Choose [ENTER]") 1620 | 1621 | ass:new_event() 1622 | ass:pos(e_l + m, e_b - m) 1623 | ass:append( string.format(txt_fmt, small_font_size, 1) ) 1624 | ass:append("[ESC] Cancel") 1625 | 1626 | ass:new_event() 1627 | ass:pos(e_l + m, e_t + m) 1628 | ass:append( string.format(txt_fmt, small_font_size, 7) ) 1629 | ass:append("[UP]/[DOWN] Select") 1630 | 1631 | local color_text = function( text, r, g, b ) 1632 | return string.format("{\\c&H%02X%02X%02X&}%s{\\c}", b, g, r, text) 1633 | end 1634 | 1635 | local color_gray = {190, 190, 190} 1636 | 1637 | local item_height = line_h; 1638 | local text_height = main_font_size; 1639 | local item_margin = (item_height - text_height) / 2; 1640 | 1641 | local base_y = e_t + m + item_height 1642 | 1643 | local choice_index = 0 1644 | 1645 | for j, choice in pairs(self.choices) do 1646 | choice_index = choice_index + 1 1647 | 1648 | if choice_index == self.choice_index then 1649 | ass:new_event() 1650 | ass:pos(0,0) 1651 | ass:append( string.format("{\\bord0\\1a&H%02X&\\1c&H%02X%02X%02X&}", 128, 250, 250, 250) ) 1652 | ass:draw_start() 1653 | ass:rect_cw(e_l, base_y - item_margin, e_r, base_y + item_height + item_margin) 1654 | ass:draw_stop() 1655 | end 1656 | 1657 | ass:new_event() 1658 | ass:pos(cx, base_y) 1659 | ass:append(string.format(txt_fmt, text_height, 8)) 1660 | ass:append(choice.name) 1661 | 1662 | base_y = base_y + line_h 1663 | end 1664 | 1665 | return ass 1666 | end 1667 | --[ FileConcat-E lib/input_tools.lua HASH:7c5b9b73f8d67119a3db9898eea7c260857f4af803a52e8dcc0f0df575683b3b ]-- 1668 | --[ FileConcat-S src/main.lua HASH:8779eedc9c165c33e43fbf0a05a4e882aaa896bd55cdfc66ffe7f7eb015843f3 ]-- 1669 | local mp = require 'mp' 1670 | local msg = require 'mp.msg' 1671 | 1672 | 1673 | local function search() 1674 | local search_dialog = NumberInputter() 1675 | local screen_w,screen_h,_ = mp.get_osd_size() 1676 | 1677 | local function tick_callback() 1678 | local ass=assdraw.ass_new() 1679 | ass:append(search_dialog:get_ass(screen_w,screen_h).text) 1680 | mp.set_osd_ass(screen_w,screen_h,ass.text) 1681 | end 1682 | mp.register_event("tick", tick_callback) 1683 | 1684 | local function cancel_callback() 1685 | search_dialog:stop() 1686 | mp.set_osd_ass(screen_w,screen_h,"") 1687 | mp.unregister_event(tick_callback) 1688 | end 1689 | local function callback(e,v) 1690 | cancel_callback() 1691 | msg.verbose("searching for: "..v) 1692 | mp.commandv("loadfile", "ytdl://ytsearch50:"..v) 1693 | 1694 | local function trigger_gallery(prop,count) 1695 | if count > 1 then 1696 | msg.verbose("triggering gallery-view") 1697 | mp.unobserve_property(trigger_gallery) 1698 | mp.commandv("script-message", "gallery-view", "true") 1699 | end 1700 | end 1701 | mp.observe_property("playlist-count", "number", trigger_gallery) 1702 | end 1703 | 1704 | search_dialog:start({{"search","Search Youtube:",nil,"text"}}, callback, cancel_callback) 1705 | end 1706 | 1707 | mp.add_forced_key_binding("/", "youtube-search", search) 1708 | --[ FileConcat-E src/main.lua HASH:8779eedc9c165c33e43fbf0a05a4e882aaa896bd55cdfc66ffe7f7eb015843f3 ]-- 1709 | --------------------------------------------------------------------------------