diff --git a/.gitignore b/.gitignore index fc6e8d8..b087f25 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/backend/app.go b/backend/app.go index fab167f..bbf421d 100644 --- a/backend/app.go +++ b/backend/app.go @@ -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 { diff --git a/backend/mpmedia.go b/backend/mpmedia.go new file mode 100644 index 0000000..385a113 --- /dev/null +++ b/backend/mpmedia.go @@ -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) +} diff --git a/backend/mpmedia_mac.go b/backend/mpmedia_mac.go new file mode 100644 index 0000000..7855ed2 --- /dev/null +++ b/backend/mpmedia_mac.go @@ -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() +} diff --git a/backend/mpmedia_other.go b/backend/mpmedia_other.go new file mode 100644 index 0000000..1c0c275 --- /dev/null +++ b/backend/mpmedia_other.go @@ -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 +} diff --git a/backend/mpmediabridge.h b/backend/mpmediabridge.h new file mode 100644 index 0000000..ece42e0 --- /dev/null +++ b/backend/mpmediabridge.h @@ -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 +#include + +/** +* 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(); diff --git a/backend/mpmediabridge.m b/backend/mpmediabridge.m new file mode 100644 index 0000000..6cb2a41 --- /dev/null +++ b/backend/mpmediabridge.m @@ -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; +} diff --git a/player/player.go b/player/player.go index d10e936..b33c9c3 100644 --- a/player/player.go +++ b/player/player.go @@ -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.