Skip to content

feat(wear): Wear OS companion app — playlist transfer, local playback & remote control - #2733

Open
PonceGL wants to merge 5 commits into
PixelPlayerHQ:masterfrom
PonceGL:upstream-proposal/wear-companion-app
Open

feat(wear): Wear OS companion app — playlist transfer, local playback & remote control#2733
PonceGL wants to merge 5 commits into
PixelPlayerHQ:masterfrom
PonceGL:upstream-proposal/wear-companion-app

Conversation

@PonceGL

@PonceGL PonceGL commented Aug 12, 2026

Copy link
Copy Markdown

Summary

Adds a full Wear OS companion experience to PixelPlay: transfer playlists from the phone to the watch for fully offline listening, play them locally on the watch with no phone nearby, and control phone playback remotely when the watch is in range — plus several rounds of reliability and performance hardening driven entirely by physical-device testing.

This PR is a squashed extraction of ~55 commits (13 PRs) built incrementally on a personal fork. It's isolated onto current master as a single self-contained changeset for review, with everything from that fork unrelated to this feature (an unrelated search-filter feature, an unrelated FTS fix, personal app branding) deliberately excluded — none of it is touched by this diff.

I'm aware there are several other Wear-OS-related branches already open in this repo. I haven't cross-checked this work against them in detail — happy to coordinate, rebase, or split this into smaller PRs if it overlaps with anything already in flight.

Motivation

A phone-tethered watch companion is the easy 80%; the harder, more useful case is a watch that keeps working when the phone is left behind — running, gym, commuting without a pocket. That's the scenario this feature targets: transfer once over Bluetooth while both devices are together, then play back entirely offline on watch hardware that's meaningfully more constrained than even a budget phone (RAM, CPU, battery, a single shared Bluetooth radio).

What's included

1. Playlist transfer: phone → watch

  • Shared contract (:shared module) for playlist sync and per-song transfer metadata/progress, versioned so older/newer builds on either side degrade gracefully instead of crashing on an unknown field.
  • Room schema on the watch for locally-stored playlists (membership + order), independent of song-download state — a playlist's structure arrives and is browsable before all of its audio has finished transferring.
  • Phone-side transcoding: lossless or high-bitrate sources are re-encoded to AAC-LC 128 kbps before sending; sources already lossy at a reasonable bitrate are sent as-is (re-encoding an already-small MP3 only costs CPU and quality, no transfer-time benefit).
  • A batch coordinator that syncs playlist structure first, then transcodes and streams pending songs one at a time — deliberately serial, never parallel, since the watch has a single Bluetooth radio to share with whatever else is using it (e.g. connected headphones).
  • Phone UI: "Send to Watch" / "Update on Watch" action with a size/time estimate, a non-blocking progress banner/notification, and a library-wide badge so a batch transfer doesn't get lost behind app navigation.
  • Watch UI: playlists received from the phone, songs not yet transferred shown disabled with a "waiting" state that flips live as songs arrive — no manual refresh needed.
  • Batch-transfer intent (not full progress — that's cheap to rebuild) is persisted so a transfer interrupted by the phone process dyutomatically on next launch insteadof silently disappearing.
    ### 2. Reliability hardening (multi a real failure seen on hardware)
  • Every fire-and-forget message in the pipeline now has an ack-and-retry: playlist-sync messages, transfer-metadata messages, and final transfer outcome are all confirmed by the receiving side rather than assumed by the sender. This closes three real bugs found in testinsecond playlist's songs arriving bulaylist (sync message silentlydropped), a song completing on the watch but the phone never re-enabling retry for it (phone assumed success as soon as it finishannel, not when the watch confirmedthe save), and a duplicate-transfer rejection from the watch being silently dropped by the phone (the receiving message path existed in both manifests but had no handler on the phone side).
  • Transfer watchdog fix: a stalled transfer's timeout used to only update in-memory state,
    leaving the coroutine still blocked — so a "timed out" transfer couldstill finish successfully afterward and corrupt bookkeeping (Transfer metadata missing for data that had, in fact, fully arrived). The watchdog now closes the real input stream on timeout, routing the failure through the same single cleanup path the read loop already uses.
  • Failed-song retry: songs that: Bluetooth radio contention withconnected headphones) are now retried once with a short backoff before being given up on.
  • Both send-side (phone won't let y while any batch transfer is active)
    and receive-side (watch-reported ouf truth for "is this song really on
    the watch") race conditions are add

3. Local playback performance ()

  • Extractor scoped to MP4/AAC-LC only (the only container this pipeline ever produces): the default extractor factory probes ~15 container formats on first use, each costing 120-300ms on watch-class hardware — visible as dropped frames right at playback start.
  • **LoadControl buffer profile siring the phone's existing low-RAMbuffer-sizing logic, amplified for a device with meaningfully less memory that's often shared with another running app.
  • Audio offload with a runtime fallback: requests offloaded audio playback (screen-off/AOD
    play was measurably smoother than scontention between audio decode andCompose recomposition on the same core) — and, because ExoPlayer doesn't handle a HAL that accepts offload and then resets/stalls shortly after, replicates the same runtime detect-and-fallback approach already used on the phone side.
  • Mid-song stall watchdog: covel mode found in release-build testing
    — the player reports STATE_READY/udio has silently stopped, with no
    state-change event to react to. A p(checks for stalled position overconsecutive ticks) triggers the same player-rebuild recovery independent of cause.
  • Lifecycle-aware recomposition audit: ~45 collection sites across 11 files switched from collectAsState() to collectAsStateWithLifecycle(), plus gating the position/lyrics-index tick
    loops on watch interactivity — closng (and costing CPU/battery) evenwith the screen off or in ambient mode.
  • Playback-state persistence across process death: queue, current index, and position are
    persisted (on meaningful events plut every UI tick) and restored —
    paused, not auto-playing — on next r memory pressure from another
    running app can have its process re

4. User-facing controls

  • A "Watch" settings section on the phone with three toggles (album art, dynamic color theming,
    play-button animation) that affect and are set from the phone but cached durably on the watch via a DataItem (survives the watch being disconnected at the moment of playback, which is the whole point). Backed by real measured costs: uncompressed artwork bitmaps up to ~16MB per song with no caching, full-bitmap re-sampling for theming on the main thread, and continuous per-frame path reconstruction for the play-button animation.
  • All watch-related UI on the phone (send-to-watch actions, watch settings section) is hidden
    entirely for a phone that has neverrather than only hiding whentemporarily disconnected — the previously-existing "paired but disconnected" state was already handled correctly.

Key design decisions

  • **ChannelClient for audio, Meser protocol, DataItem for durable settings/pairing state** — deliberafor different guarantee needs.MessageClient` only confirms local delivery, not that the other side received it, so every place
    it's used for something that must ns an explicit application-level ack.
  • Serial transfer, no queue yet: multiple playlists transferring at once is explicitly out of scope for this PR — the phone currently blocks starting a second batch transfer while one is active, rather than queuing it. A real queue is a larger, separable piece of work.
  • Transcoding threshold: re-enchelps (lossless/high-bitratesources), leave already-reasonable lossy sources alone.
  • **Offload/stall recovery duplicatth the phone's equivalentplayer-engine code: the two players differ enough in shape (single-player watch service vs. dual-player crossfading phone engine) that sharing would mean threading watch-only parameters through phone-only code for a ~30-line function.

Testing

  • Extensive JVM unit test coverage (shared contract, RoomDAOs/migrations that don't need instrumentation, transcoding decision logic, batch coordinator, performance-settings repository, offload/stall watchdogs, playback-state persistence, playlist-sync ack, transfer-outcome ack). Where a path genuinely can't be exercised without a real
    device (ChannelClient streaming, hardware,MediaController/foreground-service wiring, resource-shrinker behavior, static GMS client mocking that hangs this environment's sandbox), that's called out explicitly rather than left implicit or faked.
  • All included commits build clean (compileDebugKotlin across :app/:wear/:shared) and pass their unit test suites; release builds (assembleRelease with R8 + resource shrinking) were verified clean, including a shrinker-specific bug this feature exposed (a capability-advertising resource reachable only by naming convention, not by any code reference, was being stripped from release builds).
  • Physical-device testing: iterated over multiple real-hardware sessions across varied real-world conditions — screen on and off/ambient, watch worn on the wrist versus resting, charging and on battery, actively controlling music through the watch's own local playback as well as in remote-control mode with the phone in range, and a real ~30-minute outdoor run wearing the watch while a separate fitness-tracking app ran concurrently and competed for CPU/RAM/Bluetooth. Several of the reliability and performance fixes above exist specifically because that testing
    surfaced real failures (lost songs, stalls, a watch that stoppedadvertising itself in release builds) that unit tests alone wouldn't have caught.

Known gaps / explicitly out of scope

  • FAVORITES_SYNC_REQUEST/STATE are declared in the shared contract but not fully wired (no manifest registration, no phone-side handler) — pre-existing from earlier in this feature's development, unrelated to what this PR delivers, left as-is rather than silently finishing
    unrelated scope inside this PR.
  • No real multi-playlist transfer queue yet — starting a second batch transfer is blocked, not
    queued, while one is in progress.
  • Reusing the batch-transfer maching. sending a whole album or artist)is a natural follow-up but isn't part of this PR.

Checklist

  • :app, :wear, :shared compile clean
  • Unit test suites pass (no regests)
  • Release builds (assembleRelease, R8 + resource shrinking) verified clean
  • Tested on physical hardware aconditions (see Testing above)
  • Open to feedback on splitting this into smaller reviewable PRs if preferred, given the size

…, reliability hardening

Adds a full Wear OS companion experience for offline listening: transfer
playlists from the phone to the watch (transcode, chunked transfer, resumable
across process death), play them locally on the watch without the phone
nearby, and control the phone remotely when it's in range — plus several
rounds of reliability and performance hardening driven by real hardware
testing (audio offload with HAL-reset fallback, mid-song stall recovery,
playlist-sync and transfer-outcome acks so the phone never assumes success
without watch confirmation, and user-facing performance toggles — album art,
dynamic color, play-button animation — for constrained watch hardware).

This is a squashed extraction of ~55 commits (PR8, PR24, PR25, PR26) built up
across a longer development history on a personal fork, isolated onto master
here as a single self-contained changeset for review. Excludes everything
from that fork unrelated to this feature (a playlist search filter, an FTS
search fix, personal app branding) — none of it touched.

Known gap carried over from the fork: FAVORITES_SYNC_REQUEST/STATE are
declared but not fully wired (no manifest registration, no phone-side
handler) — pre-existing, unrelated to this feature, left as-is rather than
silently finishing it inside an unrelated PR.
@PonceGL
PonceGL force-pushed the upstream-proposal/wear-companion-app branch from 5c981c7 to ff2f25b Compare August 12, 2026 20:21
Nueve defectos encontrados al revisar upstream-proposal/wear-companion-app
contra master, todos verificados contra el código antes de corregirse.

Altos:
- Una transferencia guardada en biblioteca podía quedarse para siempre en
  STATUS_AWAITING_WATCH_ACK si el reporte del reloj nunca llegaba, dejando
  clavada la notificación en primer plano y el indicador de "enviando". El
  timeout vive en PhoneWatchTransferStateStore para cubrir también el camino
  de canción suelta, que no tiene ningún await propio del que colgar uno.
- El interruptor de tema dinámico no hacía nada mientras la carátula
  estuviera activada: el tema caía a derivar la paleta del bitmap, por la
  ruta más cara que el propio interruptor existe para evitar.

Medios:
- Un batch reanudado tras la muerte del proceso perdía su intent para
  siempre si el reloj no estaba al alcance en ese arranque. Ahora sobrevive
  y caduca a los 7 días, arrastrando la marca de tiempo original.
- Con bitrate desconocido (el caso normal: sólo el escaneo profundo lo
  captura) se re-codificaba toda la biblioteca a AAC 128k. Ahora se sondea
  el archivo antes de decidir.
- Un stream cerrado por el watchdog podía confirmar como completo un
  archivo truncado, que quedaba en Room como reproducible y el teléfono no
  volvía a intentar.
- ERROR_ALREADY_ON_WATCH se contaba como fallo: se re-transcodificaba y se
  reofrecía la misma canción para reportarla luego como fallida.
- Un Transformer atascado colgaba el batch indefinidamente.

Bajos:
- Un batch cancelado antes de arrancar se reportaba como "Completado".
- Al restaurar la reproducción, la posición guardada se aplicaba a otra
  canción si la original había sido borrada del reloj.
Reportado en dispositivo: con "mostrar carátula", "color dinámico" y
"animación del botón" desactivados en el teléfono, la reproducción local
del reloj seguía mostrando las tres cosas.

Los gates estaban bien —los verifiqué de punta a punta: el repositorio
local, el ViewModel y PlayerScreen aplican los tres flags correctamente.
Lo que fallaba era que el reloj nunca se enteraba del valor:

- El reloj sólo aprendía los ajustes por push (onDataChanged), y un
  DataItem con contenido idéntico no genera evento. Reinstalar la app del
  reloj —cada build de depuración— borra su copia local y la deja en los
  valores por defecto (todo activado), y el reanuncio del teléfono al
  abrir la pantalla de ajustes es byte a byte el mismo DataItem, así que
  se descarta y el reloj se queda desincronizado para siempre. Ahora el
  reloj lee el DataItem publicado al arrancar el proceso.
- Además, la escritura del ajuste corría en el scope del
  WearableListenerService, que onDestroy cancela en cuanto el callback
  retorna: la escritura en DataStore competía con ese teardown y podía
  perderse. Al ser un evento único, perderla es permanente. Ahora se
  escribe en línea sobre el hilo del callback.
… en dispositivo

Del reporte de pruebas físicas:

- La carátula y los colores aparecían al iniciar la reproducción y se
  quitaban uno o dos segundos después. La lectura de los ajustes al
  arrancar tarda lo suyo (llamada a Play Services), y hasta entonces se
  actuaba con los valores cacheados. Ahora hay un estado "resuelto": los
  consumidores tratan "aún no lo sé" como desactivado, así que el trabajo
  caro se difiere en vez de hacerse y deshacerse. Con red de seguridad
  para que un fallo no deje la carátula suprimida para siempre.

- El teléfono nunca procesaba la respuesta de biblioteca del reloj: el
  manifiesto enruta /watch_library_state a WearCommandReceiver, pero su
  `when` no tenía rama y caía en "Unknown message path". La consecuencia
  es que el teléfono sólo sabía lo que él mismo había transferido y nunca
  se corregía —canciones borradas en el reloj, o el reloj reinstalado con
  su biblioteca en blanco, seguían contando como presentes—, y también
  que isWatchLibraryResolved nunca se ponía a true, así que el resume
  siempre agotaba sus 10 s de espera.

- "Actualizar en el reloj" salía para playlists nunca enviadas, porque se
  decidía con "alguna canción de la playlist está en el reloj". Ahora el
  reloj informa qué playlists tiene y la etiqueta usa esa señal.

- El chip de error del reloj no decía qué canción falló, y sobrevivía a
  un reintento exitoso. Ahora recupera el título y una transferencia
  completada descarta los errores previos de esa misma canción.
…dispositivo

Revisión de código de la rama contra master (9 hallazgos, todos con
escenario de fallo verificado) más lo que salió de la prueba física:

- Cuelgue permanente en STATUS_AWAITING_WATCH_ACK y notificación clavada.
- El interruptor de tema dinámico no hacía nada con la carátula activada.
- Intent de batch perdido al reanudar sin reloj al alcance; ahora caduca
  a los 7 días en vez de descartarse.
- Todo se re-codificaba a AAC 128k por bitrate desconocido; ahora se
  sondea el archivo.
- Un archivo truncado podía confirmarse como completo.
- ERROR_ALREADY_ON_WATCH se contaba como fallo y disparaba un reintento.
- Un Transformer atascado colgaba el batch indefinidamente.
- Batch cancelado antes de arrancar reportado como "Completado".
- Posición restaurada sobre la canción equivocada.

Y del dispositivo:

- Los tres ajustes de rendimiento nunca llegaban al reloj (sólo push, que
  el Data Layer descarta si el DataItem no cambia, más una escritura que
  competía con el teardown del servicio).
- El teléfono no procesaba /watch_library_state, así que nunca conocía la
  biblioteca real del reloj.
- "Actualizar en el reloj" para playlists nunca enviadas.
- Carátula y colores aplicándose y quitándose al iniciar la reproducción.
- Chip de error sin título de canción y persistente tras un reintento
  exitoso.
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