@@ -8,7 +8,12 @@ import {
88 type FormEvent ,
99} from "react" ;
1010
11- import { ApiError , accessTokenFromLocation , pairBrowser } from "../api/client" ;
11+ import {
12+ ApiError ,
13+ accessTokenFromLocation ,
14+ apiRequest ,
15+ pairBrowser ,
16+ } from "../api/client" ;
1217import { apiUrl , configureSimDeckClient } from "../api/config" ;
1318import {
1419 bootSimulator ,
@@ -98,12 +103,38 @@ const LOCAL_STREAM_DEFAULTS: StreamConfig = {
98103 quality : "quality" ,
99104} ;
100105const REMOTE_STREAM_DEFAULTS : StreamConfig = {
101- encoder : "auto " ,
106+ encoder : "software " ,
102107 fps : 30 ,
103108 quality : "balanced" ,
104109} ;
110+ const STREAM_CONFIG_SYNC_INTERVAL_MS = 5000 ;
111+ const STREAM_CONFIG_USER_CHANGE_GRACE_MS = 1000 ;
112+ const STREAM_ENCODER_VALUES = new Set < StreamEncoder > ( [
113+ "auto" ,
114+ "hardware" ,
115+ "software" ,
116+ ] ) ;
117+ const STREAM_QUALITY_VALUES = new Set < StreamQualityPreset > ( [
118+ "balanced" ,
119+ "ci-software" ,
120+ "economy" ,
121+ "fast" ,
122+ "quality" ,
123+ "smooth" ,
124+ ] ) ;
105125clearLegacyVolatileUiState ( ) ;
106126
127+ interface StreamQualityResponse {
128+ ok ?: boolean ;
129+ quality ?: {
130+ fps ?: number ;
131+ maxEdge ?: number ;
132+ profile ?: string ;
133+ videoCodec ?: string ;
134+ } ;
135+ videoCodec ?: string ;
136+ }
137+
107138function buildChromeUrl ( udid : string , stamp : number ) : string {
108139 return buildAuthenticatedAssetUrl (
109140 `/api/simulators/${ udid } /chrome.png` ,
@@ -296,6 +327,8 @@ export function AppShell({
296327 const [ streamConfig , setStreamConfig ] = useState < StreamConfig > ( ( ) =>
297328 remoteStream ? REMOTE_STREAM_DEFAULTS : LOCAL_STREAM_DEFAULTS ,
298329 ) ;
330+ const [ streamConfigApplyKey , setStreamConfigApplyKey ] = useState ( 0 ) ;
331+ const [ streamConfigReady , setStreamConfigReady ] = useState ( false ) ;
299332 const [ touchIndicators , setTouchIndicators ] = useState < TouchIndicator [ ] > ( [ ] ) ;
300333
301334 const menuRef = useRef < HTMLDivElement | null > ( null ) ;
@@ -313,6 +346,8 @@ export function AppShell({
313346 const gestureStartZoomRef = useRef ( 1 ) ;
314347 const accessibilityRequestIdRef = useRef ( 0 ) ;
315348 const accessibilityLoadingRef = useRef ( false ) ;
349+ const streamConfigRequestIdRef = useRef ( 0 ) ;
350+ const streamConfigUserChangeAtRef = useRef ( 0 ) ;
316351 const controlSocketRef = useRef < {
317352 udid : string ;
318353 socket : WebSocket ;
@@ -395,6 +430,52 @@ export function AppShell({
395430 [ ] ,
396431 ) ;
397432
433+ const syncStreamConfig = useCallback ( async ( ) => {
434+ const requestId = ++ streamConfigRequestIdRef . current ;
435+ try {
436+ const response = await apiRequest < StreamQualityResponse > (
437+ "/api/stream-quality" ,
438+ ) ;
439+ if ( requestId !== streamConfigRequestIdRef . current ) {
440+ return ;
441+ }
442+ if (
443+ Date . now ( ) - streamConfigUserChangeAtRef . current <
444+ STREAM_CONFIG_USER_CHANGE_GRACE_MS
445+ ) {
446+ return ;
447+ }
448+ setStreamConfig ( ( current ) =>
449+ mergeStreamQualityResponse ( current , response ) ,
450+ ) ;
451+ } catch {
452+ // Keep the existing local/default selection; the stream path will surface
453+ // provider reachability errors separately.
454+ } finally {
455+ if ( requestId === streamConfigRequestIdRef . current ) {
456+ setStreamConfigReady ( true ) ;
457+ }
458+ }
459+ } , [ ] ) ;
460+
461+ useEffect ( ( ) => {
462+ let cancelled = false ;
463+ setStreamConfigReady ( false ) ;
464+
465+ const run = ( ) => {
466+ if ( ! cancelled ) {
467+ void syncStreamConfig ( ) ;
468+ }
469+ } ;
470+
471+ run ( ) ;
472+ const intervalId = window . setInterval ( run , STREAM_CONFIG_SYNC_INTERVAL_MS ) ;
473+ return ( ) => {
474+ cancelled = true ;
475+ window . clearInterval ( intervalId ) ;
476+ } ;
477+ } , [ remoteStream , syncStreamConfig ] ) ;
478+
398479 const {
399480 deviceNaturalSize,
400481 error : streamError ,
@@ -407,20 +488,31 @@ export function AppShell({
407488 streamCanvasKey,
408489 } = useLiveStream ( {
409490 canvasElement : streamCanvasElement ,
491+ paused : ! streamConfigReady ,
410492 remote : remoteStream ,
411493 simulator : selectedSimulator ,
412494 streamConfig,
495+ streamConfigApplyKey,
413496 } ) ;
414497
415498 const updateStreamEncoder = useCallback ( ( encoder : StreamEncoder ) => {
499+ streamConfigUserChangeAtRef . current = Date . now ( ) ;
500+ setStreamConfigReady ( true ) ;
501+ setStreamConfigApplyKey ( ( current ) => current + 1 ) ;
416502 setStreamConfig ( ( current ) => ( { ...current , encoder } ) ) ;
417503 } , [ ] ) ;
418504
419505 const updateStreamFps = useCallback ( ( fps : StreamFps ) => {
506+ streamConfigUserChangeAtRef . current = Date . now ( ) ;
507+ setStreamConfigReady ( true ) ;
508+ setStreamConfigApplyKey ( ( current ) => current + 1 ) ;
420509 setStreamConfig ( ( current ) => ( { ...current , fps } ) ) ;
421510 } , [ ] ) ;
422511
423512 const updateStreamQuality = useCallback ( ( quality : StreamQualityPreset ) => {
513+ streamConfigUserChangeAtRef . current = Date . now ( ) ;
514+ setStreamConfigReady ( true ) ;
515+ setStreamConfigApplyKey ( ( current ) => current + 1 ) ;
424516 setStreamConfig ( ( current ) => ( { ...current , quality } ) ) ;
425517 } , [ ] ) ;
426518
@@ -899,27 +991,34 @@ export function AppShell({
899991 } ) ;
900992
901993 const pairingRequired =
994+ ! remoteStream &&
902995 pairingEnabled &&
903996 listError === AUTH_REQUIRED_MESSAGE &&
904997 ! accessTokenFromLocation ( ) ;
905- const visibleListError = selectedSimulator
906- ? friendlyClientError ( listError )
907- : listError ;
998+ const visibleListError =
999+ remoteStream && listError === AUTH_REQUIRED_MESSAGE
1000+ ? ""
1001+ : selectedSimulator
1002+ ? friendlyClientError ( listError )
1003+ : listError ;
9081004 const toolbarError = pairingRequired
9091005 ? localError
9101006 : localError || ( selectedSimulator ? "" : visibleListError ) ;
911- const streamStatusMessage = streamStatus . error
1007+ const visibleStreamError = friendlyStreamError ( streamStatus . error , {
1008+ remote : remoteStream ,
1009+ } ) ;
1010+ const streamStatusMessage = visibleStreamError
9121011 ? streamStatus . detail
913- ? `${ streamStatus . error } ${ streamStatus . detail } `
914- : streamStatus . error
1012+ ? `${ visibleStreamError } ${ streamStatus . detail } `
1013+ : visibleStreamError
9151014 : "" ;
9161015 const viewportStatusOverlayLabel =
9171016 simulatorStatusOverlayLabel ||
9181017 streamStatusMessage ||
9191018 ( selectedSimulator ? visibleListError : "" ) ;
9201019 const viewportHasStreamError = Boolean (
9211020 streamStatus . state === "error" ||
922- streamStatus . error ||
1021+ visibleStreamError ||
9231022 ( selectedSimulator && visibleListError ) ,
9241023 ) ;
9251024 const deviceTransform = `translate(${ pan . x } px, ${ pan . y + autoViewportOffsetY } px) scale(${ effectiveZoom } )` ;
@@ -1061,6 +1160,9 @@ export function AppShell({
10611160 if ( sendWebRtcControlMessage ( encoded ) ) {
10621161 return true ;
10631162 }
1163+ if ( remoteStream ) {
1164+ return false ;
1165+ }
10641166 const state = ensureControlSocket ( udid ) ;
10651167 if ( state . socket . readyState === WebSocket . OPEN ) {
10661168 state . socket . send ( encoded ) ;
@@ -1462,6 +1564,7 @@ export function AppShell({
14621564 onToggleTouchOverlay = { ( ) =>
14631565 setTouchOverlayVisible ( ( current ) => ! current )
14641566 }
1567+ remoteStream = { remoteStream }
14651568 search = { search }
14661569 selectedSimulator = { selectedSimulator }
14671570 selectedSimulatorIdentifier = { selectedSimulatorDetail }
@@ -1607,3 +1710,87 @@ function friendlyClientError(message: string): string {
16071710 }
16081711 return message ;
16091712}
1713+
1714+ function friendlyStreamError (
1715+ message : string | undefined ,
1716+ options : { remote : boolean } ,
1717+ ) : string {
1718+ const normalized = message ?. trim ( ) ?? "" ;
1719+ if ( ! normalized ) {
1720+ return "" ;
1721+ }
1722+ if (
1723+ options . remote &&
1724+ normalized . toLowerCase ( ) . includes ( AUTH_REQUIRED_MESSAGE . toLowerCase ( ) )
1725+ ) {
1726+ return "" ;
1727+ }
1728+ return friendlyClientError ( normalized ) ;
1729+ }
1730+
1731+ function mergeStreamQualityResponse (
1732+ current : StreamConfig ,
1733+ response : StreamQualityResponse ,
1734+ ) : StreamConfig {
1735+ const quality = response . quality ?? { } ;
1736+ const next : StreamConfig = {
1737+ ...current ,
1738+ encoder : normalizeStreamEncoder (
1739+ quality . videoCodec ?? response . videoCodec ,
1740+ current . encoder ,
1741+ ) ,
1742+ fps : normalizeStreamFps ( quality . fps , current . fps ) ,
1743+ maxEdge : normalizeMaxEdge ( quality . maxEdge , current . maxEdge ) ,
1744+ quality : normalizeStreamQuality ( quality . profile , current . quality ) ,
1745+ } ;
1746+ return streamConfigsEqual ( current , next ) ? current : next ;
1747+ }
1748+
1749+ function normalizeStreamEncoder (
1750+ value : string | undefined ,
1751+ fallback : StreamEncoder ,
1752+ ) : StreamEncoder {
1753+ const normalized = value ?. trim ( ) . toLowerCase ( ) as StreamEncoder | undefined ;
1754+ return normalized && STREAM_ENCODER_VALUES . has ( normalized )
1755+ ? normalized
1756+ : fallback ;
1757+ }
1758+
1759+ function normalizeStreamQuality (
1760+ value : string | undefined ,
1761+ fallback : StreamQualityPreset ,
1762+ ) : StreamQualityPreset {
1763+ const normalized = value ?. trim ( ) . toLowerCase ( ) as
1764+ | StreamQualityPreset
1765+ | undefined ;
1766+ return normalized && STREAM_QUALITY_VALUES . has ( normalized )
1767+ ? normalized
1768+ : fallback ;
1769+ }
1770+
1771+ function normalizeStreamFps (
1772+ value : number | undefined ,
1773+ fallback : StreamFps ,
1774+ ) : StreamFps {
1775+ return typeof value === "number" && Number . isFinite ( value ) && value > 0
1776+ ? Math . round ( value )
1777+ : fallback ;
1778+ }
1779+
1780+ function normalizeMaxEdge (
1781+ value : number | undefined ,
1782+ fallback : number | undefined ,
1783+ ) : number | undefined {
1784+ return typeof value === "number" && Number . isFinite ( value ) && value > 0
1785+ ? Math . round ( value )
1786+ : fallback ;
1787+ }
1788+
1789+ function streamConfigsEqual ( left : StreamConfig , right : StreamConfig ) : boolean {
1790+ return (
1791+ left . encoder === right . encoder &&
1792+ left . fps === right . fps &&
1793+ left . maxEdge === right . maxEdge &&
1794+ left . quality === right . quality
1795+ ) ;
1796+ }
0 commit comments