From c1bce40cdb2f040eb342d97a26cb424bf74d3b01 Mon Sep 17 00:00:00 2001 From: Justin Date: Thu, 29 Oct 2026 16:01:13 +0000 Subject: [PATCH 1/4] fix(macos): find renamed Sony headsets by their control service Auto-connect matched paired devices only by name prefix (WH-, WF-, ...), so a headset renamed in macOS (e.g. "Birdie") fell through to the system device picker, which doesn't list it. Also accept any paired device whose cached SDP records advertise either Sony control service UUID. Co-Authored-By: Claude Opus 5.5 --- Client/macos/HeadphonesBridge.mm | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/Client/macos/HeadphonesBridge.mm b/Client/macos/HeadphonesBridge.mm index 90678d39..bb0ef696 100644 --- a/Client/macos/HeadphonesBridge.mm +++ b/Client/macos/HeadphonesBridge.mm @@ -199,12 +199,24 @@ static BOOL SHCLooksLikeSonyHeadset(NSString *name) { return NO; } +// A user-renamed headset (e.g. "Birdie") fails the name check, so also accept any paired device whose +// cached SDP records advertise either Sony control service - the same lookup connect() relies on. +static BOOL SHCHasSonyService(IOBluetoothDevice *device) { + IOBluetoothSDPUUID *v1 = [IOBluetoothSDPUUID uuidWithBytes:(void*)SERVICE_UUID_IN_BYTES length:16]; + IOBluetoothSDPUUID *v2 = [IOBluetoothSDPUUID uuidWithBytes:(void*)SERVICE_UUID_V2_IN_BYTES length:16]; + return [device getServiceRecordForUUID:v1] != nil || [device getServiceRecordForUUID:v2] != nil; +} + +static BOOL SHCIsSonyHeadset(IOBluetoothDevice *device) { + return SHCLooksLikeSonyHeadset([device name]) || SHCHasSonyService(device); +} + - (void)scanAndConnectWithCompletion:(void (^)(BOOL, NSString * _Nullable))completion { // Prefer the already system-connected Sony headset (Sony-app "My Device" behaviour) and skip the // native picker, which shows a confusing empty list when nothing is connected. IOBluetoothDevice *device = nil; for (IOBluetoothDevice *paired in [IOBluetoothDevice pairedDevices]) { - if ([paired isConnected] && SHCLooksLikeSonyHeadset([paired name])) { + if ([paired isConnected] && SHCIsSonyHeadset(paired)) { device = paired; break; } @@ -212,7 +224,7 @@ - (void)scanAndConnectWithCompletion:(void (^)(BOOL, NSString * _Nullable))compl // BLE/audio-only headsets (e.g. ULT WEAR) report isConnected == NO; fall back to any paired Sony headset. if (!device) { for (IOBluetoothDevice *paired in [IOBluetoothDevice pairedDevices]) { - if (SHCLooksLikeSonyHeadset([paired name])) { + if (SHCIsSonyHeadset(paired)) { device = paired; break; } From 8a13a2fd8c604806dacb81a34c748df194f492a9 Mon Sep 17 00:00:00 2001 From: Justin Date: Thu, 29 Oct 2026 16:01:13 +0000 Subject: [PATCH 2/4] feat(macos): menu-bar-only app, graph EQ editor and panel settings - Run as a menu-bar app (LSUIElement): no Dock icon, and the window opens only from the menu-bar item (or by relaunching the app). - Native Glass: set the menu-bar panel's appearance from the system light/dark setting; a status-item popover otherwise takes the menu bar's wallpaper-driven appearance. - Menu-bar panel: collapsible "More Settings" with DSEE and Auto Power-Off. - Equalizer: replace the preview and per-band sliders with a draggable graph. Bands sit on a log-frequency axis with dB gridlines; editing switches to Manual from the current preset's curve. Adds a Flat reset and a Clear Bass slider. - Spell "Noise Cancelling" the British way. Co-Authored-By: Claude Opus 5.5 --- Client/macos/AppDelegate.h | 1 - Client/macos/AppDelegate.mm | 5 +- Client/macos/ContentView.swift | 85 ++++++---- Client/macos/Main.storyboard | 4 +- Client/macos/MenuBarController.swift | 25 ++- Client/macos/QuickControlsView.swift | 64 +++++++- Client/macos/Theme.swift | 231 ++++++++++++++++++++++----- Client/macos/info.plist | 2 + 8 files changed, 339 insertions(+), 78 deletions(-) diff --git a/Client/macos/AppDelegate.h b/Client/macos/AppDelegate.h index 02174374..9d046f4f 100644 --- a/Client/macos/AppDelegate.h +++ b/Client/macos/AppDelegate.h @@ -13,6 +13,5 @@ #import "MacOSBluetoothConnector.h" @interface AppDelegate : NSObject - @property (weak) NSWindow* window; @end diff --git a/Client/macos/AppDelegate.mm b/Client/macos/AppDelegate.mm index 7339a465..6a6e16f4 100644 --- a/Client/macos/AppDelegate.mm +++ b/Client/macos/AppDelegate.mm @@ -14,18 +14,19 @@ @interface AppDelegate () @end @implementation AppDelegate +// A menu-bar app (LSUIElement): no Dock icon, and the window only opens from the menu-bar item. - (void)applicationDidFinishLaunching:(NSNotification *)aNotification { - _window = [[[NSApplication sharedApplication] windows] firstObject]; [MenuBarController install]; } +// Launching the app again while it's running (e.g. from Finder) opens the window. - (BOOL)applicationShouldHandleReopen:(NSApplication *)theApplication hasVisibleWindows:(BOOL)flag { if (flag) { return NO; } else { - [_window makeKeyAndOrderFront:self]; + [MenuBarController openMainWindow]; return YES; } } diff --git a/Client/macos/ContentView.swift b/Client/macos/ContentView.swift index 22773ca5..c3d781b3 100644 --- a/Client/macos/ContentView.swift +++ b/Client/macos/ContentView.swift @@ -192,44 +192,64 @@ struct ContentView: View { private let eqBandLabels = ["400", "1k", "2.5k", "6.3k", "16k"] private let tenBandEqLabels = ["31", "63", "125", "250", "500", "1k", "2k", "4k", "8k", "16k"] + // The graph shows the active preset's curve. Editing it (or Clear Bass) switches to Manual, starting + // from that curve, the way Sony's app does. private var equalizerCard: some View { - VStack(alignment: .leading, spacing: 14) { - cardTitle("Equalizer") - EQPreview(bands: model.eqBands, range: model.tenBandEqualizer ? 0...12 : -10...10) + let manual = model.eqPreset == 0xA0 + return VStack(alignment: .leading, spacing: 14) { + HStack { + cardTitle("Equalizer") + Spacer() + if manual && !model.tenBandEqualizer { + CapsuleButton("Flat") { + model.eqBands = model.eqBands.map { _ in 0 } + model.clearBass = 0 + model.applyCustomEq() + } + } + } + // The ten-band layout's 0...12 device steps have no known dB mapping yet, so they're shown raw. + EQEditor(bands: model.eqBands, + labels: model.tenBandEqualizer ? tenBandEqLabels : eqBandLabels, + range: model.tenBandEqualizer ? 0...12 : -10...10, + signed: !model.tenBandEqualizer) { band, value in + model.eqBands[band] = value + model.applyCustomEq() + } + if !manual { + Text("Drag a band to fine-tune. This switches to Manual.") + .font(.system(size: 11)) + .foregroundColor(palette.secondary) + } + if !model.tenBandEqualizer { + clearBassRow + } LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], spacing: 10) { ForEach(EQPresets.list(tenBand: model.tenBandEqualizer), id: \.1) { preset in eqChip(preset.0, preset.1) } } - if model.eqPreset == 0xA0 { - Divider().background(palette.cardStroke) - let labels = model.tenBandEqualizer ? tenBandEqLabels : eqBandLabels - ForEach(0.., display: Int, accent: Bool = false, raw: Bool = false) -> some View { + private var clearBassRow: some View { HStack(spacing: 10) { - Text(label) + Text("Clear Bass") .font(.system(size: 11, weight: .medium)) - .foregroundColor(accent ? palette.accentText : palette.secondary) - .frame(width: 34, alignment: .leading) - Slider(value: value, in: raw ? 0...12 : -10...10, step: 1).accentColor(palette.accent) - Text(raw ? "\(display)" : "\(display > 0 ? "+" : "")\(display)") + .foregroundColor(palette.accentText) + .fixedSize() + Slider(value: Binding( + get: { Double(model.clearBass) }, + set: { + let value = Int($0.rounded()) + guard value != model.clearBass else { return } + model.clearBass = value + model.applyCustomEq() + } + ), in: -10...10, step: 1) + .accentColor(palette.accent) + Text(model.clearBass > 0 ? "+\(model.clearBass)" : "\(model.clearBass)") .font(.system(size: 11, weight: .semibold, design: .monospaced)) .foregroundColor(palette.primary) .frame(width: 26, alignment: .trailing) @@ -258,8 +278,6 @@ struct ContentView: View { .buttonStyle(PlainButtonStyle()) } - private let apoOptions = ["Off", "5 min", "30 min", "1 hour", "3 hours", "When taken off"] - private var settingsCard: some View { VStack(alignment: .leading, spacing: 14) { cardTitle("Settings") @@ -293,7 +311,7 @@ struct ContentView: View { get: { model.autoPowerOff }, set: { model.setAutoPowerOff($0) } )) { - ForEach(0.. [(String, Int)] { tenBand ? Self.tenBand : fiveBand } } +// Indexed like Headphones::setAutoPowerOff (0=Off ... 5=when taken off). +enum AutoPowerOff { + static let options = ["Off", "5 min", "30 min", "1 hour", "3 hours", "When taken off"] +} + extension SHCAmbientMode { var displayName: String { switch self { - case .noiseCanceling: return "Noise Canceling" + case .noiseCanceling: return "Noise Cancelling" case .ambientSound: return "Ambient" default: return "Off" } @@ -666,7 +689,7 @@ struct StatusChips: View { } } -// Noise Canceling / Ambient / Off: segments in a recessed track, or separate pills in Sony Signature. +// Noise Cancelling / Ambient / Off: segments in a recessed track, or separate pills in Sony Signature. struct AmbientModeControl: View { @ObservedObject var model: HeadphonesModel diff --git a/Client/macos/Main.storyboard b/Client/macos/Main.storyboard index 6241e88e..f00f7e20 100644 --- a/Client/macos/Main.storyboard +++ b/Client/macos/Main.storyboard @@ -1,5 +1,5 @@ - + @@ -128,7 +128,7 @@ - + diff --git a/Client/macos/MenuBarController.swift b/Client/macos/MenuBarController.swift index 140e97f9..843ada6a 100644 --- a/Client/macos/MenuBarController.swift +++ b/Client/macos/MenuBarController.swift @@ -18,6 +18,7 @@ import SwiftUI private let menu = NSMenu() private let popover = NSPopover() private var cancellables = Set() + private var appearanceObservation: NSKeyValueObservation? @objc static func install() { guard instance == nil else { return } @@ -47,6 +48,10 @@ import SwiftUI model.objectWillChange .sink { [weak self] _ in DispatchQueue.main.async { self?.updateIcon() } } .store(in: &cancellables) + // Follow a light/dark switch made while the panel is open. + appearanceObservation = NSApp.observe(\.effectiveAppearance) { [weak self] _, _ in + DispatchQueue.main.async { self?.updatePopoverAppearance() } + } } @objc private func statusItemClicked(_ sender: NSStatusBarButton) { @@ -61,6 +66,7 @@ import SwiftUI popover.performClose(nil) } else { refreshMultipointIfIdle() + updatePopoverAppearance() if let panel = popover.contentViewController, #unavailable(macOS 13.0) { popover.contentSize = panel.view.fittingSize } @@ -69,9 +75,24 @@ import SwiftUI } } - static func openMainWindow() { + // The storyboard window isn't shown at launch (menu-bar app); it's made on first open and kept, since + // it isn't released when closed. + private static var windowController: NSWindowController? + + @objc static func openMainWindow() { + if windowController == nil { + windowController = NSStoryboard(name: "Main", bundle: nil) + .instantiateController(withIdentifier: "MainWindow") as? NSWindowController + } NSApp.activate(ignoringOtherApps: true) - NSApp.windows.first { $0.contentViewController != nil && !($0 is NSPanel) }?.makeKeyAndOrderFront(nil) + windowController?.showWindow(nil) + windowController?.window?.makeKeyAndOrderFront(nil) + } + + // A status-item popover takes the menu bar's appearance, which follows the wallpaper rather than the + // system setting, so set it from the theme (Native Glass: the system's light/dark) each time it shows. + private func updatePopoverAppearance() { + popover.appearance = AppTheme.stored.resolvedAppearance } // Re-read so the panel or menu reflects changes made from the phone or the headset. diff --git a/Client/macos/QuickControlsView.swift b/Client/macos/QuickControlsView.swift index 98f6c0c6..818c3574 100644 --- a/Client/macos/QuickControlsView.swift +++ b/Client/macos/QuickControlsView.swift @@ -11,12 +11,13 @@ struct QuickControlsView: View { @ObservedObject private var model = HeadphonesModel.shared @AppStorage(AppTheme.storageKey) private var theme = AppTheme.refinedDark + @AppStorage("quickControlsMoreExpanded") private var moreExpanded = false @Environment(\.colorScheme) private var colorScheme private var palette: Palette { theme.palette(colorScheme) } // 320pt gives each mode segment a little more room than the window's at its 360pt minimum, so - // "Noise Canceling" fits within the mode control's minimum scale. The height follows the content. + // "Noise Cancelling" fits within the mode control's minimum scale. The height follows the content. var body: some View { VStack(alignment: .leading, spacing: 14) { if model.connected { @@ -94,9 +95,70 @@ struct QuickControlsView: View { .themedCard(compact: true) } + if model.supportsDsee || model.hasAutoPowerOff { + moreSettings + } + errorText } + // Less-used settings, collapsed by default so the panel stays short. The open/closed state is remembered. + private var moreSettings: some View { + VStack(alignment: .leading, spacing: 12) { + Button(action: { withAnimation(.easeInOut(duration: 0.15)) { moreExpanded.toggle() } }) { + HStack { + Text("More Settings") + .font(.system(size: 13, weight: .medium)) + .foregroundColor(palette.primary) + Spacer() + Image(systemName: "chevron.right") + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(palette.secondary) + .rotationEffect(.degrees(moreExpanded ? 90 : 0)) + } + .contentShape(Rectangle()) + } + .buttonStyle(PlainButtonStyle()) + .accessibilityValue(moreExpanded ? "Expanded" : "Collapsed") + + if moreExpanded { + if model.supportsDsee { + SwitchRow("DSEE", isOn: Binding(get: { model.dsee }, set: { model.setDsee($0) })) { + VStack(alignment: .leading, spacing: 2) { + Text("DSEE") + .font(.system(size: 13, weight: .medium)) + .foregroundColor(palette.primary) + Text("Upscale compressed audio") + .font(.system(size: 11)) + .foregroundColor(palette.secondary) + } + } + } + if model.hasAutoPowerOff { + HStack { + Text("Auto Power-Off") + .font(.system(size: 13, weight: .medium)) + .foregroundColor(palette.primary) + Spacer() + Picker("Auto Power-Off", selection: Binding( + get: { model.autoPowerOff }, + set: { model.setAutoPowerOff($0) } + )) { + ForEach(0.. + let labels: [String] + let range: ClosedRange + let signed: Bool // dB values (-10...10); the ten-band layout's raw 0...12 steps are unsigned + let set: (_ band: Int, _ value: Int) -> Void + + @State private var dragging: Int? @Environment(\.appTheme) private var theme @Environment(\.colorScheme) private var colorScheme + private let plotHeight: CGFloat = 150 + private let axisWidth: CGFloat = 26 + private let labelsHeight: CGFloat = 34 + var body: some View { - let accent = theme.palette(colorScheme).accent - let span = range.upperBound - range.lowerBound - let levels = bands.map { min(max((Double($0) - range.lowerBound) / span, 0), 1) } - Group { - if theme.isSignature { - HStack(alignment: .bottom, spacing: 6) { - ForEach(levels.indices, id: \.self) { i in - RoundedRectangle(cornerRadius: 4, style: .continuous) - .fill(accent.opacity(0.5 + 0.4 * levels[i])) - .frame(maxWidth: 34) - .frame(height: 6 + 48 * levels[i]) - .frame(maxWidth: .infinity) + let palette = theme.palette(colorScheme) + GeometryReader { proxy in + let geo = EQGeometry(plot: CGRect(x: axisWidth, y: 0, width: proxy.size.width - axisWidth, height: plotHeight), + count: bands.count, range: range) + ZStack(alignment: .topLeading) { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(palette.track) + .overlay(RoundedRectangle(cornerRadius: 10, style: .continuous) + .strokeBorder(palette.cardStroke, lineWidth: 1)) + .frame(width: geo.plot.width, height: geo.plot.height) + .offset(x: geo.plot.minX) + + grid(geo, palette) + trace(geo, palette) + + ForEach(bands.indices, id: \.self) { i in + node(i, geo, palette) + } + + // Frequency under each node, its value under that. + ForEach(bands.indices, id: \.self) { i in + VStack(spacing: 2) { + Text(i < labels.count ? labels[i] : "") + .font(.system(size: 10, weight: .medium)) + .foregroundColor(palette.secondary) + Text(valueText(bands[i])) + .font(.system(size: 10, weight: .semibold, design: .monospaced)) + .foregroundColor(dragging == i ? palette.accentText : palette.primary) } + .fixedSize() + .position(x: geo.x(i), y: plotHeight + labelsHeight / 2 + 2) } - } else { - ZStack { - EQCurve(levels: levels, closed: true).fill(accent.opacity(0.15)) - EQCurve(levels: levels, closed: false) - .stroke(accent, style: StrokeStyle(lineWidth: 2.5, lineCap: .round, lineJoin: .round)) + } + .contentShape(Rectangle()) + .gesture(DragGesture(minimumDistance: 0) + .onChanged { drag in + let band = dragging ?? geo.band(atX: drag.startLocation.x) + dragging = band + let value = geo.value(atY: drag.location.y) + if band < bands.count && value != bands[band] { set(band, value) } + } + .onEnded { _ in dragging = nil }) + } + .frame(height: plotHeight + labelsHeight) + } + + private var gridValues: [Int] { + let quarter = (range.upperBound - range.lowerBound) / 4 + return (0...4).map { range.lowerBound + $0 * quarter } + } + + private func valueText(_ value: Int) -> String { + signed && value > 0 ? "+\(value)" : "\(value)" + } + + private func grid(_ geo: EQGeometry, _ palette: Palette) -> some View { + ZStack(alignment: .topLeading) { + ForEach(gridValues, id: \.self) { value in + let zero = signed && value == 0 + Path { p in + p.move(to: CGPoint(x: geo.plot.minX, y: geo.y(value))) + p.addLine(to: CGPoint(x: geo.plot.maxX, y: geo.y(value))) + } + .stroke(palette.secondary.opacity(zero ? 0.45 : 0.18), + style: StrokeStyle(lineWidth: 1, dash: zero ? [] : [3, 3])) + Text(valueText(value)) + .font(.system(size: 9, weight: .medium, design: .monospaced)) + .foregroundColor(palette.secondary) + .fixedSize() + .position(x: axisWidth / 2 - 2, y: geo.y(value)) + } + ForEach(bands.indices, id: \.self) { i in + Path { p in + p.move(to: CGPoint(x: geo.x(i), y: geo.plot.minY + 6)) + p.addLine(to: CGPoint(x: geo.x(i), y: geo.plot.maxY - 6)) } + .stroke(palette.secondary.opacity(dragging == i ? 0.35 : 0.1), lineWidth: 1) } } - .frame(height: 54, alignment: .bottom) + } + + @ViewBuilder + private func trace(_ geo: EQGeometry, _ palette: Palette) -> some View { + // Fill toward 0 dB, or the bottom for the unsigned raw steps. + let base = signed ? 0 : range.lowerBound + if theme.isSignature { + ForEach(bands.indices, id: \.self) { i in + let top = min(geo.y(bands[i]), geo.y(base)) + RoundedRectangle(cornerRadius: 3, style: .continuous) + .fill(palette.accent.opacity(dragging == i ? 0.6 : 0.35)) + .frame(width: 10, height: max(abs(geo.y(bands[i]) - geo.y(base)), 2)) + .position(x: geo.x(i), y: top + max(abs(geo.y(bands[i]) - geo.y(base)), 2) / 2) + } + } else { + geo.curve(bands, fillTo: base).fill(palette.accent.opacity(0.15)) + geo.curve(bands, fillTo: nil) + .stroke(palette.accent, style: StrokeStyle(lineWidth: 2.5, lineCap: .round, lineJoin: .round)) + } + } + + private func node(_ i: Int, _ geo: EQGeometry, _ palette: Palette) -> some View { + let active = dragging == i + let size: CGFloat = active ? 16 : 12 + return ZStack { + Circle() + .fill(palette.accent) + .overlay(Circle().strokeBorder(palette.onAccent, lineWidth: 2)) + .frame(width: size, height: size) + .shadow(color: palette.accent.opacity(active ? 0.5 : 0), radius: 6) + if active { + Text(valueText(bands[i])) + .font(.system(size: 11, weight: .bold, design: .monospaced)) + .foregroundColor(palette.onAccent) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(palette.accent, in: Capsule()) + .fixedSize() + .offset(y: geo.y(bands[i]) - geo.plot.minY < 34 ? 22 : -22) + } + } + .position(x: geo.x(i), y: geo.y(bands[i])) + .accessibilityElement(children: .ignore) + .accessibilityLabel(i < labels.count ? "\(labels[i]) Hz" : "Band \(i + 1)") + .accessibilityValue(signed ? "\(valueText(bands[i])) dB" : valueText(bands[i])) + .accessibilityAdjustableAction { direction in + let step = direction == .increment ? 1 : -1 + let value = min(max(bands[i] + step, range.lowerBound), range.upperBound) + if value != bands[i] { set(i, value) } + } } } -// Levels are 0...1, spread evenly left to right. Horizontal tangents at each band keep the curve -// from overshooting between them. -private struct EQCurve: Shape { - let levels: [Double] - let closed: Bool +// Band index/value <-> point in the plot. Nodes are inset so ones at the ends or the limits aren't clipped. +private struct EQGeometry { + let plot: CGRect + let count: Int + let range: ClosedRange + + private let sideInset: CGFloat = 16 + private let topBottomInset: CGFloat = 12 - func path(in rect: CGRect) -> Path { + private var span: Double { Double(range.upperBound - range.lowerBound) } + private var usableHeight: CGFloat { plot.height - 2 * topBottomInset } + + func x(_ band: Int) -> CGFloat { + guard count > 1 else { return plot.midX } + return plot.minX + sideInset + CGFloat(band) * (plot.width - 2 * sideInset) / CGFloat(count - 1) + } + + func y(_ value: Int) -> CGFloat { + let t = (Double(value) - Double(range.lowerBound)) / span + return plot.maxY - topBottomInset - CGFloat(t) * usableHeight + } + + func value(atY y: CGFloat) -> Int { + let t = Double((plot.maxY - topBottomInset - y) / usableHeight) + let value = Int((Double(range.lowerBound) + t * span).rounded()) + return min(max(value, range.lowerBound), range.upperBound) + } + + func band(atX x: CGFloat) -> Int { + guard count > 1 else { return 0 } + let step = (plot.width - 2 * sideInset) / CGFloat(count - 1) + return min(max(Int(((x - plot.minX - sideInset) / step).rounded()), 0), count - 1) + } + + // Through every node, flat out to the plot's edges. Horizontal tangents at each node keep the curve + // from overshooting between bands, so it never shows a level no band is set to. + func curve(_ bands: [Int], fillTo base: Int?) -> Path { var path = Path() - let levels = self.levels.count == 1 ? self.levels + self.levels : self.levels - guard levels.count > 1 else { return path } - let inset: CGFloat = 3 - let step = rect.width / CGFloat(levels.count - 1) - let points = levels.enumerated().map { i, level in - CGPoint(x: rect.minX + CGFloat(i) * step, - y: rect.maxY - inset - CGFloat(level) * (rect.height - 2 * inset)) - } - path.move(to: points[0]) + guard !bands.isEmpty else { return path } + let points = bands.indices.map { CGPoint(x: x($0), y: y(bands[$0])) } + path.move(to: CGPoint(x: plot.minX + 1, y: points[0].y)) + path.addLine(to: points[0]) for (from, to) in zip(points, points.dropFirst()) { let midX = (from.x + to.x) / 2 path.addCurve(to: to, control1: CGPoint(x: midX, y: from.y), control2: CGPoint(x: midX, y: to.y)) } - if closed { - path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY)) - path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY)) + path.addLine(to: CGPoint(x: plot.maxX - 1, y: points[points.count - 1].y)) + if let base = base { + path.addLine(to: CGPoint(x: plot.maxX - 1, y: y(base))) + path.addLine(to: CGPoint(x: plot.minX + 1, y: y(base))) path.closeSubpath() } return path diff --git a/Client/macos/info.plist b/Client/macos/info.plist index 80ad76cc..9e058b93 100644 --- a/Client/macos/info.plist +++ b/Client/macos/info.plist @@ -18,6 +18,8 @@ 0.4.0 LSApplicationCategoryType public.app-category.utilities + LSUIElement + NSBluetoothAlwaysUsageDescription This app uses Bluetooth in order to connect to your headphones. NSHighResolutionCapable From ab57c5636b1a3482d76659d55cc1c589213d2c7f Mon Sep 17 00:00:00 2001 From: Justin Date: Thu, 29 Oct 2026 16:56:21 +0000 Subject: [PATCH 3/4] feat(xm4): Speak-to-Chat, multipoint and touch panel on v1 headsets Captured from a WH-1000XM4 on fw 3.0.1, plus an iPhone Sound Connect trace of the multipoint switch. - Speak-to-Chat: the GET reply is f7 05 00 , not f7 05 01 (that kind only appears in change notifications), so the probe rejected it and the setting stayed hidden. Accept both. - General settings: find MULTIPOINT_SETTING and TOUCH_PANEL_SETTING by name from the function list (06 00, d0 01) and read them (d6 -> d7 01 <01=on>), as acouplet does. - Touch panel: d8 01 . - Multipoint: send 94 01 00 first, as Sound Connect does; without it the headset declines with a d9 notification of the old value. Then d8 01 , and answer its 99 01 06/07 confirmation with 98 01 06/07 01, after which it restarts. A declined change is read back and reported instead of being shown as applied. Tests replay the captured replies, including the decline. Co-Authored-By: Claude Opus 5.5 --- Client/Constants.h | 31 ++++++ Client/Headphones.cpp | 123 ++++++++++++++++++++++- Client/Headphones.h | 9 ++ Client/tests/HeadphonesProtocolTests.cpp | 113 ++++++++++++++++++--- 4 files changed, 260 insertions(+), 16 deletions(-) diff --git a/Client/Constants.h b/Client/Constants.h index 7a609e71..da22d986 100644 --- a/Client/Constants.h +++ b/Client/Constants.h @@ -57,6 +57,37 @@ namespace V1Command inline constexpr unsigned char SUB_AUTO_POWER_OFF = 0x04; inline constexpr unsigned char SUB_SPEAK_TO_CHAT = 0x05; inline constexpr unsigned char MANUAL_EQ = 0xff; + + // Supported-function list: 06 00 -> 07 00 . + inline constexpr unsigned char SUPPORT_FUNCTION_GET = 0x06; + inline constexpr unsigned char SUPPORT_FUNCTION_RET = 0x07; + // General settings, in the slots (d1..d4) the function list advertises. Same opcodes as v2, different + // layout, captured from a WH-1000XM4 on fw 3.0.1 and matching acouplet (baglayan/acouplet PR #5): + // d0 01 -> d1 02(boolean) name summary 01 00 + // d6 -> d7 01 <01=on/00=off> + // d8 01 <01=on/00=off> + inline constexpr unsigned char GS_GET_CAPABILITY = 0xd0; + inline constexpr unsigned char GS_RET_CAPABILITY = 0xd1; + inline constexpr unsigned char GS_GET_PARAM = 0xd6; + inline constexpr unsigned char GS_RET_PARAM = 0xd7; + inline constexpr unsigned char GS_SET_PARAM = 0xd8; + inline constexpr unsigned char GS_TYPE_BOOLEAN = 0x02; + inline constexpr unsigned char GS_PARAM_BOOLEAN = 0x01; + inline constexpr unsigned char GS_SLOT_FIRST = 0xd1; + inline constexpr unsigned char GS_SLOT_LAST = 0xd4; + inline constexpr unsigned char GS_DISPLAY_LANGUAGE_ENGLISH = 0x01; + // A setting that drops the Bluetooth links (turning multipoint on/off) is held until confirmed: + // the device sends 99 01 and applies it on 98 01 01. It only asks + // a client that has sent 94 01 00 (Sound Connect does, once per connection); otherwise it declines the + // change with a d9 notification of the old value. From an iPhone Sound Connect capture, XM4 fw 3.0.1. + inline constexpr unsigned char ALERT_SET_STATUS = 0x94; + inline constexpr unsigned char ALERT_STATUS_ENABLE = 0x00; + inline constexpr unsigned char ALERT_NTFY = 0x99; + inline constexpr unsigned char ALERT_SET = 0x98; + inline constexpr unsigned char ALERT_FORMAT_FIXED = 0x01; + inline constexpr unsigned char ALERT_MULTIPOINT_ON = 0x06; + inline constexpr unsigned char ALERT_MULTIPOINT_OFF = 0x07; + inline constexpr unsigned char ALERT_ACTION_POSITIVE = 0x01; } // Second-generation (v2) inquiry/response command opcodes (the first payload byte). diff --git a/Client/Headphones.cpp b/Client/Headphones.cpp index 10b20c64..7ae468d4 100644 --- a/Client/Headphones.cpp +++ b/Client/Headphones.cpp @@ -573,7 +573,9 @@ void Headphones::probeCapabilities() try { auto r = this->_conn.sendCommandAndReadResponse({ (char)V1Command::BTNMODE_GET, (char)V1Command::SUB_SPEAK_TO_CHAT }, V1Command::BTNMODE_RET, V1Command::SUB_SPEAK_TO_CHAT); - if (r.size() >= 4 && r[2] == 0x01) { std::lock_guard g(this->_propertyMtx); this->_speakToChat = r[3] != 0; this->_hasSpeakToChat = true; } + // f7 05 . A WH-1000XM4 on fw 3.0.1 answers this GET with kind 00 (f7 05 00 01 when on) + // and uses kind 01 only in its change notifications (f9 05 01 01), so accept either. + if (r.size() >= 4 && (r[2] == 0x00 || r[2] == 0x01)) { std::lock_guard g(this->_propertyMtx); this->_speakToChat = r[3] != 0; this->_hasSpeakToChat = true; } } catch (...) {} return; } @@ -716,7 +718,11 @@ namespace void Headphones::requestMultipoint() { - if (this->_conn.getProtocolVersion() != SonyProtocolVersion::V2) return; + if (this->_conn.getProtocolVersion() == SonyProtocolVersion::V1) + { + this->_requestGeneralSettingsV1(); + return; + } Buffer initReply; bool table2Supported; @@ -936,6 +942,87 @@ bool Headphones::hasPlaybackPinState() { std::lock_guard g(this->_propertyMtx); bool Headphones::isPlaybackPinned() { std::lock_guard g(this->_propertyMtx); return this->_playbackPinned; } bool Headphones::hasMultipointSetting() { std::lock_guard g(this->_propertyMtx); return this->_hasMultipointSetting; } bool Headphones::getMultipointEnabled() { std::lock_guard g(this->_propertyMtx); return this->_multipointEnabled; } + +// The v1 general settings have no device list or playback pin (those are v2 table 2); only the settings +// slots, found by name once per connection from the function list, then just read. +void Headphones::_requestGeneralSettingsV1() +{ + bool searched; int multipointSlot; int touchPanelSlot; + { + std::lock_guard guard(this->_propertyMtx); + searched = this->_multipointSlotSearched; + multipointSlot = this->_multipointSlot; + touchPanelSlot = this->_touchPanelSlot; + } + + if (!searched) + { + try { + auto functions = this->_conn.sendCommandAndReadResponse({ (char)V1Command::SUPPORT_FUNCTION_GET, 0x00 }, + V1Command::SUPPORT_FUNCTION_RET, 0x00); + size_t end = functions.size() >= 3 ? std::min(functions.size(), (size_t)3 + (unsigned char)functions[2]) : 0; + for (size_t i = 3; i < end; i++) + { + auto slot = (unsigned char)functions[i]; + if (slot < V1Command::GS_SLOT_FIRST || slot > V1Command::GS_SLOT_LAST) continue; + try { + auto resp = this->_conn.sendCommandAndReadResponse( + { (char)V1Command::GS_GET_CAPABILITY, (char)slot, (char)V1Command::GS_DISPLAY_LANGUAGE_ENGLISH }, + V1Command::GS_RET_CAPABILITY, slot); + if (resp.size() < 4 || (unsigned char)resp[2] != V1Command::GS_TYPE_BOOLEAN) continue; + size_t nameLength = (unsigned char)resp[3]; + if (4 + nameLength > resp.size()) continue; + std::string name(resp.begin() + 4, resp.begin() + 4 + nameLength); + if (name == "MULTIPOINT_SETTING") multipointSlot = slot; + else if (name == "TOUCH_PANEL_SETTING") touchPanelSlot = slot; + } catch (...) {} + } + } catch (...) {} + } + + // d7 01 <01=on/00=off>; anything else leaves the setting unconfirmed (hidden). + auto read = [this](int slot, bool& has, bool& enabled) { + has = false; enabled = false; + if (slot < 0) return; + try { + auto resp = this->_conn.sendCommandAndReadResponse({ (char)V1Command::GS_GET_PARAM, (char)slot }, + V1Command::GS_RET_PARAM, slot); + if (resp.size() >= 4 && (unsigned char)resp[2] == V1Command::GS_PARAM_BOOLEAN && (unsigned char)resp[3] <= 1) + { + has = true; + enabled = resp[3] == 0x01; + } + } catch (...) {} + }; + bool hasMultipoint, multipointEnabled, hasTouchPanel, touchPanelEnabled; + read(multipointSlot, hasMultipoint, multipointEnabled); + read(touchPanelSlot, hasTouchPanel, touchPanelEnabled); + + std::lock_guard guard(this->_propertyMtx); + this->_multipointSlotSearched = true; + this->_multipointSlot = multipointSlot; + this->_touchPanelSlot = touchPanelSlot; + this->_hasMultipointSetting = hasMultipoint; + this->_multipointEnabled = multipointEnabled; + this->_hasTouchPanelSetting = hasTouchPanel; + this->_touchPanelEnabled = touchPanelEnabled; +} + +bool Headphones::hasTouchPanelSetting() { std::lock_guard g(this->_propertyMtx); return this->_hasTouchPanelSetting; } +bool Headphones::getTouchPanelEnabled() { std::lock_guard g(this->_propertyMtx); return this->_touchPanelEnabled; } + +void Headphones::setTouchPanel(bool enabled) +{ + int slot; + { + std::lock_guard guard(this->_propertyMtx); + if (!this->_hasTouchPanelSetting) throw RecoverableException("This headset doesn't report a touch controls setting."); + slot = this->_touchPanelSlot; + } + this->_conn.sendCommand({ (char)V1Command::GS_SET_PARAM, (char)slot, (char)V1Command::GS_PARAM_BOOLEAN, (char)(enabled ? 0x01 : 0x00) }); + std::lock_guard guard(this->_propertyMtx); + this->_touchPanelEnabled = enabled; +} std::string Headphones::getMultipointDiagnostics() { std::lock_guard g(this->_propertyMtx); return this->_multipointDiagnostics; } void Headphones::setMultipoint(bool enabled) @@ -946,6 +1033,38 @@ void Headphones::setMultipoint(bool enabled) if (!this->_hasMultipointSetting) throw RecoverableException("This headset doesn't report a multipoint setting."); slot = this->_multipointSlot; } + if (this->_conn.getProtocolVersion() == SonyProtocolVersion::V1) + { + // Applying this restarts the headset, so it acknowledges the write and then asks for confirmation + // (99 01 06/07 ), holding the change until answered. Only a multipoint alert is answered. + // It asks only once alerts are enabled (94 01 00), which is sent here rather than on every connect + // so no other confirmation prompts, which nothing here answers, get switched on. + this->_conn.sendCommand({ (char)V1Command::ALERT_SET_STATUS, (char)V1Command::ALERT_FORMAT_FIXED, (char)V1Command::ALERT_STATUS_ENABLE }); + Buffer alert; + try { + alert = this->_conn.sendCommandAndReadResponse( + { (char)V1Command::GS_SET_PARAM, (char)slot, (char)V1Command::GS_PARAM_BOOLEAN, (char)(enabled ? 0x01 : 0x00) }, + V1Command::ALERT_NTFY, V1Command::ALERT_FORMAT_FIXED); + } catch (...) {} + if (alert.size() >= 4 && ((unsigned char)alert[2] == V1Command::ALERT_MULTIPOINT_ON || (unsigned char)alert[2] == V1Command::ALERT_MULTIPOINT_OFF) + && ((unsigned char)alert[3] == 0x01 || (unsigned char)alert[3] == 0x02)) + { + this->_conn.sendCommand({ (char)V1Command::ALERT_SET, (char)V1Command::ALERT_FORMAT_FIXED, alert[2], (char)V1Command::ALERT_ACTION_POSITIVE }); + std::lock_guard guard(this->_propertyMtx); + this->_multipointEnabled = enabled; + return; + } + // No confirmation asked: read back what the device kept. A WH-1000XM4 on fw 3.0.1 answers d8 d2 01 00 + // with d9 d2 01 01 (still on) and no alert, i.e. it declines the change. + auto resp = this->_conn.sendCommandAndReadResponse({ (char)V1Command::GS_GET_PARAM, (char)slot }, V1Command::GS_RET_PARAM, slot); + bool kept = resp.size() >= 4 && resp[3] == 0x01; + { + std::lock_guard guard(this->_propertyMtx); + this->_multipointEnabled = kept; + } + if (kept != enabled) throw RecoverableException("The headphones didn't accept the change."); + return; + } // The state frame goes first (d8 00 <00=on/01=off>), then the fixed apply commit 98 00 06 01. // sendCommand waits for each ACK, which reproduces GadgetBridge's frame/ack interleaving. this->_conn.sendCommand({ (char)V2Command::GS_SET_PARAM, (char)slot, 0x00, (char)(enabled ? 0x00 : 0x01) }); diff --git a/Client/Headphones.h b/Client/Headphones.h index fa69c279..f1bb0eba 100644 --- a/Client/Headphones.h +++ b/Client/Headphones.h @@ -132,8 +132,14 @@ class Headphones { int setPlaybackPinned(bool pinned); // "Connect to 2 devices". The setting is device-persistent, so the app never enforces a default. Throws // (without sending) unless requestMultipoint() found the MULTIPOINT_SETTING general-settings slot. + // On v1 the headset restarts to apply it, dropping this connection. void setMultipoint(bool enabled); + // Touch controls on/off (v1 TOUCH_PANEL_SETTING general setting, found by requestMultipoint()). + bool hasTouchPanelSetting(); + bool getTouchPanelEnabled(); + void setTouchPanel(bool enabled); + bool isChanged(); void setChanges(); @@ -165,6 +171,8 @@ class Headphones { // The WH-1000XM6 speaks a different layout for two v2 commands: noise-control state is read via inquired // type 0x19 (0x17 answers all zeros), and the equalizer uses inquired type 0x04 with 10 raw bands. bool _isWh1000Xm6() const; + // requestMultipoint() on v1: the multipoint and touch panel general settings (GETs only). + void _requestGeneralSettingsV1(); // Both return false if the device didn't answer that battery sub-type. bool _requestDualBattery(unsigned char subType); bool _requestSingleBattery(unsigned char subType, int& levelOut, bool& chargingOut); @@ -188,6 +196,7 @@ class Headphones { bool _hasPlaybackPinState = false; bool _playbackPinned = false; bool _multipointSlotSearched = false; int _multipointSlot = -1; bool _hasMultipointSetting = false; bool _multipointEnabled = false; + int _touchPanelSlot = -1; bool _hasTouchPanelSetting = false; bool _touchPanelEnabled = false; std::string _multipointDiscoveryLog; std::string _multipointWriteLog; std::string _multipointDiagnostics; diff --git a/Client/tests/HeadphonesProtocolTests.cpp b/Client/tests/HeadphonesProtocolTests.cpp index 1517a049..e0ad21ea 100644 --- a/Client/tests/HeadphonesProtocolTests.cpp +++ b/Client/tests/HeadphonesProtocolTests.cpp @@ -59,6 +59,9 @@ class FakeV1Connector final : public IBluetoothConnector std::vector sentPayloads; Buffer recvPrefix; bool notificationBeforeNextAck = false; + Buffer speakToChatReply = { (char)V1Command::BTNMODE_RET, (char)V1Command::SUB_SPEAK_TO_CHAT, 0x01, 0x01 }; + bool multipointDeclines = false; + bool alertsEnabled = false; private: void queue(const Buffer& payload, DATA_TYPE type, unsigned char sequence) @@ -66,6 +69,18 @@ class FakeV1Connector final : public IBluetoothConnector incoming.push_back(CommandSerializer::packageDataForBt(payload, type, sequence)); } + // d1 02(boolean) name summary 01 00 + static Buffer generalSetting(unsigned char slot, const std::string& name, const std::string& summary) + { + Buffer reply = { (char)V1Command::GS_RET_CAPABILITY, (char)slot, (char)V1Command::GS_TYPE_BOOLEAN, (char)name.size() }; + reply.insert(reply.end(), name.begin(), name.end()); + reply.push_back((char)summary.size()); + reply.insert(reply.end(), summary.begin(), summary.end()); + reply.push_back(0x01); + reply.push_back(0x00); + return reply; + } + void queueResponse(const Buffer& request) { if (request.empty()) return; @@ -83,7 +98,35 @@ class FakeV1Connector final : public IBluetoothConnector if (request.size() >= 2 && (unsigned char)request[1] == V1Command::SUB_AUTO_POWER_OFF) response = { (char)V1Command::BTNMODE_RET, (char)V1Command::SUB_AUTO_POWER_OFF, 0x01, 0x01, 0x01 }; else if (request.size() >= 2 && (unsigned char)request[1] == V1Command::SUB_SPEAK_TO_CHAT) - response = { (char)V1Command::BTNMODE_RET, (char)V1Command::SUB_SPEAK_TO_CHAT, 0x01, 0x01 }; + response = speakToChatReply; + break; + // General settings, as a WH-1000XM4 on fw 3.0.1 answered them (multipoint in d2, touch panel in d1). + case V1Command::SUPPORT_FUNCTION_GET: + response = { (char)V1Command::SUPPORT_FUNCTION_RET, 0x00, 0x17, 0x71, 0x38, 0x62, (char)0xf5, (char)0x81, 0x51, + (char)0xa1, (char)0xe1, (char)0xe2, (char)0xd2, (char)0xf6, (char)0xd1, (char)0xf4, (char)0xf3, 0x39, 0x12, + 0x13, 0x11, 0x30, (char)0xc1, 0x14, 0x22, 0x21 }; + break; + case V1Command::GS_GET_CAPABILITY: + if (request.size() >= 2 && (unsigned char)request[1] == 0xd2) + response = generalSetting(0xd2, "MULTIPOINT_SETTING", "MULTIPOINT_SETTING_SUMMARY"); + else if (request.size() >= 2 && (unsigned char)request[1] == 0xd1) + response = generalSetting(0xd1, "TOUCH_PANEL_SETTING", ""); + break; + case V1Command::ALERT_SET_STATUS: + if (request.size() >= 3 && (unsigned char)request[1] == V1Command::ALERT_FORMAT_FIXED) + alertsEnabled = request[2] == V1Command::ALERT_STATUS_ENABLE; + return; + case V1Command::GS_GET_PARAM: + if (request.size() >= 2) response = { (char)V1Command::GS_RET_PARAM, request[1], 0x01, 0x01 }; + break; + case V1Command::GS_SET_PARAM: + // Changing multipoint is held until confirmed: 99 01 <06 on / 07 off> 01, but only once alerts are + // enabled (94 01 00). Otherwise an XM4 on fw 3.0.1 notifies its unchanged value, d9 d2 01 01. + if (request.size() >= 4 && (unsigned char)request[1] == 0xd2) + response = (multipointDeclines || !alertsEnabled) + ? Buffer({ (char)0xd9, (char)0xd2, 0x01, 0x01 }) + : Buffer({ (char)V1Command::ALERT_NTFY, (char)V1Command::ALERT_FORMAT_FIXED, + (char)(request[3] ? V1Command::ALERT_MULTIPOINT_ON : V1Command::ALERT_MULTIPOINT_OFF), 0x01 }); break; default: return; // SET commands only need the ACK already queued above. } @@ -205,6 +248,20 @@ int main() require(headphones.hasAutoPowerOff() && headphones.getAutoPowerOff() == 2, "v1 auto-power-off response was not parsed"); require(headphones.hasSpeakToChat() && headphones.getSpeakToChat(), "v1 Speak-to-Chat response was not parsed"); + // Captured from a WH-1000XM4 on fw 3.0.1: its Speak-to-Chat GET reply carries kind 00, not 01 + // (f7 05 00 00 when off, f7 05 00 01 when on), so the capability must still be confirmed. + for (char on : { 0x00, 0x01 }) + { + auto xm4 = std::make_unique(); + xm4->speakToChatReply = { (char)V1Command::BTNMODE_RET, (char)V1Command::SUB_SPEAK_TO_CHAT, 0x00, on }; + BluetoothWrapper xm4Bluetooth(std::move(xm4)); + Headphones xm4Headphones(xm4Bluetooth); + xm4Headphones.initDevice(); + xm4Headphones.probeCapabilities(); + require(xm4Headphones.hasSpeakToChat(), "XM4 fw 3.0.1 Speak-to-Chat reply (kind 00) was rejected"); + require(xm4Headphones.getSpeakToChat() == (on != 0), "XM4 fw 3.0.1 Speak-to-Chat state was misread"); + } + const auto hasV1BatteryRequest = std::any_of(observed->sentPayloads.begin(), observed->sentPayloads.end(), [](const Buffer& payload) { return payload.size() >= 2 && (unsigned char)payload[0] == V1Command::BATTERY_GET && payload[1] == 0x00; }); @@ -253,21 +310,49 @@ int main() (unsigned char)speakToChat[1] == V1Command::SUB_SPEAK_TO_CHAT && (unsigned char)speakToChat[3] == 0x00, "Speak-to-Chat did not use the v1 command layout"); - // Multipoint has no proven v1 frames (GadgetBridge SonyProtocolImplV1: "Connect two devices not - // implemented for V1"), so the XM4 path must never probe or set it. + // XM4 multipoint and touch panel: the v1 general-settings layout, found by name from the function list. headphones.requestMultipoint(); - require(!headphones.hasMultipointSetting(), "multipoint capability must stay unconfirmed on v1"); - const auto hasMultipointFrame = std::any_of(observed->sentPayloads.begin(), observed->sentPayloads.end(), [](const Buffer& payload) { - return !payload.empty() && ((unsigned char)payload[0] == V2Command::GS_GET_CAPABILITY || - (unsigned char)payload[0] == V2Command::GS_GET_PARAM || - (unsigned char)payload[0] == V2Command::GS_SET_PARAM || - (unsigned char)payload[0] == V2Command::SYSCTL_SET); + require(headphones.hasMultipointSetting() && headphones.getMultipointEnabled(), "XM4 multipoint setting was not read"); + require(headphones.hasTouchPanelSetting() && headphones.getTouchPanelEnabled(), "XM4 touch panel setting was not read"); + const auto hasV2SettingRead = std::any_of(observed->sentPayloads.begin(), observed->sentPayloads.end(), [](const Buffer& payload) { + return payload.size() == 2 && (unsigned char)payload[0] == V1Command::GS_GET_CAPABILITY; + }); + require(!hasV2SettingRead, "XM4 general-settings name read must carry the display language byte"); + + headphones.setMultipoint(false); + const auto& confirm = observed->sentPayloads.back(); + const auto& multipointWrite = observed->sentPayloads[observed->sentPayloads.size() - 2]; + require(observed->sentPayloads[observed->sentPayloads.size() - 3] == Buffer({ (char)V1Command::ALERT_SET_STATUS, 0x01, 0x00 }), + "XM4 multipoint change was not preceded by 94 01 00 (without it the headset declines)"); + require(multipointWrite == Buffer({ (char)V1Command::GS_SET_PARAM, (char)0xd2, 0x01, 0x00 }), + "XM4 multipoint off did not use d8 01 00"); + require(confirm == Buffer({ (char)V1Command::ALERT_SET, 0x01, (char)V1Command::ALERT_MULTIPOINT_OFF, 0x01 }), + "XM4 multipoint off confirmation was not answered with 98 01 07 01"); + require(!headphones.getMultipointEnabled(), "XM4 multipoint state did not follow the write"); + + // A declined change must not be reported as applied: the state is read back and the write throws. + { + auto declining = std::make_unique(); + declining->multipointDeclines = true; + BluetoothWrapper decliningBluetooth(std::move(declining)); + Headphones decliningHeadphones(decliningBluetooth); + decliningHeadphones.initDevice(); + decliningHeadphones.requestMultipoint(); + bool declined = false; + try { decliningHeadphones.setMultipoint(false); } + catch (const RecoverableException&) { declined = true; } + require(declined, "a declined XM4 multipoint change was reported as applied"); + require(decliningHeadphones.getMultipointEnabled(), "a declined XM4 multipoint change left the state flipped"); + } + + headphones.setTouchPanel(false); + require(observed->sentPayloads.back() == Buffer({ (char)V1Command::GS_SET_PARAM, (char)0xd1, 0x01, 0x00 }), + "XM4 touch panel off did not use d8 01 00"); + + const auto hasV2MultipointCommit = std::any_of(observed->sentPayloads.begin(), observed->sentPayloads.end(), [](const Buffer& payload) { + return payload.size() >= 2 && (unsigned char)payload[0] == V2Command::SYSCTL_SET && payload[1] == 0x00; }); - require(!hasMultipointFrame, "XM4 path emitted a v2 multipoint frame (d0/d6/d8/98)"); - bool multipointRejected = false; - try { headphones.setMultipoint(true); } - catch (const std::exception&) { multipointRejected = true; } - require(multipointRejected, "setMultipoint on v1 must throw instead of sending"); + require(!hasV2MultipointCommit, "XM4 path emitted the v2 multipoint commit (98 00 ...)"); // --- v2 multipoint ("Connect to 2 devices"), byte layout proven by GadgetBridge on the WH-ULT900N // (SonyProtocolImplV2 commits 8cbc309346, e8520865f3, 5d7b37d1b7) --- From bede201bab44c6d9e40e59877c19071cda8b3286 Mon Sep 17 00:00:00 2001 From: Justin Date: Thu, 29 Oct 2026 16:56:39 +0000 Subject: [PATCH 4/4] feat(macos): the menu-bar panel is the whole app - Drop the window (storyboard scenes, ViewController, ContentView); its shared pieces move to SharedViews.swift. Everything lives in the menu-bar panel: the EQ graph, preset menu and Clear Bass up top, and settings, devices, appearance, device info and Quit behind a footer arrow. Drop the codec chip. - Auto-connect on launch and whenever a Sony headset connects to the Mac, never via the device picker, and not after a manual disconnect. - Hide the menu-bar icon while no Sony headset is connected to the Mac; relaunching the app shows it with the panel. - XM4 switches: Connect to 2 devices (asks before restarting the headset, then reconnects once it's back) and Touch Controls. A declined change shows the headset's real state and an error. Co-Authored-By: Claude Opus 5.5 --- Client/macos/AppDelegate.mm | 12 +- Client/macos/ContentView.swift | 796 ------------------ Client/macos/HeadphonesBridge.h | 7 + Client/macos/HeadphonesBridge.mm | 40 +- Client/macos/HeadphonesModel.swift | 76 +- Client/macos/Main.storyboard | 203 ----- Client/macos/MenuBarController.swift | 69 +- Client/macos/QuickControlsView.swift | 519 +++++++++--- Client/macos/SharedViews.swift | 234 +++++ .../project.pbxproj | 14 +- Client/macos/Theme.swift | 2 +- Client/macos/ViewController.h | 31 - Client/macos/ViewController.mm | 57 -- 13 files changed, 826 insertions(+), 1234 deletions(-) delete mode 100644 Client/macos/ContentView.swift create mode 100644 Client/macos/SharedViews.swift delete mode 100644 Client/macos/ViewController.h delete mode 100644 Client/macos/ViewController.mm diff --git a/Client/macos/AppDelegate.mm b/Client/macos/AppDelegate.mm index 6a6e16f4..099a96e3 100644 --- a/Client/macos/AppDelegate.mm +++ b/Client/macos/AppDelegate.mm @@ -14,21 +14,15 @@ @interface AppDelegate () @end @implementation AppDelegate -// A menu-bar app (LSUIElement): no Dock icon, and the window only opens from the menu-bar item. +// A menu-bar app (LSUIElement): no Dock icon or window; everything lives in the menu-bar panel. - (void)applicationDidFinishLaunching:(NSNotification *)aNotification { [MenuBarController install]; } -// Launching the app again while it's running (e.g. from Finder) opens the window. - (BOOL)applicationShouldHandleReopen:(NSApplication *)theApplication hasVisibleWindows:(BOOL)flag { - if (flag) { - return NO; - } - else { - [MenuBarController openMainWindow]; - return YES; - } + [MenuBarController showPanel]; + return NO; } - (void)applicationWillTerminate:(NSNotification *)aNotification { diff --git a/Client/macos/ContentView.swift b/Client/macos/ContentView.swift deleted file mode 100644 index c3d781b3..00000000 --- a/Client/macos/ContentView.swift +++ /dev/null @@ -1,796 +0,0 @@ -// -// ContentView.swift -// Sony Sound Connect-style SwiftUI interface. -// - -import SwiftUI -import AppKit - -struct ContentView: View { - @StateObject private var model = HeadphonesModel.shared - @State private var showAbout = false - @AppStorage(AppTheme.storageKey) private var theme = AppTheme.refinedDark - @Environment(\.colorScheme) private var colorScheme - - private var palette: Palette { theme.palette(colorScheme) } - - var body: some View { - ZStack { - if model.connected { - connectedView - } else { - disconnectedView - } - } - .frame(minWidth: 360, minHeight: 500) - .background(palette.background) - .environment(\.appTheme, theme) - .onAppear { theme.applyToApp() } - .onChange(of: theme) { $0.applyToApp() } - } - - // MARK: - Disconnected - - private var disconnectedView: some View { - VStack(spacing: 24) { - Spacer() - Image(systemName: "headphones") - .font(.system(size: 64, weight: .thin)) - .foregroundColor(palette.secondary) - Text("No headphones connected") - .font(.system(size: 15, weight: .medium)) - .foregroundColor(palette.secondary) - if let error = model.errorMessage { - Text(error) - .font(.system(size: 12)) - .foregroundColor(palette.danger) - .multilineTextAlignment(.center) - .padding(.horizontal, 32) - } - ConnectButton(model: model) - .padding(.horizontal, 40) - Spacer() - } - } - - // MARK: - Connected - - // Two independently scrolling columns once the window is wide enough, otherwise one. The pair is capped - // and centered so cards don't stretch on a very wide window. - private var connectedView: some View { - GeometryReader { proxy in - if proxy.size.width >= 720 { - HStack(alignment: .top, spacing: 0) { - column { primaryCards } - .frame(width: 380) - column(padding: [.vertical, .trailing]) { secondaryCards } - .frame(maxWidth: 580) - } - .frame(maxWidth: .infinity) - } else { - column { - primaryCards - secondaryCards - } - } - } - } - - private func column(padding edges: Edge.Set = .all, - @ViewBuilder _ content: () -> Content) -> some View { - ScrollView(showsIndicators: false) { - VStack(spacing: 16, content: content) - .padding(edges, 20) - } - } - - @ViewBuilder - private var primaryCards: some View { - deviceHero - ambientCard - if model.mode == .ambientSound { - ambientLevelCard - } - } - - @ViewBuilder - private var secondaryCards: some View { - if model.multipointLoading || model.hasPairedDevices || model.hasPlaybackPinState { - devicesCard - } - if model.supportsEqualizer { - equalizerCard - } - if model.supportsDsee { - dseeCard - } - if model.hasAdaptiveVolume || model.hasSpeakToChat || model.hasAutoPowerOff || model.hasMultipointSetting { - settingsCard - } - if let error = model.errorMessage { - Text(error) - .font(.system(size: 12)) - .foregroundColor(palette.danger) - } - } - - private var deviceHero: some View { - VStack(spacing: 12) { - HStack(spacing: 10) { - Spacer() - Button(action: { showAbout = true }) { - roundIcon("ellipsis", size: 15) - } - .buttonStyle(PlainButtonStyle()) - .popover(isPresented: $showAbout, arrowEdge: .bottom) { aboutView } - Button(action: model.disconnect) { - roundIcon("power", size: 14) - } - .buttonStyle(PlainButtonStyle()) - } - - // Product-style banner. Generic headphone graphic (device-specific Sony renders are - // copyrighted and can't be bundled) over the theme's backdrop, mirroring Sony's app layout. - ZStack { - HeroBackdrop() - ProductImage(deviceName: model.deviceName, size: 190) - } - - Text(model.deviceName) - .font(.system(size: 24, weight: .bold)) - .foregroundColor(palette.primary) - - StatusChips(model: model) - } - .padding(.bottom, 4) - } - - private func roundIcon(_ symbol: String, size: CGFloat) -> some View { - Image(systemName: symbol) - .font(.system(size: size, weight: .semibold)) - .foregroundColor(palette.secondary) - .frame(width: 32, height: 32) - .background(palette.raised, in: Circle()) - .overlay(Circle().strokeBorder(palette.cardStroke, lineWidth: 1)) - } - - private func cardTitle(_ title: String) -> some View { - Text(theme.isSignature ? title.uppercased() : title) - .font(.system(size: theme.isSignature ? 11 : 15, weight: .semibold)) - .tracking(theme.isSignature ? 1.2 : 0) - .foregroundColor(theme.isSignature ? palette.secondary : palette.primary) - } - - private var ambientCard: some View { - VStack(alignment: .leading, spacing: 16) { - cardTitle("Ambient Sound Control") - AmbientModeControl(model: model) - } - .themedCard() - } - - private var ambientLevelCard: some View { - VStack(alignment: .leading, spacing: 14) { - HStack { - Text("Ambient Sound Level") - .font(.system(size: 13, weight: .medium)) - .foregroundColor(palette.secondary) - Spacer() - Text("\(model.ambientLevel)") - .font(.system(size: 20, weight: .bold, design: theme.isSignature ? .monospaced : .rounded)) - .foregroundColor(palette.accentText) - } - AmbientLevelSlider(model: model) - - if model.focusOnVoiceAvailable { - FocusOnVoiceSwitch(model: model) - } - } - .themedCard() - } - - private let eqBandLabels = ["400", "1k", "2.5k", "6.3k", "16k"] - private let tenBandEqLabels = ["31", "63", "125", "250", "500", "1k", "2k", "4k", "8k", "16k"] - - // The graph shows the active preset's curve. Editing it (or Clear Bass) switches to Manual, starting - // from that curve, the way Sony's app does. - private var equalizerCard: some View { - let manual = model.eqPreset == 0xA0 - return VStack(alignment: .leading, spacing: 14) { - HStack { - cardTitle("Equalizer") - Spacer() - if manual && !model.tenBandEqualizer { - CapsuleButton("Flat") { - model.eqBands = model.eqBands.map { _ in 0 } - model.clearBass = 0 - model.applyCustomEq() - } - } - } - // The ten-band layout's 0...12 device steps have no known dB mapping yet, so they're shown raw. - EQEditor(bands: model.eqBands, - labels: model.tenBandEqualizer ? tenBandEqLabels : eqBandLabels, - range: model.tenBandEqualizer ? 0...12 : -10...10, - signed: !model.tenBandEqualizer) { band, value in - model.eqBands[band] = value - model.applyCustomEq() - } - if !manual { - Text("Drag a band to fine-tune. This switches to Manual.") - .font(.system(size: 11)) - .foregroundColor(palette.secondary) - } - if !model.tenBandEqualizer { - clearBassRow - } - LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], spacing: 10) { - ForEach(EQPresets.list(tenBand: model.tenBandEqualizer), id: \.1) { preset in - eqChip(preset.0, preset.1) - } - } - } - .themedCard() - } - - private var clearBassRow: some View { - HStack(spacing: 10) { - Text("Clear Bass") - .font(.system(size: 11, weight: .medium)) - .foregroundColor(palette.accentText) - .fixedSize() - Slider(value: Binding( - get: { Double(model.clearBass) }, - set: { - let value = Int($0.rounded()) - guard value != model.clearBass else { return } - model.clearBass = value - model.applyCustomEq() - } - ), in: -10...10, step: 1) - .accentColor(palette.accent) - Text(model.clearBass > 0 ? "+\(model.clearBass)" : "\(model.clearBass)") - .font(.system(size: 11, weight: .semibold, design: .monospaced)) - .foregroundColor(palette.primary) - .frame(width: 26, alignment: .trailing) - } - } - - private var dseeCard: some View { - SwitchRow("DSEE", isOn: Binding(get: { model.dsee }, set: { model.setDsee($0) })) { - VStack(alignment: .leading, spacing: 2) { - Text("DSEE").font(.system(size: 14, weight: .semibold)).foregroundColor(palette.primary) - Text("Upscale compressed audio").font(.system(size: 11)).foregroundColor(palette.secondary) - } - } - .themedCard() - } - - private func eqChip(_ name: String, _ code: Int) -> some View { - let selected = model.eqPreset == code - return Button(action: { model.setEqualizer(code) }) { - Text(name) - .font(.system(size: 12, weight: .medium)) - .frame(maxWidth: .infinity) - .padding(.vertical, 8) - .selectable(selected, filledWhenIdle: true) - } - .buttonStyle(PlainButtonStyle()) - } - - private var settingsCard: some View { - VStack(alignment: .leading, spacing: 14) { - cardTitle("Settings") - if model.hasAdaptiveVolume { - settingToggle("Adaptive Volume", "Auto-adjust volume by surroundings", - on: model.adaptiveVolume) { model.setAdaptiveVolume($0) } - } - if model.hasSpeakToChat { - settingToggle("Speak-to-Chat", "Pause music when you talk", - on: model.speakToChat) { model.setSpeakToChat($0) } - SwitchRow("Keep off on connect", isOn: $model.keepSpeakToChatOff) { - Text("Keep off on connect") - .font(.system(size: 11)) - .foregroundColor(palette.secondary) - } - .controlSize(.small) - .padding(.leading, 12) - } - if model.hasMultipointSetting { - settingToggle("Connect to 2 devices", "Stay paired to two devices at once", - on: model.multipointEnabled) { model.setMultipoint($0) } - } - if model.hasAutoPowerOff { - HStack { - VStack(alignment: .leading, spacing: 2) { - Text("Auto Power-Off").font(.system(size: 14, weight: .semibold)).foregroundColor(palette.primary) - Text("Turn off when idle").font(.system(size: 11)).foregroundColor(palette.secondary) - } - Spacer() - Picker("", selection: Binding( - get: { model.autoPowerOff }, - set: { model.setAutoPowerOff($0) } - )) { - ForEach(0.. Void) -> some View { - SwitchRow(title, isOn: Binding(get: { on }, set: { action($0) })) { - VStack(alignment: .leading, spacing: 2) { - Text(title).font(.system(size: 14, weight: .semibold)).foregroundColor(palette.primary) - Text(subtitle).font(.system(size: 11)).foregroundColor(palette.secondary) - } - } - } - - // Multipoint device list. Read-only for now: nothing here sends a command that changes the headset. - private var devicesCard: some View { - VStack(alignment: .leading, spacing: 14) { - HStack { - cardTitle("Devices") - Spacer() - if model.multipointLoading || model.refreshingMultipoint || model.multipointBusy { - ProgressView() - .scaleEffect(0.5) - .frame(width: 16, height: 16) - } else { - Button(action: model.refreshMultipoint) { - Image(systemName: "arrow.clockwise") - .font(.system(size: 12, weight: .semibold)) - .foregroundColor(palette.secondary) - } - .buttonStyle(PlainButtonStyle()) - } - } - if model.multipointLoading { - HStack(spacing: 10) { - ProgressView() - .scaleEffect(0.6) - .frame(width: 18, height: 18) - Text("Reading devices from the headset…") - .font(.system(size: 12)) - .foregroundColor(palette.secondary) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.vertical, 6) - } - if model.hasPlaybackPinState { - HStack { - VStack(alignment: .leading, spacing: 2) { - Text("Playback device").font(.system(size: 13, weight: .medium)).foregroundColor(palette.primary) - Text(model.playbackPinned ? "Fixed" : "Switches automatically") - .font(.system(size: 11)).foregroundColor(palette.secondary) - } - Spacer() - CapsuleButton(model.playbackPinned ? "Unfix" : "Fix") { - model.setPlaybackPinned(!model.playbackPinned) - } - .disabled(model.multipointBusy) - } - } - if model.hasPairedDevices { - let connected = model.pairedDevices.filter { $0.connected }.sorted { $0.slot < $1.slot } - let paired = model.pairedDevices.filter { !$0.connected } - if !connected.isEmpty { - sectionLabel("Connected") - ForEach(connected) { deviceRow($0) } - } - if !paired.isEmpty { - sectionLabel("Paired") - ForEach(paired) { deviceRow($0) } - } - } else if !model.multipointLoading { - Text("This headset didn't return a device list.") - .font(.system(size: 12)) - .foregroundColor(palette.secondary) - } - if !model.multipointLoading { - devicesFooter - } - } - .themedCard() - } - - private var devicesFooter: some View { - VStack(alignment: .leading, spacing: 14) { - Divider().background(palette.cardStroke) - HStack { - Text(model.multipointBusy ? "Sending…" : "Switching and fixing are experimental") - .font(.system(size: 11)) - .foregroundColor(palette.secondary.opacity(0.7)) - Spacer() - Button(action: copyMultipointDiagnostics) { - Text("Copy diagnostics") - .font(.system(size: 11, weight: .medium)) - .foregroundColor(palette.accentText) - } - .buttonStyle(PlainButtonStyle()) - } - } - } - - private func sectionLabel(_ text: String) -> some View { - Text(text.uppercased()) - .font(.system(size: 10, weight: .semibold)) - .tracking(0.8) - .foregroundColor(palette.secondary.opacity(0.8)) - .padding(.top, 2) - } - - private func deviceRow(_ device: PairedDeviceInfo) -> some View { - HStack(spacing: 12) { - Image(systemName: deviceSymbol(device.classOfDevice)) - .font(.system(size: 15)) - .foregroundColor(device.connected ? palette.primary : palette.secondary) - .frame(width: 22) - VStack(alignment: .leading, spacing: 2) { - Text(device.name.isEmpty ? device.address : device.name) - .font(.system(size: 13, weight: .medium)) - .foregroundColor(device.connected ? palette.primary : palette.secondary) - .lineLimit(1) - if device.isThisMac { - Text("This Mac").font(.system(size: 11)).foregroundColor(palette.accentText) - } - } - Spacer() - if device.playback { - if model.playbackPinned { - Image(systemName: "lock.fill") - .font(.system(size: 11)) - .foregroundColor(palette.accent) - } - Image(systemName: "speaker.wave.2.fill") - .font(.system(size: 12)) - .foregroundColor(palette.accent) - } - if device.connected && model.hasPlaybackPinState { - Menu { - Button("Switch playback device and fix") { model.switchPlayback(to: device, fix: true) } - if !device.playback { - Button("Switch playback device") { model.switchPlayback(to: device, fix: false) } - } - } label: { - Image(systemName: "ellipsis") - } - .menuStyle(BorderlessButtonMenuStyle()) - .fixedSize() - .disabled(model.multipointBusy) - } - } - } - - // Major device class lives in bits 8-12 of the class of device (1 = computer, 2 = phone). - private func deviceSymbol(_ classOfDevice: Int) -> String { - guard classOfDevice >= 0, classOfDevice != 0xFFFFFF else { return "dot.radiowaves.left.and.right" } - switch (classOfDevice >> 8) & 0x1F { - case 1: return "laptopcomputer" - case 2: return "iphone" - default: return "dot.radiowaves.left.and.right" - } - } - - private func copyMultipointDiagnostics() { - let header = "\(model.deviceName) · fw \(model.firmware) · \(model.protocolVersion)\n" - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(header + model.multipointDiagnostics, forType: .string) - } - - // ⋯ About / device info popover. - private var aboutView: some View { - VStack(alignment: .leading, spacing: 0) { - Text(model.deviceName) - .font(.system(size: 15, weight: .bold)) - .foregroundColor(palette.primary) - .padding(.bottom, 10) - aboutRow("Status", model.connected ? "Connected" : "Disconnected") - if model.hasDualBattery { - aboutRow("Battery L", batteryText(model.batteryLeft, model.batteryLeftCharging)) - aboutRow("Battery R", batteryText(model.batteryRight, model.batteryRightCharging)) - if model.batteryCase >= 0 { - aboutRow("Case", batteryText(model.batteryCase, model.batteryCaseCharging)) - } - } else if model.batteryLevel >= 0 { - aboutRow("Battery", "\(model.batteryLevel)%\(model.batteryCharging ? " (charging)" : "")") - } - if !model.codec.isEmpty { aboutRow("Codec", model.codec) } - if !model.firmware.isEmpty { aboutRow("Firmware", model.firmware) } - if !model.protocolVersion.isEmpty { aboutRow("Protocol", model.protocolVersion) } - if !model.deviceMac.isEmpty { aboutRow("Bluetooth", model.deviceMac) } - sectionLabel("Appearance") - .padding(.top, 12) - .padding(.bottom, 8) - HStack(spacing: 6) { - ForEach(AppTheme.allCases) { option in - themeOption(option) - } - } - Divider().background(palette.cardStroke).padding(.vertical, 10) - Text("SonyBridge · not affiliated with Sony") - .font(.system(size: 10)) - .foregroundColor(palette.secondary.opacity(0.7)) - } - .padding(18) - .frame(width: 290) - .background(palette.card) - } - - private func themeOption(_ option: AppTheme) -> some View { - let selected = option == theme - return Button(action: { theme = option }) { - VStack(spacing: 5) { - ThemeSwatch(theme: option) - Text(option.displayName) - .font(.system(size: 10.5, weight: .medium)) - .foregroundColor(selected ? palette.primary : palette.secondary) - .lineLimit(1) - .minimumScaleFactor(0.8) - } - .padding(5) - .overlay(RoundedRectangle(cornerRadius: 10, style: .continuous) - .strokeBorder(selected ? palette.accent : .clear, lineWidth: 2)) - .contentShape(Rectangle()) - } - .buttonStyle(PlainButtonStyle()) - } - - private func batteryText(_ level: Int, _ charging: Bool) -> String { - guard level >= 0 else { return "—" } - return "\(level)%\(charging ? " (charging)" : "")" - } - - private func aboutRow(_ label: String, _ value: String) -> some View { - HStack { - Text(label).font(.system(size: 12)).foregroundColor(palette.secondary) - Spacer() - Text(value).font(.system(size: 12, weight: .medium)).foregroundColor(palette.primary) - } - .padding(.vertical, 3) - } -} - -// MARK: - Shared with the menu-bar panel - -enum EQPresets { - static let fiveBand: [(String, Int)] = [ - ("Off", 0x00), ("Bright", 0x10), ("Excited", 0x11), - ("Mellow", 0x12), ("Relaxed", 0x13), ("Vocal", 0x14), - ("Treble", 0x15), ("Bass", 0x16), ("Speech", 0x17), - ("Manual", 0xA0) - ] - // WH-1000XM6: only Off and Custom have confirmed preset codes on the ten-band layout; its other preset - // bytes (0x20, 0x30-0x33, 0xa1-0xa2) are unnamed, and the 5-band codes above don't apply to it. - static let tenBand: [(String, Int)] = [("Off", 0x00), ("Custom", 0xA0)] - - static func list(tenBand: Bool) -> [(String, Int)] { tenBand ? Self.tenBand : fiveBand } -} - -// Indexed like Headphones::setAutoPowerOff (0=Off ... 5=when taken off). -enum AutoPowerOff { - static let options = ["Off", "5 min", "30 min", "1 hour", "3 hours", "When taken off"] -} - -extension SHCAmbientMode { - var displayName: String { - switch self { - case .noiseCanceling: return "Noise Cancelling" - case .ambientSound: return "Ambient" - default: return "Off" - } - } -} - -// The bundled product cutout matching the device name (e.g. "WH-CH720N" -> "wh-ch720n"), or a generic -// headphones glyph for models without one. -struct ProductImage: View { - let deviceName: String - let size: CGFloat - - @Environment(\.appTheme) private var theme - @Environment(\.colorScheme) private var colorScheme - - var body: some View { - if let image = NSImage(named: deviceName.lowercased().replacingOccurrences(of: " ", with: "-")) { - Image(nsImage: image) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: size, height: size) - } else { - Image(systemName: "headphones") - .font(.system(size: size * 0.48, weight: .thin)) - .foregroundColor(theme.palette(colorScheme).primary) - .frame(width: size, height: size) - } - } -} - -// Battery, codec and (optionally) the current mode as capsule chips. -struct StatusChips: View { - @ObservedObject var model: HeadphonesModel - var showsMode = true - - @Environment(\.appTheme) private var theme - @Environment(\.colorScheme) private var colorScheme - - private var palette: Palette { theme.palette(colorScheme) } - - var body: some View { - HStack(spacing: 6) { - chip { - if model.hasDualBattery { - // Earbuds report a battery each, plus the case when it's connected. An earbud that - // isn't reporting (docked / powered down) comes through as -1 and is left out. - budBattery("L", model.batteryLeft, charging: model.batteryLeftCharging) - Text("·") - budBattery("R", model.batteryRight, charging: model.batteryRightCharging) - if model.batteryCase >= 0 { - Text("·") - budBattery("Case", model.batteryCase, charging: model.batteryCaseCharging) - } - } else if model.batteryLevel >= 0 { - Image(systemName: model.batteryCharging ? "bolt.fill" : batterySymbol(model.batteryLevel)) - .foregroundColor(model.batteryLevel <= 20 ? palette.danger : palette.accent) - chipText("\(model.batteryLevel)%") - } else { - Circle().fill(palette.accent).frame(width: 6, height: 6) - chipText("Connected") - } - } - if !model.codec.isEmpty { - chip { chipText(model.codec) } - } - if showsMode { - chip { chipText(model.mode.displayName) } - } - } - } - - private func chip(@ViewBuilder _ content: () -> Content) -> some View { - HStack(spacing: 4, content: content) - .font(.system(size: theme.isSignature ? 10 : 11, weight: .semibold)) - .foregroundColor(palette.secondary) - .lineLimit(1) - .padding(.horizontal, 9) - .padding(.vertical, 4) - .background(palette.raised, in: Capsule()) - .overlay(Capsule().strokeBorder(palette.cardStroke, lineWidth: 1)) - } - - private func chipText(_ text: String) -> Text { - Text(theme.isSignature ? text.uppercased() : text).tracking(theme.isSignature ? 0.8 : 0) - } - - // One "L 82%" reading in the battery chip, with a bolt in place of the battery glyph while that unit - // is charging. A docked/powered-down earbud reports no level; show it dimmed as "R --" rather than - // dropping it, so it reads as "in the case" instead of looking like the app lost track of it. - private func budBattery(_ label: String, _ level: Int, charging: Bool) -> some View { - HStack(spacing: 3) { - Image(systemName: level < 0 ? "battery.0" : (charging ? "bolt.fill" : batterySymbol(level))) - .foregroundColor(level < 0 ? palette.secondary.opacity(0.5) - : (level <= 20 && !charging ? palette.danger : palette.accent)) - chipText(level < 0 ? "\(label) --" : "\(label) \(level)%") - .foregroundColor(palette.secondary.opacity(level < 0 ? 0.5 : 1.0)) - } - } - - private func batterySymbol(_ level: Int) -> String { - switch level { - case ...15: return "battery.0" - case ...50: return "battery.25" - default: return "battery.100" - } - } -} - -// Noise Cancelling / Ambient / Off: segments in a recessed track, or separate pills in Sony Signature. -struct AmbientModeControl: View { - @ObservedObject var model: HeadphonesModel - - @Environment(\.appTheme) private var theme - @Environment(\.colorScheme) private var colorScheme - - private var palette: Palette { theme.palette(colorScheme) } - - var body: some View { - let track = RoundedRectangle(cornerRadius: 11, style: .continuous) - HStack(spacing: theme.isSignature ? 6 : 0) { - modeButton(.noiseCanceling) - modeButton(.ambientSound) - modeButton(.off) - } - .padding(theme.isSignature ? 0 : 3) - .background(palette.track, in: track) - .overlay(track.strokeBorder(theme.isSignature ? .clear : palette.cardStroke, lineWidth: 1)) - } - - private func modeButton(_ mode: SHCAmbientMode) -> some View { - let selected = model.mode == mode - return Button(action: { model.setMode(mode) }) { - Text(mode.displayName) - .font(.system(size: 11.5, weight: .semibold)) - .lineLimit(1) - .minimumScaleFactor(0.8) - .padding(.horizontal, 8) - .frame(maxWidth: .infinity) - .padding(.vertical, 8) - .selectable(selected) - .shadow(color: selected ? palette.selectedShadow : .clear, radius: 8) - } - .buttonStyle(.plain) - } -} - -struct AmbientLevelSlider: View { - @ObservedObject var model: HeadphonesModel - - @Environment(\.appTheme) private var theme - @Environment(\.colorScheme) private var colorScheme - - var body: some View { - Slider( - value: Binding( - get: { Double(model.ambientLevel) }, - set: { model.setLevel(Int($0.rounded())) } - ), - in: 1...Double(model.maxAmbientLevel), - step: 1 - ) - .accentColor(theme.palette(colorScheme).accent) - } -} - -struct FocusOnVoiceSwitch: View { - @ObservedObject var model: HeadphonesModel - - @Environment(\.appTheme) private var theme - @Environment(\.colorScheme) private var colorScheme - - var body: some View { - SwitchRow("Focus on Voice", isOn: Binding( - get: { model.focusOnVoice }, - set: { model.setFocusOnVoice($0) } - )) { - Text("Focus on Voice") - .font(.system(size: 13, weight: .medium)) - .foregroundColor(theme.palette(colorScheme).primary) - } - } -} - -struct ConnectButton: View { - @ObservedObject var model: HeadphonesModel - - @Environment(\.appTheme) private var theme - @Environment(\.colorScheme) private var colorScheme - - var body: some View { - let palette = theme.palette(colorScheme) - // Activate first: from the menu-bar panel the app isn't frontmost, and the device picker the - // connect shows would open behind other apps. - Button(action: { NSApp.activate(ignoringOtherApps: true); model.connect() }) { - Text(model.connecting ? "Connecting…" : "Connect headphones") - .font(.system(size: 14, weight: .semibold)) - .foregroundColor(palette.onAccent) - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - .background(palette.accent) - .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) - } - .buttonStyle(PlainButtonStyle()) - .disabled(model.connecting) - } -} - -// Factory so the Obj-C++ ViewController can instantiate the SwiftUI hierarchy. -@objc final class HeadphonesUIFactory: NSObject { - @objc static func makeViewController() -> NSViewController { - return NSHostingController(rootView: ContentView()) - } -} diff --git a/Client/macos/HeadphonesBridge.h b/Client/macos/HeadphonesBridge.h index 76ac43ac..c9260205 100644 --- a/Client/macos/HeadphonesBridge.h +++ b/Client/macos/HeadphonesBridge.h @@ -77,11 +77,17 @@ typedef NS_ENUM(NSInteger, SHCAmbientMode) { @property (nonatomic, readonly) BOOL playbackPinned; @property (nonatomic, readonly) BOOL hasMultipointSetting; @property (nonatomic, readonly) BOOL multipointEnabled; +@property (nonatomic, readonly) BOOL hasTouchPanelSetting; +@property (nonatomic, readonly) BOOL touchPanelEnabled; @property (nonatomic, copy, readonly) NSString *multipointDiagnostics; // hex log of the last multipoint reads // Runs the native Bluetooth device picker (modal, main thread) and connects to the chosen device. // completion is called on the main thread. - (void)scanAndConnectWithCompletion:(void (^)(BOOL ok, NSString * _Nullable error))completion; +// YES when a paired Sony headset is connected to the Mac, i.e. scanAndConnect would use it without a picker. +@property (nonatomic, readonly) BOOL hasConnectedSonyHeadset; +// Calls handler (on the main queue) whenever a Sony headset connects to the Mac. +- (void)watchForHeadsetConnections:(void (^)(void))handler NS_SWIFT_NAME(watchForHeadsetConnections(_:)); - (void)disconnect; @@ -110,6 +116,7 @@ typedef NS_ENUM(NSInteger, SHCAmbientMode) { - (void)setSpeakToChat:(BOOL)enabled completion:(void (^)(BOOL ok, NSString * _Nullable error))completion; - (void)setAdaptiveVolume:(BOOL)enabled completion:(void (^)(BOOL ok, NSString * _Nullable error))completion; - (void)setMultipoint:(BOOL)enabled completion:(void (^)(BOOL ok, NSString * _Nullable error))completion; +- (void)setTouchPanel:(BOOL)enabled completion:(void (^)(BOOL ok, NSString * _Nullable error))completion; // Re-reads the fast-changing state (ambient/NC, level, EQ, DSEE) so changes made with the headphone's // own button show up in the app. Called on a timer while connected. diff --git a/Client/macos/HeadphonesBridge.mm b/Client/macos/HeadphonesBridge.mm index bb0ef696..03963cfa 100644 --- a/Client/macos/HeadphonesBridge.mm +++ b/Client/macos/HeadphonesBridge.mm @@ -49,6 +49,8 @@ @implementation HeadphonesBridge { // All device I/O runs on this serial queue. Bluetooth commands are stateful, and serializing reads with // writes also prevents a slow capability probe from racing a user action or dynamic refresh. dispatch_queue_t _cmdQueue; + IOBluetoothUserNotification *_connectNotification; + void (^_headsetConnectedHandler)(void); } - (instancetype)init { @@ -168,6 +170,8 @@ - (BOOL)hasPlaybackPinState { return _hp && _hp->hasPlaybackPinState(); } - (BOOL)playbackPinned { return _hp && _hp->isPlaybackPinned(); } - (BOOL)hasMultipointSetting { return _hp && _hp->hasMultipointSetting(); } - (BOOL)multipointEnabled { return _hp && _hp->getMultipointEnabled(); } +- (BOOL)hasTouchPanelSetting { return _hp && _hp->hasTouchPanelSetting(); } +- (BOOL)touchPanelEnabled { return _hp && _hp->getTouchPanelEnabled(); } - (NSString *)multipointDiagnostics { return _hp ? @(_hp->getMultipointDiagnostics().c_str()) : @""; } - (NSArray *)pairedDevices { @@ -211,6 +215,28 @@ static BOOL SHCIsSonyHeadset(IOBluetoothDevice *device) { return SHCLooksLikeSonyHeadset([device name]) || SHCHasSonyService(device); } +// The same test scanAndConnect's first pass uses, so a connect made because this said YES never falls +// through to the device picker. +- (BOOL)hasConnectedSonyHeadset { + for (IOBluetoothDevice *paired in [IOBluetoothDevice pairedDevices]) { + if ([paired isConnected] && SHCIsSonyHeadset(paired)) return YES; + } + return NO; +} + +- (void)watchForHeadsetConnections:(void (^)(void))handler { + _headsetConnectedHandler = [handler copy]; + if (!_connectNotification) { + _connectNotification = [IOBluetoothDevice registerForConnectNotifications:self + selector:@selector(bluetoothDeviceConnected:device:)]; + } +} + +// Any Bluetooth device connecting to the Mac (delivered on the main run loop); only Sony headsets count. +- (void)bluetoothDeviceConnected:(IOBluetoothUserNotification *)notification device:(IOBluetoothDevice *)device { + if (_headsetConnectedHandler && SHCIsSonyHeadset(device)) _headsetConnectedHandler(); +} + - (void)scanAndConnectWithCompletion:(void (^)(BOOL, NSString * _Nullable))completion { // Prefer the already system-connected Sony headset (Sony-app "My Device" behaviour) and skip the // native picker, which shows a confusing empty list when nothing is connected. @@ -319,8 +345,8 @@ - (void)refreshStatusWithCompletion:(void (^)(void))completion { } - (void)refreshMultipointWithCompletion:(void (^)(void))completion { - // GETs only, and v2 only: on v1 these opcodes are unverified (0x22-style collisions are possible). - if (!_hp || !self.connected || _bt->getProtocolVersion() != SonyProtocolVersion::V2 || !_initialized.load()) { + // GETs only. On v1 that's just the general-settings reads (function list, d0, d6), captured from an XM4. + if (!_hp || !self.connected || !_initialized.load()) { completion(); return; } @@ -563,4 +589,14 @@ - (void)setMultipoint:(BOOL)enabled completion:(void (^)(BOOL, NSString * _Nulla }); } +- (void)setTouchPanel:(BOOL)enabled completion:(void (^)(BOOL, NSString * _Nullable))completion { + if (!_hp || !self.connected) { completion(NO, @"Not connected."); return; } + auto hp = _hp; + dispatch_async(_cmdQueue, ^{ + NSString *error = nil; BOOL ok = YES; + try { hp->setTouchPanel(enabled); } catch (std::exception &exc) { ok = NO; error = @(exc.what()); } + dispatch_async(dispatch_get_main_queue(), ^{ completion(ok, error); }); + }); +} + @end diff --git a/Client/macos/HeadphonesModel.swift b/Client/macos/HeadphonesModel.swift index add153d0..fefd68ae 100644 --- a/Client/macos/HeadphonesModel.swift +++ b/Client/macos/HeadphonesModel.swift @@ -28,6 +28,8 @@ final class HeadphonesModel: ObservableObject { @Published var connected = false @Published var connecting = false + // A paired Sony headset is connected to the Mac (audio), whether or not this app's control link is up. + @Published var headsetConnectedToMac = false @Published var deviceName = "" @Published var supportsVpt = false @Published var maxAmbientLevel = 20 @@ -78,6 +80,8 @@ final class HeadphonesModel: ObservableObject { // toggle just mirrors the on-device state. @Published var hasMultipointSetting = false @Published var multipointEnabled = false + @Published var hasTouchPanelSetting = false + @Published var touchPanelEnabled = false @Published var multipointDiagnostics = "" @Published var refreshingMultipoint = false @Published var multipointBusy = false @@ -106,6 +110,38 @@ final class HeadphonesModel: ObservableObject { // confirmed, so the off-on-connect enforcement fires at most once per connection. private var s2cAutoDisablePending = false + // Set while a multipoint change may restart the headset: a link drop before it then reconnects. + private var restartReconnectDeadline: Date? + + // Set by a manual disconnect so auto-connect leaves the headset alone until it next connects to the Mac. + private var autoConnectSuppressed = false + private var headsetTimer: Timer? + + // Connects on launch and whenever a Sony headset connects to the Mac, never through the device picker. + // An idle link drop (headset still connected to the Mac) isn't retried: nothing new connected. + func startAutoConnect() { + headsetConnectedToMac = bridge.hasConnectedSonyHeadset + // macOS notifies connects but not disconnects, so poll for the Mac-level connection (cheap: paired + // devices plus cached SDP records). The menu-bar icon hides on it. + headsetTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { [weak self] _ in + guard let self = self else { return } + self.assignIfChanged(\.headsetConnectedToMac, self.bridge.hasConnectedSonyHeadset) + } + bridge.watchForHeadsetConnections { [weak self] in + guard let self = self else { return } + self.headsetConnectedToMac = true + self.autoConnectSuppressed = false + // The headset's control service isn't ready the moment the Mac sees it connect. + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { self.autoConnectIfIdle() } + } + autoConnectIfIdle() + } + + private func autoConnectIfIdle() { + guard !connected, !connecting, !autoConnectSuppressed, bridge.hasConnectedSonyHeadset else { return } + connect() + } + func connect() { connecting = true errorMessage = nil @@ -114,6 +150,7 @@ final class HeadphonesModel: ObservableObject { self.bridge.scanAndConnect { ok, error in self.connecting = false if ok { + self.restartReconnectDeadline = nil self.s2cAutoDisablePending = true self.syncFromBridge() self.startWatchingConnection() @@ -195,6 +232,8 @@ final class HeadphonesModel: ObservableObject { playbackPinned = bridge.playbackPinned hasMultipointSetting = bridge.hasMultipointSetting multipointEnabled = bridge.multipointEnabled + hasTouchPanelSetting = bridge.hasTouchPanelSetting + touchPanelEnabled = bridge.touchPanelEnabled multipointDiagnostics = bridge.multipointDiagnostics } @@ -235,7 +274,36 @@ final class HeadphonesModel: ObservableObject { func setMultipoint(_ on: Bool) { multipointEnabled = on errorMessage = nil - bridge.setMultipoint(on) { ok, error in if !ok, let e = error { self.errorMessage = e } } + // Applying it restarts the headset (XM4 at least), which drops this link; reconnect once it's back. + restartReconnectDeadline = Date().addingTimeInterval(90) + bridge.setMultipoint(on) { ok, error in + if !ok { + self.restartReconnectDeadline = nil + // Show what the headset actually kept rather than the value just requested. + self.multipointEnabled = self.bridge.multipointEnabled + if let e = error { self.errorMessage = e } + } + } + } + + func setTouchPanel(_ on: Bool) { + touchPanelEnabled = on + errorMessage = nil + bridge.setTouchPanel(on) { ok, error in if !ok, let e = error { self.errorMessage = e } } + } + + // Retries connect() every few seconds while a headset restart (after a multipoint change) is expected. + private func reconnectAfterRestart() { + DispatchQueue.main.asyncAfter(deadline: .now() + 6) { [weak self] in + guard let self = self, !self.connected, let deadline = self.restartReconnectDeadline else { return } + guard Date() < deadline else { + self.restartReconnectDeadline = nil + self.errorMessage = "Headphones didn't come back after restarting. Connect them again." + return + } + if !self.connecting { self.connect() } + self.reconnectAfterRestart() + } } // Every @Published setter fires objectWillChange — and a full ContentView body @@ -343,6 +411,8 @@ final class HeadphonesModel: ObservableObject { pendingPush = nil pendingEq = nil s2cAutoDisablePending = false + restartReconnectDeadline = nil + autoConnectSuppressed = true bridge.disconnect() connected = false deviceName = "" @@ -365,6 +435,10 @@ final class HeadphonesModel: ObservableObject { // linger with Fix/Switch enabled. self.bridge.disconnect() self.syncMultipointFromBridge() + if let deadline = self.restartReconnectDeadline, Date() < deadline { + self.errorMessage = "Headphones restarting…" + self.reconnectAfterRestart() + } } } } diff --git a/Client/macos/Main.storyboard b/Client/macos/Main.storyboard index f00f7e20..353b320e 100644 --- a/Client/macos/Main.storyboard +++ b/Client/macos/Main.storyboard @@ -125,208 +125,5 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ! This product is not affiliated with Sony. Use at your own risk. ! -Source: https://github.com/Plutoberth/SonyHeadphonesClient - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Client/macos/MenuBarController.swift b/Client/macos/MenuBarController.swift index 843ada6a..07ace7ac 100644 --- a/Client/macos/MenuBarController.swift +++ b/Client/macos/MenuBarController.swift @@ -10,7 +10,9 @@ import SwiftUI // Installed once from AppDelegate; reads and drives the same HeadphonesModel as the window. The icon shows the // playback device next to the headphones (phone, computer, or its multipoint slot number), and the headphones // fill in while playback is fixed to this Mac. -@objc final class MenuBarController: NSObject, NSMenuDelegate { +// The icon is only in the menu bar while a Sony headset is connected to the Mac (auto-connect handles the +// rest); launching the app again shows it, with the panel, until the panel closes. +@objc final class MenuBarController: NSObject, NSMenuDelegate, NSPopoverDelegate { private static var instance: MenuBarController? private let model = HeadphonesModel.shared @@ -32,13 +34,11 @@ import SwiftUI menu.delegate = self menu.autoenablesItems = false - let panel = NSHostingController(rootView: QuickControlsView( - openWindow: { [weak self] in self?.popover.performClose(nil); Self.openMainWindow() }, - quit: { NSApp.terminate(nil) } - )) + let panel = NSHostingController(rootView: QuickControlsView(quit: { NSApp.terminate(nil) })) if #available(macOS 13.0, *) { panel.sizingOptions = .preferredContentSize } popover.contentViewController = panel popover.behavior = .transient + popover.delegate = self statusItem.button?.target = self statusItem.button?.action = #selector(statusItemClicked(_:)) @@ -46,12 +46,27 @@ import SwiftUI updateIcon() // objectWillChange fires before the new values land, so read them on the next main-loop turn. model.objectWillChange - .sink { [weak self] _ in DispatchQueue.main.async { self?.updateIcon() } } + .sink { [weak self] _ in DispatchQueue.main.async { self?.updateIcon(); self?.updateVisibility() } } .store(in: &cancellables) // Follow a light/dark switch made while the panel is open. appearanceObservation = NSApp.observe(\.effectiveAppearance) { [weak self] _, _ in DispatchQueue.main.async { self?.updatePopoverAppearance() } } + model.startAutoConnect() + updateVisibility() + } + + // Set from a relaunch until that panel closes, so the icon anchoring it isn't hidden in between. + private var panelRequested = false + + private func updateVisibility() { + let show = model.headsetConnectedToMac || model.connected || model.connecting || popover.isShown || panelRequested + if statusItem.isVisible != show { statusItem.isVisible = show } + } + + func popoverDidClose(_ notification: Notification) { + panelRequested = false + updateVisibility() } @objc private func statusItemClicked(_ sender: NSStatusBarButton) { @@ -65,28 +80,34 @@ import SwiftUI } else if popover.isShown { popover.performClose(nil) } else { - refreshMultipointIfIdle() - updatePopoverAppearance() - if let panel = popover.contentViewController, #unavailable(macOS 13.0) { - popover.contentSize = panel.view.fittingSize - } - popover.show(relativeTo: sender.bounds, of: sender, preferredEdge: .minY) - popover.contentViewController?.view.window?.makeKey() + showPopover() } } - // The storyboard window isn't shown at launch (menu-bar app); it's made on first open and kept, since - // it isn't released when closed. - private static var windowController: NSWindowController? - - @objc static func openMainWindow() { - if windowController == nil { - windowController = NSStoryboard(name: "Main", bundle: nil) - .instantiateController(withIdentifier: "MainWindow") as? NSWindowController + private func showPopover() { + guard let button = statusItem.button else { return } + refreshMultipointIfIdle() + updatePopoverAppearance() + if let panel = popover.contentViewController, #unavailable(macOS 13.0) { + popover.contentSize = panel.view.fittingSize } + popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY) + popover.contentViewController?.view.window?.makeKey() + } + + // The panel is the whole app; launching it again while it's running (e.g. from Finder) opens it. + @objc static func showPanel() { + guard let instance = instance else { return } NSApp.activate(ignoringOtherApps: true) - windowController?.showWindow(nil) - windowController?.window?.makeKeyAndOrderFront(nil) + if instance.statusItem.isVisible { + instance.showPopover() + } else { + // Hidden while no headset is connected: bring the icon back to anchor the panel, and let the + // button lay out before showing it. Closing the panel hides the icon again. + instance.panelRequested = true + instance.statusItem.isVisible = true + DispatchQueue.main.async { instance.showPopover() } + } } // A status-item popover takes the menu bar's appearance, which follows the wallpaper rather than the @@ -183,13 +204,11 @@ import SwiftUI menu.addItem(connect) } menu.addItem(.separator()) - menu.addItem(actionItem("Open SonyBridge", #selector(openWindow))) menu.addItem(actionItem("Quit SonyBridge", #selector(quit), key: "q")) } @objc private func pinToThisMac() { model.pinPlaybackToThisMac() } @objc private func unfixPlayback() { model.setPlaybackPinned(false) } - @objc private func openWindow() { Self.openMainWindow() } @objc private func quit() { NSApp.terminate(nil) } @objc private func connectHeadphones() { diff --git a/Client/macos/QuickControlsView.swift b/Client/macos/QuickControlsView.swift index 818c3574..d71b7673 100644 --- a/Client/macos/QuickControlsView.swift +++ b/Client/macos/QuickControlsView.swift @@ -1,39 +1,58 @@ // // QuickControlsView.swift -// The menu-bar panel: the everyday controls without opening the window. +// The menu-bar panel, which is the whole app: everyday controls up top, everything else behind the +// footer arrow. // import SwiftUI +import AppKit struct QuickControlsView: View { - let openWindow: () -> Void let quit: () -> Void @ObservedObject private var model = HeadphonesModel.shared @AppStorage(AppTheme.storageKey) private var theme = AppTheme.refinedDark @AppStorage("quickControlsMoreExpanded") private var moreExpanded = false + // The multipoint value waiting for Restart/Cancel (nil when nothing is pending). + @State private var pendingMultipoint: Bool? @Environment(\.colorScheme) private var colorScheme private var palette: Palette { theme.palette(colorScheme) } - // 320pt gives each mode segment a little more room than the window's at its 360pt minimum, so - // "Noise Cancelling" fits within the mode control's minimum scale. The height follows the content. + private let eqBandLabels = ["400", "1k", "2.5k", "6.3k", "16k"] + private let tenBandEqLabels = ["31", "63", "125", "250", "500", "1k", "2k", "4k", "8k", "16k"] + + // At 320pt "Noise Cancelling" fits within the mode control's minimum scale. The height follows the + // content, scrolling once it would run off the screen. var body: some View { - VStack(alignment: .leading, spacing: 14) { - if model.connected { - connectedContent - } else { - disconnectedContent + VStack(spacing: 6) { + CappedScrollView(maxHeight: maxContentHeight) { + VStack(alignment: .leading, spacing: 14) { + if model.connected { + connectedContent + } else { + disconnectedContent + } + if moreExpanded { + moreContent + } + } + .padding([.horizontal, .top], 14) } - Divider().background(palette.cardStroke) footer } - .padding(14) .frame(width: 320) .background(palette.background) .environment(\.appTheme, theme) + .onChange(of: theme) { $0.applyToApp() } } + private var maxContentHeight: CGFloat { + (NSScreen.main?.visibleFrame.height ?? 800) - 80 + } + + // MARK: - Main controls + private var disconnectedContent: some View { VStack(spacing: 12) { Image(systemName: "headphones") @@ -60,6 +79,17 @@ struct QuickControlsView: View { .lineLimit(1) StatusChips(model: model, showsMode: false) } + Spacer(minLength: 0) + Button(action: model.disconnect) { + Image(systemName: "power") + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(palette.secondary) + .frame(width: 26, height: 26) + .background(palette.raised, in: Circle()) + .overlay(Circle().strokeBorder(palette.cardStroke, lineWidth: 1)) + } + .buttonStyle(PlainButtonStyle()) + .help("Disconnect") } AmbientModeControl(model: model) @@ -80,103 +110,83 @@ struct QuickControlsView: View { } } - if model.supportsEqualizer || model.hasPlaybackPinState { - VStack(alignment: .leading, spacing: 12) { - if model.supportsEqualizer { - equalizerRow - } - if model.supportsEqualizer && model.hasPlaybackPinState { - Divider().background(palette.cardStroke) - } - if model.hasPlaybackPinState { - playbackRow - } - } - .themedCard(compact: true) + if model.supportsEqualizer { + equalizerCard } - if model.supportsDsee || model.hasAutoPowerOff { - moreSettings + if model.hasPlaybackPinState { + playbackRow + .themedCard(compact: true) } errorText } - // Less-used settings, collapsed by default so the panel stays short. The open/closed state is remembered. - private var moreSettings: some View { - VStack(alignment: .leading, spacing: 12) { - Button(action: { withAnimation(.easeInOut(duration: 0.15)) { moreExpanded.toggle() } }) { - HStack { - Text("More Settings") - .font(.system(size: 13, weight: .medium)) - .foregroundColor(palette.primary) - Spacer() - Image(systemName: "chevron.right") - .font(.system(size: 11, weight: .semibold)) - .foregroundColor(palette.secondary) - .rotationEffect(.degrees(moreExpanded ? 90 : 0)) - } - .contentShape(Rectangle()) - } - .buttonStyle(PlainButtonStyle()) - .accessibilityValue(moreExpanded ? "Expanded" : "Collapsed") - - if moreExpanded { - if model.supportsDsee { - SwitchRow("DSEE", isOn: Binding(get: { model.dsee }, set: { model.setDsee($0) })) { - VStack(alignment: .leading, spacing: 2) { - Text("DSEE") - .font(.system(size: 13, weight: .medium)) - .foregroundColor(palette.primary) - Text("Upscale compressed audio") - .font(.system(size: 11)) - .foregroundColor(palette.secondary) - } + // The graph shows the active preset's curve. Editing it (or Clear Bass) switches to Manual, starting + // from that curve, the way Sony's app does. + private var equalizerCard: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + Text("Equalizer") + .font(.system(size: 13, weight: .medium)) + .foregroundColor(palette.primary) + Spacer() + if model.eqPreset == 0xA0 && !model.tenBandEqualizer { + CapsuleButton("Flat") { + model.eqBands = model.eqBands.map { _ in 0 } + model.clearBass = 0 + model.applyCustomEq() } } - if model.hasAutoPowerOff { - HStack { - Text("Auto Power-Off") - .font(.system(size: 13, weight: .medium)) - .foregroundColor(palette.primary) - Spacer() - Picker("Auto Power-Off", selection: Binding( - get: { model.autoPowerOff }, - set: { model.setAutoPowerOff($0) } - )) { - ForEach(0.. 0 ? "+\(model.clearBass)" : "\(model.clearBass)") + .font(.system(size: 11, weight: .semibold, design: .monospaced)) + .foregroundColor(palette.primary) + .frame(width: 26, alignment: .trailing) } } @@ -224,20 +234,331 @@ struct QuickControlsView: View { } } - private var footer: some View { - HStack { - Button(action: openWindow) { - Text("Open SonyBridge") + // MARK: - Behind the footer arrow + + @ViewBuilder + private var moreContent: some View { + if model.connected { + if model.supportsDsee || model.hasAdaptiveVolume || model.hasSpeakToChat + || model.hasMultipointSetting || model.hasTouchPanelSetting || model.hasAutoPowerOff { + settingsCard + } + if model.multipointLoading || model.hasPairedDevices { + devicesCard + } + } + appearanceCard + if model.connected { + deviceInfo + } + Button(action: quit) { + Text("Quit SonyBridge") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(palette.secondary) + .frame(maxWidth: .infinity) + } + .buttonStyle(PlainButtonStyle()) + } + + private var settingsCard: some View { + VStack(alignment: .leading, spacing: 12) { + if model.supportsDsee { + settingToggle("DSEE", "Upscale compressed audio", on: model.dsee) { model.setDsee($0) } + } + if model.hasAdaptiveVolume { + settingToggle("Adaptive Volume", "Auto-adjust volume by surroundings", + on: model.adaptiveVolume) { model.setAdaptiveVolume($0) } + } + if model.hasSpeakToChat { + settingToggle("Speak-to-Chat", "Pause music when you talk", + on: model.speakToChat) { model.setSpeakToChat($0) } + SwitchRow("Keep off on connect", isOn: $model.keepSpeakToChatOff) { + Text("Keep off on connect") + .font(.system(size: 11)) + .foregroundColor(palette.secondary) + } + .controlSize(.small) + .padding(.leading, 12) + } + if model.hasMultipointSetting { + // Applying it restarts the headset, so flipping the switch only asks; Restart sends it. + settingToggle("Connect to 2 devices", "Restarts the headphones to apply", + on: pendingMultipoint ?? model.multipointEnabled) { + pendingMultipoint = $0 == model.multipointEnabled ? nil : $0 + } + if let target = pendingMultipoint { + multipointConfirmation(target) + } + } + if model.hasTouchPanelSetting { + settingToggle("Touch Controls", "Swipe and tap on the earcup", + on: model.touchPanelEnabled) { model.setTouchPanel($0) } + } + if model.hasAutoPowerOff { + HStack { + Text("Auto Power-Off") + .font(.system(size: 13, weight: .medium)) + .foregroundColor(palette.primary) + Spacer() + Picker("Auto Power-Off", selection: Binding( + get: { model.autoPowerOff }, + set: { model.setAutoPowerOff($0) } + )) { + ForEach(0.. some View { + VStack(alignment: .leading, spacing: 8) { + Text("\(model.deviceName) will restart\(target ? "" : " and disconnect your other device").") + .font(.system(size: 11)) + .foregroundColor(palette.secondary) + .fixedSize(horizontal: false, vertical: true) + HStack(spacing: 8) { + Spacer() + CapsuleButton("Cancel") { pendingMultipoint = nil } + CapsuleButton("Restart") { + pendingMultipoint = nil + model.setMultipoint(target) + } + } + } + .padding(10) + .background(palette.track, in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + + private func settingToggle(_ title: String, _ subtitle: String, on: Bool, action: @escaping (Bool) -> Void) -> some View { + SwitchRow(title, isOn: Binding(get: { on }, set: { action($0) })) { + VStack(alignment: .leading, spacing: 2) { + Text(title).font(.system(size: 13, weight: .medium)).foregroundColor(palette.primary) + Text(subtitle).font(.system(size: 11)).foregroundColor(palette.secondary) + } + } + } + + // Multipoint device list. Switching playback is experimental, hence the diagnostics link. + private var devicesCard: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + Text("Devices") + .font(.system(size: 13, weight: .medium)) + .foregroundColor(palette.primary) + Spacer() + if model.multipointLoading || model.refreshingMultipoint || model.multipointBusy { + ProgressView() + .scaleEffect(0.5) + .frame(width: 16, height: 16) + } else { + Button(action: model.refreshMultipoint) { + Image(systemName: "arrow.clockwise") + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(palette.secondary) + } + .buttonStyle(PlainButtonStyle()) + .help("Refresh") + } + } + if model.multipointLoading { + Text("Reading devices from the headset…") + .font(.system(size: 11)) + .foregroundColor(palette.secondary) + } else { + let connected = model.pairedDevices.filter { $0.connected }.sorted { $0.slot < $1.slot } + let paired = model.pairedDevices.filter { !$0.connected } + ForEach(connected) { deviceRow($0) } + ForEach(paired) { deviceRow($0) } + HStack { + Text(model.multipointBusy ? "Sending…" : "Switching is experimental") + .font(.system(size: 10)) + .foregroundColor(palette.secondary.opacity(0.7)) + Spacer() + Button(action: copyMultipointDiagnostics) { + Text("Copy diagnostics") + .font(.system(size: 10, weight: .medium)) + .foregroundColor(palette.accentText) + } + .buttonStyle(PlainButtonStyle()) + } + } + } + .themedCard(compact: true) + } + + private func deviceRow(_ device: PairedDeviceInfo) -> some View { + HStack(spacing: 10) { + Image(systemName: deviceSymbol(device.classOfDevice)) + .font(.system(size: 13)) + .foregroundColor(device.connected ? palette.primary : palette.secondary) + .frame(width: 18) + VStack(alignment: .leading, spacing: 1) { + Text(device.name.isEmpty ? device.address : device.name) .font(.system(size: 12, weight: .medium)) - .foregroundColor(palette.accentText) + .foregroundColor(device.connected ? palette.primary : palette.secondary) + .lineLimit(1) + if device.isThisMac { + Text("This Mac").font(.system(size: 10)).foregroundColor(palette.accentText) + } } Spacer() - Button(action: quit) { - Text("Quit") - .font(.system(size: 12, weight: .medium)) - .foregroundColor(palette.secondary) + if device.playback { + if model.playbackPinned { + Image(systemName: "lock.fill") + .font(.system(size: 10)) + .foregroundColor(palette.accent) + } + Image(systemName: "speaker.wave.2.fill") + .font(.system(size: 11)) + .foregroundColor(palette.accent) + } + if device.connected && model.hasPlaybackPinState { + Menu { + Button("Switch playback device and fix") { model.switchPlayback(to: device, fix: true) } + if !device.playback { + Button("Switch playback device") { model.switchPlayback(to: device, fix: false) } + } + } label: { + Image(systemName: "ellipsis") + } + .menuStyle(BorderlessButtonMenuStyle()) + .fixedSize() + .disabled(model.multipointBusy) + } + } + } + + // Major device class lives in bits 8-12 of the class of device (1 = computer, 2 = phone). + private func deviceSymbol(_ classOfDevice: Int) -> String { + guard classOfDevice >= 0, classOfDevice != 0xFFFFFF else { return "dot.radiowaves.left.and.right" } + switch (classOfDevice >> 8) & 0x1F { + case 1: return "laptopcomputer" + case 2: return "iphone" + default: return "dot.radiowaves.left.and.right" + } + } + + private func copyMultipointDiagnostics() { + let header = "\(model.deviceName) · fw \(model.firmware) · \(model.protocolVersion)\n" + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(header + model.multipointDiagnostics, forType: .string) + } + + private var appearanceCard: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Appearance") + .font(.system(size: 13, weight: .medium)) + .foregroundColor(palette.primary) + HStack(spacing: 6) { + ForEach(AppTheme.allCases) { option in + themeOption(option) + } + } + } + .themedCard(compact: true) + } + + private func themeOption(_ option: AppTheme) -> some View { + let selected = option == theme + return Button(action: { theme = option }) { + VStack(spacing: 4) { + ThemeSwatch(theme: option) + Text(option.displayName) + .font(.system(size: 10, weight: .medium)) + .foregroundColor(selected ? palette.primary : palette.secondary) + .lineLimit(1) + .minimumScaleFactor(0.8) } + .padding(4) + .overlay(RoundedRectangle(cornerRadius: 9, style: .continuous) + .strokeBorder(selected ? palette.accent : .clear, lineWidth: 2)) + .contentShape(Rectangle()) + } + .buttonStyle(PlainButtonStyle()) + } + + private var deviceInfo: some View { + VStack(alignment: .leading, spacing: 0) { + if model.hasDualBattery { + infoRow("Battery L", batteryText(model.batteryLeft, model.batteryLeftCharging)) + infoRow("Battery R", batteryText(model.batteryRight, model.batteryRightCharging)) + if model.batteryCase >= 0 { + infoRow("Case", batteryText(model.batteryCase, model.batteryCaseCharging)) + } + } + if !model.firmware.isEmpty { infoRow("Firmware", model.firmware) } + if !model.protocolVersion.isEmpty { infoRow("Protocol", model.protocolVersion) } + if !model.deviceMac.isEmpty { infoRow("Bluetooth", model.deviceMac) } + Text("SonyBridge · not affiliated with Sony") + .font(.system(size: 10)) + .foregroundColor(palette.secondary.opacity(0.7)) + .padding(.top, 6) + } + .padding(.horizontal, 4) + } + + private func batteryText(_ level: Int, _ charging: Bool) -> String { + guard level >= 0 else { return "—" } + return "\(level)%\(charging ? " (charging)" : "")" + } + + private func infoRow(_ label: String, _ value: String) -> some View { + HStack { + Text(label).font(.system(size: 11)).foregroundColor(palette.secondary) + Spacer() + Text(value).font(.system(size: 11, weight: .medium)).foregroundColor(palette.primary) + } + .padding(.vertical, 2) + } + + // MARK: - Footer + + // Just the arrow that shows or hides everything above it; open/closed is remembered. + private var footer: some View { + Button(action: { withAnimation(.easeInOut(duration: 0.15)) { moreExpanded.toggle() } }) { + Image(systemName: "chevron.down") + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(palette.secondary) + .rotationEffect(.degrees(moreExpanded ? 180 : 0)) + .frame(maxWidth: .infinity) + .padding(.vertical, 6) + .contentShape(Rectangle()) } .buttonStyle(PlainButtonStyle()) + .help(moreExpanded ? "Fewer settings" : "More settings") + .accessibilityLabel(moreExpanded ? "Fewer settings" : "More settings") + .padding(.bottom, 4) + } +} + +// Exactly as tall as its content until that passes maxHeight, then scrolls, so the popover still sizes +// to fit. (A bare ScrollView would take all the height it's offered.) +private struct CappedScrollView: View { + let maxHeight: CGFloat + @ViewBuilder let content: Content + + @State private var contentHeight: CGFloat = 0 + + var body: some View { + ScrollView(showsIndicators: false) { + content.background(GeometryReader { proxy in + Color.clear.preference(key: ContentHeightKey.self, value: proxy.size.height) + }) + } + .frame(height: min(contentHeight, maxHeight)) + .onPreferenceChange(ContentHeightKey.self) { contentHeight = $0 } } } + +private struct ContentHeightKey: PreferenceKey { + static var defaultValue: CGFloat = 0 + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = max(value, nextValue()) } +} diff --git a/Client/macos/SharedViews.swift b/Client/macos/SharedViews.swift new file mode 100644 index 00000000..9cb8f177 --- /dev/null +++ b/Client/macos/SharedViews.swift @@ -0,0 +1,234 @@ +// +// SharedViews.swift +// Building blocks used by the menu-bar panel (QuickControlsView). +// + +import SwiftUI +import AppKit + +enum EQPresets { + static let fiveBand: [(String, Int)] = [ + ("Off", 0x00), ("Bright", 0x10), ("Excited", 0x11), + ("Mellow", 0x12), ("Relaxed", 0x13), ("Vocal", 0x14), + ("Treble", 0x15), ("Bass", 0x16), ("Speech", 0x17), + ("Manual", 0xA0) + ] + // WH-1000XM6: only Off and Custom have confirmed preset codes on the ten-band layout; its other preset + // bytes (0x20, 0x30-0x33, 0xa1-0xa2) are unnamed, and the 5-band codes above don't apply to it. + static let tenBand: [(String, Int)] = [("Off", 0x00), ("Custom", 0xA0)] + + static func list(tenBand: Bool) -> [(String, Int)] { tenBand ? Self.tenBand : fiveBand } +} + +// Indexed like Headphones::setAutoPowerOff (0=Off ... 5=when taken off). +enum AutoPowerOff { + static let options = ["Off", "5 min", "30 min", "1 hour", "3 hours", "When taken off"] +} + +extension SHCAmbientMode { + var displayName: String { + switch self { + case .noiseCanceling: return "Noise Cancelling" + case .ambientSound: return "Ambient" + default: return "Off" + } + } +} + +// The bundled product cutout matching the device name (e.g. "WH-CH720N" -> "wh-ch720n"), or a generic +// headphones glyph for models without one. +struct ProductImage: View { + let deviceName: String + let size: CGFloat + + @Environment(\.appTheme) private var theme + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + if let image = NSImage(named: deviceName.lowercased().replacingOccurrences(of: " ", with: "-")) { + Image(nsImage: image) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: size, height: size) + } else { + Image(systemName: "headphones") + .font(.system(size: size * 0.48, weight: .thin)) + .foregroundColor(theme.palette(colorScheme).primary) + .frame(width: size, height: size) + } + } +} + +// Battery, codec and (optionally) the current mode as capsule chips. +struct StatusChips: View { + @ObservedObject var model: HeadphonesModel + var showsMode = true + + @Environment(\.appTheme) private var theme + @Environment(\.colorScheme) private var colorScheme + + private var palette: Palette { theme.palette(colorScheme) } + + var body: some View { + HStack(spacing: 6) { + chip { + if model.hasDualBattery { + // Earbuds report a battery each, plus the case when it's connected. An earbud that + // isn't reporting (docked / powered down) comes through as -1 and is left out. + budBattery("L", model.batteryLeft, charging: model.batteryLeftCharging) + Text("·") + budBattery("R", model.batteryRight, charging: model.batteryRightCharging) + if model.batteryCase >= 0 { + Text("·") + budBattery("Case", model.batteryCase, charging: model.batteryCaseCharging) + } + } else if model.batteryLevel >= 0 { + Image(systemName: model.batteryCharging ? "bolt.fill" : batterySymbol(model.batteryLevel)) + .foregroundColor(model.batteryLevel <= 20 ? palette.danger : palette.accent) + chipText("\(model.batteryLevel)%") + } else { + Circle().fill(palette.accent).frame(width: 6, height: 6) + chipText("Connected") + } + } + if showsMode { + chip { chipText(model.mode.displayName) } + } + } + } + + private func chip(@ViewBuilder _ content: () -> Content) -> some View { + HStack(spacing: 4, content: content) + .font(.system(size: theme.isSignature ? 10 : 11, weight: .semibold)) + .foregroundColor(palette.secondary) + .lineLimit(1) + .padding(.horizontal, 9) + .padding(.vertical, 4) + .background(palette.raised, in: Capsule()) + .overlay(Capsule().strokeBorder(palette.cardStroke, lineWidth: 1)) + } + + private func chipText(_ text: String) -> Text { + Text(theme.isSignature ? text.uppercased() : text).tracking(theme.isSignature ? 0.8 : 0) + } + + // One "L 82%" reading in the battery chip, with a bolt in place of the battery glyph while that unit + // is charging. A docked/powered-down earbud reports no level; show it dimmed as "R --" rather than + // dropping it, so it reads as "in the case" instead of looking like the app lost track of it. + private func budBattery(_ label: String, _ level: Int, charging: Bool) -> some View { + HStack(spacing: 3) { + Image(systemName: level < 0 ? "battery.0" : (charging ? "bolt.fill" : batterySymbol(level))) + .foregroundColor(level < 0 ? palette.secondary.opacity(0.5) + : (level <= 20 && !charging ? palette.danger : palette.accent)) + chipText(level < 0 ? "\(label) --" : "\(label) \(level)%") + .foregroundColor(palette.secondary.opacity(level < 0 ? 0.5 : 1.0)) + } + } + + private func batterySymbol(_ level: Int) -> String { + switch level { + case ...15: return "battery.0" + case ...50: return "battery.25" + default: return "battery.100" + } + } +} + +// Noise Cancelling / Ambient / Off: segments in a recessed track, or separate pills in Sony Signature. +struct AmbientModeControl: View { + @ObservedObject var model: HeadphonesModel + + @Environment(\.appTheme) private var theme + @Environment(\.colorScheme) private var colorScheme + + private var palette: Palette { theme.palette(colorScheme) } + + var body: some View { + let track = RoundedRectangle(cornerRadius: 11, style: .continuous) + HStack(spacing: theme.isSignature ? 6 : 0) { + modeButton(.noiseCanceling) + modeButton(.ambientSound) + modeButton(.off) + } + .padding(theme.isSignature ? 0 : 3) + .background(palette.track, in: track) + .overlay(track.strokeBorder(theme.isSignature ? .clear : palette.cardStroke, lineWidth: 1)) + } + + private func modeButton(_ mode: SHCAmbientMode) -> some View { + let selected = model.mode == mode + return Button(action: { model.setMode(mode) }) { + Text(mode.displayName) + .font(.system(size: 11.5, weight: .semibold)) + .lineLimit(1) + .minimumScaleFactor(0.8) + .padding(.horizontal, 8) + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + .selectable(selected) + .shadow(color: selected ? palette.selectedShadow : .clear, radius: 8) + } + .buttonStyle(.plain) + } +} + +struct AmbientLevelSlider: View { + @ObservedObject var model: HeadphonesModel + + @Environment(\.appTheme) private var theme + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + Slider( + value: Binding( + get: { Double(model.ambientLevel) }, + set: { model.setLevel(Int($0.rounded())) } + ), + in: 1...Double(model.maxAmbientLevel), + step: 1 + ) + .accentColor(theme.palette(colorScheme).accent) + } +} + +struct FocusOnVoiceSwitch: View { + @ObservedObject var model: HeadphonesModel + + @Environment(\.appTheme) private var theme + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + SwitchRow("Focus on Voice", isOn: Binding( + get: { model.focusOnVoice }, + set: { model.setFocusOnVoice($0) } + )) { + Text("Focus on Voice") + .font(.system(size: 13, weight: .medium)) + .foregroundColor(theme.palette(colorScheme).primary) + } + } +} + +struct ConnectButton: View { + @ObservedObject var model: HeadphonesModel + + @Environment(\.appTheme) private var theme + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + let palette = theme.palette(colorScheme) + // Activate first: from the menu-bar panel the app isn't frontmost, and the device picker the + // connect shows would open behind other apps. + Button(action: { NSApp.activate(ignoringOtherApps: true); model.connect() }) { + Text(model.connecting ? "Connecting…" : "Connect headphones") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(palette.onAccent) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background(palette.accent) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + } + .buttonStyle(PlainButtonStyle()) + .disabled(model.connecting) + } +} diff --git a/Client/macos/SonyHeadphonesClient.xcodeproj/project.pbxproj b/Client/macos/SonyHeadphonesClient.xcodeproj/project.pbxproj index fc1b8bd5..d225cc6e 100644 --- a/Client/macos/SonyHeadphonesClient.xcodeproj/project.pbxproj +++ b/Client/macos/SonyHeadphonesClient.xcodeproj/project.pbxproj @@ -18,10 +18,9 @@ A8DC78FF2576967D00447738 /* ByteMagic.cpp in Sources */ = {isa = PBXBuildFile; fileRef = A8DC78FD2576967D00447738 /* ByteMagic.cpp */; }; A8DC790525769A6600447738 /* IOBluetooth.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A8DC790425769A6600447738 /* IOBluetooth.framework */; }; A8DC792C25769C9900447738 /* AppDelegate.mm in Sources */ = {isa = PBXBuildFile; fileRef = A8DC792825769C9900447738 /* AppDelegate.mm */; }; - A8DC792D25769C9900447738 /* ViewController.mm in Sources */ = {isa = PBXBuildFile; fileRef = A8DC792A25769C9900447738 /* ViewController.mm */; }; A8DC793225769EA500447738 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = A8DC78E92576923D00447738 /* Main.storyboard */; }; CC0000000000000000000001 /* HeadphonesBridge.mm in Sources */ = {isa = PBXBuildFile; fileRef = CC0000000000000000000002 /* HeadphonesBridge.mm */; }; - CC0000000000000000000004 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0000000000000000000005 /* ContentView.swift */; }; + CC0000000000000000000004 /* SharedViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0000000000000000000005 /* SharedViews.swift */; }; CC0000000000000000000006 /* HeadphonesModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0000000000000000000007 /* HeadphonesModel.swift */; }; CC00000000000000000000A1 /* Theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC00000000000000000000A2 /* Theme.swift */; }; CC00000000000000000000A3 /* MenuBarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC00000000000000000000A4 /* MenuBarController.swift */; }; @@ -54,12 +53,10 @@ A8DC78FE2576967D00447738 /* ByteMagic.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = ByteMagic.h; path = ../ByteMagic.h; sourceTree = ""; }; A8DC790425769A6600447738 /* IOBluetooth.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = IOBluetooth.framework; path = System/Library/Frameworks/IOBluetooth.framework; sourceTree = SDKROOT; }; A8DC792825769C9900447738 /* AppDelegate.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; path = AppDelegate.mm; sourceTree = ""; }; - A8DC792925769C9900447738 /* ViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ViewController.h; sourceTree = ""; }; - A8DC792A25769C9900447738 /* ViewController.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; path = ViewController.mm; sourceTree = ""; }; A8DC792B25769C9900447738 /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AppDelegate.h; sourceTree = ""; }; CC0000000000000000000002 /* HeadphonesBridge.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = HeadphonesBridge.mm; sourceTree = ""; }; CC0000000000000000000003 /* HeadphonesBridge.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = HeadphonesBridge.h; sourceTree = ""; }; - CC0000000000000000000005 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + CC0000000000000000000005 /* SharedViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedViews.swift; sourceTree = ""; }; CC0000000000000000000007 /* HeadphonesModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeadphonesModel.swift; sourceTree = ""; }; CC00000000000000000000A2 /* Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Theme.swift; sourceTree = ""; }; CC00000000000000000000A4 /* MenuBarController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarController.swift; sourceTree = ""; }; @@ -107,11 +104,9 @@ A8A3D31C283EC38500148E80 /* Headphones.h */, A8DC792B25769C9900447738 /* AppDelegate.h */, A8DC792825769C9900447738 /* AppDelegate.mm */, - A8DC792925769C9900447738 /* ViewController.h */, - A8DC792A25769C9900447738 /* ViewController.mm */, CC0000000000000000000003 /* HeadphonesBridge.h */, CC0000000000000000000002 /* HeadphonesBridge.mm */, - CC0000000000000000000005 /* ContentView.swift */, + CC0000000000000000000005 /* SharedViews.swift */, CC0000000000000000000007 /* HeadphonesModel.swift */, CC00000000000000000000A2 /* Theme.swift */, CC00000000000000000000A4 /* MenuBarController.swift */, @@ -203,14 +198,13 @@ A8A3D31E283EC38600148E80 /* Headphones.cpp in Sources */, A8DC78EB2576923D00447738 /* main.mm in Sources */, A8DC78EC2576923D00447738 /* MacOSBluetoothConnector.mm in Sources */, - A8DC792D25769C9900447738 /* ViewController.mm in Sources */, A8DC78F12576952E00447738 /* BluetoothWrapper.cpp in Sources */, A8DC78FA2576954100447738 /* TimedMessageQueue.cpp in Sources */, A8DC78F52576953600447738 /* CommandSerializer.cpp in Sources */, A8DC78FF2576967D00447738 /* ByteMagic.cpp in Sources */, A8DC792C25769C9900447738 /* AppDelegate.mm in Sources */, CC0000000000000000000001 /* HeadphonesBridge.mm in Sources */, - CC0000000000000000000004 /* ContentView.swift in Sources */, + CC0000000000000000000004 /* SharedViews.swift in Sources */, CC0000000000000000000006 /* HeadphonesModel.swift in Sources */, CC00000000000000000000A1 /* Theme.swift in Sources */, CC00000000000000000000A3 /* MenuBarController.swift in Sources */, diff --git a/Client/macos/Theme.swift b/Client/macos/Theme.swift index 249432a5..2d74162c 100644 --- a/Client/macos/Theme.swift +++ b/Client/macos/Theme.swift @@ -316,6 +316,7 @@ struct EQEditor: View { let labels: [String] let range: ClosedRange let signed: Bool // dB values (-10...10); the ten-band layout's raw 0...12 steps are unsigned + var plotHeight: CGFloat = 150 let set: (_ band: Int, _ value: Int) -> Void @State private var dragging: Int? @@ -323,7 +324,6 @@ struct EQEditor: View { @Environment(\.appTheme) private var theme @Environment(\.colorScheme) private var colorScheme - private let plotHeight: CGFloat = 150 private let axisWidth: CGFloat = 26 private let labelsHeight: CGFloat = 34 diff --git a/Client/macos/ViewController.h b/Client/macos/ViewController.h deleted file mode 100644 index df90ee30..00000000 --- a/Client/macos/ViewController.h +++ /dev/null @@ -1,31 +0,0 @@ -// -// ViewController.h -// SonyHeadphonesClient -// -// Created by Sem Visscher on 01/12/2020. -// - -#import -#import "AppDelegate.h" - -BluetoothWrapper bt = (BluetoothWrapper)nil; -Headphones* headphones; -NSStatusItem* statusItem; - -@interface ViewController : NSViewController -@property (weak, nonatomic) IBOutlet NSTextField *connectedLabel; -@property (weak, nonatomic) IBOutlet NSTextField *ANCValuePrefixLabel; -@property (weak, nonatomic) IBOutlet NSTextField *ANCValueLabel; -@property (weak, nonatomic) IBOutlet NSButton *focusOnVoice; -@property (weak, nonatomic) IBOutlet NSButton *connectButton; -@property (weak, nonatomic) IBOutlet NSSlider *ANCSlider; -@property (weak, nonatomic) IBOutlet NSButton *ANCEnabled; -@property (weak, nonatomic) IBOutlet NSTextField *virtualSoundLabel; -@property (weak, nonatomic) IBOutlet NSTextField *soundPositionLabel; -@property (weak, nonatomic) IBOutlet NSTextField *surroundLabel; -@property (weak, nonatomic) IBOutlet NSPopUpButton *soundPosition; -@property (weak, nonatomic) IBOutlet NSPopUpButton *surround; - -@property (strong, nonatomic) NSView *vptCard; -@end - diff --git a/Client/macos/ViewController.mm b/Client/macos/ViewController.mm deleted file mode 100644 index dfcc5aa4..00000000 --- a/Client/macos/ViewController.mm +++ /dev/null @@ -1,57 +0,0 @@ -// -// ViewController.mm -// Thin AppKit host: the storyboard instantiates this controller; it swaps its content for the -// SwiftUI interface (built in ContentView.swift, exposed via the generated -Swift.h header). -// - -#import "ViewController.h" -#import "SonyHeadphonesClient-Swift.h" - -@implementation ViewController - -- (void)viewDidLoad { - [super viewDidLoad]; - - // Drop the legacy storyboard controls; the SwiftUI view takes over the whole content area. - for (NSView *subview in [self.view.subviews copy]) { - [subview removeFromSuperview]; - } - - NSViewController *ui = [HeadphonesUIFactory makeViewController]; - [self addChildViewController:ui]; - ui.view.frame = self.view.bounds; - ui.view.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; - [self.view addSubview:ui.view]; -} - -- (void)viewWillAppear { - [super viewWillAppear]; - - NSWindow *window = self.view.window; - if (!window) return; - - // The storyboard pins the window to a small fixed size; open it up to a tall portrait layout that the - // user can widen into two columns. The size they leave it at is remembered. Appearance comes from the - // selected theme (ContentView sets NSApp.appearance), so the window must not force one of its own. - window.contentMinSize = NSMakeSize(360, 500); - window.contentMaxSize = NSMakeSize(1200, CGFLOAT_MAX); - window.styleMask |= NSWindowStyleMaskResizable; - if (![window setFrameUsingName:@"SonyBridgeMain"]) { - CGFloat visibleHeight = NSHeight(window.screen.visibleFrame) - 60; - [window setContentSize:NSMakeSize(400, MIN(820, MAX(500, visibleHeight)))]; - [window center]; - } - window.frameAutosaveName = @"SonyBridgeMain"; - window.titlebarAppearsTransparent = YES; - window.titleVisibility = NSWindowTitleHidden; -} - -// Legacy storyboard action connections resolve to these no-op stubs (their controls are removed above). -- (IBAction)connectToDevice:(id)sender {} -- (IBAction)ANCSliderChanged:(id)sender {} -- (IBAction)ANCEnabledButtonChanged:(id)sender {} -- (IBAction)focusOnVoiceChanged:(id)sender {} -- (IBAction)surroundChanged:(id)sender {} -- (IBAction)soundPositionChanged:(id)sender {} - -@end