Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -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<Boolean> = _isForeground.asStateFlow()

private val _isAmbient = MutableStateFlow(false)
val isAmbient: StateFlow<Boolean> = _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<Boolean> = 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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
}
Expand All @@ -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()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@ 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
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
Expand All @@ -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)
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) }
Expand Down Expand Up @@ -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),
)
}

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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 {
Expand Down
Loading