diff --git a/app/src/main/java/com/theveloper/pixelplay/data/preferences/UserPreferencesRepository.kt b/app/src/main/java/com/theveloper/pixelplay/data/preferences/UserPreferencesRepository.kt index 4598f1a86..9dbcf996f 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/preferences/UserPreferencesRepository.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/preferences/UserPreferencesRepository.kt @@ -219,6 +219,11 @@ class UserPreferencesRepository @Inject constructor( longPreferencesKey("advanced_performance_diagnostics_started_at_epoch_ms") val ADVANCED_PERFORMANCE_DIAGNOSTICS_EXPIRES_AT = longPreferencesKey("advanced_performance_diagnostics_expires_at_epoch_ms") + // Wear OS performance toggles (synced to the watch via WearPerformanceSettingsPublisher) + val WEAR_SHOW_ALBUM_ART = booleanPreferencesKey("wear_show_album_art") + val WEAR_DYNAMIC_COLOR_THEMING = booleanPreferencesKey("wear_dynamic_color_theming") + val WEAR_PLAY_BUTTON_ANIMATION = booleanPreferencesKey("wear_play_button_animation") + val IMMERSIVE_LYRICS_ENABLED = booleanPreferencesKey("immersive_lyrics_enabled") val IMMERSIVE_LYRICS_TIMEOUT = longPreferencesKey("immersive_lyrics_timeout") val USE_ANIMATED_LYRICS = booleanPreferencesKey("use_animated_lyrics") @@ -1244,6 +1249,32 @@ suspend fun markDirectoryRulesVersionApplied(version: Int) { dataStore.edit { it[PreferencesKeys.HAPTICS_ENABLED] = enabled } } + // ─── Wear OS performance toggles ──────────────────────────────────────────── + // Only take effect during standalone local playback on the watch (no phone connected) — + // remote-controller mode never decodes anything heavy on the watch, so there's nothing to + // save there. All three default to true, preserving today's behavior. + + val wearShowAlbumArtFlow: Flow = + pref { it[PreferencesKeys.WEAR_SHOW_ALBUM_ART] ?: true } + + suspend fun setWearShowAlbumArt(enabled: Boolean) { + dataStore.edit { it[PreferencesKeys.WEAR_SHOW_ALBUM_ART] = enabled } + } + + val wearDynamicColorThemingFlow: Flow = + pref { it[PreferencesKeys.WEAR_DYNAMIC_COLOR_THEMING] ?: true } + + suspend fun setWearDynamicColorTheming(enabled: Boolean) { + dataStore.edit { it[PreferencesKeys.WEAR_DYNAMIC_COLOR_THEMING] = enabled } + } + + val wearPlayButtonAnimationFlow: Flow = + pref { it[PreferencesKeys.WEAR_PLAY_BUTTON_ANIMATION] ?: true } + + suspend fun setWearPlayButtonAnimation(enabled: Boolean) { + dataStore.edit { it[PreferencesKeys.WEAR_PLAY_BUTTON_ANIMATION] = enabled } + } + // ─── Backup / restore ───────────────────────────────────────────────────── val advancedPerformanceDiagnosticsSettingsFlow: Flow = diff --git a/app/src/main/java/com/theveloper/pixelplay/data/service/wear/WearPerformanceSettingsPublisher.kt b/app/src/main/java/com/theveloper/pixelplay/data/service/wear/WearPerformanceSettingsPublisher.kt new file mode 100644 index 000000000..f94bb5035 --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/wear/WearPerformanceSettingsPublisher.kt @@ -0,0 +1,55 @@ +package com.theveloper.pixelplay.data.service.wear + +import android.app.Application +import com.google.android.gms.wearable.PutDataMapRequest +import com.google.android.gms.wearable.Wearable +import com.theveloper.pixelplay.shared.WearDataPaths +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Publishes the watch performance toggles (Settings -> "Watch") to the Wear Data Layer as a + * DataItem, not a `MessageClient` message — mirrors [WearStatePublisher]'s `PLAYER_STATE` + * publishing. A `MessageClient` send only confirms local hand-off, not that the watch received + * it (the exact bug fixed for playlist sync — see [WearDataPaths.PLAYLIST_SYNC_ACK]'s doc). A + * DataItem instead syncs durably: if the watch is mid-reconnect when this publishes, it still + * gets the update once it's back, with no ack/retry machinery needed on our side for that. + */ +@Singleton +class WearPerformanceSettingsPublisher @Inject constructor( + private val application: Application, +) { + private val dataClient by lazy { Wearable.getDataClient(application) } + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + fun publish(showAlbumArt: Boolean, dynamicColorTheming: Boolean, playButtonAnimation: Boolean) { + scope.launch { + try { + val request = PutDataMapRequest.create(WearDataPaths.WEAR_PERFORMANCE_SETTINGS).apply { + dataMap.putBoolean(WearDataPaths.KEY_SHOW_ALBUM_ART, showAlbumArt) + dataMap.putBoolean(WearDataPaths.KEY_DYNAMIC_COLOR_THEMING, dynamicColorTheming) + dataMap.putBoolean(WearDataPaths.KEY_PLAY_BUTTON_ANIMATION, playButtonAnimation) + }.asPutDataRequest().setUrgent() + + dataClient.putDataItem(request) + Timber.tag(TAG).d( + "Published performance settings: albumArt=%s dynamicColor=%s playButtonAnim=%s", + showAlbumArt, + dynamicColorTheming, + playButtonAnimation, + ) + } catch (e: Exception) { + Timber.tag(TAG).w(e, "Failed to publish performance settings to watch") + } + } + } + + private companion object { + const val TAG = "WearPerfSettingsPub" + } +} diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/model/SettingsCategory.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/model/SettingsCategory.kt index e59c3d7a8..15f9aafa6 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/model/SettingsCategory.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/model/SettingsCategory.kt @@ -73,6 +73,12 @@ enum class SettingsCategory( subtitleRes = R.string.settings_category_device_capabilities_subtitle, icon = Icons.Rounded.DeveloperBoard // Placeholder, maybe Memory or SettingsInputComponent ), + WEAR_OS( + id = "wear_os", + titleRes = R.string.settings_category_wear_title, + subtitleRes = R.string.settings_category_wear_subtitle, + iconRes = R.drawable.rounded_watch_arrow_down_24 + ), ABOUT( id = "about", titleRes = R.string.settings_category_about_title, diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsCategoryScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsCategoryScreen.kt index dd1672dbc..1f56afdf0 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsCategoryScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsCategoryScreen.kt @@ -74,12 +74,15 @@ import androidx.compose.material.icons.outlined.PlayCircle import androidx.compose.material.icons.outlined.Style import androidx.compose.material.icons.outlined.Warning import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.rounded.Animation import androidx.compose.material.icons.rounded.BlurOff import androidx.compose.material.icons.rounded.Check import androidx.compose.material.icons.rounded.ChevronRight import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.ColorLens import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.ExpandMore +import androidx.compose.material.icons.rounded.Image import androidx.compose.material.icons.rounded.MusicNote import androidx.compose.material.icons.rounded.Restore import androidx.compose.material.icons.rounded.Science @@ -915,6 +918,44 @@ fun SettingsCategoryScreen( ) } } + SettingsCategory.WEAR_OS -> { + // Re-announce the current values on entry — covers a freshly paired + // or reinstalled watch that's never received a DataItem sync yet. + LaunchedEffect(Unit) { + settingsViewModel.publishWearPerformanceSettings() + } + SettingsSubsection( + title = stringResource(R.string.settings_wear_performance_section) + ) { + Text( + text = stringResource(R.string.settings_wear_performance_scope_note), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp) + ) + SwitchSettingItem( + title = stringResource(R.string.settings_wear_show_album_art_title), + subtitle = stringResource(R.string.settings_wear_show_album_art_subtitle), + checked = uiState.wearShowAlbumArt, + onCheckedChange = { settingsViewModel.setWearShowAlbumArt(it) }, + leadingIcon = { Icon(Icons.Rounded.Image, null, tint = MaterialTheme.colorScheme.secondary) } + ) + SwitchSettingItem( + title = stringResource(R.string.settings_wear_dynamic_color_title), + subtitle = stringResource(R.string.settings_wear_dynamic_color_subtitle), + checked = uiState.wearDynamicColorTheming, + onCheckedChange = { settingsViewModel.setWearDynamicColorTheming(it) }, + leadingIcon = { Icon(Icons.Rounded.ColorLens, null, tint = MaterialTheme.colorScheme.secondary) } + ) + SwitchSettingItem( + title = stringResource(R.string.settings_wear_play_button_animation_title), + subtitle = stringResource(R.string.settings_wear_play_button_animation_subtitle), + checked = uiState.wearPlayButtonAnimation, + onCheckedChange = { settingsViewModel.setWearPlayButtonAnimation(it) }, + leadingIcon = { Icon(Icons.Rounded.Animation, null, tint = MaterialTheme.colorScheme.secondary) } + ) + } + } SettingsCategory.AI_INTEGRATION -> { val provider = com.theveloper.pixelplay.data.ai.provider.AiProvider.fromString(aiProvider) val currentCustomBaseUrl by settingsViewModel.customBaseUrl.collectAsStateWithLifecycle() diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsScreen.kt index fa40ecb26..27e6817f5 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SettingsScreen.kt @@ -487,7 +487,8 @@ private fun getCategoryColors(category: SettingsCategory, isDark: Boolean): Pair SettingsCategory.DEVELOPER -> Color(0xFF324F34) to Color(0xFFCBEFD0) SettingsCategory.EQUALIZER -> Color(0xFF6E4E13) to Color(0xFFFFDEAC) SettingsCategory.DEVICE_CAPABILITIES -> Color(0xFF004D61) to Color(0xFFACEFEE) // Custom teal/cyan mix - SettingsCategory.ABOUT -> Color(0xFF3F474D) to Color(0xFFDEE3EB) + SettingsCategory.WEAR_OS -> Color(0xFF3B4869) to Color(0xFFD9E2FF) // Same family as BACKUP_RESTORE + SettingsCategory.ABOUT -> Color(0xFF3F474D) to Color(0xFFDEE3EB) } } else { when (category) { @@ -500,6 +501,7 @@ private fun getCategoryColors(category: SettingsCategory, isDark: Boolean): Pair SettingsCategory.DEVELOPER -> Color(0xFFCBEFD0) to Color(0xFF042106) SettingsCategory.EQUALIZER -> Color(0xFFFFDEAC) to Color(0xFF281900) SettingsCategory.DEVICE_CAPABILITIES -> Color(0xFFACEFEE) to Color(0xFF002022) + SettingsCategory.WEAR_OS -> Color(0xFFD9E2FF) to Color(0xFF27304E) SettingsCategory.ABOUT -> Color(0xFFEFF1F7) to Color(0xFF44474F) } } diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/SettingsViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/SettingsViewModel.kt index abba7eace..4b7d06f8a 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/SettingsViewModel.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/SettingsViewModel.kt @@ -111,7 +111,11 @@ data class SettingsUiState( val replayGainEnabled: Boolean = false, val replayGainUseAlbumGain: Boolean = false, val isSafeTokenLimitEnabled: Boolean = true, - val showScrollbar: Boolean = true + val showScrollbar: Boolean = true, + // Wear OS performance toggles — only apply during standalone local playback on the watch. + val wearShowAlbumArt: Boolean = true, + val wearDynamicColorTheming: Boolean = true, + val wearPlayButtonAnimation: Boolean = true, ) data class FailedSongInfo( @@ -190,6 +194,7 @@ class SettingsViewModel @Inject constructor( private val lyricsRepository: LyricsRepository, private val musicRepository: MusicRepository, private val backupManager: BackupManager, + private val wearPerformanceSettingsPublisher: com.theveloper.pixelplay.data.service.wear.WearPerformanceSettingsPublisher, @ApplicationContext private val context: Context ) : ViewModel() { @@ -767,6 +772,24 @@ class SettingsViewModel @Inject constructor( } } + viewModelScope.launch { + userPreferencesRepository.wearShowAlbumArtFlow.collect { enabled -> + _uiState.update { it.copy(wearShowAlbumArt = enabled) } + } + } + + viewModelScope.launch { + userPreferencesRepository.wearDynamicColorThemingFlow.collect { enabled -> + _uiState.update { it.copy(wearDynamicColorTheming = enabled) } + } + } + + viewModelScope.launch { + userPreferencesRepository.wearPlayButtonAnimationFlow.collect { enabled -> + _uiState.update { it.copy(wearPlayButtonAnimation = enabled) } + } + } + viewModelScope.launch { userPreferencesRepository.minSongDurationFlow.collect { duration -> _uiState.update { it.copy(minSongDuration = duration) } @@ -1370,6 +1393,56 @@ class SettingsViewModel @Inject constructor( } } + fun setWearShowAlbumArt(enabled: Boolean) { + viewModelScope.launch { + userPreferencesRepository.setWearShowAlbumArt(enabled) + // Use the value we just wrote directly rather than re-reading uiState.value: the + // reactive collector that updates uiState from this same DataStore write hasn't + // necessarily run yet at this point, so uiState.value could still be stale. + wearPerformanceSettingsPublisher.publish( + showAlbumArt = enabled, + dynamicColorTheming = uiState.value.wearDynamicColorTheming, + playButtonAnimation = uiState.value.wearPlayButtonAnimation, + ) + } + } + + fun setWearDynamicColorTheming(enabled: Boolean) { + viewModelScope.launch { + userPreferencesRepository.setWearDynamicColorTheming(enabled) + wearPerformanceSettingsPublisher.publish( + showAlbumArt = uiState.value.wearShowAlbumArt, + dynamicColorTheming = enabled, + playButtonAnimation = uiState.value.wearPlayButtonAnimation, + ) + } + } + + fun setWearPlayButtonAnimation(enabled: Boolean) { + viewModelScope.launch { + userPreferencesRepository.setWearPlayButtonAnimation(enabled) + wearPerformanceSettingsPublisher.publish( + showAlbumArt = uiState.value.wearShowAlbumArt, + dynamicColorTheming = uiState.value.wearDynamicColorTheming, + playButtonAnimation = enabled, + ) + } + } + + /** + * Re-announces the current (already-persisted) values without changing anything — called once + * when the "Watch" settings screen opens, so a watch that's freshly paired or reinstalled gets + * them without the user needing to toggle something first (DataItem sync only reaches nodes + * once something has actually been `putDataItem`'d at least once). + */ + fun publishWearPerformanceSettings() { + wearPerformanceSettingsPublisher.publish( + showAlbumArt = uiState.value.wearShowAlbumArt, + dynamicColorTheming = uiState.value.wearDynamicColorTheming, + playButtonAnimation = uiState.value.wearPlayButtonAnimation, + ) + } + fun setBackupInfoDismissed(dismissed: Boolean) { viewModelScope.launch { userPreferencesRepository.setBackupInfoDismissed(dismissed) diff --git a/app/src/main/res/values/strings_settings.xml b/app/src/main/res/values/strings_settings.xml index 1c6ae4e28..344e79d15 100644 --- a/app/src/main/res/values/strings_settings.xml +++ b/app/src/main/res/values/strings_settings.xml @@ -23,6 +23,8 @@ Manage Telegram, Google Drive, NetEase, and more services About App info, version, and credits + Watch + Performance options for offline playback on your watch On @@ -655,4 +657,14 @@ Volume Pause when volume reaches zero Automatically pause playback when the volume is set to 0 + + + Performance + These only apply when your watch plays music on its own, without the phone connected. Controlling phone playback from the watch always shows the full experience. + Show album art + Turn off to save memory on the watch — biggest impact of the three + Dynamic color + Tint the watch UI from each song\'s artwork + Play button animation + Continuous spinning ring around the play button while music plays diff --git a/shared/src/main/java/com/theveloper/pixelplay/shared/WearDataPaths.kt b/shared/src/main/java/com/theveloper/pixelplay/shared/WearDataPaths.kt index b45678a3c..c601d7231 100644 --- a/shared/src/main/java/com/theveloper/pixelplay/shared/WearDataPaths.kt +++ b/shared/src/main/java/com/theveloper/pixelplay/shared/WearDataPaths.kt @@ -74,4 +74,19 @@ object WearDataPaths { * only means local hand-off, not delivery. */ const val PLAYLIST_SYNC_ACK = "/playlist_sync_ack" + + /** + * DataItem path for the watch performance toggles configured from the phone's Settings -> + * "Reloj" screen (phone -> watch). DataItem, not MessageClient: these need to durably reach + * the watch even if it's mid-reconnect when the phone publishes, same reasoning as + * [PLAYER_STATE] — see [PLAYLIST_SYNC]'s ack for what happens when a phone->watch send is + * only best-effort instead. + */ + const val WEAR_PERFORMANCE_SETTINGS = "/wear_performance_settings" + + /** DataMap keys within [WEAR_PERFORMANCE_SETTINGS]. All three default to `true` on the watch + * if never synced, preserving today's behavior. */ + const val KEY_SHOW_ALBUM_ART = "show_album_art" + const val KEY_DYNAMIC_COLOR_THEMING = "dynamic_color_theming" + const val KEY_PLAY_BUTTON_ANIMATION = "play_button_animation" } diff --git a/wear/src/main/java/com/theveloper/pixelplay/data/WearDataListenerService.kt b/wear/src/main/java/com/theveloper/pixelplay/data/WearDataListenerService.kt index d4bb874b6..426cad4a5 100644 --- a/wear/src/main/java/com/theveloper/pixelplay/data/WearDataListenerService.kt +++ b/wear/src/main/java/com/theveloper/pixelplay/data/WearDataListenerService.kt @@ -59,6 +59,9 @@ class WearDataListenerService : WearableListenerService() { @Inject lateinit var favoriteSyncRepository: WearFavoriteSyncRepository + @Inject + lateinit var performanceSettingsRepository: WearPerformanceSettingsRepository + private val json = Json { ignoreUnknownKeys = true } private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) @@ -85,14 +88,32 @@ class WearDataListenerService : WearableListenerService() { val dataItem = event.dataItem Timber.tag(TAG).d("Data event path=%s", dataItem.uri.path) - if (dataItem.uri.path == WearDataPaths.PLAYER_STATE) { - // Copy DataMap in callback thread; DataEventBuffer is invalid once callback returns. - val dataMap = DataMapItem.fromDataItem(dataItem).dataMap - scope.launch { - try { - processPlayerStateUpdate(dataMap) - } catch (e: Exception) { - Timber.tag(TAG).e(e, "Failed to process player state update") + when (dataItem.uri.path) { + WearDataPaths.PLAYER_STATE -> { + // Copy DataMap in callback thread; DataEventBuffer is invalid once callback returns. + val dataMap = DataMapItem.fromDataItem(dataItem).dataMap + scope.launch { + try { + processPlayerStateUpdate(dataMap) + } catch (e: Exception) { + Timber.tag(TAG).e(e, "Failed to process player state update") + } + } + } + + WearDataPaths.WEAR_PERFORMANCE_SETTINGS -> { + val dataMap = DataMapItem.fromDataItem(dataItem).dataMap + scope.launch { + try { + performanceSettingsRepository.save( + showAlbumArt = dataMap.getBoolean(WearDataPaths.KEY_SHOW_ALBUM_ART, true), + dynamicColorTheming = dataMap.getBoolean(WearDataPaths.KEY_DYNAMIC_COLOR_THEMING, true), + playButtonAnimation = dataMap.getBoolean(WearDataPaths.KEY_PLAY_BUTTON_ANIMATION, true), + ) + Timber.tag(TAG).d("Performance settings synced from phone") + } catch (e: Exception) { + Timber.tag(TAG).e(e, "Failed to process performance settings sync") + } } } } 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 d4b4f4b49..0c00c8fd5 100644 --- a/wear/src/main/java/com/theveloper/pixelplay/data/WearLocalPlayerRepository.kt +++ b/wear/src/main/java/com/theveloper/pixelplay/data/WearLocalPlayerRepository.kt @@ -28,6 +28,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine @@ -82,6 +83,7 @@ class WearLocalPlayerRepository @Inject constructor( private val application: Application, private val localSongDao: LocalSongDao, private val playbackStatePersistence: WearPlaybackStatePersistence, + private val performanceSettings: WearPerformanceSettingsRepository, ) { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) private val json = Json { ignoreUnknownKeys = true } @@ -137,6 +139,21 @@ class WearLocalPlayerRepository @Inject constructor( } } } + + // React to the performance toggles changing while a song is already loaded — e.g. the + // user turns "show album art" off from the phone mid-song: drop the bitmap immediately + // instead of waiting for the next song change, since the whole point is freeing RAM right + // away. drop(1) skips the initial replay so this doesn't fire redundantly at construction. + scope.launch { + performanceSettings.showAlbumArt.drop(1).collect { + updateArtworkForSong(_localPlayerState.value.songId) + } + } + scope.launch { + performanceSettings.dynamicColorTheming.drop(1).collect { + updatePaletteForSong(_localPlayerState.value.songId) + } + } } private val playerListener = object : Player.Listener { @@ -729,7 +746,10 @@ class WearLocalPlayerRepository @Inject constructor( } private fun updatePaletteForSong(songId: String) { - if (songId.isBlank()) { + if (songId.isBlank() || !performanceSettings.dynamicColorTheming.value) { + // Also reset lastPaletteSongId when the toggle is off (not just for a blank songId): + // otherwise re-enabling it later without a song change would leave it pointing at a + // song that's technically "already handled" and skip re-extracting the seed. lastPaletteSongId = "" _localThemePalette.value = null _localPaletteSeedArgb.value = null @@ -792,7 +812,10 @@ class WearLocalPlayerRepository @Inject constructor( } private fun updateArtworkForSong(songId: String) { - if (songId.isBlank()) { + if (songId.isBlank() || !performanceSettings.showAlbumArt.value) { + // Same reasoning as updatePaletteForSong: reset lastArtworkSongId even when the + // toggle (not a blank songId) is why we're bailing, so a later re-enable without a + // song change still triggers a fresh decode instead of being silently skipped. lastArtworkSongId = "" _localAlbumArt.value = null return diff --git a/wear/src/main/java/com/theveloper/pixelplay/data/WearPerformanceSettingsRepository.kt b/wear/src/main/java/com/theveloper/pixelplay/data/WearPerformanceSettingsRepository.kt new file mode 100644 index 000000000..b46c40696 --- /dev/null +++ b/wear/src/main/java/com/theveloper/pixelplay/data/WearPerformanceSettingsRepository.kt @@ -0,0 +1,62 @@ +package com.theveloper.pixelplay.data + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Watch-side durable cache of the performance toggles configured from the phone's Settings -> + * "Reloj" screen. [WearDataListenerService] writes into this whenever a + * `WearDataPaths.WEAR_PERFORMANCE_SETTINGS` DataItem syncs in; [WearLocalPlayerRepository] and the + * player UI read it reactively. Caching locally (rather than querying the phone live) is the + * whole point — these need to apply during standalone local playback, exactly when the phone may + * not be reachable at all. + * + * All three default to `true` — current behavior preserved for a watch that's never received a + * sync (e.g. right after this feature ships, before the user opens the new phone settings screen). + * + * Only meaningful during local playback (`WearOutputTarget.WATCH`) — see call sites for why + * remote-controller mode ignores these entirely. + */ +@Singleton +class WearPerformanceSettingsRepository @Inject constructor( + private val dataStore: DataStore, +) { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + val showAlbumArt: StateFlow = dataStore.data + .map { it[Keys.SHOW_ALBUM_ART] ?: true } + .stateIn(scope, SharingStarted.Eagerly, true) + + val dynamicColorTheming: StateFlow = dataStore.data + .map { it[Keys.DYNAMIC_COLOR_THEMING] ?: true } + .stateIn(scope, SharingStarted.Eagerly, true) + + val playButtonAnimation: StateFlow = dataStore.data + .map { it[Keys.PLAY_BUTTON_ANIMATION] ?: true } + .stateIn(scope, SharingStarted.Eagerly, true) + + suspend fun save(showAlbumArt: Boolean, dynamicColorTheming: Boolean, playButtonAnimation: Boolean) { + dataStore.edit { prefs -> + prefs[Keys.SHOW_ALBUM_ART] = showAlbumArt + prefs[Keys.DYNAMIC_COLOR_THEMING] = dynamicColorTheming + prefs[Keys.PLAY_BUTTON_ANIMATION] = playButtonAnimation + } + } + + private object Keys { + val SHOW_ALBUM_ART = booleanPreferencesKey("wear_perf_show_album_art") + val DYNAMIC_COLOR_THEMING = booleanPreferencesKey("wear_perf_dynamic_color_theming") + val PLAY_BUTTON_ANIMATION = booleanPreferencesKey("wear_perf_play_button_animation") + } +} 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 48a950bb8..9df0f1d1d 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 @@ -155,6 +155,7 @@ fun PlayerScreen( val activeOutputRouteType by viewModel.activeOutputRouteType.collectAsStateWithLifecycle() val activeVolumeState by viewModel.activeVolumeState.collectAsStateWithLifecycle() val albumArt by viewModel.albumArt.collectAsStateWithLifecycle() + val showPlayButtonAnimation by viewModel.showPlayButtonAnimation.collectAsStateWithLifecycle() PlayerContent( state = state, @@ -162,6 +163,7 @@ fun PlayerScreen( isPhoneConnected = isPhoneConnected, isWatchOutputSelected = isWatchOutputSelected, activeVolumeState = activeVolumeState, + showPlayButtonAnimation = showPlayButtonAnimation, onTogglePlayPause = viewModel::togglePlayPause, onNext = viewModel::next, onPrevious = viewModel::previous, @@ -182,6 +184,7 @@ private fun PlayerContent( isPhoneConnected: Boolean, isWatchOutputSelected: Boolean = false, activeVolumeState: WearVolumeState, + showPlayButtonAnimation: Boolean = true, onTogglePlayPause: () -> Unit, onNext: () -> Unit, onPrevious: () -> Unit, @@ -272,6 +275,7 @@ private fun PlayerContent( isPhoneConnected = isPhoneConnected, isWatchOutputSelected = isWatchOutputSelected, activeVolumeState = activeVolumeState, + showPlayButtonAnimation = showPlayButtonAnimation, onTogglePlayPause = onTogglePlayPause, onNext = onNext, onPrevious = onPrevious, @@ -323,6 +327,7 @@ private fun PlayerMainPageHost( isPhoneConnected: Boolean, isWatchOutputSelected: Boolean, activeVolumeState: WearVolumeState, + showPlayButtonAnimation: Boolean, onTogglePlayPause: () -> Unit, onNext: () -> Unit, onPrevious: () -> Unit, @@ -390,6 +395,7 @@ private fun PlayerMainPageHost( isWatchOutputSelected = isWatchOutputSelected, isAmbient = isAmbient, activeVolumeState = activeVolumeState, + showPlayButtonAnimation = showPlayButtonAnimation, onTogglePlayPause = onTogglePlayPause, onNext = onNext, onPrevious = onPrevious, @@ -1052,6 +1058,7 @@ private fun MainPlayerPage( isWatchOutputSelected: Boolean = false, isAmbient: Boolean, activeVolumeState: WearVolumeState, + showPlayButtonAnimation: Boolean = true, onTogglePlayPause: () -> Unit, onNext: () -> Unit, onPrevious: () -> Unit, @@ -1194,6 +1201,7 @@ private fun MainPlayerPage( enabled = if (isWatchOutputSelected) !state.isEmpty else isPhoneConnected, outlined = isAmbient, trackProgress = trackProgress, + showPlayButtonAnimation = showPlayButtonAnimation, onTogglePlayPause = onTogglePlayPause, onNext = onNext, onPrevious = onPrevious, @@ -1658,6 +1666,7 @@ private fun MainControlsRow( enabled: Boolean, outlined: Boolean, trackProgress: Float, + showPlayButtonAnimation: Boolean = true, onTogglePlayPause: () -> Unit, onNext: () -> Unit, onPrevious: () -> Unit, @@ -1686,6 +1695,7 @@ private fun MainControlsRow( enabled = enabled && !isEmpty, outlined = outlined, trackProgress = trackProgress, + showAnimation = showPlayButtonAnimation, onClick = onTogglePlayPause, ) @@ -1762,6 +1772,7 @@ private fun CenterPlayButton( outlined: Boolean, trackProgress: Float, onClick: () -> Unit, + showAnimation: Boolean = true, ) { val palette = LocalWearPalette.current @@ -1774,8 +1785,11 @@ private fun CenterPlayButton( val isInteractive by WearLifecycleState.isInteractive.collectAsStateWithLifecycle( initialValue = WearLifecycleState.isInteractiveNow, ) - LaunchedEffect(isPlaying, isInteractive) { - if (!isPlaying || !isInteractive) { + // showAnimation off is treated exactly like "not interactive": settle back to 0° and stop — + // the ring itself still draws (and still reflects trackProgress), it just stops continuously + // rebuilding its 320-point path every animation frame while spinning. + LaunchedEffect(isPlaying, isInteractive, showAnimation) { + if (!isPlaying || !isInteractive || !showAnimation) { val normalizedRotation = ((rotation.value % 360f) + 360f) % 360f rotation.snapTo(normalizedRotation) rotation.animateTo( diff --git a/wear/src/main/java/com/theveloper/pixelplay/presentation/theme/WearTheme.kt b/wear/src/main/java/com/theveloper/pixelplay/presentation/theme/WearTheme.kt index 9ceb1ddcd..7eecb7624 100644 --- a/wear/src/main/java/com/theveloper/pixelplay/presentation/theme/WearTheme.kt +++ b/wear/src/main/java/com/theveloper/pixelplay/presentation/theme/WearTheme.kt @@ -98,8 +98,13 @@ fun WearPixelPlayTheme( val palette = remember(themePalette, albumArt, seedColorArgb) { when { themePalette != null -> themePalette.toWearPalette() - albumArt != null -> buildPaletteFromAlbumArt(albumArt) + // Prefer the seed over re-deriving one from the full bitmap: WearLocalPlayerRepository + // already extracts this cheaply off the main thread (small downsampled bitmap, + // recycled after use). Falling through to buildPaletteFromAlbumArt here would redo + // that work on the main thread against the full-size bitmap (~576 getPixel() calls) + // for no benefit, every time albumArt is present alongside a seed. seedColorArgb != null -> buildPaletteFromSeedColor(Color(seedColorArgb)) + albumArt != null -> buildPaletteFromAlbumArt(albumArt) else -> DefaultWearPalette } } 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 1d37720b0..b1ef47320 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 @@ -10,6 +10,7 @@ import com.theveloper.pixelplay.data.WearLifecycleState import com.theveloper.pixelplay.data.WearLocalQueueState import com.theveloper.pixelplay.data.WearLocalPlayerRepository import com.theveloper.pixelplay.data.WearOutputTarget +import com.theveloper.pixelplay.data.WearPerformanceSettingsRepository import com.theveloper.pixelplay.data.WearPlaybackController import com.theveloper.pixelplay.data.WearStateRepository import com.theveloper.pixelplay.data.WearTransferRepository @@ -49,6 +50,7 @@ class WearPlayerViewModel @Inject constructor( private val transferRepository: WearTransferRepository, private val volumeRepository: WearVolumeRepository, private val favoriteSyncRepository: WearFavoriteSyncRepository, + private val performanceSettingsRepository: WearPerformanceSettingsRepository, ) : ViewModel() { companion object { private const val PHONE_SYNC_BOOTSTRAP_ATTEMPTS = 3 @@ -129,6 +131,18 @@ class WearPlayerViewModel @Inject constructor( } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) + /** + * Whether the play button's continuous rotation/ring animation should run. Only restricted + * during local playback — remote-controller mode never decodes anything heavy on the watch, + * so there's nothing to save there and the full animation always shows. + */ + val showPlayButtonAnimation: StateFlow = combine( + stateRepository.outputTarget, + performanceSettingsRepository.playButtonAnimation, + ) { target, animationEnabled -> + target != WearOutputTarget.WATCH || animationEnabled + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), true) + val isPhoneConnected: StateFlow = stateRepository.isPhoneConnected val phoneVolumeState: StateFlow = stateRepository.volumeState val watchVolumeState: StateFlow = volumeRepository.watchVolumeState diff --git a/wear/src/test/java/com/theveloper/pixelplay/data/WearPerformanceSettingsRepositoryTest.kt b/wear/src/test/java/com/theveloper/pixelplay/data/WearPerformanceSettingsRepositoryTest.kt new file mode 100644 index 000000000..c5affb3a7 --- /dev/null +++ b/wear/src/test/java/com/theveloper/pixelplay/data/WearPerformanceSettingsRepositoryTest.kt @@ -0,0 +1,111 @@ +package com.theveloper.pixelplay.data + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import java.nio.file.Files +import java.nio.file.Path +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +/** + * The StateFlows here are `stateIn(..., SharingStarted.Eagerly, true)` on the repository's own + * background scope, not the test's — so round-trip assertions use Turbine's `awaitItem()` + * (properly suspends for the async DataStore write to propagate) rather than reading `.value` + * immediately after `save()`, which would race the background collector. + */ +class WearPerformanceSettingsRepositoryTest { + + private lateinit var dataStoreScope: CoroutineScope + private lateinit var tempDir: Path + private lateinit var dataStore: DataStore + private lateinit var repository: WearPerformanceSettingsRepository + + @BeforeEach + fun setUp() { + tempDir = Files.createTempDirectory("wear-performance-settings-test") + dataStoreScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + dataStore = PreferenceDataStoreFactory.create( + scope = dataStoreScope, + produceFile = { tempDir.resolve("settings.preferences_pb").toFile() }, + ) + repository = WearPerformanceSettingsRepository(dataStore) + } + + @AfterEach + fun tearDown() { + dataStoreScope.cancel() + tempDir.toFile().deleteRecursively() + } + + @Test + fun `all three default to true when nothing has been persisted`() = runTest { + // stateIn's initialValue is available synchronously, before any real collection has + // started, so this is safe to read without awaiting anything. + assertThat(repository.showAlbumArt.value).isTrue() + assertThat(repository.dynamicColorTheming.value).isTrue() + assertThat(repository.playButtonAnimation.value).isTrue() + } + + @Test + fun `save propagates false for showAlbumArt through its StateFlow`() = runTest { + repository.showAlbumArt.test { + assertThat(awaitItem()).isTrue() + repository.save(showAlbumArt = false, dynamicColorTheming = true, playButtonAnimation = true) + assertThat(awaitItem()).isFalse() + } + } + + @Test + fun `save propagates false for dynamicColorTheming through its StateFlow`() = runTest { + repository.dynamicColorTheming.test { + assertThat(awaitItem()).isTrue() + repository.save(showAlbumArt = true, dynamicColorTheming = false, playButtonAnimation = true) + assertThat(awaitItem()).isFalse() + } + } + + @Test + fun `save propagates false for playButtonAnimation through its StateFlow`() = runTest { + repository.playButtonAnimation.test { + assertThat(awaitItem()).isTrue() + repository.save(showAlbumArt = true, dynamicColorTheming = true, playButtonAnimation = false) + assertThat(awaitItem()).isFalse() + } + } + + @Test + fun `saving one flag false does not affect the other two`() = runTest { + repository.showAlbumArt.test { + assertThat(awaitItem()).isTrue() + repository.save(showAlbumArt = false, dynamicColorTheming = true, playButtonAnimation = true) + assertThat(awaitItem()).isFalse() + + // The other two flows aren't being collected here, but a second save that changes + // only showAlbumArt back should still round-trip correctly, confirming save() writes + // exactly the three values passed rather than something order-dependent. + repository.save(showAlbumArt = true, dynamicColorTheming = false, playButtonAnimation = false) + assertThat(awaitItem()).isTrue() + } + assertThat(repository.dynamicColorTheming.value).isFalse() + assertThat(repository.playButtonAnimation.value).isFalse() + } + + @Test + fun `a later save overwrites an earlier one, not merges`() = runTest { + repository.save(showAlbumArt = false, dynamicColorTheming = false, playButtonAnimation = false) + repository.save(showAlbumArt = true, dynamicColorTheming = true, playButtonAnimation = true) + + repository.showAlbumArt.test { + assertThat(awaitItem()).isTrue() + } + } +} diff --git a/wear/src/test/java/com/theveloper/pixelplay/data/WearTransferRepositoryPlaylistSyncTest.kt b/wear/src/test/java/com/theveloper/pixelplay/data/WearTransferRepositoryPlaylistSyncTest.kt index 8f0f431f6..3bdc8ffab 100644 --- a/wear/src/test/java/com/theveloper/pixelplay/data/WearTransferRepositoryPlaylistSyncTest.kt +++ b/wear/src/test/java/com/theveloper/pixelplay/data/WearTransferRepositoryPlaylistSyncTest.kt @@ -22,6 +22,7 @@ import io.mockk.just import io.mockk.mockk import io.mockk.slot import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest import kotlinx.serialization.decodeFromString @@ -66,7 +67,17 @@ class WearTransferRepositoryPlaylistSyncTest { coEvery { localPlaylistDao.upsertPlaylist(any(), any()) } just Runs val stateRepository = WearStateRepository() - val localPlayerRepository = WearLocalPlayerRepository(application, localSongDao, mockk()) + val performanceSettingsRepository = mockk { + every { showAlbumArt } returns MutableStateFlow(true) + every { dynamicColorTheming } returns MutableStateFlow(true) + every { playButtonAnimation } returns MutableStateFlow(true) + } + val localPlayerRepository = WearLocalPlayerRepository( + application, + localSongDao, + mockk(), + performanceSettingsRepository, + ) val playbackController = WearPlaybackController(application, stateRepository) repository = WearTransferRepository( diff --git a/wear/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/WearLocalPlaylistViewModelTest.kt b/wear/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/WearLocalPlaylistViewModelTest.kt index 9398441dd..5b78e7eb0 100644 --- a/wear/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/WearLocalPlaylistViewModelTest.kt +++ b/wear/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/WearLocalPlaylistViewModelTest.kt @@ -9,6 +9,7 @@ import com.google.common.truth.Truth.assertThat import com.theveloper.pixelplay.MainCoroutineExtension import com.theveloper.pixelplay.data.WearLocalPlayerRepository import com.theveloper.pixelplay.data.WearOutputTarget +import com.theveloper.pixelplay.data.WearPerformanceSettingsRepository import com.theveloper.pixelplay.data.WearPlaybackController import com.theveloper.pixelplay.data.WearPlaybackStatePersistence import com.theveloper.pixelplay.data.WearStateRepository @@ -99,7 +100,17 @@ class WearLocalPlaylistViewModelTest { coEvery { localSongDao.deleteById(any()) } just Runs stateRepository = WearStateRepository() - val localPlayerRepository = WearLocalPlayerRepository(application, localSongDao, mockk()) + val performanceSettingsRepository = mockk { + every { showAlbumArt } returns MutableStateFlow(true) + every { dynamicColorTheming } returns MutableStateFlow(true) + every { playButtonAnimation } returns MutableStateFlow(true) + } + val localPlayerRepository = WearLocalPlayerRepository( + application, + localSongDao, + mockk(), + performanceSettingsRepository, + ) val playbackController = WearPlaybackController(application, stateRepository) transferRepository = WearTransferRepository( application = application,