From cb017037768292e64b56e93dae6893cb0577b8b9 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Wed, 15 Jan 2025 17:42:44 -0800 Subject: [PATCH] initial implementation of Windows SMTC integration --- backend/app.go | 52 ++++++++++++ backend/smtc.go | 159 ++++++++++++++++++++++++++++++++++++ backend/smtc_cfuncs.go | 16 ++++ backend/smtc_unsupported.go | 32 ++++++++ main.go | 38 +++++---- 5 files changed, 282 insertions(+), 15 deletions(-) create mode 100644 backend/smtc.go create mode 100644 backend/smtc_cfuncs.go create mode 100644 backend/smtc_unsupported.go diff --git a/backend/app.go b/backend/app.go index 06f1fc96..9a014ab6 100644 --- a/backend/app.go +++ b/backend/app.go @@ -12,6 +12,7 @@ import ( "reflect" "runtime" "slices" + "strings" "time" "github.com/dweymouth/supersonic/backend/ipc" @@ -45,6 +46,7 @@ type App struct { LocalPlayer *mpv.Player UpdateChecker UpdateChecker MPRISHandler *MPRISHandler + WinSMTC *SMTC ipcServer ipc.IPCServer // UI callbacks to be set in main @@ -165,11 +167,14 @@ func StartupApp(appName, displayAppName, appVersion, appVersionTag, latestReleas } // OS media center integrations + // Linux MPRIS a.setupMPRIS(displayAppName) + // MacOS MPNowPlayingInfoCenter InitMPMediaHandler(a.PlaybackManager, func(id string) (string, error) { a.ImageManager.GetCoverThumbnail(id) // ensure image is cached locally return a.ImageManager.GetCoverArtUrl(id) }) + // Windows SMTC is initialized from main once we have a window HWND. a.startConfigWriter(a.bgrndCtx) @@ -331,6 +336,50 @@ func (a *App) setupMPRIS(mprisAppName string) { a.MPRISHandler.Start() } +func (a *App) SetupWindowsSMTC(hwnd uintptr) { + smtc, err := InitSMTCForWindow(hwnd) + if err != nil { + log.Printf("error initializing SMTC: %d", err) + return + } + a.WinSMTC = smtc + + smtc.OnButtonPressed(func(btn SMTCButton) { + switch btn { + case SMTCButtonPlay: + a.PlaybackManager.Continue() + case SMTCButtonPause: + a.PlaybackManager.Pause() + case SMTCButtonNext: + a.PlaybackManager.SeekNext() + case SMTCButtonPrevious: + a.PlaybackManager.SeekBackOrPrevious() + case SMTCButtonStop: + a.PlaybackManager.Stop() + } + }) + smtc.OnSeek(func(millis int) { + a.PlaybackManager.SeekSeconds(float64(millis) / 1000) + }) + + a.PlaybackManager.OnSongChange(func(nowPlaying mediaprovider.MediaItem, _ *mediaprovider.Track) { + if nowPlaying == nil { + smtc.UpdateMetadata("", "") + return + } + artist := strings.Join(nowPlaying.Metadata().Artists, ", ") + smtc.UpdateMetadata(nowPlaying.Metadata().Name, artist) + smtc.UpdatePosition(0, nowPlaying.Metadata().Duration*1000) + }) + a.PlaybackManager.OnSeek(func() { + dur := a.PlaybackManager.NowPlaying().Metadata().Duration + smtc.UpdatePosition(int(a.PlaybackManager.CurrentPlayer().GetStatus().TimePos*1000), dur*1000) + }) + a.PlaybackManager.OnPlaying(func() { smtc.UpdatePlaybackState(SMTCPlaybackStatePlaying) }) + a.PlaybackManager.OnPaused(func() { smtc.UpdatePlaybackState(SMTCPlaybackStatePaused) }) + a.PlaybackManager.OnStopped(func() { smtc.UpdatePlaybackState(SMTCPlaybackStateStopped) }) +} + func (a *App) LoginToDefaultServer(string) error { serverCfg := a.ServerManager.GetDefaultServer() if serverCfg == nil { @@ -370,6 +419,9 @@ func (a *App) Shutdown() { a.ipcServer.Shutdown(a.bgrndCtx) } a.MPRISHandler.Shutdown() + if a.WinSMTC != nil { + a.WinSMTC.Shutdown() + } a.PlaybackManager.DisableCallbacks() a.PlaybackManager.Stop() // will trigger scrobble check a.cancel() diff --git a/backend/smtc.go b/backend/smtc.go new file mode 100644 index 00000000..6df9069b --- /dev/null +++ b/backend/smtc.go @@ -0,0 +1,159 @@ +//go:build windows + +package backend + +/* +#cgo CFLAGS: -I . +void btn_callback_cgo(int in); +void seek_callback_cgo(int in); +*/ +import "C" + +import ( + "errors" + "fmt" + "unsafe" + + "golang.org/x/sys/windows" +) + +type SMTCPlaybackState int +type SMTCButton int + +const ( + // constants from smtc.h in github.com/supersonic-app/smtc-dll + SMTCPlaybackStateStopped SMTCPlaybackState = 2 + SMTCPlaybackStatePlaying SMTCPlaybackState = 3 + SMTCPlaybackStatePaused SMTCPlaybackState = 4 + + SMTCButtonPlay SMTCButton = 0 + SMTCButtonPause SMTCButton = 1 + SMTCButtonStop SMTCButton = 2 + SMTCButtonPrevious SMTCButton = 4 + SMTCButtonNext SMTCButton = 5 +) + +type SMTC struct { + dll *windows.DLL + + onButtonPressed func(SMTCButton) + onSeek func(int) +} + +var smtcInstance *SMTC + +func InitSMTCForWindow(hwnd uintptr) (*SMTC, error) { + dll, err := windows.LoadDLL("smtc.dll") + if err != nil { + return nil, err + } + + proc, err := dll.FindProc("InitializeForWindow") + if err != nil { + return nil, err + } + + hr, _, _ := proc.Call(hwnd, uintptr(unsafe.Pointer(C.btn_callback_cgo)), uintptr(unsafe.Pointer(C.seek_callback_cgo))) + if hr < 0 { + return nil, fmt.Errorf("InitializeForWindow failed with HRESULT=%d", hr) + } + + smtcInstance = &SMTC{dll: dll} + return smtcInstance, nil +} + +func (s *SMTC) OnButtonPressed(f func(SMTCButton)) { + s.onButtonPressed = f +} + +func (s *SMTC) OnSeek(f func(millis int)) { + s.onSeek = f +} + +func (s *SMTC) Shutdown() { + if s.dll == nil { + return + } + proc, err := s.dll.FindProc("Destroy") + if err == nil { + proc.Call() + } + + s.dll.Release() + s.dll = nil + smtcInstance = nil +} + +func (s *SMTC) UpdatePlaybackState(state SMTCPlaybackState) error { + if s.dll == nil { + return errors.New("SMTC DLL not available") + } + + proc, err := s.dll.FindProc("UpdatePlaybackState") + if err != nil { + return err + } + + if hr, _, _ := proc.Call(uintptr(state)); hr < 0 { + return fmt.Errorf("UpdatePlaybackState failed with HRESULT=%d", hr) + } + return nil +} + +func (s *SMTC) UpdateMetadata(title, artist string) error { + if s.dll == nil { + return errors.New("SMTC DLL not available") + } + + utfTitle, err := windows.UTF16PtrFromString(title) + if err != nil { + return err + } + + utfArtist, err := windows.UTF16PtrFromString(artist) + if err != nil { + return err + } + + proc, err := s.dll.FindProc("UpdateMetadata") + if err != nil { + return err + } + + hr, _, _ := proc.Call(uintptr(unsafe.Pointer(utfTitle)), uintptr(unsafe.Pointer(utfArtist))) + if hr < 0 { + return fmt.Errorf("UpdateMetadata failed with HRESULT=%d", hr) + } + return nil +} + +func (s *SMTC) UpdatePosition(positionMillis, durationMillis int) error { + if s.dll == nil { + return errors.New("SMTC DLL not available") + } + + proc, err := s.dll.FindProc("UpdatePosition") + if err != nil { + return err + } + + hr, _, _ := proc.Call(uintptr(positionMillis), uintptr(durationMillis)) + if hr < 0 { + return fmt.Errorf("UpdatePosition failed with HRESULT=%d", hr) + } + return nil +} + +//export btnCallback +func btnCallback(in int) { + if smtcInstance != nil && smtcInstance.onButtonPressed != nil { + smtcInstance.onButtonPressed(SMTCButton(in)) + } +} + +//export seekCallback +func seekCallback(millis int) { + if smtcInstance != nil && smtcInstance.onSeek != nil { + smtcInstance.onSeek(millis) + } +} diff --git a/backend/smtc_cfuncs.go b/backend/smtc_cfuncs.go new file mode 100644 index 00000000..9c1b317c --- /dev/null +++ b/backend/smtc_cfuncs.go @@ -0,0 +1,16 @@ +//go:build windows + +package backend + +/* +void btn_callback_cgo(int in) { + void btnCallback(int); + btnCallback(in); +} + +void seek_callback_cgo(int in) { + void seekCallback(int); + seekCallback(in); +} +*/ +import "C" diff --git a/backend/smtc_unsupported.go b/backend/smtc_unsupported.go new file mode 100644 index 00000000..36c0ba19 --- /dev/null +++ b/backend/smtc_unsupported.go @@ -0,0 +1,32 @@ +//go:build !windows + +package backend + +import "errors" + +type SMTCPlaybackState int + +const ( + SMTCPlaybackStateStopped SMTCPlaybackState = 0 + SMTCPlaybackStatePlaying SMTCPlaybackState = 1 + SMTCPlaybackStatePaused SMTCPlaybackState = 2 +) + +type SMTC struct{} + +var smtcUnsupportedErr = errors.New("SMTC is not supported on this platformo") + +func InitSMTCForWindow(hwnd uintptr) (*SMTC, error) { + return nil, smtcUnsupportedErr +} + +func (s *SMTC) UpdatePlaybackState(state SMTCPlaybackState) error { + return smtcUnsupportedErr +} + +func (s *SMTC) UpdateMetadata(title, artist string) error { + return smtcUnsupportedErr +} + +func (s *SMTC) Shutdown() { +} diff --git a/main.go b/main.go index d41eedfc..c1dc73e8 100644 --- a/main.go +++ b/main.go @@ -10,6 +10,8 @@ import ( "sync" "time" + "golang.org/x/sys/windows" + "github.com/dweymouth/supersonic/backend" "github.com/dweymouth/supersonic/res" "github.com/dweymouth/supersonic/ui" @@ -87,27 +89,33 @@ func main() { } }() - // slightly hacky workaround for https://github.com/fyne-io/fyne/issues/4964 - if runtime.GOOS == "linux" { - workaroundWindowSize := sync.OnceFunc(func() { - go func() { - isWayland := false - mainWindow.Window.(driver.NativeWindow).RunNative(func(ctx any) { - _, isWayland = ctx.(*driver.WaylandWindowContext) - }) - if !isWayland { + startupOnceTasks := sync.OnceFunc(func() { + mainWindow.Window.(driver.NativeWindow).RunNative(func(ctx any) { + // intialize Windows SMTC + if runtime.GOOS == "windows" { + if maj, _, _ := windows.RtlGetNtVersionNumbers(); maj >= 10 { + // SMTC is only available from Windows 10 (10.0.10240) onward + hwnd := ctx.(driver.WindowsWindowContext).HWND + myApp.SetupWindowsSMTC(hwnd) + } + } + + // slightly hacky workaround for https://github.com/fyne-io/fyne/issues/4964 + _, isWayland := ctx.(*driver.WaylandWindowContext) + if runtime.GOOS == "linux" && !isWayland { + go func() { time.Sleep(50 * time.Millisecond) s := mainWindow.DesiredSize() mainWindow.Window.Resize(s.Subtract(fyne.NewSize(4, 0))) time.Sleep(50 * time.Millisecond) mainWindow.Window.Resize(s) // back to desired size - } - }() + }() + } }) - fyneApp.Lifecycle().SetOnEnteredForeground(func() { - workaroundWindowSize() - }) - } + }) + fyneApp.Lifecycle().SetOnEnteredForeground(func() { + startupOnceTasks() + }) mainWindow.ShowAndRun()