Files
wehub-resource-sync ead81af521
Deploy to GitHub Pages / deploy (push) Failing after 0s
CI / go (push) Has been cancelled
CI / build (darwin, macos-14) (push) Has been cancelled
CI / build (windows, windows-2025) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:31:13 +08:00

282 lines
8.2 KiB
Go

//go:build linux
package mediactl
import (
"fmt"
"sync"
"time"
tea "charm.land/bubbletea/v2"
"github.com/godbus/dbus/v5"
"github.com/godbus/dbus/v5/introspect"
"github.com/godbus/dbus/v5/prop"
"github.com/bjarneo/cliamp/internal/playback"
)
func Run(prog *tea.Program, svc *Service) (tea.Model, error) {
return prog.Run()
}
type Service struct {
conn *dbus.Conn
props *prop.Properties
send func(tea.Msg)
mu sync.Mutex
lastStatus playback.Status
lastTrack playback.Track
lastVol float64
lastCanSeek bool
trackSeq int64 // bumped on each track change
trackID dbus.ObjectPath // current track's MPRIS object path
}
const introspectXML = `
<node>
<interface name="org.mpris.MediaPlayer2">
<method name="Raise"/>
<method name="Quit"/>
<property name="Identity" type="s" access="read"/>
<property name="CanQuit" type="b" access="read"/>
<property name="CanRaise" type="b" access="read"/>
<property name="HasTrackList" type="b" access="read"/>
<property name="SupportedUriSchemes" type="as" access="read"/>
<property name="SupportedMimeTypes" type="as" access="read"/>
</interface>
<interface name="org.mpris.MediaPlayer2.Player">
<method name="Next"/>
<method name="Previous"/>
<method name="Pause"/>
<method name="PlayPause"/>
<method name="Stop"/>
<method name="Play"/>
<method name="Seek"><arg direction="in" type="x"/></method>
<method name="SetPosition"><arg direction="in" type="o"/><arg direction="in" type="x"/></method>
<signal name="Seeked"><arg type="x"/></signal>
<property name="PlaybackStatus" type="s" access="read"/>
<property name="Rate" type="d" access="read"/>
<property name="Metadata" type="a{sv}" access="read"/>
<property name="Volume" type="d" access="readwrite"/>
<property name="Position" type="x" access="read"/>
<property name="MinimumRate" type="d" access="read"/>
<property name="MaximumRate" type="d" access="read"/>
<property name="CanGoNext" type="b" access="read"/>
<property name="CanGoPrevious" type="b" access="read"/>
<property name="CanPlay" type="b" access="read"/>
<property name="CanPause" type="b" access="read"/>
<property name="CanSeek" type="b" access="read"/>
<property name="CanControl" type="b" access="read"/>
</interface>
` + introspect.IntrospectDataString + `</node>`
type root struct{ svc *Service }
func (r root) Raise() *dbus.Error { return nil }
func (r root) Quit() *dbus.Error {
r.svc.send(playback.QuitMsg{})
return nil
}
type playerIface struct{ svc *Service }
func (p playerIface) Next() *dbus.Error {
p.svc.send(playback.NextMsg{})
return nil
}
func (p playerIface) Previous() *dbus.Error {
p.svc.send(playback.PrevMsg{})
return nil
}
func (p playerIface) Pause() *dbus.Error {
p.svc.send(playback.PauseMsg{})
return nil
}
func (p playerIface) PlayPause() *dbus.Error {
p.svc.send(playback.PlayPauseMsg{})
return nil
}
func (p playerIface) Stop() *dbus.Error {
p.svc.send(playback.StopMsg{})
return nil
}
func (p playerIface) Play() *dbus.Error {
p.svc.send(playback.PlayMsg{})
return nil
}
func (p playerIface) DoSeek(offset int64) *dbus.Error {
p.svc.send(playback.SeekMsg{Offset: time.Duration(offset) * time.Microsecond})
return nil
}
func (p playerIface) SetPosition(trackID dbus.ObjectPath, position int64) *dbus.Error {
// Ignore a seek aimed at a track that is no longer current (the MPRIS
// spec treats a mismatched TrackId as stale).
p.svc.mu.Lock()
cur := p.svc.trackID
p.svc.mu.Unlock()
if trackID != cur {
return nil
}
p.svc.send(playback.SetPositionMsg{Position: time.Duration(position) * time.Microsecond})
return nil
}
func New(send func(tea.Msg)) (*Service, error) {
conn, err := dbus.ConnectSessionBus()
if err != nil {
return nil, fmt.Errorf("mpris: session bus: %w", err)
}
reply, err := conn.RequestName("org.mpris.MediaPlayer2.cliamp",
dbus.NameFlagDoNotQueue)
if err != nil {
conn.Close()
return nil, fmt.Errorf("mpris: request name: %w", err)
}
if reply != dbus.RequestNameReplyPrimaryOwner {
conn.Close()
return nil, fmt.Errorf("mpris: name already taken")
}
svc := &Service{conn: conn, send: send, trackSeq: 1, trackID: trackPath(1)}
path := dbus.ObjectPath("/org/mpris/MediaPlayer2")
if err := conn.Export(root{svc}, path, "org.mpris.MediaPlayer2"); err != nil {
conn.Close()
return nil, fmt.Errorf("mpris: export root: %w", err)
}
if err := conn.ExportWithMap(playerIface{svc}, map[string]string{
"DoSeek": "Seek",
}, path, "org.mpris.MediaPlayer2.Player"); err != nil {
conn.Close()
return nil, fmt.Errorf("mpris: export player: %w", err)
}
if err := conn.Export(introspect.Introspectable(introspectXML), path,
"org.freedesktop.DBus.Introspectable"); err != nil {
conn.Close()
return nil, fmt.Errorf("mpris: export introspect: %w", err)
}
propsSpec := map[string]map[string]*prop.Prop{
"org.mpris.MediaPlayer2": {
"Identity": {Value: "Cliamp", Writable: false, Emit: prop.EmitTrue},
"CanQuit": {Value: true, Writable: false, Emit: prop.EmitTrue},
"CanRaise": {Value: false, Writable: false, Emit: prop.EmitTrue},
"HasTrackList": {Value: false, Writable: false, Emit: prop.EmitTrue},
"SupportedUriSchemes": {Value: []string{}, Writable: false, Emit: prop.EmitTrue},
"SupportedMimeTypes": {Value: []string{}, Writable: false, Emit: prop.EmitTrue},
},
"org.mpris.MediaPlayer2.Player": {
"PlaybackStatus": {Value: string(playback.StatusStopped), Writable: false, Emit: prop.EmitTrue},
"Metadata": {Value: makeMetadata(playback.Track{}, svc.trackID), Writable: false, Emit: prop.EmitTrue},
"Volume": {Value: 1.0, Writable: true, Emit: prop.EmitTrue, Callback: func(c *prop.Change) *dbus.Error {
v, ok := c.Value.(float64)
if !ok {
return nil
}
if v < 0 {
v = 0
}
if v > 1 {
v = 1
}
// Send synchronously: an extra goroutine per change lets rapid
// volume updates apply out of order. send (prog.Send) is already
// goroutine-safe and non-blocking.
svc.send(playback.SetVolumeMsg{VolumeDB: linearToDb(v)})
return nil
}},
"Position": {Value: int64(0), Writable: false, Emit: prop.EmitFalse},
"Rate": {Value: 1.0, Writable: false, Emit: prop.EmitTrue},
"MinimumRate": {Value: 1.0, Writable: false, Emit: prop.EmitTrue},
"MaximumRate": {Value: 1.0, Writable: false, Emit: prop.EmitTrue},
"CanControl": {Value: true, Writable: false, Emit: prop.EmitTrue},
"CanPlay": {Value: true, Writable: false, Emit: prop.EmitTrue},
"CanPause": {Value: true, Writable: false, Emit: prop.EmitTrue},
"CanGoNext": {Value: true, Writable: false, Emit: prop.EmitTrue},
"CanGoPrevious": {Value: true, Writable: false, Emit: prop.EmitTrue},
"CanSeek": {Value: true, Writable: false, Emit: prop.EmitTrue},
},
}
props, err := prop.Export(conn, path, propsSpec)
if err != nil {
conn.Close()
return nil, fmt.Errorf("mpris: export props: %w", err)
}
svc.props = props
return svc, nil
}
func (s *Service) Update(state playback.State) {
if s == nil {
return
}
s.mu.Lock()
defer s.mu.Unlock()
if s.props == nil {
return
}
iface := "org.mpris.MediaPlayer2.Player"
if state.Status != s.lastStatus {
s.props.SetMust(iface, "PlaybackStatus", string(state.Status))
s.lastStatus = state.Status
}
if state.Track != s.lastTrack {
s.trackSeq++
s.trackID = trackPath(s.trackSeq)
s.props.SetMust(iface, "Metadata", makeMetadata(state.Track, s.trackID))
s.lastTrack = state.Track
}
vol := dbToLinear(state.VolumeDB)
if vol != s.lastVol {
s.props.SetMust(iface, "Volume", vol)
s.lastVol = vol
}
s.props.SetMust(iface, "Position", state.Position.Microseconds())
if state.Seekable != s.lastCanSeek {
s.props.SetMust(iface, "CanSeek", state.Seekable)
s.lastCanSeek = state.Seekable
}
}
func (s *Service) Seeked(position time.Duration) {
if s == nil {
return
}
s.mu.Lock()
defer s.mu.Unlock()
if s.conn == nil {
return
}
s.conn.Emit(
dbus.ObjectPath("/org/mpris/MediaPlayer2"),
"org.mpris.MediaPlayer2.Player.Seeked",
position.Microseconds(),
)
}
func (s *Service) Close() {
if s == nil {
return
}
if s.conn != nil {
s.conn.Close()
}
}