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
4 changes: 4 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,10 @@
android:scheme="wear"
android:host="*"
android:pathPrefix="/watch_library_state" />
<data
android:scheme="wear"
android:host="*"
android:pathPrefix="/playlist_sync_ack" />
</intent-filter>
</service>

Expand Down
16 changes: 16 additions & 0 deletions app/src/main/java/com/theveloper/pixelplay/PixelPlayApplication.kt
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,9 @@ class PixelPlayApplication : Application(), ImageLoaderFactory, Configuration.Pr
@Inject
lateinit var playlistWatchTransferCoordinator: dagger.Lazy<PlaylistWatchTransferCoordinator>

@Inject
lateinit var wearPhoneTransferSender: dagger.Lazy<com.theveloper.pixelplay.data.service.wear.WearPhoneTransferSender>

private val startupScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)

// AÑADE EL COMPANION OBJECT
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,17 @@ class PhoneWatchTransferStateStore @Inject constructor() {
private val _watchSongIds = MutableStateFlow<Set<String>>(emptySet())
val watchSongIds: StateFlow<Set<String>> = _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<Boolean> = _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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Unit> {
return runCatching {
val capability = capabilityClient.getCapability(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) }

Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,11 @@ class PlaylistViewModel @Inject constructor(
private val _isRefreshingWatchAvailability = MutableStateFlow(false)
val watchSongIds: StateFlow<Set<String>> = 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<Boolean> = watchTransferStateStore.isAnyWatchPaired

/**
* Whichever playlist batch transfer is currently active, regardless of which screen/ViewModel
* instance started it — queried off the shared [PhoneWatchTransferStateStore] instead of
Expand Down Expand Up @@ -1282,6 +1287,9 @@ class PlaylistViewModel @Inject constructor(
wearPhoneTransferSender.refreshWatchLibraryState()
}
}
viewModelScope.launch {
wearPhoneTransferSender.refreshWatchPairingState()
}
}

fun isPlaylistFullyOnWatch(songIds: List<String>): Boolean {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<SettingsUiState> = _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<Boolean> = 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<String> = aiPreferencesRepository.aiProvider
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "GEMINI")
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,10 @@ class SongInfoBottomSheetViewModel @Inject constructor(
val isWatchAvailabilityResolved: StateFlow<Boolean> = _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<Boolean> = transferStateStore.isAnyWatchPaired

private val _isRequestingToWatch = MutableStateFlow(false)
val watchTransfers: StateFlow<Map<String, PhoneWatchTransferState>> = transferStateStore.transfers
val watchSongIds: StateFlow<Set<String>> = transferStateStore.watchSongIds
Expand Down Expand Up @@ -173,6 +177,9 @@ class SongInfoBottomSheetViewModel @Inject constructor(
}
}
}
viewModelScope.launch {
wearPhoneTransferSender.refreshWatchPairingState()
}
}

fun isLocalSongForWatchTransfer(song: Song): Boolean {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading