Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
b00ef45
feat(app): add IO and Main dispatcher qualifiers
PonceGL Aug 8, 2026
115641e
feat(wear): add AAC transcoding decision and encode pipeline for watc…
PonceGL Aug 8, 2026
57084c9
feat(wear): add playlist transfer size/time estimator
PonceGL Aug 8, 2026
86e0329
test(wear): add unit tests for the transcode decision and transfer es…
PonceGL Aug 8, 2026
12d9028
feat(wear): allow the direct transfer coordinator to send an already-…
PonceGL Aug 8, 2026
608a5af
feat(app): provide CapabilityClient and MessageClient via Hilt
PonceGL Aug 8, 2026
bb65381
feat(wear): add playlist batch transfer state to the phone-side store
PonceGL Aug 8, 2026
2e63be1
feat(wear): add playlist batch transfer coordinator
PonceGL Aug 8, 2026
3cac9e9
feat(wear): show playlist batch progress in the transfer notification
PonceGL Aug 8, 2026
4342216
test(wear): add unit tests for the playlist batch coordinator and pho…
PonceGL Aug 8, 2026
a821741
feat(app): add playlist watch-transfer methods to PlaylistViewModel
PonceGL Aug 8, 2026
5c1c43e
feat(app): add send-to-watch action and confirmation dialog to the pl…
PonceGL Aug 8, 2026
abbf3aa
feat(app): show playlist batch transfer progress in the library screen
PonceGL Aug 8, 2026
9e00e7b
test(app): add unit tests for PlaylistViewModel's watch-transfer methods
PonceGL Aug 8, 2026
90f6540
feat(wear): receive playlist syncs from the phone into local storage
PonceGL Aug 8, 2026
7407dbe
feat(wear): add WearLocalPlaylistViewModel
PonceGL Aug 8, 2026
6c8ba65
feat(wear): add local playlists screens and wire up navigation
PonceGL Aug 8, 2026
30f6e28
feat(app): add PlaylistBatchTransferPersistence for in-flight batches
PonceGL Aug 9, 2026
fa59850
feat(app): resume an interrupted playlist watch transfer at startup
PonceGL Aug 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions app/src/main/java/com/theveloper/pixelplay/PixelPlayApplication.kt
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import coil.ImageLoaderFactory
import com.theveloper.pixelplay.data.preferences.UserPreferencesRepository
import com.theveloper.pixelplay.data.diagnostics.AdvancedPerformanceDiagnosticsController
import com.theveloper.pixelplay.data.repository.ArtistImageRepository
import com.theveloper.pixelplay.data.service.wear.PlaylistWatchTransferCoordinator
import com.theveloper.pixelplay.data.telegram.TelegramRepository
import com.theveloper.pixelplay.presentation.viewmodel.LibraryStateHolder
import com.theveloper.pixelplay.presentation.viewmodel.ThemeStateHolder
Expand Down Expand Up @@ -72,6 +73,9 @@ class PixelPlayApplication : Application(), ImageLoaderFactory, Configuration.Pr
@Inject
lateinit var advancedPerformanceDiagnosticsController: dagger.Lazy<AdvancedPerformanceDiagnosticsController>

@Inject
lateinit var playlistWatchTransferCoordinator: dagger.Lazy<PlaylistWatchTransferCoordinator>

private val startupScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)

// AÑADE EL COMPANION OBJECT
Expand Down Expand Up @@ -130,6 +134,21 @@ class PixelPlayApplication : Application(), ImageLoaderFactory, Configuration.Pr
AlbumArtCacheManager.configuredCacheLimitMb = savedLimit.toLong()
}
}

startupScope.launch {
// Best-effort: a cold start not directly triggered by the user (e.g. the system
// reviving the process for an unrelated broadcast) may be too restricted to start the
// foreground service this resumes into — resumePersistedBatchIfNeeded() just skips
// resuming this time rather than crashing app startup over it; the persisted intent
// stays put for the next launch that can.
try {
playlistWatchTransferCoordinator.get().resumePersistedBatchIfNeeded()
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} catch (e: Exception) {
Timber.w(e, "Failed to resume an interrupted playlist watch transfer")
}
}
}

override fun newImageLoader(): ImageLoader {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,13 +91,26 @@ class PhoneDirectWatchTransferCoordinator @Inject constructor(
}
}

/**
* Substitutes the audio actually streamed to the watch with an already-transcoded file (see
* [WatchAudioTranscoder]), bypassing [isSongTransferEligible] and the song's own local-file
* resolution — the override file was just written locally by the transcoder, so it's
* unconditionally eligible regardless of what the original [Song]'s source was.
*/
data class WatchAudioOverride(
val file: File,
val mimeType: String,
val bitrateBps: Int,
)

fun startTransferToWatch(
nodeId: String,
requestId: String,
songId: String,
transferMode: String = WearTransferRequest.MODE_SAVE_TO_LIBRARY,
startPositionMs: Long = 0L,
autoPlay: Boolean = false,
audioOverride: WatchAudioOverride? = null,
) {
transferStateStore.markRequested(
requestId = requestId,
Expand All @@ -112,6 +125,7 @@ class PhoneDirectWatchTransferCoordinator @Inject constructor(
transferMode = transferMode,
startPositionMs = startPositionMs,
autoPlay = autoPlay,
audioOverride = audioOverride,
)
}
}
Expand All @@ -123,6 +137,7 @@ class PhoneDirectWatchTransferCoordinator @Inject constructor(
transferMode: String,
startPositionMs: Long,
autoPlay: Boolean,
audioOverride: WatchAudioOverride? = null,
) {
var openedSongSource: OpenedSongSource? = null
try {
Expand All @@ -138,6 +153,7 @@ class PhoneDirectWatchTransferCoordinator @Inject constructor(
}

if (
audioOverride == null &&
transferMode == WearTransferRequest.MODE_SAVE_TO_LIBRARY &&
!isSongTransferEligible(song)
) {
Expand All @@ -150,19 +166,23 @@ class PhoneDirectWatchTransferCoordinator @Inject constructor(
return
}

val songSource = openSongSource(
song = song,
allowProxyStreaming = transferMode == WearTransferRequest.MODE_TEMPORARY_PLAYBACK,
)
val songSource = if (audioOverride != null) {
openOverrideSongSource(audioOverride)
} else {
openSongSource(
song = song,
allowProxyStreaming = transferMode == WearTransferRequest.MODE_TEMPORARY_PLAYBACK,
)
}
if (songSource == null) {
sendTransferMetadataError(
nodeId = nodeId,
requestId = requestId,
songId = song.id,
errorMessage = if (transferMode == WearTransferRequest.MODE_TEMPORARY_PLAYBACK) {
"Cannot stream audio source to watch"
} else {
"Cannot read audio file"
errorMessage = when {
audioOverride != null -> "Cannot read transcoded audio file"
transferMode == WearTransferRequest.MODE_TEMPORARY_PLAYBACK -> "Cannot stream audio source to watch"
else -> "Cannot read audio file"
},
)
return
Expand All @@ -182,9 +202,9 @@ class PhoneDirectWatchTransferCoordinator @Inject constructor(
album = song.album,
albumId = song.albumId,
duration = song.duration,
mimeType = song.mimeType ?: "audio/mpeg",
mimeType = audioOverride?.mimeType ?: (song.mimeType ?: "audio/mpeg"),
fileSize = fileSize,
bitrate = song.bitrate ?: 0,
bitrate = audioOverride?.bitrateBps ?: (song.bitrate ?: 0),
sampleRate = song.sampleRate ?: 0,
isFavorite = song.isFavorite,
paletteSeedArgb = paletteSeedArgb,
Expand Down Expand Up @@ -325,6 +345,15 @@ class PhoneDirectWatchTransferCoordinator @Inject constructor(
return openHttpSongSource(streamUrl)
}

private fun openOverrideSongSource(override: WatchAudioOverride): OpenedSongSource? {
val file = override.file.takeIf { it.isFile && it.canRead() && it.length() > 0L } ?: return null
return runCatching {
OpenedSongSource(inputStream = file.inputStream(), fileSize = file.length())
}.onFailure { error ->
Timber.tag(TAG).w(error, "Failed to open transcoded override file=%s", file.absolutePath)
}.getOrNull()
}

private fun openDirectSongSource(song: Song): OpenedSongSource? {
val directFile = song.path
.takeIf { it.isNotBlank() }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,36 @@ data class PhoneWatchTransferState(
}
}

/**
* Aggregate state of a whole-playlist watch transfer, driven by [PlaylistWatchTransferCoordinator].
* [currentSongProgress] is the 0f..1f progress of whichever song [activeRequestId] refers to
* (weighted across transcode+transfer phases by the coordinator) — the per-song byte-level detail
* lives in [PhoneWatchTransferState], keyed by that same requestId.
*/
data class PhoneWatchBatchTransferState(
val batchId: String,
val playlistId: String,
val playlistName: String,
val totalSongCount: Int,
val completedSongCount: Int = 0,
val failedSongCount: Int = 0,
val status: String = WearTransferProgress.STATUS_TRANSFERRING,
val activeRequestId: String? = null,
val currentSongTitle: String = "",
val currentSongProgress: Float = 0f,
val errorMessage: String? = null,
val updatedAtMillis: Long = System.currentTimeMillis(),
) {
val processedSongCount: Int get() = completedSongCount + failedSongCount
}

@Singleton
class PhoneWatchTransferStateStore @Inject constructor() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val _transfers = MutableStateFlow<Map<String, PhoneWatchTransferState>>(emptyMap())
val transfers: StateFlow<Map<String, PhoneWatchTransferState>> = _transfers.asStateFlow()
private val _batchTransfers = MutableStateFlow<Map<String, PhoneWatchBatchTransferState>>(emptyMap())
val batchTransfers: StateFlow<Map<String, PhoneWatchBatchTransferState>> = _batchTransfers.asStateFlow()
private val _reachableWatchNodeIds = MutableStateFlow<Set<String>>(emptySet())
val reachableWatchNodeIds: StateFlow<Set<String>> = _reachableWatchNodeIds.asStateFlow()
private val _watchLibrarySyncedNodeIds = MutableStateFlow<Set<String>>(emptySet())
Expand Down Expand Up @@ -232,6 +257,133 @@ class PhoneWatchTransferStateStore @Inject constructor() {
}
}

// --- Playlist batch transfers, driven by PlaylistWatchTransferCoordinator ---

private val batchCleanupJobs = ConcurrentHashMap<String, Job>()

fun markBatchStarted(batchId: String, playlistId: String, playlistName: String, totalSongCount: Int) {
batchCleanupJobs.remove(batchId)?.cancel()
_batchTransfers.update { map ->
map + (batchId to PhoneWatchBatchTransferState(
batchId = batchId,
playlistId = playlistId,
playlistName = playlistName,
totalSongCount = totalSongCount,
status = WearTransferProgress.STATUS_TRANSFERRING,
))
}
}

fun markBatchSongStarted(
batchId: String,
activeRequestId: String,
songTitle: String,
startingProgress: Float = 0f,
) {
_batchTransfers.update { map ->
val current = map[batchId] ?: return@update map
map + (batchId to current.copy(
activeRequestId = activeRequestId,
currentSongTitle = songTitle,
currentSongProgress = startingProgress.coerceIn(0f, 1f),
updatedAtMillis = System.currentTimeMillis(),
))
}
}

/** [status] is informational only here — [progress] is what actually drives the notification/UI. */
fun markBatchSongProgress(batchId: String, status: String, progress: Float) {
_batchTransfers.update { map ->
val current = map[batchId] ?: return@update map
map + (batchId to current.copy(
currentSongProgress = progress.coerceIn(0f, 1f),
updatedAtMillis = System.currentTimeMillis(),
))
}
}

fun markBatchSongCompleted(batchId: String) {
_batchTransfers.update { map ->
val current = map[batchId] ?: return@update map
map + (batchId to current.copy(
completedSongCount = current.completedSongCount + 1,
activeRequestId = null,
currentSongProgress = 0f,
updatedAtMillis = System.currentTimeMillis(),
))
}
}

fun markBatchSongFailed(batchId: String, errorMessage: String? = null) {
_batchTransfers.update { map ->
val current = map[batchId] ?: return@update map
map + (batchId to current.copy(
failedSongCount = current.failedSongCount + 1,
activeRequestId = null,
currentSongProgress = 0f,
errorMessage = errorMessage ?: current.errorMessage,
updatedAtMillis = System.currentTimeMillis(),
))
}
}

fun markBatchCancelled(batchId: String) {
_batchTransfers.update { map ->
val current = map[batchId] ?: return@update map
map + (batchId to current.copy(
status = WearTransferProgress.STATUS_CANCELLED,
activeRequestId = null,
updatedAtMillis = System.currentTimeMillis(),
))
}
scheduleBatchTerminalCleanup(batchId)
}

fun markBatchFailed(batchId: String, errorMessage: String) {
_batchTransfers.update { map ->
val current = map[batchId] ?: return@update map
map + (batchId to current.copy(
status = WearTransferProgress.STATUS_FAILED,
errorMessage = errorMessage,
activeRequestId = null,
updatedAtMillis = System.currentTimeMillis(),
))
}
scheduleBatchTerminalCleanup(batchId)
}

fun markBatchCompleted(batchId: String) {
_batchTransfers.update { map ->
val current = map[batchId] ?: return@update map
map + (batchId to current.copy(
status = WearTransferProgress.STATUS_COMPLETED,
activeRequestId = null,
updatedAtMillis = System.currentTimeMillis(),
))
}
scheduleBatchTerminalCleanup(batchId)
}

private fun scheduleBatchTerminalCleanup(batchId: String) {
batchCleanupJobs.remove(batchId)?.cancel()
batchCleanupJobs[batchId] = scope.launch {
delay(TERMINAL_STATE_VISIBILITY_MS)
_batchTransfers.update { map ->
val current = map[batchId]
if (current != null &&
(current.status == WearTransferProgress.STATUS_COMPLETED ||
current.status == WearTransferProgress.STATUS_FAILED ||
current.status == WearTransferProgress.STATUS_CANCELLED)
) {
map - batchId
} else {
map
}
}
batchCleanupJobs.remove(batchId)
}
}

private companion object {
const val TERMINAL_STATE_VISIBILITY_MS = 3500L
}
Expand Down
Loading