added mpris (linux media player controls) support
This commit is contained in:
+50
-9
@@ -17,6 +17,7 @@ import (
|
|||||||
"yellowjacket/backend/database"
|
"yellowjacket/backend/database"
|
||||||
"yellowjacket/backend/frontendutil"
|
"yellowjacket/backend/frontendutil"
|
||||||
"yellowjacket/backend/library"
|
"yellowjacket/backend/library"
|
||||||
|
"yellowjacket/backend/mediacontrols"
|
||||||
"yellowjacket/backend/player"
|
"yellowjacket/backend/player"
|
||||||
"yellowjacket/backend/playlist"
|
"yellowjacket/backend/playlist"
|
||||||
"yellowjacket/backend/profiling"
|
"yellowjacket/backend/profiling"
|
||||||
@@ -28,15 +29,16 @@ type YellowJacketApp struct {
|
|||||||
FEBindings []any
|
FEBindings []any
|
||||||
FrontendUtil *frontendutil.FrontendUtil
|
FrontendUtil *frontendutil.FrontendUtil
|
||||||
|
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
assetHandler *assets.Handler
|
assetHandler *assets.Handler
|
||||||
database *database.DB
|
database *database.DB
|
||||||
library *library.Library
|
library *library.Library
|
||||||
player *player.Player
|
player *player.Player
|
||||||
playlist *playlist.Service
|
playlist *playlist.Service
|
||||||
queue *queue.Queue
|
queue *queue.Queue
|
||||||
appContext context.Context
|
mediaControls mediacontrols.Handler
|
||||||
appConfig *config.Config
|
appContext context.Context
|
||||||
|
appConfig *config.Config
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewYellowJacketApp creates and initializes the application.
|
// NewYellowJacketApp creates and initializes the application.
|
||||||
@@ -170,6 +172,41 @@ func (yj *YellowJacketApp) OnStartup(ctx context.Context) {
|
|||||||
|
|
||||||
// Register playback finished handler to drive queue auto-advance.
|
// Register playback finished handler to drive queue auto-advance.
|
||||||
yj.player.SetPlaybackFinishedHandler(yj.queue.OnPlaybackFinished)
|
yj.player.SetPlaybackFinishedHandler(yj.queue.OnPlaybackFinished)
|
||||||
|
|
||||||
|
// Initialize OS media controls (MPRIS on Linux, no-op elsewhere).
|
||||||
|
yj.mediaControls = mediacontrols.NewHandler(yj.logger)
|
||||||
|
|
||||||
|
if err := yj.mediaControls.Init(mediacontrols.Callbacks{
|
||||||
|
OnPlay: yj.queue.Play,
|
||||||
|
OnPause: func() { _ = yj.player.Pause() },
|
||||||
|
OnPlayPause: func() {
|
||||||
|
if yj.player.IsPlaying() {
|
||||||
|
_ = yj.player.Pause()
|
||||||
|
} else {
|
||||||
|
yj.queue.Play()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
OnStop: func() { _ = yj.player.Pause() },
|
||||||
|
OnNext: yj.queue.Next,
|
||||||
|
OnPrevious: yj.queue.Previous,
|
||||||
|
OnSeek: func(positionSec int) {
|
||||||
|
_ = yj.player.Seek(positionSec)
|
||||||
|
},
|
||||||
|
OnVolume: func(vol float64) {
|
||||||
|
yj.player.SetVolume(
|
||||||
|
player.UserVolume(
|
||||||
|
vol * float64(player.MaxUserVol),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
yj.logger.Error(
|
||||||
|
"Failed to initialize media controls",
|
||||||
|
"err", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
yj.player.SetMediaControls(yj.mediaControls)
|
||||||
}
|
}
|
||||||
|
|
||||||
// OnBeforeClose captures window state while the window is still alive.
|
// OnBeforeClose captures window state while the window is still alive.
|
||||||
@@ -198,6 +235,10 @@ func (yj *YellowJacketApp) OnShutdown(_ context.Context) {
|
|||||||
if yj.queue != nil {
|
if yj.queue != nil {
|
||||||
yj.queue.SaveState()
|
yj.queue.SaveState()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if yj.mediaControls != nil {
|
||||||
|
yj.mediaControls.Close()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// OnDomReady handles post-DOM initialization and startup error reporting.
|
// OnDomReady handles post-DOM initialization and startup error reporting.
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
// Package mediacontrols provides OS media control integration.
|
||||||
|
//
|
||||||
|
// On Linux this registers a MPRIS2 D-Bus service so that desktop
|
||||||
|
// environments, playerctl, and media keys can control playback and
|
||||||
|
// see the currently playing track. Other platforms get a no-op stub.
|
||||||
|
package mediacontrols
|
||||||
|
|
||||||
|
// PlaybackState represents the current playback state for the OS.
|
||||||
|
type PlaybackState int
|
||||||
|
|
||||||
|
// Playback state values.
|
||||||
|
const (
|
||||||
|
StateStopped PlaybackState = iota
|
||||||
|
StatePlaying
|
||||||
|
StatePaused
|
||||||
|
)
|
||||||
|
|
||||||
|
// Metadata holds track information to display in the OS media overlay.
|
||||||
|
type Metadata struct {
|
||||||
|
Title string
|
||||||
|
Artist string
|
||||||
|
Album string
|
||||||
|
ArtFilePath string // Absolute filesystem path to cover art.
|
||||||
|
DurationSec int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Callbacks are invoked when the OS sends media commands.
|
||||||
|
type Callbacks struct {
|
||||||
|
OnPlay func()
|
||||||
|
OnPause func()
|
||||||
|
OnPlayPause func()
|
||||||
|
OnStop func()
|
||||||
|
OnNext func()
|
||||||
|
OnPrevious func()
|
||||||
|
OnSeek func(positionSec int)
|
||||||
|
OnVolume func(volume float64) // 0.0–1.0 linear scale.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler manages the OS media control integration.
|
||||||
|
type Handler interface {
|
||||||
|
// Init registers with the OS and wires incoming commands to
|
||||||
|
// the provided callbacks. It must be called once during startup.
|
||||||
|
Init(callbacks Callbacks) error
|
||||||
|
|
||||||
|
// UpdateMetadata pushes new track metadata to the OS overlay.
|
||||||
|
UpdateMetadata(meta Metadata)
|
||||||
|
|
||||||
|
// UpdatePlaybackState pushes the playback state and current
|
||||||
|
// position. The position is used as a new anchor; the OS
|
||||||
|
// interpolates from there while playing.
|
||||||
|
UpdatePlaybackState(state PlaybackState, positionSec int)
|
||||||
|
|
||||||
|
// NotifySeek signals that the user seeked to a new position.
|
||||||
|
// This is separate from UpdatePlaybackState because MPRIS
|
||||||
|
// emits a distinct Seeked signal for this.
|
||||||
|
NotifySeek(positionSec int)
|
||||||
|
|
||||||
|
// UpdateVolume pushes the current volume (0.0–1.0) to the OS.
|
||||||
|
UpdateVolume(volume float64)
|
||||||
|
|
||||||
|
// Close tears down the OS registration and releases resources.
|
||||||
|
Close()
|
||||||
|
}
|
||||||
@@ -0,0 +1,637 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package mediacontrols
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/godbus/dbus/v5"
|
||||||
|
"github.com/godbus/dbus/v5/introspect"
|
||||||
|
"github.com/godbus/dbus/v5/prop"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
busName = "org.mpris.MediaPlayer2.yellowjacket"
|
||||||
|
objectPath = "/org/mpris/MediaPlayer2"
|
||||||
|
playerIf = "org.mpris.MediaPlayer2.Player"
|
||||||
|
rootIf = "org.mpris.MediaPlayer2"
|
||||||
|
|
||||||
|
usPerSec = 1_000_000
|
||||||
|
|
||||||
|
// updateChanSize is the buffer size for the async update
|
||||||
|
// channel. A small buffer avoids blocking callers while the
|
||||||
|
// D-Bus goroutine processes updates.
|
||||||
|
updateChanSize = 64
|
||||||
|
)
|
||||||
|
|
||||||
|
var errNotPrimaryOwner = errors.New(
|
||||||
|
"failed to become primary owner of bus name",
|
||||||
|
)
|
||||||
|
|
||||||
|
// mprisRoot handles the org.mpris.MediaPlayer2 interface methods.
|
||||||
|
type mprisRoot struct{}
|
||||||
|
|
||||||
|
// Raise is a no-op; YellowJacket does not support raising via MPRIS.
|
||||||
|
func (r *mprisRoot) Raise() *dbus.Error { return nil }
|
||||||
|
|
||||||
|
// Quit is a no-op; shutdown is managed by the Wails lifecycle.
|
||||||
|
func (r *mprisRoot) Quit() *dbus.Error { return nil }
|
||||||
|
|
||||||
|
// mprisPlayer handles the org.mpris.MediaPlayer2.Player
|
||||||
|
// interface methods. Every D-Bus method callback dispatches to a
|
||||||
|
// goroutine so that the godbus handler goroutine returns
|
||||||
|
// immediately and never blocks on player/queue mutexes.
|
||||||
|
type mprisPlayer struct {
|
||||||
|
callbacks Callbacks
|
||||||
|
}
|
||||||
|
|
||||||
|
// Play requests playback start/resume.
|
||||||
|
func (p *mprisPlayer) Play() *dbus.Error {
|
||||||
|
if p.callbacks.OnPlay != nil {
|
||||||
|
go p.callbacks.OnPlay()
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pause requests playback pause.
|
||||||
|
func (p *mprisPlayer) Pause() *dbus.Error {
|
||||||
|
if p.callbacks.OnPause != nil {
|
||||||
|
go p.callbacks.OnPause()
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlayPause toggles between play and pause.
|
||||||
|
func (p *mprisPlayer) PlayPause() *dbus.Error {
|
||||||
|
if p.callbacks.OnPlayPause != nil {
|
||||||
|
go p.callbacks.OnPlayPause()
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop requests playback stop.
|
||||||
|
func (p *mprisPlayer) Stop() *dbus.Error {
|
||||||
|
if p.callbacks.OnStop != nil {
|
||||||
|
go p.callbacks.OnStop()
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Next requests skipping to the next track.
|
||||||
|
func (p *mprisPlayer) Next() *dbus.Error {
|
||||||
|
if p.callbacks.OnNext != nil {
|
||||||
|
go p.callbacks.OnNext()
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Previous requests skipping to the previous track.
|
||||||
|
func (p *mprisPlayer) Previous() *dbus.Error {
|
||||||
|
if p.callbacks.OnPrevious != nil {
|
||||||
|
go p.callbacks.OnPrevious()
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeekTo requests a relative seek by offset microseconds.
|
||||||
|
// Exported on D-Bus as "Seek" via ExportWithMap; renamed in Go
|
||||||
|
// to avoid a false positive from go vet's stdmethods checker.
|
||||||
|
func (p *mprisPlayer) SeekTo(offsetUs int64) *dbus.Error {
|
||||||
|
if p.callbacks.OnSeek != nil {
|
||||||
|
secs := int(offsetUs / usPerSec)
|
||||||
|
|
||||||
|
go p.callbacks.OnSeek(secs)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetPosition requests an absolute seek to positionUs on the
|
||||||
|
// given track.
|
||||||
|
func (p *mprisPlayer) SetPosition(
|
||||||
|
_ dbus.ObjectPath,
|
||||||
|
positionUs int64,
|
||||||
|
) *dbus.Error {
|
||||||
|
if p.callbacks.OnSeek != nil {
|
||||||
|
secs := int(positionUs / usPerSec)
|
||||||
|
|
||||||
|
go p.callbacks.OnSeek(secs)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenUri is required by the MPRIS2 spec but not supported.
|
||||||
|
//
|
||||||
|
//nolint:revive // D-Bus requires this exact method name.
|
||||||
|
func (p *mprisPlayer) OpenUri(_ string) *dbus.Error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MPRISHandler is the Linux MPRIS2 implementation of Handler.
|
||||||
|
//
|
||||||
|
// All public update methods (UpdateMetadata, UpdatePlaybackState,
|
||||||
|
// NotifySeek, UpdateVolume) send work to a buffered channel that a
|
||||||
|
// dedicated goroutine drains. This avoids calling into godbus
|
||||||
|
// (which acquires props.mut and does D-Bus I/O) while the caller
|
||||||
|
// holds the player mutex, preventing a deadlock between p.mu and
|
||||||
|
// props.mut.
|
||||||
|
type MPRISHandler struct {
|
||||||
|
logger *slog.Logger
|
||||||
|
conn *dbus.Conn
|
||||||
|
props *prop.Properties
|
||||||
|
player *mprisPlayer
|
||||||
|
updates chan func()
|
||||||
|
done chan struct{}
|
||||||
|
mu sync.Mutex
|
||||||
|
trackID uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHandler creates a new MPRIS2 handler.
|
||||||
|
func NewHandler(logger *slog.Logger) Handler {
|
||||||
|
return &MPRISHandler{
|
||||||
|
logger: logger.WithGroup("mpris"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init connects to the D-Bus session bus, exports the MPRIS2
|
||||||
|
// interfaces, and registers the well-known bus name.
|
||||||
|
func (h *MPRISHandler) Init(callbacks Callbacks) error {
|
||||||
|
conn, err := dbus.SessionBus()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"failed to connect to session bus: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
h.conn = conn
|
||||||
|
h.player = &mprisPlayer{callbacks: callbacks}
|
||||||
|
h.updates = make(chan func(), updateChanSize)
|
||||||
|
h.done = make(chan struct{})
|
||||||
|
|
||||||
|
go h.processUpdates()
|
||||||
|
|
||||||
|
// Export properties for both interfaces.
|
||||||
|
h.props, err = prop.Export(
|
||||||
|
conn,
|
||||||
|
objectPath,
|
||||||
|
h.propertySpec(),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"failed to export properties: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export method handlers.
|
||||||
|
root := &mprisRoot{}
|
||||||
|
|
||||||
|
if err := conn.Export(
|
||||||
|
root, objectPath, rootIf,
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"failed to export root interface: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := conn.ExportWithMap(
|
||||||
|
h.player,
|
||||||
|
map[string]string{"SeekTo": "Seek"},
|
||||||
|
objectPath,
|
||||||
|
playerIf,
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"failed to export player interface: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export introspection.
|
||||||
|
if err := conn.Export(
|
||||||
|
introspect.NewIntrospectable(h.introspectNode()),
|
||||||
|
objectPath,
|
||||||
|
"org.freedesktop.DBus.Introspectable",
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"failed to export introspection: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Claim the well-known bus name.
|
||||||
|
reply, err := conn.RequestName(
|
||||||
|
busName, dbus.NameFlagReplaceExisting,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"failed to request bus name: %w", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if reply != dbus.RequestNameReplyPrimaryOwner {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"%w: %s (reply=%d)",
|
||||||
|
errNotPrimaryOwner, busName, reply,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
h.logger.Info(
|
||||||
|
"MPRIS2 registered on D-Bus", "name", busName,
|
||||||
|
)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// processUpdates drains the update channel on a dedicated
|
||||||
|
// goroutine. All props.SetMust and conn.Emit calls happen here,
|
||||||
|
// safely away from the player's mutex.
|
||||||
|
func (h *MPRISHandler) processUpdates() {
|
||||||
|
for fn := range h.updates {
|
||||||
|
fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
close(h.done)
|
||||||
|
}
|
||||||
|
|
||||||
|
// enqueue sends a function to the update goroutine. If the
|
||||||
|
// channel is full the update is dropped to avoid blocking the
|
||||||
|
// caller (this is acceptable — the next update will overwrite
|
||||||
|
// stale state).
|
||||||
|
func (h *MPRISHandler) enqueue(fn func()) {
|
||||||
|
select {
|
||||||
|
case h.updates <- fn:
|
||||||
|
default:
|
||||||
|
h.logger.Debug("MPRIS update channel full, dropping")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateMetadata pushes track metadata to D-Bus.
|
||||||
|
func (h *MPRISHandler) UpdateMetadata(meta Metadata) {
|
||||||
|
h.mu.Lock()
|
||||||
|
h.trackID++
|
||||||
|
tid := h.trackID
|
||||||
|
h.mu.Unlock()
|
||||||
|
|
||||||
|
m := map[string]interface{}{
|
||||||
|
"mpris:trackid": dbus.ObjectPath(
|
||||||
|
fmt.Sprintf(
|
||||||
|
"/org/yellowjacket/Track/%d", tid,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
if meta.Title != "" {
|
||||||
|
m["xesam:title"] = meta.Title
|
||||||
|
}
|
||||||
|
|
||||||
|
if meta.Artist != "" {
|
||||||
|
m["xesam:artist"] = []string{meta.Artist}
|
||||||
|
}
|
||||||
|
|
||||||
|
if meta.Album != "" {
|
||||||
|
m["xesam:album"] = meta.Album
|
||||||
|
}
|
||||||
|
|
||||||
|
if meta.ArtFilePath != "" {
|
||||||
|
m["mpris:artUrl"] = "file://" + meta.ArtFilePath
|
||||||
|
}
|
||||||
|
|
||||||
|
if meta.DurationSec > 0 {
|
||||||
|
m["mpris:length"] = int64(
|
||||||
|
meta.DurationSec,
|
||||||
|
) * usPerSec
|
||||||
|
}
|
||||||
|
|
||||||
|
h.enqueue(func() {
|
||||||
|
h.props.SetMust(playerIf, "Metadata", m)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdatePlaybackState pushes the playback state and position
|
||||||
|
// anchor.
|
||||||
|
func (h *MPRISHandler) UpdatePlaybackState(
|
||||||
|
state PlaybackState,
|
||||||
|
positionSec int,
|
||||||
|
) {
|
||||||
|
var status string
|
||||||
|
|
||||||
|
switch state {
|
||||||
|
case StatePlaying:
|
||||||
|
status = "Playing"
|
||||||
|
case StatePaused:
|
||||||
|
status = "Paused"
|
||||||
|
default:
|
||||||
|
status = "Stopped"
|
||||||
|
}
|
||||||
|
|
||||||
|
posUs := int64(positionSec) * usPerSec
|
||||||
|
|
||||||
|
h.enqueue(func() {
|
||||||
|
// Update Position silently (EmitFalse) then
|
||||||
|
// PlaybackStatus loudly (EmitTrue). The DE
|
||||||
|
// re-anchors on the status change.
|
||||||
|
h.props.SetMust(playerIf, "Position", posUs)
|
||||||
|
h.props.SetMust(
|
||||||
|
playerIf, "PlaybackStatus", status,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotifySeek emits the MPRIS Seeked signal.
|
||||||
|
func (h *MPRISHandler) NotifySeek(positionSec int) {
|
||||||
|
posUs := int64(positionSec) * usPerSec
|
||||||
|
|
||||||
|
h.enqueue(func() {
|
||||||
|
h.props.SetMust(playerIf, "Position", posUs)
|
||||||
|
|
||||||
|
if err := h.conn.Emit(
|
||||||
|
objectPath,
|
||||||
|
playerIf+".Seeked",
|
||||||
|
posUs,
|
||||||
|
); err != nil {
|
||||||
|
h.logger.Error(
|
||||||
|
"Failed to emit Seeked signal",
|
||||||
|
"err", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateVolume pushes the current volume (0.0-1.0) to D-Bus.
|
||||||
|
func (h *MPRISHandler) UpdateVolume(volume float64) {
|
||||||
|
h.enqueue(func() {
|
||||||
|
h.props.SetMust(playerIf, "Volume", volume)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close signals the update goroutine to stop, waits for it to
|
||||||
|
// drain, and closes the D-Bus connection.
|
||||||
|
func (h *MPRISHandler) Close() {
|
||||||
|
if h.updates != nil {
|
||||||
|
close(h.updates)
|
||||||
|
<-h.done
|
||||||
|
}
|
||||||
|
|
||||||
|
if h.conn != nil {
|
||||||
|
if err := h.conn.Close(); err != nil {
|
||||||
|
h.logger.Error(
|
||||||
|
"Failed to close D-Bus connection",
|
||||||
|
"err", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
h.logger.Info("MPRIS2 D-Bus connection closed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// onVolumeChanged is called when an external D-Bus client sets
|
||||||
|
// the Volume property. The callback runs under props.mut (held by
|
||||||
|
// godbus), so we dispatch to a goroutine to avoid acquiring p.mu
|
||||||
|
// under props.mut — which would invert the lock order with the
|
||||||
|
// update goroutine's SetMust calls.
|
||||||
|
func (h *MPRISHandler) onVolumeChanged(
|
||||||
|
c *prop.Change,
|
||||||
|
) *dbus.Error {
|
||||||
|
vol, ok := c.Value.(float64)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if h.player.callbacks.OnVolume != nil {
|
||||||
|
go h.player.callbacks.OnVolume(vol)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// onLoopStatusChanged is called when an external D-Bus client
|
||||||
|
// sets the LoopStatus property.
|
||||||
|
func (h *MPRISHandler) onLoopStatusChanged(
|
||||||
|
_ *prop.Change,
|
||||||
|
) *dbus.Error {
|
||||||
|
// LoopStatus changes via D-Bus are acknowledged but not
|
||||||
|
// actively wired to the queue's CycleRepeat. The queue
|
||||||
|
// cycles through modes and MPRIS reflects the result.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// onShuffleChanged is called when an external D-Bus client sets
|
||||||
|
// the Shuffle property.
|
||||||
|
func (h *MPRISHandler) onShuffleChanged(
|
||||||
|
_ *prop.Change,
|
||||||
|
) *dbus.Error {
|
||||||
|
// Shuffle changes via D-Bus are acknowledged but not
|
||||||
|
// actively wired to the queue's ToggleShuffle. The queue
|
||||||
|
// toggles and MPRIS reflects the result.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// propertySpec builds the full property map for both MPRIS
|
||||||
|
// interfaces.
|
||||||
|
func (h *MPRISHandler) propertySpec() map[string]map[string]*prop.Prop {
|
||||||
|
noTrack := map[string]interface{}{
|
||||||
|
"mpris:trackid": dbus.ObjectPath(
|
||||||
|
"/org/mpris/MediaPlayer2/TrackList/NoTrack",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]map[string]*prop.Prop{
|
||||||
|
rootIf: {
|
||||||
|
"CanQuit": newReadOnlyProp(false),
|
||||||
|
"CanRaise": newReadOnlyProp(false),
|
||||||
|
"HasTrackList": newReadOnlyProp(false),
|
||||||
|
"Identity": newReadOnlyProp("YellowJacket"),
|
||||||
|
"DesktopEntry": newReadOnlyProp(
|
||||||
|
"yellowjacket",
|
||||||
|
),
|
||||||
|
"SupportedUriSchemes": newReadOnlyProp(
|
||||||
|
[]string{},
|
||||||
|
),
|
||||||
|
"SupportedMimeTypes": newReadOnlyProp(
|
||||||
|
[]string{},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
playerIf: {
|
||||||
|
"PlaybackStatus": newReadOnlyProp("Stopped"),
|
||||||
|
"LoopStatus": {
|
||||||
|
Value: "None",
|
||||||
|
Writable: true,
|
||||||
|
Emit: prop.EmitTrue,
|
||||||
|
Callback: h.onLoopStatusChanged,
|
||||||
|
},
|
||||||
|
"Rate": newReadOnlyProp(1.0),
|
||||||
|
"MinimumRate": newReadOnlyProp(1.0),
|
||||||
|
"MaximumRate": newReadOnlyProp(1.0),
|
||||||
|
"Shuffle": {
|
||||||
|
Value: false,
|
||||||
|
Writable: true,
|
||||||
|
Emit: prop.EmitTrue,
|
||||||
|
Callback: h.onShuffleChanged,
|
||||||
|
},
|
||||||
|
"Metadata": newReadOnlyProp(noTrack),
|
||||||
|
"Volume": {
|
||||||
|
Value: 1.0,
|
||||||
|
Writable: true,
|
||||||
|
Emit: prop.EmitTrue,
|
||||||
|
Callback: h.onVolumeChanged,
|
||||||
|
},
|
||||||
|
"Position": {
|
||||||
|
Value: int64(0),
|
||||||
|
Writable: false,
|
||||||
|
Emit: prop.EmitFalse,
|
||||||
|
},
|
||||||
|
"CanGoNext": newReadOnlyProp(true),
|
||||||
|
"CanGoPrevious": newReadOnlyProp(true),
|
||||||
|
"CanPlay": newReadOnlyProp(true),
|
||||||
|
"CanPause": newReadOnlyProp(true),
|
||||||
|
"CanSeek": newReadOnlyProp(true),
|
||||||
|
"CanControl": newReadOnlyProp(true),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newReadOnlyProp creates a read-only property with EmitTrue.
|
||||||
|
// Read-only here means external D-Bus clients cannot set it via
|
||||||
|
// the Properties.Set interface; the server updates it internally
|
||||||
|
// via SetMust.
|
||||||
|
func newReadOnlyProp(value interface{}) *prop.Prop {
|
||||||
|
return &prop.Prop{
|
||||||
|
Value: value,
|
||||||
|
Writable: false,
|
||||||
|
Emit: prop.EmitTrue,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// introspectNode builds the introspection data for the MPRIS
|
||||||
|
// object.
|
||||||
|
func (h *MPRISHandler) introspectNode() *introspect.Node {
|
||||||
|
return &introspect.Node{
|
||||||
|
Name: busName,
|
||||||
|
Interfaces: []introspect.Interface{
|
||||||
|
introspect.IntrospectData,
|
||||||
|
{
|
||||||
|
Name: rootIf,
|
||||||
|
Properties: introspectProps(
|
||||||
|
roProp("CanQuit", "b"),
|
||||||
|
roProp("CanRaise", "b"),
|
||||||
|
roProp("HasTrackList", "b"),
|
||||||
|
roProp("Identity", "s"),
|
||||||
|
roProp("DesktopEntry", "s"),
|
||||||
|
roProp(
|
||||||
|
"SupportedUriSchemes", "as",
|
||||||
|
),
|
||||||
|
roProp(
|
||||||
|
"SupportedMimeTypes", "as",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Methods: []introspect.Method{
|
||||||
|
{Name: "Raise"},
|
||||||
|
{Name: "Quit"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: playerIf,
|
||||||
|
Properties: introspectProps(
|
||||||
|
roProp("PlaybackStatus", "s"),
|
||||||
|
rwProp("LoopStatus", "s"),
|
||||||
|
rwProp("Rate", "d"),
|
||||||
|
rwProp("Shuffle", "b"),
|
||||||
|
roProp("Metadata", "a{sv}"),
|
||||||
|
rwProp("Volume", "d"),
|
||||||
|
roProp("Position", "x"),
|
||||||
|
roProp("MinimumRate", "d"),
|
||||||
|
roProp("MaximumRate", "d"),
|
||||||
|
roProp("CanGoNext", "b"),
|
||||||
|
roProp("CanGoPrevious", "b"),
|
||||||
|
roProp("CanPlay", "b"),
|
||||||
|
roProp("CanPause", "b"),
|
||||||
|
roProp("CanSeek", "b"),
|
||||||
|
roProp("CanControl", "b"),
|
||||||
|
),
|
||||||
|
Signals: []introspect.Signal{
|
||||||
|
{
|
||||||
|
Name: "Seeked",
|
||||||
|
Args: []introspect.Arg{
|
||||||
|
{
|
||||||
|
Name: "Position",
|
||||||
|
Type: "x",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Methods: []introspect.Method{
|
||||||
|
{Name: "Next"},
|
||||||
|
{Name: "Previous"},
|
||||||
|
{Name: "Pause"},
|
||||||
|
{Name: "PlayPause"},
|
||||||
|
{Name: "Stop"},
|
||||||
|
{Name: "Play"},
|
||||||
|
{
|
||||||
|
Name: "Seek",
|
||||||
|
Args: []introspect.Arg{
|
||||||
|
{
|
||||||
|
Name: "Offset",
|
||||||
|
Type: "x",
|
||||||
|
Direction: "in",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "SetPosition",
|
||||||
|
Args: []introspect.Arg{
|
||||||
|
{
|
||||||
|
Name: "TrackId",
|
||||||
|
Type: "o",
|
||||||
|
Direction: "in",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Position",
|
||||||
|
Type: "x",
|
||||||
|
Direction: "in",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "OpenUri",
|
||||||
|
Args: []introspect.Arg{
|
||||||
|
{
|
||||||
|
Name: "Uri",
|
||||||
|
Type: "s",
|
||||||
|
Direction: "in",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func roProp(name, typ string) introspect.Property {
|
||||||
|
return introspect.Property{
|
||||||
|
Name: name,
|
||||||
|
Type: typ,
|
||||||
|
Access: "read",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func rwProp(name, typ string) introspect.Property {
|
||||||
|
return introspect.Property{
|
||||||
|
Name: name,
|
||||||
|
Type: typ,
|
||||||
|
Access: "readwrite",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func introspectProps(
|
||||||
|
props ...introspect.Property,
|
||||||
|
) []introspect.Property {
|
||||||
|
return props
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
//go:build !linux
|
||||||
|
|
||||||
|
package mediacontrols
|
||||||
|
|
||||||
|
import "log/slog"
|
||||||
|
|
||||||
|
// stubHandler is a no-op Handler for platforms without media control
|
||||||
|
// integration.
|
||||||
|
type stubHandler struct{}
|
||||||
|
|
||||||
|
// NewHandler returns a no-op handler on unsupported platforms.
|
||||||
|
func NewHandler(_ *slog.Logger) Handler {
|
||||||
|
return &stubHandler{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubHandler) Init(_ Callbacks) error { return nil }
|
||||||
|
|
||||||
|
func (s *stubHandler) UpdateMetadata(_ Metadata) {}
|
||||||
|
|
||||||
|
func (s *stubHandler) UpdatePlaybackState(
|
||||||
|
_ PlaybackState,
|
||||||
|
_ int,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubHandler) NotifySeek(_ int) {}
|
||||||
|
|
||||||
|
func (s *stubHandler) UpdateVolume(_ float64) {}
|
||||||
|
|
||||||
|
func (s *stubHandler) Close() {}
|
||||||
+123
-2
@@ -22,6 +22,7 @@ import (
|
|||||||
"yellowjacket/backend/database"
|
"yellowjacket/backend/database"
|
||||||
"yellowjacket/backend/database/sql/sqlcgen"
|
"yellowjacket/backend/database/sql/sqlcgen"
|
||||||
"yellowjacket/backend/events"
|
"yellowjacket/backend/events"
|
||||||
|
"yellowjacket/backend/mediacontrols"
|
||||||
"yellowjacket/backend/metadata"
|
"yellowjacket/backend/metadata"
|
||||||
"yellowjacket/backend/profiling"
|
"yellowjacket/backend/profiling"
|
||||||
)
|
)
|
||||||
@@ -52,6 +53,7 @@ type Player struct {
|
|||||||
speakerStreamer beep.Streamer
|
speakerStreamer beep.Streamer
|
||||||
playbackFinishedHandler func()
|
playbackFinishedHandler func()
|
||||||
trackChangeID uint64
|
trackChangeID uint64
|
||||||
|
mediaControls mediacontrols.Handler
|
||||||
}
|
}
|
||||||
|
|
||||||
// State represents the current playback state.
|
// State represents the current playback state.
|
||||||
@@ -139,6 +141,16 @@ func (p *Player) SetPlaybackFinishedHandler(handler func()) {
|
|||||||
p.playbackFinishedHandler = handler
|
p.playbackFinishedHandler = handler
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetMediaControls provides an OS media controls handler. When set,
|
||||||
|
// the player pushes metadata, playback state, volume, and seek
|
||||||
|
// notifications to the OS media overlay.
|
||||||
|
func (p *Player) SetMediaControls(h mediacontrols.Handler) {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
|
||||||
|
p.mediaControls = h
|
||||||
|
}
|
||||||
|
|
||||||
// SetContext sets the Wails runtime context and restores persisted
|
// SetContext sets the Wails runtime context and restores persisted
|
||||||
// state.
|
// state.
|
||||||
func (p *Player) SetContext(ctx context.Context) {
|
func (p *Player) SetContext(ctx context.Context) {
|
||||||
@@ -172,6 +184,13 @@ func (p *Player) emitPlaybackStateChanged(state State) {
|
|||||||
events.PlaybackStateChanged,
|
events.PlaybackStateChanged,
|
||||||
map[string]string{"state": string(state)},
|
map[string]string{"state": string(state)},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if p.mediaControls != nil {
|
||||||
|
p.mediaControls.UpdatePlaybackState(
|
||||||
|
stateToMediaControls(state),
|
||||||
|
p.currentPositionSecondsLocked(),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Player) emitPlaybackFinished() {
|
func (p *Player) emitPlaybackFinished() {
|
||||||
@@ -198,6 +217,13 @@ func (p *Player) emitVolumeChanged() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
runtime.EventsEmit(p.ctx, events.VolumeChanged, volume)
|
runtime.EventsEmit(p.ctx, events.VolumeChanged, volume)
|
||||||
|
|
||||||
|
if p.mediaControls != nil {
|
||||||
|
// MPRIS volume is 0.0–1.0 linear.
|
||||||
|
p.mediaControls.UpdateVolume(
|
||||||
|
float64(volume) / float64(MaxUserVol),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Player) emitTrackChanged() {
|
func (p *Player) emitTrackChanged() {
|
||||||
@@ -237,6 +263,14 @@ func (p *Player) emitTrackChanged() {
|
|||||||
"Emitting TrackChangedEvent with track info",
|
"Emitting TrackChangedEvent with track info",
|
||||||
"trackInfo", trackInfo,
|
"trackInfo", trackInfo,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if p.mediaControls != nil {
|
||||||
|
p.mediaControls.UpdateMetadata(
|
||||||
|
p.buildMediaMetadata(
|
||||||
|
trackInfo, trackLengthSecs,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// EmitCurrentState pushes the current player state to the frontend.
|
// EmitCurrentState pushes the current player state to the frontend.
|
||||||
@@ -325,12 +359,29 @@ func (p *Player) onPlaybackFinished() {
|
|||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
p.state = Stopped
|
p.state = Stopped
|
||||||
handler := p.playbackFinishedHandler
|
handler := p.playbackFinishedHandler
|
||||||
|
mc := p.mediaControls
|
||||||
p.mu.Unlock()
|
p.mu.Unlock()
|
||||||
|
|
||||||
// Emit events outside the lock — these are non-blocking Wails
|
// Emit Wails events outside the lock — these are non-blocking
|
||||||
// calls that don't need player state.
|
// calls that don't need player state.
|
||||||
p.emitPlaybackStateChanged(Stopped)
|
|
||||||
p.emitPlaybackFinished()
|
p.emitPlaybackFinished()
|
||||||
|
|
||||||
|
if p.ctx != nil {
|
||||||
|
runtime.EventsEmit(
|
||||||
|
p.ctx,
|
||||||
|
events.PlaybackStateChanged,
|
||||||
|
map[string]string{"state": string(Stopped)},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify media controls outside the lock. The track just
|
||||||
|
// ended so position is 0.
|
||||||
|
if mc != nil {
|
||||||
|
mc.UpdatePlaybackState(
|
||||||
|
mediacontrols.StateStopped, 0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
p.logger.Info("Playback finished naturally")
|
p.logger.Info("Playback finished naturally")
|
||||||
|
|
||||||
// Notify queue for auto-advance. Called without p.mu held
|
// Notify queue for auto-advance. Called without p.mu held
|
||||||
@@ -566,6 +617,11 @@ func (p *Player) UnloadTrack() {
|
|||||||
// Notify frontend that there is no longer a current track.
|
// Notify frontend that there is no longer a current track.
|
||||||
p.emitPlaybackStateChanged(p.state)
|
p.emitPlaybackStateChanged(p.state)
|
||||||
runtime.EventsEmit(p.ctx, events.TrackChanged, nil)
|
runtime.EventsEmit(p.ctx, events.TrackChanged, nil)
|
||||||
|
|
||||||
|
if p.mediaControls != nil {
|
||||||
|
p.mediaControls.UpdateMetadata(mediacontrols.Metadata{})
|
||||||
|
}
|
||||||
|
|
||||||
p.saveState()
|
p.saveState()
|
||||||
|
|
||||||
p.logger.Info("Track unloaded")
|
p.logger.Info("Track unloaded")
|
||||||
@@ -706,6 +762,10 @@ func (p *Player) seekLocked(targetSeconds int) error {
|
|||||||
|
|
||||||
speaker.Unlock()
|
speaker.Unlock()
|
||||||
|
|
||||||
|
if p.mediaControls != nil {
|
||||||
|
p.mediaControls.NotifySeek(targetSeconds)
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -786,6 +846,67 @@ func (p *Player) trackLengthLocked() (int, error) {
|
|||||||
return length, nil
|
return length, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// Media controls helpers
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
|
||||||
|
// stateToMediaControls maps the player's State type to the
|
||||||
|
// mediacontrols PlaybackState.
|
||||||
|
func stateToMediaControls(s State) mediacontrols.PlaybackState {
|
||||||
|
switch s {
|
||||||
|
case Playing:
|
||||||
|
return mediacontrols.StatePlaying
|
||||||
|
case Paused:
|
||||||
|
return mediacontrols.StatePaused
|
||||||
|
default:
|
||||||
|
return mediacontrols.StateStopped
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// currentPositionSecondsLocked returns the playback position in
|
||||||
|
// seconds. Must be called with p.mu held.
|
||||||
|
func (p *Player) currentPositionSecondsLocked() int {
|
||||||
|
if p.seeker == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
speaker.Lock()
|
||||||
|
pos := p.seeker.Position() / int(p.format.SampleRate)
|
||||||
|
speaker.Unlock()
|
||||||
|
|
||||||
|
return pos
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildMediaMetadata constructs a mediacontrols.Metadata from a
|
||||||
|
// TrackInfo and duration. It resolves the cover art filesystem path
|
||||||
|
// from the database for use by MPRIS (which needs file:// URIs).
|
||||||
|
// Must be called with p.mu held.
|
||||||
|
func (p *Player) buildMediaMetadata(
|
||||||
|
info TrackInfo,
|
||||||
|
durationSec int,
|
||||||
|
) mediacontrols.Metadata {
|
||||||
|
meta := mediacontrols.Metadata{
|
||||||
|
Title: info.Title,
|
||||||
|
Artist: info.Artist,
|
||||||
|
Album: info.Album,
|
||||||
|
DurationSec: durationSec,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve cover art filesystem path. The database stores the
|
||||||
|
// full path; ResolveURLs converts it to relative HTTP paths
|
||||||
|
// for the frontend, but MPRIS needs the actual file path.
|
||||||
|
if p.db != nil && info.FilePath != "" {
|
||||||
|
dbMeta, err := p.db.Queries.GetTrackMetadataByPath(
|
||||||
|
p.ctx, info.FilePath,
|
||||||
|
)
|
||||||
|
if err == nil && dbMeta.CoverArtPath != "" {
|
||||||
|
meta.ArtFilePath = dbMeta.CoverArtPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return meta
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------
|
// ---------------------------------------------------------------
|
||||||
// State persistence
|
// State persistence
|
||||||
// ---------------------------------------------------------------
|
// ---------------------------------------------------------------
|
||||||
|
|||||||
+3
@@ -2,6 +2,7 @@
|
|||||||
// This file is automatically generated. DO NOT EDIT
|
// This file is automatically generated. DO NOT EDIT
|
||||||
import {player} from '../models';
|
import {player} from '../models';
|
||||||
import {context} from '../models';
|
import {context} from '../models';
|
||||||
|
import {mediacontrols} from '../models';
|
||||||
|
|
||||||
export function ChangeVolume(arg1:number):Promise<void>;
|
export function ChangeVolume(arg1:number):Promise<void>;
|
||||||
|
|
||||||
@@ -33,6 +34,8 @@ export function Seek(arg1:number):Promise<void>;
|
|||||||
|
|
||||||
export function SetContext(arg1:context.Context):Promise<void>;
|
export function SetContext(arg1:context.Context):Promise<void>;
|
||||||
|
|
||||||
|
export function SetMediaControls(arg1:mediacontrols.Handler):Promise<void>;
|
||||||
|
|
||||||
export function SetPlaybackFinishedHandler(arg1:any):Promise<void>;
|
export function SetPlaybackFinishedHandler(arg1:any):Promise<void>;
|
||||||
|
|
||||||
export function SetVolume(arg1:player.UserVolume):Promise<void>;
|
export function SetVolume(arg1:player.UserVolume):Promise<void>;
|
||||||
|
|||||||
@@ -62,6 +62,10 @@ export function SetContext(arg1) {
|
|||||||
return window['go']['player']['Player']['SetContext'](arg1);
|
return window['go']['player']['Player']['SetContext'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SetMediaControls(arg1) {
|
||||||
|
return window['go']['player']['Player']['SetMediaControls'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SetPlaybackFinishedHandler(arg1) {
|
export function SetPlaybackFinishedHandler(arg1) {
|
||||||
return window['go']['player']['Player']['SetPlaybackFinishedHandler'](arg1);
|
return window['go']['player']['Player']['SetPlaybackFinishedHandler'](arg1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ require (
|
|||||||
github.com/TheCodeOfCaleb/beep/v2 v2.1.2
|
github.com/TheCodeOfCaleb/beep/v2 v2.1.2
|
||||||
github.com/a-h/templ v0.3.977
|
github.com/a-h/templ v0.3.977
|
||||||
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8
|
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8
|
||||||
|
github.com/godbus/dbus/v5 v5.1.0
|
||||||
github.com/golang-cz/devslog v0.0.15
|
github.com/golang-cz/devslog v0.0.15
|
||||||
github.com/wailsapp/wails/v2 v2.10.2
|
github.com/wailsapp/wails/v2 v2.10.2
|
||||||
golang.org/x/image v0.12.0
|
golang.org/x/image v0.12.0
|
||||||
@@ -128,7 +129,6 @@ require (
|
|||||||
github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect
|
github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect
|
||||||
github.com/gobwas/glob v0.2.3 // indirect
|
github.com/gobwas/glob v0.2.3 // indirect
|
||||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
|
||||||
github.com/godoc-lint/godoc-lint v0.11.1 // indirect
|
github.com/godoc-lint/godoc-lint v0.11.1 // indirect
|
||||||
github.com/gofrs/flock v0.13.0 // indirect
|
github.com/gofrs/flock v0.13.0 // indirect
|
||||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||||
|
|||||||
Reference in New Issue
Block a user