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