Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 20 additions & 5 deletions src/discord-presence.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,23 @@ export const IDLE_BEHAVIORS = Object.freeze(["clear", "grace", "show", "recent"]
const TIMESTAMP_MODES = Object.freeze(["elapsed", "remaining", "both", "none"]);
const ACTIVE = new Set(["playing", "paused"]);

export function createDiscordPresenceLoop({
getPresence, client, idleBehavior = "clear", timestamps = "both", artwork,
intervalMs = 15_000, graceMs = 120_000, stuckAfterMs = 300_000, now = () => performance.now(),
setTimer = setTimeout, clearTimer = clearTimeout,
} = {}) {
function validateLoopOptions({ getPresence, client, idleBehavior, timestamps, intervalMs, graceMs, stuckAfterMs, stallAfterMs }) {
if (typeof getPresence !== "function") throw new TypeError("getPresence is required");
if (!client || typeof client.publish !== "function") throw new TypeError("discord client.publish is required");
if (!IDLE_BEHAVIORS.includes(idleBehavior)) throw new TypeError("idleBehavior is invalid");
if (!TIMESTAMP_MODES.includes(timestamps)) throw new TypeError("timestamps is invalid");
if (!Number.isInteger(intervalMs) || intervalMs < 1000) throw new RangeError("intervalMs must be at least 1000");
if (!Number.isInteger(graceMs) || graceMs < 0) throw new RangeError("graceMs is invalid");
if (!Number.isInteger(stuckAfterMs) || stuckAfterMs < 1000) throw new RangeError("stuckAfterMs must be at least 1000");
if (!Number.isInteger(stallAfterMs) || stallAfterMs < 1000) throw new RangeError("stallAfterMs must be at least 1000");
}

export function createDiscordPresenceLoop({
getPresence, client, idleBehavior = "clear", timestamps = "both", artwork,
intervalMs = 15_000, graceMs = 120_000, stuckAfterMs = 300_000, stallAfterMs = 60_000, now = () => performance.now(),
setTimer = setTimeout, clearTimer = clearTimeout,
} = {}) {
validateLoopOptions({ getPresence, client, idleBehavior, timestamps, intervalMs, graceMs, stuckAfterMs, stallAfterMs });

const live = createDiscordController({ client, settings: { idleBehavior: idleBehavior === "show" ? "show" : "clear", timestamps }, ...(artwork ? { artwork } : {}) });
const frozen = createDiscordController({ client, settings: { timestamps: "none" }, ...(artwork ? { artwork } : {}) });
Expand All @@ -49,6 +54,15 @@ export function createDiscordPresenceLoop({
return now() - stuck.since >= stuckAfterMs;
}

// Before a frozen position is stale enough to clear, it is already not
// "playing": the player closed or the server stopped updating. Show it as
// stopped at that position instead of letting Discord's timer keep running,
// then the stuck rule above clears it. Never applies to a position that
// moved since the last poll or to the first sight of an item.
function isStalled(presence) {
return Boolean(stuck) && presence.state === "playing" && now() - stuck.since >= Math.min(stallAfterMs, stuckAfterMs - 1);
}

// Ticks run one at a time (a Refresh artwork tick can land during a
// scheduled one). Otherwise an older poll whose artwork lookup is slow
// publishes after a newer one and Discord shows the previous track.
Expand All @@ -71,6 +85,7 @@ export function createDiscordPresenceLoop({
if (active) {
lastActive = presence;
idleSince = null;
if (isStalled(presence)) return Object.freeze({ action: "publish", stalled: true, ...(await frozen.publish({ ...presence, state: "paused" })) });
return Object.freeze({ action: "publish", ...(await live.publish(presence)) });
}
idleSince ??= now();
Expand Down
31 changes: 31 additions & 0 deletions test/discord-presence.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -356,3 +356,34 @@ test("default Discord timers do not depend on a backward Date.now correction (#7
Date.now = originalNow;
}
});

test("a playing session whose position stops moving is shown as stopped before it is cleared", async () => {
let time = Date.parse("2026-09-23T12:00:00.000Z");
const client = fakeClient();
let position = 1000;
const l = createDiscordPresenceLoop({ client, now: () => time, stallAfterMs: 60_000, stuckAfterMs: 300_000,
getPresence: async () => ({ ...playing, positionMs: position, updatedAt: new Date(time).toISOString() }) });
await l.tick();
assert.equal(client.calls.at(-1).startTimestamp !== undefined, true, "moving playback keeps its timer");
time += 15_000; position += 15_000;
await l.tick();
time += 15_000;
assert.equal((await l.tick()).stalled, undefined, "15s without movement is not stalled yet");
time += 45_000;
const stalled = await l.tick();
assert.equal(stalled.stalled, true);
const shown = client.calls.at(-1);
assert.notEqual(shown, null);
assert.equal(shown.startTimestamp, undefined, "no running timer once stalled");
assert.equal(shown.endTimestamp, undefined);
assert.match(JSON.stringify(shown), /Paused/);
time += 239_000;
assert.equal((await l.tick()).action, "publish", "still shown just before the clear deadline");
time += 1_000;
assert.equal((await l.tick()).action, "clear");
assert.equal(client.calls.at(-1), null);
time += 15_000; position += 15_000;
const resumed = await l.tick();
assert.equal(resumed.stalled, undefined);
assert.notEqual(client.calls.at(-1).startTimestamp, undefined, "playback that moves again gets its timer back");
});
Loading