Skip to content

Commit 0462a5e

Browse files
authored
added discord RPC #138 (#156)
- added a discord rpc ipc using node's native net and fs instead of additional npm packages - the rpc shows which episode/which movie you are currently watching and a button to get to the github repo - this is turned off by default
1 parent ceaca69 commit 0462a5e

8 files changed

Lines changed: 575 additions & 1 deletion

File tree

index.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ const downloadsIpc = require("./src/ipc/downloads");
4242
const subtitlesIpc = require("./src/ipc/subtitles");
4343
const allmangaIpc = require("./src/ipc/allmanga");
4444
const playerIpc = require("./src/ipc/player");
45+
const discordRpc = require("./src/ipc/discordRpc");
4546

4647
// -- Ad/tracker block list -----------------------------------------------------
4748
const BLOCKED_HOSTS = [
@@ -323,6 +324,7 @@ playerIpc.register(getMainWindow, {
323324
writeSecretMigration: storageIpc.writeSecretMigration,
324325
});
325326
blockStats.init(getMainWindow);
327+
discordRpc.register(ipcMain);
326328

327329
// get-block-stats lives with its data
328330
ipcMain.handle("get-block-stats", () => blockStats.getBlockStats());
@@ -503,6 +505,7 @@ if (!gotTheLock) {
503505
createWindow();
504506
});
505507
app.on("window-all-closed", () => app.quit());
508+
app.on("before-quit", () => discordRpc.shutdown());
506509
app.on("activate", () => {
507510
if (mainWindow === null) createWindow();
508511
});

preload.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,12 @@ contextBridge.exposeInMainWorld("electron", {
187187

188188
fetchReleaseImage: (url) =>
189189
ipcRenderer.invoke("fetch-release-image", { url }),
190+
191+
// Discord Rich Presence (off by default, toggled in Settings)
192+
discordRpcSetEnabled: (enabled) =>
193+
ipcRenderer.invoke("discord-rpc-set-enabled", enabled),
194+
discordRpcUpdateActivity: (activity) =>
195+
ipcRenderer.invoke("discord-rpc-update-activity", activity),
190196
});
191197

192198
if (process.platform === "darwin") {

src/App.jsx

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,15 @@ import {
1717
ACCENT_PRESETS,
1818
} from "./utils/appearance";
1919
import { collectBackupData } from "./utils/backup";
20-
import { tmdbFetch, setApiErrorHandlers } from "./utils/api";
20+
import { tmdbFetch, setApiErrorHandlers, imgUrl } from "./utils/api";
2121
import { clearAppCaches } from "./utils/storage";
22+
import {
23+
readDiscordRpcSettings,
24+
applyDiscordRpcEnabled,
25+
sendWatchingActivity,
26+
sendIdleActivity,
27+
clearActivity as clearDiscordActivity,
28+
} from "./utils/discordPresence";
2229

2330
import Sidebar from "./components/Sidebar";
2431
import SearchModal from "./components/SearchModal";
@@ -91,6 +98,16 @@ export default function App() {
9198
};
9299
const [playerSettings, setPlayerSettings] = useState(readPlayerSettings);
93100

101+
// ── Discord Rich Presence ──────────────────────────────────────────────────
102+
// Off by default. `watchingEpisode` is filled in by TVPage (season/episode
103+
// of whatever is currently open) via onEpisodeChange; MoviePage needs no
104+
// extra data since title/poster already live on `selected`.
105+
const [discordSettings, setDiscordSettings] = useState(
106+
readDiscordRpcSettings,
107+
);
108+
const [watchingEpisode, setWatchingEpisode] = useState(null);
109+
const watchStartRef = useRef(null);
110+
94111
// ── Scheduled backup: run on startup if due ─────────────────────────────────
95112
useEffect(() => {
96113
if (!window.electron?.onScheduledBackupRequested) return;
@@ -556,6 +573,51 @@ export default function App() {
556573
return () =>
557574
window.removeEventListener("streambert:player-settings-changed", handler);
558575
}, []);
576+
577+
// ── Discord Rich Presence: sync settings + connect/disconnect ─────────────
578+
useEffect(() => {
579+
applyDiscordRpcEnabled(discordSettings.enabled);
580+
if (!discordSettings.enabled) clearDiscordActivity();
581+
}, [discordSettings.enabled]);
582+
583+
useEffect(() => {
584+
const handler = () => setDiscordSettings(readDiscordRpcSettings());
585+
window.addEventListener("streambert:discord-rpc-settings-changed", handler);
586+
return () =>
587+
window.removeEventListener(
588+
"streambert:discord-rpc-settings-changed",
589+
handler,
590+
);
591+
}, []);
592+
593+
// Reset episode info + elapsed-time anchor whenever the open title changes
594+
useEffect(() => {
595+
setWatchingEpisode(null);
596+
watchStartRef.current = Date.now();
597+
}, [selected?.id, selected?.media_type]);
598+
599+
// Push the current activity to Discord whenever what's on screen changes
600+
useEffect(() => {
601+
if (!discordSettings.enabled) return;
602+
if ((page === "movie" || page === "tv") && selected) {
603+
const title = selected.title || selected.name || "";
604+
let subtitle = page === "movie" ? "Movie" : "Series";
605+
if (page === "tv" && watchingEpisode) {
606+
subtitle = `S${watchingEpisode.season} · E${watchingEpisode.episode}`;
607+
}
608+
sendWatchingActivity(
609+
{
610+
title,
611+
subtitle,
612+
posterUrl: imgUrl(selected.poster_path, "w500"),
613+
startedAt: watchStartRef.current,
614+
},
615+
discordSettings,
616+
);
617+
} else {
618+
sendIdleActivity(discordSettings);
619+
}
620+
}, [page, selected, watchingEpisode, discordSettings]);
559621
useEffect(() => {
560622
// Accent colour
561623
const accent = storage.get(STORAGE_KEYS.ACCENT_COLOR) || "red";
@@ -1017,6 +1079,7 @@ export default function App() {
10171079
onMarkUnwatched={markUnwatched}
10181080
downloads={downloads}
10191081
onGoToDownloads={handleGoToDownloads}
1082+
onEpisodeChange={setWatchingEpisode}
10201083
/>
10211084
)}
10221085
{page === "history" && (

src/ipc/discordRpc.js

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
// ── Discord Rich Presence (zero-dependency IPC client) ────────────────────────
2+
// Talks directly to the local Discord client trough native node net/fs
3+
//
4+
// Protocol summary (see discord/discord-rpc docs):
5+
// Frame = uint32LE opcode + uint32LE payloadLength + JSON payload (utf8)
6+
// Opcodes: 0 HANDSHAKE, 1 FRAME, 2 CLOSE
7+
8+
const net = require("net");
9+
const fs = require("fs");
10+
const path = require("path");
11+
12+
const CLIENT_ID = "1522558650076627076";
13+
14+
const MIN_SEND_INTERVAL_MS = 15000;
15+
// How long to wait before retrying a failed/absent Discord connection.
16+
const RECONNECT_DELAY_MS = 15000;
17+
18+
const OP = { HANDSHAKE: 0, FRAME: 1, CLOSE: 2 };
19+
20+
function candidatePipePaths() {
21+
if (process.platform === "win32") {
22+
return Array.from({ length: 10 }, (_, i) => `\\\\.\\pipe\\discord-ipc-${i}`);
23+
}
24+
const base =
25+
process.env.XDG_RUNTIME_DIR ||
26+
process.env.TMPDIR ||
27+
process.env.TMP ||
28+
process.env.TEMP ||
29+
"/tmp";
30+
const dirs = [
31+
base,
32+
path.join(base, "app/com.discordapp.Discord"), // flatpak
33+
path.join(base, "snap.discord"), // snap
34+
];
35+
const paths = [];
36+
for (const dir of dirs) {
37+
for (let i = 0; i < 10; i++) paths.push(path.join(dir, `discord-ipc-${i}`));
38+
}
39+
return paths;
40+
}
41+
42+
function makeNonce() {
43+
return `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`;
44+
}
45+
46+
function encodeFrame(op, obj) {
47+
const payload = Buffer.from(JSON.stringify(obj), "utf8");
48+
const header = Buffer.alloc(8);
49+
header.writeUInt32LE(op, 0);
50+
header.writeUInt32LE(payload.length, 4);
51+
return Buffer.concat([header, payload]);
52+
}
53+
54+
// ── Module state ───────────────────────────────────────────────────────────
55+
let enabled = false;
56+
let socket = null;
57+
let ready = false;
58+
let recvBuffer = Buffer.alloc(0);
59+
let reconnectTimer = null;
60+
61+
let pendingActivity = undefined; // undefined = nothing queued yet
62+
let hasPending = false;
63+
let lastSentAt = 0;
64+
let flushTimer = null;
65+
66+
function clearReconnectTimer() {
67+
if (reconnectTimer) {
68+
clearTimeout(reconnectTimer);
69+
reconnectTimer = null;
70+
}
71+
}
72+
73+
function clearFlushTimer() {
74+
if (flushTimer) {
75+
clearTimeout(flushTimer);
76+
flushTimer = null;
77+
}
78+
}
79+
80+
function scheduleReconnect() {
81+
if (!enabled || reconnectTimer) return;
82+
reconnectTimer = setTimeout(() => {
83+
reconnectTimer = null;
84+
if (enabled) connect();
85+
}, RECONNECT_DELAY_MS);
86+
}
87+
88+
function teardownSocket() {
89+
ready = false;
90+
recvBuffer = Buffer.alloc(0);
91+
if (socket) {
92+
socket.removeAllListeners();
93+
socket.destroy();
94+
socket = null;
95+
}
96+
}
97+
98+
function onSocketDown() {
99+
teardownSocket();
100+
scheduleReconnect();
101+
}
102+
103+
function handleFrame(op, obj) {
104+
if (op === OP.FRAME && obj && obj.cmd === "DISPATCH" && obj.evt === "READY") {
105+
ready = true;
106+
flush();
107+
}
108+
}
109+
110+
function onData(chunk) {
111+
recvBuffer = Buffer.concat([recvBuffer, chunk]);
112+
while (recvBuffer.length >= 8) {
113+
const op = recvBuffer.readUInt32LE(0);
114+
const len = recvBuffer.readUInt32LE(4);
115+
if (recvBuffer.length < 8 + len) break;
116+
const payload = recvBuffer.subarray(8, 8 + len);
117+
recvBuffer = recvBuffer.subarray(8 + len);
118+
try {
119+
handleFrame(op, JSON.parse(payload.toString("utf8")));
120+
} catch {
121+
}
122+
}
123+
}
124+
125+
function tryPaths(paths, index) {
126+
if (!enabled || index >= paths.length) {
127+
scheduleReconnect();
128+
return;
129+
}
130+
const target = paths[index];
131+
const sock = net.createConnection(target);
132+
let settled = false;
133+
134+
const failNext = () => {
135+
if (settled) return;
136+
settled = true;
137+
clearTimeout(timeout);
138+
sock.removeAllListeners();
139+
sock.destroy();
140+
tryPaths(paths, index + 1);
141+
};
142+
143+
const timeout = setTimeout(failNext, 1000);
144+
145+
sock.once("connect", () => {
146+
if (settled) return;
147+
settled = true;
148+
clearTimeout(timeout);
149+
socket = sock;
150+
socket.on("data", onData);
151+
socket.on("close", onSocketDown);
152+
socket.on("error", onSocketDown);
153+
socket.write(encodeFrame(OP.HANDSHAKE, { v: 1, client_id: CLIENT_ID }));
154+
});
155+
sock.once("error", failNext);
156+
}
157+
158+
function connect() {
159+
if (!enabled || socket) return;
160+
clearReconnectTimer();
161+
tryPaths(candidatePipePaths(), 0);
162+
}
163+
164+
function doSend() {
165+
if (!hasPending || !socket || !ready) return;
166+
const activity = pendingActivity;
167+
hasPending = false;
168+
lastSentAt = Date.now();
169+
try {
170+
socket.write(
171+
encodeFrame(OP.FRAME, {
172+
cmd: "SET_ACTIVITY",
173+
args: { pid: process.pid, activity: activity || null },
174+
nonce: makeNonce(),
175+
}),
176+
);
177+
} catch {
178+
onSocketDown();
179+
}
180+
}
181+
182+
function flush() {
183+
if (!hasPending || !socket || !ready) return;
184+
clearFlushTimer();
185+
const elapsed = Date.now() - lastSentAt;
186+
if (elapsed >= MIN_SEND_INTERVAL_MS) {
187+
doSend();
188+
} else {
189+
flushTimer = setTimeout(() => {
190+
flushTimer = null;
191+
flush();
192+
}, MIN_SEND_INTERVAL_MS - elapsed);
193+
}
194+
}
195+
196+
// ── Public API ────────────────────────────────────────────────────────────
197+
198+
function setEnabled(next) {
199+
next = !!next;
200+
if (next === enabled) return;
201+
enabled = next;
202+
if (enabled) {
203+
connect();
204+
} else {
205+
clearReconnectTimer();
206+
clearFlushTimer();
207+
hasPending = false;
208+
pendingActivity = undefined;
209+
teardownSocket();
210+
}
211+
}
212+
213+
/** activity: null clears presence, otherwise a Discord activity payload. */
214+
function updateActivity(activity) {
215+
if (!enabled) return;
216+
pendingActivity = activity;
217+
hasPending = true;
218+
flush();
219+
}
220+
221+
function shutdown() {
222+
clearReconnectTimer();
223+
clearFlushTimer();
224+
teardownSocket();
225+
}
226+
227+
function register(ipcMain) {
228+
ipcMain.handle("discord-rpc-set-enabled", (_e, next) => {
229+
setEnabled(next);
230+
});
231+
ipcMain.handle("discord-rpc-update-activity", (_e, activity) => {
232+
updateActivity(activity);
233+
});
234+
}
235+
236+
module.exports = { register, setEnabled, updateActivity, shutdown };

0 commit comments

Comments
 (0)