Merge pull request #533 from dweymouth/feature/windows-smtc

Windows SMTC (System Media Transport Controls) integration
This commit is contained in:
Drew Weymouth
2025-02-01 17:15:48 -03:00
committed by GitHub
15 changed files with 411 additions and 39 deletions
+1 -1
View File
@@ -32,7 +32,7 @@ jobs:
./appimage-build-compat.sh ./appimage-build-compat.sh
- name: upload artifact - name: upload artifact
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: Supersonic-compat.AppImage name: Supersonic-compat.AppImage
path: Supersonic-x86_64.AppImage path: Supersonic-x86_64.AppImage
+1 -1
View File
@@ -32,7 +32,7 @@ jobs:
./appimage-build.sh ./appimage-build.sh
- name: upload artifact - name: upload artifact
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: Supersonic.AppImage name: Supersonic.AppImage
path: Supersonic-x86_64.AppImage path: Supersonic-x86_64.AppImage
+1 -1
View File
@@ -44,7 +44,7 @@ jobs:
make zip_macos make zip_macos
- name: Upload package - name: Upload package
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: Supersonic.zip name: Supersonic.zip
path: Supersonic.zip path: Supersonic.zip
+2 -2
View File
@@ -49,13 +49,13 @@ jobs:
mv Supersonic.zip Supersonic_HighSierra+.zip mv Supersonic.zip Supersonic_HighSierra+.zip
- name: Upload Big Sur+ package - name: Upload Big Sur+ package
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: Supersonic_mac_x64_BigSur+.zip name: Supersonic_mac_x64_BigSur+.zip
path: Supersonic_BigSur+.zip path: Supersonic_BigSur+.zip
- name: Upload High Sierra+ package - name: Upload High Sierra+ package
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: Supersonic_mac_x64_HighSierra+.zip name: Supersonic_mac_x64_HighSierra+.zip
path: Supersonic_HighSierra+.zip path: Supersonic_HighSierra+.zip
+1 -1
View File
@@ -33,7 +33,7 @@ jobs:
run: make package_linux run: make package_linux
- name: Upload artifact - name: Upload artifact
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: Supersonic_ubuntu_x64.tar.xz name: Supersonic_ubuntu_x64.tar.xz
path: Supersonic.tar.xz path: Supersonic.tar.xz
+1 -1
View File
@@ -33,7 +33,7 @@ jobs:
run: make package_linux run: make package_linux
- name: Upload artifact - name: Upload artifact
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: Supersonic_ubuntu_x64.tar.xz name: Supersonic_ubuntu_x64.tar.xz
path: Supersonic.tar.xz path: Supersonic.tar.xz
+7 -3
View File
@@ -53,8 +53,12 @@ jobs:
rm Supersonic.exe && rm Supersonic.exe &&
mv Supersonic-newbuild.exe Supersonic.exe mv Supersonic-newbuild.exe Supersonic.exe
- name: Download smtc dll
run: >
wget https://github.com/supersonic-app/smtc-dll/releases/download/v0.1.0/SMTC.dll
- name: Generate zip bundle - name: Generate zip bundle
run: zip Supersonic-windows.zip Supersonic.exe libmpv-2.dll run: zip Supersonic-windows.zip Supersonic.exe libmpv-2.dll SMTC.dll
- name: Generate installer - name: Generate installer
uses: Minionguyjpro/Inno-Setup-Action@v1.2.2 uses: Minionguyjpro/Inno-Setup-Action@v1.2.2
@@ -62,13 +66,13 @@ jobs:
path: win_inno_installscript.iss path: win_inno_installscript.iss
- name: Upload zip - name: Upload zip
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: Supersonic_windows_x64.zip name: Supersonic_windows_x64.zip
path: Supersonic-windows.zip path: Supersonic-windows.zip
- name: Upload installer - name: Upload installer
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: Supersonic_windows_x64_installer.exe name: Supersonic_windows_x64_installer.exe
path: Output/supersonic-installer.exe path: Output/supersonic-installer.exe
+70
View File
@@ -12,6 +12,7 @@ import (
"reflect" "reflect"
"runtime" "runtime"
"slices" "slices"
"strings"
"time" "time"
"github.com/dweymouth/supersonic/backend/ipc" "github.com/dweymouth/supersonic/backend/ipc"
@@ -45,6 +46,7 @@ type App struct {
LocalPlayer *mpv.Player LocalPlayer *mpv.Player
UpdateChecker UpdateChecker UpdateChecker UpdateChecker
MPRISHandler *MPRISHandler MPRISHandler *MPRISHandler
WinSMTC *SMTC
ipcServer ipc.IPCServer ipcServer ipc.IPCServer
// UI callbacks to be set in main // UI callbacks to be set in main
@@ -52,6 +54,7 @@ type App struct {
OnExit func() OnExit func()
appName string appName string
displayAppName string
appVersionTag string appVersionTag string
configDir string configDir string
cacheDir string cacheDir string
@@ -97,6 +100,7 @@ func StartupApp(appName, displayAppName, appVersion, appVersionTag, latestReleas
a := &App{ a := &App{
logFile: logFile, logFile: logFile,
appName: appName, appName: appName,
displayAppName: displayAppName,
appVersionTag: appVersionTag, appVersionTag: appVersionTag,
configDir: confDir, configDir: confDir,
cacheDir: cacheDir, cacheDir: cacheDir,
@@ -165,11 +169,14 @@ func StartupApp(appName, displayAppName, appVersion, appVersionTag, latestReleas
} }
// OS media center integrations // OS media center integrations
// Linux MPRIS
a.setupMPRIS(displayAppName) a.setupMPRIS(displayAppName)
// MacOS MPNowPlayingInfoCenter
InitMPMediaHandler(a.PlaybackManager, func(id string) (string, error) { InitMPMediaHandler(a.PlaybackManager, func(id string) (string, error) {
a.ImageManager.GetCoverThumbnail(id) // ensure image is cached locally a.ImageManager.GetCoverThumbnail(id) // ensure image is cached locally
return a.ImageManager.GetCoverArtUrl(id) return a.ImageManager.GetCoverArtUrl(id)
}) })
// Windows SMTC is initialized from main once we have a window HWND.
a.startConfigWriter(a.bgrndCtx) a.startConfigWriter(a.bgrndCtx)
@@ -331,6 +338,66 @@ func (a *App) setupMPRIS(mprisAppName string) {
a.MPRISHandler.Start() 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.UpdateMetadata(a.displayAppName, "")
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("Supersonic", "")
return
}
meta := nowPlaying.Metadata()
smtc.UpdateMetadata(meta.Name, strings.Join(meta.Artists, ", "))
smtc.UpdatePosition(0, meta.Duration*1000)
go func() {
a.ImageManager.GetCoverThumbnail(meta.CoverArtID) // ensure image is cached locally
if path, err := a.ImageManager.GetCoverArtPath(meta.CoverArtID); err == nil {
smtc.SetThumbnail(path)
}
}()
})
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.SetEnabled(true)
smtc.UpdatePlaybackState(SMTCPlaybackStatePlaying)
})
a.PlaybackManager.OnPaused(func() {
smtc.SetEnabled(true)
smtc.UpdatePlaybackState(SMTCPlaybackStatePaused)
})
a.PlaybackManager.OnStopped(func() {
smtc.SetEnabled(false)
smtc.UpdatePlaybackState(SMTCPlaybackStateStopped)
})
}
func (a *App) LoginToDefaultServer(string) error { func (a *App) LoginToDefaultServer(string) error {
serverCfg := a.ServerManager.GetDefaultServer() serverCfg := a.ServerManager.GetDefaultServer()
if serverCfg == nil { if serverCfg == nil {
@@ -370,6 +437,9 @@ func (a *App) Shutdown() {
a.ipcServer.Shutdown(a.bgrndCtx) a.ipcServer.Shutdown(a.bgrndCtx)
} }
a.MPRISHandler.Shutdown() a.MPRISHandler.Shutdown()
if a.WinSMTC != nil {
a.WinSMTC.Shutdown()
}
a.PlaybackManager.DisableCallbacks() a.PlaybackManager.DisableCallbacks()
a.PlaybackManager.Stop() // will trigger scrobble check a.PlaybackManager.Stop() // will trigger scrobble check
a.cancel() a.cancel()
+18 -2
View File
@@ -12,6 +12,7 @@ import (
"os" "os"
"path" "path"
"path/filepath" "path/filepath"
"regexp"
"sort" "sort"
"strings" "strings"
"time" "time"
@@ -160,6 +161,15 @@ func (i *ImageManager) GetCoverArtUrl(coverID string) (string, error) {
return "", errors.New("cover not found") return "", errors.New("cover not found")
} }
// GetCoverArtPath returns the file path for the locally cached cover thumbnail, if it exists.
func (i *ImageManager) GetCoverArtPath(coverID string) (string, error) {
path := i.filePathForCover(coverID)
if _, err := os.Stat(path); err == nil {
return path, nil
}
return "", errors.New("cover not found")
}
// GetCachedArtistImage returns the artist image for the given artistID from the on-disc cache, if it exists. // GetCachedArtistImage returns the artist image for the given artistID from the on-disc cache, if it exists.
func (i *ImageManager) GetCachedArtistImage(artistID string) (image.Image, bool) { func (i *ImageManager) GetCachedArtistImage(artistID string) (image.Image, bool) {
return i.loadLocalImage(i.filePathForArtistImage(artistID)) return i.loadLocalImage(i.filePathForArtistImage(artistID))
@@ -306,11 +316,11 @@ func (i *ImageManager) checkRefreshLocalCover(stat os.FileInfo, coverID string,
} }
func (i *ImageManager) filePathForCover(coverID string) string { func (i *ImageManager) filePathForCover(coverID string) string {
return filepath.Join(i.ensureCoverCacheDir(), fmt.Sprintf("%s.jpg", coverID)) return filepath.Join(i.ensureCoverCacheDir(), fmt.Sprintf("%s.jpg", sanitizeFileName(coverID)))
} }
func (i *ImageManager) filePathForArtistImage(id string) string { func (i *ImageManager) filePathForArtistImage(id string) string {
return filepath.Join(i.ensureArtistCoverCacheDir(), fmt.Sprintf("%s.jpg", id)) return filepath.Join(i.ensureArtistCoverCacheDir(), fmt.Sprintf("%s.jpg", sanitizeFileName(id)))
} }
func (i *ImageManager) writeJpeg(img image.Image, path string) error { func (i *ImageManager) writeJpeg(img image.Image, path string) error {
@@ -391,3 +401,9 @@ func (im *ImageManager) pruneOnDiskCache() {
} }
im.filesWrittenSinceLastPrune = false im.filesWrittenSinceLastPrune = false
} }
var illegalFilename = regexp.MustCompile("[" + regexp.QuoteMeta("`~!@#$%^&*+={}|/\\:;\"'<>?") + "]")
func sanitizeFileName(s string) string {
return illegalFilename.ReplaceAllString(s, "_")
}
+207
View File
@@ -0,0 +1,207 @@
//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) {
if maj, _, _ := windows.RtlGetNtVersionNumbers(); maj < 10 {
return nil, errors.New("SMTC is not supported on Windows versions < 10")
}
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("SetPlaybackState")
if err != nil {
return err
}
if hr, _, _ := proc.Call(uintptr(state)); hr < 0 {
return fmt.Errorf("SetPlaybackState 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("SetMetadata")
if err != nil {
return err
}
hr, _, _ := proc.Call(uintptr(unsafe.Pointer(utfTitle)), uintptr(unsafe.Pointer(utfArtist)))
if hr < 0 {
return fmt.Errorf("SetMetadata 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("SetPosition")
if err != nil {
return err
}
hr, _, _ := proc.Call(uintptr(positionMillis), uintptr(durationMillis))
if hr < 0 {
return fmt.Errorf("SetPosition failed with HRESULT=%d", hr)
}
return nil
}
func (s *SMTC) SetThumbnail(filepath string) error {
if s.dll == nil {
return errors.New("SMTC DLL not available")
}
proc, err := s.dll.FindProc("SetThumbnailPath")
if err != nil {
return err
}
utfPath, err := windows.UTF16PtrFromString(filepath)
if err != nil {
return err
}
hr, _, _ := proc.Call(uintptr(unsafe.Pointer(utfPath)))
if hr < 0 {
return fmt.Errorf("SetThumbnailPath failed with HRESULT=%d", hr)
}
return nil
}
func (s *SMTC) SetEnabled(enabled bool) error {
if s.dll == nil {
return errors.New("SMTC DLL not available")
}
proc, err := s.dll.FindProc("SetEnabled")
if err != nil {
return err
}
var arg uintptr = 0
if enabled {
arg = 1
}
hr, _, _ := proc.Call(arg)
if hr < 0 {
return fmt.Errorf("SetEnabled 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
View File
@@ -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"
+55
View File
@@ -0,0 +1,55 @@
//go:build !windows
package backend
import "errors"
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{}
var smtcUnsupportedErr = errors.New("SMTC is not supported on this platformo")
func InitSMTCForWindow(hwnd uintptr) (*SMTC, error) {
return nil, smtcUnsupportedErr
}
func (s *SMTC) SetEnabled(enabled bool) error {
return smtcUnsupportedErr
}
func (s *SMTC) SetThumbnail(filepath string) error {
return smtcUnsupportedErr
}
func (s *SMTC) OnButtonPressed(func(SMTCButton)) {}
func (s *SMTC) OnSeek(f func(millis int)) {}
func (s *SMTC) Shutdown() {}
func (s *SMTC) UpdatePlaybackState(state SMTCPlaybackState) error {
return smtcUnsupportedErr
}
func (s *SMTC) UpdateMetadata(title, artist string) error {
return smtcUnsupportedErr
}
func (s *SMTC) UpdatePosition(positionMillis, durationMillis int) error {
return smtcUnsupportedErr
}
+1 -1
View File
@@ -20,6 +20,7 @@ require (
github.com/supersonic-app/go-subsonic v0.0.0-20241224013245-9b2841f3711d github.com/supersonic-app/go-subsonic v0.0.0-20241224013245-9b2841f3711d
github.com/zalando/go-keyring v0.2.6 github.com/zalando/go-keyring v0.2.6
golang.org/x/net v0.25.0 golang.org/x/net v0.25.0
golang.org/x/sys v0.26.0
golang.org/x/text v0.16.0 golang.org/x/text v0.16.0
) )
@@ -50,7 +51,6 @@ require (
github.com/yuin/goldmark v1.7.1 // indirect github.com/yuin/goldmark v1.7.1 // indirect
golang.org/x/image v0.18.0 // indirect golang.org/x/image v0.18.0 // indirect
golang.org/x/mobile v0.0.0-20231127183840-76ac6878050a // indirect golang.org/x/mobile v0.0.0-20231127183840-76ac6878050a // indirect
golang.org/x/sys v0.26.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )
+14 -11
View File
@@ -87,27 +87,30 @@ func main() {
} }
}() }()
// slightly hacky workaround for https://github.com/fyne-io/fyne/issues/4964 startupOnceTasks := sync.OnceFunc(func() {
if runtime.GOOS == "linux" {
workaroundWindowSize := sync.OnceFunc(func() {
go func() {
isWayland := false
mainWindow.Window.(driver.NativeWindow).RunNative(func(ctx any) { mainWindow.Window.(driver.NativeWindow).RunNative(func(ctx any) {
_, isWayland = ctx.(*driver.WaylandWindowContext) // intialize Windows SMTC
}) if runtime.GOOS == "windows" {
if !isWayland { 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) time.Sleep(50 * time.Millisecond)
s := mainWindow.DesiredSize() s := mainWindow.DesiredSize()
mainWindow.Window.Resize(s.Subtract(fyne.NewSize(4, 0))) mainWindow.Window.Resize(s.Subtract(fyne.NewSize(4, 0)))
time.Sleep(50 * time.Millisecond) time.Sleep(50 * time.Millisecond)
mainWindow.Window.Resize(s) // back to desired size mainWindow.Window.Resize(s) // back to desired size
}
}() }()
}
})
}) })
fyneApp.Lifecycle().SetOnEnteredForeground(func() { fyneApp.Lifecycle().SetOnEnteredForeground(func() {
workaroundWindowSize() startupOnceTasks()
}) })
}
mainWindow.ShowAndRun() mainWindow.ShowAndRun()
+1
View File
@@ -46,6 +46,7 @@ Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{
[Files] [Files]
Source: "{#MyAppExeName}"; DestDir: "{app}"; Flags: ignoreversion Source: "{#MyAppExeName}"; DestDir: "{app}"; Flags: ignoreversion
Source: "libmpv-2.dll"; DestDir: "{app}"; Flags: ignoreversion Source: "libmpv-2.dll"; DestDir: "{app}"; Flags: ignoreversion
Source: "SMTC.dll"; DestDir: "{app}"; Flags: ignoreversion
; NOTE: Don't use "Flags: ignoreversion" on any shared system files ; NOTE: Don't use "Flags: ignoreversion" on any shared system files
[Icons] [Icons]