diff --git a/backend/app.go b/backend/app.go index 1e3e8bc..9a25996 100644 --- a/backend/app.go +++ b/backend/app.go @@ -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", diff --git a/backend/mediacontrols/android.go b/backend/mediacontrols/android.go new file mode 100644 index 0000000..a19c2e0 --- /dev/null +++ b/backend/mediacontrols/android.go @@ -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) +} diff --git a/backend/mediacontrols/androidpayload.go b/backend/mediacontrols/androidpayload.go new file mode 100644 index 0000000..8985772 --- /dev/null +++ b/backend/mediacontrols/androidpayload.go @@ -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 +} diff --git a/backend/mediacontrols/androidpayload_test.go b/backend/mediacontrols/androidpayload_test.go new file mode 100644 index 0000000..314a154 --- /dev/null +++ b/backend/mediacontrols/androidpayload_test.go @@ -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) + } + } +} diff --git a/backend/mediacontrols/mediacontrols.go b/backend/mediacontrols/mediacontrols.go index ae7f5d6..7df9c8a 100644 --- a/backend/mediacontrols/mediacontrols.go +++ b/backend/mediacontrols/mediacontrols.go @@ -34,6 +34,13 @@ type Callbacks struct { OnPrevious func() OnSeek func(positionSec int) OnVolume func(volume float64) // 0.0–1.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. diff --git a/backend/mediacontrols/stub.go b/backend/mediacontrols/stub.go index d79c69f..d1739a0 100644 --- a/backend/mediacontrols/stub.go +++ b/backend/mediacontrols/stub.go @@ -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 diff --git a/backend/player/player.go b/backend/player/player.go index d35e14b..b141a6d 100644 --- a/backend/player/player.go +++ b/backend/player/player.go @@ -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. diff --git a/backend/player/volume.go b/backend/player/volume.go index 5f2a002..e9cd0b0 100644 --- a/backend/player/volume.go +++ b/backend/player/volume.go @@ -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 diff --git a/backend/player/volume_test.go b/backend/player/volume_test.go index b887a28..423e1c7 100644 --- a/backend/player/volume_test.go +++ b/backend/player/volume_test.go @@ -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) + } +} diff --git a/build/android/app/src/main/java/com/wails/app/WailsBridge.java b/build/android/app/src/main/java/com/wails/app/WailsBridge.java index bdce333..6198770 100644 --- a/build/android/app/src/main/java/com/wails/app/WailsBridge.java +++ b/build/android/app/src/main/java/com/wails/app/WailsBridge.java @@ -81,6 +81,9 @@ public class WailsBridge { System.loadLibrary("wails"); } + /** The live bridge, for in-process components that are not given one. */ + private static volatile WailsBridge instance; + private final Activity activity; private final Handler mainHandler = new Handler(Looper.getMainLooper()); private WebView webView; @@ -122,6 +125,7 @@ public class WailsBridge { public WailsBridge(Activity activity) { this.activity = activity; + instance = this; } /** @@ -210,6 +214,19 @@ public class WailsBridge { if (initialized) nativeEmitEvent(name, json); } + /** + * Emit an event from a component that holds no bridge reference — + * {@link WailsForegroundService}, which Android constructs itself. It is a + * static hop rather than a binder because the service runs in this same + * process; before the bridge exists (or after it is gone) the event is + * dropped, which is the same thing {@link #emitEvent} does when the native + * library has not been initialized. + */ + public static void emitFromService(String name, String json) { + WailsBridge b = instance; + if (b != null) b.emitEvent(name, json); + } + /** * Serve an asset from the Go asset server */ @@ -1193,7 +1210,16 @@ public class WailsBridge { i.setAction(WailsForegroundService.ACTION_START); i.putExtra("title", title); i.putExtra("text", text); - ContextCompat.startForegroundService(activity, i); + // The whole document, for the media service: seven extras + // would be seven chances for the two sides to disagree about + // a key, and the service already has to parse JSON for the + // fields the scaffold's title/text pair cannot carry. + i.putExtra("payload", json); + if (WailsForegroundService.running) { + activity.startService(i); + } else { + ContextCompat.startForegroundService(activity, i); + } emitEvent("android:foregroundService", "{\"running\":true}"); } catch (Exception e) { Log.e(TAG, "startForegroundService failed", e); diff --git a/build/android/app/src/main/java/com/wails/app/WailsForegroundService.java b/build/android/app/src/main/java/com/wails/app/WailsForegroundService.java index a54b66d..3954e63 100644 --- a/build/android/app/src/main/java/com/wails/app/WailsForegroundService.java +++ b/build/android/app/src/main/java/com/wails/app/WailsForegroundService.java @@ -4,71 +4,545 @@ import android.app.Notification; import android.app.NotificationChannel; import android.app.NotificationManager; import android.app.PendingIntent; +import android.content.BroadcastReceiver; +import android.content.Context; import android.content.Intent; +import android.content.IntentFilter; import android.content.pm.ServiceInfo; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.media.AudioAttributes; +import android.media.AudioFocusRequest; +import android.media.AudioManager; +import android.media.MediaMetadata; +import android.media.session.MediaSession; +import android.media.session.PlaybackState; import android.os.Build; +import android.os.Handler; import android.os.IBinder; +import android.os.Looper; +import android.util.Log; import androidx.annotation.Nullable; -import androidx.core.app.NotificationCompat; + +import org.json.JSONObject; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; /** - * A minimal started foreground service. It does no work of its own — its purpose - * is to keep the app's process alive (with the required ongoing notification) so - * the developer's Go goroutines keep running while the app is backgrounded, - * which Android would otherwise be free to kill. Start it from - * {@link WailsBridge#startForegroundService(String)} and stop it with - * {@link WailsBridge#stopForegroundService()}. + * The foreground service that keeps playback alive with the screen off, and + * the app's whole media-control surface: a {@link MediaSession} for the lock + * screen and headset buttons, a transport notification, and audio focus. + * + *

The scaffold shipped this as a generic "keep the process alive" service + * typed {@code dataSync}. YellowJacket's reason for staying alive in the + * background is that a song is playing, so it is {@code mediaPlayback} — the + * manifest and {@code startForeground} must agree on that or the call throws. + * + *

It is driven entirely from Go. {@code backend/mediacontrols/android.go} + * pushes a JSON payload through + * {@link WailsBridge#startForegroundService(String)}, and every command the + * user gives here — a notification button, the lock screen, a headset, or the + * OS taking audio focus away — goes back the other way as a + * {@code yj:media:command} event. Nothing about playback is decided here: this + * class renders state and reports intent. */ public class WailsForegroundService extends android.app.Service { public static final String ACTION_START = "com.wails.app.FGS_START"; - private static final String CHANNEL_ID = "wails_foreground"; + + // Transport actions, delivered to ourselves by the notification's + // PendingIntents. getService rather than a broadcast: a receiver would + // have to be exported or registered, and this service is already the + // thing that has to be running for any of them to be meaningful. + private static final String ACTION_PLAY = "com.wails.app.MEDIA_PLAY"; + private static final String ACTION_PAUSE = "com.wails.app.MEDIA_PAUSE"; + private static final String ACTION_NEXT = "com.wails.app.MEDIA_NEXT"; + private static final String ACTION_PREVIOUS = "com.wails.app.MEDIA_PREVIOUS"; + + private static final String TAG = "WailsMedia"; + private static final String CHANNEL_ID = "yellowjacket_playback"; private static final int NOTIFICATION_ID = 0x57A1; // "WAI" + private static final String COMMAND_EVENT = "yj:media:command"; + + /** Cover art is decoded down to this, which is larger than any lock screen. */ + private static final int ART_MAX_PX = 512; + + /** + * Whether an instance is alive. {@link WailsBridge} reads it to decide + * between startForegroundService and startService: from Android 12 an app + * in the background may not start a foreground service, but it + * may go on delivering intents to one it already has — and every update + * after the first (a track change with the screen off, most of them) is + * exactly that case. + */ + static volatile boolean running = false; + + private final Handler mainHandler = new Handler(Looper.getMainLooper()); + private final ExecutorService artExecutor = Executors.newSingleThreadExecutor(); + + private MediaSession session; + private AudioManager audioManager; + private AudioFocusRequest focusRequest; // API 26+ only. + private AudioManager.OnAudioFocusChangeListener focusListener; + + private String title = ""; + private String artist = ""; + private String album = ""; + private String artPath = ""; + private long durationMs = 0; + private long positionMs = 0; + private boolean playing = false; + + private Bitmap art; + + private boolean hasFocus = false; + /** + * Whether *we* paused because focus went away. Only then does regaining it + * resume: a user who paused during a phone call did not ask us to start + * again when it ended. + */ + private boolean pausedByFocusLoss = false; + + private boolean noisyRegistered = false; + + /** Headphones pulled out. Anything else and the room hears the album. */ + private final BroadcastReceiver noisyReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + if (AudioManager.ACTION_AUDIO_BECOMING_NOISY.equals(intent.getAction())) { + emitCommand("pause"); + } + } + }; + + @Override + public void onCreate() { + super.onCreate(); + running = true; + audioManager = (AudioManager) getSystemService(AUDIO_SERVICE); + createChannel(); + createSession(); + } @Override public int onStartCommand(Intent intent, int flags, int startId) { - String title = "Wails"; - String text = "Running in the background"; - if (intent != null) { - if (intent.getStringExtra("title") != null) title = intent.getStringExtra("title"); - if (intent.getStringExtra("text") != null) text = intent.getStringExtra("text"); + String action = intent == null ? null : intent.getAction(); + + if (ACTION_PLAY.equals(action)) { + emitCommand("play"); + } else if (ACTION_PAUSE.equals(action)) { + emitCommand("pause"); + } else if (ACTION_NEXT.equals(action)) { + emitCommand("next"); + } else if (ACTION_PREVIOUS.equals(action)) { + emitCommand("previous"); + } else if (intent != null) { + applyPayload(intent); } - NotificationManager nm = (NotificationManager) getSystemService(NOTIFICATION_SERVICE); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - NotificationChannel ch = new NotificationChannel( - CHANNEL_ID, "Background work", NotificationManager.IMPORTANCE_LOW); - nm.createNotificationChannel(ch); + // Unconditionally, on every path: a service started with + // startForegroundService that returns from onStartCommand without + // calling startForeground is killed with a + // ForegroundServiceDidNotStartInTimeException. + goForeground(); + + return START_STICKY; + } + + /** + * Read the state Go pushed. "payload" is the whole JSON document; the + * title/text extras are the scaffold's original contract and are kept as a + * fallback so a non-media caller still gets a sensible notification. + */ + private void applyPayload(Intent intent) { + String payload = intent.getStringExtra("payload"); + if (payload == null || payload.isEmpty()) { + if (intent.getStringExtra("title") != null) { + title = intent.getStringExtra("title"); + } + if (intent.getStringExtra("text") != null) { + artist = intent.getStringExtra("text"); + } + return; } - PendingIntent contentIntent = null; - Intent launch = getPackageManager().getLaunchIntentForPackage(getPackageName()); - if (launch != null) { - int piFlags = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M - ? PendingIntent.FLAG_IMMUTABLE : 0; - contentIntent = PendingIntent.getActivity(this, 0, launch, piFlags); + try { + JSONObject o = new JSONObject(payload); + title = o.optString("title", ""); + artist = o.optString("artist", ""); + album = o.optString("album", ""); + durationMs = o.optLong("durationSec", 0) * 1000L; + positionMs = o.optLong("positionSec", 0) * 1000L; + playing = "playing".equals(o.optString("state", "paused")); + + String path = o.optString("artPath", ""); + if (!path.equals(artPath)) { + artPath = path; + loadArt(path); + } + } catch (Exception e) { + Log.e(TAG, "bad media payload", e); + return; } - Notification n = new NotificationCompat.Builder(this, CHANNEL_ID) - .setSmallIcon(android.R.drawable.ic_popup_sync) - .setContentTitle(title) - .setContentText(text) - .setOngoing(true) - .setContentIntent(contentIntent) + if (playing) { + requestFocus(); + registerNoisy(); + } else { + unregisterNoisy(); + } + + updateSession(); + } + + // --- MediaSession ------------------------------------------------------ + + private void createSession() { + session = new MediaSession(this, "YellowJacket"); + session.setFlags(MediaSession.FLAG_HANDLES_MEDIA_BUTTONS + | MediaSession.FLAG_HANDLES_TRANSPORT_CONTROLS); + session.setCallback(new MediaSession.Callback() { + @Override + public void onPlay() { + emitCommand("play"); + } + + @Override + public void onPause() { + emitCommand("pause"); + } + + @Override + public void onStop() { + emitCommand("stop"); + } + + @Override + public void onSkipToNext() { + emitCommand("next"); + } + + @Override + public void onSkipToPrevious() { + emitCommand("previous"); + } + + @Override + public void onSeekTo(long pos) { + try { + JSONObject o = new JSONObject(); + o.put("command", "seek"); + o.put("positionSec", pos / 1000L); + WailsBridge.emitFromService(COMMAND_EVENT, o.toString()); + } catch (Exception e) { + Log.e(TAG, "seek command failed", e); + } + } + }); + session.setActive(true); + } + + private void updateSession() { + MediaMetadata.Builder meta = new MediaMetadata.Builder() + .putString(MediaMetadata.METADATA_KEY_TITLE, title) + .putString(MediaMetadata.METADATA_KEY_ARTIST, artist) + .putString(MediaMetadata.METADATA_KEY_ALBUM, album) + .putLong(MediaMetadata.METADATA_KEY_DURATION, durationMs); + if (art != null) { + meta.putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, art); + } + session.setMetadata(meta.build()); + + // The position is an anchor, not a clock: the state carries the + // playback speed and the OS interpolates from here, which is why the + // Go side only pushes on a real state change or a seek. + PlaybackState state = new PlaybackState.Builder() + .setActions(PlaybackState.ACTION_PLAY + | PlaybackState.ACTION_PAUSE + | PlaybackState.ACTION_PLAY_PAUSE + | PlaybackState.ACTION_STOP + | PlaybackState.ACTION_SKIP_TO_NEXT + | PlaybackState.ACTION_SKIP_TO_PREVIOUS + | PlaybackState.ACTION_SEEK_TO) + .setState(playing ? PlaybackState.STATE_PLAYING : PlaybackState.STATE_PAUSED, + positionMs, playing ? 1.0f : 0.0f) .build(); + session.setPlaybackState(state); + } + // --- Notification ------------------------------------------------------ + + private void createChannel() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return; + } + NotificationManager nm = (NotificationManager) getSystemService(NOTIFICATION_SERVICE); + // LOW: a transport notification is a control surface, not news, and + // IMPORTANCE_DEFAULT would make a sound on every track change. + NotificationChannel ch = new NotificationChannel( + CHANNEL_ID, "Playback", NotificationManager.IMPORTANCE_LOW); + ch.setShowBadge(false); + nm.createNotificationChannel(ch); + } + + private void goForeground() { + Notification n = buildNotification(); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - // MEDIA_PLAYBACK, not the scaffold's DATA_SYNC. It must match - // android:foregroundServiceType in the manifest, or - // startForeground throws; and on Android 14+ the declared type - // is what decides whether the service may start from the - // background at all. startForeground(NOTIFICATION_ID, n, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK); } else { startForeground(NOTIFICATION_ID, n); } - // Restart if the OS kills us while still wanted. - return START_STICKY; + } + + @SuppressWarnings("deprecation") + private Notification buildNotification() { + Notification.Builder b = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O + ? new Notification.Builder(this, CHANNEL_ID) + : new Notification.Builder(this); + + b.setSmallIcon(android.R.drawable.ic_media_play) + .setContentTitle(title.isEmpty() ? getString(R.string.app_name) : title) + .setContentText(artist) + .setSubText(album) + .setOngoing(playing) + .setVisibility(Notification.VISIBILITY_PUBLIC) + .setContentIntent(launchIntent()); + + if (art != null) { + b.setLargeIcon(art); + } + + b.addAction(new Notification.Action.Builder( + android.R.drawable.ic_media_previous, "Previous", + transportIntent(ACTION_PREVIOUS, 1)).build()); + b.addAction(playing + ? new Notification.Action.Builder(android.R.drawable.ic_media_pause, "Pause", + transportIntent(ACTION_PAUSE, 2)).build() + : new Notification.Action.Builder(android.R.drawable.ic_media_play, "Play", + transportIntent(ACTION_PLAY, 3)).build()); + b.addAction(new Notification.Action.Builder( + android.R.drawable.ic_media_next, "Next", + transportIntent(ACTION_NEXT, 4)).build()); + + Notification.MediaStyle style = new Notification.MediaStyle() + .setShowActionsInCompactView(0, 1, 2); + if (session != null) { + style.setMediaSession(session.getSessionToken()); + } + b.setStyle(style); + + return b.build(); + } + + private PendingIntent transportIntent(String action, int requestCode) { + Intent i = new Intent(this, WailsForegroundService.class).setAction(action); + return PendingIntent.getService(this, requestCode, i, pendingIntentFlags()); + } + + private PendingIntent launchIntent() { + Intent launch = getPackageManager().getLaunchIntentForPackage(getPackageName()); + if (launch == null) { + return null; + } + return PendingIntent.getActivity(this, 0, launch, pendingIntentFlags()); + } + + private int pendingIntentFlags() { + // Mandatory from S, unavailable before M. + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.M + ? PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT + : PendingIntent.FLAG_UPDATE_CURRENT; + } + + // --- Cover art --------------------------------------------------------- + + /** + * Decode the cover off the main thread and redraw when it lands. A track + * change must not wait on a JPEG, and the notification is correct without + * one — it simply has no image until this returns. + */ + private void loadArt(final String path) { + art = null; + if (path == null || path.isEmpty()) { + return; + } + + artExecutor.execute(() -> { + Bitmap decoded = decodeScaled(path); + mainHandler.post(() -> { + // The track may have changed while we decoded. + if (!path.equals(artPath)) { + return; + } + art = decoded; + updateSession(); + goForeground(); + }); + }); + } + + private Bitmap decodeScaled(String path) { + try { + BitmapFactory.Options bounds = new BitmapFactory.Options(); + bounds.inJustDecodeBounds = true; + BitmapFactory.decodeFile(path, bounds); + + int longest = Math.max(bounds.outWidth, bounds.outHeight); + int sample = 1; + while (longest / sample > ART_MAX_PX) { + sample *= 2; + } + + BitmapFactory.Options opts = new BitmapFactory.Options(); + opts.inSampleSize = sample; + return BitmapFactory.decodeFile(path, opts); + } catch (Throwable t) { + // OutOfMemoryError included: a missing cover is not a crash. + Log.w(TAG, "cover art decode failed: " + path, t); + return null; + } + } + + // --- Audio focus ------------------------------------------------------- + + private void requestFocus() { + if (hasFocus || audioManager == null) { + return; + } + + if (focusListener == null) { + focusListener = this::onFocusChange; + } + + int result; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + AudioAttributes attrs = new AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_MEDIA) + .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) + .build(); + // No setWillPauseWhenDucked: from Oreo the framework ducks us + // itself and reports no CAN_DUCK loss, so the Go-side duck below + // is a pre-Oreo path. Asking to be told instead would mean + // pausing for every notification tone. + focusRequest = new AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN) + .setAudioAttributes(attrs) + .setOnAudioFocusChangeListener(focusListener, mainHandler) + .build(); + result = audioManager.requestAudioFocus(focusRequest); + } else { + result = requestFocusLegacy(); + } + + hasFocus = result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED; + } + + @SuppressWarnings("deprecation") + private int requestFocusLegacy() { + return audioManager.requestAudioFocus(focusListener, + AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN); + } + + @SuppressWarnings("deprecation") + private void abandonFocus() { + if (!hasFocus || audioManager == null) { + return; + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && focusRequest != null) { + audioManager.abandonAudioFocusRequest(focusRequest); + } else { + audioManager.abandonAudioFocus(focusListener); + } + hasFocus = false; + } + + private void onFocusChange(int change) { + switch (change) { + case AudioManager.AUDIOFOCUS_LOSS: + // Someone else owns the output now, for good. + hasFocus = false; + pausedByFocusLoss = false; + emitCommand("pause"); + break; + case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT: + // A phone call. Remember that the pause was ours to undo. + pausedByFocusLoss = playing; + emitCommand("pause"); + break; + case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK: + emitDuck(true); + break; + case AudioManager.AUDIOFOCUS_GAIN: + hasFocus = true; + emitDuck(false); + if (pausedByFocusLoss) { + pausedByFocusLoss = false; + emitCommand("play"); + } + break; + default: + break; + } + } + + // --- Noisy (headphones) ------------------------------------------------ + + private void registerNoisy() { + if (noisyRegistered) { + return; + } + registerReceiver(noisyReceiver, + new IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY)); + noisyRegistered = true; + } + + private void unregisterNoisy() { + if (!noisyRegistered) { + return; + } + try { + unregisterReceiver(noisyReceiver); + } catch (IllegalArgumentException ignored) { + // Already gone; nothing to undo. + } + noisyRegistered = false; + } + + // --- Talking to Go ----------------------------------------------------- + + private void emitCommand(String command) { + try { + JSONObject o = new JSONObject(); + o.put("command", command); + WailsBridge.emitFromService(COMMAND_EVENT, o.toString()); + } catch (Exception e) { + Log.e(TAG, "command emit failed: " + command, e); + } + } + + private void emitDuck(boolean on) { + try { + JSONObject o = new JSONObject(); + o.put("command", "duck"); + o.put("on", on); + WailsBridge.emitFromService(COMMAND_EVENT, o.toString()); + } catch (Exception e) { + Log.e(TAG, "duck emit failed", e); + } + } + + @Override + public void onDestroy() { + running = false; + unregisterNoisy(); + abandonFocus(); + if (session != null) { + session.setActive(false); + session.release(); + session = null; + } + artExecutor.shutdownNow(); + super.onDestroy(); } @Nullable