├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug.yml │ ├── config.yml │ └── enhancement.yml └── workflows │ └── lint.yml ├── .luacheckrc ├── LICENSE.txt ├── README.md ├── _config.yml ├── clipshot.lua ├── discord.lua ├── misc.lua └── open-dialog ├── README.md ├── kdialog.lua ├── osascript.lua ├── powershell.lua └── zenity.lua /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: ObserverOfTime 2 | liberapay: ObserverOfTime 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | labels: [bug] 3 | description: Did something not work as expected? 4 | body: 5 | - type: dropdown 6 | id: scripts 7 | attributes: 8 | label: Script 9 | multiple: false 10 | options: 11 | - "`clipshot.lua`" 12 | - "`discord.lua`" 13 | - "`misc.lua`" 14 | - "`open-dialog/kdialog.lua`" 15 | - "`open-dialog/powershell.lua`" 16 | - "`open-dialog/zenity.lua`" 17 | validations: {required: true} 18 | - type: textarea 19 | id: description 20 | attributes: 21 | label: Description 22 | description: >- 23 | Provide a detailed description of the 24 | issue, and why you consider it to be a bug. 25 | validations: {required: true} 26 | - type: textarea 27 | id: suggestion 28 | attributes: 29 | label: Possible Fix 30 | description: >- 31 | Can you suggest a fix or reason for the bug? 32 | validations: {required: false} 33 | - type: textarea 34 | id: reproduce 35 | attributes: 36 | label: Steps to Reproduce 37 | description: >- 38 | Provide some screenshots, or an unambiguous set of steps to 39 | reproduce this bug. Include code to reproduce, if relevant. 40 | validations: {required: false} 41 | - type: checkboxes 42 | id: operating-system 43 | attributes: 44 | label: Operating System(s) 45 | options: 46 | - label: Windows 47 | - label: Linux 48 | - label: macOS 49 | - type: textarea 50 | id: mpv-version 51 | attributes: 52 | label: MPV version 53 | description: "`mpv --version`" 54 | render: text 55 | validations: {required: false} 56 | - type: input 57 | id: lua-version 58 | attributes: 59 | label: Lua version 60 | description: |- 61 | Linux/macOS: `ldd "$(which mpv)" | grep lua` 62 | Windows: Figure it out ¯\\_(ツ)\_/¯ 63 | validations: {required: false} 64 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/enhancement.yml: -------------------------------------------------------------------------------- 1 | name: Feature Request 2 | labels: [enhancement] 3 | description: Do you want something changed or implemented? 4 | body: 5 | - type: dropdown 6 | id: scripts 7 | attributes: 8 | label: Script 9 | multiple: false 10 | options: 11 | - "`clipshot.lua`" 12 | - "`discord.lua`" 13 | - "`misc.lua`" 14 | - "`open-dialog/kdialog.lua`" 15 | - "`open-dialog/powershell.lua`" 16 | - "`open-dialog/zenity.lua`" 17 | validations: {required: true} 18 | - type: textarea 19 | id: description 20 | attributes: 21 | label: Description 22 | description: >- 23 | Provide a detailed description of the 24 | change or addition you are proposing. 25 | validations: {required: true} 26 | - type: textarea 27 | id: suggestion 28 | attributes: 29 | label: Possible Implementation 30 | description: >- 31 | Can you suggest an idea for implementing the feature? 32 | validations: {required: false} 33 | - type: textarea 34 | id: context 35 | attributes: 36 | label: Context 37 | description: >- 38 | Why is this change or addition important to you? 39 | How would you use it, and how can it benefit other users? 40 | validations: {required: false} 41 | - type: checkboxes 42 | id: operating-system 43 | attributes: 44 | label: Operating System(s) 45 | options: 46 | - label: Windows 47 | - label: Linux 48 | - label: macOS 49 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Check scripts with luacheck 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | paths: ["**/*.lua"] 7 | pull_request: 8 | paths: ["**/*.lua"] 9 | 10 | jobs: 11 | luacheck: 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | lua: 16 | - ver: 5.1.5 17 | std: lua51 18 | - ver: 5.2.4 19 | std: lua52c 20 | - ver: luajit-2.0.5 21 | std: luajit 22 | steps: 23 | - uses: actions/checkout@v4 24 | name: Checkout repository 25 | - uses: leafo/gh-actions-lua@ecdb13962d7d7274594480620bb6075504122bfe 26 | name: Install lua [${{matrix.lua.ver}}] 27 | with: 28 | luaVersion: ${{matrix.lua.ver}} 29 | - uses: leafo/gh-actions-luarocks@4dcae7fc5aff45e847b32f62b60a13167e912395 30 | name: Install luarocks 31 | - name: Install luacheck 32 | run: luarocks install luacheck 33 | - name: Lint with luacheck 34 | run: luacheck --std ${{matrix.lua.std}} . 35 | -------------------------------------------------------------------------------- /.luacheckrc: -------------------------------------------------------------------------------- 1 | ---@diagnostic disable: lowercase-global 2 | 3 | std = 'luajit' 4 | 5 | read_globals = {'mp'} 6 | 7 | allow_defined_top = false 8 | 9 | max_line_length = 100 10 | 11 | max_comment_line_length = false 12 | 13 | include_files = { 14 | 'clipshot.lua', 15 | 'discord.lua', 16 | 'misc.lua', 17 | 'open-dialog/*.lua' 18 | } 19 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019-2021 ObserverOfTime 2 | 3 | Permission to use, copy, modify, and/or distribute this software 4 | for any purpose with or without fee is hereby granted. 5 | 6 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 7 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 8 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 9 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 10 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 11 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 12 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## mpv scripts 2 | 3 | [![LICENSE](https://img.shields.io/badge/license-BSD0-red.svg)](LICENSE.txt "BSD Zero Clause License") 4 | 5 | My collection of cross-platform scripts for [mpv][mpv]. 6 | 7 | Feel free to edit and adapt them however you like 8 |
and if you think your changes should be merged, 9 |
don't hesitate to submit a pull request. 10 | 11 | ### [open-dialog](open-dialog) 12 | 13 | Scripts that launch a dialog for opening files or URLs. 14 |
Follow the link for details. 15 | 16 | ### [clipshot.lua](clipshot.lua) 17 | 18 | #### `clipshot-subs` 19 | 20 | Screenshot the video (with subs) and copy it to the clipboard. 21 |
Default key binding: c 22 | 23 | #### `clipshot-video` 24 | 25 | Screenshot the video (w/o subs) and copy it to the clipboard. 26 |
Default key binding: C 27 | 28 | #### `clipshot-window` 29 | 30 | Screenshot the full window and copy it to the clipboard. 31 |
Default key binding: Alt + c 32 | 33 | ### [discord.lua](discord.lua) 34 | 35 | Discord rich presence in a single script. 36 |
Default key binding: D 37 | 38 | | | Windows | Linux | MacOS | 39 | |:------------:|:---------:|:-------:|:-------:| 40 | | **LuaJIT** | ✓ | ✓ | ✗ | 41 | | **Lua** | ✓ | ∗ | ∗ | 42 | 43 | ∗ Requires [LuaSocket](https://w3.impa.br/~diego/software/luasocket/) 44 | 45 | ### [misc.lua](misc.lua) 46 | 47 | Miscellaneous simple functions. 48 | 49 | #### `show-time` 50 | 51 | Show the current time (`HH:MM`) on the OSD. 52 |
Default key binding: Ctrl+t 53 | 54 | [mpv]: https://github.com/mpv-player/mpv 55 | -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-leap-day 2 | title: mpv scripts 3 | description: My personal mpv scripts 4 | show_downloads: false 5 | -------------------------------------------------------------------------------- /clipshot.lua: -------------------------------------------------------------------------------- 1 | ---Screenshot the video and copy it to the clipboard 2 | ---@author ObserverOfTime 3 | ---@license 0BSD 4 | 5 | ---@class ClipshotOptions 6 | ---@field name string 7 | ---@field type string 8 | local o = { 9 | name = 'mpv-screenshot.jpeg', 10 | type = '' -- defaults to jpeg 11 | } 12 | require('mp.options').read_options(o, 'clipshot') 13 | 14 | local file, cmd 15 | 16 | local platform = mp.get_property_native('platform') 17 | if platform == 'windows' then 18 | file = os.getenv('TEMP')..'\\'..o.name 19 | cmd = { 20 | 'powershell', '-NoProfile', '-Command', 21 | 'Add-Type -Assembly System.Windows.Forms, System.Drawing;', 22 | string.format( 23 | "[Windows.Forms.Clipboard]::SetImage([Drawing.Image]::FromFile('%s'))", 24 | file:gsub("'", "''") 25 | ) 26 | } 27 | elseif platform == 'darwin' then 28 | file = os.getenv('TMPDIR')..'/'..o.name 29 | -- png: «class PNGf» 30 | local type = o.type ~= '' and o.type or 'JPEG picture' 31 | cmd = { 32 | 'osascript', '-e', string.format( 33 | 'set the clipboard to (read (POSIX file %q) as %s)', 34 | file, type 35 | ) 36 | } 37 | else 38 | file = '/tmp/'..o.name 39 | if os.getenv('XDG_SESSION_TYPE') == 'wayland' then 40 | cmd = {'sh', '-c', ('wl-copy < %q'):format(file)} 41 | else 42 | local type = o.type ~= '' and o.type or 'image/jpeg' 43 | cmd = {'xclip', '-sel', 'c', '-t', type, '-i', file} 44 | end 45 | end 46 | 47 | ---@param arg string 48 | ---@return fun() 49 | local function clipshot(arg) 50 | return function() 51 | mp.commandv('screenshot-to-file', file, arg) 52 | mp.command_native_async({'run', unpack(cmd)}, function(suc, _, err) 53 | mp.osd_message(suc and 'Copied screenshot to clipboard' or err, 1) 54 | end) 55 | end 56 | end 57 | 58 | mp.add_key_binding('c', 'clipshot-subs', clipshot('subtitles')) 59 | mp.add_key_binding('C', 'clipshot-video', clipshot('video')) 60 | mp.add_key_binding('Alt+c', 'clipshot-window', clipshot('window')) 61 | -------------------------------------------------------------------------------- /discord.lua: -------------------------------------------------------------------------------- 1 | ---Discord rich presence 2 | ---@author ObserverOfTime 3 | ---@license 0BSD 4 | 5 | local utils = require 'mp.utils' 6 | local msg = require 'mp.msg' 7 | 8 | ---@class DiscordOptions 9 | ---@field timeout integer 10 | ---@field keybind string 11 | ---@field enabled boolean 12 | ---@field invidious string 13 | ---@field piped string 14 | ---@field nitter string 15 | ---@field libreddit string 16 | ---@field client_id string 17 | local o = { 18 | timeout = 2, 19 | keybind = 'D', 20 | enabled = false, 21 | invidious = 'yewtu%.be', 22 | piped = 'piped%.kavin%.rocks', 23 | nitter = 'nitter%.net', 24 | libreddit = 'libredd%.it', 25 | client_id = '700723249889149038' 26 | } 27 | require('mp.options').read_options(o, 'discord') 28 | 29 | ---@return string 30 | local function uuid() 31 | math.randomseed(mp.get_time() * 1e4) 32 | local tpl = 'XXXXXXXX-XXXX-4XXX-%xXXX-XXXXXXXXXXXX' 33 | return tpl:format(math.random(8, 0xb)):gsub('X', function(_) 34 | return ('%x'):format(math.random(0, 0xf)) 35 | end) 36 | end 37 | 38 | ---@param str string 39 | ---@return string 40 | local function tohex(str) 41 | return str:gsub('.', function(c) 42 | return ('\\x%02x'):format(c:byte()) 43 | end) 44 | end 45 | 46 | ---@type string 47 | local VERSION = mp.get_property('mpv-version') 48 | 49 | ---@enum OP 50 | local OP = {AUTHENTICATE = 0, FRAME = 1, CLOSE = 2} 51 | 52 | ---@class unixstream 53 | ---@field connect fun() 54 | ---@field receive fun() 55 | ---@field send fun() 56 | 57 | ---@class RPC 58 | ---@field socket integer|file*|unixstream? 59 | ---@field pid integer 60 | ---@field unix boolean 61 | ---@field path string 62 | ---@field _last string? 63 | local RPC = { 64 | socket = nil, 65 | pid = utils.getpid(), 66 | unix = package.config:sub(1, 1) == '/' 67 | } 68 | 69 | if RPC.unix then 70 | local temp = os.getenv('XDG_RUNTIME_DIR') 71 | or os.getenv('TMPDIR') 72 | or os.getenv('TMP') 73 | or os.getenv('TEMP') 74 | or '/tmp' 75 | RPC.path = temp..'/discord-ipc-0' 76 | if _G.jit then 77 | msg.verbose('using', _G.jit.version) 78 | local ffi = require 'ffi' 79 | ffi.cdef[[ 80 | struct sockaddr { 81 | unsigned short int sa_family; 82 | char sa_data[14]; 83 | }; 84 | struct sockaddr_un { 85 | unsigned short int sun_family; 86 | char sun_path[108]; 87 | }; 88 | int socket(int domain, int type, int protocol); 89 | int connect(int fd, const struct sockaddr *addr, unsigned int len); 90 | int recv(int fd, void *buf, unsigned int len, int flags); 91 | int send(int fd, const void *buf, unsigned int n, int flags); 92 | int close(int fd); 93 | char *strerror(int errnum); 94 | ]] 95 | ---@return string 96 | function RPC._strerror() 97 | return ffi.string(ffi.C.strerror(ffi.errno())) 98 | end 99 | ---@param fd integer 100 | ---@param addr ffi.cdata* 101 | ---@return integer 102 | function RPC._connect(fd, addr) 103 | local cast = ffi.cast('const struct sockaddr *', addr) 104 | return ffi.C.connect(fd, cast, ffi.sizeof(addr[0])) 105 | end 106 | ---@param fd integer 107 | ---@param len integer 108 | ---@return boolean, string 109 | function RPC._recv(fd, len) 110 | local buff = ffi.new('unsigned char[?]', len) 111 | local status = ffi.C.recv(fd, buff, len, 0) ~= -1 112 | return status, status and ffi.string(buff, len) or RPC._strerror() 113 | end 114 | else 115 | local socket = assert(require 'socket') 116 | msg.verbose('using', socket._VERSION) 117 | end 118 | else 119 | RPC.path = [[\\.\pipe\discord-ipc-0]] 120 | end 121 | 122 | ---@class Assets 123 | ---@field large_image string 124 | ---@field large_text string? 125 | ---@field small_image string? 126 | ---@field small_text string? 127 | 128 | ---@class Timestamps 129 | ---@field start integer 130 | ---@field end integer? 131 | 132 | ---@class Button 133 | ---@field label string 134 | ---@field url string 135 | 136 | ---@class Activity 137 | ---@field details string 138 | ---@field state string? 139 | ---@field timestamps Timestamps? 140 | ---@field buttons Button[]? 141 | ---@field assets Assets 142 | RPC.activity = { 143 | details = 'No file', 144 | state = nil, 145 | timestamps = nil, 146 | buttons = nil, 147 | assets = { 148 | large_image = 'mpv', 149 | large_text = VERSION, 150 | small_image = 'stop', 151 | small_text = 'Idle' 152 | } 153 | } 154 | 155 | ---@return integer 156 | function RPC.get_time() 157 | local pos = mp.get_property_number('time-pos', 0) 158 | return math.floor(os.time() - pos) 159 | end 160 | 161 | ---@param op OP 162 | ---@param body string? 163 | ---@return string 164 | function RPC.pack(op, body) 165 | local bytes = {} 166 | assert(body, 'empty body') 167 | local len = body:len() 168 | for _ = 1, 4 do 169 | table.insert(bytes, string.char(op % (2 ^ 8))) 170 | op = math.floor(op / (2 ^ 8)) 171 | end 172 | for _ = 1, 4 do 173 | table.insert(bytes, string.char(len % (2 ^ 8))) 174 | len = math.floor(len / (2 ^ 8)) 175 | end 176 | return table.concat(bytes, '')..body 177 | end 178 | 179 | ---@param body string? 180 | ---@return number, number 181 | function RPC.unpack(body) 182 | local byte 183 | local op = 0 184 | local len = 0 185 | local iter = 1 186 | assert(body, 'empty body') 187 | for j = 1, 4 do 188 | byte = body:sub(iter, iter):byte() 189 | op = op + byte * (2 ^ ((j - 1) * 8)) 190 | iter = iter + 1 191 | end 192 | for j = 1, 4 do 193 | byte = body:sub(iter, iter):byte() 194 | len = len + byte * (2 ^ ((j - 1) * 8)) 195 | iter = iter + 1 196 | end 197 | return math.floor(op), math.floor(len) 198 | end 199 | 200 | ---@return boolean 201 | function RPC:connect() 202 | local status, data 203 | if _G.jit then 204 | local ffi = package.loaded.ffi 205 | local addr = ffi.new('struct sockaddr_un[1]', {{ 206 | sun_family = 1, -- AF_UNIX 207 | sun_path = self.path 208 | }}) 209 | self.socket = ffi.C.socket(1, 1, 0) -- AF_UNIX, SOCK_STREAM, 0 210 | if self.socket ~= -1 then 211 | status = self._connect(self.socket, addr) 212 | if status ~= -1 then return true end 213 | end 214 | data = self._strerror() 215 | elseif self.unix then 216 | self.socket = require 'socket.unix' () 217 | status, data = pcall(function() 218 | assert(self.socket:connect(self.path)) 219 | end) 220 | if status then return true end 221 | else 222 | status, data = pcall(function() 223 | return assert(io.open(self.path, 'r+b')) 224 | end) 225 | self.socket = data 226 | if status then return true end 227 | end 228 | self.socket = nil 229 | msg.fatal(data, '('..self.path..')') 230 | return false 231 | end 232 | 233 | ---@param len integer 234 | ---@return string? 235 | function RPC:recv(len) 236 | if not self.socket then 237 | assert(self:connect(), 'failed to connect') 238 | end 239 | local status, data 240 | if _G.jit then 241 | status, data = self._recv(self.socket, len) 242 | elseif self.unix then 243 | status, data = pcall(function() 244 | return assert(self.socket:receive(len)) 245 | end) 246 | else 247 | status, data = pcall(function() 248 | return assert(self.socket:read(len)) 249 | end) 250 | end 251 | if not status then 252 | msg.error(data) 253 | return nil 254 | end 255 | msg.debug('received', tohex(data)) 256 | assert(data:len() == len, 'incorrect data length') 257 | return data 258 | end 259 | 260 | ---@param op OP 261 | ---@param body string? 262 | function RPC:send(op, body) 263 | if not self.socket then 264 | assert(self:connect(), 'failed to connect') 265 | end 266 | local data = self.pack(op, body) 267 | msg.debug('sending', tohex(data)) 268 | if _G.jit then 269 | local status = package.loaded.ffi.C 270 | .send(self.socket, data, #data, 0) 271 | assert(status ~= -1, self._strerror()) 272 | elseif self.unix then 273 | assert(self.socket:send(data)) 274 | else 275 | assert(self.socket:write(data)) 276 | self.socket:flush() 277 | end 278 | end 279 | 280 | ---@param version? integer 281 | function RPC:handshake(version) 282 | local body = utils.format_json { 283 | v = version or 1, 284 | client_id = o.client_id 285 | } 286 | self:send(OP.AUTHENTICATE, body) 287 | local op, len = self.unpack(self:recv(8)) 288 | local res = utils.parse_json(self:recv(len)) 289 | assert(op == OP.FRAME, res.message) 290 | assert(res.evt == 'READY', res.message) 291 | msg.verbose('performed handshake') 292 | end 293 | 294 | ---@return string? 295 | function RPC:set_activity() 296 | if self.activity.details:len() > 127 then 297 | self.activity.details = self.activity.details:sub(1, 126)..'…' 298 | end 299 | local nonce = uuid() 300 | local body = utils.format_json { 301 | cmd = 'SET_ACTIVITY', nonce = nonce, 302 | args = {activity = self.activity, pid = self.pid} 303 | } 304 | self:send(OP.FRAME, body) 305 | local res = self:recv(8) 306 | if not res then 307 | msg.info('reattempting to set activity') 308 | return self:set_activity() 309 | end 310 | local _, len = self.unpack(res) 311 | res = utils.parse_json(self:recv(len)) 312 | if not res then 313 | msg.info('reattempting to set activity') 314 | return self:set_activity() 315 | end 316 | assert(res.cmd == 'SET_ACTIVITY', 'incorrect cmd') 317 | assert(res.nonce == nonce, 'incorrect nonce') 318 | if res.evt == 'ERROR' then 319 | msg.error(res.data.message) 320 | return nil 321 | end 322 | return body 323 | end 324 | 325 | function RPC:disconnect() 326 | if self.socket then 327 | self:send(OP.CLOSE, '') 328 | if _G.jit then 329 | local status = package.loaded.ffi.C.close(self.socket) 330 | assert(status ~= -1, self._strerror()) 331 | else 332 | self.socket:close() 333 | end 334 | self.socket = nil 335 | end 336 | end 337 | 338 | mp.register_event('idle-active', function() 339 | RPC.activity = { 340 | details = 'No file', 341 | state = nil, 342 | buttons = nil, 343 | timestamps = nil, 344 | assets = { 345 | small_image = 'stop', 346 | small_text = 'Idle', 347 | large_image = 'mpv', 348 | large_text = VERSION 349 | } 350 | } 351 | end) 352 | 353 | mp.register_event('file-loaded', function() 354 | local title = mp.get_property('media-title') or 'Untitled' 355 | local artist = mp.get_property('metadata/by-key/Artist') 356 | title = artist and title..' - '..artist or title 357 | local time = mp.get_property_number('duration') 358 | time = time and 'Duration: '..os.date('!%T', time) or '' 359 | local plist = mp.get_property_number('playlist-count') 360 | if plist > 1 then 361 | local item = mp.get_property('playlist-pos') 362 | plist = (' [%d/%d]'):format(item + 1, plist) 363 | item = mp.get_property(('playlist/%d/title'):format(item)) 364 | if item then title = item end 365 | else 366 | plist = '' 367 | end 368 | local path = mp.get_property('path') 369 | if path and path:find('^https?://') then 370 | if path:find('youtube%.com') or 371 | path:find('youtu%.be') then 372 | RPC.activity.assets.large_image = 'youtube' 373 | elseif path:find('twitch%.tv') then 374 | RPC.activity.assets.large_image = 'twitch' 375 | elseif path:find('cdn%.discordapp%.com') or 376 | path:find('media%.discordapp%.net') then 377 | RPC.activity.assets.large_image = 'discord' 378 | elseif path:find('drive%.google%.') then 379 | RPC.activity.assets.large_image = 'drive' 380 | elseif path:find('twitter%.com') then 381 | RPC.activity.assets.large_image = 'twitter' 382 | elseif path:find('reddit%.com') or 383 | path:find('redd%.it') then 384 | RPC.activity.assets.large_image = 'reddit' 385 | elseif path:find(o.invidious) then 386 | RPC.activity.assets.large_image = 'invidious' 387 | elseif path:find(o.piped) then 388 | RPC.activity.assets.large_image = 'piped' 389 | elseif path:find(o.nitter) then 390 | RPC.activity.assets.large_image = 'nitter' 391 | elseif path:find(o.libreddit) then 392 | RPC.activity.assets.large_image = 'libreddit' 393 | else 394 | RPC.activity.assets.large_image = 'stream' 395 | end 396 | RPC.activity.buttons = { 397 | {label = 'Open URL', url = path} 398 | } 399 | else 400 | RPC.activity.buttons = nil 401 | RPC.activity.assets.large_image = 'mpv' 402 | end 403 | RPC.activity.details = title 404 | RPC.activity.state = time..plist 405 | RPC.activity.assets.small_image = 'pause' 406 | RPC.activity.assets.small_text = 'Paused' 407 | RPC.activity.timestamps = nil 408 | end) 409 | 410 | mp.register_event('shutdown', function() 411 | RPC:disconnect() 412 | end) 413 | 414 | mp.register_event('seek', function() 415 | if not mp.get_property_bool('pause') then 416 | RPC.activity.timestamps = {start = RPC.get_time()} 417 | end 418 | end) 419 | 420 | mp.observe_property('paused-for-cache', 'bool', function(_, value) 421 | if value then 422 | RPC.activity.timestamps = nil 423 | RPC.activity.assets.small_image = 'play' 424 | RPC.activity.assets.small_text = 'Playing' 425 | else 426 | RPC.activity.timestamps = {start = RPC.get_time()} 427 | RPC.activity.assets.small_image = 'play' 428 | RPC.activity.assets.small_text = 'Playing' 429 | end 430 | end) 431 | 432 | mp.observe_property('core-idle', 'bool', function(_, value) 433 | if value then 434 | RPC.activity.timestamps = nil 435 | RPC.activity.assets.small_image = 'pause' 436 | RPC.activity.assets.small_text = 'Loading' 437 | else 438 | RPC.activity.timestamps = {start = RPC.get_time()} 439 | RPC.activity.assets.small_image = 'play' 440 | RPC.activity.assets.small_text = 'Playing' 441 | end 442 | end) 443 | 444 | mp.observe_property('pause', 'bool', function(_, value) 445 | if value then 446 | RPC.activity.timestamps = nil 447 | RPC.activity.assets.small_image = 'pause' 448 | RPC.activity.assets.small_text = 'Paused' 449 | else 450 | RPC.activity.timestamps = {start = RPC.get_time()} 451 | RPC.activity.assets.small_image = 'play' 452 | RPC.activity.assets.small_text = 'Playing' 453 | end 454 | end) 455 | 456 | mp.observe_property('eof-reached', 'bool', function(_, value) 457 | if value then 458 | RPC.activity.timestamps = nil 459 | RPC.activity.assets.small_image = 'stop' 460 | RPC.activity.assets.small_text = 'Idle' 461 | end 462 | end) 463 | 464 | local timer = mp.add_periodic_timer(o.timeout, function() 465 | local curr = utils.format_json(RPC.activity) 466 | if RPC._last ~= curr then 467 | RPC:set_activity() 468 | RPC._last = curr 469 | end 470 | end) 471 | 472 | mp.add_key_binding(o.keybind, 'toggle-discord-rpc', function() 473 | o.enabled = not o.enabled 474 | if o.enabled then 475 | RPC:handshake() 476 | timer:resume() 477 | else 478 | RPC._last = nil 479 | RPC:disconnect() 480 | timer:kill() 481 | end 482 | end) 483 | 484 | if o.enabled then 485 | RPC:handshake() 486 | RPC:set_activity() 487 | else 488 | timer:kill() 489 | end 490 | -------------------------------------------------------------------------------- /misc.lua: -------------------------------------------------------------------------------- 1 | ---Miscellaneous utilities 2 | ---@author ObserverOfTime 3 | ---@license 0BSD 4 | 5 | ---@type string 6 | local TIME_FORMAT = '%H:%M' 7 | 8 | mp.add_key_binding('Ctrl+t', 'show-time', function() 9 | mp.osd_message(os.date(TIME_FORMAT), 2) 10 | end) 11 | -------------------------------------------------------------------------------- /open-dialog/README.md: -------------------------------------------------------------------------------- 1 | ## Open Dialog 2 | 3 | Rather than write a single cross-platform script, which would be 4 | too complicated, I opted for separate scripts for each platform. 5 | 6 | All scripts provide the following functions: 7 | 8 | #### `open-files` 9 | 10 | Open one or more media files in mpv. 11 |
Default key binding: Ctrl + f 12 | 13 | #### `open-url` 14 | 15 | Open a URL in mpv. 16 |
Default key binding: Ctrl + F 17 | 18 | #### `open-subs` 19 | 20 | Open one or more subtitle files in mpv. 21 |
Default key binding: Alt + f 22 | 23 | --- 24 | 25 | ### Linux 26 | 27 | If you're on KDE, you should download 28 | [kdialog.lua](kdialog.lua) which uses [KDialog][kdialog]. 29 | 30 | If not, you should download 31 | [zenity.lua](zenity.lua) which uses [Zenity][zenity]. 32 | 33 | [xdotool][xdotool] is required for both scripts. 34 | 35 | ### Windows 36 | 37 | Download [powershell.lua](powershell.lua). 38 | 39 | ### MacOS 40 | 41 | Download [osascript.lua](osascript.lua). 42 | 43 | [kdialog]: https://github.com/KDE/kdialog 44 | [zenity]: https://github.com/GNOME/zenity 45 | [xdotool]: https://github.com/jordansissel/xdotool 46 | -------------------------------------------------------------------------------- /open-dialog/kdialog.lua: -------------------------------------------------------------------------------- 1 | ---Launch a dialog for opening files or URLs (KDialog) 2 | ---@author ObserverOfTime 3 | ---@license 0BSD 4 | 5 | local utils = require 'mp.utils' 6 | 7 | local MULTIMEDIA = table.concat({ 8 | '*.aac', 9 | '*.avi', 10 | '*.flac', 11 | '*.flv', 12 | '*.m3u', 13 | '*.m3u8', 14 | '*.m4v', 15 | '*.mkv', 16 | '*.mov', 17 | '*.mp3', 18 | '*.mp4', 19 | '*.mpeg', 20 | '*.mpg', 21 | '*.oga', 22 | '*.ogg', 23 | '*.ogv', 24 | '*.opus', 25 | '*.wav', 26 | '*.webm', 27 | '*.wmv', 28 | }, ' ') 29 | 30 | local SUBTITLES = table.concat({ 31 | '*.ass', 32 | '*.srt', 33 | '*.ssa', 34 | '*.sub', 35 | '*.txt', 36 | }, ' ') 37 | 38 | local ICON = 'mpv' 39 | 40 | ---@class KDOpts 41 | ---@field title string 42 | ---@field text string 43 | ---@field default? string 44 | ---@field type? string 45 | ---@field args string[] 46 | 47 | ---@param opts KDOpts 48 | ---@return fun() 49 | local function KDialog(opts) 50 | return function() 51 | local path = mp.get_property('path') 52 | path = path == nil and '' or utils.split_path( 53 | utils.join_path(utils.getcwd(), path) 54 | ) 55 | local ontop = mp.get_property_native('ontop') 56 | local focus = utils.subprocess { 57 | args = {'xdotool', 'getwindowfocus'} 58 | }.stdout:gsub('\n$', '') 59 | mp.set_property_native('ontop', false) 60 | local kdialog = utils.subprocess { 61 | args = { 62 | 'kdialog', opts.default or path, 63 | '--title', opts.title, 64 | '--attach', focus, 65 | '--icon', ICON, 66 | '--multiple', '--separate-output', 67 | opts.type or '--getopenfilename', opts.text, 68 | }, cancellable = false, 69 | } 70 | mp.set_property_native('ontop', ontop) 71 | if kdialog.status ~= 0 then return end 72 | for file in kdialog.stdout:gmatch('[^\n]+') do 73 | mp.commandv(opts.args[1], file, opts.args[2]) 74 | end 75 | end 76 | end 77 | 78 | mp.add_key_binding('Ctrl+f', 'open-files', KDialog { 79 | title = 'Select Files', 80 | text = 'Multimedia Files ('..MULTIMEDIA..')', 81 | args = {'loadfile', 'append-play'}, 82 | }) 83 | mp.add_key_binding('Ctrl+F', 'open-url', KDialog { 84 | title = 'Open URL', 85 | text = 'Enter the URL to open:', 86 | default = '', 87 | type = '--inputbox', 88 | args = {'loadfile', 'replace'}, 89 | }) 90 | mp.add_key_binding('Alt+f', 'open-subs', KDialog { 91 | title = 'Select Subs', 92 | text = 'Subtitle Files ('..SUBTITLES..')', 93 | args = {'sub-add', 'select'}, 94 | }) 95 | -------------------------------------------------------------------------------- /open-dialog/osascript.lua: -------------------------------------------------------------------------------- 1 | ---Launch a dialog for opening files or URLs (OSAScript) 2 | ---@author ObserverOfTime 3 | ---@license 0BSD 4 | 5 | local utils = require 'mp.utils' 6 | 7 | local MULTIMEDIA = utils.format_json({ 8 | 'AAC', 9 | 'AVI', 10 | 'FLAC', 11 | 'FLV', 12 | 'M3U', 13 | 'M3U8', 14 | 'M4V', 15 | 'MKV', 16 | 'MOV', 17 | 'MP3', 18 | 'MP4', 19 | 'MPEG', 20 | 'MPG', 21 | 'OGA', 22 | 'OGG', 23 | 'OGV', 24 | 'OPUS', 25 | 'WAV', 26 | 'WEBM', 27 | 'WMV', 28 | }) 29 | 30 | local SUBTITLES = utils.format_json({ 31 | 'ASS', 32 | 'SRT', 33 | 'SSA', 34 | 'SUB', 35 | 'TXT', 36 | }) 37 | 38 | ---@class OSAOpts 39 | ---@field title string 40 | ---@field text string|nil 41 | ---@field args string[] 42 | ---@field language? string 43 | ---@field template? string 44 | 45 | ---@param opts OSAOpts 46 | ---@return fun() 47 | local function OSAScript(opts) 48 | return function() 49 | local template = opts.template or [[ 50 | var app = Application.currentApplication() 51 | app.includeStandardAdditions = true 52 | app.chooseFile({ 53 | ofType: %s, 54 | withPrompt: %q, 55 | defaultLocation: %q, 56 | multipleSelectionsAllowed: true 57 | }).join("\n\r") 58 | ]] 59 | local language = opts.language or 'AppleScript' 60 | local path = mp.get_property('path') 61 | path = path == nil and '.' or utils.split_path( 62 | utils.join_path(utils.getcwd(), path) 63 | ) 64 | local ontop = mp.get_property_native('ontop') 65 | mp.set_property_native('ontop', false) 66 | local osascript = utils.subprocess { 67 | args = { 68 | 'osascript', '-l', language, '-e', 69 | template:format(opts.text, opts.title, path) 70 | }, cancellable = false 71 | } 72 | mp.set_property_native('ontop', ontop) 73 | if osascript.status ~= 0 then return end 74 | for file in osascript.stdout:gmatch('[^\r\n]+') do 75 | mp.commandv(opts.args[1], file, opts.args[2]) 76 | end 77 | end 78 | end 79 | 80 | mp.add_key_binding('Ctrl+f', 'open-files', OSAScript { 81 | title = 'Select Media Files', 82 | text = MULTIMEDIA, 83 | args = {'loadfile', 'append-play'}, 84 | language = 'JavaScript' 85 | }) 86 | mp.add_key_binding('Ctrl+F', 'open-url', OSAScript { 87 | title = 'Open URL', 88 | text = 'Enter the URL to open:', 89 | args = {'loadfile', 'replace'}, 90 | template = [[ 91 | try 92 | return text returned of ( ¬ 93 | display dialog %q ¬ 94 | with title %q default answer "" ¬ 95 | buttons {"Cancel", "OK"} default button 2) 96 | on error number -128 97 | return "" 98 | end try 99 | ]], 100 | }) 101 | mp.add_key_binding('Alt+f', 'open-subs', OSAScript { 102 | title = 'Select Subtitles', 103 | text = SUBTITLES, 104 | args = {'sub-add', 'select'}, 105 | language = 'JavaScript' 106 | }) 107 | -------------------------------------------------------------------------------- /open-dialog/powershell.lua: -------------------------------------------------------------------------------- 1 | ---Launch a dialog for opening files or URLs (PowerShell) 2 | ---@author ObserverOfTime 3 | ---@license 0BSD 4 | 5 | local utils = require 'mp.utils' 6 | 7 | local MULTIMEDIA = table.concat({ 8 | '*.aac', 9 | '*.avi', 10 | '*.flac', 11 | '*.flv', 12 | '*.m3u', 13 | '*.m3u8', 14 | '*.m4v', 15 | '*.mkv', 16 | '*.mov', 17 | '*.mp3', 18 | '*.mp4', 19 | '*.mpeg', 20 | '*.mpg', 21 | '*.oga', 22 | '*.ogg', 23 | '*.ogv', 24 | '*.opus', 25 | '*.wav', 26 | '*.webm', 27 | '*.wmv', 28 | }, ';') 29 | 30 | local SUBTITLES = table.concat({ 31 | '*.ass', 32 | '*.srt', 33 | '*.ssa', 34 | '*.sub', 35 | '*.txt', 36 | }, ';') 37 | 38 | ---@class PSOpts 39 | ---@field title string 40 | ---@field text string 41 | ---@field args string[] 42 | ---@field template? string 43 | 44 | ---@param opts PSOpts 45 | ---@return fun() 46 | local function PowerShell(opts) 47 | return function() 48 | local template = opts.template or [[& { 49 | Add-Type -AssemblyName System.Windows.Forms; 50 | [Windows.Forms.Application]::EnableVisualStyles(); 51 | $dialog = New-Object Windows.Forms.OpenFileDialog; 52 | $dialog.Filter = %q; 53 | $dialog.Title = %q; 54 | $dialog.InitialDirectory = %q; 55 | $dialog.Multiselect = $true; 56 | $dialog.ShowHelp = $true; 57 | $dialog.ShowDialog() > $null; 58 | Write-Output $dialog.FileNames; 59 | $dialog.Dispose(); 60 | }]] 61 | local path = mp.get_property('path') 62 | path = path == nil and '' or utils.split_path( 63 | utils.join_path(utils.getcwd(), path) 64 | ) 65 | local ontop = mp.get_property_native('ontop') 66 | mp.set_property_native('ontop', false) 67 | local powershell = utils.subprocess { 68 | args = { 69 | 'powershell', '-NoProfile', '-Command', 70 | template:format(opts.text, opts.title, path) 71 | }, cancellable = false 72 | } 73 | mp.set_property_native('ontop', ontop) 74 | if powershell.status ~= 0 then return end 75 | for file in powershell.stdout:gmatch('[^\r\n]+') do 76 | mp.commandv(opts.args[1], file, opts.args[2]) 77 | end 78 | end 79 | end 80 | 81 | mp.add_key_binding('Ctrl+f', 'open-files', PowerShell { 82 | title = 'Select Files', 83 | text = 'Multimedia Files|'..MULTIMEDIA, 84 | args = {'loadfile', 'append-play'}, 85 | }) 86 | mp.add_key_binding('Ctrl+F', 'open-url', PowerShell { 87 | title = 'Open URL', 88 | text = 'Enter the URL to open:', 89 | args = {'loadfile', 'replace'}, 90 | template = [[& { 91 | Add-Type -AssemblyName Microsoft.VisualBasic; 92 | $url = [Microsoft.VisualBasic.Interaction]::InputBox(%q, %q); 93 | Write-Output $url; 94 | }]], 95 | }) 96 | mp.add_key_binding('Alt+f', 'open-subs', PowerShell { 97 | title = 'Select Subs', 98 | text = 'Subtitle Files|'..SUBTITLES, 99 | args = {'sub-add', 'select'}, 100 | }) 101 | -------------------------------------------------------------------------------- /open-dialog/zenity.lua: -------------------------------------------------------------------------------- 1 | ---Launch a dialog for opening files or URLs (Zenity) 2 | ---@author ObserverOfTime 3 | ---@license 0BSD 4 | 5 | local utils = require 'mp.utils' 6 | 7 | local MULTIMEDIA = table.concat({ 8 | '*.aac', 9 | '*.avi', 10 | '*.flac', 11 | '*.flv', 12 | '*.m3u', 13 | '*.m3u8', 14 | '*.m4v', 15 | '*.mkv', 16 | '*.mov', 17 | '*.mp3', 18 | '*.mp4', 19 | '*.mpeg', 20 | '*.mpg', 21 | '*.oga', 22 | '*.ogg', 23 | '*.ogv', 24 | '*.opus', 25 | '*.wav', 26 | '*.webm', 27 | '*.wmv', 28 | }, ' ') 29 | 30 | local SUBTITLES = table.concat({ 31 | '*.ass', 32 | '*.srt', 33 | '*.ssa', 34 | '*.sub', 35 | '*.txt', 36 | }, ' ') 37 | 38 | local ICON = '/usr/share/icons/hicolor/16x16/apps/mpv.png' 39 | 40 | ---@vararg table 41 | local function merge(...) 42 | local ret = {} 43 | for _, t in pairs({...}) do 44 | for _, v in pairs(t) do 45 | table.insert(ret, v) 46 | end 47 | end 48 | return ret 49 | end 50 | 51 | ---@class ZOpts 52 | ---@field title string 53 | ---@field text string[] 54 | ---@field default? string[] 55 | ---@field type? string[] 56 | ---@field args string[] 57 | 58 | ---@param opts ZOpts 59 | ---@return fun() 60 | local function Zenity(opts) 61 | return function() 62 | local path = mp.get_property('path') 63 | path = path == nil and {} or { 64 | '--filename', utils.split_path( 65 | utils.join_path(utils.getcwd(), path) 66 | ) 67 | } 68 | local ontop = mp.get_property_native('ontop') 69 | local focus = utils.subprocess { 70 | args = {'xdotool', 'getwindowfocus'} 71 | }.stdout:gsub('\n$', '') 72 | mp.set_property_native('ontop', false) 73 | local zenity = utils.subprocess { 74 | args = merge({ 75 | 'zenity', '--modal', 76 | '--title', opts.title, 77 | '--attach', focus, 78 | '--window-icon', ICON, 79 | }, opts.default or path, 80 | opts.text, opts.type or { 81 | '--file-selection', 82 | '--separator', '\n', 83 | '--multiple', 84 | }), cancellable = false, 85 | } 86 | mp.set_property_native('ontop', ontop) 87 | if zenity.status ~= 0 then return end 88 | for file in zenity.stdout:gmatch('[^\n]+') do 89 | mp.commandv(opts.args[1], file, opts.args[2]) 90 | end 91 | end 92 | end 93 | 94 | mp.add_key_binding('Ctrl+f', 'open-files', Zenity { 95 | title = 'Select Files', 96 | text = {'--file-filter', 'Multimedia Files | '..MULTIMEDIA}, 97 | args = {'loadfile', 'append-play'}, 98 | }) 99 | mp.add_key_binding('Ctrl+F', 'open-url', Zenity { 100 | title = 'Open URL', 101 | text = {'--text', 'Enter the URL to open:'}, 102 | default = {}, 103 | type = {'--entry'}, 104 | args = {'loadfile', 'replace'}, 105 | }) 106 | mp.add_key_binding('Alt+f', 'open-subs', Zenity { 107 | title = 'Select Subs', 108 | text = {'--file-filter', 'Subtitle Files | '..SUBTITLES}, 109 | args = {'sub-add', 'select'}, 110 | }) 111 | --------------------------------------------------------------------------------