From b154a7b608296d1416c000b4f81f18b63974c365 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Mar 2026 17:24:34 +0000 Subject: [PATCH 1/2] Initial plan From a9d05d7d488c2027bc2b4d4aea3b4f48fd03965c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Mar 2026 17:37:01 +0000 Subject: [PATCH 2/2] feat: implement energy-saving mode as UI-only overlay to keep RTMP stream alive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The energy-saving mode toggle was breaking the RTMP stream because any implementation that calls stopPreview() on the encoder while streaming closes the camera capture session — the video encoder loses its frame input and the stream fails. Fix: implement energy-saving entirely in the UI layer. - CameraPreview (SurfaceView) always stays in the Compose composition, so surfaceDestroyed / stopPreview is never triggered on the encoder. - A dark 0xFF111111 overlay composable is shown on top when energy-saving is active, hiding the camera feed visually without touching the encoder. - Toggling on/off any number of times is safe; the RTMP stream is completely unaffected. Changes: - StreamViewModel: add isEnergySavingEnabled StateFlow, toggleEnergySaving() method, and auto-reset to false on stream Idle/Stopped transitions. - StreamScreen: add energy-saving overlay (Layer 2 in the Box stack), add VisibilityOff/Visibility toggle button to the streaming control panel. Co-authored-by: alxayo <2588978+alxayo@users.noreply.github.com> --- .../com/port80/app/ui/stream/StreamScreen.kt | 47 +++++++++++++++++-- .../port80/app/ui/stream/StreamViewModel.kt | 34 ++++++++++++++ 2 files changed, 77 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/port80/app/ui/stream/StreamScreen.kt b/app/src/main/java/com/port80/app/ui/stream/StreamScreen.kt index 470b1df..f05c924 100644 --- a/app/src/main/java/com/port80/app/ui/stream/StreamScreen.kt +++ b/app/src/main/java/com/port80/app/ui/stream/StreamScreen.kt @@ -15,6 +15,8 @@ import androidx.compose.material.icons.filled.MicOff import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Stop +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -68,6 +70,7 @@ fun StreamScreen( val streamState by viewModel.streamState.collectAsState() val streamStats by viewModel.streamStats.collectAsState() val lastFailureDetail by viewModel.lastFailureDetail.collectAsState() + val isEnergySavingEnabled by viewModel.isEnergySavingEnabled.collectAsState() val snackbarHostState = remember { SnackbarHostState() } @@ -107,14 +110,37 @@ fun StreamScreen( .fillMaxSize() .padding(contentPadding) ) { - // Layer 1: Camera preview (full-screen background) + // Layer 1: Camera preview (full-screen background). + // IMPORTANT: This composable must ALWAYS be present while streaming, + // even when energy-saving mode is active. Removing it would destroy + // the SurfaceView and trigger stopPreview() on the encoder, which + // breaks the live RTMP stream. The energy-saving overlay (Layer 2) + // hides the camera feed visually without touching the encoder. CameraPreview( modifier = Modifier.fillMaxSize(), onSurfaceReady = { openGlView -> viewModel.onSurfaceReady(openGlView) }, onSurfaceDestroyed = { viewModel.onSurfaceDestroyed() } ) - // Layer 2: HUD overlay at the top (only visible when live or reconnecting) + // Layer 2: Energy-saving overlay — shown instead of the live camera + // feed when the user has enabled energy-saving mode. The camera and + // encoder continue running underneath; only the display is dimmed. + if (isEnergySavingEnabled) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color(0xFF111111)), + contentAlignment = Alignment.Center + ) { + Text( + text = "⚡ Energy Saving", + color = Color.White.copy(alpha = 0.5f), + fontSize = 14.sp + ) + } + } + + // Layer 3: HUD overlay at the top (only visible when live or reconnecting) if (streamState is StreamState.Live || streamState is StreamState.Reconnecting) { StreamHud( stats = streamStats, @@ -122,7 +148,7 @@ fun StreamScreen( ) } - // Layer 3: Connection state indicator at bottom-start + // Layer 4: Connection state indicator at bottom-start ConnectionStateLabel( state = streamState, modifier = Modifier @@ -130,9 +156,10 @@ fun StreamScreen( .padding(16.dp) ) - // Layer 4: Control buttons on the right edge + // Layer 5: Control buttons on the right edge ControlPanel( streamState = streamState, + isEnergySavingEnabled = isEnergySavingEnabled, viewModel = viewModel, onSettingsClick = onNavigateToSettings, modifier = Modifier @@ -149,6 +176,7 @@ fun StreamScreen( @Composable private fun ControlPanel( streamState: StreamState, + isEnergySavingEnabled: Boolean, viewModel: StreamViewModel, onSettingsClick: () -> Unit, modifier: Modifier = Modifier @@ -218,6 +246,17 @@ private fun ControlPanel( ) } + // Energy-saving toggle (only shown when streaming). + // Hides the camera preview with a dark overlay without touching the + // encoder, so the RTMP stream continues uninterrupted. + if (isStreaming) { + ControlButton( + icon = if (isEnergySavingEnabled) Icons.Filled.VisibilityOff else Icons.Filled.Visibility, + contentDescription = if (isEnergySavingEnabled) "Show preview" else "Hide preview", + onClick = { viewModel.toggleEnergySaving() } + ) + } + // Settings button (only shown when idle or stopped) if (!isStreaming) { ControlButton( diff --git a/app/src/main/java/com/port80/app/ui/stream/StreamViewModel.kt b/app/src/main/java/com/port80/app/ui/stream/StreamViewModel.kt index d50fb71..6f13ac5 100644 --- a/app/src/main/java/com/port80/app/ui/stream/StreamViewModel.kt +++ b/app/src/main/java/com/port80/app/ui/stream/StreamViewModel.kt @@ -81,6 +81,19 @@ class StreamViewModel @Inject constructor( /** Last user-facing diagnostic detail for stream startup/connection failures. */ val lastFailureDetail: StateFlow = _lastFailureDetail.asStateFlow() + private val _isEnergySavingEnabled = MutableStateFlow(false) + + /** + * Whether energy-saving mode is active. + * + * When true, the camera preview is hidden behind a dark overlay. The RTMP + * stream and camera encoder continue running undisturbed — only the on-screen + * display is suppressed. This is implemented entirely in the UI layer so that + * neither [stopPreview] nor any encoder operation is triggered, which would + * otherwise break the live stream. + */ + val isEnergySavingEnabled: StateFlow = _isEnergySavingEnabled.asStateFlow() + // One-shot UI events (e.g. "service died") — SharedFlow so they're // not replayed on recomposition / re-collection. private val _uiEvents = MutableSharedFlow(extraBufferCapacity = 1) @@ -125,6 +138,12 @@ class StreamViewModel @Inject constructor( viewModelScope.launch { service.streamState.collect { state -> _streamState.value = state + // Energy-saving mode is only meaningful while streaming. + // Reset it automatically so the next session starts with + // preview enabled, regardless of how the previous stream ended. + if (state is StreamState.Idle || state is StreamState.Stopped) { + _isEnergySavingEnabled.value = false + } } } viewModelScope.launch { @@ -246,6 +265,21 @@ class StreamViewModel @Inject constructor( serviceControl?.switchCamera() } + /** + * Toggle the energy-saving (preview-hidden) mode on or off. + * + * This is a **UI-only** toggle — it never touches the encoder, camera, or + * RTMP connection. A dark overlay is shown over the SurfaceView so the device + * display consumes less power, but video encoding and streaming continue + * without interruption. + * + * Safe to call any number of times while streaming. + */ + fun toggleEnergySaving() { + _isEnergySavingEnabled.value = !_isEnergySavingEnabled.value + RedactingLogger.d(TAG, "Energy-saving mode toggled: ${_isEnergySavingEnabled.value}") + } + // ══════════════════════════════════════════════ // Surface Lifecycle // ══════════════════════════════════════════════