Skip to content

Commit

Permalink
initial implementation of Windows SMTC integration
Browse files Browse the repository at this point in the history
  • Loading branch information
dweymouth committed Jan 16, 2025
1 parent 6d4d87c commit cb01703
Show file tree
Hide file tree
Showing 5 changed files with 282 additions and 15 deletions.
52 changes: 52 additions & 0 deletions backend/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"reflect"
"runtime"
"slices"
"strings"
"time"

"github.com/dweymouth/supersonic/backend/ipc"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
Expand Down
159 changes: 159 additions & 0 deletions backend/smtc.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
16 changes: 16 additions & 0 deletions backend/smtc_cfuncs.go
Original file line number Diff line number Diff line change
@@ -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"
32 changes: 32 additions & 0 deletions backend/smtc_unsupported.go
Original file line number Diff line number Diff line change
@@ -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() {
}
38 changes: 23 additions & 15 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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()

Expand Down

0 comments on commit cb01703

Please sign in to comment.