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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions Sources/Kaset/Resources/Kaset.sdef
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@
<direct-parameter type="integer" description="Volume level (0-100)."/>
</command>

<command name="seek" code="Kastseek" description="Seek to a position (in seconds) in the current track.">
<cocoa class="KasetSeekCommand"/>
<direct-parameter type="real" description="Position in seconds."/>
</command>

<command name="toggle shuffle" code="Kastshuf" description="Toggle shuffle on or off (binary; does not enable Smart Shuffle).">
<cocoa class="KasetToggleShuffleCommand"/>
</command>
Expand Down
71 changes: 71 additions & 0 deletions Sources/Kaset/Services/Player/NowPlayingBroadcaster.swift
Original file line number Diff line number Diff line change
@@ -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
)
}
}
27 changes: 27 additions & 0 deletions Sources/Kaset/Services/Scripting/ScriptCommands.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions Sources/Kaset/Views/MainWindow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions Tests/KasetTests/NowPlayingBroadcasterTests.swift
Original file line number Diff line number Diff line change
@@ -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")
}
}
34 changes: 34 additions & 0 deletions Tests/KasetTests/ScriptCommandsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down Expand Up @@ -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")
}
Expand Down Expand Up @@ -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")
Expand Down
23 changes: 23 additions & 0 deletions docs/applescript.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down Expand Up @@ -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'
Expand All @@ -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."
Expand Down
Loading