diff --git a/app/src/main/java/com/theveloper/pixelplay/PixelPlayApplication.kt b/app/src/main/java/com/theveloper/pixelplay/PixelPlayApplication.kt index 30014e1d09..48cf1d8ab4 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/PhoneDirectWatchTransferCoordinator.kt b/app/src/main/java/com/theveloper/pixelplay/data/service/wear/PhoneDirectWatchTransferCoordinator.kt index 7f935a0801..e745b86c45 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/service/wear/PhoneDirectWatchTransferCoordinator.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/wear/PhoneDirectWatchTransferCoordinator.kt @@ -91,6 +91,18 @@ class PhoneDirectWatchTransferCoordinator @Inject constructor( } } + /** + * Substitutes the audio actually streamed to the watch with an already-transcoded file (see + * [WatchAudioTranscoder]), bypassing [isSongTransferEligible] and the song's own local-file + * resolution — the override file was just written locally by the transcoder, so it's + * unconditionally eligible regardless of what the original [Song]'s source was. + */ + data class WatchAudioOverride( + val file: File, + val mimeType: String, + val bitrateBps: Int, + ) + fun startTransferToWatch( nodeId: String, requestId: String, @@ -98,6 +110,7 @@ class PhoneDirectWatchTransferCoordinator @Inject constructor( transferMode: String = WearTransferRequest.MODE_SAVE_TO_LIBRARY, startPositionMs: Long = 0L, autoPlay: Boolean = false, + audioOverride: WatchAudioOverride? = null, ) { transferStateStore.markRequested( requestId = requestId, @@ -112,6 +125,7 @@ class PhoneDirectWatchTransferCoordinator @Inject constructor( transferMode = transferMode, startPositionMs = startPositionMs, autoPlay = autoPlay, + audioOverride = audioOverride, ) } } @@ -123,6 +137,7 @@ class PhoneDirectWatchTransferCoordinator @Inject constructor( transferMode: String, startPositionMs: Long, autoPlay: Boolean, + audioOverride: WatchAudioOverride? = null, ) { var openedSongSource: OpenedSongSource? = null try { @@ -138,6 +153,7 @@ class PhoneDirectWatchTransferCoordinator @Inject constructor( } if ( + audioOverride == null && transferMode == WearTransferRequest.MODE_SAVE_TO_LIBRARY && !isSongTransferEligible(song) ) { @@ -150,19 +166,23 @@ class PhoneDirectWatchTransferCoordinator @Inject constructor( return } - val songSource = openSongSource( - song = song, - allowProxyStreaming = transferMode == WearTransferRequest.MODE_TEMPORARY_PLAYBACK, - ) + val songSource = if (audioOverride != null) { + openOverrideSongSource(audioOverride) + } else { + openSongSource( + song = song, + allowProxyStreaming = transferMode == WearTransferRequest.MODE_TEMPORARY_PLAYBACK, + ) + } if (songSource == null) { sendTransferMetadataError( nodeId = nodeId, requestId = requestId, songId = song.id, - errorMessage = if (transferMode == WearTransferRequest.MODE_TEMPORARY_PLAYBACK) { - "Cannot stream audio source to watch" - } else { - "Cannot read audio file" + errorMessage = when { + audioOverride != null -> "Cannot read transcoded audio file" + transferMode == WearTransferRequest.MODE_TEMPORARY_PLAYBACK -> "Cannot stream audio source to watch" + else -> "Cannot read audio file" }, ) return @@ -182,9 +202,9 @@ class PhoneDirectWatchTransferCoordinator @Inject constructor( album = song.album, albumId = song.albumId, duration = song.duration, - mimeType = song.mimeType ?: "audio/mpeg", + mimeType = audioOverride?.mimeType ?: (song.mimeType ?: "audio/mpeg"), fileSize = fileSize, - bitrate = song.bitrate ?: 0, + bitrate = audioOverride?.bitrateBps ?: (song.bitrate ?: 0), sampleRate = song.sampleRate ?: 0, isFavorite = song.isFavorite, paletteSeedArgb = paletteSeedArgb, @@ -325,6 +345,15 @@ class PhoneDirectWatchTransferCoordinator @Inject constructor( return openHttpSongSource(streamUrl) } + private fun openOverrideSongSource(override: WatchAudioOverride): OpenedSongSource? { + val file = override.file.takeIf { it.isFile && it.canRead() && it.length() > 0L } ?: return null + return runCatching { + OpenedSongSource(inputStream = file.inputStream(), fileSize = file.length()) + }.onFailure { error -> + Timber.tag(TAG).w(error, "Failed to open transcoded override file=%s", file.absolutePath) + }.getOrNull() + } + private fun openDirectSongSource(song: Song): OpenedSongSource? { val directFile = song.path .takeIf { it.isNotBlank() } diff --git a/app/src/main/java/com/theveloper/pixelplay/data/service/wear/PhoneWatchTransferStateStore.kt b/app/src/main/java/com/theveloper/pixelplay/data/service/wear/PhoneWatchTransferStateStore.kt index 752c10e619..e9a69f9c57 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/service/wear/PhoneWatchTransferStateStore.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/wear/PhoneWatchTransferStateStore.kt @@ -33,11 +33,36 @@ data class PhoneWatchTransferState( } } +/** + * Aggregate state of a whole-playlist watch transfer, driven by [PlaylistWatchTransferCoordinator]. + * [currentSongProgress] is the 0f..1f progress of whichever song [activeRequestId] refers to + * (weighted across transcode+transfer phases by the coordinator) — the per-song byte-level detail + * lives in [PhoneWatchTransferState], keyed by that same requestId. + */ +data class PhoneWatchBatchTransferState( + val batchId: String, + val playlistId: String, + val playlistName: String, + val totalSongCount: Int, + val completedSongCount: Int = 0, + val failedSongCount: Int = 0, + val status: String = WearTransferProgress.STATUS_TRANSFERRING, + val activeRequestId: String? = null, + val currentSongTitle: String = "", + val currentSongProgress: Float = 0f, + val errorMessage: String? = null, + val updatedAtMillis: Long = System.currentTimeMillis(), +) { + val processedSongCount: Int get() = completedSongCount + failedSongCount +} + @Singleton class PhoneWatchTransferStateStore @Inject constructor() { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val _transfers = MutableStateFlow>(emptyMap()) val transfers: StateFlow> = _transfers.asStateFlow() + private val _batchTransfers = MutableStateFlow>(emptyMap()) + val batchTransfers: StateFlow> = _batchTransfers.asStateFlow() private val _reachableWatchNodeIds = MutableStateFlow>(emptySet()) val reachableWatchNodeIds: StateFlow> = _reachableWatchNodeIds.asStateFlow() private val _watchLibrarySyncedNodeIds = MutableStateFlow>(emptySet()) @@ -232,6 +257,133 @@ class PhoneWatchTransferStateStore @Inject constructor() { } } + // --- Playlist batch transfers, driven by PlaylistWatchTransferCoordinator --- + + private val batchCleanupJobs = ConcurrentHashMap() + + fun markBatchStarted(batchId: String, playlistId: String, playlistName: String, totalSongCount: Int) { + batchCleanupJobs.remove(batchId)?.cancel() + _batchTransfers.update { map -> + map + (batchId to PhoneWatchBatchTransferState( + batchId = batchId, + playlistId = playlistId, + playlistName = playlistName, + totalSongCount = totalSongCount, + status = WearTransferProgress.STATUS_TRANSFERRING, + )) + } + } + + fun markBatchSongStarted( + batchId: String, + activeRequestId: String, + songTitle: String, + startingProgress: Float = 0f, + ) { + _batchTransfers.update { map -> + val current = map[batchId] ?: return@update map + map + (batchId to current.copy( + activeRequestId = activeRequestId, + currentSongTitle = songTitle, + currentSongProgress = startingProgress.coerceIn(0f, 1f), + updatedAtMillis = System.currentTimeMillis(), + )) + } + } + + /** [status] is informational only here — [progress] is what actually drives the notification/UI. */ + fun markBatchSongProgress(batchId: String, status: String, progress: Float) { + _batchTransfers.update { map -> + val current = map[batchId] ?: return@update map + map + (batchId to current.copy( + currentSongProgress = progress.coerceIn(0f, 1f), + updatedAtMillis = System.currentTimeMillis(), + )) + } + } + + fun markBatchSongCompleted(batchId: String) { + _batchTransfers.update { map -> + val current = map[batchId] ?: return@update map + map + (batchId to current.copy( + completedSongCount = current.completedSongCount + 1, + activeRequestId = null, + currentSongProgress = 0f, + updatedAtMillis = System.currentTimeMillis(), + )) + } + } + + fun markBatchSongFailed(batchId: String, errorMessage: String? = null) { + _batchTransfers.update { map -> + val current = map[batchId] ?: return@update map + map + (batchId to current.copy( + failedSongCount = current.failedSongCount + 1, + activeRequestId = null, + currentSongProgress = 0f, + errorMessage = errorMessage ?: current.errorMessage, + updatedAtMillis = System.currentTimeMillis(), + )) + } + } + + fun markBatchCancelled(batchId: String) { + _batchTransfers.update { map -> + val current = map[batchId] ?: return@update map + map + (batchId to current.copy( + status = WearTransferProgress.STATUS_CANCELLED, + activeRequestId = null, + updatedAtMillis = System.currentTimeMillis(), + )) + } + scheduleBatchTerminalCleanup(batchId) + } + + fun markBatchFailed(batchId: String, errorMessage: String) { + _batchTransfers.update { map -> + val current = map[batchId] ?: return@update map + map + (batchId to current.copy( + status = WearTransferProgress.STATUS_FAILED, + errorMessage = errorMessage, + activeRequestId = null, + updatedAtMillis = System.currentTimeMillis(), + )) + } + scheduleBatchTerminalCleanup(batchId) + } + + fun markBatchCompleted(batchId: String) { + _batchTransfers.update { map -> + val current = map[batchId] ?: return@update map + map + (batchId to current.copy( + status = WearTransferProgress.STATUS_COMPLETED, + activeRequestId = null, + updatedAtMillis = System.currentTimeMillis(), + )) + } + scheduleBatchTerminalCleanup(batchId) + } + + private fun scheduleBatchTerminalCleanup(batchId: String) { + batchCleanupJobs.remove(batchId)?.cancel() + batchCleanupJobs[batchId] = scope.launch { + delay(TERMINAL_STATE_VISIBILITY_MS) + _batchTransfers.update { map -> + val current = map[batchId] + if (current != null && + (current.status == WearTransferProgress.STATUS_COMPLETED || + current.status == WearTransferProgress.STATUS_FAILED || + current.status == WearTransferProgress.STATUS_CANCELLED) + ) { + map - batchId + } else { + map + } + } + batchCleanupJobs.remove(batchId) + } + } + private companion object { const val TERMINAL_STATE_VISIBILITY_MS = 3500L } 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 0000000000..89e3214f11 --- /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/main/java/com/theveloper/pixelplay/data/service/wear/PlaylistWatchTransferCoordinator.kt b/app/src/main/java/com/theveloper/pixelplay/data/service/wear/PlaylistWatchTransferCoordinator.kt new file mode 100644 index 0000000000..ece0ac0125 --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/wear/PlaylistWatchTransferCoordinator.kt @@ -0,0 +1,365 @@ +package com.theveloper.pixelplay.data.service.wear + +import android.app.Application +import com.google.android.gms.wearable.CapabilityClient +import com.google.android.gms.wearable.MessageClient +import com.google.android.gms.wearable.Node +import com.theveloper.pixelplay.data.model.Song +import com.theveloper.pixelplay.data.repository.MusicRepository +import com.theveloper.pixelplay.di.AppScope +import com.theveloper.pixelplay.shared.WearCapabilities +import com.theveloper.pixelplay.shared.WearDataPaths +import com.theveloper.pixelplay.shared.WearPlaylistSync +import com.theveloper.pixelplay.shared.WearTransferProgress +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.launch +import kotlinx.coroutines.tasks.await +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import timber.log.Timber + +/** + * Orchestrates sending a whole playlist to the watch: syncs the playlist's membership/order + * first (so the watch can show it, and start playing it, before every song has arrived), then + * transfers songs that aren't already on the watch one at a time — never in parallel, to avoid + * saturating the single Bluetooth channel and spiking CPU/battery on the watch (see + * [WatchAudioTranscoder]'s doc for why the encode itself is also sequential per song). + * + * Reuses the existing single-song pipeline end to end: [WatchAudioTranscoder] decides/produces + * the audio to send, and [PhoneDirectWatchTransferCoordinator] still owns the actual chunked + * ChannelClient streaming (via its [PhoneDirectWatchTransferCoordinator.WatchAudioOverride] hook) + * and per-song cancellation. + */ +@Singleton +class PlaylistWatchTransferCoordinator @Inject constructor( + private val application: Application, + private val musicRepository: MusicRepository, + private val watchAudioTranscoder: WatchAudioTranscoder, + 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. + private val capabilityClient: CapabilityClient, + private val messageClient: MessageClient, + @AppScope private val scope: CoroutineScope, +) { + private val json = Json { ignoreUnknownKeys = true } + private val cancelledBatchIds = ConcurrentHashMap.newKeySet() + + /** + * Deliberately an instance property, not a companion `const`/`var`: tests shrink it on their + * own coordinator instance, so runs never leak a mutated timeout into unrelated tests the way + * a shared static field would. + */ + internal var songTransferAwaitTimeoutMs: Long = DEFAULT_SONG_TRANSFER_AWAIT_TIMEOUT_MS + + /** Returns the generated batchId immediately; the transfer itself runs asynchronously. */ + fun requestPlaylistTransfer(playlistId: String, playlistName: String, songIds: List): String { + val batchId = UUID.randomUUID().toString() + if (songIds.isEmpty()) return batchId + + scope.launch { + runBatchTransfer(batchId, playlistId, playlistName, songIds) + } + return batchId + } + + fun cancelPlaylistTransfer(batchId: String) { + cancelledBatchIds.add(batchId) + val activeRequestId = transferStateStore.batchTransfers.value[batchId]?.activeRequestId + if (activeRequestId != null) { + 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( + batchId: String, + playlistId: String, + 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()) + + WatchTransferForegroundService.start(application) + sendPlaylistSyncToNodes(nodes, playlistId, playlistName, songIds) + + val alreadyPresentCount = songIds.count { transferStateStore.isSongSavedOnAllReachableWatches(it) } + repeat(alreadyPresentCount) { transferStateStore.markBatchSongCompleted(batchId) } + + val pendingSongIds = songIds.filterNot { transferStateStore.isSongSavedOnAllReachableWatches(it) } + + for (songId in pendingSongIds) { + if (cancelledBatchIds.contains(batchId)) break + + val song = musicRepository.getSongsByIds(listOf(songId)).first().firstOrNull() + if (song == null) { + Timber.tag(TAG).w("Song not found for playlist transfer: songId=%s", songId) + transferStateStore.markBatchSongFailed(batchId) + continue + } + + val outcome = transferSongToAllNodes(batchId, nodes, song) + if (outcome.completed) { + transferStateStore.markBatchSongCompleted(batchId) + } else { + transferStateStore.markBatchSongFailed(batchId, outcome.errorCode) + } + } + + cancelledBatchIds.remove(batchId) + if (transferStateStore.batchTransfers.value[batchId]?.status != WearTransferProgress.STATUS_CANCELLED) { + transferStateStore.markBatchCompleted(batchId) + batchPersistence.clearInFlightBatch(batchId) + } + } + + private suspend fun resolveReachableNodes(): List { + return try { + capabilityClient.getCapability( + WearCapabilities.PIXELPLAY_WEAR_APP, + CapabilityClient.FILTER_REACHABLE, + ).await().nodes.toList() + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + Timber.tag(TAG).w(error, "Failed to resolve reachable watches for playlist transfer") + emptyList() + } + } + + private suspend fun sendPlaylistSyncToNodes( + nodes: List, + playlistId: String, + playlistName: String, + songIds: List, + ) { + val syncPayload = json.encodeToString(WearPlaylistSync(playlistId, playlistName, songIds)) + .toByteArray(Charsets.UTF_8) + nodes.forEach { node -> + try { + messageClient.sendMessage(node.id, WearDataPaths.PLAYLIST_SYNC, syncPayload).await() + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + Timber.tag(TAG).w(error, "Failed to send playlist sync to node=%s", node.id) + } + } + } + + /** Transcodes [song] once (if needed) and streams it to every reachable [nodes] in turn. */ + private suspend fun transferSongToAllNodes( + batchId: String, + nodes: List, + song: Song, + ): SongTransferResult { + if (cancelledBatchIds.contains(batchId)) return SongTransferResult(completed = false) + + val transcodeRequestId = UUID.randomUUID().toString() + transferStateStore.markBatchSongStarted(batchId, transcodeRequestId, song.title) + + val transcodeResult = watchAudioTranscoder.transcodeIfNeeded( + song = song, + requestId = transcodeRequestId, + onProgress = { fraction -> + transferStateStore.markBatchSongProgress( + batchId, + WearTransferProgress.STATUS_TRANSCODING, + fraction.coerceIn(0f, 1f) * TRANSCODE_PHASE_WEIGHT, + ) + }, + ) + if (transcodeResult is WatchAudioTranscoder.TranscodeResult.Failed) { + Timber.tag(TAG).w(transcodeResult.error, "Transcode failed for songId=%s, skipping", song.id) + return SongTransferResult(completed = false, errorCode = WearTransferProgress.ERROR_CODE_GENERIC) + } + if (cancelledBatchIds.contains(batchId)) { + watchAudioTranscoder.cleanup(transcodeResult) + return SongTransferResult(completed = false) + } + + val audioOverride = (transcodeResult as? WatchAudioTranscoder.TranscodeResult.Transcoded)?.let { transcoded -> + PhoneDirectWatchTransferCoordinator.WatchAudioOverride( + file = transcoded.outputFile, + mimeType = WatchAudioTranscoder.TRANSCODED_OUTPUT_MIME_TYPE, + bitrateBps = WatchAudioTranscoder.TARGET_BITRATE_BPS, + ) + } + val wasTranscoded = audioOverride != null + + // Send to every reachable node (not just the first) — with multiple paired watches this + // song should land on all of them. Present on at least one counts as done overall; if + // every node failed, report whichever node failed last (good enough for the UI's + // single-line failure summary). + var succeededOnAnyNode = false + var lastFailureErrorCode: String? = null + for (node in nodes) { + if (cancelledBatchIds.contains(batchId)) break + val nodeOutcome = transferSongToNode(batchId, node, song, audioOverride, wasTranscoded) + if (nodeOutcome.completed) { + succeededOnAnyNode = true + } else { + lastFailureErrorCode = nodeOutcome.errorCode + } + } + + watchAudioTranscoder.cleanup(transcodeResult) + return SongTransferResult( + completed = succeededOnAnyNode, + errorCode = if (succeededOnAnyNode) null else lastFailureErrorCode, + ) + } + + private suspend fun transferSongToNode( + batchId: String, + node: Node, + song: Song, + audioOverride: PhoneDirectWatchTransferCoordinator.WatchAudioOverride?, + wasTranscoded: Boolean, + ): SongTransferResult { + val requestId = UUID.randomUUID().toString() + // Re-targets activeRequestId to this node's request without resetting the visible + // progress: if the song was transcoded, it's already sitting at TRANSCODE_PHASE_WEIGHT. + val startingProgress = if (wasTranscoded) TRANSCODE_PHASE_WEIGHT else 0f + transferStateStore.markBatchSongStarted(batchId, requestId, song.title, startingProgress) + + val progressWatcherJob: Job = scope.launch { + transferStateStore.transfers + .mapNotNull { it[requestId] } + .collect { state -> + if (state.status == WearTransferProgress.STATUS_TRANSFERRING) { + // Transferring is the second phase for a transcoded song: continue from + // TRANSCODE_PHASE_WEIGHT up to 1.0 instead of restarting at 0. + val overallProgress = if (wasTranscoded) { + TRANSCODE_PHASE_WEIGHT + state.progress * (1f - TRANSCODE_PHASE_WEIGHT) + } else { + state.progress + } + transferStateStore.markBatchSongProgress(batchId, state.status, overallProgress) + } + } + } + + directTransferCoordinator.startTransferToWatch( + nodeId = node.id, + requestId = requestId, + songId = song.id, + audioOverride = audioOverride, + ) + + val finalState = withTimeoutOrNull(songTransferAwaitTimeoutMs) { + transferStateStore.transfers + .mapNotNull { it[requestId] } + .first { it.status in TERMINAL_STATUSES } + } + progressWatcherJob.cancel() + + if (finalState == null) { + Timber.tag(TAG).w( + "Timed out awaiting watch confirmation: songId=%s requestId=%s", + song.id, + requestId, + ) + transferStateStore.markProgress( + requestId = requestId, + songId = song.id, + bytesTransferred = 0L, + totalBytes = 0L, + status = WearTransferProgress.STATUS_FAILED, + error = "Timed out waiting for watch confirmation", + ) + return SongTransferResult(completed = false, errorCode = WearTransferProgress.ERROR_CODE_TIMED_OUT) + } + + return SongTransferResult( + completed = finalState.status == WearTransferProgress.STATUS_COMPLETED, + errorCode = if (finalState.status == WearTransferProgress.STATUS_FAILED) { + WearTransferProgress.ERROR_CODE_GENERIC + } else { + null + }, + ) + } + + private data class SongTransferResult(val completed: Boolean, val errorCode: String? = null) + + internal companion object { + private const val TAG = "PlaylistWatchTransfer" + + // Transcoding and transferring both report 0f..1f progress for the same song; weighting + // them into one continuous 0..1 scale (instead of each resetting to 0) avoids the visible + // jump-then-reset when a song moves from one phase to the other. + private const val TRANSCODE_PHASE_WEIGHT = 0.3f + + // Deliberately generous relative to the watch's own idle watchdog: leaves room for slow + // transcoding plus a slow Bluetooth link on large files. Better to wait too long than to + // 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, + WearTransferProgress.STATUS_CANCELLED, + ) + } +} diff --git a/app/src/main/java/com/theveloper/pixelplay/data/service/wear/WatchAudioTranscoder.kt b/app/src/main/java/com/theveloper/pixelplay/data/service/wear/WatchAudioTranscoder.kt index 6c12748777..793df4882a 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/service/wear/WatchAudioTranscoder.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/wear/WatchAudioTranscoder.kt @@ -193,6 +193,14 @@ class WatchAudioTranscoder @Inject constructor( companion object { /** Also used by [WatchPlaylistTransferEstimator] to size-estimate songs that will be transcoded. */ const val TARGET_BITRATE_BPS = 128_000 + + /** + * Container mime type of [transcodeIfNeeded]'s output file (an .m4a produced by + * [Transformer]'s default muxer) — used by callers reporting [WatchAudioOverride][ + * PhoneDirectWatchTransferCoordinator.WatchAudioOverride] metadata to the watch. + */ + const val TRANSCODED_OUTPUT_MIME_TYPE = "audio/mp4" + private const val TAG = "WatchAudioTranscoder" private const val MAX_PASSTHROUGH_BITRATE_BPS = 256_000 private const val PROGRESS_POLL_INTERVAL_MS = 250L diff --git a/app/src/main/java/com/theveloper/pixelplay/data/service/wear/WatchTransferForegroundService.kt b/app/src/main/java/com/theveloper/pixelplay/data/service/wear/WatchTransferForegroundService.kt index 5a956da612..a799a86b58 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/service/wear/WatchTransferForegroundService.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/wear/WatchTransferForegroundService.kt @@ -24,6 +24,7 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch import timber.log.Timber @@ -43,7 +44,10 @@ class WatchTransferForegroundService : Service() { } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - val notification = buildNotification(transferStateStore.transfers.value.values.toList()) + val notification = buildNotification( + transferStateStore.transfers.value.values.toList(), + transferStateStore.batchTransfers.value.values.toList(), + ) if (!hasStartedForeground) { startInForeground(notification) } else { @@ -63,21 +67,24 @@ class WatchTransferForegroundService : Service() { private fun observeTransfers() { transferObserverJob?.cancel() transferObserverJob = serviceScope.launch { - transferStateStore.transfers.collect { transfers -> - val states = transfers.values.toList() - if (states.isEmpty()) { - stopForegroundCompat() - stopSelf() - return@collect - } - - val notification = buildNotification(states) - if (!hasStartedForeground) { - startInForeground(notification) - } else { - notificationManager().notify(NOTIFICATION_ID, notification) + combine( + transferStateStore.transfers, + transferStateStore.batchTransfers, + ) { transfers, batches -> transfers.values.toList() to batches.values.toList() } + .collect { (transferStates, batchStates) -> + if (transferStates.isEmpty() && batchStates.isEmpty()) { + stopForegroundCompat() + stopSelf() + return@collect + } + + val notification = buildNotification(transferStates, batchStates) + if (!hasStartedForeground) { + startInForeground(notification) + } else { + notificationManager().notify(NOTIFICATION_ID, notification) + } } - } } } @@ -110,7 +117,85 @@ class WatchTransferForegroundService : Service() { hasStartedForeground = false } - private fun buildNotification(transfers: List): Notification { + /** + * A playlist batch, when one is active or just finished, takes priority over any concurrent + * lone single-song transfer (e.g. from the song info sheet) — it's the longer-running, more + * significant operation, and showing both at once would make the notification unreadable. + */ + private fun buildNotification( + transfers: List, + batches: List, + ): Notification { + val selectedBatch = batches.firstOrNull { it.status == WearTransferProgress.STATUS_TRANSFERRING } + ?: batches.maxByOrNull { it.updatedAtMillis } + return if (selectedBatch != null) { + buildBatchNotification(selectedBatch) + } else { + buildSongNotification(transfers) + } + } + + private fun buildBatchNotification(batch: PhoneWatchBatchTransferState): Notification { + val isOngoing = batch.status == WearTransferProgress.STATUS_TRANSFERRING + val title = when (batch.status) { + WearTransferProgress.STATUS_TRANSFERRING -> + getString(R.string.watch_transfer_status_sending_playlist_to_watch, batch.playlistName) + WearTransferProgress.STATUS_COMPLETED -> getString(R.string.watch_transfer_status_complete_service) + WearTransferProgress.STATUS_FAILED -> getString(R.string.watch_transfer_status_failed_service) + WearTransferProgress.STATUS_CANCELLED -> getString(R.string.watch_transfer_status_cancelled_service) + else -> getString(R.string.watch_transfer_status_preparing_service) + } + val contentText = getString( + R.string.watch_transfer_batch_progress, + batch.processedSongCount, + batch.totalSongCount, + ) + val overallProgress = if (batch.totalSongCount > 0) { + ((batch.processedSongCount + batch.currentSongProgress) / batch.totalSongCount.toFloat()) + } else { + 0f + }.coerceIn(0f, 1f) + val progressPercent = (overallProgress * 100f).toInt().coerceIn(0, 100) + + val builder = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID) + .setSmallIcon(R.drawable.monochrome_player) + .setContentTitle(title) + .setContentText(contentText) + .setContentIntent(createOpenAppPendingIntent()) + .setCategory(NotificationCompat.CATEGORY_SERVICE) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setOnlyAlertOnce(true) + .setSilent(true) + .setOngoing(isOngoing) + .setShowWhen(false) + .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) + + if (isOngoing) { + builder.setProgress(100, progressPercent, false) + } else { + builder.setProgress(0, 0, false) + } + + val detailText = buildBatchDetailedText(batch) + if (detailText.isNotBlank()) { + builder.setStyle(NotificationCompat.BigTextStyle().bigText(detailText)) + } + + return builder.build() + } + + private fun buildBatchDetailedText(batch: PhoneWatchBatchTransferState): String { + val songLine = batch.currentSongTitle.ifBlank { null } + val failedLine = if (batch.failedSongCount > 0) { + getString(R.string.watch_transfer_batch_failed_count, batch.failedSongCount) + } else { + null + } + val errorLine = batch.errorMessage?.takeIf { it.isNotBlank() } + return listOfNotNull(songLine, failedLine, errorLine).joinToString(separator = "\n") + } + + private fun buildSongNotification(transfers: List): Notification { val activeTransfers = transfers.filter { it.status == WearTransferProgress.STATUS_TRANSFERRING } val selectedTransfer = activeTransfers.maxByOrNull { it.updatedAtMillis } ?: transfers.maxByOrNull { it.updatedAtMillis } diff --git a/app/src/main/java/com/theveloper/pixelplay/di/AppModule.kt b/app/src/main/java/com/theveloper/pixelplay/di/AppModule.kt index bb6045b94d..f0e8453f8f 100644 --- a/app/src/main/java/com/theveloper/pixelplay/di/AppModule.kt +++ b/app/src/main/java/com/theveloper/pixelplay/di/AppModule.kt @@ -1,5 +1,6 @@ package com.theveloper.pixelplay.di +import android.app.Application import android.content.Context import androidx.annotation.OptIn import androidx.datastore.core.DataStore @@ -15,6 +16,9 @@ import androidx.work.WorkManager import coil.ImageLoader import coil.disk.DiskCache import coil.memory.MemoryCache +import com.google.android.gms.wearable.CapabilityClient +import com.google.android.gms.wearable.MessageClient +import com.google.android.gms.wearable.Wearable import com.theveloper.pixelplay.BuildConfig import com.theveloper.pixelplay.PixelPlayApplication import com.theveloper.pixelplay.data.database.AlbumArtThemeDao @@ -122,6 +126,20 @@ object AppModule { @MainDispatcher fun provideMainDispatcher(): CoroutineDispatcher = Dispatchers.Main + // Injected (unlike the rest of the wear/ package, which resolves these via + // Wearable.getXClient(application) internally) so PlaylistWatchTransferCoordinator can be + // constructed with fakes in tests — CapabilityClient/MessageClient are non-final abstract + // classes, so MockK can subclass them directly with no inline-mocking agent involved. + @Singleton + @Provides + fun provideCapabilityClient(application: Application): CapabilityClient = + Wearable.getCapabilityClient(application) + + @Singleton + @Provides + fun provideMessageClient(application: Application): MessageClient = + Wearable.getMessageClient(application) + @Singleton @Provides fun provideWorkManager(@ApplicationContext context: Context): WorkManager { 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 5d37d029e9..9e92c17ac4 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( 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 9783bf6c7f..3c610da6c5 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/java/com/theveloper/pixelplay/presentation/viewmodel/PlaylistViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlaylistViewModel.kt index 8c5c620ef3..a95ed3dde3 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) + } } diff --git a/app/src/main/res/values/strings_library.xml b/app/src/main/res/values/strings_library.xml index 8b7c6c2c60..539617f73e 100644 --- a/app/src/main/res/values/strings_library.xml +++ b/app/src/main/res/values/strings_library.xml @@ -265,6 +265,8 @@ Cancel transfer + %1$d songs failed + %1$d of %2$d songs %1$s / %2$s Shows live progress for phone-to-watch music transfers Watch transfers @@ -281,6 +283,7 @@ Preparing watch transfer Preparing transfer… Sending %1$d songs to watch + Sending \"%1$s\" to watch Sending to watch Starting transfer… Starting diff --git a/app/src/main/res/values/strings_screens.xml b/app/src/main/res/values/strings_screens.xml index 90ec1b118e..082191d312 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 diff --git a/app/src/test/java/com/theveloper/pixelplay/data/service/wear/PhoneWatchTransferStateStoreTest.kt b/app/src/test/java/com/theveloper/pixelplay/data/service/wear/PhoneWatchTransferStateStoreTest.kt new file mode 100644 index 0000000000..514dab13c9 --- /dev/null +++ b/app/src/test/java/com/theveloper/pixelplay/data/service/wear/PhoneWatchTransferStateStoreTest.kt @@ -0,0 +1,225 @@ +package com.theveloper.pixelplay.data.service.wear + +import com.google.common.truth.Truth.assertThat +import com.theveloper.pixelplay.shared.WearTransferProgress +import org.junit.jupiter.api.Test + +/** + * Doesn't exercise the store's terminal-state cleanup (it runs on an internal, non-injectable + * `Dispatchers.Default` scope after a fixed real-time delay — asserting on it here would mean + * either a real sleep, which `GEN-TEST-04` rules out, or refactoring the store's scope handling, + * which is out of scope for this change). Every test below only asserts on state transitions that + * are visible synchronously. + */ +class PhoneWatchTransferStateStoreTest { + + private val store = PhoneWatchTransferStateStore() + + // --- Per-song transfers (existing, previously untested) --- + + @Test + fun `markRequested creates a transferring entry`() { + store.markRequested(requestId = "r1", songId = "s1", songTitle = "Song") + + val state = store.transfers.value["r1"] + assertThat(state?.songId).isEqualTo("s1") + assertThat(state?.status).isEqualTo(WearTransferProgress.STATUS_TRANSFERRING) + } + + @Test + fun `markProgress keeps the highest bytesTransferred seen, never regresses`() { + store.markProgress("r1", "s1", bytesTransferred = 500L, totalBytes = 1000L, status = WearTransferProgress.STATUS_TRANSFERRING) + store.markProgress("r1", "s1", bytesTransferred = 200L, totalBytes = 1000L, status = WearTransferProgress.STATUS_TRANSFERRING) + + assertThat(store.transfers.value["r1"]?.bytesTransferred).isEqualTo(500L) + } + + @Test + fun `progress is the clamped ratio of bytesTransferred to totalBytes`() { + val state = PhoneWatchTransferState(requestId = "r1", songId = "s1", bytesTransferred = 50L, totalBytes = 100L) + assertThat(state.progress).isEqualTo(0.5f) + } + + @Test + fun `progress is zero when totalBytes is not yet known`() { + val state = PhoneWatchTransferState(requestId = "r1", songId = "s1", bytesTransferred = 0L, totalBytes = 0L) + assertThat(state.progress).isEqualTo(0f) + } + + @Test + fun `markCancelled marks an existing transfer as cancelled without creating a new one`() { + store.markRequested("r1", "s1") + store.markCancelled("r1", error = "user cancelled") + + val state = store.transfers.value["r1"] + assertThat(state?.status).isEqualTo(WearTransferProgress.STATUS_CANCELLED) + assertThat(state?.error).isEqualTo("user cancelled") + } + + @Test + fun `markCancelled for an unknown requestId is a no-op`() { + store.markCancelled("unknown") + assertThat(store.transfers.value).isEmpty() + } + + @Test + fun `markSongPresentOnWatch and isSongSavedOnAllReachableWatches agree once every reachable node has it`() { + store.retainReachableWatchNodes(setOf("node-1", "node-2")) + + assertThat(store.isSongSavedOnAllReachableWatches("s1")).isFalse() + + store.markSongPresentOnWatch("node-1", "s1") + assertThat(store.isSongSavedOnAllReachableWatches("s1")).isFalse() + + store.markSongPresentOnWatch("node-2", "s1") + assertThat(store.isSongSavedOnAllReachableWatches("s1")).isTrue() + } + + @Test + fun `isSongSavedOnAllReachableWatches is false when there are no reachable watches`() { + assertThat(store.isSongSavedOnAllReachableWatches("s1")).isFalse() + } + + @Test + fun `retainReachableWatchNodes forgets song presence recorded for a node that dropped out`() { + store.retainReachableWatchNodes(setOf("node-1")) + store.markSongPresentOnWatch("node-1", "s1") + assertThat(store.isSongSavedOnAllReachableWatches("s1")).isTrue() + + store.retainReachableWatchNodes(setOf("node-2")) + + assertThat(store.watchSongIds.value).isEmpty() + } + + // --- Playlist batch transfers --- + + @Test + fun `markBatchStarted publishes the initial aggregate state`() { + store.markBatchStarted("b1", "playlist-1", "Running mix", totalSongCount = 20) + + val batch = store.batchTransfers.value["b1"] + assertThat(batch?.playlistName).isEqualTo("Running mix") + assertThat(batch?.totalSongCount).isEqualTo(20) + assertThat(batch?.completedSongCount).isEqualTo(0) + assertThat(batch?.failedSongCount).isEqualTo(0) + assertThat(batch?.status).isEqualTo(WearTransferProgress.STATUS_TRANSFERRING) + } + + @Test + fun `song lifecycle updates activeRequestId, progress and completed count`() { + store.markBatchStarted("b1", "p1", "Playlist", totalSongCount = 2) + + store.markBatchSongStarted("b1", activeRequestId = "r1", songTitle = "Track 1") + var batch = store.batchTransfers.value["b1"] + assertThat(batch?.activeRequestId).isEqualTo("r1") + assertThat(batch?.currentSongTitle).isEqualTo("Track 1") + + store.markBatchSongProgress("b1", WearTransferProgress.STATUS_TRANSFERRING, progress = 0.6f) + batch = store.batchTransfers.value["b1"] + assertThat(batch?.currentSongProgress).isEqualTo(0.6f) + + store.markBatchSongCompleted("b1") + batch = store.batchTransfers.value["b1"] + assertThat(batch?.completedSongCount).isEqualTo(1) + assertThat(batch?.activeRequestId).isNull() + assertThat(batch?.currentSongProgress).isEqualTo(0f) + } + + @Test + fun `markBatchSongProgress clamps out-of-range values`() { + store.markBatchStarted("b1", "p1", "Playlist", totalSongCount = 1) + + store.markBatchSongProgress("b1", WearTransferProgress.STATUS_TRANSFERRING, progress = 1.5f) + assertThat(store.batchTransfers.value["b1"]?.currentSongProgress).isEqualTo(1f) + + store.markBatchSongProgress("b1", WearTransferProgress.STATUS_TRANSFERRING, progress = -0.5f) + assertThat(store.batchTransfers.value["b1"]?.currentSongProgress).isEqualTo(0f) + } + + @Test + fun `markBatchSongFailed increments the failure count and records the reason`() { + store.markBatchStarted("b1", "p1", "Playlist", totalSongCount = 1) + + store.markBatchSongFailed("b1", errorMessage = WearTransferProgress.ERROR_CODE_TIMED_OUT) + + val batch = store.batchTransfers.value["b1"] + assertThat(batch?.failedSongCount).isEqualTo(1) + assertThat(batch?.errorMessage).isEqualTo(WearTransferProgress.ERROR_CODE_TIMED_OUT) + } + + @Test + fun `markBatchSongFailed without a new reason keeps the previous one`() { + store.markBatchStarted("b1", "p1", "Playlist", totalSongCount = 2) + store.markBatchSongFailed("b1", errorMessage = "first failure") + + store.markBatchSongFailed("b1", errorMessage = null) + + assertThat(store.batchTransfers.value["b1"]?.errorMessage).isEqualTo("first failure") + assertThat(store.batchTransfers.value["b1"]?.failedSongCount).isEqualTo(2) + } + + @Test + fun `markBatchCompleted sets the terminal status and clears the active song`() { + store.markBatchStarted("b1", "p1", "Playlist", totalSongCount = 1) + store.markBatchSongStarted("b1", "r1", "Track") + + store.markBatchCompleted("b1") + + val batch = store.batchTransfers.value["b1"] + assertThat(batch?.status).isEqualTo(WearTransferProgress.STATUS_COMPLETED) + assertThat(batch?.activeRequestId).isNull() + } + + @Test + fun `markBatchFailed records the error message and sets the terminal status`() { + store.markBatchStarted("b1", "p1", "Playlist", totalSongCount = 1) + + store.markBatchFailed("b1", "No reachable watch with PixelPlay") + + val batch = store.batchTransfers.value["b1"] + assertThat(batch?.status).isEqualTo(WearTransferProgress.STATUS_FAILED) + assertThat(batch?.errorMessage).isEqualTo("No reachable watch with PixelPlay") + } + + @Test + fun `markBatchCancelled sets the cancelled status and keeps the song counts so far`() { + store.markBatchStarted("b1", "p1", "Playlist", totalSongCount = 3) + store.markBatchSongCompleted("b1") + + store.markBatchCancelled("b1") + + val batch = store.batchTransfers.value["b1"] + assertThat(batch?.status).isEqualTo(WearTransferProgress.STATUS_CANCELLED) + assertThat(batch?.completedSongCount).isEqualTo(1) + } + + @Test + fun `updates for an unknown batchId are ignored rather than creating a partial entry`() { + store.markBatchSongCompleted("never-started") + store.markBatchSongProgress("never-started", WearTransferProgress.STATUS_TRANSFERRING, 0.5f) + store.markBatchCompleted("never-started") + + assertThat(store.batchTransfers.value).isEmpty() + } + + @Test + fun `processedSongCount sums completed and failed songs`() { + store.markBatchStarted("b1", "p1", "Playlist", totalSongCount = 5) + store.markBatchSongCompleted("b1") + store.markBatchSongCompleted("b1") + store.markBatchSongFailed("b1") + + assertThat(store.batchTransfers.value["b1"]?.processedSongCount).isEqualTo(3) + } + + @Test + fun `two concurrent batches keep independent state`() { + store.markBatchStarted("b1", "p1", "Playlist 1", totalSongCount = 2) + store.markBatchStarted("b2", "p2", "Playlist 2", totalSongCount = 5) + + store.markBatchSongCompleted("b1") + + assertThat(store.batchTransfers.value["b1"]?.completedSongCount).isEqualTo(1) + assertThat(store.batchTransfers.value["b2"]?.completedSongCount).isEqualTo(0) + } +} 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 0000000000..fbed1de71c --- /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() + } +} 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 new file mode 100644 index 0000000000..aea06d83f4 --- /dev/null +++ b/app/src/test/java/com/theveloper/pixelplay/data/service/wear/PlaylistWatchTransferCoordinatorTest.kt @@ -0,0 +1,387 @@ +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 +import com.google.android.gms.wearable.MessageClient +import com.google.android.gms.wearable.Node +import com.google.common.truth.Truth.assertThat +import com.theveloper.pixelplay.data.model.Song +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 + +/** + * capabilityClient/messageClient are constructor-injected into the coordinator (unlike most of + * the wear/ package, which resolves them via `Wearable.getXClient(application)` internally) so + * they can be faked here directly — both are non-final abstract GMS classes, so MockK subclasses + * them with no inline-mocking agent involved. Mocking `Wearable`'s static factory methods instead + * would need that agent, which hangs indefinitely under this environment's sandboxing. + */ +class PlaylistWatchTransferCoordinatorTest { + + private val application = mockk(relaxed = true) + private val musicRepository = mockk() + private val watchAudioTranscoder = mockk() + private val directTransferCoordinator = mockk(relaxed = true) + private val wearPhoneTransferSender = mockk(relaxed = true) + private val transferStateStore = PhoneWatchTransferStateStore() + private val capabilityClient = mockk() + 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. + coEvery { watchAudioTranscoder.transcodeIfNeeded(any(), any(), any()) } returns + WatchAudioTranscoder.TranscodeResult.Passthrough + every { watchAudioTranscoder.cleanup(any()) } just Runs + + // Tasks.forResult builds a real, already-completed Task — play-services-tasks has no + // Android framework dependency for this, so it resolves correctly off-device. + every { messageClient.sendMessage(any(), any(), any()) } returns Tasks.forResult(0) + + every { musicRepository.getSongsByIds(any()) } answers { + val requestedIds = firstArg>() + flowOf(requestedIds.mapNotNull { id -> songsById[id] }) + } + } + + @AfterEach + fun tearDown() { + tempDir.toFile().deleteRecursively() + } + + private val songsById = mutableMapOf() + + private fun song(id: String, title: String = "Song $id"): Song { + val song = Song.emptySong().copy(id = id, title = title) + songsById[id] = song + return song + } + + private fun stubReachableNodes(vararg nodeIds: String) { + val nodes = nodeIds.map { nodeId -> mockk { every { id } returns nodeId } }.toSet() + val capabilityInfo = mockk { every { this@mockk.nodes } returns nodes } + every { capabilityClient.getCapability(any(), any()) } returns Tasks.forResult(capabilityInfo) + } + + /** Every startTransferToWatch call resolves to [status] as soon as it's invoked. */ + private fun stubTransfersResolveTo(status: 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() + transferredSongIdsInOrder += songId + transferStateStore.markProgress( + requestId = requestId, + songId = songId, + bytesTransferred = 100L, + totalBytes = 100L, + status = status, + ) + } + } + + 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 { + val coordinator = buildCoordinator(this) + + coordinator.requestPlaylistTransfer("p1", "Empty", emptyList()) + advanceUntilIdle() + + assertThat(transferStateStore.batchTransfers.value).isEmpty() + verify(exactly = 0) { directTransferCoordinator.startTransferToWatch(any(), any(), any(), any(), any(), any(), any()) } + } + + @Test + fun `fails the batch when no watch is reachable`() = runTest { + stubReachableNodes() + song("s1") + val coordinator = buildCoordinator(this) + + val batchId = coordinator.requestPlaylistTransfer("p1", "Playlist", listOf("s1")) + advanceUntilIdle() + + val batch = transferStateStore.batchTransfers.value[batchId] + assertThat(batch?.status).isEqualTo(WearTransferProgress.STATUS_FAILED) + } + + @Test + fun `transfers songs in playlist order`() = runTest { + stubReachableNodes("node-1") + stubTransfersResolveTo(WearTransferProgress.STATUS_COMPLETED) + song("s3"); song("s1"); song("s2") + val coordinator = buildCoordinator(this) + + coordinator.requestPlaylistTransfer("p1", "Playlist", listOf("s3", "s1", "s2")) + advanceUntilIdle() + + assertThat(transferredSongIdsInOrder).containsExactly("s3", "s1", "s2").inOrder() + } + + @Test + fun `songs already saved on every reachable watch are not re-transferred`() = runTest { + stubReachableNodes("node-1") + transferStateStore.retainReachableWatchNodes(setOf("node-1")) + transferStateStore.markSongPresentOnWatch("node-1", "already-there") + stubTransfersResolveTo(WearTransferProgress.STATUS_COMPLETED) + song("already-there"); song("pending") + val coordinator = buildCoordinator(this) + + val batchId = coordinator.requestPlaylistTransfer("p1", "Playlist", listOf("already-there", "pending")) + advanceUntilIdle() + + assertThat(transferredSongIdsInOrder).containsExactly("pending") + val batch = transferStateStore.batchTransfers.value[batchId] + assertThat(batch?.completedSongCount).isEqualTo(2) + } + + @Test + fun `one song failing does not abort the rest of the batch`() = runTest { + stubReachableNodes("node-1") + song("s1"); song("s2"); song("s3") + every { + directTransferCoordinator.startTransferToWatch( + nodeId = any(), requestId = any(), songId = any(), + transferMode = any(), startPositionMs = any(), autoPlay = any(), audioOverride = any(), + ) + } answers { + val requestId = secondArg() + val songId = thirdArg() + transferredSongIdsInOrder += songId + val status = if (songId == "s2") WearTransferProgress.STATUS_FAILED else WearTransferProgress.STATUS_COMPLETED + transferStateStore.markProgress(requestId, songId, 0L, 0L, status) + } + val coordinator = buildCoordinator(this) + + val batchId = coordinator.requestPlaylistTransfer("p1", "Playlist", listOf("s1", "s2", "s3")) + advanceUntilIdle() + + assertThat(transferredSongIdsInOrder).containsExactly("s1", "s2", "s3").inOrder() + val batch = transferStateStore.batchTransfers.value[batchId] + assertThat(batch?.completedSongCount).isEqualTo(2) + assertThat(batch?.failedSongCount).isEqualTo(1) + assertThat(batch?.status).isEqualTo(WearTransferProgress.STATUS_COMPLETED) + } + + @Test + fun `cancelling a batch stops remaining songs from being transferred`() = runTest { + stubReachableNodes("node-1") + song("s1"); song("s2"); song("s3") + 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() + transferredSongIdsInOrder += songId + if (songId == "s1") { + // Cancel mid-batch, right after the first song starts, before it resolves. + coordinator.cancelPlaylistTransfer(batchId) + } + transferStateStore.markProgress(requestId, songId, 0L, 0L, WearTransferProgress.STATUS_COMPLETED) + } + + batchId = coordinator.requestPlaylistTransfer("p1", "Playlist", listOf("s1", "s2", "s3")) + advanceUntilIdle() + + assertThat(transferredSongIdsInOrder).containsExactly("s1") + val batch = transferStateStore.batchTransfers.value[batchId] + assertThat(batch?.status).isEqualTo(WearTransferProgress.STATUS_CANCELLED) + } + + @Test + fun `a song whose transfer never reaches a terminal state is failed as timed out`() = runTest { + stubReachableNodes("node-1") + song("s1") + // directTransferCoordinator is a relaxed mock here — startTransferToWatch is a no-op and + // never pushes a terminal state into transferStateStore, simulating a watch that never + // acknowledges the transfer. + val coordinator = buildCoordinator(this) + coordinator.songTransferAwaitTimeoutMs = 50L + + val batchId = coordinator.requestPlaylistTransfer("p1", "Playlist", listOf("s1")) + advanceUntilIdle() + + val batch = transferStateStore.batchTransfers.value[batchId] + assertThat(batch?.failedSongCount).isEqualTo(1) + assertThat(batch?.status).isEqualTo(WearTransferProgress.STATUS_COMPLETED) + } + + @Test + fun `sends the song to every reachable node, counting it as one completed song`() = runTest { + stubReachableNodes("node-1", "node-2") + stubTransfersResolveTo(WearTransferProgress.STATUS_COMPLETED) + song("s1") + val coordinator = buildCoordinator(this) + + val batchId = coordinator.requestPlaylistTransfer("p1", "Playlist", listOf("s1")) + advanceUntilIdle() + + verify(exactly = 2) { + directTransferCoordinator.startTransferToWatch( + nodeId = any(), requestId = any(), songId = any(), + transferMode = any(), startPositionMs = any(), autoPlay = any(), audioOverride = any(), + ) + } + val batch = transferStateStore.batchTransfers.value[batchId] + assertThat(batch?.completedSongCount).isEqualTo(1) + } + + @Test + fun `a song missing from the library is counted as failed, not silently dropped`() = runTest { + stubReachableNodes("node-1") + // "missing" is never registered via song(), so musicRepository.getSongsByIds returns nothing for it. + val coordinator = buildCoordinator(this) + + val batchId = coordinator.requestPlaylistTransfer("p1", "Playlist", listOf("missing")) + advanceUntilIdle() + + val batch = transferStateStore.batchTransfers.value[batchId] + 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() } + } +} 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 0000000000..74f00ef06c --- /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() + } +} diff --git a/wear/src/main/AndroidManifest.xml b/wear/src/main/AndroidManifest.xml index 4a049d1d6b..1b06e4246e 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 16c99756a4..ec841510d5 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 dbe8040845..4821fbdb9b 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/main/java/com/theveloper/pixelplay/presentation/WearNavigation.kt b/wear/src/main/java/com/theveloper/pixelplay/presentation/WearNavigation.kt index a0f7e3dc53..a918647dda 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 64141b3fa0..b2aa48d7b0 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 0000000000..250086cf03 --- /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 0000000000..87acec1a84 --- /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/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 0000000000..c681e9b205 --- /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/main/res/values/strings_wear.xml b/wear/src/main/res/values/strings_wear.xml index 454dcf99cf..39fd8f3607 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 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 0000000000..e081fb10ba --- /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") + } +} 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 0000000000..3b666f93ad --- /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) + } +}