├── .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.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 |
--------------------------------------------------------------------------------