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."