diff --git a/app/components/player/nowplaying/nowplaying.go b/app/components/player/nowplaying/nowplaying.go new file mode 100644 index 0000000..62d8260 --- /dev/null +++ b/app/components/player/nowplaying/nowplaying.go @@ -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() +} diff --git a/app/components/player/nowplaying/nowplaying_darwin.go b/app/components/player/nowplaying/nowplaying_darwin.go new file mode 100644 index 0000000..4f3dd33 --- /dev/null +++ b/app/components/player/nowplaying/nowplaying_darwin.go @@ -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 +#include +#include + +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() +} diff --git a/app/components/player/nowplaying/nowplaying_darwin.m b/app/components/player/nowplaying/nowplaying_darwin.m new file mode 100644 index 0000000..40a8a9f --- /dev/null +++ b/app/components/player/nowplaying/nowplaying_darwin.m @@ -0,0 +1,166 @@ +//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; +} + +// 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; +} diff --git a/app/components/player/nowplaying/nowplaying_other.go b/app/components/player/nowplaying/nowplaying_other.go new file mode 100644 index 0000000..0eb76e4 --- /dev/null +++ b/app/components/player/nowplaying/nowplaying_other.go @@ -0,0 +1,11 @@ +//go:build !darwin + +// Stubs for non-Darwin builds; every exported function is a no-op. +package nowplaying + +func Configure(info Info, handlers Handlers) {} +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 8204427..57d7275 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" @@ -746,12 +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() + if ts != lastNowPlayingPosUs { + nowplaying.SetPosition(ts) + lastNowPlayingPosUs = ts + } if !seeking.Load() && progressScale != nil && dur > 0 { progressScale.SetRange(0, float64(dur)) progressScale.SetValue(float64(ts)) @@ -834,6 +840,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 +919,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 } @@ -991,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_darwin.go b/app/components/player/player_nowplaying_darwin.go new file mode 100644 index 0000000..fec8f9d --- /dev/null +++ b/app/components/player/player_nowplaying_darwin.go @@ -0,0 +1,84 @@ +//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, + DurationUs: int64(meta.Duration) * 1000, + } + if meta.Type == "episode" { + 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), 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 + } + 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) {} 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 {