feat(wear): Wear OS companion app — playlist transfer, local playback & remote control - #2733
Open
PonceGL wants to merge 5 commits into
Open
feat(wear): Wear OS companion app — playlist transfer, local playback & remote control#2733PonceGL wants to merge 5 commits into
PonceGL wants to merge 5 commits into
Conversation
…, 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
force-pushed
the
upstream-proposal/wear-companion-app
branch
from
August 12, 2026 20:21
5c981c7 to
ff2f25b
Compare
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
masteras 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
:sharedmodule) 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.### 2. Reliability hardening (multi a real failure seen on hardware)
leaving the coroutine still blocked — so a "timed out" transfer couldstill finish successfully afterward and corrupt bookkeeping (
Transfer metadata missingfor 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.and receive-side (watch-reported ouf truth for "is this song really on
the watch") race conditions are add
3. Local playback performance ()
LoadControlbuffer 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.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.
— the player reports
STATE_READY/udio has silently stopped, with nostate-change event to react to. A p(checks for stalled position overconsecutive ticks) triggers the same player-rebuild recovery independent of cause.
collectAsState()tocollectAsStateWithLifecycle(), plus gating the position/lyrics-index tickloops on watch interactivity — closng (and costing CPU/battery) evenwith the screen off or in ambient mode.
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
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.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
ChannelClientfor audio,Meser protocol,DataItemfor durable settings/pairing state** — deliberafor different guarantee needs.MessageClient` only confirms local delivery, not that the other side received it, so every placeit's used for something that must ns an explicit application-level ack.
Testing
device (
ChannelClientstreaming, 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.compileDebugKotlinacross:app/:wear/:shared) and pass their unit test suites; release builds (assembleReleasewith 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).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/STATEare 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 finishingunrelated scope inside this PR.
queued, while one is in progress.
Checklist
:app,:wear,:sharedcompile cleanassembleRelease, R8 + resource shrinking) verified clean