11import { Cpu , Monitor , Radio , Wifi , X , XCircle } from "lucide-react" ;
2- import { useEffect , useState } from "react" ;
2+ import { useEffect , useMemo , useRef , useState } from "react" ;
33import type { JSX , Ref } from "react" ;
44import { m , useReducedMotion } from "motion/react" ;
55import {
@@ -15,6 +15,12 @@ import { QueueAdPreview, type QueueAdPlaybackEvent, type QueueAdPreviewHandle }
1515import { LazyShaderAtmosphere } from "./LazyShaderAtmosphere" ;
1616import { useTranslation } from "../i18n" ;
1717import { getStatusPulseMotion } from "./MotionProvider" ;
18+ import {
19+ estimateQueueWait ,
20+ formatQueueWaitEstimate ,
21+ type QueuePositionObservation ,
22+ } from "../utils/queueWaitEstimator" ;
23+ import "./StreamLoadingResilience.css" ;
1824
1925type TranslateFunction = typeof import ( "../i18n" ) . t ;
2026
@@ -32,6 +38,7 @@ export interface StreamLoadingProps {
3238 status : "queue" | "setup" | "starting" | "connecting" ;
3339 queuePosition ?: number ;
3440 estimatedWait ?: string ;
41+ queueEstimateKey ?: string ;
3542 adState ?: SessionAdState ;
3643 activeAd ?: SessionAdInfo ;
3744 activeAdMediaUrl ?: string ;
@@ -127,6 +134,7 @@ export function StreamLoading({
127134 status,
128135 queuePosition,
129136 estimatedWait,
137+ queueEstimateKey = "default" ,
130138 adState,
131139 activeAd,
132140 activeAdMediaUrl,
@@ -141,21 +149,100 @@ export function StreamLoading({
141149 const statusPulseMotion = getStatusPulseMotion ( reducedMotion ) ;
142150 const [ startedAt ] = useState ( ( ) => Date . now ( ) ) ;
143151 const [ elapsedSeconds , setElapsedSeconds ] = useState ( 0 ) ;
152+ const [ activeQueueSeconds , setActiveQueueSeconds ] = useState ( 0 ) ;
153+ const [ queueObservations , setQueueObservations ] = useState < QueuePositionObservation [ ] > ( [ ] ) ;
154+ const [ persistedQueueRate , setPersistedQueueRate ] = useState < number | null > ( null ) ;
155+ const lastTimerAtRef = useRef ( Date . now ( ) ) ;
156+ const lastPersistedClearedRef = useRef ( 0 ) ;
144157 const hasError = Boolean ( error ) ;
145158 const statusMessage = getStatusMessage ( t , status , queuePosition , adState , hasError ) ;
146159 const platformName = platformStore ? getStoreDisplayName ( platformStore ) : "" ;
147160 const PlatformIcon = platformStore ? getStoreIconComponent ( platformStore ) : null ;
148161 const adSummary = getAdSummary ( t , adState ) ;
149162 const cachedAdMediaUrl = activeAdMediaUrl ?? getPreferredSessionAdMediaUrl ( activeAd ) ;
150163 const activeStage = getActiveStage ( status ) ;
164+ const queuePaused = isSessionQueuePaused ( adState ) ;
165+
166+ useEffect ( ( ) => {
167+ const storageKey = `opennow.queue-rate.v1.${ queueEstimateKey } ` ;
168+ try {
169+ const stored = JSON . parse ( window . localStorage . getItem ( storageKey ) ?? "null" ) as {
170+ secondsPerPosition ?: number ;
171+ updatedAt ?: number ;
172+ } | null ;
173+ const isFresh = stored ?. updatedAt && Date . now ( ) - stored . updatedAt < 14 * 24 * 60 * 60 * 1000 ;
174+ setPersistedQueueRate ( isFresh && Number . isFinite ( stored ?. secondsPerPosition )
175+ ? stored ! . secondsPerPosition !
176+ : null ) ;
177+ } catch {
178+ setPersistedQueueRate ( null ) ;
179+ }
180+ setQueueObservations ( [ ] ) ;
181+ setActiveQueueSeconds ( 0 ) ;
182+ lastPersistedClearedRef . current = 0 ;
183+ } , [ queueEstimateKey ] ) ;
151184
152185 useEffect ( ( ) => {
153186 if ( hasError ) return undefined ;
187+ lastTimerAtRef . current = Date . now ( ) ;
154188 const timer = window . setInterval ( ( ) => {
155- setElapsedSeconds ( Math . floor ( ( Date . now ( ) - startedAt ) / 1000 ) ) ;
189+ const now = Date . now ( ) ;
190+ const deltaSeconds = Math . max ( 0 , ( now - lastTimerAtRef . current ) / 1000 ) ;
191+ lastTimerAtRef . current = now ;
192+ setElapsedSeconds ( Math . floor ( ( now - startedAt ) / 1000 ) ) ;
193+ if ( status === "queue" && ! queuePaused ) {
194+ setActiveQueueSeconds ( ( value ) => value + deltaSeconds ) ;
195+ }
156196 } , 1000 ) ;
157197 return ( ) => window . clearInterval ( timer ) ;
158- } , [ hasError , startedAt ] ) ;
198+ } , [ hasError , queuePaused , startedAt , status ] ) ;
199+
200+ useEffect ( ( ) => {
201+ if ( status !== "queue" || ! queuePosition || queuePosition < 1 ) return ;
202+ setQueueObservations ( ( current ) => {
203+ const last = current . at ( - 1 ) ;
204+ if ( last ?. position === queuePosition ) return current ;
205+ const next = { activeElapsedSeconds : activeQueueSeconds , position : queuePosition } ;
206+ if ( last && queuePosition > last . position ) return [ next ] ;
207+ return [ ...current , next ] . slice ( - 40 ) ;
208+ } ) ;
209+ } , [ activeQueueSeconds , queuePosition , status ] ) ;
210+
211+ const queueWaitEstimate = useMemo ( ( ) => {
212+ if ( status !== "queue" || ! queuePosition ) return null ;
213+ return estimateQueueWait ( {
214+ position : queuePosition ,
215+ activeElapsedSeconds : activeQueueSeconds ,
216+ observations : queueObservations ,
217+ persistedSecondsPerPosition : persistedQueueRate ,
218+ } ) ;
219+ } , [ activeQueueSeconds , persistedQueueRate , queueObservations , queuePosition , status ] ) ;
220+
221+ useEffect ( ( ) => {
222+ if ( ! queueWaitEstimate || queueWaitEstimate . clearedPositions < 3 ) return ;
223+ if ( queueWaitEstimate . clearedPositions <= lastPersistedClearedRef . current ) return ;
224+ lastPersistedClearedRef . current = queueWaitEstimate . clearedPositions ;
225+ const blendedRate = persistedQueueRate === null
226+ ? queueWaitEstimate . secondsPerPosition
227+ : persistedQueueRate * 0.75 + queueWaitEstimate . secondsPerPosition * 0.25 ;
228+ setPersistedQueueRate ( blendedRate ) ;
229+ try {
230+ window . localStorage . setItem ( `opennow.queue-rate.v1.${ queueEstimateKey } ` , JSON . stringify ( {
231+ secondsPerPosition : blendedRate ,
232+ updatedAt : Date . now ( ) ,
233+ } ) ) ;
234+ } catch {
235+ // The live estimate still works when storage is disabled.
236+ }
237+ } , [ persistedQueueRate , queueEstimateKey , queueWaitEstimate ] ) ;
238+
239+ const queueWaitText = status === "queue"
240+ ? estimatedWait
241+ ? `~${ estimatedWait } `
242+ : queueWaitEstimate
243+ ? formatQueueWaitEstimate ( queueWaitEstimate . remainingSeconds )
244+ : t ( "streamLoading.telemetry.calculating" )
245+ : t ( "streamLoading.telemetry.cleared" ) ;
159246
160247 return (
161248 < div className = { `sload${ hasError ? " sload--error" : "" } ` } >
@@ -234,7 +321,7 @@ export function StreamLoading({
234321 </ div >
235322
236323 { ! hasError && (
237- < div className = "sload-facts" >
324+ < div className = "sload-facts sload-facts--with-eta " >
238325 < div className = "sload-fact" >
239326 < p > { t ( "streamLoading.telemetry.queuePosition" ) } </ p >
240327 < strong > { status === "queue" && queuePosition ? `#${ queuePosition } ` : status === "queue" ? t ( "streamLoading.telemetry.calculating" ) : t ( "streamLoading.telemetry.cleared" ) } </ strong >
@@ -243,6 +330,10 @@ export function StreamLoading({
243330 < p > { t ( "streamLoading.telemetry.elapsed" ) } </ p >
244331 < strong > { formatWaitTime ( elapsedSeconds ) } </ strong >
245332 </ div >
333+ < div className = "sload-fact" >
334+ < p > { t ( "streamLoading.telemetry.estimatedLeft" ) } </ p >
335+ < strong > { queueWaitText } </ strong >
336+ </ div >
246337 < div className = "sload-fact" >
247338 < p > { t ( "streamLoading.cozy.next" ) } </ p >
248339 < strong > { getNextStep ( t , status ) } </ strong >
@@ -267,10 +358,6 @@ export function StreamLoading({
267358 </ div >
268359 ) }
269360
270- { status === "queue" && estimatedWait && ! hasError && (
271- < p className = "sload-queue" > < span className = "sload-wait" > ~{ estimatedWait } </ span > </ p >
272- ) }
273-
274361 < div className = "sload-actions" >
275362 { hasError && error ?. actionLabel && onErrorAction && (
276363 < button className = "sload-cancel sload-cancel--primary" onClick = { onErrorAction } >
0 commit comments