Skip to content
Open
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
4 changes: 2 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ android {
applicationId = "com.example.googlehomeapisampleapp"
minSdk = 29
targetSdk = 36
versionCode = 40
versionName = "1.8.0"
versionCode = 41
versionName = "1.8.1"

// Store your GCP project web client ID in local.properties and access it via project properties.
// If local.properties doesn't exist in your app root folder, just create it
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import com.google.home.google.GoogleTVDevice
import com.google.home.google.MediaActivityState
import com.google.home.google.Notification
import com.google.home.google.PushAvStreamTransport
import com.google.home.google.RecordingMode
import com.google.home.google.Time
import com.google.home.google.VoiceStarter
import com.google.home.google.Volume
Expand Down Expand Up @@ -153,6 +154,7 @@ object HomeModule {
OccupancySensing,
OnOff,
PushAvStreamTransport,
RecordingMode,
TemperatureControl,
TemperatureMeasurement,
Thermostat,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
Expand All @@ -33,14 +34,14 @@ import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.MicOff
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
Expand All @@ -51,7 +52,6 @@ import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold
Expand All @@ -73,6 +73,8 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import com.example.googlehomeapisampleapp.RuntimePermissionsManager
import com.example.googlehomeapisampleapp.camera.timeline.CameraTimeline
import com.example.googlehomeapisampleapp.camera.timeline.CameraTimelineUiState
import com.google.home.google.ChimeTrait

@OptIn(ExperimentalMaterial3Api::class)
Expand All @@ -91,6 +93,10 @@ fun CameraStreamView(
isChimeToggleSupported: Boolean = false,
isChimeEnabled: Boolean = false,
chimeType: ChimeTrait.ExternalChimeType = ChimeTrait.ExternalChimeType.Electronic,
cameraTimelineUiState: CameraTimelineUiState? = null,
recordingModeOptions: List<RecordingModeOption> = emptyList(),
selectedRecordingModeIndex: Int? = null,
onSetRecordingMode: (Int) -> Unit = {},
onTurnCameraOn: (Boolean) -> Unit = {},
onSetTalkback: (Boolean) -> Unit = {},
onSetAudioRecording: (Boolean) -> Unit = {},
Expand Down Expand Up @@ -137,22 +143,50 @@ fun CameraStreamView(
containerColor = Color.Black
) { _ ->
Column(modifier = Modifier.fillMaxSize()) {
Box(
modifier = Modifier.fillMaxWidth().aspectRatio(4f / 3f),
BoxWithConstraints(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
PunchThroughSurface(
isVisible = isCurrentlyStreaming,
onSurfaceCreated = onSurfaceCreated,
onSurfaceDestroyed = onSurfaceDestroyed,
modifier = Modifier.fillMaxSize()
val screenMaxHeight = maxHeight

Box(
modifier = Modifier
.fillMaxWidth()
.run {
if (cameraTimelineUiState != null) {
// When timeline is present, cap video at 50% of screen height
heightIn(max = screenMaxHeight * 0.5f)
} else {
// When no timeline, use original 4:3 aspect ratio
aspectRatio(4f / 3f)
}
},
contentAlignment = Alignment.Center
) {
PunchThroughSurface(
isVisible = isCurrentlyStreaming,
onSurfaceCreated = onSurfaceCreated,
onSurfaceDestroyed = onSurfaceDestroyed,
modifier = Modifier.fillMaxSize()
)
LiveStreamOverlay(playerState, isCurrentlyStreaming, onRetry)
}
}
if (cameraTimelineUiState != null) {
CameraTimeline(
uiState = cameraTimelineUiState,
modifier = Modifier
.weight(1f)
.fillMaxWidth()
)
LiveStreamOverlay(playerState, isCurrentlyStreaming, onRetry)
}

if (isTalkbackSupported && isCurrentlyStreaming) {
Spacer(modifier = Modifier.height(32.dp))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
MicrophoneOverlay(
isEnabled = isTalkbackEnabled,
onToggle = { requestedEnabled ->
Expand All @@ -167,7 +201,17 @@ fun CameraStreamView(
}

Box(
modifier = Modifier.fillMaxWidth().weight(1f).padding(26.dp),
modifier = Modifier
.fillMaxWidth()
.run {
if (cameraTimelineUiState == null) {
// When no timeline, give FAB its own weighted space
weight(1f).padding(26.dp)
} else {
// When timeline is present, just add padding
padding(26.dp)
}
},
contentAlignment = Alignment.BottomEnd
) {
FloatingActionButton(onClick = { showBottomSheet = true }) {
Expand Down Expand Up @@ -206,6 +250,48 @@ fun CameraStreamView(
}
)

// RECORDING MODE
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
Text(
"Recording Settings",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)

val availableModes = recordingModeOptions.filter { it.available }
val currentMode = recordingModeOptions.firstOrNull { it.index == selectedRecordingModeIndex }
var showRecordingModeMenu by remember { mutableStateOf(false) }

ListItem(
headlineContent = { Text("Recording Mode") },
supportingContent = {
Text("Current: ${currentMode?.readableString ?: "Unknown"}")
},
modifier = Modifier.clickable(enabled = availableModes.isNotEmpty()) {
showRecordingModeMenu = true
},
trailingContent = {
Box {
Icon(Icons.Default.ChevronRight, null)
DropdownMenu(
expanded = showRecordingModeMenu,
onDismissRequest = { showRecordingModeMenu = false }
) {
availableModes.forEach { option ->
DropdownMenuItem(
text = { Text(option.readableString) },
onClick = {
onSetRecordingMode(option.index)
showRecordingModeMenu = false
}
)
}
}
}
}
)

// --- DOORBELL CHIME ---
if (isDoorbell) {
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ import com.example.googlehomeapisampleapp.camera.livestreamplayer.LiveStreamPlay
import com.example.googlehomeapisampleapp.camera.livestreamplayer.LiveStreamPlayerFactory
import com.example.googlehomeapisampleapp.camera.livestreamplayer.OnOffController
import com.example.googlehomeapisampleapp.camera.livestreamplayer.OnOffControllerFactory
import com.example.googlehomeapisampleapp.camera.timeline.CameraTimelinePresenter
import com.example.googlehomeapisampleapp.camera.timeline.CameraTimelineUiState
import com.example.googlehomeapisampleapp.doorbell.DoorbellChimeController
import com.example.googlehomeapisampleapp.doorbell.DoorbellChimeControllerFactory
import com.google.home.HomeDevice
Expand All @@ -55,6 +57,7 @@ import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
Expand All @@ -70,6 +73,8 @@ open class CameraStreamViewModel @Inject internal constructor(
private val onOffControllerFactory: OnOffControllerFactory,
private val cameraAvStreamManagementControllerFactory: CameraAvStreamManagementControllerFactory,
private val doorbellChimeControllerFactory: DoorbellChimeControllerFactory,
private val recordingModeControllerFactory: RecordingModeControllerFactory,
private val cameraTimelinePresenter: CameraTimelinePresenter,
) : ViewModel() {
private val TAG = "CameraStreamViewModel"
private val TOGGLE_WAIT_TIME = 4000L
Expand Down Expand Up @@ -143,6 +148,28 @@ open class CameraStreamViewModel @Inject internal constructor(
.flatMapLatest { it?.externalChimeType ?: flowOf(ChimeTrait.ExternalChimeType.Electronic) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), ChimeTrait.ExternalChimeType.Electronic)

// Recording mode controller
private val _recordingModeController = MutableStateFlow<RecordingModeController?>(null)

/**
* Emits the full list of recording mode options for this device, each
* annotated with its availability and a human-readable label.
*/
@OptIn(ExperimentalCoroutinesApi::class)
val recordingModeOptions: StateFlow<List<RecordingModeOption>> =
_recordingModeController
.flatMapLatest { it?.recordingModeOptions ?: flowOf(emptyList()) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList())

/**
* Emits the index of the currently active recording mode, or null if unavailable.
*/
@OptIn(ExperimentalCoroutinesApi::class)
val selectedRecordingModeIndex: StateFlow<Int?> =
_recordingModeController
.flatMapLatest { it?.selectedRecordingModeIndex ?: flowOf(null) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null)

// --- UI State Flows ---
@OptIn(ExperimentalCoroutinesApi::class)
val isRecording: StateFlow<Boolean> = _onOffController
Expand Down Expand Up @@ -209,6 +236,24 @@ open class CameraStreamViewModel @Inject internal constructor(

private var surface: Surface? = null

@OptIn(ExperimentalCoroutinesApi::class)
val cameraTimelineUiState: StateFlow<CameraTimelineUiState?> =
flow {
emit(deviceDeferred.await())
}
.flatMapLatest { device ->
Log.d(TAG, "Timeline: Starting presenter for device ${device.id.id}")
cameraTimelinePresenter.present(
deviceId = device.id.id,
initialTimestamp = null,
)
}
.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5000),
null
)

// --- Commands ---

fun setRecording(enabled: Boolean) {
Expand Down Expand Up @@ -301,6 +346,18 @@ open class CameraStreamViewModel @Inject internal constructor(
}
}

/**
* Updates the camera's recording mode (CVR, EBR, ETR, Still Images, Live View, Disabled).
*
* @param index The index of the selected mode from [recordingModeOptions].
*/
fun setRecordingMode(index: Int) {
val controller = _recordingModeController.value ?: return
viewModelScope.launch {
controller.setRecordingMode(index)
}
}

@OptIn(ExperimentalCoroutinesApi::class)
fun setDevice(device: HomeDevice) {
val currentDevice = if (deviceDeferred.isCompleted) deviceDeferred.getCompleted() else null
Expand Down Expand Up @@ -351,6 +408,7 @@ open class CameraStreamViewModel @Inject internal constructor(
val controller = onOffControllerFactory.create(device)
_onOffController.value = controller
_cameraAvStreamManagementController.value = cameraAvStreamManagementControllerFactory.create(device)
_recordingModeController.value = recordingModeControllerFactory.create(device)

val player = liveStreamPlayerFactory.createPlayerFromDevice(device, viewModelScope, micGranted)
_liveStreamPlayer.value = player
Expand Down
Loading