Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 43 additions & 4 deletions app/src/main/java/com/port80/app/ui/stream/StreamScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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() }

Expand Down Expand Up @@ -107,32 +110,56 @@ 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,
modifier = Modifier.align(Alignment.TopCenter)
)
}

// Layer 3: Connection state indicator at bottom-start
// Layer 4: Connection state indicator at bottom-start
ConnectionStateLabel(
state = streamState,
modifier = Modifier
.align(Alignment.BottomStart)
.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
Expand All @@ -149,6 +176,7 @@ fun StreamScreen(
@Composable
private fun ControlPanel(
streamState: StreamState,
isEnergySavingEnabled: Boolean,
viewModel: StreamViewModel,
onSettingsClick: () -> Unit,
modifier: Modifier = Modifier
Expand Down Expand Up @@ -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(
Expand Down
34 changes: 34 additions & 0 deletions app/src/main/java/com/port80/app/ui/stream/StreamViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,19 @@ class StreamViewModel @Inject constructor(
/** Last user-facing diagnostic detail for stream startup/connection failures. */
val lastFailureDetail: StateFlow<String?> = _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<Boolean> = _isEnergySavingEnabled.asStateFlow()

// One-shot UI events (e.g. "service died") — SharedFlow so they're
// not replayed on recomposition / re-collection.
private val _uiEvents = MutableSharedFlow<UiEvent>(extraBufferCapacity = 1)
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
// ══════════════════════════════════════════════
Expand Down