Skip to content

Commit 6ed67ae

Browse files
committed
Show a frozen hosted session as paused before it is cleared
The hosted loop only reacted to a frozen position after stuckAfterMs (300s), so until then every poll uploaded the session as playing at a fixed position and the card kept showing a live track after the player closed without a clean stop. A stallAfterMs window (60s, below stuckAfterMs, same as the Discord loop in #1099) now uploads the item as paused at that position; the 300s idle upload is unchanged and moving playback goes back to playing. Fixes #1100.
1 parent e808303 commit 6ed67ae

2 files changed

Lines changed: 55 additions & 12 deletions

File tree

‎src/hosted-loop.js‎

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,15 @@ import { presenceItemKey } from "./presence-identity.js";
1313
// rule and default as Discord, #339) is uploaded as idle until it moves.
1414

1515
export function createHostedLoop({
16-
getPresence, uploader, intervalMs = 15_000, failAfterMs = 60_000, stuckAfterMs = 300_000,
16+
getPresence, uploader, intervalMs = 15_000, failAfterMs = 60_000, stuckAfterMs = 300_000, stallAfterMs = 60_000,
1717
elapsedNow = () => performance.now(), setTimer = setTimeout, clearTimer = clearTimeout,
1818
} = {}) {
1919
if (typeof getPresence !== "function") throw new TypeError("getPresence is required");
2020
if (typeof uploader?.push !== "function") throw new TypeError("uploader.push is required");
2121
if (!Number.isInteger(intervalMs) || intervalMs < 1000) throw new RangeError("intervalMs must be at least 1000");
2222
if (!Number.isInteger(failAfterMs) || failAfterMs < 0) throw new RangeError("failAfterMs is invalid");
2323
if (!Number.isInteger(stuckAfterMs) || stuckAfterMs < 1000) throw new RangeError("stuckAfterMs must be at least 1000");
24+
if (!Number.isInteger(stallAfterMs) || stallAfterMs < 1000) throw new RangeError("stallAfterMs must be at least 1000");
2425
let timer = null;
2526
let stopped = true;
2627
let running = null;
@@ -43,6 +44,14 @@ export function createHostedLoop({
4344
return elapsedNow() - stuck.since >= stuckAfterMs;
4445
}
4546

47+
// Before a frozen position is stale enough to clear, it is already not
48+
// playing (the player closed, or the server stopped updating): show it as
49+
// paused at that position, as the Discord loop does (#1099). Never applies
50+
// to a position that moved or to the first sight of an item.
51+
function isStalled(presence) {
52+
return Boolean(stuck) && presence.state === "playing" && elapsedNow() - stuck.since >= Math.min(stallAfterMs, stuckAfterMs - 1);
53+
}
54+
4655
async function push(presence) {
4756
try { return await uploader.push(presence); } catch { return { sent: false, reason: "upload_failed" }; }
4857
}
@@ -53,27 +62,41 @@ export function createHostedLoop({
5362
try {
5463
presence = await getPresence();
5564
} catch {
56-
if (privacyClearPending) return clearForPrivacy();
57-
failingSince ??= elapsedNow();
58-
if (clearedForFailure || elapsedNow() - failingSince < failAfterMs) return { sent: false, reason: "provider_error" };
59-
const result = await push(createPresence({ state: "idle" }));
60-
// Only stop retrying once the idle state actually reached the host (or
61-
// the uploader says it already has it).
62-
if (result.sent || result.reason === "unchanged") clearedForFailure = true;
63-
return { ...result, cleared: "provider_error" };
65+
return providerFailed();
6466
}
6567
if (privacyClearPending) return clearForPrivacy();
6668
failingSince = null;
6769
clearedForFailure = false;
6870
if (!presence) return { sent: false, reason: "no_presence" };
71+
const frozen = await pushFrozen(presence);
72+
if (frozen) return frozen;
73+
if (privacyClearPending) return clearForPrivacy();
74+
return push(presence);
75+
}
76+
77+
async function providerFailed() {
78+
if (privacyClearPending) return clearForPrivacy();
79+
failingSince ??= elapsedNow();
80+
if (clearedForFailure || elapsedNow() - failingSince < failAfterMs) return { sent: false, reason: "provider_error" };
81+
const result = await push(createPresence({ state: "idle" }));
82+
// Only stop retrying once the idle state actually reached the host (or
83+
// the uploader says it already has it).
84+
if (result.sent || result.reason === "unchanged") clearedForFailure = true;
85+
return { ...result, cleared: "provider_error" };
86+
}
87+
88+
// A position that stopped moving: paused after the stall window, idle after
89+
// the stuck window. Returns null while the position is moving (or new).
90+
async function pushFrozen(presence) {
6991
if (isStuck(presence)) {
7092
if (clearedForStuck) return { sent: false, reason: "stuck" };
7193
const result = await push(createPresence({ state: "idle" }));
7294
if (result.sent || result.reason === "unchanged") clearedForStuck = true;
7395
return { ...result, cleared: "stuck" };
7496
}
7597
if (privacyClearPending) return clearForPrivacy();
76-
return push(presence);
98+
if (isStalled(presence)) return { ...(await push({ ...presence, state: "paused" })), stalled: true };
99+
return null;
77100
}
78101

79102
function clearForPrivacy() {

‎test/hosted-loop.test.js‎

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ test("a playing session frozen at one position is uploaded as idle after the Dis
167167
await env.loop.tick();
168168
env.advance(299_000);
169169
await env.loop.tick();
170-
assert.equal(env.pushed.at(-1).state, "playing");
170+
assert.equal(env.pushed.at(-1).state, "paused");
171171
env.advance(1_000);
172172
assert.equal((await env.loop.tick()).cleared, "stuck");
173173
assert.equal(env.pushed.at(-1).state, "idle");
@@ -206,7 +206,7 @@ test("frozen playback clears after five elapsed minutes despite a wall-clock rol
206206
assert.equal((await env.loop.tick()).cleared, "stuck");
207207
env.advance(60_000);
208208
assert.equal((await env.loop.tick()).reason, "stuck");
209-
assert.deepEqual(env.pushed.map((p) => p.state), ["playing", "playing", "playing", "playing", "playing", "idle"]);
209+
assert.deepEqual(env.pushed.map((p) => p.state), ["playing", "paused", "paused", "paused", "paused", "idle"]);
210210
env.set(playingAt(43_000));
211211
assert.notEqual((await env.loop.tick()).cleared, "stuck");
212212
assert.equal(env.pushed.at(-1).state, "playing");
@@ -253,3 +253,23 @@ test("normal playback and paused sessions are never treated as stuck", async ()
253253
for (let i = 0; i < 30; i += 1) { env.advance(15_000); await env.loop.tick(); }
254254
assert.equal(env.pushed.some((p) => p.state === "idle"), false);
255255
});
256+
257+
test("a playing session frozen for the stall window is uploaded as paused at that position (#1100)", async () => {
258+
const env = staleSetup({ presence: playingAt(42_000) });
259+
await env.loop.tick();
260+
env.advance(15_000);
261+
assert.equal((await env.loop.tick()).stalled, undefined, "15s without movement is not stalled");
262+
assert.equal(env.pushed.at(-1).state, "playing");
263+
env.advance(45_000);
264+
assert.equal((await env.loop.tick()).stalled, true);
265+
assert.equal(env.pushed.at(-1).state, "paused");
266+
assert.equal(env.pushed.at(-1).positionMs, 42_000);
267+
env.set(playingAt(43_000));
268+
env.advance(15_000);
269+
assert.equal((await env.loop.tick()).stalled, undefined);
270+
assert.equal(env.pushed.at(-1).state, "playing", "playback that moves again is playing");
271+
});
272+
273+
test("stallAfterMs is validated", () => {
274+
assert.throws(() => createHostedLoop({ getPresence() {}, uploader: { push() {} }, stallAfterMs: 10 }), RangeError);
275+
});

0 commit comments

Comments
 (0)