Files
yellowjacket/backend/mediacontrols/mediacontrols.go
logan da38b865fc feat(android): playback that survives the screen locking
An app that plays audio becomes a music player at the point where the
screen can lock, a call can interrupt, and the headphones can come out.
None of that existed: the foreground service was typed for media but
had no MediaSession, no transport notification and no audio focus, so
oto would happily keep writing to a stream nobody could hear.

The apparent blocker is that Wails' androidBridge* helpers are
unexported, so Go cannot call arbitrary Java. It does not need to.
StartForegroundService(json) *is* exported, and build/android/ is our
tree, so widening the JSON WailsBridge already accepts is a local edit;
coming back, WailsBridge.emitEvent lands on the application event bus,
which Go subscribes to with app.Event.On. One document out, one command
event back, and no new JNI. No new Gradle dependency either: minSdk is
21, which is exactly when android.media.session.MediaSession and
Notification.MediaStyle arrived, so androidx.media buys two
Build.VERSION branches' worth of nothing.

Four things in it are load-bearing.

**A duck is not a volume change.** Player.SetDuck holds the attenuation
as an offset and re-applies the user's level through setVolumeLocked,
so it cannot accumulate across repeated ducks and getUserVolume -- which
feeds the event, the persisted state and every relative change -- still
reports what the user chose. Writing through to the volume would let
one notification tone permanently turn the music down.

**The duck path is pre-Oreo only.** From API 26 the framework ducks the
app itself and sends no CAN_DUCK focus change; asking to be told
instead (setWillPauseWhenDucked) would mean pausing for every
notification tone, and doing both would attenuate twice.

**An unchanged payload is not an event**, the rule emitStatus already
states one package over: every push crosses JNI and re-delivers an
Intent, and the player pushes state on several paths that can agree.

**After the first start, an update is startService.** From Android 12 a
background app may not *start* a foreground service but may keep
feeding one it already has, which is every track change with the screen
off. Relatedly, every path through onStartCommand calls startForeground
-- one that returns without it is killed.

The contract with Java lives in androidpayload.go *without* the android
build tag, and is tested. Everything left in android.go is untested by
construction: make lint and make test are three tag sets on
linux/amd64, so the only thing that compiles it is the cross-compiler
in make android, and the only thing that can run it is a phone.

None of the behaviour above has been observed on a device. The APK
builds and both halves compile; that is the whole of what is verified.
2026-08-16 22:26:03 -04:00

71 lines
2.2 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package mediacontrols provides OS media control integration.
//
// On Linux this registers a MPRIS2 D-Bus service so that desktop
// environments, playerctl, and media keys can control playback and
// see the currently playing track. Other platforms get a no-op stub.
package mediacontrols
// PlaybackState represents the current playback state for the OS.
type PlaybackState int
// Playback state values.
const (
StateStopped PlaybackState = iota
StatePlaying
StatePaused
)
// Metadata holds track information to display in the OS media overlay.
type Metadata struct {
Title string
Artist string
Album string
ArtFilePath string // Absolute filesystem path to cover art.
DurationSec int
}
// Callbacks are invoked when the OS sends media commands.
type Callbacks struct {
OnPlay func()
OnPause func()
OnPlayPause func()
OnStop func()
OnNext func()
OnPrevious func()
OnSeek func(positionSec int)
OnVolume func(volume float64) // 0.01.0 linear scale.
// OnDuck asks for playback to be attenuated (true) or restored
// (false) without changing the user's volume. Android alone sends
// it, and only below API 26 -- from Oreo the audio framework ducks
// the app itself and reports no such focus change, so doing both
// would attenuate twice.
OnDuck func(ducked bool)
}
// Handler manages the OS media control integration.
type Handler interface {
// Init registers with the OS and wires incoming commands to
// the provided callbacks. It must be called once during startup.
Init(callbacks Callbacks) error
// UpdateMetadata pushes new track metadata to the OS overlay.
UpdateMetadata(meta Metadata)
// UpdatePlaybackState pushes the playback state and current
// position. The position is used as a new anchor; the OS
// interpolates from there while playing.
UpdatePlaybackState(state PlaybackState, positionSec int)
// NotifySeek signals that the user seeked to a new position.
// This is separate from UpdatePlaybackState because MPRIS
// emits a distinct Seeked signal for this.
NotifySeek(positionSec int)
// UpdateVolume pushes the current volume (0.01.0) to the OS.
UpdateVolume(volume float64)
// Close tears down the OS registration and releases resources.
Close()
}