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.
This commit is contained in:
2026-08-16 22:26:03 -04:00
parent ced537ecf2
commit da38b865fc
11 changed files with 1130 additions and 51 deletions
+10 -5
View File
@@ -484,20 +484,24 @@ func (yj *YellowJacketApp) OnStartup(ctx context.Context) {
// Register playback finished handler to drive queue auto-advance.
yj.player.SetPlaybackFinishedHandler(yj.queue.OnPlaybackFinished)
// Initialize OS media controls (MPRIS on Linux, no-op elsewhere).
// Initialize OS media controls (MPRIS on desktop Linux, a
// MediaSession on Android, no-op elsewhere). The callbacks are the
// same on every platform; only what delivers them differs.
yj.mediaControls = mediacontrols.NewHandler(yj.logger)
if err := yj.mediaControls.Init(mediacontrols.Callbacks{
OnPlay: yj.queue.Play,
OnPause: func() {
if err := yj.player.Pause(); err != nil {
yj.logger.Warn("MPRIS Pause failed", "err", err)
yj.logger.Warn("Media controls Pause failed", "err", err)
}
},
OnPlayPause: func() {
if yj.player.IsPlaying() {
if err := yj.player.Pause(); err != nil {
yj.logger.Warn("MPRIS PlayPause(pause) failed", "err", err)
yj.logger.Warn(
"Media controls PlayPause(pause) failed", "err", err,
)
}
} else {
yj.queue.Play()
@@ -505,14 +509,14 @@ func (yj *YellowJacketApp) OnStartup(ctx context.Context) {
},
OnStop: func() {
if err := yj.player.Pause(); err != nil {
yj.logger.Warn("MPRIS Stop failed", "err", err)
yj.logger.Warn("Media controls Stop failed", "err", err)
}
},
OnNext: yj.queue.Next,
OnPrevious: yj.queue.Previous,
OnSeek: func(positionSec int) {
if err := yj.player.Seek(positionSec); err != nil {
yj.logger.Warn("MPRIS Seek failed", "err", err)
yj.logger.Warn("Media controls Seek failed", "err", err)
}
},
OnVolume: func(vol float64) {
@@ -522,6 +526,7 @@ func (yj *YellowJacketApp) OnStartup(ctx context.Context) {
),
)
},
OnDuck: yj.player.SetDuck,
}); err != nil {
yj.logger.Error(
"Failed to initialize media controls",
+215
View File
@@ -0,0 +1,215 @@
//go:build android
// Android's answer to MPRIS is a MediaSession, and reaching it needs no
// new JNI: Wails exports application.Android.StartForegroundService(json)
// going out, and Java's WailsBridge.emitEvent lands on the application
// event bus coming back. So this handler is one JSON payload pushed to
// the foreground service and one command event read from it. The Java
// half is
// build/android/app/src/main/java/com/wails/app/WailsForegroundService.java
// and the payload keys below are its contract.
package mediacontrols
import (
"errors"
"log/slog"
"sync"
"github.com/wailsapp/wails/v3/pkg/application"
)
// commandEvent is the event name the Java side emits transport
// commands on. It is a plain string on both sides; changing it means
// changing WailsForegroundService too.
const commandEvent = "yj:media:command"
var errNoApplication = errors.New(
"no running application to attach media controls to",
)
// androidHandler drives the media notification, the lock-screen
// transport and audio focus through the foreground service.
type androidHandler struct {
logger *slog.Logger
mu sync.Mutex
callbacks Callbacks
meta Metadata
state PlaybackState
positionSec int
// running tracks whether the foreground service has been started.
// Android 12+ forbids starting one from the background, so it is
// started when playback starts -- a user action, in a visible app
// -- and stopped only when playback stops, which is what keeps
// queue auto-advance working with the screen off.
running bool
// lastPayload is the last JSON sent. An unchanged payload is not
// an event here either: every push crosses JNI and re-delivers an
// Intent, and the player pushes state on several paths that can
// agree.
lastPayload string
unsubscribe func()
}
// NewHandler returns the Android media-session handler.
func NewHandler(logger *slog.Logger) Handler {
return &androidHandler{logger: logger, state: StateStopped}
}
// Init subscribes to the transport commands the Java side emits.
func (a *androidHandler) Init(callbacks Callbacks) error {
app := application.Get()
if app == nil {
return errNoApplication
}
a.mu.Lock()
a.callbacks = callbacks
a.mu.Unlock()
a.unsubscribe = app.Event.On(commandEvent, a.onCommand)
return nil
}
// onCommand dispatches one transport command from the notification,
// the lock screen, a headset button or an audio-focus change.
//
// Every callback runs on its own goroutine, for the reason the MPRIS
// handler does the same: they take the player and queue mutexes, and
// this runs on the event processor's dispatch goroutine.
func (a *androidHandler) onCommand(event *application.CustomEvent) {
data, ok := event.Data.(map[string]any)
if !ok {
return
}
command := parseMediaCommand(data)
a.mu.Lock()
cb := a.callbacks
a.mu.Unlock()
switch command.name {
case cmdPlay:
run(cb.OnPlay)
case cmdPause:
run(cb.OnPause)
case cmdPlayPause:
run(cb.OnPlayPause)
case cmdStop:
run(cb.OnStop)
case cmdNext:
run(cb.OnNext)
case cmdPrevious:
run(cb.OnPrevious)
case cmdSeek:
if cb.OnSeek != nil {
go cb.OnSeek(command.positionSec)
}
case cmdDuck:
if cb.OnDuck != nil {
go cb.OnDuck(command.duck)
}
default:
a.logger.Warn("Unknown media command", "command", command.name)
}
}
// run invokes a callback on its own goroutine, tolerating a nil one.
func run(fn func()) {
if fn != nil {
go fn()
}
}
// UpdateMetadata pushes new track details to the notification.
func (a *androidHandler) UpdateMetadata(meta Metadata) {
a.mu.Lock()
defer a.mu.Unlock()
a.meta = meta
a.push()
}
// UpdatePlaybackState pushes the state and a fresh position anchor;
// the MediaSession interpolates from there while playing.
func (a *androidHandler) UpdatePlaybackState(
state PlaybackState,
positionSec int,
) {
a.mu.Lock()
defer a.mu.Unlock()
a.state = state
a.positionSec = positionSec
a.push()
}
// NotifySeek re-anchors the position. Unlike MPRIS, a MediaSession has
// no separate seeked signal -- a new state with a new position is the
// whole mechanism.
func (a *androidHandler) NotifySeek(positionSec int) {
a.mu.Lock()
defer a.mu.Unlock()
a.positionSec = positionSec
a.push()
}
// UpdateVolume is deliberately a no-op. Android's volume keys act on
// the media stream, which the OS owns; an app that also moved its own
// volume in response would move it twice.
func (a *androidHandler) UpdateVolume(_ float64) {}
// Close stops the service and drops the command subscription.
func (a *androidHandler) Close() {
a.mu.Lock()
defer a.mu.Unlock()
if a.unsubscribe != nil {
a.unsubscribe()
a.unsubscribe = nil
}
if a.running {
application.Android.StopForegroundService()
a.running = false
}
}
// push sends the current state to the Java side, if it has changed.
// The caller holds a.mu.
func (a *androidHandler) push() {
if a.state == StateStopped {
// Nothing is playing, so nothing justifies an ongoing
// notification or the process staying alive.
if a.running {
application.Android.StopForegroundService()
a.running = false
a.lastPayload = ""
}
return
}
payload, err := mediaPayload(a.meta, a.state, a.positionSec)
if err != nil {
a.logger.Error("Failed to encode media payload", "err", err)
return
}
if payload == a.lastPayload {
return
}
a.lastPayload = payload
a.running = true
application.Android.StartForegroundService(payload)
}
+85
View File
@@ -0,0 +1,85 @@
// The contract between the Android handler and the Java
// WailsForegroundService is two JSON documents -- one pushed out with
// the track and the state, one read back with a transport command --
// and neither side can check the other.
//
// It lives here, *without* the android build tag, so that `go test` on
// any platform exercises it. android.go itself can only be compiled by
// a cross-compiler and only be run by a phone, so anything left in it
// is untested by construction; this is the half worth not leaving
// there.
package mediacontrols
import "encoding/json"
// Media command names, as the Java side spells them.
const (
cmdPlay = "play"
cmdPause = "pause"
cmdPlayPause = "playpause"
cmdStop = "stop"
cmdNext = "next"
cmdPrevious = "previous"
cmdSeek = "seek"
cmdDuck = "duck"
)
// stateNames are what the payload's "state" key carries. Words rather
// than the PlaybackState integers, because the Java side reads them as
// JSON and a renumbered constant would silently mean something else
// there.
var stateNames = map[PlaybackState]string{
StateStopped: "stopped",
StatePlaying: "playing",
StatePaused: "paused",
}
// mediaCommand is one transport command from the notification, the
// lock screen, a headset button or an audio-focus change.
type mediaCommand struct {
name string
positionSec int
duck bool
}
// mediaPayload encodes the state the notification and MediaSession
// render.
func mediaPayload(
meta Metadata,
state PlaybackState,
positionSec int,
) (string, error) {
payload, err := json.Marshal(map[string]any{
"title": meta.Title,
"artist": meta.Artist,
"album": meta.Album,
"artPath": meta.ArtFilePath,
"durationSec": meta.DurationSec,
"positionSec": positionSec,
"state": stateNames[state],
})
if err != nil {
return "", err
}
return string(payload), nil
}
// parseMediaCommand reads one command out of the event payload.
//
// The numbers arrive as float64 because they came through
// encoding/json as an untyped document -- asserting int here is the
// way a seek silently becomes a seek to zero.
func parseMediaCommand(data map[string]any) mediaCommand {
cmd := mediaCommand{}
cmd.name, _ = data["command"].(string)
if position, ok := data["positionSec"].(float64); ok {
cmd.positionSec = int(position)
}
cmd.duck, _ = data["on"].(bool)
return cmd
}
@@ -0,0 +1,165 @@
package mediacontrols
import (
"encoding/json"
"testing"
)
// TestMediaPayloadKeys pins the document the Java side parses. The
// keys are the contract: a rename here is silently a track with no
// title on the lock screen, because WailsForegroundService reads them
// with optString and a missing key is simply "".
func TestMediaPayloadKeys(t *testing.T) {
t.Parallel()
payload, err := mediaPayload(Metadata{
Title: "Tideline",
Artist: "Sea Change",
Album: "Ebb",
ArtFilePath: "/covers/ebb_lg.jpg",
DurationSec: 245,
}, StatePlaying, 30)
if err != nil {
t.Fatalf("mediaPayload: %v", err)
}
var got map[string]any
if err := json.Unmarshal([]byte(payload), &got); err != nil {
t.Fatalf("payload is not JSON: %v", err)
}
want := map[string]any{
"title": "Tideline",
"artist": "Sea Change",
"album": "Ebb",
"artPath": "/covers/ebb_lg.jpg",
"durationSec": float64(245),
"positionSec": float64(30),
"state": "playing",
}
if len(got) != len(want) {
t.Errorf("payload has %d keys, want %d: %s", len(got), len(want), payload)
}
for key, expected := range want {
if got[key] != expected {
t.Errorf("payload[%q] = %v, want %v", key, got[key], expected)
}
}
}
// TestMediaPayloadStateNames covers the one value the Java side
// compares against a literal.
func TestMediaPayloadStateNames(t *testing.T) {
t.Parallel()
tests := []struct {
state PlaybackState
want string
}{
{StatePlaying, "playing"},
{StatePaused, "paused"},
{StateStopped, "stopped"},
}
for _, tt := range tests {
payload, err := mediaPayload(Metadata{}, tt.state, 0)
if err != nil {
t.Fatalf("mediaPayload: %v", err)
}
var got struct {
State string `json:"state"`
}
if err := json.Unmarshal([]byte(payload), &got); err != nil {
t.Fatalf("payload is not JSON: %v", err)
}
if got.State != tt.want {
t.Errorf("state %d encoded as %q, want %q", tt.state, got.State, tt.want)
}
}
}
// TestParseMediaCommand covers the direction that arrives untyped.
// The seek case is the one with teeth: the position crosses as a JSON
// number, so it is a float64 in the map and an int assertion would
// make every seek a seek to zero.
func TestParseMediaCommand(t *testing.T) {
t.Parallel()
tests := []struct {
name string
data map[string]any
want mediaCommand
}{
{
name: "play",
data: map[string]any{"command": "play"},
want: mediaCommand{name: cmdPlay},
},
{
name: "seek carries a position",
data: map[string]any{"command": "seek", "positionSec": float64(93)},
want: mediaCommand{name: cmdSeek, positionSec: 93},
},
{
name: "duck carries a flag",
data: map[string]any{"command": "duck", "on": true},
want: mediaCommand{name: cmdDuck, duck: true},
},
{
name: "unduck",
data: map[string]any{"command": "duck", "on": false},
want: mediaCommand{name: cmdDuck},
},
{
name: "a command with nothing in it is not a panic",
data: map[string]any{},
want: mediaCommand{},
},
{
name: "wrongly typed fields fall back to zero",
data: map[string]any{"command": "seek", "positionSec": "93"},
want: mediaCommand{name: cmdSeek},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := parseMediaCommand(tt.data); got != tt.want {
t.Errorf("parseMediaCommand(%v) = %+v, want %+v", tt.data, got, tt.want)
}
})
}
}
// TestMediaCommandNamesAreWhatJavaSends is a spelling check against
// the Java side, which builds these strings by hand. It is a list, not
// a mechanism: nothing can reach across into the .java file, so the
// point is that changing one of these constants fails a test that
// names the file to change with it.
//
// See build/android/app/src/main/java/com/wails/app/WailsForegroundService.java.
func TestMediaCommandNamesAreWhatJavaSends(t *testing.T) {
t.Parallel()
want := []string{
"play", "pause", "playpause", "stop",
"next", "previous", "seek", "duck",
}
got := []string{
cmdPlay, cmdPause, cmdPlayPause, cmdStop,
cmdNext, cmdPrevious, cmdSeek, cmdDuck,
}
for i, name := range want {
if got[i] != name {
t.Errorf("command %d = %q, want %q", i, got[i], name)
}
}
}
+7
View File
@@ -34,6 +34,13 @@ type Callbacks struct {
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.
+5 -6
View File
@@ -1,10 +1,9 @@
//go:build !linux || android
//go:build !linux
// Android is covered here rather than by mpris_linux.go: it satisfies
// the `linux` tag but has no D-Bus session bus. Its real equivalent is
// a MediaSession, which is Java-side work and not yet built -- so for
// now the app simply has no lock-screen transport there, which is a
// missing feature rather than a broken one.
// Windows and macOS have no media-control integration yet. `!linux`
// covers Android too without naming it, since `android` implies the
// `linux` tag -- android.go claims it, mpris_linux.go excludes it, and
// this file is left with the platforms neither wants.
package mediacontrols
+39 -2
View File
@@ -56,6 +56,13 @@ type Player struct {
trackChangeID uint64
mediaControls mediacontrols.Handler
// duckAmount is the attenuation currently applied on top of the
// user's volume, in the same base-2 exponent effects.Volume uses.
// It is deliberately not persisted and emits no VolumeChanged: a
// duck is something the OS did for the length of a notification,
// not something the user chose.
duckAmount float64
// trackLengthMs holds the authoritative track duration in
// milliseconds, sourced from the database (which uses the
// custom header parser). The go-mp3 decoder's Len() can be
@@ -793,12 +800,40 @@ func (p *Player) setVolumeLocked(desiredVolume UserVolume) {
speaker.Lock()
volume := clampVolume(desiredVolume)
p.volume.Volume = float64(volume.ToVolume())
p.volume.Volume = float64(volume.ToVolume()) - p.duckAmount
p.volume.Silent = volume == MinUserVol
speaker.Unlock()
}
// SetDuck attenuates playback (or restores it) without changing the
// user's volume, for an OS that has asked us to get out of the way of
// something short -- a navigation prompt, a notification tone.
//
// It re-applies the *user's* level through setVolumeLocked rather than
// nudging the effect directly, so the offset cannot accumulate across
// repeated ducks, and it neither emits nor persists: the level the user
// set has not changed and the UI must not claim it has.
//
//wails:ignore // driven by OS audio focus, not by the frontend.
func (p *Player) SetDuck(ducked bool) {
p.mu.Lock()
defer p.mu.Unlock()
amount := 0.0
if ducked {
amount = duckAttenuation
}
if p.volume == nil || amount == p.duckAmount {
return
}
current := p.getUserVolume()
p.duckAmount = amount
p.setVolumeLocked(current)
}
// ChangeVolume adjusts the volume by a relative amount.
func (p *Player) ChangeVolume(deltaVolume int) error {
p.mu.Lock()
@@ -812,7 +847,9 @@ func (p *Player) ChangeVolume(deltaVolume int) error {
}
func (p *Player) getUserVolume() UserVolume {
return Volume(p.volume.Volume).ToUserVolume()
// Undo any duck, so every caller -- the event, the persisted
// state, a relative change -- sees the level the user chose.
return Volume(p.volume.Volume + p.duckAmount).ToUserVolume()
}
// Muted reports whether playback is currently silenced.
+6
View File
@@ -19,6 +19,12 @@ const (
MaxVol Volume = 0
)
// duckAttenuation is how far playback drops when the OS asks us to
// duck, on the same base-2 exponent scale: two steps is a quarter of
// the amplitude (-12 dB), which is audible under a spoken notification
// without sounding like a pause.
const duckAttenuation = 2.0
// ToVolume converts user volume to internal player volume.
func (oldVol UserVolume) ToVolume() Volume {
var newVol Volume
+60
View File
@@ -1,9 +1,12 @@
package player
import (
"log/slog"
"math"
"testing"
"github.com/gopxl/beep/v2/effects"
"yellowjacket/backend/mediacontrols"
)
@@ -202,3 +205,60 @@ func TestStateToMediaControls(t *testing.T) {
})
}
}
// TestSetDuck covers the property the duck rests on: the attenuation
// is applied to the output and is invisible to everything that asks
// what the volume is -- the event, the persisted state, a relative
// change. Getting that wrong would let one notification tone
// permanently rewrite the user's volume.
func TestSetDuck(t *testing.T) {
t.Parallel()
p := NewPlayer(slog.Default(), nil)
p.volume = &effects.Volume{Base: 2}
p.setVolumeLocked(80)
unducked := p.volume.Volume
p.SetDuck(true)
if p.volume.Volume >= unducked {
t.Errorf(
"ducked output volume = %v, want less than %v",
p.volume.Volume, unducked,
)
}
if got := p.getUserVolume(); got != 80 {
t.Errorf("user volume while ducked = %d, want 80", got)
}
// A second duck must not stack: the offset is re-applied to the
// user's level, never subtracted again from the current output.
ducked := p.volume.Volume
p.SetDuck(true)
if p.volume.Volume != ducked {
t.Errorf(
"duck applied twice = %v, want %v", p.volume.Volume, ducked,
)
}
// Changing the volume while ducked keeps the attenuation.
p.setVolumeLocked(60)
if got := p.getUserVolume(); got != 60 {
t.Errorf("user volume set while ducked = %d, want 60", got)
}
if want := float64(UserVolume(60).ToVolume()) - duckAttenuation; p.volume.Volume != want {
t.Errorf("output while ducked = %v, want %v", p.volume.Volume, want)
}
p.SetDuck(false)
if want := float64(UserVolume(60).ToVolume()); p.volume.Volume != want {
t.Errorf("output after unduck = %v, want %v", p.volume.Volume, want)
}
}