diff --git a/.gitignore b/.gitignore index 78144578..ef1803f1 100644 --- a/.gitignore +++ b/.gitignore @@ -75,3 +75,4 @@ AGENTS.md .tend-stack docs/plans/ build.log +*.trace \ No newline at end of file diff --git a/airsync-mac/Core/AppState.swift b/airsync-mac/Core/AppState.swift index 3d9304c2..5c8cf774 100644 --- a/airsync-mac/Core/AppState.swift +++ b/airsync-mac/Core/AppState.swift @@ -24,6 +24,7 @@ class AppState: ObservableObject { private var lastClipboardValue: String? = nil private var shouldSkipSave = false private var cancellables = Set() + private var bleWakeUpWorkItem: DispatchWorkItem? private static let licenseDetailsKey = "licenseDetails" @Published var isOS26: Bool = true @@ -103,6 +104,7 @@ class AppState: ObservableObject { self.useADBWhenPossible = UserDefaults.standard.object(forKey: "useADBWhenPossible") == nil ? true : UserDefaults.standard.bool(forKey: "useADBWhenPossible") self.useNativeMirroringByDefault = UserDefaults.standard.bool(forKey: "useNativeMirroringByDefault") + self.useNativeDesktopMirroringByDefault = UserDefaults.standard.bool(forKey: "useNativeDesktopMirroringByDefault") self.isMusicCardHidden = UserDefaults.standard.bool(forKey: "isMusicCardHidden") self.isCrashReportingEnabled = UserDefaults.standard.object(forKey: "isCrashReportingEnabled") == nil ? true : UserDefaults.standard.bool(forKey: "isCrashReportingEnabled") @@ -172,6 +174,7 @@ class AppState: ObservableObject { // Reset mirroring state on launch to prevent auto-opening if it was open during last session self.isNativeMirroring = false + self.isNativeDesktopMirroring = false startMediaTimer() @@ -206,23 +209,34 @@ class AppState: ObservableObject { self.selectedTab = .notifications } - // BLE scan management: pause when a regular (non-BLE) connection is active + // BLE connection management: Wi-Fi priority over BLE let isRegularConnection = device?.ipAddress != nil && device?.ipAddress != "BLE" let wasRegularConnection = oldValue?.ipAddress != nil && oldValue?.ipAddress != "BLE" - if isRegularConnection && !wasRegularConnection { - // Regular connection established — stop BLE scanning to save power/bandwidth - if isBLEEnabled && BLECentralManager.shared.connectionStatus == .scanning { - print("[state] Regular connection active — pausing BLE scan") + if isRegularConnection { + // Regular connection established — immediately put BLE to idle (disconnect & stop scan) + if isBLEEnabled { + print("[state] Regular connection active — disconnecting BLE and putting to idle") BLECentralManager.shared.stopScanning() + BLECentralManager.shared.disconnect() } + // Cancel any pending delayed BLE wake-up tasks + self.bleWakeUpWorkItem?.cancel() + self.bleWakeUpWorkItem = nil } else if !isRegularConnection && wasRegularConnection { - // Regular connection lost — resume BLE scanning if BLE is enabled and not already BLE-connected - if isBLEEnabled && !BLECentralManager.shared.isAuthenticated { - print("[state] Regular connection lost — resuming BLE scan") - BLECentralManager.shared.isManuallyDisconnected = false - BLECentralManager.shared.startScanning() + // Regular connection lost — schedule BLE scanning after 5 seconds to give Wi-Fi a chance to reconnect + self.bleWakeUpWorkItem?.cancel() + let workItem = DispatchWorkItem { [weak self] in + guard let self = self else { return } + let stillDisconnected = self.device?.ipAddress == nil || self.device?.ipAddress == "BLE" + if stillDisconnected && self.isBLEEnabled && !BLECentralManager.shared.isAuthenticated { + print("[state] Regular connection stayed lost for 5s — resuming BLE scan") + BLECentralManager.shared.isManuallyDisconnected = false + BLECentralManager.shared.startScanning() + } } + self.bleWakeUpWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + 5.0, execute: workItem) } } } @@ -307,7 +321,30 @@ class AppState: ObservableObject { @Published var adbConnectionMode: ADBConnectionMode? = nil @Published var recentApps: [AndroidApp] = [] - @Published var isNativeMirroring: Bool = false + @Published var isNativeMirroring: Bool = false { + didSet { + if isNativeMirroring { + if isSidebarMirroring { isSidebarMirroring = false } + if isNativeDesktopMirroring { isNativeDesktopMirroring = false } + } + } + } + @Published var isSidebarMirroring: Bool = false { + didSet { + if isSidebarMirroring { + if isNativeMirroring { isNativeMirroring = false } + if isNativeDesktopMirroring { isNativeDesktopMirroring = false } + } + } + } + @Published var isNativeDesktopMirroring: Bool = false { + didSet { + if isNativeDesktopMirroring { + if isNativeMirroring { isNativeMirroring = false } + if isSidebarMirroring { isSidebarMirroring = false } + } + } + } @Published var temporaryDragLabel: String? = nil // MARK: - Centralized Media Seekbar State @@ -644,6 +681,12 @@ class AppState: ObservableObject { } } + @Published var useNativeDesktopMirroringByDefault: Bool { + didSet { + UserDefaults.standard.set(useNativeDesktopMirroringByDefault, forKey: "useNativeDesktopMirroringByDefault") + } + } + @Published var isFileAccessEnabled: Bool { didSet { if !isPlus && licenseCheck { @@ -1092,9 +1135,8 @@ class AppState: ObservableObject { self.notifications.insert(notif, at: 0) } } - // Trigger native macOS notification if not silent and content actually changed/new - // Default to alerting if priority is missing (backwards compatibility) - if notif.priority != "silent" && contentChanged { + let isAppSilentOnMac = UserDefaults.standard.appSilentNotifications[notif.package] ?? false + if notif.priority != "silent" && !isAppSilentOnMac && contentChanged { var appIcon: NSImage? = nil if let iconPath = self.androidApps[notif.package]?.iconUrl { appIcon = NSImage(contentsOfFile: iconPath) diff --git a/airsync-mac/Core/BLE/BLECentralManager.swift b/airsync-mac/Core/BLE/BLECentralManager.swift index e7068910..5e8d18cd 100644 --- a/airsync-mac/Core/BLE/BLECentralManager.swift +++ b/airsync-mac/Core/BLE/BLECentralManager.swift @@ -212,6 +212,12 @@ extension BLECentralManager: CBCentralManagerDelegate { // Auto connect if enabled and not manually disconnected if AppState.shared.isBLEAutoConnectEnabled && !isManuallyDisconnected { + 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") + return + } + let token = UserDefaults.standard.string(forKey: "bleAuthToken") ?? "" if token.isEmpty { return diff --git a/airsync-mac/Core/MenuBarManager.swift b/airsync-mac/Core/MenuBarManager.swift index 293d12bc..98ee3753 100644 --- a/airsync-mac/Core/MenuBarManager.swift +++ b/airsync-mac/Core/MenuBarManager.swift @@ -15,6 +15,7 @@ class MenuBarManager: NSObject { private var statusItem: NSStatusItem? private var menubarPanel: MenubarPanel? private var eventMonitor: Any? + private var localEventMonitor: Any? private var cancellables = Set() private var appState = AppState.shared private var temporaryDragLabel: String? @@ -187,7 +188,7 @@ class MenuBarManager: NSObject { appState.isMenubarWindowOpen = true - // Monitor clicks outside to close + // Monitor clicks outside to close (both globally and locally within current app) eventMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) { [weak self] event in if let eventLocation = NSEvent.mouseLocation as NSPoint?, let panelFrame = self?.menubarPanel?.frame, @@ -195,6 +196,14 @@ class MenuBarManager: NSObject { self?.hidePopover() } } + localEventMonitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) { [weak self] event in + if let eventLocation = NSEvent.mouseLocation as NSPoint?, + let panelFrame = self?.menubarPanel?.frame, + !NSMouseInRect(eventLocation, panelFrame, false) { + self?.hidePopover() + } + return event + } } } @@ -205,6 +214,10 @@ class MenuBarManager: NSObject { NSEvent.removeMonitor(monitor) eventMonitor = nil } + if let localMonitor = localEventMonitor { + NSEvent.removeMonitor(localMonitor) + localEventMonitor = nil + } } } diff --git a/airsync-mac/Core/QuickConnect/QuickConnectManager.swift b/airsync-mac/Core/QuickConnect/QuickConnectManager.swift index e260371b..4d2365a2 100644 --- a/airsync-mac/Core/QuickConnect/QuickConnectManager.swift +++ b/airsync-mac/Core/QuickConnect/QuickConnectManager.swift @@ -44,11 +44,23 @@ class QuickConnectManager: ObservableObject { return } + let isBLE = device.ipAddress == "Bluetooth LE" || device.ipAddress == "BLE" + DispatchQueue.main.async { + if isBLE { + if let existing = self.lastConnectedDevices[currentMacIP] { + let existingIsBLE = existing.ipAddress == "Bluetooth LE" || existing.ipAddress == "BLE" + if !existingIsBLE { + print("[quick-connect] Not overwriting existing Wi-Fi device (\(existing.name): \(existing.ipAddress)) with BLE device") + return + } + } + } + self.lastConnectedDevices[currentMacIP] = device self.saveDeviceHistoryToDisk() + print("[quick-connect] Saved last connected device for network \(currentMacIP): \(device.name) (\(device.ipAddress))") } - print("[quick-connect] Saved last connected device for network \(currentMacIP): \(device.name) (\(device.ipAddress))") } /// Clears the last connected device for the current network @@ -64,8 +76,36 @@ class QuickConnectManager: ObservableObject { /// Attempts to wake up and reconnect to a specific discovered device func connect(to discoveredDevice: DiscoveredDevice) { + let hasWifiIPs = discoveredDevice.ips.contains { $0 != "Bluetooth LE" && !$0.isEmpty } + if (discoveredDevice.type == "ble" || discoveredDevice.ips.contains("Bluetooth LE")) && !hasWifiIPs { + print("[quick-connect] Discovered device is Bluetooth LE, connecting directly via BLE...") + // Show progress in UI + DispatchQueue.main.async { + self.connectingDeviceID = discoveredDevice.id + } + + // Save as last connected device (so we can auto-reconnect later) + let device = Device( + name: discoveredDevice.name, + ipAddress: "Bluetooth LE", + port: discoveredDevice.port, + version: "Unknown", + adbPorts: [], + deviceId: discoveredDevice.deviceId + ) + saveLastConnectedDevice(device) + + BLECentralManager.shared.connectManually(toUuid: discoveredDevice.deviceId) + + // Clear progress after short delay + DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { + self.connectingDeviceID = nil + } + return + } + // Pick best IP: prefer local (non-100.x) over VPN - let bestIP = discoveredDevice.ips.first(where: { !$0.hasPrefix("100.") }) ?? discoveredDevice.ips.first ?? "" + let bestIP = discoveredDevice.ips.first(where: { !$0.hasPrefix("100.") && $0 != "Bluetooth LE" }) ?? discoveredDevice.ips.first(where: { $0 != "Bluetooth LE" }) ?? "" // Convert DiscoveredDevice to Device model let device = Device( @@ -98,6 +138,12 @@ class QuickConnectManager: ObservableObject { return } + if lastDevice.ipAddress == "Bluetooth LE" { + print("[quick-connect] Last connected device is BLE. Initiating BLE reconnection...") + BLECentralManager.shared.connectManually(toUuid: lastDevice.deviceId) + return + } + print("[quick-connect] Attempting to wake up device: \(lastDevice.name) at \(lastDevice.ipAddress)") print("[quick-connect] Will try HTTP port \(Self.ANDROID_HTTP_WAKEUP_PORT), then UDP port \(Self.ANDROID_UDP_WAKEUP_PORT) if needed") diff --git a/airsync-mac/Core/Remote/Scrcpy/MetalVideoView.swift b/airsync-mac/Core/Remote/Scrcpy/MetalVideoView.swift index da675ccf..178cfb41 100644 --- a/airsync-mac/Core/Remote/Scrcpy/MetalVideoView.swift +++ b/airsync-mac/Core/Remote/Scrcpy/MetalVideoView.swift @@ -29,73 +29,203 @@ struct MetalVideoView: NSViewRepresentable { class ScrcpyMTKView: MTKView { var streamClient: ScrcpyStreamClient? - override func mouseDown(with event: NSEvent) { sendTouchEvent(action: 0, event: event) } + override func mouseDown(with event: NSEvent) { + window?.makeFirstResponder(self) + sendTouchEvent(action: 0, event: event) + } override func mouseUp(with event: NSEvent) { sendTouchEvent(action: 1, event: event) } override func mouseDragged(with event: NSEvent) { sendTouchEvent(action: 2, event: event) } - // Secondary click as "Back" button (AKEYCODE_BACK = 4) - override func rightMouseDown(with event: NSEvent) { ScrcpyControlClient.shared.sendKeyEvent(action: 0, keycode: 4) } - override func rightMouseUp(with event: NSEvent) { ScrcpyControlClient.shared.sendKeyEvent(action: 1, keycode: 4) } - - private var scrollDragY: Double = 0 - private var isVirtualScrolling: Bool = false - private var scrollTimer: Timer? - - override func scrollWheel(with event: NSEvent) { - guard let client = streamClient, client.videoWidth > 0, client.videoHeight > 0 else { return } - - let centerX = UInt32(Double(client.videoWidth) / 2.0) - let centerY = UInt32(Double(client.videoHeight) / 2.0) - - // NSEvent phases provide much more accurate lifecycle for trackpad gestures - let phase = event.phase - let momentumPhase = event.momentumPhase - - if phase == .began { - isVirtualScrolling = true - scrollDragY = Double(centerY) - sendVirtualTouch(action: 0, x: centerX, y: UInt32(scrollDragY), client: client) - } else if phase == .changed || (phase == [] && momentumPhase == []) { - // Handle actual scrolling movement - if !isVirtualScrolling { - isVirtualScrolling = true - scrollDragY = Double(centerY) - sendVirtualTouch(action: 0, x: centerX, y: UInt32(scrollDragY), client: client) + // Keyboard event handling + override func keyDown(with event: NSEvent) { + if let keycode = androidKeycode(for: event.keyCode) { + var metaState: UInt32 = 0 + let flags = event.modifierFlags + let swap = UserDefaults.standard.swapCmdAndCtrl + + if flags.contains(.shift) { metaState |= 0x01 } + if flags.contains(.option) { metaState |= 0x02 } + if flags.contains(.capsLock) { metaState |= 0x100000 } + + if swap { + if flags.contains(.control) { metaState |= 0x10000 } // Control -> Meta + if flags.contains(.command) { metaState |= 0x1000 } // Command -> Control + } else { + if flags.contains(.control) { metaState |= 0x1000 } + if flags.contains(.command) { metaState |= 0x10000 } } - // Increase sensitivity and invert for "Natural" feel - let sensitivity: Double = event.hasPreciseScrollingDeltas ? 1.5 : 10.0 - scrollDragY += Double(event.scrollingDeltaY) * sensitivity - scrollDragY = max(0, min(Double(client.videoHeight), scrollDragY)) + ScrcpyControlClient.shared.sendKeyEvent(action: 0, keycode: keycode, metaState: metaState) + } else { + super.keyDown(with: event) + } + } + + override func keyUp(with event: NSEvent) { + if let keycode = androidKeycode(for: event.keyCode) { + var metaState: UInt32 = 0 + let flags = event.modifierFlags + let swap = UserDefaults.standard.swapCmdAndCtrl + + if flags.contains(.shift) { metaState |= 0x01 } + if flags.contains(.option) { metaState |= 0x02 } + if flags.contains(.capsLock) { metaState |= 0x100000 } - sendVirtualTouch(action: 2, x: centerX, y: UInt32(scrollDragY), client: client) + if swap { + if flags.contains(.control) { metaState |= 0x10000 } // Control -> Meta + if flags.contains(.command) { metaState |= 0x1000 } // Command -> Control + } else { + if flags.contains(.control) { metaState |= 0x1000 } + if flags.contains(.command) { metaState |= 0x10000 } + } + + ScrcpyControlClient.shared.sendKeyEvent(action: 1, keycode: keycode, metaState: metaState) + } else { + super.keyUp(with: event) } + } + + private func androidKeycode(for macKeycode: UInt16) -> UInt32? { + let swap = UserDefaults.standard.swapCmdAndCtrl + switch macKeycode { + // Modifiers + case 59: return swap ? 117 : 113 // Left Control + case 55: return swap ? 113 : 117 // Left Command/Meta + case 58: return 57 // Left Option/Alt + case 56: return 59 // Left Shift + case 62: return swap ? 118 : 114 // Right Control + case 54: return swap ? 114 : 118 // Right Command/Meta + case 61: return 58 // Right Option/Alt + case 60: return 60 // Right Shift - // End virtual touch session - if phase == .ended || phase == .cancelled || momentumPhase == .ended || momentumPhase == .cancelled { - if isVirtualScrolling { - sendVirtualTouch(action: 1, x: centerX, y: UInt32(scrollDragY), client: client) - isVirtualScrolling = false - } - scrollTimer?.invalidate() - scrollTimer = nil - } else if phase == [] && momentumPhase == [] { - // Fallback for traditional mice wheel (timer based) - scrollTimer?.invalidate() - scrollTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: false) { [weak self] _ in - guard let self = self, let client = self.streamClient, self.isVirtualScrolling else { return } - self.sendVirtualTouch(action: 1, x: centerX, y: UInt32(self.scrollDragY), client: client) - self.isVirtualScrolling = false - } + // Letters + case 0: return 29 // A + case 11: return 30 // B + case 8: return 31 // C + case 2: return 32 // D + case 14: return 33 // E + case 3: return 34 // F + case 5: return 35 // G + case 4: return 36 // H + case 34: return 37 // I + case 38: return 38 // J + case 40: return 39 // K + case 37: return 40 // L + case 46: return 41 // M + case 45: return 42 // N + case 31: return 43 // O + case 35: return 44 // P + case 12: return 45 // Q + case 15: return 46 // R + case 1: return 47 // S + case 17: return 48 // T + case 32: return 49 // U + case 9: return 50 // V + case 13: return 51 // W + case 7: return 52 // X + case 16: return 53 // Y + case 6: return 54 // Z + + // Numbers + case 29: return 7 // 0 + case 18: return 8 // 1 + case 19: return 9 // 2 + case 20: return 10 // 3 + case 21: return 11 // 4 + case 23: return 12 // 5 + case 22: return 13 // 6 + case 26: return 14 // 7 + case 28: return 15 // 8 + case 25: return 16 // 9 + + // Special / Navigation + case 36: return 66 // Enter + case 51: return 67 // Delete (Backspace) + case 53: return 111 // Escape + case 48: return 61 // Tab + case 49: return 62 // Space + case 123: return 21 // Left + case 124: return 22 // Right + case 126: return 19 // Up + case 125: return 20 // Down + + // Additional symbols + case 24: return 81 // Plus/Equal + case 27: return 69 // Minus + case 33: return 71 // Left Bracket + case 30: return 72 // Right Bracket + case 42: return 73 // Backslash + case 41: return 74 // Semicolon + case 39: return 75 // Apostrophe + case 43: return 55 // Comma + case 47: return 56 // Period + case 44: return 76 // Slash + case 50: return 68 // Grave (Backtick) + + default: return nil } } - private func sendVirtualTouch(action: UInt8, x: UInt32, y: UInt32, client: ScrcpyStreamClient) { - ScrcpyControlClient.shared.sendTouchEvent( - action: action, - x: x, y: y, + private var rightClickTimer: Timer? + private var rightClickDidTriggerHome = false + + private func sendHomeButton() { + ScrcpyControlClient.shared.sendKeyEvent(action: 0, keycode: 3) + ScrcpyControlClient.shared.sendKeyEvent(action: 1, keycode: 3) + } + + // Secondary click: long press sends Home (3), short press sends Back (4) + override func rightMouseDown(with event: NSEvent) { + rightClickDidTriggerHome = false + rightClickTimer?.invalidate() + rightClickTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { [weak self] _ in + self?.rightClickDidTriggerHome = true + self?.sendHomeButton() + } + } + + override func rightMouseUp(with event: NSEvent) { + rightClickTimer?.invalidate() + rightClickTimer = nil + if !rightClickDidTriggerHome { + ScrcpyControlClient.shared.sendKeyEvent(action: 0, keycode: 4) + ScrcpyControlClient.shared.sendKeyEvent(action: 1, keycode: 4) + } + } + + // Middle click sends Home (3) + override func otherMouseDown(with event: NSEvent) { + if event.buttonNumber == 2 { + sendHomeButton() + } else { + super.otherMouseDown(with: event) + } + } + + override func scrollWheel(with event: NSEvent) { + guard let client = streamClient, client.videoWidth > 0, client.videoHeight > 0 else { return } + + let point = convert(event.locationInWindow, from: nil) + + // Coordinate mapping: NSView (flipped Y) to Android (0,0 is top-left) + let x = Int32(max(0, min(1.0, point.x / frame.width)) * Double(client.videoWidth)) + let y = Int32(max(0, min(1.0, 1.0 - (point.y / frame.height))) * Double(client.videoHeight)) + + // For scrcpy scroll events, positive scrolls left/up, negative scrolls right/down. + // On macOS: + // - scrollingDeltaY is positive when scrolling up (moving page down) + // - scrollingDeltaX is positive when scrolling left (moving page right) + // We pass the delta values directly so Android handles continuous/precise scrolling smoothly. + let scrollX = Float(event.scrollingDeltaX) + let scrollY = Float(event.scrollingDeltaY) + + ScrcpyControlClient.shared.sendScrollEvent( + x: x, + y: y, width: UInt16(client.videoWidth), - height: UInt16(client.videoHeight) + height: UInt16(client.videoHeight), + scrollX: scrollX, + scrollY: scrollY ) } diff --git a/airsync-mac/Core/Remote/Scrcpy/ScrcpyControlClient.swift b/airsync-mac/Core/Remote/Scrcpy/ScrcpyControlClient.swift index 42a5d074..2c967661 100644 --- a/airsync-mac/Core/Remote/Scrcpy/ScrcpyControlClient.swift +++ b/airsync-mac/Core/Remote/Scrcpy/ScrcpyControlClient.swift @@ -70,7 +70,7 @@ class ScrcpyControlClient { send(data: data) } - func sendKeyEvent(action: UInt8, keycode: UInt32) { + func sendKeyEvent(action: UInt8, keycode: UInt32, metaState: UInt32 = 0) { var data = Data() data.append(0) // Type 0: Inject key event data.append(action) // 0: down, 1: up @@ -83,12 +83,58 @@ class ScrcpyControlClient { withUnsafeBytes(of: repeatCount.bigEndian) { data.append(contentsOf: $0) } // Meta state (4 bytes) - let metaState: UInt32 = 0 withUnsafeBytes(of: metaState.bigEndian) { data.append(contentsOf: $0) } send(data: data) } + func sendScrollEvent(x: Int32, y: Int32, width: UInt16, height: UInt16, scrollX: Float, scrollY: Float) { + var data = Data() + data.append(3) // Type 3: Inject scroll event + + // Position X, Y (4 bytes each) + withUnsafeBytes(of: x.bigEndian) { data.append(contentsOf: $0) } + withUnsafeBytes(of: y.bigEndian) { data.append(contentsOf: $0) } + + // Width, Height (2 bytes each) + withUnsafeBytes(of: width.bigEndian) { data.append(contentsOf: $0) } + withUnsafeBytes(of: height.bigEndian) { data.append(contentsOf: $0) } + + // Convert to 16-bit fixed point with separate sensitivity multipliers (horizontal is inverted) + let hSensitivity: Float = 0.05 + let vSensitivity: Float = 0.05 + let hscroll = floatToI16fp((-scrollX * hSensitivity) / 16.0) + let vscroll = floatToI16fp((scrollY * vSensitivity) / 16.0) + + withUnsafeBytes(of: hscroll.bigEndian) { data.append(contentsOf: $0) } + withUnsafeBytes(of: vscroll.bigEndian) { data.append(contentsOf: $0) } + + // Buttons (4 bytes) + let buttons: UInt32 = 0 + withUnsafeBytes(of: buttons.bigEndian) { data.append(contentsOf: $0) } + + send(data: data) + } + + private func floatToI16fp(_ f: Float) -> Int16 { + let clamped = max(-1.0, min(1.0, f)) + var i = Int32(clamped * 32768.0) + if i >= 32767 { + i = 32767 + } else if i <= -32768 { + i = -32768 + } + return Int16(i) + } + + func sendResizeDisplay(width: UInt16, height: UInt16) { + var data = Data() + data.append(21) // Type 21: Resize display + withUnsafeBytes(of: width.bigEndian) { data.append(contentsOf: $0) } + withUnsafeBytes(of: height.bigEndian) { data.append(contentsOf: $0) } + send(data: data) + } + private func send(data: Data) { connection?.send(content: data, completion: .contentProcessed({ error in if let error = error { diff --git a/airsync-mac/Core/Remote/Scrcpy/ScrcpyServerManager.swift b/airsync-mac/Core/Remote/Scrcpy/ScrcpyServerManager.swift index 560f1d37..f5cd51e0 100644 --- a/airsync-mac/Core/Remote/Scrcpy/ScrcpyServerManager.swift +++ b/airsync-mac/Core/Remote/Scrcpy/ScrcpyServerManager.swift @@ -16,7 +16,7 @@ class ScrcpyServerManager: NSObject { private var adbProcess: Process? - func startServer(serial: String, completion: @escaping (Bool) -> Void) { + func startServer(serial: String, desktopMode: Bool = false, completion: @escaping (Bool) -> Void) { let adbPath = ADBConnector.findExecutable(named: "adb", fallbackPaths: ADBConnector.possibleADBPaths) ?? "/opt/homebrew/bin/adb" // Step 0: Cleanup previous instances and port forwards @@ -51,7 +51,7 @@ class ScrcpyServerManager: NSObject { } // Step 3: Launch server - self.launchServer(serial: serial, completion: completion) + self.launchServer(serial: serial, desktopMode: desktopMode, completion: completion) } } } @@ -93,21 +93,36 @@ class ScrcpyServerManager: NSObject { private var launchCompletion: ((Bool) -> Void)? private var launchTimer: Timer? - func launchServer(serial: String, completion: @escaping (Bool) -> Void) { + func launchServer(serial: String, desktopMode: Bool = false, completion: @escaping (Bool) -> Void) { let adbPath = ADBConnector.findExecutable(named: "adb", fallbackPaths: ADBConnector.possibleADBPaths) ?? "/opt/homebrew/bin/adb" let process = Process() process.executableURL = URL(fileURLWithPath: adbPath) - // Explicitly include video=true and bit_rate/max_size + var serverArgs = [ + "tunnel_forward=true", "audio=false", "video=true", "control=true", + "video_codec=h265", "video_bit_rate=8000000" + ] + + if desktopMode { + // Virtual display — new_display requires "WxH" or "WxH/DPI" format + let dpi = UserDefaults.standard.scrcpyDesktopDpi + if !dpi.isEmpty { + serverArgs.append("new_display=1920x1080/\(dpi)") + } else { + serverArgs.append("new_display=1920x1080") + } + serverArgs.append("flex_display=true") + } else { + serverArgs.append("max_size=1440") + } + process.arguments = [ "-s", serial, "shell", "CLASSPATH=\(serverRemotePath)", - "app_process", "/", "com.genymobile.scrcpy.Server", "4.0", - "tunnel_forward=true", "audio=false", "video=true", "control=true", - "video_codec=h265", "video_bit_rate=8000000", "max_size=1440" - ] + "app_process", "/", "com.genymobile.scrcpy.Server", "4.0" + ] + serverArgs self.adbProcess = process self.launchCompletion = completion @@ -125,8 +140,8 @@ class ScrcpyServerManager: NSObject { guard !trimmed.isEmpty else { continue } print("[scrcpy-server] \(trimmed)") - // Detect readiness: "[server] INFO: Video size: ..." - if trimmed.contains("INFO: Video size:") { + // Detect readiness: "[server] INFO: Video size: ..." or "[server] INFO: New display: ..." + if trimmed.contains("INFO: Video size:") || trimmed.contains("INFO: New display:") { DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { self?.launchTimer?.invalidate() self?.launchTimer = nil @@ -170,7 +185,15 @@ class ScrcpyServerManager: NSObject { } func stopServer() { - adbProcess?.terminate() + if let process = adbProcess { + if let pipe = process.standardOutput as? Pipe { + pipe.fileHandleForReading.readabilityHandler = nil + } + if let pipe = process.standardError as? Pipe { + pipe.fileHandleForReading.readabilityHandler = nil + } + process.terminate() + } adbProcess = nil // Remove port forward @@ -187,4 +210,64 @@ class ScrcpyServerManager: NSObject { print("[ScrcpyServerManager] Failed to remove port forward: \(error)") } } + + func startMirroringSession(appState: AppState, streamClient: ScrcpyStreamClient, desktopMode: Bool = false, completion: @escaping (Bool, String?) -> Void) { + // Stop any current session first to prevent port/process clashes + self.stopMirroringSession(streamClient: streamClient) + + let wiredAdbEnabled = appState.wiredAdbEnabled + let wirelessAddress = "\(appState.adbConnectedIP):\(appState.adbPort)" + let adbConnected = appState.adbConnected + + ADBConnector.getWiredDevices { devices in + let mappedSerial = appState.selectedWiredSerial ?? (appState.device?.deviceId).flatMap { appState.deviceAdbSerials[$0] } + let serialToUse: String? + if let mapped = mappedSerial, devices.contains(where: { $0.serial == mapped }) { + serialToUse = mapped + } else if mappedSerial == nil { + serialToUse = devices.first?.serial + } else { + serialToUse = nil + } + + let finalSerial: String? + if wiredAdbEnabled, let serial = serialToUse { + finalSerial = serial + } else if adbConnected && !appState.adbConnectedIP.isEmpty { + finalSerial = wirelessAddress + } else { + finalSerial = nil + } + + guard let serial = finalSerial else { + DispatchQueue.main.async { + completion(false, "No ADB device detected. Please connect your device via USB or Wi-Fi.") + } + return + } + + self.startServer(serial: serial, desktopMode: desktopMode) { success in + guard success else { + DispatchQueue.main.async { + completion(false, "Failed to start scrcpy server on device.") + } + return + } + DispatchQueue.main.async { + streamClient.onPacketReceived = { data, isConfig, isKeyframe, pts in + ScrcpyVideoDecoder.shared.decodePacket(data: data, isConfig: isConfig, pts: pts) + } + streamClient.connect() + ScrcpyControlClient.shared.connect() + completion(true, nil) + } + } + } + } + + func stopMirroringSession(streamClient: ScrcpyStreamClient) { + streamClient.disconnect() + ScrcpyControlClient.shared.disconnect() + self.stopServer() + } } diff --git a/airsync-mac/Core/Storage/UserDefaults.swift b/airsync-mac/Core/Storage/UserDefaults.swift index 24d56122..ac7c744e 100644 --- a/airsync-mac/Core/Storage/UserDefaults.swift +++ b/airsync-mac/Core/Storage/UserDefaults.swift @@ -38,6 +38,7 @@ extension UserDefaults { static let trialExpiryDate = "trialExpiryDate" static let trialDeviceIdentifier = "trialDeviceIdentifier" static let trialLastSync = "trialLastSync" + static let appSilentNotifications = "appSilentNotifications" } var consecutiveLicenseFailCount: Int { @@ -188,6 +189,16 @@ extension UserDefaults { set { set(newValue, forKey: Keys.trialLastSync) } } + var swapCmdAndCtrl: Bool { + get { object(forKey: "swapCmdAndCtrl") == nil ? true : bool(forKey: "swapCmdAndCtrl") } + set { set(newValue, forKey: "swapCmdAndCtrl") } + } + + var showMirrorControls: Bool { + get { object(forKey: "showMirrorControls") == nil ? true : bool(forKey: "showMirrorControls") } + set { set(newValue, forKey: "showMirrorControls") } + } + // MARK: - String-based Onboarding Tracking var lastOnboarding: String? { @@ -217,5 +228,10 @@ extension UserDefaults { func resetOnboarding() { lastOnboarding = "000" } + + var appSilentNotifications: [String: Bool] { + get { dictionary(forKey: Keys.appSilentNotifications) as? [String: Bool] ?? [:] } + set { set(newValue, forKey: Keys.appSilentNotifications) } + } } diff --git a/airsync-mac/Core/Util/CLI/ADBConnector.swift b/airsync-mac/Core/Util/CLI/ADBConnector.swift index 51f8ae61..2be8cc21 100644 --- a/airsync-mac/Core/Util/CLI/ADBConnector.swift +++ b/airsync-mac/Core/Util/CLI/ADBConnector.swift @@ -425,7 +425,15 @@ struct ADBConnector { let mappedSerial = AppState.shared.selectedWiredSerial ?? (AppState.shared.device?.deviceId).flatMap { AppState.shared.deviceAdbSerials[$0] } getWiredDevices { devices in - let serialToUse = mappedSerial ?? devices.first?.serial + let serialToUse: String? + if let mapped = mappedSerial, devices.contains(where: { $0.serial == mapped }) { + serialToUse = mapped + } else if mappedSerial == nil { + serialToUse = devices.first?.serial + } else { + serialToUse = nil + } + DispatchQueue.global(qos: .userInitiated).async { if wiredAdbEnabled, let serial = serialToUse { args.append("--serial=\(serial)") @@ -519,7 +527,15 @@ struct ADBConnector { let mappedSerial = AppState.shared.selectedWiredSerial ?? (AppState.shared.device?.deviceId).flatMap { AppState.shared.deviceAdbSerials[$0] } getWiredDevices { devices in - let serialToUse = mappedSerial ?? devices.first?.serial + let serialToUse: String? + if let mapped = mappedSerial, devices.contains(where: { $0.serial == mapped }) { + serialToUse = mapped + } else if mappedSerial == nil { + serialToUse = devices.first?.serial + } else { + serialToUse = nil + } + DispatchQueue.global(qos: .userInitiated).async { guard let adbPath = findExecutable(named: "adb", fallbackPaths: possibleADBPaths) else { DispatchQueue.main.async { AppState.shared.isADBTransferring = false } @@ -562,7 +578,15 @@ struct ADBConnector { let mappedSerial = AppState.shared.selectedWiredSerial ?? (AppState.shared.device?.deviceId).flatMap { AppState.shared.deviceAdbSerials[$0] } getWiredDevices { devices in - let serialToUse = mappedSerial ?? devices.first?.serial + let serialToUse: String? + if let mapped = mappedSerial, devices.contains(where: { $0.serial == mapped }) { + serialToUse = mapped + } else if mappedSerial == nil { + serialToUse = devices.first?.serial + } else { + serialToUse = nil + } + DispatchQueue.global(qos: .userInitiated).async { guard let adbPath = findExecutable(named: "adb", fallbackPaths: possibleADBPaths) else { DispatchQueue.main.async { AppState.shared.isADBTransferring = false } diff --git a/airsync-mac/Core/WebSocket/WebSocketServer+Handlers.swift b/airsync-mac/Core/WebSocket/WebSocketServer+Handlers.swift index 93d5a2c3..ea5c5d33 100644 --- a/airsync-mac/Core/WebSocket/WebSocketServer+Handlers.swift +++ b/airsync-mac/Core/WebSocket/WebSocketServer+Handlers.swift @@ -658,7 +658,9 @@ extension WebSocketServer { private func handleRemoteControl(_ message: Message) { if let dict = message.data.value as? [String: Any], let action = dict["action"] as? String { + #if DEBUG print("[WebSocketServer] Received remote action: \(action)") + #endif switch action { case "keypress": diff --git a/airsync-mac/Core/WebSocket/WebSocketServer.swift b/airsync-mac/Core/WebSocket/WebSocketServer.swift index f2930fe1..7b89407d 100644 --- a/airsync-mac/Core/WebSocket/WebSocketServer.swift +++ b/airsync-mac/Core/WebSocket/WebSocketServer.swift @@ -35,6 +35,7 @@ class WebSocketServer: ObservableObject { internal let networkCheckInterval: TimeInterval = 10.0 internal let lock = NSRecursiveLock() internal let fileQueue = DispatchQueue(label: "com.airsync.fileio") + private let jsonDecoder = JSONDecoder() internal var servers: [String: HttpServer] = [:] internal var isListeningOnAll = false @@ -182,7 +183,7 @@ class WebSocketServer: ObservableObject { if let data = decryptedText.data(using: .utf8) { do { - let message = try JSONDecoder().decode(Message.self, from: data) + let message = try self.jsonDecoder.decode(Message.self, from: data) self.lock.lock() self.lastActivity[ObjectIdentifier(session)] = Date() self.lock.unlock() diff --git a/airsync-mac/Localization/en.json b/airsync-mac/Localization/en.json index 8af4550b..b0f4b695 100644 --- a/airsync-mac/Localization/en.json +++ b/airsync-mac/Localization/en.json @@ -5,6 +5,7 @@ "menu.about": "About AirSync", "menu.checkUpdates": "Check for Updates…", "menu.quit": "Quit", + "button.connect": "Connect", "home.title": "AirSync", "home.sidebar.ready": "Ready", "onboarding.title": "Welcome to AirSync", @@ -19,6 +20,26 @@ "settings.tab": "Settings", "settings.myMac": "My Mac", "settings.sync": "Sync", + "settings.notifications": "Notifications & Alerts", + "settings.notifications.apps": "Apps", + "settings.notifications.sync": "Notifications Sync", + "settings.notifications.dismiss": "Sync notification dismissals", + "settings.notifications.system": "System Notifications", + "settings.notifications.sound.default": "Default", + "settings.notifications.grant": "Grant Permission", + "settings.notifications.calls": "Call Alerts", + "settings.notifications.callAlert": "Call Alert", + "settings.notifications.ring": "Ring for calls", + "settings.notifications.apps.connect": "Connect your Android to see the list of apps", + "settings.notifications.apps.none": "No apps found", + "settings.notifications.app.settings": "%@ Notification Settings", + "settings.notifications.app.noSettings": "No settings yet", + "settings.notifications.app.title": "App notifications", + "settings.notifications.app.priority": "Priority", + "settings.notifications.app.priority.alert": "Alert", + "settings.notifications.app.priority.silent": "Silent", + "settings.notifications.close": "Close", + "settings.notifications.mediaPlayback": "Media Playback", "settings.mirroring": "Mirroring", "settings.menubar": "Menubar", "settings.appearance": "Appearance & more", @@ -74,9 +95,10 @@ "settings.fileAccess.description": "Mount your Android device storage as a local drive in macOS Finder automatically.", "settings.mirroring.scrcpy.title": "scrcpy Mirror", "settings.mirroring.scrcpy.description": "scrcpy is a reliable integration built-in to AirSync", - "settings.mirroring.native.title": "Android Mirror (BETA)", + "settings.mirroring.native.title": "Native Mirror (BETA)", "settings.mirroring.native.description": "As close as you can get to iPhone Mirroring XD. Powered by scrcpy. WIP", - "settings.mirroring.defaultMode": "Default mode", + "settings.mirroring.defaultMode": "Android Mirror Mode", + "settings.mirroring.desktop.defaultMode": "Desktop Mirror Mode", "settings.mirroring.native.info": "Native Android Mirroring powered by scrcpy to feel just like iPhone Mirroring. WIP. You can always right click the mirror button for other options.", "settings.mirroring.scrcpy.info": "Integration with scrcpy to provide reliable Android Mirroring. You can always right click the mirror button for other options.", "settings.mirroring.appMirroring": "App Mirroring", @@ -96,6 +118,9 @@ "settings.mirroring.y": "y", "settings.mirroring.set": "Set", "settings.mirroring.plusFeatureMessage": "App Mirroring is an AirSync+ feature", + "settings.mirroring.androidMirroring": "Android mirroring settings", + "settings.mirroring.swapCmdCtrl": "Swap ⌘ and ⌃ (macOS familiar)", + "settings.mirroring.showMirrorControls": "Show navigation buttons (⌘B)", "quickshare.copy": "Copy to clipboard", "settings.menubar.showIcon": "Show Menu Bar Icon", "settings.menubar.showText": "Show Menu Bar Text", diff --git a/airsync-mac/Model/SettingsTab.swift b/airsync-mac/Model/SettingsTab.swift index d4e97a02..35354ae8 100644 --- a/airsync-mac/Model/SettingsTab.swift +++ b/airsync-mac/Model/SettingsTab.swift @@ -10,6 +10,7 @@ import SwiftUI enum SettingsTab: String, CaseIterable, Identifiable { case myMac = "my_mac" case sync = "sync" + case notifications = "notifications" case mirroring = "mirroring" case quickShare = "quick_share" case menubar = "menubar" @@ -24,6 +25,8 @@ enum SettingsTab: String, CaseIterable, Identifiable { return L("settings.myMac") case .sync: return L("settings.sync") + case .notifications: + return L("settings.notifications") case .mirroring: return L("settings.mirroring") case .quickShare: @@ -43,6 +46,8 @@ enum SettingsTab: String, CaseIterable, Identifiable { return DeviceTypeUtil.deviceIconName() case .sync: return "arrow.triangle.2.circlepath" + case .notifications: + return "bell" case .mirroring: return "apps.iphone.badge.plus" case .quickShare: diff --git a/airsync-mac/Screens/HomeScreen/AppContentView.swift b/airsync-mac/Screens/HomeScreen/AppContentView.swift index ea73420b..ad57168f 100644 --- a/airsync-mac/Screens/HomeScreen/AppContentView.swift +++ b/airsync-mac/Screens/HomeScreen/AppContentView.swift @@ -24,6 +24,7 @@ struct AppContentView: View { // Label("Scan", systemImage: "qrcode") } .tag(TabIdentifier.qr) + .help(L("qr.tab")) .toolbar { ToolbarItemGroup { Button("Help", systemImage: "questionmark.circle") { @@ -51,6 +52,7 @@ struct AppContentView: View { // Label("Notifications", systemImage: "bell.badge") } .tag(TabIdentifier.notifications) + .help("\(L("notifications.tab")) (⌘N)") .toolbar { if appState.notifications.count > 0 || appState.callEvents.count > 0 { ToolbarItem(placement: .primaryAction) { @@ -81,6 +83,7 @@ struct AppContentView: View { // Label("Apps", systemImage: "app") } .tag(TabIdentifier.apps) + .help("\(L("apps.tab")) (⌘A)") } @@ -91,6 +94,7 @@ struct AppContentView: View { Image(systemName: "gear") } .tag(TabIdentifier.settings) + .help("\(L("settings.tab")) (⌘,)") .toolbar { ToolbarItemGroup { Button("Help", systemImage: "questionmark.circle") { @@ -118,6 +122,27 @@ struct AppContentView: View { } } } + .background( + Group { + Button("") { + if appState.device != nil { + appState.selectedTab = .notifications + } + } + .keyboardShortcut("n", modifiers: [.command]) + .opacity(0) + .allowsHitTesting(false) + + Button("") { + if appState.device != nil { + appState.selectedTab = .apps + } + } + .keyboardShortcut("a", modifiers: [.command]) + .opacity(0) + .allowsHitTesting(false) + } + ) .tabViewStyle(.automatic) .frame(minWidth: 550, minHeight: 510) .onAppear { diff --git a/airsync-mac/Screens/HomeScreen/AppsView/AppGridView.swift b/airsync-mac/Screens/HomeScreen/AppsView/AppGridView.swift index de98e335..81675dac 100644 --- a/airsync-mac/Screens/HomeScreen/AppsView/AppGridView.swift +++ b/airsync-mac/Screens/HomeScreen/AppsView/AppGridView.swift @@ -11,14 +11,38 @@ import SwiftUI struct AppGridView: View { @ObservedObject var appState = AppState.shared @State private var searchText: String = "" + @FocusState private var isSearchFocused: Bool + @State private var launchingPackageName: String? = nil var filteredApps: [AndroidApp] { - if searchText.isEmpty { + let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines) + if query.isEmpty { return Array(appState.androidApps.values) } else { return appState.androidApps.values.filter { - $0.name.localizedCaseInsensitiveContains(searchText) - || $0.packageName.localizedCaseInsensitiveContains(searchText) + $0.name.localizedCaseInsensitiveContains(query) + } + } + } + + var sortedAppsList: [AndroidApp] { + let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines) + if query.isEmpty { + return filteredApps.sorted(by: { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }) + } else { + let lowerQuery = query.lowercased() + return filteredApps.sorted { app1, app2 in + let name1 = app1.name.lowercased() + let name2 = app2.name.lowercased() + + let starts1 = name1.hasPrefix(lowerQuery) + let starts2 = name2.hasPrefix(lowerQuery) + + if starts1 != starts2 { + return starts1 + } + + return app1.name.localizedCaseInsensitiveCompare(app2.name) == .orderedAscending } } } @@ -30,46 +54,143 @@ struct AppGridView: View { let columnsCount = max(1, Int((geometry.size.width + spacing) / (itemWidth + spacing))) let columns = Array(repeating: GridItem(.flexible(), spacing: spacing), count: columnsCount) - ScrollView { - LazyVGrid(columns: columns, spacing: 12) { - ForEach(filteredApps.sorted(by: { $0.name.lowercased() < $1.name.lowercased() }), id: \.packageName) { app in - AppGridItemView(app: app) + ZStack(alignment: .bottom) { + ScrollView { + let sorted = sortedAppsList + LazyVGrid(columns: columns, spacing: 12) { + ForEach(Array(sorted.enumerated()), id: \.element.packageName) { index, app in + AppGridItemView( + app: app, + isHighlighted: !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && index == 0, + isLaunching: launchingPackageName == app.packageName, + onLaunch: { + launchApp(app) + } + ) + } + } + .padding(12) + .padding(.bottom, 60) + } + .whatsNewPopover(item: .appsGrid, arrowEdge: .top) + + // Hint Chip & Floating Searchbar VStack + VStack(spacing: 8) { + if !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + Text("Press ⏎ to launch") + .font(.system(size: 10, weight: .medium)) + .foregroundColor(.secondary) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .glassBoxIfAvailable(radius: 12) + .transition(.asymmetric( + insertion: .opacity.combined(with: .scale(scale: 0.9)).combined(with: .move(edge: .bottom)), + removal: .opacity.combined(with: .scale(scale: 0.9)).combined(with: .move(edge: .bottom)) + )) + } + + // Liquid glass floating searchbar + HStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .foregroundColor(.secondary) + + TextField("Search Apps", text: $searchText) + .textFieldStyle(.plain) + .font(.system(size: 12)) + .focused($isSearchFocused) + .onSubmit { + if let firstApp = sortedAppsList.first { + launchApp(firstApp) + } + } + .onExitCommand { + searchText = "" + } + + if !searchText.isEmpty { + Button(action: { searchText = "" }) { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.secondary) + } + .buttonStyle(.plain) + } } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .frame(width: 260) + .glassBoxIfAvailable(radius: 20) + .shadow(color: Color.black.opacity(0.15), radius: 10, x: 0, y: 5) } - .padding(12) + .animation(.spring(response: 0.3, dampingFraction: 0.7), value: searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + .padding(.bottom, 16) } - .whatsNewPopover(item: .appsGrid, arrowEdge: .top) } - .searchable( - text: $searchText, - placement: .toolbar, - prompt: "Search Apps" - ) .padding(0) .onAppear { WhatsNewTourManager.shared.evaluateActiveItem() + isSearchFocused = true } .onChange(of: appState.selectedTab) { _, _ in WhatsNewTourManager.shared.evaluateActiveItem() } + .onChange(of: isSearchFocused) { _, newValue in + if !newValue { + DispatchQueue.main.async { + isSearchFocused = true + } + } + } + } + + private func launchApp(_ app: AndroidApp) { + if let device = appState.device, appState.adbConnected { + launchingPackageName = app.packageName + appState.trackAppUse(app) + ADBConnector.startScrcpy( + ip: device.ipAddress, + port: appState.adbPort, + deviceName: device.name, + package: app.packageName + ) + DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { + if launchingPackageName == app.packageName { + launchingPackageName = nil + } + } + } } } // MARK: - App Grid Item private struct AppGridItemView: View { let app: AndroidApp + let isHighlighted: Bool + let isLaunching: Bool + let onLaunch: () -> Void @ObservedObject var appState = AppState.shared var body: some View { ZStack(alignment: .topTrailing) { - AppIconButtonView(app: app) - .padding(8) - .glassBoxIfAvailable(radius: 15) - .onTapGesture(perform: handleTap) - .contextMenu { - AppContextMenuContent(app: app) + ZStack { + AppIconButtonView(app: app) + .padding(8) + .glassBoxIfAvailable(radius: 15) + .overlay( + RoundedRectangle(cornerRadius: 15) + .stroke(isHighlighted ? Color.accentColor : Color.clear, lineWidth: 2) + ) + .opacity(isLaunching ? 0.4 : 1.0) + + if isLaunching { + ProgressView() + .controlSize(.small) } - .onDrag(createDragProvider) + } + .onTapGesture(perform: onLaunch) + .contextMenu { + AppContextMenuContent(app: app) + } + .onDrag(createDragProvider) // Notification mute indicator if !app.listening { @@ -81,17 +202,7 @@ private struct AppGridItemView: View { } } - private func handleTap() { - if let device = appState.device, appState.adbConnected { - appState.trackAppUse(app) - ADBConnector.startScrcpy( - ip: device.ipAddress, - port: appState.adbPort, - deviceName: device.name, - package: app.packageName - ) - } - } + private func createDragProvider() -> NSItemProvider { let provider = NSItemProvider() diff --git a/airsync-mac/Screens/HomeScreen/HomeView.swift b/airsync-mac/Screens/HomeScreen/HomeView.swift index 1483bc61..cd01880e 100644 --- a/airsync-mac/Screens/HomeScreen/HomeView.swift +++ b/airsync-mac/Screens/HomeScreen/HomeView.swift @@ -28,13 +28,10 @@ struct HomeView: View { ZStack { if appState.selectedTab == .settings { SettingsSidebarView() - .transition(.opacity.combined(with: .scale)) } else if appState.device == nil { QRScannerSidebarView() - .transition(.opacity.combined(with: .scale)) } else { SidebarView() - .transition(.opacity.combined(with: .scale)) } } .frame(minWidth: 270) diff --git a/airsync-mac/Screens/HomeScreen/PhoneView/PhoneView.swift b/airsync-mac/Screens/HomeScreen/PhoneView/PhoneView.swift index 07e3225b..27ebb9b9 100644 --- a/airsync-mac/Screens/HomeScreen/PhoneView/PhoneView.swift +++ b/airsync-mac/Screens/HomeScreen/PhoneView/PhoneView.swift @@ -10,15 +10,20 @@ import SwiftUI struct PhoneView: View { @ObservedObject var appState = AppState.shared @State private var displayedImage: NSImage? - // 3D tilt state - @State private var tiltX: Double = 0 - @State private var tiltY: Double = 0 - @State private var isInteracting: Bool = false + + private var safeRatio: CGFloat { + let width = ScrcpyStreamClient.shared.videoWidth + let height = ScrcpyStreamClient.shared.videoHeight + if width > 0 && height > 0 { + return CGFloat(width) / CGFloat(height) + } + return 9.0 / 19.5 + } var body: some View { GeometryReader { geo in let cardWidth: CGFloat = 220 - let cardHeight: CGFloat = 460 + let cardHeight: CGFloat = appState.isSidebarMirroring ? (cardWidth / safeRatio) : 460 let corner: CGFloat = 24 ZStack { // Wallpaper background layer(s) WITH 3D tilt @@ -29,21 +34,20 @@ struct PhoneView: View { startPoint: .top, endPoint: .bottom ) ) - .scaleEffect(isInteracting ? 1.085 : 1.035) - .rotation3DEffect(.degrees(tiltX), axis: (x: 1, y: 0, z: 0)) - .rotation3DEffect(.degrees(tiltY), axis: (x: 0, y: 1, z: 0)) - .animation(.easeOut(duration: 0.22), value: tiltX) - .animation(.easeOut(duration: 0.22), value: tiltY) - .animation(.easeOut(duration: 0.25), value: isInteracting) - + .opacity(appState.isSidebarMirroring ? 0 : 1) // Seasonal Snowfall Overlay // SnowfallView() // Foreground content - ScreenView() - .padding(.horizontal, 4) - .transition(.blurReplace) + if appState.isSidebarMirroring { + SidebarMirrorView() + .transition(.blurReplace) + } else { + ScreenView() + .padding(.horizontal, 4) + .transition(.blurReplace) + } } .frame(width: cardWidth, height: cardHeight) @@ -54,38 +58,11 @@ struct PhoneView: View { ) .shadow(color: .black.opacity(0.25), radius: 22, x: 0, y: 8) .contentShape(RoundedRectangle(cornerRadius: corner)) - .gesture( - DragGesture(minimumDistance: 0) - .onChanged { value in - let size = CGSize(width: cardWidth, height: cardHeight) - let origin = CGPoint( - x: value.location.x - (geo.size.width - cardWidth) / 2, - y: value.location.y - (geo.size.height - cardHeight) / 2 - ) - let dx = origin.x - size.width / 2 - let dy = origin.y - size.height / 2 - let maxAngle: CGFloat = 5 // tight limit to prevent edge exposure - if !isInteracting { isInteracting = true } - withAnimation(.interactiveSpring(response: 0.35, dampingFraction: 0.75, blendDuration: 0.2)) { - let rawY = Double((dx / size.width) * maxAngle) - let rawX = Double((-dy / size.height) * maxAngle) - let limit = Double(maxAngle) - tiltY = max(min(rawY, limit), -limit) - tiltX = max(min(rawX, limit), -limit) - } - } - .onEnded { _ in - withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) { - tiltX = 0 - tiltY = 0 - isInteracting = false - } - } - ) .onAppear { updateImage() } .onChange(of: appState.status?.music?.isPlaying) { updateImage() } .onChange(of: appState.status?.music?.albumArt) { updateImage() } .onChange(of: AppState.shared.currentDeviceWallpaperBase64) { updateImage() } + .onChange(of: appState.isSidebarMirroring) { _, _ in updateImage() } .frame(maxWidth: .infinity, maxHeight: .infinity) } } diff --git a/airsync-mac/Screens/HomeScreen/PhoneView/SidebarMirrorView.swift b/airsync-mac/Screens/HomeScreen/PhoneView/SidebarMirrorView.swift new file mode 100644 index 00000000..b81136cb --- /dev/null +++ b/airsync-mac/Screens/HomeScreen/PhoneView/SidebarMirrorView.swift @@ -0,0 +1,74 @@ +// +// SidebarMirrorView.swift +// AirSync +// +// Created by Sameera Wijerathna on 2026-06-04. +// + +import SwiftUI +import MetalKit + +struct SidebarMirrorView: View { + @ObservedObject var appState = AppState.shared + @StateObject private var streamClient = ScrcpyStreamClient.shared + @State private var isMirroring = false + @State private var errorMessage: String? + + private var safeRatio: CGFloat { + if streamClient.videoWidth > 0 && streamClient.videoHeight > 0 { + return CGFloat(streamClient.videoWidth) / CGFloat(streamClient.videoHeight) + } + return 9.0 / 19.5 + } + + var body: some View { + ZStack { + if isMirroring { + MetalVideoView(streamClient: streamClient) + .overlay { + if streamClient.videoWidth == 0 { + ProgressView() + } + } + } else { + VStack(spacing: 12) { + ProgressView() + if let error = errorMessage { + Text(error) + .font(.caption) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + } else { + Text("Connecting...") + .font(.caption) + .foregroundColor(.secondary) + } + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onAppear { + startMirroring() + } + .onDisappear { + stopMirroring() + } + } + + private func startMirroring() { + errorMessage = nil + ScrcpyServerManager.shared.startMirroringSession(appState: AppState.shared, streamClient: streamClient) { success, errorMsg in + if success { + self.isMirroring = true + } else { + self.errorMessage = errorMsg + } + } + } + + private func stopMirroring() { + ScrcpyServerManager.shared.stopMirroringSession(streamClient: streamClient) + isMirroring = false + } +} diff --git a/airsync-mac/Screens/HomeScreen/SidebarView.swift b/airsync-mac/Screens/HomeScreen/SidebarView.swift index b691ec2f..ddfa17f0 100644 --- a/airsync-mac/Screens/HomeScreen/SidebarView.swift +++ b/airsync-mac/Screens/HomeScreen/SidebarView.swift @@ -46,26 +46,45 @@ struct SidebarView: View { if appState.adbConnected { HStack(spacing: 12) { GlassButtonView( - label: "Mirror", - systemImage: "apps.iphone", + label: appState.isSidebarMirroring ? "Close" : "Mirror", + systemImage: appState.isSidebarMirroring ? "xmark.circle" : "apps.iphone", action: { - if appState.useNativeMirroringByDefault { - appState.isNativeMirroring = true + if appState.isSidebarMirroring { + appState.isSidebarMirroring = false } else { + if appState.useNativeMirroringByDefault { + appState.isNativeMirroring = true + } else { + ADBConnector.startScrcpy( + ip: appState.device?.ipAddress ?? "", + port: appState.adbPort, + deviceName: appState.device?.name ?? "My Phone" + ) + } + } + } + ) + .transition(.identity) + .keyboardShortcut("p", modifiers: [.command]) + .contextMenu { + // 1. Default mirror action + if appState.useNativeMirroringByDefault { + Button("Android Mirror") { + appState.isNativeMirroring = true + } + .keyboardShortcut("p", modifiers: [.command]) + } else { + Button("scrcpy Mirror") { ADBConnector.startScrcpy( ip: appState.device?.ipAddress ?? "", port: appState.adbPort, deviceName: appState.device?.name ?? "My Phone" ) } + .keyboardShortcut("p", modifiers: [.command]) } - ) - .transition(.identity) - .keyboardShortcut( - "p", - modifiers: .command - ) - .contextMenu { + + // 2. Alternative mirror action if appState.useNativeMirroringByDefault { Button("scrcpy Mirror") { ADBConnector.startScrcpy( @@ -74,10 +93,12 @@ struct SidebarView: View { deviceName: appState.device?.name ?? "My Phone" ) } + .keyboardShortcut("p", modifiers: [.command, .shift]) } else { Button("Android Mirror") { appState.isNativeMirroring = true } + .keyboardShortcut("p", modifiers: [.command, .shift]) } Button("Desktop Mode") { @@ -88,33 +109,74 @@ struct SidebarView: View { desktop: true ) } + .keyboardShortcut("d", modifiers: [.command]) + + Button(appState.isSidebarMirroring ? "Stop Mirroring Here" : "Mirror Here") { + appState.isSidebarMirroring.toggle() + } + .keyboardShortcut("s", modifiers: [.command, .shift]) } - .keyboardShortcut( - "d", - modifiers: [.command, .shift] - ) GlassButtonView( label: "Desktop", systemImage: "desktopcomputer", action: { if appState.isPlus && appState.licenseCheck { + if appState.useNativeDesktopMirroringByDefault { + appState.isNativeDesktopMirroring = true + } else { + ADBConnector.startScrcpy( + ip: appState.device?.ipAddress ?? "", + port: appState.adbPort, + deviceName: appState.device?.name ?? "My Phone", + desktop: true + ) + } + } else { + showingPlusDesktopPopover = true + } + } + ) + .transition(.identity) + .keyboardShortcut("d", modifiers: [.command]) + .contextMenu { + if appState.useNativeDesktopMirroringByDefault { + Button("Native Desktop") { + appState.isNativeDesktopMirroring = true + } + .keyboardShortcut("d", modifiers: [.command]) + + Button("scrcpy Desktop") { + ADBConnector.startScrcpy( + ip: appState.device?.ipAddress ?? "", + port: appState.adbPort, + deviceName: appState.device?.name ?? "My Phone", + desktop: true + ) + } + .keyboardShortcut("d", modifiers: [.command, .shift]) + } else { + Button("scrcpy Desktop") { ADBConnector.startScrcpy( ip: appState.device?.ipAddress ?? "", port: appState.adbPort, deviceName: appState.device?.name ?? "My Phone", desktop: true ) - } else { - showingPlusDesktopPopover = true } + .keyboardShortcut("d", modifiers: [.command]) + + Button("Native Desktop") { + appState.isNativeDesktopMirroring = true + } + .keyboardShortcut("d", modifiers: [.command, .shift]) } - ) - .transition(.identity) + } .popover(isPresented: $showingPlusDesktopPopover, arrowEdge: .top) { PlusFeaturePopover(message: "Desktop Mode is an AirSync+ feature") } .whatsNewPopover(item: .desktopMode, arrowEdge: .top) + } .padding(.top, 8) .padding(.bottom, 12) diff --git a/airsync-mac/Screens/MenubarView/MenubarSegments.swift b/airsync-mac/Screens/MenubarView/MenubarSegments.swift index b0ea33a2..2c13536d 100644 --- a/airsync-mac/Screens/MenubarView/MenubarSegments.swift +++ b/airsync-mac/Screens/MenubarView/MenubarSegments.swift @@ -118,16 +118,36 @@ struct TopSegmentView: View { appState.isNativeMirroring = true } } - - Button("Desktop Mode") { - ADBConnector.startScrcpy( - ip: appState.device?.ipAddress ?? "", - port: appState.adbPort, - deviceName: appState.device?.name ?? "My Phone", - desktop: true - ) + + Divider() + + if appState.useNativeDesktopMirroringByDefault { + Button("Native Desktop") { + appState.isNativeDesktopMirroring = true + } + Button("scrcpy Desktop") { + ADBConnector.startScrcpy( + ip: appState.device?.ipAddress ?? "", + port: appState.adbPort, + deviceName: appState.device?.name ?? "My Phone", + desktop: true + ) + } + } else { + Button("Desktop Mode") { + ADBConnector.startScrcpy( + ip: appState.device?.ipAddress ?? "", + port: appState.adbPort, + deviceName: appState.device?.name ?? "My Phone", + desktop: true + ) + } + Button("Native Desktop") { + appState.isNativeDesktopMirroring = true + } } } + } diff --git a/airsync-mac/Screens/Remote/NativeDesktopMirrorView.swift b/airsync-mac/Screens/Remote/NativeDesktopMirrorView.swift new file mode 100644 index 00000000..b7bc0d8b --- /dev/null +++ b/airsync-mac/Screens/Remote/NativeDesktopMirrorView.swift @@ -0,0 +1,21 @@ +// +// NativeDesktopMirrorView.swift +// AirSync +// +// Created by Sameera Wijerathna on 2026-06-04. +// + +import SwiftUI + +struct NativeDesktopMirrorView: View { + var body: some View { + ScrcpyBaseMirrorView( + desktopMode: true, + windowId: "nativeDesktopMirror", + defaultTitle: "AirSync Desktop", + defaultIconName: "desktopcomputer", + defaultRatio: 16.0 / 9.0, + isDesktopResizeEnabled: true + ) + } +} diff --git a/airsync-mac/Screens/Remote/ScrcpyBaseMirrorView.swift b/airsync-mac/Screens/Remote/ScrcpyBaseMirrorView.swift new file mode 100644 index 00000000..906cf704 --- /dev/null +++ b/airsync-mac/Screens/Remote/ScrcpyBaseMirrorView.swift @@ -0,0 +1,359 @@ +// +// ScrcpyBaseMirrorView.swift +// AirSync +// +// Created by Sameera Wijerathna on 2026-06-04. +// + +import SwiftUI +import AppKit +import MetalKit + +struct ScrcpyBaseMirrorView: View { + let desktopMode: Bool + let windowId: String + let defaultTitle: String + let defaultIconName: String + let defaultRatio: CGFloat + let isDesktopResizeEnabled: Bool + + @Environment(\.dismissWindow) var dismissWindow + @EnvironmentObject var appState: AppState + @StateObject private var streamClient = ScrcpyStreamClient.shared + @State private var isMirroring = false + @State private var errorMessage: String? + @State private var isHovering = false + @State private var currentWindow: NSWindow? + @State private var isWindowActive = false + @State private var navbarController = NavbarWindowController() + @State private var sideController = SideControlWindowController() + @AppStorage("showMirrorControls") private var showMirrorControls = true + + private var safeRatio: CGFloat { + if streamClient.videoWidth > 0 && streamClient.videoHeight > 0 { + return CGFloat(streamClient.videoWidth) / CGFloat(streamClient.videoHeight) + } + return defaultRatio + } + + private var contentCornerRadius: CGFloat { + isHovering ? 24 : 0 + } + + private var isStreaming: Bool { + isMirroring && streamClient.videoWidth > 0 + } + + @ViewBuilder + private var metalView: some View { + MetalVideoView(streamClient: streamClient) + .aspectRatio(safeRatio, contentMode: .fit) + .cornerRadius(contentCornerRadius) + .padding(.top, isHovering ? 8 : 0) + .padding(.horizontal, isHovering ? 8 : 0) + .padding(.bottom, isHovering ? 20 : 0) + .opacity(isStreaming ? 1 : 0) + .blur(radius: isStreaming ? 0 : 20) + .animation(.spring(response: 0.6, dampingFraction: 0.8), value: isStreaming) + .animation(.spring(response: 0.3, dampingFraction: 0.85), value: isHovering) + .overlay { + if !isStreaming { + connectingView(message: "Loading") + .transition(.opacity.combined(with: .scale(scale: 0.95))) + } + } + } + + @ViewBuilder + private var connectionStatusView: some View { + connectingView(message: errorMessage ?? "Connecting") + .cornerRadius(contentCornerRadius) + .transition(.opacity) + } + + @ViewBuilder + private var mainMirrorContent: some View { + ZStack(alignment: .top) { + if isMirroring { + metalView + } else { + connectionStatusView + } + } + } + + @ViewBuilder + private var controlsHintView: some View { + if isHovering { + Text("⌘B to toggle controls") + .font(.system(size: 10, weight: .medium, design: .rounded)) + .foregroundColor(.primary.opacity(0.4)) + .padding(.bottom, 8) + .transition(.opacity.combined(with: .move(edge: .bottom))) + } + } + + var body: some View { + ZStack(alignment: .top) { + wallpaperView + .opacity(isStreaming ? 0 : 0.4) + .animation(.easeInOut(duration: 0.8), value: isStreaming) + .ignoresSafeArea() + + Button(action: { + showMirrorControls.toggle() + }) { + Text("") + } + .buttonStyle(.plain) + .keyboardShortcut("b", modifiers: [.command]) + .frame(width: 0, height: 0) + .opacity(0) + + VStack(spacing: 0) { + if isHovering { + headerView + .frame(height: 36) + .clipped() + .transition(.opacity) + .onHover { hovering in + if !hovering { + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + isHovering = false + updateWindowUI(isHovering: false) + } + } + } + } + + mainMirrorContent + .animation(.easeInOut(duration: 1.25), value: isMirroring) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .overlay(alignment: .top) { + if !isHovering { + Color.clear + .frame(height: 6) + .contentShape(Rectangle()) + .onHover { hovering in + if hovering { + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + isHovering = true + updateWindowUI(isHovering: true) + } + } + } + } + } + + controlsHintView + } + .background(WindowAccessor(callback: { window in + self.setupWindow(window) + })) + .ignoresSafeArea() + .onAppear { + let isActive = desktopMode ? AppState.shared.isNativeDesktopMirroring : AppState.shared.isNativeMirroring + if !isActive { + dismissWindow(id: windowId) + return + } + startMirroring() + } + } + .background(.ultraThinMaterial.opacity(isMirroring ? 0.01 : 1.0)) + .onChange(of: isHovering) { _, newValue in + updateWindowUI(isHovering: newValue) + } + .onChange(of: isMirroring) { _, newValue in + if !newValue { isHovering = false } + updateNavbarVisibility() + } + .onChange(of: isWindowActive) { _, _ in + updateNavbarVisibility() + } + .onChange(of: showMirrorControls) { _, _ in + updateNavbarVisibility() + } + .onChange(of: streamClient.videoWidth) { _, newValue in + updateWindowConstraints(width: newValue, height: streamClient.videoHeight) + } + .onChange(of: streamClient.videoHeight) { _, newValue in + updateWindowConstraints(width: streamClient.videoWidth, height: newValue) + } + .onChange(of: streamClient.deviceName) { _, newValue in + currentWindow?.title = newValue + } + .frame(minWidth: isDesktopResizeEnabled ? 400 : 200, minHeight: isDesktopResizeEnabled ? 250 : 300) + .ignoresSafeArea() + .onDisappear { + navbarController.hide() + sideController.hide() + stopMirroring() + } + } + + private var wallpaperView: some View { + Group { + if let wallpaperBase64 = appState.currentDeviceWallpaperBase64, + let data = Data(base64Encoded: wallpaperBase64), + let image = NSImage(data: data) { + Image(nsImage: image) + .resizable() + .aspectRatio(contentMode: .fill) + .blur(radius: 15) + } else { + Color.clear + } + } + } + + private var headerView: some View { + ZStack { + if #available(macOS 15.0, *) { + Color.clear + .contentShape(Rectangle()) + .gesture(WindowDragGesture()) + } + + HStack { + Spacer() + + Text(isMirroring ? streamClient.deviceName : defaultTitle) + .font(.system(size: 13, weight: .bold, design: .rounded)) + .foregroundColor(.primary.opacity(0.7)) + + Spacer() + } + } + .frame(height: 36) + .background(Color.clear) + } + + private func connectingView(message: String) -> some View { + VStack(spacing: 24) { + VStack { + Image(systemName: defaultIconName) + .font(.system(size: 40)) + .foregroundColor(.accentColor) + + ProgressView() + } + + Text(message) + .font(.system(size: 16, weight: .medium, design: .rounded)) + .foregroundColor(.primary.opacity(0.8)) + .multilineTextAlignment(.center) + .padding(.horizontal) + + if errorMessage != nil { + Button(action: startMirroring) { + Text("Retry Connection") + .font(.headline) + .padding(.horizontal, 24) + .padding(.vertical, 10) + .background(Color.accentColor) + .foregroundColor(.white) + .cornerRadius(10) + } + .buttonStyle(.plain) + } + } + } + + private func setupWindow(_ window: NSWindow) { + self.currentWindow = window + window.backgroundColor = NSColor.clear + window.isOpaque = false + window.titlebarAppearsTransparent = true + window.titleVisibility = NSWindow.TitleVisibility.hidden + window.isMovableByWindowBackground = false + window.isRestorable = false + window.level = .floating + + window.standardWindowButton(.closeButton)?.isHidden = true + window.standardWindowButton(.miniaturizeButton)?.isHidden = true + window.standardWindowButton(.zoomButton)?.isHidden = true + window.isMovable = false + + window.title = isMirroring ? streamClient.deviceName : defaultTitle + + if !isDesktopResizeEnabled && isMirroring && streamClient.videoWidth > 0 { + window.contentAspectRatio = NSSize(width: CGFloat(streamClient.videoWidth), height: CGFloat(streamClient.videoHeight)) + } + + NotificationCenter.default.addObserver(forName: NSWindow.willCloseNotification, object: window, queue: .main) { _ in + if desktopMode { + AppState.shared.isNativeDesktopMirroring = false + } else { + AppState.shared.isNativeMirroring = false + } + self.stopMirroring() + } + + NotificationCenter.default.addObserver(forName: NSWindow.didMoveNotification, object: window, queue: .main) { _ in + navbarController.updatePosition(parent: window) + sideController.updatePosition(parent: window) + } + NotificationCenter.default.addObserver(forName: NSWindow.didResizeNotification, object: window, queue: .main) { _ in + navbarController.updatePosition(parent: window) + sideController.updatePosition(parent: window) + + if isDesktopResizeEnabled, let contentView = window.contentView { + let backingSize = contentView.convertToBacking(contentView.bounds).size + let width = UInt16(backingSize.width) + let height = UInt16(backingSize.height) + ScrcpyControlClient.shared.sendResizeDisplay(width: width, height: height) + } + } + + self.isWindowActive = window.isKeyWindow + NotificationCenter.default.addObserver(forName: NSWindow.didBecomeKeyNotification, object: window, queue: .main) { _ in + self.isWindowActive = true + } + NotificationCenter.default.addObserver(forName: NSWindow.didResignKeyNotification, object: window, queue: .main) { _ in + self.isWindowActive = false + } + } + + private func updateWindowUI(isHovering: Bool) { + guard let window = currentWindow else { return } + window.isMovable = isHovering + + window.standardWindowButton(.closeButton)?.isHidden = !isHovering + window.standardWindowButton(.miniaturizeButton)?.isHidden = !isHovering + window.standardWindowButton(.zoomButton)?.isHidden = true + } + + private func updateWindowConstraints(width: UInt32, height: UInt32) { + guard !isDesktopResizeEnabled else { return } + guard width > 0 && height > 0 else { return } + currentWindow?.contentAspectRatio = NSSize(width: CGFloat(width), height: CGFloat(height)) + } + + private func startMirroring() { + errorMessage = nil + ScrcpyServerManager.shared.startMirroringSession(appState: AppState.shared, streamClient: streamClient, desktopMode: desktopMode) { success, errorMsg in + if success { + self.isMirroring = true + } else { + self.errorMessage = errorMsg + } + } + } + + private func stopMirroring() { + ScrcpyServerManager.shared.stopMirroringSession(streamClient: streamClient) + isMirroring = false + } + + private func updateNavbarVisibility() { + guard let window = currentWindow else { return } + if isWindowActive && isMirroring && showMirrorControls { + navbarController.show(parent: window, isMirroring: isMirroring) + sideController.show(parent: window, isMirroring: isMirroring) + } else { + navbarController.hide() + sideController.hide() + } + } +} diff --git a/airsync-mac/Screens/Remote/ScrcpyMirrorView.swift b/airsync-mac/Screens/Remote/ScrcpyMirrorView.swift index fed38008..0be5bf16 100644 --- a/airsync-mac/Screens/Remote/ScrcpyMirrorView.swift +++ b/airsync-mac/Screens/Remote/ScrcpyMirrorView.swift @@ -6,326 +6,16 @@ // import SwiftUI -import AppKit -import MetalKit struct ScrcpyMirrorView: View { - @Environment(\.dismissWindow) var dismissWindow - @EnvironmentObject var appState: AppState - @StateObject private var streamClient = ScrcpyStreamClient.shared - @State private var isMirroring = false - @State private var errorMessage: String? - @State private var isHovering = false - @State private var currentWindow: NSWindow? - @State private var isWindowActive = false - @State private var navbarController = NavbarWindowController() - @State private var sideController = SideControlWindowController() - @AppStorage("showMirrorControls") private var showMirrorControls = true - - private var safeRatio: CGFloat { - if streamClient.videoWidth > 0 && streamClient.videoHeight > 0 { - return CGFloat(streamClient.videoWidth) / CGFloat(streamClient.videoHeight) - } - return 9.0 / 19.5 - } - - private var contentCornerRadius: CGFloat { - isHovering ? 24 : 0 - } - - private var isStreaming: Bool { - isMirroring && streamClient.videoWidth > 0 - } - var body: some View { - ZStack(alignment: .top) { - // Immersive Wallpaper Background - wallpaperView - .opacity(isStreaming ? 0 : 0.4) - .animation(.easeInOut(duration: 0.8), value: isStreaming) - .ignoresSafeArea() - - // Invisible button for Command+B shortcut - Button(action: { - showMirrorControls.toggle() - }) { - Text("") - } - .buttonStyle(.plain) - .keyboardShortcut("b", modifiers: [.command]) - .frame(width: 0, height: 0) - .opacity(0) - - VStack(spacing: 0) { - // Expanding Header - headerView - .frame(height: isHovering ? 36 : 0) - .opacity(isHovering ? 1 : 0) - .clipped() - - ZStack(alignment: .top) { - if isMirroring { - MetalVideoView(streamClient: streamClient) - .aspectRatio(safeRatio, contentMode: .fit) - .cornerRadius(contentCornerRadius) - .padding(.top, isHovering ? 8 : 0) - .padding(.horizontal, isHovering ? 8 : 0) - .padding(.bottom, isHovering ? 20 : 0) - .opacity(isStreaming ? 1 : 0) - .blur(radius: isStreaming ? 0 : 20) - .animation(.spring(response: 0.6, dampingFraction: 0.8), value: isStreaming) - .animation(.spring(response: 0.3, dampingFraction: 0.85), value: isHovering) - .overlay { - if !isStreaming { - connectingView(message: "Loading") - .transition(.opacity.combined(with: .scale(scale: 0.95))) - } - } - } else { - connectingView(message: errorMessage ?? "Connecting") - .cornerRadius(contentCornerRadius) - .transition(.opacity) - } - } - .animation(.easeInOut(duration: 1.25), value: isMirroring) - .frame(maxWidth: .infinity, maxHeight: .infinity) - - if isHovering { - Text("⌘B to toggle controls") - .font(.system(size: 10, weight: .medium, design: .rounded)) - .foregroundColor(.primary.opacity(0.4)) - .padding(.bottom, 8) - .transition(.opacity.combined(with: .move(edge: .bottom))) - } - } - .background(WindowAccessor(callback: { window in - self.currentWindow = window - window.backgroundColor = NSColor.clear - window.isOpaque = false - window.titlebarAppearsTransparent = true - window.titleVisibility = NSWindow.TitleVisibility.hidden - window.isMovableByWindowBackground = false - window.isRestorable = false // Prevent macOS from restoring this window on next launch - window.level = .floating - - // Hide native traffic lights - window.standardWindowButton(.closeButton)?.isHidden = true - window.standardWindowButton(.miniaturizeButton)?.isHidden = true - window.standardWindowButton(.zoomButton)?.isHidden = true - - window.title = isMirroring ? streamClient.deviceName : "AirSync Mirror" - - if isMirroring && streamClient.videoWidth > 0 { - window.contentAspectRatio = NSSize(width: CGFloat(streamClient.videoWidth), height: CGFloat(streamClient.videoHeight)) - } - - // Handle manual window closure - NotificationCenter.default.addObserver(forName: NSWindow.willCloseNotification, object: window, queue: .main) { _ in - AppState.shared.isNativeMirroring = false - self.stopMirroring() - } - - // Track parent movement/resize to reposition child floating window - NotificationCenter.default.addObserver(forName: NSWindow.didMoveNotification, object: window, queue: .main) { _ in - navbarController.updatePosition(parent: window) - sideController.updatePosition(parent: window) - } - NotificationCenter.default.addObserver(forName: NSWindow.didResizeNotification, object: window, queue: .main) { _ in - navbarController.updatePosition(parent: window) - sideController.updatePosition(parent: window) - } - - // Track focus/active state - self.isWindowActive = window.isKeyWindow - NotificationCenter.default.addObserver(forName: NSWindow.didBecomeKeyNotification, object: window, queue: .main) { _ in - self.isWindowActive = true - } - NotificationCenter.default.addObserver(forName: NSWindow.didResignKeyNotification, object: window, queue: .main) { _ in - self.isWindowActive = false - } - })) - .ignoresSafeArea() - .onAppear { - if !AppState.shared.isNativeMirroring { - dismissWindow(id: "nativeMirror") - return - } - startMirroring() - } - - // Selective Hover Trigger Area (Top Edge) - Color.clear - .frame(height: isHovering ? 36 : 6) - .contentShape(Rectangle()) - .onHover { hovering in - withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { - isHovering = hovering - updateWindowUI(isHovering: hovering) - } - } - .ignoresSafeArea() - } - .background(.ultraThinMaterial.opacity(isMirroring ? 0.01 : 1.0)) - .onChange(of: isHovering) { _, newValue in - updateWindowUI(isHovering: newValue) - } - .onChange(of: isMirroring) { _, newValue in - if !newValue { isHovering = false } - updateNavbarVisibility() - } - .onChange(of: isWindowActive) { _, _ in - updateNavbarVisibility() - } - .onChange(of: showMirrorControls) { _, _ in - updateNavbarVisibility() - } - .onChange(of: streamClient.videoWidth) { _, newValue in - updateWindowConstraints(width: newValue, height: streamClient.videoHeight) - } - .onChange(of: streamClient.videoHeight) { _, newValue in - updateWindowConstraints(width: streamClient.videoWidth, height: newValue) - } - .onChange(of: streamClient.deviceName) { _, newValue in - currentWindow?.title = newValue - } - .frame(minWidth: 200, minHeight: 300) - .onDisappear { - navbarController.hide() - sideController.hide() - stopMirroring() - } - } - - private var wallpaperView: some View { - Group { - if let wallpaperBase64 = appState.currentDeviceWallpaperBase64, - let data = Data(base64Encoded: wallpaperBase64), - let image = NSImage(data: data) { - Image(nsImage: image) - .resizable() - .aspectRatio(contentMode: .fill) - .blur(radius: 15) - } else { - Color.clear - } - } - } - - private var headerView: some View { - ZStack { - // Drag Area (Lower Layer) - if #available(macOS 15.0, *) { - Color.clear - .contentShape(Rectangle()) - .gesture(WindowDragGesture()) - } - - // Title Content (Upper Layer) - HStack { - Spacer() - - Text(isMirroring ? streamClient.deviceName : "AirSync") - .font(.system(size: 13, weight: .bold, design: .rounded)) - .foregroundColor(.primary.opacity(0.7)) - - Spacer() - } - } - .frame(height: 36) - .background(Color.clear) - } - - private func connectingView(message: String) -> some View { - VStack(spacing: 24) { - VStack { - Image(systemName: "iphone") - .font(.system(size: 40)) - .foregroundColor(.accentColor) - - ProgressView() - } - - Text(message) - .font(.system(size: 16, weight: .medium, design: .rounded)) - .foregroundColor(.primary.opacity(0.8)) - .multilineTextAlignment(.center) - .padding(.horizontal) - - if errorMessage != nil { - Button(action: startMirroring) { - Text("Retry Connection") - .font(.headline) - .padding(.horizontal, 24) - .padding(.vertical, 10) - .background(Color.accentColor) - .foregroundColor(.white) - .cornerRadius(10) - } - .buttonStyle(.plain) - } - } - } - - private func updateWindowUI(isHovering: Bool) { - guard let window = currentWindow else { return } - window.isMovable = isHovering - - // Toggle native traffic lights visibility - window.standardWindowButton(.closeButton)?.isHidden = !isHovering - window.standardWindowButton(.miniaturizeButton)?.isHidden = !isHovering - window.standardWindowButton(.zoomButton)?.isHidden = true // Keep zoom hidden as it breaks mirroring aspect ratio - } - - private func updateWindowConstraints(width: UInt32, height: UInt32) { - guard width > 0 && height > 0 else { return } - currentWindow?.contentAspectRatio = NSSize(width: CGFloat(width), height: CGFloat(height)) - } - - private func startMirroring() { - errorMessage = nil - ADBConnector.getWiredDeviceSerial { serial in - guard let serial = serial else { - DispatchQueue.main.async { - self.errorMessage = "No wired ADB device detected. Please connect your device via USB." - } - return - } - ScrcpyServerManager.shared.startServer(serial: serial) { success in - guard success else { - DispatchQueue.main.async { - self.errorMessage = "Failed to start scrcpy server on device." - } - return - } - DispatchQueue.main.async { - self.streamClient.onPacketReceived = { data, isConfig, isKeyframe, pts in - ScrcpyVideoDecoder.shared.decodePacket(data: data, isConfig: isConfig, pts: pts) - } - self.streamClient.connect() - ScrcpyControlClient.shared.connect() - self.isMirroring = true - } - } - } - } - - private func stopMirroring() { - streamClient.disconnect() - ScrcpyControlClient.shared.disconnect() - ScrcpyServerManager.shared.stopServer() - isMirroring = false - } - - private func updateNavbarVisibility() { - guard let window = currentWindow else { return } - if isWindowActive && isMirroring && showMirrorControls { - navbarController.show(parent: window, isMirroring: isMirroring) - sideController.show(parent: window, isMirroring: isMirroring) - } else { - navbarController.hide() - sideController.hide() - } + ScrcpyBaseMirrorView( + desktopMode: false, + windowId: "nativeMirror", + defaultTitle: "AirSync Mirror", + defaultIconName: "iphone", + defaultRatio: 9.0 / 19.5, + isDesktopResizeEnabled: false + ) } } - - diff --git a/airsync-mac/Screens/ScannerView/DeviceCard.swift b/airsync-mac/Screens/ScannerView/DeviceCard.swift index 21f0842b..a4fd2f86 100644 --- a/airsync-mac/Screens/ScannerView/DeviceCard.swift +++ b/airsync-mac/Screens/ScannerView/DeviceCard.swift @@ -85,12 +85,13 @@ struct DeviceCard: View { Spacer() GlassButtonView( - label: "Connect", + label: isLastConnected ? "\(L("button.connect")) ⌘⏎" : L("button.connect"), systemImage: "bolt.circle.fill", primary: device.isActive, isLoading: isLoading, action: connectAction ) + .conditionalKeyboardShortcut(isEnabled: isLastConnected) .frame(maxWidth: .infinity) if !device.isActive { @@ -144,3 +145,20 @@ struct DeviceCard: View { } } } + +fileprivate struct KeyboardShortcutModifier: ViewModifier { + var isEnabled: Bool + func body(content: Content) -> some View { + if isEnabled { + content.keyboardShortcut(.return, modifiers: .command) + } else { + content + } + } +} + +extension View { + fileprivate func conditionalKeyboardShortcut(isEnabled: Bool) -> some View { + self.modifier(KeyboardShortcutModifier(isEnabled: isEnabled)) + } +} diff --git a/airsync-mac/Screens/Settings/AppNotificationSettingsView.swift b/airsync-mac/Screens/Settings/AppNotificationSettingsView.swift new file mode 100644 index 00000000..ac8a2a38 --- /dev/null +++ b/airsync-mac/Screens/Settings/AppNotificationSettingsView.swift @@ -0,0 +1,88 @@ +// +// AppNotificationSettingsView.swift +// AirSync +// +// Created by Antigravity on 2026-06-04. +// + +import SwiftUI + +struct AppNotificationSettingsView: View { + @Environment(\.dismiss) private var dismiss + let app: AndroidApp + @State private var isSilent = false + + var body: some View { + ZStack { + VisualEffectBlur(material: .hudWindow, blendingMode: .behindWindow) + + VStack(spacing: 0) { + // Header (Title & Icon on left, Close button on right end) + HStack(spacing: 12) { + if let iconPath = app.iconUrl, + let image = Image(filePath: iconPath) { + image + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 24, height: 24) + .cornerRadius(5) + } else { + Image(systemName: "app.badge") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 24, height: 24) + .foregroundColor(.gray) + } + + Text(String(format: L("settings.notifications.app.settings"), app.name)) + .font(.headline) + + Spacer() + + Button(action: { + dismiss() + }) { + Image(systemName: "xmark.circle") + .font(.system(size: 18)) + .foregroundColor(.secondary) + } + .buttonStyle(.plain) + .keyboardShortcut(.cancelAction) + } + .padding(.top, 16) + .padding(.horizontal, 16) + .padding(.bottom, 12) + + Divider() + + VStack(alignment: .leading, spacing: 16) { + HStack { + Text(L("settings.notifications.app.priority")) + .font(.body) + Spacer() + Picker("", selection: $isSilent) { + Text(L("settings.notifications.app.priority.alert")).tag(false) + Text(L("settings.notifications.app.priority.silent")).tag(true) + } + .pickerStyle(.segmented) + .controlSize(.large) + } + + Spacer() + } + .padding(20) + } + } + .frame(width: 450, height: 250) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .shadow(radius: 20) + .onAppear { + isSilent = UserDefaults.standard.appSilentNotifications[app.packageName] ?? false + } + .onChange(of: isSilent) { _, newValue in + var dict = UserDefaults.standard.appSilentNotifications + dict[app.packageName] = newValue + UserDefaults.standard.appSilentNotifications = dict + } + } +} diff --git a/airsync-mac/Screens/Settings/MirroringSettingsView.swift b/airsync-mac/Screens/Settings/MirroringSettingsView.swift index 7d841fb5..f9ac6878 100644 --- a/airsync-mac/Screens/Settings/MirroringSettingsView.swift +++ b/airsync-mac/Screens/Settings/MirroringSettingsView.swift @@ -27,200 +27,247 @@ struct MirroringSettingsView: View { @State private var xCoords: String = "0" @State private var yCoords: String = "0" + @AppStorage("swapCmdAndCtrl") private var swapCmdAndCtrl = true + @AppStorage("showMirrorControls") private var showMirrorControls = true + var body: some View { - Group { - if appState.isPlus { - unlockedMirroringView - } else { - lockedMirroringView + ScrollView { + VStack(alignment: .leading, spacing: 20) { + if appState.isPlus { + unlockedMirroringViewContent + } else { + androidMirroringSection + lockedMirroringViewContent + } } + .padding() + } + .onAppear { + tempBitrate = Double(AppState.shared.scrcpyBitrate) + tempResolution = Double(AppState.shared.scrcpyResolution) + let coords = UserDefaults.standard.manualPositionCoords + xCoords = coords.indices.contains(0) ? coords[0] : "0" + yCoords = coords.indices.contains(1) ? coords[1] : "0" } } - private var unlockedMirroringView: some View { - ScrollView { - VStack(alignment: .leading, spacing: 20) { - VStack{ - HStack{ - Label(L("settings.mirroring.defaultMode"), systemImage: "rectangle.on.rectangle.badge.gearshape") - Spacer() - Picker("", selection: $appState.useNativeMirroringByDefault) { - Label(L("settings.mirroring.scrcpy.title"), systemImage: "macwindow").tag(false) - Label(L("settings.mirroring.native.title"), systemImage: "apps.iphone").tag(true) - // Text(L("settings.mirroring.scrcpy.title")).tag(false) - // Text(L("settings.mirroring.native.title")).tag(true) - } - .pickerStyle(.segmented) - .controlSize(.large) - } + private var androidMirroringSection: some View { + VStack(alignment: .leading, spacing: 12) { + headerSection(title: L("settings.mirroring.androidMirroring"), icon: "keyboard") + + VStack(spacing: 12) { + SettingsToggleView( + name: L("settings.mirroring.swapCmdCtrl"), + icon: "arrow.triangle.2.circlepath", + isOn: $swapCmdAndCtrl + ) + + SettingsToggleView( + name: L("settings.mirroring.showMirrorControls"), + icon: "sidebar.left", + isOn: $showMirrorControls + ) + } + .padding() + .glassBoxIfAvailable(radius: 18) + } + } - Spacer(minLength: 8) + private var unlockedMirroringViewContent: some View { + VStack(alignment: .leading, spacing: 20) { + VStack(spacing: 16) { + HStack{ + Label(L("settings.mirroring.defaultMode"), systemImage: "iphone") + Spacer() + Picker("", selection: $appState.useNativeMirroringByDefault) { + Text(L("settings.mirroring.scrcpy.title")).tag(false) + Text(L("settings.mirroring.native.title")).tag(true) + } + .pickerStyle(.segmented) + .controlSize(.large) + } - if(appState.useNativeMirroringByDefault) { - Text(L("settings.mirroring.native.info")) - .font(.caption) - } else { - Text(L("settings.mirroring.scrcpy.info")) - .font(.caption) + if(appState.useNativeMirroringByDefault) { + Text(L("settings.mirroring.native.info")) + .font(.caption) + } else { + Text(L("settings.mirroring.scrcpy.info")) + .font(.caption) + } + } + .padding() + .glassBoxIfAvailable(radius: 18) + + VStack(spacing: 16) { + HStack{ + Label(L("settings.mirroring.desktop.defaultMode"), systemImage: "desktopcomputer") + Spacer() + Picker("", selection: $appState.useNativeDesktopMirroringByDefault) { + Text(L("settings.mirroring.scrcpy.title")).tag(false) + Text(L("settings.mirroring.native.title")).tag(true) } + .pickerStyle(.segmented) + .controlSize(.large) } - .padding() - headerSection(title: L("settings.mirroring.appMirroring"), icon: "apps.iphone.badge.plus") + Text(appState.useNativeDesktopMirroringByDefault + ? "Uses the built-in stream for desktop mode — no scrcpy binary required." + : "Launches desktop mode via the scrcpy binary.") + .font(.caption) + } + .padding() + .glassBoxIfAvailable(radius: 18) + + + androidMirroringSection + + headerSection(title: L("settings.mirroring.appMirroring"), icon: "apps.iphone.badge.plus") + + VStack(spacing: 16) { + HStack { + Label(L("settings.mirroring.enableAppMirroring"), systemImage: "apps.iphone.badge.plus") + Spacer() + Toggle("", isOn: $appState.mirroringPlus) + .toggleStyle(.switch) + } + + Divider() - VStack(spacing: 16) { + VStack(spacing: 12) { HStack { - Label(L("settings.mirroring.enableAppMirroring"), systemImage: "apps.iphone.badge.plus") + Text(L("settings.mirroring.videoBitrate")) Spacer() - Toggle("", isOn: $appState.mirroringPlus) - .toggleStyle(.switch) - } - Divider() - - VStack(spacing: 12) { - HStack { - Text(L("settings.mirroring.videoBitrate")) - Spacer() - - Slider( - value: $tempBitrate, - in: 1...12, - step: 1, - onEditingChanged: { editing in - if !editing { - AppState.shared.scrcpyBitrate = Int(tempBitrate) - } - if editing { - NSHapticFeedbackManager.defaultPerformer.perform(.generic, performanceTime: .default) - } else { - NSHapticFeedbackManager.defaultPerformer.perform(.generic, performanceTime: .default) - } - isDraggingBitrate = editing - isDragging = editing + Slider( + value: $tempBitrate, + in: 1...12, + step: 1, + onEditingChanged: { editing in + if !editing { + AppState.shared.scrcpyBitrate = Int(tempBitrate) } - ) - .focusable(false) - .frame(maxWidth: 150) - .onChange(of: tempBitrate) { oldValue, newValue in - guard isDraggingBitrate else { return } - if newValue != oldValue { - NSHapticFeedbackManager.defaultPerformer.perform(.levelChange, performanceTime: .default) + if editing { + NSHapticFeedbackManager.defaultPerformer.perform(.generic, performanceTime: .default) + } else { + NSHapticFeedbackManager.defaultPerformer.perform(.generic, performanceTime: .default) } + isDraggingBitrate = editing + isDragging = editing + } + ) + .focusable(false) + .frame(maxWidth: 150) + .onChange(of: tempBitrate) { oldValue, newValue in + guard isDraggingBitrate else { return } + if newValue != oldValue { + NSHapticFeedbackManager.defaultPerformer.perform(.levelChange, performanceTime: .default) } - - Text(String(format: L("settings.mirroring.bitrateFormat"), AppState.shared.scrcpyBitrate)) - .monospacedDigit() - .foregroundColor(isDragging ? .accentColor : .secondary) - .frame(width: 60, alignment: .leading) } - HStack { - Text(L("settings.mirroring.maxSize")) - Spacer() - - Slider( - value: $tempResolution, - in: 800...2600, - step: 200, - onEditingChanged: { editing in - if !editing { - AppState.shared.scrcpyResolution = Int(tempResolution) - } - if editing { - NSHapticFeedbackManager.defaultPerformer.perform(.generic, performanceTime: .default) - } else { - NSHapticFeedbackManager.defaultPerformer.perform(.generic, performanceTime: .default) - } - isDraggingResolution = editing - isDragging = editing + Text(String(format: L("settings.mirroring.bitrateFormat"), AppState.shared.scrcpyBitrate)) + .monospacedDigit() + .foregroundColor(isDragging ? .accentColor : .secondary) + .frame(width: 60, alignment: .leading) + } + + HStack { + Text(L("settings.mirroring.maxSize")) + Spacer() + + Slider( + value: $tempResolution, + in: 800...2600, + step: 200, + onEditingChanged: { editing in + if !editing { + AppState.shared.scrcpyResolution = Int(tempResolution) } - ) - .focusable(false) - .frame(maxWidth: 150) - .onChange(of: tempResolution) { oldValue, newValue in - guard isDraggingResolution else { return } - if newValue != oldValue { - NSHapticFeedbackManager.defaultPerformer.perform(.levelChange, performanceTime: .default) + if editing { + NSHapticFeedbackManager.defaultPerformer.perform(.generic, performanceTime: .default) + } else { + NSHapticFeedbackManager.defaultPerformer.perform(.generic, performanceTime: .default) } + isDraggingResolution = editing + isDragging = editing + } + ) + .focusable(false) + .frame(maxWidth: 150) + .onChange(of: tempResolution) { oldValue, newValue in + guard isDraggingResolution else { return } + if newValue != oldValue { + NSHapticFeedbackManager.defaultPerformer.perform(.levelChange, performanceTime: .default) } - - Text("\(AppState.shared.scrcpyResolution)") - .monospacedDigit() - .foregroundColor(isDragging ? .accentColor : .secondary) - .frame(width: 60, alignment: .leading) } - SettingsToggleView(name: L("settings.mirroring.stayOnTop"), icon: "inset.filled.toptrailing.rectangle.portrait", isOn: $scrcpyOnTop) + Text("\(AppState.shared.scrcpyResolution)") + .monospacedDigit() + .foregroundColor(isDragging ? .accentColor : .secondary) + .frame(width: 60, alignment: .leading) + } - SettingsToggleView(name: L("settings.mirroring.stayAwake"), icon: "cup.and.heat.waves", isOn: $stayAwake) + SettingsToggleView(name: L("settings.mirroring.stayOnTop"), icon: "inset.filled.toptrailing.rectangle.portrait", isOn: $scrcpyOnTop) - SettingsToggleView(name: L("settings.mirroring.blankDisplay"), icon: "iphone.gen3.slash", isOn: $turnScreenOff) + SettingsToggleView(name: L("settings.mirroring.stayAwake"), icon: "cup.and.heat.waves", isOn: $stayAwake) - SettingsToggleView(name: L("settings.mirroring.noAudio"), icon: "speaker.slash", isOn: $noAudio) + SettingsToggleView(name: L("settings.mirroring.blankDisplay"), icon: "iphone.gen3.slash", isOn: $turnScreenOff) - SettingsToggleView(name: L("settings.mirroring.continueApp"), icon: "arrow.turn.up.forward.iphone", isOn: $continueApp) + SettingsToggleView(name: L("settings.mirroring.noAudio"), icon: "speaker.slash", isOn: $noAudio) - SettingsToggleView(name: L("settings.mirroring.directKeyboardInput"), icon: "keyboard.chevron.compact.down", isOn: $directKeyInput) + SettingsToggleView(name: L("settings.mirroring.continueApp"), icon: "arrow.turn.up.forward.iphone", isOn: $continueApp) - HStack { - Text(L("settings.mirroring.dpi")) - Spacer() - TextField(L("settings.mirroring.dpi"), text: Binding( - get: { UserDefaults.standard.scrcpyDesktopDpi }, - set: { newValue in - UserDefaults.standard.scrcpyDesktopDpi = newValue.filter { "0123456789".contains($0) } - } - )) - .textFieldStyle(.roundedBorder) - .frame(maxWidth: 60) - } + SettingsToggleView(name: L("settings.mirroring.directKeyboardInput"), icon: "keyboard.chevron.compact.down", isOn: $directKeyInput) + + HStack { + Text(L("settings.mirroring.dpi")) + Spacer() + TextField(L("settings.mirroring.dpi"), text: Binding( + get: { UserDefaults.standard.scrcpyDesktopDpi }, + set: { newValue in + UserDefaults.standard.scrcpyDesktopDpi = newValue.filter { "0123456789".contains($0) } + } + )) + .textFieldStyle(.roundedBorder) + .frame(maxWidth: 60) + } - HStack { - Text(L("settings.mirroring.manualPosition")) - Spacer() + HStack { + Text(L("settings.mirroring.manualPosition")) + Spacer() - TextField(L("settings.mirroring.x"), text: $xCoords) - .textFieldStyle(.roundedBorder) - .onChange(of: xCoords) { oldValue, newValue in - xCoords = newValue.filter { "0123456789".contains($0) } - } - .disabled(!manualPosition) + TextField(L("settings.mirroring.x"), text: $xCoords) + .textFieldStyle(.roundedBorder) + .onChange(of: xCoords) { oldValue, newValue in + xCoords = newValue.filter { "0123456789".contains($0) } + } + .disabled(!manualPosition) - TextField(L("settings.mirroring.y"), text: $yCoords) - .textFieldStyle(.roundedBorder) - .onChange(of: yCoords) { oldValue, newValue in - yCoords = newValue.filter { "0123456789".contains($0) } - } - .disabled(!manualPosition) + TextField(L("settings.mirroring.y"), text: $yCoords) + .textFieldStyle(.roundedBorder) + .onChange(of: yCoords) { oldValue, newValue in + yCoords = newValue.filter { "0123456789".contains($0) } + } + .disabled(!manualPosition) - GlassButtonView( - label: L("settings.mirroring.set"), - action: { - UserDefaults.standard.manualPositionCoords = [xCoords, yCoords] - } - ) - .disabled(xCoords.isEmpty || yCoords.isEmpty || !manualPosition) + GlassButtonView( + label: L("settings.mirroring.set"), + action: { + UserDefaults.standard.manualPositionCoords = [xCoords, yCoords] + } + ) + .disabled(xCoords.isEmpty || yCoords.isEmpty || !manualPosition) - Toggle("", isOn: $manualPosition) - .toggleStyle(.switch) - } + Toggle("", isOn: $manualPosition) + .toggleStyle(.switch) } } - .padding() - .glassBoxIfAvailable(radius: 18) } .padding() - } - .onAppear { - tempBitrate = Double(AppState.shared.scrcpyBitrate) - tempResolution = Double(AppState.shared.scrcpyResolution) - xCoords = UserDefaults.standard.manualPositionCoords[0] - yCoords = UserDefaults.standard.manualPositionCoords[1] + .glassBoxIfAvailable(radius: 18) } } - private var lockedMirroringView: some View { + private var lockedMirroringViewContent: some View { VStack(spacing: 20) { Spacer() @@ -239,7 +286,6 @@ struct MirroringSettingsView: View { Spacer() } - .padding() } @ViewBuilder diff --git a/airsync-mac/Screens/Settings/NotificationsSettingsView.swift b/airsync-mac/Screens/Settings/NotificationsSettingsView.swift new file mode 100644 index 00000000..b80ab0f6 --- /dev/null +++ b/airsync-mac/Screens/Settings/NotificationsSettingsView.swift @@ -0,0 +1,194 @@ +// +// NotificationsSettingsView.swift +// AirSync +// +// Created by Antigravity on 2026-06-04. +// + +import SwiftUI +import UserNotifications + +struct NotificationsSettingsView: View { + @ObservedObject var appState = AppState.shared + + + + // State for notification permissions + @State private var notificationsGranted = false + @State private var notificationsChecked = false + @State private var selectedSettingsApp: AndroidApp? = nil + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + // 1. Notifications Sync + headerSection(title: L("settings.notifications.sync"), icon: "bell.badge") + VStack { + SettingsToggleView(name: L("settings.notifications.dismiss"), icon: "bell.badge", isOn: $appState.dismissNotif) + + HStack { + Label(L("settings.notifications.system"), systemImage: "bell.badge") + Spacer() + + if notificationsGranted { + Picker("", selection: $appState.notificationSound) { + Text(L("settings.notifications.sound.default")).tag("default") + ForEach(SystemSounds.availableSounds, id: \.self) { sound in + Text(sound).tag(sound) + } + } + .pickerStyle(MenuPickerStyle()) + .frame(minWidth: 100) + + Button(action: { + SystemSounds.playSound(appState.notificationSound) + }) { + Image(systemName: "play.circle") + } + .buttonStyle(.borderless) + .help("Test notification sound") + } else { + GlassButtonView( + label: L("settings.notifications.grant"), + systemImage: "bell.badge", + primary: true, + action: { + openNotificationSettings() + } + ) + } + } + } + .padding() + .glassBoxIfAvailable(radius: 18) + .onAppear { + checkNotificationPermissions() + } + .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in + checkNotificationPermissions() + } + + // 2. Call Alerts + headerSection(title: L("settings.notifications.calls"), icon: "phone") + VStack { + HStack { + Label(L("settings.notifications.callAlert"), systemImage: "phone") + Spacer() + + Picker("", selection: $appState.callNotificationMode) { + ForEach(CallNotificationMode.allCases, id: \.self) { mode in + Text(mode.displayName).tag(mode) + } + } + .pickerStyle(MenuPickerStyle()) + .frame(minWidth: 120) + } + + SettingsToggleView(name: L("settings.notifications.ring"), icon: "speaker.wave.3", isOn: $appState.ringForCalls) + } + .padding() + .glassBoxIfAvailable(radius: 18) + + // 3. Apps + headerSection(title: L("settings.notifications.app.title"), icon: "app.badge") + VStack(spacing: 12) { + if appState.device == nil { + Text(L("settings.notifications.apps.connect")) + .foregroundColor(.secondary) + .padding(.vertical, 8) + } else { + let sortedApps = appState.androidApps.values.sorted(by: { $0.name.lowercased() < $1.name.lowercased() }) + if sortedApps.isEmpty { + Text(L("settings.notifications.apps.none")) + .foregroundColor(.secondary) + .padding(.vertical, 8) + } else { + ForEach(sortedApps, id: \.packageName) { app in + HStack { + if let iconPath = app.iconUrl, + let image = Image(filePath: iconPath) { + image + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 24, height: 24) + .cornerRadius(4) + } else { + Image(systemName: "app.badge") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 24, height: 24) + .foregroundColor(.gray) + } + + Text(app.name) + .font(.body) + + Spacer() + + Button(action: { + selectedSettingsApp = app + }) { + Image(systemName: "gearshape") + .font(.system(size: 14)) + .foregroundColor(app.listening ? .primary : .secondary.opacity(0.5)) + } + .buttonStyle(.plain) + .disabled(!app.listening) + .padding(.trailing, 8) + + Toggle("", isOn: Binding( + get: { app.listening }, + set: { newValue in + WebSocketServer.shared.toggleNotification(for: app.packageName, to: newValue) + } + )) + .toggleStyle(.switch) + .labelsHidden() + } + + if app.packageName != sortedApps.last?.packageName { + Divider() + } + } + } + } + } + .padding() + .glassBoxIfAvailable(radius: 18) + } + .padding() + } + .sheet(item: $selectedSettingsApp) { app in + AppNotificationSettingsView(app: app) + } + } + + @ViewBuilder + private func headerSection(title: String, icon: String) -> some View { + HStack(spacing: 8) { + Image(systemName: icon) + .foregroundStyle(Color.accentColor) + Text(title) + .font(.system(size: 13, weight: .bold)) + .foregroundStyle(.secondary) + Spacer() + } + .padding(.horizontal, 8) + } + + // MARK: - Notification Permission Helpers + func checkNotificationPermissions() { + UNUserNotificationCenter.current().getNotificationSettings { settings in + DispatchQueue.main.async { + notificationsGranted = (settings.authorizationStatus == .authorized) + notificationsChecked = true + } + } + } + + func openNotificationSettings() { + if let url = URL(string: "x-apple.systempreferences:com.apple.preference.notifications") { + NSWorkspace.shared.open(url) + } + } +} diff --git a/airsync-mac/Screens/Settings/SettingsFeaturesView.swift b/airsync-mac/Screens/Settings/SettingsFeaturesView.swift index 0be14d84..33d8e811 100644 --- a/airsync-mac/Screens/Settings/SettingsFeaturesView.swift +++ b/airsync-mac/Screens/Settings/SettingsFeaturesView.swift @@ -293,8 +293,9 @@ struct SettingsFeaturesView: View { .padding() .glassBoxIfAvailable(radius: 18) .onAppear{ - xCoords = UserDefaults.standard.manualPositionCoords[0] - yCoords = UserDefaults.standard.manualPositionCoords[1] + let coords = UserDefaults.standard.manualPositionCoords + xCoords = coords.indices.contains(0) ? coords[0] : "0" + yCoords = coords.indices.contains(1) ? coords[1] : "0" } // Clipboard Sync diff --git a/airsync-mac/Screens/Settings/SettingsView.swift b/airsync-mac/Screens/Settings/SettingsView.swift index 147ec309..4a562de5 100644 --- a/airsync-mac/Screens/Settings/SettingsView.swift +++ b/airsync-mac/Screens/Settings/SettingsView.swift @@ -10,6 +10,8 @@ struct SettingsView: View { MyMacSettingsView() case .sync: SyncSettingsView() + case .notifications: + NotificationsSettingsView() case .mirroring: MirroringSettingsView() case .quickShare: diff --git a/airsync-mac/Screens/Settings/SyncSettingsView.swift b/airsync-mac/Screens/Settings/SyncSettingsView.swift index b434012c..1173c0a4 100644 --- a/airsync-mac/Screens/Settings/SyncSettingsView.swift +++ b/airsync-mac/Screens/Settings/SyncSettingsView.swift @@ -13,13 +13,9 @@ struct SyncSettingsView: View { @State private var showingPlusPopover = false @State private var showRemoteSheet = false + @State private var showPairingSheet = false @AppStorage("showInControlCenter") private var showInControlCenter = false @State private var showControlCenterInfo = false - @State private var showPairingSheet = false - - // State for notification permissions - @State private var notificationsGranted = false - @State private var notificationsChecked = false var body: some View { ScrollView { @@ -167,44 +163,9 @@ struct SyncSettingsView: View { .padding() .glassBoxIfAvailable(radius: 18) - // 3. Notifications - headerSection(title: "Notifications Sync", icon: "bell.badge") + // 3. Media Playback + headerSection(title: L("settings.notifications.mediaPlayback"), icon: "play.circle") VStack { - SettingsToggleView(name: "Sync notification dismissals", icon: "bell.badge", isOn: $appState.dismissNotif) - - HStack { - Label("System Notifications", systemImage: "bell.badge") - Spacer() - - if notificationsGranted { - Picker("", selection: $appState.notificationSound) { - Text("Default").tag("default") - ForEach(SystemSounds.availableSounds, id: \.self) { sound in - Text(sound).tag(sound) - } - } - .pickerStyle(MenuPickerStyle()) - .frame(minWidth: 100) - - Button(action: { - SystemSounds.playSound(appState.notificationSound) - }) { - Image(systemName: "play.circle") - } - .buttonStyle(.borderless) - .help("Test notification sound") - } else { - GlassButtonView( - label: "Grant Permission", - systemImage: "bell.badge", - primary: true, - action: { - openNotificationSettings() - } - ) - } - } - SettingsToggleView(name: "Send now playing status", icon: "play.circle", isOn: $appState.sendNowPlayingStatus) HStack { @@ -233,33 +194,8 @@ struct SyncSettingsView: View { } .padding() .glassBoxIfAvailable(radius: 18) - .onAppear { - checkNotificationPermissions() - } - .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in - checkNotificationPermissions() - } - - // 4. Call Alerts - headerSection(title: "Call Alerts", icon: "phone") - VStack { - HStack { - Label("Call Alert", systemImage: "phone") - Spacer() - Picker("", selection: $appState.callNotificationMode) { - ForEach(CallNotificationMode.allCases, id: \.self) { mode in - Text(mode.displayName).tag(mode) - } - } - .pickerStyle(MenuPickerStyle()) - .frame(minWidth: 120) - } - SettingsToggleView(name: "Ring for calls", icon: "speaker.wave.3", isOn: $appState.ringForCalls) - } - .padding() - .glassBoxIfAvailable(radius: 18) // 5. Remote Accessibility Control headerSection(title: "Remote Accessibility", icon: "accessibility") @@ -297,19 +233,5 @@ struct SyncSettingsView: View { .padding(.horizontal, 8) } - // MARK: - Notification Permission Helpers - func checkNotificationPermissions() { - UNUserNotificationCenter.current().getNotificationSettings { settings in - DispatchQueue.main.async { - notificationsGranted = (settings.authorizationStatus == .authorized) - notificationsChecked = true - } - } - } - func openNotificationSettings() { - if let url = URL(string: "x-apple.systempreferences:com.apple.preference.notifications") { - NSWorkspace.shared.open(url) - } - } } diff --git a/airsync-mac/airsync_macApp.swift b/airsync-mac/airsync_macApp.swift index 000adef6..e20c70f8 100644 --- a/airsync-mac/airsync_macApp.swift +++ b/airsync-mac/airsync_macApp.swift @@ -91,6 +91,9 @@ struct airsync_macApp: App { if !appState.isNativeMirroring { dismissWindow(id: "nativeMirror") } + if !appState.isNativeDesktopMirroring { + dismissWindow(id: "nativeDesktopMirror") + } if appState.activeCall == nil || appState.callNotificationMode != .popup { dismissWindow(id: "callWindow") } @@ -130,11 +133,24 @@ struct airsync_macApp: App { dismissWindow(id: "nativeMirror") } } + .onChange(of: appState.isNativeDesktopMirroring) { oldValue, newValue in + if newValue { + openWindow(id: "nativeDesktopMirror") + } else { + dismissWindow(id: "nativeDesktopMirror") + } + } .commands { CommandGroup(after: .appInfo) { CheckForUpdatesView(updater: updaterController.updater) } CommandGroup(replacing: .newItem) { } + CommandGroup(replacing: .appSettings) { + Button("Settings...") { + AppState.shared.selectedTab = .settings + } + .keyboardShortcut(",") + } CommandGroup(replacing: .help) { Button(action: { if let url = URL(string: "https://airsync.notion.site") { @@ -255,6 +271,21 @@ struct airsync_macApp: App { .windowStyle(.hiddenTitleBar) .defaultPosition(.center) + Window("Desktop Mirror", id: "nativeDesktopMirror") { + if #available(macOS 15.0, *) { + NativeDesktopMirrorView() + .environmentObject(appState) + .containerBackground(.ultraThinMaterial, for: .window) + } else { + NativeDesktopMirrorView() + .environmentObject(appState) + } + } + .windowResizability(.contentSize) + .defaultSize(width: 900, height: 560) + .windowStyle(.hiddenTitleBar) + .defaultPosition(.center) + } }