From c09dc0985738162eb9cb49a44c96d25b3b5082a1 Mon Sep 17 00:00:00 2001 From: PonceGL Date: Sat, 8 Aug 2026 13:13:43 -0600 Subject: [PATCH 1/9] feat(app): add playlist watch-transfer methods to PlaylistViewModel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit estimateWatchTransfer, isPlaylistFullyOnWatch, sendPlaylistToWatch, cancelPlaylistTransfer, and activePlaylistBatchTransfer — thin delegation to PlaylistWatchTransferCoordinator/PhoneWatchTransferStateStore/ WearPhoneTransferSender, mirroring the exact pattern SongInfoBottomSheetViewModel already uses for the single-song case. Adds 4 constructor dependencies to an already-1200-line ViewModel. Flagged, not fixed here — splitting it is a separate, unrelated refactor. --- .../viewmodel/PlaylistViewModel.kt | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) 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 8c5c620ef..a95ed3dde 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 @@ -14,6 +14,14 @@ import com.theveloper.pixelplay.data.model.SortOption import com.theveloper.pixelplay.data.playlist.M3uManager import com.theveloper.pixelplay.data.preferences.PlaylistPreferencesRepository import com.theveloper.pixelplay.data.repository.MusicRepository +import com.theveloper.pixelplay.data.service.wear.PhoneWatchBatchTransferState +import com.theveloper.pixelplay.data.service.wear.PhoneWatchTransferStateStore +import com.theveloper.pixelplay.data.service.wear.PlaylistWatchTransferCoordinator +import com.theveloper.pixelplay.data.service.wear.WatchAudioTranscoder +import com.theveloper.pixelplay.data.service.wear.WatchPlaylistTransferEstimate +import com.theveloper.pixelplay.data.service.wear.WatchPlaylistTransferEstimator +import com.theveloper.pixelplay.data.service.wear.WearPhoneTransferSender +import com.theveloper.pixelplay.shared.WearTransferProgress import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -22,6 +30,9 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -75,6 +86,10 @@ class PlaylistViewModel @Inject constructor( private val dailyMixManager: DailyMixManager, private val aiPlaylistGenerator: AiPlaylistGenerator, private val m3uManager: M3uManager, + private val playlistWatchTransferCoordinator: PlaylistWatchTransferCoordinator, + private val watchTransferStateStore: PhoneWatchTransferStateStore, + private val wearPhoneTransferSender: WearPhoneTransferSender, + private val watchAudioTranscoder: WatchAudioTranscoder, @ApplicationContext private val context: Context ) : ViewModel() { @@ -87,10 +102,34 @@ class PlaylistViewModel @Inject constructor( ) val playlistCreationEvent: SharedFlow = _playlistCreationEvent.asSharedFlow() + private val _isPixelPlayWatchAvailable = MutableStateFlow(false) + val isPixelPlayWatchAvailable: StateFlow = _isPixelPlayWatchAvailable.asStateFlow() + private val _isRefreshingWatchAvailability = MutableStateFlow(false) + val watchSongIds: StateFlow> = watchTransferStateStore.watchSongIds + + /** + * Whichever playlist batch transfer is currently active, regardless of which screen/ViewModel + * instance started it — queried off the shared [PhoneWatchTransferStateStore] instead of + * remembering "the last batchId this instance kicked off", so re-entering the detail screen + * for the same playlist still sees a batch already in flight. + */ + val activePlaylistBatchTransfer: StateFlow = watchTransferStateStore.batchTransfers + .map { batches -> batches.values.firstOrNull { it.status !in TERMINAL_BATCH_STATUSES } } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000L), + initialValue = null, + ) + companion object { const val FOLDER_PLAYLIST_PREFIX = "folder_playlist:" private const val MANUAL_ORDER_MODE = "manual" private const val SMART_PLAYLIST_MAX_ITEMS = 100 + private val TERMINAL_BATCH_STATUSES = setOf( + WearTransferProgress.STATUS_COMPLETED, + WearTransferProgress.STATUS_FAILED, + WearTransferProgress.STATUS_CANCELLED, + ) fun sanitizeFileName(name: String): String { val sanitized = name.replace(Regex("[\\\\/:*?\"<>|\\s]+"), "_").trim('_') @@ -1224,4 +1263,43 @@ class PlaylistViewModel @Inject constructor( } } } + + // --- Watch transfer --- + + /** + * Refreshes reachable-watch capability + free storage. Call before showing the send-to-watch + * confirmation sheet so its estimate and availability state are current. + */ + fun refreshWatchAvailability() { + if (_isRefreshingWatchAvailability.value) return + + viewModelScope.launch { + _isRefreshingWatchAvailability.value = true + val available = wearPhoneTransferSender.isPixelPlayWatchAvailable() + _isPixelPlayWatchAvailable.value = available + _isRefreshingWatchAvailability.value = false + if (available) { + wearPhoneTransferSender.refreshWatchLibraryState() + } + } + } + + fun isPlaylistFullyOnWatch(songIds: List): Boolean { + return songIds.isNotEmpty() && songIds.all { watchTransferStateStore.isSongSavedOnAllReachableWatches(it) } + } + + /** Size/time estimate over only the songs from [songs] that aren't already on every reachable watch. */ + fun estimateWatchTransfer(songs: List): WatchPlaylistTransferEstimate { + val pendingSongs = songs.filterNot { watchTransferStateStore.isSongSavedOnAllReachableWatches(it.id) } + return WatchPlaylistTransferEstimator.estimate(songs, pendingSongs, watchAudioTranscoder) + } + + /** Returns the generated batchId immediately; the transfer itself runs asynchronously. */ + fun sendPlaylistToWatch(playlistId: String, playlistName: String, songIds: List): String { + return playlistWatchTransferCoordinator.requestPlaylistTransfer(playlistId, playlistName, songIds) + } + + fun cancelPlaylistTransfer(batchId: String) { + playlistWatchTransferCoordinator.cancelPlaylistTransfer(batchId) + } } From 6289c99a41c49fbde3e1797a58179ae16f48e463 Mon Sep 17 00:00:00 2001 From: PonceGL Date: Sat, 8 Aug 2026 13:15:12 -0600 Subject: [PATCH 2/9] feat(app): add send-to-watch action and confirmation dialog to the playlist screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New action in the playlist options sheet — labeled "Send to Watch" or "Update on Watch" depending on whether any of its songs are already there. Tapping it refreshes watch availability and opens a confirmation dialog showing pending-song count and the size/time estimate (WatchPlaylistTransferEstimator, already built) before anything is sent. A non-blocking progress banner appears at the top of the songs list once a batch is running for this playlist, with a cancel action — the user can navigate away or leave the app while it continues; the foreground notification (already built) is what tracks it from there. --- .../screens/PlaylistDetailScreen.kt | 170 ++++++++++++++++++ app/src/main/res/values/strings_screens.xml | 13 ++ 2 files changed, 183 insertions(+) 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 9783bf6c7..3c610da6c 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 @@ -136,6 +136,9 @@ import com.theveloper.pixelplay.ui.theme.GoogleSansRounded import com.theveloper.pixelplay.presentation.viewmodel.PlaylistSongsOrderMode import com.theveloper.pixelplay.utils.formatSongCount import com.theveloper.pixelplay.utils.formatTotalDuration +import com.theveloper.pixelplay.utils.formatListeningDurationCompact +import com.theveloper.pixelplay.data.service.wear.PhoneWatchBatchTransferState +import androidx.compose.material3.LinearProgressIndicator import racra.compose.smooth_corner_rect_library.AbsoluteSmoothCornerShape import sh.calvin.reorderable.ReorderableItem import sh.calvin.reorderable.rememberReorderableLazyListState @@ -183,6 +186,9 @@ fun PlaylistDetailScreen( val deletePlaylistLabel = stringResource(R.string.playlist_action_delete_playlist) val setDefaultTransitionLabel = stringResource(R.string.playlist_action_set_default_transition) val exportPlaylistLabel = stringResource(R.string.playlist_action_export_playlist) + val sendToWatchLabel = stringResource(R.string.playlist_action_send_to_watch) + val updateOnWatchLabel = stringResource(R.string.playlist_action_update_on_watch) + val sendToWatchCd = stringResource(R.string.playlist_cd_send_to_watch) val deletePlaylistConfirmTitle = stringResource(R.string.playlist_dialog_delete_title) val deletePlaylistConfirmBody = stringResource(R.string.playlist_dialog_delete_body) val sortSheetTitle = stringResource(R.string.playlist_sort_songs_title) @@ -204,6 +210,7 @@ fun PlaylistDetailScreen( var showPlaylistOptionsSheet by remember { mutableStateOf(false) } var showEditPlaylistDialog by remember { mutableStateOf(false) } var showDeleteConfirmation by remember { mutableStateOf(false) } + var showSendToWatchDialog by remember { mutableStateOf(false) } var searchQuery by remember(playlistId) { mutableStateOf("") } LaunchedEffect(searchQuery.isNotBlank()) { @@ -225,6 +232,13 @@ fun PlaylistDetailScreen( val selectedSongForInfo by playerViewModel.selectedSongForInfo.collectAsStateWithLifecycle() val favoriteIds by playerViewModel.favoriteSongIds.collectAsStateWithLifecycle() // Reintroducir favoriteIds aquí + val isPixelPlayWatchAvailable by playlistViewModel.isPixelPlayWatchAvailable.collectAsStateWithLifecycle() + val watchSongIds by playlistViewModel.watchSongIds.collectAsStateWithLifecycle() + val activeBatchTransfer by playlistViewModel.activePlaylistBatchTransfer.collectAsStateWithLifecycle() + val activePlaylistTransfer = activeBatchTransfer?.takeIf { it.playlistId == playlistId } + val isAnySongOnWatch = remember(songsInPlaylist, watchSongIds) { + songsInPlaylist.isNotEmpty() && songsInPlaylist.any { it.id in watchSongIds } + } val stableOnMoreOptionsClick: (Song) -> Unit = remember { { song -> playerViewModel.selectSongForInfo(song) @@ -367,6 +381,12 @@ fun PlaylistDetailScreen( .fillMaxSize() .padding(top = innerPadding.calculateTopPadding()) ) { + activePlaylistTransfer?.let { batch -> + WatchTransferProgressBanner( + batch = batch, + onCancelClick = { playlistViewModel.cancelPlaylistTransfer(batch.batchId) }, + ) + } val actionButtonsHeight = 42.dp val playbackControlBottomPadding = if (isFolderPlaylist) 8.dp else 6.dp if (searchQuery.isBlank()) { @@ -898,6 +918,15 @@ 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 + } + ) PlaylistActionItem( icon = painterResource(R.drawable.rounded_delete_24), label = deletePlaylistLabel, @@ -992,6 +1021,90 @@ fun PlaylistDetailScreen( ) } + if (showSendToWatchDialog && currentPlaylist != null) { + val playlistName = currentPlaylist.name + val estimate = remember(songsInPlaylist, watchSongIds) { + playlistViewModel.estimateWatchTransfer(songsInPlaylist) + } + val estimatedSizeText = android.text.format.Formatter.formatShortFileSize(context, estimate.estimatedBytes) + val estimatedTimeText = formatListeningDurationCompact(estimate.estimatedTransferSeconds * 1000L) + val canSend = isPixelPlayWatchAvailable && estimate.pendingSongCount > 0 + + AlertDialog( + onDismissRequest = { showSendToWatchDialog = false }, + title = { + Text( + if (isAnySongOnWatch) { + stringResource(R.string.playlist_send_to_watch_dialog_update_title, playlistName) + } else { + stringResource(R.string.playlist_send_to_watch_dialog_title, playlistName) + } + ) + }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + when { + !isPixelPlayWatchAvailable -> Text(stringResource(R.string.playlist_send_to_watch_dialog_watch_unavailable)) + estimate.pendingSongCount == 0 -> Text( + stringResource(R.string.playlist_send_to_watch_dialog_all_songs, estimate.totalSongCount) + ) + else -> { + Text( + if (estimate.pendingSongCount == estimate.totalSongCount) { + stringResource(R.string.playlist_send_to_watch_dialog_all_songs, estimate.totalSongCount) + } else { + stringResource( + R.string.playlist_send_to_watch_dialog_pending_songs, + estimate.pendingSongCount, + estimate.totalSongCount, + ) + } + ) + Text( + text = stringResource( + R.string.playlist_send_to_watch_dialog_estimate, + estimatedSizeText, + estimatedTimeText, + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + }, + confirmButton = { + TextButton( + enabled = canSend, + onClick = { + showSendToWatchDialog = false + playlistViewModel.sendPlaylistToWatch( + currentPlaylist.id, + playlistName, + songsInPlaylist.map { it.id }, + ) + playerViewModel.sendToast( + context.getString(R.string.playlist_watch_transfer_started_toast, playlistName) + ) + } + ) { + Text( + if (isAnySongOnWatch) { + stringResource(R.string.playlist_send_to_watch_dialog_update_confirm) + } else { + stringResource(R.string.playlist_send_to_watch_dialog_confirm) + } + ) + } + }, + dismissButton = { + TextButton(onClick = { showSendToWatchDialog = false }) { + Text(stringResource(R.string.common_cancel)) + } + } + ) + } + if (showSongInfoBottomSheet && selectedSongForInfo != null) { val currentSong = selectedSongForInfo val isFavorite = remember(currentSong?.id, favoriteIds) { @@ -1190,3 +1303,60 @@ private fun PlaylistActionItem( ) } } + +/** + * Non-blocking playlist-transfer indicator shown at the top of the songs list — the user can + * leave the screen (or the app) while it continues; the foreground notification (see + * `WatchTransferForegroundService`) is what tracks completion once they do. + */ +@Composable +private fun WatchTransferProgressBanner( + batch: PhoneWatchBatchTransferState, + onCancelClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val overallProgress by remember(batch.processedSongCount, batch.currentSongProgress, batch.totalSongCount) { + derivedStateOf { + if (batch.totalSongCount > 0) { + ((batch.processedSongCount + batch.currentSongProgress) / batch.totalSongCount.toFloat()) + .coerceIn(0f, 1f) + } else { + 0f + } + } + } + + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 8.dp) + .clip(RoundedCornerShape(18.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerHigh) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource( + R.string.watch_transfer_batch_progress, + batch.processedSongCount, + batch.totalSongCount, + ), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + LinearProgressIndicator( + progress = { overallProgress }, + modifier = Modifier + .fillMaxWidth() + .padding(top = 6.dp) + .clip(CircleShape), + ) + } + TextButton(onClick = onCancelClick) { + Text(stringResource(R.string.watch_transfer_action_cancel), maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } +} + diff --git a/app/src/main/res/values/strings_screens.xml b/app/src/main/res/values/strings_screens.xml index 90ec1b118..082191d31 100644 --- a/app/src/main/res/values/strings_screens.xml +++ b/app/src/main/res/values/strings_screens.xml @@ -126,6 +126,19 @@ Remove songs Reorder Reorder songs + Send to Watch + Update on Watch + Send playlist to watch + Send \"%1$s\" to your watch? + Update \"%1$s\" on your watch? + %1$d of %2$d songs to send + %1$d songs + %1$s · about %2$s + Send + Update + No watch connected + Sending \"%1$s\" to your watch + Couldn\'t start the transfer: %1$s Global transitions From de8be26a478f87c6cfc5cdb8419a3e39391c0928 Mon Sep 17 00:00:00 2001 From: PonceGL Date: Sat, 8 Aug 2026 13:15:19 -0600 Subject: [PATCH 3/9] feat(app): show playlist batch transfer progress in the library screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A playlist batch takes priority over a concurrent lone single-song transfer in the top bar badge and compact-navigation pill — same priority rule as the transfer notification (WatchTransferForegroundService) and the playlist screen's own banner: it's the longer-running, more significant operation, and showing both at once would be unreadable. WatchPlaylistBatchProgressDialog mirrors the existing single-song WatchTransferProgressDialog's look (loading ring + percent, wavy progress bar, cancel button) rather than reusing PlaylistDetailScreen's banner — LibraryScreen already establishes badge-tap-opens-dialog as its own convention for this, and a lone playlist name/song-count doesn't need the full list context a banner implies. --- .../presentation/screens/LibraryScreen.kt | 195 +++++++++++++++++- 1 file changed, 192 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryScreen.kt index 5d37d029e..9e92c17ac 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryScreen.kt @@ -245,6 +245,7 @@ import com.theveloper.pixelplay.presentation.components.ExpressiveScrollBar import com.theveloper.pixelplay.ui.theme.LocalShowScrollbar import com.theveloper.pixelplay.presentation.components.LibrarySortBottomSheet import com.theveloper.pixelplay.presentation.components.subcomps.EnhancedSongListItem +import com.theveloper.pixelplay.data.service.wear.PhoneWatchBatchTransferState import com.theveloper.pixelplay.data.service.wear.PhoneWatchTransferState import com.theveloper.pixelplay.shared.WearTransferProgress import java.io.File @@ -385,6 +386,131 @@ private fun WatchTransferProgressDialog( } } +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun WatchPlaylistBatchProgressDialog( + batch: PhoneWatchBatchTransferState, + onDismiss: () -> Unit, + onCancelTransfer: () -> Unit, +) { + val rawProgress = if (batch.totalSongCount > 0) { + ((batch.processedSongCount + batch.currentSongProgress) / batch.totalSongCount.toFloat()) + } else { + 0f + }.coerceIn(0f, 1f) + val animatedProgress by animateFloatAsState( + targetValue = rawProgress, + animationSpec = tween(durationMillis = 300), + label = "WatchPlaylistBatchProgressDialog" + ) + val progressPercent = (animatedProgress * 100f).toInt().coerceIn(0, 100) + val statusText = when (batch.status) { + WearTransferProgress.STATUS_TRANSFERRING -> stringResource(R.string.watch_transfer_status_transferring) + WearTransferProgress.STATUS_COMPLETED -> stringResource(R.string.watch_transfer_status_completed) + WearTransferProgress.STATUS_FAILED -> stringResource(R.string.watch_transfer_status_failed) + WearTransferProgress.STATUS_CANCELLED -> stringResource(R.string.watch_transfer_status_cancelled) + else -> stringResource(R.string.watch_transfer_status_preparing) + } + val songsText = stringResource( + R.string.watch_transfer_batch_progress, + batch.processedSongCount, + batch.totalSongCount, + ) + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties( + dismissOnBackPress = true, + dismissOnClickOutside = true + ) + ) { + Surface( + shape = RoundedCornerShape(28.dp), + tonalElevation = 6.dp, + color = MaterialTheme.colorScheme.surface + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = stringResource(R.string.watch_transfer_dialog_title), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold + ) + Box( + modifier = Modifier + .size(96.dp) + .padding(vertical = 20.dp), + contentAlignment = Alignment.Center + ) { + LoadingIndicator( + modifier = Modifier + .fillMaxSize() + .scale(1.84f), + color = MaterialTheme.colorScheme.primary + ) + Text( + text = stringResource(R.string.common_percentage_text, progressPercent), + style = MaterialTheme.typography.labelLarge.copy( + fontSize = MaterialTheme.typography.labelLarge.fontSize * 1.4f + ), + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimary + ) + } + LinearWavyProgressIndicator( + progress = { animatedProgress }, + modifier = Modifier + .fillMaxWidth() + .height(8.dp) + .clip(RoundedCornerShape(50)), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.surfaceContainerHighest + ) + Text( + text = batch.playlistName, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center + ) + Text( + text = stringResource(R.string.watch_transfer_bullet_step, statusText, songsText), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + if (batch.failedSongCount > 0) { + Text( + text = stringResource(R.string.watch_transfer_batch_failed_count, batch.failedSongCount), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center + ) + } + if (batch.status == WearTransferProgress.STATUS_TRANSFERRING) { + Button( + modifier = Modifier.padding(top = 4.dp), + onClick = onCancelTransfer, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError + ) + ) { + Text(text = stringResource(R.string.watch_transfer_action_cancel), maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } + } + } +} + private data class LibraryScreenPlayerProjection( val currentFolder: MusicFolder? = null, val folderSourceRootPath: String = "", @@ -486,6 +612,11 @@ fun LibraryScreen( val isSendingToWatch by songInfoBottomSheetViewModel.isSendingToWatch.collectAsStateWithLifecycle() val activeWatchTransfer by songInfoBottomSheetViewModel.activeWatchTransfer.collectAsStateWithLifecycle() var showWatchTransferDialog by remember { mutableStateOf(false) } + // A playlist batch takes priority over a concurrent lone single-song transfer in this badge — + // same priority rule as the transfer notification (WatchTransferForegroundService): it's the + // longer-running, more significant operation. + val activePlaylistBatchTransfer by playlistViewModel.activePlaylistBatchTransfer.collectAsStateWithLifecycle() + var showWatchBatchProgressDialog by remember { mutableStateOf(false) } val canNavigateBackInFolders by remember(playerViewModel) { playerViewModel.playerUiState .map { uiState -> uiState.currentFolder != null && uiState.folderBackGestureNavigationEnabled } @@ -508,6 +639,12 @@ fun LibraryScreen( var showReorderTabsSheet by remember { mutableStateOf(false) } var showTabSwitcherSheet by remember { mutableStateOf(false) } + LaunchedEffect(activePlaylistBatchTransfer?.batchId) { + if (activePlaylistBatchTransfer == null) { + showWatchBatchProgressDialog = false + } + } + LaunchedEffect(activeWatchTransfer?.requestId) { if (activeWatchTransfer == null) { showWatchTransferDialog = false @@ -847,14 +984,15 @@ fun LibraryScreen( TopAppBar( title = { if (isCompactNavigation) { + val isShowingWatchBadge = activePlaylistBatchTransfer != null || isSendingToWatch LibraryNavigationPill( modifier = Modifier, title = currentTabTitle, isExpanded = showTabSwitcherSheet, - showIcon = !isSendingToWatch, + showIcon = !isShowingWatchBadge, iconRes = currentTab.iconRes(), pageIndex = pagerState.currentPage, - compressForWatchTransfer = isSendingToWatch, + compressForWatchTransfer = isShowingWatchBadge, onClick = { showTabSwitcherSheet = true }, @@ -873,7 +1011,45 @@ fun LibraryScreen( } }, actions = { - if (isSendingToWatch) { + val currentBatch = activePlaylistBatchTransfer + if (currentBatch != null) { + val batchProgress = if (currentBatch.totalSongCount > 0) { + ((currentBatch.processedSongCount + currentBatch.currentSongProgress) / currentBatch.totalSongCount.toFloat()) + .coerceIn(0f, 1f) + } else { + 0f + } + val batchPercent = (batchProgress * 100f).toInt().coerceIn(0, 100) + Surface( + modifier = Modifier + .padding(end = 8.dp) + .wrapContentWidth() + .height(40.dp) + .clip(CircleShape) + .clickable { showWatchBatchProgressDialog = true }, + shape = CircleShape, + color = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer + ) { + Row( + modifier = Modifier + .padding(horizontal = 8.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(R.drawable.rounded_watch_arrow_down_24), + contentDescription = stringResource(R.string.library_cd_watch_transfer), + modifier = Modifier.size(20.dp) + ) + Text( + text = stringResource(R.string.common_percentage_text, batchPercent), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold + ) + } + } + } else if (isSendingToWatch) { val watchTransferProgress = activeWatchTransfer?.progress ?: 0f val watchTransferPercent = (watchTransferProgress * 100f).toInt().coerceIn(0, 100) Surface( @@ -1818,6 +1994,19 @@ fun LibraryScreen( } ) + if (showWatchBatchProgressDialog) { + activePlaylistBatchTransfer?.let { currentBatch -> + WatchPlaylistBatchProgressDialog( + batch = currentBatch, + onDismiss = { showWatchBatchProgressDialog = false }, + onCancelTransfer = { + playlistViewModel.cancelPlaylistTransfer(currentBatch.batchId) + showWatchBatchProgressDialog = false + } + ) + } + } + if (showWatchTransferDialog && activeWatchTransfer != null) { val currentWatchTransfer = activeWatchTransfer!! WatchTransferProgressDialog( From c0a56f0a3386a1c60f8cadc99d5d16442444960e Mon Sep 17 00:00:00 2001 From: PonceGL Date: Sat, 8 Aug 2026 13:15:25 -0600 Subject: [PATCH 4/9] test(app): add unit tests for PlaylistViewModel's watch-transfer methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers estimateWatchTransfer, isPlaylistFullyOnWatch (empty list, partial, and fully-on-watch cases), sendPlaylistToWatch, cancelPlaylistTransfer, activePlaylistBatchTransfer, and refreshWatchAvailability. The rest of PlaylistViewModel's existing surface (CRUD, sorting, AI generation, M3U import/export) is untouched and out of scope — no PlaylistViewModelTest existed before this. activePlaylistBatchTransfer is a stateIn(WhileSubscribed) flow — reading .value directly never triggers the upstream collection, so that test uses Turbine's test{} for a real subscriber instead. Verified: :app:testDebugUnitTest, full suite, 462 tests. Only the same 5 pre-existing failures unrelated to this branch. The 7 new tests in this PR are green. --- .../viewmodel/PlaylistViewModelTest.kt | 160 ++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 app/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/PlaylistViewModelTest.kt 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 new file mode 100644 index 000000000..74f00ef06 --- /dev/null +++ b/app/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/PlaylistViewModelTest.kt @@ -0,0 +1,160 @@ +package com.theveloper.pixelplay.presentation.viewmodel + +import android.content.Context +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import com.theveloper.pixelplay.MainCoroutineExtension +import com.theveloper.pixelplay.data.DailyMixManager +import com.theveloper.pixelplay.data.ai.AiPlaylistGenerator +import com.theveloper.pixelplay.data.model.Song +import com.theveloper.pixelplay.data.playlist.M3uManager +import com.theveloper.pixelplay.data.preferences.PlaylistPreferencesRepository +import com.theveloper.pixelplay.data.preferences.TelegramTopicDisplayMode +import com.theveloper.pixelplay.data.repository.MusicRepository +import com.theveloper.pixelplay.data.service.wear.PhoneWatchTransferStateStore +import com.theveloper.pixelplay.data.service.wear.PlaylistWatchTransferCoordinator +import com.theveloper.pixelplay.data.service.wear.WatchAudioTranscoder +import com.theveloper.pixelplay.data.service.wear.WearPhoneTransferSender +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith + +/** + * Covers only the watch-transfer surface this feature adds — estimateWatchTransfer, + * isPlaylistFullyOnWatch, sendPlaylistToWatch, cancelPlaylistTransfer, activePlaylistBatchTransfer, + * refreshWatchAvailability. The rest of PlaylistViewModel's large existing surface (CRUD, + * sorting, AI generation, M3U import/export) is out of scope for this change and untouched. + */ +@ExperimentalCoroutinesApi +@ExtendWith(MainCoroutineExtension::class) +class PlaylistViewModelTest { + + private val playlistPreferencesRepository = mockk() + private val musicRepository = mockk() + private val dailyMixManager = mockk(relaxed = true) + private val aiPlaylistGenerator = mockk(relaxed = true) + private val m3uManager = mockk(relaxed = true) + private val playlistWatchTransferCoordinator = mockk() + private val watchTransferStateStore = PhoneWatchTransferStateStore() + private val wearPhoneTransferSender = mockk() + private val watchAudioTranscoder = mockk() + private val context = mockk(relaxed = true) + + @BeforeEach + fun setUp() { + every { playlistPreferencesRepository.userPlaylistsFlow } returns flowOf(emptyList()) + every { playlistPreferencesRepository.playlistSongOrderModesFlow } returns flowOf(emptyMap()) + every { playlistPreferencesRepository.playlistsSortOptionFlow } returns flowOf("") + every { playlistPreferencesRepository.showTelegramCloudPlaylistsFlow } returns flowOf(true) + every { playlistPreferencesRepository.telegramTopicDisplayModeFlow } returns + flowOf(TelegramTopicDisplayMode.CHANNELS_AND_TOPICS) + } + + private fun buildViewModel() = PlaylistViewModel( + playlistPreferencesRepository = playlistPreferencesRepository, + musicRepository = musicRepository, + dailyMixManager = dailyMixManager, + aiPlaylistGenerator = aiPlaylistGenerator, + m3uManager = m3uManager, + playlistWatchTransferCoordinator = playlistWatchTransferCoordinator, + watchTransferStateStore = watchTransferStateStore, + wearPhoneTransferSender = wearPhoneTransferSender, + watchAudioTranscoder = watchAudioTranscoder, + context = context, + ) + + private fun song(id: String, mimeType: String = "audio/mpeg", bitrate: Int? = 128_000) = + Song.emptySong().copy(id = id, mimeType = mimeType, bitrate = bitrate) + + @Test + fun `estimateWatchTransfer only counts songs not already on every reachable watch`() = runTest { + coEvery { watchAudioTranscoder.transcodeIfNeeded(any(), any(), any()) } returns + WatchAudioTranscoder.TranscodeResult.Passthrough + every { watchAudioTranscoder.requiresTranscoding(any()) } returns false + watchTransferStateStore.retainReachableWatchNodes(setOf("node-1")) + watchTransferStateStore.markSongPresentOnWatch("node-1", "already-there") + val viewModel = buildViewModel() + + val estimate = viewModel.estimateWatchTransfer(listOf(song("already-there"), song("pending"))) + + assertThat(estimate.totalSongCount).isEqualTo(2) + assertThat(estimate.pendingSongCount).isEqualTo(1) + } + + @Test + fun `isPlaylistFullyOnWatch is false for an empty playlist`() { + val viewModel = buildViewModel() + assertThat(viewModel.isPlaylistFullyOnWatch(emptyList())).isFalse() + } + + @Test + fun `isPlaylistFullyOnWatch is true only once every song is on every reachable watch`() { + watchTransferStateStore.retainReachableWatchNodes(setOf("node-1")) + val viewModel = buildViewModel() + + assertThat(viewModel.isPlaylistFullyOnWatch(listOf("s1", "s2"))).isFalse() + + watchTransferStateStore.markSongPresentOnWatch("node-1", "s1") + assertThat(viewModel.isPlaylistFullyOnWatch(listOf("s1", "s2"))).isFalse() + + watchTransferStateStore.markSongPresentOnWatch("node-1", "s2") + assertThat(viewModel.isPlaylistFullyOnWatch(listOf("s1", "s2"))).isTrue() + } + + @Test + fun `sendPlaylistToWatch delegates to the coordinator and returns its batchId`() { + every { + playlistWatchTransferCoordinator.requestPlaylistTransfer("p1", "Playlist", listOf("s1", "s2")) + } returns "batch-123" + val viewModel = buildViewModel() + + val batchId = viewModel.sendPlaylistToWatch("p1", "Playlist", listOf("s1", "s2")) + + assertThat(batchId).isEqualTo("batch-123") + } + + @Test + fun `cancelPlaylistTransfer delegates to the coordinator`() { + every { playlistWatchTransferCoordinator.cancelPlaylistTransfer("batch-123") } returns Unit + val viewModel = buildViewModel() + + viewModel.cancelPlaylistTransfer("batch-123") + + io.mockk.verify { playlistWatchTransferCoordinator.cancelPlaylistTransfer("batch-123") } + } + + @Test + fun `activePlaylistBatchTransfer reflects the only non-terminal batch in the shared store`() = runTest { + // stateIn(WhileSubscribed) only starts collecting the upstream flow once something + // subscribes — reading .value without a collector never triggers it, so this needs an + // actual subscriber (Turbine's test{}), not a bare .value read. + val viewModel = buildViewModel() + + viewModel.activePlaylistBatchTransfer.test { + assertThat(awaitItem()).isNull() + + watchTransferStateStore.markBatchStarted("b1", "p1", "Playlist", totalSongCount = 3) + + assertThat(awaitItem()?.batchId).isEqualTo("b1") + } + } + + @Test + fun `refreshWatchAvailability updates isPixelPlayWatchAvailable from the sender`() = runTest { + coEvery { wearPhoneTransferSender.isPixelPlayWatchAvailable() } returns true + coEvery { wearPhoneTransferSender.refreshWatchLibraryState() } returns Result.success(Unit) + val viewModel = buildViewModel() + + viewModel.refreshWatchAvailability() + advanceUntilIdle() + + assertThat(viewModel.isPixelPlayWatchAvailable.value).isTrue() + } +} From ed40d33ea3085e35ee250ab9eab9276b26107227 Mon Sep 17 00:00:00 2001 From: PonceGL Date: Sat, 8 Aug 2026 14:52:09 -0600 Subject: [PATCH 5/9] feat(wear): receive playlist syncs from the phone into local storage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WearDataListenerService now routes the PLAYLIST_SYNC message path to WearTransferRepository.onPlaylistSyncReceived, which upserts the playlist entity and its song cross-refs (order preserved via position) into LocalPlaylistDao in one transaction. Re-syncing an existing playlistId (e.g. after editing it on the phone) replaces membership/order rather than merging with stale cross-refs, and preserves the original createdAt while bumping updatedAt — the DAO's upsertPlaylist already had this transactional behavior from PR2, this just starts calling it. The manifest's MESSAGE_RECEIVED intent filter gets a matching entry for /playlist_sync, mirroring the existing entries for the other message paths. Unlike the two pre-existing branches in the same when-block (TRANSFER_METADATA, FAVORITES_SYNC_STATE), this new branch's catch re-throws CancellationException instead of swallowing it — left the other two alone since fixing them is out of scope here. --- wear/src/main/AndroidManifest.xml | 4 + .../pixelplay/data/WearDataListenerService.kt | 16 ++ .../pixelplay/data/WearTransferRepository.kt | 32 ++++ .../WearTransferRepositoryPlaylistSyncTest.kt | 162 ++++++++++++++++++ 4 files changed, 214 insertions(+) create mode 100644 wear/src/test/java/com/theveloper/pixelplay/data/WearTransferRepositoryPlaylistSyncTest.kt diff --git a/wear/src/main/AndroidManifest.xml b/wear/src/main/AndroidManifest.xml index 4a049d1d6..1b06e4246 100644 --- a/wear/src/main/AndroidManifest.xml +++ b/wear/src/main/AndroidManifest.xml @@ -112,6 +112,10 @@ android:scheme="wear" android:host="*" android:pathPrefix="/volume_state" /> + diff --git a/wear/src/main/java/com/theveloper/pixelplay/data/WearDataListenerService.kt b/wear/src/main/java/com/theveloper/pixelplay/data/WearDataListenerService.kt index 16c99756a..ec841510d 100644 --- a/wear/src/main/java/com/theveloper/pixelplay/data/WearDataListenerService.kt +++ b/wear/src/main/java/com/theveloper/pixelplay/data/WearDataListenerService.kt @@ -17,11 +17,13 @@ import com.theveloper.pixelplay.shared.WearDataPaths import com.theveloper.pixelplay.shared.WearFavoriteSyncResponse import com.theveloper.pixelplay.shared.WearPlaybackResult import com.theveloper.pixelplay.shared.WearPlayerState +import com.theveloper.pixelplay.shared.WearPlaylistSync import com.theveloper.pixelplay.shared.WearTransferMetadata import com.theveloper.pixelplay.shared.WearTransferProgress import com.theveloper.pixelplay.shared.WearTransferRequest import com.theveloper.pixelplay.shared.WearVolumeState import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -252,6 +254,20 @@ class WearDataListenerService : WearableListenerService() { } } + WearDataPaths.PLAYLIST_SYNC -> { + scope.launch { + try { + val syncJson = String(messageEvent.data, Charsets.UTF_8) + val sync = json.decodeFromString(syncJson) + transferRepository.onPlaylistSyncReceived(sync) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Timber.tag(TAG).e(e, "Failed to process playlist sync") + } + } + } + WearDataPaths.TRANSFER_PROGRESS -> { try { val progressJson = String(messageEvent.data, Charsets.UTF_8) diff --git a/wear/src/main/java/com/theveloper/pixelplay/data/WearTransferRepository.kt b/wear/src/main/java/com/theveloper/pixelplay/data/WearTransferRepository.kt index dbe804084..4821fbdb9 100644 --- a/wear/src/main/java/com/theveloper/pixelplay/data/WearTransferRepository.kt +++ b/wear/src/main/java/com/theveloper/pixelplay/data/WearTransferRepository.kt @@ -6,10 +6,14 @@ import android.webkit.MimeTypeMap import com.google.android.gms.wearable.ChannelClient import com.google.android.gms.wearable.MessageClient import com.google.android.gms.wearable.NodeClient +import com.theveloper.pixelplay.data.local.LocalPlaylistDao +import com.theveloper.pixelplay.data.local.LocalPlaylistEntity +import com.theveloper.pixelplay.data.local.LocalPlaylistSongCrossRef import com.theveloper.pixelplay.data.local.LocalSongDao import com.theveloper.pixelplay.data.local.LocalSongEntity import com.theveloper.pixelplay.shared.WearDataPaths import com.theveloper.pixelplay.shared.WearLibraryState +import com.theveloper.pixelplay.shared.WearPlaylistSync import com.theveloper.pixelplay.shared.WearTransferMetadata import com.theveloper.pixelplay.shared.WearTransferProgress import com.theveloper.pixelplay.shared.WearTransferRequest @@ -70,6 +74,7 @@ data class TransferState( class WearTransferRepository @Inject constructor( private val application: Application, private val localSongDao: LocalSongDao, + private val localPlaylistDao: LocalPlaylistDao, private val channelClient: ChannelClient, private val messageClient: MessageClient, private val nodeClient: NodeClient, @@ -866,6 +871,33 @@ class WearTransferRepository @Inject constructor( } } + /** + * Called when a playlist sync arrives from the phone — sent once up front, before any of its + * songs' audio has necessarily finished transferring, so the watch can show the playlist and + * start playing whatever's already local right away. Idempotent: re-syncing the same + * [WearPlaylistSync.playlistId] (e.g. after the user edits the playlist on the phone) replaces + * membership/order in one transaction rather than merging with the stale cross-refs. + */ + suspend fun onPlaylistSyncReceived(sync: WearPlaylistSync) { + val now = System.currentTimeMillis() + val existing = localPlaylistDao.getPlaylistById(sync.playlistId) + val entity = LocalPlaylistEntity( + playlistId = sync.playlistId, + name = sync.name, + createdAt = existing?.createdAt ?: now, + updatedAt = now, + ) + val crossRefs = sync.songIds.mapIndexed { index, songId -> + LocalPlaylistSongCrossRef(playlistId = sync.playlistId, songId = songId, position = index) + } + localPlaylistDao.upsertPlaylist(entity, crossRefs) + Timber.tag(TAG).d( + "Playlist synced: %s (%d songs)", + sync.name, + sync.songIds.size, + ) + } + /** * Called when artwork bytes arrive over the dedicated artwork channel. * If song row exists, artwork is persisted immediately; otherwise cached until audio finishes. diff --git a/wear/src/test/java/com/theveloper/pixelplay/data/WearTransferRepositoryPlaylistSyncTest.kt b/wear/src/test/java/com/theveloper/pixelplay/data/WearTransferRepositoryPlaylistSyncTest.kt new file mode 100644 index 000000000..e081fb10b --- /dev/null +++ b/wear/src/test/java/com/theveloper/pixelplay/data/WearTransferRepositoryPlaylistSyncTest.kt @@ -0,0 +1,162 @@ +package com.theveloper.pixelplay.data + +import android.app.Application +import com.google.android.gms.wearable.ChannelClient +import com.google.android.gms.wearable.MessageClient +import com.google.android.gms.wearable.NodeClient +import com.google.common.truth.Truth.assertThat +import com.theveloper.pixelplay.MainCoroutineExtension +import com.theveloper.pixelplay.data.local.LocalPlaylistDao +import com.theveloper.pixelplay.data.local.LocalPlaylistEntity +import com.theveloper.pixelplay.data.local.LocalPlaylistSongCrossRef +import com.theveloper.pixelplay.data.local.LocalSongDao +import com.theveloper.pixelplay.shared.WearPlaylistSync +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.slot +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension + +/** + * Covers [WearTransferRepository.onPlaylistSyncReceived] only — everything else on the repository + * (song-by-song ChannelClient transfer, artwork, watchdogs) is exercised on-device, not here. + * + * [WearLocalPlayerRepository] and [WearPlaybackController] are constructed for real rather than + * mocked: both are final Kotlin classes (no `open`), so MockK could only fake them via its + * inline-mocking Java agent — which hangs indefinitely under this sandbox (see + * `PlaylistWatchTransferCoordinatorTest` in `:app` for the same constraint on the GMS side). + * Real construction needs no agent and is safe here because `onPlaylistSyncReceived` never calls + * either collaborator; [MainCoroutineExtension] supplies the `Dispatchers.Main` both of their + * `init` blocks need to launch on. + */ +@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) +class WearTransferRepositoryPlaylistSyncTest { + + companion object { + @JvmField + @RegisterExtension + val mainCoroutineExtension = MainCoroutineExtension() + } + + private val application = mockk(relaxed = true) + private val localSongDao = mockk() + private val localPlaylistDao = mockk() + private val channelClient = mockk() + private val messageClient = mockk() + private val nodeClient = mockk() + + private lateinit var repository: WearTransferRepository + + @BeforeEach + fun setUp() { + every { localSongDao.getAllSongs() } returns flowOf(emptyList()) + coEvery { localPlaylistDao.upsertPlaylist(any(), any()) } just Runs + + val stateRepository = WearStateRepository() + val localPlayerRepository = WearLocalPlayerRepository(application, localSongDao) + val playbackController = WearPlaybackController(application, stateRepository) + + repository = WearTransferRepository( + application = application, + localSongDao = localSongDao, + localPlaylistDao = localPlaylistDao, + channelClient = channelClient, + messageClient = messageClient, + nodeClient = nodeClient, + localPlayerRepository = localPlayerRepository, + stateRepository = stateRepository, + playbackController = playbackController, + ) + } + + @Test + fun `first sync sets createdAt equal to updatedAt`() = runTest { + coEvery { localPlaylistDao.getPlaylistById("p1") } returns null + val entitySlot = slot() + coEvery { localPlaylistDao.upsertPlaylist(capture(entitySlot), any()) } just Runs + + repository.onPlaylistSyncReceived(WearPlaylistSync(playlistId = "p1", name = "Road trip", songIds = listOf("s1"))) + + assertThat(entitySlot.captured.createdAt).isEqualTo(entitySlot.captured.updatedAt) + } + + @Test + fun `re-sync preserves original createdAt but bumps updatedAt`() = runTest { + val originalCreatedAt = 1_000L + coEvery { localPlaylistDao.getPlaylistById("p1") } returns LocalPlaylistEntity( + playlistId = "p1", + name = "Road trip", + createdAt = originalCreatedAt, + updatedAt = originalCreatedAt, + ) + val entitySlot = slot() + coEvery { localPlaylistDao.upsertPlaylist(capture(entitySlot), any()) } just Runs + + repository.onPlaylistSyncReceived(WearPlaylistSync(playlistId = "p1", name = "Road trip", songIds = listOf("s1", "s2"))) + + assertThat(entitySlot.captured.createdAt).isEqualTo(originalCreatedAt) + assertThat(entitySlot.captured.updatedAt).isGreaterThan(originalCreatedAt) + } + + @Test + fun `re-sync with a different song set replaces membership, not merges it`() = runTest { + coEvery { localPlaylistDao.getPlaylistById("p1") } returns null + val crossRefsSlot = slot>() + coEvery { localPlaylistDao.upsertPlaylist(any(), capture(crossRefsSlot)) } just Runs + + repository.onPlaylistSyncReceived(WearPlaylistSync(playlistId = "p1", name = "Road trip", songIds = listOf("a", "b"))) + repository.onPlaylistSyncReceived(WearPlaylistSync(playlistId = "p1", name = "Road trip", songIds = listOf("c"))) + + // The repository always regenerates the full cross-ref list from the incoming sync's + // songIds alone — it never reads current membership back in — so the last call's payload + // is exactly the new set, with no trace of the songs from the first call. + assertThat(crossRefsSlot.captured).containsExactly( + LocalPlaylistSongCrossRef(playlistId = "p1", songId = "c", position = 0), + ) + } + + @Test + fun `cross-refs preserve songId order and position from the sync payload`() = runTest { + coEvery { localPlaylistDao.getPlaylistById("p1") } returns null + val crossRefsSlot = slot>() + coEvery { localPlaylistDao.upsertPlaylist(any(), capture(crossRefsSlot)) } just Runs + + repository.onPlaylistSyncReceived( + WearPlaylistSync(playlistId = "p1", name = "Road trip", songIds = listOf("s3", "s1", "s2")) + ) + + assertThat(crossRefsSlot.captured).containsExactly( + LocalPlaylistSongCrossRef(playlistId = "p1", songId = "s3", position = 0), + LocalPlaylistSongCrossRef(playlistId = "p1", songId = "s1", position = 1), + LocalPlaylistSongCrossRef(playlistId = "p1", songId = "s2", position = 2), + ).inOrder() + } + + @Test + fun `empty song list still upserts an empty cross-ref list, not a no-op`() = runTest { + coEvery { localPlaylistDao.getPlaylistById("p1") } returns null + + repository.onPlaylistSyncReceived(WearPlaylistSync(playlistId = "p1", name = "Empty playlist", songIds = emptyList())) + + coVerify(exactly = 1) { localPlaylistDao.upsertPlaylist(any(), emptyList()) } + } + + @Test + fun `entity carries the synced name and playlistId through`() = runTest { + coEvery { localPlaylistDao.getPlaylistById("p1") } returns null + val entitySlot = slot() + coEvery { localPlaylistDao.upsertPlaylist(capture(entitySlot), any()) } just Runs + + repository.onPlaylistSyncReceived(WearPlaylistSync(playlistId = "p1", name = "Summer mix", songIds = listOf("s1"))) + + assertThat(entitySlot.captured.playlistId).isEqualTo("p1") + assertThat(entitySlot.captured.name).isEqualTo("Summer mix") + } +} From edfc2267f258503e56b114de18ea61d828032c41 Mon Sep 17 00:00:00 2001 From: PonceGL Date: Sat, 8 Aug 2026 14:52:14 -0600 Subject: [PATCH 6/9] feat(wear): add WearLocalPlaylistViewModel Backs the upcoming local-playlists screens. Resolves each playlist song's availability reactively by joining its cross-ref order against LocalSongDao.getAllSongs(), so a song that finishes transferring while the detail screen is open flips from pending to playable without the user backing out and re-entering. playlistIdsReceiving surfaces which playlists currently have an in-flight song transfer, for a receiving indicator on the list screen. playAll/playFrom skip songs still pending transfer. --- .../viewmodel/WearLocalPlaylistViewModel.kt | 115 +++++++ .../WearLocalPlaylistViewModelTest.kt | 305 ++++++++++++++++++ 2 files changed, 420 insertions(+) create mode 100644 wear/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/WearLocalPlaylistViewModel.kt create mode 100644 wear/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/WearLocalPlaylistViewModelTest.kt diff --git a/wear/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/WearLocalPlaylistViewModel.kt b/wear/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/WearLocalPlaylistViewModel.kt new file mode 100644 index 000000000..c681e9b20 --- /dev/null +++ b/wear/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/WearLocalPlaylistViewModel.kt @@ -0,0 +1,115 @@ +package com.theveloper.pixelplay.presentation.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.theveloper.pixelplay.data.TransferState +import com.theveloper.pixelplay.data.WearLocalPlayerRepository +import com.theveloper.pixelplay.data.WearOutputTarget +import com.theveloper.pixelplay.data.WearStateRepository +import com.theveloper.pixelplay.data.local.LocalPlaylistDao +import com.theveloper.pixelplay.data.local.LocalPlaylistEntity +import com.theveloper.pixelplay.data.local.LocalSongDao +import com.theveloper.pixelplay.data.local.LocalSongEntity +import com.theveloper.pixelplay.data.WearTransferRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.stateIn + +/** A song's position in a local playlist snapshot, resolved against what's actually on disk. */ +data class WearLocalPlaylistSongItem( + val songId: String, + val song: LocalSongEntity?, +) { + val isAvailable: Boolean get() = song != null +} + +/** + * Backs [com.theveloper.pixelplay.presentation.screens.LocalPlaylistsScreen] and + * [com.theveloper.pixelplay.presentation.screens.LocalPlaylistDetailScreen]. Song availability is + * resolved reactively by joining the playlist's song order against [LocalSongDao.getAllSongs], so + * a song that finishes transferring while the detail screen is open flips from pending to + * playable without the user needing to back out and re-enter. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@HiltViewModel +class WearLocalPlaylistViewModel @Inject constructor( + private val localPlaylistDao: LocalPlaylistDao, + private val localSongDao: LocalSongDao, + private val localPlayerRepository: WearLocalPlayerRepository, + private val stateRepository: WearStateRepository, + transferRepository: WearTransferRepository, +) : ViewModel() { + + val playlists: StateFlow> = localPlaylistDao.observePlaylists() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), emptyList()) + + /** In-flight song transfers from the phone, keyed by requestId — for on-screen receive feedback. */ + val activeTransfers: StateFlow> = transferRepository.activeTransfers + + /** Playlists that currently have at least one of their songs actively transferring. */ + val playlistIdsReceiving: StateFlow> = combine( + localPlaylistDao.observeAllPlaylistSongCrossRefs(), + transferRepository.activeTransfers, + ) { crossRefs, transfers -> + if (transfers.isEmpty()) { + emptySet() + } else { + val activeSongIds = transfers.values.map { it.songId }.toSet() + crossRefs.filter { it.songId in activeSongIds }.map { it.playlistId }.toSet() + } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), emptySet()) + + private val _playlistId = MutableStateFlow(null) + + val playlistDetails: StateFlow = combine( + playlists, + _playlistId, + ) { allPlaylists, playlistId -> + allPlaylists.find { it.playlistId == playlistId } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), null) + + val playlistSongs: StateFlow> = _playlistId + .flatMapLatest { playlistId -> + if (playlistId == null) { + flowOf(emptyList()) + } else { + combine( + localPlaylistDao.observePlaylistSongs(playlistId), + localSongDao.getAllSongs(), + ) { crossRefs, allSongs -> + val songsById = allSongs.associateBy { it.songId } + crossRefs.map { ref -> WearLocalPlaylistSongItem(ref.songId, songsById[ref.songId]) } + } + } + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), emptyList()) + + fun loadPlaylist(playlistId: String) { + if (_playlistId.value == playlistId) return + _playlistId.value = playlistId + } + + /** Plays every available (already-transferred) song in order, from the start. */ + fun playAll() { + val available = playlistSongs.value.mapNotNull { it.song } + if (available.isEmpty()) return + localPlayerRepository.playLocalSongs(available, startIndex = 0) + stateRepository.setOutputTarget(WearOutputTarget.WATCH) + } + + /** Plays every available song, starting from [songId] — pending songs aren't tappable. */ + fun playFrom(songId: String) { + val available = playlistSongs.value.mapNotNull { it.song } + val startIndex = available.indexOfFirst { it.songId == songId } + if (startIndex == -1) return + localPlayerRepository.playLocalSongs(available, startIndex = startIndex) + stateRepository.setOutputTarget(WearOutputTarget.WATCH) + } +} diff --git a/wear/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/WearLocalPlaylistViewModelTest.kt b/wear/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/WearLocalPlaylistViewModelTest.kt new file mode 100644 index 000000000..3b666f93a --- /dev/null +++ b/wear/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/WearLocalPlaylistViewModelTest.kt @@ -0,0 +1,305 @@ +package com.theveloper.pixelplay.presentation.viewmodel + +import android.app.Application +import app.cash.turbine.test +import com.google.android.gms.wearable.ChannelClient +import com.google.android.gms.wearable.MessageClient +import com.google.android.gms.wearable.NodeClient +import com.google.common.truth.Truth.assertThat +import com.theveloper.pixelplay.MainCoroutineExtension +import com.theveloper.pixelplay.data.WearLocalPlayerRepository +import com.theveloper.pixelplay.data.WearOutputTarget +import com.theveloper.pixelplay.data.WearPlaybackController +import com.theveloper.pixelplay.data.WearStateRepository +import com.theveloper.pixelplay.data.WearTransferRepository +import com.theveloper.pixelplay.data.local.LocalPlaylistDao +import com.theveloper.pixelplay.data.local.LocalPlaylistEntity +import com.theveloper.pixelplay.data.local.LocalPlaylistSongCrossRef +import com.theveloper.pixelplay.data.local.LocalSongDao +import com.theveloper.pixelplay.data.local.LocalSongEntity +import com.theveloper.pixelplay.shared.WearTransferProgress +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import java.io.File +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.api.extension.RegisterExtension + +/** + * [WearLocalPlayerRepository], [WearStateRepository], [WearPlaybackController] and + * [WearTransferRepository] are all constructed for real, not mocked: they're final Kotlin + * classes, and MockK can only fake a final class through its inline-mocking Java agent — which + * hangs indefinitely under this sandbox (documented in `WearTransferRepositoryPlaylistSyncTest` + * and `PlaylistWatchTransferCoordinatorTest` in `:app`). + * + * That constrains what `playAll`/`playFrom` can assert: [WearLocalPlayerRepository.playLocalSongs] + * itself is not verifiable here (it launches a coroutine that tries to bind a real + * `MediaController` to `WearPlaybackService`, which fails fast — and silently — off-device with + * no Android runtime present). What *is* real production behavior, reachable without a device, is + * the guard clause in the ViewModel that decides whether to call it at all, and the + * `stateRepository.setOutputTarget(WATCH)` call right after it — both are asserted via + * [WearStateRepository.outputTarget], a real (not mocked) collaborator. + * + * Every `stateIn`-backed property here (`playlists`, `playlistDetails`, `playlistSongs`, + * `playlistIdsReceiving`) delivers its `stateIn` initial value as a first, synchronous event to + * any new collector — *before* the upstream's real current value has had a chance to run through + * `WhileSubscribed`'s forwarding coroutine. Tests that assert on the sequence of emissions, or + * that read `.value` right after establishing a subscription, account for that placeholder + * explicitly rather than assuming the first `awaitItem()` is already the "real" one. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class WearLocalPlaylistViewModelTest { + + companion object { + @JvmField + @RegisterExtension + val mainCoroutineExtension = MainCoroutineExtension() + } + + private val application = mockk(relaxed = true) + private val localPlaylistDao = mockk() + private val localSongDao = mockk() + private val channelClient = mockk() + private val messageClient = mockk() + private val nodeClient = mockk() + + private val playlistsFlow = MutableStateFlow>(emptyList()) + private val allCrossRefsFlow = MutableStateFlow>(emptyList()) + private val playlistSongsFlowById = mutableMapOf>>() + private val allSongsFlow = MutableStateFlow>(emptyList()) + private val tempFiles = mutableListOf() + + private lateinit var stateRepository: WearStateRepository + private lateinit var transferRepository: WearTransferRepository + private lateinit var viewModel: WearLocalPlaylistViewModel + + @BeforeEach + fun setUp() { + every { localPlaylistDao.observePlaylists() } returns playlistsFlow + every { localPlaylistDao.observeAllPlaylistSongCrossRefs() } returns allCrossRefsFlow + every { localPlaylistDao.observePlaylistSongs(any()) } answers { + val playlistId = firstArg() + playlistSongsFlowById.getOrPut(playlistId) { MutableStateFlow(emptyList()) } + } + every { localSongDao.getAllSongs() } returns allSongsFlow + // WearTransferRepository's own init block treats any LocalSongEntity whose localPath + // doesn't resolve to a real, non-empty file as stale and deletes it — irrelevant to what + // this ViewModel does, but its background collector still runs and would call this on + // every song() fixture below if we didn't back them with real files (we do, see song()). + coEvery { localSongDao.deleteById(any()) } just Runs + + stateRepository = WearStateRepository() + val localPlayerRepository = WearLocalPlayerRepository(application, localSongDao) + val playbackController = WearPlaybackController(application, stateRepository) + transferRepository = WearTransferRepository( + application = application, + localSongDao = localSongDao, + localPlaylistDao = localPlaylistDao, + channelClient = channelClient, + messageClient = messageClient, + nodeClient = nodeClient, + localPlayerRepository = localPlayerRepository, + stateRepository = stateRepository, + playbackController = playbackController, + ) + + viewModel = WearLocalPlaylistViewModel( + localPlaylistDao = localPlaylistDao, + localSongDao = localSongDao, + localPlayerRepository = localPlayerRepository, + stateRepository = stateRepository, + transferRepository = transferRepository, + ) + } + + @AfterEach + fun tearDown() { + tempFiles.forEach { it.delete() } + tempFiles.clear() + } + + /** Backed by a real, non-empty file so `hasPlayableLocalFile()`-style checks see it as valid. */ + private fun song(id: String): LocalSongEntity { + val file = File.createTempFile("local-song-$id", ".m4a").apply { + writeBytes(byteArrayOf(1, 2, 3, 4)) + deleteOnExit() + } + tempFiles += file + return LocalSongEntity( + songId = id, + title = "Title $id", + artist = "Artist", + album = "Album", + albumId = 1L, + duration = 180_000L, + mimeType = "audio/mp4", + fileSize = file.length(), + bitrate = 128_000, + sampleRate = 44_100, + localPath = file.absolutePath, + transferredAt = 0L, + ) + } + + private fun crossRef(playlistId: String, songId: String, position: Int) = + LocalPlaylistSongCrossRef(playlistId = playlistId, songId = songId, position = position) + + /** Subscribes long enough for `WhileSubscribed`'s forwarding coroutine to run and update + * `.value` past the `stateIn` placeholder, then lets go — `.value` keeps the real result. */ + private suspend fun warmUp(flow: kotlinx.coroutines.flow.StateFlow<*>) { + flow.test { + awaitItem() // stateIn's initial placeholder + awaitItem() // the real, upstream-derived value + } + } + + /** + * `playAll`/`playFrom` call into [WearLocalPlayerRepository.playLocalSongs], which + * fire-and-forgets a coroutine on `Dispatchers.Main` that ends up calling + * `android.net.Uri.fromFile` — unstubbed on a bare JVM, so it NPEs. That NPE is a pure artifact + * of running off-device (see the class doc) and unrelated to what these two tests actually + * assert — but `runTest` re-resolves `Dispatchers.Main` at cleanup and drains whatever is + * queued on it before returning, so the NPE always surfaces by the time `runTest` itself + * returns, *after* the test body (and its assertions) already ran to completion. Rather than + * fight `runTest`'s cleanup — every attempt to reroute `Dispatchers.Main` away from it still + * gets drained, since the lookup happens fresh at cleanup time, not once at start — this names + * the crash explicitly instead of letting it surface as an unexplained failure. + */ + private fun expectFireAndForgetPlaybackCrash(body: suspend TestScope.() -> Unit) { + val error = assertThrows { runTest { body() } } + assertThat(error.message).contains("fromFile") + } + + @Test + fun `playlists mirrors the DAO's observePlaylists flow`() = runTest { + viewModel.playlists.test { + assertThat(awaitItem()).isEmpty() + playlistsFlow.value = listOf(LocalPlaylistEntity("p1", "Road trip", 0L, 0L)) + assertThat(awaitItem()).containsExactly(LocalPlaylistEntity("p1", "Road trip", 0L, 0L)) + } + } + + @Test + fun `playlistDetails resolves the entity matching the loaded playlistId`() = runTest { + playlistsFlow.value = listOf( + LocalPlaylistEntity("p1", "Road trip", 0L, 0L), + LocalPlaylistEntity("p2", "Gym", 0L, 0L), + ) + + viewModel.playlistDetails.test { + assertThat(awaitItem()).isNull() + viewModel.loadPlaylist("p2") + assertThat(awaitItem()?.playlistId).isEqualTo("p2") + } + } + + @Test + fun `playlistSongs marks songs without a matching local file as unavailable, in sync order`() = runTest { + playlistSongsFlowById["p1"] = MutableStateFlow( + listOf(crossRef("p1", "s1", 0), crossRef("p1", "s2", 1), crossRef("p1", "s3", 2)) + ) + allSongsFlow.value = listOf(song("s1"), song("s3")) // s2 hasn't arrived yet + + viewModel.loadPlaylist("p1") + viewModel.playlistSongs.test { + awaitItem() // stateIn's initial placeholder (emptyList) + val items = awaitItem() + assertThat(items.map { it.songId }).containsExactly("s1", "s2", "s3").inOrder() + assertThat(items.first { it.songId == "s1" }.isAvailable).isTrue() + assertThat(items.first { it.songId == "s2" }.isAvailable).isFalse() + assertThat(items.first { it.songId == "s3" }.isAvailable).isTrue() + } + } + + @Test + fun `a song flips from pending to available as soon as it lands, without reloading`() = runTest { + playlistSongsFlowById["p1"] = MutableStateFlow(listOf(crossRef("p1", "s1", 0))) + viewModel.loadPlaylist("p1") + + viewModel.playlistSongs.test { + awaitItem() // stateIn's initial placeholder (emptyList) + assertThat(awaitItem().single().isAvailable).isFalse() + allSongsFlow.value = listOf(song("s1")) + assertThat(awaitItem().single().isAvailable).isTrue() + } + } + + @Test + fun `playlistIdsReceiving reports playlists with an actively transferring member`() = runTest { + allCrossRefsFlow.value = listOf(crossRef("p1", "s1", 0), crossRef("p2", "s2", 0)) + + viewModel.playlistIdsReceiving.test { + // Unlike playlistSongs, the placeholder (emptySet) and the real first combined value + // (also emptySet, since there's no active transfer yet) are structurally equal — + // StateFlow dedups them into a single emission, so there's only one item to await here. + assertThat(awaitItem()).isEmpty() + transferRepository.onProgressReceived( + WearTransferProgress( + requestId = "r1", + songId = "s1", + bytesTransferred = 10L, + totalBytes = 100L, + status = WearTransferProgress.STATUS_TRANSFERRING, + ) + ) + assertThat(awaitItem()).containsExactly("p1") + } + } + + @Test + fun `playAll switches output to watch when at least one song is available`() = expectFireAndForgetPlaybackCrash { + playlistSongsFlowById["p1"] = MutableStateFlow(listOf(crossRef("p1", "s1", 0))) + allSongsFlow.value = listOf(song("s1")) + viewModel.loadPlaylist("p1") + warmUp(viewModel.playlistSongs) + + assertThat(stateRepository.outputTarget.value).isEqualTo(WearOutputTarget.PHONE) + viewModel.playAll() + assertThat(stateRepository.outputTarget.value).isEqualTo(WearOutputTarget.WATCH) + } + + @Test + fun `playAll is a no-op when every song is still pending transfer`() = runTest { + playlistSongsFlowById["p1"] = MutableStateFlow(listOf(crossRef("p1", "s1", 0))) + viewModel.loadPlaylist("p1") + warmUp(viewModel.playlistSongs) // s1 has no matching LocalSongEntity: pending + + viewModel.playAll() + assertThat(stateRepository.outputTarget.value).isEqualTo(WearOutputTarget.PHONE) + } + + @Test + fun `playFrom is a no-op when the requested song is still pending transfer`() = runTest { + playlistSongsFlowById["p1"] = MutableStateFlow( + listOf(crossRef("p1", "s1", 0), crossRef("p1", "s2", 1)) + ) + allSongsFlow.value = listOf(song("s1")) // s2 is pending + viewModel.loadPlaylist("p1") + warmUp(viewModel.playlistSongs) + + viewModel.playFrom("s2") + assertThat(stateRepository.outputTarget.value).isEqualTo(WearOutputTarget.PHONE) + } + + @Test + fun `playFrom an available song switches output to watch`() = expectFireAndForgetPlaybackCrash { + playlistSongsFlowById["p1"] = MutableStateFlow( + listOf(crossRef("p1", "s1", 0), crossRef("p1", "s2", 1)) + ) + allSongsFlow.value = listOf(song("s1"), song("s2")) + viewModel.loadPlaylist("p1") + warmUp(viewModel.playlistSongs) + + viewModel.playFrom("s2") + assertThat(stateRepository.outputTarget.value).isEqualTo(WearOutputTarget.WATCH) + } +} From bf0e42b35be6de89cfe5b7ec5282d2aad54b52b1 Mon Sep 17 00:00:00 2001 From: PonceGL Date: Sat, 8 Aug 2026 14:52:24 -0600 Subject: [PATCH 7/9] feat(wear): add local playlists screens and wire up navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LocalPlaylistsScreen lists playlists synced from the phone with a receiving indicator; LocalPlaylistDetailScreen shows a playlist's songs in sync order, marking pending ones as disabled with a 'waiting to transfer' label instead of hiding them, so the list's shape matches the phone immediately even before every song has arrived. Both use androidx.wear.compose.foundation.lazy's items() overload with an explicit key (playlist.playlistId / item.songId) instead of the module's usual count-based items(n){} — needed here because playlists reorder by updatedAt on every sync and songs flip availability while the screen is open, both of which lose state and break animations without a stable key. DownloadsScreen gets a new 'Playlists' entry navigating into the new screens. Reachable via Downloads → Playlists → a playlist → its songs. --- .../pixelplay/presentation/WearNavigation.kt | 43 ++- .../presentation/screens/DownloadsScreen.kt | 29 ++ .../screens/LocalPlaylistDetailScreen.kt | 267 ++++++++++++++++++ .../screens/LocalPlaylistsScreen.kt | 179 ++++++++++++ wear/src/main/res/values/strings_wear.xml | 8 + 5 files changed, 525 insertions(+), 1 deletion(-) create mode 100644 wear/src/main/java/com/theveloper/pixelplay/presentation/screens/LocalPlaylistDetailScreen.kt create mode 100644 wear/src/main/java/com/theveloper/pixelplay/presentation/screens/LocalPlaylistsScreen.kt diff --git a/wear/src/main/java/com/theveloper/pixelplay/presentation/WearNavigation.kt b/wear/src/main/java/com/theveloper/pixelplay/presentation/WearNavigation.kt index a0f7e3dc5..a918647dd 100644 --- a/wear/src/main/java/com/theveloper/pixelplay/presentation/WearNavigation.kt +++ b/wear/src/main/java/com/theveloper/pixelplay/presentation/WearNavigation.kt @@ -9,6 +9,8 @@ import androidx.wear.compose.navigation.rememberSwipeDismissableNavController import com.theveloper.pixelplay.presentation.screens.BrowseScreen import com.theveloper.pixelplay.presentation.screens.DownloadsScreen import com.theveloper.pixelplay.presentation.screens.LibraryListScreen +import com.theveloper.pixelplay.presentation.screens.LocalPlaylistDetailScreen +import com.theveloper.pixelplay.presentation.screens.LocalPlaylistsScreen import com.theveloper.pixelplay.presentation.screens.MoreScreen import com.theveloper.pixelplay.presentation.screens.OutputScreen import com.theveloper.pixelplay.presentation.screens.PlayerScreen @@ -39,6 +41,8 @@ object WearScreens { const val DOWNLOADS = "downloads" const val LIBRARY_LIST = "library_list/{browseType}/{title}" const val SONG_LIST = "song_list/{browseType}/{contextId}/{title}" + const val LOCAL_PLAYLISTS = "local_playlists" + const val LOCAL_PLAYLIST_DETAIL = "local_playlist_detail/{playlistId}/{title}" fun libraryListRoute(browseType: String, title: String): String { return "library_list/$browseType/${URLEncoder.encode(title, "UTF-8")}" @@ -47,6 +51,10 @@ object WearScreens { fun songListRoute(browseType: String, contextId: String, title: String): String { return "song_list/$browseType/$contextId/${URLEncoder.encode(title, "UTF-8")}" } + + fun localPlaylistDetailRoute(playlistId: String, title: String): String { + return "local_playlist_detail/$playlistId/${URLEncoder.encode(title, "UTF-8")}" + } } @Composable @@ -138,7 +146,40 @@ fun WearNavigation() { } composable(WearScreens.DOWNLOADS) { - DownloadsScreen() + DownloadsScreen( + onPlaylistsClick = { + navController.navigate(WearScreens.LOCAL_PLAYLISTS) { + launchSingleTop = true + } + }, + ) + } + + composable(WearScreens.LOCAL_PLAYLISTS) { + LocalPlaylistsScreen( + onPlaylistClick = { playlistId, title -> + navController.navigate( + WearScreens.localPlaylistDetailRoute(playlistId, title) + ) + }, + ) + } + + composable( + route = WearScreens.LOCAL_PLAYLIST_DETAIL, + arguments = listOf( + navArgument("playlistId") { type = NavType.StringType }, + navArgument("title") { type = NavType.StringType }, + ), + ) { backStackEntry -> + val playlistId = backStackEntry.arguments?.getString("playlistId") ?: "" + val title = URLDecoder.decode( + backStackEntry.arguments?.getString("title") ?: "", "UTF-8" + ) + LocalPlaylistDetailScreen( + playlistId = playlistId, + title = title, + ) } composable(WearScreens.BROWSE) { diff --git a/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/DownloadsScreen.kt b/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/DownloadsScreen.kt index 64141b3fa..b2aa48d7b 100644 --- a/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/DownloadsScreen.kt +++ b/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/DownloadsScreen.kt @@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.QueueMusic import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.ErrorOutline @@ -81,6 +82,7 @@ import kotlinx.coroutines.flow.collect */ @Composable fun DownloadsScreen( + onPlaylistsClick: () -> Unit = {}, viewModel: WearDownloadsViewModel = hiltViewModel(), playerViewModel: WearPlayerViewModel = hiltViewModel(), ) { @@ -183,6 +185,33 @@ fun DownloadsScreen( ) } + item { + Chip( + label = { + Text( + text = stringResource(R.string.wear_local_playlists_entry), + color = palette.textPrimary, + ) + }, + icon = { + Icon( + imageVector = Icons.AutoMirrored.Rounded.QueueMusic, + contentDescription = null, + tint = palette.textSecondary, + modifier = Modifier.size(18.dp), + ) + }, + onClick = onPlaylistsClick, + colors = ChipDefaults.chipColors( + backgroundColor = surfaceContainer, + contentColor = palette.chipContent, + ), + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 4.dp), + ) + } + if (inlineMessage != null) { item { Chip( diff --git a/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/LocalPlaylistDetailScreen.kt b/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/LocalPlaylistDetailScreen.kt new file mode 100644 index 000000000..250086cf0 --- /dev/null +++ b/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/LocalPlaylistDetailScreen.kt @@ -0,0 +1,267 @@ +package com.theveloper.pixelplay.presentation.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.MusicNote +import androidx.compose.material.icons.rounded.PlayArrow +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.wear.compose.foundation.lazy.items +import androidx.wear.compose.material.Chip +import androidx.wear.compose.material.ChipDefaults +import androidx.wear.compose.material.CircularProgressIndicator +import androidx.wear.compose.material.Icon +import androidx.wear.compose.material.MaterialTheme +import androidx.wear.compose.material.Text +import com.google.android.horologist.compose.layout.ScalingLazyColumn +import com.google.android.horologist.compose.layout.rememberResponsiveColumnState +import com.theveloper.pixelplay.R +import com.theveloper.pixelplay.presentation.components.AlwaysOnScalingPositionIndicator +import com.theveloper.pixelplay.presentation.components.PlayingEqIcon +import com.theveloper.pixelplay.presentation.components.WearTopTimeText +import com.theveloper.pixelplay.presentation.theme.LocalWearPalette +import com.theveloper.pixelplay.presentation.theme.screenBackgroundColor +import com.theveloper.pixelplay.presentation.theme.surfaceContainerColor +import com.theveloper.pixelplay.presentation.theme.surfaceContainerHighColor +import com.theveloper.pixelplay.presentation.viewmodel.WearLocalPlaylistSongItem +import com.theveloper.pixelplay.presentation.viewmodel.WearLocalPlaylistViewModel +import com.theveloper.pixelplay.presentation.viewmodel.WearPlayerViewModel + +/** + * Songs in a phone playlist synced locally on the watch. Songs still awaiting transfer show as + * disabled with a "waiting to transfer" label rather than being hidden — the list order and count + * matches the phone immediately, only playability lags behind. + */ +@Composable +fun LocalPlaylistDetailScreen( + playlistId: String, + title: String, + viewModel: WearLocalPlaylistViewModel = hiltViewModel(), + playerViewModel: WearPlayerViewModel = hiltViewModel(), +) { + val playlistDetails by viewModel.playlistDetails.collectAsStateWithLifecycle() + val songs by viewModel.playlistSongs.collectAsStateWithLifecycle() + val playerState by playerViewModel.playerState.collectAsStateWithLifecycle() + val palette = LocalWearPalette.current + val columnState = rememberResponsiveColumnState() + val background = palette.screenBackgroundColor() + val displayTitle = playlistDetails?.name ?: title + val availableCount = songs.count { it.isAvailable } + + LaunchedEffect(playlistId) { + viewModel.loadPlaylist(playlistId) + } + + Box( + modifier = Modifier + .fillMaxSize() + .background(background), + ) { + ScalingLazyColumn( + modifier = Modifier.fillMaxSize(), + columnState = columnState, + ) { + item { Spacer(modifier = Modifier.height(18.dp)) } + + item { + Text( + text = displayTitle, + style = MaterialTheme.typography.title2, + fontWeight = FontWeight(760), + color = palette.textPrimary, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 2.dp), + ) + } + + if (songs.isNotEmpty()) { + item { + Text( + text = stringResource( + R.string.wear_playlist_pending_songs, + availableCount, + songs.size, + ), + style = MaterialTheme.typography.caption2, + color = palette.textSecondary.copy(alpha = 0.82f), + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 6.dp), + ) + } + + item { + val playAllEnabled = availableCount > 0 + val playAllContentColor = if (playAllEnabled) { + palette.textPrimary + } else { + palette.textSecondary.copy(alpha = 0.72f) + } + Chip( + label = { Text(text = stringResource(R.string.wear_play_all), color = playAllContentColor) }, + icon = { + Icon( + imageVector = Icons.Rounded.PlayArrow, + contentDescription = null, + tint = playAllContentColor, + modifier = Modifier.size(18.dp), + ) + }, + onClick = { viewModel.playAll() }, + enabled = playAllEnabled, + colors = ChipDefaults.chipColors( + backgroundColor = if (playAllEnabled) { + palette.shuffleActive.copy(alpha = 0.38f) + } else { + palette.surfaceContainerHighColor() + }, + contentColor = palette.chipContent, + ), + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 4.dp), + ) + } + } + + if (songs.isEmpty()) { + item { + Text( + text = stringResource(R.string.wear_playlist_empty), + style = MaterialTheme.typography.body2, + color = palette.textSecondary.copy(alpha = 0.7f), + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + ) + } + } else { + items(items = songs, key = { it.songId }) { item -> + val isCurrentSong = item.song != null && + item.songId == playerState.songId && + playerState.songId.isNotBlank() + val isPlayingSong = isCurrentSong && playerState.isPlaying + LocalPlaylistSongChip( + item = item, + isCurrentSong = isCurrentSong, + isPlayingSong = isPlayingSong, + onClick = { if (item.isAvailable) viewModel.playFrom(item.songId) }, + ) + } + } + } + + AlwaysOnScalingPositionIndicator( + listState = columnState.state, + modifier = Modifier.align(Alignment.CenterEnd), + color = palette.textPrimary, + ) + + WearTopTimeText( + modifier = Modifier + .align(Alignment.TopCenter) + .zIndex(5f), + color = palette.textPrimary, + ) + } +} + +@Composable +private fun LocalPlaylistSongChip( + item: WearLocalPlaylistSongItem, + isCurrentSong: Boolean, + isPlayingSong: Boolean, + onClick: () -> Unit, +) { + val palette = LocalWearPalette.current + val song = item.song + val title = song?.title ?: item.songId + val containerColor = if (isCurrentSong) palette.surfaceContainerHighColor() else palette.surfaceContainerColor() + val contentAlpha = if (item.isAvailable) 1f else 0.55f + + Chip( + label = { + Text( + text = title, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = palette.textPrimary.copy(alpha = contentAlpha), + ) + }, + secondaryLabel = when { + !item.isAvailable -> { + { + Text( + text = stringResource(R.string.wear_song_pending_transfer), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = palette.textSecondary.copy(alpha = 0.72f), + ) + } + } + !song?.artist.isNullOrEmpty() -> { + { + Text( + text = song.artist.orEmpty(), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = palette.textSecondary.copy(alpha = 0.78f), + ) + } + } + else -> null + }, + icon = { + when { + isCurrentSong -> PlayingEqIcon( + color = if (isPlayingSong) palette.shuffleActive else palette.textSecondary, + isPlaying = isPlayingSong, + modifier = Modifier.size(18.dp), + ) + !item.isAvailable -> CircularProgressIndicator( + indicatorColor = palette.textSecondary.copy(alpha = 0.6f), + trackColor = palette.surfaceContainerColor(), + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + ) + else -> Icon( + imageVector = Icons.Rounded.MusicNote, + contentDescription = null, + tint = palette.textSecondary, + modifier = Modifier.size(18.dp), + ) + } + }, + onClick = onClick, + enabled = item.isAvailable, + colors = ChipDefaults.chipColors( + backgroundColor = containerColor, + contentColor = palette.chipContent, + ), + modifier = Modifier.fillMaxWidth(), + ) +} diff --git a/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/LocalPlaylistsScreen.kt b/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/LocalPlaylistsScreen.kt new file mode 100644 index 000000000..87acec1a8 --- /dev/null +++ b/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/LocalPlaylistsScreen.kt @@ -0,0 +1,179 @@ +package com.theveloper.pixelplay.presentation.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.QueueMusic +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.wear.compose.foundation.lazy.items +import androidx.wear.compose.material.Chip +import androidx.wear.compose.material.ChipDefaults +import androidx.wear.compose.material.CircularProgressIndicator +import androidx.wear.compose.material.Icon +import androidx.wear.compose.material.MaterialTheme +import androidx.wear.compose.material.Text +import com.google.android.horologist.compose.layout.ScalingLazyColumn +import com.google.android.horologist.compose.layout.rememberResponsiveColumnState +import com.theveloper.pixelplay.R +import com.theveloper.pixelplay.data.local.LocalPlaylistEntity +import com.theveloper.pixelplay.presentation.components.AlwaysOnScalingPositionIndicator +import com.theveloper.pixelplay.presentation.components.WearTopTimeText +import com.theveloper.pixelplay.presentation.theme.LocalWearPalette +import com.theveloper.pixelplay.presentation.theme.screenBackgroundColor +import com.theveloper.pixelplay.presentation.theme.surfaceContainerColor +import com.theveloper.pixelplay.presentation.theme.surfaceContainerHighColor +import com.theveloper.pixelplay.presentation.viewmodel.WearLocalPlaylistViewModel + +/** + * Playlists synced from the phone, stored locally on the watch. Tapping one opens + * [LocalPlaylistDetailScreen] regardless of whether every song has finished transferring yet — + * the playlist's membership/order arrives before its audio (see `WearPlaylistSync`), so the list + * itself is meaningful immediately. + */ +@Composable +fun LocalPlaylistsScreen( + onPlaylistClick: (playlistId: String, title: String) -> Unit, + viewModel: WearLocalPlaylistViewModel = hiltViewModel(), +) { + val playlists by viewModel.playlists.collectAsStateWithLifecycle() + val playlistIdsReceiving by viewModel.playlistIdsReceiving.collectAsStateWithLifecycle() + val palette = LocalWearPalette.current + val columnState = rememberResponsiveColumnState() + val background = palette.screenBackgroundColor() + + Box( + modifier = Modifier + .fillMaxSize() + .background(background), + ) { + ScalingLazyColumn( + modifier = Modifier.fillMaxSize(), + columnState = columnState, + ) { + item { Spacer(modifier = Modifier.height(18.dp)) } + + item { + Text( + text = stringResource(R.string.wear_playlists_title), + style = MaterialTheme.typography.title2, + fontWeight = FontWeight(760), + color = palette.textPrimary, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 4.dp), + ) + } + + if (playlists.isEmpty()) { + item { + Text( + text = stringResource(R.string.wear_no_local_playlists), + style = MaterialTheme.typography.body2, + color = palette.textSecondary.copy(alpha = 0.7f), + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + ) + } + } else { + items(items = playlists, key = { it.playlistId }) { playlist -> + LocalPlaylistChip( + playlist = playlist, + isReceiving = playlistIdsReceiving.contains(playlist.playlistId), + onClick = { onPlaylistClick(playlist.playlistId, playlist.name) }, + ) + } + } + } + + AlwaysOnScalingPositionIndicator( + listState = columnState.state, + modifier = Modifier.align(Alignment.CenterEnd), + color = palette.textPrimary, + ) + + WearTopTimeText( + modifier = Modifier + .align(Alignment.TopCenter) + .zIndex(5f), + color = palette.textPrimary, + ) + } +} + +@Composable +private fun LocalPlaylistChip( + playlist: LocalPlaylistEntity, + isReceiving: Boolean, + onClick: () -> Unit, +) { + val palette = LocalWearPalette.current + Chip( + label = { + Text( + text = playlist.name, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = palette.textPrimary, + ) + }, + secondaryLabel = if (isReceiving) { + { + Text( + text = stringResource(R.string.wear_playlist_receiving), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = palette.shuffleActive.copy(alpha = 0.90f), + ) + } + } else { + null + }, + icon = { + if (isReceiving) { + CircularProgressIndicator( + indicatorColor = palette.shuffleActive, + trackColor = palette.surfaceContainerColor(), + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + ) + } else { + Icon( + imageVector = Icons.AutoMirrored.Rounded.QueueMusic, + contentDescription = null, + tint = palette.textSecondary, + modifier = Modifier.size(18.dp), + ) + } + }, + onClick = onClick, + colors = ChipDefaults.chipColors( + backgroundColor = if (isReceiving) { + palette.surfaceContainerHighColor() + } else { + palette.surfaceContainerColor() + }, + contentColor = palette.chipContent, + ), + modifier = Modifier.fillMaxWidth(), + ) +} diff --git a/wear/src/main/res/values/strings_wear.xml b/wear/src/main/res/values/strings_wear.xml index 454dcf99c..39fd8f360 100644 --- a/wear/src/main/res/values/strings_wear.xml +++ b/wear/src/main/res/values/strings_wear.xml @@ -14,6 +14,14 @@ Scanning watch storage… Retry scan No local songs found + Playlists + No playlists sent from your phone yet + Receiving… + Playlists + %1$d of %2$d ready + Play all + This playlist has no songs + Waiting to transfer… Playing Current More options From 1c3c4a32bb00e0f57ea5e944606d849d4d2d3005 Mon Sep 17 00:00:00 2001 From: PonceGL Date: Sun, 9 Aug 2026 09:53:58 -0600 Subject: [PATCH 8/9] feat(app): add PlaylistBatchTransferPersistence for in-flight batches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Persists at most one in-flight playlist batch transfer intent (batchId, playlistId, playlistName, songIds, requestedAtMillis) to the app's shared DataStore, matching the existing *PreferencesRepository convention. Deliberately doesn't persist the rest of PhoneWatchTransferStateStore (per-song byte progress, reachable nodes, ...) — that's UI-only state, cheap to rebuild, and churns too fast to persist sensibly. Only the intent needs to survive a process restart; the coordinator already re-derives everything else when it runs a batch. clearInFlightBatch(batchId) only removes the stored intent if its batchId still matches — if a newer batch already overwrote it (e.g. the user sent another playlist before the first one's cleanup ran), clearing unconditionally would drop that newer intent instead. --- .../wear/PlaylistBatchTransferPersistence.kt | 87 +++++++++++++ .../PlaylistBatchTransferPersistenceTest.kt | 117 ++++++++++++++++++ 2 files changed, 204 insertions(+) create mode 100644 app/src/main/java/com/theveloper/pixelplay/data/service/wear/PlaylistBatchTransferPersistence.kt create mode 100644 app/src/test/java/com/theveloper/pixelplay/data/service/wear/PlaylistBatchTransferPersistenceTest.kt diff --git a/app/src/main/java/com/theveloper/pixelplay/data/service/wear/PlaylistBatchTransferPersistence.kt b/app/src/main/java/com/theveloper/pixelplay/data/service/wear/PlaylistBatchTransferPersistence.kt new file mode 100644 index 000000000..89e3214f1 --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/wear/PlaylistBatchTransferPersistence.kt @@ -0,0 +1,87 @@ +package com.theveloper.pixelplay.data.service.wear + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.flow.first +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import timber.log.Timber + +/** + * A playlist batch transfer request, in just enough detail to resume it after the phone process + * dies mid-transfer — a realistic outcome for a transfer that can run tens of minutes over + * Bluetooth, not a theoretical one (see the plan's §R-06). [songIds] is the original request, not + * whatever subset was still pending when the process died: [PlaylistWatchTransferCoordinator] + * already re-derives which of them are still needed by asking the watch what it already has, the + * same way it does for a fresh, non-resumed send. + */ +@Serializable +data class PersistedPlaylistBatchIntent( + val batchId: String, + val playlistId: String, + val playlistName: String, + val songIds: List, + val requestedAtMillis: Long, +) + +/** + * Persists at most one in-flight playlist batch intent — deliberately not the rest of + * [PhoneWatchTransferStateStore]'s state (per-song byte progress, reachable nodes, ...), which is + * UI-only, cheap to rebuild, and churns too fast to persist sensibly. Only the intent — "this + * playlist batch was requested and hadn't finished" — needs to survive a process restart. + * + * Reuses the app's single shared `DataStore` (see [com.theveloper.pixelplay.di.AppModule]) + * rather than a dedicated file, matching the existing `*PreferencesRepository` convention. + */ +@Singleton +class PlaylistBatchTransferPersistence @Inject constructor( + private val dataStore: DataStore, +) { + private val json = Json { ignoreUnknownKeys = true } + + suspend fun saveInFlightBatch(intent: PersistedPlaylistBatchIntent) { + dataStore.edit { preferences -> + preferences[Keys.IN_FLIGHT_BATCH] = json.encodeToString(intent) + } + } + + /** + * No-ops if [batchId] isn't the one currently stored: a newer batch (e.g. the user sent + * another playlist while this one was still finishing up) may already have overwritten it, + * and clearing unconditionally here would drop that newer, still-in-flight intent instead. + */ + suspend fun clearInFlightBatch(batchId: String) { + dataStore.edit { preferences -> + val stored = preferences[Keys.IN_FLIGHT_BATCH]?.let(::decode) + if (stored?.batchId == batchId) { + preferences.remove(Keys.IN_FLIGHT_BATCH) + } + } + } + + suspend fun getInFlightBatch(): PersistedPlaylistBatchIntent? { + val stored = dataStore.data.first()[Keys.IN_FLIGHT_BATCH] ?: return null + return decode(stored) + } + + private fun decode(raw: String): PersistedPlaylistBatchIntent? = try { + json.decodeFromString(raw) + } catch (e: Exception) { + Timber.tag(TAG).w(e, "Failed to decode persisted playlist batch intent, discarding it") + null + } + + private object Keys { + val IN_FLIGHT_BATCH = stringPreferencesKey("wear_playlist_batch_in_flight_v1") + } + + private companion object { + const val TAG = "PlaylistBatchPersist" + } +} diff --git a/app/src/test/java/com/theveloper/pixelplay/data/service/wear/PlaylistBatchTransferPersistenceTest.kt b/app/src/test/java/com/theveloper/pixelplay/data/service/wear/PlaylistBatchTransferPersistenceTest.kt new file mode 100644 index 000000000..fbed1de71 --- /dev/null +++ b/app/src/test/java/com/theveloper/pixelplay/data/service/wear/PlaylistBatchTransferPersistenceTest.kt @@ -0,0 +1,117 @@ +package com.theveloper.pixelplay.data.service.wear + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import com.google.common.truth.Truth.assertThat +import java.nio.file.Files +import java.nio.file.Path +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class PlaylistBatchTransferPersistenceTest { + + // DataStore's internal write-actor needs a CoroutineScope that outlives any single test + // method's own `runTest {}` block — a `runTest`-scoped `backgroundScope` gets cancelled the + // moment that particular runTest call returns, which would tear this down mid-test if it were + // built in @BeforeEach's own runTest instead of here. + private lateinit var dataStoreScope: CoroutineScope + private lateinit var tempDir: Path + private lateinit var dataStore: DataStore + private lateinit var persistence: PlaylistBatchTransferPersistence + + @BeforeEach + fun setUp() { + tempDir = Files.createTempDirectory("playlist-batch-persistence-test") + dataStoreScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + dataStore = PreferenceDataStoreFactory.create( + scope = dataStoreScope, + produceFile = { tempDir.resolve("settings.preferences_pb").toFile() }, + ) + persistence = PlaylistBatchTransferPersistence(dataStore) + } + + @AfterEach + fun tearDown() { + dataStoreScope.cancel() + tempDir.toFile().deleteRecursively() + } + + private fun intent( + batchId: String = "batch-1", + playlistId: String = "p1", + songIds: List = listOf("s1", "s2"), + ) = PersistedPlaylistBatchIntent( + batchId = batchId, + playlistId = playlistId, + playlistName = "Road trip", + songIds = songIds, + requestedAtMillis = 1_000L, + ) + + @Test + fun `nothing persisted returns null`() = runTest { + assertThat(persistence.getInFlightBatch()).isNull() + } + + @Test + fun `save then get round-trips the intent`() = runTest { + val saved = intent() + persistence.saveInFlightBatch(saved) + + assertThat(persistence.getInFlightBatch()).isEqualTo(saved) + } + + @Test + fun `saving a second batch overwrites the first`() = runTest { + persistence.saveInFlightBatch(intent(batchId = "batch-1", playlistId = "p1")) + persistence.saveInFlightBatch(intent(batchId = "batch-2", playlistId = "p2")) + + assertThat(persistence.getInFlightBatch()?.batchId).isEqualTo("batch-2") + } + + @Test + fun `clearing with the matching batchId removes it`() = runTest { + persistence.saveInFlightBatch(intent(batchId = "batch-1")) + + persistence.clearInFlightBatch("batch-1") + + assertThat(persistence.getInFlightBatch()).isNull() + } + + @Test + fun `clearing with a stale batchId is a no-op, so a newer batch survives`() = runTest { + persistence.saveInFlightBatch(intent(batchId = "batch-1")) + persistence.saveInFlightBatch(intent(batchId = "batch-2")) + + // Batch 1's own coordinator finally reaches its terminal state and tries to clear itself, + // but batch 2 already overwrote the stored intent — must not clear batch 2's. + persistence.clearInFlightBatch("batch-1") + + assertThat(persistence.getInFlightBatch()?.batchId).isEqualTo("batch-2") + } + + @Test + fun `clearing when nothing is stored does not throw`() = runTest { + persistence.clearInFlightBatch("batch-1") + + assertThat(persistence.getInFlightBatch()).isNull() + } + + @Test + fun `malformed stored data is treated as nothing persisted, not a crash`() = runTest { + dataStore.edit { preferences -> + preferences[stringPreferencesKey("wear_playlist_batch_in_flight_v1")] = "{not valid json" + } + + assertThat(persistence.getInFlightBatch()).isNull() + } +} From 53ab946858d2d872c2bca6f7e06c5293bf557d0d Mon Sep 17 00:00:00 2001 From: PonceGL Date: Sun, 9 Aug 2026 09:54:07 -0600 Subject: [PATCH 9/9] feat(app): resume an interrupted playlist watch transfer at startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PlaylistWatchTransferCoordinator now persists its batch intent when a transfer starts and clears it on every terminal outcome (completed, failed with no reachable watch, cancelled) — so a batch surviving to the next app start is exactly the ones that were cut off by the process dying mid-transfer, not a theoretical case for a transfer that can run tens of minutes over Bluetooth. resumePersistedBatchIfNeeded() re-runs any such orphaned batch: it refreshes the watch-library snapshot first (empty right after a cold start) and waits briefly for it to resolve, so the existing dedup against what's already on the watch is accurate on the first pass instead of relying solely on the watch's own duplicate rejection. Re-running from scratch is safe either way — the watch rejects a transfer for a song it already has. Wired into PixelPlayApplication.onCreate(), alongside the app's other one-shot startup work. Best-effort: a cold start not directly triggered by the user may be too restricted to start the foreground service this resumes into, so failures here are logged and skipped rather than crashing app startup — the persisted intent stays put for the next launch that can. --- .../pixelplay/PixelPlayApplication.kt | 19 +++ .../wear/PlaylistWatchTransferCoordinator.kt | 43 ++++++ .../PlaylistWatchTransferCoordinatorTest.kt | 129 ++++++++++++++++-- 3 files changed, 180 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 30014e1d0..48cf1d8ab 100644 --- a/app/src/main/java/com/theveloper/pixelplay/PixelPlayApplication.kt +++ b/app/src/main/java/com/theveloper/pixelplay/PixelPlayApplication.kt @@ -16,6 +16,7 @@ import coil.ImageLoaderFactory import com.theveloper.pixelplay.data.preferences.UserPreferencesRepository import com.theveloper.pixelplay.data.diagnostics.AdvancedPerformanceDiagnosticsController import com.theveloper.pixelplay.data.repository.ArtistImageRepository +import com.theveloper.pixelplay.data.service.wear.PlaylistWatchTransferCoordinator import com.theveloper.pixelplay.data.telegram.TelegramRepository import com.theveloper.pixelplay.presentation.viewmodel.LibraryStateHolder import com.theveloper.pixelplay.presentation.viewmodel.ThemeStateHolder @@ -72,6 +73,9 @@ class PixelPlayApplication : Application(), ImageLoaderFactory, Configuration.Pr @Inject lateinit var advancedPerformanceDiagnosticsController: dagger.Lazy + @Inject + lateinit var playlistWatchTransferCoordinator: dagger.Lazy + private val startupScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) // AÑADE EL COMPANION OBJECT @@ -130,6 +134,21 @@ class PixelPlayApplication : Application(), ImageLoaderFactory, Configuration.Pr AlbumArtCacheManager.configuredCacheLimitMb = savedLimit.toLong() } } + + startupScope.launch { + // Best-effort: a cold start not directly triggered by the user (e.g. the system + // reviving the process for an unrelated broadcast) may be too restricted to start the + // foreground service this resumes into — resumePersistedBatchIfNeeded() just skips + // resuming this time rather than crashing app startup over it; the persisted intent + // stays put for the next launch that can. + try { + playlistWatchTransferCoordinator.get().resumePersistedBatchIfNeeded() + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { + Timber.w(e, "Failed to resume an interrupted playlist watch transfer") + } + } } override fun newImageLoader(): ImageLoader { diff --git a/app/src/main/java/com/theveloper/pixelplay/data/service/wear/PlaylistWatchTransferCoordinator.kt b/app/src/main/java/com/theveloper/pixelplay/data/service/wear/PlaylistWatchTransferCoordinator.kt index 03d94c042..ece0ac012 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/service/wear/PlaylistWatchTransferCoordinator.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/wear/PlaylistWatchTransferCoordinator.kt @@ -47,6 +47,7 @@ class PlaylistWatchTransferCoordinator @Inject constructor( private val directTransferCoordinator: PhoneDirectWatchTransferCoordinator, private val wearPhoneTransferSender: WearPhoneTransferSender, private val transferStateStore: PhoneWatchTransferStateStore, + private val batchPersistence: PlaylistBatchTransferPersistence, // Injected directly (unlike most of this package, which resolves these via // Wearable.getXClient(application) internally) so this coordinator is constructible with // fakes in tests without needing to mock a static Java method. @@ -82,6 +83,31 @@ class PlaylistWatchTransferCoordinator @Inject constructor( scope.launch { wearPhoneTransferSender.cancelTransfer(activeRequestId) } } transferStateStore.markBatchCancelled(batchId) + scope.launch { batchPersistence.clearInFlightBatch(batchId) } + } + + /** + * Called once at process start ([com.theveloper.pixelplay.PixelPlayApplication]). If the + * process died mid-transfer last time, [PlaylistBatchTransferPersistence] still has that + * batch's intent — re-running it from scratch is safe and correct: the watch itself rejects + * a duplicate transfer for a song it already has (`ERROR_ALREADY_ON_WATCH`), and + * [runBatchTransfer] already skips anything [PhoneWatchTransferStateStore] can confirm is + * already there. That confirmation is only as good as the watch-library snapshot in memory — + * empty right after a cold start — so this waits (briefly) for a fresh one before resuming, + * instead of re-attempting everything and relying solely on the watch's own rejection. + */ + suspend fun resumePersistedBatchIfNeeded() { + val persisted = batchPersistence.getInFlightBatch() ?: return + Timber.tag(TAG).i( + "Resuming playlist transfer interrupted by process death: playlistId=%s (%d songs)", + persisted.playlistId, + persisted.songIds.size, + ) + runCatching { wearPhoneTransferSender.refreshWatchLibraryState() } + withTimeoutOrNull(WATCH_LIBRARY_RESOLVE_TIMEOUT_MS) { + transferStateStore.isWatchLibraryResolved.first { it } + } + requestPlaylistTransfer(persisted.playlistId, persisted.playlistName, persisted.songIds) } private suspend fun runBatchTransfer( @@ -90,11 +116,22 @@ class PlaylistWatchTransferCoordinator @Inject constructor( playlistName: String, songIds: List, ) { + batchPersistence.saveInFlightBatch( + PersistedPlaylistBatchIntent( + batchId = batchId, + playlistId = playlistId, + playlistName = playlistName, + songIds = songIds, + requestedAtMillis = System.currentTimeMillis(), + ) + ) + val nodes = resolveReachableNodes() transferStateStore.markBatchStarted(batchId, playlistId, playlistName, songIds.size) if (nodes.isEmpty()) { transferStateStore.markBatchFailed(batchId, "No reachable watch with PixelPlay") + batchPersistence.clearInFlightBatch(batchId) return } transferStateStore.retainReachableWatchNodes(nodes.map { it.id }.toSet()) @@ -128,6 +165,7 @@ class PlaylistWatchTransferCoordinator @Inject constructor( cancelledBatchIds.remove(batchId) if (transferStateStore.batchTransfers.value[batchId]?.status != WearTransferProgress.STATUS_CANCELLED) { transferStateStore.markBatchCompleted(batchId) + batchPersistence.clearInFlightBatch(batchId) } } @@ -313,6 +351,11 @@ class PlaylistWatchTransferCoordinator @Inject constructor( // mark a legitimately-slow transfer as failed. private const val DEFAULT_SONG_TRANSFER_AWAIT_TIMEOUT_MS = 300_000L + // How long resumePersistedBatchIfNeeded() waits for a fresh watch-library snapshot before + // giving up and resuming anyway. Short: this only avoids some wasted duplicate-rejected + // round-trips, it's not load-bearing for correctness (the watch rejects duplicates itself). + private const val WATCH_LIBRARY_RESOLVE_TIMEOUT_MS = 10_000L + private val TERMINAL_STATUSES = setOf( WearTransferProgress.STATUS_COMPLETED, WearTransferProgress.STATUS_FAILED, diff --git a/app/src/test/java/com/theveloper/pixelplay/data/service/wear/PlaylistWatchTransferCoordinatorTest.kt b/app/src/test/java/com/theveloper/pixelplay/data/service/wear/PlaylistWatchTransferCoordinatorTest.kt index 2609a3494..aea06d83f 100644 --- a/app/src/test/java/com/theveloper/pixelplay/data/service/wear/PlaylistWatchTransferCoordinatorTest.kt +++ b/app/src/test/java/com/theveloper/pixelplay/data/service/wear/PlaylistWatchTransferCoordinatorTest.kt @@ -1,6 +1,7 @@ package com.theveloper.pixelplay.data.service.wear import android.app.Application +import androidx.datastore.preferences.core.PreferenceDataStoreFactory import com.google.android.gms.tasks.Tasks import com.google.android.gms.wearable.CapabilityClient import com.google.android.gms.wearable.CapabilityInfo @@ -12,13 +13,16 @@ import com.theveloper.pixelplay.data.repository.MusicRepository import com.theveloper.pixelplay.shared.WearTransferProgress import io.mockk.Runs import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.every import io.mockk.just import io.mockk.mockk import io.mockk.verify +import java.nio.file.Files import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -41,9 +45,12 @@ class PlaylistWatchTransferCoordinatorTest { private val messageClient = mockk() private val transferredSongIdsInOrder = mutableListOf() + private lateinit var tempDir: java.nio.file.Path + private lateinit var batchPersistence: PlaylistBatchTransferPersistence @BeforeEach fun setUp() { + tempDir = Files.createTempDirectory("playlist-watch-transfer-coordinator-test") // Default: no song needs transcoding. transcodeIfNeeded is what the coordinator actually // calls — requiresTranscoding lives inside it and is never invoked directly by the // coordinator, so stubbing that instead would silently test nothing. @@ -61,6 +68,11 @@ class PlaylistWatchTransferCoordinatorTest { } } + @AfterEach + fun tearDown() { + tempDir.toFile().deleteRecursively() + } + private val songsById = mutableMapOf() private fun song(id: String, title: String = "Song $id"): Song { @@ -101,17 +113,26 @@ class PlaylistWatchTransferCoordinatorTest { } } - private fun buildCoordinator(scope: kotlinx.coroutines.CoroutineScope) = PlaylistWatchTransferCoordinator( - application = application, - musicRepository = musicRepository, - watchAudioTranscoder = watchAudioTranscoder, - directTransferCoordinator = directTransferCoordinator, - wearPhoneTransferSender = wearPhoneTransferSender, - transferStateStore = transferStateStore, - capabilityClient = capabilityClient, - messageClient = messageClient, - scope = scope, - ) + private fun buildCoordinator(scope: kotlinx.coroutines.CoroutineScope): PlaylistWatchTransferCoordinator { + batchPersistence = PlaylistBatchTransferPersistence( + dataStore = PreferenceDataStoreFactory.create( + scope = scope, + produceFile = { tempDir.resolve("settings.preferences_pb").toFile() }, + ), + ) + return PlaylistWatchTransferCoordinator( + application = application, + musicRepository = musicRepository, + watchAudioTranscoder = watchAudioTranscoder, + directTransferCoordinator = directTransferCoordinator, + wearPhoneTransferSender = wearPhoneTransferSender, + transferStateStore = transferStateStore, + batchPersistence = batchPersistence, + capabilityClient = capabilityClient, + messageClient = messageClient, + scope = scope, + ) + } @Test fun `an empty playlist does not start a batch`() = runTest { @@ -277,4 +298,90 @@ class PlaylistWatchTransferCoordinatorTest { assertThat(batch?.failedSongCount).isEqualTo(1) assertThat(batch?.completedSongCount).isEqualTo(0) } + + // --- Persistence: resuming a batch interrupted by process death (PR7) --- + + @Test + fun `a completed batch clears its persisted intent`() = runTest { + stubReachableNodes("node-1") + stubTransfersResolveTo(WearTransferProgress.STATUS_COMPLETED) + song("s1") + val coordinator = buildCoordinator(this) + + coordinator.requestPlaylistTransfer("p1", "Playlist", listOf("s1")) + advanceUntilIdle() + + assertThat(batchPersistence.getInFlightBatch()).isNull() + } + + @Test + fun `a batch that fails with no reachable watch clears its persisted intent`() = runTest { + stubReachableNodes() + song("s1") + val coordinator = buildCoordinator(this) + + coordinator.requestPlaylistTransfer("p1", "Playlist", listOf("s1")) + advanceUntilIdle() + + assertThat(batchPersistence.getInFlightBatch()).isNull() + } + + @Test + fun `cancelling a batch clears its persisted intent`() = runTest { + stubReachableNodes("node-1") + song("s1"); song("s2") + val coordinator = buildCoordinator(this) + lateinit var batchId: String + + every { + directTransferCoordinator.startTransferToWatch( + nodeId = any(), requestId = any(), songId = any(), + transferMode = any(), startPositionMs = any(), autoPlay = any(), audioOverride = any(), + ) + } answers { + val requestId = secondArg() + val songId = thirdArg() + coordinator.cancelPlaylistTransfer(batchId) + transferStateStore.markProgress(requestId, songId, 0L, 0L, WearTransferProgress.STATUS_COMPLETED) + } + + batchId = coordinator.requestPlaylistTransfer("p1", "Playlist", listOf("s1", "s2")) + advanceUntilIdle() + + assertThat(batchPersistence.getInFlightBatch()).isNull() + } + + @Test + fun `resuming with nothing persisted does not start a transfer`() = runTest { + val coordinator = buildCoordinator(this) + + coordinator.resumePersistedBatchIfNeeded() + advanceUntilIdle() + + assertThat(transferStateStore.batchTransfers.value).isEmpty() + verify(exactly = 0) { directTransferCoordinator.startTransferToWatch(any(), any(), any(), any(), any(), any(), any()) } + } + + @Test + fun `resuming a persisted intent re-runs the transfer for the same playlist and songs`() = runTest { + stubReachableNodes("node-1") + stubTransfersResolveTo(WearTransferProgress.STATUS_COMPLETED) + song("s1"); song("s2") + val coordinator = buildCoordinator(this) + batchPersistence.saveInFlightBatch( + PersistedPlaylistBatchIntent( + batchId = "orphaned-batch", + playlistId = "p1", + playlistName = "Playlist", + songIds = listOf("s1", "s2"), + requestedAtMillis = 0L, + ) + ) + + coordinator.resumePersistedBatchIfNeeded() + advanceUntilIdle() + + assertThat(transferredSongIdsInOrder).containsExactly("s1", "s2").inOrder() + coVerify { wearPhoneTransferSender.refreshWatchLibraryState() } + } }