From 4f3244c9927f62f3f3de5cd20fb412cd352bd6df Mon Sep 17 00:00:00 2001 From: Caleb Allen Date: Wed, 25 Feb 2026 20:51:29 -0500 Subject: [PATCH] added mpris (linux media player controls) support --- backend/app.go | 59 ++- backend/mediacontrols/mediacontrols.go | 63 +++ backend/mediacontrols/mpris_linux.go | 637 +++++++++++++++++++++++++ backend/mediacontrols/stub.go | 30 ++ backend/player/player.go | 125 ++++- frontend/wailsjs/go/player/Player.d.ts | 3 + frontend/wailsjs/go/player/Player.js | 4 + go.mod | 2 +- 8 files changed, 911 insertions(+), 12 deletions(-) create mode 100644 backend/mediacontrols/mediacontrols.go create mode 100644 backend/mediacontrols/mpris_linux.go create mode 100644 backend/mediacontrols/stub.go diff --git a/backend/app.go b/backend/app.go index 7af31ce..a4dd325 100644 --- a/backend/app.go +++ b/backend/app.go @@ -17,6 +17,7 @@ import ( "yellowjacket/backend/database" "yellowjacket/backend/frontendutil" "yellowjacket/backend/library" + "yellowjacket/backend/mediacontrols" "yellowjacket/backend/player" "yellowjacket/backend/playlist" "yellowjacket/backend/profiling" @@ -28,15 +29,16 @@ type YellowJacketApp struct { FEBindings []any FrontendUtil *frontendutil.FrontendUtil - logger *slog.Logger - assetHandler *assets.Handler - database *database.DB - library *library.Library - player *player.Player - playlist *playlist.Service - queue *queue.Queue - appContext context.Context - appConfig *config.Config + logger *slog.Logger + assetHandler *assets.Handler + database *database.DB + library *library.Library + player *player.Player + playlist *playlist.Service + queue *queue.Queue + mediaControls mediacontrols.Handler + appContext context.Context + appConfig *config.Config } // 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. 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. @@ -198,6 +235,10 @@ func (yj *YellowJacketApp) OnShutdown(_ context.Context) { if yj.queue != nil { yj.queue.SaveState() } + + if yj.mediaControls != nil { + yj.mediaControls.Close() + } } // OnDomReady handles post-DOM initialization and startup error reporting. diff --git a/backend/mediacontrols/mediacontrols.go b/backend/mediacontrols/mediacontrols.go new file mode 100644 index 0000000..ae7f5d6 --- /dev/null +++ b/backend/mediacontrols/mediacontrols.go @@ -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() +} diff --git a/backend/mediacontrols/mpris_linux.go b/backend/mediacontrols/mpris_linux.go new file mode 100644 index 0000000..ee3cd9d --- /dev/null +++ b/backend/mediacontrols/mpris_linux.go @@ -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 +} diff --git a/backend/mediacontrols/stub.go b/backend/mediacontrols/stub.go new file mode 100644 index 0000000..0eccd12 --- /dev/null +++ b/backend/mediacontrols/stub.go @@ -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() {} diff --git a/backend/player/player.go b/backend/player/player.go index 9436096..2fd3f60 100644 --- a/backend/player/player.go +++ b/backend/player/player.go @@ -22,6 +22,7 @@ import ( "yellowjacket/backend/database" "yellowjacket/backend/database/sql/sqlcgen" "yellowjacket/backend/events" + "yellowjacket/backend/mediacontrols" "yellowjacket/backend/metadata" "yellowjacket/backend/profiling" ) @@ -52,6 +53,7 @@ type Player struct { speakerStreamer beep.Streamer playbackFinishedHandler func() trackChangeID uint64 + mediaControls mediacontrols.Handler } // State represents the current playback state. @@ -139,6 +141,16 @@ func (p *Player) SetPlaybackFinishedHandler(handler func()) { 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 // state. func (p *Player) SetContext(ctx context.Context) { @@ -172,6 +184,13 @@ func (p *Player) emitPlaybackStateChanged(state State) { events.PlaybackStateChanged, map[string]string{"state": string(state)}, ) + + if p.mediaControls != nil { + p.mediaControls.UpdatePlaybackState( + stateToMediaControls(state), + p.currentPositionSecondsLocked(), + ) + } } func (p *Player) emitPlaybackFinished() { @@ -198,6 +217,13 @@ func (p *Player) emitVolumeChanged() { ) 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() { @@ -237,6 +263,14 @@ func (p *Player) emitTrackChanged() { "Emitting TrackChangedEvent with track info", "trackInfo", trackInfo, ) + + if p.mediaControls != nil { + p.mediaControls.UpdateMetadata( + p.buildMediaMetadata( + trackInfo, trackLengthSecs, + ), + ) + } } // EmitCurrentState pushes the current player state to the frontend. @@ -325,12 +359,29 @@ func (p *Player) onPlaybackFinished() { p.mu.Lock() p.state = Stopped handler := p.playbackFinishedHandler + mc := p.mediaControls 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. - p.emitPlaybackStateChanged(Stopped) 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") // 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. p.emitPlaybackStateChanged(p.state) runtime.EventsEmit(p.ctx, events.TrackChanged, nil) + + if p.mediaControls != nil { + p.mediaControls.UpdateMetadata(mediacontrols.Metadata{}) + } + p.saveState() p.logger.Info("Track unloaded") @@ -706,6 +762,10 @@ func (p *Player) seekLocked(targetSeconds int) error { speaker.Unlock() + if p.mediaControls != nil { + p.mediaControls.NotifySeek(targetSeconds) + } + return nil } @@ -786,6 +846,67 @@ func (p *Player) trackLengthLocked() (int, error) { 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 // --------------------------------------------------------------- diff --git a/frontend/wailsjs/go/player/Player.d.ts b/frontend/wailsjs/go/player/Player.d.ts index b63e6c1..2dee35c 100755 --- a/frontend/wailsjs/go/player/Player.d.ts +++ b/frontend/wailsjs/go/player/Player.d.ts @@ -2,6 +2,7 @@ // This file is automatically generated. DO NOT EDIT import {player} from '../models'; import {context} from '../models'; +import {mediacontrols} from '../models'; export function ChangeVolume(arg1:number):Promise; @@ -33,6 +34,8 @@ export function Seek(arg1:number):Promise; export function SetContext(arg1:context.Context):Promise; +export function SetMediaControls(arg1:mediacontrols.Handler):Promise; + export function SetPlaybackFinishedHandler(arg1:any):Promise; export function SetVolume(arg1:player.UserVolume):Promise; diff --git a/frontend/wailsjs/go/player/Player.js b/frontend/wailsjs/go/player/Player.js index fea7417..c25834c 100755 --- a/frontend/wailsjs/go/player/Player.js +++ b/frontend/wailsjs/go/player/Player.js @@ -62,6 +62,10 @@ export function SetContext(arg1) { return window['go']['player']['Player']['SetContext'](arg1); } +export function SetMediaControls(arg1) { + return window['go']['player']['Player']['SetMediaControls'](arg1); +} + export function SetPlaybackFinishedHandler(arg1) { return window['go']['player']['Player']['SetPlaybackFinishedHandler'](arg1); } diff --git a/go.mod b/go.mod index 80b5b23..0f1bebd 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/TheCodeOfCaleb/beep/v2 v2.1.2 github.com/a-h/templ v0.3.977 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/wailsapp/wails/v2 v2.10.2 golang.org/x/image v0.12.0 @@ -128,7 +129,6 @@ require ( github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect github.com/gobwas/glob v0.2.3 // 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/gofrs/flock v0.13.0 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect