|
| 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 | +}; |
0 commit comments