Skip to content

Commit 93e4998

Browse files
lukemarsdenclaude
andcommitted
feat: visibility-based connection and screenshot mode recommendation
1. Visibility-based initialization: - Stream only connects when component becomes visible (IntersectionObserver) - Saves bandwidth when user hasn't switched to the stream tab yet - Once connected, stays connected (doesn't disconnect on hide) 2. Screenshot mode recommendation: - When at minimum bitrate (5Mbps) and still experiencing congestion, recommend switching to screenshot mode instead of just suffering - Red "Struggling · Try screenshots" button appears in toolbar - Clicking switches to screenshot mode 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent b47f2f2 commit 93e4998

1 file changed

Lines changed: 87 additions & 18 deletions

File tree

frontend/src/components/external-agent/MoonlightStreamViewer.tsx

Lines changed: 87 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ const MoonlightStreamViewer: React.FC<MoonlightStreamViewerProps> = ({
114114
const [isConnected, setIsConnected] = useState(false);
115115
const [error, setError] = useState<string | null>(null);
116116
const [status, setStatus] = useState('Initializing...');
117+
const [isVisible, setIsVisible] = useState(false); // Track if component is visible (for deferred connection)
117118
const [isFullscreen, setIsFullscreen] = useState(false);
118119
const [audioEnabled, setAudioEnabled] = useState(true);
119120
const [pendingAutoJoin, setPendingAutoJoin] = useState(false); // Wait for video before auto-join
@@ -129,7 +130,7 @@ const MoonlightStreamViewer: React.FC<MoonlightStreamViewerProps> = ({
129130
const manualBitrateSelectionTimeRef = useRef<number>(0); // Track when user manually selected bitrate (20s cooldown before auto-reduce)
130131
// Bandwidth recommendation state - instead of auto-switching, we show a recommendation popup
131132
const [bitrateRecommendation, setBitrateRecommendation] = useState<{
132-
type: 'decrease' | 'increase';
133+
type: 'decrease' | 'increase' | 'screenshot';
133134
targetBitrate: number;
134135
reason: string;
135136
frameDrift?: number; // Current frame drift for decrease recommendations
@@ -1509,16 +1510,24 @@ const MoonlightStreamViewer: React.FC<MoonlightStreamViewerProps> = ({
15091510
return BITRATE_OPTIONS[pessimisticIndex];
15101511
}, []);
15111512

1512-
// Auto-connect when wolfLobbyId becomes available
1513+
// Auto-connect when wolfLobbyId becomes available AND component is visible
15131514
// wolfLobbyId is fetched asynchronously from session data, so it's undefined on initial render
15141515
// If we connect before it's available, we use the wrong app_id (apps mode instead of lobbies mode)
1516+
// NEW: Wait for visibility before connecting (saves bandwidth when component not in view)
15151517
// NEW: Probe bandwidth FIRST, then connect at optimal bitrate (avoids reconnect on startup)
15161518
const hasConnectedRef = useRef(false);
15171519
const hasEverConnectedRef = useRef(false); // True after first successful connection (distinguishes initial vs reconnect)
15181520
useEffect(() => {
15191521
// Only auto-connect once
15201522
if (hasConnectedRef.current) return;
15211523

1524+
// Wait for component to become visible before connecting
1525+
// This prevents wasting bandwidth on hidden tabs/components
1526+
if (!isVisible) {
1527+
console.log('[MoonlightStreamViewer] Waiting for component to become visible before connecting...');
1528+
return;
1529+
}
1530+
15221531
// If wolfLobbyId prop is expected but not yet loaded, wait for it
15231532
// We detect this by checking if sessionId is provided (external agent mode)
15241533
// In this mode, wolfLobbyId should be provided by the parent once session data loads
@@ -1553,7 +1562,7 @@ const MoonlightStreamViewer: React.FC<MoonlightStreamViewerProps> = ({
15531562

15541563
probeAndConnect();
15551564
// eslint-disable-next-line react-hooks/exhaustive-deps
1556-
}, [wolfLobbyId, sessionId]); // Only trigger on props, not on function identity changes
1565+
}, [wolfLobbyId, sessionId, isVisible]); // Only trigger on props and visibility, not on function identity changes
15571566

15581567
// Cleanup on unmount
15591568
useEffect(() => {
@@ -1898,6 +1907,31 @@ const MoonlightStreamViewer: React.FC<MoonlightStreamViewerProps> = ({
18981907
}
18991908
}
19001909
}
1910+
} else if (congestionDetected && currentBitrate === MIN_BITRATE) {
1911+
// Already at minimum bitrate but still experiencing congestion
1912+
// Recommend switching to screenshot mode for better reliability
1913+
congestionCheckCountRef.current++;
1914+
stableCheckCountRef.current = 0;
1915+
1916+
if (congestionCheckCountRef.current >= CONGESTION_CHECKS_FOR_REDUCE) {
1917+
const timeSinceLastChange = now - lastBitrateChangeRef.current;
1918+
1919+
if (timeSinceLastChange > REDUCE_COOLDOWN_MS) {
1920+
console.log(`[AdaptiveBitrate] At minimum bitrate (${MIN_BITRATE}Mbps) but still experiencing congestion (${frameDrift.toFixed(0)}ms drift), recommending screenshot mode`);
1921+
1922+
setBitrateRecommendation({
1923+
type: 'screenshot',
1924+
targetBitrate: MIN_BITRATE, // Keep same bitrate, just switch mode
1925+
reason: `Video streaming is struggling even at ${MIN_BITRATE}Mbps`,
1926+
frameDrift: frameDrift,
1927+
});
1928+
1929+
lastBitrateChangeRef.current = now;
1930+
stableCheckCountRef.current = 0;
1931+
congestionCheckCountRef.current = 0;
1932+
return;
1933+
}
1934+
}
19011935
} else {
19021936
// Low frame drift - connection is stable at current bitrate
19031937
congestionCheckCountRef.current = 0; // Reset congestion counter on good sample
@@ -2010,6 +2044,28 @@ const MoonlightStreamViewer: React.FC<MoonlightStreamViewerProps> = ({
20102044
return () => resizeObserver.disconnect();
20112045
}, []);
20122046

2047+
// Track visibility for deferred connection - only connect when component is visible
2048+
// This saves bandwidth and avoids connection issues on high-latency networks
2049+
useEffect(() => {
2050+
const container = containerRef.current;
2051+
if (!container) return;
2052+
2053+
const observer = new IntersectionObserver(
2054+
(entries) => {
2055+
const entry = entries[0];
2056+
if (entry.isIntersecting && !isVisible) {
2057+
console.log('[MoonlightStreamViewer] Component became visible - will trigger connection');
2058+
setIsVisible(true);
2059+
}
2060+
// Note: we don't set isVisible=false when hidden - once connected, stay connected
2061+
},
2062+
{ threshold: 0.1 } // Trigger when 10% visible
2063+
);
2064+
2065+
observer.observe(container);
2066+
return () => observer.disconnect();
2067+
}, [isVisible]);
2068+
20132069
// Calculate proper canvas display size to maintain aspect ratio
20142070
useEffect(() => {
20152071
if (!containerSize || !canvasRef.current) return;
@@ -3062,35 +3118,48 @@ const MoonlightStreamViewer: React.FC<MoonlightStreamViewerProps> = ({
30623118
<Button
30633119
size="small"
30643120
onClick={() => {
3065-
setUserBitrate(bitrateRecommendation.targetBitrate);
3121+
if (bitrateRecommendation.type === 'screenshot') {
3122+
// Switch to screenshot mode
3123+
setQualityMode('low');
3124+
addChartEvent('reduce', 'User switched to screenshot mode');
3125+
} else {
3126+
// Change bitrate
3127+
setUserBitrate(bitrateRecommendation.targetBitrate);
3128+
addChartEvent(
3129+
bitrateRecommendation.type === 'decrease' ? 'reduce' : 'increase',
3130+
`User accepted: ${userBitrate ?? requestedBitrate}${bitrateRecommendation.targetBitrate} Mbps`
3131+
);
3132+
}
30663133
manualBitrateSelectionTimeRef.current = Date.now();
3067-
addChartEvent(
3068-
bitrateRecommendation.type === 'decrease' ? 'reduce' : 'increase',
3069-
`User accepted: ${userBitrate ?? requestedBitrate}${bitrateRecommendation.targetBitrate} Mbps`
3070-
);
30713134
setBitrateRecommendation(null);
30723135
}}
30733136
sx={{
3074-
backgroundColor: bitrateRecommendation.type === 'decrease'
3075-
? 'rgba(255, 152, 0, 0.9)'
3076-
: 'rgba(76, 175, 80, 0.9)',
3077-
color: bitrateRecommendation.type === 'decrease' ? 'black' : 'white',
3137+
backgroundColor: bitrateRecommendation.type === 'screenshot'
3138+
? 'rgba(244, 67, 54, 0.9)' // Red for screenshot recommendation
3139+
: bitrateRecommendation.type === 'decrease'
3140+
? 'rgba(255, 152, 0, 0.9)'
3141+
: 'rgba(76, 175, 80, 0.9)',
3142+
color: 'white',
30783143
fontSize: '0.65rem',
30793144
px: 1,
30803145
py: 0.25,
30813146
minWidth: 'auto',
30823147
textTransform: 'none',
30833148
borderRadius: 1,
30843149
'&:hover': {
3085-
backgroundColor: bitrateRecommendation.type === 'decrease'
3086-
? 'rgba(255, 152, 0, 1)'
3087-
: 'rgba(76, 175, 80, 1)',
3150+
backgroundColor: bitrateRecommendation.type === 'screenshot'
3151+
? 'rgba(244, 67, 54, 1)'
3152+
: bitrateRecommendation.type === 'decrease'
3153+
? 'rgba(255, 152, 0, 1)'
3154+
: 'rgba(76, 175, 80, 1)',
30883155
},
30893156
}}
30903157
>
3091-
{bitrateRecommendation.type === 'decrease'
3092-
? `Slow connection · Try ${bitrateRecommendation.targetBitrate}M`
3093-
: `Improved · Try ${bitrateRecommendation.targetBitrate}M`}
3158+
{bitrateRecommendation.type === 'screenshot'
3159+
? 'Struggling · Try screenshots'
3160+
: bitrateRecommendation.type === 'decrease'
3161+
? `Slow connection · Try ${bitrateRecommendation.targetBitrate}M`
3162+
: `Improved · Try ${bitrateRecommendation.targetBitrate}M`}
30943163
</Button>
30953164
</Tooltip>
30963165
)}

0 commit comments

Comments
 (0)