From 9ca8d080695ca54d2ae2dbe175cf006d36eeae58 Mon Sep 17 00:00:00 2001 From: aynakeya Date: Thu, 19 Feb 2026 11:25:48 +0800 Subject: [PATCH] add linux smc --- go.mod | 2 +- internal/sysmediacontrol/smc_linux.go | 415 +++++++++++++++++++++++++- 2 files changed, 414 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index c20cfcd..596486c 100644 --- a/go.mod +++ b/go.mod @@ -22,6 +22,7 @@ require ( github.com/go-gl/glfw/v3.3/glfw v0.0.0-20250301202403-da16c1255728 github.com/go-ole/go-ole v1.3.0 github.com/go-resty/resty/v2 v2.17.1 + github.com/godbus/dbus/v5 v5.1.0 github.com/gorilla/websocket v1.5.3 github.com/mattn/go-colorable v0.1.14 github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 @@ -59,7 +60,6 @@ require ( github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 // indirect github.com/go-text/render v0.2.0 // indirect github.com/go-text/typesetting v0.2.1 // indirect - github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/hack-pad/go-indexeddb v0.3.2 // indirect github.com/hack-pad/safejs v0.1.0 // indirect diff --git a/internal/sysmediacontrol/smc_linux.go b/internal/sysmediacontrol/smc_linux.go index 7cbfcd5..2e806e8 100644 --- a/internal/sysmediacontrol/smc_linux.go +++ b/internal/sysmediacontrol/smc_linux.go @@ -1,9 +1,420 @@ package sysmediacontrol +import ( + "AynaLivePlayer/core/events" + "AynaLivePlayer/core/model" + "AynaLivePlayer/global" + "AynaLivePlayer/pkg/config" + "AynaLivePlayer/pkg/eventbus" + "AynaLivePlayer/pkg/logger" + "fmt" + "github.com/godbus/dbus/v5" + "github.com/godbus/dbus/v5/introspect" + "github.com/godbus/dbus/v5/prop" + "sync" + "time" +) + +const ( + mprisBusName = "org.mpris.MediaPlayer2.AynaLivePlayer" + mprisObjPath = dbus.ObjectPath("/org/mpris/MediaPlayer2") + mprisRootIF = "org.mpris.MediaPlayer2" + mprisPlayerIF = "org.mpris.MediaPlayer2.Player" + noTrackPath = dbus.ObjectPath("/org/mpris/MediaPlayer2/TrackList/NoTrack") +) + +var ( + linuxSMCLog logger.ILogger + linuxSMC *linuxMpris +) + +type linuxMpris struct { + conn *dbus.Conn + props *prop.Properties + mu sync.Mutex + + trackSeq uint64 + trackPath dbus.ObjectPath + metadata map[string]dbus.Variant + positionUS int64 + durationUS int64 + volume float64 + playback string +} + +type mprisRoot struct{} +type mprisPlayer struct{} + +func (m *linuxMpris) nextTrackPath() dbus.ObjectPath { + m.trackSeq++ + return dbus.ObjectPath(fmt.Sprintf("/org/mpris/MediaPlayer2/track/%d", m.trackSeq)) +} + +func (m *linuxMpris) emitProps(changed map[string]dbus.Variant) { + _ = m.conn.Emit( + mprisObjPath, + "org.freedesktop.DBus.Properties.PropertiesChanged", + mprisPlayerIF, + changed, + []string{}, + ) +} + +func toMprisVolume(v float64) float64 { + if v < 0 { + v = 0 + } + return v / 100.0 +} + +func fromMprisVolume(v float64) float64 { + if v < 0 { + return 0 + } + return v * 100.0 +} + +func (m *linuxMpris) setPlayback(status string) { + m.mu.Lock() + m.playback = status + props := m.props + m.mu.Unlock() + if props != nil { + props.SetMust(mprisPlayerIF, "PlaybackStatus", status) + } + m.emitProps(map[string]dbus.Variant{ + "PlaybackStatus": dbus.MakeVariant(status), + }) +} + +func (m *linuxMpris) setPosition(seconds float64) { + pos := int64(seconds * float64(time.Second/time.Microsecond)) + if pos < 0 { + pos = 0 + } + m.mu.Lock() + m.positionUS = pos + props := m.props + m.mu.Unlock() + // Keep Position up-to-date for Get(Position), but avoid emitting change signal. + // KDE/GNOME estimate progress locally while playing; frequent signal pushes cause jitter. + if props != nil { + props.SetMust(mprisPlayerIF, "Position", pos) + } +} + +func (m *linuxMpris) setDuration(seconds float64) { + duration := int64(seconds * float64(time.Second/time.Microsecond)) + if duration < 0 { + duration = 0 + } + m.mu.Lock() + m.durationUS = duration + m.metadata["mpris:length"] = dbus.MakeVariant(duration) + md := m.metadata + props := m.props + m.mu.Unlock() + if props != nil { + props.SetMust(mprisPlayerIF, "Metadata", md) + } + m.emitProps(map[string]dbus.Variant{ + "Metadata": dbus.MakeVariant(md), + }) +} + +func (m *linuxMpris) setVolume(v float64) { + mv := toMprisVolume(v) + m.mu.Lock() + m.volume = mv + props := m.props + m.mu.Unlock() + if props != nil { + props.SetMust(mprisPlayerIF, "Volume", mv) + } + m.emitProps(map[string]dbus.Variant{ + "Volume": dbus.MakeVariant(mv), + }) +} + +func (m *linuxMpris) setPlaying(data events.PlayerPlayingUpdateEvent) { + m.mu.Lock() + defer m.mu.Unlock() + + if data.Removed { + m.trackPath = noTrackPath + m.metadata = map[string]dbus.Variant{ + "mpris:trackid": dbus.MakeVariant(noTrackPath), + } + m.positionUS = 0 + m.playback = "Stopped" + props := m.props + if props != nil { + props.SetMust(mprisPlayerIF, "PlaybackStatus", m.playback) + props.SetMust(mprisPlayerIF, "Metadata", m.metadata) + props.SetMust(mprisPlayerIF, "Position", m.positionUS) + } + m.emitProps(map[string]dbus.Variant{ + "PlaybackStatus": dbus.MakeVariant(m.playback), + "Metadata": dbus.MakeVariant(m.metadata), + }) + return + } + + m.trackPath = m.nextTrackPath() + metadata := map[string]dbus.Variant{ + "mpris:trackid": dbus.MakeVariant(m.trackPath), + "xesam:title": dbus.MakeVariant(data.Media.Info.Title), + "xesam:album": dbus.MakeVariant(data.Media.Info.Album), + } + if data.Media.Info.Artist != "" { + metadata["xesam:artist"] = dbus.MakeVariant([]string{data.Media.Info.Artist}) + } + if data.Media.Info.Cover.Url != "" { + metadata["mpris:artUrl"] = dbus.MakeVariant(data.Media.Info.Cover.Url) + } + if m.durationUS > 0 { + metadata["mpris:length"] = dbus.MakeVariant(m.durationUS) + } + + m.metadata = metadata + m.positionUS = 0 + m.playback = "Playing" + props := m.props + if props != nil { + props.SetMust(mprisPlayerIF, "PlaybackStatus", m.playback) + props.SetMust(mprisPlayerIF, "Metadata", m.metadata) + props.SetMust(mprisPlayerIF, "Position", m.positionUS) + } + m.emitProps(map[string]dbus.Variant{ + "PlaybackStatus": dbus.MakeVariant(m.playback), + "Metadata": dbus.MakeVariant(m.metadata), + }) +} + +func (m *mprisRoot) Raise() *dbus.Error { return nil } +func (m *mprisRoot) Quit() *dbus.Error { return nil } + +func (m *mprisPlayer) Next() *dbus.Error { + _ = global.EventBus.Publish(events.PlayerPlayNextCmd, events.PlayerPlayNextCmdEvent{}) + return nil +} + +func (m *mprisPlayer) Previous() *dbus.Error { + _ = global.EventBus.Publish(events.PlayerSeekCmd, events.PlayerSeekCmdEvent{ + Position: 0, + Absolute: true, + }) + return nil +} + +func (m *mprisPlayer) Pause() *dbus.Error { + _ = global.EventBus.Publish(events.PlayerSetPauseCmd, events.PlayerSetPauseCmdEvent{Pause: true}) + return nil +} + +func (m *mprisPlayer) PlayPause() *dbus.Error { + _ = global.EventBus.Publish(events.PlayerToggleCmd, events.PlayerToggleCmdEvent{}) + return nil +} + +func (m *mprisPlayer) Stop() *dbus.Error { + _ = global.EventBus.Publish(events.PlayerSetPauseCmd, events.PlayerSetPauseCmdEvent{Pause: true}) + return nil +} + +func (m *mprisPlayer) Play() *dbus.Error { + _ = global.EventBus.Publish(events.PlayerSetPauseCmd, events.PlayerSetPauseCmdEvent{Pause: false}) + return nil +} + +func (m *mprisPlayer) Seek(offset int64) *dbus.Error { + _ = global.EventBus.Publish(events.PlayerSeekCmd, events.PlayerSeekCmdEvent{ + Position: float64(offset) / float64(time.Second/time.Microsecond), + Absolute: false, + }) + return nil +} + +func (m *mprisPlayer) SetPosition(trackID dbus.ObjectPath, position int64) *dbus.Error { + if linuxSMC == nil { + return nil + } + linuxSMC.mu.Lock() + currentTrack := linuxSMC.trackPath + linuxSMC.mu.Unlock() + if currentTrack != noTrackPath && trackID != currentTrack { + return nil + } + _ = global.EventBus.Publish(events.PlayerSeekCmd, events.PlayerSeekCmdEvent{ + Position: float64(position) / float64(time.Second/time.Microsecond), + Absolute: true, + }) + return nil +} + +func (m *mprisPlayer) OpenUri(_ string) *dbus.Error { return nil } + func InitSystemMediaControl() { - // stub + linuxSMCLog = global.Logger.WithPrefix("SMTC-Linux") + conn, err := dbus.ConnectSessionBus() + if err != nil { + linuxSMCLog.Warnf("failed to connect session bus: %v", err) + return + } + + reply, err := conn.RequestName(mprisBusName, dbus.NameFlagDoNotQueue) + if err != nil || reply != dbus.RequestNameReplyPrimaryOwner { + linuxSMCLog.Warnf("failed to own mpris bus name (%v, %v)", reply, err) + _ = conn.Close() + return + } + + linuxSMC = &linuxMpris{ + conn: conn, + trackPath: noTrackPath, + metadata: map[string]dbus.Variant{ + "mpris:trackid": dbus.MakeVariant(noTrackPath), + }, + positionUS: 0, + durationUS: 0, + volume: 0.5, + playback: "Stopped", + } + + propsSpec := map[string]map[string]*prop.Prop{ + mprisRootIF: { + "CanQuit": {Value: false, Writable: false, Emit: prop.EmitTrue}, + "CanRaise": {Value: false, Writable: false, Emit: prop.EmitTrue}, + "HasTrackList": {Value: false, Writable: false, Emit: prop.EmitTrue}, + "Identity": {Value: config.ProgramName, Writable: false, Emit: prop.EmitTrue}, + "DesktopEntry": {Value: "AynaLivePlayer", Writable: false, Emit: prop.EmitTrue}, + "SupportedUriSchemes": {Value: []string{"file", "http", "https"}, Writable: false, Emit: prop.EmitTrue}, + "SupportedMimeTypes": {Value: []string{}, Writable: false, Emit: prop.EmitTrue}, + "Fullscreen": {Value: false, Writable: false, Emit: prop.EmitTrue}, + "CanSetFullscreen": {Value: false, Writable: false, Emit: prop.EmitTrue}, + }, + mprisPlayerIF: { + "PlaybackStatus": {Value: linuxSMC.playback, Writable: false, Emit: prop.EmitFalse}, + "Metadata": {Value: linuxSMC.metadata, Writable: false, Emit: prop.EmitFalse}, + "Volume": { + Value: linuxSMC.volume, + Writable: true, + Emit: prop.EmitFalse, + Callback: func(c *prop.Change) *dbus.Error { + v, ok := c.Value.(float64) + if !ok { + return dbus.MakeFailedError(fmt.Errorf("invalid volume type %T", c.Value)) + } + linuxSMC.setVolume(fromMprisVolume(v)) + _ = global.EventBus.Publish(events.PlayerVolumeChangeCmd, events.PlayerVolumeChangeCmdEvent{ + Volume: fromMprisVolume(v), + }) + return nil + }, + }, + "Position": {Value: linuxSMC.positionUS, Writable: false, Emit: prop.EmitFalse}, + "CanGoNext": {Value: true, Writable: false, Emit: prop.EmitTrue}, + "CanGoPrevious": {Value: true, Writable: false, Emit: prop.EmitTrue}, + "CanPlay": {Value: true, Writable: false, Emit: prop.EmitTrue}, + "CanPause": {Value: true, Writable: false, Emit: prop.EmitTrue}, + "CanSeek": {Value: true, Writable: false, Emit: prop.EmitTrue}, + "CanControl": {Value: true, Writable: false, Emit: prop.EmitTrue}, + }, + } + + linuxSMC.props = prop.New(conn, mprisObjPath, propsSpec) + _ = conn.Export(&mprisRoot{}, mprisObjPath, mprisRootIF) + _ = conn.Export(&mprisPlayer{}, mprisObjPath, mprisPlayerIF) + + node := &introspect.Node{ + Name: string(mprisObjPath), + Interfaces: []introspect.Interface{ + introspect.IntrospectData, + prop.IntrospectData, + { + Name: mprisRootIF, + Methods: []introspect.Method{ + {Name: "Raise"}, + {Name: "Quit"}, + }, + }, + { + Name: mprisPlayerIF, + 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"}}, + }, + }, + Signals: []introspect.Signal{ + { + Name: "Seeked", + Args: []introspect.Arg{{Name: "Position", Type: "x"}}, + }, + }, + }, + }, + } + _ = conn.Export(introspect.NewIntrospectable(node), mprisObjPath, "org.freedesktop.DBus.Introspectable") + + _ = global.EventBus.Subscribe("", events.PlayerPlayingUpdate, "sysmediacontrol.linux.playing", func(event *eventbus.Event) { + linuxSMC.setPlaying(event.Data.(events.PlayerPlayingUpdateEvent)) + }) + _ = global.EventBus.Subscribe("", events.PlayerPropertyPauseUpdate, "sysmediacontrol.linux.pause", func(event *eventbus.Event) { + if event.Data.(events.PlayerPropertyPauseUpdateEvent).Paused { + linuxSMC.setPlayback("Paused") + } else { + linuxSMC.setPlayback("Playing") + } + }) + _ = global.EventBus.Subscribe("", events.PlayerPropertyDurationUpdate, "sysmediacontrol.linux.duration", func(event *eventbus.Event) { + linuxSMC.setDuration(event.Data.(events.PlayerPropertyDurationUpdateEvent).Duration) + }) + _ = global.EventBus.Subscribe("", events.PlayerPropertyTimePosUpdate, "sysmediacontrol.linux.timepos", func(event *eventbus.Event) { + linuxSMC.setPosition(event.Data.(events.PlayerPropertyTimePosUpdateEvent).TimePos) + }) + _ = global.EventBus.Subscribe("", events.PlayerPropertyVolumeUpdate, "sysmediacontrol.linux.volume", func(event *eventbus.Event) { + linuxSMC.setVolume(event.Data.(events.PlayerPropertyVolumeUpdateEvent).Volume) + }) + _ = global.EventBus.Subscribe("", events.PlayerPropertyStateUpdate, "sysmediacontrol.linux.state", func(event *eventbus.Event) { + state := event.Data.(events.PlayerPropertyStateUpdateEvent).State + if state == model.PlayerStateIdle { + linuxSMC.setPlayback("Stopped") + } + }) + + linuxSMCLog.Info("linux MPRIS media control initialized") } func Destroy() { - // stub + if linuxSMC == nil { + return + } + _ = global.EventBus.Unsubscribe(events.PlayerPlayingUpdate, "sysmediacontrol.linux.playing") + _ = global.EventBus.Unsubscribe(events.PlayerPropertyPauseUpdate, "sysmediacontrol.linux.pause") + _ = global.EventBus.Unsubscribe(events.PlayerPropertyDurationUpdate, "sysmediacontrol.linux.duration") + _ = global.EventBus.Unsubscribe(events.PlayerPropertyTimePosUpdate, "sysmediacontrol.linux.timepos") + _ = global.EventBus.Unsubscribe(events.PlayerPropertyVolumeUpdate, "sysmediacontrol.linux.volume") + _ = global.EventBus.Unsubscribe(events.PlayerPropertyStateUpdate, "sysmediacontrol.linux.state") + + _, _ = linuxSMC.conn.ReleaseName(mprisBusName) + _ = linuxSMC.conn.Close() + linuxSMC = nil }