Skip to content

Commit

Permalink
Merge pull request #533 from dweymouth/feature/windows-smtc
Browse files Browse the repository at this point in the history
Windows SMTC (System Media Transport Controls) integration
  • Loading branch information
dweymouth authored Feb 1, 2025
2 parents 7382067 + c9acac6 commit 1ace22c
Show file tree
Hide file tree
Showing 15 changed files with 411 additions and 39 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/build-appimage-compat.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ jobs:
./appimage-build-compat.sh
- name: upload artifact
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: Supersonic-compat.AppImage
path: Supersonic-x86_64.AppImage
2 changes: 1 addition & 1 deletion .github/workflows/build-appimage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ jobs:
./appimage-build.sh
- name: upload artifact
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: Supersonic.AppImage
path: Supersonic-x86_64.AppImage
2 changes: 1 addition & 1 deletion .github/workflows/build-macos-arm64.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ jobs:
make zip_macos
- name: Upload package
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: Supersonic.zip
path: Supersonic.zip
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/build-macos-x64.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,13 @@ jobs:
mv Supersonic.zip Supersonic_HighSierra+.zip
- name: Upload Big Sur+ package
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: Supersonic_mac_x64_BigSur+.zip
path: Supersonic_BigSur+.zip

- name: Upload High Sierra+ package
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: Supersonic_mac_x64_HighSierra+.zip
path: Supersonic_HighSierra+.zip
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/build-ubuntu-22.04.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ jobs:
run: make package_linux

- name: Upload artifact
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: Supersonic_ubuntu_x64.tar.xz
path: Supersonic.tar.xz
2 changes: 1 addition & 1 deletion .github/workflows/build-ubuntu-24.04.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ jobs:
run: make package_linux

- name: Upload artifact
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: Supersonic_ubuntu_x64.tar.xz
path: Supersonic.tar.xz
10 changes: 7 additions & 3 deletions .github/workflows/build-windows.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,22 +53,26 @@ jobs:
rm 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
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
uses: Minionguyjpro/[email protected]
with:
path: win_inno_installscript.iss

- name: Upload zip
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: Supersonic_windows_x64.zip
path: Supersonic-windows.zip

- name: Upload installer
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: Supersonic_windows_x64_installer.exe
path: Output/supersonic-installer.exe
92 changes: 81 additions & 11 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,17 +46,19 @@ type App struct {
LocalPlayer *mpv.Player
UpdateChecker UpdateChecker
MPRISHandler *MPRISHandler
WinSMTC *SMTC
ipcServer ipc.IPCServer

// UI callbacks to be set in main
OnReactivate func()
OnExit func()

appName string
appVersionTag string
configDir string
cacheDir string
portableMode bool
appName string
displayAppName string
appVersionTag string
configDir string
cacheDir string
portableMode bool

isFirstLaunch bool // set by config file reader
bgrndCtx context.Context
Expand Down Expand Up @@ -95,12 +98,13 @@ func StartupApp(appName, displayAppName, appVersion, appVersionTag, latestReleas
}

a := &App{
logFile: logFile,
appName: appName,
appVersionTag: appVersionTag,
configDir: confDir,
cacheDir: cacheDir,
portableMode: portableMode,
logFile: logFile,
appName: appName,
displayAppName: displayAppName,
appVersionTag: appVersionTag,
configDir: confDir,
cacheDir: cacheDir,
portableMode: portableMode,
}
a.bgrndCtx, a.cancel = context.WithCancel(context.Background())
a.readConfig()
Expand Down Expand Up @@ -165,11 +169,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 +338,66 @@ 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.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 {
serverCfg := a.ServerManager.GetDefaultServer()
if serverCfg == nil {
Expand Down Expand Up @@ -370,6 +437,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
20 changes: 18 additions & 2 deletions backend/imagemanager.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"os"
"path"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
Expand Down Expand Up @@ -160,6 +161,15 @@ func (i *ImageManager) GetCoverArtUrl(coverID string) (string, error) {
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.
func (i *ImageManager) GetCachedArtistImage(artistID string) (image.Image, bool) {
return i.loadLocalImage(i.filePathForArtistImage(artistID))
Expand Down Expand Up @@ -306,11 +316,11 @@ func (i *ImageManager) checkRefreshLocalCover(stat os.FileInfo, coverID 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 {
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 {
Expand Down Expand Up @@ -391,3 +401,9 @@ func (im *ImageManager) pruneOnDiskCache() {
}
im.filesWrittenSinceLastPrune = false
}

var illegalFilename = regexp.MustCompile("[" + regexp.QuoteMeta("`~!@#$%^&*+={}|/\\:;\"'<>?") + "]")

func sanitizeFileName(s string) string {
return illegalFilename.ReplaceAllString(s, "_")
}
Loading

0 comments on commit 1ace22c

Please sign in to comment.