Skip to content

feat: detached audio playback - #148

Merged
brunoro merged 3 commits into
mainfrom
feat/audio-detached
Aug 12, 2026
Merged

feat: detached audio playback#148
brunoro merged 3 commits into
mainfrom
feat/audio-detached

Conversation

@brunoro

@brunoro brunoro commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Tools can move playback into an SDK-owned MediaSessionService, so audio survives the screen that started it. newPlayer(playback = Detached) returns a handle backed by a MediaController instead of an in-process ExoPlayer.
Both modes share one public surface for queue, transport, position, metadata and errors, so nothing from media3 reaches the public API.

A detailed explanation of the architecture can be found in docs/design_decisions/detached_audio.md.

Some noteworthy details:

  • release() on a detached handle disconnects without stopping playback. Call stop() first to actually end it.
  • A reconnecting player may find a live session, so it must awaitReady() and reuse a non-empty queue rather than replacing it.
  • The service stops itself 60s after playback pauses with no handle open. The handle is the liveness signal, not the controller count. media3's notification controller and platform media button controllers might connect too, and this design ignores those connections for the timeout.
  • A live session keeps its LightAudioUsage. Reconnecting with a different one throws.
  • Playback failures surface as LightAudioError in both modes; player-creation failures throw LightAudioPlayerException.

Opting in

[tool]
capabilities = ["detached-audio"]

[tool].capabilities is new. The Gradle plugin expands detached-audio into the two foreground-service permissions, the LightAudioService declaration, and a com.thelightphone.sdk.CAPABILITY_DETACHED_AUDIO metadata marker.

The metada marker is what the SDK checks at runtime.

androidx.work contributes FOREGROUND_SERVICE transitively to every tool, so a platform permission can never be a statement about what the tool asked for. A metadata key under our own namespace can only come from our generator.

Both permissions were removed from ALLOWED_PERMISSIONS; hand-writing one now fails the build with an error naming the capability to declare instead.

LightAudioService also moved out of the SDK library manifest, so tools that never touch audio no longer ship a mediaPlayback claim.

Testing

The audio demo Player tab has a DETACHED toggle that switches between the playback modes. With DETACHED: ON, you should be able to lock the phone and navigate in the UI without stopping playback. The player state should be updated when you come back to it.

Tools can move playback into an SDK-owned MediaSessionService so it
survives the screen that started it. newPlayer(playback = Detached)
returns a handle backed by a MediaController instead of an in-process
ExoPlayer; both modes share one public surface for queue, transport,
position, metadata and errors.

Opt in with `capabilities = ["detached-audio"]` in lighttool.toml. The
Gradle plugin turns that one entry into the foreground-service
permissions, the service declaration, and a meta-data marker under our
own namespace. The marker is what the SDK checks at runtime, because a
platform permission can be contributed by any transitive dependency and
says nothing about what the tool asked for.

Detached ownership differs from attached in two ways the docs lead with:
release() disconnects the handle without stopping playback, and a
reconnecting player may find a live session, so it awaits readiness and
reuses a non-empty queue rather than replacing it. The service stops
itself 60 seconds after playback pauses with no handle open.

Playback failures surface as SDK-owned LightAudioError values in both
modes, so no media3 type reaches the public API.
@brunoro
brunoro requested a review from dupontgu August 5, 2026 23:37
@brunoro

brunoro commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

cc @jonathancaudill @tthayer

@jonathancaudill

Copy link
Copy Markdown

@brunoro this looks great!!

@dupontgu dupontgu left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Awesome!

val error = playerFlow
.flatMapLatest { it.error }
.stateIn(viewModelScope, SharingStarted.Eagerly, currentPlayer.error.value)
val playback = mode.playback

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: can add asStateFlow() so mutability isn't available publicly

@dupontgu

Copy link
Copy Markdown
Contributor

@jonathancaudill we wanna get your PR in too - would it be a huge pain if we merged this first? Sorry to keep you waiting.

@jonathancaudill

Copy link
Copy Markdown

@dupontgu no worries at all! just happy to be here

PlayerDemoMode.playback and PlayerViewModel.speed were public
MutableStateFlows. Back them with private mutable flows and expose
asStateFlow() so callers can't write to them.

mode.set() now updates the flow before persisting, which lets
replacePlayer() drop its redundant playback write.
The player's state flows were typed StateFlow but backed directly by
the MutableStateFlow instances, so a consumer could cast one and write
to it — desyncing reported state from actual playback. Wrap them so
only the player can emit.

availability is wrapped in PendingPlayerCommands, which LightAudioPlayer
forwards.
@brunoro
brunoro merged commit d2d8593 into main Aug 12, 2026
1 check passed
@brunoro
brunoro deleted the feat/audio-detached branch August 12, 2026 14:43
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.

3 participants