From cb85daf4f788c9a02056cc7515c7ed588177475a Mon Sep 17 00:00:00 2001 From: PonceGL Date: Thu, 2 Jul 2026 18:22:13 -0600 Subject: [PATCH 01/30] test(wear): add unit and instrumentation test dependencies The wear module had no test source sets or dependencies configured at all, unlike app. Mirrors app/build.gradle.kts: JUnit5 + MockK + Turbine + Truth for local unit tests, and androidx.room.testing + AndroidJUnit4 for instrumented DAO/migration tests, needed for the upcoming playlist transfer feature. --- wear/build.gradle.kts | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/wear/build.gradle.kts b/wear/build.gradle.kts index 2edcc8ff6..0145a9c06 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,27 @@ 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.truth) + androidTestImplementation(libs.mockk) + constraints { // Fix vulnerabilities in transitive dependencies implementation(libs.netty.common) @@ -131,3 +159,7 @@ dependencies { implementation(libs.apache.httpclient) } } + +tasks.withType { + useJUnitPlatform() +} From b071d0b6549c07975a36df905a75fbc54c53c313 Mon Sep 17 00:00:00 2001 From: PonceGL Date: Thu, 2 Jul 2026 18:30:25 -0600 Subject: [PATCH 02/30] feat(shared): add playlist sync contract and free storage field Adds the shared Data Layer contracts needed for sending a whole playlist to the watch: WearDataPaths.PLAYLIST_SYNC(_ACK), the WearPlaylistSync payload, a STATUS_TRANSCODING transfer state, and a freeStorageBytes field on WearLibraryState so the phone can warn before a transfer that won't fit. Also adds unit test infrastructure to the shared module (it had none) and round-trip serialization tests for the new/changed models. --- shared/build.gradle.kts | 17 ++++ .../pixelplay/shared/WearDataPaths.kt | 6 ++ .../pixelplay/shared/WearLibraryState.kt | 2 + .../pixelplay/shared/WearPlaylistSync.kt | 17 ++++ .../pixelplay/shared/WearTransferProgress.kt | 1 + .../pixelplay/shared/WearPlaylistSyncTest.kt | 80 +++++++++++++++++++ 6 files changed, 123 insertions(+) create mode 100644 shared/src/main/java/com/theveloper/pixelplay/shared/WearPlaylistSync.kt create mode 100644 shared/src/test/java/com/theveloper/pixelplay/shared/WearPlaylistSyncTest.kt 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..0a2af69b5 100644 --- a/shared/src/main/java/com/theveloper/pixelplay/shared/WearTransferProgress.kt +++ b/shared/src/main/java/com/theveloper/pixelplay/shared/WearTransferProgress.kt @@ -16,6 +16,7 @@ data class WearTransferProgress( val error: String? = null, ) { companion object { + const val STATUS_TRANSCODING = "transcoding" const val STATUS_TRANSFERRING = "transferring" const val STATUS_COMPLETED = "completed" const val STATUS_FAILED = "failed" 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) + } +} From a7130c90f9fd049498992cfd95c10dbdcf140be3 Mon Sep 17 00:00:00 2001 From: PonceGL Date: Thu, 2 Jul 2026 18:30:35 -0600 Subject: [PATCH 03/30] fix(wear-transfer): wire watch free storage into phone state store WATCH_LIBRARY_STATE messages from the watch were silently dropped on the phone (fell into the "unknown message path" branch), and PhoneWatchTransferStateStore.updateWatchSongIds was dead code with no caller. Neither was actually wired end to end. Wires WearCommandReceiver to handle WATCH_LIBRARY_STATE and forward both songIds and the new freeStorageBytes into the store, and has the watch populate freeStorageBytes via StatFs on its songs directory when publishing library state. --- .../service/wear/PhoneWatchTransferStateStore.kt | 15 +++++++++++++++ .../data/service/wear/WearCommandReceiver.kt | 15 +++++++++++++++ .../pixelplay/data/WearTransferRepository.kt | 15 ++++++++++++++- 3 files changed, 44 insertions(+), 1 deletion(-) 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..a6e144789 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 @@ -47,6 +47,9 @@ 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 cleanupJobs = ConcurrentHashMap() @@ -164,6 +167,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() @@ -192,6 +201,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() 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..b539c8b8b 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,6 +98,7 @@ class WearCommandReceiver : WearableListenerService() { WearDataPaths.BROWSE_REQUEST -> handleBrowseRequest(messageEvent) WearDataPaths.TRANSFER_REQUEST -> handleTransferRequest(messageEvent) WearDataPaths.TRANSFER_CANCEL -> handleTransferCancel(messageEvent) + WearDataPaths.WATCH_LIBRARY_STATE -> handleWatchLibraryState(messageEvent) else -> Timber.tag(TAG).w("Unknown message path: ${messageEvent.path}") } } @@ -538,6 +540,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/wear/src/main/java/com/theveloper/pixelplay/data/WearTransferRepository.kt b/wear/src/main/java/com/theveloper/pixelplay/data/WearTransferRepository.kt index dbe804084..4043855f6 100644 --- a/wear/src/main/java/com/theveloper/pixelplay/data/WearTransferRepository.kt +++ b/wear/src/main/java/com/theveloper/pixelplay/data/WearTransferRepository.kt @@ -1,6 +1,7 @@ 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 @@ -841,7 +842,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 +870,15 @@ class WearTransferRepository @Inject constructor( } } + /** 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. From d268a8bde6be33124d9a05aa203097c5ce603299 Mon Sep 17 00:00:00 2001 From: PonceGL Date: Thu, 2 Jul 2026 18:36:48 -0600 Subject: [PATCH 04/30] feat(wear-transfer): add AAC transcoding for watch handoff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds WatchAudioTranscoder: re-encodes lossless/high-bitrate sources to AAC-LC ~256kbps via Media3 Transformer before they go to the watch (hardware-decoded there, unlike FLAC), and passes already-compliant lossy sources (<=256kbps) through untouched. Sample rate is left to Transformer/DefaultEncoderFactory, which already clamps to the closest rate the chosen encoder supports. Not yet wired into the actual transfer flow — that lands with the batch coordinator in the next phase. --- .../data/service/wear/WatchAudioTranscoder.kt | 201 ++++++++++++++++++ .../service/wear/WatchAudioTranscoderTest.kt | 73 +++++++ 2 files changed, 274 insertions(+) create mode 100644 app/src/main/java/com/theveloper/pixelplay/data/service/wear/WatchAudioTranscoder.kt create mode 100644 app/src/test/java/com/theveloper/pixelplay/data/service/wear/WatchAudioTranscoderTest.kt 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..1226e1521 --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/wear/WatchAudioTranscoder.kt @@ -0,0 +1,201 @@ +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") + } + + private companion object { + const val TAG = "WatchAudioTranscoder" + const val TARGET_BITRATE_BPS = 256_000 + const val MAX_PASSTHROUGH_BITRATE_BPS = 256_000 + const val PROGRESS_POLL_INTERVAL_MS = 250L + val PASSTHROUGH_MIME_TYPES = setOf( + "audio/mpeg", + "audio/mp4", + "audio/aac", + "audio/mp4a-latm", + "audio/ogg", + "audio/opus", + ) + } +} 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() + } +} From ae61c4f98a6952315b1b0e387faac993fac87498 Mon Sep 17 00:00:00 2001 From: PonceGL Date: Thu, 2 Jul 2026 18:50:17 -0600 Subject: [PATCH 05/30] feat(wear-transfer): add playlist batch transfer coordinator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds PlaylistWatchTransferCoordinator: syncs a playlist's membership/ order to the watch first, then sends songs not already there one at a time (never in parallel), transcoding each via WatchAudioTranscoder when needed. Cancelling a batch stops the remaining queue but leaves already-completed songs on the watch. PhoneWatchTransferStateStore gains a PhoneWatchBatchTransferState aggregate (StateFlow>) so the UI can show one progress row per playlist transfer instead of one per song. PhoneDirectWatchTransferCoordinator gets a minimal additive hook (overrideAudioFile) so it can stream a transcoded temp file instead of the original — the single-song transfer path is unchanged when this param is omitted. --- .../PhoneDirectWatchTransferCoordinator.kt | 27 ++- .../wear/PhoneWatchTransferStateStore.kt | 136 +++++++++++ .../wear/PlaylistWatchTransferCoordinator.kt | 215 ++++++++++++++++++ .../wear/PhoneWatchTransferStateStoreTest.kt | 119 ++++++++++ .../PlaylistWatchTransferCoordinatorTest.kt | 110 +++++++++ 5 files changed, 601 insertions(+), 6 deletions(-) create mode 100644 app/src/main/java/com/theveloper/pixelplay/data/service/wear/PlaylistWatchTransferCoordinator.kt create mode 100644 app/src/test/java/com/theveloper/pixelplay/data/service/wear/PhoneWatchTransferStateStoreTest.kt create mode 100644 app/src/test/java/com/theveloper/pixelplay/data/service/wear/PlaylistWatchTransferCoordinatorTest.kt 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..92b6e53d5 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) TRANSCODED_BITRATE_BPS else (song.bitrate ?: 0), sampleRate = song.sampleRate ?: 0, isFavorite = song.isFavorite, paletteSeedArgb = paletteSeedArgb, @@ -961,5 +973,8 @@ 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" + const val TRANSCODED_BITRATE_BPS = 256_000 } } 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 a6e144789..6c9d74936 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/service/wear/PhoneWatchTransferStateStore.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/wear/PhoneWatchTransferStateStore.kt @@ -33,6 +33,33 @@ 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 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) @@ -50,8 +77,11 @@ class PhoneWatchTransferStateStore @Inject constructor() { 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, @@ -221,6 +251,112 @@ 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, + )) + } + } + + fun markBatchSongStarted(batchId: String, requestId: String, songTitle: String) { + _batchTransfers.update { map -> + val current = map[batchId] ?: return@update map + map + (batchId to current.copy( + activeRequestId = requestId, + currentSongTitle = songTitle, + currentSongProgress = 0f, + 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 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..e9076840d --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/wear/PlaylistWatchTransferCoordinator.kt @@ -0,0 +1,215 @@ +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.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 + } + + if (transferSongToAllNodes(batchId, nodes, song)) { + transferStateStore.markBatchSongCompleted(batchId) + } + } + + 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, + ): Boolean { + if (cancelledBatchIds.contains(batchId)) return 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) + }, + ) + if (transcodeResult is WatchAudioTranscoder.TranscodeResult.Failed) { + Timber.tag(TAG).w(transcodeResult.error, "Transcode failed for songId=%s, skipping", song.id) + return false + } + if (cancelledBatchIds.contains(batchId)) { + watchAudioTranscoder.cleanup(transcodeResult) + return false + } + + val overrideAudioFile = (transcodeResult as? WatchAudioTranscoder.TranscodeResult.Transcoded)?.outputFile + + var succeededOnAnyNode = false + for (node in nodes) { + if (cancelledBatchIds.contains(batchId)) break + succeededOnAnyNode = transferSongToNode(batchId, node, song, overrideAudioFile) || succeededOnAnyNode + } + + watchAudioTranscoder.cleanup(transcodeResult) + return succeededOnAnyNode + } + + private suspend fun transferSongToNode( + batchId: String, + node: Node, + song: Song, + overrideAudioFile: java.io.File?, + ): Boolean { + val requestId = UUID.randomUUID().toString() + transferStateStore.markBatchSongStarted(batchId, requestId, song.title) + + val progressWatcherJob: Job = scope.launch { + transferStateStore.transfers + .mapNotNull { it[requestId] } + .collect { state -> + if (state.status == WearTransferProgress.STATUS_TRANSFERRING) { + transferStateStore.markBatchSongProgress(batchId, state.status, state.progress) + } + } + } + + directTransferCoordinator.startTransferToWatch( + nodeId = node.id, + requestId = requestId, + songId = song.id, + overrideAudioFile = overrideAudioFile, + ) + + val finalState = transferStateStore.transfers + .mapNotNull { it[requestId] } + .first { it.status in TERMINAL_STATUSES } + progressWatcherJob.cancel() + + return finalState.status == WearTransferProgress.STATUS_COMPLETED + } + + private companion object { + const val TAG = "PlaylistWatchTransfer" + val TERMINAL_STATUSES = setOf( + WearTransferProgress.STATUS_COMPLETED, + WearTransferProgress.STATUS_FAILED, + WearTransferProgress.STATUS_CANCELLED, + ) + } +} 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..0d12c3f9f --- /dev/null +++ b/app/src/test/java/com/theveloper/pixelplay/data/service/wear/PhoneWatchTransferStateStoreTest.kt @@ -0,0 +1,119 @@ +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() + } +} 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..1ebe3b7d2 --- /dev/null +++ b/app/src/test/java/com/theveloper/pixelplay/data/service/wear/PlaylistWatchTransferCoordinatorTest.kt @@ -0,0 +1,110 @@ +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.Wearable +import com.google.common.truth.Truth.assertThat +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.unmockkStatic +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, + ) + + @BeforeEach + fun mockWearable() { + mockkStatic(Wearable::class) + } + + @AfterEach + fun unmockWearable() { + unmockkStatic(Wearable::class) + } + + 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) + } + + @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) + } +} From ff83c379317c902c9bed7bffd5f4ee58952b3554 Mon Sep 17 00:00:00 2001 From: PonceGL Date: Thu, 2 Jul 2026 19:47:55 -0600 Subject: [PATCH 06/30] feat(wear-transfer): add playlist transfer size/time estimator Pure size/time heuristic for the send-to-watch confirmation sheet, summed only over songs not already on the watch. Reuses WatchAudioTranscoder.requiresTranscoding to know whether a song counts at its own bitrate (passthrough) or the target AAC bitrate (transcoded). --- .../data/service/wear/WatchAudioTranscoder.kt | 11 +-- .../wear/WatchPlaylistTransferEstimator.kt | 56 ++++++++++++ .../WatchPlaylistTransferEstimatorTest.kt | 87 +++++++++++++++++++ 3 files changed, 149 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/com/theveloper/pixelplay/data/service/wear/WatchPlaylistTransferEstimator.kt create mode 100644 app/src/test/java/com/theveloper/pixelplay/data/service/wear/WatchPlaylistTransferEstimatorTest.kt diff --git a/app/src/main/java/com/theveloper/pixelplay/data/service/wear/WatchAudioTranscoder.kt b/app/src/main/java/com/theveloper/pixelplay/data/service/wear/WatchAudioTranscoder.kt index 1226e1521..c4a393cf9 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/service/wear/WatchAudioTranscoder.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/wear/WatchAudioTranscoder.kt @@ -184,12 +184,13 @@ class WatchAudioTranscoder @Inject constructor( return File(dir, "${songId}_$requestId.m4a") } - private companion object { - const val TAG = "WatchAudioTranscoder" + companion object { + /** Also used by [WatchPlaylistTransferEstimator] to size-estimate songs that will be transcoded. */ const val TARGET_BITRATE_BPS = 256_000 - const val MAX_PASSTHROUGH_BITRATE_BPS = 256_000 - const val PROGRESS_POLL_INTERVAL_MS = 250L - val PASSTHROUGH_MIME_TYPES = setOf( + 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", 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..2a4184b80 --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/wear/WatchPlaylistTransferEstimator.kt @@ -0,0 +1,56 @@ +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 { + + /** Conservative assumed throughput for the single Bluetooth channel used for the transfer. */ + private const val ASSUMED_TRANSFER_RATE_BYTES_PER_SEC = 200_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/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..75509b7ca --- /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 * 256_000L / 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) + } +} From c1ccf94afbf15a09700a2147ebdf2879b7acc685 Mon Sep 17 00:00:00 2001 From: PonceGL Date: Thu, 2 Jul 2026 19:47:59 -0600 Subject: [PATCH 07/30] feat(wear-transfer): wire watch playlist transfer into PlaylistViewModel Exposes reachable-watch availability, per-node free storage, a size/time estimate helper, and send/cancel entry points into PlaylistWatchTransferCoordinator so PlaylistDetailScreen can drive the whole-playlist send-to-watch flow without touching the coordinator directly. --- .../viewmodel/PlaylistViewModel.kt | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlaylistViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlaylistViewModel.kt index 8c5c620ef..163772f8a 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,13 @@ 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 dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -22,6 +29,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 +85,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,6 +101,24 @@ 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 + private val _activePlaylistBatchId = MutableStateFlow(null) + + /** Batch transfer state for whichever playlist we most recently kicked off a send for. */ + val activePlaylistBatchTransfer: StateFlow = kotlinx.coroutines.flow.combine( + watchTransferStateStore.batchTransfers, + _activePlaylistBatchId, + ) { batches, batchId -> batchId?.let { batches[it] } } + .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" @@ -1224,4 +1256,45 @@ 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 { + val batchId = playlistWatchTransferCoordinator.requestPlaylistTransfer(playlistId, playlistName, songIds) + _activePlaylistBatchId.value = batchId + return batchId + } + + fun cancelPlaylistTransfer(batchId: String) { + playlistWatchTransferCoordinator.cancelPlaylistTransfer(batchId) + } } From 0428194f078a83a3d1a97a9400a622709fea0904 Mon Sep 17 00:00:00 2001 From: PonceGL Date: Thu, 2 Jul 2026 19:51:06 -0600 Subject: [PATCH 08/30] feat(wear-transfer): add send-to-watch action to playlist options sheet Adds "Send to watch" / "Update on watch" to the playlist options bottom sheet (label flips once every song is already on a reachable watch), refreshing watch availability on entry and exposing watchSongIds from PlaylistViewModel so the label/state stay reactive. Confirmation sheet and batch progress dialog land in follow-up commits. --- .../screens/PlaylistDetailScreen.kt | 28 +++++++++++++++++++ .../viewmodel/PlaylistViewModel.kt | 1 + app/src/main/res/values/strings_library.xml | 18 ++++++++++++ app/src/main/res/values/strings_screens.xml | 2 ++ 4 files changed, 49 insertions(+) diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/PlaylistDetailScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/PlaylistDetailScreen.kt index a2b8c0c67..c70085b05 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 @@ -179,6 +179,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 +192,7 @@ fun PlaylistDetailScreen( LaunchedEffect(playlistId) { playlistViewModel.loadPlaylistDetails(playlistId) + playlistViewModel.refreshWatchAvailability() } var showAddSongsSheet by remember { mutableStateOf(false) } @@ -200,6 +203,20 @@ 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 isPlaylistOnWatch = currentPlaylist != null && + currentPlaylist.songIds.isNotEmpty() && + currentPlaylist.songIds.all { it in watchSongIds } + + LaunchedEffect(activePlaylistBatchTransfer?.batchId) { + if (activePlaylistBatchTransfer == null) { + showWatchBatchProgressDialog = false + } + } val m3uExportLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.CreateDocument("audio/x-mpegurl") @@ -885,6 +902,17 @@ 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 + playlistViewModel.refreshWatchAvailability() + showSendToWatchSheet = true + } + ) + } } } } 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 163772f8a..6626ba238 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 @@ -106,6 +106,7 @@ class PlaylistViewModel @Inject constructor( private val _isRefreshingWatchAvailability = MutableStateFlow(false) val watchFreeStorageBytesByNodeId: StateFlow> = watchTransferStateStore.watchFreeStorageBytesByNodeId + val watchSongIds: StateFlow> = watchTransferStateStore.watchSongIds private val _activePlaylistBatchId = MutableStateFlow(null) /** Batch transfer state for whichever playlist we most recently kicked off a send for. */ diff --git a/app/src/main/res/values/strings_library.xml b/app/src/main/res/values/strings_library.xml index 8b7c6c2c6..2ac8b4430 100644 --- a/app/src/main/res/values/strings_library.xml +++ b/app/src/main/res/values/strings_library.xml @@ -285,8 +285,26 @@ Starting transfer… Starting Transferring + Converting audio %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. + Sending “%1$s” + %1$d / %2$d songs + Sending playlist %1$s (%2$d/%3$d) + 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 From 99a478ec386ee9cf960eb219a2417da74d867704 Mon Sep 17 00:00:00 2001 From: PonceGL Date: Thu, 2 Jul 2026 19:54:35 -0600 Subject: [PATCH 09/30] feat(wear-transfer): add send-to-watch confirmation + batch progress UI SendPlaylistToWatchSheet shows song count, estimated size/time from WatchPlaylistTransferEstimator, and blocks confirm when there's no reachable watch or the estimate exceeds the watch's free storage. WatchPlaylistBatchProgressDialog mirrors the existing single-song transfer dialog but drives off PhoneWatchBatchTransferState (aggregate progress, current song, cancel), auto-hiding once the batch clears. --- .../screens/PlaylistDetailScreen.kt | 290 +++++++++++++++++- 1 file changed, 289 insertions(+), 1 deletion(-) 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 c70085b05..cf85ae4a7 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 @@ -208,6 +222,7 @@ fun PlaylistDetailScreen( 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.all { it in watchSongIds } @@ -916,7 +931,35 @@ fun PlaylistDetailScreen( } } } - + + 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 = { + 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 @@ -1180,3 +1223,248 @@ 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 + ) + } 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)) + } + } + } + } +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private 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_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) + } + + 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 + ) + } + TextButton(onClick = { + onCancelTransfer() + onDismiss() + }) { + Text(stringResource(R.string.watch_transfer_action_cancel)) + } + } + } + } +} From 4c9f547373915eccd1ddc4ed9f9968515caa5792 Mon Sep 17 00:00:00 2001 From: PonceGL Date: Thu, 2 Jul 2026 19:55:52 -0600 Subject: [PATCH 10/30] feat(wear-transfer): show playlist batch progress in transfer notification WatchTransferForegroundService now observes batchTransfers alongside the existing per-song transfers map and, whenever a playlist batch is active, shows "Sending playlist X (12/40)" with aggregate progress instead of listing individual songs. Falls back to the prior per-song notification when there's no active batch. --- .../wear/WatchTransferForegroundService.kt | 115 +++++++++++++++--- 1 file changed, 99 insertions(+), 16 deletions(-) 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..73e3c6250 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/service/wear/WatchTransferForegroundService.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/wear/WatchTransferForegroundService.kt @@ -24,6 +24,7 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch import timber.log.Timber @@ -43,7 +44,10 @@ class WatchTransferForegroundService : Service() { } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - val notification = buildNotification(transferStateStore.transfers.value.values.toList()) + val notification = buildNotification( + transferStateStore.transfers.value.values.toList(), + transferStateStore.batchTransfers.value.values.toList(), + ) if (!hasStartedForeground) { startInForeground(notification) } else { @@ -63,21 +67,24 @@ class WatchTransferForegroundService : Service() { private fun observeTransfers() { transferObserverJob?.cancel() transferObserverJob = serviceScope.launch { - transferStateStore.transfers.collect { transfers -> - val states = transfers.values.toList() - if (states.isEmpty()) { - stopForegroundCompat() - stopSelf() - return@collect - } - - val notification = buildNotification(states) - if (!hasStartedForeground) { - startInForeground(notification) - } else { - notificationManager().notify(NOTIFICATION_ID, notification) + combine( + transferStateStore.transfers, + transferStateStore.batchTransfers, + ) { transfers, batches -> transfers.values.toList() to batches.values.toList() } + .collect { (states, batches) -> + if (states.isEmpty() && batches.isEmpty()) { + stopForegroundCompat() + stopSelf() + return@collect + } + + val notification = buildNotification(states, batches) + if (!hasStartedForeground) { + startInForeground(notification) + } else { + notificationManager().notify(NOTIFICATION_ID, notification) + } } - } } } @@ -110,7 +117,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 } From 63a1b57687faed7ec63f07e1ad629cc1466cfc67 Mon Sep 17 00:00:00 2001 From: PonceGL Date: Thu, 2 Jul 2026 20:14:11 -0600 Subject: [PATCH 11/30] fix(wear): wire missing androidx.test:runner instrumentation dependency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wear/build.gradle.kts sets testInstrumentationRunner to androidx.test.runner.AndroidJUnitRunner, but nothing on the androidTest classpath actually provided that class (androidx.test.ext:junit only pulls in androidx.test:monitor) — any connectedAndroidTest run in this module crashed with ClassNotFoundException before a single test could execute. The app module works only because espresso/compose-ui-test happen to pull androidx.test:runner transitively. --- gradle/libs.versions.toml | 1 + wear/build.gradle.kts | 1 + 2 files changed, 2 insertions(+) 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/wear/build.gradle.kts b/wear/build.gradle.kts index 0145a9c06..a6edea5a2 100644 --- a/wear/build.gradle.kts +++ b/wear/build.gradle.kts @@ -142,6 +142,7 @@ dependencies { 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) From 28a6983be8ba67a68cbb1aa047e0e1ca02762cd3 Mon Sep 17 00:00:00 2001 From: PonceGL Date: Thu, 2 Jul 2026 20:14:18 -0600 Subject: [PATCH 12/30] feat(wear-transfer): add Room schema for local playlist snapshots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LocalPlaylistEntity + LocalPlaylistSongCrossRef store a playlist snapshot and song order on the watch; song availability is resolved by joining against LocalSongDao at read time rather than duplicated here, since a playlist can exist before (or without) all of its songs having finished transferring. LocalPlaylistDao.upsertPlaylist replaces the full cross-ref set per sync so re-sending an updated playlist always reflects the latest order instead of merging with stale rows. Also wires MIGRATION_1_2..MIGRATION_5_6 into the Room builder in WearModule — they were defined but never passed to addMigrations(), so any upgrade of an existing watch install would have crashed with "migration not found" the moment this database was opened. Verified against a real Wear OS emulator (connectedDebugAndroidTest). --- .../data/local/LocalPlaylistDaoTest.kt | 85 +++++++++++++++++++ .../pixelplay/data/local/LocalPlaylistDao.kt | 42 +++++++++ .../data/local/LocalPlaylistEntity.kt | 17 ++++ .../data/local/LocalPlaylistSongCrossRef.kt | 14 +++ .../pixelplay/data/local/WearMusicDatabase.kt | 26 +++++- .../com/theveloper/pixelplay/di/WearModule.kt | 12 +++ 6 files changed, 195 insertions(+), 1 deletion(-) create mode 100644 wear/src/androidTest/java/com/theveloper/pixelplay/data/local/LocalPlaylistDaoTest.kt create mode 100644 wear/src/main/java/com/theveloper/pixelplay/data/local/LocalPlaylistDao.kt create mode 100644 wear/src/main/java/com/theveloper/pixelplay/data/local/LocalPlaylistEntity.kt create mode 100644 wear/src/main/java/com/theveloper/pixelplay/data/local/LocalPlaylistSongCrossRef.kt 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/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..bcf956d35 --- /dev/null +++ b/wear/src/main/java/com/theveloper/pixelplay/data/local/LocalPlaylistDao.kt @@ -0,0 +1,42 @@ +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("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> +} 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() } From cb85fbbc1a7a5e5e0764f5e15f4ba3d9f0a5de81 Mon Sep 17 00:00:00 2001 From: PonceGL Date: Thu, 2 Jul 2026 20:20:24 -0600 Subject: [PATCH 13/30] feat(wear-transfer): receive playlist sync from the phone WearDataListenerService now handles WearDataPaths.PLAYLIST_SYNC, deserializing WearPlaylistSync and delegating to the new WearTransferRepository.onPlaylistSyncReceived, which upserts the playlist snapshot via LocalPlaylistDao. Song availability is still resolved separately by joining against LocalSongDao, so the playlist becomes visible immediately even before any of its songs finish transferring. Preserves the original createdAt across re-syncs (e.g. "update on watch" after adding a song) while always bumping updatedAt and replacing the song order outright, matching the snapshot-not-merge model already used by LocalPlaylistDao.upsertPlaylist. Verified: :wear:testDebugUnitTest (new WearTransferRepositoryPlaylistSyncTest, mocks LocalPlaylistDao so no Room/instrumentation needed) and a manual install + launch on the Wear_OS_XL_Round emulator with no crash. --- .../pixelplay/data/WearDataListenerService.kt | 18 ++++ .../pixelplay/data/WearTransferRepository.kt | 32 +++++++ .../pixelplay/data/local/LocalPlaylistDao.kt | 3 + .../WearTransferRepositoryPlaylistSyncTest.kt | 88 +++++++++++++++++++ 4 files changed, 141 insertions(+) create mode 100644 wear/src/test/java/com/theveloper/pixelplay/data/WearTransferRepositoryPlaylistSyncTest.kt 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..553081414 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 @@ -314,6 +315,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) 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 4043855f6..708c1b525 100644 --- a/wear/src/main/java/com/theveloper/pixelplay/data/WearTransferRepository.kt +++ b/wear/src/main/java/com/theveloper/pixelplay/data/WearTransferRepository.kt @@ -7,10 +7,14 @@ import android.webkit.MimeTypeMap import com.google.android.gms.wearable.ChannelClient import com.google.android.gms.wearable.MessageClient import com.google.android.gms.wearable.NodeClient +import com.theveloper.pixelplay.data.local.LocalPlaylistDao +import com.theveloper.pixelplay.data.local.LocalPlaylistEntity +import com.theveloper.pixelplay.data.local.LocalPlaylistSongCrossRef import com.theveloper.pixelplay.data.local.LocalSongDao import com.theveloper.pixelplay.data.local.LocalSongEntity import com.theveloper.pixelplay.shared.WearDataPaths import com.theveloper.pixelplay.shared.WearLibraryState +import com.theveloper.pixelplay.shared.WearPlaylistSync import com.theveloper.pixelplay.shared.WearTransferMetadata import com.theveloper.pixelplay.shared.WearTransferProgress import com.theveloper.pixelplay.shared.WearTransferRequest @@ -71,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, @@ -870,6 +875,33 @@ 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 } 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 index bcf956d35..20a17956f 100644 --- a/wear/src/main/java/com/theveloper/pixelplay/data/local/LocalPlaylistDao.kt +++ b/wear/src/main/java/com/theveloper/pixelplay/data/local/LocalPlaylistDao.kt @@ -28,6 +28,9 @@ interface LocalPlaylistDao { @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) 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") + } +} From df7001358b1a0c3ba5d43d82b72bde16ed47d774 Mon Sep 17 00:00:00 2001 From: PonceGL Date: Thu, 2 Jul 2026 20:43:53 -0600 Subject: [PATCH 14/30] feat(wear-transfer): add local playlists UI to the watch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New LocalPlaylistsScreen (list) and LocalPlaylistDetailScreen (song order), backed by WearLocalPlaylistViewModel, which reactively joins LocalPlaylistDao's synced song order against LocalSongDao.getAllSongs. Songs still in flight show as a dimmed, non-tappable "Waiting to transfer" row with a cloud icon; a song that finishes downloading while the screen is open flips to playable without navigating away and back. "Play all" and per-song taps build the ExoPlayer queue from only the already-transferred songs, skipping pending ones outright rather than leaving gaps. Entry point is a "Playlists" chip on DownloadsScreen (new onPlaylistsClick param), not the existing BROWSE/LIBRARY_LIST/SONG_LIST routes — those are wired to the phone's live remote library, not this local Room snapshot. Verified: new WearLocalPlaylistViewModelTest (availability join, playAll/playFrom queue building) plus manual verification on the Wear_OS_XL_Round emulator with seeded playlist/song rows — confirmed both screens render correctly (title, song count, Play all, available vs. pending rows) with no crash. Touch input via `adb shell input tap` doesn't register on this emulator image (environment issue, not code — swipe gestures work fine), so navigation was exercised by temporarily swapping the nav graph's start destination rather than tapping through from Downloads; that temporary change is not part of this commit. --- .../pixelplay/presentation/WearNavigation.kt | 41 +++- .../presentation/screens/DownloadsScreen.kt | 30 +++ .../screens/LocalPlaylistDetailScreen.kt | 230 ++++++++++++++++++ .../screens/LocalPlaylistsScreen.kt | 145 +++++++++++ .../viewmodel/WearLocalPlaylistViewModel.kt | 95 ++++++++ wear/src/main/res/values/strings_wear.xml | 11 + .../pixelplay/MainCoroutineExtension.kt | 24 ++ .../WearLocalPlaylistViewModelTest.kt | 139 +++++++++++ 8 files changed, 714 insertions(+), 1 deletion(-) create mode 100644 wear/src/main/java/com/theveloper/pixelplay/presentation/screens/LocalPlaylistDetailScreen.kt create mode 100644 wear/src/main/java/com/theveloper/pixelplay/presentation/screens/LocalPlaylistsScreen.kt create mode 100644 wear/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/WearLocalPlaylistViewModel.kt create mode 100644 wear/src/test/java/com/theveloper/pixelplay/MainCoroutineExtension.kt create mode 100644 wear/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/WearLocalPlaylistViewModelTest.kt diff --git a/wear/src/main/java/com/theveloper/pixelplay/presentation/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/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..0c0906be8 --- /dev/null +++ b/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/LocalPlaylistDetailScreen.kt @@ -0,0 +1,230 @@ +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.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.Icon +import androidx.wear.compose.material.MaterialTheme +import androidx.wear.compose.material.Text +import com.google.android.horologist.compose.layout.ScalingLazyColumn +import com.google.android.horologist.compose.layout.rememberResponsiveColumnState +import com.theveloper.pixelplay.R +import com.theveloper.pixelplay.presentation.components.AlwaysOnScalingPositionIndicator +import com.theveloper.pixelplay.presentation.components.PlayingEqIcon +import com.theveloper.pixelplay.presentation.components.WearTopTimeText +import com.theveloper.pixelplay.presentation.theme.LocalWearPalette +import com.theveloper.pixelplay.presentation.theme.screenBackgroundColor +import com.theveloper.pixelplay.presentation.theme.surfaceContainerColor +import com.theveloper.pixelplay.presentation.theme.surfaceContainerHighColor +import com.theveloper.pixelplay.presentation.viewmodel.WearLocalPlaylistViewModel +import com.theveloper.pixelplay.presentation.viewmodel.WearPlayerViewModel + +/** + * 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 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 { + Chip( + label = { + Text( + text = stringResource(R.string.wear_song_pending_transfer), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = palette.textSecondary.copy(alpha = 0.6f), + ) + }, + icon = { + 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..f75bb8120 --- /dev/null +++ b/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/LocalPlaylistsScreen.kt @@ -0,0 +1,145 @@ +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.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 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] + Chip( + label = { + Text( + text = playlist.name, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = palette.textPrimary, + ) + }, + icon = { + 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/viewmodel/WearLocalPlaylistViewModel.kt b/wear/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/WearLocalPlaylistViewModel.kt new file mode 100644 index 000000000..c98bb27c0 --- /dev/null +++ b/wear/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/WearLocalPlaylistViewModel.kt @@ -0,0 +1,95 @@ +package com.theveloper.pixelplay.presentation.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.theveloper.pixelplay.data.WearLocalPlayerRepository +import com.theveloper.pixelplay.data.WearOutputTarget +import com.theveloper.pixelplay.data.WearStateRepository +import com.theveloper.pixelplay.data.local.LocalPlaylistDao +import com.theveloper.pixelplay.data.local.LocalPlaylistEntity +import com.theveloper.pixelplay.data.local.LocalSongDao +import com.theveloper.pixelplay.data.local.LocalSongEntity +import 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, +) : ViewModel() { + + val playlists: StateFlow> = localPlaylistDao.observePlaylists() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), emptyList()) + + private val _playlistId = MutableStateFlow(null) + + val playlistDetails: StateFlow = combine( + playlists, + _playlistId, + ) { allPlaylists, playlistId -> + allPlaylists.find { it.playlistId == playlistId } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), null) + + val playlistSongs: StateFlow> = _playlistId + .flatMapLatest { playlistId -> + if (playlistId == null) { + flowOf(emptyList()) + } else { + combine( + localPlaylistDao.observePlaylistSongs(playlistId), + localSongDao.getAllSongs(), + ) { crossRefs, allSongs -> + val songsById = allSongs.associateBy { it.songId } + crossRefs.map { ref -> WearLocalPlaylistSongItem(ref.songId, songsById[ref.songId]) } + } + } + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), emptyList()) + + fun loadPlaylist(playlistId: String) { + if (_playlistId.value == playlistId) return + _playlistId.value = playlistId + } + + /** Plays every available (already-transferred) song in order, from the start. */ + fun playAll() { + val available = playlistSongs.value.mapNotNull { it.song } + if (available.isEmpty()) return + localPlayerRepository.playLocalSongs(available, startIndex = 0) + stateRepository.setOutputTarget(WearOutputTarget.WATCH) + } + + /** Plays every available song, starting from [songId] — pending songs aren't tappable. */ + fun playFrom(songId: String) { + val available = playlistSongs.value.mapNotNull { it.song } + val startIndex = available.indexOfFirst { it.songId == songId } + if (startIndex == -1) return + localPlayerRepository.playLocalSongs(available, startIndex = startIndex) + stateRepository.setOutputTarget(WearOutputTarget.WATCH) + } +} diff --git a/wear/src/main/res/values/strings_wear.xml b/wear/src/main/res/values/strings_wear.xml index 454dcf99c..140173f95 100644 --- a/wear/src/main/res/values/strings_wear.xml +++ b/wear/src/main/res/values/strings_wear.xml @@ -77,4 +77,15 @@ Saving… Save to watch Confirm save + + + 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… 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/presentation/viewmodel/WearLocalPlaylistViewModelTest.kt b/wear/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/WearLocalPlaylistViewModelTest.kt new file mode 100644 index 000000000..0aca76b3f --- /dev/null +++ b/wear/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/WearLocalPlaylistViewModelTest.kt @@ -0,0 +1,139 @@ +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.WearLocalPlayerRepository +import com.theveloper.pixelplay.data.WearOutputTarget +import com.theveloper.pixelplay.data.WearStateRepository +import com.theveloper.pixelplay.data.local.LocalPlaylistDao +import com.theveloper.pixelplay.data.local.LocalPlaylistSongCrossRef +import com.theveloper.pixelplay.data.local.LocalSongDao +import com.theveloper.pixelplay.data.local.LocalSongEntity +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +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 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) = + LocalPlaylistSongCrossRef(playlistId = "p1", songId = songId, position = position) + + private fun buildViewModel() = WearLocalPlaylistViewModel( + localPlaylistDao = localPlaylistDao, + localSongDao = localSongDao, + localPlayerRepository = localPlayerRepository, + stateRepository = stateRepository, + ) + + @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()) } + } +} From 16e5227e3f2cc946cd49dc3ba5d91a13b083fb24 Mon Sep 17 00:00:00 2001 From: PonceGL Date: Fri, 3 Jul 2026 19:38:04 -0600 Subject: [PATCH 15/30] feat(wear-transfer): auto-skip and surface error on local playback failure WearLocalPlayerRepository.playerListener had no onPlayerError override, so a song that failed to decode (more likely now that heterogeneous transcoded content arrives in batches) would just hang playback. Skip to the next queued song automatically and expose a transient message via a new localPlaybackError StateFlow, shown as a short banner in PlayerScreen. --- .../data/WearLocalPlayerRepository.kt | 32 +++++++++++++++++ .../presentation/screens/PlayerScreen.kt | 36 +++++++++++++++++++ .../viewmodel/WearPlayerViewModel.kt | 6 ++++ 3 files changed, 74 insertions(+) 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..f70aadec3 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() @@ -153,6 +158,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 +354,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 +523,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/presentation/screens/PlayerScreen.kt b/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/PlayerScreen.kt index 057842960..cd5ad82a8 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 @@ -155,6 +155,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 +173,8 @@ fun PlayerScreen( onOutputClick = onOutputClick, onMoreClick = onMoreClick, onQueueClick = onQueueClick, + localPlaybackError = localPlaybackError, + onConsumeLocalPlaybackError = viewModel::consumeLocalPlaybackError, ) } @@ -192,6 +195,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() @@ -311,6 +316,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, + ) + } + } } } 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( From 89df88a68014d84d0e8d41b488d1ae4b176da5ba Mon Sep 17 00:00:00 2001 From: PonceGL Date: Fri, 3 Jul 2026 21:31:04 -0600 Subject: [PATCH 16/30] fix(wear-transfer): declare /playlist_sync in the wear listener manifest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WearDataListenerService already handled WearDataPaths.PLAYLIST_SYNC in code (Fase 6), but the intent-filter never listed it, so Play Services silently dropped every playlist-sync message before it reached the app — individual songs still transferred fine over their own declared paths, which is why this only showed up as "songs arrive but no playlist groups them." Also renames the local-playlists label to "My Playlists" to disambiguate it from the pre-existing (and unrelated) remote "Playlists" browse chip. --- wear/src/main/AndroidManifest.xml | 4 ++++ wear/src/main/res/values/strings_wear.xml | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/wear/src/main/AndroidManifest.xml b/wear/src/main/AndroidManifest.xml index 4a049d1d6..1b06e4246 100644 --- a/wear/src/main/AndroidManifest.xml +++ b/wear/src/main/AndroidManifest.xml @@ -112,6 +112,10 @@ android:scheme="wear" android:host="*" android:pathPrefix="/volume_state" /> + diff --git a/wear/src/main/res/values/strings_wear.xml b/wear/src/main/res/values/strings_wear.xml index 140173f95..4832585f8 100644 --- a/wear/src/main/res/values/strings_wear.xml +++ b/wear/src/main/res/values/strings_wear.xml @@ -79,7 +79,7 @@ Confirm save - Playlists + My Playlists No playlists sent yet Send a playlist from the phone app to see it here. From 553d59e02124b46bdbc5acbfeae07a4828c80517 Mon Sep 17 00:00:00 2001 From: PonceGL Date: Fri, 3 Jul 2026 21:31:17 -0600 Subject: [PATCH 17/30] feat(wear-transfer): make the watch's own ack the source of truth for transfer completion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The phone was marking a song "present on watch" purely because it finished writing its own send stream, never waiting for the watch to actually validate and finalize the file. If the connection dropped right after, the phone's belief could drift from reality with no automatic correction (the only reconciliation path, refreshWatchLibraryState, only fires from specific UI screens). Now the phone records a local-only STATUS_AWAITING_WATCH_ACK instead of optimistically completing, and the watch reports its real, validated outcome back over the existing /transfer_progress path (reusing and generalizing notifyPhoneTransferFailure into notifyPhoneTransferResult, now also used for the success case). WearCommandReceiver previously had no case for that path at all and silently dropped it — this also revives the existing-but-dead duplicate-rejection flow as a side effect. --- .../PhoneDirectWatchTransferCoordinator.kt | 17 +++-- .../wear/PhoneWatchTransferStateStore.kt | 28 +++++++ .../data/service/wear/WearCommandReceiver.kt | 52 +++++++++++++ .../wear/PhoneWatchTransferStateStoreTest.kt | 74 +++++++++++++++++++ .../pixelplay/shared/WearTransferProgress.kt | 8 ++ .../pixelplay/data/WearTransferRepository.kt | 64 ++++++++++++---- 6 files changed, 223 insertions(+), 20 deletions(-) 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 92b6e53d5..5b64234c8 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 @@ -932,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, 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 6c9d74936..4b0d57f23 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 @@ -44,6 +45,8 @@ data class PhoneWatchBatchTransferState( 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, @@ -141,6 +144,7 @@ class PhoneWatchTransferStateStore @Inject constructor() { totalBytes: Long, status: String, error: String? = null, + errorCode: String? = null, songTitle: String? = null, ) { _transfers.update { map -> @@ -154,6 +158,7 @@ class PhoneWatchTransferStateStore @Inject constructor() { totalBytes = maxOf(current.totalBytes, totalBytes), status = status, error = error, + errorCode = errorCode, updatedAtMillis = now, ) } else { @@ -165,6 +170,7 @@ class PhoneWatchTransferStateStore @Inject constructor() { totalBytes = totalBytes, status = status, error = error, + errorCode = errorCode, updatedAtMillis = now, ) } @@ -211,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 -> @@ -299,6 +314,19 @@ class PhoneWatchTransferStateStore @Inject constructor() { } } + 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 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 b539c8b8b..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 @@ -98,11 +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 { 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 index 0d12c3f9f..83d697b79 100644 --- 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 @@ -116,4 +116,78 @@ class PhoneWatchTransferStateStoreTest { 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/shared/src/main/java/com/theveloper/pixelplay/shared/WearTransferProgress.kt b/shared/src/main/java/com/theveloper/pixelplay/shared/WearTransferProgress.kt index 0a2af69b5..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,6 +14,7 @@ data class WearTransferProgress( val totalBytes: Long, val status: String, val error: String? = null, + val errorCode: String? = null, ) { companion object { const val STATUS_TRANSCODING = "transcoding" @@ -21,6 +22,13 @@ data class WearTransferProgress( 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/wear/src/main/java/com/theveloper/pixelplay/data/WearTransferRepository.kt b/wear/src/main/java/com/theveloper/pixelplay/data/WearTransferRepository.kt index 708c1b525..1be97c67f 100644 --- a/wear/src/main/java/com/theveloper/pixelplay/data/WearTransferRepository.kt +++ b/wear/src/main/java/com/theveloper/pixelplay/data/WearTransferRepository.kt @@ -128,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() @@ -181,10 +183,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 @@ -282,16 +285,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 @@ -302,8 +313,9 @@ class WearTransferRepository @Inject constructor( songId = songId, bytesTransferred = 0L, totalBytes = 0L, - status = WearTransferProgress.STATUS_FAILED, + status = status, error = message, + errorCode = errorCode, ) messageClient.sendMessage( targetNodeId, @@ -311,7 +323,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") } } @@ -322,6 +334,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 @@ -337,12 +350,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, @@ -429,6 +438,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 -> @@ -769,6 +779,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() @@ -986,9 +1006,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] @@ -1007,6 +1033,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( From 8dfefb96b4484fa107016ffbf8e0129fd4899433 Mon Sep 17 00:00:00 2001 From: PonceGL Date: Fri, 3 Jul 2026 21:41:06 -0600 Subject: [PATCH 18/30] feat(wear-transfer): bound how long a stuck song transfer can hang the batch PlaylistWatchTransferCoordinator.transferSongToNode awaited a terminal transfer status forever with no backstop. If the watch died or dropped off before ever sending an ack (its own 120s idle watchdog never even got to fire), the whole batch would hang indefinitely on that one song instead of failing it and moving on. Adds a 5-minute timeout (generous relative to the watch's 120s watchdog, to tolerate slow transcoding + slow Bluetooth) as a fallback; on expiry the song is marked failed with a distinct "timed out" error code and the batch continues with the next song, same as the existing cancel behavior. --- .../wear/PlaylistWatchTransferCoordinator.kt | 77 +++++++++++++++---- .../PlaylistWatchTransferCoordinatorTest.kt | 70 +++++++++++++++++ 2 files changed, 132 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/data/service/wear/PlaylistWatchTransferCoordinator.kt b/app/src/main/java/com/theveloper/pixelplay/data/service/wear/PlaylistWatchTransferCoordinator.kt index e9076840d..9023b5bdc 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/service/wear/PlaylistWatchTransferCoordinator.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/wear/PlaylistWatchTransferCoordinator.kt @@ -23,6 +23,7 @@ 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 @@ -120,8 +121,11 @@ class PlaylistWatchTransferCoordinator @Inject constructor( continue } - if (transferSongToAllNodes(batchId, nodes, song)) { + val outcome = transferSongToAllNodes(batchId, nodes, song) + if (outcome.completed) { transferStateStore.markBatchSongCompleted(batchId) + } else { + transferStateStore.markBatchSongFailed(batchId, outcome.errorCode) } } @@ -136,8 +140,8 @@ class PlaylistWatchTransferCoordinator @Inject constructor( batchId: String, nodes: List, song: Song, - ): Boolean { - if (cancelledBatchIds.contains(batchId)) return false + ): SongTransferResult { + if (cancelledBatchIds.contains(batchId)) return SongTransferResult(completed = false) val transcodeRequestId = UUID.randomUUID().toString() transferStateStore.markBatchSongStarted(batchId, transcodeRequestId, song.title) @@ -151,23 +155,36 @@ class PlaylistWatchTransferCoordinator @Inject constructor( ) if (transcodeResult is WatchAudioTranscoder.TranscodeResult.Failed) { Timber.tag(TAG).w(transcodeResult.error, "Transcode failed for songId=%s, skipping", song.id) - return false + return SongTransferResult(completed = false, errorCode = WearTransferProgress.ERROR_CODE_GENERIC) } if (cancelledBatchIds.contains(batchId)) { watchAudioTranscoder.cleanup(transcodeResult) - return false + return SongTransferResult(completed = false) } val overrideAudioFile = (transcodeResult as? WatchAudioTranscoder.TranscodeResult.Transcoded)?.outputFile + // 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 - succeededOnAnyNode = transferSongToNode(batchId, node, song, overrideAudioFile) || succeededOnAnyNode + val nodeOutcome = transferSongToNode(batchId, node, song, overrideAudioFile) + if (nodeOutcome.completed) { + succeededOnAnyNode = true + } else { + lastFailureErrorCode = nodeOutcome.errorCode + } } watchAudioTranscoder.cleanup(transcodeResult) - return succeededOnAnyNode + return SongTransferResult( + completed = succeededOnAnyNode, + errorCode = if (succeededOnAnyNode) null else lastFailureErrorCode, + ) } private suspend fun transferSongToNode( @@ -175,7 +192,7 @@ class PlaylistWatchTransferCoordinator @Inject constructor( node: Node, song: Song, overrideAudioFile: java.io.File?, - ): Boolean { + ): SongTransferResult { val requestId = UUID.randomUUID().toString() transferStateStore.markBatchSongStarted(batchId, requestId, song.title) @@ -196,17 +213,47 @@ class PlaylistWatchTransferCoordinator @Inject constructor( overrideAudioFile = overrideAudioFile, ) - val finalState = transferStateStore.transfers - .mapNotNull { it[requestId] } - .first { it.status in TERMINAL_STATUSES } + val finalState = withTimeoutOrNull(SONG_TRANSFER_AWAIT_TIMEOUT_MS) { + transferStateStore.transfers + .mapNotNull { it[requestId] } + .first { it.status in TERMINAL_STATUSES } + } progressWatcherJob.cancel() - return finalState.status == WearTransferProgress.STATUS_COMPLETED + 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 companion object { - const val TAG = "PlaylistWatchTransfer" - val TERMINAL_STATUSES = setOf( + private data class SongTransferResult(val completed: Boolean, val errorCode: String? = null) + + internal companion object { + private const val TAG = "PlaylistWatchTransfer" + // 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/test/java/com/theveloper/pixelplay/data/service/wear/PlaylistWatchTransferCoordinatorTest.kt b/app/src/test/java/com/theveloper/pixelplay/data/service/wear/PlaylistWatchTransferCoordinatorTest.kt index 1ebe3b7d2..a0252ccca 100644 --- a/app/src/test/java/com/theveloper/pixelplay/data/service/wear/PlaylistWatchTransferCoordinatorTest.kt +++ b/app/src/test/java/com/theveloper/pixelplay/data/service/wear/PlaylistWatchTransferCoordinatorTest.kt @@ -7,8 +7,10 @@ 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 @@ -16,6 +18,7 @@ import io.mockk.every import io.mockk.mockk import io.mockk.mockkStatic 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 @@ -38,6 +41,8 @@ class PlaylistWatchTransferCoordinatorTest { transferStateStore = transferStateStore, ) + private val originalAwaitTimeoutMs = PlaylistWatchTransferCoordinator.SONG_TRANSFER_AWAIT_TIMEOUT_MS + @BeforeEach fun mockWearable() { mockkStatic(Wearable::class) @@ -46,6 +51,7 @@ class PlaylistWatchTransferCoordinatorTest { @AfterEach fun unmockWearable() { unmockkStatic(Wearable::class) + PlaylistWatchTransferCoordinator.SONG_TRANSFER_AWAIT_TIMEOUT_MS = originalAwaitTimeoutMs } private fun stubNoReachableWatches() { @@ -56,6 +62,36 @@ class PlaylistWatchTransferCoordinatorTest { 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()) @@ -107,4 +143,38 @@ class PlaylistWatchTransferCoordinatorTest { 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() + } + } } From 1c2865572cda7fde7b56e2bd4b109a93ebd1b84c Mon Sep 17 00:00:00 2001 From: PonceGL Date: Fri, 3 Jul 2026 21:42:38 -0600 Subject: [PATCH 19/30] feat(wear-transfer): detect low watch storage proactively during receive Previously a full disk only surfaced as whatever generic IOException the write happened to throw, with no distinct signal for the phone. Now the receive loop checks remaining free space against the bytes still needed every watchdog tick and fails early with ERROR_CODE_INSUFFICIENT_STORAGE before the OS throws, so the phone can show a specific "not enough space" message instead of a generic failure. Also tags the existing idle-timeout watchdog failure with ERROR_CODE_CONNECTION_LOST so the phone can distinguish it from other failure reasons too. --- .../pixelplay/data/WearTransferRepository.kt | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) 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 1be97c67f..efbbcf50a 100644 --- a/wear/src/main/java/com/theveloper/pixelplay/data/WearTransferRepository.kt +++ b/wear/src/main/java/com/theveloper/pixelplay/data/WearTransferRepository.kt @@ -141,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 } @@ -533,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(), @@ -578,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(), @@ -588,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( @@ -1127,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, + ) } } } From 16c217e854c73b060a44ea5e47b92460bfce8ee9 Mon Sep 17 00:00:00 2001 From: PonceGL Date: Fri, 3 Jul 2026 21:48:08 -0600 Subject: [PATCH 20/30] feat(wear-transfer): show receive progress on the local playlists screens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LocalPlaylistsScreen and LocalPlaylistDetailScreen had no indication a transfer was in progress — the only feedback lived on DownloadsScreen and SongListScreen. Reuses the same WearTransferRepository.activeTransfers StateFlow and spinner+percentage pattern already established there instead of inventing new state: a playlist row shows "Receiving..." while any of its songs are actively transferring, and a pending song row shows live percentage instead of a static "waiting" label once its transfer starts. --- .../pixelplay/data/local/LocalPlaylistDao.kt | 4 ++ .../screens/LocalPlaylistDetailScreen.kt | 42 +++++++++++--- .../screens/LocalPlaylistsScreen.kt | 36 ++++++++++-- .../viewmodel/WearLocalPlaylistViewModel.kt | 19 +++++++ wear/src/main/res/values/strings_wear.xml | 1 + .../WearLocalPlaylistViewModelTest.kt | 56 ++++++++++++++++--- 6 files changed, 138 insertions(+), 20 deletions(-) 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 index 20a17956f..3f41b48ab 100644 --- a/wear/src/main/java/com/theveloper/pixelplay/data/local/LocalPlaylistDao.kt +++ b/wear/src/main/java/com/theveloper/pixelplay/data/local/LocalPlaylistDao.kt @@ -42,4 +42,8 @@ interface LocalPlaylistDao { @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/presentation/screens/LocalPlaylistDetailScreen.kt b/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/LocalPlaylistDetailScreen.kt index 0c0906be8..6fc58f50e 100644 --- a/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/LocalPlaylistDetailScreen.kt +++ b/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/LocalPlaylistDetailScreen.kt @@ -16,6 +16,7 @@ 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 @@ -28,9 +29,11 @@ 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 @@ -43,6 +46,7 @@ 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 @@ -60,6 +64,10 @@ fun LocalPlaylistDetailScreen( 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() @@ -185,22 +193,42 @@ fun LocalPlaylistDetailScreen( modifier = Modifier.fillMaxWidth(), ) } else { + val activeTransfer = activeTransferBySongId[item.songId] + val isReceiving = activeTransfer?.status == WearTransferProgress.STATUS_TRANSFERRING Chip( label = { Text( - text = stringResource(R.string.wear_song_pending_transfer), + 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 = { - Icon( - imageVector = Icons.Rounded.CloudDownload, - contentDescription = null, - tint = palette.textSecondary.copy(alpha = 0.6f), - modifier = Modifier.size(18.dp), - ) + 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, 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 index f75bb8120..1a5a6ecf9 100644 --- a/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/LocalPlaylistsScreen.kt +++ b/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/LocalPlaylistsScreen.kt @@ -23,6 +23,7 @@ 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 @@ -46,6 +47,7 @@ fun LocalPlaylistsScreen( 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() @@ -101,6 +103,7 @@ fun LocalPlaylistsScreen( } else { items(playlists.size) { index -> val playlist = playlists[index] + val isReceiving = playlist.playlistId in playlistIdsReceiving Chip( label = { Text( @@ -110,13 +113,34 @@ fun LocalPlaylistsScreen( 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 = { - Icon( - imageVector = Icons.AutoMirrored.Rounded.QueueMusic, - contentDescription = null, - tint = palette.textSecondary, - modifier = Modifier.size(18.dp), - ) + 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( 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 index c98bb27c0..85305966f 100644 --- a/wear/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/WearLocalPlaylistViewModel.kt +++ b/wear/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/WearLocalPlaylistViewModel.kt @@ -2,9 +2,11 @@ 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 @@ -41,11 +43,28 @@ class WearLocalPlaylistViewModel @Inject constructor( 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( diff --git a/wear/src/main/res/values/strings_wear.xml b/wear/src/main/res/values/strings_wear.xml index 4832585f8..238c00244 100644 --- a/wear/src/main/res/values/strings_wear.xml +++ b/wear/src/main/res/values/strings_wear.xml @@ -88,4 +88,5 @@ Play all Waiting to transfer… + Receiving… 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 index 0aca76b3f..4640a3005 100644 --- a/wear/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/WearLocalPlaylistViewModelTest.kt +++ b/wear/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/WearLocalPlaylistViewModelTest.kt @@ -3,17 +3,21 @@ 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 @@ -40,6 +44,9 @@ class WearLocalPlaylistViewModelTest { 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, @@ -58,16 +65,30 @@ class WearLocalPlaylistViewModelTest { transferredAt = 0L, ) - private fun crossRef(songId: String, position: Int) = - LocalPlaylistSongCrossRef(playlistId = "p1", songId = songId, position = position) + private fun crossRef(songId: String, position: Int, playlistId: String = "p1") = + LocalPlaylistSongCrossRef(playlistId = playlistId, songId = songId, position = position) - private fun buildViewModel() = WearLocalPlaylistViewModel( - localPlaylistDao = localPlaylistDao, - localSongDao = localSongDao, - localPlayerRepository = localPlayerRepository, - stateRepository = stateRepository, + 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()) @@ -136,4 +157,25 @@ class WearLocalPlaylistViewModelTest { 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() + } + } } From 6c76f86e3ad121977036c0836fb5b6d4e275b998 Mon Sep 17 00:00:00 2001 From: PonceGL Date: Fri, 3 Jul 2026 21:50:47 -0600 Subject: [PATCH 21/30] feat(wear-transfer): surface confirmation state and failure reasons to the user The batch and single-song progress dialogs had no way to show the new AWAITING_WATCH_ACK phase (progress would sit at 100% with no explanation before the watch's ack landed), and a batch that finished with failed songs gave no indication of what went wrong or that anything failed at all. Adds a "Confirming on watch..." status, a per-batch failure summary that distinguishes storage-full vs connection-lost vs generic reasons (using the error codes from the earlier ack/timeout/storage work), and two advisory lines in the send-to-watch sheet about keeping the watch nearby and that backgrounding the app is safe. --- .../wear/PlaylistWatchTransferCoordinator.kt | 5 ++- .../presentation/screens/LibraryScreen.kt | 1 + .../screens/PlaylistDetailScreen.kt | 36 +++++++++++++++++++ app/src/main/res/values/strings_library.xml | 10 ++++++ 4 files changed, 51 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/data/service/wear/PlaylistWatchTransferCoordinator.kt b/app/src/main/java/com/theveloper/pixelplay/data/service/wear/PlaylistWatchTransferCoordinator.kt index 9023b5bdc..8f613a9e8 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/service/wear/PlaylistWatchTransferCoordinator.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/wear/PlaylistWatchTransferCoordinator.kt @@ -200,7 +200,10 @@ class PlaylistWatchTransferCoordinator @Inject constructor( transferStateStore.transfers .mapNotNull { it[requestId] } .collect { state -> - if (state.status == WearTransferProgress.STATUS_TRANSFERRING) { + if ( + state.status == WearTransferProgress.STATUS_TRANSFERRING || + state.status == WearTransferProgress.STATUS_AWAITING_WATCH_ACK + ) { transferStateStore.markBatchSongProgress(batchId, state.status, state.progress) } } 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..2ebc6a74b 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) 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 cf85ae4a7..bd90d4a4e 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 @@ -1305,6 +1305,17 @@ private fun SendPlaylistToWatchSheet( 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), @@ -1373,11 +1384,28 @@ private fun WatchPlaylistBatchProgressDialog( 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, @@ -1458,6 +1486,14 @@ private fun WatchPlaylistBatchProgressDialog( textAlign = TextAlign.Center ) } + failureSummaryText?.let { text -> + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center + ) + } TextButton(onClick = { onCancelTransfer() onDismiss() diff --git a/app/src/main/res/values/strings_library.xml b/app/src/main/res/values/strings_library.xml index 2ac8b4430..dc56f7d2a 100644 --- a/app/src/main/res/values/strings_library.xml +++ b/app/src/main/res/values/strings_library.xml @@ -286,6 +286,7 @@ Starting Transferring Converting audio + Confirming on watch… %1$d transfers @@ -301,9 +302,18 @@ 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 From 79fdf2381c838d08d7e0cd63feebf87fa6a25a82 Mon Sep 17 00:00:00 2001 From: PonceGL Date: Sat, 4 Jul 2026 11:08:50 -0600 Subject: [PATCH 22/30] feat(wear-transfer): hold a wake lock on the phone during active transfers Prevents CPU deep-sleep from silently stalling a long-running transfer once the screen turns off, which could otherwise outlast our own idle-timeout watchdogs and mark a healthy-but-slow transfer as failed. --- .../wear/WatchTransferForegroundService.kt | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) 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 73e3c6250..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 @@ -36,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() @@ -43,6 +45,28 @@ 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(), @@ -59,6 +83,7 @@ class WatchTransferForegroundService : Service() { override fun onDestroy() { transferObserverJob?.cancel() serviceScope.cancel() + releaseWakeLock() super.onDestroy() } @@ -73,11 +98,13 @@ class WatchTransferForegroundService : Service() { ) { 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) @@ -349,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) From 607063825479ab38ac51610aa7f25f75c37c5098 Mon Sep 17 00:00:00 2001 From: PonceGL Date: Sat, 4 Jul 2026 11:08:59 -0600 Subject: [PATCH 23/30] feat(wear-transfer): keep the watch process alive with a dataSync foreground service while receiving MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wear OS is more aggressive than phone Android about suspending background processes on screen-off/ambient, and receiving is purely reactive (driven by WearableListenerService callbacks). Without this, a long playlist transfer left running overnight risks the process being paused mid-transfer with nothing to resume it. Started from WearDataListenerService at the two entry points where a receive begins (transfer metadata, audio channel open); holds a PARTIAL_WAKE_LOCK for the same reason as the phone side. The reactive stop-if-empty check only fires once the service has genuinely started foreground at least once — onCreate() begins collecting activeTransfers before onStartCommand() has run, so the flow's current (still-empty) value can arrive first and otherwise trigger stopSelf() before startForeground(), which crashes the process with ForegroundServiceDidNotStartInTimeException (reproduced and confirmed fixed on a physical Galaxy Watch5 Pro). --- wear/src/main/AndroidManifest.xml | 8 + .../pixelplay/data/WearDataListenerService.kt | 2 + .../data/WearTransferForegroundService.kt | 223 ++++++++++++++++++ 3 files changed, 233 insertions(+) create mode 100644 wear/src/main/java/com/theveloper/pixelplay/data/WearTransferForegroundService.kt diff --git a/wear/src/main/AndroidManifest.xml b/wear/src/main/AndroidManifest.xml index 1b06e4246..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 @@ + + + { + WearTransferForegroundService.start(applicationContext) scope.launch { try { val metadataJson = String(messageEvent.data, Charsets.UTF_8) @@ -366,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/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") + } + } + } +} From 0b0c464e73e85a2975eb04e652b1476ec1cc9f55 Mon Sep 17 00:00:00 2001 From: PonceGL Date: Sat, 4 Jul 2026 11:09:12 -0600 Subject: [PATCH 24/30] feat(wear-transfer): add notification strings for the watch receive foreground service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Missed alongside the previous commit — used by WearTransferForegroundService's notification title/placeholder text. --- wear/src/main/res/values/strings_wear.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/wear/src/main/res/values/strings_wear.xml b/wear/src/main/res/values/strings_wear.xml index 238c00244..dcf3daf22 100644 --- a/wear/src/main/res/values/strings_wear.xml +++ b/wear/src/main/res/values/strings_wear.xml @@ -89,4 +89,6 @@ Play all Waiting to transfer… Receiving… + Receiving from phone + Starting… From d570b355c54fd46b801bf6881c53abe33ec7d462 Mon Sep 17 00:00:00 2001 From: PonceGL Date: Sat, 4 Jul 2026 11:09:26 -0600 Subject: [PATCH 25/30] feat(wear-transfer): lower transcode target to 128kbps and use a realistic transfer estimate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Halves transcoded file size (and transfer time) for lossless/high- bitrate sources. 128kbps AAC-LC is the de facto standard for "high quality portable" audio, an easy trade-off for gym/running use — not aimed at the audiophile-at-home listening context. Also fixes PhoneDirectWatchTransferCoordinator's own hardcoded TRANSCODED_BITRATE_BPS, a separate copy of the same value that would have silently drifted from WatchAudioTranscoder.TARGET_BITRATE_BPS the moment only one of the two got changed; now references the single source of truth directly. WatchPlaylistTransferEstimator's assumed throughput was also replaced with a figure measured on a real phone+watch pair (~40KB/s over the Wearable Data Layer ChannelClient) instead of the previous ~200KB/s guess, which made the confirmation sheet's time estimate wildly optimistic during physical-device testing. --- .../wear/PhoneDirectWatchTransferCoordinator.kt | 3 +-- .../data/service/wear/WatchAudioTranscoder.kt | 2 +- .../service/wear/WatchPlaylistTransferEstimator.kt | 10 ++++++++-- .../service/wear/WatchPlaylistTransferEstimatorTest.kt | 2 +- 4 files changed, 11 insertions(+), 6 deletions(-) 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 5b64234c8..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 @@ -196,7 +196,7 @@ class PhoneDirectWatchTransferCoordinator @Inject constructor( duration = song.duration, mimeType = if (overrideAudioFile != null) TRANSCODED_MIME_TYPE else (song.mimeType ?: "audio/mpeg"), fileSize = fileSize, - bitrate = if (overrideAudioFile != null) TRANSCODED_BITRATE_BPS else (song.bitrate ?: 0), + bitrate = if (overrideAudioFile != null) WatchAudioTranscoder.TARGET_BITRATE_BPS else (song.bitrate ?: 0), sampleRate = song.sampleRate ?: 0, isFavorite = song.isFavorite, paletteSeedArgb = paletteSeedArgb, @@ -978,6 +978,5 @@ class PhoneDirectWatchTransferCoordinator @Inject constructor( const val METADATA_GUARD_DELAY_MS = 250L // Kept in sync with WatchAudioTranscoder's AAC-LC output. const val TRANSCODED_MIME_TYPE = "audio/mp4" - const val TRANSCODED_BITRATE_BPS = 256_000 } } diff --git a/app/src/main/java/com/theveloper/pixelplay/data/service/wear/WatchAudioTranscoder.kt b/app/src/main/java/com/theveloper/pixelplay/data/service/wear/WatchAudioTranscoder.kt index c4a393cf9..f5c833e6f 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/service/wear/WatchAudioTranscoder.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/wear/WatchAudioTranscoder.kt @@ -186,7 +186,7 @@ class WatchAudioTranscoder @Inject constructor( companion object { /** Also used by [WatchPlaylistTransferEstimator] to size-estimate songs that will be transcoded. */ - const val TARGET_BITRATE_BPS = 256_000 + 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 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 index 2a4184b80..0106df224 100644 --- 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 @@ -22,8 +22,14 @@ data class WatchPlaylistTransferEstimate( @UnstableApi object WatchPlaylistTransferEstimator { - /** Conservative assumed throughput for the single Bluetooth channel used for the transfer. */ - private const val ASSUMED_TRANSFER_RATE_BYTES_PER_SEC = 200_000L + /** + * 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)) { 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 index 75509b7ca..20bf034b6 100644 --- 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 @@ -48,7 +48,7 @@ class WatchPlaylistTransferEstimatorTest { val bytes = WatchPlaylistTransferEstimator.estimateBytesForSong(flacSong, transcoder) - assertThat(bytes).isEqualTo(60L * 256_000L / 8L) + assertThat(bytes).isEqualTo(60L * WatchAudioTranscoder.TARGET_BITRATE_BPS.toLong() / 8L) } @Test From 42f51e3eccb4e8a7415a03e696fd2f50e302d417 Mon Sep 17 00:00:00 2001 From: PonceGL Date: Sat, 4 Jul 2026 11:09:37 -0600 Subject: [PATCH 26/30] fix(wear-transfer): show "Update on Watch" once any song has landed, not only once all have MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A playlist sent in multiple partial sessions (the common case for large playlists on a slow link) previously kept showing "Send to Watch" — implying a first-time send — until every single song had arrived, even though most of it was already there. Found during physical-device testing. --- .../pixelplay/presentation/screens/PlaylistDetailScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 bd90d4a4e..44a75d66d 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 @@ -225,7 +225,7 @@ fun PlaylistDetailScreen( val isPixelPlayWatchAvailable by playlistViewModel.isPixelPlayWatchAvailable.collectAsStateWithLifecycle() val isPlaylistOnWatch = currentPlaylist != null && currentPlaylist.songIds.isNotEmpty() && - currentPlaylist.songIds.all { it in watchSongIds } + currentPlaylist.songIds.any { it in watchSongIds } LaunchedEffect(activePlaylistBatchTransfer?.batchId) { if (activePlaylistBatchTransfer == null) { From 9aec5073d8a14e05d16cf40f3f85327f9d491a1d Mon Sep 17 00:00:00 2001 From: PonceGL Date: Sun, 5 Jul 2026 21:08:15 -0600 Subject: [PATCH 27/30] fix(wear-playback): stop watch UI work from starving local decode on weak hardware On-device profiling (2-core Wear SoC) traced audible playback stutter to the watch's own continuous Compose animations (now-playing eq icon, play-button rotation) pegging the main thread, which starved ExoPlayer's decode/render threads long enough to cause AudioFlinger underruns. - Add WearDeviceTier: gates non-essential continuous UI work by core count. - PlayingEqIcon: static shape on constrained tier, cheap low-freq tick on capable tier, instead of two continuous per-frame Animatable tweens. - CenterPlayButton rotation and the live position ticker: only skip on constrained tier while local (on-watch) playback is active, since remote (phone-controlled) playback does no local decode work and has CPU to spare. - WearPlaybackService: larger AudioTrack buffer (1-3s) via a custom DefaultAudioTrackBufferSizeProvider, so transient CPU stalls (ours or a future concurrent app, e.g. workout tracking) don't reach the ear. - WearLocalPlayerRepository: gate the Room-flow-triggered updateState() by isInteractiveNow, consistent with startPositionUpdates(). --- .../pixelplay/data/WearDeviceTier.kt | 20 ++++++ .../data/WearLocalPlayerRepository.kt | 4 +- .../pixelplay/data/WearPlaybackService.kt | 37 ++++++++++- .../presentation/components/PlayingEqIcon.kt | 61 ++++++++----------- .../presentation/screens/PlayerScreen.kt | 61 ++++++++++++++----- 5 files changed, 130 insertions(+), 53 deletions(-) create mode 100644 wear/src/main/java/com/theveloper/pixelplay/data/WearDeviceTier.kt 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 f70aadec3..30ba718e5 100644 --- a/wear/src/main/java/com/theveloper/pixelplay/data/WearLocalPlayerRepository.kt +++ b/wear/src/main/java/com/theveloper/pixelplay/data/WearLocalPlayerRepository.kt @@ -135,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() } } 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/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/PlayerScreen.kt b/wear/src/main/java/com/theveloper/pixelplay/presentation/screens/PlayerScreen.kt index cd5ad82a8..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 @@ -256,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) { @@ -1114,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 { @@ -1230,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, @@ -1284,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 } @@ -1669,6 +1689,7 @@ private fun MainControlsRow( enabled: Boolean, outlined: Boolean, trackProgress: Float, + isWatchOutputSelected: Boolean, onTogglePlayPause: () -> Unit, onNext: () -> Unit, onPrevious: () -> Unit, @@ -1697,6 +1718,7 @@ private fun MainControlsRow( enabled = enabled && !isEmpty, outlined = outlined, trackProgress = trackProgress, + isWatchOutputSelected = isWatchOutputSelected, onClick = onTogglePlayPause, ) @@ -1772,6 +1794,7 @@ private fun CenterPlayButton( enabled: Boolean, outlined: Boolean, trackProgress: Float, + isWatchOutputSelected: Boolean, onClick: () -> Unit, ) { val palette = LocalWearPalette.current @@ -1785,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) { From 2bbbb6d48505b32a19f829786cdce81d9939e224 Mon Sep 17 00:00:00 2001 From: PonceGL Date: Sun, 5 Jul 2026 21:16:45 -0600 Subject: [PATCH 28/30] fix(wear-transfer): weight transcode/transfer progress into one continuous scale currentSongProgress was fed by two different 0f..1f fractions on the same field (transcode, then transfer-byte progress), causing a visible jump-then-reset in the batch progress UI each time a song moved from one phase to the other. Weight transcode into 0-30% and transfer into 30-100% instead, and let markBatchSongStarted take an explicit initial progress so re-targeting activeRequestId to the per-node transfer request no longer snaps progress back to 0. --- .../wear/PhoneWatchTransferStateStore.kt | 14 ++++- .../wear/PlaylistWatchTransferCoordinator.kt | 28 ++++++++-- .../PlaylistWatchTransferCoordinatorTest.kt | 52 +++++++++++++++++++ 3 files changed, 88 insertions(+), 6 deletions(-) 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 4b0d57f23..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 @@ -278,13 +278,23 @@ class PhoneWatchTransferStateStore @Inject constructor() { } } - fun markBatchSongStarted(batchId: String, requestId: String, songTitle: String) { + /** + * [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 = 0f, + currentSongProgress = initialProgress, status = WearTransferProgress.STATUS_TRANSFERRING, updatedAtMillis = System.currentTimeMillis(), )) diff --git a/app/src/main/java/com/theveloper/pixelplay/data/service/wear/PlaylistWatchTransferCoordinator.kt b/app/src/main/java/com/theveloper/pixelplay/data/service/wear/PlaylistWatchTransferCoordinator.kt index 8f613a9e8..02b37abe4 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/service/wear/PlaylistWatchTransferCoordinator.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/wear/PlaylistWatchTransferCoordinator.kt @@ -150,7 +150,11 @@ class PlaylistWatchTransferCoordinator @Inject constructor( song = song, requestId = transcodeRequestId, onProgress = { fraction -> - transferStateStore.markBatchSongProgress(batchId, WearTransferProgress.STATUS_TRANSCODING, fraction) + transferStateStore.markBatchSongProgress( + batchId, + WearTransferProgress.STATUS_TRANSCODING, + fraction.coerceIn(0f, 1f) * TRANSCODE_PHASE_WEIGHT, + ) }, ) if (transcodeResult is WatchAudioTranscoder.TranscodeResult.Failed) { @@ -163,6 +167,7 @@ class PlaylistWatchTransferCoordinator @Inject constructor( } 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 @@ -172,7 +177,7 @@ class PlaylistWatchTransferCoordinator @Inject constructor( var lastFailureErrorCode: String? = null for (node in nodes) { if (cancelledBatchIds.contains(batchId)) break - val nodeOutcome = transferSongToNode(batchId, node, song, overrideAudioFile) + val nodeOutcome = transferSongToNode(batchId, node, song, overrideAudioFile, wasTranscoded) if (nodeOutcome.completed) { succeededOnAnyNode = true } else { @@ -192,9 +197,13 @@ class PlaylistWatchTransferCoordinator @Inject constructor( node: Node, song: Song, overrideAudioFile: java.io.File?, + wasTranscoded: Boolean, ): SongTransferResult { val requestId = UUID.randomUUID().toString() - transferStateStore.markBatchSongStarted(batchId, requestId, song.title) + // 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 @@ -204,7 +213,14 @@ class PlaylistWatchTransferCoordinator @Inject constructor( state.status == WearTransferProgress.STATUS_TRANSFERRING || state.status == WearTransferProgress.STATUS_AWAITING_WATCH_ACK ) { - transferStateStore.markBatchSongProgress(batchId, state.status, state.progress) + // 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) } } } @@ -251,6 +267,10 @@ class PlaylistWatchTransferCoordinator @Inject constructor( 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. diff --git a/app/src/test/java/com/theveloper/pixelplay/data/service/wear/PlaylistWatchTransferCoordinatorTest.kt b/app/src/test/java/com/theveloper/pixelplay/data/service/wear/PlaylistWatchTransferCoordinatorTest.kt index a0252ccca..7ce048c86 100644 --- a/app/src/test/java/com/theveloper/pixelplay/data/service/wear/PlaylistWatchTransferCoordinatorTest.kt +++ b/app/src/test/java/com/theveloper/pixelplay/data/service/wear/PlaylistWatchTransferCoordinatorTest.kt @@ -17,6 +17,7 @@ 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 @@ -177,4 +178,55 @@ class PlaylistWatchTransferCoordinatorTest { 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() + } + } } From 22135dd98c1bfbf29f630671987655d2fac5191e Mon Sep 17 00:00:00 2001 From: PonceGL Date: Sun, 5 Jul 2026 21:27:34 -0600 Subject: [PATCH 29/30] fix(wear-transfer): show the batch dialog, not the per-song one, in LibraryScreen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LibraryScreen's "sending to watch" chip only knew about individual song transfers, never playlist batches. Since a batch's songs still flow through the same per-song transfer pipeline, the chip would show during a batch too, but reopening it showed the single-song dialog — whose Cancel only stopped the one song currently in flight, not the whole batch. PlaylistViewModel.activePlaylistBatchTransfer also only tracked "the last batchId this ViewModel instance kicked off", so a batch started from PlaylistDetailScreen was invisible to LibraryScreen's separate instance. Changed it to query the shared PhoneWatchTransferStateStore directly for any non-terminal batch, so it's correct regardless of which screen started it. --- .../presentation/screens/LibraryScreen.kt | 41 ++++++++++++++++--- .../screens/PlaylistDetailScreen.kt | 4 +- .../viewmodel/PlaylistViewModel.kt | 24 +++++++---- 3 files changed, 53 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryScreen.kt index 2ebc6a74b..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 @@ -486,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 } @@ -514,6 +517,11 @@ fun LibraryScreen( showWatchTransferDialog = false } } + LaunchedEffect(activePlaylistBatchTransfer?.batchId) { + if (activePlaylistBatchTransfer == null) { + showWatchBatchProgressDialog = false + } + } // Multi-selection state val multiSelectionState = playerViewModel.multiSelectionStateHolder @@ -852,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 }, @@ -874,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 @@ -883,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, @@ -1831,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 44a75d66d..506f8c954 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 @@ -1368,9 +1368,11 @@ private fun SendPlaylistToWatchSheet( } } +// 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 -private fun WatchPlaylistBatchProgressDialog( +fun WatchPlaylistBatchProgressDialog( batch: PhoneWatchBatchTransferState, onDismiss: () -> Unit, onCancelTransfer: () -> Unit, 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 6626ba238..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 @@ -21,6 +21,7 @@ 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 @@ -107,13 +108,15 @@ class PlaylistViewModel @Inject constructor( val watchFreeStorageBytesByNodeId: StateFlow> = watchTransferStateStore.watchFreeStorageBytesByNodeId val watchSongIds: StateFlow> = watchTransferStateStore.watchSongIds - private val _activePlaylistBatchId = MutableStateFlow(null) - /** Batch transfer state for whichever playlist we most recently kicked off a send for. */ - val activePlaylistBatchTransfer: StateFlow = kotlinx.coroutines.flow.combine( - watchTransferStateStore.batchTransfers, - _activePlaylistBatchId, - ) { batches, batchId -> batchId?.let { batches[it] } } + /** + * 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), @@ -124,6 +127,11 @@ class PlaylistViewModel @Inject constructor( 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('_') @@ -1290,9 +1298,7 @@ class PlaylistViewModel @Inject constructor( fun maxWatchFreeStorageBytes(): Long? = watchFreeStorageBytesByNodeId.value.values.maxOrNull() fun sendPlaylistToWatch(playlistId: String, playlistName: String, songIds: List): String { - val batchId = playlistWatchTransferCoordinator.requestPlaylistTransfer(playlistId, playlistName, songIds) - _activePlaylistBatchId.value = batchId - return batchId + return playlistWatchTransferCoordinator.requestPlaylistTransfer(playlistId, playlistName, songIds) } fun cancelPlaylistTransfer(batchId: String) { From 1b590843da6608477d60093f2918bb5016150905 Mon Sep 17 00:00:00 2001 From: PonceGL Date: Sun, 5 Jul 2026 21:35:40 -0600 Subject: [PATCH 30/30] fix(wear-transfer): guard Send-to-Watch against a duplicate concurrent batch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tapping "Send to Watch"/"Update on Watch" always reopened the confirmation sheet, even while a batch transfer (this playlist's or another's) was already running — confirming it would start a second batch contending for the single Bluetooth channel. Now the menu action shows the existing batch's progress dialog instead when one is active, and the sheet's onConfirm re-checks before sending in case a batch started while it was open. --- .../screens/PlaylistDetailScreen.kt | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) 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 506f8c954..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 @@ -923,8 +923,14 @@ fun PlaylistDetailScreen( label = if (isPlaylistOnWatch) updateOnWatchLabel else sendToWatchLabel, onClick = { showPlaylistOptionsSheet = false - playlistViewModel.refreshWatchAvailability() - showSendToWatchSheet = true + 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 + } } ) } @@ -944,7 +950,12 @@ fun PlaylistDetailScreen( isUpdate = isPlaylistOnWatch, onDismiss = { showSendToWatchSheet = false }, onConfirm = { - playlistViewModel.sendPlaylistToWatch(currentPlaylist.id, currentPlaylist.name, currentPlaylist.songIds) + // 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 }