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
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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<Boolean> =
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<Boolean> =
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<Boolean> =
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<AdvancedPerformanceDiagnosticsSettings> =
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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() {

Expand Down Expand Up @@ -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) }
Expand Down Expand Up @@ -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)
Expand Down
12 changes: 12 additions & 0 deletions app/src/main/res/values/strings_settings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@
<string name="settings_category_accounts_subtitle">Manage Telegram, Google Drive, NetEase, and more services</string>
<string name="settings_category_about_title">About</string>
<string name="settings_category_about_subtitle">App info, version, and credits</string>
<string name="settings_category_wear_title">Watch</string>
<string name="settings_category_wear_subtitle">Performance options for offline playback on your watch</string>

<!-- Common toggles / labels used in ThemeSelectorItem maps -->
<string name="settings_label_on">On</string>
Expand Down Expand Up @@ -655,4 +657,14 @@
<string name="settings_volume_section">Volume</string>
<string name="settings_pause_on_volume_zero">Pause when volume reaches zero</string>
<string name="settings_pause_on_volume_zero_desc">Automatically pause playback when the volume is set to 0</string>

<!-- Watch category -->
<string name="settings_wear_performance_section">Performance</string>
<string name="settings_wear_performance_scope_note">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.</string>
<string name="settings_wear_show_album_art_title">Show album art</string>
<string name="settings_wear_show_album_art_subtitle">Turn off to save memory on the watch — biggest impact of the three</string>
<string name="settings_wear_dynamic_color_title">Dynamic color</string>
<string name="settings_wear_dynamic_color_subtitle">Tint the watch UI from each song\'s artwork</string>
<string name="settings_wear_play_button_animation_title">Play button animation</string>
<string name="settings_wear_play_button_animation_subtitle">Continuous spinning ring around the play button while music plays</string>
</resources>
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Loading