Skip to content

Commit ec36d6a

Browse files
feat: add a Discord RPC script
1 parent 9b9ea6b commit ec36d6a

File tree

5 files changed

+365
-0
lines changed

5 files changed

+365
-0
lines changed

.github/ISSUE_TEMPLATE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
* [ ] `open-dialog/kdialog.lua`
88
* [ ] `open-dialog/zenity.lua`
99
* [ ] `open-dialog/powershell.lua`
10+
* [ ] `discord.lua`
1011

1112
## Description
1213
<!--- Provide a detailed description of the issue. -->

.github/workflows/issues.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,12 @@ jobs:
5252
project: Open Dialog (PowerShell)
5353
column: To do
5454
repo-token: ${{secrets.GITHUB_TOKEN}}
55+
56+
- name: Add to Discord project
57+
if: |
58+
contains(github.event.issue.body, '[x] `discord.lua`')
59+
uses: alex-page/[email protected]
60+
with:
61+
project: Discord
62+
column: To do
63+
repo-token: ${{secrets.GITHUB_TOKEN}}

.github/workflows/lint.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,13 @@ jobs:
1616
std: luajit
1717
steps:
1818
- uses: actions/checkout@master
19+
name: Checkout repository
1920
- uses: leafo/gh-actions-lua@v5
21+
name: Install lua - ${{matrix.lua.ver}}
2022
with:
2123
luaVersion: ${{matrix.lua.ver}}
2224
- uses: leafo/gh-actions-luarocks@v2
25+
name: Install luarocks
2326
- name: Install luacheck
2427
run: luarocks install luacheck
2528
- name: Lint with luacheck

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,13 @@ Screenshot the video (w/o subs) and copy it to the clipboard.
3030
Screenshot the full window and copy it to the clipboard.
3131
<br>Default key binding: `Alt+c`
3232

33+
### [discord.lua](discord.lua)
34+
35+
A Discord RPC script that does not require LuaJIT.
36+
<br>It can be used out of the box on Windows.
37+
<br>On Linux and MacOS it depends on [luasocket][].
38+
<br>Default key binding: `D`
39+
3340
### [misc.lua](misc.lua)
3441

3542
Miscellaneous simple functions.
@@ -40,3 +47,4 @@ Show the current time (`HH:MM`) on the OSD.
4047
<br>Default key binding: `Ctrl+t`
4148

4249
[mpv]: https://github.com/mpv-player/mpv
50+
[luasocket]: http://w3.impa.br/~diego/software/luasocket/home.html

discord.lua

Lines changed: 344 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,344 @@
1+
utils = require 'mp.utils'
2+
msg = require 'mp.msg'
3+
4+
local o = {
5+
timeout = 2,
6+
keybind = 'D',
7+
enabled = false,
8+
client_id = '700723249889149038'
9+
}
10+
require('mp.options').read_options(o, 'discord')
11+
12+
function string.uuid()
13+
math.randomseed(mp.get_time() * 1e4)
14+
local tpl = 'XXXXXXXX-XXXX-4XXX-%xXXX-XXXXXXXXXXXX'
15+
return tpl:format(math.random(8, 0xb)):gsub('X', function(c)
16+
return ('%x'):format(math.random(0, 0xf))
17+
end)
18+
end
19+
20+
function string:tohex()
21+
return self:gsub('.', function(c)
22+
return ('\\x%02x'):format(string.byte(c))
23+
end)
24+
end
25+
26+
MPV = mp.get_property('mpv-version')
27+
28+
OP = {AUTHENTICATE = 0, FRAME = 1, CLOSE = 2}
29+
30+
RPC = {
31+
socket = nil,
32+
pid = utils.getpid(),
33+
unix = package.config:sub(1, 1) == '/'
34+
}
35+
36+
if RPC.unix then
37+
local temp = os.getenv('XDG_RUNTIME_DIR')
38+
or os.getenv('TMPDIR')
39+
or os.getenv('TEMP')
40+
or os.getenv('TMP')
41+
or '/tmp'
42+
RPC.path = temp..'/discord-ipc-0'
43+
else
44+
RPC.path = [[\\?\pipe\discord-ipc-0]]
45+
end
46+
47+
RPC.activity = {
48+
details = 'No file',
49+
state = nil,
50+
timestamps = {},
51+
assets = {
52+
large_image = 'mpv',
53+
large_text = MPV,
54+
small_image = 'stop',
55+
small_text = 'Idle'
56+
}
57+
}
58+
59+
function RPC.pack(op, body)
60+
local bytes = {}
61+
assert(body, 'empty body')
62+
local len = body:len()
63+
for _ = 1, 4 do
64+
table.insert(bytes, string.char(op % (2 ^ 8)))
65+
op = math.floor(op / (2 ^ 8))
66+
end
67+
for _ = 1, 4 do
68+
table.insert(bytes, string.char(len % (2 ^ 8)))
69+
len = math.floor(len / (2 ^ 8))
70+
end
71+
return table.concat(bytes, '')..body
72+
end
73+
74+
function RPC.unpack(body)
75+
local op = 0
76+
local len = 0
77+
local iter = 1
78+
local byte = nil
79+
assert(body, 'empty body')
80+
for j = 1, 4 do
81+
byte = string.byte(body:sub(iter, iter))
82+
op = op + byte * (2 ^ ((j - 1) * 8))
83+
iter = iter + 1
84+
end
85+
for j = 1, 4 do
86+
byte = string.byte(body:sub(iter, iter))
87+
len = len + byte * (2 ^ ((j - 1) * 8))
88+
iter = iter + 1
89+
end
90+
return math.floor(op), math.floor(len)
91+
end
92+
93+
function RPC:connect()
94+
local status, data
95+
if self.unix then
96+
self.socket = assert(require 'socket.unix' ())
97+
status, data = pcall(function()
98+
assert(self.socket:connect(self.path))
99+
end)
100+
if status then return true end
101+
else
102+
status, data = pcall(function()
103+
return assert(io.open(self.path, 'r+b'))
104+
end)
105+
self.socket = data
106+
if status then return true end
107+
end
108+
self.socket = nil
109+
msg.fatal(data, '('..self.path..')')
110+
return false
111+
end
112+
113+
function RPC:recv(len)
114+
if not self.socket then
115+
assert(self:connect(), 'failed to connect')
116+
end
117+
local status, data
118+
if self.unix then
119+
status, data = pcall(function()
120+
return assert(self.socket:receive(len))
121+
end)
122+
else
123+
status, data = pcall(function()
124+
return assert(self.socket:read(len))
125+
end)
126+
end
127+
if not status then
128+
msg.error(data)
129+
return nil
130+
end
131+
assert(string.len(data) == len, 'incorrect data length')
132+
msg.debug('received', data:tohex())
133+
return data
134+
end
135+
136+
function RPC:send(op, body)
137+
if not self.socket then
138+
assert(self:connect(), 'failed to connect')
139+
end
140+
local data = self.pack(op, body)
141+
msg.debug('sending', data:tohex())
142+
if self.unix then
143+
assert(self.socket:send(data))
144+
else
145+
assert(self.socket:write(data))
146+
self.socket:flush()
147+
end
148+
end
149+
150+
function RPC:handshake(version)
151+
local body = utils.format_json {
152+
v = version or 1,
153+
client_id = o.client_id
154+
}
155+
self:send(OP.AUTHENTICATE, body)
156+
local op, len = self.unpack(self:recv(8))
157+
local res = utils.parse_json(self:recv(len))
158+
assert(op == OP.FRAME, res.message)
159+
assert(res.evt == 'READY', res.message)
160+
msg.verbose('performed handshake')
161+
end
162+
163+
function RPC:set_activity()
164+
if self.activity.details:len() > 127 then
165+
self.activity.details = self.activity.details:sub(1, 126)..''
166+
end
167+
local nonce = string.uuid()
168+
local body = utils.format_json {
169+
cmd = 'SET_ACTIVITY', nonce = nonce,
170+
args = {activity = self.activity, pid = self.pid}
171+
}:gsub('%[]', '{}')
172+
self:send(OP.FRAME, body)
173+
local res = self:recv(8)
174+
if not res then
175+
msg.info('reattempting to set activity')
176+
return self:set_activity()
177+
end
178+
local _, len = self.unpack(res)
179+
res = utils.parse_json(self:recv(len))
180+
if not res then
181+
msg.info('reattempting to set activity')
182+
return self:set_activity()
183+
end
184+
assert(res.cmd == 'SET_ACTIVITY', 'incorrect cmd')
185+
assert(res.nonce == nonce, 'incorrect nonce')
186+
if res.evt == 'ERROR' then
187+
msg.error(res.data.message)
188+
return nil
189+
end
190+
return body
191+
end
192+
193+
function RPC:disconnect()
194+
if self.socket then
195+
self:send(OP.CLOSE, '')
196+
self.socket:close()
197+
self.socket = nil
198+
end
199+
end
200+
201+
mp.register_event('idle', function()
202+
RPC.activity = {
203+
details = 'No file',
204+
state = nil,
205+
timestamps = {},
206+
assets = {
207+
small_image = 'stop',
208+
small_text = 'Idle',
209+
large_image = 'mpv',
210+
large_text = MPV
211+
}
212+
}
213+
end)
214+
215+
mp.register_event('file-loaded', function()
216+
local title = mp.get_property('media-title') or 'Untitled'
217+
local artist = mp.get_property('metadata/by-key/Artist')
218+
title = artist and title..' - '..artist or title
219+
local time = mp.get_property_number('duration')
220+
time = time and 'Duration: '..os.date('!%T', time) or ''
221+
local plist = mp.get_property_number('playlist-count')
222+
if plist > 1 then
223+
local pos = mp.get_property('playlist-pos-1')
224+
plist = (' [%d/%d]'):format(pos, plist)
225+
else
226+
plist = ''
227+
end
228+
local path = mp.get_property('path')
229+
if path and path:find('^https?://') then
230+
if path:find('youtube%.com') or path:find('youtu%.be') then
231+
RPC.activity.assets.large_image = 'youtube'
232+
elseif path:find('invidio%.us') or path:find('yewtu%.be') then
233+
RPC.activity.assets.large_image = 'invidious'
234+
elseif path:find('twitch%.tv') then
235+
RPC.activity.assets.large_image = 'twitch'
236+
elseif path:find('twitter%.com') then
237+
RPC.activity.assets.large_image = 'twitter'
238+
elseif path:find('discordapp%.com') then
239+
RPC.activity.assets.large_image = 'discord'
240+
else
241+
RPC.activity.assets.large_image = 'stream'
242+
end
243+
RPC.activity.assets.large_text = path
244+
else
245+
RPC.activity.assets.large_image = 'mpv'
246+
RPC.activity.assets.large_text = MPV
247+
end
248+
RPC.activity.details = title
249+
RPC.activity.state = time..plist
250+
RPC.activity.assets.small_image = 'pause'
251+
RPC.activity.assets.small_text = 'Paused'
252+
RPC.activity.timestamps = {}
253+
end)
254+
255+
mp.register_event('shutdown', function()
256+
RPC:disconnect()
257+
end)
258+
259+
mp.register_event('seek', function()
260+
local pos = mp.get_property_number('time-pos')
261+
RPC.activity.timestamps = {
262+
start = math.floor(os.time() - (pos or 0))
263+
}
264+
end)
265+
266+
mp.observe_property('paused-for-cache', 'bool', function(_, value)
267+
if value then
268+
RPC.activity.timestamps = {}
269+
RPC.activity.assets.small_image = 'play'
270+
RPC.activity.assets.small_text = 'Playing'
271+
else
272+
local pos = mp.get_property_number('time-pos')
273+
RPC.activity.timestamps = {
274+
start = math.floor(os.time() - (pos or 0))
275+
}
276+
RPC.activity.assets.small_image = 'play'
277+
RPC.activity.assets.small_text = 'Playing'
278+
end
279+
end)
280+
281+
mp.observe_property('core-idle', 'bool', function(_, value)
282+
if value then
283+
RPC.activity.timestamps = {}
284+
RPC.activity.assets.small_image = 'pause'
285+
RPC.activity.assets.small_text = 'Loading'
286+
else
287+
local pos = mp.get_property_number('time-pos')
288+
RPC.activity.timestamps = {
289+
start = math.floor(os.time() - (pos or 0))
290+
}
291+
RPC.activity.assets.small_image = 'play'
292+
RPC.activity.assets.small_text = 'Playing'
293+
end
294+
end)
295+
296+
mp.observe_property('pause', 'bool', function(_, value)
297+
if value then
298+
RPC.activity.timestamps = {}
299+
RPC.activity.assets.small_image = 'pause'
300+
RPC.activity.assets.small_text = 'Paused'
301+
else
302+
local pos = mp.get_property_number('time-pos')
303+
RPC.activity.timestamps = {
304+
start = math.floor(os.time() - (pos or 0))
305+
}
306+
RPC.activity.assets.small_image = 'play'
307+
RPC.activity.assets.small_text = 'Playing'
308+
end
309+
end)
310+
311+
mp.observe_property('eof-reached', 'bool', function(_, value)
312+
if value then
313+
RPC.activity.timestamps = {}
314+
RPC.activity.assets.small_image = 'stop'
315+
RPC.activity.assets.small_text = 'Idle'
316+
end
317+
end)
318+
319+
timer = mp.add_periodic_timer(o.timeout, function()
320+
local curr = utils.format_json(RPC.activity)
321+
if timer._last ~= curr then
322+
RPC:set_activity()
323+
timer._last = curr
324+
end
325+
end)
326+
327+
mp.add_key_binding(o.keybind, 'toggle-discord-rpc', function()
328+
o.enabled = not o.enabled
329+
if o.enabled then
330+
RPC:handshake()
331+
timer:resume()
332+
else
333+
timer._last = nil
334+
RPC:disconnect()
335+
timer:kill()
336+
end
337+
end)
338+
339+
if o.enabled then
340+
RPC:handshake()
341+
RPC:set_activity()
342+
else
343+
timer:kill()
344+
end

0 commit comments

Comments
 (0)