diff --git a/wear/src/main/java/com/theveloper/pixelplay/data/WearLifecycleState.kt b/wear/src/main/java/com/theveloper/pixelplay/data/WearLifecycleState.kt new file mode 100644 index 000000000..8d7d7b41d --- /dev/null +++ b/wear/src/main/java/com/theveloper/pixelplay/data/WearLifecycleState.kt @@ -0,0 +1,45 @@ +package com.theveloper.pixelplay.data + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged + +/** + * Process-wide signal for whether the watch UI is actively interactive. + * + * Polling loops, position-update jobs and continuous animations should gate on + * [isInteractive] so they pause as soon as the activity moves to the background + * or the watch enters ambient (always-on display) mode. Wear devices have very + * small batteries, so any work that keeps the CPU/GPU/radio awake while the + * user is not actually looking at the screen translates directly into drain. + */ +object WearLifecycleState { + private val _isForeground = MutableStateFlow(false) + val isForeground: StateFlow = _isForeground.asStateFlow() + + private val _isAmbient = MutableStateFlow(false) + val isAmbient: StateFlow = _isAmbient.asStateFlow() + + /** + * True when the activity is foreground AND the watch is not in ambient mode. + * This is the right signal for "should we keep the CPU busy?". + */ + val isInteractive: Flow = combine(_isForeground, _isAmbient) { fg, ambient -> + fg && !ambient + }.distinctUntilChanged() + + /** Snapshot read for non-suspending call sites. */ + val isInteractiveNow: Boolean + get() = _isForeground.value && !_isAmbient.value + + fun setForeground(value: Boolean) { + _isForeground.value = value + } + + fun setAmbient(value: Boolean) { + _isAmbient.value = value + } +} diff --git a/wear/src/main/java/com/theveloper/pixelplay/data/WearLocalPlayerRepository.kt b/wear/src/main/java/com/theveloper/pixelplay/data/WearLocalPlayerRepository.kt index f57927d27..f7a92f566 100644 --- a/wear/src/main/java/com/theveloper/pixelplay/data/WearLocalPlayerRepository.kt +++ b/wear/src/main/java/com/theveloper/pixelplay/data/WearLocalPlayerRepository.kt @@ -497,8 +497,15 @@ class WearLocalPlayerRepository @Inject constructor( positionUpdateJob?.cancel() positionUpdateJob = scope.launch { while (isActive) { + // Skip the StateFlow churn when the user can't see the UI: the + // ExoPlayer keeps tracking position internally, we just don't + // need to repaint composables. We still wake every second to + // notice the screen turning back on quickly, but we don't run + // the (allocating) updateState() pipeline. + if (WearLifecycleState.isInteractiveNow) { + updateState() + } delay(POSITION_UPDATE_INTERVAL_MS) - updateState() } } } diff --git a/wear/src/main/java/com/theveloper/pixelplay/presentation/WearMainActivity.kt b/wear/src/main/java/com/theveloper/pixelplay/presentation/WearMainActivity.kt index 3f0add5fa..8f60b8dce 100644 --- a/wear/src/main/java/com/theveloper/pixelplay/presentation/WearMainActivity.kt +++ b/wear/src/main/java/com/theveloper/pixelplay/presentation/WearMainActivity.kt @@ -7,6 +7,7 @@ import androidx.compose.runtime.collectAsState import androidx.fragment.app.FragmentActivity import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.wear.ambient.AmbientLifecycleObserver +import com.theveloper.pixelplay.data.WearLifecycleState import com.theveloper.pixelplay.presentation.theme.WearPixelPlayTheme import com.theveloper.pixelplay.presentation.viewmodel.WearPlayerViewModel import dagger.hilt.android.AndroidEntryPoint @@ -15,16 +16,23 @@ import dagger.hilt.android.AndroidEntryPoint class WearMainActivity : FragmentActivity() { companion object { - @Volatile - var isForeground: Boolean = false - private set + val isForeground: Boolean + get() = WearLifecycleState.isForeground.value } - private val ambientCallback = object : AmbientLifecycleObserver.AmbientLifecycleCallback {} + private val ambientCallback = object : AmbientLifecycleObserver.AmbientLifecycleCallback { + override fun onEnterAmbient(ambientDetails: AmbientLifecycleObserver.AmbientDetails) { + WearLifecycleState.setAmbient(true) + } + + override fun onExitAmbient() { + WearLifecycleState.setAmbient(false) + } + } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - + AmbientLifecycleObserver(this, ambientCallback).also { lifecycle.addObserver(it) } @@ -47,11 +55,11 @@ class WearMainActivity : FragmentActivity() { override fun onStart() { super.onStart() - isForeground = true + WearLifecycleState.setForeground(true) } override fun onStop() { - isForeground = false + WearLifecycleState.setForeground(false) super.onStop() } } diff --git a/wear/src/main/java/com/theveloper/pixelplay/presentation/components/PlayingEqIcon.kt b/wear/src/main/java/com/theveloper/pixelplay/presentation/components/PlayingEqIcon.kt index adcf4d5e9..e43e81b86 100644 --- a/wear/src/main/java/com/theveloper/pixelplay/presentation/components/PlayingEqIcon.kt +++ b/wear/src/main/java/com/theveloper/pixelplay/presentation/components/PlayingEqIcon.kt @@ -8,6 +8,7 @@ import androidx.compose.animation.core.tween import androidx.compose.foundation.Canvas import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier @@ -15,6 +16,7 @@ import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color +import com.theveloper.pixelplay.data.WearLifecycleState import kotlinx.coroutines.isActive import kotlin.math.PI import kotlin.math.sin @@ -34,9 +36,13 @@ fun PlayingEqIcon( val fullRotation = (2f * PI).toFloat() val phaseAnim = remember { Animatable(0f) } val wanderAnim = remember { Animatable(0f) } + val isInteractive by WearLifecycleState.isInteractive.collectAsState( + initial = WearLifecycleState.isInteractiveNow, + ) + val animate = isPlaying && isInteractive - LaunchedEffect(isPlaying, phaseDurationMillis) { - if (!isPlaying) return@LaunchedEffect + LaunchedEffect(animate, phaseDurationMillis) { + if (!animate) return@LaunchedEffect while (isActive) { val start = (phaseAnim.value % fullRotation).let { if (it < 0f) it + fullRotation else it } phaseAnim.snapTo(start) @@ -47,8 +53,8 @@ fun PlayingEqIcon( } } - LaunchedEffect(isPlaying, wanderDurationMillis) { - if (!isPlaying) return@LaunchedEffect + LaunchedEffect(animate, wanderDurationMillis) { + if (!animate) return@LaunchedEffect while (isActive) { val start = (wanderAnim.value % fullRotation).let { if (it < 0f) it + fullRotation else it } wanderAnim.snapTo(start) diff --git a/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/OutputScreen.kt b/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/OutputScreen.kt index 105895faf..ec907c442 100644 --- a/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/OutputScreen.kt +++ b/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/OutputScreen.kt @@ -34,6 +34,7 @@ import androidx.wear.compose.material.Icon import androidx.wear.compose.material.MaterialTheme import androidx.wear.compose.material.Text import com.theveloper.pixelplay.data.WearAudioOutputRoute +import com.theveloper.pixelplay.data.WearLifecycleState import com.theveloper.pixelplay.R import com.google.android.horologist.compose.layout.ScalingLazyColumn import com.google.android.horologist.compose.layout.rememberResponsiveColumnState @@ -74,10 +75,16 @@ fun OutputScreen( viewModel.setWatchRouteDiscoveryEnabled(false) } } + // The MediaRouter callback in WearVolumeRepository pushes route/volume changes + // reactively while discovery is on (DisposableEffect above). This loop is just a + // slow safety net and pauses whenever the screen turns off. LaunchedEffect(viewModel) { - while (true) { - viewModel.refreshWatchAudioState() - delay(700L) + WearLifecycleState.isInteractive.collect { interactive -> + if (!interactive) return@collect + while (WearLifecycleState.isInteractiveNow) { + viewModel.refreshWatchAudioState() + delay(2_500L) + } } } diff --git a/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/PlayerScreen.kt b/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/PlayerScreen.kt index 3a39ae776..2decd25b3 100644 --- a/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/PlayerScreen.kt +++ b/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/PlayerScreen.kt @@ -111,6 +111,7 @@ import com.google.android.horologist.audio.ui.volumeRotaryBehavior import com.google.android.horologist.compose.layout.ScalingLazyColumn import com.google.android.horologist.compose.layout.rememberResponsiveColumnState import com.theveloper.pixelplay.R +import com.theveloper.pixelplay.data.WearLifecycleState import com.theveloper.pixelplay.presentation.components.AlwaysOnScalingPositionIndicator import com.theveloper.pixelplay.presentation.components.CurvedVolumeIndicator import com.theveloper.pixelplay.presentation.components.outputRouteIcon @@ -187,7 +188,10 @@ private fun PlayerContent( onQueueClick: () -> Unit, ) { val palette = LocalWearPalette.current - val background = palette.radialBackgroundBrush() + // Memoize: radialGradient allocates Shader inputs on every call. PlayerContent + // recomposes whenever the play-button ring animation ticks, so without this + // we'd churn the GC for nothing. + val background = remember(palette) { palette.radialBackgroundBrush() } val pagerState = rememberPagerState(initialPage = 0, pageCount = { 2 }) var mainPageQueueReveal by remember { mutableFloatStateOf(0f) } @@ -508,10 +512,11 @@ private fun AlbumArtPage( modifier = Modifier.fillMaxSize(), ) } else { + val fallbackBrush = remember(palette) { palette.radialBackgroundBrush() } Box( modifier = Modifier .fillMaxSize() - .background(palette.radialBackgroundBrush()), + .background(fallbackBrush), ) } @@ -622,8 +627,11 @@ private fun LargeAlbumClockText( val displayTime by produceState(initialValue = "--:--") { val formatter = DateTimeFormatter.ofPattern("H:mm") while (true) { - value = LocalTime.now().format(formatter) - delay(1000L) + val now = LocalTime.now() + value = now.format(formatter) + // Sleep until the next minute boundary so we never recompose mid-minute. + val secondsToNextMinute = (60 - now.second).coerceAtLeast(1) + delay(secondsToNextMinute * 1000L) } } val gSansFlex = remember { @@ -1407,20 +1415,22 @@ private fun CenterPlayButton( label = "playStarCurve", ) val rotation = remember { Animatable(0f) } - LaunchedEffect(isPlaying) { - if (isPlaying) { - while (true) { - val current = rotation.value - rotation.animateTo( - targetValue = current + 360f, - animationSpec = tween( - durationMillis = 13800, - easing = LinearEasing, - ), - ) - if (rotation.value >= 3600f) { - rotation.snapTo(rotation.value % 360f) - } + val isInteractive by WearLifecycleState.isInteractive.collectAsState( + initial = WearLifecycleState.isInteractiveNow, + ) + LaunchedEffect(isPlaying, isInteractive) { + if (!isPlaying || !isInteractive) return@LaunchedEffect + while (true) { + val current = rotation.value + rotation.animateTo( + targetValue = current + 360f, + animationSpec = tween( + durationMillis = 13800, + easing = LinearEasing, + ), + ) + if (rotation.value >= 3600f) { + rotation.snapTo(rotation.value % 360f) } } } diff --git a/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/VolumeScreen.kt b/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/VolumeScreen.kt index b4aae4111..b84f6388f 100644 --- a/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/VolumeScreen.kt +++ b/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/VolumeScreen.kt @@ -26,6 +26,7 @@ import androidx.compose.material.icons.automirrored.rounded.VolumeUp import androidx.compose.material.icons.rounded.Add import androidx.compose.material.icons.rounded.Remove import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -49,6 +50,7 @@ import androidx.wear.compose.material.MaterialTheme import androidx.wear.compose.material.Text import com.google.android.horologist.audio.ui.VolumeUiState import com.google.android.horologist.audio.ui.volumeRotaryBehavior +import com.theveloper.pixelplay.data.WearLifecycleState import com.theveloper.pixelplay.presentation.components.CurvedVolumeIndicator import com.theveloper.pixelplay.presentation.components.WearTopTimeText import com.theveloper.pixelplay.presentation.theme.LocalWearPalette @@ -67,10 +69,23 @@ fun VolumeScreen( val volumePercent by viewModel.activeVolumePercent.collectAsState() val activeDeviceName by viewModel.activeVolumeDeviceName.collectAsState() - LaunchedEffect(Unit) { - while (true) { - viewModel.refreshActiveVolumeState() - delay(350L) + // Enable MediaRouter discovery while this screen is visible so the + // route-callback path in WearVolumeRepository pushes updates reactively. + DisposableEffect(viewModel) { + viewModel.setWatchRouteDiscoveryEnabled(true) + viewModel.refreshActiveVolumeState() + onDispose { viewModel.setWatchRouteDiscoveryEnabled(false) } + } + // Slow safety-net poll for volume changes that the route callback might miss + // (e.g. system-wide hard-key presses on some Wear builds). 1.5s is plenty + // for a UI knob; it pauses immediately when the screen turns off. + LaunchedEffect(viewModel) { + WearLifecycleState.isInteractive.collect { interactive -> + if (!interactive) return@collect + while (WearLifecycleState.isInteractiveNow) { + viewModel.refreshActiveVolumeState() + delay(1_500L) + } } } val progressTarget = if (volumeState.max > 0) { diff --git a/wear/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/WearPlayerViewModel.kt b/wear/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/WearPlayerViewModel.kt index 8b8f5079b..3a6038e81 100644 --- a/wear/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/WearPlayerViewModel.kt +++ b/wear/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/WearPlayerViewModel.kt @@ -6,6 +6,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.theveloper.pixelplay.data.WearAudioOutputRoute import com.theveloper.pixelplay.data.WearFavoriteSyncRepository +import com.theveloper.pixelplay.data.WearLifecycleState import com.theveloper.pixelplay.data.WearLocalQueueState import com.theveloper.pixelplay.data.WearLocalPlayerRepository import com.theveloper.pixelplay.data.WearOutputTarget @@ -52,7 +53,11 @@ class WearPlayerViewModel @Inject constructor( companion object { private const val PHONE_SYNC_BOOTSTRAP_ATTEMPTS = 3 private const val PHONE_SYNC_BOOTSTRAP_RETRY_DELAY_MS = 1200L - private const val PHONE_ROUTE_REFRESH_INTERVAL_MS = 5000L + // The route callback already pushes updates reactively; this is a slow safety + // net for cases where a system event was missed (e.g. headset just paired). + // Wear batteries are tiny, so we keep it infrequent and pause it whenever the + // screen is off / ambient. + private const val PHONE_ROUTE_REFRESH_INTERVAL_MS = 30_000L } private val _sleepTimerUiState = MutableStateFlow(WearSleepTimerUiState()) @@ -201,13 +206,16 @@ class WearPlayerViewModel @Inject constructor( } } viewModelScope.launch { - while (true) { - if (isWatchOutputSelected.value) { - volumeRepository.refreshWatchVolumeState() - } else if (isPhoneConnected.value) { - playbackController.requestPhoneVolumeState() + WearLifecycleState.isInteractive.collect { interactive -> + if (!interactive) return@collect + while (WearLifecycleState.isInteractiveNow) { + if (isWatchOutputSelected.value) { + volumeRepository.refreshWatchVolumeState() + } else if (isPhoneConnected.value) { + playbackController.requestPhoneVolumeState() + } + delay(PHONE_ROUTE_REFRESH_INTERVAL_MS) } - delay(PHONE_ROUTE_REFRESH_INTERVAL_MS) } } viewModelScope.launch {