@@ -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
1515export 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 ( ) {
0 commit comments