├── LICENSE ├── README.md ├── examples └── chapter-list.lua └── scroll-list.lua /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Oscar Manglaras 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mpv-scroll-list 2 | 3 | This is an API to allow scripts to create interactive scrollable lists in mpv player. 4 | 5 | ## For Users 6 | Installing the script as a user is very simple, just place the `scroll-list.lua` file inside the `~~/script-modules` folder (you may need to make it). 7 | 8 | For more advanced users you can also place the file into one of the lua package directories specified by the `LUA_PATH` environment variable. 9 | 10 | ## For Developers 11 | 12 | ### Importing the Module 13 | Importing the module in such a way as to respect the above settings can be done with the following code: 14 | 15 | ``` 16 | package.path = mp.command_native({"expand-path", "~~/script-modules/?.lua;"})..package.path 17 | local list = require "scroll-list" 18 | ``` 19 | 20 | The list variable then contains a table that represents a single scroll-list object. 21 | 22 | ### Conceptual Overview 23 | Each list object maintains a separate osd ass overlay, and has a suite of variables and methods to modify and control the overlay. 24 | When the `open` method is run the list creates a header and then iterates through the list of items and creates a formatted ass string using the item objects. 25 | Forced keybindings are then set to allow the user to control the selection, and hence scroll up or down. 26 | When the `close` method is run the keybinds are removed, and the osd hidden. 27 | Basic scripts can create a full scrollable list by simply constructing an array of valid item objects and running the `open`, `close`, and `toggle` methods, but generally one will want to change the settings and modify the keybindings to actually provide the list with functionality beyond scrolling. 28 | 29 | ### Variables 30 | 31 | The following variables are provided to modify the behaviour of the list. 32 | 33 | | Variable | Description | Default | 34 | |----------------|-------------------------------------------------------|-------------------------------------------------------------| 35 | | global_style | An ass string prepended to the start of the overlay | empty 36 | | header | The string to print as the header | `header \\N ----------------------------------------------` | 37 | | header_style | The ass tag used to format the header | `{\q2\fs35\c&00ccff&}` | 38 | | list | Array of item objects | empty | 39 | | list_style | Generic ass tag to apply to all list items | `{\q2\fs25\c&Hffffff&}` | 40 | | wrapper_style | Ass tag for the 'x item(s) remaining' text | `{\c&00ccff&\fs16}` | 41 | | cursor | String to print before the selected item | `➤\h` | 42 | | indent | String to print before non-selected items | `\h\h\h\h` | 43 | | cursor_style | Ass tag for the cursor | `{\c&00ccff&}` | 44 | | selected_style | Ass tag to use after the cursor | `{\c&Hfce788&}` | 45 | | num_entries | Number of items to display on screen before scrolling | 16 | 46 | | selected | Currently selected item | | 47 | | wrap | Whether scrolling should wrap around the list | false | 48 | | empty_text | Text to print when list is empty | `no entries` | 49 | | keybinds | Array of keybind objects to use when the list is open | See [Keybinds entry](#The-Keybinds-Array) | 50 | 51 | There are also a small number of variables that are intended for internal use by the list, however they may be useful if one want to write custom functions. 52 | 53 | | Variable | Description | 54 | |-------------|--------------------------------------------------------------------| 55 | | ass | Contains the object returned by `mp.create_osd_overlay` | 56 | | hidden | Used to track if the list is closed in order to defer redraws | 57 | | flag_update | Used to track if an update was requested while the list was closed | 58 | 59 | 60 | ### Methods 61 | These methods must all be run using `object:function()` syntax so that they act on the correct list object. 62 | 63 | #### Methods meant to control the list overlay: 64 | 65 | | Method | Description | 66 | |--------------- |---------------------------------------------| 67 | | open() | opens the list | 68 | | close() | closes the list | 69 | | toggle() | toggles the list | 70 | | update() | re-scan the list and update the osd overlay | 71 | | scroll_down() | move cursor down | 72 | | scroll_up() | move cursor up | 73 | | move_pagedown() | move cursor to next page | 74 | | move_pagedup() | move cursor to previous page | 75 | | move_begin() | move cursor to begin | 76 | | move_end() | move cursor to end | 77 | 78 | #### Methods designed to be replaceable for custom behaviour: 79 | Changing these can break the script if certain function calls are missing. Make sure to check the defaults. 80 | 81 | | Method | Description | 82 | | ------------------------ | ------------------------------------------------------------ | 83 | | format_header_string() | format and return the header string - allows one to modify or substitute the header string on each redraw | 84 | | format_header() | formats and prints the header to the overlay | 85 | | format_line(index, item) | formats the ass string for the given `item` at list position `index` - this handles the cursor, indents, styles and newlines. | 86 | | open() | is called by `toggle` - runs the functions required when opening the list | 87 | | close() | is called by `toggle` - runs the functions required when closing the list | 88 | 89 | #### Methods to support custom functions: 90 | 91 | Generally these shouldn't be changed. 92 | 93 | | Method | Description | 94 | |-------------------|------------------------------------------------------------------------------------| 95 | | append(str) | appends the string `str` to the ass overlay - if text is nil then it safely exits | 96 | | newline() | alias for `append("\\N")` | 97 | | add_keybinds() | adds the keybinds defined in the `keybinds` variable - used by `open` | 98 | | remove_keybinds() | removes the defined keybinds - used by `close` | 99 | | open_list() | sends the ass update command and manages the hidden flag - used by `open` | 100 | | close_list() | sends the ass remove command and manages the hidden flag - used by `close` | 101 | 102 | #### Internally used methods (for reference): 103 | 104 | | Method | Description | 105 | |--------------|--------------------------------------------------------------------------------| 106 | | update_ass() | Main function that runs the format functions and calculates the scroll offsets | 107 | 108 | ### The List Array 109 | 110 | Each item in the list array is a table with the following values: 111 | | key | Description | 112 | |-------|------------------------------------------------------------------------------------------------------------------| 113 | | ass | the ass string to print to the screen | 114 | | style | Optional - an ass string to prepend before `ass` - this is to provide an easier way to add/remove a custom style | 115 | 116 | Any other key is ignored, so can be used by a script if it needs to store more information. 117 | Note that it is possible that future versions of the script may add functionality for other keys. 118 | 119 | ### The Keybinds Array 120 | 121 | The `keybinds` variable is an array of keybinds that the script applies when the script is open. 122 | Each keybind is itself an array consisting of: 123 | 124 | key a string describing the key to bind to - same as input.conf 125 | name a unique name for this binding 126 | fn a function to run when the key is pressed 127 | flags a table of flags (can be an empty table) 128 | 129 | These are passed almost directly to the `mp.add_forced_key_binding` function. 130 | For details on flags see [mp.add_key_binding](https://mpv.io/manual/master/#lua-scripting-[,flags]]) 131 | 132 | 133 | ### Utility Functions 134 | -------------------------------------------------------------------------------- /examples/chapter-list.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | This script implements an interractive chapter list 3 | Usage: add bindings to input.conf 4 | -- key script-message-to chapter_list toggle-chapter-browser 5 | 6 | This script was written as an example for the mpv-scroll-list api 7 | https://github.com/CogentRedTester/mpv-scroll-list 8 | ]] 9 | 10 | local mp = require 'mp' 11 | local opts = require("mp.options") 12 | 13 | local o = { 14 | -- header of the list 15 | -- %cursor% and %total% to be used to display the cursor position and the total number of lists 16 | header = "Chapter List [%cursor%/%total%]\\N ------------------------------------", 17 | -- wrap the cursor around the top and bottom of the list 18 | wrap = true, 19 | -- reset cursor navigation when open the list 20 | reset_cursor_on_close = true, 21 | -- set dynamic keybinds to bind when the list is open 22 | key_move_begin = "HOME", 23 | key_move_end = "END", 24 | key_move_pageup = "PGUP", 25 | key_move_pagedown = "PGDWN", 26 | key_scroll_down = "DOWN WHEEL_DOWN", 27 | key_scroll_up = "UP WHEEL_UP", 28 | key_open_chapter = "ENTER MBTN_LEFT", 29 | key_close_browser = "ESC MBTN_RIGHT", 30 | } 31 | 32 | opts.read_options(o) 33 | 34 | --adding the source directory to the package path and loading the module 35 | package.path = mp.command_native({"expand-path", "~~/script-modules/?.lua;"})..package.path 36 | local list = require "scroll-list" 37 | 38 | --modifying the list settings 39 | local original_open = list.open 40 | list.header = o.header 41 | list.wrap = o.wrap 42 | 43 | --escape header specifies the format 44 | --display the cursor position and the total number of lists in the header 45 | function list:format_header_string(str) 46 | if #list.list > 0 then 47 | str = str:gsub("%%(%a+)%%", { cursor = list.selected, total = #list.list }) 48 | else str = str:gsub("%[.*%]", "") end 49 | return str 50 | end 51 | 52 | --update the list when the current chapter changes 53 | mp.observe_property('chapter', 'number', function(_, curr_chapter) 54 | list.list = {} 55 | local chapter_list = mp.get_property_native('chapter-list', {}) 56 | for i = 1, #chapter_list do 57 | local item = {} 58 | if (i - 1 == curr_chapter) then 59 | list.selected = curr_chapter + 1 60 | item.style = [[{\c&H33ff66&}]] 61 | end 62 | 63 | local time = chapter_list[i].time 64 | local title = chapter_list[i].title 65 | if title == "" then title = "Chapter " .. string.format("%02.f", i) end 66 | if time < 0 then time = 0 67 | else time = math.floor(time) end 68 | item.ass = string.format("[%02d:%02d:%02d]", math.floor(time/60/60), math.floor(time/60)%60, time%60) 69 | item.ass = item.ass..'\\h\\h\\h'..list.ass_escape(title) 70 | list.list[i] = item 71 | end 72 | list:update() 73 | end) 74 | 75 | --jump to the selected chapter 76 | local function open_chapter() 77 | if list.list[list.selected] then 78 | mp.set_property_number('chapter', list.selected - 1) 79 | end 80 | end 81 | 82 | --reset cursor navigation when open the list 83 | local function reset_cursor() 84 | if o.reset_cursor_on_close then 85 | if mp.get_property('chapter') then 86 | list.selected = mp.get_property_number('chapter') + 1 87 | list:update() 88 | end 89 | end 90 | end 91 | 92 | function list:open() 93 | reset_cursor() 94 | original_open(self) 95 | end 96 | 97 | --dynamic keybinds to bind when the list is open 98 | list.keybinds = {} 99 | 100 | local function add_keys(keys, name, fn, flags) 101 | local i = 1 102 | for key in keys:gmatch("%S+") do 103 | table.insert(list.keybinds, {key, name..i, fn, flags}) 104 | i = i + 1 105 | end 106 | end 107 | 108 | add_keys(o.key_scroll_down, 'scroll_down', function() list:scroll_down() end, {repeatable = true}) 109 | add_keys(o.key_scroll_up, 'scroll_up', function() list:scroll_up() end, {repeatable = true}) 110 | add_keys(o.key_move_pageup, 'move_pageup', function() list:move_pageup() end, {}) 111 | add_keys(o.key_move_pagedown, 'move_pagedown', function() list:move_pagedown() end, {}) 112 | add_keys(o.key_move_begin, 'move_begin', function() list:move_begin() end, {}) 113 | add_keys(o.key_move_end, 'move_end', function() list:move_end() end, {}) 114 | add_keys(o.key_open_chapter, 'open_chapter', open_chapter, {}) 115 | add_keys(o.key_close_browser, 'close_browser', function() list:close() end, {}) 116 | 117 | mp.register_script_message("toggle-chapter-browser", function() list:toggle() end) 118 | -------------------------------------------------------------------------------- /scroll-list.lua: -------------------------------------------------------------------------------- 1 | local mp = require 'mp' 2 | local scroll_list = { 3 | global_style = [[]], 4 | header_style = [[{\q2\fs35\c&00ccff&}]], 5 | list_style = [[{\q2\fs25\c&Hffffff&}]], 6 | wrapper_style = [[{\c&00ccff&\fs16}]], 7 | cursor_style = [[{\c&00ccff&}]], 8 | selected_style = [[{\c&Hfce788&}]], 9 | 10 | cursor = [[➤\h]], 11 | indent = [[\h\h\h\h]], 12 | 13 | num_entries = 16, 14 | wrap = false, 15 | empty_text = "no entries" 16 | } 17 | 18 | --formats strings for ass handling 19 | --this function is based on a similar function from https://github.com/mpv-player/mpv/blob/master/player/lua/console.lua#L110 20 | function scroll_list.ass_escape(str, replace_newline) 21 | if replace_newline == true then replace_newline = "\\\239\187\191n" end 22 | 23 | --escape the invalid single characters 24 | str = str:gsub('[\\{}\n]', { 25 | -- There is no escape for '\' in ASS (I think?) but '\' is used verbatim if 26 | -- it isn't followed by a recognised character, so add a zero-width 27 | -- non-breaking space 28 | ['\\'] = '\\\239\187\191', 29 | ['{'] = '\\{', 30 | ['}'] = '\\}', 31 | -- Precede newlines with a ZWNBSP to prevent ASS's weird collapsing of 32 | -- consecutive newlines 33 | ['\n'] = '\239\187\191\\N', 34 | }) 35 | 36 | -- Turn leading spaces into hard spaces to prevent ASS from stripping them 37 | str = str:gsub('\\N ', '\\N\\h') 38 | str = str:gsub('^ ', '\\h') 39 | 40 | if replace_newline then 41 | str = str:gsub("\\N", replace_newline) 42 | end 43 | return str 44 | end 45 | 46 | --format and return the header string 47 | function scroll_list:format_header_string(str) 48 | return str 49 | end 50 | 51 | --appends the entered text to the overlay 52 | function scroll_list:append(text) 53 | if text == nil then return end 54 | self.ass.data = self.ass.data .. text 55 | end 56 | 57 | --appends a newline character to the osd 58 | function scroll_list:newline() 59 | self.ass.data = self.ass.data .. '\\N' 60 | end 61 | 62 | --re-parses the list into an ass string 63 | --if the list is closed then it flags an update on the next open 64 | function scroll_list:update() 65 | if self.hidden then self.flag_update = true 66 | else self:update_ass() end 67 | end 68 | 69 | --prints the header to the overlay 70 | function scroll_list:format_header() 71 | self:append(self.header_style) 72 | self:append(self:format_header_string(self.header)) 73 | self:newline() 74 | end 75 | 76 | --formats each line of the list and prints it to the overlay 77 | function scroll_list:format_line(index, item) 78 | self:append(self.list_style) 79 | 80 | if index == self.selected then self:append(self.cursor_style..self.cursor..self.selected_style) 81 | else self:append(self.indent) end 82 | 83 | self:append(item.style) 84 | self:append(item.ass) 85 | self:newline() 86 | end 87 | 88 | --refreshes the ass text using the contents of the list 89 | function scroll_list:update_ass() 90 | self.ass.data = self.global_style 91 | self:format_header() 92 | 93 | if #self.list < 1 then 94 | self:append(self.empty_text) 95 | self.ass:update() 96 | return 97 | end 98 | 99 | local start = 1 100 | local finish = start+self.num_entries-1 101 | 102 | --handling cursor positioning 103 | local mid = math.ceil(self.num_entries/2)+1 104 | if self.selected+mid > finish then 105 | local offset = self.selected - finish + mid 106 | 107 | --if we've overshot the end of the list then undo some of the offset 108 | if finish + offset > #self.list then 109 | offset = offset - ((finish+offset) - #self.list) 110 | end 111 | 112 | start = start + offset 113 | finish = finish + offset 114 | end 115 | 116 | --making sure that we don't overstep the boundaries 117 | if start < 1 then start = 1 end 118 | local overflow = finish < #self.list 119 | --this is necessary when the number of items in the dir is less than the max 120 | if not overflow then finish = #self.list end 121 | 122 | --adding a header to show there are items above in the list 123 | if start > 1 then self:append(self.wrapper_style..(start-1)..' item(s) above\\N\\N') end 124 | 125 | for i=start, finish do 126 | self:format_line(i, self.list[i]) 127 | end 128 | 129 | if overflow then self:append('\\N'..self.wrapper_style..#self.list-finish..' item(s) remaining') end 130 | self.ass:update() 131 | end 132 | 133 | --moves the selector down the list 134 | function scroll_list:scroll_down() 135 | if self.selected < #self.list then 136 | self.selected = self.selected + 1 137 | self:update_ass() 138 | elseif self.wrap then 139 | self.selected = 1 140 | self:update_ass() 141 | end 142 | end 143 | 144 | --moves the selector up the list 145 | function scroll_list:scroll_up() 146 | if self.selected > 1 then 147 | self.selected = self.selected - 1 148 | self:update_ass() 149 | elseif self.wrap then 150 | self.selected = #self.list 151 | self:update_ass() 152 | end 153 | end 154 | 155 | --moves the selector to the list next page 156 | function scroll_list:move_pagedown() 157 | if #self.list > 1 then 158 | self.selected = self.selected + self.num_entries 159 | if self.selected > #self.list then self.selected = #self.list end 160 | self:update_ass() 161 | end 162 | end 163 | 164 | --moves the selector to the list previous page 165 | function scroll_list:move_pageup() 166 | if #self.list > 1 then 167 | self.selected = self.selected - self.num_entries 168 | if self.selected < 1 then self.selected = 1 end 169 | self:update_ass() 170 | end 171 | end 172 | 173 | --moves the selector to the list begin 174 | function scroll_list:move_begin() 175 | if #self.list > 1 then 176 | self.selected = 1 177 | self:update_ass() 178 | end 179 | end 180 | 181 | --moves the selector to the list end 182 | function scroll_list:move_end() 183 | if #self.list > 1 then 184 | self.selected = #self.list 185 | self:update_ass() 186 | end 187 | end 188 | 189 | --adds the forced keybinds 190 | function scroll_list:add_keybinds() 191 | for _,v in ipairs(self.keybinds) do 192 | mp.add_forced_key_binding(v[1], 'dynamic/'..self.ass.id..'/'..v[2], v[3], v[4]) 193 | end 194 | end 195 | 196 | --removes the forced keybinds 197 | function scroll_list:remove_keybinds() 198 | for _,v in ipairs(self.keybinds) do 199 | mp.remove_key_binding('dynamic/'..self.ass.id..'/'..v[2]) 200 | end 201 | end 202 | 203 | --opens the list and sets the hidden flag 204 | function scroll_list:open_list() 205 | self.hidden = false 206 | if not self.flag_update then self.ass:update() 207 | else self.flag_update = false ; self:update_ass() end 208 | end 209 | 210 | --closes the list and sets the hidden flag 211 | function scroll_list:close_list() 212 | self.hidden = true 213 | self.ass:remove() 214 | end 215 | 216 | --modifiable function that opens the list 217 | function scroll_list:open() 218 | if self.hidden then self:add_keybinds() end 219 | self:open_list() 220 | end 221 | 222 | --modifiable function that closes the list 223 | function scroll_list:close() 224 | self:remove_keybinds() 225 | self:close_list() 226 | end 227 | 228 | --toggles the list 229 | function scroll_list:toggle() 230 | if self.hidden then self:open() 231 | else self:close() end 232 | end 233 | 234 | --clears the list in-place 235 | function scroll_list:clear() 236 | local i = 1 237 | while self.list[i] do 238 | self.list[i] = nil 239 | i = i + 1 240 | end 241 | end 242 | 243 | --added alias for ipairs(list.list) for lua 5.1 244 | function scroll_list:ipairs() 245 | return ipairs(self.list) 246 | end 247 | 248 | --append item to the end of the list 249 | function scroll_list:insert(item) 250 | self.list[#self.list + 1] = item 251 | end 252 | 253 | local metatable = { 254 | __index = function(t, key) 255 | if scroll_list[key] ~= nil then return scroll_list[key] 256 | elseif key == "__current" then return t.list[t.selected] 257 | elseif type(key) == "number" then return t.list[key] end 258 | end, 259 | __newindex = function(t, key, value) 260 | if type(key) == "number" then rawset(t.list, key, value) 261 | else rawset(t, key, value) end 262 | end, 263 | __scroll_list = scroll_list, 264 | __len = function(t) return #t.list end, 265 | __ipairs = function(t) return ipairs(t.list) end 266 | } 267 | 268 | --creates a new list object 269 | function scroll_list:new() 270 | local vars 271 | vars = { 272 | ass = mp.create_osd_overlay('ass-events'), 273 | hidden = true, 274 | flag_update = true, 275 | 276 | header = "header \\N ----------------------------------------------", 277 | list = {}, 278 | selected = 1, 279 | 280 | keybinds = { 281 | {'DOWN', 'scroll_down', function() vars:scroll_down() end, {repeatable = true}}, 282 | {'UP', 'scroll_up', function() vars:scroll_up() end, {repeatable = true}}, 283 | {'PGDWN', 'move_pagedown', function() vars:move_pagedown() end, {}}, 284 | {'PGUP', 'move_pageup', function() vars:move_pageup() end, {}}, 285 | {'HOME', 'move_begin', function() vars:move_begin() end, {}}, 286 | {'END', 'move_end', function() vars:move_end() end, {}}, 287 | {'ESC', 'close_browser', function() vars:close() end, {}} 288 | } 289 | } 290 | return setmetatable(vars, metatable) 291 | end 292 | 293 | return scroll_list:new() 294 | --------------------------------------------------------------------------------