Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions app/components/player/nowplaying/nowplaying.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// Package nowplaying integrates Scanline with the macOS Control Center
// "Now Playing" widget and MPRemoteCommandCenter for hardware media keys.
//
// On non-Darwin builds every exported function is a no-op (see the build-tag
// shadowed file). On Darwin, the implementation lives in
// nowplaying_darwin.{go,m} and bridges to MediaPlayer.framework via cgo.
package nowplaying

// State enumerates the playback states we report to the OS.
type State int

const (
StatePlaying State = iota
StatePaused
StateStopped
)

// Info is the per-session metadata we publish to MPNowPlayingInfoCenter.
// Title is required; Artist / AlbumTitle are optional and typically only set
// for episodes (show name and season label respectively). Duration is in
// microseconds to match the player core's internal clock.
type Info struct {
Title string
Artist string
AlbumTitle string
DurationUs int64
}

// Handlers receives commands from MPRemoteCommandCenter (media keys, the
// Control Center widget, Bluetooth remotes, AirPods clicker). All handlers
// are invoked on the GTK main thread.
//
// A nil handler disables the corresponding command on the OS side. Handlers
// taking a seconds argument receive the user-configured skip interval (we
// hint 15s by default). SeekTo receives the target position in microseconds.
type Handlers struct {
PlayPause func()
Play func()
Pause func()
Next func()
Previous func()
SkipFwd func(seconds float64)
SkipBack func(seconds float64)
SeekTo func(positionUs int64)
Stop func()
}
177 changes: 177 additions & 0 deletions app/components/player/nowplaying/nowplaying_darwin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
//go:build darwin

package nowplaying

/*
#cgo darwin CFLAGS: -fobjc-arc -fmodules
#cgo darwin LDFLAGS: -framework MediaPlayer -framework Foundation -framework AppKit

#include <stdint.h>
#include <stdbool.h>
#include <stdlib.h>

void scanline_np_init(void);
void scanline_np_set_metadata(const char *title, const char *artist,
const char *album, double durationSec);
void scanline_np_set_state(int state);
void scanline_np_set_position(double positionSec);
void scanline_np_set_artwork(const void *data, int len);
void scanline_np_set_handler_enabled(int handlerID, bool enabled);
void scanline_np_clear(void);
*/
import "C"

import (
"sync"
"unsafe"

"codeberg.org/dergs/tonearm/pkg/schwifty"
)

// Command IDs — must stay in sync with CMD_* defines in nowplaying_darwin.m.
const (
cmdPlayPause = 0
cmdPlay = 1
cmdPause = 2
cmdNext = 3
cmdPrev = 4
cmdSkipFwd = 5
cmdSkipBack = 6
cmdSeekTo = 7
cmdStop = 8
)

var (
mu sync.Mutex
current *Handlers
initOnce sync.Once
)

//export scanlineNpDispatch
func scanlineNpDispatch(handlerID C.int, doubleArg C.double) {
id := int(handlerID)
arg := float64(doubleArg)
// MediaPlayer.framework calls us on the AppKit main runloop, but the
// player handlers call into GStreamer / GTK so we hop onto the GLib main
// loop. Re-check current under the lock inside the hop because the
// session can rotate between the OS callback and the idle-tick.
schwifty.OnMainThreadOncePure(func() {
mu.Lock()
h := current
mu.Unlock()
if h == nil {
return
}
switch id {
case cmdPlayPause:
if h.PlayPause != nil {
h.PlayPause()
}
case cmdPlay:
if h.Play != nil {
h.Play()
}
case cmdPause:
if h.Pause != nil {
h.Pause()
}
case cmdNext:
if h.Next != nil {
h.Next()
}
case cmdPrev:
if h.Previous != nil {
h.Previous()
}
case cmdSkipFwd:
if h.SkipFwd != nil {
h.SkipFwd(arg)
}
case cmdSkipBack:
if h.SkipBack != nil {
h.SkipBack(arg)
}
case cmdSeekTo:
if h.SeekTo != nil {
h.SeekTo(int64(arg * 1e6))
}
case cmdStop:
if h.Stop != nil {
h.Stop()
}
}
})
}

// Configure registers the active player session. Idempotent — later calls
// replace the current session. The OS-side target blocks are installed once
// and dispatch through the package-global current pointer.
func Configure(info Info, h Handlers) {
initOnce.Do(func() {
C.scanline_np_init()
})
mu.Lock()
current = &h
mu.Unlock()
pushMetadata(info)
C.scanline_np_set_handler_enabled(C.int(cmdPlayPause), C.bool(h.PlayPause != nil))
C.scanline_np_set_handler_enabled(C.int(cmdPlay), C.bool(h.Play != nil))
C.scanline_np_set_handler_enabled(C.int(cmdPause), C.bool(h.Pause != nil))
C.scanline_np_set_handler_enabled(C.int(cmdNext), C.bool(h.Next != nil))
C.scanline_np_set_handler_enabled(C.int(cmdPrev), C.bool(h.Previous != nil))
C.scanline_np_set_handler_enabled(C.int(cmdSkipFwd), C.bool(h.SkipFwd != nil))
C.scanline_np_set_handler_enabled(C.int(cmdSkipBack), C.bool(h.SkipBack != nil))
C.scanline_np_set_handler_enabled(C.int(cmdSeekTo), C.bool(h.SeekTo != nil))
C.scanline_np_set_handler_enabled(C.int(cmdStop), C.bool(h.Stop != nil))
}

// SetTextMetadata refreshes the title / artist / album / kind / duration
// fields without touching state, position, or artwork. Used to apply
// late-arriving show + season titles after the async metadata fetch lands.
func SetTextMetadata(info Info) {
pushMetadata(info)
}

func pushMetadata(info Info) {
cTitle := C.CString(info.Title)
defer C.free(unsafe.Pointer(cTitle))
cArtist := C.CString(info.Artist)
defer C.free(unsafe.Pointer(cArtist))
cAlbum := C.CString(info.AlbumTitle)
defer C.free(unsafe.Pointer(cAlbum))
C.scanline_np_set_metadata(cTitle, cArtist, cAlbum,
C.double(info.DurationUs)/1e6)
}

// SetState publishes the current playback state. Called from the player's
// OnStateChange callback.
func SetState(state State) {
C.scanline_np_set_state(C.int(state))
}

// SetPosition publishes the elapsed playback time. Called from the existing
// 500ms progress ticker. Does not touch the playback rate — that's owned by
// SetState — so a paused tick won't accidentally set rate=1.0.
func SetPosition(positionUs int64) {
C.scanline_np_set_position(C.double(positionUs) / 1e6)
}

// SetArtwork attaches cover-art bytes (PNG or JPEG). Pass nil to leave the
// existing artwork in place. Decoded into MPMediaItemArtwork via NSImage on
// the ObjC side.
func SetArtwork(data []byte) {
if len(data) == 0 {
return
}
C.scanline_np_set_artwork(unsafe.Pointer(&data[0]), C.int(len(data)))
}

// Clear tears down the published Now Playing info and disables every remote
// command. Safe to call multiple times. Called from the player's cleanup
// path so the Control Center widget vanishes the moment the player closes.
func Clear() {
mu.Lock()
current = nil
mu.Unlock()
C.scanline_np_clear()
}
166 changes: 166 additions & 0 deletions app/components/player/nowplaying/nowplaying_darwin.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
//go:build darwin

#import <Foundation/Foundation.h>
#import <AppKit/AppKit.h>
#import <MediaPlayer/MediaPlayer.h>
#include "_cgo_export.h"

// Command IDs — must stay in sync with cmd* iota in nowplaying_darwin.go.
#define CMD_PLAY_PAUSE 0
#define CMD_PLAY 1
#define CMD_PAUSE 2
#define CMD_NEXT 3
#define CMD_PREV 4
#define CMD_SKIP_FWD 5
#define CMD_SKIP_BACK 6
#define CMD_SEEK_TO 7
#define CMD_STOP 8

// sInfo accumulates the dictionary we publish to nowPlayingInfo. We mutate
// it in place across metadata / state / position / artwork updates, then
// re-assign nowPlayingInfo to push the new snapshot to the framework.
static NSMutableDictionary<NSString *, id> *sInfo = nil;

static MPNowPlayingInfoCenter *infoCenter(void) {
return MPNowPlayingInfoCenter.defaultCenter;
}

// Blocks capture only the primitive cmdID. Targets persist for the process
// lifetime; per-session state lives Go-side behind `current` in
// nowplaying_darwin.go.
static void addCommandTarget(MPRemoteCommand *cmd, int cmdID) {
[cmd addTargetWithHandler:^MPRemoteCommandHandlerStatus(MPRemoteCommandEvent *event) {
double arg = 0.0;
if ([event isKindOfClass:[MPSkipIntervalCommandEvent class]]) {
arg = ((MPSkipIntervalCommandEvent *)event).interval;
} else if ([event isKindOfClass:[MPChangePlaybackPositionCommandEvent class]]) {
arg = ((MPChangePlaybackPositionCommandEvent *)event).positionTime;
}
scanlineNpDispatch(cmdID, arg);
return MPRemoteCommandHandlerStatusSuccess;
}];
}

void scanline_np_init(void) {
sInfo = [NSMutableDictionary dictionary];
MPRemoteCommandCenter *cc = MPRemoteCommandCenter.sharedCommandCenter;

cc.skipForwardCommand.preferredIntervals = @[@15];
cc.skipBackwardCommand.preferredIntervals = @[@15];

addCommandTarget(cc.togglePlayPauseCommand, CMD_PLAY_PAUSE);
addCommandTarget(cc.playCommand, CMD_PLAY);
addCommandTarget(cc.pauseCommand, CMD_PAUSE);
addCommandTarget(cc.nextTrackCommand, CMD_NEXT);
addCommandTarget(cc.previousTrackCommand, CMD_PREV);
addCommandTarget(cc.skipForwardCommand, CMD_SKIP_FWD);
addCommandTarget(cc.skipBackwardCommand, CMD_SKIP_BACK);
addCommandTarget(cc.changePlaybackPositionCommand, CMD_SEEK_TO);
addCommandTarget(cc.stopCommand, CMD_STOP);
}

void scanline_np_set_metadata(const char *title, const char *artist,
const char *album, double durSec) {
@autoreleasepool {
if (title && *title) {
sInfo[MPMediaItemPropertyTitle] = [NSString stringWithUTF8String:title];
}
if (artist && *artist) {
sInfo[MPMediaItemPropertyArtist] = [NSString stringWithUTF8String:artist];
} else {
[sInfo removeObjectForKey:MPMediaItemPropertyArtist];
}
if (album && *album) {
sInfo[MPMediaItemPropertyAlbumTitle] = [NSString stringWithUTF8String:album];
} else {
[sInfo removeObjectForKey:MPMediaItemPropertyAlbumTitle];
}
if (durSec > 0) {
sInfo[MPMediaItemPropertyPlaybackDuration] = @(durSec);
}
sInfo[MPNowPlayingInfoPropertyMediaType] = @(MPNowPlayingInfoMediaTypeVideo);
infoCenter().nowPlayingInfo = sInfo;
}
}

void scanline_np_set_state(int state) {
MPNowPlayingPlaybackState s = MPNowPlayingPlaybackStateUnknown;
double rate = 0.0;
switch (state) {
case 0: s = MPNowPlayingPlaybackStatePlaying; rate = 1.0; break;
case 1: s = MPNowPlayingPlaybackStatePaused; rate = 0.0; break;
case 2: s = MPNowPlayingPlaybackStateStopped; rate = 0.0; break;
}
infoCenter().playbackState = s;
if (sInfo) {
sInfo[MPNowPlayingInfoPropertyPlaybackRate] = @(rate);
infoCenter().nowPlayingInfo = sInfo;
}
}

void scanline_np_set_position(double posSec) {
// Rate is owned by scanline_np_set_state — leaving it untouched here
// keeps the OS-side state coherent when the 500ms ticker fires after a
// pause (otherwise a stale rate=1.0 would let Control Center extrapolate
// time forward despite the widget showing a paused playback state).
if (sInfo) {
sInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = @(posSec);
infoCenter().nowPlayingInfo = sInfo;
}
}

void scanline_np_set_artwork(const void *bytes, int len) {
@autoreleasepool {
NSData *data = [NSData dataWithBytes:bytes length:len];
NSImage *img = [[NSImage alloc] initWithData:data];
if (!img) return;
// ARC keeps `img` alive via the requestHandler block's capture;
// MPMediaItemArtwork retains the block; sInfo retains the artwork;
// setting nowPlayingInfo = nil in scanline_np_clear breaks the
// chain and releases everything.
MPMediaItemArtwork *art = [[MPMediaItemArtwork alloc]
initWithBoundsSize:img.size
requestHandler:^NSImage *(CGSize size) {
return img;
}];
if (sInfo) {
sInfo[MPMediaItemPropertyArtwork] = art;
infoCenter().nowPlayingInfo = sInfo;
}
}
}

void scanline_np_set_handler_enabled(int handlerID, bool enabled) {
MPRemoteCommandCenter *cc = MPRemoteCommandCenter.sharedCommandCenter;
MPRemoteCommand *cmd = nil;
switch (handlerID) {
case CMD_PLAY_PAUSE: cmd = cc.togglePlayPauseCommand; break;
case CMD_PLAY: cmd = cc.playCommand; break;
case CMD_PAUSE: cmd = cc.pauseCommand; break;
case CMD_NEXT: cmd = cc.nextTrackCommand; break;
case CMD_PREV: cmd = cc.previousTrackCommand; break;
case CMD_SKIP_FWD: cmd = cc.skipForwardCommand; break;
case CMD_SKIP_BACK: cmd = cc.skipBackwardCommand; break;
case CMD_SEEK_TO: cmd = cc.changePlaybackPositionCommand; break;
case CMD_STOP: cmd = cc.stopCommand; break;
}
if (cmd) cmd.enabled = enabled;
}

void scanline_np_clear(void) {
infoCenter().nowPlayingInfo = nil;
infoCenter().playbackState = MPNowPlayingPlaybackStateStopped;
[sInfo removeAllObjects];
// Disable every command — targets stay installed so the next Configure
// can re-enable cheaply without re-registering blocks.
MPRemoteCommandCenter *cc = MPRemoteCommandCenter.sharedCommandCenter;
cc.playCommand.enabled = NO;
cc.pauseCommand.enabled = NO;
cc.togglePlayPauseCommand.enabled = NO;
cc.nextTrackCommand.enabled = NO;
cc.previousTrackCommand.enabled = NO;
cc.skipForwardCommand.enabled = NO;
cc.skipBackwardCommand.enabled = NO;
cc.changePlaybackPositionCommand.enabled = NO;
cc.stopCommand.enabled = NO;
}
Loading