Skip to content
Merged
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
14 changes: 14 additions & 0 deletions airsync-mac/Core/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ class AppState: ObservableObject {
let savedFallbackToMdns = UserDefaults.standard.object(forKey: "fallbackToMdns")
self.fallbackToMdns = savedFallbackToMdns == nil ? true : UserDefaults.standard.bool(forKey: "fallbackToMdns")

let savedShowInMenubar = UserDefaults.standard.object(forKey: "showInMenubar")
self.showInMenubar = savedShowInMenubar == nil ? true : UserDefaults.standard.bool(forKey: "showInMenubar")

self.showMenubarText = UserDefaults.standard.bool(forKey: "showMenubarText")
self.showMenubarDeviceName = UserDefaults.standard.object(forKey: "showMenubarDeviceName") == nil ? true : UserDefaults.standard.bool(forKey: "showMenubarDeviceName")

Expand Down Expand Up @@ -541,6 +544,8 @@ class AppState: ObservableObject {
}
}

@Published var lastLicenseCheckFailureReason: String? = nil

@Published var adbPort: UInt16 {
didSet {
UserDefaults.standard.set(adbPort, forKey: "adbPort")
Expand Down Expand Up @@ -590,9 +595,18 @@ class AppState: ObservableObject {
}
}

@Published var showInMenubar: Bool {
didSet {
UserDefaults.standard.set(showInMenubar, forKey: "showInMenubar")
}
}

@Published var hideDockIcon: Bool {
didSet {
UserDefaults.standard.set(hideDockIcon, forKey: "hideDockIcon")
if hideDockIcon {
showInMenubar = true
}
updateDockIconVisibility()
}
}
Expand Down
15 changes: 9 additions & 6 deletions airsync-mac/Core/BLE/BLECentralManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,8 @@ class BLECentralManager: NSObject, ObservableObject {
func write(characteristicUUID: CBUUID, data: Data) {
resetWatchdog()
guard let peripheral = discoveredPeripheral, let char = characteristics[characteristicUUID] else { return }
peripheral.writeValue(data, for: char, type: .withoutResponse)
let writeType: CBCharacteristicWriteType = char.properties.contains(.write) ? .withResponse : .withoutResponse
peripheral.writeValue(data, for: char, type: writeType)
}

func writeChunked(characteristicUUID: CBUUID, payload: String) {
Expand Down Expand Up @@ -210,7 +211,6 @@ class BLECentralManager: NSObject, ObservableObject {
scanTimer = nil

connectingDeviceUUID = uuidStr
connectionStatus = .scanning
centralManager.connect(peripheral, options: [
CBConnectPeripheralOptionNotifyOnDisconnectionKey: true
])
Expand All @@ -234,8 +234,8 @@ class BLECentralManager: NSObject, ObservableObject {
private func resetWatchdog() {
DispatchQueue.main.async {
self.watchdogTimer?.invalidate()
self.watchdogTimer = Timer.scheduledTimer(withTimeInterval: 25.0, repeats: false) { [weak self] _ in
print("[BLE] Heartbeat timeout (25s), disconnecting...")
self.watchdogTimer = Timer.scheduledTimer(withTimeInterval: 120.0, repeats: false) { [weak self] _ in
print("[BLE] Heartbeat timeout (120s), disconnecting...")
self?.disconnect()
}
}
Expand Down Expand Up @@ -270,6 +270,9 @@ extension BLECentralManager: CBCentralManagerDelegate {

// Auto connect if enabled and not manually disconnected
if AppState.shared.isBLEAutoConnectEnabled && !isManuallyDisconnected {
guard connectionStatus == .disconnected || connectionStatus == .scanning else { return }
guard discoveredPeripheral == nil else { return }

let isWifiConnected = AppState.shared.device != nil && AppState.shared.device?.ipAddress != "BLE" && AppState.shared.device?.ipAddress != "Bluetooth LE"
if isWifiConnected {
print("[BLE] Regular Wi-Fi connection is active — skipping auto-connect to BLE")
Expand Down Expand Up @@ -456,9 +459,9 @@ extension BLECentralManager: CBPeripheralDelegate {
// Immediately notify Android of Mac status
WebSocketServer.shared.sendMacStatusOverBLE()

// Also trigger a full fetch (which includes media info)
// Also trigger a status update (which includes current media info)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
MacInfoSyncManager.shared.fetch()
MacInfoSyncManager.shared.forceRefresh()
}
} else {
print("[BLE] Auth Failed!")
Expand Down
9 changes: 7 additions & 2 deletions airsync-mac/Core/MenuBarManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ class MenuBarManager: NSObject {
appState.$device.map { _ in () }.eraseToAnyPublisher(),
appState.$notifications.map { _ in () }.eraseToAnyPublisher(),
appState.$status.map { _ in () }.eraseToAnyPublisher(),
appState.$showInMenubar.map { _ in () }.eraseToAnyPublisher(),
appState.$showMenubarText.map { _ in () }.eraseToAnyPublisher(),
appState.$showingQuickShareTransfer.map { _ in () }.eraseToAnyPublisher(),
appState.$showMenubarIcon.map { _ in () }.eraseToAnyPublisher(),
Expand Down Expand Up @@ -121,13 +122,17 @@ class MenuBarManager: NSObject {
}

func updateStatusItem() {
guard let button = statusItem?.button, let hostingView = hostingView else { return }
guard let statusItem = statusItem else { return }

let isVisible = appState.showInMenubar
statusItem.isVisible = isVisible
guard isVisible, let button = statusItem.button, let hostingView = hostingView else { return }

button.image = nil
button.title = ""

let fittingSize = hostingView.fittingSize
statusItem?.length = max(22, fittingSize.width)
statusItem.length = max(22, fittingSize.width)
}

func showDragLabel(_ label: String) {
Expand Down
177 changes: 133 additions & 44 deletions airsync-mac/Core/Util/CLI/NowPlayingCLI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,74 +49,163 @@ class NowPlayingCLI {
return nil
}

func fetchNowPlaying(completion: @escaping (NowPlayingInfo?) -> Void) {
private var streamProcess: Process?
private var streamPipe: Pipe?
private var onUpdateHandler: ((NowPlayingInfo?) -> Void)?
private var isStreamingActive = false
private var retryCount = 0
private let maxRetries = 10
private var lineBuffer = Data()

func startStreaming(onUpdate: @escaping (NowPlayingInfo?) -> Void) {
self.onUpdateHandler = onUpdate
self.isStreamingActive = true
self.retryCount = 0
launchStreamProcess()
}

func stopStreaming() {
self.isStreamingActive = false
self.retryCount = 0
self.onUpdateHandler = nil
terminateStreamProcess()
}

func resetRetryCount() {
self.retryCount = 0
}

private func terminateStreamProcess() {
if let process = streamProcess {
process.terminationHandler = nil
if process.isRunning {
process.terminate()
}
}
streamPipe?.fileHandleForReading.readabilityHandler = nil
streamProcess = nil
streamPipe = nil
lineBuffer.removeAll()
}

private func launchStreamProcess() {
guard isStreamingActive else { return }

guard let binPath = resolveBinaryPath() else {
// media-control not available; gracefully return nil
print("[now-playing] media-control binary not found. Install with: brew install media-control")
completion(Optional<NowPlayingInfo>.none)
print("[now-playing] media-control binary not found. Cannot stream updates.")
DispatchQueue.main.async { [weak self] in
self?.onUpdateHandler?(nil)
}
return
}

terminateStreamProcess()

let process = Process()
process.executableURL = URL(fileURLWithPath: binPath)
process.arguments = ["get"]
process.arguments = ["stream", "--no-diff"]

let pipe = Pipe()
process.standardOutput = pipe
process.standardError = pipe

let handle = pipe.fileHandleForReading
process.standardError = FileHandle.nullDevice

var buffer = Data()
let readHandle = pipe.fileHandleForReading

handle.readabilityHandler = { fileHandle in
let data = fileHandle.availableData
readHandle.readabilityHandler = { [weak self] handle in
let data = handle.availableData
guard !data.isEmpty else { return }
buffer.append(data)
self?.handleStreamData(data)
}

process.terminationHandler = { _ in
handle.readabilityHandler = nil
guard !buffer.isEmpty else {
DispatchQueue.main.async { completion(Optional<NowPlayingInfo>.none) }
return
}

// Try decoding the full JSON at once
if let rawString = String(data: buffer, encoding: .utf8) {
let trimmed = rawString.trimmingCharacters(in: .whitespacesAndNewlines)
// print("[now-playing] Full media-control output:", trimmed) // debug
process.terminationHandler = { [weak self] proc in
guard let self = self else { return }
readHandle.readabilityHandler = nil

// If media-control returns literal "null", treat as no media
if trimmed.isEmpty || trimmed.lowercased() == "null" {
DispatchQueue.main.async { completion(nil) }
return
}
DispatchQueue.main.async {
guard self.isStreamingActive else { return }

do {
let obj = try JSONSerialization.jsonObject(with: Data(trimmed.utf8))
if let dict = obj as? [String: Any] {
var info = NowPlayingInfo()
info.updateFromPayload(dict)
DispatchQueue.main.async { completion(info) }
} else {
// Not a dictionary (could be null/array) -> no media info
DispatchQueue.main.async { completion(nil) }
if self.retryCount < self.maxRetries {
self.retryCount += 1
print("[now-playing] media-control stream terminated unexpectedly. Retry \(self.retryCount)/\(self.maxRetries)")
let delay = min(Double(self.retryCount) * 0.5, 3.0)
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
self.launchStreamProcess()
}
} catch {
print("[now-playing] JSON parse error:", error)
DispatchQueue.main.async { completion(nil) }
} else {
print("[now-playing] media-control stream reached max retries (\(self.maxRetries)). Stopping retry attempts until reset.")
self.onUpdateHandler?(nil)
}
} else {
DispatchQueue.main.async { completion(nil) }
}
}

self.streamProcess = process
self.streamPipe = pipe

do {
try process.run()
print("[now-playing] Successfully launched media-control stream --no-diff")
} catch {
print("[now-playing] Failed to run media-control get:", error)
completion(Optional<NowPlayingInfo>.none)
print("[now-playing] Failed to launch media-control stream:", error)
process.terminationHandler = nil
if retryCount < maxRetries {
retryCount += 1
let delay = min(Double(retryCount) * 0.5, 3.0)
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
self?.launchStreamProcess()
}
}
}
}

private func handleStreamData(_ data: Data) {
lineBuffer.append(data)

while let newlineIndex = lineBuffer.firstIndex(of: UInt8(ascii: "\n")) {
let lineData = lineBuffer.subdata(in: 0..<newlineIndex)
lineBuffer.removeSubrange(0...newlineIndex)

guard !lineData.isEmpty else { continue }
parseStreamLine(lineData)
}
}

private func parseStreamLine(_ lineData: Data) {
guard let obj = try? JSONSerialization.jsonObject(with: lineData) else {
if let lineStr = String(data: lineData, encoding: .utf8) {
print("[now-playing] Raw line (non-JSON): \(lineStr)")
}
return
}

let dict: [String: Any]? = {
if let root = obj as? [String: Any] {
if let payload = root["payload"] as? [String: Any] {
return payload.isEmpty ? nil : payload
}
if root.keys.contains("type") && root.keys.contains("payload") {
return nil
}
return root.isEmpty ? nil : root
}
return nil
}()

let info: NowPlayingInfo? = {
guard let payload = dict, !payload.isEmpty else { return nil }
var item = NowPlayingInfo()
item.updateFromPayload(payload)
return item
}()

if let info = info {
print("[now-playing] Parsed update: '\(info.title ?? "")' by '\(info.artist ?? "")' (playing: \(info.isPlaying ?? false))")
} else {
print("[now-playing] Parsed update: no media payload")
}

DispatchQueue.main.async { [weak self] in
guard let self = self, self.isStreamingActive else { return }
self.onUpdateHandler?(info)
}
}

Expand Down
Loading