├── LICENSE ├── README.md ├── menu.png ├── playlistmanager.conf ├── playlistmanager.lua └── playlistmanager.png /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Mpv-Playlistmanager 2 | Mpv lua script to create and manage playlists. 3 | 4 | This script allows you to see and interact with your playlist in an intuitive way. The key features are removing, reordering and playing files. Additional features include resolving url titles, stripping filenames according to patterns and creating/saving/selecting/shuffling/sorting playlists. 5 | 6 | Requires mpv version 0.39.0, for a more compatible playlistmanager version see [this](https://github.com/jonniek/mpv-playlistmanager/tree/16e18949e3d604c2ffe43e95391f420227881139). 7 | 8 | ![](playlistmanager.png) 9 | Default visual cues(might render differently on browsers): 10 | ○ default file 11 | ▷ playing file 12 | ● hovered file(for removing, playing and moving) 13 | ▶ playing and hovered file 14 | ➔ selected file(file is being moved) 15 | ➤ playing and selected file 16 | It will make sense once you try the script! 17 | 18 | ## Installation 19 | Copy the `playlistmanager.lua` file to your mpv scripts directory which is usually `~/.config/mpv/scripts/` or `%APPDATA%/mpv/scripts/`. See [https://mpv.io/manual/master/#files](https://mpv.io/manual/master/#files) and [https://mpv.io/manual/master/#script-location](https://mpv.io/manual/master/#script-location) for more detailed information. 20 | 21 | ## Settings 22 | You can modify behaviour of the script in the settings variable in the lua file or a `playlistmanager.conf` lua-setting file in `script-opts` directory. 23 | Note: the conf file will override any changed setting in the lua file. There is a playlistmanager.conf file in this repo with the default values of the script for reference. 24 | 25 | You can pass settings from the command line on startup such as `mpv --idle=once --script-opts=playlistmanager-loadfiles_on_start=yes`. 26 | 27 | You can also change settings during runtime with a keybind or command like `KEY change-list script-opts append playlistmanager-showamount=10`. 28 | 29 | If you are using [save-position-on-quit](https://mpv.io/manual/master/#options-save-position-on-quit) then the playlist will by default write watch later config when switching between files. There is a setting to disable this. 30 | 31 | #### Title resolving 32 | This script is able to resolve titles for local files with ffprobe and urls with youtube-dl. See the `resolve_url_titles`, `resolve_local_titles`, `prefer_titles` and `youtube_dl_executable` settings for details. 33 | 34 | ## Keybinds 35 | ### Static keybindings 36 | - __showplaylist__(SHIFT+ENTER) 37 | - Displays the current playlist and loads the dynamic keybinds for navigating 38 | 39 | #### Functions without default keybindings 40 | - __openmenu__ 41 | - One keybind to execute all playlistmanager functions. This is useful if you do not use the individual actions often and do not want to bind individual keys for every action. 42 | ![](menu.png) 43 | - __sortplaylist__ 44 | - Sorts the current playlist with stripped values from filename(not media title, no paths, usercreated strips applied). To start playlist from start you can use a script message `KEY script-message playlistmanager sortplaylist startover`. Settings involving sort include alphanumeric sort(nonpadded numbers in order, case insensitivity), sort on mpv start and sort on file added to playlist. Sort algorithm credit [zsugabubus](https://github.com/zsugabubus/dotfiles/blob/master/.config/mpv/scripts/playlist-filtersort.lua) 45 | - __shuffleplaylist__ 46 | - Shuffles the current playlist. Stops currently playing file and starts playlist from start of new playlist unlike native shuffle that doesn't shuffle current file. 47 | - __reverseplaylist__ 48 | - Reverses the current playlist. Does not stop playing the current file. 49 | - __loadfiles__ 50 | - Attempts to load all files from the currently playing files directory to the playlist keeping the order. Option to run at startup if 0 or 1 files are opened, with 0 opens files from working directory. On startup with no file requires `--idle=yes or --idle=once`. 51 | - __saveplaylist__ 52 | - Saves the current playlist to m3u file. Saves to `mpv/playlists/` by default. Prompts for filename by default. 53 | - __selectplaylist__ 54 | - Opens a search prompt of saved playlists and loads the playlist on enter. 55 | 56 | The above functions do not have default keybindings(except for showplaylist). There is a couple of ways to bind keys for them: 57 | - Edit the `playlistmanager.lua` settings 58 | - Downside: you have to merge two versions if you want to update the script 59 | - Edit the `playlistmanager.conf` settings 60 | - Upside: you can update the `playlistmanager.lua` file without losing your configurations 61 | - Edit `input.conf` with for example `KEY script-binding playlistmanager/showplaylist` 62 | - in above example you might want to remove the default keybind of `showplaylist` or use `--no-input-default-bindings` 63 | 64 | 65 | ### Dynamic keybindings for navigating the playlist 66 | - __moveup__(UP) 67 | - __movedown__(DOWN) 68 | - __movepageup__ (Page Up) 69 | - __movepagedown__ (Page Down) 70 | - __movebegin__ (Home) 71 | - __moveend__ (End) 72 | - __removefile__(Backspace) 73 | - Removes the file currently selected with the cursor from the playlist 74 | - __playfile__(Enter) 75 | - Opens the file currently selected with the cursor, if cursor on playing file, open the next file 76 | - __selectfile__(RIGHT or LEFT) 77 | - Selects or unselects the file under the cursor 78 | - When moving the cursor the selected file will follow, allowing reordering of the playlist 79 | - __unselectfile__(no default bind) 80 | - Unselects the file under the cursor if it was selected 81 | - __closeplaylist__(ESC) 82 | 83 | Dynamic keybinds will only work when playlist is visible. Dynamic binds cannot be defined in `input.conf`, only in `playlistmanager.lua` or `playlistmanager.conf`. There is a setting to change the binds to static ones, which allows you to define keybindings in `input.conf`. 84 | 85 | ## Script messages 86 | 87 | In order to control the playlistmanager from other script it registers some script messages. 88 | The script messages can also be invoked by keybinds `KEY script-message playlistmanager command value value2`. 89 | 90 | List of commands, values and their effects: 91 | 92 | Command | Value | Value2 | Effect 93 | --- | --- | --- | --- 94 | show | playlist | - / duration / toggle | show for default duration, show for given seconds, toggle playlist visibility 95 | show | playlist-nokeys | - / duration / toggle | same as above but don't bind dynamic keys to navigate playlist 96 | show | filename | - / seconds | shows stripped filename for default or set seconds 97 | sort | startover | - | Sorts the playlist, any value will start playlist from start on sort 98 | shuffle | - | - | Shuffles the playlist 99 | reverse | - | - | Reverses the playlist 100 | loadfiles | - / path | - | Loads files from playing files dir(default), or specified path 101 | save | - / filename | - | Saves the playlist 102 | save-interactive | - | - | Prompts for playlist name and saves on enter 103 | open-menu | - | - | Opens the playlistmanager menu 104 | select-playlist | - | - | Opens the saved playlist list 105 | playlist-next | - | - | Plays next item in playlist (position of current file saved) 106 | playlist-prev | - | - | Plays previous item in playlist (position of current file saved) 107 | playlist-next-random | - | - | Jumps to a random file in the playlist 108 | close | - | - | Hides the playlist if it's being rendered 109 | 110 | 111 | examples: 112 | `RIGHT playlist-next ; script-message playlistmanager show playlist` Shows the playlist after playlist-next. Note that the playlist-next is native mpv command, not the playlistmanager one. 113 | `KEY show-text "Shuffled playlist" ; script-message playlistmanager shuffle` Text message on shuffle 114 | 115 | 116 | ## My other mpv scripts 117 | [collection of scripts](https://github.com/jonniek/mpv-scripts) 118 | -------------------------------------------------------------------------------- /menu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jonniek/mpv-playlistmanager/a2755132c18c050269e5fea6093672f0a36ed7db/menu.png -------------------------------------------------------------------------------- /playlistmanager.conf: -------------------------------------------------------------------------------- 1 | #### ------- Mpv-Playlistmanager configuration ------- #### 2 | 3 | #navigation keybindings force override only while playlist is visible 4 | #if "no" then you can display the playlist by any of the navigation keys 5 | dynamic_binds=yes 6 | 7 | # To bind multiple keys separate them by a space 8 | 9 | # main keys to show playlist and command menu 10 | key_showplaylist=SHIFT+ENTER 11 | key_openmenu= 12 | 13 | # display playlist while key is held down 14 | key_peek_at_playlist= 15 | 16 | # dynamic keys 17 | key_moveup=UP 18 | key_movedown=DOWN 19 | key_movepageup=PGUP 20 | key_movepagedown=PGDWN 21 | key_movebegin=HOME 22 | key_moveend=END 23 | key_selectfile=RIGHT LEFT 24 | key_unselectfile= 25 | key_playfile=ENTER 26 | key_removefile=BS 27 | key_closeplaylist=ESC SHIFT+ENTER 28 | 29 | # extra functionality keys 30 | key_sortplaylist= 31 | key_shuffleplaylist= 32 | key_reverseplaylist= 33 | key_loadfiles= 34 | key_saveplaylist= 35 | key_selectplaylist= 36 | 37 | #json format for replacing, check .lua for explanation 38 | #example json=[{"ext":{"all":true},"rules":[{"_":" "}]},{"ext":{"mp4":true,"mkv":true},"rules":[{"^(.+)%..+$":"%1"},{"%s*[%[%(].-[%]%)]%s*":""},{"(%w)%.(%w)":"%1 %2"}]},{"protocol":{"http":true,"https":true},"rules":[{"^%a+://w*%.?":""}]}] 39 | #empty for no replace 40 | filename_replace=[{"protocol":{"all":true},"rules":[{"%%(%x%x)":"hex_to_char"}]}] 41 | 42 | #filetypes to search from directory 43 | loadfiles_filetypes=["jpg","jpeg","png","tif","tiff","gif","webp","svg","bmp","mp3","wav","ogm","flac","m4a","wma","ogg","opus","mkv","avi","mp4","ogv","webm","rmvb","flv","wmv","mpeg","mpg","m4v","3gp"] 44 | 45 | #loadfiles at startup if 1 or more items in playlist 46 | loadfiles_on_start=no 47 | #loadfiles from working directory on idle startup 48 | loadfiles_on_idle_start=no 49 | #always put loaded files after currently playing file 50 | loadfiles_always_append=no 51 | 52 | #sort playlist when any files are added to playlist after initial load 53 | sortplaylist_on_file_add=no 54 | 55 | #default sorting method, must be one of: "name-asc", "name-desc", "date-asc", "date-desc", "size-asc", "size-desc". 56 | default_sort=name-asc 57 | 58 | #linux | windows | auto 59 | system=auto 60 | 61 | #Use ~ for home directory. Leave as empty to use mpv/playlists 62 | playlist_savepath= 63 | 64 | #prompt for playlist filename on save 65 | playlist_save_interactive=yes 66 | 67 | #constant filename to save playlist as. Note that it will override existing playlist. Leave empty for generated name. 68 | playlist_save_filename= 69 | 70 | #save playlist automatically after current file was unloaded 71 | save_playlist_on_file_end=no 72 | 73 | #show file title every time a new file is loaded 74 | show_title_on_file_load=no 75 | #show playlist every time a new file is loaded 76 | show_playlist_on_file_load=no 77 | #close playlist when selecting file to play 78 | close_playlist_on_playfile=no 79 | 80 | #sync cursor when file is loaded from outside reasons(file-ending, playlist-next shortcut etc.) 81 | sync_cursor_on_load=yes 82 | 83 | #allow the playlist cursor to loop from end to start and vice versa 84 | loop_cursor=yes 85 | 86 | #allow playlistmanager to write watch later config when navigating between files 87 | allow_write_watch_later_config=yes 88 | 89 | #reset cursor navigation when closing or opening playlist 90 | reset_cursor_on_close=yes 91 | reset_cursor_on_open=yes 92 | 93 | #prefer to display titles for following files: "all", "url", "none". Sorting still uses filename 94 | prefer_titles=url 95 | 96 | #youtube-dl executable for title resolving if enabled, probably "youtube-dl" or "yt-dlp", can be absolute path 97 | youtube_dl_executable=yt-dlp 98 | 99 | #call youtube-dl to resolve the titles of urls in the playlist 100 | #if yes, prefer_titles must be set to "url" or "all" for this to work 101 | resolve_url_titles=no 102 | 103 | #call ffprobe to resolve the titles of local files in the playlist (if they exist in the metadata) 104 | #if yes, prefer_titles must be set to "all" for this to work 105 | resolve_local_titles=no 106 | 107 | #timeout in seconds for url title resolving 108 | resolve_title_timeout=15 109 | 110 | #how many url titles can be resolved at a time. Higher number might lead to stutters. 111 | concurrent_title_resolve_limit=10 112 | 113 | #osd timeout on inactivity in seconds, use 0 for no timeout 114 | playlist_display_timeout=0 115 | 116 | #when peeking at playlist, show playlist at the very least for display timeout 117 | peek_respect_display_timeout=no 118 | 119 | #the maximum amount of lines playlist will render. -1 will automatically calculate lines. 120 | showamount=-1 121 | 122 | #playlist ass style overrides 123 | #example {\q2\an7\fnUbuntu\fs10\b0\bord1} equals: line-wrap=no, align=top left, font=Ubuntu, size=10, bold=no, border=1 124 | #read http://docs.aegisub.org/3.2/ASS_Tags/ for reference of tags 125 | #undeclared tags will use default osd settings 126 | #these styles will be used for the whole playlist 127 | #\q2 style is recommended since filename wrapping may lead to unexpected rendering 128 | #\an7 style is recommended to align to top left, otherwise osd-align-x/y is respected 129 | style_ass_tags={\q2\an7} 130 | #paddings for left right and top bottom 131 | text_padding_x=30 132 | text_padding_y=60 133 | 134 | #screen dim when menu is open 0.0 - 1.0 (0 is no dim, 1 is black) 135 | curtain_opacity=0.0 136 | 137 | #set title of window with stripped name 138 | set_title_stripped=no 139 | title_prefix= 140 | title_suffix= - mpv 141 | 142 | #slice long filenames, and how many chars to show 143 | slice_longfilenames=no 144 | slice_longfilenames_amount=70 145 | 146 | #Playing header. One newline will be added after the string. 147 | #%mediatitle or %filename = title or name of playing file 148 | #%pos = position of playing file 149 | #%cursor = position of navigation 150 | #%plen = playlist lenght 151 | #%N = newline 152 | playlist_header=[%cursor/%plen] 153 | 154 | #Playlist file templates 155 | #%pos = position of file with leading zeros 156 | #%name = title or name of file 157 | #%N = newline 158 | #you can also use the ass tags mentioned above. For example: 159 | # selected_file={\c&HFF00FF&}➔ %name | to add a color for selected file. However, if you 160 | # use ass tags you need to reset them for every line (see https://github.com/jonniek/mpv-playlistmanager/issues/20) 161 | normal_file=○ %name 162 | hovered_file=● %name 163 | selected_file=➔ %name 164 | playing_file=▷ %name 165 | playing_hovered_file=▶ %name 166 | playing_selected_file=➤ %name 167 | 168 | #what to show when playlist is truncated 169 | playlist_sliced_prefix=... 170 | playlist_sliced_suffix=... 171 | 172 | #output visual feedback to OSD when saving, shuffling, reversing playlists 173 | display_osd_feedback=yes 174 | 175 | -------------------------------------------------------------------------------- /playlistmanager.lua: -------------------------------------------------------------------------------- 1 | local settings = { 2 | --navigation keybindings force override only while playlist is visible 3 | --if "no" then you can display the playlist by any of the navigation keys 4 | dynamic_binds = true, 5 | 6 | -- to bind multiple keys separate them by a space 7 | 8 | -- main keys to show playlist and command menu 9 | key_showplaylist = "SHIFT+ENTER", 10 | key_openmenu = "", 11 | 12 | -- display playlist while key is held down 13 | key_peek_at_playlist = "", 14 | 15 | -- dynamic keys 16 | key_moveup = "UP", 17 | key_movedown = "DOWN", 18 | key_movepageup = "PGUP", 19 | key_movepagedown = "PGDWN", 20 | key_movebegin = "HOME", 21 | key_moveend = "END", 22 | key_selectfile = "RIGHT LEFT", 23 | key_unselectfile = "", 24 | key_playfile = "ENTER", 25 | key_removefile = "BS", 26 | key_closeplaylist = "ESC SHIFT+ENTER", 27 | 28 | -- extra functionality keys 29 | key_sortplaylist = "", 30 | key_shuffleplaylist = "", 31 | key_reverseplaylist = "", 32 | key_loadfiles = "", 33 | key_saveplaylist = "", 34 | key_selectplaylist = "", 35 | 36 | --replaces matches on filenames based on extension, put as empty string to not replace anything 37 | --replace rules are executed in provided order 38 | --replace rule key is the pattern and value is the replace value 39 | --uses :gsub('pattern', 'replace'), read more http://lua-users.org/wiki/StringLibraryTutorial 40 | --'all' will match any extension or protocol if it has one 41 | --uses json and parses it into a lua table to be able to support .conf file 42 | 43 | filename_replace = [[ 44 | [ 45 | { 46 | "protocol": { "all": true }, 47 | "rules": [ 48 | { "%%(%x%x)": "hex_to_char" } 49 | ] 50 | } 51 | ] 52 | ]], 53 | 54 | --[=====[ START OF SAMPLE REPLACE - Remove this line to use it 55 | --Sample replace: replaces underscore to space on all files 56 | --for mp4 and webm; remove extension, remove brackets and surrounding whitespace, change dot between alphanumeric to space 57 | filename_replace = [[ 58 | [ 59 | { 60 | "ext": { "all": true}, 61 | "rules": [ 62 | { "_" : " " } 63 | ] 64 | },{ 65 | "ext": { "mp4": true, "mkv": true }, 66 | "rules": [ 67 | { "^(.+)%..+$": "%1" }, 68 | { "%s*[%[%(].-[%]%)]%s*": "" }, 69 | { "(%w)%.(%w)": "%1 %2" } 70 | ] 71 | },{ 72 | "protocol": { "http": true, "https": true }, 73 | "rules": [ 74 | { "^%a+://w*%.?": "" } 75 | ] 76 | } 77 | ] 78 | ]], 79 | --END OF SAMPLE REPLACE ]=====] 80 | 81 | --json array of filetypes to search from directory 82 | loadfiles_filetypes = [[ 83 | [ 84 | "jpg", "jpeg", "png", "tif", "tiff", "gif", "webp", "svg", "bmp", 85 | "mp3", "wav", "ogm", "flac", "m4a", "wma", "ogg", "opus", 86 | "mkv", "avi", "mp4", "ogv", "webm", "rmvb", "flv", "wmv", "mpeg", "mpg", "m4v", "3gp" 87 | ] 88 | ]], 89 | 90 | --loadfiles at startup if 1 or more items in playlist 91 | loadfiles_on_start = false, 92 | -- loadfiles from working directory on idle startup 93 | loadfiles_on_idle_start = false, 94 | --always put loaded files after currently playing file 95 | loadfiles_always_append = false, 96 | 97 | --sort playlist when files are added to playlist 98 | sortplaylist_on_file_add = false, 99 | 100 | --default sorting method, must be one of: "name-asc", "name-desc", "date-asc", "date-desc", "size-asc", "size-desc". 101 | default_sort = "name-asc", 102 | 103 | --"linux | windows | auto" 104 | system = "auto", 105 | 106 | --Use ~ for home directory. Leave as empty to use mpv/playlists 107 | playlist_savepath = "", 108 | 109 | -- prompt for playlist filename on save 110 | playlist_save_interactive = true, 111 | 112 | -- constant filename to save playlist as. Note that it will override existing playlist. Leave empty for generated name. 113 | playlist_save_filename = "", 114 | 115 | --save playlist automatically after current file was unloaded 116 | save_playlist_on_file_end = false, 117 | 118 | 119 | --show file title every time a new file is loaded 120 | show_title_on_file_load = false, 121 | --show playlist every time a new file is loaded 122 | show_playlist_on_file_load = false, 123 | --close playlist when selecting file to play 124 | close_playlist_on_playfile = false, 125 | 126 | --sync cursor when file is loaded from outside reasons(file-ending, playlist-next shortcut etc.) 127 | --has the sideeffect of moving cursor if file happens to change when navigating 128 | --good side is cursor always following current file when going back and forth files with playlist-next/prev 129 | sync_cursor_on_load = true, 130 | 131 | --allow the playlist cursor to loop from end to start and vice versa 132 | loop_cursor = true, 133 | 134 | 135 | -- allow playlistmanager to write watch later config when navigating between files 136 | allow_write_watch_later_config = true, 137 | 138 | -- reset cursor navigation when closing or opening playlist 139 | reset_cursor_on_close = true, 140 | reset_cursor_on_open = true, 141 | 142 | --prefer to display titles for following files: "all", "url", "none". Sorting still uses filename. 143 | prefer_titles = "url", 144 | 145 | --youtube-dl executable for title resolving if enabled, probably "youtube-dl" or "yt-dlp", can be absolute path 146 | youtube_dl_executable = "yt-dlp", 147 | 148 | --call youtube-dl to resolve the titles of urls in the playlist 149 | resolve_url_titles = false, 150 | 151 | --call ffprobe to resolve the titles of local files in the playlist (if they exist in the metadata) 152 | resolve_local_titles = false, 153 | 154 | -- timeout in seconds for url title resolving 155 | resolve_title_timeout = 15, 156 | 157 | -- how many url titles can be resolved at a time. Higher number might lead to stutters. 158 | concurrent_title_resolve_limit = 10, 159 | 160 | --osd timeout on inactivity in seconds, use 0 for no timeout 161 | playlist_display_timeout = 0, 162 | 163 | -- when peeking at playlist, show playlist at the very least for display timeout 164 | peek_respect_display_timeout = false, 165 | 166 | -- the maximum amount of lines playlist will render. -1 will automatically calculate lines. 167 | showamount = -1, 168 | 169 | --playlist ass style overrides inside curly brackets, \keyvalue is one field, extra \ for escape in lua 170 | --example {\\q2\\an7\\fnUbuntu\\fs10\\b0\\bord1} equals: line-wrap=no, align=top left, font=Ubuntu, size=10, bold=no, border=1 171 | --read http://docs.aegisub.org/3.2/ASS_Tags/ for reference of tags 172 | --undeclared tags will use default osd settings 173 | --these styles will be used for the whole playlist 174 | --\\q2 style is recommended since filename wrapping may lead to unexpected rendering 175 | --\\an7 style is recommended to align to top left otherwise, osd-align-x/y is respected 176 | style_ass_tags = "{\\q2\\an7}", 177 | --paddings for left right and top bottom 178 | text_padding_x = 30, 179 | text_padding_y = 60, 180 | 181 | --screen dim when menu is open 0.0 - 1.0 (0 is no dim, 1 is black) 182 | curtain_opacity=0.0, 183 | 184 | --set title of window with stripped name 185 | set_title_stripped = false, 186 | title_prefix = "", 187 | title_suffix = " - mpv", 188 | 189 | --slice long filenames, and how many chars to show 190 | slice_longfilenames = false, 191 | slice_longfilenames_amount = 70, 192 | 193 | --Playlist header template 194 | --%mediatitle or %filename = title or name of playing file 195 | --%pos = position of playing file 196 | --%cursor = position of navigation 197 | --%plen = playlist length 198 | --%N = newline 199 | playlist_header = "[%cursor/%plen]", 200 | 201 | --Playlist file templates 202 | --%pos = position of file with leading zeros 203 | --%name = title or name of file 204 | --%N = newline 205 | --you can also use the ass tags mentioned above. For example: 206 | -- selected_file="{\\c&HFF00FF&}➔ %name" | to add a color for selected file. However, if you 207 | -- use ass tags you need to reset them for every line (see https://github.com/jonniek/mpv-playlistmanager/issues/20) 208 | normal_file = "○ %name", 209 | hovered_file = "● %name", 210 | selected_file = "➔ %name", 211 | playing_file = "▷ %name", 212 | playing_hovered_file = "▶ %name", 213 | playing_selected_file = "➤ %name", 214 | 215 | 216 | -- what to show when playlist is truncated 217 | playlist_sliced_prefix = "...", 218 | playlist_sliced_suffix = "...", 219 | 220 | --output visual feedback to OSD for tasks 221 | display_osd_feedback = true, 222 | } 223 | local opts = require("mp.options") 224 | opts.read_options(settings, "playlistmanager", function(list) update_opts(list) end) 225 | 226 | local utils = require("mp.utils") 227 | local msg = require("mp.msg") 228 | local assdraw = require("mp.assdraw") 229 | local input = require("mp.input") 230 | 231 | local alignment_table = { 232 | [1] = { ["x"] = "left", ["y"] = "bottom" }, 233 | [2] = { ["x"] = "center", ["y"] = "bottom" }, 234 | [3] = { ["x"] = "right", ["y"] = "bottom" }, 235 | [4] = { ["x"] = "left", ["y"] = "center" }, 236 | [5] = { ["x"] = "center", ["y"] = "center" }, 237 | [6] = { ["x"] = "right", ["y"] = "center" }, 238 | [7] = { ["x"] = "left", ["y"] = "top" }, 239 | [8] = { ["x"] = "center", ["y"] = "top" }, 240 | [9] = { ["x"] = "right", ["y"] = "top" }, 241 | } 242 | 243 | --check os 244 | if settings.system=="auto" then 245 | local o = {} 246 | if mp.get_property_native('options/vo-mmcss-profile', o) ~= o then 247 | settings.system = "windows" 248 | else 249 | settings.system = "linux" 250 | end 251 | end 252 | 253 | -- auto calculate showamount 254 | if settings.showamount == -1 then 255 | -- same as draw_playlist() height 256 | local h = 720 257 | 258 | local playlist_h = h 259 | -- both top and bottom with same padding 260 | playlist_h = playlist_h - settings.text_padding_y * 2 261 | 262 | -- osd-font-size is based on 720p height 263 | -- see https://mpv.io/manual/stable/#options-osd-font-size 264 | -- details in https://mpv.io/manual/stable/#options-sub-font-size 265 | -- draw_playlist() is based on 720p, need some conversion 266 | local fs = mp.get_property_native('osd-font-size') * h / 720 267 | -- get the ass font size 268 | if settings.style_ass_tags ~= nil then 269 | local ass_fs_tag = settings.style_ass_tags:match('\\fs%d+') 270 | if ass_fs_tag ~= nil then 271 | fs = tonumber(ass_fs_tag:match('%d+')) 272 | end 273 | end 274 | 275 | settings.showamount = math.floor(playlist_h / fs) 276 | 277 | -- exclude the header line 278 | if settings.playlist_header ~= "" then 279 | settings.showamount = settings.showamount - 1 280 | -- probably some newlines (%N or \N) in the header 281 | for _ in settings.playlist_header:gmatch('%%N') do 282 | settings.showamount = settings.showamount - 1 283 | end 284 | for _ in settings.playlist_header:gmatch('\\N') do 285 | settings.showamount = settings.showamount - 1 286 | end 287 | end 288 | 289 | msg.info('auto showamount: ' .. settings.showamount) 290 | end 291 | 292 | --global variables 293 | local playlist_overlay = mp.create_osd_overlay("ass-events") 294 | local playlist_visible = false 295 | local strippedname = nil 296 | local path = nil 297 | local directory = nil 298 | local filename = nil 299 | local pos = 0 300 | local plen = 0 301 | local cursor = 0 302 | --table for saved media titles for later if we prefer them 303 | local title_table = {} 304 | -- table for urls and local file paths that we have requested to be resolved to titles 305 | local requested_titles = {} 306 | 307 | local filetype_lookup = {} 308 | 309 | function refresh_UI() 310 | if not playlist_visible then return end 311 | refresh_globals() 312 | if plen == 0 then return end 313 | draw_playlist() 314 | end 315 | 316 | function update_opts(changelog) 317 | msg.verbose('updating options') 318 | 319 | --parse filename json 320 | if changelog.filename_replace then 321 | if(settings.filename_replace~="") then 322 | settings.filename_replace = utils.parse_json(settings.filename_replace) 323 | else 324 | settings.filename_replace = false 325 | end 326 | end 327 | 328 | --parse loadfiles json 329 | if changelog.loadfiles_filetypes then 330 | settings.loadfiles_filetypes = utils.parse_json(settings.loadfiles_filetypes) 331 | 332 | filetype_lookup = {} 333 | --create loadfiles set 334 | for _, ext in ipairs(settings.loadfiles_filetypes) do 335 | filetype_lookup[ext] = true 336 | end 337 | end 338 | 339 | if changelog.resolve_url_titles then 340 | resolve_titles() 341 | end 342 | 343 | if changelog.resolve_local_titles then 344 | resolve_titles() 345 | end 346 | 347 | if changelog.playlist_display_timeout then 348 | keybindstimer = mp.add_periodic_timer(settings.playlist_display_timeout, remove_keybinds) 349 | keybindstimer:kill() 350 | end 351 | 352 | refresh_UI() 353 | end 354 | 355 | update_opts({filename_replace = true, loadfiles_filetypes = true}) 356 | 357 | ----- winapi start ----- 358 | -- in windows system, we can use the sorting function provided by the win32 API 359 | -- see https://learn.microsoft.com/en-us/windows/win32/api/shlwapi/nf-shlwapi-strcmplogicalw 360 | local winapisort = nil 361 | if settings.system == "windows" then 362 | -- ffiok is false usually means the mpv builds without luajit 363 | local ffiok, ffi = pcall(require, "ffi") 364 | if ffiok then 365 | ffi.cdef[[ 366 | int MultiByteToWideChar(unsigned int CodePage, unsigned long dwFlags, const char *lpMultiByteStr, int cbMultiByte, wchar_t *lpWideCharStr, int cchWideChar); 367 | int StrCmpLogicalW(const wchar_t * psz1, const wchar_t * psz2); 368 | ]] 369 | 370 | local shlwapi = ffi.load("shlwapi.dll") 371 | 372 | function MultiByteToWideChar(MultiByteStr) 373 | local UTF8_CODEPAGE = 65001 374 | if MultiByteStr then 375 | local utf16_len = ffi.C.MultiByteToWideChar(UTF8_CODEPAGE, 0, MultiByteStr, -1, nil, 0) 376 | if utf16_len > 0 then 377 | local utf16_str = ffi.new("wchar_t[?]", utf16_len) 378 | if ffi.C.MultiByteToWideChar(UTF8_CODEPAGE, 0, MultiByteStr, -1, utf16_str, utf16_len) > 0 then 379 | return utf16_str 380 | end 381 | end 382 | end 383 | return "" 384 | end 385 | 386 | winapisort = function (a, b) 387 | return shlwapi.StrCmpLogicalW(MultiByteToWideChar(a), MultiByteToWideChar(b)) < 0 388 | end 389 | 390 | end 391 | end 392 | ----- winapi end ----- 393 | 394 | local sort_modes = { 395 | { 396 | id="name-asc", 397 | title="name ascending", 398 | sort_fn=function (a, b, playlist) 399 | if winapisort ~= nil then 400 | return winapisort(playlist[a].string, playlist[b].string) 401 | end 402 | return alphanumsort(playlist[a].string, playlist[b].string) 403 | end, 404 | }, 405 | { 406 | id="name-desc", 407 | title="name descending", 408 | sort_fn=function (a, b, playlist) 409 | if winapisort ~= nil then 410 | return winapisort(playlist[b].string, playlist[a].string) 411 | end 412 | return alphanumsort(playlist[b].string, playlist[a].string) 413 | end, 414 | }, 415 | { 416 | id="date-asc", 417 | title="date ascending", 418 | sort_fn=function (a, b) 419 | return (get_file_info(a).mtime or 0) < (get_file_info(b).mtime or 0) 420 | end, 421 | }, 422 | { 423 | id="date-desc", 424 | title="date descending", 425 | sort_fn=function (a, b) 426 | return (get_file_info(a).mtime or 0) > (get_file_info(b).mtime or 0) 427 | end, 428 | }, 429 | { 430 | id="size-asc", 431 | title="size ascending", 432 | sort_fn=function (a, b) 433 | return (get_file_info(a).size or 0) < (get_file_info(b).size or 0) 434 | end, 435 | }, 436 | { 437 | id="size-desc", 438 | title="size descending", 439 | sort_fn=function (a, b) 440 | return (get_file_info(a).size or 0) > (get_file_info(b).size or 0) 441 | end, 442 | }, 443 | } 444 | 445 | local sort_mode = 1 446 | for mode, sort_data in pairs(sort_modes) do 447 | if sort_data.id == settings.default_sort then 448 | sort_mode = mode 449 | end 450 | end 451 | 452 | function is_protocol(path) 453 | return type(path) == 'string' and path:match('^%a[%a%d-_]+://') ~= nil 454 | end 455 | 456 | function on_file_loaded() 457 | refresh_globals() 458 | if settings.sync_cursor_on_load then cursor=pos end 459 | refresh_UI() -- refresh only after moving cursor 460 | 461 | filename = mp.get_property("filename") 462 | path = mp.get_property('path') 463 | local media_title = mp.get_property("media-title") 464 | if is_protocol(path) and not title_table[path] and path ~= media_title then 465 | title_table[path] = media_title 466 | end 467 | 468 | strippedname = stripfilename(mp.get_property('media-title')) 469 | if settings.show_title_on_file_load then 470 | mp.commandv('show-text', strippedname) 471 | end 472 | if settings.show_playlist_on_file_load then 473 | showplaylist() 474 | end 475 | if settings.set_title_stripped then 476 | mp.set_property("title", settings.title_prefix..strippedname..settings.title_suffix) 477 | end 478 | end 479 | 480 | function on_start_file() 481 | refresh_globals() 482 | filename = mp.get_property("filename") 483 | path = mp.get_property('path') 484 | --if not a url then join path with working directory 485 | if not is_protocol(path) then 486 | path = utils.join_path(mp.get_property('working-directory'), path) 487 | directory = utils.split_path(path) 488 | else 489 | directory = nil 490 | end 491 | 492 | if settings.loadfiles_on_start and plen == 1 then 493 | local ext = filename:match("%.([^%.]+)$") 494 | -- a directory or playlist has been loaded, let's not do anything as mpv will expand it into files 495 | if ext and filetype_lookup[ext:lower()] then 496 | msg.info("Loading files from playing files directory") 497 | playlist() 498 | end 499 | end 500 | end 501 | 502 | function on_end_file() 503 | if settings.save_playlist_on_file_end then save_playlist() end 504 | strippedname = nil 505 | path = nil 506 | directory = nil 507 | filename = nil 508 | end 509 | 510 | function refresh_globals() 511 | pos = mp.get_property_number('playlist-pos', 0) 512 | plen = mp.get_property_number('playlist-count', 0) 513 | end 514 | 515 | function escapepath(dir, escapechar) 516 | return string.gsub(dir, escapechar, '\\'..escapechar) 517 | end 518 | 519 | function replace_table_has_value(value, valid_values) 520 | if value == nil or valid_values == nil then 521 | return false 522 | end 523 | return valid_values['all'] or valid_values[value] 524 | end 525 | 526 | local filename_replace_functions = { 527 | --decode special characters in url 528 | hex_to_char = function(x) return string.char(tonumber(x, 16)) end 529 | } 530 | 531 | -- from http://lua-users.org/wiki/LuaUnicode 532 | local UTF8_PATTERN = '[%z\1-\127\194-\244][\128-\191]*' 533 | 534 | -- return a substring based on utf8 characters 535 | -- like string.sub, but negative index is not supported 536 | local function utf8_sub(s, i, j) 537 | if i > j then 538 | return s 539 | end 540 | 541 | local t = {} 542 | local idx = 1 543 | for char in s:gmatch(UTF8_PATTERN) do 544 | if i <= idx and idx <= j then 545 | local width = #char > 2 and 2 or 1 546 | idx = idx + width 547 | t[#t + 1] = char 548 | end 549 | end 550 | return table.concat(t) 551 | end 552 | 553 | --strip a filename based on its extension or protocol according to rules in settings 554 | function stripfilename(pathfile, media_title) 555 | if pathfile == nil then return '' end 556 | local ext = pathfile:match("%.([^%.]+)$") 557 | local protocol = pathfile:match("^(%a%a+)://") 558 | if not ext then ext = "" end 559 | local tmp = pathfile 560 | if settings.filename_replace and not media_title then 561 | for k,v in ipairs(settings.filename_replace) do 562 | if replace_table_has_value(ext, v['ext']) or replace_table_has_value(protocol, v['protocol']) then 563 | for ruleindex, indexrules in ipairs(v['rules']) do 564 | for rule, override in pairs(indexrules) do 565 | override = filename_replace_functions[override] or override 566 | tmp = tmp:gsub(rule, override) 567 | end 568 | end 569 | end 570 | end 571 | end 572 | local tmp_clip = utf8_sub(tmp, 1, settings.slice_longfilenames_amount) 573 | if settings.slice_longfilenames and tmp ~= tmp_clip then 574 | tmp = tmp_clip .. "..." 575 | end 576 | return tmp 577 | end 578 | 579 | --gets the file info of an item 580 | function get_file_info(item) 581 | local path = mp.get_property('playlist/' .. item - 1 .. '/filename') 582 | if is_protocol(path) then return {} end 583 | local file_info = utils.file_info(path) 584 | if not file_info then 585 | msg.warn('failed to read file info for', path) 586 | return {} 587 | end 588 | 589 | return file_info 590 | end 591 | 592 | --gets a nicename of playlist entry at 0-based position i 593 | function get_name_from_index(i, notitle) 594 | refresh_globals() 595 | if plen <= i then msg.error("no index in playlist", i, "length", plen); return nil end 596 | local _, name = nil 597 | local title = mp.get_property('playlist/'..i..'/title') 598 | local name = mp.get_property('playlist/'..i..'/filename') 599 | 600 | local should_use_title = settings.prefer_titles == 'all' or is_protocol(name) and settings.prefer_titles == 'url' 601 | 602 | --check if file has a media title stored 603 | if not title and should_use_title and title_table[name] then 604 | title = title_table[name] 605 | end 606 | 607 | --if we have media title use a more conservative strip 608 | if title and not notitle and should_use_title then 609 | -- Escape a string for verbatim display on the OSD 610 | -- Ref: https://github.com/mpv-player/mpv/blob/94677723624fb84756e65c8f1377956667244bc9/player/lua/stats.lua#L145 611 | return stripfilename(title, true):gsub("\\", '\\\239\187\191'):gsub("{", "\\{"):gsub("^ ", "\\h") 612 | end 613 | 614 | --remove paths if they exist, keeping protocols for stripping 615 | if string.sub(name, 1, 1) == '/' or name:match("^%a:[/\\]") then 616 | _, name = utils.split_path(name) 617 | end 618 | return stripfilename(name):gsub("\\", '\\\239\187\191'):gsub("{", "\\{"):gsub("^ ", "\\h") 619 | end 620 | 621 | function parse_header(string) 622 | local esc_title = stripfilename(mp.get_property("media-title"), true):gsub("%%", "%%%%") 623 | local esc_file = stripfilename(mp.get_property("filename")):gsub("%%", "%%%%") 624 | return string:gsub("%%N", "\\N") 625 | -- add a blank character at the end of each '\N' to ensure that the height of the empty line is the same as the non empty line 626 | :gsub("\\N", "\\N ") 627 | :gsub("%%pos", mp.get_property_number("playlist-pos",0)+1) 628 | :gsub("%%plen", mp.get_property("playlist-count")) 629 | :gsub("%%cursor", cursor+1) 630 | :gsub("%%mediatitle", esc_title) 631 | :gsub("%%filename", esc_file) 632 | -- undo name escape 633 | :gsub("%%%%", "%%") 634 | end 635 | 636 | function parse_filename(string, name, index) 637 | local base = tostring(plen):len() 638 | local esc_name = stripfilename(name):gsub("%%", "%%%%") 639 | return string:gsub("%%N", "\\N") 640 | :gsub("%%pos", string.format("%0"..base.."d", index+1)) 641 | :gsub("%%name", esc_name) 642 | -- undo name escape 643 | :gsub("%%%%", "%%") 644 | end 645 | 646 | function parse_filename_by_index(index) 647 | local template = settings.normal_file 648 | 649 | local is_idle = mp.get_property_native('idle-active') 650 | local position = is_idle and -1 or pos 651 | 652 | if index == position then 653 | if index == cursor then 654 | if selection then 655 | template = settings.playing_selected_file 656 | else 657 | template = settings.playing_hovered_file 658 | end 659 | else 660 | template = settings.playing_file 661 | end 662 | elseif index == cursor then 663 | if selection then 664 | template = settings.selected_file 665 | else 666 | template = settings.hovered_file 667 | end 668 | end 669 | 670 | return parse_filename(template, get_name_from_index(index), index) 671 | end 672 | 673 | function is_terminal_mode() 674 | local width, height, aspect_ratio = mp.get_osd_size() 675 | return width == 0 and height == 0 and aspect_ratio == 0 676 | end 677 | 678 | function draw_playlist() 679 | refresh_globals() 680 | 681 | -- if there is no playing file, then cursor can be -1. That would break rendering of playlist. 682 | if cursor == -1 then 683 | cursor = 0 684 | end 685 | 686 | local ass = assdraw.ass_new() 687 | local terminaloutput = "" 688 | 689 | local _, _, a = mp.get_osd_size() 690 | local h = 720 691 | local w = math.ceil(h * a) 692 | 693 | if settings.curtain_opacity ~= nil and settings.curtain_opacity ~= 0 and settings.curtain_opacity <= 1.0 then 694 | -- curtain dim from https://github.com/christoph-heinrich/mpv-quality-menu/blob/501794bfbef468ee6a61e54fc8821fe5cd72c4ed/quality-menu.lua#L699-L707 695 | local alpha = 255 - math.ceil(255 * settings.curtain_opacity) 696 | ass.text = string.format('{\\pos(0,0)\\rDefault\\an7\\1c&H000000&\\alpha&H%X&}', alpha) 697 | ass:draw_start() 698 | ass:rect_cw(0, 0, w, h) 699 | ass:draw_stop() 700 | ass:new_event() 701 | end 702 | 703 | ass:append(settings.style_ass_tags) 704 | 705 | -- add \clip style 706 | -- make both left and right follow text_padding_x 707 | -- both top and bottom follow text_padding_y 708 | local border_size = mp.get_property_number('osd-border-size') 709 | if settings.style_ass_tags ~= nil then 710 | local bord = tonumber(settings.style_ass_tags:match('\\bord(%d+%.?%d*)')) 711 | if bord ~= nil then border_size = bord end 712 | end 713 | ass:append(string.format('{\\clip(%f,%f,%f,%f)}', 714 | settings.text_padding_x - border_size, settings.text_padding_y - border_size, 715 | w - 1 - settings.text_padding_x + border_size, h - 1 - settings.text_padding_y + border_size)) 716 | 717 | -- align from mpv.conf 718 | local align_x = mp.get_property("osd-align-x") 719 | local align_y = mp.get_property("osd-align-y") 720 | -- align from style_ass_tags 721 | if settings.style_ass_tags ~= nil then 722 | local an = tonumber(settings.style_ass_tags:match('\\an(%d)')) 723 | if an ~= nil and alignment_table[an] ~= nil then 724 | align_x = alignment_table[an]["x"] 725 | align_y = alignment_table[an]["y"] 726 | end 727 | end 728 | -- range of x [0, w-1] 729 | local pos_x 730 | if align_x == 'left' then 731 | pos_x = settings.text_padding_x 732 | elseif align_x == 'right' then 733 | pos_x = w - 1 - settings.text_padding_x 734 | else 735 | pos_x = math.floor((w - 1) / 2) 736 | end 737 | -- range of y [0, h-1] 738 | local pos_y 739 | if align_y == 'top' then 740 | pos_y = settings.text_padding_y 741 | elseif align_y == 'bottom' then 742 | pos_y = h - 1 - settings.text_padding_y 743 | else 744 | pos_y = math.floor((h - 1) / 2) 745 | end 746 | ass:pos(pos_x, pos_y) 747 | 748 | if settings.playlist_header ~= "" then 749 | local header = parse_header(settings.playlist_header) 750 | ass:append(header.."\\N") 751 | terminaloutput = terminaloutput..header.."\n" 752 | end 753 | 754 | -- (visible index, playlist index) pairs of playlist entries that should be rendered 755 | local visible_indices = {} 756 | 757 | local one_based_cursor = cursor + 1 758 | table.insert(visible_indices, one_based_cursor) 759 | 760 | local offset = 1; 761 | local visible_indices_length = 1; 762 | while visible_indices_length < settings.showamount and visible_indices_length < plen do 763 | -- add entry for offset steps below the cursor 764 | local below = one_based_cursor + offset 765 | if below <= plen then 766 | table.insert(visible_indices, below) 767 | visible_indices_length = visible_indices_length + 1; 768 | end 769 | 770 | -- add entry for offset steps above the cursor 771 | -- also need to double check that there is still space, this happens if we have even numbered limit 772 | local above = one_based_cursor - offset 773 | if above >= 1 and visible_indices_length < settings.showamount and visible_indices_length < plen then 774 | table.insert(visible_indices, 1, above) 775 | visible_indices_length = visible_indices_length + 1; 776 | end 777 | 778 | offset = offset + 1 779 | end 780 | 781 | -- both indices are 1 based 782 | for display_index, playlist_index in pairs(visible_indices) do 783 | if display_index == 1 and playlist_index ~= 1 then 784 | ass:append(settings.playlist_sliced_prefix.."\\N") 785 | terminaloutput = terminaloutput..settings.playlist_sliced_prefix.."\n" 786 | elseif display_index == settings.showamount and playlist_index ~= plen then 787 | ass:append(settings.playlist_sliced_suffix) 788 | terminaloutput = terminaloutput..settings.playlist_sliced_suffix.."\n" 789 | else 790 | -- parse_filename_by_index expects 0 based index 791 | local fname = parse_filename_by_index(playlist_index - 1) 792 | ass:append(fname.."\\N") 793 | terminaloutput = terminaloutput..fname.."\n" 794 | end 795 | end 796 | 797 | if is_terminal_mode() then 798 | local timeout_setting = settings.playlist_display_timeout 799 | local timeout = timeout_setting == 0 and 2147483 or timeout_setting 800 | -- TODO: probably have to strip ass tags from terminal output 801 | -- would maybe be possible to use terminal color output instead 802 | mp.osd_message(terminaloutput, timeout) 803 | else 804 | playlist_overlay.data = ass.text 805 | playlist_overlay:update() 806 | end 807 | end 808 | 809 | local peek_display_timer = nil 810 | local peek_button_pressed = false 811 | 812 | function peek_timeout() 813 | peek_display_timer:kill() 814 | if not peek_button_pressed and not playlist_visible then 815 | remove_keybinds() 816 | end 817 | end 818 | 819 | function handle_complex_playlist_toggle(table) 820 | local event = table["event"] 821 | if event == "press" then 822 | msg.error("Complex key event not supported. Falling back to normal playlist display.") 823 | showplaylist() 824 | elseif event == "down" then 825 | showplaylist(1000000) 826 | if settings.peek_respect_display_timeout then 827 | peek_button_pressed = true 828 | peek_display_timer = mp.add_periodic_timer(settings.playlist_display_timeout, peek_timeout) 829 | end 830 | elseif event == "up" then 831 | -- set playlist state to not visible, doesn't actually hide playlist yet 832 | -- this will allow us to check if other functionality has rendered playlist before removing binds 833 | playlist_visible = false 834 | 835 | function remove_keybinds_after_timeout() 836 | -- if playlist is still not visible then lets actually hide it 837 | -- this lets other keys that interupt the peek to render playlist without peek up event closing it 838 | if not playlist_visible then 839 | remove_keybinds() 840 | end 841 | end 842 | 843 | if settings.peek_respect_display_timeout then 844 | peek_button_pressed = false 845 | if not peek_display_timer:is_enabled() then 846 | mp.add_timeout(0.01, remove_keybinds_after_timeout) 847 | end 848 | else 849 | -- use small delay to let dynamic binds run before keys are potentially unbound 850 | mp.add_timeout(0.01, remove_keybinds_after_timeout) 851 | end 852 | end 853 | end 854 | 855 | function toggle_playlist(show_function) 856 | local show = show_function or showplaylist 857 | if playlist_visible then 858 | remove_keybinds() 859 | else 860 | -- toggle always shows without timeout 861 | show(0) 862 | end 863 | end 864 | 865 | function showplaylist(duration) 866 | refresh_globals() 867 | if plen == 0 then return end 868 | if not playlist_visible and settings.reset_cursor_on_open then 869 | resetcursor() 870 | end 871 | 872 | playlist_visible = true 873 | add_keybinds() 874 | 875 | draw_playlist() 876 | keybindstimer:kill() 877 | 878 | local dur = tonumber(duration) or settings.playlist_display_timeout 879 | if dur > 0 then 880 | keybindstimer = mp.add_periodic_timer(dur, remove_keybinds) 881 | end 882 | end 883 | 884 | function showplaylist_non_interactive(duration) 885 | refresh_globals() 886 | if plen == 0 then return end 887 | if not playlist_visible and settings.reset_cursor_on_open then 888 | resetcursor() 889 | end 890 | playlist_visible = true 891 | draw_playlist() 892 | keybindstimer:kill() 893 | 894 | local dur = tonumber(duration) or settings.playlist_display_timeout 895 | if dur > 0 then 896 | keybindstimer = mp.add_periodic_timer(dur, remove_keybinds) 897 | end 898 | end 899 | 900 | selection=nil 901 | function selectfile() 902 | refresh_globals() 903 | if plen == 0 then return end 904 | if not selection then 905 | selection=cursor 906 | else 907 | selection=nil 908 | end 909 | showplaylist() 910 | end 911 | 912 | function unselectfile() 913 | selection=nil 914 | showplaylist() 915 | end 916 | 917 | function resetcursor() 918 | selection = nil 919 | cursor = mp.get_property_number('playlist-pos', 1) 920 | end 921 | 922 | function removefile() 923 | refresh_globals() 924 | if plen == 0 then return end 925 | selection = nil 926 | if cursor==pos then mp.command("script-message unseenplaylist mark true \"playlistmanager avoid conflict when removing file\"") end 927 | mp.commandv("playlist-remove", cursor) 928 | if cursor==plen-1 then cursor = cursor - 1 end 929 | if plen == 1 then 930 | remove_keybinds() 931 | else 932 | showplaylist() 933 | end 934 | end 935 | 936 | function moveup() 937 | refresh_globals() 938 | if plen == 0 then return end 939 | if cursor~=0 then 940 | if selection then mp.commandv("playlist-move", cursor,cursor-1) end 941 | cursor = cursor-1 942 | elseif settings.loop_cursor then 943 | if selection then mp.commandv("playlist-move", cursor,plen) end 944 | cursor = plen-1 945 | end 946 | showplaylist() 947 | end 948 | 949 | function movedown() 950 | refresh_globals() 951 | if plen == 0 then return end 952 | if cursor ~= plen-1 then 953 | if selection then mp.commandv("playlist-move", cursor,cursor+2) end 954 | cursor = cursor + 1 955 | elseif settings.loop_cursor then 956 | if selection then mp.commandv("playlist-move", cursor,0) end 957 | cursor = 0 958 | end 959 | showplaylist() 960 | end 961 | 962 | 963 | function movepageup() 964 | refresh_globals() 965 | if plen == 0 or cursor == 0 then return end 966 | local offset = settings.showamount % 2 == 0 and 1 or 0 967 | local last_file_that_doesnt_scroll = math.ceil(settings.showamount / 2) 968 | local reverse_cursor = plen - cursor 969 | local files_to_jump = math.max(last_file_that_doesnt_scroll + offset - reverse_cursor, 0) + settings.showamount - 2 970 | local prev_cursor = cursor 971 | cursor = cursor - files_to_jump 972 | if cursor < last_file_that_doesnt_scroll then 973 | cursor = 0 974 | end 975 | if selection then 976 | mp.commandv("playlist-move", prev_cursor, cursor) 977 | end 978 | showplaylist() 979 | end 980 | 981 | function movepagedown() 982 | refresh_globals() 983 | if plen == 0 or cursor == plen - 1 then return end 984 | local last_file_that_doesnt_scroll = math.ceil(settings.showamount / 2) - 1 985 | local files_to_jump = math.max(last_file_that_doesnt_scroll - cursor, 0) + settings.showamount - 2 986 | local prev_cursor = cursor 987 | cursor = cursor + files_to_jump 988 | 989 | local cursor_on_last_page = plen - (settings.showamount - 3) 990 | if cursor > cursor_on_last_page then 991 | cursor = plen - 1 992 | end 993 | if selection then 994 | mp.commandv("playlist-move", prev_cursor, cursor + 1) 995 | end 996 | showplaylist() 997 | end 998 | 999 | 1000 | function movebegin() 1001 | refresh_globals() 1002 | if plen == 0 or cursor == 0 then return end 1003 | local prev_cursor = cursor 1004 | cursor = 0 1005 | if selection then mp.commandv("playlist-move", prev_cursor, cursor) end 1006 | showplaylist() 1007 | end 1008 | 1009 | function moveend() 1010 | refresh_globals() 1011 | if plen == 0 or cursor == plen-1 then return end 1012 | local prev_cursor = cursor 1013 | cursor = plen-1 1014 | if selection then mp.commandv("playlist-move", prev_cursor, cursor+1) end 1015 | showplaylist() 1016 | end 1017 | 1018 | function write_watch_later(force_write) 1019 | if settings.allow_write_watch_later_config then 1020 | if mp.get_property_bool("save-position-on-quit") or force_write then 1021 | mp.command("write-watch-later-config") 1022 | end 1023 | end 1024 | end 1025 | 1026 | function playlist_next() 1027 | write_watch_later(true) 1028 | mp.commandv("playlist-next", "weak") 1029 | if settings.close_playlist_on_playfile then 1030 | remove_keybinds() 1031 | end 1032 | refresh_UI() 1033 | end 1034 | 1035 | function playlist_prev() 1036 | write_watch_later(true) 1037 | mp.commandv("playlist-prev", "weak") 1038 | if settings.close_playlist_on_playfile then 1039 | remove_keybinds() 1040 | end 1041 | refresh_UI() 1042 | end 1043 | 1044 | function playlist_random() 1045 | write_watch_later() 1046 | refresh_globals() 1047 | if plen < 2 then return end 1048 | math.randomseed(os.time()) 1049 | local random = pos 1050 | while random == pos do 1051 | random = math.random(0, plen-1) 1052 | end 1053 | mp.set_property("playlist-pos", random) 1054 | if settings.close_playlist_on_playfile then 1055 | remove_keybinds() 1056 | end 1057 | end 1058 | 1059 | function playfile() 1060 | refresh_globals() 1061 | if plen == 0 then return end 1062 | selection = nil 1063 | local is_idle = mp.get_property_native('idle-active') 1064 | if cursor ~= pos or is_idle then 1065 | write_watch_later() 1066 | mp.set_property("playlist-pos", cursor) 1067 | else 1068 | if cursor~=plen-1 then 1069 | cursor = cursor + 1 1070 | end 1071 | write_watch_later() 1072 | mp.commandv("playlist-next", "weak") 1073 | end 1074 | if settings.close_playlist_on_playfile then 1075 | remove_keybinds() 1076 | elseif playlist_visible then 1077 | showplaylist() 1078 | end 1079 | end 1080 | 1081 | function file_filter(filenames) 1082 | local files = {} 1083 | for i = 1, #filenames do 1084 | local file = filenames[i] 1085 | local ext = file:match('%.([^%.]+)$') 1086 | if ext and filetype_lookup[ext:lower()] then 1087 | table.insert(files, file) 1088 | end 1089 | end 1090 | return files 1091 | end 1092 | 1093 | function get_playlist_filenames_set() 1094 | local filenames = {} 1095 | for n=0,plen-1,1 do 1096 | local filename = mp.get_property('playlist/'..n..'/filename') 1097 | local _, file = utils.split_path(filename) 1098 | filenames[file] = true 1099 | end 1100 | return filenames 1101 | end 1102 | 1103 | --Creates a playlist of all files in directory, will keep the order and position 1104 | --For exaple, Folder has 12 files, you open the 5th file and run this, the remaining 7 are added behind the 5th file and prior 4 files before it 1105 | function playlist(force_dir) 1106 | refresh_globals() 1107 | if not directory and plen > 0 then return end 1108 | local hasfile = true 1109 | if plen == 0 then 1110 | hasfile = false 1111 | dir = mp.get_property('working-directory') 1112 | else 1113 | dir = directory 1114 | end 1115 | 1116 | if dir == "." then dir = "" end 1117 | if force_dir then dir = force_dir end 1118 | 1119 | local files = file_filter(utils.readdir(dir, "files")) 1120 | if winapisort ~= nil then 1121 | table.sort(files, winapisort) 1122 | else 1123 | table.sort(files, alphanumsort) 1124 | end 1125 | 1126 | 1127 | if files == nil then 1128 | msg.verbose("no files in directory") 1129 | return 1130 | end 1131 | 1132 | local filenames = get_playlist_filenames_set() 1133 | local c, c2 = 0,0 1134 | if files then 1135 | local cur = false 1136 | local filename = mp.get_property("filename") 1137 | for _, file in ipairs(files) do 1138 | if file == nil or file[1] == "." then 1139 | break 1140 | end 1141 | local appendstr = "append" 1142 | if not hasfile then 1143 | cur = true 1144 | appendstr = "append-play" 1145 | hasfile = true 1146 | end 1147 | if filename == file then 1148 | cur = true 1149 | elseif filenames[file] then 1150 | -- skip files already in playlist 1151 | elseif cur == true or settings.loadfiles_always_append then 1152 | mp.commandv("loadfile", utils.join_path(dir, file), appendstr) 1153 | msg.info("Appended to playlist: " .. file) 1154 | c2 = c2 + 1 1155 | else 1156 | mp.commandv("loadfile", utils.join_path(dir, file), appendstr) 1157 | msg.info("Prepended to playlist: " .. file) 1158 | mp.commandv("playlist-move", mp.get_property_number("playlist-count", 1)-1, c) 1159 | c = c + 1 1160 | end 1161 | end 1162 | if c2 > 0 or c>0 then 1163 | msg.info("Added "..c + c2.." files to playlist") 1164 | else 1165 | msg.info("No additional files found") 1166 | end 1167 | cursor = mp.get_property_number('playlist-pos', 1) 1168 | else 1169 | msg.error("Could not scan for files: "..(error or "")) 1170 | end 1171 | refresh_globals() 1172 | if playlist_visible then 1173 | showplaylist() 1174 | end 1175 | if settings.display_osd_feedback then 1176 | if c2 > 0 or c>0 then 1177 | mp.osd_message("Added "..c + c2.." files to playlist") 1178 | else 1179 | mp.osd_message("No additional files found") 1180 | end 1181 | end 1182 | return c + c2 1183 | end 1184 | 1185 | local menu_items = { 1186 | { 1187 | label = "Show playlist", 1188 | action = function() 1189 | showplaylist() 1190 | end, 1191 | }, 1192 | { 1193 | label = "Save playlist", 1194 | action = function() 1195 | mp.add_timeout(0.1, activate_playlist_save) 1196 | end, 1197 | }, 1198 | { 1199 | label = "Select playlist", 1200 | action = function() 1201 | mp.add_timeout(0.1, select_playlist) 1202 | end, 1203 | }, 1204 | { 1205 | label = "Load files to playlist", 1206 | action = function() 1207 | playlist() 1208 | end, 1209 | }, 1210 | { 1211 | label = "Sort playlist", 1212 | action = function() 1213 | sortplaylist_by_next_mode() 1214 | end, 1215 | }, 1216 | { 1217 | label = "Reverse playlist", 1218 | action = function() 1219 | reverseplaylist() 1220 | end, 1221 | }, 1222 | { 1223 | label = "Shuffle playlist", 1224 | action = function() 1225 | shuffleplaylist() 1226 | end, 1227 | }, 1228 | { 1229 | label = "Play random file", 1230 | action = function() 1231 | playlist_random() 1232 | end, 1233 | }, 1234 | } 1235 | 1236 | local menu_labels = {} 1237 | for _, item in pairs(menu_items) do 1238 | table.insert(menu_labels, item.label) 1239 | end 1240 | 1241 | function open_menu() 1242 | remove_keybinds() 1243 | input.select({ 1244 | prompt = "Search menu: ", 1245 | items = menu_labels, 1246 | submit = function (index) 1247 | menu_items[index].action() 1248 | end, 1249 | }) 1250 | end 1251 | 1252 | function parse_home(path) 1253 | if not path:find("^~") then 1254 | return path 1255 | end 1256 | local home_dir = os.getenv("HOME") or os.getenv("USERPROFILE") 1257 | if not home_dir then 1258 | local drive = os.getenv("HOMEDRIVE") 1259 | local path = os.getenv("HOMEPATH") 1260 | if drive and path then 1261 | home_dir = utils.join_path(drive, path) 1262 | else 1263 | msg.error("Couldn't find home dir.") 1264 | return nil 1265 | end 1266 | end 1267 | local result = path:gsub("^~", home_dir) 1268 | return result 1269 | end 1270 | 1271 | function activate_playlist_name_prompt() 1272 | input.get({ 1273 | cursor_position = 1, 1274 | prompt = "Enter playlist name: ", 1275 | submit = function (text) 1276 | input.terminate() 1277 | save_playlist(text) 1278 | end, 1279 | default_text = ".m3u" 1280 | }) 1281 | end 1282 | 1283 | function activate_playlist_save() 1284 | if settings.playlist_save_interactive then 1285 | remove_keybinds() 1286 | activate_playlist_name_prompt() 1287 | else 1288 | save_playlist() 1289 | end 1290 | end 1291 | 1292 | 1293 | function select_playlist() 1294 | remove_keybinds() 1295 | local save_path = get_playlist_save_path() 1296 | local files, err = utils.readdir(save_path, "files") 1297 | if err ~= nil then 1298 | mp.error("Error reading playlist files", err) 1299 | return 1300 | end 1301 | 1302 | local playlists = {} 1303 | for index, file in pairs(files) do 1304 | table.insert(playlists, file) 1305 | end 1306 | 1307 | input.select({ 1308 | prompt = "Search for playlist: ", 1309 | items = playlists, 1310 | submit = function (index) 1311 | mp.commandv("loadfile", utils.join_path(save_path, playlists[index])) 1312 | end, 1313 | }) 1314 | end 1315 | 1316 | function get_playlist_save_path() 1317 | if settings.playlist_savepath == nil or settings.playlist_savepath == "" then 1318 | return mp.command_native({"expand-path", "~~home/"}).."/playlists" 1319 | else 1320 | local p = parse_home(settings.playlist_savepath) 1321 | if p == nil then 1322 | msg.error("Could not resolve playlist save path") 1323 | end 1324 | return p or "" 1325 | end 1326 | end 1327 | 1328 | --saves the current playlist into a m3u file 1329 | function save_playlist(filename) 1330 | local length = mp.get_property_number('playlist-count', 0) 1331 | if length == 0 then return end 1332 | 1333 | --get playlist save path 1334 | local savepath = get_playlist_save_path() 1335 | 1336 | --create savepath if it doesn't exist 1337 | if utils.readdir(savepath) == nil then 1338 | local windows_args = {'powershell', '-NoProfile', '-Command', 'mkdir', savepath} 1339 | local unix_args = { 'mkdir', savepath } 1340 | local args = settings.system == 'windows' and windows_args or unix_args 1341 | local res = utils.subprocess({ args = args, cancellable = false }) 1342 | if res.status ~= 0 then 1343 | msg.error("Failed to create playlist save directory "..savepath..". Error: "..(res.error or "unknown")) 1344 | return 1345 | end 1346 | end 1347 | 1348 | local name = filename 1349 | if name == nil then 1350 | if settings.playlist_save_filename == nil or settings.playlist_save_filename == "" then 1351 | local date = os.date("*t") 1352 | local datestring = ("%02d-%02d-%02d_%02d-%02d-%02d"):format(date.year, date.month, date.day, date.hour, date.min, date.sec) 1353 | 1354 | name = datestring.."_playlist-size_"..length..".m3u" 1355 | else 1356 | name = settings.playlist_save_filename 1357 | end 1358 | end 1359 | 1360 | local savepath = utils.join_path(savepath, name) 1361 | local file, err = io.open(savepath, "w") 1362 | if not file then 1363 | msg.error("Error in creating playlist file, check permissions. Error: "..(err or "unknown")) 1364 | else 1365 | file:write("#EXTM3U\n") 1366 | local i=0 1367 | while i < length do 1368 | local pwd = mp.get_property("working-directory") 1369 | local filename = mp.get_property('playlist/'..i..'/filename') 1370 | local fullpath = filename 1371 | if not is_protocol(filename) then 1372 | fullpath = utils.join_path(pwd, filename) 1373 | end 1374 | local title = mp.get_property('playlist/'..i..'/title') or title_table[filename] 1375 | if title then 1376 | file:write("#EXTINF:,"..title.."\n") 1377 | end 1378 | file:write(fullpath, "\n") 1379 | i=i+1 1380 | end 1381 | local saved_msg = "Playlist written to: "..savepath 1382 | if settings.display_osd_feedback then mp.osd_message(saved_msg) end 1383 | msg.info(saved_msg) 1384 | file:close() 1385 | end 1386 | end 1387 | 1388 | function alphanumsort(a, b) 1389 | local function padnum(d) 1390 | local dec, n = string.match(d, "(%.?)0*(.+)") 1391 | return #dec > 0 and ("%.12f"):format(d) or ("%s%03d%s"):format(dec, #n, n) 1392 | end 1393 | return tostring(a):lower():gsub("%.?%d+",padnum)..("%3d"):format(#b) 1394 | < tostring(b):lower():gsub("%.?%d+",padnum)..("%3d"):format(#a) 1395 | end 1396 | 1397 | -- fast sort algo from https://github.com/zsugabubus/dotfiles/blob/master/.config/mpv/scripts/playlist-filtersort.lua 1398 | function sortplaylist(startover) 1399 | local playlist = mp.get_property_native('playlist') 1400 | if #playlist < 2 then return end 1401 | 1402 | local order = {} 1403 | for i=1, #playlist do 1404 | order[i] = i 1405 | playlist[i].string = get_name_from_index(i - 1, true) 1406 | end 1407 | 1408 | table.sort(order, function(a, b) 1409 | return sort_modes[sort_mode].sort_fn(a, b, playlist) 1410 | end) 1411 | 1412 | for i=1, #playlist do 1413 | playlist[order[i]].new_pos = i 1414 | end 1415 | 1416 | for i=1, #playlist do 1417 | while true do 1418 | local j = playlist[i].new_pos 1419 | if i == j then 1420 | break 1421 | end 1422 | mp.commandv('playlist-move', (i) - 1, (j + 1) - 1) 1423 | mp.commandv('playlist-move', (j - 1) - 1, (i) - 1) 1424 | playlist[j], playlist[i] = playlist[i], playlist[j] 1425 | end 1426 | end 1427 | 1428 | for i = 1, #playlist do 1429 | local filename = mp.get_property('playlist/' .. i - 1 .. '/filename') 1430 | local ext = filename:match("%.([^%.]+)$") 1431 | if not ext or not filetype_lookup[ext:lower()] then 1432 | --move the directory to the end of the playlist 1433 | mp.commandv('playlist-move', i - 1, #playlist) 1434 | end 1435 | end 1436 | 1437 | cursor = mp.get_property_number('playlist-pos', 0) 1438 | if startover then 1439 | mp.set_property('playlist-pos', 0) 1440 | end 1441 | if playlist_visible then 1442 | showplaylist() 1443 | end 1444 | if settings.display_osd_feedback then 1445 | mp.osd_message("Playlist sorted with "..sort_modes[sort_mode].title) 1446 | end 1447 | end 1448 | 1449 | function sortplaylist_by_next_mode() 1450 | sortplaylist() 1451 | sort_mode = sort_mode + 1 1452 | if sort_mode > #sort_modes then sort_mode = 1 end 1453 | end 1454 | 1455 | function reverseplaylist() 1456 | local length = mp.get_property_number('playlist-count', 0) 1457 | if length < 2 then return end 1458 | for outer=1, length-1, 1 do 1459 | mp.commandv('playlist-move', outer, 0) 1460 | end 1461 | if playlist_visible then 1462 | showplaylist() 1463 | end 1464 | if settings.display_osd_feedback then 1465 | mp.osd_message("Playlist reversed") 1466 | end 1467 | end 1468 | 1469 | function shuffleplaylist() 1470 | refresh_globals() 1471 | if plen < 2 then return end 1472 | mp.command("playlist-shuffle") 1473 | math.randomseed(os.time()) 1474 | mp.commandv("playlist-move", pos, math.random(0, plen-1)) 1475 | 1476 | local playlist = mp.get_property_native('playlist') 1477 | for i = 1, #playlist do 1478 | local filename = mp.get_property('playlist/' .. i - 1 .. '/filename') 1479 | local ext = filename:match("%.([^%.]+)$") 1480 | if not ext or not filetype_lookup[ext:lower()] then 1481 | --move the directory to the end of the playlist 1482 | mp.commandv('playlist-move', i - 1, #playlist) 1483 | end 1484 | end 1485 | 1486 | mp.set_property('playlist-pos', 0) 1487 | refresh_globals() 1488 | if playlist_visible then 1489 | showplaylist() 1490 | end 1491 | if settings.display_osd_feedback then 1492 | mp.osd_message("Playlist shuffled") 1493 | end 1494 | end 1495 | 1496 | function bind_keys(keys, name, func, opts) 1497 | if keys == nil or keys == "" then 1498 | mp.add_key_binding(keys, name, func, opts) 1499 | return 1500 | end 1501 | local i = 1 1502 | for key in keys:gmatch("[^%s]+") do 1503 | local prefix = i == 1 and '' or i 1504 | mp.add_key_binding(key, name..prefix, func, opts) 1505 | i = i + 1 1506 | end 1507 | end 1508 | 1509 | function bind_keys_forced(keys, name, func, opts) 1510 | if keys == nil or keys == "" then 1511 | mp.add_forced_key_binding(keys, name, func, opts) 1512 | return 1513 | end 1514 | local i = 1 1515 | for key in keys:gmatch("[^%s]+") do 1516 | local prefix = i == 1 and '' or i 1517 | mp.add_forced_key_binding(key, name..prefix, func, opts) 1518 | i = i + 1 1519 | end 1520 | end 1521 | 1522 | function unbind_keys(keys, name) 1523 | if keys == nil or keys == "" then 1524 | mp.remove_key_binding(name) 1525 | return 1526 | end 1527 | local i = 1 1528 | for key in keys:gmatch("[^%s]+") do 1529 | local prefix = i == 1 and '' or i 1530 | mp.remove_key_binding(name..prefix) 1531 | i = i + 1 1532 | end 1533 | end 1534 | 1535 | function add_keybinds() 1536 | bind_keys_forced(settings.key_moveup, 'moveup', moveup, "repeatable") 1537 | bind_keys_forced(settings.key_movedown, 'movedown', movedown, "repeatable") 1538 | bind_keys_forced(settings.key_movepageup, 'movepageup', movepageup, "repeatable") 1539 | bind_keys_forced(settings.key_movepagedown, 'movepagedown', movepagedown, "repeatable") 1540 | bind_keys_forced(settings.key_movebegin, 'movebegin', movebegin, "repeatable") 1541 | bind_keys_forced(settings.key_moveend, 'moveend', moveend, "repeatable") 1542 | bind_keys_forced(settings.key_selectfile, 'selectfile', selectfile) 1543 | bind_keys_forced(settings.key_unselectfile, 'unselectfile', unselectfile) 1544 | bind_keys_forced(settings.key_playfile, 'playfile', playfile) 1545 | bind_keys_forced(settings.key_removefile, 'removefile', removefile, "repeatable") 1546 | bind_keys_forced(settings.key_closeplaylist, 'closeplaylist', remove_keybinds) 1547 | end 1548 | 1549 | function remove_keybinds() 1550 | keybindstimer:kill() 1551 | keybindstimer = mp.add_periodic_timer(settings.playlist_display_timeout, remove_keybinds) 1552 | keybindstimer:kill() 1553 | playlist_overlay.data = "" 1554 | playlist_overlay:remove() 1555 | if is_terminal_mode() then 1556 | mp.osd_message("") 1557 | end 1558 | playlist_visible = false 1559 | if settings.reset_cursor_on_close then 1560 | resetcursor() 1561 | end 1562 | if settings.dynamic_binds then 1563 | unbind_keys(settings.key_moveup, 'moveup') 1564 | unbind_keys(settings.key_movedown, 'movedown') 1565 | unbind_keys(settings.key_movepageup, 'movepageup') 1566 | unbind_keys(settings.key_movepagedown, 'movepagedown') 1567 | unbind_keys(settings.key_movebegin, 'movebegin') 1568 | unbind_keys(settings.key_moveend, 'moveend') 1569 | unbind_keys(settings.key_selectfile, 'selectfile') 1570 | unbind_keys(settings.key_unselectfile, 'unselectfile') 1571 | unbind_keys(settings.key_playfile, 'playfile') 1572 | unbind_keys(settings.key_removefile, 'removefile') 1573 | unbind_keys(settings.key_closeplaylist, 'closeplaylist') 1574 | end 1575 | end 1576 | 1577 | keybindstimer = mp.add_periodic_timer(settings.playlist_display_timeout, remove_keybinds) 1578 | keybindstimer:kill() 1579 | 1580 | if not settings.dynamic_binds then 1581 | add_keybinds() 1582 | end 1583 | 1584 | if settings.loadfiles_on_idle_start and mp.get_property_number('playlist-count', 0) == 0 then 1585 | playlist() 1586 | end 1587 | 1588 | mp.observe_property('playlist-count', "number", function(_, plcount) 1589 | --if we promised to listen and sort on playlist size increase do it 1590 | if settings.sortplaylist_on_file_add and (plcount > plen) then 1591 | msg.info("Added files will be automatically sorted") 1592 | refresh_globals() 1593 | sortplaylist() 1594 | end 1595 | refresh_UI() 1596 | resolve_titles() 1597 | end) 1598 | mp.observe_property('osd-dimensions', 'native', refresh_UI) 1599 | 1600 | 1601 | url_request_queue = {} 1602 | function url_request_queue.push(item) table.insert(url_request_queue, item) end 1603 | function url_request_queue.pop() return table.remove(url_request_queue, 1) end 1604 | local url_titles_to_fetch = url_request_queue 1605 | local ongoing_url_requests = {} 1606 | 1607 | function url_fetching_throttler() 1608 | if #url_titles_to_fetch == 0 then 1609 | url_title_fetch_timer:kill() 1610 | end 1611 | 1612 | local ongoing_url_requests_count = 0 1613 | for _, ongoing in pairs(ongoing_url_requests) do 1614 | if ongoing then 1615 | ongoing_url_requests_count = ongoing_url_requests_count + 1 1616 | end 1617 | end 1618 | 1619 | -- start resolving some url titles if there is available slots 1620 | local amount_to_fetch = math.max(0, settings.concurrent_title_resolve_limit - ongoing_url_requests_count) 1621 | for index=1,amount_to_fetch,1 do 1622 | local file = url_titles_to_fetch.pop() 1623 | if file then 1624 | ongoing_url_requests[file] = true 1625 | resolve_ytdl_title(file) 1626 | end 1627 | end 1628 | end 1629 | 1630 | url_title_fetch_timer = mp.add_periodic_timer(0.1, url_fetching_throttler) 1631 | url_title_fetch_timer:kill() 1632 | 1633 | local_request_queue = {} 1634 | function local_request_queue.push(item) table.insert(local_request_queue, item) end 1635 | function local_request_queue.pop() return table.remove(local_request_queue, 1) end 1636 | local local_titles_to_fetch = local_request_queue 1637 | local ongoing_local_request = false 1638 | 1639 | -- this will only allow 1 concurrent local title resolve process 1640 | function local_fetching_throttler() 1641 | if not ongoing_local_request then 1642 | local file = local_titles_to_fetch.pop() 1643 | if file then 1644 | ongoing_local_request = true 1645 | resolve_ffprobe_title(file) 1646 | end 1647 | end 1648 | end 1649 | 1650 | function resolve_titles() 1651 | if settings.prefer_titles == 'none' then return end 1652 | if not settings.resolve_url_titles and not settings.resolve_local_titles then return end 1653 | 1654 | local length = mp.get_property_number('playlist-count', 0) 1655 | if length < 2 then return end 1656 | -- loop all items in playlist because we can't predict how it has changed 1657 | local added_urls = false 1658 | local added_local = false 1659 | for i=0,length - 1,1 do 1660 | local filename = mp.get_property('playlist/'..i..'/filename') 1661 | local title = mp.get_property('playlist/'..i..'/title') 1662 | if i ~= pos 1663 | and filename 1664 | and not title 1665 | and not title_table[filename] 1666 | and not requested_titles[filename] 1667 | then 1668 | requested_titles[filename] = true 1669 | if filename:match('^https?://') and settings.resolve_url_titles then 1670 | url_titles_to_fetch.push(filename) 1671 | added_urls = true 1672 | elseif settings.prefer_titles == "all" and settings.resolve_local_titles then 1673 | local_titles_to_fetch.push(filename) 1674 | added_local = true 1675 | end 1676 | end 1677 | end 1678 | if added_urls then 1679 | url_title_fetch_timer:resume() 1680 | end 1681 | if added_local then 1682 | local_fetching_throttler() 1683 | end 1684 | end 1685 | 1686 | function resolve_ytdl_title(filename) 1687 | local args = { 1688 | settings.youtube_dl_executable, 1689 | '--no-playlist', 1690 | '--flat-playlist', 1691 | '-sJ', 1692 | '--no-config', 1693 | filename, 1694 | } 1695 | local req = mp.command_native_async( 1696 | { 1697 | name = "subprocess", 1698 | args = args, 1699 | playback_only = false, 1700 | capture_stdout = true 1701 | }, 1702 | function (success, res) 1703 | ongoing_url_requests[filename] = false 1704 | if res.killed_by_us then 1705 | msg.verbose('Request to resolve url title ' .. filename .. ' timed out') 1706 | return 1707 | end 1708 | if res.status == 0 then 1709 | local json, err = utils.parse_json(res.stdout) 1710 | if not err then 1711 | local is_playlist = json['_type'] and json['_type'] == 'playlist' 1712 | local title = (is_playlist and '[playlist]: ' or '') .. json['title'] 1713 | msg.verbose(filename .. " resolved to '" .. title .. "'") 1714 | title_table[filename] = title 1715 | mp.set_property_native('user-data/playlistmanager/titles', title_table) 1716 | refresh_UI() 1717 | else 1718 | msg.error("Failed parsing json, reason: "..(err or "unknown")) 1719 | end 1720 | else 1721 | msg.error("Failed to resolve url title "..filename.." Error: "..(res.error or "unknown")) 1722 | end 1723 | end 1724 | ) 1725 | 1726 | mp.add_timeout( 1727 | settings.resolve_title_timeout, 1728 | function() 1729 | mp.abort_async_command(req) 1730 | ongoing_url_requests[filename] = false 1731 | end 1732 | ) 1733 | end 1734 | 1735 | function resolve_ffprobe_title(filename) 1736 | local args = { "ffprobe", "-show_format", "-show_entries", "format=tags", "-loglevel", "quiet", filename } 1737 | local req = mp.command_native_async( 1738 | { 1739 | name = "subprocess", 1740 | args = args, 1741 | playback_only = false, 1742 | capture_stdout = true 1743 | }, 1744 | function (success, res) 1745 | ongoing_local_request = false 1746 | local_fetching_throttler() 1747 | if res.killed_by_us then 1748 | msg.verbose('Request to resolve local title ' .. filename .. ' timed out') 1749 | return 1750 | end 1751 | if res.status == 0 then 1752 | local title = string.match(res.stdout, "title=([^\n\r]+)") 1753 | if title then 1754 | msg.verbose(filename .. " resolved to '" .. title .. "'") 1755 | title_table[filename] = title 1756 | mp.set_property_native('user-data/playlistmanager/titles', title_table) 1757 | refresh_UI() 1758 | end 1759 | else 1760 | msg.error("Failed to resolve local title "..filename.." Error: "..(res.error or "unknown")) 1761 | end 1762 | end 1763 | ) 1764 | end 1765 | 1766 | --script message handler 1767 | function handlemessage(msg, value, value2) 1768 | if msg == "show" and value == "playlist" then 1769 | if value2 ~= "toggle" then 1770 | showplaylist(value2) 1771 | return 1772 | else 1773 | toggle_playlist(showplaylist) 1774 | return 1775 | end 1776 | end 1777 | if msg == "show" and value == "playlist-nokeys" then 1778 | if value2 ~= "toggle" then 1779 | showplaylist_non_interactive(value2) 1780 | return 1781 | else 1782 | toggle_playlist(showplaylist_non_interactive) 1783 | return 1784 | end 1785 | end 1786 | if msg == "show" and value == "filename" and strippedname and value2 then 1787 | mp.commandv('show-text', strippedname, tonumber(value2)*1000 ) ; return 1788 | end 1789 | if msg == "show" and value == "filename" and strippedname then 1790 | mp.commandv('show-text', strippedname ) ; return 1791 | end 1792 | if msg == "sort" then sortplaylist(value) ; return end 1793 | if msg == "shuffle" then shuffleplaylist() ; return end 1794 | if msg == "reverse" then reverseplaylist() ; return end 1795 | if msg == "loadfiles" then playlist(value) ; return end 1796 | if msg == "save" then save_playlist(value) ; return end 1797 | if msg == "save-interactive" then activate_playlist_name_prompt() ; return end 1798 | if msg == "open-menu" then open_menu() ; return end 1799 | if msg == "select-playlist" then select_playlist() ; return end 1800 | if msg == "playlist-next" then playlist_next() ; return end 1801 | if msg == "playlist-prev" then playlist_prev() ; return end 1802 | if msg == "playlist-next-random" then playlist_random() ; return end 1803 | if msg == "close" then remove_keybinds() end 1804 | end 1805 | 1806 | mp.register_script_message("playlistmanager", handlemessage) 1807 | 1808 | bind_keys(settings.key_sortplaylist, "sortplaylist", sortplaylist_by_next_mode) 1809 | bind_keys(settings.key_shuffleplaylist, "shuffleplaylist", shuffleplaylist) 1810 | bind_keys(settings.key_reverseplaylist, "reverseplaylist", reverseplaylist) 1811 | bind_keys(settings.key_loadfiles, "loadfiles", playlist) 1812 | bind_keys(settings.key_saveplaylist, "saveplaylist", activate_playlist_save) 1813 | bind_keys(settings.key_selectplaylist, "selectplaylist", select_playlist) 1814 | bind_keys(settings.key_openmenu, "openmenu", open_menu) 1815 | bind_keys(settings.key_showplaylist, "showplaylist", showplaylist) 1816 | bind_keys( 1817 | settings.key_peek_at_playlist, 1818 | "peek_at_playlist", 1819 | handle_complex_playlist_toggle, 1820 | { complex=true } 1821 | ) 1822 | 1823 | mp.register_event("start-file", on_start_file) 1824 | mp.register_event("file-loaded", on_file_loaded) 1825 | mp.register_event("end-file", on_end_file) 1826 | -------------------------------------------------------------------------------- /playlistmanager.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jonniek/mpv-playlistmanager/a2755132c18c050269e5fea6093672f0a36ed7db/playlistmanager.png --------------------------------------------------------------------------------