Skip to content

Commit b6a0ed7

Browse files
authored
Merge pull request #62 from MaxMB15/feature/auto-reconnect-recovery
feat: auto-reconnect playback after network outages with resume
2 parents dc7b86f + 8f2a44f commit b6a0ed7

12 files changed

Lines changed: 1477 additions & 40 deletions

File tree

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import { Loader2, RotateCw, WifiOff } from "lucide-react";
2+
3+
interface ConnectionStatusOverlayProps {
4+
/** True while the Rust reconnect monitor is actively re-issuing loadfile
5+
* after an `EndFile(Error)` from libmpv. */
6+
reconnecting: boolean;
7+
/** Attempt counter shown when `reconnecting` is true. */
8+
reconnectAttempt: number;
9+
/** True when mpv's `paused-for-cache` flag has been set for longer than
10+
* the debounce window in `useMpv` (i.e. real network stall, not a
11+
* startup blip). */
12+
buffering: boolean;
13+
/** True when the Rust reconnect monitor has exhausted its retry budget
14+
* (URL genuinely unreachable). User must take action — show a retry
15+
* button so they don't get stuck. */
16+
loadFailed: boolean;
17+
/** Transient flag set by `useMpv` for ~2.5 s right after a successful
18+
* reconnect (either via `mpv://reconnected` or the navigator.onLine
19+
* auto-recovery path). Powers the green "Connection restored" banner. */
20+
recentlyRecovered: boolean;
21+
/** Invoked when the user clicks the retry button on the failed state. */
22+
onRetry?: () => void;
23+
}
24+
25+
type Status = "idle" | "buffering" | "reconnecting" | "failed" | "recovered";
26+
27+
/**
28+
* Prominent overlay surfacing connection health to the user.
29+
*
30+
* - `buffering` (amber): cache drained, FFmpeg silently retrying. Recovery
31+
* is usually transparent and happens within seconds of network return.
32+
* - `reconnecting` (red): hard `EndFile(Error)` / `eof-reached` — the
33+
* monitor is re-issuing `loadfile` with exponential backoff.
34+
* - `failed` (red, terminal): all retries exhausted. Shows a retry button.
35+
* - `recovered` (green): briefly shown for ~2.5 s after the connection
36+
* returns, so the user gets positive confirmation.
37+
*
38+
* Priority (high → low when multiple flags are true):
39+
* failed > reconnecting > recovered > buffering > idle
40+
*
41+
* `recovered` deliberately outranks `buffering` so the green ack survives a
42+
* post-recovery cache-refill blip on the new stream. It still loses to
43+
* `reconnecting`/`failed` so a fresh outage immediately overrides the
44+
* lingering green.
45+
*/
46+
export const ConnectionStatusOverlay = ({
47+
reconnecting,
48+
reconnectAttempt,
49+
buffering,
50+
loadFailed,
51+
recentlyRecovered,
52+
onRetry,
53+
}: ConnectionStatusOverlayProps) => {
54+
const status: Status = loadFailed
55+
? "failed"
56+
: reconnecting
57+
? "reconnecting"
58+
: recentlyRecovered
59+
? "recovered"
60+
: buffering
61+
? "buffering"
62+
: "idle";
63+
64+
if (status === "idle") return null;
65+
66+
const config = {
67+
buffering: {
68+
container: "border-amber-400/50 bg-amber-950/85 text-amber-100 shadow-amber-500/20",
69+
iconWrap: "bg-amber-500/20 text-amber-300",
70+
title: "Connection issues",
71+
detail: "Stream stalled — waiting for network.",
72+
Icon: <WifiOff className="h-5 w-5" />,
73+
Trailing: <Loader2 className="h-4 w-4 animate-spin text-amber-300" />,
74+
},
75+
reconnecting: {
76+
container: "border-red-400/50 bg-red-950/85 text-red-100 shadow-red-500/20",
77+
iconWrap: "bg-red-500/20 text-red-300",
78+
title: "Connection lost",
79+
detail:
80+
reconnectAttempt > 1
81+
? `Reconnecting to stream (attempt #${reconnectAttempt})…`
82+
: "Reconnecting to stream…",
83+
Icon: <WifiOff className="h-5 w-5" />,
84+
Trailing: <Loader2 className="h-4 w-4 animate-spin text-red-300" />,
85+
},
86+
failed: {
87+
container: "border-red-400/60 bg-red-950/90 text-red-100 shadow-red-500/30",
88+
iconWrap: "bg-red-500/25 text-red-200",
89+
title: "Stream unavailable",
90+
detail: "Couldn't connect after several attempts. Check your network.",
91+
Icon: <WifiOff className="h-5 w-5" />,
92+
Trailing: onRetry ? (
93+
<button
94+
type="button"
95+
onClick={onRetry}
96+
className="inline-flex items-center gap-1.5 rounded-md bg-red-500/20 px-2.5 py-1.5 text-xs font-medium text-red-100 ring-1 ring-inset ring-red-400/30 transition hover:bg-red-500/30 focus:outline-none focus:ring-2 focus:ring-red-400/60"
97+
>
98+
<RotateCw className="h-3.5 w-3.5" />
99+
Retry
100+
</button>
101+
) : null,
102+
},
103+
recovered: {
104+
container:
105+
"border-emerald-400/50 bg-emerald-950/85 text-emerald-100 shadow-emerald-500/20",
106+
iconWrap: "bg-emerald-500/20 text-emerald-300",
107+
title: "Connection restored",
108+
detail: "Stream is playing again.",
109+
Icon: (
110+
<svg
111+
viewBox="0 0 24 24"
112+
fill="none"
113+
stroke="currentColor"
114+
strokeWidth="2.5"
115+
strokeLinecap="round"
116+
strokeLinejoin="round"
117+
className="h-5 w-5"
118+
aria-hidden
119+
>
120+
<path d="M5 12l5 5L20 7" />
121+
</svg>
122+
),
123+
Trailing: null as React.ReactNode,
124+
},
125+
}[status];
126+
127+
return (
128+
<div
129+
role="status"
130+
aria-live="polite"
131+
className={`absolute top-6 left-1/2 z-50 -translate-x-1/2 flex items-center gap-3 rounded-xl border ${config.container} px-4 py-3 shadow-lg backdrop-blur-md max-w-md`}
132+
>
133+
<div
134+
className={`flex h-9 w-9 items-center justify-center rounded-full ${config.iconWrap}`}
135+
>
136+
{config.Icon}
137+
</div>
138+
<div className="flex-1 min-w-0">
139+
<p className="text-sm font-semibold leading-tight">{config.title}</p>
140+
<p className="text-xs leading-tight opacity-80 mt-0.5">{config.detail}</p>
141+
</div>
142+
{config.Trailing && <div className="shrink-0">{config.Trailing}</div>}
143+
</div>
144+
);
145+
};

apps/desktop/src/components/player/VideoPlayer.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Controls } from "./Controls";
22
import { ChannelOverlay } from "./ChannelOverlay";
3+
import { ConnectionStatusOverlay } from "./ConnectionStatusOverlay";
34
import { SubtitlePicker } from "./SubtitlePicker";
45
import { SubtitleOverlay } from "./SubtitleOverlay";
56
import { MovieInfoDrawer } from "@/components/channels/MovieInfoDrawer";
@@ -565,6 +566,27 @@ export const PlayerView = () => {
565566
</div>
566567
)}
567568

569+
<ConnectionStatusOverlay
570+
reconnecting={mpv.reconnecting}
571+
reconnectAttempt={mpv.reconnectAttempt}
572+
buffering={mpv.buffering}
573+
loadFailed={mpv.loadFailed}
574+
recentlyRecovered={mpv.recentlyRecovered}
575+
onRetry={() => {
576+
const url = mpv.state.currentUrl;
577+
if (!url) return;
578+
// Preserve playback position across the hard restart so
579+
// the user resumes where they were when the stream broke,
580+
// not from the beginning of the movie/episode. We use the
581+
// sticky `getLastKnownPosition()` (rather than the polled
582+
// `state.position`) so the retry is symmetric with the
583+
// `online`-triggered auto-recovery and robust to the
584+
// transient position=0 window during the `loadfile`.
585+
const resumeAt = mpv.getLastKnownPosition();
586+
mpv.load(url, resumeAt > 1.0 ? resumeAt : undefined).catch(() => {});
587+
}}
588+
/>
589+
568590
<div className="absolute inset-0 flex flex-col items-center justify-center bg-transparent">
569591
{mpv.error && (
570592
<div className="text-center p-6 max-w-md">

0 commit comments

Comments
 (0)