diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1afb6f404..91b51c8a7 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/PixelPlayApplication.kt b/app/src/main/java/com/theveloper/pixelplay/PixelPlayApplication.kt index 48cf1d8ab..3be1d8a87 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 17da3616b..3c7e970bb 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 967bddab8..4cac4cf80 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 5dc12aa89..16ee02032 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 158c2c245..ebafe47e4 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 27e6817f5..dee450e51 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 a95ed3dde..b61052a9e 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 4b7d06f8a..f4d810aa7 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") @@ -1396,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/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/SongInfoBottomSheetViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/SongInfoBottomSheetViewModel.kt index 2e94f6957..0f13d0545 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 514dab13c..da1cef4ad 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 74f00ef06..1eb3e36b1 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( diff --git a/wear/src/main/AndroidManifest.xml b/wear/src/main/AndroidManifest.xml index 1b06e4246..ab47e7231 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" /> +