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 7f935a080..e7b18c7b1 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 @@ -98,6 +98,7 @@ class PhoneDirectWatchTransferCoordinator @Inject constructor( transferMode: String = WearTransferRequest.MODE_SAVE_TO_LIBRARY, startPositionMs: Long = 0L, autoPlay: Boolean = false, + overrideAudioFile: File? = null, ) { transferStateStore.markRequested( requestId = requestId, @@ -112,6 +113,7 @@ class PhoneDirectWatchTransferCoordinator @Inject constructor( transferMode = transferMode, startPositionMs = startPositionMs, autoPlay = autoPlay, + overrideAudioFile = overrideAudioFile, ) } } @@ -123,6 +125,7 @@ class PhoneDirectWatchTransferCoordinator @Inject constructor( transferMode: String, startPositionMs: Long, autoPlay: Boolean, + overrideAudioFile: File? = null, ) { var openedSongSource: OpenedSongSource? = null try { @@ -150,10 +153,19 @@ class PhoneDirectWatchTransferCoordinator @Inject constructor( return } - val songSource = openSongSource( - song = song, - allowProxyStreaming = transferMode == WearTransferRequest.MODE_TEMPORARY_PLAYBACK, - ) + val songSource = if (overrideAudioFile != null) { + runCatching { + OpenedSongSource( + inputStream = overrideAudioFile.inputStream(), + fileSize = overrideAudioFile.length(), + ) + }.getOrNull() + } else { + openSongSource( + song = song, + allowProxyStreaming = transferMode == WearTransferRequest.MODE_TEMPORARY_PLAYBACK, + ) + } if (songSource == null) { sendTransferMetadataError( nodeId = nodeId, @@ -182,9 +194,9 @@ class PhoneDirectWatchTransferCoordinator @Inject constructor( album = song.album, albumId = song.albumId, duration = song.duration, - mimeType = song.mimeType ?: "audio/mpeg", + mimeType = if (overrideAudioFile != null) TRANSCODED_MIME_TYPE else (song.mimeType ?: "audio/mpeg"), fileSize = fileSize, - bitrate = song.bitrate ?: 0, + bitrate = if (overrideAudioFile != null) WatchAudioTranscoder.TARGET_BITRATE_BPS else (song.bitrate ?: 0), sampleRate = song.sampleRate ?: 0, isFavorite = song.isFavorite, paletteSeedArgb = paletteSeedArgb, @@ -920,20 +932,23 @@ class PhoneDirectWatchTransferCoordinator @Inject constructor( status: String, error: String? = null, ) { + // Local bookkeeping only: a phone-side "completed" just means the phone finished + // writing its own stream. The watch still has to validate the file and ack it before + // this song is trustworthy as "present on watch" — see WearCommandReceiver's handling + // of the watch's own TRANSFER_PROGRESS reply for where STATUS_COMPLETED actually lands. + val localStatus = if (status == WearTransferProgress.STATUS_COMPLETED) { + WearTransferProgress.STATUS_AWAITING_WATCH_ACK + } else { + status + } transferStateStore.markProgress( requestId = requestId, songId = songId, bytesTransferred = bytesTransferred, totalBytes = totalBytes, - status = status, + status = localStatus, error = error, ) - if (status == WearTransferProgress.STATUS_COMPLETED) { - transferStateStore.markSongPresentOnWatch( - nodeId = nodeId, - songId = songId, - ) - } val progress = WearTransferProgress( requestId = requestId, songId = songId, @@ -961,5 +976,7 @@ class PhoneDirectWatchTransferCoordinator @Inject constructor( const val TRANSFER_ARTWORK_QUALITY = 95 const val TRANSFER_ARTWORK_MAX_BYTES = 1_500_000 const val METADATA_GUARD_DELAY_MS = 250L + // Kept in sync with WatchAudioTranscoder's AAC-LC output. + const val TRANSCODED_MIME_TYPE = "audio/mp4" } } 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 752c10e61..95233741e 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 @@ -23,6 +23,7 @@ data class PhoneWatchTransferState( val totalBytes: Long = 0L, val status: String = WearTransferProgress.STATUS_TRANSFERRING, val error: String? = null, + val errorCode: String? = null, val updatedAtMillis: Long = System.currentTimeMillis(), ) { val progress: Float @@ -33,6 +34,35 @@ data class PhoneWatchTransferState( } } +/** + * Aggregate state of a whole-playlist transfer. [activeRequestId] is the requestId of the song + * currently in flight (if any) — the UI uses it to avoid showing that same song a second time as + * an individual [PhoneWatchTransferState] row while its batch row is already visible. + */ +data class PhoneWatchBatchTransferState( + val batchId: String, + val playlistId: String, + val playlistName: String, + val totalSongs: Int, + val completedSongs: Int = 0, + val failedSongCount: Int = 0, + val lastFailureErrorCode: String? = null, + val activeRequestId: String? = null, + val currentSongTitle: String = "", + val currentSongProgress: Float = 0f, + val status: String = WearTransferProgress.STATUS_TRANSFERRING, + val error: String? = null, + val updatedAtMillis: Long = System.currentTimeMillis(), +) { + val overallProgress: Float + get() = if (totalSongs > 0) { + ((completedSongs.toFloat() + currentSongProgress.coerceIn(0f, 1f)) / totalSongs.toFloat()) + .coerceIn(0f, 1f) + } else { + 0f + } +} + @Singleton class PhoneWatchTransferStateStore @Inject constructor() { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) @@ -47,8 +77,14 @@ class PhoneWatchTransferStateStore @Inject constructor() { private val watchSongIdsByNodeId = ConcurrentHashMap>() private val _watchSongIds = MutableStateFlow>(emptySet()) val watchSongIds: StateFlow> = _watchSongIds.asStateFlow() + private val freeStorageBytesByNodeId = ConcurrentHashMap() + private val _watchFreeStorageBytesByNodeId = MutableStateFlow>(emptyMap()) + val watchFreeStorageBytesByNodeId: StateFlow> = _watchFreeStorageBytesByNodeId.asStateFlow() + private val _batchTransfers = MutableStateFlow>(emptyMap()) + val batchTransfers: StateFlow> = _batchTransfers.asStateFlow() private val cleanupJobs = ConcurrentHashMap() + private val batchCleanupJobs = ConcurrentHashMap() fun markRequested( requestId: String, @@ -108,6 +144,7 @@ class PhoneWatchTransferStateStore @Inject constructor() { totalBytes: Long, status: String, error: String? = null, + errorCode: String? = null, songTitle: String? = null, ) { _transfers.update { map -> @@ -121,6 +158,7 @@ class PhoneWatchTransferStateStore @Inject constructor() { totalBytes = maxOf(current.totalBytes, totalBytes), status = status, error = error, + errorCode = errorCode, updatedAtMillis = now, ) } else { @@ -132,6 +170,7 @@ class PhoneWatchTransferStateStore @Inject constructor() { totalBytes = totalBytes, status = status, error = error, + errorCode = errorCode, updatedAtMillis = now, ) } @@ -164,6 +203,12 @@ class PhoneWatchTransferStateStore @Inject constructor() { updateWatchLibraryResolution() } + fun updateWatchFreeStorageBytes(nodeId: String, freeStorageBytes: Long) { + if (nodeId.isBlank()) return + freeStorageBytesByNodeId[nodeId] = freeStorageBytes + _watchFreeStorageBytesByNodeId.value = freeStorageBytesByNodeId.toMap() + } + fun markSongPresentOnWatch(nodeId: String, songId: String) { if (nodeId.isBlank() || songId.isBlank()) return val existingSongIds = watchSongIdsByNodeId[nodeId].orEmpty() @@ -172,6 +217,15 @@ class PhoneWatchTransferStateStore @Inject constructor() { _watchSongIds.value = watchSongIdsByNodeId.values.flatten().toSet() } + /** Corrects a wrongly-optimistic presence mark once the watch reports it never actually landed. */ + fun clearSongPresentOnWatch(nodeId: String, songId: String) { + if (nodeId.isBlank() || songId.isBlank()) return + val existing = watchSongIdsByNodeId[nodeId] ?: return + if (songId !in existing) return + watchSongIdsByNodeId[nodeId] = existing - songId + _watchSongIds.value = watchSongIdsByNodeId.values.flatten().toSet() + } + fun markCancelled(requestId: String, error: String? = null) { cleanupJobs.remove(requestId)?.cancel() _transfers.update { map -> @@ -192,6 +246,12 @@ class PhoneWatchTransferStateStore @Inject constructor() { watchSongIdsByNodeId.remove(nodeId) } } + freeStorageBytesByNodeId.keys.toList().forEach { nodeId -> + if (nodeId !in nodeIds) { + freeStorageBytesByNodeId.remove(nodeId) + } + } + _watchFreeStorageBytesByNodeId.value = freeStorageBytesByNodeId.toMap() _watchLibrarySyncedNodeIds.value = _watchLibrarySyncedNodeIds.value.intersect(nodeIds) _watchSongIds.value = watchSongIdsByNodeId.values.flatten().toSet() updateWatchLibraryResolution() @@ -206,6 +266,135 @@ class PhoneWatchTransferStateStore @Inject constructor() { } } + fun markBatchStarted(batchId: String, playlistId: String, playlistName: String, totalSongs: Int) { + batchCleanupJobs.remove(batchId)?.cancel() + _batchTransfers.update { map -> + map + (batchId to PhoneWatchBatchTransferState( + batchId = batchId, + playlistId = playlistId, + playlistName = playlistName, + totalSongs = totalSongs, + )) + } + } + + /** + * [initialProgress] lets a caller re-target [activeRequestId] (e.g. moving from a transcode + * request to the per-node transfer request for the same song) without snapping the visible + * progress back to 0 — see [PlaylistWatchTransferCoordinator]'s transcode/transfer weighting. + */ + fun markBatchSongStarted( + batchId: String, + requestId: String, + songTitle: String, + initialProgress: Float = 0f, + ) { + _batchTransfers.update { map -> + val current = map[batchId] ?: return@update map + map + (batchId to current.copy( + activeRequestId = requestId, + currentSongTitle = songTitle, + currentSongProgress = initialProgress, + status = WearTransferProgress.STATUS_TRANSFERRING, + updatedAtMillis = System.currentTimeMillis(), + )) + } + } + + fun markBatchSongProgress(batchId: String, status: String, progress: Float) { + _batchTransfers.update { map -> + val current = map[batchId] ?: return@update map + map + (batchId to current.copy( + status = status, + currentSongProgress = progress, + updatedAtMillis = System.currentTimeMillis(), + )) + } + } + + fun markBatchSongCompleted(batchId: String) { + _batchTransfers.update { map -> + val current = map[batchId] ?: return@update map + map + (batchId to current.copy( + completedSongs = current.completedSongs + 1, + activeRequestId = null, + currentSongProgress = 0f, + updatedAtMillis = System.currentTimeMillis(), + )) + } + } + + fun markBatchSongFailed(batchId: String, errorCode: String?) { + _batchTransfers.update { map -> + val current = map[batchId] ?: return@update map + map + (batchId to current.copy( + failedSongCount = current.failedSongCount + 1, + activeRequestId = null, + currentSongProgress = 0f, + lastFailureErrorCode = errorCode ?: current.lastFailureErrorCode, + updatedAtMillis = System.currentTimeMillis(), + )) + } + } + + 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, + currentSongProgress = 0f, + updatedAtMillis = System.currentTimeMillis(), + )) + } + scheduleBatchTerminalCleanup(batchId) + } + + 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, error: String?) { + _batchTransfers.update { map -> + val current = map[batchId] ?: return@update map + map + (batchId to current.copy( + status = WearTransferProgress.STATUS_FAILED, + error = error, + 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 fun updateWatchLibraryResolution() { val reachableNodeIds = _reachableWatchNodeIds.value _isWatchLibraryResolved.value = reachableNodeIds.isEmpty() || 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 000000000..02b37abe4 --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/wear/PlaylistWatchTransferCoordinator.kt @@ -0,0 +1,285 @@ +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.google.android.gms.wearable.Wearable +import com.theveloper.pixelplay.data.model.Song +import com.theveloper.pixelplay.data.repository.MusicRepository +import com.theveloper.pixelplay.shared.WearCapabilities +import com.theveloper.pixelplay.shared.WearDataPaths +import com.theveloper.pixelplay.shared.WearPlaylistSync +import com.theveloper.pixelplay.shared.WearTransferProgress +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +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 even 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]). + * + * 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 `overrideAudioFile` 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 capabilityClient by lazy { Wearable.getCapabilityClient(application) } + private val messageClient: MessageClient by lazy { Wearable.getMessageClient(application) } + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val json = Json { ignoreUnknownKeys = true } + private val cancelledBatchIds = ConcurrentHashMap.newKeySet() + + /** 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) + } + + private suspend fun runBatchTransfer( + batchId: String, + playlistId: String, + playlistName: String, + songIds: List, + ) { + val nodes = runCatching { + capabilityClient.getCapability( + WearCapabilities.PIXELPLAY_WEAR_APP, + CapabilityClient.FILTER_REACHABLE, + ).await().nodes.toList() + }.getOrElse { error -> + Timber.tag(TAG).w(error, "Failed to resolve reachable watches for playlist transfer") + emptyList() + } + + transferStateStore.markBatchStarted(batchId, playlistId, playlistName, songIds.size) + + if (nodes.isEmpty()) { + transferStateStore.markBatchFailed(batchId, "No reachable watch with PixelPlay") + return + } + transferStateStore.retainReachableWatchNodes(nodes.map { it.id }.toSet()) + + val syncPayload = json.encodeToString(WearPlaylistSync(playlistId, playlistName, songIds)) + .toByteArray(Charsets.UTF_8) + nodes.forEach { node -> + runCatching { + messageClient.sendMessage(node.id, WearDataPaths.PLAYLIST_SYNC, syncPayload).await() + }.onFailure { error -> + Timber.tag(TAG).w(error, "Failed to send playlist sync to node=%s", node.id) + } + } + + 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) + continue + } + + val outcome = transferSongToAllNodes(batchId, nodes, song) + if (outcome.completed) { + transferStateStore.markBatchSongCompleted(batchId) + } else { + transferStateStore.markBatchSongFailed(batchId, outcome.errorCode) + } + } + + cancelledBatchIds.remove(batchId) + if (transferStateStore.batchTransfers.value[batchId]?.status != WearTransferProgress.STATUS_CANCELLED) { + transferStateStore.markBatchCompleted(batchId) + } + } + + /** 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 overrideAudioFile = (transcodeResult as? WatchAudioTranscoder.TranscodeResult.Transcoded)?.outputFile + val wasTranscoded = overrideAudioFile != 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, overrideAudioFile, 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, + overrideAudioFile: java.io.File?, + 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 || + state.status == WearTransferProgress.STATUS_AWAITING_WATCH_ACK + ) { + // 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, + overrideAudioFile = overrideAudioFile, + ) + + val finalState = withTimeoutOrNull(SONG_TRANSFER_AWAIT_TIMEOUT_MS) { + 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", + errorCode = WearTransferProgress.ERROR_CODE_TIMED_OUT, + ) + return SongTransferResult(completed = false, errorCode = WearTransferProgress.ERROR_CODE_TIMED_OUT) + } + + return SongTransferResult( + completed = finalState.status == WearTransferProgress.STATUS_COMPLETED, + errorCode = finalState.errorCode, + ) + } + + 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 120s idle watchdog: leaves room for + // slow transcoding + a slow Bluetooth link on large files. Better to wait too long than + // to mark a legitimately-slow transfer as failed. + // Var (not const) so tests can shrink it instead of waiting out the real timeout. + internal var SONG_TRANSFER_AWAIT_TIMEOUT_MS = 300_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 000000000..f5c833e6f --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/wear/WatchAudioTranscoder.kt @@ -0,0 +1,202 @@ +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 kotlinx.coroutines.CancellableContinuation +import kotlinx.coroutines.Dispatchers +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 ~256kbps: 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 target bitrate are sent through untouched (see + * [PhoneDirectWatchTransferCoordinator]). + */ +@UnstableApi +@Singleton +class WatchAudioTranscoder @Inject constructor( + private val application: Application, +) { + + 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") } + } + } + + private suspend fun runTransform( + inputMediaItem: MediaItem, + outputFile: File, + onProgress: (Float) -> Unit, + ): TranscodeResult = withContext(Dispatchers.Main) { + 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 + 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 000000000..0106df224 --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/wear/WatchPlaylistTransferEstimator.kt @@ -0,0 +1,62 @@ +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. Measured on a + * real phone+watch pair (Wearable Data Layer ChannelClient, not raw BT bandwidth) rather than + * taken from a spec sheet — observed rate was closer to ~40KB/s per song. Kept deliberately + * conservative: an estimate that undershoots the real time erodes trust in the sheet more + * than one that's a bit pessimistic. + */ + 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 5a956da61..8383377b7 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 @@ -10,6 +10,7 @@ import android.content.Intent import android.content.pm.ServiceInfo import android.os.Build import android.os.IBinder +import android.os.PowerManager import android.text.format.Formatter import androidx.core.app.NotificationCompat import androidx.core.content.ContextCompat @@ -24,6 +25,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 @@ -35,6 +37,7 @@ class WatchTransferForegroundService : Service() { private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) private var transferObserverJob: Job? = null private var hasStartedForeground = false + private var wakeLock: PowerManager.WakeLock? = null override fun onCreate() { super.onCreate() @@ -42,8 +45,33 @@ class WatchTransferForegroundService : Service() { observeTransfers() } + /** + * Without this, the CPU can deep-sleep between Bluetooth chunks once the screen is off, + * which can outlast our own idle-timeout watchdogs and mark a healthy-but-slow transfer as + * failed. Held only while a transfer is actually active; released as soon as it's not. + */ + private fun acquireWakeLock() { + if (wakeLock?.isHeld == true) return + val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager + wakeLock = powerManager.newWakeLock( + PowerManager.PARTIAL_WAKE_LOCK, + "PixelPlay:watchTransfer", + ).apply { + setReferenceCounted(false) + acquire(MAX_WAKE_LOCK_DURATION_MS) + } + } + + private fun releaseWakeLock() { + wakeLock?.let { if (it.isHeld) it.release() } + wakeLock = null + } + 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 { @@ -55,6 +83,7 @@ class WatchTransferForegroundService : Service() { override fun onDestroy() { transferObserverJob?.cancel() serviceScope.cancel() + releaseWakeLock() super.onDestroy() } @@ -63,21 +92,26 @@ 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 + combine( + transferStateStore.transfers, + transferStateStore.batchTransfers, + ) { transfers, batches -> transfers.values.toList() to batches.values.toList() } + .collect { (states, batches) -> + if (states.isEmpty() && batches.isEmpty()) { + releaseWakeLock() + stopForegroundCompat() + stopSelf() + return@collect + } + + acquireWakeLock() + val notification = buildNotification(states, batches) + if (!hasStartedForeground) { + startInForeground(notification) + } else { + notificationManager().notify(NOTIFICATION_ID, notification) + } } - - val notification = buildNotification(states) - if (!hasStartedForeground) { - startInForeground(notification) - } else { - notificationManager().notify(NOTIFICATION_ID, notification) - } - } } } @@ -110,7 +144,83 @@ class WatchTransferForegroundService : Service() { hasStartedForeground = false } - private fun buildNotification(transfers: List): Notification { + private fun buildNotification( + transfers: List, + batches: List, + ): Notification { + val activeBatches = batches.filter { + it.status == WearTransferProgress.STATUS_TRANSCODING || it.status == WearTransferProgress.STATUS_TRANSFERRING + } + val selectedBatch = activeBatches.maxByOrNull { it.updatedAtMillis } + ?: batches.maxByOrNull { it.updatedAtMillis } + + if (selectedBatch != null) { + return buildBatchNotification(selectedBatch, isOngoing = activeBatches.isNotEmpty()) + } + + return buildIndividualNotification(transfers) + } + + private fun buildBatchNotification(batch: PhoneWatchBatchTransferState, isOngoing: Boolean): Notification { + val title = if (isOngoing) { + getString( + R.string.watch_transfer_status_sending_playlist_service, + batch.playlistName, + batch.completedSongs, + batch.totalSongs, + ) + } else { + when (batch.status) { + 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 statusText = when (batch.status) { + WearTransferProgress.STATUS_TRANSCODING -> getString(R.string.watch_transfer_status_transcoding) + WearTransferProgress.STATUS_TRANSFERRING -> getString(R.string.watch_transfer_status_transferring) + WearTransferProgress.STATUS_COMPLETED -> getString(R.string.watch_transfer_status_completed) + WearTransferProgress.STATUS_FAILED -> getString(R.string.watch_transfer_status_failed) + WearTransferProgress.STATUS_CANCELLED -> getString(R.string.watch_transfer_status_cancelled) + else -> getString(R.string.watch_transfer_status_preparing) + } + val contentText = batch.currentSongTitle.ifBlank { statusText } + val progressPercent = (batch.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 = listOfNotNull( + batch.currentSongTitle.ifBlank { null }, + statusText, + batch.error?.takeIf { it.isNotBlank() }, + ).joinToString(separator = "\n") + if (detailText.isNotBlank()) { + builder.setStyle(NotificationCompat.BigTextStyle().bigText(detailText)) + } + + return builder.build() + } + + private fun buildIndividualNotification(transfers: List): Notification { val activeTransfers = transfers.filter { it.status == WearTransferProgress.STATUS_TRANSFERRING } val selectedTransfer = activeTransfers.maxByOrNull { it.updatedAtMillis } ?: transfers.maxByOrNull { it.updatedAtMillis } @@ -266,6 +376,9 @@ class WatchTransferForegroundService : Service() { private const val NOTIFICATION_CHANNEL_ID = "pixelplay_watch_transfers" private const val NOTIFICATION_ID = 1003 private const val MAX_STYLE_LINES = 5 + // Safety net in case release() is ever skipped by a bug: auto-releases well past any + // legitimately slow whole-playlist transfer instead of draining battery indefinitely. + private const val MAX_WAKE_LOCK_DURATION_MS = 6 * 60 * 60 * 1000L fun start(context: Context) { val intent = Intent(context, WatchTransferForegroundService::class.java) 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 0b0ce27ee..9038ae5fb 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 @@ -25,6 +25,7 @@ import com.theveloper.pixelplay.shared.WearBrowseRequest import com.theveloper.pixelplay.shared.WearBrowseResponse import com.theveloper.pixelplay.shared.WearDataPaths import com.theveloper.pixelplay.shared.WearLibraryItem +import com.theveloper.pixelplay.shared.WearLibraryState import com.theveloper.pixelplay.shared.WearPlaybackCommand import com.theveloper.pixelplay.shared.WearTransferMetadata import com.theveloper.pixelplay.shared.WearTransferProgress @@ -97,10 +98,63 @@ class WearCommandReceiver : WearableListenerService() { WearDataPaths.BROWSE_REQUEST -> handleBrowseRequest(messageEvent) WearDataPaths.TRANSFER_REQUEST -> handleTransferRequest(messageEvent) WearDataPaths.TRANSFER_CANCEL -> handleTransferCancel(messageEvent) + WearDataPaths.TRANSFER_PROGRESS -> handleTransferProgressAck(messageEvent) + WearDataPaths.WATCH_LIBRARY_STATE -> handleWatchLibraryState(messageEvent) else -> Timber.tag(TAG).w("Unknown message path: ${messageEvent.path}") } } + /** + * The watch reports the real, validated outcome of a transfer over this same path (reusing + * the schema the phone uses for its own outgoing progress updates). This is the only source + * of truth for "is this song actually present on the watch" — see + * PhoneDirectWatchTransferCoordinator.sendTransferProgress, which deliberately no longer + * marks a song present just because the phone finished sending it. + */ + private fun handleTransferProgressAck(messageEvent: MessageEvent) { + val progress = try { + json.decodeFromString(String(messageEvent.data, Charsets.UTF_8)) + } catch (e: Exception) { + Timber.tag(TAG).e(e, "Failed to parse transfer progress ack") + return + } + when (progress.status) { + WearTransferProgress.STATUS_COMPLETED -> { + transferStateStore.markProgress( + requestId = progress.requestId, + songId = progress.songId, + bytesTransferred = progress.totalBytes, + totalBytes = progress.totalBytes, + status = WearTransferProgress.STATUS_COMPLETED, + ) + transferStateStore.markSongPresentOnWatch( + nodeId = messageEvent.sourceNodeId, + songId = progress.songId, + ) + } + WearTransferProgress.STATUS_FAILED -> { + transferStateStore.markProgress( + requestId = progress.requestId, + songId = progress.songId, + bytesTransferred = progress.bytesTransferred, + totalBytes = progress.totalBytes, + status = WearTransferProgress.STATUS_FAILED, + error = progress.error, + errorCode = progress.errorCode, + ) + // A rejection because it's already on the watch means it IS genuinely present — + // don't downgrade that case, only a real failure should clear presence. + if (progress.error != WearTransferProgress.ERROR_ALREADY_ON_WATCH) { + transferStateStore.clearSongPresentOnWatch( + nodeId = messageEvent.sourceNodeId, + songId = progress.songId, + ) + } + } + else -> Timber.tag(TAG).d("Ignoring non-terminal transfer ack: %s", progress.status) + } + } + private fun handlePlaybackCommand(messageEvent: MessageEvent) { val commandJson = String(messageEvent.data, Charsets.UTF_8) val command = try { @@ -538,6 +592,19 @@ class WearCommandReceiver : WearableListenerService() { Timber.tag(TAG).d("Transfer cancelled: requestId=${request.requestId}") } + private fun handleWatchLibraryState(messageEvent: MessageEvent) { + val stateJson = String(messageEvent.data, Charsets.UTF_8) + val state = try { + json.decodeFromString(stateJson) + } catch (e: Exception) { + Timber.tag(TAG).e(e, "Failed to parse watch library state") + return + } + val nodeId = messageEvent.sourceNodeId + transferStateStore.updateWatchSongIds(nodeId, state.songIds.toSet()) + transferStateStore.updateWatchFreeStorageBytes(nodeId, state.freeStorageBytes) + } + /** * Open a song's audio file, trying direct File access first, * then falling back to ContentResolver. diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryScreen.kt index 5d37d029e..52db3f61c 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 @@ -287,6 +287,7 @@ private fun WatchTransferProgressDialog( } val statusText = when (transfer.status) { WearTransferProgress.STATUS_TRANSFERRING -> stringResource(R.string.watch_transfer_status_transferring) + WearTransferProgress.STATUS_AWAITING_WATCH_ACK -> stringResource(R.string.watch_transfer_status_confirming) 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) @@ -485,7 +486,10 @@ fun LibraryScreen( val isSortSheetVisible by playerViewModel.isSortingSheetVisible.collectAsStateWithLifecycle() val isSendingToWatch by songInfoBottomSheetViewModel.isSendingToWatch.collectAsStateWithLifecycle() val activeWatchTransfer by songInfoBottomSheetViewModel.activeWatchTransfer.collectAsStateWithLifecycle() + val activePlaylistBatchTransfer by playlistViewModel.activePlaylistBatchTransfer.collectAsStateWithLifecycle() + val isTransferringToWatch = isSendingToWatch || activePlaylistBatchTransfer != null var showWatchTransferDialog by remember { mutableStateOf(false) } + var showWatchBatchProgressDialog by remember { mutableStateOf(false) } val canNavigateBackInFolders by remember(playerViewModel) { playerViewModel.playerUiState .map { uiState -> uiState.currentFolder != null && uiState.folderBackGestureNavigationEnabled } @@ -513,6 +517,11 @@ fun LibraryScreen( showWatchTransferDialog = false } } + LaunchedEffect(activePlaylistBatchTransfer?.batchId) { + if (activePlaylistBatchTransfer == null) { + showWatchBatchProgressDialog = false + } + } // Multi-selection state val multiSelectionState = playerViewModel.multiSelectionStateHolder @@ -851,10 +860,10 @@ fun LibraryScreen( modifier = Modifier, title = currentTabTitle, isExpanded = showTabSwitcherSheet, - showIcon = !isSendingToWatch, + showIcon = !isTransferringToWatch, iconRes = currentTab.iconRes(), pageIndex = pagerState.currentPage, - compressForWatchTransfer = isSendingToWatch, + compressForWatchTransfer = isTransferringToWatch, onClick = { showTabSwitcherSheet = true }, @@ -873,8 +882,10 @@ fun LibraryScreen( } }, actions = { - if (isSendingToWatch) { - val watchTransferProgress = activeWatchTransfer?.progress ?: 0f + if (isTransferringToWatch) { + val currentBatchTransfer = activePlaylistBatchTransfer + val watchTransferProgress = currentBatchTransfer?.overallProgress + ?: (activeWatchTransfer?.progress ?: 0f) val watchTransferPercent = (watchTransferProgress * 100f).toInt().coerceIn(0, 100) Surface( modifier = Modifier @@ -882,8 +893,15 @@ fun LibraryScreen( .wrapContentWidth() .height(40.dp) .clip(CircleShape) - .clickable(enabled = activeWatchTransfer != null) { - showWatchTransferDialog = true + .clickable(enabled = currentBatchTransfer != null || activeWatchTransfer != null) { + // A playlist batch takes priority: it's the more meaningful + // aggregate view, and its Cancel actually stops the whole + // batch instead of just the one song currently in flight. + if (currentBatchTransfer != null) { + showWatchBatchProgressDialog = true + } else { + showWatchTransferDialog = true + } }, shape = CircleShape, color = MaterialTheme.colorScheme.secondaryContainer, @@ -1830,6 +1848,18 @@ fun LibraryScreen( ) } + if (showWatchBatchProgressDialog && activePlaylistBatchTransfer != null) { + val currentBatchTransfer = activePlaylistBatchTransfer!! + WatchPlaylistBatchProgressDialog( + batch = currentBatchTransfer, + onDismiss = { showWatchBatchProgressDialog = false }, + onCancelTransfer = { + playlistViewModel.cancelPlaylistTransfer(currentBatchTransfer.batchId) + showWatchBatchProgressDialog = false + } + ) + } + if (showSongInfoBottomSheet && selectedSongForInfo != null) { val currentSong = selectedSongForInfo val isFavorite = remember(currentSong?.id, favoriteIds) { derivedStateOf { currentSong?.let { 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 a2b8c0c67..d2c40d05b 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 @@ -3,12 +3,14 @@ package com.theveloper.pixelplay.presentation.screens import com.theveloper.pixelplay.presentation.navigation.navigateSafely import com.theveloper.pixelplay.presentation.navigation.navigateSafelyReplacing +import android.text.format.Formatter import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts 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 @@ -65,10 +67,13 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.LargeFlexibleTopAppBar +import androidx.compose.material3.LinearWavyProgressIndicator +import androidx.compose.material3.LoadingIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TextFieldDefaults @@ -89,6 +94,10 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.draw.scale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color @@ -100,9 +109,11 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.layout.Layout +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.dp @@ -114,6 +125,9 @@ import androidx.navigation.NavController import coil.size.Size import com.theveloper.pixelplay.R import com.theveloper.pixelplay.data.model.Song +import com.theveloper.pixelplay.data.service.wear.PhoneWatchBatchTransferState +import com.theveloper.pixelplay.data.service.wear.WatchPlaylistTransferEstimate +import com.theveloper.pixelplay.shared.WearTransferProgress import com.theveloper.pixelplay.presentation.components.MiniPlayerHeight import com.theveloper.pixelplay.presentation.components.PlaylistBottomSheet import com.theveloper.pixelplay.presentation.components.QueuePlaylistSongItem @@ -179,6 +193,8 @@ 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 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) @@ -190,6 +206,7 @@ fun PlaylistDetailScreen( LaunchedEffect(playlistId) { playlistViewModel.loadPlaylistDetails(playlistId) + playlistViewModel.refreshWatchAvailability() } var showAddSongsSheet by remember { mutableStateOf(false) } @@ -200,6 +217,21 @@ fun PlaylistDetailScreen( var showPlaylistOptionsSheet by remember { mutableStateOf(false) } var showEditPlaylistDialog by remember { mutableStateOf(false) } var showDeleteConfirmation by remember { mutableStateOf(false) } + var showSendToWatchSheet by remember { mutableStateOf(false) } + var showWatchBatchProgressDialog by remember { mutableStateOf(false) } + val watchSongIds by playlistViewModel.watchSongIds.collectAsStateWithLifecycle() + val activePlaylistBatchTransfer by playlistViewModel.activePlaylistBatchTransfer.collectAsStateWithLifecycle() + val watchFreeStorageBytesByNodeId by playlistViewModel.watchFreeStorageBytesByNodeId.collectAsStateWithLifecycle() + val isPixelPlayWatchAvailable by playlistViewModel.isPixelPlayWatchAvailable.collectAsStateWithLifecycle() + val isPlaylistOnWatch = currentPlaylist != null && + currentPlaylist.songIds.isNotEmpty() && + currentPlaylist.songIds.any { it in watchSongIds } + + LaunchedEffect(activePlaylistBatchTransfer?.batchId) { + if (activePlaylistBatchTransfer == null) { + showWatchBatchProgressDialog = false + } + } val m3uExportLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.CreateDocument("audio/x-mpegurl") @@ -885,10 +917,60 @@ fun PlaylistDetailScreen( m3uExportLauncher.launch("$sanitizedName.m3u") } ) + if (songsInPlaylist.isNotEmpty()) { + PlaylistActionItem( + icon = painterResource(R.drawable.rounded_watch_arrow_down_24), + label = if (isPlaylistOnWatch) updateOnWatchLabel else sendToWatchLabel, + onClick = { + showPlaylistOptionsSheet = false + if (activePlaylistBatchTransfer != null) { + // A batch (this playlist's or another's) is already running — show + // its progress instead of letting a second one start concurrently. + showWatchBatchProgressDialog = true + } else { + playlistViewModel.refreshWatchAvailability() + showSendToWatchSheet = true + } + } + ) + } } } } - + + if (showSendToWatchSheet && currentPlaylist != null) { + val estimate = remember(songsInPlaylist, watchSongIds) { + playlistViewModel.estimateWatchTransfer(songsInPlaylist) + } + SendPlaylistToWatchSheet( + playlistName = currentPlaylist.name, + estimate = estimate, + maxFreeStorageBytes = watchFreeStorageBytesByNodeId.values.maxOrNull(), + isWatchAvailable = isPixelPlayWatchAvailable, + isUpdate = isPlaylistOnWatch, + onDismiss = { showSendToWatchSheet = false }, + onConfirm = { + // Re-check rather than trusting the click above: a batch could have started + // elsewhere in the narrow window this sheet was open, and starting a second one + // concurrently would contend for the single Bluetooth channel. + if (activePlaylistBatchTransfer == null) { + playlistViewModel.sendPlaylistToWatch(currentPlaylist.id, currentPlaylist.name, currentPlaylist.songIds) + } + showSendToWatchSheet = false + showWatchBatchProgressDialog = true + } + ) + } + + val currentBatchTransfer = activePlaylistBatchTransfer + if (showWatchBatchProgressDialog && currentBatchTransfer != null) { + WatchPlaylistBatchProgressDialog( + batch = currentBatchTransfer, + onDismiss = { showWatchBatchProgressDialog = false }, + onCancelTransfer = { playlistViewModel.cancelPlaylistTransfer(currentBatchTransfer.batchId) } + ) + } + if (showEditPlaylistDialog && currentPlaylist != null) { val initialShapeType = try { currentPlaylist.coverShapeType?.let { PlaylistShapeType.valueOf(it) } ?: PlaylistShapeType.Circle @@ -1152,3 +1234,286 @@ private fun PlaylistActionItem( ) } } + +private fun formatEstimatedDuration(seconds: Long): String { + if (seconds <= 0L) return "0s" + val minutes = seconds / 60 + val remainingSeconds = seconds % 60 + return if (minutes > 0) "${minutes}m ${remainingSeconds}s" else "${remainingSeconds}s" +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SendPlaylistToWatchSheet( + playlistName: String, + estimate: WatchPlaylistTransferEstimate, + maxFreeStorageBytes: Long?, + isWatchAvailable: Boolean, + isUpdate: Boolean, + onDismiss: () -> Unit, + onConfirm: () -> Unit, +) { + val context = LocalContext.current + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val estimatedSizeText = Formatter.formatShortFileSize(context, estimate.estimatedBytes) + val insufficientStorage = maxFreeStorageBytes != null && estimate.estimatedBytes > maxFreeStorageBytes + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surfaceContainerLow, + tonalElevation = 4.dp, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 8.dp) + .padding(bottom = 12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = stringResource( + if (isUpdate) R.string.send_playlist_to_watch_sheet_title_update + else R.string.send_playlist_to_watch_sheet_title + ), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = playlistName, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = stringResource(R.string.send_playlist_to_watch_song_count, estimate.totalSongCount), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface + ) + + if (estimate.pendingSongCount > 0) { + Text( + text = pluralStringResource( + R.plurals.send_playlist_to_watch_pending_count_plural, + estimate.pendingSongCount, + estimate.pendingSongCount + ), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = stringResource(R.string.send_playlist_to_watch_estimated_size, estimatedSizeText), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = stringResource( + R.string.send_playlist_to_watch_estimated_time, + formatEstimatedDuration(estimate.estimatedTransferSeconds) + ), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = stringResource(R.string.send_playlist_to_watch_keep_nearby_advisory), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 8.dp) + ) + Text( + text = stringResource(R.string.send_playlist_to_watch_safe_background_advisory), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } else { + Text( + text = stringResource(R.string.send_playlist_to_watch_up_to_date), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + if (!isWatchAvailable) { + Text( + text = stringResource(R.string.send_playlist_to_watch_no_watch), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(top = 8.dp) + ) + } else if (insufficientStorage) { + Text( + text = stringResource( + R.string.send_playlist_to_watch_insufficient_storage, + estimatedSizeText, + Formatter.formatShortFileSize(context, maxFreeStorageBytes ?: 0L) + ), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(top = 8.dp) + ) + } + + Spacer(modifier = Modifier.height(20.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + TextButton( + modifier = Modifier.weight(1f), + onClick = onDismiss + ) { + Text(stringResource(R.string.common_cancel)) + } + Button( + modifier = Modifier.weight(1f), + enabled = isWatchAvailable && !insufficientStorage, + onClick = onConfirm + ) { + Text(stringResource(R.string.common_confirm)) + } + } + } + } +} + +// Not private: also used by LibraryScreen, which needs to show batch (not per-song) progress +// when reopening its "sending to watch" chip while a playlist batch transfer is active. +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun WatchPlaylistBatchProgressDialog( + batch: PhoneWatchBatchTransferState, + onDismiss: () -> Unit, + onCancelTransfer: () -> Unit, +) { + val animatedProgress by animateFloatAsState( + targetValue = batch.overallProgress.coerceIn(0f, 1f), + animationSpec = tween(durationMillis = 300), + label = "WatchPlaylistBatchProgressDialog" + ) + val progressPercent = (animatedProgress * 100f).toInt().coerceIn(0, 100) + val statusText = when (batch.status) { + WearTransferProgress.STATUS_TRANSCODING -> stringResource(R.string.watch_transfer_status_transcoding) + WearTransferProgress.STATUS_TRANSFERRING -> stringResource(R.string.watch_transfer_status_transferring) + WearTransferProgress.STATUS_AWAITING_WATCH_ACK -> stringResource(R.string.watch_transfer_status_confirming) + 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 failureSummaryText = if (batch.status == WearTransferProgress.STATUS_COMPLETED && batch.failedSongCount > 0) { + val reasonText = when (batch.lastFailureErrorCode) { + WearTransferProgress.ERROR_CODE_INSUFFICIENT_STORAGE -> + stringResource(R.string.watch_playlist_batch_failure_reason_storage) + WearTransferProgress.ERROR_CODE_CONNECTION_LOST, WearTransferProgress.ERROR_CODE_TIMED_OUT -> + stringResource(R.string.watch_playlist_batch_failure_reason_connection) + else -> stringResource(R.string.watch_playlist_batch_failure_reason_generic) + } + pluralStringResource( + R.plurals.watch_playlist_batch_failed_count_plural, + batch.failedSongCount, + batch.failedSongCount, + ) + " · " + reasonText + } else { + null + } + + 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_playlist_batch_dialog_title, batch.playlistName), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center + ) + 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 = stringResource(R.string.watch_playlist_batch_progress, batch.completedSongs, batch.totalSongs), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = batch.currentSongTitle.ifBlank { statusText }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center + ) + batch.error?.takeIf { it.isNotBlank() }?.let { errorText -> + Text( + text = errorText, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center + ) + } + failureSummaryText?.let { text -> + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center + ) + } + TextButton(onClick = { + onCancelTransfer() + onDismiss() + }) { + Text(stringResource(R.string.watch_transfer_action_cancel)) + } + } + } + } +} diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlaylistViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlaylistViewModel.kt index 8c5c620ef..1b22a794d 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,36 @@ 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 watchFreeStorageBytesByNodeId: StateFlow> = + watchTransferStateStore.watchFreeStorageBytesByNodeId + val watchSongIds: StateFlow> = watchTransferStateStore.watchSongIds + + /** + * Whichever playlist batch transfer is currently active, regardless of which screen/ViewModel + * instance started it — queried directly off the shared [PhoneWatchTransferStateStore] instead + * of remembering "the last batchId this instance kicked off", so screens other than the one + * that started the send (e.g. LibraryScreen reopening from its notification chip) still see it. + */ + 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 +1265,43 @@ class PlaylistViewModel @Inject constructor( } } } + + /** + * Refreshes reachable-watch capability + free storage. Call when the "send to watch" sheet is + * about to be shown so the confirmation UI has up-to-date free-space data. + */ + 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) + } + + /** Highest free-storage figure among reachable watches, or null if none reported yet. */ + fun maxWatchFreeStorageBytes(): Long? = watchFreeStorageBytesByNodeId.value.values.maxOrNull() + + fun sendPlaylistToWatch(playlistId: String, playlistName: String, songIds: List): String { + return playlistWatchTransferCoordinator.requestPlaylistTransfer(playlistId, playlistName, songIds) + } + + fun cancelPlaylistTransfer(batchId: String) { + playlistWatchTransferCoordinator.cancelPlaylistTransfer(batchId) + } } diff --git a/app/src/main/res/values/strings_library.xml b/app/src/main/res/values/strings_library.xml index 8b7c6c2c6..dc56f7d2a 100644 --- a/app/src/main/res/values/strings_library.xml +++ b/app/src/main/res/values/strings_library.xml @@ -285,8 +285,36 @@ Starting transfer… Starting Transferring + Converting audio + Confirming on watch… %1$d transfers + + Send to watch + Update on watch + %1$d songs + + %1$d new song will be sent + %1$d new songs will be sent + + All songs are already on the watch. Only the playlist itself will be updated. + Estimated size: %1$s + Estimated time: %1$s + Not enough free space on the watch: needs %1$s, only %2$s free. + No reachable watch with PixelPlay installed. + Keep your watch nearby and connected during the transfer. + You can lock your phone or use other apps — the transfer continues in the background. + Sending “%1$s” + %1$d / %2$d songs + Sending playlist %1$s (%2$d/%3$d) + Not enough free space on the watch + Connection to the watch was lost + Some songs failed to transfer + + %1$d song couldn\'t be sent + %1$d songs couldn\'t be sent + + Edit song Show information diff --git a/app/src/main/res/values/strings_screens.xml b/app/src/main/res/values/strings_screens.xml index 90ec1b118..c27f7ca88 100644 --- a/app/src/main/res/values/strings_screens.xml +++ b/app/src/main/res/values/strings_screens.xml @@ -118,6 +118,8 @@ Are you sure you want to delete this playlist? Set default transition Export Playlist + Send to watch + Update on watch %1$s • %2$s Play it Add 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 000000000..83d697b79 --- /dev/null +++ b/app/src/test/java/com/theveloper/pixelplay/data/service/wear/PhoneWatchTransferStateStoreTest.kt @@ -0,0 +1,193 @@ +package com.theveloper.pixelplay.data.service.wear + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import com.theveloper.pixelplay.shared.WearTransferProgress +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test + +class PhoneWatchTransferStateStoreTest { + + private val store = PhoneWatchTransferStateStore() + + @Test + fun `markBatchStarted publishes the initial aggregate state`() = runTest { + store.batchTransfers.test { + assertThat(awaitItem()).isEmpty() + + store.markBatchStarted("batch-1", "playlist-1", "QA Transcode Test", totalSongs = 4) + + val batch = awaitItem().getValue("batch-1") + assertThat(batch.totalSongs).isEqualTo(4) + assertThat(batch.completedSongs).isEqualTo(0) + assertThat(batch.status).isEqualTo(WearTransferProgress.STATUS_TRANSFERRING) + assertThat(batch.overallProgress).isEqualTo(0f) + } + } + + @Test + fun `song lifecycle updates activeRequestId, progress and completedSongs`() = runTest { + store.batchTransfers.test { + awaitItem() // initial empty snapshot from subscribing to the StateFlow + + store.markBatchStarted("batch-1", "playlist-1", "QA Transcode Test", totalSongs = 2) + awaitItem() + + store.markBatchSongStarted("batch-1", "req-1", "test_flac") + val started = awaitItem().getValue("batch-1") + assertThat(started.activeRequestId).isEqualTo("req-1") + assertThat(started.currentSongTitle).isEqualTo("test_flac") + assertThat(started.status).isEqualTo(WearTransferProgress.STATUS_TRANSFERRING) + + store.markBatchSongProgress("batch-1", WearTransferProgress.STATUS_TRANSCODING, 0.5f) + val transcoding = awaitItem().getValue("batch-1") + assertThat(transcoding.status).isEqualTo(WearTransferProgress.STATUS_TRANSCODING) + assertThat(transcoding.currentSongProgress).isEqualTo(0.5f) + // First of 2 songs, halfway through: (0 completed + 0.5) / 2 + assertThat(transcoding.overallProgress).isEqualTo(0.25f) + + store.markBatchSongCompleted("batch-1") + val completedOne = awaitItem().getValue("batch-1") + assertThat(completedOne.completedSongs).isEqualTo(1) + assertThat(completedOne.activeRequestId).isNull() + assertThat(completedOne.currentSongProgress).isEqualTo(0f) + assertThat(completedOne.overallProgress).isEqualTo(0.5f) + } + } + + @Test + fun `markBatchCompleted sets terminal status and clears the active song`() = runTest { + store.batchTransfers.test { + awaitItem() // initial empty snapshot from subscribing to the StateFlow + + store.markBatchStarted("batch-1", "playlist-1", "QA Transcode Test", totalSongs = 1) + awaitItem() + store.markBatchSongStarted("batch-1", "req-1", "test_mp3_320") + awaitItem() + + store.markBatchCompleted("batch-1") + val completed = awaitItem().getValue("batch-1") + assertThat(completed.status).isEqualTo(WearTransferProgress.STATUS_COMPLETED) + assertThat(completed.activeRequestId).isNull() + } + } + + @Test + fun `markBatchCancelled sets cancelled status and keeps completedSongs so far`() = runTest { + store.batchTransfers.test { + awaitItem() // initial empty snapshot from subscribing to the StateFlow + + store.markBatchStarted("batch-1", "playlist-1", "QA Transcode Test", totalSongs = 3) + awaitItem() + store.markBatchSongStarted("batch-1", "req-1", "test_flac") + awaitItem() + store.markBatchSongCompleted("batch-1") + awaitItem() + + store.markBatchCancelled("batch-1") + val cancelled = awaitItem().getValue("batch-1") + assertThat(cancelled.status).isEqualTo(WearTransferProgress.STATUS_CANCELLED) + // The song already sent before cancelling is not undone. + assertThat(cancelled.completedSongs).isEqualTo(1) + } + } + + @Test + fun `markBatchFailed records the error message`() = runTest { + store.batchTransfers.test { + awaitItem() // initial empty snapshot from subscribing to the StateFlow + + store.markBatchStarted("batch-1", "playlist-1", "QA Transcode Test", totalSongs = 1) + awaitItem() + + store.markBatchFailed("batch-1", "No reachable watch with PixelPlay") + val failed = awaitItem().getValue("batch-1") + assertThat(failed.status).isEqualTo(WearTransferProgress.STATUS_FAILED) + assertThat(failed.error).isEqualTo("No reachable watch with PixelPlay") + } + } + + @Test + fun `updates for an unknown batchId are ignored`() = runTest { + store.markBatchSongStarted("unknown-batch", "req-1", "test_flac") + store.markBatchSongProgress("unknown-batch", WearTransferProgress.STATUS_TRANSFERRING, 0.5f) + store.markBatchSongCompleted("unknown-batch") + store.markBatchCompleted("unknown-batch") + + assertThat(store.batchTransfers.value).isEmpty() + } + + @Test + fun `markBatchSongFailed increments the failure count and records the reason`() = runTest { + store.batchTransfers.test { + awaitItem() // initial empty snapshot from subscribing to the StateFlow + + store.markBatchStarted("batch-1", "playlist-1", "QA Transcode Test", totalSongs = 2) + awaitItem() + store.markBatchSongStarted("batch-1", "req-1", "test_flac") + awaitItem() + + store.markBatchSongFailed("batch-1", WearTransferProgress.ERROR_CODE_TIMED_OUT) + val failedOnce = awaitItem().getValue("batch-1") + assertThat(failedOnce.failedSongCount).isEqualTo(1) + assertThat(failedOnce.lastFailureErrorCode).isEqualTo(WearTransferProgress.ERROR_CODE_TIMED_OUT) + assertThat(failedOnce.activeRequestId).isNull() + + // A later failure with a null errorCode shouldn't erase the last known reason. + store.markBatchSongFailed("batch-1", null) + val failedTwice = awaitItem().getValue("batch-1") + assertThat(failedTwice.failedSongCount).isEqualTo(2) + assertThat(failedTwice.lastFailureErrorCode).isEqualTo(WearTransferProgress.ERROR_CODE_TIMED_OUT) + } + } + + @Test + fun `markSongPresentOnWatch and clearSongPresentOnWatch toggle isSongSavedOnAllReachableWatches`() = runTest { + store.retainReachableWatchNodes(setOf("node-1")) + assertThat(store.isSongSavedOnAllReachableWatches("song-1")).isFalse() + + store.markSongPresentOnWatch("node-1", "song-1") + assertThat(store.isSongSavedOnAllReachableWatches("song-1")).isTrue() + + store.clearSongPresentOnWatch("node-1", "song-1") + assertThat(store.isSongSavedOnAllReachableWatches("song-1")).isFalse() + } + + @Test + fun `clearSongPresentOnWatch is a no-op for a song that was never marked present`() = runTest { + store.retainReachableWatchNodes(setOf("node-1")) + store.markSongPresentOnWatch("node-1", "song-1") + + store.clearSongPresentOnWatch("node-1", "song-2") + + assertThat(store.isSongSavedOnAllReachableWatches("song-1")).isTrue() + } + + @Test + fun `markProgress with AWAITING_WATCH_ACK status is stored as-is`() = runTest { + store.transfers.test { + awaitItem() // initial empty snapshot from subscribing to the StateFlow + + store.markProgress( + requestId = "req-1", + songId = "song-1", + bytesTransferred = 100L, + totalBytes = 100L, + status = WearTransferProgress.STATUS_AWAITING_WATCH_ACK, + ) + val awaitingAck = awaitItem().getValue("req-1") + assertThat(awaitingAck.status).isEqualTo(WearTransferProgress.STATUS_AWAITING_WATCH_ACK) + + // The watch's ack later resolves it to a real terminal status. + store.markProgress( + requestId = "req-1", + songId = "song-1", + bytesTransferred = 100L, + totalBytes = 100L, + status = WearTransferProgress.STATUS_COMPLETED, + ) + val completed = awaitItem().getValue("req-1") + assertThat(completed.status).isEqualTo(WearTransferProgress.STATUS_COMPLETED) + } + } +} 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 000000000..7ce048c86 --- /dev/null +++ b/app/src/test/java/com/theveloper/pixelplay/data/service/wear/PlaylistWatchTransferCoordinatorTest.kt @@ -0,0 +1,232 @@ +package com.theveloper.pixelplay.data.service.wear + +import android.app.Application +import android.content.Context +import app.cash.turbine.test +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.android.gms.wearable.Wearable +import com.google.common.truth.Truth.assertThat +import com.theveloper.pixelplay.data.model.Song +import com.theveloper.pixelplay.data.repository.MusicRepository +import com.theveloper.pixelplay.shared.WearTransferProgress +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.slot +import io.mockk.unmockkStatic +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class PlaylistWatchTransferCoordinatorTest { + + private val transferStateStore = PhoneWatchTransferStateStore() + private val musicRepository = mockk(relaxed = true) + private val watchAudioTranscoder = mockk(relaxed = true) + private val directTransferCoordinator = mockk(relaxed = true) + private val wearPhoneTransferSender = mockk(relaxed = true) + + private val coordinator = PlaylistWatchTransferCoordinator( + application = mockk(relaxed = true), + musicRepository = musicRepository, + watchAudioTranscoder = watchAudioTranscoder, + directTransferCoordinator = directTransferCoordinator, + wearPhoneTransferSender = wearPhoneTransferSender, + transferStateStore = transferStateStore, + ) + + private val originalAwaitTimeoutMs = PlaylistWatchTransferCoordinator.SONG_TRANSFER_AWAIT_TIMEOUT_MS + + @BeforeEach + fun mockWearable() { + mockkStatic(Wearable::class) + } + + @AfterEach + fun unmockWearable() { + unmockkStatic(Wearable::class) + PlaylistWatchTransferCoordinator.SONG_TRANSFER_AWAIT_TIMEOUT_MS = originalAwaitTimeoutMs + } + + private fun stubNoReachableWatches() { + val capabilityClient = mockk() + val capabilityInfo = mockk { every { nodes } returns emptySet() } + every { capabilityClient.getCapability(any(), any()) } returns Tasks.forResult(capabilityInfo) + every { Wearable.getCapabilityClient(any()) } returns capabilityClient + every { Wearable.getMessageClient(any()) } returns mockk(relaxed = true) + } + + private fun stubOneReachableWatch(): MessageClient { + val node = mockk { every { id } returns "node-1" } + val capabilityClient = mockk() + val capabilityInfo = mockk { every { nodes } returns setOf(node) } + every { capabilityClient.getCapability(any(), any()) } returns Tasks.forResult(capabilityInfo) + every { Wearable.getCapabilityClient(any()) } returns capabilityClient + val messageClient = mockk(relaxed = true) + // A relaxed mock's default Task for sendMessage never actually completes its + // listeners, so .await() on it would hang forever — return a genuinely resolved Task. + every { messageClient.sendMessage(any(), any(), any()) } returns Tasks.forResult(0) + every { Wearable.getMessageClient(any()) } returns messageClient + return messageClient + } + + private fun song(id: String) = Song( + id = id, + title = "Song $id", + artist = "Test Artist", + artistId = 1L, + album = "Test Album", + albumId = 1L, + path = "/sdcard/Music/$id.file", + contentUriString = "content://media/external/audio/media/$id", + albumArtUriString = null, + duration = 60_000L, + mimeType = "audio/mpeg", + bitrate = 128_000, + sampleRate = 44_100, + ) + + @Test + fun `requestPlaylistTransfer with an empty playlist does not start a batch`() = runTest { + val batchId = coordinator.requestPlaylistTransfer("playlist-1", "Empty", emptyList()) + + assertThat(batchId).isNotEmpty() + assertThat(transferStateStore.batchTransfers.value).isEmpty() + } + + @Test + fun `requestPlaylistTransfer fails the batch when no watch is reachable`() = runTest { + stubNoReachableWatches() + + transferStateStore.batchTransfers.test { + assertThat(awaitItem()).isEmpty() + + val batchId = coordinator.requestPlaylistTransfer( + "playlist-1", + "QA Transcode Test", + listOf("song-1", "song-2"), + ) + + val started = awaitItem().getValue(batchId) + assertThat(started.totalSongs).isEqualTo(2) + + val failed = awaitItem().getValue(batchId) + assertThat(failed.status).isEqualTo(WearTransferProgress.STATUS_FAILED) + assertThat(failed.error).isEqualTo("No reachable watch with PixelPlay") + } + } + + @Test + fun `cancelPlaylistTransfer marks the batch cancelled without an active song`() { + transferStateStore.markBatchStarted("batch-1", "playlist-1", "QA Transcode Test", totalSongs = 3) + + coordinator.cancelPlaylistTransfer("batch-1") + + assertThat(transferStateStore.batchTransfers.value.getValue("batch-1").status) + .isEqualTo(WearTransferProgress.STATUS_CANCELLED) + } + + @Test + fun `cancelPlaylistTransfer forwards the active requestId to WearPhoneTransferSender`() = runTest { + transferStateStore.markBatchStarted("batch-1", "playlist-1", "QA Transcode Test", totalSongs = 1) + transferStateStore.markBatchSongStarted("batch-1", "req-1", "test_flac") + coEvery { wearPhoneTransferSender.cancelTransfer(any()) } returns Unit + + coordinator.cancelPlaylistTransfer("batch-1") + + assertThat(transferStateStore.batchTransfers.value.getValue("batch-1").status) + .isEqualTo(WearTransferProgress.STATUS_CANCELLED) + } + + @Test + fun `a song whose transfer never reaches a terminal state is failed as timed out, not hung forever`() = runTest { + PlaylistWatchTransferCoordinator.SONG_TRANSFER_AWAIT_TIMEOUT_MS = 50L + stubOneReachableWatch() + every { musicRepository.getSongsByIds(listOf("song-1")) } returns flowOf(listOf(song("song-1"))) + coEvery { + watchAudioTranscoder.transcodeIfNeeded(any(), any(), any()) + } returns WatchAudioTranscoder.TranscodeResult.Passthrough + // directTransferCoordinator is relaxed: startTransferToWatch is a no-op that never pushes + // a terminal status into transferStateStore.transfers, simulating a watch that went + // silent (dead battery, out of range) right after the request went out. + + val batchId = coordinator.requestPlaylistTransfer( + "playlist-1", + "QA Transcode Test", + listOf("song-1"), + ) + + // The batch runs on the coordinator's own background scope, concurrently with this test, + // so intermediate StateFlow emissions (song-started, etc.) can be conflated — poll until + // the batch actually reaches a terminal status instead of asserting an exact step count. + transferStateStore.batchTransfers.test { + var batch = awaitItem()[batchId] + while (batch == null || batch.status == WearTransferProgress.STATUS_TRANSFERRING) { + batch = awaitItem()[batchId] + } + assertThat(batch.status).isEqualTo(WearTransferProgress.STATUS_COMPLETED) + assertThat(batch.completedSongs).isEqualTo(0) + assertThat(batch.failedSongCount).isEqualTo(1) + assertThat(batch.lastFailureErrorCode).isEqualTo(WearTransferProgress.ERROR_CODE_TIMED_OUT) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `transcode and transfer progress combine into one continuous scale instead of resetting`() = runTest { + stubOneReachableWatch() + every { musicRepository.getSongsByIds(listOf("song-1")) } returns flowOf(listOf(song("song-1"))) + val transcodedFile = mockk(relaxed = true) + coEvery { + watchAudioTranscoder.transcodeIfNeeded(any(), any(), any()) + } coAnswers { + thirdArg<(Float) -> Unit>().invoke(0.5f) + WatchAudioTranscoder.TranscodeResult.Transcoded(transcodedFile) + } + val requestIdSlot = slot() + every { + directTransferCoordinator.startTransferToWatch( + any(), capture(requestIdSlot), any(), any(), any(), any(), any(), + ) + } answers { + transferStateStore.markProgress( + requestId = requestIdSlot.captured, + songId = "song-1", + bytesTransferred = 50L, + totalBytes = 100L, + status = WearTransferProgress.STATUS_TRANSFERRING, + ) + } + + val batchId = coordinator.requestPlaylistTransfer( + "playlist-1", + "QA Transcode Test", + listOf("song-1"), + ) + + transferStateStore.batchTransfers.test { + var batch = awaitItem()[batchId] + // Transcode phase: 0.5 fraction scaled into the first 30% of the overall bar. + while (batch == null || batch.currentSongProgress < 0.1f) { + batch = awaitItem()[batchId] + } + assertThat(batch!!.currentSongProgress).isWithin(0.01f).of(0.15f) + + // Transfer phase must continue from there, not reset to 0, landing at + // 0.3 + 0.5 * 0.7 = 0.65. + while (batch!!.currentSongProgress < 0.5f) { + batch = awaitItem()[batchId] + assertThat(batch!!.currentSongProgress).isAtLeast(0.1f) + } + assertThat(batch.currentSongProgress).isWithin(0.01f).of(0.65f) + cancelAndIgnoreRemainingEvents() + } + } +} 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 000000000..b226aaf0f --- /dev/null +++ b/app/src/test/java/com/theveloper/pixelplay/data/service/wear/WatchAudioTranscoderTest.kt @@ -0,0 +1,73 @@ +package com.theveloper.pixelplay.data.service.wear + +import android.app.Application +import androidx.media3.common.util.UnstableApi +import com.google.common.truth.Truth.assertThat +import com.theveloper.pixelplay.data.model.Song +import io.mockk.mockk +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource + +@UnstableApi +class WatchAudioTranscoderTest { + + private val transcoder = WatchAudioTranscoder(mockk(relaxed = true)) + + private fun song(mimeType: String?, bitrate: Int?) = Song( + id = "song-1", + title = "Test Song", + artist = "Test Artist", + artistId = 1L, + album = "Test Album", + albumId = 1L, + path = "/sdcard/Music/test.file", + contentUriString = "content://media/external/audio/media/1", + albumArtUriString = null, + duration = 30_000L, + mimeType = mimeType, + bitrate = bitrate, + sampleRate = 44_100, + ) + + @ParameterizedTest(name = "{0} @ {1}bps requires transcoding") + @CsvSource( + "audio/flac, 0", + "audio/x-flac, 0", + "audio/wav, 0", + "audio/x-wav, 0", + "audio/mpeg, 320000", + "audio/aac, 320000", + ) + fun `lossless or over-budget lossy sources require transcoding`(mimeType: String, bitrate: Int) { + assertThat(transcoder.requiresTranscoding(song(mimeType, bitrate))).isTrue() + } + + @ParameterizedTest(name = "{0} @ {1}bps is passthrough") + @CsvSource( + "audio/mpeg, 128000", + "audio/mp4, 128000", + "audio/aac, 256000", + "audio/mp4a-latm, 192000", + "audio/ogg, 128000", + "audio/opus, 96000", + ) + fun `lossy sources at or under the target bitrate are passthrough`(mimeType: String, bitrate: Int) { + assertThat(transcoder.requiresTranscoding(song(mimeType, bitrate))).isFalse() + } + + @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(mimeType = "audio/mpeg", bitrate = null))).isTrue() + } + + @Test + fun `mimeType is matched case-insensitively`() { + assertThat(transcoder.requiresTranscoding(song(mimeType = "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 000000000..20bf034b6 --- /dev/null +++ b/app/src/test/java/com/theveloper/pixelplay/data/service/wear/WatchPlaylistTransferEstimatorTest.kt @@ -0,0 +1,87 @@ +package com.theveloper.pixelplay.data.service.wear + +import android.app.Application +import androidx.media3.common.util.UnstableApi +import com.google.common.truth.Truth.assertThat +import com.theveloper.pixelplay.data.model.Song +import io.mockk.mockk +import org.junit.jupiter.api.Test + +@UnstableApi +class WatchPlaylistTransferEstimatorTest { + + private val transcoder = WatchAudioTranscoder(mockk(relaxed = true)) + + private fun song( + id: String, + durationMs: Long, + mimeType: String?, + bitrate: Int?, + ) = Song( + id = id, + title = "Song $id", + artist = "Test Artist", + artistId = 1L, + album = "Test Album", + albumId = 1L, + path = "/sdcard/Music/$id.file", + contentUriString = "content://media/external/audio/media/$id", + albumArtUriString = null, + duration = durationMs, + mimeType = mimeType, + bitrate = bitrate, + sampleRate = 44_100, + ) + + @Test + fun `passthrough song is sized using its own bitrate`() { + val passthroughSong = song("1", durationMs = 60_000L, mimeType = "audio/mpeg", bitrate = 128_000) + + val bytes = WatchPlaylistTransferEstimator.estimateBytesForSong(passthroughSong, transcoder) + + assertThat(bytes).isEqualTo(60L * 128_000L / 8L) + } + + @Test + fun `transcoded song is sized using the target AAC bitrate, not its source bitrate`() { + val flacSong = song("1", durationMs = 60_000L, mimeType = "audio/flac", bitrate = 900_000) + + val bytes = WatchPlaylistTransferEstimator.estimateBytesForSong(flacSong, transcoder) + + assertThat(bytes).isEqualTo(60L * WatchAudioTranscoder.TARGET_BITRATE_BPS.toLong() / 8L) + } + + @Test + fun `estimate only sums pending songs, not the whole playlist`() { + val allSongs = listOf( + song("1", durationMs = 60_000L, mimeType = "audio/mpeg", bitrate = 128_000), + song("2", durationMs = 60_000L, mimeType = "audio/mpeg", bitrate = 128_000), + ) + val pendingSongs = listOf(allSongs[1]) + + val estimate = WatchPlaylistTransferEstimator.estimate(allSongs, pendingSongs, transcoder) + + assertThat(estimate.totalSongCount).isEqualTo(2) + assertThat(estimate.pendingSongCount).isEqualTo(1) + assertThat(estimate.estimatedBytes).isEqualTo(60L * 128_000L / 8L) + } + + @Test + fun `no pending songs means zero bytes and zero seconds`() { + val allSongs = listOf(song("1", durationMs = 60_000L, mimeType = "audio/mpeg", bitrate = 128_000)) + + val estimate = WatchPlaylistTransferEstimator.estimate(allSongs, emptyList(), transcoder) + + 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("1", durationMs = 1_000L, mimeType = "audio/mpeg", bitrate = 32_000) + + val estimate = WatchPlaylistTransferEstimator.estimate(listOf(tinySong), listOf(tinySong), transcoder) + + assertThat(estimate.estimatedTransferSeconds).isAtLeast(1L) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e653436ec..e12d2c196 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -225,6 +225,7 @@ androidx-benchmark-macro-junit4 = { group = "androidx.benchmark", name = "benchm androidx-profileinstaller = { group = "androidx.profileinstaller", name = "profileinstaller", version.ref = "profileinstaller" } androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" } androidx-test-core = { group = "androidx.test", name = "core", version.ref = "androidxTestCore" } +androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "androidxTestCore" } kotlin-test-junit = { group = "org.jetbrains.kotlin", name = "kotlin-test-junit", version.ref = "kotlin" } junit-jupiter-api = { group = "org.junit.jupiter", name = "junit-jupiter-api", version.ref = "junitJupiter" } junit-jupiter-engine = { group = "org.junit.jupiter", name = "junit-jupiter-engine", version.ref = "junitJupiter" } diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 233f33822..36c8e35ea 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 35fa7debe..486a9dfd8 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,10 @@ 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/updates a local playlist) */ + const val PLAYLIST_SYNC = "/playlist_sync" + + /** Message path for playlist sync acknowledgement (watch -> phone) */ + const val PLAYLIST_SYNC_ACK = "/playlist_sync_ack" } diff --git a/shared/src/main/java/com/theveloper/pixelplay/shared/WearLibraryState.kt b/shared/src/main/java/com/theveloper/pixelplay/shared/WearLibraryState.kt index 05a201d65..04f1efdea 100644 --- a/shared/src/main/java/com/theveloper/pixelplay/shared/WearLibraryState.kt +++ b/shared/src/main/java/com/theveloper/pixelplay/shared/WearLibraryState.kt @@ -8,4 +8,6 @@ import kotlinx.serialization.Serializable @Serializable data class WearLibraryState( val songIds: List = emptyList(), + /** Free space available on the watch's internal storage, used to warn before large transfers. */ + val freeStorageBytes: Long = 0L, ) 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 000000000..a966fbfdc --- /dev/null +++ b/shared/src/main/java/com/theveloper/pixelplay/shared/WearPlaylistSync.kt @@ -0,0 +1,17 @@ +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 been transferred. + */ +@Serializable +data class WearPlaylistSync( + val playlistId: String, + val name: String, + val songIds: List, +) 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 266155160..fc4c52991 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,21 @@ data class WearTransferProgress( val totalBytes: Long, val status: String, val error: String? = null, + val errorCode: String? = null, ) { companion object { + 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" + // Local-only to PhoneWatchTransferStateStore — a song the phone finished sending but + // hasn't yet heard the watch's own completion ack for. 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 000000000..efd323eb6 --- /dev/null +++ b/shared/src/test/java/com/theveloper/pixelplay/shared/WearPlaylistSyncTest.kt @@ -0,0 +1,80 @@ +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 + +/** + * Round-trip serialization tests for the contracts added by the playlist-to-watch + * transfer feature. Phone and watch each decode with `ignoreUnknownKeys = true`, + * mirroring how WearCommandReceiver/WearDataListenerService configure their Json + * instances in the app/wear modules. + */ +class WearPlaylistSyncTest { + + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun `WearPlaylistSync round-trips through JSON preserving song order`() { + val original = WearPlaylistSync( + playlistId = "playlist-42", + name = "QA Transcode Test", + songIds = listOf("song-flac", "song-wav", "song-mp3-320", "song-aac-128", "song-ogg"), + ) + + val decoded = json.decodeFromString(json.encodeToString(original)) + + assertThat(decoded).isEqualTo(original) + assertThat(decoded.songIds).containsExactlyElementsIn(original.songIds).inOrder() + } + + @Test + fun `WearPlaylistSync decodes an empty song list`() { + val original = WearPlaylistSync(playlistId = "empty-playlist", name = "Empty", songIds = emptyList()) + + val decoded = json.decodeFromString(json.encodeToString(original)) + + assertThat(decoded.songIds).isEmpty() + } + + @Test + fun `WearLibraryState round-trips freeStorageBytes`() { + val original = WearLibraryState( + songIds = listOf("song-1", "song-2"), + freeStorageBytes = 4_294_967_296L, + ) + + val decoded = json.decodeFromString(json.encodeToString(original)) + + assertThat(decoded).isEqualTo(original) + } + + @Test + fun `WearLibraryState defaults freeStorageBytes to 0 when absent from an older payload`() { + // Simulates a watch running an older build that never sent freeStorageBytes. + val legacyPayload = """{"songIds":["song-1","song-2"]}""" + + val decoded = json.decodeFromString(legacyPayload) + + assertThat(decoded.freeStorageBytes).isEqualTo(0L) + assertThat(decoded.songIds).containsExactly("song-1", "song-2") + } + + @Test + fun `WearTransferProgress serializes STATUS_TRANSCODING as its raw string value`() { + val original = WearTransferProgress( + requestId = "req-1", + songId = "song-flac", + bytesTransferred = 0L, + totalBytes = 0L, + status = WearTransferProgress.STATUS_TRANSCODING, + ) + + val encoded = json.encodeToString(original) + + assertThat(encoded).contains("\"transcoding\"") + assertThat(json.decodeFromString(encoded).status) + .isEqualTo(WearTransferProgress.STATUS_TRANSCODING) + } +} diff --git a/wear/build.gradle.kts b/wear/build.gradle.kts index 2edcc8ff6..a6edea5a2 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 { @@ -117,6 +124,28 @@ dependencies { implementation(libs.androidx.media3.session) implementation(libs.androidx.mediarouter) + // Testing (Unit) + testImplementation(libs.junit.jupiter.api) + testImplementation(libs.junit.jupiter.params) + testRuntimeOnly(libs.junit.jupiter.engine) + // JUnit 4 (Vintage) — required for legacy JUnit 4 tests under useJUnitPlatform() + testImplementation(libs.junit) + testRuntimeOnly(libs.junit.vintage.engine) + testRuntimeOnly(libs.junitplatformlauncher) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.mockk) + testImplementation(libs.turbine) + testImplementation(libs.truth) + testImplementation(kotlin("test")) + + // Testing (Instrumentation) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.room.testing) + androidTestImplementation(libs.androidx.test.core) + androidTestImplementation(libs.androidx.test.runner) + androidTestImplementation(libs.truth) + androidTestImplementation(libs.mockk) + constraints { // Fix vulnerabilities in transitive dependencies implementation(libs.netty.common) @@ -131,3 +160,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 000000000..28cc9041f --- /dev/null +++ b/wear/src/androidTest/java/com/theveloper/pixelplay/data/local/LocalPlaylistDaoTest.kt @@ -0,0 +1,85 @@ +package com.theveloper.pixelplay.data.local + +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.runBlocking +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Instrumented (needs a device/emulator — Room requires a real SQLite driver) coverage for the + * playlist snapshot DAO added for whole-playlist watch transfer. + */ +@RunWith(AndroidJUnit4::class) +class LocalPlaylistDaoTest { + + private lateinit var database: WearMusicDatabase + private lateinit var dao: LocalPlaylistDao + + @Before + fun createDatabase() { + database = Room.inMemoryDatabaseBuilder( + ApplicationProvider.getApplicationContext(), + WearMusicDatabase::class.java, + ).build() + dao = database.localPlaylistDao() + } + + @After + fun closeDatabase() { + database.close() + } + + private fun crossRefs(playlistId: String, songIds: List) = + songIds.mapIndexed { index, songId -> LocalPlaylistSongCrossRef(playlistId, songId, index) } + + @Test + fun upsertPlaylist_isVisibleViaObservePlaylists(): Unit = runBlocking { + val playlist = LocalPlaylistEntity("p1", "Road Trip", createdAt = 1L, updatedAt = 1L) + + dao.upsertPlaylist(playlist, crossRefs("p1", listOf("s1", "s2"))) + + val playlists = dao.observePlaylists().first() + assertThat(playlists).containsExactly(playlist) + } + + @Test + fun observePlaylistSongs_isOrderedByPosition() = runBlocking { + val playlist = LocalPlaylistEntity("p1", "Road Trip", createdAt = 1L, updatedAt = 1L) + dao.upsertPlaylist(playlist, crossRefs("p1", listOf("s3", "s1", "s2"))) + + val songs = dao.observePlaylistSongs("p1").first() + + assertThat(songs.map { it.songId }).containsExactly("s3", "s1", "s2").inOrder() + } + + @Test + fun upsertPlaylist_replacesPreviousCrossRefsInsteadOfMerging() = runBlocking { + val playlist = LocalPlaylistEntity("p1", "Road Trip", createdAt = 1L, updatedAt = 1L) + dao.upsertPlaylist(playlist, crossRefs("p1", listOf("s1", "s2"))) + + // Re-sync with one fewer song and a new one, as if the phone playlist changed. + dao.upsertPlaylist(playlist, crossRefs("p1", listOf("s2", "s3"))) + + val songs = dao.observePlaylistSongs("p1").first() + assertThat(songs.map { it.songId }).containsExactly("s2", "s3").inOrder() + } + + @Test + fun upsertPlaylist_doesNotAffectOtherPlaylists(): Unit = runBlocking { + val playlistA = LocalPlaylistEntity("p1", "Road Trip", createdAt = 1L, updatedAt = 1L) + val playlistB = LocalPlaylistEntity("p2", "Gym", createdAt = 2L, updatedAt = 2L) + dao.upsertPlaylist(playlistA, crossRefs("p1", listOf("s1"))) + dao.upsertPlaylist(playlistB, crossRefs("p2", listOf("s2"))) + + dao.upsertPlaylist(playlistA, crossRefs("p1", listOf("s1", "s3"))) + + assertThat(dao.observePlaylistSongs("p2").first().map { it.songId }).containsExactly("s2") + assertThat(dao.observePlaylists().first()).containsExactly(playlistA, playlistB) + } +} diff --git a/wear/src/main/AndroidManifest.xml b/wear/src/main/AndroidManifest.xml index 4a049d1d6..02ee7cbd0 100644 --- a/wear/src/main/AndroidManifest.xml +++ b/wear/src/main/AndroidManifest.xml @@ -10,6 +10,7 @@ mediaPlayback foreground service (WearPlaybackService). --> + @@ -67,6 +68,13 @@ + + + + diff --git a/wear/src/main/java/com/theveloper/pixelplay/data/WearDataListenerService.kt b/wear/src/main/java/com/theveloper/pixelplay/data/WearDataListenerService.kt index 16c99756a..ab9865a3c 100644 --- a/wear/src/main/java/com/theveloper/pixelplay/data/WearDataListenerService.kt +++ b/wear/src/main/java/com/theveloper/pixelplay/data/WearDataListenerService.kt @@ -17,6 +17,7 @@ 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 @@ -234,6 +235,7 @@ class WearDataListenerService : WearableListenerService() { } WearDataPaths.TRANSFER_METADATA -> { + WearTransferForegroundService.start(applicationContext) scope.launch { try { val metadataJson = String(messageEvent.data, Charsets.UTF_8) @@ -314,6 +316,23 @@ 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) + Timber.tag(TAG).d( + "Received playlist sync: %s (%d songs)", + sync.name, + sync.songIds.size, + ) + } catch (e: Exception) { + Timber.tag(TAG).e(e, "Failed to process playlist sync") + } + } + } + WearDataPaths.WATCH_LIBRARY_QUERY -> { scope.launch { transferRepository.publishLibraryState(targetNodeId = messageEvent.sourceNodeId) @@ -348,6 +367,7 @@ class WearDataListenerService : WearableListenerService() { when (channel.path) { WearDataPaths.TRANSFER_CHANNEL -> { Timber.tag(TAG).d("Audio transfer channel opened") + WearTransferForegroundService.start(applicationContext) transferRepository.receiveAudioChannel(channel) } diff --git a/wear/src/main/java/com/theveloper/pixelplay/data/WearDeviceTier.kt b/wear/src/main/java/com/theveloper/pixelplay/data/WearDeviceTier.kt new file mode 100644 index 000000000..6f85b529a --- /dev/null +++ b/wear/src/main/java/com/theveloper/pixelplay/data/WearDeviceTier.kt @@ -0,0 +1,20 @@ +package com.theveloper.pixelplay.data + +/** + * Coarse "how much CPU headroom does this watch have" signal, computed once per process. + * + * Wear OS SoC core count tracks chip generation closely: entry/mid chips (e.g. the dual-core + * Exynos W920/W930) ship with 2 Cortex-A55 cores, while newer/higher-tier chips (Snapdragon W5+, + * Exynos W1000) ship with 4. On-device profiling on a 2-core watch showed a continuous per-frame + * Compose animation alone was enough to saturate the main thread and starve ExoPlayer's + * decode/render threads of the CPU they needed, causing audible playback stutter. Non-essential + * continuous UI work should check this before running unconditionally. + */ +object WearDeviceTier { + private const val CAPABLE_CORE_THRESHOLD = 4 + + /** True when the device has enough CPU headroom to afford non-essential continuous UI work. */ + val isCapable: Boolean by lazy { + Runtime.getRuntime().availableProcessors() >= CAPABLE_CORE_THRESHOLD + } +} 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 70ae97e6a..30ba718e5 100644 --- a/wear/src/main/java/com/theveloper/pixelplay/data/WearLocalPlayerRepository.kt +++ b/wear/src/main/java/com/theveloper/pixelplay/data/WearLocalPlayerRepository.kt @@ -12,6 +12,7 @@ import androidx.core.content.ContextCompat import androidx.media3.common.C import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata +import androidx.media3.common.PlaybackException import androidx.media3.common.Player import androidx.media3.session.MediaController import androidx.media3.session.SessionToken @@ -104,6 +105,10 @@ class WearLocalPlayerRepository @Inject constructor( private val _localQueueState = MutableStateFlow(WearLocalQueueState()) val localQueueState: StateFlow = _localQueueState.asStateFlow() + private val _localPlaybackError = MutableStateFlow(null) + /** Transient message describing the last playback error, for the UI to show as a snackbar. */ + val localPlaybackError: StateFlow = _localPlaybackError.asStateFlow() + private var positionUpdateJob: Job? = null private var currentQueueSongIds: List = emptyList() private var currentQueueSongsById: Map = emptyMap() @@ -130,7 +135,9 @@ class WearLocalPlayerRepository @Inject constructor( } } - if (!localPlayerState.value.isEmpty) { + // Same rationale as startPositionUpdates(): skip the repaint pipeline while + // nobody can see it. The in-memory song map above is kept fresh either way. + if (!localPlayerState.value.isEmpty && WearLifecycleState.isInteractiveNow) { updateState() } } @@ -153,6 +160,31 @@ class WearLocalPlayerRepository @Inject constructor( override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { updateState() } + + override fun onPlayerError(error: PlaybackException) { + val player = mediaController + val failedItem = player?.currentMediaItem + val trackTitle = failedItem?.mediaMetadata?.title?.toString() + ?: currentQueueSongsById[failedItem?.mediaId]?.title + ?: "Unknown track" + Timber.tag(TAG).e(error, "Local playback error for songId=%s", failedItem?.mediaId) + _localPlaybackError.value = "Skipped $trackTitle (${error.localizedMessage ?: error.message ?: "playback error"})" + + // ExoPlayer drops to STATE_IDLE on error, so a plain seekToNext() wouldn't resume + // playback on its own — re-prepare and play to actually skip forward. + if (player != null && player.hasNextMediaItem()) { + player.seekToNext() + player.prepare() + player.play() + } else { + stopPositionUpdates() + } + } + } + + /** Called by the UI after it has shown [localPlaybackError], to clear the transient message. */ + fun consumeLocalPlaybackError() { + _localPlaybackError.value = null } /** @@ -324,6 +356,7 @@ class WearLocalPlayerRepository @Inject constructor( _localThemePalette.value = null _localPaletteSeedArgb.value = null _localAlbumArt.value = null + _localPlaybackError.value = null val mediaItems = queueSongs.map { song -> MediaItem.Builder() @@ -492,6 +525,7 @@ class WearLocalPlayerRepository @Inject constructor( _localPaletteSeedArgb.value = null _localAlbumArt.value = null _localQueueState.value = WearLocalQueueState() + _localPlaybackError.value = null currentQueueSongIds = emptyList() currentQueueSongsById = emptyMap() currentQueueItemsById = emptyMap() 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 b273ddf6b..e779b55d7 100644 --- a/wear/src/main/java/com/theveloper/pixelplay/data/WearPlaybackService.kt +++ b/wear/src/main/java/com/theveloper/pixelplay/data/WearPlaybackService.kt @@ -1,11 +1,17 @@ package com.theveloper.pixelplay.data import android.app.PendingIntent +import android.content.Context import android.content.Intent import androidx.media3.common.AudioAttributes import androidx.media3.common.C import androidx.media3.common.MediaItem +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.DefaultRenderersFactory import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.audio.AudioSink +import androidx.media3.exoplayer.audio.DefaultAudioSink +import androidx.media3.exoplayer.audio.DefaultAudioTrackBufferSizeProvider import androidx.media3.session.MediaSession import androidx.media3.session.MediaSessionService import com.google.common.util.concurrent.Futures @@ -24,6 +30,7 @@ import timber.log.Timber * * [WearLocalPlayerRepository] drives this service's player through a `MediaController`. */ +@UnstableApi class WearPlaybackService : MediaSessionService() { private var player: ExoPlayer? = null @@ -32,7 +39,7 @@ class WearPlaybackService : MediaSessionService() { override fun onCreate() { super.onCreate() - val exoPlayer = ExoPlayer.Builder(this) + val exoPlayer = ExoPlayer.Builder(this, BufferedAudioRenderersFactory(this)) .setAudioAttributes( AudioAttributes.Builder() .setUsage(C.USAGE_MEDIA) @@ -111,8 +118,36 @@ class WearPlaybackService : MediaSessionService() { } } + /** + * Local playback here is a single background audio track with no latency requirements, so + * trade a larger [AudioSink] buffer for resilience: this watch's SoC can have as few as 2 + * cores, and a momentary CPU stall (our own UI work, or a concurrent fitness-tracking app in + * the future) is enough to starve the platform default's small low-latency buffer and produce + * an audible underrun. See [WearDeviceTier]. + */ + private class BufferedAudioRenderersFactory(context: Context) : DefaultRenderersFactory(context) { + override fun buildAudioSink( + context: Context, + enableFloatOutput: Boolean, + enableAudioTrackPlaybackParams: Boolean, + ): AudioSink { + return DefaultAudioSink.Builder(context) + .setEnableFloatOutput(enableFloatOutput) + .setEnableAudioTrackPlaybackParams(enableAudioTrackPlaybackParams) + .setAudioTrackBufferSizeProvider( + DefaultAudioTrackBufferSizeProvider.Builder() + .setMinPcmBufferDurationUs(MIN_PCM_BUFFER_DURATION_US) + .setMaxPcmBufferDurationUs(MAX_PCM_BUFFER_DURATION_US) + .build() + ) + .build() + } + } + companion object { private const val TAG = "WearPlaybackService" private const val MEDIA_SESSION_ID = "wear-local-playback" + private const val MIN_PCM_BUFFER_DURATION_US = 1_000_000 // 1s + private const val MAX_PCM_BUFFER_DURATION_US = 3_000_000 // 3s } } diff --git a/wear/src/main/java/com/theveloper/pixelplay/data/WearTransferForegroundService.kt b/wear/src/main/java/com/theveloper/pixelplay/data/WearTransferForegroundService.kt new file mode 100644 index 000000000..4e434b9f8 --- /dev/null +++ b/wear/src/main/java/com/theveloper/pixelplay/data/WearTransferForegroundService.kt @@ -0,0 +1,223 @@ +package com.theveloper.pixelplay.data + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.os.Build +import android.os.IBinder +import android.os.PowerManager +import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat +import com.theveloper.pixelplay.R +import com.theveloper.pixelplay.presentation.WearMainActivity +import com.theveloper.pixelplay.shared.WearTransferProgress +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import timber.log.Timber + +/** + * Keeps the receiving side alive while a transfer from the phone is in flight. Wear OS is more + * aggressive than phone Android about suspending background processes on screen-off/ambient — + * without this, a long playlist transfer left running overnight (screen off, watch charging + * flat) risks the process being paused mid-transfer with nothing to resume it, since receiving + * is purely reactive (driven by [WearDataListenerService] callbacks, not user-initiated). + */ +@AndroidEntryPoint +class WearTransferForegroundService : Service() { + + @Inject lateinit var transferRepository: WearTransferRepository + + private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + private var transferObserverJob: Job? = null + private var hasStartedForeground = false + private var wakeLock: PowerManager.WakeLock? = null + + override fun onCreate() { + super.onCreate() + ensureNotificationChannel() + observeTransfers() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + val notification = buildNotification(transferRepository.activeTransfers.value.values.toList()) + if (!hasStartedForeground) { + startInForeground(notification) + } else { + notificationManager().notify(NOTIFICATION_ID, notification) + } + return START_NOT_STICKY + } + + override fun onDestroy() { + transferObserverJob?.cancel() + serviceScope.cancel() + releaseWakeLock() + super.onDestroy() + } + + override fun onBind(intent: Intent?): IBinder? = null + + private fun observeTransfers() { + transferObserverJob?.cancel() + transferObserverJob = serviceScope.launch { + transferRepository.activeTransfers.collect { transfers -> + val states = transfers.values.toList() + if (states.isEmpty()) { + // onCreate() starts collecting before onStartCommand() has had a chance to + // run, so this StateFlow's current (often still-empty) value can arrive + // before there's any real work yet. Only actually stop once we've genuinely + // started foreground at least once — otherwise stopSelf() here races ahead + // of the pending startForeground() call and crashes the whole process with + // ForegroundServiceDidNotStartInTimeException. + if (hasStartedForeground) { + releaseWakeLock() + stopForegroundCompat() + stopSelf() + } + return@collect + } + + acquireWakeLock() + val notification = buildNotification(states) + if (!hasStartedForeground) { + startInForeground(notification) + } else { + notificationManager().notify(NOTIFICATION_ID, notification) + } + } + } + } + + private fun acquireWakeLock() { + if (wakeLock?.isHeld == true) return + val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager + wakeLock = powerManager.newWakeLock( + PowerManager.PARTIAL_WAKE_LOCK, + "PixelPlay:watchTransferReceive", + ).apply { + setReferenceCounted(false) + acquire(MAX_WAKE_LOCK_DURATION_MS) + } + } + + private fun releaseWakeLock() { + wakeLock?.let { if (it.isHeld) it.release() } + wakeLock = null + } + + private fun startInForeground(notification: Notification) { + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startForeground( + NOTIFICATION_ID, + notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC, + ) + } else { + startForeground(NOTIFICATION_ID, notification) + } + hasStartedForeground = true + } catch (error: Exception) { + Timber.tag(TAG).e(error, "Failed to start wear transfer foreground service") + stopSelf() + } + } + + private fun stopForegroundCompat() { + if (!hasStartedForeground) return + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + stopForeground(STOP_FOREGROUND_REMOVE) + } else { + @Suppress("DEPRECATION") + stopForeground(true) + } + hasStartedForeground = false + } + + private fun buildNotification(transfers: List): Notification { + val activeTransfer = transfers + .filter { it.status == WearTransferProgress.STATUS_TRANSFERRING } + .maxByOrNull { it.bytesTransferred } + ?: transfers.firstOrNull() + + val contentText = when { + activeTransfer == null -> getString(R.string.wear_transfer_notification_starting) + activeTransfer.songTitle.isNotBlank() -> activeTransfer.songTitle + else -> getString(R.string.wear_transfer_notification_starting) + } + val progressPercent = (activeTransfer?.progress?.times(100f) ?: 0f).toInt().coerceIn(0, 100) + val isIndeterminate = activeTransfer == null || activeTransfer.totalBytes <= 0L + + return NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID) + .setSmallIcon(R.drawable.new_monochrome) + .setContentTitle(getString(R.string.wear_transfer_notification_title)) + .setContentText(contentText) + .setContentIntent(createOpenAppPendingIntent()) + .setCategory(NotificationCompat.CATEGORY_SERVICE) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setOnlyAlertOnce(true) + .setSilent(true) + .setOngoing(true) + .setShowWhen(false) + .setProgress(100, progressPercent, isIndeterminate) + .build() + } + + private fun createOpenAppPendingIntent(): PendingIntent { + val intent = Intent(this, WearMainActivity::class.java).apply { + setPackage(packageName) + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP + } + return PendingIntent.getActivity( + this, + 0, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + } + + private fun ensureNotificationChannel() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + val channel = NotificationChannel( + NOTIFICATION_CHANNEL_ID, + getString(R.string.wear_transfer_notification_title), + NotificationManager.IMPORTANCE_LOW, + ).apply { + setShowBadge(false) + } + notificationManager().createNotificationChannel(channel) + } + + private fun notificationManager(): NotificationManager { + return getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + } + + companion object { + private const val TAG = "WearTransferFgSvc" + private const val NOTIFICATION_CHANNEL_ID = "pixelplay_wear_transfers" + private const val NOTIFICATION_ID = 2003 + // Safety net in case release() is ever skipped by a bug: auto-releases well past any + // legitimately slow whole-playlist transfer instead of draining battery indefinitely. + private const val MAX_WAKE_LOCK_DURATION_MS = 6 * 60 * 60 * 1000L + + fun start(context: Context) { + val intent = Intent(context, WearTransferForegroundService::class.java) + runCatching { + ContextCompat.startForegroundService(context, intent) + }.onFailure { error -> + Timber.tag(TAG).w(error, "Failed to start wear transfer foreground service") + } + } + } +} diff --git a/wear/src/main/java/com/theveloper/pixelplay/data/WearTransferRepository.kt b/wear/src/main/java/com/theveloper/pixelplay/data/WearTransferRepository.kt index dbe804084..efbbcf50a 100644 --- a/wear/src/main/java/com/theveloper/pixelplay/data/WearTransferRepository.kt +++ b/wear/src/main/java/com/theveloper/pixelplay/data/WearTransferRepository.kt @@ -1,15 +1,20 @@ package com.theveloper.pixelplay.data import android.app.Application +import android.os.StatFs import android.os.SystemClock import android.webkit.MimeTypeMap import com.google.android.gms.wearable.ChannelClient import com.google.android.gms.wearable.MessageClient import com.google.android.gms.wearable.NodeClient +import com.theveloper.pixelplay.data.local.LocalPlaylistDao +import com.theveloper.pixelplay.data.local.LocalPlaylistEntity +import com.theveloper.pixelplay.data.local.LocalPlaylistSongCrossRef import com.theveloper.pixelplay.data.local.LocalSongDao import com.theveloper.pixelplay.data.local.LocalSongEntity import com.theveloper.pixelplay.shared.WearDataPaths import com.theveloper.pixelplay.shared.WearLibraryState +import com.theveloper.pixelplay.shared.WearPlaylistSync import com.theveloper.pixelplay.shared.WearTransferMetadata import com.theveloper.pixelplay.shared.WearTransferProgress import com.theveloper.pixelplay.shared.WearTransferRequest @@ -70,6 +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, @@ -122,6 +128,8 @@ class WearTransferRepository @Inject constructor( private val transferWatchdogs = ConcurrentHashMap() /** Request IDs currently receiving bytes through ChannelClient. */ private val activeChannelRequestIds = ConcurrentHashMap.newKeySet() + /** Node that originated each in-flight requestId, so the final ack can be routed back to it. */ + private val sourceNodeIdByRequestId = ConcurrentHashMap() /** Cancelled request IDs retained briefly so late metadata/progress/channel events are ignored safely. */ private val cancelledRequestIds = ConcurrentHashMap.newKeySet() @@ -133,6 +141,7 @@ class WearTransferRepository @Inject constructor( private const val METADATA_POLL_INTERVAL_MS = 120L private const val WATCHDOG_TOUCH_INTERVAL_MS = 1_500L private const val LOCAL_PROGRESS_UPDATE_INTERVAL_BYTES = 65_536L + private const val STORAGE_SAFETY_MARGIN_BYTES = 5 * 1024 * 1024L private const val CANCELLED_REQUEST_RETENTION_MS = 300_000L } @@ -175,10 +184,11 @@ class WearTransferRepository @Inject constructor( scope.launch { val existingSong = localSongDao.getSongById(songId) if (existingSong?.hasPlayableLocalFile() == true) { - notifyPhoneTransferFailure( + notifyPhoneTransferResult( targetNodeId = targetNodeId, requestId = requestId, songId = songId, + status = WearTransferProgress.STATUS_FAILED, message = WearTransferProgress.ERROR_ALREADY_ON_WATCH, ) return@launch @@ -276,16 +286,24 @@ class WearTransferRepository @Inject constructor( ) } - private suspend fun notifyPhoneTransferFailure( + /** + * Reports the real, watch-confirmed outcome of a transfer back to the phone, so the phone's + * belief about "what's on the watch" (PhoneWatchTransferStateStore) reflects what the watch + * actually validated — not just what the phone thinks it finished sending. + */ + private suspend fun notifyPhoneTransferResult( targetNodeId: String?, requestId: String, songId: String, - message: String, + status: String, + message: String? = null, + errorCode: String? = null, ) { Timber.tag(TAG).d( - "Rejecting transfer requestId=%s songId=%s: %s", + "Reporting transfer result requestId=%s songId=%s status=%s: %s", requestId, songId, + status, message, ) if (targetNodeId == null) return @@ -296,8 +314,9 @@ class WearTransferRepository @Inject constructor( songId = songId, bytesTransferred = 0L, totalBytes = 0L, - status = WearTransferProgress.STATUS_FAILED, + status = status, error = message, + errorCode = errorCode, ) messageClient.sendMessage( targetNodeId, @@ -305,7 +324,7 @@ class WearTransferRepository @Inject constructor( json.encodeToString(progress).toByteArray(Charsets.UTF_8), ).await() }.onFailure { error -> - Timber.tag(TAG).w(error, "Failed to report transfer failure to phone") + Timber.tag(TAG).w(error, "Failed to report transfer result to phone") } } @@ -316,6 +335,7 @@ class WearTransferRepository @Inject constructor( metadata: WearTransferMetadata, sourceNodeId: String? = null, ) { + sourceNodeId?.let { sourceNodeIdByRequestId[metadata.requestId] = it } if (isTransferCancelled(metadata.requestId)) { cleanupCancelledTransfer(metadata.requestId, metadata.songId) return @@ -331,12 +351,8 @@ class WearTransferRepository @Inject constructor( metadata.transferMode == WearTransferRequest.MODE_SAVE_TO_LIBRARY && existingSong?.hasPlayableLocalFile() == true ) { - notifyPhoneTransferFailure( - targetNodeId = sourceNodeId, - requestId = metadata.requestId, - songId = metadata.songId, - message = WearTransferProgress.ERROR_ALREADY_ON_WATCH, - ) + // handleTransferError below reports this back to the phone automatically via + // sourceNodeIdByRequestId (populated at the top of this function). handleTransferError( requestId = metadata.requestId, songId = metadata.songId, @@ -423,6 +439,7 @@ class WearTransferRepository @Inject constructor( channelClient.getInputStream(channel).await().use { inputStream -> val requestId = readLengthPrefixedString(inputStream, "requestId") Timber.tag(TAG).d("Audio transfer channel: requestId=%s", requestId) + sourceNodeIdByRequestId.putIfAbsent(requestId, channel.nodeId) onAudioChannelOpened(requestId, inputStream) } }.onFailure { error -> @@ -517,6 +534,7 @@ class WearTransferRepository @Inject constructor( var lastProgressUpdateAtBytes = 0L var lastWatchdogTouchAt = SystemClock.elapsedRealtime() var cancelledDuringStream = false + var storageExhausted = false armTransferWatchdog( requestId = requestId, songId = metadata?.songId ?: _activeTransfers.value[requestId]?.songId.orEmpty(), @@ -562,6 +580,16 @@ class WearTransferRepository @Inject constructor( val now = SystemClock.elapsedRealtime() if (now - lastWatchdogTouchAt >= WATCHDOG_TOUCH_INTERVAL_MS) { + val expectedTotal = metadata?.fileSize ?: 0L + val remainingNeeded = expectedTotal - totalReceived + if ( + expectedTotal > 0L && + remainingNeeded > 0L && + availableStorageBytes() < remainingNeeded + STORAGE_SAFETY_MARGIN_BYTES + ) { + storageExhausted = true + break + } armTransferWatchdog( requestId = requestId, songId = metadata?.songId ?: _activeTransfers.value[requestId]?.songId.orEmpty(), @@ -572,6 +600,17 @@ class WearTransferRepository @Inject constructor( } inputStream.close() + if (storageExhausted) { + tempFile.delete() + handleTransferError( + requestId = requestId, + songId = metadata?.songId ?: _activeTransfers.value[requestId]?.songId.orEmpty(), + message = "Not enough storage on watch", + errorCode = WearTransferProgress.ERROR_CODE_INSUFFICIENT_STORAGE, + ) + return + } + if (cancelledDuringStream || isTransferCancelled(requestId)) { if (tempFile.exists() && !tempFile.delete()) { Timber.tag(TAG).w( @@ -763,6 +802,16 @@ class WearTransferRepository @Inject constructor( Timber.tag(TAG).d( "Transfer complete: ${resolvedMetadata.title} ($actualSize bytes) → ${localFile.absolutePath}" ) + sourceNodeIdByRequestId.remove(requestId)?.let { nodeId -> + scope.launch { + notifyPhoneTransferResult( + targetNodeId = nodeId, + requestId = requestId, + songId = resolvedMetadata.songId, + status = WearTransferProgress.STATUS_COMPLETED, + ) + } + } } catch (e: Exception) { Timber.tag(TAG).e(e, "Failed to write transferred file") tempFile.delete() @@ -841,7 +890,10 @@ class WearTransferRepository @Inject constructor( ) { val snapshotSongIds = songIds ?: localSongDao.getAllSongIds().first().toSet() val payload = json.encodeToString( - WearLibraryState(songIds = snapshotSongIds.sorted()) + WearLibraryState( + songIds = snapshotSongIds.sorted(), + freeStorageBytes = availableStorageBytes(), + ) ).toByteArray(Charsets.UTF_8) runCatching { @@ -866,6 +918,42 @@ class WearTransferRepository @Inject constructor( } } + /** + * Creates/updates a playlist snapshot from a phone sync. This runs independently of song + * transfers — a playlist can exist (and be browsed) before any, or all, of its songs have + * finished downloading; availability is resolved by joining against [localSongDao] at read + * time. Re-syncing the same [playlistId] (e.g. after "update") replaces the full song order, + * it doesn't merge with the previous snapshot. + */ + suspend fun onPlaylistSyncReceived(sync: WearPlaylistSync) { + val now = System.currentTimeMillis() + val existing = localPlaylistDao.getPlaylistById(sync.playlistId) + val entity = LocalPlaylistEntity( + playlistId = sync.playlistId, + name = sync.name, + createdAt = existing?.createdAt ?: now, + updatedAt = now, + ) + val songCrossRefs = sync.songIds.mapIndexed { index, songId -> + LocalPlaylistSongCrossRef(playlistId = sync.playlistId, songId = songId, position = index) + } + localPlaylistDao.upsertPlaylist(entity, songCrossRefs) + Timber.tag(TAG).d( + "Playlist synced: %s (%d songs)", + sync.name, + sync.songIds.size, + ) + } + + /** Free space on the same volume where transferred songs are written (see [application.filesDir]). */ + private fun availableStorageBytes(): Long { + return runCatching { StatFs(application.filesDir.path).availableBytes } + .getOrElse { error -> + Timber.tag(TAG).w(error, "Failed to read available storage") + 0L + } + } + /** * Called when artwork bytes arrive over the dedicated artwork channel. * If song row exists, artwork is persisted immediately; otherwise cached until audio finishes. @@ -941,9 +1029,15 @@ class WearTransferRepository @Inject constructor( pendingArtworkByRequestId.remove(requestId) clearTransferWatchdog(requestId) activeChannelRequestIds.remove(requestId) + sourceNodeIdByRequestId.remove(requestId) } - private fun handleTransferError(requestId: String, songId: String, message: String) { + private fun handleTransferError( + requestId: String, + songId: String, + message: String, + errorCode: String? = null, + ) { Timber.tag(TAG).e("Transfer error: $message (requestId=$requestId, songId=$songId)") _activeTransfers.update { map -> val current = map[requestId] @@ -962,6 +1056,18 @@ class WearTransferRepository @Inject constructor( pendingArtworkByRequestId.remove(requestId) clearTransferWatchdog(requestId) activeChannelRequestIds.remove(requestId) + sourceNodeIdByRequestId.remove(requestId)?.let { nodeId -> + scope.launch { + notifyPhoneTransferResult( + targetNodeId = nodeId, + requestId = requestId, + songId = songId, + status = WearTransferProgress.STATUS_FAILED, + message = message, + errorCode = errorCode, + ) + } + } } private fun resolveTemporaryPlaybackStartPosition( @@ -1044,7 +1150,12 @@ class WearTransferRepository @Inject constructor( transferWatchdogs[requestId] = scope.launch { delay(TRANSFER_IDLE_TIMEOUT_MS) if (_activeTransfers.value.containsKey(requestId)) { - handleTransferError(requestId, songId, "Transfer timed out") + handleTransferError( + requestId, + songId, + "Transfer timed out", + errorCode = WearTransferProgress.ERROR_CODE_CONNECTION_LOST, + ) } } } 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 000000000..3f41b48ab --- /dev/null +++ b/wear/src/main/java/com/theveloper/pixelplay/data/local/LocalPlaylistDao.kt @@ -0,0 +1,49 @@ +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 its playlist(s). */ + @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 000000000..a844424b8 --- /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 for a playlist snapshot synced from the phone. Song availability is resolved by + * joining [LocalPlaylistSongCrossRef] with [LocalSongDao] at read time, not stored here — a + * playlist can exist before all (or any) of its songs have finished transferring. + */ +@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 000000000..6083b07f5 --- /dev/null +++ b/wear/src/main/java/com/theveloper/pixelplay/data/local/LocalPlaylistSongCrossRef.kt @@ -0,0 +1,14 @@ +package com.theveloper.pixelplay.data.local + +import androidx.room.Entity + +/** + * Membership + order of a song within a local playlist snapshot. [songId] may not (yet) have a + * matching [LocalSongEntity] row — that's how a pending/not-yet-transferred song is represented. + */ +@Entity(tableName = "local_playlist_songs", primaryKeys = ["playlistId", "songId"]) +data class LocalPlaylistSongCrossRef( + val playlistId: String, + val songId: String, + val position: Int, +) 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 25c99e278..032a06b72 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 = 6, + 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,24 @@ 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))" + ) + } + } } } 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 4edfd651c..105c6fe86 100644 --- a/wear/src/main/java/com/theveloper/pixelplay/di/WearModule.kt +++ b/wear/src/main/java/com/theveloper/pixelplay/di/WearModule.kt @@ -7,6 +7,7 @@ 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 dagger.Module @@ -46,10 +47,21 @@ object WearModule { application, WearMusicDatabase::class.java, "wear_music.db" + ).addMigrations( + WearMusicDatabase.MIGRATION_1_2, + WearMusicDatabase.MIGRATION_2_3, + WearMusicDatabase.MIGRATION_3_4, + WearMusicDatabase.MIGRATION_4_5, + WearMusicDatabase.MIGRATION_5_6, ).build() @Provides @Singleton fun provideLocalSongDao(database: WearMusicDatabase): LocalSongDao = database.localSongDao() + + @Provides + @Singleton + fun provideLocalPlaylistDao(database: WearMusicDatabase): LocalPlaylistDao = + database.localPlaylistDao() } diff --git a/wear/src/main/java/com/theveloper/pixelplay/presentation/WearNavigation.kt b/wear/src/main/java/com/theveloper/pixelplay/presentation/WearNavigation.kt index a0f7e3dc5..471d20474 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}/{name}" 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, name: String): String { + return "local_playlist_detail/$playlistId/${URLEncoder.encode(name, "UTF-8")}" + } } @Composable @@ -138,7 +146,38 @@ fun WearNavigation() { } composable(WearScreens.DOWNLOADS) { - DownloadsScreen() + DownloadsScreen( + onPlaylistsClick = { + navController.navigate(WearScreens.LOCAL_PLAYLISTS) { + launchSingleTop = true + } + }, + ) + } + + composable(WearScreens.LOCAL_PLAYLISTS) { + LocalPlaylistsScreen( + onPlaylistClick = { playlistId, name -> + navController.navigate(WearScreens.localPlaylistDetailRoute(playlistId, name)) + }, + ) + } + + composable( + route = WearScreens.LOCAL_PLAYLIST_DETAIL, + arguments = listOf( + navArgument("playlistId") { type = NavType.StringType }, + navArgument("name") { type = NavType.StringType }, + ), + ) { backStackEntry -> + val playlistId = backStackEntry.arguments?.getString("playlistId") ?: "" + val name = URLDecoder.decode( + backStackEntry.arguments?.getString("name") ?: "", "UTF-8" + ) + LocalPlaylistDetailScreen( + playlistId = playlistId, + playlistName = name, + ) } 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 e43e81b86..8c49ae874 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 @@ -1,8 +1,6 @@ package com.theveloper.pixelplay.presentation.components -import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.FastOutSlowInEasing -import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.Canvas @@ -10,17 +8,27 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier 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 com.theveloper.pixelplay.data.WearDeviceTier import com.theveloper.pixelplay.data.WearLifecycleState +import kotlinx.coroutines.delay import kotlinx.coroutines.isActive -import kotlin.math.PI import kotlin.math.sin +/** + * Small "now playing" indicator: a few bars that move with a cheap, low-frequency discrete tick + * on devices with CPU headroom to spare ([WearDeviceTier.isCapable]), and a static shape + * everywhere else. A prior version drove two continuous, per-display-frame `Animatable` tweens + * here; on a 2-core Wear SoC that alone was enough main-thread load to starve ExoPlayer's + * decode/render threads and cause audible playback stutter (confirmed via on-device profiling). + */ @Composable fun PlayingEqIcon( modifier: Modifier = Modifier, @@ -29,45 +37,22 @@ fun PlayingEqIcon( bars: Int = 3, minHeightFraction: Float = 0.28f, maxHeightFraction: Float = 1.0f, - phaseDurationMillis: Int = 2400, - wanderDurationMillis: Int = 8000, gapFraction: Float = 0.30f, ) { - 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 animate = isPlaying && isInteractive + val animate = isPlaying && isInteractive && WearDeviceTier.isCapable - LaunchedEffect(animate, phaseDurationMillis) { + var tick by remember { mutableFloatStateOf(0f) } + LaunchedEffect(animate) { if (!animate) return@LaunchedEffect while (isActive) { - val start = (phaseAnim.value % fullRotation).let { if (it < 0f) it + fullRotation else it } - phaseAnim.snapTo(start) - phaseAnim.animateTo( - targetValue = start + fullRotation, - animationSpec = tween(durationMillis = phaseDurationMillis, easing = LinearEasing), - ) - } - } - - LaunchedEffect(animate, wanderDurationMillis) { - if (!animate) return@LaunchedEffect - while (isActive) { - val start = (wanderAnim.value % fullRotation).let { if (it < 0f) it + fullRotation else it } - wanderAnim.snapTo(start) - wanderAnim.animateTo( - targetValue = start + fullRotation, - animationSpec = tween(durationMillis = wanderDurationMillis, easing = LinearEasing), - ) + tick += 1f + delay(TICK_INTERVAL_MS) } } - val phase = phaseAnim.value - val wander = wanderAnim.value - val activity by animateFloatAsState( targetValue = if (isPlaying) 1f else 0f, animationSpec = tween(durationMillis = 240, easing = FastOutSlowInEasing), @@ -87,11 +72,12 @@ fun PlayingEqIcon( val corner = CornerRadius(barWidth / 2f, barWidth / 2f) repeat(bars) { i -> - val slowShift = 0.6f * sin(wander + i * 0.4f) - val slowAmplitude = 0.85f + 0.15f * sin(wander * 0.5f + 1.1f + i * 0.3f) - - val waveform = (sin(phase * speeds[i] + shifts[i] + slowShift) * slowAmplitude + 1f) * 0.5f - val eased = waveform * waveform * (3 - 2 * waveform) + val eased = if (animate) { + val waveform = (sin(tick * 0.5f + shifts[i] * speeds[i]) + 1f) * 0.5f + waveform * waveform * (3 - 2 * waveform) + } else { + STATIC_BAR_EASED[i % STATIC_BAR_EASED.size] + } val barsHeightFraction = minHeightFraction + (maxHeightFraction - minHeightFraction) * eased val barHeight = height * barsHeightFraction @@ -110,3 +96,6 @@ fun PlayingEqIcon( } } } + +private const val TICK_INTERVAL_MS = 140L +private val STATIC_BAR_EASED = floatArrayOf(0.82f, 0.42f, 0.62f) diff --git a/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/DownloadsScreen.kt b/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/DownloadsScreen.kt index 64141b3fa..cb29c5ba0 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 @@ -24,6 +25,7 @@ import androidx.compose.material.icons.rounded.MoreVert import androidx.compose.material.icons.rounded.MusicNote import androidx.compose.material.icons.rounded.PhoneAndroid import androidx.compose.material.icons.rounded.PlayArrow +import androidx.compose.material.icons.rounded.QueueMusic import androidx.compose.material.icons.rounded.Refresh import androidx.compose.material.icons.rounded.Security import androidx.compose.runtime.Composable @@ -81,6 +83,7 @@ import kotlinx.coroutines.flow.collect */ @Composable fun DownloadsScreen( + onPlaylistsClick: () -> Unit = {}, viewModel: WearDownloadsViewModel = hiltViewModel(), playerViewModel: WearPlayerViewModel = hiltViewModel(), ) { @@ -183,6 +186,33 @@ fun DownloadsScreen( ) } + item { + Chip( + label = { + Text( + text = stringResource(R.string.wear_local_playlists_title), + 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(top = 2.dp), + ) + } + if (inlineMessage != null) { item { Chip( diff --git a/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/LocalPlaylistDetailScreen.kt b/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/LocalPlaylistDetailScreen.kt new file mode 100644 index 000000000..6fc58f50e --- /dev/null +++ b/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/LocalPlaylistDetailScreen.kt @@ -0,0 +1,258 @@ +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.CloudDownload +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.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource +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.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.theveloper.pixelplay.data.TransferState +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.WearLocalPlaylistViewModel +import com.theveloper.pixelplay.presentation.viewmodel.WearPlayerViewModel +import com.theveloper.pixelplay.shared.WearTransferProgress + +/** + * Song list for one playlist snapshot. Songs that haven't finished transferring yet are shown + * dimmed with a cloud icon and aren't tappable — they resolve to normal, playable rows on their + * own once the transfer completes (see [WearLocalPlaylistViewModel.playlistSongs]). + */ +@Composable +fun LocalPlaylistDetailScreen( + playlistId: String, + playlistName: String, + viewModel: WearLocalPlaylistViewModel = hiltViewModel(), + playerViewModel: WearPlayerViewModel = hiltViewModel(), +) { + LaunchedEffect(playlistId) { viewModel.loadPlaylist(playlistId) } + + val songs by viewModel.playlistSongs.collectAsState() + val playerState by playerViewModel.playerState.collectAsState() + val activeTransfers by viewModel.activeTransfers.collectAsState() + val activeTransferBySongId = remember(activeTransfers) { + activeTransfers.values.associateBy { it.songId } + } + val palette = LocalWearPalette.current + val columnState = rememberResponsiveColumnState() + val background = palette.screenBackgroundColor() + val surfaceContainer = palette.surfaceContainerColor() + val elevatedSurfaceContainer = palette.surfaceContainerHighColor() + val availableCount = songs.count { it.isAvailable } + + Box( + modifier = Modifier + .fillMaxSize() + .background(background), + ) { + ScalingLazyColumn( + modifier = Modifier.fillMaxSize(), + columnState = columnState, + ) { + item { Spacer(modifier = Modifier.height(18.dp)) } + + item { + Text( + text = playlistName, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.title2, + fontWeight = FontWeight.Bold, + color = palette.textPrimary, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 2.dp), + ) + } + + item { + Text( + text = pluralStringResource( + R.plurals.wear_local_playlist_song_count, + songs.size, + songs.size, + ), + style = MaterialTheme.typography.caption2, + color = palette.textSecondary.copy(alpha = 0.8f), + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 4.dp), + ) + } + + if (availableCount > 0) { + item { + Chip( + label = { + Text( + text = stringResource(R.string.wear_play_all), + color = palette.textPrimary, + ) + }, + icon = { + Icon( + imageVector = Icons.Rounded.PlayArrow, + contentDescription = null, + tint = palette.textPrimary, + modifier = Modifier.size(18.dp), + ) + }, + onClick = { viewModel.playAll() }, + colors = ChipDefaults.chipColors( + backgroundColor = palette.shuffleActive.copy(alpha = 0.38f), + contentColor = palette.chipContent, + ), + modifier = Modifier.fillMaxWidth(), + ) + } + } + + items(songs.size) { index -> + val item = songs[index] + if (item.isAvailable) { + val song = item.song!! + val isCurrentSong = song.songId == playerState.songId && playerState.songId.isNotBlank() + val isPlayingSong = isCurrentSong && playerState.isPlaying + Chip( + label = { + Text( + text = song.title, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = palette.textPrimary, + ) + }, + secondaryLabel = if (song.artist.isNotEmpty()) { + { + Text( + text = song.artist, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = palette.textSecondary.copy(alpha = 0.78f), + ) + } + } else null, + icon = { + if (isCurrentSong) { + PlayingEqIcon( + color = if (isPlayingSong) palette.shuffleActive else palette.textSecondary, + isPlaying = isPlayingSong, + modifier = Modifier.size(18.dp), + ) + } else { + Icon( + imageVector = Icons.Rounded.MusicNote, + contentDescription = null, + tint = palette.textSecondary, + modifier = Modifier.size(18.dp), + ) + } + }, + onClick = { viewModel.playFrom(song.songId) }, + colors = ChipDefaults.chipColors( + backgroundColor = if (isCurrentSong) elevatedSurfaceContainer else surfaceContainer, + contentColor = palette.chipContent, + ), + modifier = Modifier.fillMaxWidth(), + ) + } else { + val activeTransfer = activeTransferBySongId[item.songId] + val isReceiving = activeTransfer?.status == WearTransferProgress.STATUS_TRANSFERRING + Chip( + label = { + Text( + text = if (isReceiving) { + val percent = (activeTransfer!!.progress * 100f).toInt().coerceIn(0, 100) + if (activeTransfer.totalBytes > 0L) { + "$percent%" + } else { + stringResource(R.string.wear_transfer_starting) + } + } else { + stringResource(R.string.wear_song_pending_transfer) + }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = palette.textSecondary.copy(alpha = 0.6f), + ) + }, + icon = { + if (isReceiving) { + CircularProgressIndicator( + indicatorColor = palette.shuffleActive, + trackColor = surfaceContainer, + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + ) + } else { + Icon( + imageVector = Icons.Rounded.CloudDownload, + contentDescription = null, + tint = palette.textSecondary.copy(alpha = 0.6f), + modifier = Modifier.size(18.dp), + ) + } + }, + onClick = {}, + enabled = false, + colors = ChipDefaults.chipColors( + backgroundColor = surfaceContainer.copy(alpha = 0.5f), + contentColor = palette.chipContent, + ), + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + + AlwaysOnScalingPositionIndicator( + listState = columnState.state, + modifier = Modifier.align(Alignment.CenterEnd), + color = palette.textPrimary, + ) + + WearTopTimeText( + modifier = Modifier + .align(Alignment.TopCenter) + .zIndex(5f), + color = palette.textPrimary, + ) + } +} diff --git a/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/LocalPlaylistsScreen.kt b/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/LocalPlaylistsScreen.kt new file mode 100644 index 000000000..1a5a6ecf9 --- /dev/null +++ b/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/LocalPlaylistsScreen.kt @@ -0,0 +1,169 @@ +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.collectAsState +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.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.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.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.viewmodel.WearLocalPlaylistViewModel + +/** + * Lists playlist snapshots synced from the phone (see [WearLocalPlaylistViewModel]). A playlist + * shows up here as soon as its metadata syncs, even before any of its songs finish transferring. + */ +@Composable +fun LocalPlaylistsScreen( + viewModel: WearLocalPlaylistViewModel = hiltViewModel(), + onPlaylistClick: (playlistId: String, name: String) -> Unit, +) { + val playlists by viewModel.playlists.collectAsState() + val playlistIdsReceiving by viewModel.playlistIdsReceiving.collectAsState() + val palette = LocalWearPalette.current + val columnState = rememberResponsiveColumnState() + val background = palette.screenBackgroundColor() + val surfaceContainer = palette.surfaceContainerColor() + + 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_local_playlists_title), + style = MaterialTheme.typography.title2, + fontWeight = androidx.compose.ui.text.font.FontWeight.Bold, + 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.8f), + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(top = 6.dp), + ) + } + item { + Text( + text = stringResource(R.string.wear_no_local_playlists_hint), + style = MaterialTheme.typography.caption2, + color = palette.textSecondary.copy(alpha = 0.7f), + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp), + ) + } + } else { + items(playlists.size) { index -> + val playlist = playlists[index] + val isReceiving = playlist.playlistId in playlistIdsReceiving + Chip( + label = { + Text( + text = playlist.name, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = palette.textPrimary, + ) + }, + secondaryLabel = if (isReceiving) { + { + Text( + text = stringResource(R.string.wear_local_playlist_receiving), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = palette.textSecondary.copy(alpha = 0.82f), + ) + } + } else { + null + }, + icon = { + if (isReceiving) { + CircularProgressIndicator( + indicatorColor = palette.shuffleActive, + trackColor = surfaceContainer, + 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 = { onPlaylistClick(playlist.playlistId, playlist.name) }, + colors = ChipDefaults.chipColors( + backgroundColor = surfaceContainer, + contentColor = palette.chipContent, + ), + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + + AlwaysOnScalingPositionIndicator( + listState = columnState.state, + modifier = Modifier.align(Alignment.CenterEnd), + color = palette.textPrimary, + ) + + WearTopTimeText( + modifier = Modifier + .align(Alignment.TopCenter) + .zIndex(5f), + color = palette.textPrimary, + ) + } +} 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 057842960..270e14d4c 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 @@ -116,6 +116,7 @@ import com.google.android.horologist.audio.ui.volumeRotaryBehavior 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.WearDeviceTier import com.theveloper.pixelplay.data.WearLifecycleState import com.theveloper.pixelplay.presentation.components.AlwaysOnScalingPositionIndicator import com.theveloper.pixelplay.presentation.components.CurvedVolumeIndicator @@ -155,6 +156,7 @@ fun PlayerScreen( val activeOutputRouteType by viewModel.activeOutputRouteType.collectAsState() val activeVolumeState by viewModel.activeVolumeState.collectAsState() val albumArt by viewModel.albumArt.collectAsState() + val localPlaybackError by viewModel.localPlaybackError.collectAsState() PlayerContent( state = state, @@ -172,6 +174,8 @@ fun PlayerScreen( onOutputClick = onOutputClick, onMoreClick = onMoreClick, onQueueClick = onQueueClick, + localPlaybackError = localPlaybackError, + onConsumeLocalPlaybackError = viewModel::consumeLocalPlaybackError, ) } @@ -192,6 +196,8 @@ private fun PlayerContent( onOutputClick: () -> Unit, onMoreClick: () -> Unit, onQueueClick: () -> Unit, + localPlaybackError: String? = null, + onConsumeLocalPlaybackError: () -> Unit = {}, ) { val palette = LocalWearPalette.current val isAmbient by WearLifecycleState.isAmbient.collectAsState() @@ -251,6 +257,12 @@ private fun PlayerContent( HorizontalPager( state = pagerState, modifier = Modifier.fillMaxSize(), + // Reverted: beyondViewportPageCount = 0 traded a small idle-CPU saving for composing + // the destination page on demand during the swipe gesture itself — a CPU burst at + // exactly the worst possible moment (mid-interaction), which caused audible playback + // stutter during swipes and when opening the volume screen. Keeping the neighbor + // preloaded avoids that; audible glitches during real use matter far more than + // background CPU while idle. beyondViewportPageCount = 1, ) { page -> when (page) { @@ -311,6 +323,37 @@ private fun PlayerContent( backgroundColor = Color.Transparent, ) } + + LaunchedEffect(localPlaybackError) { + if (localPlaybackError != null) { + delay(3000L) + onConsumeLocalPlaybackError() + } + } + + AnimatedVisibility( + visible = localPlaybackError != null, + enter = fadeIn(animationSpec = tween(durationMillis = 180)), + exit = fadeOut(animationSpec = tween(durationMillis = 240)), + modifier = Modifier + .align(Alignment.TopCenter) + .padding(top = 36.dp) + .zIndex(7f), + ) { + Box( + modifier = Modifier + .background(Color.Black.copy(alpha = 0.75f), RoundedCornerShape(12.dp)) + .padding(horizontal = 12.dp, vertical = 6.dp), + ) { + Text( + text = localPlaybackError.orEmpty(), + color = Color.White, + style = MaterialTheme.typography.caption2, + textAlign = TextAlign.Center, + maxLines = 2, + ) + } + } } } @@ -1078,7 +1121,7 @@ private fun MainPlayerPage( }, ) - val livePositionMs by rememberLivePositionMs(state) + val livePositionMs by rememberLivePositionMs(state, isWatchOutputSelected) val trackProgressTarget = if (state.totalDurationMs > 0L) { (livePositionMs.toFloat() / state.totalDurationMs.toFloat()).coerceIn(0f, 1f) } else { @@ -1194,6 +1237,7 @@ private fun MainPlayerPage( enabled = if (isWatchOutputSelected) !state.isEmpty else isPhoneConnected, outlined = isAmbient, trackProgress = trackProgress, + isWatchOutputSelected = isWatchOutputSelected, onTogglePlayPause = onTogglePlayPause, onNext = onNext, onPrevious = onPrevious, @@ -1248,24 +1292,36 @@ private fun MainPlayerPage( } @Composable -private fun rememberLivePositionMs(state: WearPlayerState): androidx.compose.runtime.State { +private fun rememberLivePositionMs( + state: WearPlayerState, + isWatchOutputSelected: Boolean, +): androidx.compose.runtime.State { val safeDuration = state.totalDurationMs.coerceAtLeast(0L) val safeAnchorPosition = state.currentPositionMs.coerceIn(0L, safeDuration) + val isInteractive by WearLifecycleState.isInteractive.collectAsState( + initial = WearLifecycleState.isInteractiveNow, + ) + // The 250ms tick below only exists to make the seek bar glide smoothly between the ~1s + // position updates from the repository. That's not worth it while nobody can see it + // (isInteractive), or while the watch itself is decoding audio on a constrained tier (see + // WearDeviceTier) where it competes for CPU with ExoPlayer's decode/render threads. + val shouldInterpolate = isInteractive && (isWatchOutputSelected.not() || WearDeviceTier.isCapable) val positionKey = remember( state.songId, safeAnchorPosition, safeDuration, state.isPlaying, state.positionUpdatedElapsedRealtimeMs, + shouldInterpolate, ) { - "${state.songId}|$safeAnchorPosition|$safeDuration|${state.isPlaying}|${state.positionUpdatedElapsedRealtimeMs}" + "${state.songId}|$safeAnchorPosition|$safeDuration|${state.isPlaying}|${state.positionUpdatedElapsedRealtimeMs}|$shouldInterpolate" } return produceState( initialValue = state.livePositionFromAnchor(safeAnchorPosition, safeDuration), key1 = positionKey, ) { value = state.livePositionFromAnchor(safeAnchorPosition, safeDuration) - if (!state.isPlaying || safeDuration <= 0L) { + if (!state.isPlaying || safeDuration <= 0L || !shouldInterpolate) { return@produceState } @@ -1633,6 +1689,7 @@ private fun MainControlsRow( enabled: Boolean, outlined: Boolean, trackProgress: Float, + isWatchOutputSelected: Boolean, onTogglePlayPause: () -> Unit, onNext: () -> Unit, onPrevious: () -> Unit, @@ -1661,6 +1718,7 @@ private fun MainControlsRow( enabled = enabled && !isEmpty, outlined = outlined, trackProgress = trackProgress, + isWatchOutputSelected = isWatchOutputSelected, onClick = onTogglePlayPause, ) @@ -1736,6 +1794,7 @@ private fun CenterPlayButton( enabled: Boolean, outlined: Boolean, trackProgress: Float, + isWatchOutputSelected: Boolean, onClick: () -> Unit, ) { val palette = LocalWearPalette.current @@ -1749,17 +1808,25 @@ private fun CenterPlayButton( val isInteractive by WearLifecycleState.isInteractive.collectAsState( initial = WearLifecycleState.isInteractiveNow, ) - LaunchedEffect(isPlaying, isInteractive) { - if (!isPlaying || !isInteractive) { - val normalizedRotation = ((rotation.value % 360f) + 360f) % 360f - rotation.snapTo(normalizedRotation) - rotation.animateTo( - targetValue = 0f, - animationSpec = tween( - durationMillis = 616, - easing = FastOutSlowInEasing, - ), - ) + LaunchedEffect(isPlaying, isInteractive, isWatchOutputSelected) { + // Continuous per-frame rotation is real CPU cost for the whole time music plays. That + // only competes with ExoPlayer's decode/render threads when the watch itself is doing the + // decoding (isWatchOutputSelected): when it's just remote-controlling phone playback, the + // watch does no local decode work, so there's no reason to hold back on a constrained + // tier. See WearDeviceTier. + val spinAllowed = isWatchOutputSelected.not() || WearDeviceTier.isCapable + if (!isPlaying || !isInteractive || !spinAllowed) { + if (rotation.value != 0f) { + val normalizedRotation = ((rotation.value % 360f) + 360f) % 360f + rotation.snapTo(normalizedRotation) + rotation.animateTo( + targetValue = 0f, + animationSpec = tween( + durationMillis = 616, + easing = FastOutSlowInEasing, + ), + ) + } return@LaunchedEffect } while (true) { diff --git a/wear/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/WearLocalPlaylistViewModel.kt b/wear/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/WearLocalPlaylistViewModel.kt new file mode 100644 index 000000000..85305966f --- /dev/null +++ b/wear/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/WearLocalPlaylistViewModel.kt @@ -0,0 +1,114 @@ +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.WearTransferRepository +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 dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.stateIn + +/** A song's position in a local playlist snapshot, resolved against what's actually on disk. */ +data class WearLocalPlaylistSongItem( + val songId: String, + val song: LocalSongEntity?, +) { + val isAvailable: Boolean get() = song != null +} + +/** + * ViewModel backing [LocalPlaylistsScreen] and [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, + private val transferRepository: WearTransferRepository, +) : ViewModel() { + + val playlists: StateFlow> = localPlaylistDao.observePlaylists() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), emptyList()) + + /** In-flight song transfers from the phone, keyed by requestId — for on-screen receive feedback. */ + val activeTransfers: StateFlow> = transferRepository.activeTransfers + + /** Playlists that currently have at least one of their songs actively transferring. */ + val playlistIdsReceiving: StateFlow> = combine( + localPlaylistDao.observeAllPlaylistSongCrossRefs(), + transferRepository.activeTransfers, + ) { crossRefs, transfers -> + if (transfers.isEmpty()) { + emptySet() + } else { + val activeSongIds = transfers.values.map { it.songId }.toSet() + crossRefs.filter { it.songId in activeSongIds }.map { it.playlistId }.toSet() + } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), emptySet()) + + private val _playlistId = MutableStateFlow(null) + + val playlistDetails: StateFlow = combine( + playlists, + _playlistId, + ) { allPlaylists, playlistId -> + allPlaylists.find { it.playlistId == playlistId } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), null) + + val playlistSongs: StateFlow> = _playlistId + .flatMapLatest { playlistId -> + if (playlistId == null) { + flowOf(emptyList()) + } else { + combine( + localPlaylistDao.observePlaylistSongs(playlistId), + localSongDao.getAllSongs(), + ) { crossRefs, allSongs -> + val songsById = allSongs.associateBy { it.songId } + crossRefs.map { ref -> WearLocalPlaylistSongItem(ref.songId, songsById[ref.songId]) } + } + } + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), emptyList()) + + fun loadPlaylist(playlistId: String) { + if (_playlistId.value == playlistId) return + _playlistId.value = playlistId + } + + /** Plays every available (already-transferred) song in order, from the start. */ + fun playAll() { + val available = playlistSongs.value.mapNotNull { it.song } + if (available.isEmpty()) return + localPlayerRepository.playLocalSongs(available, startIndex = 0) + stateRepository.setOutputTarget(WearOutputTarget.WATCH) + } + + /** Plays every available song, starting from [songId] — pending songs aren't tappable. */ + fun playFrom(songId: String) { + val available = playlistSongs.value.mapNotNull { it.song } + val startIndex = available.indexOfFirst { it.songId == songId } + if (startIndex == -1) return + localPlayerRepository.playLocalSongs(available, startIndex = startIndex) + stateRepository.setOutputTarget(WearOutputTarget.WATCH) + } +} diff --git a/wear/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/WearPlayerViewModel.kt b/wear/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/WearPlayerViewModel.kt index 3a6038e81..f5859535b 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 @@ -66,6 +66,8 @@ class WearPlayerViewModel @Inject constructor( /** Whether local playback is currently active on the watch */ val isLocalPlaybackActive: StateFlow = localPlayerRepository.isLocalPlaybackActive val localQueueState: StateFlow = localPlayerRepository.localQueueState + /** Transient local-playback error message (e.g. a song that failed to decode and got skipped). */ + val localPlaybackError: StateFlow = localPlayerRepository.localPlaybackError private val localSongs = transferRepository.localSongs .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) @@ -496,6 +498,10 @@ class WearPlayerViewModel @Inject constructor( fun stopLocalPlayback() { localPlayerRepository.release() } + + fun consumeLocalPlaybackError() { + localPlayerRepository.consumeLocalPlaybackError() + } } data class WearSleepTimerUiState( diff --git a/wear/src/main/res/values/strings_wear.xml b/wear/src/main/res/values/strings_wear.xml index 454dcf99c..dcf3daf22 100644 --- a/wear/src/main/res/values/strings_wear.xml +++ b/wear/src/main/res/values/strings_wear.xml @@ -77,4 +77,18 @@ Saving… Save to watch Confirm save + + + My Playlists + No playlists sent yet + Send a playlist from the phone app to see it here. + + %1$d song + %1$d songs + + Play all + Waiting to transfer… + Receiving… + Receiving from phone + Starting… 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 000000000..8e2cec5ff --- /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/WearTransferRepositoryPlaylistSyncTest.kt b/wear/src/test/java/com/theveloper/pixelplay/data/WearTransferRepositoryPlaylistSyncTest.kt new file mode 100644 index 000000000..a80909ab4 --- /dev/null +++ b/wear/src/test/java/com/theveloper/pixelplay/data/WearTransferRepositoryPlaylistSyncTest.kt @@ -0,0 +1,88 @@ +package com.theveloper.pixelplay.data + +import android.app.Application +import com.google.android.gms.wearable.ChannelClient +import com.google.android.gms.wearable.MessageClient +import com.google.android.gms.wearable.NodeClient +import com.theveloper.pixelplay.data.local.LocalPlaylistDao +import com.theveloper.pixelplay.data.local.LocalPlaylistEntity +import com.theveloper.pixelplay.data.local.LocalPlaylistSongCrossRef +import com.theveloper.pixelplay.data.local.LocalSongDao +import com.theveloper.pixelplay.shared.WearPlaylistSync +import com.google.common.truth.Truth.assertThat +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test + +/** + * Covers [WearTransferRepository.onPlaylistSyncReceived] — the watch-side handler for a + * whole-playlist sync from the phone. Doesn't need Room: [LocalPlaylistDao] is mocked, so this + * runs as a plain JVM test; DAO upsert/ordering behavior itself is covered separately by the + * instrumented LocalPlaylistDaoTest. + */ +class WearTransferRepositoryPlaylistSyncTest { + + private val localSongDao = mockk(relaxed = true) { + every { getAllSongs() } returns flowOf(emptyList()) + } + private val localPlaylistDao = mockk(relaxed = true) + + private val repository = WearTransferRepository( + application = mockk(relaxed = true), + localSongDao = localSongDao, + localPlaylistDao = localPlaylistDao, + channelClient = mockk(relaxed = true), + messageClient = mockk(relaxed = true), + nodeClient = mockk(relaxed = true), + localPlayerRepository = mockk(relaxed = true), + stateRepository = mockk(relaxed = true), + playbackController = mockk(relaxed = true), + ) + + @Test + fun `first sync sets createdAt and updatedAt to the same timestamp`() = runTest { + coEvery { localPlaylistDao.getPlaylistById("p1") } returns null + val entitySlot = slot() + val crossRefsSlot = slot>() + + repository.onPlaylistSyncReceived(WearPlaylistSync("p1", "Road Trip", listOf("s1", "s2"))) + + coVerify { localPlaylistDao.upsertPlaylist(capture(entitySlot), capture(crossRefsSlot)) } + assertThat(entitySlot.captured.playlistId).isEqualTo("p1") + assertThat(entitySlot.captured.name).isEqualTo("Road Trip") + assertThat(entitySlot.captured.createdAt).isEqualTo(entitySlot.captured.updatedAt) + assertThat(crossRefsSlot.captured.map { it.songId to it.position }) + .containsExactly("s1" to 0, "s2" to 1) + .inOrder() + } + + @Test + fun `re-sync preserves the original createdAt but bumps updatedAt`() = runTest { + val original = LocalPlaylistEntity("p1", "Road Trip", createdAt = 1_000L, updatedAt = 1_000L) + coEvery { localPlaylistDao.getPlaylistById("p1") } returns original + val entitySlot = slot() + + repository.onPlaylistSyncReceived(WearPlaylistSync("p1", "Road Trip", listOf("s1"))) + + coVerify { localPlaylistDao.upsertPlaylist(capture(entitySlot), any()) } + assertThat(entitySlot.captured.createdAt).isEqualTo(1_000L) + assertThat(entitySlot.captured.updatedAt).isAtLeast(1_000L) + } + + @Test + fun `re-sync with a different song set is passed through as-is, not merged with the old one`() = runTest { + coEvery { localPlaylistDao.getPlaylistById("p1") } returns + LocalPlaylistEntity("p1", "Road Trip", createdAt = 1_000L, updatedAt = 1_000L) + val crossRefsSlot = slot>() + + repository.onPlaylistSyncReceived(WearPlaylistSync("p1", "Road Trip", listOf("s2", "s3"))) + + coVerify { localPlaylistDao.upsertPlaylist(any(), capture(crossRefsSlot)) } + assertThat(crossRefsSlot.captured.map { it.songId }).containsExactly("s2", "s3") + } +} diff --git a/wear/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/WearLocalPlaylistViewModelTest.kt b/wear/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/WearLocalPlaylistViewModelTest.kt new file mode 100644 index 000000000..4640a3005 --- /dev/null +++ b/wear/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/WearLocalPlaylistViewModelTest.kt @@ -0,0 +1,181 @@ +package com.theveloper.pixelplay.presentation.viewmodel + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import com.theveloper.pixelplay.MainCoroutineExtension +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.WearTransferRepository +import com.theveloper.pixelplay.data.local.LocalPlaylistDao +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.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension + +/** + * Covers the phone-song ↔ local-song join in [WearLocalPlaylistViewModel] — the part of Fase 7 + * that actually has logic (deciding availability and building the playable-only queue for + * playAll/playFrom). The two screens that render this state are UI-only glue, verified manually. + * + * Uses an unconfined Main dispatcher (rather than the shared MainCoroutineExtension default) so + * the viewModelScope.stateIn(...) chains resolve synchronously without needing a second, + * independently-ticked test scheduler. + */ +@ExperimentalCoroutinesApi +class WearLocalPlaylistViewModelTest { + + @JvmField + @RegisterExtension + val mainDispatcherExtension = MainCoroutineExtension(UnconfinedTestDispatcher()) + + private val localPlaylistDao = mockk() + private val localSongDao = mockk() + private val localPlayerRepository = mockk(relaxed = true) + private val stateRepository = mockk(relaxed = true) + private val transferRepository = mockk() + private val crossRefsFlow = MutableStateFlow>(emptyList()) + private val activeTransfersFlow = MutableStateFlow>(emptyMap()) + + private fun song(id: String) = LocalSongEntity( + songId = id, + title = "Title $id", + artist = "Artist", + album = "Album", + albumId = 0L, + duration = 60_000L, + mimeType = "audio/mpeg", + fileSize = 1_000L, + bitrate = 128_000, + sampleRate = 44_100, + isFavorite = false, + favoriteSyncPending = false, + localPath = "/music/$id.mp3", + transferredAt = 0L, + ) + + private fun crossRef(songId: String, position: Int, playlistId: String = "p1") = + LocalPlaylistSongCrossRef(playlistId = playlistId, songId = songId, position = position) + + private fun transferring(songId: String) = TransferState( + requestId = "req-$songId", + songId = songId, + songTitle = "Title $songId", + bytesTransferred = 0L, + totalBytes = 0L, + status = WearTransferProgress.STATUS_TRANSFERRING, + ) + + private fun buildViewModel(): WearLocalPlaylistViewModel { + every { localPlaylistDao.observeAllPlaylistSongCrossRefs() } returns crossRefsFlow + every { transferRepository.activeTransfers } returns activeTransfersFlow + return WearLocalPlaylistViewModel( + localPlaylistDao = localPlaylistDao, + localSongDao = localSongDao, + localPlayerRepository = localPlayerRepository, + stateRepository = stateRepository, + transferRepository = transferRepository, + ) + } + + @Test + fun `playlistSongs marks songs without a matching local song as unavailable`() = runTest { + every { localPlaylistDao.observePlaylists() } returns flowOf(emptyList()) + every { localPlaylistDao.observePlaylistSongs("p1") } returns + flowOf(listOf(crossRef("s1", 0), crossRef("s2", 1))) + every { localSongDao.getAllSongs() } returns flowOf(listOf(song("s1"))) + + val viewModel = buildViewModel() + viewModel.loadPlaylist("p1") + + viewModel.playlistSongs.test { + val items = awaitItem() + assertThat(items.map { it.songId to it.isAvailable }) + .containsExactly("s1" to true, "s2" to false) + .inOrder() + } + } + + @Test + fun `playAll sends only the available songs to the player, skipping pending ones`() = runTest { + every { localPlaylistDao.observePlaylists() } returns flowOf(emptyList()) + every { localPlaylistDao.observePlaylistSongs("p1") } returns + flowOf(listOf(crossRef("s1", 0), crossRef("s2", 1), crossRef("s3", 2))) + every { localSongDao.getAllSongs() } returns flowOf(listOf(song("s1"), song("s3"))) + + val viewModel = buildViewModel() + viewModel.loadPlaylist("p1") + viewModel.playlistSongs.test { awaitItem() } + + viewModel.playAll() + + verify { localPlayerRepository.playLocalSongs(listOf(song("s1"), song("s3")), startIndex = 0) } + verify { stateRepository.setOutputTarget(WearOutputTarget.WATCH) } + } + + @Test + fun `playFrom resolves the start index within the available-only queue, not the full playlist`() = runTest { + every { localPlaylistDao.observePlaylists() } returns flowOf(emptyList()) + every { localPlaylistDao.observePlaylistSongs("p1") } returns + flowOf(listOf(crossRef("s1", 0), crossRef("s2", 1), crossRef("s3", 2))) + every { localSongDao.getAllSongs() } returns flowOf(listOf(song("s1"), song("s3"))) + + val viewModel = buildViewModel() + viewModel.loadPlaylist("p1") + viewModel.playlistSongs.test { awaitItem() } + + // s2 is pending (filtered out), so s3 is at index 1 of the [s1, s3] playable queue, + // not index 2 of the full [s1, s2, s3] playlist. + viewModel.playFrom("s3") + + verify { localPlayerRepository.playLocalSongs(listOf(song("s1"), song("s3")), startIndex = 1) } + } + + @Test + fun `playFrom does nothing for a pending song id`() = runTest { + every { localPlaylistDao.observePlaylists() } returns flowOf(emptyList()) + every { localPlaylistDao.observePlaylistSongs("p1") } returns + flowOf(listOf(crossRef("s1", 0), crossRef("s2", 1))) + every { localSongDao.getAllSongs() } returns flowOf(listOf(song("s1"))) + + val viewModel = buildViewModel() + viewModel.loadPlaylist("p1") + viewModel.playlistSongs.test { awaitItem() } + + viewModel.playFrom("s2") + + verify(exactly = 0) { localPlayerRepository.playLocalSongs(any(), any(), any(), any()) } + } + + @Test + fun `playlistIdsReceiving only includes playlists with a song actively transferring`() = runTest { + every { localPlaylistDao.observePlaylists() } returns flowOf(emptyList()) + crossRefsFlow.value = listOf( + crossRef("s1", 0, playlistId = "p1"), + crossRef("s2", 0, playlistId = "p2"), + ) + + val viewModel = buildViewModel() + + viewModel.playlistIdsReceiving.test { + assertThat(awaitItem()).isEmpty() + + activeTransfersFlow.value = mapOf("req-s1" to transferring("s1")) + assertThat(awaitItem()).containsExactly("p1") + + activeTransfersFlow.value = emptyMap() + assertThat(awaitItem()).isEmpty() + } + } +}