From 764a56a228ba743f4001d783dc1ee1631673d2ee Mon Sep 17 00:00:00 2001 From: PonceGL Date: Tue, 11 Aug 2026 18:27:23 -0600 Subject: [PATCH 1/4] feat: hide watch-related UI entirely when no watch has ever been paired MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three watch-related entry points on the phone — the "Send to Watch" playlist menu item, SongInfoBottomSheet's per-song send button, and the new "Watch" Settings category — used to always show, even for someone who's never paired a Wear OS device. The existing reachability check (CapabilityClient FILTER_REACHABLE) already correctly shows these as disabled/"not connected" for a paired-but-currently-unreachable watch; what was missing was a distinct signal for "has the user ever paired a watch with PixelPlay installed at all", to hide the UI entirely in that case instead of showing a permanently-broken option. Added WearPhoneTransferSender.refreshWatchPairingState(), same shape as the existing isPixelPlayWatchAvailable() but using CapabilityClient's FILTER_ALL (every node that's ever advertised the capability, reachable or not) instead of FILTER_REACHABLE. Stored in PhoneWatchTransferStateStore.isAnyWatchPaired, the same shared singleton that already holds reachableWatchNodeIds — same pattern, distinct concept, defaults to false (safer to hide for someone who's never paired anything than to flash the UI on before the first check resolves). Refreshed at three points: app startup (PixelPlayApplication, same dagger.Lazy + best-effort try/catch shape as resumePersistedBatchIfNeeded), inside the existing refreshWatchAvailability() in both PlaylistViewModel and SongInfoBottomSheetViewModel (fires wherever reachability already gets refreshed, no new call sites needed there), and a new LaunchedEffect(Unit) on PlaylistDetailScreen's composition and on the Settings hub — needed so the flag is resolved before the user opens a menu/list that needs to gate on it, not only after they tap something that was already conditionally hidden. Left untouched: the send-to-watch confirmation dialog's existing "not connected" messaging for a paired-but-unreachable watch — already correct, not this change's concern. Test coverage: 3 new PhoneWatchTransferStateStore cases (default false, flips independently of reachableWatchNodeIds). Fixed PlaylistViewModelTest's mock, which needed a stub for the new call. refreshWatchPairingState() itself isn't unit tested — same static-GMS-call constraint as isPixelPlayWatchAvailable() next to it, already undtested for the same reason. Verified :app still builds clean in release (R8 + shrinkResources). --- .../pixelplay/PixelPlayApplication.kt | 16 +++++++++++ .../wear/PhoneWatchTransferStateStore.kt | 11 ++++++++ .../service/wear/WearPhoneTransferSender.kt | 25 +++++++++++++++++ .../components/SongInfoBottomSheet.kt | 9 +++++++ .../screens/PlaylistDetailScreen.kt | 27 ++++++++++++------- .../presentation/screens/SettingsScreen.kt | 7 +++-- .../viewmodel/PlaylistViewModel.kt | 8 ++++++ .../viewmodel/SettingsViewModel.kt | 15 +++++++++++ .../viewmodel/SongInfoBottomSheetViewModel.kt | 7 +++++ .../wear/PhoneWatchTransferStateStoreTest.kt | 24 +++++++++++++++++ .../viewmodel/PlaylistViewModelTest.kt | 1 + 11 files changed, 139 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/PixelPlayApplication.kt b/app/src/main/java/com/theveloper/pixelplay/PixelPlayApplication.kt index 48cf1d8ab4..3be1d8a87d 100644 --- a/app/src/main/java/com/theveloper/pixelplay/PixelPlayApplication.kt +++ b/app/src/main/java/com/theveloper/pixelplay/PixelPlayApplication.kt @@ -76,6 +76,9 @@ class PixelPlayApplication : Application(), ImageLoaderFactory, Configuration.Pr @Inject lateinit var playlistWatchTransferCoordinator: dagger.Lazy + @Inject + lateinit var wearPhoneTransferSender: dagger.Lazy + private val startupScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) // AÑADE EL COMPANION OBJECT @@ -149,6 +152,19 @@ class PixelPlayApplication : Application(), ImageLoaderFactory, Configuration.Pr Timber.w(e, "Failed to resume an interrupted playlist watch transfer") } } + + startupScope.launch { + // Local Play Services call, not a network wait — resolved well before the user could + // navigate to a screen that needs it. Best-effort: watch-related UI just stays hidden + // this session if this fails, rather than crashing startup over it. + try { + wearPhoneTransferSender.get().refreshWatchPairingState() + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { + Timber.w(e, "Failed to check watch pairing state at startup") + } + } } override fun newImageLoader(): ImageLoader { diff --git a/app/src/main/java/com/theveloper/pixelplay/data/service/wear/PhoneWatchTransferStateStore.kt b/app/src/main/java/com/theveloper/pixelplay/data/service/wear/PhoneWatchTransferStateStore.kt index 17da3616b4..3c7e970bb2 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/service/wear/PhoneWatchTransferStateStore.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/wear/PhoneWatchTransferStateStore.kt @@ -77,6 +77,17 @@ class PhoneWatchTransferStateStore @Inject constructor() { private val _watchSongIds = MutableStateFlow>(emptySet()) val watchSongIds: StateFlow> = _watchSongIds.asStateFlow() + // Distinct from reachableWatchNodeIds: "ever paired" (CapabilityClient FILTER_ALL) vs + // "reachable right now" (FILTER_REACHABLE). Defaults to false — safer to hide watch-related + // UI for someone who's never paired a watch than to flash it on before the first check + // resolves. See WearPhoneTransferSender.refreshWatchPairingState(). + private val _isAnyWatchPaired = MutableStateFlow(false) + val isAnyWatchPaired: StateFlow = _isAnyWatchPaired.asStateFlow() + + fun setAnyWatchPaired(paired: Boolean) { + _isAnyWatchPaired.value = paired + } + // Replay a handful rather than 0: the ack can in principle arrive and be emitted before // PlaylistWatchTransferCoordinator starts collecting for it (right after messageClient's own // send call returns), and a plain event stream with no replay would silently drop it in that diff --git a/app/src/main/java/com/theveloper/pixelplay/data/service/wear/WearPhoneTransferSender.kt b/app/src/main/java/com/theveloper/pixelplay/data/service/wear/WearPhoneTransferSender.kt index 967bddab82..4cac4cf801 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/service/wear/WearPhoneTransferSender.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/wear/WearPhoneTransferSender.kt @@ -42,6 +42,31 @@ class WearPhoneTransferSender @Inject constructor( } } + /** + * Distinct from [isPixelPlayWatchAvailable]: `FILTER_ALL` returns every node that has ever + * advertised the PixelPlay capability, reachable or not, instead of only ones reachable right + * now — the signal for "has the user ever paired a watch with PixelPlay installed at all", + * used to hide watch-related UI entirely for someone who never has, as opposed to showing it + * disabled/"not connected" for a paired watch that's just out of range at the moment. + */ + suspend fun refreshWatchPairingState(): Boolean { + return runCatching { + val capability = capabilityClient.getCapability( + WearCapabilities.PIXELPLAY_WEAR_APP, + CapabilityClient.FILTER_ALL, + ).await() + val paired = capability.nodes.isNotEmpty() + transferStateStore.setAnyWatchPaired(paired) + paired + }.getOrElse { error -> + Timber.tag(TAG).w(error, "Failed checking whether any watch is paired") + // Deliberately don't clear transferStateStore's flag on failure (unlike + // isPixelPlayWatchAvailable's reachability reset): a transient error here shouldn't + // hide UI that a previous successful check already confirmed should be visible. + transferStateStore.isAnyWatchPaired.value + } + } + suspend fun refreshWatchLibraryState(): Result { return runCatching { val capability = capabilityClient.getCapability( diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/SongInfoBottomSheet.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/SongInfoBottomSheet.kt index 5dc12aa893..16ee020327 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/SongInfoBottomSheet.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/SongInfoBottomSheet.kt @@ -158,6 +158,7 @@ fun SongInfoBottomSheet( val audioMeta by songInfoViewModel.audioMeta.collectAsStateWithLifecycle() val resolvedArtists by songInfoViewModel.resolvedArtists.collectAsStateWithLifecycle() val isPixelPlayWatchAvailable by songInfoViewModel.isPixelPlayWatchAvailable.collectAsStateWithLifecycle() + val isAnyWatchPaired by songInfoViewModel.isAnyWatchPaired.collectAsStateWithLifecycle() val isWatchAvailabilityResolved by songInfoViewModel.isWatchAvailabilityResolved.collectAsStateWithLifecycle() val isSendingToWatch by songInfoViewModel.isSendingToWatch.collectAsStateWithLifecycle() val watchTransfers by songInfoViewModel.watchTransfers.collectAsStateWithLifecycle() @@ -281,21 +282,29 @@ fun SongInfoBottomSheet( val shouldOfferWatchTransfer = remember( canSendToWatch, currentSongTransfer, + isAnyWatchPaired, isPixelPlayWatchAvailable, isSongSavedOnWatch, isWatchAvailabilityResolved, ) { currentSongTransfer == null && canSendToWatch && + isAnyWatchPaired && isWatchAvailabilityResolved && isPixelPlayWatchAvailable && !isSongSavedOnWatch } + // isAnyWatchPaired gates this too — otherwise this "checking watch…" state would flash for + // every song info sheet even on a phone that's never paired a watch at all, since + // isWatchAvailabilityResolved only flips true after the (irrelevant, in that case) reachability + // check finishes. val shouldShowWatchTransferLoading = remember( canSendToWatch, + isAnyWatchPaired, isWatchAvailabilityResolved, ) { canSendToWatch && + isAnyWatchPaired && !isWatchAvailabilityResolved } diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/PlaylistDetailScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/PlaylistDetailScreen.kt index 158c2c2458..ebafe47e45 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/PlaylistDetailScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/PlaylistDetailScreen.kt @@ -205,6 +205,12 @@ fun PlaylistDetailScreen( playlistViewModel.loadPlaylistDetails(playlistId) } + // So "Send to Watch" in the options sheet below can be gated on isAnyWatchPaired before the + // user ever opens that sheet, not only refreshed reactively once they tap it. + LaunchedEffect(Unit) { + playlistViewModel.refreshWatchAvailability() + } + var showAddSongsSheet by remember { mutableStateOf(false) } var isReorderModeEnabled by remember { mutableStateOf(false) } @@ -236,6 +242,7 @@ fun PlaylistDetailScreen( val selectedSongForInfo by playerViewModel.selectedSongForInfo.collectAsStateWithLifecycle() val favoriteIds by playerViewModel.favoriteSongIds.collectAsStateWithLifecycle() // Reintroducir favoriteIds aquí val isPixelPlayWatchAvailable by playlistViewModel.isPixelPlayWatchAvailable.collectAsStateWithLifecycle() + val isAnyWatchPaired by playlistViewModel.isAnyWatchPaired.collectAsStateWithLifecycle() val watchSongIds by playlistViewModel.watchSongIds.collectAsStateWithLifecycle() val activeBatchTransfer by playlistViewModel.activePlaylistBatchTransfer.collectAsStateWithLifecycle() val activePlaylistTransfer = activeBatchTransfer?.takeIf { it.playlistId == playlistId } @@ -921,15 +928,17 @@ fun PlaylistDetailScreen( showEditPlaylistDialog = true } ) - PlaylistActionItem( - icon = painterResource(R.drawable.rounded_watch_arrow_down_24), - label = if (isAnySongOnWatch) updateOnWatchLabel else sendToWatchLabel, - onClick = { - showPlaylistOptionsSheet = false - playlistViewModel.refreshWatchAvailability() - showSendToWatchDialog = true - } - ) + if (isAnyWatchPaired) { + PlaylistActionItem( + icon = painterResource(R.drawable.rounded_watch_arrow_down_24), + label = if (isAnySongOnWatch) updateOnWatchLabel else sendToWatchLabel, + onClick = { + showPlaylistOptionsSheet = false + playlistViewModel.refreshWatchAvailability() + showSendToWatchDialog = true + } + ) + } PlaylistActionItem( icon = painterResource(R.drawable.rounded_delete_24), label = deletePlaylistLabel, 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 27e6817f52..dee450e51f 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 @@ -131,6 +131,8 @@ fun SettingsScreen( val uiState by settingsViewModel.uiState.collectAsStateWithLifecycle() val launchTab = uiState.launchTab val useSmoothCorners by settingsViewModel.useSmoothCorners.collectAsStateWithLifecycle() + val isAnyWatchPaired by settingsViewModel.isAnyWatchPaired.collectAsStateWithLifecycle() + LaunchedEffect(Unit) { settingsViewModel.refreshWatchPairingState() } var showCornerRadiusOverlay by remember { mutableStateOf(false) } @@ -214,8 +216,9 @@ fun SettingsScreen( val isDark = MaterialTheme.colorScheme.surface.luminance() < 0.5f ExpressiveSettingsGroup { val mainCategories = SettingsCategory.entries.filter { - it != SettingsCategory.ABOUT && - it != SettingsCategory.DEVICE_CAPABILITIES + it != SettingsCategory.ABOUT && + it != SettingsCategory.DEVICE_CAPABILITIES && + (it != SettingsCategory.WEAR_OS || isAnyWatchPaired) } val totalItems = mainCategories.size + 3 // Device + Accounts + About diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlaylistViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlaylistViewModel.kt index a95ed3dde3..b61052a9e7 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlaylistViewModel.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlaylistViewModel.kt @@ -107,6 +107,11 @@ class PlaylistViewModel @Inject constructor( private val _isRefreshingWatchAvailability = MutableStateFlow(false) val watchSongIds: StateFlow> = watchTransferStateStore.watchSongIds + /** Whether any watch has ever been paired with PixelPlay installed — as opposed to + * [isPixelPlayWatchAvailable], which is "reachable right now". Gates whether watch-related + * actions show at all, vs. showing disabled for a paired-but-out-of-range watch. */ + val isAnyWatchPaired: StateFlow = watchTransferStateStore.isAnyWatchPaired + /** * Whichever playlist batch transfer is currently active, regardless of which screen/ViewModel * instance started it — queried off the shared [PhoneWatchTransferStateStore] instead of @@ -1282,6 +1287,9 @@ class PlaylistViewModel @Inject constructor( wearPhoneTransferSender.refreshWatchLibraryState() } } + viewModelScope.launch { + wearPhoneTransferSender.refreshWatchPairingState() + } } fun isPlaylistFullyOnWatch(songIds: List): Boolean { 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 4b7d06f8ab..53fb3464bb 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 @@ -195,12 +195,27 @@ class SettingsViewModel @Inject constructor( private val musicRepository: MusicRepository, private val backupManager: BackupManager, private val wearPerformanceSettingsPublisher: com.theveloper.pixelplay.data.service.wear.WearPerformanceSettingsPublisher, + private val wearPhoneTransferSender: com.theveloper.pixelplay.data.service.wear.WearPhoneTransferSender, + private val watchTransferStateStore: com.theveloper.pixelplay.data.service.wear.PhoneWatchTransferStateStore, @ApplicationContext private val context: Context ) : ViewModel() { private val _uiState = MutableStateFlow(SettingsUiState()) val uiState: StateFlow = _uiState.asStateFlow() + /** Whether any watch has ever been paired with PixelPlay installed — gates whether the + * "Watch" category even shows in the Settings hub. See PlaylistViewModel's identical field + * for the full explanation of why this differs from "reachable right now". */ + val isAnyWatchPaired: StateFlow = watchTransferStateStore.isAnyWatchPaired + + /** Re-checks watch pairing state — call once when the Settings hub (or "Watch" category) + * opens, on top of the app-startup check, in case a watch was paired mid-session. */ + fun refreshWatchPairingState() { + viewModelScope.launch { + wearPhoneTransferSender.refreshWatchPairingState() + } + } + // AI Provider State val aiProvider: StateFlow = aiPreferencesRepository.aiProvider .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "GEMINI") diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/SongInfoBottomSheetViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/SongInfoBottomSheetViewModel.kt index 2e94f69576..0f13d0545e 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/SongInfoBottomSheetViewModel.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/SongInfoBottomSheetViewModel.kt @@ -77,6 +77,10 @@ class SongInfoBottomSheetViewModel @Inject constructor( val isWatchAvailabilityResolved: StateFlow = _isWatchAvailabilityResolved.asStateFlow() private val _isRefreshingWatchAvailability = MutableStateFlow(false) + /** Whether any watch has ever been paired with PixelPlay installed — see PlaylistViewModel's + * identical field for the full explanation. */ + val isAnyWatchPaired: StateFlow = transferStateStore.isAnyWatchPaired + private val _isRequestingToWatch = MutableStateFlow(false) val watchTransfers: StateFlow> = transferStateStore.transfers val watchSongIds: StateFlow> = transferStateStore.watchSongIds @@ -173,6 +177,9 @@ class SongInfoBottomSheetViewModel @Inject constructor( } } } + viewModelScope.launch { + wearPhoneTransferSender.refreshWatchPairingState() + } } fun isLocalSongForWatchTransfer(song: Song): Boolean { diff --git a/app/src/test/java/com/theveloper/pixelplay/data/service/wear/PhoneWatchTransferStateStoreTest.kt b/app/src/test/java/com/theveloper/pixelplay/data/service/wear/PhoneWatchTransferStateStoreTest.kt index 514dab13c9..da1cef4ad3 100644 --- a/app/src/test/java/com/theveloper/pixelplay/data/service/wear/PhoneWatchTransferStateStoreTest.kt +++ b/app/src/test/java/com/theveloper/pixelplay/data/service/wear/PhoneWatchTransferStateStoreTest.kt @@ -222,4 +222,28 @@ class PhoneWatchTransferStateStoreTest { assertThat(store.batchTransfers.value["b1"]?.completedSongCount).isEqualTo(1) assertThat(store.batchTransfers.value["b2"]?.completedSongCount).isEqualTo(0) } + + // --- isAnyWatchPaired: distinct from reachableWatchNodeIds (paired vs. reachable now) --- + + @Test + fun `isAnyWatchPaired defaults to false`() { + assertThat(store.isAnyWatchPaired.value).isFalse() + } + + @Test + fun `setAnyWatchPaired true flips the flag`() { + store.setAnyWatchPaired(true) + + assertThat(store.isAnyWatchPaired.value).isTrue() + } + + @Test + fun `setAnyWatchPaired is independent of reachableWatchNodeIds`() { + store.setAnyWatchPaired(true) + store.retainReachableWatchNodes(emptySet()) + + // A paired watch that's simply out of range right now shouldn't un-pair itself. + assertThat(store.isAnyWatchPaired.value).isTrue() + assertThat(store.reachableWatchNodeIds.value).isEmpty() + } } diff --git a/app/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/PlaylistViewModelTest.kt b/app/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/PlaylistViewModelTest.kt index 74f00ef06c..1eb3e36b1d 100644 --- a/app/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/PlaylistViewModelTest.kt +++ b/app/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/PlaylistViewModelTest.kt @@ -55,6 +55,7 @@ class PlaylistViewModelTest { every { playlistPreferencesRepository.showTelegramCloudPlaylistsFlow } returns flowOf(true) every { playlistPreferencesRepository.telegramTopicDisplayModeFlow } returns flowOf(TelegramTopicDisplayMode.CHANNELS_AND_TOPICS) + coEvery { wearPhoneTransferSender.refreshWatchPairingState() } returns true } private fun buildViewModel() = PlaylistViewModel( From 7603a4fef11286ab069ed8fed5adfe750255f983 Mon Sep 17 00:00:00 2001 From: PonceGL Date: Tue, 11 Aug 2026 20:45:29 -0600 Subject: [PATCH 2/4] fix(wear): register missing DATA_CHANGED/MESSAGE_RECEIVED manifest paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of "the new performance toggles have zero effect on the watch, even with all three off": WearDataListenerService's manifest intent-filter for DATA_CHANGED only declared pathPrefix="/player_state". Android's Wearable Data Layer routes DATA_CHANGED broadcasts to a listener service based on the manifest's declared path prefixes — WEAR_PERFORMANCE_SETTINGS's DataItem never matched any of them, so onDataChanged() was never even invoked for it. The receiving code (added this session) was correct but simply unreachable; the watch's WearPerformanceSettingsRepository stayed at its all-true defaults no matter what the phone published. Same bug, opposite direction, found by auditing every WearDataPaths constant against both manifests: the phone's WearCommandReceiver never registered "/playlist_sync_ack" either — the watch's ack (added in the playlist-sync-reliability fix, already merged) has been sent correctly this whole time but silently dropped by the OS before reaching onMessageReceived(). That's the real explanation for "Playlist sync unconfirmed after retry" observed live earlier this session — not (only) the watch being disconnected too long, as I'd assumed at the time. Fixed by adding both missing pathPrefix entries. Also fixed a related-but-separate bug found while investigating: SettingsViewModel's setWearShowAlbumArt/setWearDynamicColorTheming/ setWearPlayButtonAnimation each published all three fields together, but read the two fields *not* being changed from uiState.value — a reactively-derived cache that isn't guaranteed to have caught up to a DataStore write from a different setter call moments earlier. Toggling multiple switches in quick succession could publish a stale value for whichever one's collector hadn't updated yet, silently reverting it on the watch. Fixed by reading the two untouched fields directly from UserPreferencesRepository's flows via `.first()` at publish time instead of from uiState.value — always reflects what was actually just persisted, no race. Disclosed, not fixed here (pre-existing, unrelated to this session): audited all of WearDataPaths this way and found FAVORITES_SYNC_REQUEST/ FAVORITES_SYNC_STATE aren't registered in either manifest either, and the phone side has no handler for FAVORITES_SYNC_REQUEST at all — the watch-to-phone favorites sync feature appears to have never been fully wired end to end. Out of scope for what's being tested right now; noting it rather than silently leaving it for someone to rediscover. Verified: :app and :wear compile clean, and processDebugMainManifest succeeds on both (validates the manifest XML itself, not just Kotlin). --- app/src/main/AndroidManifest.xml | 4 ++ .../viewmodel/SettingsViewModel.kt | 63 +++++++++++-------- wear/src/main/AndroidManifest.xml | 4 ++ 3 files changed, 44 insertions(+), 27 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1afb6f4044..91b51c8a7a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -302,6 +302,10 @@ android:scheme="wear" android:host="*" android:pathPrefix="/watch_library_state" /> + 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 53fb3464bb..f4d810aa79 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 @@ -1411,51 +1411,60 @@ 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, - ) + publishWearPerformanceSettings(showAlbumArtOverride = enabled) } } fun setWearDynamicColorTheming(enabled: Boolean) { viewModelScope.launch { userPreferencesRepository.setWearDynamicColorTheming(enabled) - wearPerformanceSettingsPublisher.publish( - showAlbumArt = uiState.value.wearShowAlbumArt, - dynamicColorTheming = enabled, - playButtonAnimation = uiState.value.wearPlayButtonAnimation, - ) + publishWearPerformanceSettings(dynamicColorThemingOverride = enabled) } } fun setWearPlayButtonAnimation(enabled: Boolean) { viewModelScope.launch { userPreferencesRepository.setWearPlayButtonAnimation(enabled) - wearPerformanceSettingsPublisher.publish( - showAlbumArt = uiState.value.wearShowAlbumArt, - dynamicColorTheming = uiState.value.wearDynamicColorTheming, - playButtonAnimation = enabled, - ) + publishWearPerformanceSettings(playButtonAnimationOverride = 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). + * Publishes the current watch performance settings to the watch. Called once when the + * "Watch" settings screen opens (no overrides — just re-announces whatever's persisted, so a + * freshly paired or reinstalled watch gets them without the user touching a toggle first, + * since DataItem sync only reaches a node once something's actually been `putDataItem`'d at + * least once), and after each individual setter above with that field's fresh value passed as + * an override. + * + * Deliberately reads the other (non-overridden) fields straight from + * [UserPreferencesRepository]'s flows via `.first()`, not from `uiState.value`: `uiState` is + * updated by a separate reactive collector that isn't guaranteed to have caught up to a write + * that just happened a moment ago on a *different* setter call — flipping two switches in + * quick succession could publish a stale value for whichever one's collector hadn't run yet, + * silently reverting it on the watch. Reading the repository directly has no such race: by + * the time this runs, every `set...()` call that's already returned has durably completed its + * `DataStore.edit`, so a fresh `.first()` always reflects it. */ + private fun publishWearPerformanceSettings( + showAlbumArtOverride: Boolean? = null, + dynamicColorThemingOverride: Boolean? = null, + playButtonAnimationOverride: Boolean? = null, + ) { + viewModelScope.launch { + wearPerformanceSettingsPublisher.publish( + showAlbumArt = showAlbumArtOverride + ?: userPreferencesRepository.wearShowAlbumArtFlow.first(), + dynamicColorTheming = dynamicColorThemingOverride + ?: userPreferencesRepository.wearDynamicColorThemingFlow.first(), + playButtonAnimation = playButtonAnimationOverride + ?: userPreferencesRepository.wearPlayButtonAnimationFlow.first(), + ) + } + } + fun publishWearPerformanceSettings() { - wearPerformanceSettingsPublisher.publish( - showAlbumArt = uiState.value.wearShowAlbumArt, - dynamicColorTheming = uiState.value.wearDynamicColorTheming, - playButtonAnimation = uiState.value.wearPlayButtonAnimation, - ) + publishWearPerformanceSettings(null, null, null) } fun setBackupInfoDismissed(dismissed: Boolean) { diff --git a/wear/src/main/AndroidManifest.xml b/wear/src/main/AndroidManifest.xml index 1b06e4246e..ab47e7231a 100644 --- a/wear/src/main/AndroidManifest.xml +++ b/wear/src/main/AndroidManifest.xml @@ -77,6 +77,10 @@ android:scheme="wear" android:host="*" android:pathPrefix="/player_state" /> + From bc4f5a7233937d45ed9c37e1b337eedc9522ff13 Mon Sep 17 00:00:00 2001 From: PonceGL Date: Wed, 12 Aug 2026 08:07:49 -0600 Subject: [PATCH 3/4] fix(ui): consistent spacing in playlist detail, disable mutation during watch transfer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two pieces of hardware-test feedback on PlaylistDetailScreen, unrelated to each other but both surfaced in the same test session: 1. The controls stacked above the song list (play/shuffle row, action row, search field) each used their own ad-hoc padding value — 20dp horizontal on the buttons but 16dp on the search field (visibly misaligned left edges), and five different vertical gaps between sections (6/8dp, 2dp, 8dp, 8dp, 12dp) with no consistent rhythm. Normalized to a single sectionSpacing = 12dp gap between every stacked section and 20dp horizontal padding throughout. SearchFilterTextField gained horizontalPadding/verticalPadding parameters (defaulting to its existing 16dp/8dp, so its other call sites are unaffected) so this screen can override them. 2. Reorder/add/remove were still fully interactive while a playlist batch transfer to the watch was in progress — mutating the playlist mid-transfer could desync what's actually being sent from what's shown on screen. Disabled (not removed, to avoid a layout jump) all three while activePlaylistTransfer is non-null for this playlist, and force reorder/remove mode off via a LaunchedEffect if a transfer starts while already in one of those modes. Deliberately out of scope (flagged by the user for a later, larger pass): consolidating add/remove into the existing overflow menu and moving reorder elsewhere. This just prevents mutation during an active transfer with the current layout. --- .../components/SearchFilterTextField.kt | 6 ++-- .../screens/PlaylistDetailScreen.kt | 28 ++++++++++++++++--- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/SearchFilterTextField.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/SearchFilterTextField.kt index 0ee069a5a1..6ddf488308 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/SearchFilterTextField.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/SearchFilterTextField.kt @@ -28,7 +28,9 @@ fun SearchFilterTextField( searchQuery: String, onSearchQueryChange: (String) -> Unit, modifier: Modifier = Modifier, - label: String = stringResource(R.string.song_picker_search_label) + label: String = stringResource(R.string.song_picker_search_label), + horizontalPadding: androidx.compose.ui.unit.Dp = 16.dp, + verticalPadding: androidx.compose.ui.unit.Dp = 8.dp, ) { OutlinedTextField( value = searchQuery, @@ -46,7 +48,7 @@ fun SearchFilterTextField( label = { Text(label) }, modifier = modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), + .padding(horizontal = horizontalPadding, vertical = verticalPadding), shape = CircleShape, singleLine = true, leadingIcon = { diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/PlaylistDetailScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/PlaylistDetailScreen.kt index ebafe47e45..393ac8125a 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/PlaylistDetailScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/PlaylistDetailScreen.kt @@ -246,6 +246,16 @@ fun PlaylistDetailScreen( val watchSongIds by playlistViewModel.watchSongIds.collectAsStateWithLifecycle() val activeBatchTransfer by playlistViewModel.activePlaylistBatchTransfer.collectAsStateWithLifecycle() val activePlaylistTransfer = activeBatchTransfer?.takeIf { it.playlistId == playlistId } + // Mutating the playlist while it's mid-transfer to the watch could desync what's actually + // being sent from what the user sees on screen — reorder/add/remove are disabled (not + // removed, to avoid a layout jump) for the duration. + val isTransferActive = activePlaylistTransfer != null + LaunchedEffect(isTransferActive) { + if (isTransferActive) { + isReorderModeEnabled = false + isRemoveModeEnabled = false + } + } val isAnySongOnWatch = remember(songsInPlaylist, watchSongIds) { songsInPlaylist.isNotEmpty() && songsInPlaylist.any { it.id in watchSongIds } } @@ -398,14 +408,17 @@ fun PlaylistDetailScreen( ) } val actionButtonsHeight = 42.dp - val playbackControlBottomPadding = if (isFolderPlaylist) 8.dp else 6.dp + // Single gap value between every stacked section below (playback row, action + // row, search field, song list card) — previously each used its own ad-hoc + // value (6dp/8dp/2dp/12dp), which read as visually inconsistent spacing. + val sectionSpacing = 12.dp if (searchQuery.isBlank()) { Row( modifier = Modifier .fillMaxWidth() .height(62.dp) .padding(horizontal = 20.dp) - .padding(bottom = playbackControlBottomPadding), + .padding(bottom = sectionSpacing), horizontalArrangement = Arrangement.spacedBy(8.dp) ) { Button( @@ -496,7 +509,8 @@ fun PlaylistDetailScreen( Row( modifier = Modifier .fillMaxWidth() - .padding(start = 20.dp, end = 20.dp, bottom = 8.dp, top = 2.dp), + .padding(horizontal = 20.dp) + .padding(bottom = sectionSpacing), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically ) { @@ -528,6 +542,7 @@ fun PlaylistDetailScreen( Button( onClick = { showAddSongsSheet = true }, + enabled = !isTransferActive, shape = CircleShape, contentPadding = PaddingValues(horizontal = 12.dp), colors = ButtonDefaults.buttonColors( @@ -593,6 +608,7 @@ fun PlaylistDetailScreen( content = { Button( onClick = { isRemoveModeEnabled = !isRemoveModeEnabled }, + enabled = !isTransferActive, shape = RoundedCornerShape(removeCornerRadius), contentPadding = PaddingValues(horizontal = 8.dp), colors = ButtonDefaults.buttonColors( @@ -623,6 +639,7 @@ fun PlaylistDetailScreen( Button( onClick = { isReorderModeEnabled = !isReorderModeEnabled }, + enabled = !isTransferActive, shape = RoundedCornerShape(reorderCornerRadius), contentPadding = PaddingValues(horizontal = 8.dp), colors = ButtonDefaults.buttonColors( @@ -708,7 +725,10 @@ fun PlaylistDetailScreen( if (localReorderableSongs.isNotEmpty()) { SearchFilterTextField( searchQuery = searchQuery, - onSearchQueryChange = { searchQuery = it } + onSearchQueryChange = { searchQuery = it }, + modifier = Modifier.padding(bottom = sectionSpacing), + horizontalPadding = 20.dp, + verticalPadding = 0.dp, ) } From c39134ab7edee2175102f51f7164387854261ca5 Mon Sep 17 00:00:00 2001 From: PonceGL Date: Wed, 12 Aug 2026 08:07:57 -0600 Subject: [PATCH 4/4] i18n: translate the Watch settings category into all supported locales MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 10 new strings for the "Watch" performance settings category (PR #24) only existed in the base values/strings_settings.xml (English), so every other locale fell back to English for just that one section — jarring next to the rest of Ajustes being fully translated. Added all 10 keys to the 11 existing values-XX/strings_settings.xml files (ar, de, es, fr, in, it, ko, nb, ru, tr, zh-rCN), matching each file's existing tone/register (e.g. informal "tú" in Spanish, "vous" in French, matching neighboring strings already in each file). Verified :app:mergeDebugResources succeeds (validates the XML itself across all 11 files) and :app:assembleRelease still builds clean. --- app/src/main/res/values-ar/strings_settings.xml | 12 ++++++++++++ app/src/main/res/values-de/strings_settings.xml | 12 ++++++++++++ app/src/main/res/values-es/strings_settings.xml | 12 ++++++++++++ app/src/main/res/values-fr/strings_settings.xml | 12 ++++++++++++ app/src/main/res/values-in/strings_settings.xml | 12 ++++++++++++ app/src/main/res/values-it/strings_settings.xml | 12 ++++++++++++ app/src/main/res/values-ko/strings_settings.xml | 12 ++++++++++++ app/src/main/res/values-nb/strings_settings.xml | 12 ++++++++++++ app/src/main/res/values-ru/strings_settings.xml | 12 ++++++++++++ app/src/main/res/values-tr/strings_settings.xml | 12 ++++++++++++ app/src/main/res/values-zh-rCN/strings_settings.xml | 12 ++++++++++++ 11 files changed, 132 insertions(+) diff --git a/app/src/main/res/values-ar/strings_settings.xml b/app/src/main/res/values-ar/strings_settings.xml index afced2d2d2..6dbc154d9d 100644 --- a/app/src/main/res/values-ar/strings_settings.xml +++ b/app/src/main/res/values-ar/strings_settings.xml @@ -306,4 +306,16 @@ مستوى الصوت إيقاف مؤقت عند وصول مستوى الصوت إلى الصفر إيقاف التشغيل تلقائيًا مؤقتًا عندما يكون مستوى الصوت 0 + + + الساعة + خيارات الأداء للتشغيل دون اتصال على ساعتك + الأداء + تنطبق هذه الخيارات فقط عندما تشغّل ساعتك الموسيقى بمفردها، دون اتصال بالهاتف. يعرض التحكم في تشغيل الهاتف من الساعة التجربة الكاملة دائمًا. + إظهار غلاف الألبوم + أوقف التشغيل لتوفير الذاكرة على الساعة — الأكبر تأثيرًا من بين الثلاثة + اللون الديناميكي + يلوّن واجهة الساعة وفقًا لغلاف كل أغنية + رسوم زر التشغيل المتحركة + حلقة تدور باستمرار حول زر التشغيل أثناء تشغيل الموسيقى diff --git a/app/src/main/res/values-de/strings_settings.xml b/app/src/main/res/values-de/strings_settings.xml index e6fcda8436..8274c67610 100644 --- a/app/src/main/res/values-de/strings_settings.xml +++ b/app/src/main/res/values-de/strings_settings.xml @@ -646,4 +646,16 @@ Lautstärke Pausieren, wenn Lautstärke null erreicht Wiedergabe automatisch pausieren, wenn die Lautstärke auf 0 gesetzt wird + + + Uhr + Leistungsoptionen für die Offline-Wiedergabe auf deiner Uhr + Leistung + Diese Optionen gelten nur, wenn deine Uhr Musik eigenständig abspielt, ohne verbundenes Smartphone. Wenn du die Wiedergabe auf dem Smartphone von der Uhr aus steuerst, wird immer die vollständige Ansicht angezeigt. + Albumcover anzeigen + Deaktivieren, um Speicher auf der Uhr zu sparen — hat von den dreien die größte Wirkung + Dynamische Farbe + Färbt die Oberfläche der Uhr passend zum Cover des jeweiligen Songs + Animation der Wiedergabetaste + Durchgehend rotierender Ring um die Wiedergabetaste, während Musik läuft diff --git a/app/src/main/res/values-es/strings_settings.xml b/app/src/main/res/values-es/strings_settings.xml index 79a7e62578..9eb274d557 100644 --- a/app/src/main/res/values-es/strings_settings.xml +++ b/app/src/main/res/values-es/strings_settings.xml @@ -646,4 +646,16 @@ Volumen Pausar cuando el volumen llegue a cero Pausar automáticamente la reproducción cuando el volumen sea 0 + + + Reloj + Opciones de rendimiento para la reproducción sin conexión en tu reloj + Rendimiento + Estas opciones solo aplican cuando tu reloj reproduce música por su cuenta, sin el teléfono conectado. Controlar la reproducción del teléfono desde el reloj siempre muestra la experiencia completa. + Mostrar carátula del álbum + Desactívalo para ahorrar memoria en el reloj — el de mayor impacto de los tres + Color dinámico + Tiñe la interfaz del reloj según la carátula de cada canción + Animación del botón de reproducción + Anillo giratorio continuo alrededor del botón de reproducción mientras suena la música diff --git a/app/src/main/res/values-fr/strings_settings.xml b/app/src/main/res/values-fr/strings_settings.xml index 5932d39357..25fec4a458 100644 --- a/app/src/main/res/values-fr/strings_settings.xml +++ b/app/src/main/res/values-fr/strings_settings.xml @@ -642,4 +642,16 @@ Volume Mettre en pause quand le volume atteint zéro Mettre automatiquement en pause la lecture lorsque le volume est à 0 + + + Montre + Options de performance pour la lecture hors ligne sur votre montre + Performance + Ces options s\'appliquent uniquement lorsque votre montre lit de la musique de manière autonome, sans connexion au téléphone. Le contrôle de la lecture du téléphone depuis la montre affiche toujours l\'expérience complète. + Afficher la pochette de l\'album + Désactivez pour économiser la mémoire de la montre — l\'option ayant le plus d\'impact des trois + Couleur dynamique + Teinte l\'interface de la montre selon la pochette de chaque morceau + Animation du bouton de lecture + Anneau tournant en continu autour du bouton de lecture pendant la musique diff --git a/app/src/main/res/values-in/strings_settings.xml b/app/src/main/res/values-in/strings_settings.xml index cabbc3ab0d..ce711c1bfc 100644 --- a/app/src/main/res/values-in/strings_settings.xml +++ b/app/src/main/res/values-in/strings_settings.xml @@ -642,4 +642,16 @@ Volume Jeda saat volume mencapai nol Otomatis menjeda pemutaran saat volume diatur ke 0 + + + Jam Tangan + Opsi performa untuk pemutaran offline di jam tangan Anda + Performa + Opsi ini hanya berlaku saat jam tangan Anda memutar musik secara mandiri, tanpa ponsel terhubung. Mengontrol pemutaran ponsel dari jam tangan selalu menampilkan pengalaman lengkap. + Tampilkan sampul album + Nonaktifkan untuk menghemat memori di jam tangan — dampak terbesar dari ketiganya + Warna dinamis + Mewarnai antarmuka jam tangan berdasarkan sampul setiap lagu + Animasi tombol putar + Cincin berputar terus-menerus di sekitar tombol putar saat musik diputar diff --git a/app/src/main/res/values-it/strings_settings.xml b/app/src/main/res/values-it/strings_settings.xml index 24ad47887e..e28a586ced 100644 --- a/app/src/main/res/values-it/strings_settings.xml +++ b/app/src/main/res/values-it/strings_settings.xml @@ -646,4 +646,16 @@ Volume Metti in pausa quando il volume raggiunge zero Metti automaticamente in pausa la riproduzione quando il volume è 0 + + + Orologio + Opzioni di prestazioni per la riproduzione offline sul tuo orologio + Prestazioni + Queste opzioni si applicano solo quando il tuo orologio riproduce musica in autonomia, senza il telefono connesso. Controllare la riproduzione del telefono dall\'orologio mostra sempre l\'esperienza completa. + Mostra copertina dell\'album + Disattiva per risparmiare memoria sull\'orologio — quella con il maggiore impatto delle tre + Colore dinamico + Colora l\'interfaccia dell\'orologio in base alla copertina di ogni brano + Animazione del pulsante di riproduzione + Anello che ruota continuamente attorno al pulsante di riproduzione durante la musica diff --git a/app/src/main/res/values-ko/strings_settings.xml b/app/src/main/res/values-ko/strings_settings.xml index 4b4fe3a87f..cc0a099c9b 100644 --- a/app/src/main/res/values-ko/strings_settings.xml +++ b/app/src/main/res/values-ko/strings_settings.xml @@ -646,4 +646,16 @@ 볼륨 볼륨이 0이 되면 일시정지 볼륨이 0으로 설정되면 자동으로 재생을 일시정지합니다 + + + 워치 + 워치에서 오프라인 재생을 위한 성능 옵션 + 성능 + 이 옵션은 워치가 휴대폰 연결 없이 단독으로 음악을 재생할 때만 적용됩니다. 워치에서 휴대폰 재생을 제어할 때는 항상 전체 화면이 표시됩니다. + 앨범 아트 표시 + 워치의 메모리를 절약하려면 꺼두세요 — 세 가지 중 가장 큰 영향을 미칩니다 + 다이내믹 컬러 + 각 곡의 앨범 아트에 따라 워치 UI 색상을 지정합니다 + 재생 버튼 애니메이션 + 음악 재생 중 재생 버튼 주위로 계속 회전하는 링 diff --git a/app/src/main/res/values-nb/strings_settings.xml b/app/src/main/res/values-nb/strings_settings.xml index d8e2abe1fc..f958389bac 100644 --- a/app/src/main/res/values-nb/strings_settings.xml +++ b/app/src/main/res/values-nb/strings_settings.xml @@ -646,4 +646,16 @@ Volum Sett på pause når volumet er null Sett automatisk avspillingen på pause når volumet settes til 0 + + + Klokke + Ytelsesalternativer for frakoblet avspilling på klokken din + Ytelse + Disse alternativene gjelder bare når klokken din spiller musikk på egen hånd, uten tilkoblet telefon. Å styre telefonavspilling fra klokken viser alltid hele opplevelsen. + Vis albumcover + Slå av for å spare minne på klokken — den med størst innvirkning av de tre + Dynamisk farge + Fargelegger klokkens grensesnitt basert på coveret til hver sang + Animasjon for avspillingsknapp + Kontinuerlig roterende ring rundt avspillingsknappen mens musikken spilles \ No newline at end of file diff --git a/app/src/main/res/values-ru/strings_settings.xml b/app/src/main/res/values-ru/strings_settings.xml index c49784964b..0e2856c08d 100644 --- a/app/src/main/res/values-ru/strings_settings.xml +++ b/app/src/main/res/values-ru/strings_settings.xml @@ -646,4 +646,16 @@ Громкость Пауза при нулевой громкости Автоматически приостанавливать воспроизведение, когда громкость равна 0 + + + Часы + Настройки производительности для автономного воспроизведения на часах + Производительность + Эти параметры применяются, только когда часы воспроизводят музыку самостоятельно, без подключения к телефону. Управление воспроизведением на телефоне с часов всегда показывает полный интерфейс. + Показывать обложку альбома + Отключите, чтобы сэкономить память на часах — оказывает наибольшее влияние из трёх + Динамический цвет + Окрашивает интерфейс часов по обложке каждой песни + Анимация кнопки воспроизведения + Непрерывно вращающееся кольцо вокруг кнопки воспроизведения во время игры музыки diff --git a/app/src/main/res/values-tr/strings_settings.xml b/app/src/main/res/values-tr/strings_settings.xml index 71b05a38e5..1b73444771 100644 --- a/app/src/main/res/values-tr/strings_settings.xml +++ b/app/src/main/res/values-tr/strings_settings.xml @@ -646,4 +646,16 @@ Ses Ses sıfıra ulaştığında duraklat Ses seviyesi 0\'a ayarlandığında oynatmayı otomatik olarak duraklat + + + Saat + Saatinizde çevrimdışı oynatma için performans seçenekleri + Performans + Bu seçenekler yalnızca saatiniz telefon bağlı olmadan kendi başına müzik çaldığında geçerlidir. Telefon oynatımını saatten kontrol etmek her zaman tam deneyimi gösterir. + Albüm kapağını göster + Saatte bellekten tasarruf etmek için kapatın — üçü arasında en büyük etkiye sahip olan + Dinamik renk + Saat arayüzünü her şarkının kapak resmine göre renklendirir + Oynat düğmesi animasyonu + Müzik çalarken oynat düğmesinin etrafında sürekli dönen halka diff --git a/app/src/main/res/values-zh-rCN/strings_settings.xml b/app/src/main/res/values-zh-rCN/strings_settings.xml index daf7d4eac8..414a03ab77 100644 --- a/app/src/main/res/values-zh-rCN/strings_settings.xml +++ b/app/src/main/res/values-zh-rCN/strings_settings.xml @@ -646,4 +646,16 @@ 音量 音量为零时暂停 当音量设置为 0 时自动暂停播放 + + + 手表 + 手表离线播放的性能选项 + 性能 + 这些选项仅在您的手表脱离手机独立播放音乐时生效。从手表控制手机播放时,始终显示完整体验。 + 显示专辑封面 + 关闭以节省手表内存 — 三项中影响最大的一项 + 动态颜色 + 根据每首歌曲的封面为手表界面着色 + 播放按钮动画 + 音乐播放时播放按钮周围持续旋转的圆环