All files / src/components/player ConnectionStatusOverlay.tsx

0% Statements 0/91
0% Branches 0/1
0% Functions 0/1
0% Lines 0/91

Press n or j to go to the next uncovered block, b, p or k for the previous block.

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