Skip to content

Commit ad14634

Browse files
committed
feat(mac): complete rewrite of macOS app in SwiftUI
- Replaced python/tkinter prototype with a native Swift/SwiftUI application\n- Implemented full immersive album art layout with masking in PlayerView\n- Added native Slider for bidirectional timeline seek control\n- Re-implemented UDP Discovery and Command Syncing using Network.framework\n- Provided build.sh for easy compilation on macOS
1 parent 54dcb86 commit ad14634

8 files changed

Lines changed: 649 additions & 971 deletions

File tree

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import SwiftUI
2+
3+
struct DevicesView: View {
4+
@ObservedObject var networkManager = NetworkManager.shared
5+
6+
var body: some View {
7+
VStack(alignment: .leading) {
8+
Text("Discovered Devices")
9+
.font(.headline)
10+
.padding(.bottom, 10)
11+
12+
List(networkManager.discoveredDevices, id: \.self) { device in
13+
HStack {
14+
VStack(alignment: .leading) {
15+
Text(device.name).fontWeight(.bold)
16+
Text("\(device.ip):\(device.port) - \(device.type)")
17+
.font(.caption)
18+
.foregroundColor(.secondary)
19+
}
20+
Spacer()
21+
if networkManager.connectedDevice == device {
22+
Text("Connected").foregroundColor(.green)
23+
Button("Disconnect") {
24+
networkManager.disconnect()
25+
}
26+
} else {
27+
Button("Connect") {
28+
networkManager.connectToDevice(device)
29+
}
30+
}
31+
}
32+
.padding(.vertical, 5)
33+
}
34+
.listStyle(PlainListStyle())
35+
36+
Spacer()
37+
}
38+
.padding()
39+
}
40+
}
41+
42+
struct SettingsView: View {
43+
var body: some View {
44+
VStack {
45+
Text("Settings")
46+
.font(.title)
47+
Text("Future configuration options will go here.")
48+
.foregroundColor(.secondary)
49+
}
50+
.padding()
51+
}
52+
}
53+
54+
struct ContentView: View {
55+
var body: some View {
56+
TabView {
57+
PlayerView()
58+
.tabItem { Text("Now Playing") }
59+
DevicesView()
60+
.tabItem { Text("Devices") }
61+
SettingsView()
62+
.tabItem { Text("Settings") }
63+
}
64+
.frame(minWidth: 400, minHeight: 500)
65+
}
66+
}
67+
68+
@main
69+
struct CarpeCastApp: App {
70+
// Initialize network manager and media manager early
71+
@StateObject var networkManager = NetworkManager.shared
72+
@StateObject var mediaManager = MediaManager.shared
73+
74+
var body: some Scene {
75+
WindowGroup {
76+
ContentView()
77+
}
78+
}
79+
}

Mac/CarpeCast-Swift/Info.plist

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3+
<plist version="1.0">
4+
<dict>
5+
<key>CFBundleExecutable</key>
6+
<string>CarpeCast</string>
7+
<key>CFBundleIdentifier</key>
8+
<string>com.jayfunc.carpecast</string>
9+
<key>CFBundleName</key>
10+
<string>CarpeCast</string>
11+
<key>CFBundleVersion</key>
12+
<string>1.0</string>
13+
<key>CFBundleShortVersionString</key>
14+
<string>1.0</string>
15+
<key>LSMinimumSystemVersion</key>
16+
<string>11.0</string>
17+
<key>NSHighResolutionCapable</key>
18+
<true/>
19+
<key>NSAppleEventsUsageDescription</key>
20+
<string>CarpeCast needs permission to control Apple Music and Spotify to sync playback.</string>
21+
<key>NSLocalNetworkUsageDescription</key>
22+
<string>CarpeCast needs local network access to sync media state with other devices.</string>
23+
</dict>
24+
</plist>
Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
import Foundation
2+
import AppKit
3+
4+
class MediaManager: ObservableObject {
5+
static let shared = MediaManager()
6+
7+
@Published var title: String = ""
8+
@Published var artist: String = ""
9+
@Published var album: String = ""
10+
@Published var isPlaying: Bool = false
11+
@Published var position: Double = 0.0
12+
@Published var duration: Double = 0.0
13+
@Published var albumArtBase64: String = ""
14+
@Published var lastFetchMethod: String = ""
15+
16+
private var updateTimer: Timer?
17+
18+
init() {
19+
startPolling()
20+
}
21+
22+
func startPolling() {
23+
updateTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
24+
self?.fetchGlobalTrackInfo()
25+
}
26+
}
27+
28+
func fetchGlobalTrackInfo() {
29+
getMediaRemoteInfo { [weak self] result in
30+
guard let self = self else { return }
31+
DispatchQueue.main.async {
32+
if let res = result {
33+
self.parseAndApplyInfo(res, method: "MediaRemote")
34+
} else {
35+
self.getAppleScriptFallback { asResult in
36+
DispatchQueue.main.async {
37+
if let asRes = asResult {
38+
self.parseAndApplyInfo(asRes, method: "AppleScript")
39+
} else {
40+
self.clearInfo()
41+
}
42+
}
43+
}
44+
}
45+
}
46+
}
47+
}
48+
49+
private func parseAndApplyInfo(_ raw: String, method: String) {
50+
let parts = raw.components(separatedBy: "|||")
51+
if parts.count >= 6 {
52+
self.title = parts[0]
53+
self.artist = parts[1]
54+
self.album = parts[2]
55+
self.isPlaying = (parts[3] == "true")
56+
self.position = (Double(parts[4]) ?? 0.0) * 1000.0
57+
self.duration = (Double(parts[5]) ?? 0.0) * 1000.0
58+
self.albumArtBase64 = parts.count >= 7 ? parts[6] : ""
59+
self.lastFetchMethod = method
60+
}
61+
}
62+
63+
private func clearInfo() {
64+
self.title = ""
65+
self.artist = ""
66+
self.album = ""
67+
self.isPlaying = false
68+
self.position = 0.0
69+
self.duration = 0.0
70+
self.albumArtBase64 = ""
71+
self.lastFetchMethod = ""
72+
}
73+
74+
// MARK: - MediaRemote Fetch
75+
private func getMediaRemoteInfo(completion: @escaping (String?) -> Void) {
76+
let bundleURL = NSURL(fileURLWithPath: "/System/Library/PrivateFrameworks/MediaRemote.framework")
77+
guard let bundle = CFBundleCreate(kCFAllocatorDefault, bundleURL),
78+
let pointer = CFBundleGetFunctionPointerForName(bundle, "MRMediaRemoteGetNowPlayingInfo" as CFString) else {
79+
completion(nil)
80+
return
81+
}
82+
83+
typealias MRMediaRemoteGetNowPlayingInfoFunction = @convention(c) (DispatchQueue, @escaping ([String: Any]) -> Void) -> Void
84+
let MRMediaRemoteGetNowPlayingInfo = unsafeBitCast(pointer, to: MRMediaRemoteGetNowPlayingInfoFunction.self)
85+
86+
MRMediaRemoteGetNowPlayingInfo(DispatchQueue.global()) { info in
87+
let title = (info["kMRMediaRemoteNowPlayingInfoTitle"] as? String) ?? ""
88+
let artist = (info["kMRMediaRemoteNowPlayingInfoArtist"] as? String) ?? ""
89+
let album = (info["kMRMediaRemoteNowPlayingInfoAlbum"] as? String) ?? ""
90+
let rate = (info["kMRMediaRemoteNowPlayingInfoPlaybackRate"] as? Double) ?? 0.0
91+
let isPlaying = rate > 0.0 ? "true" : "false"
92+
let duration = (info["kMRMediaRemoteNowPlayingInfoDuration"] as? Double) ?? 0.0
93+
let position = (info["kMRMediaRemoteNowPlayingInfoElapsedTime"] as? Double) ?? 0.0
94+
95+
var albumArtBase64 = ""
96+
if let artworkData = info["kMRMediaRemoteNowPlayingInfoArtworkData"] as? Data {
97+
if let image = NSImage(data: artworkData) {
98+
let maxDim: CGFloat = 500.0
99+
var size = image.size
100+
if size.width > maxDim || size.height > maxDim {
101+
let ratio = min(maxDim / size.width, maxDim / size.height)
102+
size.width = round(size.width * ratio)
103+
size.height = round(size.height * ratio)
104+
let resized = NSImage(size: size)
105+
resized.lockFocus()
106+
image.draw(in: NSRect(origin: .zero, size: size), from: .zero, operation: .copy, fraction: 1.0)
107+
resized.unlockFocus()
108+
109+
if let tiff = resized.tiffRepresentation, let bitmap = NSBitmapImageRep(data: tiff) {
110+
if let jpegData = bitmap.representation(using: .jpeg, properties: [.compressionFactor: 0.5]) {
111+
albumArtBase64 = jpegData.base64EncodedString()
112+
}
113+
}
114+
} else {
115+
if let tiff = image.tiffRepresentation, let bitmap = NSBitmapImageRep(data: tiff) {
116+
if let jpegData = bitmap.representation(using: .jpeg, properties: [.compressionFactor: 0.5]) {
117+
albumArtBase64 = jpegData.base64EncodedString()
118+
}
119+
}
120+
}
121+
}
122+
}
123+
124+
if !title.isEmpty {
125+
completion("\(title)|||\(artist)|||\(album)|||\(isPlaying)|||\(position)|||\(duration)|||\(albumArtBase64)")
126+
} else {
127+
completion(nil)
128+
}
129+
}
130+
}
131+
132+
// MARK: - AppleScript Fetch
133+
private func getAppleScriptFallback(completion: @escaping (String?) -> Void) {
134+
DispatchQueue.global().async {
135+
let script = """
136+
set track_name to ""
137+
set track_artist to ""
138+
set track_album to ""
139+
set is_playing to "false"
140+
set track_duration to 0.0
141+
set track_position to 0.0
142+
143+
try
144+
if application "Spotify" is running then
145+
tell application "Spotify"
146+
if player state is playing or player state is paused then
147+
set track_name to name of current track
148+
set track_artist to artist of current track
149+
set track_album to album of current track
150+
if player state is playing then
151+
set is_playing to "true"
152+
end if
153+
set track_duration to (duration of current track) / 1000.0
154+
set track_position to player position
155+
end if
156+
end tell
157+
end if
158+
159+
if track_name is "" and application "Music" is running then
160+
tell application "Music"
161+
if player state is playing or player state is paused then
162+
set track_name to name of current track
163+
set track_artist to artist of current track
164+
set track_album to album of current track
165+
if player state is playing then
166+
set is_playing to "true"
167+
end if
168+
set track_duration to duration of current track
169+
set track_position to player position
170+
end if
171+
end tell
172+
end if
173+
on error
174+
-- ignore
175+
end try
176+
177+
if track_name is not "" then
178+
return track_name & "|||" & track_artist & "|||" & track_album & "|||" & is_playing & "|||" & track_position & "|||" & track_duration
179+
else
180+
return ""
181+
end if
182+
"""
183+
184+
let process = Process()
185+
process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
186+
process.arguments = ["-e", script]
187+
let pipe = Pipe()
188+
process.standardOutput = pipe
189+
190+
do {
191+
try process.run()
192+
process.waitUntilExit()
193+
let data = pipe.fileHandleForReading.readDataToEndOfFile()
194+
let output = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
195+
if let out = output, !out.isEmpty {
196+
completion(out)
197+
} else {
198+
completion(nil)
199+
}
200+
} catch {
201+
completion(nil)
202+
}
203+
}
204+
}
205+
206+
// MARK: - Control Commands
207+
func sendCommand(_ cmd: String) {
208+
if cmd == "PLAY" || cmd == "PAUSE" || cmd == "TOGGLE_PLAY" {
209+
executeAppleScript(command: "playpause")
210+
} else if cmd == "NEXT" {
211+
executeAppleScript(command: "next track")
212+
} else if cmd == "PREV" {
213+
executeAppleScript(command: "previous track")
214+
} else if cmd.starts(with: "SEEK:") {
215+
let posMs = cmd.dropFirst("SEEK:".count)
216+
if let ms = Double(posMs) {
217+
let sec = ms / 1000.0
218+
executeAppleScriptSeek(position: sec)
219+
}
220+
}
221+
}
222+
223+
private func executeAppleScript(command: String) {
224+
let script = """
225+
try
226+
if application "Spotify" is running then
227+
tell application "Spotify" to \(command)
228+
else if application "Music" is running then
229+
tell application "Music" to \(command)
230+
end if
231+
end try
232+
"""
233+
runOsascript(script)
234+
}
235+
236+
private func executeAppleScriptSeek(position: Double) {
237+
let script = """
238+
try
239+
if application "Spotify" is running then
240+
tell application "Spotify" to set player position to \(position)
241+
else if application "Music" is running then
242+
tell application "Music" to set player position to \(position)
243+
end if
244+
end try
245+
"""
246+
runOsascript(script)
247+
}
248+
249+
private func runOsascript(_ script: String) {
250+
DispatchQueue.global().async {
251+
let process = Process()
252+
process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
253+
process.arguments = ["-e", script]
254+
try? process.run()
255+
}
256+
}
257+
}

0 commit comments

Comments
 (0)