Merge remote-tracking branch 'origin/main' into wails-v3
This commit is contained in:
+10
-5
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user