From 1a8ef54c2057e4ba9781e4e82df468d5abf5a3a9 Mon Sep 17 00:00:00 2001 From: Cedric Lewe <0skillallluck@pm.me> Date: Sat, 9 May 2026 01:54:24 +0200 Subject: [PATCH 1/4] Add MacOS Now Playing integration --- .../player/nowplaying/nowplaying.go | 55 +++++ .../player/nowplaying/nowplaying_darwin.go | 180 ++++++++++++++++ .../player/nowplaying/nowplaying_darwin.m | 194 ++++++++++++++++++ .../player/nowplaying/nowplaying_other.go | 21 ++ app/components/player/player.go | 86 ++++++++ app/components/player/player_nowplaying.go | 100 +++++++++ flake.nix | 46 ++++- locales/scanline.pot | 82 ++++++-- utils/imageutils/fetch.go | 8 + 9 files changed, 750 insertions(+), 22 deletions(-) create mode 100644 app/components/player/nowplaying/nowplaying.go create mode 100644 app/components/player/nowplaying/nowplaying_darwin.go create mode 100644 app/components/player/nowplaying/nowplaying_darwin.m create mode 100644 app/components/player/nowplaying/nowplaying_other.go create mode 100644 app/components/player/player_nowplaying.go diff --git a/app/components/player/nowplaying/nowplaying.go b/app/components/player/nowplaying/nowplaying.go new file mode 100644 index 0000000..f56f924 --- /dev/null +++ b/app/components/player/nowplaying/nowplaying.go @@ -0,0 +1,55 @@ +// 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 +) + +// MediaKind distinguishes movies from episodes for the OS media-type field. +type MediaKind int + +const ( + KindMovie MediaKind = iota + KindEpisode +) + +// 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 + Kind MediaKind + 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() +} diff --git a/app/components/player/nowplaying/nowplaying_darwin.go b/app/components/player/nowplaying/nowplaying_darwin.go new file mode 100644 index 0000000..76984e5 --- /dev/null +++ b/app/components/player/nowplaying/nowplaying_darwin.go @@ -0,0 +1,180 @@ +//go:build darwin + +package nowplaying + +/* +#cgo darwin CFLAGS: -fobjc-arc -fmodules +#cgo darwin LDFLAGS: -framework MediaPlayer -framework Foundation -framework AppKit + +#include +#include +#include + +void scanline_np_init(void); +void scanline_np_set_metadata(const char *title, const char *artist, + const char *album, double durationSec, int kind); +void scanline_np_set_state(int state); +void scanline_np_set_position(double positionSec, double rate); +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 +) + +type session struct { + h Handlers +} + +var ( + mu sync.Mutex + current *session + 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() + s := current + mu.Unlock() + if s == nil { + return + } + switch id { + case cmdPlayPause: + if s.h.PlayPause != nil { + s.h.PlayPause() + } + case cmdPlay: + if s.h.Play != nil { + s.h.Play() + } + case cmdPause: + if s.h.Pause != nil { + s.h.Pause() + } + case cmdNext: + if s.h.Next != nil { + s.h.Next() + } + case cmdPrev: + if s.h.Previous != nil { + s.h.Previous() + } + case cmdSkipFwd: + if s.h.SkipFwd != nil { + s.h.SkipFwd(arg) + } + case cmdSkipBack: + if s.h.SkipBack != nil { + s.h.SkipBack(arg) + } + case cmdSeekTo: + if s.h.SeekTo != nil { + s.h.SeekTo(int64(arg * 1e6)) + } + case cmdStop: + if s.h.Stop != nil { + s.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 = &session{h: 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, C.int(info.Kind)) +} + +// 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. +func SetPosition(positionUs int64) { + C.scanline_np_set_position(C.double(positionUs)/1e6, 1.0) +} + +// 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() +} diff --git a/app/components/player/nowplaying/nowplaying_darwin.m b/app/components/player/nowplaying/nowplaying_darwin.m new file mode 100644 index 0000000..ee2361c --- /dev/null +++ b/app/components/player/nowplaying/nowplaying_darwin.m @@ -0,0 +1,194 @@ +//go:build darwin + +#import +#import +#import +#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 *sInfo = nil; + +static MPNowPlayingInfoCenter *infoCenter(void) { + return MPNowPlayingInfoCenter.defaultCenter; +} + +void scanline_np_init(void) { + sInfo = [NSMutableDictionary dictionary]; + MPRemoteCommandCenter *cc = MPRemoteCommandCenter.sharedCommandCenter; + + // Each block captures only the integer command ID (a primitive value + // type). The block is retained by the MPRemoteCommand and invoked on + // the AppKit main runloop; scanlineNpDispatch hops to the GLib main + // loop before invoking Go-side handlers. Targets are installed once + // for the lifetime of the process; per-session state lives Go-side + // behind the `current` pointer in nowplaying_darwin.go. + [cc.togglePlayPauseCommand addTargetWithHandler:^MPRemoteCommandHandlerStatus(MPRemoteCommandEvent *event) { + scanlineNpDispatch(CMD_PLAY_PAUSE, 0.0); + return MPRemoteCommandHandlerStatusSuccess; + }]; + [cc.playCommand addTargetWithHandler:^MPRemoteCommandHandlerStatus(MPRemoteCommandEvent *event) { + scanlineNpDispatch(CMD_PLAY, 0.0); + return MPRemoteCommandHandlerStatusSuccess; + }]; + [cc.pauseCommand addTargetWithHandler:^MPRemoteCommandHandlerStatus(MPRemoteCommandEvent *event) { + scanlineNpDispatch(CMD_PAUSE, 0.0); + return MPRemoteCommandHandlerStatusSuccess; + }]; + [cc.nextTrackCommand addTargetWithHandler:^MPRemoteCommandHandlerStatus(MPRemoteCommandEvent *event) { + scanlineNpDispatch(CMD_NEXT, 0.0); + return MPRemoteCommandHandlerStatusSuccess; + }]; + [cc.previousTrackCommand addTargetWithHandler:^MPRemoteCommandHandlerStatus(MPRemoteCommandEvent *event) { + scanlineNpDispatch(CMD_PREV, 0.0); + return MPRemoteCommandHandlerStatusSuccess; + }]; + + cc.skipForwardCommand.preferredIntervals = @[@15]; + [cc.skipForwardCommand addTargetWithHandler:^MPRemoteCommandHandlerStatus(MPRemoteCommandEvent *event) { + double interval = 15.0; + if ([event isKindOfClass:[MPSkipIntervalCommandEvent class]]) { + interval = ((MPSkipIntervalCommandEvent *)event).interval; + } + scanlineNpDispatch(CMD_SKIP_FWD, interval); + return MPRemoteCommandHandlerStatusSuccess; + }]; + cc.skipBackwardCommand.preferredIntervals = @[@15]; + [cc.skipBackwardCommand addTargetWithHandler:^MPRemoteCommandHandlerStatus(MPRemoteCommandEvent *event) { + double interval = 15.0; + if ([event isKindOfClass:[MPSkipIntervalCommandEvent class]]) { + interval = ((MPSkipIntervalCommandEvent *)event).interval; + } + scanlineNpDispatch(CMD_SKIP_BACK, interval); + return MPRemoteCommandHandlerStatusSuccess; + }]; + + [cc.changePlaybackPositionCommand addTargetWithHandler:^MPRemoteCommandHandlerStatus(MPRemoteCommandEvent *event) { + double positionTime = 0.0; + if ([event isKindOfClass:[MPChangePlaybackPositionCommandEvent class]]) { + positionTime = ((MPChangePlaybackPositionCommandEvent *)event).positionTime; + } + scanlineNpDispatch(CMD_SEEK_TO, positionTime); + return MPRemoteCommandHandlerStatusSuccess; + }]; + + [cc.stopCommand addTargetWithHandler:^MPRemoteCommandHandlerStatus(MPRemoteCommandEvent *event) { + scanlineNpDispatch(CMD_STOP, 0.0); + return MPRemoteCommandHandlerStatusSuccess; + }]; +} + +void scanline_np_set_metadata(const char *title, const char *artist, + const char *album, double durSec, int kind) { + @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, double rate) { + if (sInfo) { + sInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = @(posSec); + sInfo[MPNowPlayingInfoPropertyPlaybackRate] = @(rate); + 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; +} diff --git a/app/components/player/nowplaying/nowplaying_other.go b/app/components/player/nowplaying/nowplaying_other.go new file mode 100644 index 0000000..c53fa35 --- /dev/null +++ b/app/components/player/nowplaying/nowplaying_other.go @@ -0,0 +1,21 @@ +//go:build !darwin + +package nowplaying + +// Configure is a no-op on non-Darwin platforms. +func Configure(info Info, handlers Handlers) {} + +// SetTextMetadata is a no-op on non-Darwin platforms. +func SetTextMetadata(info Info) {} + +// SetState is a no-op on non-Darwin platforms. +func SetState(state State) {} + +// SetPosition is a no-op on non-Darwin platforms. +func SetPosition(positionUs int64) {} + +// SetArtwork is a no-op on non-Darwin platforms. +func SetArtwork(data []byte) {} + +// Clear is a no-op on non-Darwin platforms. +func Clear() {} diff --git a/app/components/player/player.go b/app/components/player/player.go index 8204427..52fe166 100644 --- a/app/components/player/player.go +++ b/app/components/player/player.go @@ -13,6 +13,7 @@ import ( "codeberg.org/puregotk/puregotk/v4/gdk" "codeberg.org/puregotk/puregotk/v4/glib" "codeberg.org/puregotk/puregotk/v4/gtk" + "github.com/0skillallluck/scanline/app/components/player/nowplaying" "github.com/0skillallluck/scanline/app/preference" "github.com/0skillallluck/scanline/app/router" "github.com/0skillallluck/scanline/app/sources" @@ -752,6 +753,7 @@ func NewPlayer(params PlayerParams) { } dur := currentDurationUs() ts := currentTimestampUs() + nowplaying.SetPosition(ts) if !seeking.Load() && progressScale != nil && dur > 0 { progressScale.SetRange(0, float64(dur)) progressScale.SetValue(float64(ts)) @@ -834,6 +836,9 @@ func NewPlayer(params PlayerParams) { if !closed.CompareAndSwap(false, true) { return false } + // Drop the Now Playing widget immediately so the user gets a clean + // state transition before the GStreamer teardown and progress reports. + nowplaying.Clear() if id := tickerID.Load(); id != 0 { glib.SourceRemove(id) tickerID.Store(0) @@ -910,6 +915,12 @@ func NewPlayer(params PlayerParams) { }, OnStateChange: func(state gst.State) { slog.Debug("player: pipeline state", "state", state.String()) + switch state { + case gst.StatePlaying: + nowplaying.SetState(nowplaying.StatePlaying) + case gst.StatePaused: + nowplaying.SetState(nowplaying.StatePaused) + } if pcore == nil || paintableAttached.Load() { return } @@ -973,6 +984,81 @@ func NewPlayer(params PlayerParams) { } } + // --- macOS Now Playing integration --- + // Publish the session to MPNowPlayingInfoCenter and wire MPRemoteCommandCenter + // commands to the existing player closures. No-op on non-Darwin builds. + { + var nextHandler func() + if playNextEpisode != nil { + nextHandler = playNextEpisode + } + nowplaying.Configure( + nowplaying.Info{ + Title: params.Title, + Kind: nowplayingKindFor(params), + DurationUs: knownDurationUs, + }, + nowplaying.Handlers{ + PlayPause: togglePlayPause, + Play: func() { + if pcore == nil { + return + } + pcore.Play() + playing.Store(true) + if playPauseBtn != nil { + playPauseBtn.SetIconName("media-playback-pause-symbolic") + } + sendProgress(sources.StatePlaying) + }, + Pause: func() { + if pcore == nil { + return + } + pcore.Pause() + playing.Store(false) + if playPauseBtn != nil { + playPauseBtn.SetIconName("media-playback-start-symbolic") + } + sendProgress(sources.StatePaused) + }, + Next: nextHandler, + Previous: func() { + // "Seek to start of current item" — confirmed scope choice + // for the initial Now Playing PR (matches Music.app for a + // queue with a single track past 3s in). + doSeek(0) + }, + SkipFwd: func(seconds float64) { + ts := currentTimestampUs() + dur := currentDurationUs() + n := ts + int64(seconds*1e6) + if dur > 0 && n > dur { + n = dur + } + doSeek(n) + }, + SkipBack: func(seconds float64) { + n := currentTimestampUs() - int64(seconds*1e6) + if n < 0 { + n = 0 + } + doSeek(n) + }, + SeekTo: func(positionUs int64) { doSeek(positionUs) }, + Stop: func() { + if closePlayer != nil { + closePlayer() + } + }, + }, + ) + // Refine show / season titles and fetch artwork off the main thread. + // Safe to fire on Linux too — the SetTextMetadata / SetArtwork calls + // inside are no-ops there, only wasting one HTTP request. + go fetchAndPushArtwork(ctx, src, params) + } + closePlayer = func() { if !cleanup() { return diff --git a/app/components/player/player_nowplaying.go b/app/components/player/player_nowplaying.go new file mode 100644 index 0000000..3cf74ac --- /dev/null +++ b/app/components/player/player_nowplaying.go @@ -0,0 +1,100 @@ +package player + +import ( + "context" + "log/slog" + + "codeberg.org/dergs/tonearm/pkg/schwifty" + "github.com/0skillallluck/scanline/app/components/player/nowplaying" + "github.com/0skillallluck/scanline/app/sources" + "github.com/0skillallluck/scanline/utils/imageutils" +) + +// nowPlayingArtworkSize is the square edge length we request from the source's +// photo transcoder. Control Center crops to a square thumbnail; 600px is a +// reasonable balance between memory and Retina sharpness. +const nowPlayingArtworkSize = 600 + +// nowplayingKindFor guesses whether the playing item is a movie or an episode +// from the params we already have at session start. The async metadata fetch +// later refines this if needed. +func nowplayingKindFor(params PlayerParams) nowplaying.MediaKind { + if params.GrandparentRatingKey != "" { + return nowplaying.KindEpisode + } + return nowplaying.KindMovie +} + +// fetchAndPushArtwork runs in a goroutine on session start. It fetches the +// full metadata (for show/season titles and the artwork URL), pulls the +// artwork bytes through the existing image cache, and hops to the GTK main +// thread to push refined text + artwork into MPNowPlayingInfoCenter. +// +// On Linux this still runs but the SetTextMetadata / SetArtwork calls are +// no-ops (see nowplaying_other.go), so the goroutine and HTTP fetch are +// wasted; we early-exit on non-Darwin builds via a build-tag wrapper — +// that lives next to this file as fetchAndPushArtwork_other.go to keep this +// file simple. +func fetchAndPushArtwork(ctx context.Context, src sources.Source, params PlayerParams) { + if ctx.Err() != nil { + return + } + meta, err := src.GetMetadata(ctx, params.RatingKey) + if err != nil || meta == nil { + slog.Warn("nowplaying: metadata fetch failed", "error", err) + return + } + if ctx.Err() != nil { + return + } + + info := nowplaying.Info{ + Title: params.Title, + Kind: nowplayingKindFor(params), + DurationUs: int64(meta.Duration) * 1000, + } + if meta.Type == "episode" { + info.Kind = nowplaying.KindEpisode + info.Artist = meta.GrandparentTitle + info.AlbumTitle = meta.ParentTitle + } + schwifty.OnMainThreadOncePure(func() { + if ctx.Err() != nil { + return + } + nowplaying.SetTextMetadata(info) + }) + + artURL := bestArtURL(meta) + if artURL == "" { + return + } + transcoded := src.PhotoTranscodeURL(artURL, nowPlayingArtworkSize, nowPlayingArtworkSize) + data, err := imageutils.Fetch(transcoded) + if err != nil { + slog.Warn("nowplaying: artwork fetch failed", "error", err) + return + } + if ctx.Err() != nil { + return + } + schwifty.OnMainThreadOncePure(func() { + if ctx.Err() != nil { + return + } + nowplaying.SetArtwork(data) + }) +} + +// bestArtURL picks the most square-friendly artwork URL for Now Playing. +// Episodes prefer the show poster (GrandparentThumb); movies prefer Thumb; +// otherwise we fall back to the generic ArtURL helper. +func bestArtURL(meta *sources.Metadata) string { + if meta.Type == "episode" && meta.GrandparentThumb != "" { + return meta.GrandparentThumb + } + if meta.Thumb != "" { + return meta.Thumb + } + return sources.ArtURL(meta) +} diff --git a/flake.nix b/flake.nix index ad92b20..0fe5999 100644 --- a/flake.nix +++ b/flake.nix @@ -97,7 +97,11 @@ appstream ]; gstPlugins = with pkgs.gst_all_1; [ - gstreamer + # gstreamer-core's default output is "bin" (just CLI tools); the + # plugins (libgstcoreelements.dylib — which provides 'typefind') + # and gst-plugin-scanner both live in the "out" output. Without + # this, the bundle ships without typefind and all playback fails. + gstreamer.out gst-plugins-base gst-plugins-good gst-plugins-bad @@ -241,6 +245,45 @@ fi done + # 3b. gst-plugin-scanner: GStreamer forks this helper at startup to + # enumerate plugins and build the registry. Without it, the bundle + # falls back to a degraded mode that fails to register essential + # elements like 'typefind', which breaks all playback. Bundle it in + # Contents/MacOS/ so it shares the main binary's + # @executable_path/../Frameworks rpath, and point GStreamer at it + # via GST_PLUGIN_SCANNER in the wrapper. + for pkg in $gstPlugins; do + src_scanner="$pkg/libexec/gstreamer-1.0/gst-plugin-scanner" + [ -f "$src_scanner" ] || continue + cp -L "$src_scanner" "$APP/Contents/MacOS/gst-plugin-scanner" + chmod +w "$APP/Contents/MacOS/gst-plugin-scanner" + install_name_tool -add_rpath "@executable_path/../Frameworks" \ + "$APP/Contents/MacOS/gst-plugin-scanner" 2>/dev/null || true + otool -l "$APP/Contents/MacOS/gst-plugin-scanner" \ + | awk '/cmd LC_RPATH/{f=1} f && /path /{print $2; f=0}' \ + | while IFS= read -r rp; do + case "$rp" in + /nix/store/*) + install_name_tool -delete_rpath "$rp" \ + "$APP/Contents/MacOS/gst-plugin-scanner" 2>/dev/null || true + ;; + esac + done + while IFS= read -r dep; do + [ -z "$dep" ] && continue + case "$dep" in + /nix/store/*) + dep_base=$(dest_basename_for "$dep") + copy_and_fix_dylib "$dep" "$APP/Contents/Frameworks" + install_name_tool -change "$dep" "@rpath/$dep_base" \ + "$APP/Contents/MacOS/gst-plugin-scanner" 2>/dev/null || true + ;; + esac + done < <(otool -L "$APP/Contents/MacOS/gst-plugin-scanner" | tail -n +2 | awk 'NF>0 {print $1}') + chmod +x "$APP/Contents/MacOS/gst-plugin-scanner" + break + done + # 4. glib-networking GIO modules (TLS for libsoup). Note: glib uses # .so extensions for loadable modules even on Darwin. if [ -d "$glibNetworking/lib/gio/modules" ]; then @@ -359,6 +402,7 @@ export PUREGOTK_LIB_FOLDER="$APP_FW" export GDK_PIXBUF_MODULE_FILE="$PIXBUF_CACHE" export GST_PLUGIN_PATH="$APP_FW/gstreamer-1.0" + export GST_PLUGIN_SCANNER="$APP_DIR/MacOS/gst-plugin-scanner" export GIO_EXTRA_MODULES="$APP_FW/gio/modules" export XDG_DATA_DIRS="$APP_RES/share" export GSETTINGS_SCHEMA_DIR="$APP_RES/glib-2.0/schemas" diff --git a/locales/scanline.pot b/locales/scanline.pot index 264cafd..1347fb0 100644 --- a/locales/scanline.pot +++ b/locales/scanline.pot @@ -6,9 +6,9 @@ #, fuzzy msgid "" msgstr "" -"Project-Id-Version: v0.4.0-14-g2ac46d3\n" +"Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-04-27 17:17+0200\n" +"POT-Creation-Date: 2026-05-09 01:45+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -29,7 +29,7 @@ msgid_plural "%d Seasons" msgstr[0] "" msgstr[1] "" -#: app/windows/main_content.go:49 +#: app/windows/menubar_darwin.go:41 msgid "About Scanline" msgstr "" @@ -37,11 +37,11 @@ msgstr "" msgid "Add Account" msgstr "" -#: app/windows/main.go:99 +#: app/windows/main.go:100 msgid "Add Sources" msgstr "" -#: app/windows/main.go:96 +#: app/windows/main.go:97 msgid "Add your Plex media sources to get started." msgstr "" @@ -90,14 +90,18 @@ msgstr "" msgid "Automatically skip the intro when it begins playing." msgstr "" -#: app/dialogs/shortcuts/shortcuts.go:20 +#: app/dialogs/shortcuts/shortcuts.go:26 msgid "Back" msgstr "" -#: app/dialogs/shortcuts/shortcuts.go:18 +#: app/dialogs/shortcuts/shortcuts.go:24 msgid "Basic Shortcuts" msgstr "" +#: app/windows/menubar_darwin.go:32 +msgid "Bring All to Front" +msgstr "" + #: app/dialogs/preferences/performance.go:28 msgid "Cache Images" msgstr "" @@ -138,10 +142,14 @@ msgstr "" msgid "Clear Cache" msgstr "" -#: app/dialogs/shortcuts/shortcuts.go:13 +#: app/dialogs/shortcuts/shortcuts.go:12 msgid "Close" msgstr "" +#: app/windows/menubar_darwin.go:22 +msgid "Close Window" +msgstr "" + #: app/dialogs/preferences/general.go:24 msgid "Configure the behaviour of Scanline when navigating between pages." msgstr "" @@ -170,6 +178,10 @@ msgstr "" msgid "E.g. The Matrix" msgstr "" +#: app/windows/menubar_darwin.go:27 +msgid "Edit" +msgstr "" + #: app/dialogs/preferences/experimental.go:25 msgid "Enable EMBY support" msgstr "" @@ -214,7 +226,7 @@ msgstr "" msgid "Failed to discover servers" msgstr "" -#: app/pages/episode.go:118 +#: app/pages/episode.go:108 msgid "Failed to update watch status" msgstr "" @@ -222,6 +234,14 @@ msgstr "" msgid "Features" msgstr "" +#: app/windows/menubar_darwin.go:23 +msgid "File" +msgstr "" + +#: app/windows/menubar_darwin.go:26 +msgid "Find" +msgstr "" + #: app/dialogs/preferences/general.go:25 msgid "General" msgstr "" @@ -230,6 +250,10 @@ msgstr "" msgid "Genre" msgstr "" +#: app/windows/menubar_darwin.go:43 +msgid "Help" +msgstr "" + #: app/dialogs/preferences/general.go:17 msgid "History Length" msgstr "" @@ -246,7 +270,7 @@ msgstr "" msgid "Internal Error" msgstr "" -#: app/dialogs/shortcuts/shortcuts.go:16 +#: app/dialogs/shortcuts/shortcuts.go:19 msgid "Keyboard Shortcuts" msgstr "" @@ -258,7 +282,7 @@ msgstr "" msgid "Library" msgstr "" -#: app/dialogs/shortcuts/shortcuts.go:15 +#: app/dialogs/shortcuts/shortcuts_other.go:12 msgid "Main Menu" msgstr "" @@ -286,11 +310,11 @@ msgstr "" msgid "Mark this movie as watched" msgstr "" -#: app/pages/episode.go:131 +#: app/pages/episode.go:122 msgid "Marked as unwatched" msgstr "" -#: app/pages/episode.go:139 +#: app/pages/episode.go:130 msgid "Marked as watched" msgstr "" @@ -298,19 +322,23 @@ msgstr "" msgid "Maximum history length before dropping old entries." msgstr "" +#: app/windows/menubar_darwin.go:30 +msgid "Minimize" +msgstr "" + #: app/pages/movie.go:30 msgid "Movie" msgstr "" -#: app/windows/main_content.go:236 +#: app/windows/main_content.go:224 msgid "Navigate Back" msgstr "" -#: app/windows/main_content.go:74 +#: app/windows/main_content.go:64 msgid "Navigate to Home" msgstr "" -#: app/dialogs/shortcuts/shortcuts.go:22 +#: app/dialogs/shortcuts/shortcuts.go:28 msgid "Navigation" msgstr "" @@ -379,7 +407,7 @@ msgstr "" msgid "Player" msgstr "" -#: app/dialogs/shortcuts/shortcuts.go:17 +#: app/dialogs/shortcuts/shortcuts.go:20 msgid "Preferences" msgstr "" @@ -387,7 +415,7 @@ msgstr "" msgid "Quality" msgstr "" -#: app/dialogs/shortcuts/shortcuts.go:14 +#: app/dialogs/shortcuts/shortcuts.go:13 msgid "Quit" msgstr "" @@ -415,7 +443,7 @@ msgstr "" msgid "Scroll to the right" msgstr "" -#: app/dialogs/shortcuts/shortcuts.go:21 +#: app/dialogs/shortcuts/shortcuts.go:27 msgid "Search" msgstr "" @@ -423,7 +451,7 @@ msgstr "" msgid "Season" msgstr "" -#: app/pages/show.go:116 +#: app/pages/show.go:106 msgid "Seasons" msgstr "" @@ -443,6 +471,10 @@ msgstr "" msgid "Select Sources" msgstr "" +#: app/windows/menubar_darwin.go:21 +msgid "Select Sources…" +msgstr "" + #: app/dialogs/preferences/experimental.go:14 msgid "Show the Watchlist tab in the navigation bar." msgstr "" @@ -535,10 +567,14 @@ msgstr "" msgid "Watchlist" msgstr "" -#: app/windows/main.go:95 +#: app/windows/main.go:96 msgid "Welcome to Scanline" msgstr "" +#: app/windows/menubar_darwin.go:36 +msgid "Window" +msgstr "" + #: app/pages/search.go:22 msgid "Year (Newest)" msgstr "" @@ -564,6 +600,10 @@ msgstr "" msgid "Your watchlist is empty." msgstr "" +#: app/windows/menubar_darwin.go:31 +msgid "Zoom" +msgstr "" + #: app/dialogs/about/about.go:124 msgid "translator-credits" msgstr "" diff --git a/utils/imageutils/fetch.go b/utils/imageutils/fetch.go index 37caf7a..fd4b4c8 100644 --- a/utils/imageutils/fetch.go +++ b/utils/imageutils/fetch.go @@ -9,6 +9,14 @@ import ( "github.com/0skillallluck/scanline/utils/cacheutils" ) +// Fetch returns the bytes for an image URL, layered through the disk + +// in-memory cache when image caching is enabled. Use this when you need the +// raw bytes (e.g. to hand to a non-GTK consumer like macOS MediaPlayer). +// Loader-style helpers in this package go through the same path internally. +func Fetch(url string) ([]byte, error) { + return fetch(url) +} + func fetch(url string) ([]byte, error) { if preference.Performance().ShouldCacheImages() { if data, ok := cacheutils.Get(url, cacheutils.Layered, 0); ok { From f0a58e9db176e2df632826aabec1dc1d96679871 Mon Sep 17 00:00:00 2001 From: Cedric Lewe <0skillallluck@pm.me> Date: Sat, 9 May 2026 02:08:01 +0200 Subject: [PATCH 2/4] Fix Now Playing rate clobber and stale comment --- app/components/player/nowplaying/nowplaying_darwin.go | 7 ++++--- app/components/player/nowplaying/nowplaying_darwin.m | 7 +++++-- app/components/player/player_nowplaying.go | 7 ++----- 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/app/components/player/nowplaying/nowplaying_darwin.go b/app/components/player/nowplaying/nowplaying_darwin.go index 76984e5..97f8e19 100644 --- a/app/components/player/nowplaying/nowplaying_darwin.go +++ b/app/components/player/nowplaying/nowplaying_darwin.go @@ -14,7 +14,7 @@ void scanline_np_init(void); void scanline_np_set_metadata(const char *title, const char *artist, const char *album, double durationSec, int kind); void scanline_np_set_state(int state); -void scanline_np_set_position(double positionSec, double rate); +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); @@ -154,9 +154,10 @@ func SetState(state State) { } // SetPosition publishes the elapsed playback time. Called from the existing -// 500ms progress ticker. +// 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, 1.0) + C.scanline_np_set_position(C.double(positionUs) / 1e6) } // SetArtwork attaches cover-art bytes (PNG or JPEG). Pass nil to leave the diff --git a/app/components/player/nowplaying/nowplaying_darwin.m b/app/components/player/nowplaying/nowplaying_darwin.m index ee2361c..c8edc47 100644 --- a/app/components/player/nowplaying/nowplaying_darwin.m +++ b/app/components/player/nowplaying/nowplaying_darwin.m @@ -129,10 +129,13 @@ void scanline_np_set_state(int state) { } } -void scanline_np_set_position(double posSec, double rate) { +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); - sInfo[MPNowPlayingInfoPropertyPlaybackRate] = @(rate); infoCenter().nowPlayingInfo = sInfo; } } diff --git a/app/components/player/player_nowplaying.go b/app/components/player/player_nowplaying.go index 3cf74ac..4df61c9 100644 --- a/app/components/player/player_nowplaying.go +++ b/app/components/player/player_nowplaying.go @@ -30,11 +30,8 @@ func nowplayingKindFor(params PlayerParams) nowplaying.MediaKind { // artwork bytes through the existing image cache, and hops to the GTK main // thread to push refined text + artwork into MPNowPlayingInfoCenter. // -// On Linux this still runs but the SetTextMetadata / SetArtwork calls are -// no-ops (see nowplaying_other.go), so the goroutine and HTTP fetch are -// wasted; we early-exit on non-Darwin builds via a build-tag wrapper — -// that lives next to this file as fetchAndPushArtwork_other.go to keep this -// file simple. +// On non-Darwin platforms the SetTextMetadata / SetArtwork calls are no-ops, +// so the metadata + image HTTP fetches are wasted but harmless. func fetchAndPushArtwork(ctx context.Context, src sources.Source, params PlayerParams) { if ctx.Err() != nil { return From f25e8bbbce40d3a78d6b46c7c660717015bb3958 Mon Sep 17 00:00:00 2001 From: Cedric Lewe <0skillallluck@pm.me> Date: Sat, 9 May 2026 02:14:38 +0200 Subject: [PATCH 3/4] Simplify Now Playing wiring --- .../player/nowplaying/nowplaying_darwin.go | 49 +++++------ .../player/nowplaying/nowplaying_darwin.m | 81 ++++++----------- .../player/nowplaying/nowplaying_other.go | 22 ++--- app/components/player/player.go | 6 +- app/components/player/player_nowplaying.go | 86 +------------------ .../player/player_nowplaying_darwin.go | 86 +++++++++++++++++++ .../player/player_nowplaying_other.go | 13 +++ 7 files changed, 159 insertions(+), 184 deletions(-) create mode 100644 app/components/player/player_nowplaying_darwin.go create mode 100644 app/components/player/player_nowplaying_other.go diff --git a/app/components/player/nowplaying/nowplaying_darwin.go b/app/components/player/nowplaying/nowplaying_darwin.go index 97f8e19..e09a73e 100644 --- a/app/components/player/nowplaying/nowplaying_darwin.go +++ b/app/components/player/nowplaying/nowplaying_darwin.go @@ -41,13 +41,9 @@ const ( cmdStop = 8 ) -type session struct { - h Handlers -} - var ( mu sync.Mutex - current *session + current *Handlers initOnce sync.Once ) @@ -61,47 +57,47 @@ func scanlineNpDispatch(handlerID C.int, doubleArg C.double) { // session can rotate between the OS callback and the idle-tick. schwifty.OnMainThreadOncePure(func() { mu.Lock() - s := current + h := current mu.Unlock() - if s == nil { + if h == nil { return } switch id { case cmdPlayPause: - if s.h.PlayPause != nil { - s.h.PlayPause() + if h.PlayPause != nil { + h.PlayPause() } case cmdPlay: - if s.h.Play != nil { - s.h.Play() + if h.Play != nil { + h.Play() } case cmdPause: - if s.h.Pause != nil { - s.h.Pause() + if h.Pause != nil { + h.Pause() } case cmdNext: - if s.h.Next != nil { - s.h.Next() + if h.Next != nil { + h.Next() } case cmdPrev: - if s.h.Previous != nil { - s.h.Previous() + if h.Previous != nil { + h.Previous() } case cmdSkipFwd: - if s.h.SkipFwd != nil { - s.h.SkipFwd(arg) + if h.SkipFwd != nil { + h.SkipFwd(arg) } case cmdSkipBack: - if s.h.SkipBack != nil { - s.h.SkipBack(arg) + if h.SkipBack != nil { + h.SkipBack(arg) } case cmdSeekTo: - if s.h.SeekTo != nil { - s.h.SeekTo(int64(arg * 1e6)) + if h.SeekTo != nil { + h.SeekTo(int64(arg * 1e6)) } case cmdStop: - if s.h.Stop != nil { - s.h.Stop() + if h.Stop != nil { + h.Stop() } } }) @@ -114,8 +110,9 @@ func Configure(info Info, h Handlers) { initOnce.Do(func() { C.scanline_np_init() }) + hCopy := h mu.Lock() - current = &session{h: h} + current = &hCopy mu.Unlock() pushMetadata(info) C.scanline_np_set_handler_enabled(C.int(cmdPlayPause), C.bool(h.PlayPause != nil)) diff --git a/app/components/player/nowplaying/nowplaying_darwin.m b/app/components/player/nowplaying/nowplaying_darwin.m index c8edc47..40c58d2 100644 --- a/app/components/player/nowplaying/nowplaying_darwin.m +++ b/app/components/player/nowplaying/nowplaying_darwin.m @@ -25,69 +25,38 @@ 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; - // Each block captures only the integer command ID (a primitive value - // type). The block is retained by the MPRemoteCommand and invoked on - // the AppKit main runloop; scanlineNpDispatch hops to the GLib main - // loop before invoking Go-side handlers. Targets are installed once - // for the lifetime of the process; per-session state lives Go-side - // behind the `current` pointer in nowplaying_darwin.go. - [cc.togglePlayPauseCommand addTargetWithHandler:^MPRemoteCommandHandlerStatus(MPRemoteCommandEvent *event) { - scanlineNpDispatch(CMD_PLAY_PAUSE, 0.0); - return MPRemoteCommandHandlerStatusSuccess; - }]; - [cc.playCommand addTargetWithHandler:^MPRemoteCommandHandlerStatus(MPRemoteCommandEvent *event) { - scanlineNpDispatch(CMD_PLAY, 0.0); - return MPRemoteCommandHandlerStatusSuccess; - }]; - [cc.pauseCommand addTargetWithHandler:^MPRemoteCommandHandlerStatus(MPRemoteCommandEvent *event) { - scanlineNpDispatch(CMD_PAUSE, 0.0); - return MPRemoteCommandHandlerStatusSuccess; - }]; - [cc.nextTrackCommand addTargetWithHandler:^MPRemoteCommandHandlerStatus(MPRemoteCommandEvent *event) { - scanlineNpDispatch(CMD_NEXT, 0.0); - return MPRemoteCommandHandlerStatusSuccess; - }]; - [cc.previousTrackCommand addTargetWithHandler:^MPRemoteCommandHandlerStatus(MPRemoteCommandEvent *event) { - scanlineNpDispatch(CMD_PREV, 0.0); - return MPRemoteCommandHandlerStatusSuccess; - }]; - cc.skipForwardCommand.preferredIntervals = @[@15]; - [cc.skipForwardCommand addTargetWithHandler:^MPRemoteCommandHandlerStatus(MPRemoteCommandEvent *event) { - double interval = 15.0; - if ([event isKindOfClass:[MPSkipIntervalCommandEvent class]]) { - interval = ((MPSkipIntervalCommandEvent *)event).interval; - } - scanlineNpDispatch(CMD_SKIP_FWD, interval); - return MPRemoteCommandHandlerStatusSuccess; - }]; cc.skipBackwardCommand.preferredIntervals = @[@15]; - [cc.skipBackwardCommand addTargetWithHandler:^MPRemoteCommandHandlerStatus(MPRemoteCommandEvent *event) { - double interval = 15.0; - if ([event isKindOfClass:[MPSkipIntervalCommandEvent class]]) { - interval = ((MPSkipIntervalCommandEvent *)event).interval; - } - scanlineNpDispatch(CMD_SKIP_BACK, interval); - return MPRemoteCommandHandlerStatusSuccess; - }]; - [cc.changePlaybackPositionCommand addTargetWithHandler:^MPRemoteCommandHandlerStatus(MPRemoteCommandEvent *event) { - double positionTime = 0.0; - if ([event isKindOfClass:[MPChangePlaybackPositionCommandEvent class]]) { - positionTime = ((MPChangePlaybackPositionCommandEvent *)event).positionTime; - } - scanlineNpDispatch(CMD_SEEK_TO, positionTime); - return MPRemoteCommandHandlerStatusSuccess; - }]; - - [cc.stopCommand addTargetWithHandler:^MPRemoteCommandHandlerStatus(MPRemoteCommandEvent *event) { - scanlineNpDispatch(CMD_STOP, 0.0); - return MPRemoteCommandHandlerStatusSuccess; - }]; + 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, diff --git a/app/components/player/nowplaying/nowplaying_other.go b/app/components/player/nowplaying/nowplaying_other.go index c53fa35..0eb76e4 100644 --- a/app/components/player/nowplaying/nowplaying_other.go +++ b/app/components/player/nowplaying/nowplaying_other.go @@ -1,21 +1,11 @@ //go:build !darwin +// Stubs for non-Darwin builds; every exported function is a no-op. package nowplaying -// Configure is a no-op on non-Darwin platforms. func Configure(info Info, handlers Handlers) {} - -// SetTextMetadata is a no-op on non-Darwin platforms. -func SetTextMetadata(info Info) {} - -// SetState is a no-op on non-Darwin platforms. -func SetState(state State) {} - -// SetPosition is a no-op on non-Darwin platforms. -func SetPosition(positionUs int64) {} - -// SetArtwork is a no-op on non-Darwin platforms. -func SetArtwork(data []byte) {} - -// Clear is a no-op on non-Darwin platforms. -func Clear() {} +func SetTextMetadata(info Info) {} +func SetState(state State) {} +func SetPosition(positionUs int64) {} +func SetArtwork(data []byte) {} +func Clear() {} diff --git a/app/components/player/player.go b/app/components/player/player.go index 52fe166..4709088 100644 --- a/app/components/player/player.go +++ b/app/components/player/player.go @@ -747,13 +747,17 @@ func NewPlayer(params PlayerParams) { // next-episode button. End-of-stream is delivered separately by the core // via OnEOS, so the ticker only needs to handle position/UI updates. var tickerID atomic.Uint32 + var lastNowPlayingPosUs int64 = -1 tickerCb := glib.SourceFunc(func(uintptr) bool { if pcore == nil { return true // keep polling, core not built yet } dur := currentDurationUs() ts := currentTimestampUs() - nowplaying.SetPosition(ts) + if ts != lastNowPlayingPosUs { + nowplaying.SetPosition(ts) + lastNowPlayingPosUs = ts + } if !seeking.Load() && progressScale != nil && dur > 0 { progressScale.SetRange(0, float64(dur)) progressScale.SetValue(float64(ts)) diff --git a/app/components/player/player_nowplaying.go b/app/components/player/player_nowplaying.go index 4df61c9..a767c77 100644 --- a/app/components/player/player_nowplaying.go +++ b/app/components/player/player_nowplaying.go @@ -1,19 +1,6 @@ package player -import ( - "context" - "log/slog" - - "codeberg.org/dergs/tonearm/pkg/schwifty" - "github.com/0skillallluck/scanline/app/components/player/nowplaying" - "github.com/0skillallluck/scanline/app/sources" - "github.com/0skillallluck/scanline/utils/imageutils" -) - -// nowPlayingArtworkSize is the square edge length we request from the source's -// photo transcoder. Control Center crops to a square thumbnail; 600px is a -// reasonable balance between memory and Retina sharpness. -const nowPlayingArtworkSize = 600 +import "github.com/0skillallluck/scanline/app/components/player/nowplaying" // nowplayingKindFor guesses whether the playing item is a movie or an episode // from the params we already have at session start. The async metadata fetch @@ -24,74 +11,3 @@ func nowplayingKindFor(params PlayerParams) nowplaying.MediaKind { } return nowplaying.KindMovie } - -// fetchAndPushArtwork runs in a goroutine on session start. It fetches the -// full metadata (for show/season titles and the artwork URL), pulls the -// artwork bytes through the existing image cache, and hops to the GTK main -// thread to push refined text + artwork into MPNowPlayingInfoCenter. -// -// On non-Darwin platforms the SetTextMetadata / SetArtwork calls are no-ops, -// so the metadata + image HTTP fetches are wasted but harmless. -func fetchAndPushArtwork(ctx context.Context, src sources.Source, params PlayerParams) { - if ctx.Err() != nil { - return - } - meta, err := src.GetMetadata(ctx, params.RatingKey) - if err != nil || meta == nil { - slog.Warn("nowplaying: metadata fetch failed", "error", err) - return - } - if ctx.Err() != nil { - return - } - - info := nowplaying.Info{ - Title: params.Title, - Kind: nowplayingKindFor(params), - DurationUs: int64(meta.Duration) * 1000, - } - if meta.Type == "episode" { - info.Kind = nowplaying.KindEpisode - info.Artist = meta.GrandparentTitle - info.AlbumTitle = meta.ParentTitle - } - schwifty.OnMainThreadOncePure(func() { - if ctx.Err() != nil { - return - } - nowplaying.SetTextMetadata(info) - }) - - artURL := bestArtURL(meta) - if artURL == "" { - return - } - transcoded := src.PhotoTranscodeURL(artURL, nowPlayingArtworkSize, nowPlayingArtworkSize) - data, err := imageutils.Fetch(transcoded) - if err != nil { - slog.Warn("nowplaying: artwork fetch failed", "error", err) - return - } - if ctx.Err() != nil { - return - } - schwifty.OnMainThreadOncePure(func() { - if ctx.Err() != nil { - return - } - nowplaying.SetArtwork(data) - }) -} - -// bestArtURL picks the most square-friendly artwork URL for Now Playing. -// Episodes prefer the show poster (GrandparentThumb); movies prefer Thumb; -// otherwise we fall back to the generic ArtURL helper. -func bestArtURL(meta *sources.Metadata) string { - if meta.Type == "episode" && meta.GrandparentThumb != "" { - return meta.GrandparentThumb - } - if meta.Thumb != "" { - return meta.Thumb - } - return sources.ArtURL(meta) -} diff --git a/app/components/player/player_nowplaying_darwin.go b/app/components/player/player_nowplaying_darwin.go new file mode 100644 index 0000000..4a950aa --- /dev/null +++ b/app/components/player/player_nowplaying_darwin.go @@ -0,0 +1,86 @@ +//go:build darwin + +package player + +import ( + "context" + "log/slog" + + "codeberg.org/dergs/tonearm/pkg/schwifty" + "github.com/0skillallluck/scanline/app/components/player/nowplaying" + "github.com/0skillallluck/scanline/app/sources" + "github.com/0skillallluck/scanline/utils/imageutils" +) + +// nowPlayingArtworkSize is the square edge length we request from the source's +// photo transcoder. Control Center crops to a square thumbnail; 600px is a +// reasonable balance between memory and Retina sharpness. +const nowPlayingArtworkSize = 600 + +// fetchAndPushArtwork runs in a goroutine on session start. It fetches the +// full metadata (for show/season titles and the artwork URL), pulls the +// artwork bytes through the existing image cache, and hops to the GTK main +// thread to push refined text + artwork into MPNowPlayingInfoCenter. +func fetchAndPushArtwork(ctx context.Context, src sources.Source, params PlayerParams) { + if ctx.Err() != nil { + return + } + meta, err := src.GetMetadata(ctx, params.RatingKey) + if err != nil || meta == nil { + slog.Warn("nowplaying: metadata fetch failed", "error", err) + return + } + if ctx.Err() != nil { + return + } + + info := nowplaying.Info{ + Title: params.Title, + Kind: nowplayingKindFor(params), + DurationUs: int64(meta.Duration) * 1000, + } + if meta.Type == "episode" { + info.Kind = nowplaying.KindEpisode + info.Artist = meta.GrandparentTitle + info.AlbumTitle = meta.ParentTitle + } + schwifty.OnMainThreadOncePure(func() { + if ctx.Err() != nil { + return + } + nowplaying.SetTextMetadata(info) + }) + + artURL := bestArtURL(meta) + if artURL == "" { + return + } + transcoded := src.PhotoTranscodeURL(artURL, nowPlayingArtworkSize, nowPlayingArtworkSize) + data, err := imageutils.Fetch(transcoded) + if err != nil { + slog.Warn("nowplaying: artwork fetch failed", "error", err) + return + } + if ctx.Err() != nil { + return + } + schwifty.OnMainThreadOncePure(func() { + if ctx.Err() != nil { + return + } + nowplaying.SetArtwork(data) + }) +} + +// bestArtURL picks the most square-friendly artwork URL for Now Playing. +// Episodes prefer the show poster (GrandparentThumb); movies prefer Thumb; +// otherwise we fall back to the generic ArtURL helper. +func bestArtURL(meta *sources.Metadata) string { + if meta.Type == "episode" && meta.GrandparentThumb != "" { + return meta.GrandparentThumb + } + if meta.Thumb != "" { + return meta.Thumb + } + return sources.ArtURL(meta) +} diff --git a/app/components/player/player_nowplaying_other.go b/app/components/player/player_nowplaying_other.go new file mode 100644 index 0000000..ebbbabb --- /dev/null +++ b/app/components/player/player_nowplaying_other.go @@ -0,0 +1,13 @@ +//go:build !darwin + +package player + +import ( + "context" + + "github.com/0skillallluck/scanline/app/sources" +) + +// fetchAndPushArtwork is a no-op on non-Darwin: nowplaying.SetTextMetadata / +// SetArtwork are stubs there, so we'd just be issuing wasted HTTP fetches. +func fetchAndPushArtwork(ctx context.Context, src sources.Source, params PlayerParams) {} From 90d65da2357796fbfc77cd8bf30498c1e9b4cb70 Mon Sep 17 00:00:00 2001 From: Cedric Lewe <0skillallluck@pm.me> Date: Sat, 9 May 2026 16:39:52 +0200 Subject: [PATCH 4/4] Address review nits in Now Playing wiring --- .../player/nowplaying/nowplaying.go | 9 -- .../player/nowplaying/nowplaying_darwin.go | 7 +- .../player/nowplaying/nowplaying_darwin.m | 2 +- app/components/player/player.go | 133 ++++++++---------- app/components/player/player_nowplaying.go | 13 -- .../player/player_nowplaying_darwin.go | 6 +- 6 files changed, 64 insertions(+), 106 deletions(-) delete mode 100644 app/components/player/player_nowplaying.go diff --git a/app/components/player/nowplaying/nowplaying.go b/app/components/player/nowplaying/nowplaying.go index f56f924..62d8260 100644 --- a/app/components/player/nowplaying/nowplaying.go +++ b/app/components/player/nowplaying/nowplaying.go @@ -15,14 +15,6 @@ const ( StateStopped ) -// MediaKind distinguishes movies from episodes for the OS media-type field. -type MediaKind int - -const ( - KindMovie MediaKind = iota - KindEpisode -) - // 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 @@ -31,7 +23,6 @@ type Info struct { Title string Artist string AlbumTitle string - Kind MediaKind DurationUs int64 } diff --git a/app/components/player/nowplaying/nowplaying_darwin.go b/app/components/player/nowplaying/nowplaying_darwin.go index e09a73e..4f3dd33 100644 --- a/app/components/player/nowplaying/nowplaying_darwin.go +++ b/app/components/player/nowplaying/nowplaying_darwin.go @@ -12,7 +12,7 @@ package nowplaying void scanline_np_init(void); void scanline_np_set_metadata(const char *title, const char *artist, - const char *album, double durationSec, int kind); + 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); @@ -110,9 +110,8 @@ func Configure(info Info, h Handlers) { initOnce.Do(func() { C.scanline_np_init() }) - hCopy := h mu.Lock() - current = &hCopy + current = &h mu.Unlock() pushMetadata(info) C.scanline_np_set_handler_enabled(C.int(cmdPlayPause), C.bool(h.PlayPause != nil)) @@ -141,7 +140,7 @@ func pushMetadata(info Info) { cAlbum := C.CString(info.AlbumTitle) defer C.free(unsafe.Pointer(cAlbum)) C.scanline_np_set_metadata(cTitle, cArtist, cAlbum, - C.double(info.DurationUs)/1e6, C.int(info.Kind)) + C.double(info.DurationUs)/1e6) } // SetState publishes the current playback state. Called from the player's diff --git a/app/components/player/nowplaying/nowplaying_darwin.m b/app/components/player/nowplaying/nowplaying_darwin.m index 40c58d2..40a8a9f 100644 --- a/app/components/player/nowplaying/nowplaying_darwin.m +++ b/app/components/player/nowplaying/nowplaying_darwin.m @@ -60,7 +60,7 @@ void scanline_np_init(void) { } void scanline_np_set_metadata(const char *title, const char *artist, - const char *album, double durSec, int kind) { + const char *album, double durSec) { @autoreleasepool { if (title && *title) { sInfo[MPMediaItemPropertyTitle] = [NSString stringWithUTF8String:title]; diff --git a/app/components/player/player.go b/app/components/player/player.go index 4709088..57d7275 100644 --- a/app/components/player/player.go +++ b/app/components/player/player.go @@ -988,81 +988,6 @@ func NewPlayer(params PlayerParams) { } } - // --- macOS Now Playing integration --- - // Publish the session to MPNowPlayingInfoCenter and wire MPRemoteCommandCenter - // commands to the existing player closures. No-op on non-Darwin builds. - { - var nextHandler func() - if playNextEpisode != nil { - nextHandler = playNextEpisode - } - nowplaying.Configure( - nowplaying.Info{ - Title: params.Title, - Kind: nowplayingKindFor(params), - DurationUs: knownDurationUs, - }, - nowplaying.Handlers{ - PlayPause: togglePlayPause, - Play: func() { - if pcore == nil { - return - } - pcore.Play() - playing.Store(true) - if playPauseBtn != nil { - playPauseBtn.SetIconName("media-playback-pause-symbolic") - } - sendProgress(sources.StatePlaying) - }, - Pause: func() { - if pcore == nil { - return - } - pcore.Pause() - playing.Store(false) - if playPauseBtn != nil { - playPauseBtn.SetIconName("media-playback-start-symbolic") - } - sendProgress(sources.StatePaused) - }, - Next: nextHandler, - Previous: func() { - // "Seek to start of current item" — confirmed scope choice - // for the initial Now Playing PR (matches Music.app for a - // queue with a single track past 3s in). - doSeek(0) - }, - SkipFwd: func(seconds float64) { - ts := currentTimestampUs() - dur := currentDurationUs() - n := ts + int64(seconds*1e6) - if dur > 0 && n > dur { - n = dur - } - doSeek(n) - }, - SkipBack: func(seconds float64) { - n := currentTimestampUs() - int64(seconds*1e6) - if n < 0 { - n = 0 - } - doSeek(n) - }, - SeekTo: func(positionUs int64) { doSeek(positionUs) }, - Stop: func() { - if closePlayer != nil { - closePlayer() - } - }, - }, - ) - // Refine show / season titles and fetch artwork off the main thread. - // Safe to fire on Linux too — the SetTextMetadata / SetArtwork calls - // inside are no-ops there, only wasting one HTTP request. - go fetchAndPushArtwork(ctx, src, params) - } - closePlayer = func() { if !cleanup() { return @@ -1081,6 +1006,64 @@ func NewPlayer(params PlayerParams) { } router.Refresh() } + + // --- macOS Now Playing integration --- + // Wire MPRemoteCommandCenter commands to the player's closures. No-op on + // non-Darwin. Runs after closePlayer is assigned so the Stop handler can + // invoke it directly without a nil guard. + nowplaying.Configure( + nowplaying.Info{ + Title: params.Title, + DurationUs: knownDurationUs, + }, + nowplaying.Handlers{ + PlayPause: togglePlayPause, + Play: func() { + if pcore == nil { + return + } + pcore.Play() + playing.Store(true) + if playPauseBtn != nil { + playPauseBtn.SetIconName("media-playback-pause-symbolic") + } + sendProgress(sources.StatePlaying) + }, + Pause: func() { + if pcore == nil { + return + } + pcore.Pause() + playing.Store(false) + if playPauseBtn != nil { + playPauseBtn.SetIconName("media-playback-start-symbolic") + } + sendProgress(sources.StatePaused) + }, + Next: playNextEpisode, + // No previous-episode concept; map to "seek to start of current". + Previous: func() { doSeek(0) }, + SkipFwd: func(seconds float64) { + ts := currentTimestampUs() + dur := currentDurationUs() + n := ts + int64(seconds*1e6) + if dur > 0 && n > dur { + n = dur + } + doSeek(n) + }, + SkipBack: func(seconds float64) { + n := currentTimestampUs() - int64(seconds*1e6) + if n < 0 { + n = 0 + } + doSeek(n) + }, + SeekTo: func(positionUs int64) { doSeek(positionUs) }, + Stop: closePlayer, + }, + ) + go fetchAndPushArtwork(ctx, src, params) if preference.Experimental().StartInFullscreen() { win.Fullscreen() } diff --git a/app/components/player/player_nowplaying.go b/app/components/player/player_nowplaying.go deleted file mode 100644 index a767c77..0000000 --- a/app/components/player/player_nowplaying.go +++ /dev/null @@ -1,13 +0,0 @@ -package player - -import "github.com/0skillallluck/scanline/app/components/player/nowplaying" - -// nowplayingKindFor guesses whether the playing item is a movie or an episode -// from the params we already have at session start. The async metadata fetch -// later refines this if needed. -func nowplayingKindFor(params PlayerParams) nowplaying.MediaKind { - if params.GrandparentRatingKey != "" { - return nowplaying.KindEpisode - } - return nowplaying.KindMovie -} diff --git a/app/components/player/player_nowplaying_darwin.go b/app/components/player/player_nowplaying_darwin.go index 4a950aa..fec8f9d 100644 --- a/app/components/player/player_nowplaying_darwin.go +++ b/app/components/player/player_nowplaying_darwin.go @@ -36,11 +36,9 @@ func fetchAndPushArtwork(ctx context.Context, src sources.Source, params PlayerP info := nowplaying.Info{ Title: params.Title, - Kind: nowplayingKindFor(params), DurationUs: int64(meta.Duration) * 1000, } if meta.Type == "episode" { - info.Kind = nowplaying.KindEpisode info.Artist = meta.GrandparentTitle info.AlbumTitle = meta.ParentTitle } @@ -73,8 +71,8 @@ func fetchAndPushArtwork(ctx context.Context, src sources.Source, params PlayerP } // bestArtURL picks the most square-friendly artwork URL for Now Playing. -// Episodes prefer the show poster (GrandparentThumb); movies prefer Thumb; -// otherwise we fall back to the generic ArtURL helper. +// Episodes prefer the show poster (GrandparentThumb), then the episode's +// own Thumb. Movies prefer Thumb. Falls back to sources.ArtURL otherwise. func bestArtURL(meta *sources.Metadata) string { if meta.Type == "episode" && meta.GrandparentThumb != "" { return meta.GrandparentThumb