Merge pull request #258 from zackslash/main

Adds integration with MacOS native media APIs
This commit is contained in:
Drew Weymouth
2023-10-20 08:14:44 -07:00
committed by GitHub
8 changed files with 360 additions and 8 deletions
+4
View File
@@ -12,6 +12,7 @@ supersonic
*.dll
*.so
*.dylib
Supersonic.app
# Test binary, built with `go test -c`
*.test
@@ -26,3 +27,6 @@ vendor/
go.work
# End of https://www.toptal.com/developers/gitignore/api/go
# Platform specific metadata files
.DS_Store
+13
View File
@@ -38,6 +38,7 @@ type App struct {
Player *player.Player
UpdateChecker UpdateChecker
MPRISHandler *MPRISHandler
MPMediaHandler *MPMediaHandler
// UI callbacks to be set in main
OnReactivate func()
@@ -110,6 +111,7 @@ func StartupApp(appName, displayAppName, appVersionTag, configFile, latestReleas
})
a.setupMPRIS(displayAppName)
a.setupMPMedia()
return a, nil
}
@@ -231,6 +233,17 @@ func (a *App) setupMPRIS(mprisAppName string) {
a.MPRISHandler.Start()
}
func (a *App) setupMPMedia() {
a.MPMediaHandler = NewMPMediaHandler(a.Player, a.PlaybackManager)
if a.MPMediaHandler != nil {
a.MPMediaHandler.ArtURLLookup = func(coverID string) (string, error) {
// artwork needs to be in cache before playback, so cover is retrieved in advance.
a.ImageManager.GetCoverThumbnail(coverID)
return a.ImageManager.GetCoverArtUrl(coverID)
}
}
}
func (a *App) LoginToDefaultServer(string) error {
serverCfg := a.ServerManager.GetDefaultServer()
if serverCfg == nil {
+12
View File
@@ -0,0 +1,12 @@
package backend
import (
"github.com/dweymouth/supersonic/player"
)
// MPMediaHandler is the handler for MacOS media controls and system events.
type MPMediaHandler struct {
player *player.Player
playbackManager *PlaybackManager
ArtURLLookup func(trackID string) (string, error)
}
+161
View File
@@ -0,0 +1,161 @@
//go:build darwin
package backend
/**
* This file handles implementation of MacOS native controls via the native 'MediaPlayer' framework
**/
/*
#cgo CFLAGS: -x objective-c
#cgo LDFLAGS: -framework Cocoa -framework MediaPlayer
#include "mpmediabridge.h"
*/
import (
"C"
)
import (
"log"
"unsafe"
"github.com/dweymouth/supersonic/backend/mediaprovider"
"github.com/dweymouth/supersonic/player"
)
// os_remote_command_callback is called by Objective-C when incoming OS media commands are received.
//
//export os_remote_command_callback
func os_remote_command_callback(command C.Command) {
switch command {
case C.PLAY:
mpMediaEventRecipient.OnCommandPlay()
case C.PAUSE:
mpMediaEventRecipient.OnCommandPause()
case C.STOP:
mpMediaEventRecipient.OnCommandStop()
case C.TOGGLE:
mpMediaEventRecipient.OnCommandTogglePlayPause()
case C.PREVIOUS_TRACK:
mpMediaEventRecipient.OnCommandPreviousTrack()
case C.NEXT_TRACK:
mpMediaEventRecipient.OnCommandNextTrack()
default:
log.Printf("unknown OS command received: %v", command)
}
}
// global recipient for Object-C callbacks from command center.
// This is global so that it can be called from 'os_remote_command_callback' to avoid passing Go pointers into C.
var mpMediaEventRecipient *MPMediaHandler
// NewMPMediaHandler creates a new MPMediaHandler instances and sets it as the current recipient
// for incoming system events.
func NewMPMediaHandler(player *player.Player, playbackManager *PlaybackManager) *MPMediaHandler {
mp := &MPMediaHandler{
player: player,
playbackManager: playbackManager,
}
// register remote commands and set callback target
mpMediaEventRecipient = mp
C.register_os_remote_commands()
mp.playbackManager.OnSongChange(func(track, _ *mediaprovider.Track) {
if track != nil && track.ID != "" {
var artURL string
if mp.ArtURLLookup != nil {
var err error
if artURL, err = mp.ArtURLLookup(track.CoverArtID); err != nil {
log.Printf("error fetching art url: %s", err.Error())
}
}
cTitle := C.CString(track.Name)
defer C.free(unsafe.Pointer(cTitle))
var artist string
if len(track.ArtistNames) > 0 {
artist = track.ArtistNames[0]
}
cArtist := C.CString(artist)
defer C.free(unsafe.Pointer(cArtist))
cArtURL := C.CString(artURL)
defer C.free(unsafe.Pointer(cArtURL))
C.set_os_now_playing_info(cTitle, cArtist, cArtURL)
}
})
mp.player.OnStopped(func() {
C.set_os_playback_state_stopped()
})
mp.player.OnPlaying(func() {
C.set_os_playback_state_playing()
})
mp.player.OnPaused(func() {
C.set_os_playback_state_paused()
})
return mp
}
/**
* Handle incoming OS commands.
**/
// MPMediaHandler instance received OS command 'pause'
func (mp *MPMediaHandler) OnCommandPause() {
if mp == nil || mp.player == nil {
return
}
mp.player.Pause()
}
// MPMediaHandler instance received OS command 'play'
func (mp *MPMediaHandler) OnCommandPlay() {
if mp == nil || mp.player == nil {
return
}
mp.player.Continue()
}
// MPMediaHandler instance received OS command 'stop'
func (mp *MPMediaHandler) OnCommandStop() {
if mp == nil || mp.player == nil {
return
}
mp.player.Stop()
}
// MPMediaHandler instance received OS command 'toggle'
func (mp *MPMediaHandler) OnCommandTogglePlayPause() {
if mp == nil || mp.player == nil {
return
}
if mp.player.GetStatus().State == player.Playing {
mp.OnCommandPause()
} else {
mp.OnCommandPlay()
}
}
// MPMediaHandler instance received OS command 'next track'
func (mp *MPMediaHandler) OnCommandNextTrack() {
if mp == nil || mp.player == nil {
return
}
mp.player.SeekNext()
}
// MPMediaHandler instance received OS command 'previous track'
func (mp *MPMediaHandler) OnCommandPreviousTrack() {
if mp == nil || mp.player == nil {
return
}
mp.player.SeekBackOrPrevious()
}
+10
View File
@@ -0,0 +1,10 @@
//go:build !darwin
package backend
import "github.com/dweymouth/supersonic/player"
func NewMPMediaHandler(player *player.Player, playbackManager *PlaybackManager) *MPMediaHandler {
// MPMediaHandler only supports macOS.
return nil
}
+48
View File
@@ -0,0 +1,48 @@
//go:build darwin
/**
* mpmediabridge.h
*
* This file provides a C bridge to the Objective-C framework for macOS media playback & events.
* It offers a simplified interface to interact with the MPNowPlayingInfoCenter and other
* related media playback functionalities in macOS without dealing directly with Objective-C code.
*/
#include <AppKit/AppKit.h>
#include <MediaPlayer/MediaPlayer.h>
/**
* OS remote command enumeration, accepted by 'os_remote_command_callback'.
*/
typedef enum {
PLAY,
PAUSE,
STOP,
TOGGLE,
NEXT_TRACK,
PREVIOUS_TRACK
} Command;
/**
* registers the 'os_remote_command_callback' to receive OS media commands.
*/
void register_os_remote_commands();
/**
* Go-backed callback to static function that is called when OS remote commands are received.
*/
void os_remote_command_callback(Command command);
/**
* Updates the "Now Playing" information on macOS for media playback
* using the MPNowPlayingInfoCenter API to set the metadata
* for the currently playing media in the system's "Now Playing" interface.
*/
void set_os_now_playing_info(const char *title, const char *artist, const char *coverArtFileURL);
/**
* Setter functions for updating the global playback state.
*/
void set_os_playback_state_playing();
void set_os_playback_state_paused();
void set_os_playback_state_stopped();
+85
View File
@@ -0,0 +1,85 @@
//go:build darwin
#import "mpmediabridge.h"
/**
* C bridge registering callbacks for media playback events using the native CommandCenter API.
*/
void register_os_remote_commands() {
MPRemoteCommandCenter *commandCenter = [MPRemoteCommandCenter sharedCommandCenter];
[commandCenter.playCommand addTargetWithHandler:^MPRemoteCommandHandlerStatus(MPRemoteCommandEvent * _Nonnull event) {
os_remote_command_callback(PLAY);
return MPRemoteCommandHandlerStatusSuccess;
}];
[commandCenter.pauseCommand addTargetWithHandler:^MPRemoteCommandHandlerStatus(MPRemoteCommandEvent * _Nonnull event) {
os_remote_command_callback(PAUSE);
return MPRemoteCommandHandlerStatusSuccess;
}];
[commandCenter.togglePlayPauseCommand addTargetWithHandler:^MPRemoteCommandHandlerStatus(MPRemoteCommandEvent * _Nonnull event) {
os_remote_command_callback(TOGGLE);
return MPRemoteCommandHandlerStatusSuccess;
}];
[commandCenter.stopCommand addTargetWithHandler:^MPRemoteCommandHandlerStatus(MPRemoteCommandEvent * _Nonnull event) {
os_remote_command_callback(STOP);
return MPRemoteCommandHandlerStatusSuccess;
}];
[commandCenter.nextTrackCommand addTargetWithHandler:^MPRemoteCommandHandlerStatus(MPRemoteCommandEvent * _Nonnull event) {
os_remote_command_callback(NEXT_TRACK);
return MPRemoteCommandHandlerStatusSuccess;
}];
[commandCenter.previousTrackCommand addTargetWithHandler:^MPRemoteCommandHandlerStatus(MPRemoteCommandEvent * _Nonnull event) {
os_remote_command_callback(PREVIOUS_TRACK);
return MPRemoteCommandHandlerStatusSuccess;
}];
}
/**
* C bridge setting "Now Playing" information on macOS for media playback using the native APIs.
*/
void set_os_now_playing_info(const char *title, const char *artist, const char *coverArtFileURL) {
NSString *coverArtLocationString = [NSString stringWithUTF8String:coverArtFileURL];
NSURL *coverArtURL = [NSURL URLWithString:coverArtLocationString];
NSImage *coverArtImage = [[NSImage alloc] initWithContentsOfURL:coverArtURL];
MPMediaItemArtwork *coverArt = [[MPMediaItemArtwork alloc] initWithBoundsSize:coverArtImage.size requestHandler:^NSImage * _Nonnull(CGSize size) {
return coverArtImage;
}];
MPNowPlayingInfoCenter *infoCenter = [MPNowPlayingInfoCenter defaultCenter];
NSDictionary *nowPlayingInfo = @{
MPMediaItemPropertyTitle: [NSString stringWithUTF8String:title],
MPMediaItemPropertyArtist: [NSString stringWithUTF8String:artist],
MPMediaItemPropertyArtwork: coverArt
};
infoCenter.nowPlayingInfo = nowPlayingInfo;
}
/**
* C bridge setting the OS playback state to 'playing'.
*/
void set_os_playback_state_playing() {
MPNowPlayingInfoCenter *infoCenter = [MPNowPlayingInfoCenter defaultCenter];
infoCenter.playbackState = MPNowPlayingPlaybackStatePlaying;
}
/**
* C bridge setting the OS playback state to 'paused'.
*/
void set_os_playback_state_paused() {
MPNowPlayingInfoCenter *infoCenter = [MPNowPlayingInfoCenter defaultCenter];
infoCenter.playbackState = MPNowPlayingPlaybackStatePaused;
}
/**
* C bridge setting the OS playback state to 'stopped'.
*/
void set_os_playback_state_stopped() {
MPNowPlayingInfoCenter *infoCenter = [MPNowPlayingInfoCenter defaultCenter];
infoCenter.playbackState = MPNowPlayingPlaybackStateStopped;
}
+27 -8
View File
@@ -396,21 +396,40 @@ func (p *Player) PlayPause() error {
}
return nil
case Playing:
err := p.setPaused(true)
if err == nil {
p.prePausedState = p.status.State
p.setState(Paused)
}
return err
return p.Pause()
case Paused:
return p.Continue()
default:
return errors.New("Unknown player state")
}
}
// Pause playback and update the player state
func (p *Player) Pause() error {
if p.status.State != Playing {
return nil
}
err := p.setPaused(true)
if err == nil {
p.prePausedState = p.status.State
p.setState(Paused)
}
return err
}
// Continue playback and update the player state
func (p *Player) Continue() error {
if p.status.State == Paused {
err := p.setPaused(false)
if err == nil {
p.setState(p.prePausedState)
}
return err
default:
return errors.New("Unknown player state")
} else if p.status.State == Stopped {
return p.PlayFromBeginning()
}
return nil
}
// Get the loop mode of the player.