diff --git a/src/components/ModelingMachineProvider.tsx b/src/components/ModelingMachineProvider.tsx index 05cd28d76..6732eb151 100644 --- a/src/components/ModelingMachineProvider.tsx +++ b/src/components/ModelingMachineProvider.tsx @@ -166,7 +166,7 @@ export const ModelingMachineProvider = ({ store.videoElement?.pause() kclManager.executeCode(true).then(() => { - if (engineCommandManager.engineConnection?.freezeFrame) return + if (engineCommandManager.engineConnection?.idleMode) return store.videoElement?.play() }) diff --git a/src/components/Stream.tsx b/src/components/Stream.tsx index 6f98e63d2..a10416d06 100644 --- a/src/components/Stream.tsx +++ b/src/components/Stream.tsx @@ -1,4 +1,3 @@ -import { DEV } from 'env' import { MouseEventHandler, useEffect, useRef, useState } from 'react' import { getNormalisedCoordinates } from '../lib/utils' import Loading from './Loading' @@ -11,6 +10,10 @@ import { btnName } from 'lib/cameraControls' import { sendSelectEventToEngine } from 'lib/selections' import { kclManager, engineCommandManager, sceneInfra } from 'lib/singletons' import { useAppStream } from 'AppState' +import { + EngineConnectionStateType, + DisconnectingType, +} from 'lang/std/engineConnection' export const Stream = () => { const [isLoading, setIsLoading] = useState(true) @@ -20,15 +23,28 @@ export const Stream = () => { const { settings } = useSettingsAuthContext() const { state, send, context } = useModelingContext() const { mediaStream } = useAppStream() - const { overallState } = useNetworkContext() + const { overallState, immediateState } = useNetworkContext() const [isFreezeFrame, setIsFreezeFrame] = useState(false) + const [isPaused, setIsPaused] = useState(false) - const IDLE = true + const IDLE = settings.context.app.streamIdleMode.current const isNetworkOkay = overallState === NetworkHealthState.Ok || overallState === NetworkHealthState.Weak + useEffect(() => { + if ( + immediateState.type === EngineConnectionStateType.Disconnecting && + immediateState.value.type === DisconnectingType.Pause + ) { + setIsPaused(true) + } + if (immediateState.type === EngineConnectionStateType.Connecting) { + setIsPaused(false) + } + }, [immediateState]) + // Linux has a default behavior to paste text on middle mouse up // This adds a listener to block that pasting if the click target // is not a text input, so users can move in the 3D scene with @@ -65,25 +81,28 @@ export const Stream = () => { sceneInfra.modelingSend({ type: 'Cancel' }) // Give video time to pause window.requestAnimationFrame(() => { - engineCommandManager.tearDown() + engineCommandManager.tearDown({ idleMode: true }) }) } - // Teardown everything if we go hidden or reconnect - if (IDLE && DEV) { - if (globalThis?.window?.document) { - globalThis.window.document.onvisibilitychange = () => { - if (globalThis.window.document.visibilityState === 'hidden') { - clearTimeout(timeoutIdIdleA) - timeoutIdIdleA = setTimeout(teardown, IDLE_TIME_MS) - } else if (!engineCommandManager.engineConnection?.isReady()) { - clearTimeout(timeoutIdIdleA) - engineCommandManager.engineConnection?.connect(true) - } - } + const onVisibilityChange = () => { + if (globalThis.window.document.visibilityState === 'hidden') { + clearTimeout(timeoutIdIdleA) + timeoutIdIdleA = setTimeout(teardown, IDLE_TIME_MS) + } else if (!engineCommandManager.engineConnection?.isReady()) { + clearTimeout(timeoutIdIdleA) + engineCommandManager.engineConnection?.connect(true) } } + // Teardown everything if we go hidden or reconnect + if (IDLE) { + globalThis?.window?.document?.addEventListener( + 'visibilitychange', + onVisibilityChange + ) + } + let timeoutIdIdleB: ReturnType | undefined = undefined const onAnyInput = () => { @@ -93,7 +112,7 @@ export const Stream = () => { timeoutIdIdleB = setTimeout(teardown, IDLE_TIME_MS) } - if (IDLE && DEV) { + if (IDLE) { globalThis?.window?.document?.addEventListener('keydown', onAnyInput) globalThis?.window?.document?.addEventListener('mousemove', onAnyInput) globalThis?.window?.document?.addEventListener('mousedown', onAnyInput) @@ -101,7 +120,7 @@ export const Stream = () => { globalThis?.window?.document?.addEventListener('touchstart', onAnyInput) } - if (IDLE && DEV) { + if (IDLE) { timeoutIdIdleB = setTimeout(teardown, IDLE_TIME_MS) } @@ -109,7 +128,14 @@ export const Stream = () => { globalThis?.window?.document?.removeEventListener('paste', handlePaste, { capture: true, }) - if (IDLE && DEV) { + if (IDLE) { + clearTimeout(timeoutIdIdleA) + clearTimeout(timeoutIdIdleB) + + globalThis?.window?.document?.removeEventListener( + 'visibilitychange', + onVisibilityChange + ) globalThis?.window?.document?.removeEventListener('keydown', onAnyInput) globalThis?.window?.document?.removeEventListener( 'mousemove', @@ -126,7 +152,7 @@ export const Stream = () => { ) } } - }, []) + }, [IDLE]) useEffect(() => { setIsFirstRender(kclManager.isFirstRender) @@ -249,6 +275,32 @@ export const Stream = () => { + {isPaused && ( +
+
+
+ + + +
+

Paused

+
+
+ )} {(!isNetworkOkay || isLoading || isFirstRender) && !isFreezeFrame && (
diff --git a/src/hooks/useNetworkContext.tsx b/src/hooks/useNetworkContext.tsx index f0ede7971..16da16a11 100644 --- a/src/hooks/useNetworkContext.tsx +++ b/src/hooks/useNetworkContext.tsx @@ -1,11 +1,16 @@ import { createContext, useContext } from 'react' import { ConnectingTypeGroup, + EngineConnectionStateType, + EngineConnectionState, initialConnectingTypeGroupState, } from '../lang/std/engineConnection' import { NetworkStatus, NetworkHealthState } from './useNetworkStatus' export const NetworkContext = createContext({ + immediateState: { + type: EngineConnectionStateType.Disconnected, + } as EngineConnectionState, hasIssues: undefined, overallState: NetworkHealthState.Disconnected, internetConnected: true, diff --git a/src/hooks/useNetworkStatus.tsx b/src/hooks/useNetworkStatus.tsx index d5d19860e..30c4ccf2c 100644 --- a/src/hooks/useNetworkStatus.tsx +++ b/src/hooks/useNetworkStatus.tsx @@ -6,6 +6,7 @@ import { EngineCommandManagerEvents, EngineConnectionEvents, EngineConnectionStateType, + EngineConnectionState, ErrorType, initialConnectingTypeGroupState, } from '../lang/std/engineConnection' @@ -19,6 +20,7 @@ export enum NetworkHealthState { } export interface NetworkStatus { + immediateState: EngineConnectionState hasIssues: boolean | undefined overallState: NetworkHealthState internetConnected: boolean @@ -33,6 +35,9 @@ export interface NetworkStatus { // Must be called from one place in the application. // We've chosen the component for this. export function useNetworkStatus() { + const [immediateState, setImmediateState] = useState({ + type: EngineConnectionStateType.Disconnected, + }) const [steps, setSteps] = useState( structuredClone(initialConnectingTypeGroupState) ) @@ -126,6 +131,7 @@ export function useNetworkStatus() { const onConnectionStateChange = ({ detail: engineConnectionState, }: CustomEvent) => { + setImmediateState(engineConnectionState) setSteps((steps) => { let nextSteps = structuredClone(steps) @@ -215,6 +221,7 @@ export function useNetworkStatus() { }, []) return { + immediateState, hasIssues, overallState, internetConnected, diff --git a/src/lang/std/engineConnection.ts b/src/lang/std/engineConnection.ts index ff8d8b108..59d9fd1b0 100644 --- a/src/lang/std/engineConnection.ts +++ b/src/lang/std/engineConnection.ts @@ -143,6 +143,7 @@ export enum DisconnectingType { Error = 'error', Timeout = 'timeout', Quit = 'quit', + Pause = 'pause', } // Sorted by severity @@ -200,6 +201,7 @@ export type DisconnectingValue = | State | State | State + | State // These are ordered by the expected sequence. export enum ConnectingType { @@ -300,7 +302,7 @@ class EngineConnection extends EventTarget { pc?: RTCPeerConnection unreliableDataChannel?: RTCDataChannel mediaStream?: MediaStream - freezeFrame: boolean = false + idleMode: boolean = false onIceCandidate = function ( this: RTCPeerConnection, @@ -391,10 +393,10 @@ class EngineConnection extends EventTarget { this.pingPongSpan = { ping: undefined, pong: undefined } // Without an interval ping, our connection will timeout. - // If this.freezeFrame is true we skip this logic so only reconnect + // If this.idleMode is true we skip this logic so only reconnect // happens on mouse move this.pingIntervalId = setInterval(() => { - if (this.freezeFrame) return + if (this.idleMode) return switch (this.state.type as EngineConnectionStateType) { case EngineConnectionStateType.ConnectionEstablished: @@ -456,8 +458,8 @@ class EngineConnection extends EventTarget { return this.state.type === EngineConnectionStateType.ConnectionEstablished } - tearDown(opts?: { freeze: boolean }) { - this.freezeFrame = opts?.freeze ?? false + tearDown(opts?: { idleMode: boolean }) { + this.idleMode = opts?.idleMode ?? false this.disconnectAll() clearInterval(this.pingIntervalId) @@ -497,10 +499,19 @@ class EngineConnection extends EventTarget { this.onNetworkStatusReady ) - this.state = { - type: EngineConnectionStateType.Disconnecting, - value: { type: DisconnectingType.Quit }, - } + this.state = opts?.idleMode + ? { + type: EngineConnectionStateType.Disconnecting, + value: { + type: DisconnectingType.Pause, + }, + } + : { + type: EngineConnectionStateType.Disconnecting, + value: { + type: DisconnectingType.Quit, + }, + } } /** @@ -1099,8 +1110,6 @@ class EngineConnection extends EventTarget { this.unreliableDataChannel?.readyState === 'closed' if (allClosed) { // Do not notify the rest of the program that we have cut off anything. - if (this.freezeFrame) return - this.state = { type: EngineConnectionStateType.Disconnected } } } @@ -1738,7 +1747,7 @@ export class EngineCommandManager extends EventTarget { } } } - tearDown() { + tearDown(opts?: { idleMode: boolean }) { if (this.engineConnection) { this.engineConnection.removeEventListener( EngineConnectionEvents.Opened, @@ -1757,7 +1766,7 @@ export class EngineCommandManager extends EventTarget { this.onEngineConnectionNewTrack as EventListener ) - this.engineConnection?.tearDown() + this.engineConnection?.tearDown(opts) this.engineConnection = undefined // Our window.tearDown assignment causes this case to happen which is @@ -1765,7 +1774,7 @@ export class EngineCommandManager extends EventTarget { // @ts-ignore } else if (this.engineCommandManager?.engineConnection) { // @ts-ignore - this.engineCommandManager?.engineConnection?.tearDown() + this.engineCommandManager?.engineConnection?.tearDown(opts) } } async startNewSession() { diff --git a/src/lib/settings/initialSettings.tsx b/src/lib/settings/initialSettings.tsx index eec0c27c2..2ec9653c5 100644 --- a/src/lib/settings/initialSettings.tsx +++ b/src/lib/settings/initialSettings.tsx @@ -163,6 +163,17 @@ export function createSettings() { validate: (v) => typeof v === 'boolean', hideOnPlatform: 'both', //for now }), + /** + * Stream resource saving behavior toggle + */ + streamIdleMode: new Setting({ + defaultValue: false, + description: 'Toggle stream idling, saving bandwidth and battery', + validate: (v) => typeof v === 'boolean', + commandConfig: { + inputType: 'boolean', + }, + }), onboardingStatus: new Setting({ defaultValue: '', validate: (v) => typeof v === 'string', diff --git a/src/lib/settings/settingsUtils.ts b/src/lib/settings/settingsUtils.ts index 88b326db0..0f9f54846 100644 --- a/src/lib/settings/settingsUtils.ts +++ b/src/lib/settings/settingsUtils.ts @@ -38,6 +38,7 @@ function configurationToSettingsPayload( : undefined, onboardingStatus: configuration?.settings?.app?.onboarding_status, dismissWebBanner: configuration?.settings?.app?.dismiss_web_banner, + streamIdleMode: configuration?.settings?.app?.stream_idle_mode, projectDirectory: configuration?.settings?.project?.directory, enableSSAO: configuration?.settings?.modeling?.enable_ssao, }, @@ -75,6 +76,7 @@ function projectConfigurationToSettingsPayload( : undefined, onboardingStatus: configuration?.settings?.app?.onboarding_status, dismissWebBanner: configuration?.settings?.app?.dismiss_web_banner, + streamIdleMode: configuration?.settings?.app?.stream_idle_mode, enableSSAO: configuration?.settings?.modeling?.enable_ssao, }, modeling: { diff --git a/src/machines/modelingMachine.ts b/src/machines/modelingMachine.ts index 9663be425..a139e6460 100644 --- a/src/machines/modelingMachine.ts +++ b/src/machines/modelingMachine.ts @@ -1081,7 +1081,7 @@ export const modelingMachine = createMachine( type: 'start_path', }, }) - if (!engineCommandManager.engineConnection?.freezeFrame) { + if (!engineCommandManager.engineConnection?.idleMode) { store.videoElement?.play() } if (updatedAst?.selections) { diff --git a/src/machines/settingsMachine.ts b/src/machines/settingsMachine.ts index 951d7ac0f..40cd61cb7 100644 --- a/src/machines/settingsMachine.ts +++ b/src/machines/settingsMachine.ts @@ -63,6 +63,12 @@ export const settingsMachine = createMachine( ], }, + 'set.app.streamIdleMode': { + target: 'persisting settings', + + actions: ['setSettingAtLevel', 'toastSuccess'], + }, + 'set.modeling.highlightEdges': { target: 'persisting settings', diff --git a/src/wasm-lib/kcl/src/settings/types/mod.rs b/src/wasm-lib/kcl/src/settings/types/mod.rs index 61ade9c4b..48d2a3709 100644 --- a/src/wasm-lib/kcl/src/settings/types/mod.rs +++ b/src/wasm-lib/kcl/src/settings/types/mod.rs @@ -234,6 +234,9 @@ pub struct AppSettings { /// This setting only applies to the web app. And is temporary until we have Linux support. #[serde(default, alias = "dismissWebBanner", skip_serializing_if = "is_default")] pub dismiss_web_banner: bool, + /// When the user is idle, and this is true, the stream will be torn down. + #[serde(default, alias = "streamIdleMode", skip_serializing_if = "is_default")] + stream_idle_mode: bool, } // TODO: When we remove backwards compatibility with the old settings file, we can remove this. @@ -651,6 +654,7 @@ textWrapping = true theme_color: None, dismiss_web_banner: false, enable_ssao: None, + stream_idle_mode: false, }, modeling: ModelingSettings { base_unit: UnitLength::In, @@ -710,6 +714,7 @@ includeSettings = false theme_color: None, dismiss_web_banner: false, enable_ssao: None, + stream_idle_mode: false, }, modeling: ModelingSettings { base_unit: UnitLength::Yd, @@ -774,6 +779,7 @@ defaultProjectName = "projects-$nnn" theme_color: None, dismiss_web_banner: false, enable_ssao: None, + stream_idle_mode: false, }, modeling: ModelingSettings { base_unit: UnitLength::Yd, @@ -850,6 +856,7 @@ projectDirectory = "/Users/macinatormax/Documents/kittycad-modeling-projects""#; theme_color: None, dismiss_web_banner: false, enable_ssao: None, + stream_idle_mode: false, }, modeling: ModelingSettings { base_unit: UnitLength::Mm, diff --git a/src/wasm-lib/kcl/src/settings/types/project.rs b/src/wasm-lib/kcl/src/settings/types/project.rs index 36a329980..ebf442848 100644 --- a/src/wasm-lib/kcl/src/settings/types/project.rs +++ b/src/wasm-lib/kcl/src/settings/types/project.rs @@ -123,6 +123,7 @@ includeSettings = false theme_color: None, dismiss_web_banner: false, enable_ssao: None, + stream_idle_mode: false, }, modeling: ModelingSettings { base_unit: UnitLength::Yd,