Merge remote-tracking branch 'origin/main' into wails-v3

This commit is contained in:
2026-08-17 08:29:21 -04:00
96 changed files with 11952 additions and 90 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",
+181
View File
@@ -0,0 +1,181 @@
package frontendutil
import (
"errors"
"fmt"
"os"
"path/filepath"
"runtime"
"sort"
)
// errNotADirectory is returned when a caller asks to list something
// that exists but is not a directory.
var errNotADirectory = errors.New("not a directory")
// DirEntry is one selectable directory in a listing.
type DirEntry struct {
Name string `json:"name"`
Path string `json:"path"`
}
// DirListing is one level of the filesystem, as a folder picker needs
// it: where we are, what is above, and the directories below.
//
// Parent is empty at a root, which is what tells the UI not to draw an
// "up" affordance rather than having it compute that from the path
// separator.
type DirListing struct {
Path string `json:"path"`
Parent string `json:"parent"`
Entries []DirEntry `json:"entries"`
}
// ListDirectories returns the directories directly inside path, so the
// frontend can draw a folder picker.
//
// **It exists because Android has no directory picker.** Wails' file
// dialog can choose directories on every desktop platform, and on
// Android it returns an error: the Storage Access Framework yields tree
// URIs rather than filesystem paths, and a path is what this app's
// entire library model is keyed on. Rather than teach the backend about
// tree URIs, the app browses the filesystem itself — which it can do
// because it holds all-files access (see the manifest).
//
// Three rules, each of which a picker gets wrong if it is not stated:
// only directories are returned, because the caller is choosing a
// library root and files are noise; unreadable children are skipped
// rather than failing the whole listing, since Android's storage root
// contains directories no app may enter; and hidden directories are
// omitted, because a music library is not in one and `.thumbnails`
// alone would swamp the list.
func (fe *FrontendUtil) ListDirectories(path string) (DirListing, error) {
if path == "" {
path = fe.DefaultBrowseRoot()
}
path = filepath.Clean(path)
info, err := os.Stat(path)
if err != nil {
return DirListing{}, fmt.Errorf("could not open %s: %w", path, err)
}
if !info.IsDir() {
return DirListing{}, fmt.Errorf("%w: %s", errNotADirectory, path)
}
entries, err := os.ReadDir(path)
if err != nil {
return DirListing{}, fmt.Errorf("could not read %s: %w", path, err)
}
dirs := make([]DirEntry, 0, len(entries))
for _, e := range entries {
name := e.Name()
if name == "" || name[0] == '.' {
continue
}
// A symlink reports itself, not its target, so ask the
// filesystem: a symlinked music directory is ordinary and
// skipping it would be a bug the user cannot explain.
child := filepath.Join(path, name)
info, err := os.Stat(child)
if err != nil || !info.IsDir() {
continue
}
dirs = append(dirs, DirEntry{Name: name, Path: child})
}
sort.Slice(dirs, func(i, j int) bool { return dirs[i].Name < dirs[j].Name })
parent := filepath.Dir(path)
if parent == path {
parent = ""
}
return DirListing{Path: path, Parent: parent, Entries: dirs}, nil
}
// androidSharedStorage is where a user's music lives on Android. It is
// not derivable from the environment the way a desktop home directory
// is: HOME inside an Android app process is "/", so os.UserHomeDir()
// would start the picker at the filesystem root with nothing readable
// under it.
const androidSharedStorage = "/storage/emulated/0"
// DefaultBrowseRoot is where a folder picker should open.
func (fe *FrontendUtil) DefaultBrowseRoot() string {
if runtime.GOOS == "android" {
for _, candidate := range []string{androidSharedStorage, "/storage"} {
if info, err := os.Stat(candidate); err == nil && info.IsDir() {
return candidate
}
}
return "/"
}
if home, err := os.UserHomeDir(); err == nil && home != "" {
return home
}
return string(filepath.Separator)
}
// StorageAccess reports whether the app can actually read the place the
// user's music lives.
type StorageAccess struct {
Root string `json:"root"`
Readable bool `json:"readable"`
Reason string `json:"reason"`
}
// CheckStorageAccess asks the filesystem rather than the permission
// system.
//
// On Android this app holds MANAGE_EXTERNAL_STORAGE, which the user
// grants on a Settings screen rather than in a dialog — so it can be
// refused, revoked later, or simply never answered, and the permission
// API is one more thing that can disagree with reality. Reading the
// directory is the question the library scanner will actually ask, so
// it is the one worth answering.
//
// It is deliberately not an error return: "we cannot read your music
// yet" is a state the UI renders, not a failure of the call.
func (fe *FrontendUtil) CheckStorageAccess() StorageAccess {
root := fe.DefaultBrowseRoot()
if _, err := os.ReadDir(root); err != nil {
return StorageAccess{
Root: root,
Readable: false,
Reason: err.Error(),
}
}
return StorageAccess{Root: root, Readable: true}
}
// HasNativeDirectoryPicker reports whether this platform can open a
// directory dialog at all.
//
// It is asked of the backend rather than tested in the frontend with
// `System.IsAndroid()`, for three reasons. The dialog *is* backend code
// — `DirectoryPicker` above — so this is the same package saying what
// it can do. It answers for iOS too without the frontend enumerating
// platforms. And it makes the frontend's fallback testable through the
// ordinary transport fake instead of a module mock of the Wails
// runtime, whose platform helpers read build constants.
func (fe *FrontendUtil) HasNativeDirectoryPicker() bool {
switch runtime.GOOS {
case "android", "ios":
return false
default:
return true
}
}
+207
View File
@@ -0,0 +1,207 @@
package frontendutil
import (
"os"
"path/filepath"
"runtime"
"testing"
)
// listing helper: a tree with the three shapes the picker has to get
// right — an ordinary directory, a file (never listed), and a hidden
// directory (never listed).
func browseFixture(t *testing.T) string {
t.Helper()
root := t.TempDir()
for _, dir := range []string{"Music", "Podcasts", "aaa", ".thumbnails"} {
if err := os.Mkdir(filepath.Join(root, dir), 0o755); err != nil {
t.Fatalf("mkdir %s: %v", dir, err)
}
}
if err := os.WriteFile(filepath.Join(root, "track.mp3"), []byte("x"), 0o600); err != nil {
t.Fatalf("write file: %v", err)
}
return root
}
func TestListDirectories(t *testing.T) {
fe := &FrontendUtil{}
root := browseFixture(t)
got, err := fe.ListDirectories(root)
if err != nil {
t.Fatalf("ListDirectories: %v", err)
}
// Sorted, directories only, no file and no dotted entry.
want := []string{"Music", "Podcasts", "aaa"}
if len(got.Entries) != len(want) {
t.Fatalf("got %d entries %v, want %v", len(got.Entries), got.Entries, want)
}
for i, w := range want {
if got.Entries[i].Name != w {
t.Errorf("entry %d = %q, want %q", i, got.Entries[i].Name, w)
}
if got.Entries[i].Path != filepath.Join(root, w) {
t.Errorf("entry %d path = %q, want %q", i, got.Entries[i].Path, filepath.Join(root, w))
}
}
if got.Path != root {
t.Errorf("Path = %q, want %q", got.Path, root)
}
if got.Parent != filepath.Dir(root) {
t.Errorf("Parent = %q, want %q", got.Parent, filepath.Dir(root))
}
}
// A symlink reports itself rather than its target, so a listing that
// trusts DirEntry.IsDir() silently drops a symlinked music folder --
// which is an ordinary thing to have and an unexplainable thing to
// lose.
func TestListDirectoriesFollowsSymlinks(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlinks need elevation on Windows")
}
root := t.TempDir()
target := filepath.Join(root, "real")
if err := os.Mkdir(target, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
link := filepath.Join(root, "linked")
if err := os.Symlink(target, link); err != nil {
t.Fatalf("symlink: %v", err)
}
fe := &FrontendUtil{}
got, err := fe.ListDirectories(root)
if err != nil {
t.Fatalf("ListDirectories: %v", err)
}
if len(got.Entries) != 2 {
t.Fatalf("got %v, want both 'linked' and 'real'", got.Entries)
}
}
// A dangling symlink, and anything else os.Stat refuses, must be
// skipped rather than failing the whole listing: Android's storage
// root holds directories no app may enter, and one of them must not
// cost the user the picker.
func TestListDirectoriesSkipsUnreadable(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlinks need elevation on Windows")
}
root := t.TempDir()
if err := os.Mkdir(filepath.Join(root, "good"), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
dangling := filepath.Join(root, "dangling")
if err := os.Symlink(filepath.Join(root, "nowhere"), dangling); err != nil {
t.Fatalf("symlink: %v", err)
}
fe := &FrontendUtil{}
got, err := fe.ListDirectories(root)
if err != nil {
t.Fatalf("ListDirectories: %v", err)
}
if len(got.Entries) != 1 || got.Entries[0].Name != "good" {
t.Errorf("got %v, want only 'good'", got.Entries)
}
}
func TestListDirectoriesRejectsFiles(t *testing.T) {
root := t.TempDir()
file := filepath.Join(root, "track.mp3")
if err := os.WriteFile(file, []byte("x"), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
fe := &FrontendUtil{}
if _, err := fe.ListDirectories(file); err == nil {
t.Error("listing a file should be an error")
}
if _, err := fe.ListDirectories(filepath.Join(root, "missing")); err == nil {
t.Error("listing a missing path should be an error")
}
}
// An empty path means "start where the picker should open", so the
// frontend never has to know the platform.
func TestListDirectoriesDefaultsToBrowseRoot(t *testing.T) {
fe := &FrontendUtil{}
got, err := fe.ListDirectories("")
if err != nil {
t.Skipf("default root not listable in this environment: %v", err)
}
if got.Path != fe.DefaultBrowseRoot() {
t.Errorf("Path = %q, want the default root %q", got.Path, fe.DefaultBrowseRoot())
}
}
// Parent is empty at a root, which is what tells the picker not to draw
// an "up" control rather than making it reason about separators.
func TestListDirectoriesRootHasNoParent(t *testing.T) {
fe := &FrontendUtil{}
got, err := fe.ListDirectories(string(filepath.Separator))
if err != nil {
t.Skipf("filesystem root not listable: %v", err)
}
if got.Parent != "" {
t.Errorf("Parent = %q at the root, want empty", got.Parent)
}
}
func TestCheckStorageAccess(t *testing.T) {
fe := &FrontendUtil{}
got := fe.CheckStorageAccess()
if got.Root == "" {
t.Error("Root should never be empty")
}
// The developer machine running this test can read its own home
// directory; the assertion is that the two fields agree, not that
// access is granted.
if got.Readable && got.Reason != "" {
t.Errorf("readable but Reason = %q", got.Reason)
}
if !got.Readable && got.Reason == "" {
t.Error("not readable but no Reason given")
}
}
// The picker's fallback is chosen from this, so a platform that gains
// a working dialog must flip it here rather than in the frontend.
func TestHasNativeDirectoryPicker(t *testing.T) {
fe := &FrontendUtil{}
want := runtime.GOOS != "android" && runtime.GOOS != "ios"
if got := fe.HasNativeDirectoryPicker(); got != want {
t.Errorf("HasNativeDirectoryPicker() on %s = %v, want %v", runtime.GOOS, got, want)
}
}
+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.
+8 -1
View File
@@ -1,4 +1,11 @@
//go:build linux
//go:build linux && !android
// MPRIS is a D-Bus desktop specification, and `android` implies the
// `linux` build tag -- so without the `!android` this file compiled
// into the Android app and went looking for a session bus that does
// not exist. Desktop-Linux-only files need both halves; see Wails'
// mobile guide, which names this as the Android analogue of
// ios/darwin.
package mediacontrols
+5
View File
@@ -1,5 +1,10 @@
//go:build !linux
// 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
import "log/slog"
+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)
}
}
+23
View File
@@ -29,6 +29,29 @@ const (
// without touching the current user's real config.toml or yj.db.
const envHomeOverride = "YJ_HOME"
// UseHomeOverride points every config and data path at base, by setting
// the same override a development sandbox uses.
//
// It exists for mobile, where the switch in buildUserDirPath has no
// answer: there is no home directory and no XDG, only a per-app private
// directory the OS hands out at runtime. The caller is main(), which is
// the only place that can ask the platform for it — deliberately, so
// this package stays free of the Wails application package that knows
// (see backend/events' indexbuild split for why that matters).
//
// Two rules. An empty base is a no-op, because that is exactly what
// application.Mobile.StoragePath() returns on desktop. And an override
// that is already set wins, so YJ_HOME on the command line still
// relocates a sandbox on a platform that would otherwise decide for
// itself.
func UseHomeOverride(base string) {
if base == "" || os.Getenv(envHomeOverride) != "" {
return
}
_ = os.Setenv(envHomeOverride, base)
}
// getUserDirPath returns and creates the path for a user directory.
func getUserDirPath(dt dirType) (string, error) {
path, err := resolveUserDirPath(dt)
+37
View File
@@ -1,6 +1,7 @@
package system
import (
"os"
"path/filepath"
"strings"
"testing"
@@ -50,3 +51,39 @@ func TestResolveUserDirPath_NoOverrideUsesOSPath(t *testing.T) {
)
}
}
// UseHomeOverride carries two rules that a mobile launch depends on and
// that nothing else would notice breaking: an empty base must do
// nothing, because that is precisely what StoragePath() returns on
// desktop, and an override already set must win, or YJ_HOME would stop
// relocating a sandbox on the platform that decides for itself.
func TestUseHomeOverride(t *testing.T) {
const (
storage = "/data/user/0/app.yellowjacket/files"
sandbox = "/tmp/sandbox"
)
tests := []struct {
name string
already string
base string
want string
}{
{name: "empty base is a no-op", already: "", base: "", want: ""},
{name: "sets the override when unset", already: "", base: storage, want: storage},
{name: "an existing override wins", already: sandbox, base: storage, want: sandbox},
{name: "empty base keeps an existing override", already: sandbox, base: "", want: sandbox},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Setenv(envHomeOverride, tt.already)
UseHomeOverride(tt.base)
if got := os.Getenv(envHomeOverride); got != tt.want {
t.Errorf("%s = %q, want %q", envHomeOverride, got, tt.want)
}
})
}
}