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..17da3616b4 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 @@ -1,5 +1,6 @@ package com.theveloper.pixelplay.data.service.wear +import com.theveloper.pixelplay.shared.WearPlaylistSyncAck import com.theveloper.pixelplay.shared.WearTransferProgress import javax.inject.Inject import javax.inject.Singleton @@ -8,8 +9,11 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -33,11 +37,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()) @@ -48,8 +77,19 @@ class PhoneWatchTransferStateStore @Inject constructor() { private val _watchSongIds = MutableStateFlow>(emptySet()) val watchSongIds: StateFlow> = _watchSongIds.asStateFlow() + // Replay a handful rather than 0: the ack can in principle arrive and be emitted before + // PlaylistWatchTransferCoordinator starts collecting for it (right after messageClient's own + // send call returns), and a plain event stream with no replay would silently drop it in that + // case instead of just delivering it a moment "late" to a fresh collector. + private val _playlistSyncAcks = MutableSharedFlow(replay = 8) + val playlistSyncAcks: SharedFlow = _playlistSyncAcks.asSharedFlow() + private val cleanupJobs = ConcurrentHashMap() + fun onPlaylistSyncAckReceived(ack: WearPlaylistSyncAck) { + _playlistSyncAcks.tryEmit(ack) + } + fun markRequested( requestId: String, songId: String, @@ -232,6 +272,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..e460d3f2fc --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/wear/PlaylistWatchTransferCoordinator.kt @@ -0,0 +1,477 @@ +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.WearPlaylistSyncAck +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.delay +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) + val songTitles = resolveSongTitlesInOrder(songIds) + sendPlaylistSyncToNodes(nodes, playlistId, playlistName, songIds, songTitles) + + 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 = transferSongToAllNodesWithRetry(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() + } + } + + /** + * `MessageClient.sendMessage()` succeeding only means the message was handed off locally, not + * that the watch received it — real hardware testing showed a sync sent while the watch was + * mid-reconnect (its Wi-Fi/ADB link drops intermittently under this app's own load) is + * silently lost: the watch ends up with every song's audio on disk but no playlist row to + * show them under, because nothing here ever knew the sync didn't land. Each node now gets a + * fresh [WearPlaylistSync.requestId] and this waits for the matching [WearPlaylistSyncAck] + * (see [WearTransferRepository][com.theveloper.pixelplay.data.WearTransferRepository] + * `.onPlaylistSyncReceived` on the watch side), retrying once — same shape as + * [transferSongToAllNodesWithRetry] — before giving up and logging it. Giving up doesn't fail + * the batch: songs still transfer either way, and the next explicit re-sync (or "update on + * watch") is idempotent and gets another chance. + */ + private suspend fun sendPlaylistSyncToNodes( + nodes: List, + playlistId: String, + playlistName: String, + songIds: List, + songTitles: List, + ) { + nodes.forEach { node -> + sendPlaylistSyncToNodeWithRetry(node, playlistId, playlistName, songIds, songTitles) + } + } + + private suspend fun sendPlaylistSyncToNodeWithRetry( + node: Node, + playlistId: String, + playlistName: String, + songIds: List, + songTitles: List, + ) { + if (sendPlaylistSyncToNodeAndAwaitAck(node, playlistId, playlistName, songIds, songTitles)) return + + Timber.tag(TAG).w( + "Retrying playlist sync after missing ack: playlistId=%s node=%s", + playlistId, + node.id, + ) + delay(RETRY_BACKOFF_MS) + val ackedOnRetry = sendPlaylistSyncToNodeAndAwaitAck(node, playlistId, playlistName, songIds, songTitles) + if (!ackedOnRetry) { + Timber.tag(TAG).w( + "Playlist sync unconfirmed after retry: playlistId=%s node=%s — songs will still " + + "transfer, but the watch may not show this playlist until the next sync", + playlistId, + node.id, + ) + } + } + + /** Returns whether [node] acked this attempt within [PLAYLIST_SYNC_ACK_TIMEOUT_MS]. */ + private suspend fun sendPlaylistSyncToNodeAndAwaitAck( + node: Node, + playlistId: String, + playlistName: String, + songIds: List, + songTitles: List, + ): Boolean { + val requestId = UUID.randomUUID().toString() + val syncPayload = json.encodeToString( + WearPlaylistSync(playlistId, playlistName, songIds, songTitles, requestId) + ).toByteArray(Charsets.UTF_8) + + 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) + return false + } + + val ack = withTimeoutOrNull(PLAYLIST_SYNC_ACK_TIMEOUT_MS) { + transferStateStore.playlistSyncAcks.first { it.requestId == requestId } + } + return ack != null + } + + /** + * Titles for [songIds], same order, "" for any id the library doesn't resolve — purely + * cosmetic (lets the watch show a real name instead of a raw id for a song still awaiting + * transfer), so a missing title here is never a reason to fail or delay the sync. + */ + private suspend fun resolveSongTitlesInOrder(songIds: List): List { + val songsById = musicRepository.getSongsByIds(songIds).first().associateBy { it.id } + return songIds.map { songId -> songsById[songId]?.title.orEmpty() } + } + + /** + * Retries [song] once after a transient failure, with a short backoff. Real hardware + * testing showed a song can legitimately fail (watch-side idle watchdog closing a live but + * slow Bluetooth stream — see WearTransferRepository) while a retry moments later succeeds + * cleanly: the watch's Bluetooth radio is shared with any connected BT headset, and a + * transfer can genuinely stall for a while under that contention without anything actually + * being broken. Doesn't retry past a cancellation, and re-transcodes on the retry rather + * than caching the first attempt's output — simpler and safe (transcoding on a modern phone + * is a few seconds, not the bottleneck), at the cost of redoing work that likely already + * succeeded once. + */ + private suspend fun transferSongToAllNodesWithRetry( + batchId: String, + nodes: List, + song: Song, + ): SongTransferResult { + val firstAttempt = transferSongToAllNodes(batchId, nodes, song) + if (firstAttempt.completed || cancelledBatchIds.contains(batchId)) return firstAttempt + + Timber.tag(TAG).w( + "Retrying transfer after failure: songId=%s errorCode=%s", + song.id, + firstAttempt.errorCode, + ) + delay(RETRY_BACKOFF_MS) + if (cancelledBatchIds.contains(batchId)) return firstAttempt + return transferSongToAllNodes(batchId, nodes, song) + } + + /** 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 + + // Short on purpose: a retry exists for transient stalls (radio contention with a + // connected BT headset, momentary Bluetooth hiccups), not to wait out a genuinely dead + // link — a longer backoff would just make a real failure take longer to report. + private const val RETRY_BACKOFF_MS = 3_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 + + // How long to wait for the watch's playlist-sync ack before retrying. Generous relative to + // a normal round-trip (which is near-instant) to tolerate a brief Wi-Fi/ADB reconnect blip + // without firing a spurious retry. + private const val PLAYLIST_SYNC_ACK_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 new file mode 100644 index 0000000000..793df4882a --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/wear/WatchAudioTranscoder.kt @@ -0,0 +1,216 @@ +package com.theveloper.pixelplay.data.service.wear + +import android.app.Application +import android.net.Uri +import androidx.core.net.toUri +import androidx.media3.common.MediaItem +import androidx.media3.common.MimeTypes +import androidx.media3.common.util.UnstableApi +import androidx.media3.transformer.AudioEncoderSettings +import androidx.media3.transformer.Composition +import androidx.media3.transformer.DefaultEncoderFactory +import androidx.media3.transformer.ExportException +import androidx.media3.transformer.ExportResult +import androidx.media3.transformer.ProgressHolder +import androidx.media3.transformer.Transformer +import com.theveloper.pixelplay.data.model.Song +import com.theveloper.pixelplay.di.MainDispatcher +import kotlinx.coroutines.CancellableContinuation +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import timber.log.Timber +import java.io.File +import java.util.Locale +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.coroutines.resume + +/** + * Decides whether a song needs to be re-encoded before it is sent to the watch, and performs + * that re-encoding with [Transformer]. + * + * Lossless/high-bitrate sources are re-encoded to AAC-LC at [TARGET_BITRATE_BPS]: watches decode + * AAC in hardware but most FLAC decoding on Wear OS SoCs is software-only, and lossless files are + * also far larger to transfer and store on a watch's very limited flash. Sources that are already + * a compressed lossy format at or below the passthrough bitrate are sent through untouched (see + * [PhoneDirectWatchTransferCoordinator]) — re-encoding an already-small MP3 down to + * [TARGET_BITRATE_BPS] would only cost CPU and quality for no size benefit worth the transfer + * time saved. + */ +@UnstableApi +@Singleton +class WatchAudioTranscoder @Inject constructor( + private val application: Application, + @MainDispatcher private val mainDispatcher: CoroutineDispatcher, +) { + + sealed class TranscodeResult { + /** The source is already an acceptable lossy format; send it as-is. */ + data object Passthrough : TranscodeResult() + data class Transcoded(val outputFile: File) : TranscodeResult() + data class Failed(val error: Throwable) : TranscodeResult() + } + + /** Pure decision function, kept separate from the actual encode so it's cheap to unit test. */ + fun requiresTranscoding(song: Song): Boolean { + val mimeType = song.mimeType?.lowercase(Locale.ROOT) + val bitrate = song.bitrate + val isPassthroughEligible = mimeType != null && + PASSTHROUGH_MIME_TYPES.contains(mimeType) && + bitrate != null && + bitrate <= MAX_PASSTHROUGH_BITRATE_BPS + return !isPassthroughEligible + } + + /** + * Runs the transcode if [requiresTranscoding] says it's needed, reporting encode progress + * as a 0f..1f fraction via [onProgress]. Callers own [TranscodeResult.Transcoded.outputFile] + * and must delete it (via [cleanup]) once it has been sent or the transfer is abandoned. + */ + suspend fun transcodeIfNeeded( + song: Song, + requestId: String, + onProgress: (Float) -> Unit = {}, + ): TranscodeResult { + if (!requiresTranscoding(song)) return TranscodeResult.Passthrough + + val inputMediaItem = buildInputMediaItem(song) + ?: return TranscodeResult.Failed(IllegalStateException("No readable local audio source for songId=${song.id}")) + + val outputFile = outputFileFor(song.id, requestId) + outputFile.parentFile?.mkdirs() + + return runTransform(inputMediaItem, outputFile, onProgress) + } + + fun cleanup(result: TranscodeResult) { + if (result is TranscodeResult.Transcoded) { + runCatching { result.outputFile.delete() } + .onFailure { error -> Timber.tag(TAG).w(error, "Failed to delete transcoded temp file") } + } + } + + // Transformer must be built and started on a thread that has a Looper — the main thread is + // the one Android guarantees has one, so this can't move to an injected background dispatcher. + private suspend fun runTransform( + inputMediaItem: MediaItem, + outputFile: File, + onProgress: (Float) -> Unit, + ): TranscodeResult = withContext(mainDispatcher) { + suspendCancellableCoroutine { continuation -> + val encoderFactory = DefaultEncoderFactory.Builder(application) + .setRequestedAudioEncoderSettings( + AudioEncoderSettings.Builder().setBitrate(TARGET_BITRATE_BPS).build() + ) + .build() + + val transformer = Transformer.Builder(application) + .setAudioMimeType(MimeTypes.AUDIO_AAC) + .setEncoderFactory(encoderFactory) + .addListener(object : Transformer.Listener { + override fun onCompleted(composition: Composition, exportResult: ExportResult) { + if (continuation.isActive) { + continuation.resume(TranscodeResult.Transcoded(outputFile)) + } + } + + override fun onError( + composition: Composition, + exportResult: ExportResult, + exportException: ExportException, + ) { + // Transformer does not delete partial output on failure — see its Listener docs. + runCatching { outputFile.delete() } + if (continuation.isActive) { + continuation.resume(TranscodeResult.Failed(exportException)) + } + } + }) + .build() + + continuation.invokeOnCancellation { + transformer.cancel() + runCatching { outputFile.delete() } + } + + pollProgress(transformer, continuation, onProgress) + + transformer.start(inputMediaItem, outputFile.absolutePath) + } + } + + private fun pollProgress( + transformer: Transformer, + continuation: CancellableContinuation, + onProgress: (Float) -> Unit, + ) { + val progressHolder = ProgressHolder() + val handler = android.os.Handler(android.os.Looper.getMainLooper()) + val poll = object : Runnable { + override fun run() { + if (!continuation.isActive) return + val state = transformer.getProgress(progressHolder) + if (state == Transformer.PROGRESS_STATE_AVAILABLE) { + onProgress(progressHolder.progress / 100f) + } + if (state != Transformer.PROGRESS_STATE_NOT_STARTED) { + handler.postDelayed(this, PROGRESS_POLL_INTERVAL_MS) + } + } + } + handler.postDelayed(poll, PROGRESS_POLL_INTERVAL_MS) + } + + private fun buildInputMediaItem(song: Song): MediaItem? { + val directFile = song.path.takeIf { it.isNotBlank() }?.let(::File) + ?.takeIf { it.isFile && it.canRead() && it.length() > 0L } + if (directFile != null) { + return MediaItem.fromUri(Uri.fromFile(directFile)) + } + + val rawUri = song.contentUriString + if (rawUri.isBlank()) return null + if (rawUri.startsWith("/")) { + val rawFile = File(rawUri) + if (rawFile.isFile && rawFile.canRead() && rawFile.length() > 0L) { + return MediaItem.fromUri(Uri.fromFile(rawFile)) + } + } + + val uri = runCatching { rawUri.toUri() }.getOrNull() ?: return null + return when (uri.scheme?.lowercase(Locale.ROOT)) { + "file", "content" -> MediaItem.fromUri(uri) + else -> null + } + } + + private fun outputFileFor(songId: String, requestId: String): File { + val dir = File(application.cacheDir, "watch_transfer") + return File(dir, "${songId}_$requestId.m4a") + } + + 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 + private val PASSTHROUGH_MIME_TYPES = setOf( + "audio/mpeg", + "audio/mp4", + "audio/aac", + "audio/mp4a-latm", + "audio/ogg", + "audio/opus", + ) + } +} diff --git a/app/src/main/java/com/theveloper/pixelplay/data/service/wear/WatchPlaylistTransferEstimator.kt b/app/src/main/java/com/theveloper/pixelplay/data/service/wear/WatchPlaylistTransferEstimator.kt new file mode 100644 index 0000000000..4ea23a4f92 --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/wear/WatchPlaylistTransferEstimator.kt @@ -0,0 +1,63 @@ +package com.theveloper.pixelplay.data.service.wear + +import androidx.media3.common.util.UnstableApi +import com.theveloper.pixelplay.data.model.Song + +/** + * Aggregate size/time estimate shown on the send-to-watch confirmation sheet, computed only over + * the songs that still need to be transferred (already-on-watch songs are skipped by the dedupe + * in [PlaylistWatchTransferCoordinator], so they don't cost bandwidth or storage). + */ +data class WatchPlaylistTransferEstimate( + val totalSongCount: Int, + val pendingSongCount: Int, + val estimatedBytes: Long, + val estimatedTransferSeconds: Long, +) + +/** + * Pure size/time heuristics for the whole-playlist watch transfer confirmation UI. Kept separate + * from [WatchAudioTranscoder] (which does the real encode) so it stays cheap to unit test. + */ +@UnstableApi +object WatchPlaylistTransferEstimator { + + /** + * Assumed throughput for the single Bluetooth channel used for the transfer (phase 1 — no + * Wi-Fi transport yet). This is the Wearable Data Layer ChannelClient rate, not raw Bluetooth + * bandwidth, and is deliberately conservative: an estimate that undershoots the real time + * erodes trust in the confirmation sheet more than one that's a bit pessimistic. Needs + * re-measuring against a real phone+watch pair once device testing resumes — see §R-04 of the + * Wear OS guide for the documented range (~50–150 KB/s) this sits below on purpose. + */ + private const val ASSUMED_TRANSFER_RATE_BYTES_PER_SEC = 40_000L + + fun estimateBytesForSong(song: Song, transcoder: WatchAudioTranscoder): Long { + val effectiveBitrateBps = if (transcoder.requiresTranscoding(song)) { + WatchAudioTranscoder.TARGET_BITRATE_BPS + } else { + song.bitrate ?: WatchAudioTranscoder.TARGET_BITRATE_BPS + } + val durationSeconds = song.duration / 1000.0 + return (durationSeconds * effectiveBitrateBps / 8.0).toLong().coerceAtLeast(0L) + } + + fun estimate( + allSongs: List, + pendingSongs: List, + transcoder: WatchAudioTranscoder, + ): WatchPlaylistTransferEstimate { + val totalBytes = pendingSongs.sumOf { estimateBytesForSong(it, transcoder) } + return WatchPlaylistTransferEstimate( + totalSongCount = allSongs.size, + pendingSongCount = pendingSongs.size, + estimatedBytes = totalBytes, + estimatedTransferSeconds = estimateTransferSeconds(totalBytes), + ) + } + + private fun estimateTransferSeconds(totalBytes: Long): Long { + if (totalBytes <= 0L) return 0L + return (totalBytes / ASSUMED_TRANSFER_RATE_BYTES_PER_SEC).coerceAtLeast(1L) + } +} 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/data/service/wear/WearCommandReceiver.kt b/app/src/main/java/com/theveloper/pixelplay/data/service/wear/WearCommandReceiver.kt index 0b0ce27ee3..f0bcaa1a0e 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/service/wear/WearCommandReceiver.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/wear/WearCommandReceiver.kt @@ -26,6 +26,7 @@ import com.theveloper.pixelplay.shared.WearBrowseResponse import com.theveloper.pixelplay.shared.WearDataPaths import com.theveloper.pixelplay.shared.WearLibraryItem import com.theveloper.pixelplay.shared.WearPlaybackCommand +import com.theveloper.pixelplay.shared.WearPlaylistSyncAck import com.theveloper.pixelplay.shared.WearTransferMetadata import com.theveloper.pixelplay.shared.WearTransferProgress import com.theveloper.pixelplay.shared.WearTransferRequest @@ -97,6 +98,7 @@ class WearCommandReceiver : WearableListenerService() { WearDataPaths.BROWSE_REQUEST -> handleBrowseRequest(messageEvent) WearDataPaths.TRANSFER_REQUEST -> handleTransferRequest(messageEvent) WearDataPaths.TRANSFER_CANCEL -> handleTransferCancel(messageEvent) + WearDataPaths.PLAYLIST_SYNC_ACK -> handlePlaylistSyncAck(messageEvent) else -> Timber.tag(TAG).w("Unknown message path: ${messageEvent.path}") } } @@ -524,6 +526,17 @@ class WearCommandReceiver : WearableListenerService() { ) } + private fun handlePlaylistSyncAck(messageEvent: MessageEvent) { + val ackJson = String(messageEvent.data, Charsets.UTF_8) + val ack = try { + json.decodeFromString(ackJson) + } catch (e: Exception) { + Timber.tag(TAG).e(e, "Failed to parse playlist sync ack") + return + } + transferStateStore.onPlaylistSyncAckReceived(ack) + } + private fun handleTransferCancel(messageEvent: MessageEvent) { val requestJson = String(messageEvent.data, Charsets.UTF_8) val request = try { 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 7f1d935994..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 @@ -56,6 +60,7 @@ import kotlinx.serialization.json.Json import javax.inject.Qualifier import javax.inject.Singleton import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import okhttp3.OkHttpClient @@ -113,6 +118,28 @@ object AppModule { return CoroutineScope(SupervisorJob() + Dispatchers.IO) } + @Provides + @IoDispatcher + fun provideIoDispatcher(): CoroutineDispatcher = Dispatchers.IO + + @Provides + @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/di/Qualifiers.kt b/app/src/main/java/com/theveloper/pixelplay/di/Qualifiers.kt index d0207b1997..416e86ed6e 100644 --- a/app/src/main/java/com/theveloper/pixelplay/di/Qualifiers.kt +++ b/app/src/main/java/com/theveloper/pixelplay/di/Qualifiers.kt @@ -29,3 +29,21 @@ annotation class BackupGson @Qualifier @Retention(AnnotationRetention.BINARY) annotation class AppScope + +/** + * Qualifier for the IO [kotlinx.coroutines.CoroutineDispatcher]. Injected rather than referenced + * as `Dispatchers.IO` directly so tests can substitute a `TestDispatcher` (implements + * `AND-CONC-03`). Most of this codebase predates this convention and still references + * `Dispatchers.IO` directly — only use this qualifier in new code. + */ +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class IoDispatcher + +/** + * Qualifier for the Main [kotlinx.coroutines.CoroutineDispatcher]. Same rationale as + * [IoDispatcher] — new code only. + */ +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class MainDispatcher 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..077397337f 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,141 @@ 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 + ) + if (batch.status == WearTransferProgress.STATUS_TRANSFERRING && batch.currentSongTitle.isNotBlank()) { + Text( + text = stringResource(R.string.watch_transfer_current_song, batch.currentSongTitle), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + 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 +622,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 +649,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 +994,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 +1021,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 +2004,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..158c2c2458 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 @@ -9,6 +9,7 @@ import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -48,6 +49,7 @@ import androidx.compose.material.icons.filled.MusicOff import androidx.compose.material.icons.filled.RemoveCircleOutline import androidx.compose.material.icons.rounded.Check import androidx.compose.material.icons.rounded.Add +import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.DragIndicator import androidx.compose.material.icons.rounded.PlayArrow import androidx.compose.material.icons.rounded.Search @@ -136,6 +138,10 @@ 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 androidx.compose.material3.LinearWavyProgressIndicator import racra.compose.smooth_corner_rect_library.AbsoluteSmoothCornerShape import sh.calvin.reorderable.ReorderableItem import sh.calvin.reorderable.rememberReorderableLazyListState @@ -183,6 +189,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 +213,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 +235,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 +384,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 +921,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 +1024,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 +1306,101 @@ 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. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@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 + } + } + } + val animatedProgress by animateFloatAsState( + targetValue = overallProgress, + animationSpec = tween(durationMillis = 300), + label = "WatchTransferProgressBanner", + ) + + // Same icon-badge + row treatment as PlaylistActionItem right below it (40dp circular badge + // on surfaceContainerHighest, 16dp horizontal padding, 18dp corner radius) — this banner is + // conceptually one more row in that same list, not a separate, unrelated status card. + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 6.dp) + .clip(RoundedCornerShape(18.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerHigh) + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceContainerHighest), + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(R.drawable.rounded_watch_arrow_down_24), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) + } + Spacer(modifier = Modifier.width(14.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource( + R.string.watch_transfer_batch_progress, + batch.processedSongCount, + batch.totalSongCount, + ), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + if (batch.currentSongTitle.isNotBlank()) { + Text( + text = batch.currentSongTitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 2.dp), + ) + } + LinearWavyProgressIndicator( + progress = { animatedProgress }, + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp) + .height(6.dp) + .clip(RoundedCornerShape(50)), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.surfaceContainerHighest, + ) + } + Spacer(modifier = Modifier.width(4.dp)) + IconButton(onClick = onCancelClick) { + Icon( + imageVector = Icons.Rounded.Close, + contentDescription = stringResource(R.string.watch_transfer_action_cancel), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + 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-es/strings_library.xml b/app/src/main/res/values-es/strings_library.xml index f32199d428..e5d248d72d 100644 --- a/app/src/main/res/values-es/strings_library.xml +++ b/app/src/main/res/values-es/strings_library.xml @@ -265,9 +265,12 @@ Cancelar transferencia + %1$d canciones fallidas + %1$d de %2$d canciones %1$s / %2$s Muestra el progreso en tiempo real de las transferencias de música del teléfono al reloj Transferencias al reloj + Enviando: %1$s Enviando al reloj Cancelado Transferencia cancelada @@ -281,6 +284,7 @@ Preparando transferencia al reloj Preparando transferencia… Enviando %1$d canciones al reloj + Enviando \"%1$s\" al reloj Enviando al reloj Iniciando transferencia… Iniciando diff --git a/app/src/main/res/values-es/strings_screens.xml b/app/src/main/res/values-es/strings_screens.xml index 59751905c7..b1337c874d 100644 --- a/app/src/main/res/values-es/strings_screens.xml +++ b/app/src/main/res/values-es/strings_screens.xml @@ -126,6 +126,19 @@ Quitar canciones Reordenar Reordenar canciones + Enviar al reloj + Actualizar en el reloj + Enviar lista al reloj + ¿Enviar \"%1$s\" a tu reloj? + ¿Actualizar \"%1$s\" en tu reloj? + %1$d de %2$d canciones por enviar + %1$d canciones + %1$s · unos %2$s + Enviar + Actualizar + Ningún reloj conectado + Enviando \"%1$s\" a tu reloj + No se pudo iniciar la transferencia: %1$s Transiciones globales diff --git a/app/src/main/res/values/strings_library.xml b/app/src/main/res/values/strings_library.xml index 8b7c6c2c60..7ee28af338 100644 --- a/app/src/main/res/values/strings_library.xml +++ b/app/src/main/res/values/strings_library.xml @@ -265,9 +265,12 @@ 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 + Sending: %1$s Sending to Watch Cancelled Transfer cancelled @@ -281,6 +284,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..30057abe14 --- /dev/null +++ b/app/src/test/java/com/theveloper/pixelplay/data/service/wear/PlaylistWatchTransferCoordinatorTest.kt @@ -0,0 +1,589 @@ +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.WearDataPaths +import com.theveloper.pixelplay.shared.WearPlaylistSync +import com.theveloper.pixelplay.shared.WearPlaylistSyncAck +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 kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +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 json = Json { ignoreUnknownKeys = 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. Playlist + // syncs additionally auto-ack (simulating a healthy watch) so every existing test here + // keeps its original one-send-per-node behavior; tests that care about the ack-timeout/ + // retry path override this locally. + every { messageClient.sendMessage(any(), any(), any()) } answers { + autoAckIfPlaylistSync(thirdArg()) + 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 + } + + /** Decodes [bytes] as a [WearPlaylistSync] and, if it carries a requestId, immediately acks it. */ + private fun autoAckIfPlaylistSync(bytes: ByteArray) { + val sync = runCatching { + json.decodeFromString(String(bytes, Charsets.UTF_8)) + }.getOrNull() ?: return + if (sync.requestId.isEmpty()) return + transferStateStore.onPlaylistSyncAckReceived( + WearPlaylistSyncAck(playlistId = sync.playlistId, requestId = sync.requestId) + ) + } + + 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 `the playlist sync sent to the watch carries song titles in the same order as ids`() = runTest { + stubReachableNodes("node-1") + stubTransfersResolveTo(WearTransferProgress.STATUS_COMPLETED) + song("s3", title = "Third"); song("s1", title = "First"); song("s2", title = "Second") + val syncPayloads = mutableListOf() + every { messageClient.sendMessage(any(), WearDataPaths.PLAYLIST_SYNC, any()) } answers { + val bytes = thirdArg() + syncPayloads += json.decodeFromString(String(bytes, Charsets.UTF_8)) + autoAckIfPlaylistSync(bytes) + Tasks.forResult(0) + } + val coordinator = buildCoordinator(this) + + coordinator.requestPlaylistTransfer("p1", "Playlist", listOf("s3", "s1", "s2")) + advanceUntilIdle() + + assertThat(syncPayloads).hasSize(1) + assertThat(syncPayloads.single().songIds).containsExactly("s3", "s1", "s2").inOrder() + assertThat(syncPayloads.single().songTitles).containsExactly("Third", "First", "Second").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 + // s2 fails on every attempt, including its retry (see the dedicated retry tests + // below) — this test is only about the batch surviving a song that never recovers. + 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() + + // s2 appears twice: the first attempt and its retry. + assertThat(transferredSongIdsInOrder).containsExactly("s1", "s2", "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 `a song that fails once but succeeds on retry counts as completed`() = runTest { + stubReachableNodes("node-1") + song("s1") + var attempt = 0 + every { + directTransferCoordinator.startTransferToWatch( + nodeId = any(), requestId = any(), songId = any(), + transferMode = any(), startPositionMs = any(), autoPlay = any(), audioOverride = any(), + ) + } answers { + val requestId = secondArg() + val songId = thirdArg() + attempt += 1 + val status = if (attempt == 1) 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")) + advanceUntilIdle() + + assertThat(attempt).isEqualTo(2) + val batch = transferStateStore.batchTransfers.value[batchId] + assertThat(batch?.completedSongCount).isEqualTo(1) + assertThat(batch?.failedSongCount).isEqualTo(0) + } + + @Test + fun `a song failing twice in a row is only retried once, not indefinitely`() = runTest { + stubReachableNodes("node-1") + song("s1") + var attempts = 0 + every { + directTransferCoordinator.startTransferToWatch( + nodeId = any(), requestId = any(), songId = any(), + transferMode = any(), startPositionMs = any(), autoPlay = any(), audioOverride = any(), + ) + } answers { + val requestId = secondArg() + val songId = thirdArg() + attempts += 1 + transferStateStore.markProgress(requestId, songId, 0L, 0L, WearTransferProgress.STATUS_FAILED) + } + val coordinator = buildCoordinator(this) + + val batchId = coordinator.requestPlaylistTransfer("p1", "Playlist", listOf("s1")) + advanceUntilIdle() + + assertThat(attempts).isEqualTo(2) + val batch = transferStateStore.batchTransfers.value[batchId] + assertThat(batch?.failedSongCount).isEqualTo(1) + assertThat(batch?.completedSongCount).isEqualTo(0) + } + + @Test + fun `cancelling during the backoff window skips the retry`() = runTest { + stubReachableNodes("node-1") + song("s1") + 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 + coordinator.cancelPlaylistTransfer(batchId) + transferStateStore.markProgress(requestId, songId, 0L, 0L, WearTransferProgress.STATUS_FAILED) + } + + batchId = coordinator.requestPlaylistTransfer("p1", "Playlist", listOf("s1")) + advanceUntilIdle() + + assertThat(transferredSongIdsInOrder).containsExactly("s1") + val batch = transferStateStore.batchTransfers.value[batchId] + assertThat(batch?.status).isEqualTo(WearTransferProgress.STATUS_CANCELLED) + } + + @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) + } + + // --- Playlist sync reliability: ack + retry --- + + @Test + fun `a playlist sync acked on the first attempt is sent only once`() = runTest { + stubReachableNodes("node-1") + stubTransfersResolveTo(WearTransferProgress.STATUS_COMPLETED) + song("s1") + val coordinator = buildCoordinator(this) + + coordinator.requestPlaylistTransfer("p1", "Playlist", listOf("s1")) + advanceUntilIdle() + + verify(exactly = 1) { messageClient.sendMessage(any(), WearDataPaths.PLAYLIST_SYNC, any()) } + } + + @Test + fun `a playlist sync that's never acked is retried once, then given up on`() = runTest { + stubReachableNodes("node-1") + stubTransfersResolveTo(WearTransferProgress.STATUS_COMPLETED) + song("s1") + // Overrides the auto-acking default stub — this node never acks, simulating the watch + // being mid-reconnect when both attempts go out. + every { messageClient.sendMessage(any(), WearDataPaths.PLAYLIST_SYNC, any()) } returns Tasks.forResult(0) + val coordinator = buildCoordinator(this) + + coordinator.requestPlaylistTransfer("p1", "Playlist", listOf("s1")) + advanceUntilIdle() + + // One initial attempt plus exactly one retry — not retried indefinitely. + verify(exactly = 2) { messageClient.sendMessage(any(), WearDataPaths.PLAYLIST_SYNC, any()) } + } + + @Test + fun `a playlist sync acked only on the retry stops after that retry`() = runTest { + stubReachableNodes("node-1") + stubTransfersResolveTo(WearTransferProgress.STATUS_COMPLETED) + song("s1") + var attempt = 0 + every { messageClient.sendMessage(any(), WearDataPaths.PLAYLIST_SYNC, any()) } answers { + attempt += 1 + val bytes = thirdArg() + if (attempt >= 2) autoAckIfPlaylistSync(bytes) + Tasks.forResult(0) + } + val coordinator = buildCoordinator(this) + + coordinator.requestPlaylistTransfer("p1", "Playlist", listOf("s1")) + advanceUntilIdle() + + assertThat(attempt).isEqualTo(2) + } + + @Test + fun `songs still transfer even when the playlist sync is never acked`() = runTest { + stubReachableNodes("node-1") + stubTransfersResolveTo(WearTransferProgress.STATUS_COMPLETED) + song("s1") + every { messageClient.sendMessage(any(), WearDataPaths.PLAYLIST_SYNC, any()) } returns Tasks.forResult(0) + val coordinator = buildCoordinator(this) + + val batchId = coordinator.requestPlaylistTransfer("p1", "Playlist", listOf("s1")) + advanceUntilIdle() + + // An unconfirmed playlist sync is a warning, not a batch failure — the song itself still + // lands on the watch, it just might not show up under the playlist until the next sync. + assertThat(transferredSongIdsInOrder).containsExactly("s1") + val batch = transferStateStore.batchTransfers.value[batchId] + assertThat(batch?.status).isEqualTo(WearTransferProgress.STATUS_COMPLETED) + assertThat(batch?.completedSongCount).isEqualTo(1) + } + + // --- 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/data/service/wear/WatchAudioTranscoderTest.kt b/app/src/test/java/com/theveloper/pixelplay/data/service/wear/WatchAudioTranscoderTest.kt new file mode 100644 index 0000000000..5bf0e9e70a --- /dev/null +++ b/app/src/test/java/com/theveloper/pixelplay/data/service/wear/WatchAudioTranscoderTest.kt @@ -0,0 +1,61 @@ +package com.theveloper.pixelplay.data.service.wear + +import android.app.Application +import com.google.common.truth.Truth.assertThat +import com.theveloper.pixelplay.data.model.Song +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import org.junit.jupiter.api.Test + +/** + * Covers [WatchAudioTranscoder.requiresTranscoding] only — the pure decision function. The actual + * encode path (`transcodeIfNeeded` / `runTransform`) drives a real [androidx.media3.transformer.Transformer], + * which needs a hardware encoder and a Looper thread; that's only verifiable on a device. + */ +class WatchAudioTranscoderTest { + + // requiresTranscoding never touches these — a relaxed mock and any real dispatcher are enough. + private val transcoder = WatchAudioTranscoder( + application = mockk(relaxed = true), + mainDispatcher = Dispatchers.Unconfined, + ) + + private fun song(mimeType: String?, bitrate: Int?) = + Song.emptySong().copy(mimeType = mimeType, bitrate = bitrate) + + @Test + fun `a lossless format requires transcoding regardless of bitrate`() { + assertThat(transcoder.requiresTranscoding(song("audio/flac", bitrate = 128_000))).isTrue() + assertThat(transcoder.requiresTranscoding(song("audio/flac", bitrate = null))).isTrue() + } + + @Test + fun `a lossy source at or under the passthrough bitrate is sent as-is`() { + assertThat(transcoder.requiresTranscoding(song("audio/mpeg", bitrate = 128_000))).isFalse() + } + + @Test + fun `a lossy source at exactly the passthrough bitrate boundary is sent as-is`() { + assertThat(transcoder.requiresTranscoding(song("audio/mpeg", bitrate = 256_000))).isFalse() + } + + @Test + fun `a lossy source over the passthrough bitrate is transcoded down`() { + assertThat(transcoder.requiresTranscoding(song("audio/mpeg", bitrate = 320_000))).isTrue() + } + + @Test + fun `unknown mimeType requires transcoding`() { + assertThat(transcoder.requiresTranscoding(song(mimeType = null, bitrate = 128_000))).isTrue() + } + + @Test + fun `unknown bitrate requires transcoding even for an otherwise eligible lossy mimeType`() { + assertThat(transcoder.requiresTranscoding(song("audio/mpeg", bitrate = null))).isTrue() + } + + @Test + fun `mimeType is matched case-insensitively`() { + assertThat(transcoder.requiresTranscoding(song("AUDIO/MPEG", bitrate = 128_000))).isFalse() + } +} diff --git a/app/src/test/java/com/theveloper/pixelplay/data/service/wear/WatchPlaylistTransferEstimatorTest.kt b/app/src/test/java/com/theveloper/pixelplay/data/service/wear/WatchPlaylistTransferEstimatorTest.kt new file mode 100644 index 0000000000..ddcc1f6330 --- /dev/null +++ b/app/src/test/java/com/theveloper/pixelplay/data/service/wear/WatchPlaylistTransferEstimatorTest.kt @@ -0,0 +1,84 @@ +package com.theveloper.pixelplay.data.service.wear + +import android.app.Application +import com.google.common.truth.Truth.assertThat +import com.theveloper.pixelplay.data.model.Song +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import org.junit.jupiter.api.Test + +class WatchPlaylistTransferEstimatorTest { + + private val transcoder = WatchAudioTranscoder( + application = mockk(relaxed = true), + mainDispatcher = Dispatchers.Unconfined, + ) + + private fun song(id: String, mimeType: String?, bitrate: Int?, durationMs: Long = 180_000L) = + Song.emptySong().copy(id = id, mimeType = mimeType, bitrate = bitrate, duration = durationMs) + + @Test + fun `passthrough song is sized using its own bitrate`() { + val passthroughSong = song("s1", "audio/mpeg", bitrate = 128_000, durationMs = 60_000L) + + val bytes = WatchPlaylistTransferEstimator.estimateBytesForSong(passthroughSong, transcoder) + + // 60s * 128_000 bps / 8 = 960_000 bytes + assertThat(bytes).isEqualTo(960_000L) + } + + @Test + fun `transcoded song is sized using the target AAC bitrate, not its source bitrate`() { + val losslessSong = song("s1", "audio/flac", bitrate = 900_000, durationMs = 60_000L) + + val bytes = WatchPlaylistTransferEstimator.estimateBytesForSong(losslessSong, transcoder) + + // 60s * 128_000 (TARGET_BITRATE_BPS) bps / 8 = 960_000 bytes, not sized off the 900kbps source. + assertThat(bytes).isEqualTo(960_000L) + } + + @Test + fun `estimate only sums pending songs, not the whole playlist`() { + val alreadyOnWatch = song("on-watch", "audio/mpeg", bitrate = 128_000, durationMs = 60_000L) + val pending = song("pending", "audio/mpeg", bitrate = 128_000, durationMs = 60_000L) + + val estimate = WatchPlaylistTransferEstimator.estimate( + allSongs = listOf(alreadyOnWatch, pending), + pendingSongs = listOf(pending), + transcoder = transcoder, + ) + + assertThat(estimate.totalSongCount).isEqualTo(2) + assertThat(estimate.pendingSongCount).isEqualTo(1) + assertThat(estimate.estimatedBytes).isEqualTo(960_000L) + } + + @Test + fun `no pending songs means zero bytes and zero seconds`() { + val onlySong = song("s1", "audio/mpeg", bitrate = 128_000) + + val estimate = WatchPlaylistTransferEstimator.estimate( + allSongs = listOf(onlySong), + pendingSongs = emptyList(), + transcoder = transcoder, + ) + + assertThat(estimate.pendingSongCount).isEqualTo(0) + assertThat(estimate.estimatedBytes).isEqualTo(0L) + assertThat(estimate.estimatedTransferSeconds).isEqualTo(0L) + } + + @Test + fun `a small pending transfer still estimates at least one second`() { + val tinySong = song("s1", "audio/mpeg", bitrate = 128_000, durationMs = 1L) + + val estimate = WatchPlaylistTransferEstimator.estimate( + allSongs = listOf(tinySong), + pendingSongs = listOf(tinySong), + transcoder = transcoder, + ) + + assertThat(estimate.estimatedBytes).isGreaterThan(0L) + assertThat(estimate.estimatedTransferSeconds).isEqualTo(1L) + } +} 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/shared/build.gradle.kts b/shared/build.gradle.kts index 233f33822a..4dcf89bc8e 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -15,6 +15,11 @@ android { sourceCompatibility = JavaVersion.VERSION_21 targetCompatibility = JavaVersion.VERSION_21 } + + testOptions { + unitTests.isReturnDefaultValues = true + unitTests.all { it.useJUnitPlatform() } + } } kotlin { @@ -25,4 +30,16 @@ kotlin { dependencies { implementation(libs.kotlinx.serialization.json) + + // Testing (Unit) — pure DTO serialization round-trips, no Android framework needed. + testImplementation(libs.junit.jupiter.api) + testImplementation(libs.junit.jupiter.params) + testRuntimeOnly(libs.junit.jupiter.engine) + testRuntimeOnly(libs.junitplatformlauncher) + testImplementation(libs.truth) + testImplementation(kotlin("test")) +} + +tasks.withType { + useJUnitPlatform() } diff --git a/shared/src/main/java/com/theveloper/pixelplay/shared/WearDataPaths.kt b/shared/src/main/java/com/theveloper/pixelplay/shared/WearDataPaths.kt index 35fa7debe9..b45678a3c0 100644 --- a/shared/src/main/java/com/theveloper/pixelplay/shared/WearDataPaths.kt +++ b/shared/src/main/java/com/theveloper/pixelplay/shared/WearDataPaths.kt @@ -64,4 +64,14 @@ object WearDataPaths { /** Message path for favorites sync progress/state (phone -> watch) */ const val FAVORITES_SYNC_STATE = "/favorites_sync_state" + + /** Message path for playlist sync (phone -> watch): creates or updates a local playlist's membership/order. */ + const val PLAYLIST_SYNC = "/playlist_sync" + + /** + * Message path for playlist sync acknowledgement (watch -> phone): confirms a [PLAYLIST_SYNC] + * message was actually applied, since `MessageClient.sendMessage()` succeeding on the phone + * only means local hand-off, not delivery. + */ + const val PLAYLIST_SYNC_ACK = "/playlist_sync_ack" } diff --git a/shared/src/main/java/com/theveloper/pixelplay/shared/WearPlaylistSync.kt b/shared/src/main/java/com/theveloper/pixelplay/shared/WearPlaylistSync.kt new file mode 100644 index 0000000000..4d816b9866 --- /dev/null +++ b/shared/src/main/java/com/theveloper/pixelplay/shared/WearPlaylistSync.kt @@ -0,0 +1,33 @@ +package com.theveloper.pixelplay.shared + +import kotlinx.serialization.Serializable + +/** + * Snapshot of a phone playlist sent to the watch so it can be browsed and played offline. + * + * Sent once when the user taps "send to watch", and again (idempotently) when they tap + * "update" — the watch replaces its local membership/order for [playlistId] with [songIds] + * on each sync, independent of whether the audio for those songs has already arrived. This + * lets the watch show the full playlist and its intended order immediately, while individual + * songs keep streaming in afterward. + * + * [songTitles] is a parallel list to [songIds] (same index = same song) rather than a list of + * pairs, so an older watch build ignores it (via `ignoreUnknownKeys`) and an older phone build + * omitting it still deserializes cleanly on a newer watch — it's purely cosmetic (lets a song + * still awaiting transfer show its real name instead of its raw ID) and never load-bearing for + * the transfer itself. + * + * [requestId] identifies this specific send attempt so the watch's [WearPlaylistSyncAck] can be + * correlated back to it — `MessageClient.sendMessage()` doesn't guarantee delivery, so the phone + * resends (a new [requestId] each time) until it sees a matching ack. Defaults to "" for the same + * backward-compatibility reason as [songTitles]: an old phone build omitting it just means the + * watch never acks, and the phone falls back to its old fire-and-forget behavior for that sync. + */ +@Serializable +data class WearPlaylistSync( + val playlistId: String, + val name: String, + val songIds: List, + val songTitles: List = emptyList(), + val requestId: String = "", +) diff --git a/shared/src/main/java/com/theveloper/pixelplay/shared/WearPlaylistSyncAck.kt b/shared/src/main/java/com/theveloper/pixelplay/shared/WearPlaylistSyncAck.kt new file mode 100644 index 0000000000..133810d821 --- /dev/null +++ b/shared/src/main/java/com/theveloper/pixelplay/shared/WearPlaylistSyncAck.kt @@ -0,0 +1,18 @@ +package com.theveloper.pixelplay.shared + +import kotlinx.serialization.Serializable + +/** + * Sent by the watch back to the phone once a [WearPlaylistSync] has been durably applied to the + * local playlist table. `MessageClient.sendMessage()` returning success on the phone only means + * the message was handed off locally, not that the watch received it — real hardware testing + * showed a sync sent while the watch was mid-reconnect (Wi-Fi/ADB drops intermittently under this + * app's own load) is silently lost, leaving the watch with every song's audio but no playlist row + * to show them under. The phone waits for this ack (see `PlaylistWatchTransferCoordinator`) and + * resends if it doesn't arrive in time. + */ +@Serializable +data class WearPlaylistSyncAck( + val playlistId: String, + val requestId: String, +) diff --git a/shared/src/main/java/com/theveloper/pixelplay/shared/WearTransferProgress.kt b/shared/src/main/java/com/theveloper/pixelplay/shared/WearTransferProgress.kt index 266155160d..b4f56ce0b6 100644 --- a/shared/src/main/java/com/theveloper/pixelplay/shared/WearTransferProgress.kt +++ b/shared/src/main/java/com/theveloper/pixelplay/shared/WearTransferProgress.kt @@ -14,12 +14,26 @@ data class WearTransferProgress( val totalBytes: Long, val status: String, val error: String? = null, + /** Machine-readable failure reason, so callers can react (retry, prompt for space, ...) without parsing [error]. */ + val errorCode: String? = null, ) { companion object { + /** Phone is re-encoding the source file before it starts streaming; only meaningful for playlist batches. */ + const val STATUS_TRANSCODING = "transcoding" const val STATUS_TRANSFERRING = "transferring" const val STATUS_COMPLETED = "completed" const val STATUS_FAILED = "failed" const val STATUS_CANCELLED = "cancelled" + /** + * Phone finished sending the bytes but hasn't yet heard the watch's own write-complete ack. + * Local-only to the phone's in-memory/persisted transfer state — never serialized to the watch. + */ + const val STATUS_AWAITING_WATCH_ACK = "awaiting_watch_ack" + const val ERROR_ALREADY_ON_WATCH = "Song is already on watch" + const val ERROR_CODE_CONNECTION_LOST = "connection_lost" + const val ERROR_CODE_INSUFFICIENT_STORAGE = "insufficient_storage" + const val ERROR_CODE_TIMED_OUT = "timed_out" + const val ERROR_CODE_GENERIC = "generic" } } diff --git a/shared/src/test/java/com/theveloper/pixelplay/shared/WearPlaylistSyncTest.kt b/shared/src/test/java/com/theveloper/pixelplay/shared/WearPlaylistSyncTest.kt new file mode 100644 index 0000000000..ad84fb40e9 --- /dev/null +++ b/shared/src/test/java/com/theveloper/pixelplay/shared/WearPlaylistSyncTest.kt @@ -0,0 +1,74 @@ +package com.theveloper.pixelplay.shared + +import com.google.common.truth.Truth.assertThat +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.jupiter.api.Test + +class WearPlaylistSyncTest { + + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun `round-trips through JSON preserving song order`() { + val original = WearPlaylistSync( + playlistId = "playlist-1", + name = "Running mix", + songIds = listOf("3", "1", "2"), + ) + + val decoded = json.decodeFromString(json.encodeToString(original)) + + assertThat(decoded).isEqualTo(original) + assertThat(decoded.songIds).containsExactly("3", "1", "2").inOrder() + } + + @Test + fun `decodes an empty song list`() { + val original = WearPlaylistSync(playlistId = "playlist-1", name = "Empty", songIds = emptyList()) + + val decoded = json.decodeFromString(json.encodeToString(original)) + + assertThat(decoded.songIds).isEmpty() + } + + @Test + fun `ignores unknown fields from a newer sender`() { + // The receiving side (watch) may run an older app version than the phone that sent this + // payload — unknown fields must not break decoding, only newly-added optional ones should. + val payloadWithExtraField = + """{"playlistId":"playlist-1","name":"Running mix","songIds":["1"],"futureField":true}""" + + val decoded = json.decodeFromString(payloadWithExtraField) + + assertThat(decoded).isEqualTo( + WearPlaylistSync(playlistId = "playlist-1", name = "Running mix", songIds = listOf("1")), + ) + } + + @Test + fun `round-trips song titles in the same order as song ids`() { + val original = WearPlaylistSync( + playlistId = "playlist-1", + name = "Running mix", + songIds = listOf("3", "1", "2"), + songTitles = listOf("Third", "First", "Second"), + ) + + val decoded = json.decodeFromString(json.encodeToString(original)) + + assertThat(decoded.songTitles).containsExactly("Third", "First", "Second").inOrder() + } + + @Test + fun `a payload from an older phone without songTitles decodes with an empty list`() { + // The mirror case of the unknown-field test above: an OLDER sender that predates this + // field entirely, not a newer one adding an extra field this receiver doesn't know yet. + val payloadWithoutTitles = + """{"playlistId":"playlist-1","name":"Running mix","songIds":["1","2"]}""" + + val decoded = json.decodeFromString(payloadWithoutTitles) + + assertThat(decoded.songTitles).isEmpty() + } +} diff --git a/shared/src/test/java/com/theveloper/pixelplay/shared/WearTransferProgressTest.kt b/shared/src/test/java/com/theveloper/pixelplay/shared/WearTransferProgressTest.kt new file mode 100644 index 0000000000..aca138865f --- /dev/null +++ b/shared/src/test/java/com/theveloper/pixelplay/shared/WearTransferProgressTest.kt @@ -0,0 +1,64 @@ +package com.theveloper.pixelplay.shared + +import com.google.common.truth.Truth.assertThat +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.jupiter.api.Test + +class WearTransferProgressTest { + + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun `round-trips through JSON with the new errorCode field`() { + val original = WearTransferProgress( + requestId = "req-1", + songId = "song-1", + bytesTransferred = 512L, + totalBytes = 1024L, + status = WearTransferProgress.STATUS_FAILED, + error = "Connection lost", + errorCode = WearTransferProgress.ERROR_CODE_CONNECTION_LOST, + ) + + val decoded = json.decodeFromString(json.encodeToString(original)) + + assertThat(decoded).isEqualTo(original) + } + + @Test + fun `decodes a payload from an older sender that predates errorCode as null`() { + // A phone running an older build won't include errorCode in the payload at all. + val legacyPayload = + """{"requestId":"req-1","songId":"song-1","bytesTransferred":0,"totalBytes":1024,"status":"transferring"}""" + + val decoded = json.decodeFromString(legacyPayload) + + assertThat(decoded.errorCode).isNull() + assertThat(decoded.error).isNull() + } + + @Test + fun `STATUS_TRANSCODING serializes as its raw string value`() { + val progress = WearTransferProgress( + requestId = "req-1", + songId = "song-1", + bytesTransferred = 0L, + totalBytes = 1024L, + status = WearTransferProgress.STATUS_TRANSCODING, + ) + + assertThat(json.encodeToString(progress)).contains("\"status\":\"transcoding\"") + } + + @Test + fun `STATUS_AWAITING_WATCH_ACK is a distinct value from every terminal status`() { + val terminalStatuses = setOf( + WearTransferProgress.STATUS_COMPLETED, + WearTransferProgress.STATUS_FAILED, + WearTransferProgress.STATUS_CANCELLED, + ) + + assertThat(terminalStatuses).doesNotContain(WearTransferProgress.STATUS_AWAITING_WATCH_ACK) + } +} diff --git a/wear/build.gradle.kts b/wear/build.gradle.kts index 22efd5464a..8d8d1f500d 100644 --- a/wear/build.gradle.kts +++ b/wear/build.gradle.kts @@ -16,6 +16,13 @@ android { targetSdk = 37 versionCode = (project.findProperty("APP_VERSION_CODE") as? String)?.toInt() ?: 1 versionName = (project.findProperty("APP_VERSION_NAME") as? String) ?: "1.0.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + testOptions { + unitTests.isReturnDefaultValues = true + unitTests.all { it.useJUnitPlatform() } } buildTypes { @@ -112,6 +119,10 @@ dependencies { // Serialization implementation(libs.kotlinx.serialization.json) + // DataStore (persisting local playback state across process death — see + // WearPlaybackStatePersistence) + implementation(libs.androidx.datastore.preferences) + // Image loading implementation(libs.coil.compose) @@ -139,6 +150,27 @@ dependencies { implementation(libs.androidx.media3.session) implementation(libs.androidx.mediarouter) + // Testing (Unit) — no legacy JUnit 4 unit tests planned here, so no vintage engine needed + // (compare to :app, which carries pre-existing JUnit 4 tests under useJUnitPlatform()). + testImplementation(libs.junit.jupiter.api) + testImplementation(libs.junit.jupiter.params) + testRuntimeOnly(libs.junit.jupiter.engine) + testRuntimeOnly(libs.junitplatformlauncher) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.mockk) + testImplementation(libs.turbine) + testImplementation(libs.truth) + testImplementation(kotlin("test")) + + // Testing (Instrumentation) — Room in-memory DAO tests run on-device via AndroidJUnitRunner. + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.room.testing) + androidTestImplementation(libs.androidx.test.core) + androidTestImplementation(libs.kotlinx.coroutines.test) + androidTestImplementation(libs.truth) + // Android-specific artifact: plain io.mockk:mockk can't mock classes on ART. + androidTestImplementation(libs.mockk.android) + constraints { // Fix vulnerabilities in transitive dependencies implementation(libs.netty.common) @@ -153,3 +185,7 @@ dependencies { implementation(libs.apache.httpclient) } } + +tasks.withType { + useJUnitPlatform() +} diff --git a/wear/src/androidTest/java/com/theveloper/pixelplay/data/local/LocalPlaylistDaoTest.kt b/wear/src/androidTest/java/com/theveloper/pixelplay/data/local/LocalPlaylistDaoTest.kt new file mode 100644 index 0000000000..e7b798259c --- /dev/null +++ b/wear/src/androidTest/java/com/theveloper/pixelplay/data/local/LocalPlaylistDaoTest.kt @@ -0,0 +1,130 @@ +package com.theveloper.pixelplay.data.local + +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.io.IOException + +@RunWith(AndroidJUnit4::class) +class LocalPlaylistDaoTest { + + private lateinit var dao: LocalPlaylistDao + private lateinit var db: WearMusicDatabase + + @Before + fun createDb() { + val context = ApplicationProvider.getApplicationContext() + db = Room.inMemoryDatabaseBuilder(context, WearMusicDatabase::class.java) + .allowMainThreadQueries() + .build() + dao = db.localPlaylistDao() + } + + @After + @Throws(IOException::class) + fun closeDb() { + db.close() + } + + @Test + fun upsertPlaylist_isVisibleViaObservePlaylists() = runTest { + val playlist = LocalPlaylistEntity(playlistId = "p1", name = "Running mix", createdAt = 1L, updatedAt = 1L) + val songs = listOf( + LocalPlaylistSongCrossRef(playlistId = "p1", songId = "s1", position = 0), + LocalPlaylistSongCrossRef(playlistId = "p1", songId = "s2", position = 1), + ) + + dao.upsertPlaylist(playlist, songs) + + val stored = dao.observePlaylists().first() + assertThat(stored).containsExactly(playlist) + } + + @Test + fun observePlaylistSongs_isOrderedByPosition() = runTest { + val playlist = LocalPlaylistEntity(playlistId = "p1", name = "Running mix", createdAt = 1L, updatedAt = 1L) + // Inserted out of order on purpose — the DAO's ORDER BY position must correct this, not + // the insertion order. + val songs = listOf( + LocalPlaylistSongCrossRef(playlistId = "p1", songId = "third", position = 2), + LocalPlaylistSongCrossRef(playlistId = "p1", songId = "first", position = 0), + LocalPlaylistSongCrossRef(playlistId = "p1", songId = "second", position = 1), + ) + + dao.upsertPlaylist(playlist, songs) + + val ordered = dao.observePlaylistSongs("p1").first().map { it.songId } + assertThat(ordered).containsExactly("first", "second", "third").inOrder() + } + + @Test + fun upsertPlaylist_replacesPreviousCrossRefsInsteadOfMerging() = runTest { + val playlist = LocalPlaylistEntity(playlistId = "p1", name = "Running mix", createdAt = 1L, updatedAt = 1L) + dao.upsertPlaylist( + playlist, + listOf( + LocalPlaylistSongCrossRef(playlistId = "p1", songId = "s1", position = 0), + LocalPlaylistSongCrossRef(playlistId = "p1", songId = "s2", position = 1), + ), + ) + + // Re-sync with a song removed and the remaining one's position shifted — simulates the + // phone re-sending after the user edited the playlist. + dao.upsertPlaylist( + playlist.copy(updatedAt = 2L), + listOf(LocalPlaylistSongCrossRef(playlistId = "p1", songId = "s2", position = 0)), + ) + + val songIds = dao.observePlaylistSongs("p1").first().map { it.songId } + assertThat(songIds).containsExactly("s2") + } + + @Test + fun upsertPlaylist_doesNotAffectOtherPlaylists() = runTest { + dao.upsertPlaylist( + LocalPlaylistEntity(playlistId = "p1", name = "Running mix", createdAt = 1L, updatedAt = 1L), + listOf(LocalPlaylistSongCrossRef(playlistId = "p1", songId = "s1", position = 0)), + ) + dao.upsertPlaylist( + LocalPlaylistEntity(playlistId = "p2", name = "Chill mix", createdAt = 1L, updatedAt = 1L), + listOf(LocalPlaylistSongCrossRef(playlistId = "p2", songId = "s2", position = 0)), + ) + + // Re-sync p1 only. + dao.upsertPlaylist( + LocalPlaylistEntity(playlistId = "p1", name = "Running mix", createdAt = 1L, updatedAt = 2L), + listOf(LocalPlaylistSongCrossRef(playlistId = "p1", songId = "s1-updated", position = 0)), + ) + + val p2Songs = dao.observePlaylistSongs("p2").first().map { it.songId } + assertThat(p2Songs).containsExactly("s2") + } + + @Test + fun observeAllPlaylistSongCrossRefs_spansEveryPlaylist() = runTest { + dao.upsertPlaylist( + LocalPlaylistEntity(playlistId = "p1", name = "Running mix", createdAt = 1L, updatedAt = 1L), + listOf(LocalPlaylistSongCrossRef(playlistId = "p1", songId = "s1", position = 0)), + ) + dao.upsertPlaylist( + LocalPlaylistEntity(playlistId = "p2", name = "Chill mix", createdAt = 1L, updatedAt = 1L), + listOf(LocalPlaylistSongCrossRef(playlistId = "p2", songId = "s2", position = 0)), + ) + + val allSongIds = dao.observeAllPlaylistSongCrossRefs().first().map { it.songId } + assertThat(allSongIds).containsExactly("s1", "s2") + } + + @Test + fun getPlaylistById_returnsNullForAnUnknownPlaylist() = runTest { + assertThat(dao.getPlaylistById("missing")).isNull() + } +} diff --git a/wear/src/androidTest/java/com/theveloper/pixelplay/data/local/WearMusicDatabaseMigrationTest.kt b/wear/src/androidTest/java/com/theveloper/pixelplay/data/local/WearMusicDatabaseMigrationTest.kt new file mode 100644 index 0000000000..b06ca4a3ba --- /dev/null +++ b/wear/src/androidTest/java/com/theveloper/pixelplay/data/local/WearMusicDatabaseMigrationTest.kt @@ -0,0 +1,177 @@ +package com.theveloper.pixelplay.data.local + +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Verifies [WearMusicDatabase.MIGRATION_5_6] and [WearMusicDatabase.MIGRATION_6_7] against + * hand-built database files. + * + * `:wear` doesn't export Room schema JSON (`exportSchema = false`), so [androidx.room.testing.MigrationTestHelper] + * — which needs those fixtures — isn't available here. Instead this builds a real on-disk SQLite + * file matching the source version's shape, then opens it through Room with the migration(s) + * attached, the same way a real upgrading device would. + */ +@RunWith(AndroidJUnit4::class) +class WearMusicDatabaseMigrationTest { + + private val dbName = "migration-test-wear-music.db" + private val context: Context = ApplicationProvider.getApplicationContext() + + @After + fun cleanup() { + context.deleteDatabase(dbName) + } + + @Test + fun migrate5To6_createsPlaylistTablesAndPreservesExistingSongs() = runTest { + seedVersion5Database() + + val migratedDb = Room.databaseBuilder(context, WearMusicDatabase::class.java, dbName) + .addMigrations(WearMusicDatabase.MIGRATION_5_6, WearMusicDatabase.MIGRATION_6_7) + .build() + + try { + val song = migratedDb.localSongDao().getSongById("song-1") + assertThat(song).isNotNull() + assertThat(song?.title).isEqualTo("Existing song") + + // The playlist tables must exist and be queryable — this throws if the migration + // didn't run (or ran with malformed SQL) rather than returning a false "empty" result. + val playlists = migratedDb.openHelper.readableDatabase.query("SELECT * FROM local_playlists") + playlists.use { assertThat(it.count).isEqualTo(0) } + + val playlistSongs = migratedDb.openHelper.readableDatabase.query("SELECT * FROM local_playlist_songs") + playlistSongs.use { assertThat(it.count).isEqualTo(0) } + } finally { + migratedDb.close() + } + } + + @Test + fun migrate6To7_addsPendingTitleColumnDefaultingToEmpty() = runTest { + seedVersion6DatabaseWithPlaylistSong() + + val migratedDb = Room.databaseBuilder(context, WearMusicDatabase::class.java, dbName) + .addMigrations(WearMusicDatabase.MIGRATION_6_7) + .build() + + try { + // A row written before this migration existed has no pendingTitle — the migration's + // DEFAULT '' must apply, not a NULL that Room's non-null String column would choke on. + val crossRef = migratedDb.localPlaylistDao().observePlaylistSongs("p1").first().single() + assertThat(crossRef.songId).isEqualTo("s1") + assertThat(crossRef.pendingTitle).isEmpty() + } finally { + migratedDb.close() + } + } + + /** Hand-writes a v5 database file: the `local_songs` shape frozen right before this migration. */ + private fun seedVersion5Database() { + context.deleteDatabase(dbName) + val dbFile = context.getDatabasePath(dbName) + dbFile.parentFile?.mkdirs() + + val db = SQLiteDatabase.openOrCreateDatabase(dbFile, null) + db.execSQL( + "CREATE TABLE local_songs (" + + "songId TEXT NOT NULL PRIMARY KEY, " + + "title TEXT NOT NULL, " + + "artist TEXT NOT NULL, " + + "album TEXT NOT NULL, " + + "albumId INTEGER NOT NULL, " + + "duration INTEGER NOT NULL, " + + "mimeType TEXT NOT NULL, " + + "fileSize INTEGER NOT NULL, " + + "bitrate INTEGER NOT NULL, " + + "sampleRate INTEGER NOT NULL, " + + "isFavorite INTEGER NOT NULL, " + + "favoriteSyncPending INTEGER NOT NULL, " + + "paletteSeedArgb INTEGER, " + + "themePaletteJson TEXT, " + + "artworkPath TEXT, " + + "localPath TEXT NOT NULL, " + + "transferredAt INTEGER NOT NULL)" + ) + db.execSQL( + "INSERT INTO local_songs (songId, title, artist, album, albumId, duration, mimeType, " + + "fileSize, bitrate, sampleRate, isFavorite, favoriteSyncPending, paletteSeedArgb, " + + "themePaletteJson, artworkPath, localPath, transferredAt) VALUES " + + "('song-1', 'Existing song', 'Artist', 'Album', 1, 180000, 'audio/mp4', 4000000, " + + "128000, 44100, 0, 0, NULL, NULL, NULL, '/music/song-1.m4a', 1000)" + ) + db.version = 5 + db.close() + } + + /** Hand-writes a v6 database file with one playlist and one cross-ref row, the shape frozen + * right before [WearMusicDatabase.MIGRATION_6_7] added `pendingTitle`. */ + private fun seedVersion6DatabaseWithPlaylistSong() { + context.deleteDatabase(dbName) + val dbFile = context.getDatabasePath(dbName) + dbFile.parentFile?.mkdirs() + + val db = SQLiteDatabase.openOrCreateDatabase(dbFile, null) + db.execSQL( + "CREATE TABLE local_songs (" + + "songId TEXT NOT NULL PRIMARY KEY, " + + "title TEXT NOT NULL, " + + "artist TEXT NOT NULL, " + + "album TEXT NOT NULL, " + + "albumId INTEGER NOT NULL, " + + "duration INTEGER NOT NULL, " + + "mimeType TEXT NOT NULL, " + + "fileSize INTEGER NOT NULL, " + + "bitrate INTEGER NOT NULL, " + + "sampleRate INTEGER NOT NULL, " + + "isFavorite INTEGER NOT NULL, " + + "favoriteSyncPending INTEGER NOT NULL, " + + "paletteSeedArgb INTEGER, " + + "themePaletteJson TEXT, " + + "artworkPath TEXT, " + + "localPath TEXT NOT NULL, " + + "transferredAt INTEGER NOT NULL)" + ) + db.execSQL( + "CREATE TABLE local_playlists (" + + "playlistId TEXT NOT NULL PRIMARY KEY, " + + "name TEXT NOT NULL, " + + "createdAt INTEGER NOT NULL, " + + "updatedAt INTEGER NOT NULL)" + ) + db.execSQL( + "CREATE TABLE local_playlist_songs (" + + "playlistId TEXT NOT NULL, " + + "songId TEXT NOT NULL, " + + "position INTEGER NOT NULL, " + + "PRIMARY KEY(playlistId, songId))" + ) + db.execSQL( + "CREATE INDEX IF NOT EXISTS index_local_playlist_songs_playlistId_position " + + "ON local_playlist_songs(playlistId, position)" + ) + db.execSQL( + "CREATE INDEX IF NOT EXISTS index_local_playlist_songs_songId " + + "ON local_playlist_songs(songId)" + ) + db.execSQL( + "INSERT INTO local_playlists (playlistId, name, createdAt, updatedAt) VALUES " + + "('p1', 'Road trip', 1000, 1000)" + ) + db.execSQL( + "INSERT INTO local_playlist_songs (playlistId, songId, position) VALUES ('p1', 's1', 0)" + ) + db.version = 6 + db.close() + } +} 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/WearAudioOffloadPolicy.kt b/wear/src/main/java/com/theveloper/pixelplay/data/WearAudioOffloadPolicy.kt new file mode 100644 index 0000000000..ed0e8c92f8 --- /dev/null +++ b/wear/src/main/java/com/theveloper/pixelplay/data/WearAudioOffloadPolicy.kt @@ -0,0 +1,34 @@ +package com.theveloper.pixelplay.data + +/** + * Decides whether a stall shortly after audio-offload playback started should be read as an + * offload HAL reset and trigger falling back to normal (non-offload) playback for the rest of + * the session. + * + * Mirrors the phone's `DualPlayerEngine.shouldDisableAudioOffloadOnEarlyBuffering` — same + * pattern, simplified for [WearPlaybackService]'s single-player service (the phone's version + * also guards against its dual-player crossfade transitions, which don't exist here). Wear OS + * has no equivalent yet to the phone's per-OEM offload denylist + * (`shouldDisableAudioOffloadByDefaultForDevice` in `:app`) — there is no field evidence of which + * watch chipsets misbehave with offload, so [WearPlaybackService] always requests it and relies + * entirely on this runtime safety net instead of guessing at a denylist with no evidence behind + * it. + * + * The buffering is NOT treated as a HAL reset when it's explained by a recent user seek + * ([isPostSeekBuffering]) or a track change ([isPostMediaItemTransition]) — in those cases + * buffering is expected, and falling back would needlessly rebuild the player (an audible + * glitch) for no reason. + */ +internal fun wearShouldFallBackFromAudioOffload( + audioOffloadEnabled: Boolean, + lastPlayingAtMs: Long, + timeSincePlayingMs: Long, + isPostSeekBuffering: Boolean, + isPostMediaItemTransition: Boolean, +): Boolean { + return audioOffloadEnabled && + lastPlayingAtMs > 0L && + timeSincePlayingMs < 500L && + !isPostSeekBuffering && + !isPostMediaItemTransition +} 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..d4bb874b6d 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, sourceNodeId = messageEvent.sourceNodeId) + } 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/WearLoadControlProfile.kt b/wear/src/main/java/com/theveloper/pixelplay/data/WearLoadControlProfile.kt new file mode 100644 index 0000000000..111fbfd9fe --- /dev/null +++ b/wear/src/main/java/com/theveloper/pixelplay/data/WearLoadControlProfile.kt @@ -0,0 +1,41 @@ +package com.theveloper.pixelplay.data + +/** ExoPlayer [androidx.media3.exoplayer.DefaultLoadControl] buffer durations (ms). */ +internal data class WearLoadControlBufferProfile( + val minBufferMs: Int, + val maxBufferMs: Int, + val bufferForPlaybackMs: Int, + val bufferForPlaybackAfterRebufferMs: Int, +) + +/** + * Picks the [androidx.media3.exoplayer.DefaultLoadControl] buffer profile for + * [WearPlaybackService]'s player. + * + * Mirrors the phone's `DualPlayerEngine.buildAdaptiveLoadControl()` RAM tiering — the same + * reasoning applies here, amplified: a Wear OS SoC has far less RAM and CPU headroom than even + * a low-end phone, and it's sharing both with whatever fitness/health app the user has running + * at the same time (confirmed on-device: the app's own process was recycled during a system-wide + * low-memory episode while a workout tracker ran alongside local playback). ExoPlayer's default + * buffer window wasn't sized for that, and measured on-device it produced sustained + * PLAYING/BUFFERING oscillation. [android.app.ActivityManager.isLowRamDevice] is the same signal + * the phone already uses to pick its conservative tier, so this reuses it rather than inventing a + * new watch-specific threshold with no evidence behind it. + */ +internal fun wearLoadControlBufferProfileFor(isLowRamDevice: Boolean): WearLoadControlBufferProfile { + return if (isLowRamDevice) { + WearLoadControlBufferProfile( + minBufferMs = 15_000, + maxBufferMs = 30_000, + bufferForPlaybackMs = 2_500, + bufferForPlaybackAfterRebufferMs = 5_000, + ) + } else { + WearLoadControlBufferProfile( + minBufferMs = 30_000, + maxBufferMs = 60_000, + bufferForPlaybackMs = 2_500, + bufferForPlaybackAfterRebufferMs = 5_000, + ) + } +} diff --git a/wear/src/main/java/com/theveloper/pixelplay/data/WearLocalPlayerRepository.kt b/wear/src/main/java/com/theveloper/pixelplay/data/WearLocalPlayerRepository.kt index 70ae97e6a1..d4b4f4b49e 100644 --- a/wear/src/main/java/com/theveloper/pixelplay/data/WearLocalPlayerRepository.kt +++ b/wear/src/main/java/com/theveloper/pixelplay/data/WearLocalPlayerRepository.kt @@ -81,6 +81,7 @@ data class WearQueueSong( class WearLocalPlayerRepository @Inject constructor( private val application: Application, private val localSongDao: LocalSongDao, + private val playbackStatePersistence: WearPlaybackStatePersistence, ) { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) private val json = Json { ignoreUnknownKeys = true } @@ -116,6 +117,7 @@ class WearLocalPlayerRepository @Inject constructor( companion object { private const val TAG = "WearLocalPlayer" private const val POSITION_UPDATE_INTERVAL_MS = 1000L + private const val PERSIST_INTERVAL_TICKS = 10 } init { @@ -142,16 +144,21 @@ class WearLocalPlayerRepository @Inject constructor( updateState() if (playbackState == Player.STATE_ENDED) { stopPositionUpdates() + // Nothing left to resume — clear rather than leave a stale "restore" prompt + // pointing at a queue that already finished. + clearPersistedPlaybackState() } } override fun onIsPlayingChanged(isPlaying: Boolean) { updateState() + persistCurrentPlaybackState() if (isPlaying) startPositionUpdates() else stopPositionUpdates() } override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { updateState() + persistCurrentPlaybackState() } } @@ -472,6 +479,7 @@ class WearLocalPlayerRepository @Inject constructor( */ fun release() { stopPositionUpdates() + clearPersistedPlaybackState() mediaController?.let { controller -> controller.removeListener(playerListener) runCatching { @@ -536,6 +544,7 @@ class WearLocalPlayerRepository @Inject constructor( private fun startPositionUpdates() { positionUpdateJob?.cancel() positionUpdateJob = scope.launch { + var ticksSinceLastPersist = 0 while (isActive) { // Skip the StateFlow churn when the user can't see the UI: the // ExoPlayer keeps tracking position internally, we just don't @@ -545,11 +554,87 @@ class WearLocalPlayerRepository @Inject constructor( if (WearLifecycleState.isInteractiveNow) { updateState() } + // Coarser than the 1s UI tick: a DataStore write every second would be real, + // pointless disk I/O on a device this battery-constrained. Losing up to + // PERSIST_INTERVAL_TICKS seconds of position on a crash is an acceptable + // trade — onIsPlayingChanged/onMediaItemTransition already persist immediately + // on the events that matter most (a pause or a track change right before a + // crash won't be lost). + ticksSinceLastPersist++ + if (ticksSinceLastPersist >= PERSIST_INTERVAL_TICKS) { + ticksSinceLastPersist = 0 + persistCurrentPlaybackState() + } delay(POSITION_UPDATE_INTERVAL_MS) } } } + private fun persistCurrentPlaybackState() { + val player = mediaController ?: return + if (currentQueueSongIds.isEmpty()) return + val snapshot = PersistedLocalPlaybackState( + queueSongIds = currentQueueSongIds, + currentIndex = player.currentMediaItemIndex, + positionMs = player.currentPosition, + updatedAtMillis = System.currentTimeMillis(), + ) + scope.launch { + runCatching { playbackStatePersistence.save(snapshot) } + .onFailure { error -> Timber.tag(TAG).w(error, "Failed to persist local playback state") } + } + } + + private fun clearPersistedPlaybackState() { + scope.launch { + runCatching { playbackStatePersistence.clear() } + .onFailure { error -> Timber.tag(TAG).w(error, "Failed to clear persisted local playback state") } + } + } + + /** + * Restores a persisted queue, paused, if one exists and is still fresh enough + * ([isPersistedLocalPlaybackStateRestorable]) — the recovery path for a process that died + * mid-playback (see this class's KDoc). Paused rather than auto-playing: starting audio + * without a fresh user gesture on app open would be surprising, especially for headphones + * that may no longer even be in the user's ears. + * + * Safe to call unconditionally on startup: a no-op if nothing is local-playback-active + * to restore, and it never overwrites an already-active queue. + */ + suspend fun restorePersistedPlaybackIfAvailable(): Boolean { + if (_isLocalPlaybackActive.value) return false + val persisted = playbackStatePersistence.read() ?: return false + if (!isPersistedLocalPlaybackStateRestorable(persisted, System.currentTimeMillis())) { + playbackStatePersistence.clear() + return false + } + + val songsById = persisted.queueSongIds + .mapNotNull { songId -> localSongDao.getSongById(songId) } + .associateBy { it.songId } + // Songs may have been deleted from the watch since the snapshot was taken (storage + // pressure, the user removing a download) — only resume the ones that are still there, + // in their original relative order. + val playableSongs = persisted.queueSongIds.mapNotNull { songsById[it] } + if (playableSongs.isEmpty()) { + playbackStatePersistence.clear() + return false + } + + val originalIndexSongId = persisted.queueSongIds.getOrNull(persisted.currentIndex) + val restoredIndex = playableSongs.indexOfFirst { it.songId == originalIndexSongId } + .let { if (it >= 0) it else 0 } + + playLocalSongs( + songs = playableSongs, + startIndex = restoredIndex, + startPositionMs = persisted.positionMs, + autoPlay = false, + ) + return true + } + private fun stopPositionUpdates() { positionUpdateJob?.cancel() positionUpdateJob = null diff --git a/wear/src/main/java/com/theveloper/pixelplay/data/WearPlaybackService.kt b/wear/src/main/java/com/theveloper/pixelplay/data/WearPlaybackService.kt index b273ddf6b8..b0d045e34f 100644 --- a/wear/src/main/java/com/theveloper/pixelplay/data/WearPlaybackService.kt +++ b/wear/src/main/java/com/theveloper/pixelplay/data/WearPlaybackService.kt @@ -1,15 +1,31 @@ package com.theveloper.pixelplay.data +import android.app.ActivityManager import android.app.PendingIntent import android.content.Intent +import android.os.SystemClock import androidx.media3.common.AudioAttributes import androidx.media3.common.C import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.common.TrackSelectionParameters +import androidx.media3.exoplayer.DefaultLoadControl import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import androidx.media3.extractor.mp4.Mp4Extractor +import androidx.media3.extractor.text.SubtitleParser import androidx.media3.session.MediaSession import androidx.media3.session.MediaSessionService import com.google.common.util.concurrent.Futures import com.google.common.util.concurrent.ListenableFuture +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch import timber.log.Timber /** @@ -28,11 +44,75 @@ class WearPlaybackService : MediaSessionService() { private var player: ExoPlayer? = null private var mediaSession: MediaSession? = null + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + + // --- Audio offload state ------------------------------------------------------------- + // AUDIO_OFFLOAD_MODE_ENABLED (as opposed to _REQUIRED) is a *soft* request: if the watch's + // audio HAL doesn't support offloading this format, ExoPlayer silently falls back to the + // normal decode path on its own — no capability probing needed on our side for that case. + // What ExoPlayer *doesn't* handle on its own is a HAL that accepts the offloaded track but + // then resets/stalls — that failure mode is exactly what motivated the phone's + // DualPlayerEngine to build a runtime fallback (see AudioOffloadPolicyTest in :app), so this + // service mirrors that safety net rather than assuming Wear OS audio HALs are better-behaved. + private var audioOffloadEnabled = true + private var lastPlayingAtMs = 0L + private var isPostSeekBuffering = false + private var isPostMediaItemTransition = false + + // --- Mid-song stall watchdog ---------------------------------------------------------- + // See WearPlaybackStallWatchdog.kt: catches a stall AudioOffloadFallbackListener can't, one + // that happens well after playback started and never surfaces as STATE_BUFFERING. + private var stallWatchdogJob: Job? = null + private var lastWatchdogPositionMs = -1L + private var consecutiveStalledTicks = 0 override fun onCreate() { super.onCreate() + player = buildExoPlayer() + mediaSession = buildMediaSession(player!!) + startStallWatchdog() + Timber.tag(TAG).d("WearPlaybackService created") + } + + override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? = mediaSession + + override fun onTaskRemoved(rootIntent: Intent?) { + // If the user swipes the app away while nothing is playing, there's nothing to keep alive. + val activePlayer = player + if (activePlayer == null || !activePlayer.playWhenReady || activePlayer.mediaItemCount == 0) { + stopSelf() + } + } + + override fun onDestroy() { + stallWatchdogJob?.cancel() + scope.cancel() + mediaSession?.release() + mediaSession = null + player?.release() + player = null + Timber.tag(TAG).d("WearPlaybackService destroyed") + super.onDestroy() + } + + private fun buildExoPlayer(): ExoPlayer { + val isLowRamDevice = getSystemService(ActivityManager::class.java)?.isLowRamDevice == true + val bufferProfile = wearLoadControlBufferProfileFor(isLowRamDevice) + val loadControl = DefaultLoadControl.Builder() + .setBufferDurationsMs( + bufferProfile.minBufferMs, + bufferProfile.maxBufferMs, + bufferProfile.bufferForPlaybackMs, + bufferProfile.bufferForPlaybackAfterRebufferMs, + ) + // Buffered *duration*, not buffered *bytes*, decides when to (re)start playback — + // matches the phone's DualPlayerEngine and is what makes the profile above meaningful + // across formats/bitrates instead of being overridden by ExoPlayer's byte threshold. + .setPrioritizeTimeOverSizeThresholds(true) + .build() val exoPlayer = ExoPlayer.Builder(this) + .setLoadControl(loadControl) .setAudioAttributes( AudioAttributes.Builder() .setUsage(C.USAGE_MEDIA) @@ -44,35 +124,139 @@ class WearPlaybackService : MediaSessionService() { // Keep the CPU running while the watch dozes with the screen off, otherwise audio // decoding stalls a few seconds after the display turns off. .setWakeMode(C.WAKE_MODE_LOCAL) + // The default DefaultMediaSourceFactory registers ~15 extractor types (Matroska, + // FLV, AVI, MPEG-TS…) that this service never plays — every file here comes from + // [WatchAudioTranscoder] on the phone, which always writes plain (non-fragmented) + // MP4/AAC-LC. ART verifies each extractor class the first time DefaultExtractorsFactory + // touches it while sniffing the container, on the main thread; measured on-device this + // cost 120-300ms per unused class, ~2s total, stacked right on top of playback start. + // Scoping the factory to the one extractor we actually need removes that cost entirely. + .setMediaSourceFactory( + // Every other Mp4Extractor constructor/factory in this media3 version is + // deprecated in favor of newFactory(SubtitleParser.Factory); our files are + // audio-only, so subtitle parsing is simply unsupported. + DefaultMediaSourceFactory(this, Mp4Extractor.newFactory(SubtitleParser.Factory.UNSUPPORTED)) + ) + .build() + exoPlayer.trackSelectionParameters = trackSelectionParametersFor(audioOffloadEnabled) + exoPlayer.addListener(AudioOffloadFallbackListener()) + return exoPlayer + } + + private fun trackSelectionParametersFor(offloadEnabled: Boolean): TrackSelectionParameters { + return TrackSelectionParameters.DEFAULT.buildUpon() + .setAudioOffloadPreferences( + TrackSelectionParameters.AudioOffloadPreferences.Builder() + .setAudioOffloadMode( + if (offloadEnabled) { + TrackSelectionParameters.AudioOffloadPreferences.AUDIO_OFFLOAD_MODE_ENABLED + } else { + TrackSelectionParameters.AudioOffloadPreferences.AUDIO_OFFLOAD_MODE_DISABLED + } + ) + .setIsGaplessSupportRequired(false) + .setIsSpeedChangeSupportRequired(false) + .build() + ) .build() - player = exoPlayer + } - mediaSession = MediaSession.Builder(this, exoPlayer) + private fun buildMediaSession(exoPlayer: ExoPlayer): MediaSession { + return MediaSession.Builder(this, exoPlayer) .setId(MEDIA_SESSION_ID) .setSessionActivity(buildOpenAppIntent()) .setCallback(MediaItemUriRestoringCallback()) .build() + } - Timber.tag(TAG).d("WearPlaybackService created") + /** + * Rebuilds the player after [AudioOffloadFallbackListener] reads an early re-buffer as a HAL + * reset, disabling offload for the rest of the session. + */ + private fun fallBackFromAudioOffload(reason: String) { + if (!audioOffloadEnabled) return + audioOffloadEnabled = false + Timber.tag(TAG).w("Falling back from audio offload: %s", reason) + rebuildPlayerPreservingState() } - override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? = mediaSession + /** + * Rebuilds the player after the stall watchdog sees position frozen for + * [STALL_TICKS_THRESHOLD] ticks in a row — the *mid-song* wedge [fallBackFromAudioOffload] + * can't see (see WearPlaybackStallWatchdog.kt). Unlike that early check, this doesn't gate on + * [audioOffloadEnabled]: if offload is still on, disabling it too is the best available guess + * at the cause, but the rebuild itself — a fresh ExoPlayer/AudioTrack instance — is the actual + * fix regardless, so it still runs even if offload was already off from an earlier fallback. + */ + private fun recoverFromStalledPlayback(reason: String) { + Timber.tag(TAG).w("Recovering from stalled playback: %s", reason) + audioOffloadEnabled = false + rebuildPlayerPreservingState() + } - override fun onTaskRemoved(rootIntent: Intent?) { - // If the user swipes the app away while nothing is playing, there's nothing to keep alive. - val activePlayer = player - if (activePlayer == null || !activePlayer.playWhenReady || activePlayer.mediaItemCount == 0) { - stopSelf() + /** + * Preserves queue/position/play-state across a player rebuild. [MediaSession.setPlayer] lets + * the existing session (and any connected `MediaController`, including the phone acting as a + * remote) keep its binder connection across the swap instead of tearing down and + * reconnecting — the rebuild is invisible to callers beyond a brief re-buffer. + */ + private fun rebuildPlayerPreservingState() { + val oldPlayer = player ?: return + + val mediaItems = ArrayList(oldPlayer.mediaItemCount) + for (i in 0 until oldPlayer.mediaItemCount) mediaItems.add(oldPlayer.getMediaItemAt(i)) + val currentIndex = oldPlayer.currentMediaItemIndex.coerceAtLeast(0) + val positionMs = oldPlayer.currentPosition.coerceAtLeast(0L) + val playWhenReady = oldPlayer.playWhenReady + val repeatMode = oldPlayer.repeatMode + val shuffleModeEnabled = oldPlayer.shuffleModeEnabled + + val newPlayer = buildExoPlayer() + if (mediaItems.isNotEmpty()) { + newPlayer.setMediaItems(mediaItems, currentIndex, positionMs) + newPlayer.repeatMode = repeatMode + newPlayer.shuffleModeEnabled = shuffleModeEnabled + newPlayer.prepare() + newPlayer.playWhenReady = playWhenReady } + + player = newPlayer + mediaSession?.setPlayer(newPlayer) + oldPlayer.release() + + // The new player instance starts wherever setMediaItems/positionMs put it — don't let a + // stale reading from the old (just-released) player count as "no progress" against it. + lastWatchdogPositionMs = -1L + consecutiveStalledTicks = 0 } - override fun onDestroy() { - mediaSession?.release() - mediaSession = null - player?.release() - player = null - Timber.tag(TAG).d("WearPlaybackService destroyed") - super.onDestroy() + /** + * Ticks once a second, comparing the player's own reported position against the last tick's — + * a stall that doesn't change [Player.getPlaybackState] (see WearPlaybackStallWatchdog.kt) + * has no listener callback to hook, so this is the only way to catch it. + */ + private fun startStallWatchdog() { + stallWatchdogJob?.cancel() + stallWatchdogJob = scope.launch { + while (isActive) { + delay(STALL_TICK_INTERVAL_MS) + val current = player ?: continue + val isPlaying = current.isPlaying + val position = current.currentPosition + val positionAdvanced = position != lastWatchdogPositionMs + lastWatchdogPositionMs = position + consecutiveStalledTicks = wearPlaybackStalledTickCount( + isPlaying = isPlaying, + positionAdvancedSinceLastTick = positionAdvanced, + previousConsecutiveStalledTicks = consecutiveStalledTicks, + ) + if (consecutiveStalledTicks >= STALL_TICKS_THRESHOLD) { + recoverFromStalledPlayback( + "no position advance for ${STALL_TICK_INTERVAL_MS * STALL_TICKS_THRESHOLD}ms" + ) + } + } + } } private fun buildOpenAppIntent(): PendingIntent { @@ -87,6 +271,50 @@ class WearPlaybackService : MediaSessionService() { ) } + /** + * Watches for the early-buffering pattern [wearShouldFallBackFromAudioOffload] recognizes as + * an offload HAL reset, and triggers [fallBackFromAudioOffload] when it does. + */ + private inner class AudioOffloadFallbackListener : Player.Listener { + + override fun onIsPlayingChanged(isPlaying: Boolean) { + if (isPlaying) { + lastPlayingAtMs = SystemClock.elapsedRealtime() + isPostSeekBuffering = false + isPostMediaItemTransition = false + } + } + + override fun onPositionDiscontinuity( + oldPosition: Player.PositionInfo, + newPosition: Player.PositionInfo, + reason: Int, + ) { + if (reason == Player.DISCONTINUITY_REASON_SEEK) { + isPostSeekBuffering = true + } + } + + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { + isPostMediaItemTransition = true + } + + override fun onPlaybackStateChanged(playbackState: Int) { + if (playbackState != Player.STATE_BUFFERING) return + val now = SystemClock.elapsedRealtime() + val shouldFallBack = wearShouldFallBackFromAudioOffload( + audioOffloadEnabled = audioOffloadEnabled, + lastPlayingAtMs = lastPlayingAtMs, + timeSincePlayingMs = now - lastPlayingAtMs, + isPostSeekBuffering = isPostSeekBuffering, + isPostMediaItemTransition = isPostMediaItemTransition, + ) + if (shouldFallBack) { + fallBackFromAudioOffload("early re-buffer ${now - lastPlayingAtMs}ms after playing") + } + } + } + /** * A `MediaController` strips [MediaItem.localConfiguration] (the playable URI) when it hands * items across the binder to this service. The repository stashes the original URI in @@ -114,5 +342,11 @@ class WearPlaybackService : MediaSessionService() { companion object { private const val TAG = "WearPlaybackService" private const val MEDIA_SESSION_ID = "wear-local-playback" + + // 3 consecutive 1s ticks with zero position movement while isPlaying=true — long enough + // that a legitimate single slow tick can't false-positive, short enough that a real wedge + // doesn't sit silent for long before recovering. + private const val STALL_TICK_INTERVAL_MS = 1_000L + private const val STALL_TICKS_THRESHOLD = 3 } } diff --git a/wear/src/main/java/com/theveloper/pixelplay/data/WearPlaybackStallWatchdog.kt b/wear/src/main/java/com/theveloper/pixelplay/data/WearPlaybackStallWatchdog.kt new file mode 100644 index 0000000000..6fcb27b5c9 --- /dev/null +++ b/wear/src/main/java/com/theveloper/pixelplay/data/WearPlaybackStallWatchdog.kt @@ -0,0 +1,30 @@ +package com.theveloper.pixelplay.data + +/** + * Detects a playback stall that [wearShouldFallBackFromAudioOffload] can't see. + * + * That check only reacts to a stall within ~500ms of *starting* playback, surfaced as + * `STATE_BUFFERING` — the pattern the phone's `DualPlayerEngine` originally guarded against. Real + * hardware testing on the watch (a Samsung Galaxy Watch) showed a different failure: the offload + * HAL wedges *mid-song*, well past that early window, and the player's own reported state never + * changes — it stays `STATE_READY` / `isPlaying=true` (the AudioTrack has simply stopped draining + * what ExoPlayer feeds it). That's consistent with what was observed: `play()` does nothing once + * this happens (from the player's point of view, `playWhenReady` is already `true` — there's + * nothing to resume), while `seekToNext()` still works (it tears down and rebuilds the sink for + * the new item, sidestepping the wedged one — and then wedges again on that new item too, since + * whatever the underlying condition is hasn't changed). + * + * Since there's no state transition to hook, this is driven by a timer polling the player's own + * reported position instead: called once per tick while ticking at a fixed interval (see + * [WearPlaybackService]), it turns "did the position actually move since last tick" into a + * consecutive-stall counter, so a real freeze (not just a brief legitimate pause in advancing) is + * required before anything reacts. + */ +internal fun wearPlaybackStalledTickCount( + isPlaying: Boolean, + positionAdvancedSinceLastTick: Boolean, + previousConsecutiveStalledTicks: Int, +): Int { + if (!isPlaying || positionAdvancedSinceLastTick) return 0 + return previousConsecutiveStalledTicks + 1 +} diff --git a/wear/src/main/java/com/theveloper/pixelplay/data/WearPlaybackStatePersistence.kt b/wear/src/main/java/com/theveloper/pixelplay/data/WearPlaybackStatePersistence.kt new file mode 100644 index 0000000000..c815098395 --- /dev/null +++ b/wear/src/main/java/com/theveloper/pixelplay/data/WearPlaybackStatePersistence.kt @@ -0,0 +1,100 @@ +package com.theveloper.pixelplay.data + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +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 + +/** Watch-side DataStore, separate from the phone's — the two processes never share one file. */ +val Context.wearDataStore: DataStore by preferencesDataStore(name = "wear_settings") + +/** + * The watch's local-playback queue and position, in just enough detail to restore it, paused, + * after the hosting process dies mid-playback. + * + * Confirmed on-device this session, not a theoretical concern: the app's process was recycled + * during a system-wide low-memory episode while a fitness-tracking app ran alongside local + * playback (PID changed across ~90s in which 23 other system processes were also killed for + * memory). `WearPlaybackService` being a foreground `MediaSessionService` makes that less likely, + * not impossible — Wear OS watches have very little RAM to begin with. + */ +@Serializable +data class PersistedLocalPlaybackState( + val queueSongIds: List, + val currentIndex: Int, + val positionMs: Long, + val updatedAtMillis: Long, +) + +/** + * Persists at most one in-flight local-playback queue — mirrors + * `PlaylistBatchTransferPersistence` in `:app` (same DataStore-backed, single-slot, + * JSON-via-kotlinx.serialization shape), adapted to the watch's own DataStore since `:wear` and + * `:app` are separate processes with no shared storage. + */ +@Singleton +class WearPlaybackStatePersistence @Inject constructor( + private val dataStore: DataStore, +) { + private val json = Json { ignoreUnknownKeys = true } + + suspend fun save(state: PersistedLocalPlaybackState) { + dataStore.edit { preferences -> + preferences[Keys.LOCAL_PLAYBACK_STATE] = json.encodeToString(state) + } + } + + suspend fun clear() { + dataStore.edit { preferences -> preferences.remove(Keys.LOCAL_PLAYBACK_STATE) } + } + + suspend fun read(): PersistedLocalPlaybackState? { + val stored = dataStore.data.first()[Keys.LOCAL_PLAYBACK_STATE] ?: return null + return try { + json.decodeFromString(stored) + } catch (e: Exception) { + Timber.tag(TAG).w(e, "Failed to decode persisted local playback state, discarding it") + null + } + } + + private object Keys { + val LOCAL_PLAYBACK_STATE = stringPreferencesKey("wear_local_playback_state_v1") + } + + private companion object { + const val TAG = "WearPlaybackPersist" + } +} + +/** + * Whether a persisted queue is still worth restoring. + * + * Requires at least one song id (an empty queue is nothing to resume) and caps how stale the + * snapshot can be: recovering from a crash a few minutes or hours ago is the point of this + * (§R-06-adjacent — the phone-side batch-transfer persistence uses the same "was genuinely + * in-flight" reasoning); silently resurrecting whatever was playing days ago the next time the + * app happens to open would be surprising rather than helpful. There's no on-device data to + * calibrate the exact cutoff, so this picks a conservative, generously-long window instead of a + * precisely-tuned one. + */ +internal fun isPersistedLocalPlaybackStateRestorable( + state: PersistedLocalPlaybackState, + nowMillis: Long, + maxAgeMillis: Long = 6 * 60 * 60 * 1000L, +): Boolean { + if (state.queueSongIds.isEmpty()) return false + if (state.currentIndex !in state.queueSongIds.indices) return false + val ageMillis = nowMillis - state.updatedAtMillis + return ageMillis in 0..maxAgeMillis +} 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..f64e1028d8 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,15 @@ 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.WearPlaylistSyncAck import com.theveloper.pixelplay.shared.WearTransferMetadata import com.theveloper.pixelplay.shared.WearTransferProgress import com.theveloper.pixelplay.shared.WearTransferRequest @@ -70,6 +75,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, @@ -120,6 +126,13 @@ class WearTransferRepository @Inject constructor( /** Failsafe timeout per transfer to avoid hanging states at 0%. */ private val transferWatchdogs = ConcurrentHashMap() + /** The live audio InputStream for a request, while onAudioChannelOpened is reading it — + * lets armTransferWatchdog actually interrupt a stuck read instead of just updating + * bookkeeping while the real transfer keeps running unaware. */ + private val openAudioStreams = ConcurrentHashMap() + /** Request IDs whose audio stream was closed by the watchdog, so onAudioChannelOpened's + * catch block can report "Transfer timed out" instead of a generic stream-closed message. */ + private val watchdogTimedOutRequestIds = ConcurrentHashMap.newKeySet() /** Request IDs currently receiving bytes through ChannelClient. */ private val activeChannelRequestIds = ConcurrentHashMap.newKeySet() /** Cancelled request IDs retained briefly so late metadata/progress/channel events are ignored safely. */ @@ -483,6 +496,7 @@ class WearTransferRepository @Inject constructor( if (!musicDir.exists()) musicDir.mkdirs() val tempFile = File(musicDir, "$requestId.part") var metadata: WearTransferMetadata? = pendingMetadata[requestId] + openAudioStreams[requestId] = inputStream try { if (isTransferCancelled(requestId)) { @@ -764,15 +778,18 @@ class WearTransferRepository @Inject constructor( "Transfer complete: ${resolvedMetadata.title} ($actualSize bytes) → ${localFile.absolutePath}" ) } catch (e: Exception) { + val timedOut = watchdogTimedOutRequestIds.remove(requestId) Timber.tag(TAG).e(e, "Failed to write transferred file") tempFile.delete() handleTransferError( requestId = requestId, songId = metadata?.songId ?: _activeTransfers.value[requestId]?.songId.orEmpty(), - message = e.message ?: "Write failed", + message = if (timedOut) "Transfer timed out" else (e.message ?: "Write failed"), ) } finally { activeChannelRequestIds.remove(requestId) + openAudioStreams.remove(requestId) + watchdogTimedOutRequestIds.remove(requestId) } } @@ -866,6 +883,62 @@ 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. + * + * [sourceNodeId] is where the ack goes back to. Acking is best-effort and never blocks or + * fails this function — if [WearPlaylistSync.requestId] is empty (an old phone build) there's + * nothing to correlate an ack to, so none is sent. + */ + suspend fun onPlaylistSyncReceived(sync: WearPlaylistSync, sourceNodeId: String) { + 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, + // songTitles is a parallel list to songIds; an older phone build omits it + // entirely (defaults to emptyList()), so this falls back to "" per song rather + // than crashing on an index that isn't there. + pendingTitle = sync.songTitles.getOrElse(index) { "" }, + ) + } + localPlaylistDao.upsertPlaylist(entity, crossRefs) + Timber.tag(TAG).d( + "Playlist synced: %s (%d songs)", + sync.name, + sync.songIds.size, + ) + + if (sync.requestId.isNotEmpty()) { + sendPlaylistSyncAck(sourceNodeId, sync.playlistId, sync.requestId) + } + } + + private suspend fun sendPlaylistSyncAck(nodeId: String, playlistId: String, requestId: String) { + val ack = WearPlaylistSyncAck(playlistId = playlistId, requestId = requestId) + try { + val ackBytes = json.encodeToString(ack).toByteArray(Charsets.UTF_8) + messageClient.sendMessage(nodeId, WearDataPaths.PLAYLIST_SYNC_ACK, ackBytes).await() + } catch (e: Exception) { + // Not retried here: if this is lost too, the phone's own await-ack timeout fires and + // it resends the whole sync, which is idempotent — so the watch just gets another shot + // at acking rather than needing its own retry logic for the ack itself. + Timber.tag(TAG).w(e, "Failed to send playlist sync ack: playlistId=%s", playlistId) + } + } + /** * Called when artwork bytes arrive over the dedicated artwork channel. * If song row exists, artwork is persisted immediately; otherwise cached until audio finishes. @@ -941,6 +1014,8 @@ class WearTransferRepository @Inject constructor( pendingArtworkByRequestId.remove(requestId) clearTransferWatchdog(requestId) activeChannelRequestIds.remove(requestId) + openAudioStreams.remove(requestId) + watchdogTimedOutRequestIds.remove(requestId) } private fun handleTransferError(requestId: String, songId: String, message: String) { @@ -962,6 +1037,8 @@ class WearTransferRepository @Inject constructor( pendingArtworkByRequestId.remove(requestId) clearTransferWatchdog(requestId) activeChannelRequestIds.remove(requestId) + openAudioStreams.remove(requestId) + watchdogTimedOutRequestIds.remove(requestId) } private fun resolveTemporaryPlaybackStartPosition( @@ -1044,7 +1121,25 @@ class WearTransferRepository @Inject constructor( transferWatchdogs[requestId] = scope.launch { delay(TRANSFER_IDLE_TIMEOUT_MS) if (_activeTransfers.value.containsKey(requestId)) { - handleTransferError(requestId, songId, "Transfer timed out") + val stream = openAudioStreams[requestId] + if (stream != null) { + // A live audio stream is genuinely stuck: close it so the blocking + // read() in onAudioChannelOpened unblocks with an IOException and routes + // through that function's own catch block for cleanup — a single, + // consistent path instead of this watchdog declaring failure on its own + // while the read loop keeps running in the background, unaware anything + // happened. That's what let a "failed" transfer keep going and finish + // seconds later anyway, or worse, strip pendingMetadata out from under + // the still-running loop and turn a slow-but-fine transfer into a real + // failure ("Transfer metadata missing" from the loop's own metadata + // resolution not finding what this watchdog had just cleared). + watchdogTimedOutRequestIds.add(requestId) + runCatching { stream.close() } + } else { + // No audio stream open yet (still waiting on metadata/channel) — nothing + // to interrupt, so this is still the right place to declare failure. + handleTransferError(requestId, songId, "Transfer timed out") + } } } } diff --git a/wear/src/main/java/com/theveloper/pixelplay/data/local/LocalPlaylistDao.kt b/wear/src/main/java/com/theveloper/pixelplay/data/local/LocalPlaylistDao.kt new file mode 100644 index 0000000000..2dcfd1781b --- /dev/null +++ b/wear/src/main/java/com/theveloper/pixelplay/data/local/LocalPlaylistDao.kt @@ -0,0 +1,53 @@ +package com.theveloper.pixelplay.data.local + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import kotlinx.coroutines.flow.Flow + +/** + * DAO for locally stored playlist snapshots on the watch. + */ +@Dao +interface LocalPlaylistDao { + + /** + * Replaces the playlist row and its full song membership/order in one transaction, so a + * re-sync (e.g. after adding a song on the phone) always reflects the latest order rather + * than merging with stale cross-refs. + */ + @Transaction + suspend fun upsertPlaylist(entity: LocalPlaylistEntity, songCrossRefs: List) { + insertPlaylist(entity) + deleteSongsForPlaylist(entity.playlistId) + insertSongCrossRefs(songCrossRefs) + } + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertPlaylist(entity: LocalPlaylistEntity) + + @Query("SELECT * FROM local_playlists WHERE playlistId = :playlistId") + suspend fun getPlaylistById(playlistId: String): LocalPlaylistEntity? + + @Query("DELETE FROM local_playlist_songs WHERE playlistId = :playlistId") + suspend fun deleteSongsForPlaylist(playlistId: String) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertSongCrossRefs(crossRefs: List) + + @Query("SELECT * FROM local_playlists ORDER BY updatedAt DESC") + fun observePlaylists(): Flow> + + @Query("SELECT * FROM local_playlist_songs WHERE playlistId = :playlistId ORDER BY position ASC") + fun observePlaylistSongs(playlistId: String): Flow> + + /** + * All playlist/song memberships across every playlist — used to map an in-flight transfer's + * songId back to the playlist(s) it belongs to (e.g. to show "receiving" state on a playlist + * card while one of its songs is still transferring). + */ + @Query("SELECT * FROM local_playlist_songs") + fun observeAllPlaylistSongCrossRefs(): Flow> +} diff --git a/wear/src/main/java/com/theveloper/pixelplay/data/local/LocalPlaylistEntity.kt b/wear/src/main/java/com/theveloper/pixelplay/data/local/LocalPlaylistEntity.kt new file mode 100644 index 0000000000..4ceb65768d --- /dev/null +++ b/wear/src/main/java/com/theveloper/pixelplay/data/local/LocalPlaylistEntity.kt @@ -0,0 +1,17 @@ +package com.theveloper.pixelplay.data.local + +import androidx.room.Entity +import androidx.room.PrimaryKey + +/** + * Room entity representing a playlist snapshot synced from the phone, so the watch can browse + * and play it offline. Membership and order live separately in [LocalPlaylistSongCrossRef] — + * this row only carries the playlist's own identity and timestamps. + */ +@Entity(tableName = "local_playlists") +data class LocalPlaylistEntity( + @PrimaryKey val playlistId: String, + val name: String, + val createdAt: Long, + val updatedAt: Long, +) diff --git a/wear/src/main/java/com/theveloper/pixelplay/data/local/LocalPlaylistSongCrossRef.kt b/wear/src/main/java/com/theveloper/pixelplay/data/local/LocalPlaylistSongCrossRef.kt new file mode 100644 index 0000000000..9680f2d064 --- /dev/null +++ b/wear/src/main/java/com/theveloper/pixelplay/data/local/LocalPlaylistSongCrossRef.kt @@ -0,0 +1,30 @@ +package com.theveloper.pixelplay.data.local + +import androidx.room.Entity +import androidx.room.Index + +/** + * Junction row recording that [songId] belongs to [playlistId] at [position]. Deliberately has + * no foreign key to `local_songs`: a playlist syncs its full membership/order up front, before + * the audio for every song has finished transferring (see `WearPlaylistSync`), so a cross-ref + * routinely points at a songId that doesn't have a matching [LocalSongEntity] row yet. + * + * [pendingTitle] is a best-effort display name from that same sync, used only while the song + * hasn't arrived — once [LocalSongEntity] exists for [songId], the UI reads the real title from + * there instead. Empty if the sync that created this row predates [pendingTitle] (an older phone + * build) or otherwise didn't include it; callers fall back to showing [songId] in that case. + */ +@Entity( + tableName = "local_playlist_songs", + primaryKeys = ["playlistId", "songId"], + indices = [ + Index(value = ["playlistId", "position"]), + Index(value = ["songId"]), + ], +) +data class LocalPlaylistSongCrossRef( + val playlistId: String, + val songId: String, + val position: Int, + val pendingTitle: String = "", +) diff --git a/wear/src/main/java/com/theveloper/pixelplay/data/local/WearMusicDatabase.kt b/wear/src/main/java/com/theveloper/pixelplay/data/local/WearMusicDatabase.kt index 25c99e2788..9afd9e13d7 100644 --- a/wear/src/main/java/com/theveloper/pixelplay/data/local/WearMusicDatabase.kt +++ b/wear/src/main/java/com/theveloper/pixelplay/data/local/WearMusicDatabase.kt @@ -9,9 +9,14 @@ import androidx.sqlite.db.SupportSQLiteDatabase * Room database for locally stored songs on the watch. * Tracks songs that have been transferred from the phone for offline playback. */ -@Database(entities = [LocalSongEntity::class], version = 5, exportSchema = false) +@Database( + entities = [LocalSongEntity::class, LocalPlaylistEntity::class, LocalPlaylistSongCrossRef::class], + version = 7, + exportSchema = false, +) abstract class WearMusicDatabase : RoomDatabase() { abstract fun localSongDao(): LocalSongDao + abstract fun localPlaylistDao(): LocalPlaylistDao companion object { val MIGRATION_1_2 = object : Migration(1, 2) { @@ -38,5 +43,45 @@ abstract class WearMusicDatabase : RoomDatabase() { db.execSQL("ALTER TABLE local_songs ADD COLUMN favoriteSyncPending INTEGER NOT NULL DEFAULT 0") } } + + val MIGRATION_5_6 = object : Migration(5, 6) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "CREATE TABLE IF NOT EXISTS local_playlists (" + + "playlistId TEXT NOT NULL PRIMARY KEY, " + + "name TEXT NOT NULL, " + + "createdAt INTEGER NOT NULL, " + + "updatedAt INTEGER NOT NULL)" + ) + db.execSQL( + "CREATE TABLE IF NOT EXISTS local_playlist_songs (" + + "playlistId TEXT NOT NULL, " + + "songId TEXT NOT NULL, " + + "position INTEGER NOT NULL, " + + "PRIMARY KEY(playlistId, songId))" + ) + db.execSQL( + "CREATE INDEX IF NOT EXISTS index_local_playlist_songs_playlistId_position " + + "ON local_playlist_songs(playlistId, position)" + ) + db.execSQL( + "CREATE INDEX IF NOT EXISTS index_local_playlist_songs_songId " + + "ON local_playlist_songs(songId)" + ) + } + } + + val MIGRATION_6_7 = object : Migration(6, 7) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "ALTER TABLE local_playlist_songs ADD COLUMN pendingTitle TEXT NOT NULL DEFAULT ''" + ) + } + } + + /** Every migration this database has ever declared, in order — wire all of them, not just the newest. */ + val ALL_MIGRATIONS = arrayOf( + MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, + ) } } diff --git a/wear/src/main/java/com/theveloper/pixelplay/di/WearModule.kt b/wear/src/main/java/com/theveloper/pixelplay/di/WearModule.kt index 4edfd651c1..6f3c6cd440 100644 --- a/wear/src/main/java/com/theveloper/pixelplay/di/WearModule.kt +++ b/wear/src/main/java/com/theveloper/pixelplay/di/WearModule.kt @@ -1,14 +1,18 @@ package com.theveloper.pixelplay.di import android.app.Application +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences import androidx.room.Room import com.google.android.gms.wearable.ChannelClient import com.google.android.gms.wearable.DataClient import com.google.android.gms.wearable.MessageClient import com.google.android.gms.wearable.NodeClient import com.google.android.gms.wearable.Wearable +import com.theveloper.pixelplay.data.local.LocalPlaylistDao import com.theveloper.pixelplay.data.local.LocalSongDao import com.theveloper.pixelplay.data.local.WearMusicDatabase +import com.theveloper.pixelplay.data.wearDataStore import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -46,10 +50,22 @@ object WearModule { application, WearMusicDatabase::class.java, "wear_music.db" - ).build() + ) + .addMigrations(*WearMusicDatabase.ALL_MIGRATIONS) + .build() @Provides @Singleton fun provideLocalSongDao(database: WearMusicDatabase): LocalSongDao = database.localSongDao() + + @Provides + @Singleton + fun provideLocalPlaylistDao(database: WearMusicDatabase): LocalPlaylistDao = + database.localPlaylistDao() + + @Provides + @Singleton + fun provideDataStore(application: Application): DataStore = + application.wearDataStore } diff --git a/wear/src/main/java/com/theveloper/pixelplay/presentation/WearMainActivity.kt b/wear/src/main/java/com/theveloper/pixelplay/presentation/WearMainActivity.kt index 8f60b8dce6..c633916ca9 100644 --- a/wear/src/main/java/com/theveloper/pixelplay/presentation/WearMainActivity.kt +++ b/wear/src/main/java/com/theveloper/pixelplay/presentation/WearMainActivity.kt @@ -3,8 +3,8 @@ package com.theveloper.pixelplay.presentation import android.os.Bundle import androidx.activity.compose.setContent import androidx.compose.runtime.getValue -import androidx.compose.runtime.collectAsState import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.wear.ambient.AmbientLifecycleObserver import com.theveloper.pixelplay.data.WearLifecycleState @@ -39,9 +39,9 @@ class WearMainActivity : FragmentActivity() { setContent { val playerViewModel: WearPlayerViewModel = hiltViewModel() - val albumArt by playerViewModel.albumArt.collectAsState() - val paletteSeedArgb by playerViewModel.paletteSeedArgb.collectAsState() - val themePalette by playerViewModel.themePalette.collectAsState() + val albumArt by playerViewModel.albumArt.collectAsStateWithLifecycle() + val paletteSeedArgb by playerViewModel.paletteSeedArgb.collectAsStateWithLifecycle() + val themePalette by playerViewModel.themePalette.collectAsStateWithLifecycle() WearPixelPlayTheme( albumArt = albumArt, 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..fbd749de5b 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 @@ -69,6 +77,16 @@ fun WearNavigation() { } } } + // Starting playback from deep in Downloads/Playlists is otherwise a lot of swipes-back to + // reach the transport controls — jump straight there instead, clearing everything in + // between so a swipe-back from Player lands on Player's own dismiss behavior, not back + // through the browse stack. + val navigateToPlayer: () -> Unit = { + navController.navigate(WearScreens.PLAYER) { + popUpTo(WearScreens.PLAYER) { inclusive = true } + launchSingleTop = true + } + } SwipeDismissableNavHost( navController = navController, @@ -138,7 +156,42 @@ fun WearNavigation() { } composable(WearScreens.DOWNLOADS) { - DownloadsScreen() + DownloadsScreen( + onPlaylistsClick = { + navController.navigate(WearScreens.LOCAL_PLAYLISTS) { + launchSingleTop = true + } + }, + onPlaybackStarted = navigateToPlayer, + ) + } + + 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, + onPlaybackStarted = navigateToPlayer, + ) } composable(WearScreens.BROWSE) { diff --git a/wear/src/main/java/com/theveloper/pixelplay/presentation/components/PlayingEqIcon.kt b/wear/src/main/java/com/theveloper/pixelplay/presentation/components/PlayingEqIcon.kt index e43e81b86d..96f00e2ee1 100644 --- a/wear/src/main/java/com/theveloper/pixelplay/presentation/components/PlayingEqIcon.kt +++ b/wear/src/main/java/com/theveloper/pixelplay/presentation/components/PlayingEqIcon.kt @@ -8,7 +8,6 @@ import androidx.compose.animation.core.tween import androidx.compose.foundation.Canvas import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier @@ -16,6 +15,7 @@ import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.theveloper.pixelplay.data.WearLifecycleState import kotlinx.coroutines.isActive import kotlin.math.PI @@ -36,8 +36,8 @@ fun PlayingEqIcon( val fullRotation = (2f * PI).toFloat() val phaseAnim = remember { Animatable(0f) } val wanderAnim = remember { Animatable(0f) } - val isInteractive by WearLifecycleState.isInteractive.collectAsState( - initial = WearLifecycleState.isInteractiveNow, + val isInteractive by WearLifecycleState.isInteractive.collectAsStateWithLifecycle( + initialValue = WearLifecycleState.isInteractiveNow, ) val animate = isPlaying && isInteractive 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..2ad9135d7e 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 @@ -28,7 +29,6 @@ import androidx.compose.material.icons.rounded.Refresh import androidx.compose.material.icons.rounded.Security import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -47,6 +47,7 @@ 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.lifecycle.compose.collectAsStateWithLifecycle import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.core.content.ContextCompat import androidx.wear.compose.material.Chip @@ -81,17 +82,19 @@ import kotlinx.coroutines.flow.collect */ @Composable fun DownloadsScreen( + onPlaylistsClick: () -> Unit = {}, + onPlaybackStarted: () -> Unit = {}, viewModel: WearDownloadsViewModel = hiltViewModel(), playerViewModel: WearPlayerViewModel = hiltViewModel(), ) { - val localSongs by viewModel.localSongs.collectAsState() - val activeTransfers by viewModel.activeTransfers.collectAsState() - val deviceSongs by viewModel.deviceSongs.collectAsState() - val isDeviceLibraryLoading by viewModel.isDeviceLibraryLoading.collectAsState() - val deviceLibraryError by viewModel.deviceLibraryError.collectAsState() - val pendingPhonePlaybackSongId by viewModel.pendingPhonePlaybackSongId.collectAsState() - val playerState by playerViewModel.playerState.collectAsState() - val isPhoneConnected by playerViewModel.isPhoneConnected.collectAsState() + val localSongs by viewModel.localSongs.collectAsStateWithLifecycle() + val activeTransfers by viewModel.activeTransfers.collectAsStateWithLifecycle() + val deviceSongs by viewModel.deviceSongs.collectAsStateWithLifecycle() + val isDeviceLibraryLoading by viewModel.isDeviceLibraryLoading.collectAsStateWithLifecycle() + val deviceLibraryError by viewModel.deviceLibraryError.collectAsStateWithLifecycle() + val pendingPhonePlaybackSongId by viewModel.pendingPhonePlaybackSongId.collectAsStateWithLifecycle() + val playerState by playerViewModel.playerState.collectAsStateWithLifecycle() + val isPhoneConnected by playerViewModel.isPhoneConnected.collectAsStateWithLifecycle() val palette = LocalWearPalette.current val watchLibraryTitleFont = rememberWatchLibraryTitleFont() val columnState = rememberResponsiveColumnState() @@ -183,6 +186,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( @@ -555,10 +585,12 @@ fun DownloadsScreen( onPlayOnWatch = { viewModel.playLocalSong(menuSong.songId) selectedLocalSongForMenu = null + onPlaybackStarted() }, onPlayOnPhone = { viewModel.playSongOnPhone(menuSong.songId) selectedLocalSongForMenu = null + onPlaybackStarted() }, onDeleteFromWatch = { selectedLocalSongForMenu = null diff --git a/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryListScreen.kt b/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryListScreen.kt index 1dbedfb69f..24e85d4a94 100644 --- a/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryListScreen.kt +++ b/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryListScreen.kt @@ -10,7 +10,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -21,6 +20,7 @@ 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.lifecycle.compose.collectAsStateWithLifecycle import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.wear.compose.material.Chip import androidx.wear.compose.material.ChipDefaults @@ -59,7 +59,7 @@ fun LibraryListScreen( onItemClick: (item: WearLibraryItem, subBrowseType: String, itemTitle: String) -> Unit, viewModel: WearBrowseViewModel = hiltViewModel(), ) { - val uiState by viewModel.uiState.collectAsState() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() val palette = LocalWearPalette.current val subscreenTitleFont = rememberBrowseSubscreenTitleFont() 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..f62b4095ad --- /dev/null +++ b/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/LocalPlaylistDetailScreen.kt @@ -0,0 +1,273 @@ +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, + onPlaybackStarted: () -> Unit = {}, + 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(); onPlaybackStarted() }, + 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) + onPlaybackStarted() + } + }, + ) + } + } + } + + 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 = item.displayTitle + 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/screens/MoreScreen.kt b/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/MoreScreen.kt index 2ba0eb1a47..d38b921fb5 100644 --- a/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/MoreScreen.kt +++ b/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/MoreScreen.kt @@ -27,7 +27,6 @@ import androidx.compose.material.icons.rounded.Shuffle import androidx.compose.material.icons.rounded.SkipNext import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -39,6 +38,7 @@ 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.lifecycle.compose.collectAsStateWithLifecycle import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.wear.compose.material.Chip import androidx.wear.compose.material.ChipDefaults @@ -72,13 +72,13 @@ fun MoreScreen( val palette = LocalWearPalette.current val columnState = rememberResponsiveColumnState() - val playerState by playerViewModel.playerState.collectAsState() - val isPhoneConnected by playerViewModel.isPhoneConnected.collectAsState() - val isWatchOutputSelected by playerViewModel.isWatchOutputSelected.collectAsState() - val canCurrentSongBeFavorited by playerViewModel.canCurrentSongBeFavorited.collectAsState() - val queueState by browseViewModel.uiState.collectAsState() - val downloadedSongIds by downloadsViewModel.downloadedSongIds.collectAsState() - val activeTransfers by downloadsViewModel.activeTransfers.collectAsState() + val playerState by playerViewModel.playerState.collectAsStateWithLifecycle() + val isPhoneConnected by playerViewModel.isPhoneConnected.collectAsStateWithLifecycle() + val isWatchOutputSelected by playerViewModel.isWatchOutputSelected.collectAsStateWithLifecycle() + val canCurrentSongBeFavorited by playerViewModel.canCurrentSongBeFavorited.collectAsStateWithLifecycle() + val queueState by browseViewModel.uiState.collectAsStateWithLifecycle() + val downloadedSongIds by downloadsViewModel.downloadedSongIds.collectAsStateWithLifecycle() + val activeTransfers by downloadsViewModel.activeTransfers.collectAsStateWithLifecycle() LaunchedEffect(isPhoneConnected, isWatchOutputSelected) { if (isPhoneConnected && !isWatchOutputSelected) { diff --git a/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/OutputScreen.kt b/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/OutputScreen.kt index ec907c442a..a3a19d749d 100644 --- a/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/OutputScreen.kt +++ b/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/OutputScreen.kt @@ -14,7 +14,6 @@ import androidx.compose.material.icons.rounded.Watch import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import android.content.Context import androidx.compose.ui.Alignment @@ -27,6 +26,7 @@ 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.lifecycle.compose.collectAsStateWithLifecycle import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.wear.compose.material.Chip import androidx.wear.compose.material.ChipDefaults @@ -55,13 +55,13 @@ import kotlinx.coroutines.delay fun OutputScreen( viewModel: WearPlayerViewModel = hiltViewModel(), ) { - val outputTarget by viewModel.outputTarget.collectAsState() - val isPhoneConnected by viewModel.isPhoneConnected.collectAsState() - val canCurrentSongPlayOnWatch by viewModel.canCurrentSongPlayOnWatch.collectAsState() - val playerState by viewModel.playerState.collectAsState() - val phoneVolumeState by viewModel.phoneVolumeState.collectAsState() - val watchAudioRoutes by viewModel.watchAudioRoutes.collectAsState() - val watchVolumeState by viewModel.watchVolumeState.collectAsState() + val outputTarget by viewModel.outputTarget.collectAsStateWithLifecycle() + val isPhoneConnected by viewModel.isPhoneConnected.collectAsStateWithLifecycle() + val canCurrentSongPlayOnWatch by viewModel.canCurrentSongPlayOnWatch.collectAsStateWithLifecycle() + val playerState by viewModel.playerState.collectAsStateWithLifecycle() + val phoneVolumeState by viewModel.phoneVolumeState.collectAsStateWithLifecycle() + val watchAudioRoutes by viewModel.watchAudioRoutes.collectAsStateWithLifecycle() + val watchVolumeState by viewModel.watchVolumeState.collectAsStateWithLifecycle() val context = LocalContext.current val palette = LocalWearPalette.current val columnState = rememberResponsiveColumnState() diff --git a/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/PlayerScreen.kt b/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/PlayerScreen.kt index 0578429609..48a950bb86 100644 --- a/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/PlayerScreen.kt +++ b/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/PlayerScreen.kt @@ -48,7 +48,6 @@ import androidx.compose.material.icons.rounded.SkipPrevious import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf @@ -98,6 +97,7 @@ import androidx.compose.ui.unit.lerp import androidx.compose.ui.unit.sp import androidx.compose.ui.zIndex import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.wear.compose.foundation.ExperimentalWearFoundationApi import androidx.wear.compose.foundation.pager.HorizontalPager import androidx.wear.compose.foundation.pager.rememberPagerState @@ -149,12 +149,12 @@ fun PlayerScreen( onQueueClick: () -> Unit = {}, viewModel: WearPlayerViewModel = hiltViewModel(), ) { - val state by viewModel.playerState.collectAsState() - val isPhoneConnected by viewModel.isPhoneConnected.collectAsState() - val isWatchOutputSelected by viewModel.isWatchOutputSelected.collectAsState() - val activeOutputRouteType by viewModel.activeOutputRouteType.collectAsState() - val activeVolumeState by viewModel.activeVolumeState.collectAsState() - val albumArt by viewModel.albumArt.collectAsState() + val state by viewModel.playerState.collectAsStateWithLifecycle() + val isPhoneConnected by viewModel.isPhoneConnected.collectAsStateWithLifecycle() + val isWatchOutputSelected by viewModel.isWatchOutputSelected.collectAsStateWithLifecycle() + val activeOutputRouteType by viewModel.activeOutputRouteType.collectAsStateWithLifecycle() + val activeVolumeState by viewModel.activeVolumeState.collectAsStateWithLifecycle() + val albumArt by viewModel.albumArt.collectAsStateWithLifecycle() PlayerContent( state = state, @@ -194,7 +194,7 @@ private fun PlayerContent( onQueueClick: () -> Unit, ) { val palette = LocalWearPalette.current - val isAmbient by WearLifecycleState.isAmbient.collectAsState() + val isAmbient by WearLifecycleState.isAmbient.collectAsStateWithLifecycle() // Memoize: radialGradient allocates Shader inputs on every call. PlayerContent // recomposes whenever the play-button ring animation ticks, so without this // we'd churn the GC for nothing. @@ -1251,21 +1251,32 @@ private fun MainPlayerPage( private fun rememberLivePositionMs(state: WearPlayerState): androidx.compose.runtime.State { val safeDuration = state.totalDurationMs.coerceAtLeast(0L) val safeAnchorPosition = state.currentPositionMs.coerceIn(0L, safeDuration) + // WearLifecycleState's own contract: "position-update jobs... should gate on isInteractive + // so they pause as soon as the activity moves to the background or the watch enters ambient + // mode" — this loop wasn't wired to it. Ticking every 250ms recomposes this composable (and + // the progress ring/animation reading its value) whether or not the screen is actually being + // refreshed at that rate, competing with audio decode for CPU. `livePositionFromAnchor` + // computes from elapsed real time regardless, so freezing the display between ticks loses + // nothing: the moment isInteractive flips back on, the position snaps to the correct value. + val isInteractive by WearLifecycleState.isInteractive.collectAsStateWithLifecycle( + initialValue = WearLifecycleState.isInteractiveNow, + ) val positionKey = remember( state.songId, safeAnchorPosition, safeDuration, state.isPlaying, state.positionUpdatedElapsedRealtimeMs, + isInteractive, ) { - "${state.songId}|$safeAnchorPosition|$safeDuration|${state.isPlaying}|${state.positionUpdatedElapsedRealtimeMs}" + "${state.songId}|$safeAnchorPosition|$safeDuration|${state.isPlaying}|${state.positionUpdatedElapsedRealtimeMs}|$isInteractive" } return produceState( initialValue = state.livePositionFromAnchor(safeAnchorPosition, safeDuration), key1 = positionKey, ) { value = state.livePositionFromAnchor(safeAnchorPosition, safeDuration) - if (!state.isPlaying || safeDuration <= 0L) { + if (!state.isPlaying || safeDuration <= 0L || !isInteractive) { return@produceState } @@ -1485,6 +1496,13 @@ private fun rememberActiveLyricLineIndex( ): androidx.compose.runtime.State { val safeDuration = state.totalDurationMs.coerceAtLeast(0L) val safeAnchorPosition = state.currentPositionMs.coerceIn(0L, safeDuration) + // Same reasoning as rememberLivePositionMs: this page sits right next to the main player + // page in the HorizontalPager (beyondViewportPageCount = 1), so it can stay composed — + // and this loop ticking — while the user is looking at the main page instead. Gate it on + // WearLifecycleState.isInteractive for the same reason its own contract asks for. + val isInteractive by WearLifecycleState.isInteractive.collectAsStateWithLifecycle( + initialValue = WearLifecycleState.isInteractiveNow, + ) val positionKey = remember( state.songId, safeAnchorPosition, @@ -1492,8 +1510,9 @@ private fun rememberActiveLyricLineIndex( state.isPlaying, state.positionUpdatedElapsedRealtimeMs, lines, + isInteractive, ) { - "${state.songId}|$safeAnchorPosition|$safeDuration|${state.isPlaying}|${state.positionUpdatedElapsedRealtimeMs}|${lines.size}|${lines.firstOrNull()?.timeMs}|${lines.lastOrNull()?.timeMs}" + "${state.songId}|$safeAnchorPosition|$safeDuration|${state.isPlaying}|${state.positionUpdatedElapsedRealtimeMs}|${lines.size}|${lines.firstOrNull()?.timeMs}|${lines.lastOrNull()?.timeMs}|$isInteractive" } return produceState( @@ -1507,9 +1526,15 @@ private fun rememberActiveLyricLineIndex( return@produceState } + val livePositionMs = state.livePositionFromAnchor(safeAnchorPosition, safeDuration) + value = lines.activeLyricLineIndex(livePositionMs) + if (!isInteractive) { + return@produceState + } + while (true) { - val livePositionMs = state.livePositionFromAnchor(safeAnchorPosition, safeDuration) - val currentIndex = lines.activeLyricLineIndex(livePositionMs) + val currentLivePositionMs = state.livePositionFromAnchor(safeAnchorPosition, safeDuration) + val currentIndex = lines.activeLyricLineIndex(currentLivePositionMs) value = currentIndex if (!state.isPlaying || safeDuration <= 0L) { @@ -1518,7 +1543,7 @@ private fun rememberActiveLyricLineIndex( val nextLineTimeMs = lines.getOrNull(currentIndex + 1)?.timeMs?.toLong() ?: return@produceState - val delayUntilNextLine = (nextLineTimeMs - livePositionMs) + val delayUntilNextLine = (nextLineTimeMs - currentLivePositionMs) .coerceIn(80L, 60_000L) delay(delayUntilNextLine) } @@ -1746,8 +1771,8 @@ private fun CenterPlayButton( label = "playStarCurve", ) val rotation = remember { Animatable(0f) } - val isInteractive by WearLifecycleState.isInteractive.collectAsState( - initial = WearLifecycleState.isInteractiveNow, + val isInteractive by WearLifecycleState.isInteractive.collectAsStateWithLifecycle( + initialValue = WearLifecycleState.isInteractiveNow, ) LaunchedEffect(isPlaying, isInteractive) { if (!isPlaying || !isInteractive) { diff --git a/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/QueueScreen.kt b/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/QueueScreen.kt index 5350f48287..e80375d618 100644 --- a/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/QueueScreen.kt +++ b/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/QueueScreen.kt @@ -19,7 +19,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -32,6 +31,7 @@ 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.lifecycle.compose.collectAsStateWithLifecycle import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.wear.compose.material.Chip import androidx.wear.compose.material.ChipDefaults @@ -70,13 +70,13 @@ fun QueueScreen( browseViewModel: WearBrowseViewModel = hiltViewModel(), ) { val palette = LocalWearPalette.current - val playerState by viewModel.playerState.collectAsState() - val localQueueState by viewModel.localQueueState.collectAsState() - val isLocalPlaybackActive by viewModel.isLocalPlaybackActive.collectAsState() - val uiState by browseViewModel.uiState.collectAsState() - val isPhoneConnected by viewModel.isPhoneConnected.collectAsState() - val isWatchOutputSelected by viewModel.isWatchOutputSelected.collectAsState() - val timerState by viewModel.sleepTimerUiState.collectAsState() + val playerState by viewModel.playerState.collectAsStateWithLifecycle() + val localQueueState by viewModel.localQueueState.collectAsStateWithLifecycle() + val isLocalPlaybackActive by viewModel.isLocalPlaybackActive.collectAsStateWithLifecycle() + val uiState by browseViewModel.uiState.collectAsStateWithLifecycle() + val isPhoneConnected by viewModel.isPhoneConnected.collectAsStateWithLifecycle() + val isWatchOutputSelected by viewModel.isWatchOutputSelected.collectAsStateWithLifecycle() + val timerState by viewModel.sleepTimerUiState.collectAsStateWithLifecycle() val showingLocalQueue = isWatchOutputSelected val remoteControlsEnabled = isPhoneConnected && !isWatchOutputSelected diff --git a/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/SongListScreen.kt b/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/SongListScreen.kt index 68e685c1b3..c604d3aa46 100644 --- a/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/SongListScreen.kt +++ b/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/SongListScreen.kt @@ -14,7 +14,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -29,6 +28,7 @@ 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.lifecycle.compose.collectAsStateWithLifecycle import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.wear.compose.material.Chip import androidx.wear.compose.material.ChipDefaults @@ -80,10 +80,10 @@ fun SongListScreen( downloadsViewModel: WearDownloadsViewModel = hiltViewModel(), playerViewModel: WearPlayerViewModel = hiltViewModel(), ) { - val uiState by viewModel.uiState.collectAsState() - val downloadedIds by downloadsViewModel.downloadedSongIds.collectAsState() - val activeTransfers by downloadsViewModel.activeTransfers.collectAsState() - val playerState by playerViewModel.playerState.collectAsState() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val downloadedIds by downloadsViewModel.downloadedSongIds.collectAsStateWithLifecycle() + val activeTransfers by downloadsViewModel.activeTransfers.collectAsStateWithLifecycle() + val playerState by playerViewModel.playerState.collectAsStateWithLifecycle() val palette = LocalWearPalette.current val subscreenTitleFont = rememberBrowseSubscreenTitleFont() var selectedSongForMenu by remember { mutableStateOf(null) } diff --git a/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/TimerScreen.kt b/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/TimerScreen.kt index adcfe5592e..4f4182e334 100644 --- a/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/TimerScreen.kt +++ b/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/TimerScreen.kt @@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -23,6 +22,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.wear.compose.material.Chip import androidx.wear.compose.material.ChipDefaults @@ -52,9 +52,9 @@ fun TimerScreen( ) { val context = LocalContext.current val palette = LocalWearPalette.current - val timerState by viewModel.sleepTimerUiState.collectAsState() - val isPhoneConnected by viewModel.isPhoneConnected.collectAsState() - val isWatchOutputSelected by viewModel.isWatchOutputSelected.collectAsState() + val timerState by viewModel.sleepTimerUiState.collectAsStateWithLifecycle() + val isPhoneConnected by viewModel.isPhoneConnected.collectAsStateWithLifecycle() + val isWatchOutputSelected by viewModel.isWatchOutputSelected.collectAsStateWithLifecycle() val enabled = isPhoneConnected && !isWatchOutputSelected val columnState = rememberResponsiveColumnState() diff --git a/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/VolumeScreen.kt b/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/VolumeScreen.kt index b84f6388ff..bdcdc1c199 100644 --- a/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/VolumeScreen.kt +++ b/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/VolumeScreen.kt @@ -28,7 +28,6 @@ import androidx.compose.material.icons.rounded.Remove import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -41,6 +40,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.zIndex +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.wear.compose.foundation.ExperimentalWearFoundationApi import androidx.wear.compose.foundation.requestFocusOnHierarchyActive @@ -65,9 +65,9 @@ fun VolumeScreen( viewModel: WearPlayerViewModel = hiltViewModel(), ) { val palette = LocalWearPalette.current - val volumeState by viewModel.activeVolumeState.collectAsState() - val volumePercent by viewModel.activeVolumePercent.collectAsState() - val activeDeviceName by viewModel.activeVolumeDeviceName.collectAsState() + val volumeState by viewModel.activeVolumeState.collectAsStateWithLifecycle() + val volumePercent by viewModel.activeVolumePercent.collectAsStateWithLifecycle() + val activeDeviceName by viewModel.activeVolumeDeviceName.collectAsStateWithLifecycle() // Enable MediaRouter discovery while this screen is visible so the // route-callback path in WearVolumeRepository pushes updates reactively. 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..5e30700e35 --- /dev/null +++ b/wear/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/WearLocalPlaylistViewModel.kt @@ -0,0 +1,136 @@ +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 com.theveloper.pixelplay.shared.WearTransferProgress +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?, + /** Best-effort name from the phone's playlist sync, shown only while [song] is null (still + * pending transfer) — empty if the sync that created this row predates it. */ + val pendingTitle: String = "", +) { + val isAvailable: Boolean get() = song != null + + /** Real title once transferred; otherwise the phone-provided pending title; the raw + * [songId] only as a last resort, for a sync from a phone build old enough to not send one. */ + val displayTitle: String get() = song?.title ?: pendingTitle.ifBlank { songId } +} + +/** + * 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. + * + * Only [WearTransferProgress.STATUS_TRANSFERRING] counts as "still receiving" — a failed or + * cancelled transfer stays in [WearTransferRepository.activeTransfers] indefinitely (so + * DownloadsScreen can list it under "Transfer issues"), but that's a terminal state, not an + * in-progress one. Treating mere presence in the map as "active" left this badge stuck on + * forever once a song failed. + */ + val playlistIdsReceiving: StateFlow> = combine( + localPlaylistDao.observeAllPlaylistSongCrossRefs(), + transferRepository.activeTransfers, + ) { crossRefs, transfers -> + val activeSongIds = transfers.values + .filter { it.status == WearTransferProgress.STATUS_TRANSFERRING } + .map { it.songId } + .toSet() + if (activeSongIds.isEmpty()) { + emptySet() + } else { + 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], ref.pendingTitle) + } + } + } + } + .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/java/com/theveloper/pixelplay/presentation/viewmodel/WearPlayerViewModel.kt b/wear/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/WearPlayerViewModel.kt index 3a6038e813..1d37720b0f 100644 --- a/wear/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/WearPlayerViewModel.kt +++ b/wear/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/WearPlayerViewModel.kt @@ -200,6 +200,18 @@ class WearPlayerViewModel @Inject constructor( }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) init { + viewModelScope.launch { + // Recovers a queue/position that survived a process death mid-playback (see + // WearLocalPlayerRepository's KDoc) — a no-op whenever nothing was persisted, which + // is the overwhelming majority of app opens. `outputTarget` itself isn't persisted + // (WearStateRepository always starts at PHONE), so a successful restore is the + // signal that the user actually was on watch-local playback; it wouldn't otherwise + // be visible in `playerState` until switched here. + val restored = localPlayerRepository.restorePersistedPlaybackIfAvailable() + if (restored) { + stateRepository.setOutputTarget(WearOutputTarget.WATCH) + } + } viewModelScope.launch { outputTarget.collect { refreshActiveVolumeState() diff --git a/wear/src/main/res/raw/keep.xml b/wear/src/main/res/raw/keep.xml new file mode 100644 index 0000000000..704ebd9ad5 --- /dev/null +++ b/wear/src/main/res/raw/keep.xml @@ -0,0 +1,12 @@ + + + diff --git a/wear/src/main/res/values-es/strings_wear.xml b/wear/src/main/res/values-es/strings_wear.xml index f0b9816861..777675c1bd 100644 --- a/wear/src/main/res/values-es/strings_wear.xml +++ b/wear/src/main/res/values-es/strings_wear.xml @@ -14,6 +14,14 @@ Escaneando almacenamiento del reloj… Reintentar escaneo No se encontraron canciones locales + Listas de reproducción + Aún no se ha enviado ninguna lista desde tu teléfono + Recibiendo… + Listas de reproducción + %1$d de %2$d disponibles + Reproducir todo + Esta lista no tiene canciones + Esperando transferencia… Reproduciendo Actual Más opciones 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/MainCoroutineExtension.kt b/wear/src/test/java/com/theveloper/pixelplay/MainCoroutineExtension.kt new file mode 100644 index 0000000000..8e2cec5ff4 --- /dev/null +++ b/wear/src/test/java/com/theveloper/pixelplay/MainCoroutineExtension.kt @@ -0,0 +1,24 @@ +package com.theveloper.pixelplay + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.jupiter.api.extension.AfterEachCallback +import org.junit.jupiter.api.extension.BeforeEachCallback +import org.junit.jupiter.api.extension.ExtensionContext + +@ExperimentalCoroutinesApi +class MainCoroutineExtension(private val testDispatcher: TestDispatcher = StandardTestDispatcher()) : + BeforeEachCallback, AfterEachCallback { + + override fun beforeEach(context: ExtensionContext) { + Dispatchers.setMain(testDispatcher) + } + + override fun afterEach(context: ExtensionContext) { + Dispatchers.resetMain() + } +} diff --git a/wear/src/test/java/com/theveloper/pixelplay/data/WearAudioOffloadPolicyTest.kt b/wear/src/test/java/com/theveloper/pixelplay/data/WearAudioOffloadPolicyTest.kt new file mode 100644 index 0000000000..98c7fdc54b --- /dev/null +++ b/wear/src/test/java/com/theveloper/pixelplay/data/WearAudioOffloadPolicyTest.kt @@ -0,0 +1,87 @@ +package com.theveloper.pixelplay.data + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +class WearAudioOffloadPolicyTest { + + @Test + fun earlyBuffering_fallsBackForGenuineHalReset() { + val shouldFallBack = wearShouldFallBackFromAudioOffload( + audioOffloadEnabled = true, + lastPlayingAtMs = 1_000L, + timeSincePlayingMs = 120L, + isPostSeekBuffering = false, + isPostMediaItemTransition = false, + ) + + assertThat(shouldFallBack).isTrue() + } + + @Test + fun earlyBuffering_doesNotFallBackWhenOffloadIsAlreadyDisabled() { + val shouldFallBack = wearShouldFallBackFromAudioOffload( + audioOffloadEnabled = false, + lastPlayingAtMs = 1_000L, + timeSincePlayingMs = 120L, + isPostSeekBuffering = false, + isPostMediaItemTransition = false, + ) + + assertThat(shouldFallBack).isFalse() + } + + @Test + fun earlyBuffering_doesNotFallBackRightAfterASeek() { + val shouldFallBack = wearShouldFallBackFromAudioOffload( + audioOffloadEnabled = true, + lastPlayingAtMs = 1_000L, + timeSincePlayingMs = 120L, + isPostSeekBuffering = true, + isPostMediaItemTransition = false, + ) + + assertThat(shouldFallBack).isFalse() + } + + @Test + fun earlyBuffering_doesNotFallBackRightAfterATrackChange() { + val shouldFallBack = wearShouldFallBackFromAudioOffload( + audioOffloadEnabled = true, + lastPlayingAtMs = 1_000L, + timeSincePlayingMs = 120L, + isPostSeekBuffering = false, + isPostMediaItemTransition = true, + ) + + assertThat(shouldFallBack).isFalse() + } + + @Test + fun earlyBuffering_doesNotFallBackAfterLongSteadyPlayback() { + val shouldFallBack = wearShouldFallBackFromAudioOffload( + audioOffloadEnabled = true, + lastPlayingAtMs = 1_000L, + timeSincePlayingMs = 5_000L, + isPostSeekBuffering = false, + isPostMediaItemTransition = false, + ) + + assertThat(shouldFallBack).isFalse() + } + + @Test + fun earlyBuffering_doesNotFallBackBeforeAnyPlaybackEverStarted() { + // lastPlayingAtMs == 0L means playback never reached PLAYING yet — the very first + // buffer-up on cold start is not an offload HAL reset. + val shouldFallBack = wearShouldFallBackFromAudioOffload( + audioOffloadEnabled = true, + lastPlayingAtMs = 0L, + timeSincePlayingMs = 120L, + isPostSeekBuffering = false, + isPostMediaItemTransition = false, + ) + + assertThat(shouldFallBack).isFalse() + } +} diff --git a/wear/src/test/java/com/theveloper/pixelplay/data/WearLoadControlProfileTest.kt b/wear/src/test/java/com/theveloper/pixelplay/data/WearLoadControlProfileTest.kt new file mode 100644 index 0000000000..e75e7a63e5 --- /dev/null +++ b/wear/src/test/java/com/theveloper/pixelplay/data/WearLoadControlProfileTest.kt @@ -0,0 +1,49 @@ +package com.theveloper.pixelplay.data + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +class WearLoadControlProfileTest { + + @Test + fun normalDevice_usesFullPrefetchProfile() { + val profile = wearLoadControlBufferProfileFor(isLowRamDevice = false) + + assertThat(profile.minBufferMs).isEqualTo(30_000) + assertThat(profile.maxBufferMs).isEqualTo(60_000) + assertThat(profile.bufferForPlaybackMs).isEqualTo(2_500) + assertThat(profile.bufferForPlaybackAfterRebufferMs).isEqualTo(5_000) + } + + @Test + fun lowRamDevice_cutsPrefetchWindow() { + val normal = wearLoadControlBufferProfileFor(isLowRamDevice = false) + val lowRam = wearLoadControlBufferProfileFor(isLowRamDevice = true) + + assertThat(lowRam.maxBufferMs).isLessThan(normal.maxBufferMs) + assertThat(lowRam.minBufferMs).isLessThan(normal.minBufferMs) + } + + @Test + fun lowRamDevice_keepsStartLatencyIdenticalToNormal() { + // Capping the prefetch window must not regress how quickly playback actually starts. + val normal = wearLoadControlBufferProfileFor(isLowRamDevice = false) + val lowRam = wearLoadControlBufferProfileFor(isLowRamDevice = true) + + assertThat(lowRam.bufferForPlaybackMs).isEqualTo(normal.bufferForPlaybackMs) + assertThat(lowRam.bufferForPlaybackAfterRebufferMs) + .isEqualTo(normal.bufferForPlaybackAfterRebufferMs) + } + + @Test + fun bothProfiles_satisfyDefaultLoadControlConstraints() { + for (isLowRam in listOf(false, true)) { + val profile = wearLoadControlBufferProfileFor(isLowRam) + + // DefaultLoadControl.Builder.build() asserts these; violating them crashes at runtime. + assertThat(profile.minBufferMs).isAtLeast(profile.bufferForPlaybackMs) + assertThat(profile.minBufferMs).isAtLeast(profile.bufferForPlaybackAfterRebufferMs) + assertThat(profile.maxBufferMs).isAtLeast(profile.minBufferMs) + } + } +} diff --git a/wear/src/test/java/com/theveloper/pixelplay/data/WearPlaybackStallWatchdogTest.kt b/wear/src/test/java/com/theveloper/pixelplay/data/WearPlaybackStallWatchdogTest.kt new file mode 100644 index 0000000000..5bc9fdcec2 --- /dev/null +++ b/wear/src/test/java/com/theveloper/pixelplay/data/WearPlaybackStallWatchdogTest.kt @@ -0,0 +1,76 @@ +package com.theveloper.pixelplay.data + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +class WearPlaybackStallWatchdogTest { + + @Test + fun `not playing resets the counter to zero`() { + val result = wearPlaybackStalledTickCount( + isPlaying = false, + positionAdvancedSinceLastTick = false, + previousConsecutiveStalledTicks = 2, + ) + + assertThat(result).isEqualTo(0) + } + + @Test + fun `playing with position advancing resets the counter to zero`() { + val result = wearPlaybackStalledTickCount( + isPlaying = true, + positionAdvancedSinceLastTick = true, + previousConsecutiveStalledTicks = 2, + ) + + assertThat(result).isEqualTo(0) + } + + @Test + fun `playing with a frozen position increments the counter`() { + val result = wearPlaybackStalledTickCount( + isPlaying = true, + positionAdvancedSinceLastTick = false, + previousConsecutiveStalledTicks = 1, + ) + + assertThat(result).isEqualTo(2) + } + + @Test + fun `a frozen position starting from zero counts as one stalled tick`() { + val result = wearPlaybackStalledTickCount( + isPlaying = true, + positionAdvancedSinceLastTick = false, + previousConsecutiveStalledTicks = 0, + ) + + assertThat(result).isEqualTo(1) + } + + @Test + fun `a single advancing tick after several stalled ones fully resets, not decrements`() { + val result = wearPlaybackStalledTickCount( + isPlaying = true, + positionAdvancedSinceLastTick = true, + previousConsecutiveStalledTicks = 5, + ) + + assertThat(result).isEqualTo(0) + } + + @Test + fun `three consecutive stalled ticks reaches the production threshold`() { + var ticks = 0 + repeat(3) { + ticks = wearPlaybackStalledTickCount( + isPlaying = true, + positionAdvancedSinceLastTick = false, + previousConsecutiveStalledTicks = ticks, + ) + } + + assertThat(ticks).isEqualTo(3) + } +} diff --git a/wear/src/test/java/com/theveloper/pixelplay/data/WearPlaybackStatePersistenceTest.kt b/wear/src/test/java/com/theveloper/pixelplay/data/WearPlaybackStatePersistenceTest.kt new file mode 100644 index 0000000000..5a0f2dbe86 --- /dev/null +++ b/wear/src/test/java/com/theveloper/pixelplay/data/WearPlaybackStatePersistenceTest.kt @@ -0,0 +1,105 @@ +package com.theveloper.pixelplay.data + +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 WearPlaybackStatePersistenceTest { + + // Same reasoning as PlaylistBatchTransferPersistenceTest in :app: DataStore's internal + // write-actor needs a scope that outlives any single test method's own runTest {} block. + private lateinit var dataStoreScope: CoroutineScope + private lateinit var tempDir: Path + private lateinit var dataStore: DataStore + private lateinit var persistence: WearPlaybackStatePersistence + + @BeforeEach + fun setUp() { + tempDir = Files.createTempDirectory("wear-playback-state-persistence-test") + dataStoreScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + dataStore = PreferenceDataStoreFactory.create( + scope = dataStoreScope, + produceFile = { tempDir.resolve("settings.preferences_pb").toFile() }, + ) + persistence = WearPlaybackStatePersistence(dataStore) + } + + @AfterEach + fun tearDown() { + dataStoreScope.cancel() + tempDir.toFile().deleteRecursively() + } + + private fun state( + queueSongIds: List = listOf("s1", "s2"), + currentIndex: Int = 0, + positionMs: Long = 12_345L, + updatedAtMillis: Long = 1_000L, + ) = PersistedLocalPlaybackState( + queueSongIds = queueSongIds, + currentIndex = currentIndex, + positionMs = positionMs, + updatedAtMillis = updatedAtMillis, + ) + + @Test + fun `nothing persisted returns null`() = runTest { + assertThat(persistence.read()).isNull() + } + + @Test + fun `save then read round-trips the state`() = runTest { + val saved = state() + persistence.save(saved) + + assertThat(persistence.read()).isEqualTo(saved) + } + + @Test + fun `saving again overwrites the previous state`() = runTest { + persistence.save(state(currentIndex = 0, positionMs = 1_000L)) + persistence.save(state(currentIndex = 1, positionMs = 5_000L)) + + val read = persistence.read() + assertThat(read?.currentIndex).isEqualTo(1) + assertThat(read?.positionMs).isEqualTo(5_000L) + } + + @Test + fun `clearing removes the stored state`() = runTest { + persistence.save(state()) + + persistence.clear() + + assertThat(persistence.read()).isNull() + } + + @Test + fun `clearing when nothing is stored does not throw`() = runTest { + persistence.clear() + + assertThat(persistence.read()).isNull() + } + + @Test + fun `malformed stored data is treated as nothing persisted, not a crash`() = runTest { + dataStore.edit { preferences -> + preferences[stringPreferencesKey("wear_local_playback_state_v1")] = "{not valid json" + } + + assertThat(persistence.read()).isNull() + } +} diff --git a/wear/src/test/java/com/theveloper/pixelplay/data/WearPlaybackStateRestorabilityTest.kt b/wear/src/test/java/com/theveloper/pixelplay/data/WearPlaybackStateRestorabilityTest.kt new file mode 100644 index 0000000000..6ad25319a4 --- /dev/null +++ b/wear/src/test/java/com/theveloper/pixelplay/data/WearPlaybackStateRestorabilityTest.kt @@ -0,0 +1,85 @@ +package com.theveloper.pixelplay.data + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +class WearPlaybackStateRestorabilityTest { + + private val oneHourMs = 60 * 60 * 1000L + + private fun state( + queueSongIds: List = listOf("s1", "s2"), + currentIndex: Int = 0, + updatedAtMillis: Long = 0L, + ) = PersistedLocalPlaybackState( + queueSongIds = queueSongIds, + currentIndex = currentIndex, + positionMs = 1_000L, + updatedAtMillis = updatedAtMillis, + ) + + @Test + fun `a recent state with a valid index is restorable`() { + val restorable = isPersistedLocalPlaybackStateRestorable( + state = state(updatedAtMillis = 0L), + nowMillis = oneHourMs, + ) + + assertThat(restorable).isTrue() + } + + @Test + fun `an empty queue is never restorable`() { + val restorable = isPersistedLocalPlaybackStateRestorable( + state = state(queueSongIds = emptyList(), currentIndex = 0, updatedAtMillis = 0L), + nowMillis = 0L, + ) + + assertThat(restorable).isFalse() + } + + @Test + fun `an out-of-range index is not restorable`() { + val restorable = isPersistedLocalPlaybackStateRestorable( + state = state(queueSongIds = listOf("s1"), currentIndex = 5, updatedAtMillis = 0L), + nowMillis = 0L, + ) + + assertThat(restorable).isFalse() + } + + @Test + fun `a state older than the max age is not restorable`() { + val maxAge = 6 * oneHourMs + val restorable = isPersistedLocalPlaybackStateRestorable( + state = state(updatedAtMillis = 0L), + nowMillis = maxAge + 1L, + maxAgeMillis = maxAge, + ) + + assertThat(restorable).isFalse() + } + + @Test + fun `a state exactly at the max age boundary is still restorable`() { + val maxAge = 6 * oneHourMs + val restorable = isPersistedLocalPlaybackStateRestorable( + state = state(updatedAtMillis = 0L), + nowMillis = maxAge, + maxAgeMillis = maxAge, + ) + + assertThat(restorable).isTrue() + } + + @Test + fun `a state with a future timestamp is not restorable`() { + // Defensive: clock skew or a corrupted timestamp shouldn't be treated as "very fresh". + val restorable = isPersistedLocalPlaybackStateRestorable( + state = state(updatedAtMillis = 10_000L), + nowMillis = 0L, + ) + + assertThat(restorable).isFalse() + } +} 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..8f0f431f6f --- /dev/null +++ b/wear/src/test/java/com/theveloper/pixelplay/data/WearTransferRepositoryPlaylistSyncTest.kt @@ -0,0 +1,270 @@ +package com.theveloper.pixelplay.data + +import android.app.Application +import com.google.android.gms.tasks.Tasks +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.WearDataPaths +import com.theveloper.pixelplay.shared.WearPlaylistSync +import com.theveloper.pixelplay.shared.WearPlaylistSyncAck +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 io.mockk.verify +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +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, mockk()) + 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")), + sourceNodeId = "node-1", + ) + + 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")), + sourceNodeId = "node-1", + ) + + 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")), + sourceNodeId = "node-1", + ) + repository.onPlaylistSyncReceived( + WearPlaylistSync(playlistId = "p1", name = "Road trip", songIds = listOf("c")), + sourceNodeId = "node-1", + ) + + // 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")), + sourceNodeId = "node-1", + ) + + 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()), + sourceNodeId = "node-1", + ) + + 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")), + sourceNodeId = "node-1", + ) + + assertThat(entitySlot.captured.playlistId).isEqualTo("p1") + assertThat(entitySlot.captured.name).isEqualTo("Summer mix") + } + + @Test + fun `cross-refs carry the matching pending title from the sync, by index`() = 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("s1", "s2"), + songTitles = listOf("First song", "Second song"), + ), + sourceNodeId = "node-1", + ) + + assertThat(crossRefsSlot.captured).containsExactly( + LocalPlaylistSongCrossRef(playlistId = "p1", songId = "s1", position = 0, pendingTitle = "First song"), + LocalPlaylistSongCrossRef(playlistId = "p1", songId = "s2", position = 1, pendingTitle = "Second song"), + ).inOrder() + } + + @Test + fun `a sync from an older phone with no songTitles falls back to an empty pending title`() = runTest { + coEvery { localPlaylistDao.getPlaylistById("p1") } returns null + val crossRefsSlot = slot>() + coEvery { localPlaylistDao.upsertPlaylist(any(), capture(crossRefsSlot)) } just Runs + + // songTitles omitted entirely — WearPlaylistSync.songTitles defaults to emptyList(). + repository.onPlaylistSyncReceived( + WearPlaylistSync(playlistId = "p1", name = "Road trip", songIds = listOf("s1")), + sourceNodeId = "node-1", + ) + + assertThat(crossRefsSlot.captured.single().pendingTitle).isEmpty() + } + + // --- Ack (playlist-sync reliability fix) --- + + @Test + fun `a sync with a requestId acks back to the source node once applied`() = runTest { + coEvery { localPlaylistDao.getPlaylistById("p1") } returns null + val pathSlot = slot() + val bytesSlot = slot() + every { messageClient.sendMessage("node-9", capture(pathSlot), capture(bytesSlot)) } returns + Tasks.forResult(0) + + repository.onPlaylistSyncReceived( + WearPlaylistSync(playlistId = "p1", name = "Road trip", songIds = listOf("s1"), requestId = "req-1"), + sourceNodeId = "node-9", + ) + + assertThat(pathSlot.captured).isEqualTo(WearDataPaths.PLAYLIST_SYNC_ACK) + val ack = Json.decodeFromString(String(bytesSlot.captured, Charsets.UTF_8)) + assertThat(ack.playlistId).isEqualTo("p1") + assertThat(ack.requestId).isEqualTo("req-1") + } + + @Test + fun `a sync with no requestId (old phone build) sends no ack`() = runTest { + coEvery { localPlaylistDao.getPlaylistById("p1") } returns null + + repository.onPlaylistSyncReceived( + WearPlaylistSync(playlistId = "p1", name = "Road trip", songIds = listOf("s1")), + sourceNodeId = "node-9", + ) + + verify(exactly = 0) { messageClient.sendMessage(any(), any(), any()) } + } + + @Test + fun `a failure sending the ack does not propagate out of onPlaylistSyncReceived`() = runTest { + coEvery { localPlaylistDao.getPlaylistById("p1") } returns null + every { messageClient.sendMessage(any(), any(), any()) } returns + Tasks.forException(RuntimeException("no route to node")) + + // Should not throw — a lost ack just means the phone times out and resends the sync. + repository.onPlaylistSyncReceived( + WearPlaylistSync(playlistId = "p1", name = "Road trip", songIds = listOf("s1"), requestId = "req-1"), + sourceNodeId = "node-9", + ) + } +} 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..9398441dd7 --- /dev/null +++ b/wear/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/WearLocalPlaylistViewModelTest.kt @@ -0,0 +1,367 @@ +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.WearPlaybackStatePersistence +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, mockk()) + 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, pendingTitle: String = "") = + LocalPlaylistSongCrossRef( + playlistId = playlistId, + songId = songId, + position = position, + pendingTitle = pendingTitle, + ) + + /** 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 `displayTitle prefers the real title, then the phone's pending title, then the raw id`() = runTest { + playlistSongsFlowById["p1"] = MutableStateFlow( + listOf( + crossRef("p1", "s1", 0, pendingTitle = "Ignored once available"), + crossRef("p1", "s2", 1, pendingTitle = "Still transferring"), + crossRef("p1", "s3", 2), // no pendingTitle — an older phone's sync + ) + ) + allSongsFlow.value = listOf(song("s1")) // only s1 has actually arrived + + viewModel.loadPlaylist("p1") + viewModel.playlistSongs.test { + awaitItem() // stateIn's initial placeholder (emptyList) + val items = awaitItem() + assertThat(items.first { it.songId == "s1" }.displayTitle).isEqualTo("Title s1") + assertThat(items.first { it.songId == "s2" }.displayTitle).isEqualTo("Still transferring") + assertThat(items.first { it.songId == "s3" }.displayTitle).isEqualTo("s3") + } + } + + @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 `a failed transfer no longer counts as receiving once it reaches a terminal state`() = runTest { + allCrossRefsFlow.value = listOf(crossRef("p1", "s1", 0)) + + viewModel.playlistIdsReceiving.test { + assertThat(awaitItem()).isEmpty() // deduped placeholder, see the test above + transferRepository.onProgressReceived( + WearTransferProgress( + requestId = "r1", + songId = "s1", + bytesTransferred = 10L, + totalBytes = 100L, + status = WearTransferProgress.STATUS_TRANSFERRING, + ) + ) + assertThat(awaitItem()).containsExactly("p1") + + // The transfer fails — WearTransferRepository deliberately keeps this entry in + // activeTransfers (DownloadsScreen lists failed transfers under "Transfer issues"), + // it doesn't remove it. playlistIdsReceiving must stop counting it anyway: mere + // presence in the map isn't "still receiving" once the status is terminal. + transferRepository.onProgressReceived( + WearTransferProgress( + requestId = "r1", + songId = "s1", + bytesTransferred = 10L, + totalBytes = 100L, + status = WearTransferProgress.STATUS_FAILED, + error = "Transfer timed out", + ) + ) + assertThat(awaitItem()).isEmpty() + } + } + + @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) + } +}