diff --git a/.github/workflows/xcodebuild.yml b/.github/workflows/xcodebuild.yml index 2d3193e3..cd4b63df 100644 --- a/.github/workflows/xcodebuild.yml +++ b/.github/workflows/xcodebuild.yml @@ -3,53 +3,37 @@ name: macOS on: push: branches: - - master + - main + - 'feature/**' pull_request: branches: - - master + - main + workflow_dispatch: jobs: build: - strategy: - matrix: - os: ["macos-latest"] - include: - - os: macos-latest - binary_path: Client/build/SonyHeadphonesClient.zip - artifact_name: "SonyHeadphonesClient-macOS.zip" - runs-on: ${{ matrix.os }} + runs-on: macos-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: submodules: true - - name: Create build directory + - name: Protocol tests working-directory: ${{github.workspace}}/Client - run: mkdir build + run: sh tests/run-macos.sh - name: Build - working-directory: ${{github.workspace}}/Client/build - # Execute the build. - run: xcodebuild -project '../macos/SonyHeadphonesClient.xcodeproj' -config Release -scheme 'SonyHeadphonesClient' -archivePath ./archive archive | xcpretty + working-directory: ${{github.workspace}}/Client/macos + # Ad-hoc signed like the released app, so no signing identity is needed. + run: xcodebuild -project SonyHeadphonesClient.xcodeproj -scheme SonyHeadphonesClient -configuration Release -derivedDataPath ../build CODE_SIGN_IDENTITY=- build - - name: Export archive to .app - working-directory: ${{github.workspace}}/Client/build - # Exports the archive to a .app file - run: xcodebuild -exportArchive -archivePath ./archive.xcarchive -exportPath ./ -exportOptionsPlist ../macos/exportOptions.plist | xcpretty + - name: Zip the app + working-directory: ${{github.workspace}}/Client/build/Build/Products/Release + run: ditto -c -k --keepParent SonyHeadphonesClient.app ${{github.workspace}}/SonyHeadphonesClient-macOS.zip - - name: Zip the exported file - working-directory: ${{github.workspace}}/Client/build - # Zip the application for easier downloading - run: zip -r SonyHeadphonesClient.zip SonyHeadphonesClient.app - - - name: Upload the zipped application - uses: actions/upload-artifact@v3 + - name: Upload the zipped app + uses: actions/upload-artifact@v4 with: - # Artifact name - name: ${{ matrix.artifact_name }} - # A file, directory or wildcard pattern that describes what to upload - path: ${{github.workspace}}/${{matrix.binary_path}} - # The desired behavior if no files are found using the provided path. - - + name: SonyHeadphonesClient-macOS + path: ${{github.workspace}}/SonyHeadphonesClient-macOS.zip 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/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..099a96e3 100644 --- a/Client/macos/AppDelegate.mm +++ b/Client/macos/AppDelegate.mm @@ -14,20 +14,15 @@ @interface AppDelegate () @end @implementation AppDelegate +// A menu-bar app (LSUIElement): no Dock icon or window; everything lives in the menu-bar panel. - (void)applicationDidFinishLaunching:(NSNotification *)aNotification { - _window = [[[NSApplication sharedApplication] windows] firstObject]; [MenuBarController install]; } - (BOOL)applicationShouldHandleReopen:(NSApplication *)theApplication hasVisibleWindows:(BOOL)flag { - if (flag) { - return NO; - } - else { - [_window makeKeyAndOrderFront:self]; - 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 22773ca5..00000000 --- a/Client/macos/ContentView.swift +++ /dev/null @@ -1,773 +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"] - - private var equalizerCard: some View { - VStack(alignment: .leading, spacing: 14) { - cardTitle("Equalizer") - EQPreview(bands: model.eqBands, range: model.tenBandEqualizer ? 0...12 : -10...10) - 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 { - HStack(spacing: 10) { - Text(label) - .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)") - .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 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") - 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 } -} - -extension SHCAmbientMode { - var displayName: String { - switch self { - case .noiseCanceling: return "Noise Canceling" - 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 Canceling / 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 90678d39..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 { @@ -199,12 +203,46 @@ 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); +} + +// 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. 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 +250,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; } @@ -307,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; } @@ -551,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 6241e88e..353b320e 100644 --- a/Client/macos/Main.storyboard +++ b/Client/macos/Main.storyboard @@ -1,5 +1,5 @@ - + @@ -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 140e97f9..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 @@ -18,6 +20,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 } @@ -31,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(_:)) @@ -45,8 +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) { @@ -60,18 +80,40 @@ import SwiftUI } else if popover.isShown { popover.performClose(nil) } else { - refreshMultipointIfIdle() - 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() } } - static func openMainWindow() { + 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) - NSApp.windows.first { $0.contentViewController != nil && !($0 is NSPanel) }?.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 + // 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. @@ -162,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 98f6c0c6..d71b7673 100644 --- a/Client/macos/QuickControlsView.swift +++ b/Client/macos/QuickControlsView.swift @@ -1,38 +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 Canceling" 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") @@ -59,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) @@ -79,42 +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.hasPlaybackPinState { + playbackRow + .themedCard(compact: true) } errorText } - private var equalizerRow: some View { - HStack { - Text("Equalizer") - .font(.system(size: 13, weight: .medium)) - .foregroundColor(palette.primary) - Spacer() - Picker("Equalizer", selection: Binding( - get: { model.eqPreset }, - set: { model.setEqualizer($0) } - )) { - ForEach(EQPresets.list(tenBand: model.tenBandEqualizer), id: \.1) { preset in - Text(preset.0).tag(preset.1) + // 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() + } + } + Picker("Equalizer", selection: Binding( + get: { model.eqPreset }, + set: { model.setEqualizer($0) } + )) { + ForEach(EQPresets.list(tenBand: model.tenBandEqualizer), id: \.1) { preset in + Text(preset.0).tag(preset.1) + } } + .pickerStyle(MenuPickerStyle()) + .labelsHidden() + .fixedSize() + .accentColor(palette.accent) } - .pickerStyle(MenuPickerStyle()) - .labelsHidden() - .frame(width: 120) + // 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, + plotHeight: 110) { band, value in + model.eqBands[band] = value + model.applyCustomEq() + } + if !model.tenBandEqualizer { + clearBassRow + } + } + .themedCard(compact: true) + } + + 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) } } @@ -162,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 6d23d38b..2d74162c 100644 --- a/Client/macos/Theme.swift +++ b/Client/macos/Theme.swift @@ -35,6 +35,14 @@ enum AppTheme: String, CaseIterable, Identifiable { NSApp.appearance = colorScheme == .dark ? NSAppearance(named: .darkAqua) : nil } + // The concrete appearance for views that don't inherit the app's (the menu-bar panel): dark for the + // dark-only themes, the system's current light/dark for Native Glass. + var resolvedAppearance: NSAppearance? { + if colorScheme == .dark { return NSAppearance(named: .darkAqua) } + let systemIsDark = NSApp.effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua + return NSAppearance(named: systemIsDark ? .darkAqua : .aqua) + } + // Sony Signature changes shapes as well as colors: pill buttons, uppercase tracked labels, // monospaced numbers, EQ as bars and rings behind the product. var isSignature: Bool { self == .sonySignature } @@ -298,65 +306,210 @@ struct HeroBackdrop: View { } } -// Small picture of the EQ: a filled curve, or bars in Sony Signature. `range` is the band value range. -struct EQPreview: View { +// Interactive EQ graph. The bands are evenly spaced on a log-frequency axis (400 Hz-16 kHz in 2.5x steps, +// or octaves on the ten-band layout), so even spacing is their true position. One EQGeometry maps +// band/value to a point and back, and the curve, nodes, labels and drag all go through it, so they line +// up exactly. Click or drag anywhere in a band's column to set it; values snap to whole device steps. +// Drawn as a curve, or as bars in Sony Signature. +struct EQEditor: View { let bands: [Int] - let range: ClosedRange + 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? @Environment(\.appTheme) private var theme @Environment(\.colorScheme) private var colorScheme + 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/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 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 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) ---