Skip to content

Impl/wear playlist transfer#2530

Draft
PonceGL wants to merge 30 commits into
PixelPlayerHQ:masterfrom
PonceGL:impl/wear-playlist-transfer
Draft

Impl/wear playlist transfer#2530
PonceGL wants to merge 30 commits into
PixelPlayerHQ:masterfrom
PonceGL:impl/wear-playlist-transfer

Conversation

@PonceGL

@PonceGL PonceGL commented Jul 3, 2026

Copy link
Copy Markdown

No description provided.

PonceGL added 30 commits July 2, 2026 18:22
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.
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.
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.
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.
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<Map<batchId, ...>>) 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.
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).
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.
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.
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.
…ation

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/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.
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).
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.
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.
…ilure

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.
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.
… transfer completion

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.
…e 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.
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.
…eens

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.
…o 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.
…sfers

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.
…eground service while receiving

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).
…oreground service

Missed alongside the previous commit — used by
WearTransferForegroundService's notification title/placeholder text.
…istic transfer estimate

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.
…not only once all have

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.
…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().
…nuous 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.
…ibraryScreen

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.
…t batch

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant