diff --git a/.gitignore b/.gitignore index ef1803f1..5a5bf9b1 100644 --- a/.gitignore +++ b/.gitignore @@ -75,4 +75,5 @@ AGENTS.md .tend-stack docs/plans/ build.log -*.trace \ No newline at end of file +*.trace +build/ \ No newline at end of file diff --git a/AirSync.xcodeproj/project.pbxproj b/AirSync.xcodeproj/project.pbxproj index 52485a9f..7197aecb 100644 --- a/AirSync.xcodeproj/project.pbxproj +++ b/AirSync.xcodeproj/project.pbxproj @@ -185,7 +185,6 @@ buildActionMask = 2147483647; files = ( B9E423832FBD914900526D40 /* scrcpy-server in Resources */, - B99B04C62F641E1400FF3E2D /* AppIcon.icon in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/AirSync.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/AirSync.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index fd5c9388..bd8459b1 100644 --- a/AirSync.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/AirSync.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -38,12 +38,12 @@ } }, { - "identity" : "lottie-spm", + "identity" : "lottie-ios", "kind" : "remoteSourceControl", - "location" : "https://github.com/airbnb/lottie-spm.git", + "location" : "https://github.com/airbnb/lottie-ios.git", "state" : { - "revision" : "04f2fd18cc9404a0a0917265a449002674f24ec9", - "version" : "4.5.2" + "revision" : "b4bd0604ded9574807f41b4004b57dd1226a30a4", + "version" : "3.5.0" } }, { @@ -51,8 +51,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/tfmart/LottieUI", "state" : { - "revision" : "dd5b2f16213f89ab996a55fc2f51b56236da4bbe", - "version" : "1.3.3" + "revision" : "06cf691c4d1175c4fc9b9ddbf1472139107cd5a4", + "version" : "1.3.1" } }, { diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index f5528e98..e2625533 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -416,7 +416,7 @@ For button-only actions without reply text: **Direction:** Android → Mac -**Purpose:** Report device state: battery level, music playback status, and pairing information +**Purpose:** Report device state: battery level, music playback status, pairing information, and cellular network status ```json { @@ -435,7 +435,8 @@ For button-only actions without reply text: "isMuted": false, "albumArt": "data:image/png;base64,iVBORw0KG...", "likeStatus": "liked" - } + }, + "cellularNetwork": "5G" } } ``` diff --git a/README.md b/README.md index 04ddb05a..f61190a5 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ ## Requirements - Min : macOS 14.5 -- Opt: scrcpy, adb & media-control brew installs +- Opt: scrcpy, adb & media-control installed from brew > For Windows? I don't build for that platform but check this cool project out at [shrimqy/Sefirah](https://github.com/shrimqy/Sefirah) diff --git a/airsync-mac/Assets.xcassets/AppIcon.appiconset/Contents.json b/airsync-mac/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..c234cd2b --- /dev/null +++ b/airsync-mac/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,59 @@ +{ + "images" : [ + { + "idiom" : "mac", + "size" : "16x16", + "scale" : "1x" + }, + { + "idiom" : "mac", + "size" : "16x16", + "scale" : "2x" + }, + { + "idiom" : "mac", + "size" : "32x32", + "scale" : "1x" + }, + { + "idiom" : "mac", + "size" : "32x32", + "scale" : "2x" + }, + { + "idiom" : "mac", + "size" : "128x128", + "scale" : "1x" + }, + { + "idiom" : "mac", + "size" : "128x128", + "scale" : "2x" + }, + { + "idiom" : "mac", + "size" : "256x256", + "scale" : "1x" + }, + { + "idiom" : "mac", + "size" : "256x256", + "scale" : "2x" + }, + { + "idiom" : "mac", + "size" : "512x512", + "scale" : "1x" + }, + { + "idiom" : "mac", + "size" : "512x512", + "scale" : "2x", + "filename" : "icon-1024.png" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/airsync-mac/Assets.xcassets/AppIcon.appiconset/icon-1024.png b/airsync-mac/Assets.xcassets/AppIcon.appiconset/icon-1024.png new file mode 100644 index 00000000..c6aa74cb Binary files /dev/null and b/airsync-mac/Assets.xcassets/AppIcon.appiconset/icon-1024.png differ diff --git a/airsync-mac/Core/AppState.swift b/airsync-mac/Core/AppState.swift index 8f2e8841..f18c0ccc 100644 --- a/airsync-mac/Core/AppState.swift +++ b/airsync-mac/Core/AppState.swift @@ -55,6 +55,7 @@ class AppState: ObservableObject { self.menubarTextMaxLength = (savedMaxLength >= 50) ? savedMaxLength : 150 self.showMenubarIcon = UserDefaults.standard.object(forKey: "showMenubarIcon") == nil ? true : UserDefaults.standard.bool(forKey: "showMenubarIcon") + self.showMenubarNetworkStatus = UserDefaults.standard.object(forKey: "showMenubarNetworkStatus") == nil ? true : UserDefaults.standard.bool(forKey: "showMenubarNetworkStatus") self.menubarBatteryStyle = UserDefaults.standard.string(forKey: "menubarBatteryStyle") ?? "both" self.showMenubarMusicIcon = UserDefaults.standard.object(forKey: "showMenubarMusicIcon") == nil ? true : UserDefaults.standard.bool(forKey: "showMenubarMusicIcon") self.showMenubarAlbumArt = UserDefaults.standard.object(forKey: "showMenubarAlbumArt") == nil ? true : UserDefaults.standard.bool(forKey: "showMenubarAlbumArt") @@ -292,6 +293,7 @@ class AppState: ObservableObject { @Published var status: DeviceStatus? = nil { didSet { syncMediaTimerToPlayState() } } + @Published var cellularNetwork: String? = nil @Published var myDevice: Device? = nil @Published var port: UInt16 = Defaults.serverPort @Published var androidApps: [String: AndroidApp] = [:] @@ -426,6 +428,12 @@ class AppState: ObservableObject { } } + @Published var showMenubarNetworkStatus: Bool { + didSet { + UserDefaults.standard.set(showMenubarNetworkStatus, forKey: "showMenubarNetworkStatus") + } + } + @Published var menubarBatteryStyle: String { didSet { UserDefaults.standard.set(menubarBatteryStyle, forKey: "menubarBatteryStyle") diff --git a/airsync-mac/Core/MenuBarManager.swift b/airsync-mac/Core/MenuBarManager.swift index 1c2b83e3..a0d23369 100644 --- a/airsync-mac/Core/MenuBarManager.swift +++ b/airsync-mac/Core/MenuBarManager.swift @@ -105,6 +105,8 @@ class MenuBarManager: NSObject { appState.$temporaryDragLabel.map { _ in () }.eraseToAnyPublisher(), appState.$showMenubarPillStroke.map { _ in () }.eraseToAnyPublisher(), appState.$menubarNotificationStyle.map { _ in () }.eraseToAnyPublisher(), + appState.$showMenubarNetworkStatus.map { _ in () }.eraseToAnyPublisher(), + appState.$cellularNetwork.map { _ in () }.eraseToAnyPublisher(), BLECentralManager.shared.$connectionStatus.map { _ in () }.eraseToAnyPublisher(), BLECentralManager.shared.$connectedDeviceName.map { _ in () }.eraseToAnyPublisher() ] @@ -345,73 +347,79 @@ struct MenubarStatusView: View { .frame(maxWidth: CGFloat(appState.menubarTextMaxLength), alignment: .leading) } } else { - HStack(spacing: 5) { - // Left part: Device Name or Music Info - let showMusic = appState.showMenubarMusicIcon && (appState.status?.music?.isPlaying ?? false) - - if appState.showMenubarCallDetails, let callEvent = appState.activeCall { - HStack(spacing: 4) { - if let photoString = callEvent.contactPhoto, - !photoString.isEmpty, - let data = Data(base64Encoded: photoString, options: .ignoreUnknownCharacters) ?? Data(base64Encoded: photoString), - let nsImage = NSImage(data: data) { - let avatarSize = appState.menubarFontSize + 2 - Image(nsImage: nsImage) - .resizable() - .aspectRatio(contentMode: .fill) - .frame(width: avatarSize, height: avatarSize) - .clipShape(Circle()) - } else { - Image(systemName: callEvent.direction == .incoming ? "phone.arrow.down.left.fill" : "phone.arrow.up.right.fill") - .font(.system(size: appState.menubarFontSize)) - .foregroundColor(.green) - } - - Text(callEvent.contactName) - .font(.system(size: appState.menubarFontSize, weight: .medium)) - .lineLimit(1) - - Text("•") - .font(.system(size: appState.menubarFontSize)) - .foregroundColor(.secondary) - - Text(formatCallDuration(seconds: appState.activeCallDurationSec)) - .font(.system(size: appState.menubarFontSize, design: .monospaced)) - .layoutPriority(1) - } - } else if showMusic, let music = appState.status?.music { - let musicText = "\(music.title) — \(music.artist)" - - HStack(spacing: 3) { - if appState.showMenubarAlbumArt, - !music.albumArt.isEmpty, - let data = Data(base64Encoded: music.albumArt.stripBase64Prefix()) ?? Data(base64Encoded: music.albumArt), - let nsImage = NSImage(data: data) { - let albumArtSize = appState.menubarFontSize + 2 - let cornerRadius = albumArtSize * 3 / 14 - Image(nsImage: nsImage) - .resizable() - .aspectRatio(contentMode: .fill) - .frame(width: albumArtSize, height: albumArtSize) - .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) - } else { - Image(systemName: "music.note") + let showMusic = appState.showMenubarMusicIcon && (appState.status?.music?.isPlaying ?? false) + let hasDeviceName = !(appState.device?.name ?? bleManager.connectedDeviceName ?? "").isEmpty + let showLeftPart = (appState.showMenubarCallDetails && appState.activeCall != nil) || showMusic || (appState.showMenubarDeviceName && hasDeviceName) + + let showNetwork = appState.showMenubarNetworkStatus && appState.cellularNetwork != nil + let showBattery = appState.status?.battery != nil && appState.menubarBatteryStyle != "none" + let showRightPart = showNetwork || showBattery + + if showLeftPart || showRightPart { + HStack(spacing: 5) { + // Left part: Device Name or Music Info + if appState.showMenubarCallDetails, let callEvent = appState.activeCall { + HStack(spacing: 4) { + if let photoString = callEvent.contactPhoto, + !photoString.isEmpty, + let data = Data(base64Encoded: photoString, options: .ignoreUnknownCharacters) ?? Data(base64Encoded: photoString), + let nsImage = NSImage(data: data) { + let avatarSize = appState.menubarFontSize + 2 + Image(nsImage: nsImage) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: avatarSize, height: avatarSize) + .clipShape(Circle()) + } else { + Image(systemName: callEvent.direction == .incoming ? "phone.arrow.down.left.fill" : "phone.arrow.up.right.fill") + .font(.system(size: appState.menubarFontSize)) + .foregroundColor(.green) + } + + Text(callEvent.contactName) + .font(.system(size: appState.menubarFontSize, weight: .medium)) + .lineLimit(1) + + Text("•") .font(.system(size: appState.menubarFontSize)) - .foregroundColor(.accentColor) + .foregroundColor(.secondary) + + Text(formatCallDuration(seconds: appState.activeCallDurationSec)) + .font(.system(size: appState.menubarFontSize, design: .monospaced)) + .layoutPriority(1) } + } else if showMusic, let music = appState.status?.music { + let musicText = "\(music.title) — \(music.artist)" - if appState.enableMarquee { - MarqueeText(text: musicText, fontSize: appState.menubarFontSize, containerWidth: CGFloat(appState.menubarTextMaxLength)) - } else { - Text(musicText) - .font(.system(size: appState.menubarFontSize)) - .lineLimit(1) - .frame(maxWidth: CGFloat(appState.menubarTextMaxLength), alignment: .leading) + HStack(spacing: 3) { + if appState.showMenubarAlbumArt, + !music.albumArt.isEmpty, + let data = Data(base64Encoded: music.albumArt.stripBase64Prefix()) ?? Data(base64Encoded: music.albumArt), + let nsImage = NSImage(data: data) { + let albumArtSize = appState.menubarFontSize + 2 + let cornerRadius = albumArtSize * 3 / 14 + Image(nsImage: nsImage) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: albumArtSize, height: albumArtSize) + .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) + } else { + Image(systemName: "music.note") + .font(.system(size: appState.menubarFontSize)) + .foregroundColor(.accentColor) + } + + if appState.enableMarquee { + MarqueeText(text: musicText, fontSize: appState.menubarFontSize, containerWidth: CGFloat(appState.menubarTextMaxLength)) + } else { + Text(musicText) + .font(.system(size: appState.menubarFontSize)) + .lineLimit(1) + .frame(maxWidth: CGFloat(appState.menubarTextMaxLength), alignment: .leading) + } } - } - } else if appState.showMenubarDeviceName { - let deviceName = appState.device?.name ?? (bleManager.isAuthenticated ? bleManager.connectedDeviceName : nil) ?? "" - if !deviceName.isEmpty { + } else if appState.showMenubarDeviceName && hasDeviceName { + let deviceName = appState.device?.name ?? (bleManager.isAuthenticated ? bleManager.connectedDeviceName : nil) ?? "" if appState.enableMarquee { MarqueeText(text: deviceName, fontSize: appState.menubarFontSize, fontWeight: .medium, containerWidth: CGFloat(appState.menubarTextMaxLength)) } else { @@ -421,29 +429,48 @@ struct MenubarStatusView: View { .frame(maxWidth: CGFloat(appState.menubarTextMaxLength), alignment: .leading) } } - } - - // Right part: Battery - if let battery = appState.status?.battery { - let style = appState.menubarBatteryStyle - HStack(spacing: 3) { - // Show separator if there was a prefix shown - let hasPrefix = showMusic || (appState.showMenubarDeviceName && !(appState.device?.name ?? bleManager.connectedDeviceName ?? "").isEmpty) - if hasPrefix { - Text("•") - .font(.system(size: appState.menubarFontSize)) - .foregroundColor(.secondary) - } - - if style == "icon" || style == "both" { - Image(systemName: getBatteryIconName(level: battery.level, isCharging: battery.isCharging)) - .font(.system(size: appState.menubarFontSize)) - .foregroundColor(batteryColor(level: battery.level, isCharging: battery.isCharging)) - } - - if style == "percentage" || style == "both" { - Text("\(battery.level)%") - .font(.system(size: appState.menubarFontSize - 1, design: .monospaced)) + + // Right part: Battery & Network + if showRightPart { + HStack(spacing: 3) { + // Show separator if there was a prefix shown + if showLeftPart { + Text("•") + .font(.system(size: appState.menubarFontSize)) + .foregroundColor(.secondary) + } + + if appState.showMenubarNetworkStatus, let network = appState.cellularNetwork { + if network == "NO_SIGNAL" { + Image(systemName: "antenna.radiowaves.left.and.right.slash") + .font(.system(size: appState.menubarFontSize - 1, weight: .semibold)) + .foregroundColor(.red) + .padding(.trailing, showBattery ? 2 : 0) + .help(Localizer.shared.text("network.no_signal")) + } else { + let locKey = "network.\(network.lowercased())" + let locVal = Localizer.shared.text(locKey) + let displayText = locVal == locKey ? network.replacingOccurrences(of: "_", with: " ") : locVal + Text(displayText) + .font(.system(size: appState.menubarFontSize - 1, weight: .semibold)) + .foregroundColor(network.contains("5G") ? .green : (network == "LTE" ? .orange : .primary)) + .padding(.trailing, showBattery ? 2 : 0) + } + } + + if showBattery, let battery = appState.status?.battery { + let style = appState.menubarBatteryStyle + if style == "icon" || style == "both" { + Image(systemName: getBatteryIconName(level: battery.level, isCharging: battery.isCharging)) + .font(.system(size: appState.menubarFontSize)) + .foregroundColor(batteryColor(level: battery.level, isCharging: battery.isCharging)) + } + + if style == "percentage" || style == "both" { + Text("\(battery.level)%") + .font(.system(size: appState.menubarFontSize - 1, design: .monospaced)) + } + } } } } diff --git a/airsync-mac/Core/WebSocket/WebSocketServer+Handlers.swift b/airsync-mac/Core/WebSocket/WebSocketServer+Handlers.swift index d6b0eb91..57505fe8 100644 --- a/airsync-mac/Core/WebSocket/WebSocketServer+Handlers.swift +++ b/airsync-mac/Core/WebSocket/WebSocketServer+Handlers.swift @@ -353,6 +353,7 @@ extension WebSocketServer { } let oldTitle = AppState.shared.status?.music?.title + let cellularNetwork = dict["cellularNetwork"] as? String AppState.shared.status = DeviceStatus( battery: .init(level: level, isCharging: isCharging), @@ -368,10 +369,12 @@ extension WebSocketServer { duration: durationSec, position: positionSec, isBuffering: isBuffering - ) + ), + cellularNetwork: cellularNetwork ) DispatchQueue.main.async { + AppState.shared.cellularNetwork = cellularNetwork if oldTitle != title { AppState.shared.handleTrackChange() } else { diff --git a/airsync-mac/Localization/en.json b/airsync-mac/Localization/en.json index b1c6aea4..79aec011 100644 --- a/airsync-mac/Localization/en.json +++ b/airsync-mac/Localization/en.json @@ -158,6 +158,13 @@ "settings.menubar.batteryStyle.both": "Both Icon & Percentage", "settings.menubar.batteryStyle.icon": "Icon Only", "settings.menubar.batteryStyle.percentage": "Percentage Only", + "settings.menubar.batteryStyle.none": "Disable", + "settings.menubar.showNetworkStatus": "Show Network Status", + "network.no_signal": "No Signal", + "network.no_service": "No Service", + "network.emergency_only": "Emergency Only", + "network.wifi_calling": "Wi-Fi Calling", + "network.cellular_data_sync": "Cellular Data Sync", "settings.menubar.notifications": "Notifications", "settings.menubar.notifications.both": "Both Count & Icons", "settings.menubar.notifications.count": "Count Only", diff --git a/airsync-mac/Model/DeviceStatus.swift b/airsync-mac/Model/DeviceStatus.swift index 38b672d6..95ec36a2 100644 --- a/airsync-mac/Model/DeviceStatus.swift +++ b/airsync-mac/Model/DeviceStatus.swift @@ -32,4 +32,5 @@ struct DeviceStatus: Codable { var battery: Battery var isPaired: Bool var music: Music? + var cellularNetwork: String? } diff --git a/airsync-mac/Screens/HomeScreen/PhoneView/DeviceStatusView.swift b/airsync-mac/Screens/HomeScreen/PhoneView/DeviceStatusView.swift index c61750cb..11286ce5 100644 --- a/airsync-mac/Screens/HomeScreen/PhoneView/DeviceStatusView.swift +++ b/airsync-mac/Screens/HomeScreen/PhoneView/DeviceStatusView.swift @@ -34,11 +34,33 @@ struct DeviceStatusView: View { HStack{ - Image(systemName: batteryIcon(for: batteryLevel, isCharging: batteryIsCharging)) - .help("\(batteryLevel)%") - .contentTransition(.symbolEffect) - Text("\(batteryLevel)%") - .font(.caption2) + if appState.showMenubarNetworkStatus, let network = appState.cellularNetwork { + if network == "NO_SIGNAL" { + Image(systemName: "antenna.radiowaves.left.and.right.slash") + .font(.caption2.weight(.semibold)) + .foregroundColor(.red) + .help(Localizer.shared.text("network.no_signal")) + } else { + let locKey = "network.\(network.lowercased())" + let locVal = Localizer.shared.text(locKey) + let displayText = locVal == locKey ? network.replacingOccurrences(of: "_", with: " ") : locVal + Text(displayText) + .font(.caption2.weight(.semibold)) + .foregroundColor(network.contains("5G") ? .green : (network == "LTE" ? .orange : .primary)) + } + } + + if appState.menubarBatteryStyle != "none" { + if appState.menubarBatteryStyle == "icon" || appState.menubarBatteryStyle == "both" { + Image(systemName: batteryIcon(for: batteryLevel, isCharging: batteryIsCharging)) + .help("\(batteryLevel)%") + .contentTransition(.symbolEffect) + } + if appState.menubarBatteryStyle == "percentage" || appState.menubarBatteryStyle == "both" { + Text("\(batteryLevel)%") + .font(.caption2) + } + } } .padding(.leading, 4) diff --git a/airsync-mac/Screens/Settings/MenubarSettingsView.swift b/airsync-mac/Screens/Settings/MenubarSettingsView.swift index 6ab5b3d2..b3e61c99 100644 --- a/airsync-mac/Screens/Settings/MenubarSettingsView.swift +++ b/airsync-mac/Screens/Settings/MenubarSettingsView.swift @@ -110,6 +110,13 @@ struct MenubarSettingsView: View { Toggle("", isOn: $appState.showMenubarDeviceName) .toggleStyle(.switch) } + + HStack { + Label(L("settings.menubar.showNetworkStatus"), systemImage: "antenna.radiowaves.left.and.right") + Spacer() + Toggle("", isOn: $appState.showMenubarNetworkStatus) + .toggleStyle(.switch) + } } .disabled(!appState.showMenubarText) .opacity(appState.showMenubarText ? 1.0 : 0.6) @@ -121,6 +128,7 @@ struct MenubarSettingsView: View { Text(L("settings.menubar.batteryStyle.both")).tag("both") Text(L("settings.menubar.batteryStyle.icon")).tag("icon") Text(L("settings.menubar.batteryStyle.percentage")).tag("percentage") + Text(L("settings.menubar.batteryStyle.none")).tag("none") } .pickerStyle(MenuPickerStyle()) } diff --git a/appcast.xml b/appcast.xml index 7d37b3d8..4d9f0d22 100644 --- a/appcast.xml +++ b/appcast.xml @@ -3,12 +3,12 @@ AirSync - 3.0.0 - Sat, 14 Mar 2026 21:31:01 +0530 - 25 - 3.0.0 + 3.2.0 + Sat, 09 May 2026 14:14:48 +0530 + 28 + 3.2.0 14.5 - + diff --git a/build_and_install.sh b/build_and_install.sh new file mode 100755 index 00000000..93065abd --- /dev/null +++ b/build_and_install.sh @@ -0,0 +1,24 @@ +#!/bin/bash +set -e + +echo "Building AirSync..." +# We use the "AirSync" scheme and "Self Compiled" configuration based on your setup. +# The package resolution is handled automatically by xcodebuild when using -scheme. +xcodebuild -project AirSync.xcodeproj -scheme "AirSync" -configuration "Self Compiled" -derivedDataPath ./build + +echo "Killing existing AirSync processes..." +pkill -f "AirSync.app/Contents/MacOS/airsync-mac" || true +pkill -x "airsync-mac" || true +pkill -x "AirSync" || true +sleep 1 + +echo "Removing old AirSync from /Applications..." +rm -rf /Applications/AirSync.app + +echo "Copying new AirSync to /Applications..." +cp -R build/Build/Products/Self\ Compiled/AirSync.app /Applications/AirSync.app + +echo "Launching new AirSync app..." +open /Applications/AirSync.app + +echo "Done!"