Skip to content

Commit a3b1c9f

Browse files
ruslan.hryshchenkoclaude
andcommitted
feat: add AppleScript support for external control
Add AppleScript scripting support to enable external control of Kaset, primarily for Raycast extension integration. Commands added: - play, pause, playpause - playback control - next track, previous track - track navigation - set volume - volume control (0-100) - toggle shuffle, cycle repeat, toggle mute - playback modes - get player info - returns JSON with current state - like track, dislike track - rating controls Implementation: - App/Kaset.sdef: AppleScript command definitions - Core/Services/Scripting/ScriptCommands.swift: command handlers - PlayerService.shared: static accessor for script commands Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 2139d7b commit a3b1c9f

File tree

6 files changed

+303
-3
lines changed

6 files changed

+303
-3
lines changed

App/Info.plist

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,5 +32,11 @@
3232

3333
<key>SUAllowsAutomaticUpdates</key>
3434
<true/>
35+
36+
<!-- AppleScript Support -->
37+
<key>NSAppleScriptEnabled</key>
38+
<true/>
39+
<key>OSAScriptingDefinition</key>
40+
<string>Kaset.sdef</string>
3541
</dict>
3642
</plist>

App/Kaset.sdef

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<!DOCTYPE dictionary SYSTEM "file://localhost/System/Library/DTDs/sdef.dtd">
3+
<dictionary title="Kaset Terminology">
4+
5+
<!-- Standard Suite -->
6+
<suite name="Standard Suite" code="????" description="Common classes and commands for all applications.">
7+
8+
<command name="quit" code="aevtquit" description="Quit the application.">
9+
<cocoa class="NSQuitCommand"/>
10+
</command>
11+
12+
</suite>
13+
14+
<!-- Kaset Suite -->
15+
<suite name="Kaset Suite" code="Kast" description="Commands and classes for controlling Kaset.">
16+
17+
<!-- Playback Commands -->
18+
<command name="play" code="Kastplay" description="Start or resume playback.">
19+
<cocoa class="KasetPlayCommand"/>
20+
</command>
21+
22+
<command name="pause" code="Kastpaus" description="Pause playback.">
23+
<cocoa class="KasetPauseCommand"/>
24+
</command>
25+
26+
<command name="playpause" code="Kasttogg" description="Toggle play/pause state.">
27+
<cocoa class="KasetPlayPauseCommand"/>
28+
</command>
29+
30+
<command name="next track" code="Kastnext" description="Skip to the next track.">
31+
<cocoa class="KasetNextTrackCommand"/>
32+
</command>
33+
34+
<command name="previous track" code="Kastprev" description="Go to the previous track.">
35+
<cocoa class="KasetPreviousTrackCommand"/>
36+
</command>
37+
38+
<command name="set volume" code="Kastvolu" description="Set the playback volume.">
39+
<cocoa class="KasetSetVolumeCommand"/>
40+
<direct-parameter type="integer" description="Volume level (0-100)."/>
41+
</command>
42+
43+
<command name="toggle shuffle" code="Kastshuf" description="Toggle shuffle mode.">
44+
<cocoa class="KasetToggleShuffleCommand"/>
45+
</command>
46+
47+
<command name="cycle repeat" code="Kastrepe" description="Cycle through repeat modes (off, all, one).">
48+
<cocoa class="KasetCycleRepeatCommand"/>
49+
</command>
50+
51+
<command name="toggle mute" code="Kastmute" description="Toggle mute state.">
52+
<cocoa class="KasetToggleMuteCommand"/>
53+
</command>
54+
55+
<command name="get player info" code="Kastinfo" description="Get current player state as JSON.">
56+
<cocoa class="KasetGetPlayerInfoCommand"/>
57+
<result type="text" description="JSON string with player state."/>
58+
</command>
59+
60+
<command name="like track" code="Kastlike" description="Like or unlike the current track.">
61+
<cocoa class="KasetLikeTrackCommand"/>
62+
</command>
63+
64+
<command name="dislike track" code="Kastdslk" description="Dislike or undislike the current track.">
65+
<cocoa class="KasetDislikeTrackCommand"/>
66+
</command>
67+
68+
</suite>
69+
70+
</dictionary>

App/KasetApp.swift

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,9 @@ struct KasetApp: App {
8888
player.setYTMusicClient(client)
8989
SongLikeStatusManager.shared.setClient(client)
9090

91+
// Set shared instance for AppleScript access
92+
PlayerService.shared = player
93+
9194
// Create account service
9295
let account = AccountService(ytMusicClient: client, authService: auth)
9396

@@ -125,10 +128,12 @@ struct KasetApp: App {
125128
.environment(\.searchFocusTrigger, self.$searchFocusTrigger)
126129
.environment(\.navigationSelection, self.$navigationSelection)
127130
.environment(\.showCommandBar, self.$showCommandBar)
128-
.task {
129-
// Wire up PlayerService to AppDelegate for dock menu actions
131+
.onAppear {
132+
// Wire up PlayerService to AppDelegate for dock menu and AppleScript actions
133+
// This runs synchronously so AppleScript commands can access playerService immediately
130134
self.appDelegate.playerService = self.playerService
131-
135+
}
136+
.task {
132137
// Check if user is already logged in from previous session
133138
await self.authService.checkLoginStatus()
134139

Core/Services/Player/PlayerService.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import os
88
@MainActor
99
@Observable
1010
final class PlayerService: NSObject, PlayerServiceProtocol {
11+
/// Shared instance for AppleScript access. Set by KasetApp on init.
12+
static var shared: PlayerService?
1113
/// Current playback state.
1214
enum PlaybackState: Equatable, Sendable {
1315
case idle
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
import AppKit
2+
import Foundation
3+
4+
// MARK: - AppleScript Commands
5+
6+
/// Play command: start or resume playback.
7+
@objc(KasetPlayCommand)
8+
final class PlayCommand: NSScriptCommand {
9+
override func performDefaultImplementation() -> Any? {
10+
Task { @MainActor in
11+
guard let playerService = PlayerService.shared else { return }
12+
await playerService.resume()
13+
}
14+
return nil
15+
}
16+
}
17+
18+
/// Pause command: pause playback.
19+
@objc(KasetPauseCommand)
20+
final class PauseCommand: NSScriptCommand {
21+
override func performDefaultImplementation() -> Any? {
22+
Task { @MainActor in
23+
guard let playerService = PlayerService.shared else { return }
24+
await playerService.pause()
25+
}
26+
return nil
27+
}
28+
}
29+
30+
/// PlayPause command: toggle play/pause state.
31+
@objc(KasetPlayPauseCommand)
32+
final class PlayPauseCommand: NSScriptCommand {
33+
override func performDefaultImplementation() -> Any? {
34+
Task { @MainActor in
35+
guard let playerService = PlayerService.shared else { return }
36+
await playerService.playPause()
37+
}
38+
return nil
39+
}
40+
}
41+
42+
/// NextTrack command: skip to the next track.
43+
@objc(KasetNextTrackCommand)
44+
final class NextTrackCommand: NSScriptCommand {
45+
override func performDefaultImplementation() -> Any? {
46+
Task { @MainActor in
47+
guard let playerService = PlayerService.shared else { return }
48+
await playerService.next()
49+
}
50+
return nil
51+
}
52+
}
53+
54+
/// PreviousTrack command: go to the previous track.
55+
@objc(KasetPreviousTrackCommand)
56+
final class PreviousTrackCommand: NSScriptCommand {
57+
override func performDefaultImplementation() -> Any? {
58+
Task { @MainActor in
59+
guard let playerService = PlayerService.shared else { return }
60+
await playerService.previous()
61+
}
62+
return nil
63+
}
64+
}
65+
66+
/// SetVolume command: set the playback volume (0-100).
67+
@objc(KasetSetVolumeCommand)
68+
final class SetVolumeCommand: NSScriptCommand {
69+
override func performDefaultImplementation() -> Any? {
70+
guard let volumeValue = self.directParameter as? Int else {
71+
scriptErrorNumber = errAECoercionFail
72+
return nil
73+
}
74+
75+
let normalizedVolume = Double(max(0, min(100, volumeValue))) / 100.0
76+
77+
Task { @MainActor in
78+
guard let playerService = PlayerService.shared else { return }
79+
await playerService.setVolume(normalizedVolume)
80+
}
81+
return nil
82+
}
83+
}
84+
85+
/// ToggleShuffle command: toggle shuffle mode.
86+
@objc(KasetToggleShuffleCommand)
87+
final class ToggleShuffleCommand: NSScriptCommand {
88+
override func performDefaultImplementation() -> Any? {
89+
Task { @MainActor in
90+
guard let playerService = PlayerService.shared else { return }
91+
playerService.toggleShuffle()
92+
}
93+
return nil
94+
}
95+
}
96+
97+
/// CycleRepeat command: cycle through repeat modes (off, all, one).
98+
@objc(KasetCycleRepeatCommand)
99+
final class CycleRepeatCommand: NSScriptCommand {
100+
override func performDefaultImplementation() -> Any? {
101+
Task { @MainActor in
102+
guard let playerService = PlayerService.shared else { return }
103+
playerService.cycleRepeatMode()
104+
}
105+
return nil
106+
}
107+
}
108+
109+
/// ToggleMute command: toggle mute state.
110+
@objc(KasetToggleMuteCommand)
111+
final class ToggleMuteCommand: NSScriptCommand {
112+
override func performDefaultImplementation() -> Any? {
113+
Task { @MainActor in
114+
guard let playerService = PlayerService.shared else { return }
115+
await playerService.toggleMute()
116+
}
117+
return nil
118+
}
119+
}
120+
121+
/// GetPlayerInfo command: returns current player state as JSON.
122+
@objc(KasetGetPlayerInfoCommand)
123+
final class GetPlayerInfoCommand: NSScriptCommand {
124+
override func performDefaultImplementation() -> Any? {
125+
// AppleScript runs on main thread, so we can assume MainActor isolation
126+
let result: String = MainActor.assumeIsolated {
127+
guard let playerService = PlayerService.shared else {
128+
return "{\"error\": \"Player not available\"}"
129+
}
130+
131+
let track = playerService.currentTrack
132+
let repeatMode: String = switch playerService.repeatMode {
133+
case .off: "off"
134+
case .all: "all"
135+
case .one: "one"
136+
}
137+
138+
let likeStatus: String = switch playerService.currentTrackLikeStatus {
139+
case .like: "liked"
140+
case .dislike: "disliked"
141+
case .indifferent: "none"
142+
}
143+
144+
var info: [String: Any] = [
145+
"isPlaying": playerService.isPlaying,
146+
"isPaused": playerService.state == .paused,
147+
"position": playerService.progress,
148+
"duration": playerService.duration,
149+
"volume": Int(playerService.volume * 100),
150+
"shuffling": playerService.shuffleEnabled,
151+
"repeating": repeatMode,
152+
"muted": playerService.isMuted,
153+
"likeStatus": likeStatus,
154+
]
155+
156+
if let track {
157+
info["currentTrack"] = [
158+
"name": track.title,
159+
"artist": track.artistsDisplay,
160+
"album": track.album?.title ?? "",
161+
"duration": track.duration ?? 0,
162+
"videoId": track.videoId,
163+
"artworkURL": track.thumbnailURL?.absoluteString ?? "",
164+
]
165+
}
166+
167+
if let data = try? JSONSerialization.data(withJSONObject: info, options: [.sortedKeys]),
168+
let json = String(data: data, encoding: .utf8)
169+
{
170+
return json
171+
}
172+
173+
return "{}"
174+
}
175+
return result
176+
}
177+
}
178+
179+
/// LikeTrack command: like/unlike the current track.
180+
@objc(KasetLikeTrackCommand)
181+
final class LikeTrackCommand: NSScriptCommand {
182+
override func performDefaultImplementation() -> Any? {
183+
Task { @MainActor in
184+
guard let playerService = PlayerService.shared else { return }
185+
playerService.likeCurrentTrack()
186+
}
187+
return nil
188+
}
189+
}
190+
191+
/// DislikeTrack command: dislike/undislike the current track.
192+
@objc(KasetDislikeTrackCommand)
193+
final class DislikeTrackCommand: NSScriptCommand {
194+
override func performDefaultImplementation() -> Any? {
195+
Task { @MainActor in
196+
guard let playerService = PlayerService.shared else { return }
197+
playerService.dislikeCurrentTrack()
198+
}
199+
return nil
200+
}
201+
}

Kaset.xcodeproj/project.pbxproj

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,8 @@
187187
SLS0000100000001 /* SongLikeStatusManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = SLS0000200000001 /* SongLikeStatusManager.swift */; };
188188
URL0000100000001 /* URLHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = URL0000200000001 /* URLHandler.swift */; };
189189
URL0000100000002 /* URLHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = URL0000200000002 /* URLHandlerTests.swift */; };
190+
SCR0000100000001 /* Kaset.sdef in Resources */ = {isa = PBXBuildFile; fileRef = SCR0000200000001 /* Kaset.sdef */; };
191+
SCR0000100000002 /* ScriptCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = SCR0000200000002 /* ScriptCommands.swift */; };
190192
/* End PBXBuildFile section */
191193

192194
/* Begin PBXContainerItemProxy section */
@@ -394,6 +396,8 @@
394396
SLS0000200000001 /* SongLikeStatusManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SongLikeStatusManager.swift; sourceTree = "<group>"; };
395397
URL0000200000001 /* URLHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLHandler.swift; sourceTree = "<group>"; };
396398
URL0000200000002 /* URLHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLHandlerTests.swift; sourceTree = "<group>"; };
399+
SCR0000200000001 /* Kaset.sdef */ = {isa = PBXFileReference; lastKnownFileType = text.sdef; path = Kaset.sdef; sourceTree = "<group>"; };
400+
SCR0000200000002 /* ScriptCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScriptCommands.swift; sourceTree = "<group>"; };
397401
/* End PBXFileReference section */
398402

399403
/* Begin PBXFrameworksBuildPhase section */
@@ -562,6 +566,7 @@
562566
E50000020000000000000001 /* KasetApp.swift */,
563567
E50000020000000000000002 /* Assets.xcassets */,
564568
E50000020000000000000003 /* Kaset.entitlements */,
569+
SCR0000200000001 /* Kaset.sdef */,
565570
5EED7AE0758BC04F43AD72BB /* VideoWindowController.swift */,
566571
);
567572
path = App;
@@ -638,6 +643,7 @@
638643
E50000060000000000000022 /* Auth */,
639644
E50000060000000000000025 /* Notification */,
640645
E50000060000000000000023 /* Player */,
646+
SCR0000600000001 /* Scripting */,
641647
E50000060000000000000024 /* WebKit */,
642648
E50000020000000000000200 /* Protocols.swift */,
643649
E50000020000000000000502 /* UpdaterService.swift */,
@@ -718,6 +724,14 @@
718724
path = Parsers;
719725
sourceTree = "<group>";
720726
};
727+
SCR0000600000001 /* Scripting */ = {
728+
isa = PBXGroup;
729+
children = (
730+
SCR0000200000002 /* ScriptCommands.swift */,
731+
);
732+
path = Scripting;
733+
sourceTree = "<group>";
734+
};
721735
E50000060000000000000030 /* ViewModels */ = {
722736
isa = PBXGroup;
723737
children = (
@@ -970,6 +984,7 @@
970984
files = (
971985
E50000010000000000000002 /* Assets.xcassets in Resources */,
972986
C8E5397A2EF7A65A0032CA24 /* kaset.icon in Resources */,
987+
SCR0000100000001 /* Kaset.sdef in Resources */,
973988
);
974989
runOnlyForDeploymentPostprocessing = 0;
975990
};
@@ -1113,6 +1128,7 @@
11131128
D00FA32826CAC950B46D1A25 /* AccountSwitcherPopover.swift in Sources */,
11141129
BB9015B490D3697FF15B95C7 /* AccountRowView.swift in Sources */,
11151130
603D8E6C1950DADF3DDA3563 /* ToastView.swift in Sources */,
1131+
SCR0000100000002 /* ScriptCommands.swift in Sources */,
11161132
);
11171133
runOnlyForDeploymentPostprocessing = 0;
11181134
};

0 commit comments

Comments
 (0)