diff --git a/Sources/Kaset/Resources/Kaset.sdef b/Sources/Kaset/Resources/Kaset.sdef index becc005d7..0bffb2840 100644 --- a/Sources/Kaset/Resources/Kaset.sdef +++ b/Sources/Kaset/Resources/Kaset.sdef @@ -52,6 +52,11 @@ + + + + + diff --git a/Sources/Kaset/Services/Player/NowPlayingBroadcaster.swift b/Sources/Kaset/Services/Player/NowPlayingBroadcaster.swift new file mode 100644 index 000000000..efae91712 --- /dev/null +++ b/Sources/Kaset/Services/Player/NowPlayingBroadcaster.swift @@ -0,0 +1,71 @@ +import Foundation +import Observation + +/// Broadcasts a distributed notification when playback state changes so external +/// now-playing surfaces (e.g. the boring.notch "Kaset" media source) can refresh +/// without polling. +/// +/// The notification is a bare, change-only trigger: it carries no `userInfo` (the +/// App Sandbox strips `userInfo` from a sandboxed sender's distributed +/// notifications), and the name is prefixed with the app's bundle id so the sandbox +/// permits posting it. It fires only on a *change* after `configure()` — there is no +/// initial snapshot and a missed notification is not resent — so listeners read the +/// full state via the `get player info` AppleScript command, and must poll it for the +/// initial state and for high-frequency values (position, volume) that are +/// deliberately not triggers. +@MainActor +final class NowPlayingBroadcaster { + static let shared = NowPlayingBroadcaster() + + /// Distributed-notification name. **Published cross-process contract** — external + /// listeners (e.g. boring.notch) match this exact string, so it must not change + /// casually. Bundle-id-prefixed so the App Sandbox permits posting it. + static let notificationName = "com.sertacozercan.Kaset.playerInfo" + + /// Weak: `PlayerService.shared` owns its own lifetime; the broadcaster must not retain it. + private weak var playerService: PlayerService? + private let logger = DiagnosticsLogger.player + private var isConfigured = false + + private init() {} + + /// Begins observing the player and broadcasting on change. Idempotent. + func configure(playerService: PlayerService) { + guard !self.isConfigured else { + self.logger.debug("NowPlayingBroadcaster already configured, skipping") + return + } + self.isConfigured = true + self.playerService = playerService + self.observe() + self.logger.info("NowPlayingBroadcaster configured") + } + + private func observe() { + withObservationTracking { + // Track only the discrete fields a now-playing surface reacts to. + // Deliberately excludes `progress` and `volume`: both change rapidly + // (continuous playback / volume drags) and would flood the cross-process + // notification. Consumers read position and volume via `get player info`. + _ = self.playerService?.currentTrack?.videoId + _ = self.playerService?.state + _ = self.playerService?.currentTrackLikeStatus + _ = self.playerService?.shuffleEnabled + _ = self.playerService?.repeatMode + } onChange: { + Task { @MainActor [weak self] in + self?.post() + self?.observe() + } + } + } + + private func post() { + DistributedNotificationCenter.default().postNotificationName( + NSNotification.Name(Self.notificationName), + object: nil, + userInfo: nil, + deliverImmediately: true + ) + } +} diff --git a/Sources/Kaset/Services/Scripting/ScriptCommands.swift b/Sources/Kaset/Services/Scripting/ScriptCommands.swift index e885b8274..cc09b6b61 100644 --- a/Sources/Kaset/Services/Scripting/ScriptCommands.swift +++ b/Sources/Kaset/Services/Scripting/ScriptCommands.swift @@ -184,6 +184,33 @@ final class SetVolumeCommand: NSScriptCommand { } } +// MARK: - SeekCommand + +/// Seek command: jump to a position (in seconds) in the current track. +@objc(KasetSeekCommand) +final class SeekCommand: NSScriptCommand { + override func performDefaultImplementation() -> Any? { + guard let seconds = (directParameter as? NSNumber)?.doubleValue else { + logger.error("Seek command failed: invalid position parameter") + scriptErrorNumber = errAECoercionFail + scriptErrorString = "Position must be a number of seconds." + return nil + } + + guard let playerService = MainActor.assumeIsolated({ getPlayerService() }) else { + logger.error("Seek command failed: PlayerService.shared is nil") + scriptErrorNumber = errPlayerNotAvailable + scriptErrorString = playerNotAvailableMessage + return nil + } + logger.info("Executing seek command to position: \(seconds)") + Task { @MainActor in + await playerService.seek(to: max(0, seconds)) + } + return nil + } +} + // MARK: - ToggleShuffleCommand /// ToggleShuffle command: toggle shuffle mode. diff --git a/Sources/Kaset/Views/MainWindow.swift b/Sources/Kaset/Views/MainWindow.swift index a318d5312..9045b0376 100644 --- a/Sources/Kaset/Views/MainWindow.swift +++ b/Sources/Kaset/Views/MainWindow.swift @@ -343,6 +343,7 @@ struct MainWindow: View { // swiftlint:disable:this type_body_length } .task { NowPlayingManager.shared.configure(playerService: self.playerService) + NowPlayingBroadcaster.shared.configure(playerService: self.playerService) } .task(id: self.accountService.currentAccount?.id) { // Keep PodcastsViewModel in sync with the active account so diff --git a/Tests/KasetTests/NowPlayingBroadcasterTests.swift b/Tests/KasetTests/NowPlayingBroadcasterTests.swift new file mode 100644 index 000000000..0541ac887 --- /dev/null +++ b/Tests/KasetTests/NowPlayingBroadcasterTests.swift @@ -0,0 +1,14 @@ +import Testing +@testable import Kaset + +@MainActor +struct NowPlayingBroadcasterTests { + /// `notificationName` is a published cross-process contract: external now-playing + /// surfaces (e.g. the boring.notch "Kaset" media source) listen for this exact + /// string. Pin it so an accidental rename fails CI here instead of silently + /// breaking those integrations. + @Test("Broadcaster notification name matches the published contract") + func notificationNameIsStableContract() { + #expect(NowPlayingBroadcaster.notificationName == "com.sertacozercan.Kaset.playerInfo") + } +} diff --git a/Tests/KasetTests/ScriptCommandsTests.swift b/Tests/KasetTests/ScriptCommandsTests.swift index d78dc2dc3..d67781859 100644 --- a/Tests/KasetTests/ScriptCommandsTests.swift +++ b/Tests/KasetTests/ScriptCommandsTests.swift @@ -64,6 +64,10 @@ struct ScriptCommandsTests { #expect(json["repeating"] != nil) #expect(json["muted"] != nil) #expect(json["likeStatus"] != nil) + // Cross-process contract keys consumed by external now-playing surfaces + // (e.g. boring.notch): pin them so a rename can't silently break consumers. + #expect(json["position"] != nil) + #expect(json["duration"] != nil) } else { Issue.record("Failed to parse JSON response") } @@ -100,6 +104,7 @@ struct ScriptCommandsTests { #expect(trackInfo["album"] as? String == "Test Album") #expect(trackInfo["videoId"] as? String == "test-video-id") #expect(trackInfo["duration"] as? TimeInterval == 180) + #expect(trackInfo["artworkURL"] as? String == "https://example.com/thumb.jpg") } else { Issue.record("Failed to parse track info from JSON response") } @@ -234,6 +239,35 @@ struct ScriptCommandsTests { PlayerService.shared = nil } + // MARK: - SeekCommand Tests + + @Test("Seek sets error when PlayerService is nil") + func seekSetsErrorWhenNil() { + PlayerService.shared = nil + + let command = SeekCommand() + command.directParameter = 30 as NSNumber + _ = command.performDefaultImplementation() + + #expect(command.scriptErrorNumber == -1728) + } + + @Test("Seek sets error for invalid parameter type") + func seekSetsErrorForInvalidParameter() { + let playerService = PlayerService() + PlayerService.shared = playerService + + let command = SeekCommand() + command.directParameter = "not a number" as NSString + _ = command.performDefaultImplementation() + + #expect(command.scriptErrorNumber == errAECoercionFail) + #expect(command.scriptErrorString?.contains("Position must be a number") == true) + + // Cleanup + PlayerService.shared = nil + } + // MARK: - PlayCommand Tests @Test("Play sets error when PlayerService is nil") diff --git a/docs/applescript.md b/docs/applescript.md index 7c1468cca..86ff4cea5 100644 --- a/docs/applescript.md +++ b/docs/applescript.md @@ -13,6 +13,7 @@ Kaset supports AppleScript for automation with tools like Raycast, Alfred, and S | `next track` | Skip to next track | | `previous track` | Go to previous track | | `set volume N` | Set volume (0-100) | +| `seek N` | Seek to position N seconds in the current track | | `toggle mute` | Mute/unmute | | `toggle shuffle` | Toggle shuffle on/off (binary; does not reach Smart Shuffle) | | `cycle repeat` | Cycle repeat (Off → All → One) | @@ -131,6 +132,9 @@ osascript -e 'tell application "Kaset" to next track' # Set volume (0-100) osascript -e 'tell application "Kaset" to set volume 75' +# Seek to a position (seconds into the current track) +osascript -e 'tell application "Kaset" to seek 30' + # Toggle modes osascript -e 'tell application "Kaset" to toggle shuffle' osascript -e 'tell application "Kaset" to cycle repeat' @@ -148,6 +152,25 @@ osascript -e 'tell application "Kaset" to get play queue' osascript -e 'tell application "Kaset" to play track at index 2' ``` +## Now Playing Notifications + +Kaset posts a distributed notification named `com.sertacozercan.Kaset.playerInfo` +whenever discrete playback state changes (current track, play/pause, like status, +shuffle, or repeat). External now-playing surfaces — menu-bar widgets, or notch apps +such as boring.notch — can observe it to refresh reactively instead of polling on a +timer. + +The notification is a **bare, change-only trigger**: + +- It carries no payload (the App Sandbox strips `userInfo` from a sandboxed sender), + so observers read the current state with `get player info`. +- It fires only on a *change*; there is no initial snapshot and a missed notification + is not resent. Observers should call `get player info` once on startup for the + initial state and keep a low-frequency poll as a fallback. +- High-frequency values — **playback position and volume** — are intentionally not + triggers (they would flood the notification during playback and volume drags). Read + `position` and `volume` from `get player info`, polling if you need them live. + ## Error Handling If the player service is not yet initialized (e.g., during app launch), commands will return AppleScript error `-1728` with the message "Player service not initialized."