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()) }