├── HeadTrackerApp
├── Assets.xcassets
│ ├── Contents.json
│ ├── AccentColor.colorset
│ │ └── Contents.json
│ └── AppIcon.appiconset
│ │ └── Contents.json
├── Preview Content
│ └── Preview Assets.xcassets
│ │ └── Contents.json
├── HeadTrackerApp.swift
├── HeadTrackerApp.entitlements
├── Info.plist
├── LaunchScreen.storyboard
└── ContentView.swift
├── HeadTrackerAppTests
├── HeadTrackerAppTests.swift
└── Info.plist
├── HeadTrackerAppUITests
├── Info.plist
├── HeadTrackerAppUITestsLaunchTests.swift
└── HeadTrackerAppUITests.swift
├── project.yml
├── README.md
└── .gitignore
/HeadTrackerApp/Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/HeadTrackerApp/Preview Content/Preview Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/HeadTrackerApp/HeadTrackerApp.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | @main
4 | struct HeadTrackerApp: App {
5 | var body: some Scene {
6 | WindowGroup {
7 | ContentView()
8 | }
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/HeadTrackerApp/Assets.xcassets/AccentColor.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "idiom" : "universal"
5 | }
6 | ],
7 | "info" : {
8 | "author" : "xcode",
9 | "version" : 1
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/HeadTrackerApp/HeadTrackerApp.entitlements:
--------------------------------------------------------------------------------
1 |
2 | <\!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3 |
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/HeadTrackerAppTests/HeadTrackerAppTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // HeadTrackerAppTests.swift
3 | // HeadTrackerAppTests
4 | //
5 | // Created by omata on 2025/04/02.
6 | //
7 |
8 | import Testing
9 | @testable import HeadTrackerApp
10 |
11 | struct HeadTrackerAppTests {
12 |
13 | @Test func example() async throws {
14 | // Write your test here and use APIs like `#expect(...)` to check expected conditions.
15 | }
16 |
17 | }
18 |
--------------------------------------------------------------------------------
/HeadTrackerAppTests/Info.plist:
--------------------------------------------------------------------------------
1 |
2 | <\!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3 |
4 |
5 | CFBundleName
6 | $(PRODUCT_NAME)
7 | CFBundleIdentifier
8 | $(PRODUCT_BUNDLE_IDENTIFIER)
9 | CFBundleExecutable
10 | $(EXECUTABLE_NAME)
11 | CFBundleVersion
12 | $(CURRENT_PROJECT_VERSION)
13 | CFBundleShortVersionString
14 | $(MARKETING_VERSION)
15 |
16 |
17 |
--------------------------------------------------------------------------------
/HeadTrackerAppUITests/Info.plist:
--------------------------------------------------------------------------------
1 |
2 | <\!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3 |
4 |
5 | CFBundleName
6 | $(PRODUCT_NAME)
7 | CFBundleIdentifier
8 | $(PRODUCT_BUNDLE_IDENTIFIER)
9 | CFBundleExecutable
10 | $(EXECUTABLE_NAME)
11 | CFBundleVersion
12 | $(CURRENT_PROJECT_VERSION)
13 | CFBundleShortVersionString
14 | $(MARKETING_VERSION)
15 |
16 |
17 |
--------------------------------------------------------------------------------
/HeadTrackerApp/Assets.xcassets/AppIcon.appiconset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "universal",
5 | "platform" : "ios",
6 | "size" : "1024x1024"
7 | },
8 | {
9 | "appearances" : [
10 | {
11 | "appearance" : "luminosity",
12 | "value" : "dark"
13 | }
14 | ],
15 | "idiom" : "universal",
16 | "platform" : "ios",
17 | "size" : "1024x1024"
18 | },
19 | {
20 | "appearances" : [
21 | {
22 | "appearance" : "luminosity",
23 | "value" : "tinted"
24 | }
25 | ],
26 | "idiom" : "universal",
27 | "platform" : "ios",
28 | "size" : "1024x1024"
29 | }
30 | ],
31 | "info" : {
32 | "author" : "xcode",
33 | "version" : 1
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/project.yml:
--------------------------------------------------------------------------------
1 | name: HeadTrackerApp
2 | options:
3 | bundleIdPrefix: com.omata
4 | deploymentTarget:
5 | iOS: 17.0
6 | targets:
7 | HeadTrackerApp:
8 | type: application
9 | platform: iOS
10 | sources:
11 | - HeadTrackerApp
12 | info:
13 | path: HeadTrackerApp/Info.plist
14 | properties:
15 | CFBundleDisplayName: HeadTracker
16 | UILaunchStoryboardName: LaunchScreen
17 | UISupportedInterfaceOrientations: [UIInterfaceOrientationPortrait]
18 | NSMotionUsageDescription: This app needs access to motion data from your AirPods Pro to display head orientation.
19 | settings:
20 | base:
21 | GENERATE_INFOPLIST_FILE: NO
22 | INFOPLIST_FILE: HeadTrackerApp/Info.plist
23 | PRODUCT_BUNDLE_IDENTIFIER: com.omata.HeadTrackerApp
24 | attributes:
25 | SystemCapabilities:
26 | com.apple.BackgroundModes:
27 | enabled: true
--------------------------------------------------------------------------------
/HeadTrackerAppUITests/HeadTrackerAppUITestsLaunchTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // HeadTrackerAppUITestsLaunchTests.swift
3 | // HeadTrackerAppUITests
4 | //
5 | // Created by omata on 2025/04/02.
6 | //
7 |
8 | import XCTest
9 |
10 | final class HeadTrackerAppUITestsLaunchTests: XCTestCase {
11 |
12 | override class var runsForEachTargetApplicationUIConfiguration: Bool {
13 | true
14 | }
15 |
16 | override func setUpWithError() throws {
17 | continueAfterFailure = false
18 | }
19 |
20 | @MainActor
21 | func testLaunch() throws {
22 | let app = XCUIApplication()
23 | app.launch()
24 |
25 | // Insert steps here to perform after app launch but before taking a screenshot,
26 | // such as logging into a test account or navigating somewhere in the app
27 |
28 | let attachment = XCTAttachment(screenshot: app.screenshot())
29 | attachment.name = "Launch Screen"
30 | attachment.lifetime = .keepAlways
31 | add(attachment)
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/HeadTrackerApp/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | $(DEVELOPMENT_LANGUAGE)
7 | CFBundleDisplayName
8 | HeadTracker
9 | CFBundleExecutable
10 | $(EXECUTABLE_NAME)
11 | CFBundleIdentifier
12 | $(PRODUCT_BUNDLE_IDENTIFIER)
13 | CFBundleInfoDictionaryVersion
14 | 6.0
15 | CFBundleName
16 | $(PRODUCT_NAME)
17 | CFBundlePackageType
18 | APPL
19 | CFBundleShortVersionString
20 | 1.0
21 | CFBundleVersion
22 | 1
23 | NSMotionUsageDescription
24 | This app needs access to motion data from your AirPods Pro to display head orientation.
25 | UILaunchStoryboardName
26 | LaunchScreen
27 | UISupportedInterfaceOrientations
28 |
29 | UIInterfaceOrientationPortrait
30 |
31 |
32 |
33 |
--------------------------------------------------------------------------------
/HeadTrackerAppUITests/HeadTrackerAppUITests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // HeadTrackerAppUITests.swift
3 | // HeadTrackerAppUITests
4 | //
5 | // Created by omata on 2025/04/02.
6 | //
7 |
8 | import XCTest
9 |
10 | final class HeadTrackerAppUITests: XCTestCase {
11 |
12 | override func setUpWithError() throws {
13 | // Put setup code here. This method is called before the invocation of each test method in the class.
14 |
15 | // In UI tests it is usually best to stop immediately when a failure occurs.
16 | continueAfterFailure = false
17 |
18 | // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this.
19 | }
20 |
21 | override func tearDownWithError() throws {
22 | // Put teardown code here. This method is called after the invocation of each test method in the class.
23 | }
24 |
25 | @MainActor
26 | func testExample() throws {
27 | // UI tests must launch the application that they test.
28 | let app = XCUIApplication()
29 | app.launch()
30 |
31 | // Use XCTAssert and related functions to verify your tests produce the correct results.
32 | }
33 |
34 | @MainActor
35 | func testLaunchPerformance() throws {
36 | if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) {
37 | // This measures how long it takes to launch your application.
38 | measure(metrics: [XCTApplicationLaunchMetric()]) {
39 | XCUIApplication().launch()
40 | }
41 | }
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # HeadTrackerApp
2 |
3 | A Swift iOS application that visualizes head orientation data from AirPods Pro using CoreMotion's CMHeadphoneMotionManager.
4 |
5 | ## Features
6 |
7 | - Real-time head orientation tracking with AirPods Pro
8 | - 3D visual representation of head position
9 | - Displays pitch, roll, and yaw values with intuitive visualizations
10 | - Automatic connection detection for AirPods Pro
11 | - Works with AirPods Pro connected before or after app launch
12 | - Debug information for troubleshooting connections
13 |
14 | ## Requirements
15 |
16 | - iOS 17.0+
17 | - Xcode 15.0+
18 | - AirPods Pro (1st or 2nd generation)
19 | - iPhone compatible with AirPods Pro
20 |
21 | ## Installation
22 |
23 | 1. Clone the repository:
24 | ```bash
25 | git clone https://github.com/ctxzz/HeadTrackerApp.git
26 | ```
27 |
28 | 2. Open the project with Xcode:
29 | ```bash
30 | cd HeadTrackerApp
31 | xcodegen # Generate the Xcode project from project.yml
32 | open HeadTrackerApp.xcodeproj
33 | ```
34 |
35 | 3. Build and run the app on your iOS device
36 |
37 | ## Usage
38 |
39 | 1. Connect your AirPods Pro to your iPhone
40 | 2. Launch the app
41 | 3. The app will automatically detect your AirPods Pro
42 | 4. Once connected, you'll see a 3D face that rotates to match your head movements
43 | 5. The numerical values for pitch, roll, and yaw will be displayed below the visualization
44 |
45 | If your AirPods Pro aren't detected automatically, you can tap the "Retry Connection" button.
46 |
47 | ## Understanding Head Orientation
48 |
49 | - **Pitch**: Up and down movements (nodding "yes")
50 | - **Roll**: Side-to-side tilt (tilting your head toward your shoulders)
51 | - **Yaw**: Left and right rotation (shaking your head "no")
52 |
53 | ## Troubleshooting
54 |
55 | If the app doesn't detect your AirPods Pro:
56 |
57 | 1. Make sure your AirPods Pro are connected to your iPhone via Bluetooth
58 | 2. Check that your AirPods Pro have sufficient battery
59 | 3. Try tapping the "Retry Connection" button
60 | 4. Toggle the debug info (tap the info icon in the bottom right) to see connection status
61 |
62 | ## Technologies Used
63 |
64 | - Swift
65 | - SwiftUI
66 | - CoreMotion
67 | - Combine
68 |
69 | ## License
70 |
71 | [MIT License](LICENSE)
72 |
73 | ## Acknowledgments
74 |
75 | - Built using CMHeadphoneMotionManager for AirPods Pro motion data
76 | - SwiftUI for the user interface
77 | - Project structure generated using XcodeGen
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Xcode
2 | #
3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
4 |
5 | ## User settings
6 | xcuserdata/
7 |
8 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
9 | *.xcscmblueprint
10 | *.xccheckout
11 |
12 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4)
13 | build/
14 | DerivedData/
15 | *.moved-aside
16 | *.pbxuser
17 | !default.pbxuser
18 | *.mode1v3
19 | !default.mode1v3
20 | *.mode2v3
21 | !default.mode2v3
22 | *.perspectivev3
23 | !default.perspectivev3
24 |
25 | ## Obj-C/Swift specific
26 | *.hmap
27 |
28 | ## App packaging
29 | *.ipa
30 | *.dSYM.zip
31 | *.dSYM
32 |
33 | ## Playgrounds
34 | timeline.xctimeline
35 | playground.xcworkspace
36 |
37 | # Swift Package Manager
38 | #
39 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies.
40 | Packages/
41 | Package.pins
42 | Package.resolved
43 | *.xcodeproj
44 | #
45 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata
46 | # hence it is not needed unless you have added a package configuration file to your project
47 | .swiftpm
48 |
49 | # CocoaPods
50 | #
51 | # We recommend against adding the Pods directory to your .gitignore. However
52 | # you should judge for yourself, the pros and cons are mentioned at:
53 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
54 | #
55 | # Pods/
56 | #
57 | # Add this line if you want to avoid checking in source code from the Xcode workspace
58 | # *.xcworkspace
59 |
60 | # Carthage
61 | #
62 | # Add this line if you want to avoid checking in source code from Carthage dependencies.
63 | # Carthage/Checkouts
64 |
65 | Carthage/Build/
66 |
67 | # Accio dependency management
68 | Dependencies/
69 | .accio/
70 |
71 | # fastlane
72 | #
73 | # It is recommended to not store the screenshots in the git repo.
74 | # Instead, use fastlane to re-generate the screenshots whenever they are needed.
75 | # For more information about the recommended setup visit:
76 | # https://docs.fastlane.tools/best-practices/source-control/#source-control
77 |
78 | fastlane/report.xml
79 | fastlane/Preview.html
80 | fastlane/screenshots/**/*.png
81 | fastlane/test_output
82 |
83 | # Code Injection
84 | #
85 | # After new code Injection tools there's a generated folder /iOSInjectionProject
86 | # https://github.com/johnno1962/injectionforxcode
87 |
88 | iOSInjectionProject/
89 |
90 | # macOS specific
91 | .DS_Store
92 | .AppleDouble
93 | .LSOverride
94 |
95 | # Thumbnails
96 | ._*
97 |
98 | # Files that might appear in the root of a volume
99 | .DocumentRevisions-V100
100 | .fseventsd
101 | .Spotlight-V100
102 | .TemporaryItems
103 | .Trashes
104 | .VolumeIcon.icns
105 | .com.apple.timemachine.donotpresent
106 |
107 | # Directories potentially created on remote AFP share
108 | .AppleDB
109 | .AppleDesktop
110 | Network Trash Folder
111 | Temporary Items
112 | .apdisk
113 |
114 | # XcodeGen
115 | *.xcodeproj
--------------------------------------------------------------------------------
/HeadTrackerApp/LaunchScreen.storyboard:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
--------------------------------------------------------------------------------
/HeadTrackerApp/ContentView.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 | import CoreMotion
3 | import Combine
4 |
5 | struct ContentView: View {
6 | @StateObject private var headphoneMotionManager = HeadphoneMotionManager()
7 | @State private var connectionAttempts = 0
8 | @State private var showDebugInfo = false
9 |
10 | var body: some View {
11 | ZStack {
12 | Color(UIColor.systemBackground)
13 | .ignoresSafeArea()
14 |
15 | VStack(spacing: 15) {
16 | Text("AirPods Pro Head Tracking")
17 | .font(.title)
18 | .fontWeight(.bold)
19 | .padding(.top, 10)
20 |
21 | if headphoneMotionManager.isDeviceConnected {
22 | // Visual head tracker
23 | HeadVisualization(
24 | pitch: headphoneMotionManager.pitch,
25 | roll: headphoneMotionManager.roll,
26 | yaw: headphoneMotionManager.yaw
27 | )
28 | .frame(height: 180)
29 | .padding(.vertical, 10)
30 |
31 | // Numerical data
32 | VStack(alignment: .leading, spacing: 15) {
33 | Text("Head Orientation")
34 | .font(.headline)
35 | .frame(maxWidth: .infinity, alignment: .center)
36 |
37 | Divider()
38 |
39 | OrientationRow(label: "Pitch", value: headphoneMotionManager.pitch, description: "Up/Down")
40 | OrientationRow(label: "Roll", value: headphoneMotionManager.roll, description: "Tilt Left/Right")
41 | OrientationRow(label: "Yaw", value: headphoneMotionManager.yaw, description: "Turn Left/Right")
42 | }
43 | .padding()
44 | .background(Color(UIColor.secondarySystemBackground))
45 | .cornerRadius(12)
46 | .shadow(radius: 1)
47 | .padding(.horizontal)
48 | } else {
49 | Spacer()
50 |
51 | VStack(spacing: 25) {
52 | Image(systemName: "airpodspro")
53 | .font(.system(size: 60))
54 | .foregroundColor(.blue)
55 |
56 | Text("Waiting for AirPods Pro...")
57 | .font(.title3)
58 | .foregroundColor(.secondary)
59 |
60 | Button(action: {
61 | connectionAttempts += 1
62 | headphoneMotionManager.restart()
63 | }) {
64 | Text("Retry Connection")
65 | .fontWeight(.semibold)
66 | .padding(.horizontal, 20)
67 | .padding(.vertical, 10)
68 | .background(Color.blue.opacity(0.2))
69 | .cornerRadius(8)
70 | }
71 |
72 | if showDebugInfo {
73 | Text("Connection Status: \(headphoneMotionManager.connectionStatus)")
74 | .font(.caption)
75 | .foregroundColor(.secondary)
76 | .padding(.top, 10)
77 | .multilineTextAlignment(.center)
78 | .frame(maxWidth: .infinity)
79 | }
80 | }
81 | .padding()
82 | .background(Color(UIColor.secondarySystemBackground))
83 | .cornerRadius(12)
84 | .shadow(radius: 1)
85 | .padding(.horizontal)
86 |
87 | Spacer()
88 | }
89 |
90 | Spacer()
91 |
92 | // Debug info footer
93 | HStack {
94 | if showDebugInfo {
95 | Text("Attempts: \(connectionAttempts)")
96 | .font(.caption)
97 | .foregroundColor(.secondary)
98 | }
99 |
100 | Spacer()
101 |
102 | Button(action: {
103 | showDebugInfo.toggle()
104 | }) {
105 | Image(systemName: showDebugInfo ? "info.circle.fill" : "info.circle")
106 | .foregroundColor(.gray)
107 | }
108 | }
109 | .padding(.horizontal)
110 | .padding(.bottom, 5)
111 | }
112 | .padding(.horizontal, 5)
113 | }
114 | .onAppear {
115 | headphoneMotionManager.start()
116 | }
117 | .onDisappear {
118 | headphoneMotionManager.stop()
119 | }
120 | }
121 | }
122 |
123 | struct HeadVisualization: View {
124 | let pitch: Double
125 | let roll: Double
126 | let yaw: Double
127 |
128 | @State private var isAnimating = false
129 |
130 | var body: some View {
131 | ZStack {
132 | // Direction indicators
133 | DirectionIndicators()
134 | .foregroundColor(.gray.opacity(0.7))
135 |
136 | // Head outline and face
137 | ZStack {
138 | // Head outline
139 | Circle()
140 | .stroke(Color.gray, lineWidth: 1.5)
141 | .frame(width: 120, height: 120)
142 |
143 | // Face representation
144 | HeadShape()
145 | .fill(Color.blue.opacity(0.5))
146 | .frame(width: 110, height: 110)
147 | .rotation3DEffect(
148 | .degrees(pitch),
149 | axis: (x: 1.0, y: 0.0, z: 0.0)
150 | )
151 | .rotation3DEffect(
152 | .degrees(roll),
153 | axis: (x: 0.0, y: 0.0, z: 1.0)
154 | )
155 | .rotation3DEffect(
156 | .degrees(yaw),
157 | axis: (x: 0.0, y: 1.0, z: 0.0)
158 | )
159 | }
160 | }
161 | .onAppear {
162 | withAnimation(Animation.easeInOut(duration: 0.5)) {
163 | isAnimating = true
164 | }
165 | }
166 | }
167 | }
168 |
169 | struct DirectionIndicators: View {
170 | var body: some View {
171 | ZStack {
172 | // Horizontal line
173 | Rectangle()
174 | .frame(width: 180, height: 1)
175 | .opacity(0.3)
176 |
177 | // Vertical line
178 | Rectangle()
179 | .frame(width: 1, height: 180)
180 | .opacity(0.3)
181 |
182 | // Direction labels
183 | VStack {
184 | Text("Front")
185 | .font(.system(size: 11))
186 | .padding(.bottom, 160)
187 |
188 | HStack {
189 | Text("Left")
190 | .font(.system(size: 11))
191 | .padding(.trailing, 160)
192 |
193 | Text("Right")
194 | .font(.system(size: 11))
195 | .padding(.leading, 160)
196 | }
197 |
198 | Text("Back")
199 | .font(.system(size: 11))
200 | .padding(.top, 160)
201 | }
202 | }
203 | }
204 | }
205 |
206 | struct HeadShape: Shape {
207 | func path(in rect: CGRect) -> Path {
208 | var path = Path()
209 |
210 | let width = rect.width
211 | let height = rect.height
212 |
213 | // Head shape
214 | path.addEllipse(in: rect)
215 |
216 | // Eyes
217 | let eyeWidth = width * 0.12
218 | let eyeHeight = height * 0.08
219 | let eyeY = height * 0.35
220 |
221 | let leftEyeX = width * 0.3
222 | let rightEyeX = width * 0.7 - eyeWidth
223 |
224 | path.addEllipse(in: CGRect(x: leftEyeX, y: eyeY, width: eyeWidth, height: eyeHeight))
225 | path.addEllipse(in: CGRect(x: rightEyeX, y: eyeY, width: eyeWidth, height: eyeHeight))
226 |
227 | // Nose
228 | let noseWidth = width * 0.07
229 | let noseHeight = height * 0.15
230 | let noseX = width / 2 - noseWidth / 2
231 | let noseY = height * 0.45
232 |
233 | path.move(to: CGPoint(x: noseX, y: noseY))
234 | path.addLine(to: CGPoint(x: noseX + noseWidth, y: noseY))
235 | path.addLine(to: CGPoint(x: noseX + noseWidth / 2, y: noseY + noseHeight))
236 | path.addLine(to: CGPoint(x: noseX, y: noseY))
237 |
238 | // Mouth
239 | let mouthWidth = width * 0.4
240 | let mouthHeight = height * 0.05
241 | let mouthX = width / 2 - mouthWidth / 2
242 | let mouthY = height * 0.65
243 |
244 | path.move(to: CGPoint(x: mouthX, y: mouthY))
245 | path.addQuadCurve(
246 | to: CGPoint(x: mouthX + mouthWidth, y: mouthY),
247 | control: CGPoint(x: mouthX + mouthWidth / 2, y: mouthY + mouthHeight)
248 | )
249 |
250 | return path
251 | }
252 | }
253 |
254 | struct OrientationRow: View {
255 | let label: String
256 | let value: Double
257 | let description: String
258 |
259 | var body: some View {
260 | VStack(alignment: .leading, spacing: 5) {
261 | HStack {
262 | Text(label)
263 | .fontWeight(.medium)
264 |
265 | Text(description)
266 | .font(.caption)
267 | .foregroundColor(.secondary)
268 |
269 | Spacer()
270 |
271 | Text(String(format: "%.1f°", value))
272 | .fontWeight(.bold)
273 | .monospacedDigit()
274 | }
275 |
276 | // Progress bar visualization
277 | GeometryReader { geometry in
278 | ZStack(alignment: .leading) {
279 | // Background
280 | RoundedRectangle(cornerRadius: 2)
281 | .fill(Color.gray.opacity(0.2))
282 | .frame(height: 6)
283 |
284 | // Value indicator
285 | let normalizedValue = ((value + 180) / 360).clamped(to: 0...1)
286 | let width = normalizedValue * geometry.size.width
287 |
288 | RoundedRectangle(cornerRadius: 2)
289 | .fill(Color.blue)
290 | .frame(width: max(0, width), height: 6)
291 | }
292 | }
293 | .frame(height: 6)
294 | }
295 | }
296 | }
297 |
298 | extension Comparable {
299 | func clamped(to limits: ClosedRange) -> Self {
300 | return min(max(self, limits.lowerBound), limits.upperBound)
301 | }
302 | }
303 |
304 | class HeadphoneMotionManager: ObservableObject {
305 | private let motionManager = CMHeadphoneMotionManager()
306 | private var timer: Timer?
307 | private var availabilityTimer: Timer?
308 | private var updateTimer: Timer?
309 | private var lastMotionData: CMDeviceMotion?
310 |
311 | @Published var pitch: Double = 0.0
312 | @Published var roll: Double = 0.0
313 | @Published var yaw: Double = 0.0
314 | @Published var isDeviceConnected: Bool = false
315 | @Published var connectionStatus: String = "Not started"
316 |
317 | func start() {
318 | connectionStatus = "Starting headphone motion tracking"
319 |
320 | // Attempt immediate connection (for already connected AirPods)
321 | immediateConnectionAttempt()
322 |
323 | // Set up polling to check for connections periodically (for AirPods connected later)
324 | availabilityTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
325 | self?.checkDeviceAvailability()
326 | }
327 | }
328 |
329 | private func immediateConnectionAttempt() {
330 | // Check if device is already connected
331 | if motionManager.isDeviceMotionAvailable {
332 | connectionStatus = "Device already connected at launch"
333 | startMotionUpdates()
334 | } else {
335 | connectionStatus = "No device connected at launch - waiting"
336 | }
337 | }
338 |
339 | private func checkDeviceAvailability() {
340 | if motionManager.isDeviceMotionAvailable && !motionManager.isDeviceMotionActive {
341 | connectionStatus = "Device motion available"
342 | startMotionUpdates()
343 | } else if !motionManager.isDeviceMotionAvailable && isDeviceConnected {
344 | // Device was connected but is now disconnected
345 | connectionStatus = "Device disconnected"
346 | isDeviceConnected = false
347 | }
348 | }
349 |
350 | func restart() {
351 | stop()
352 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
353 | self.start()
354 | }
355 | }
356 |
357 | private func startMotionUpdates() {
358 | connectionStatus = "Starting motion updates"
359 |
360 | motionManager.startDeviceMotionUpdates(to: .main) { [weak self] motion, error in
361 | guard let self = self else { return }
362 |
363 | if let error = error {
364 | self.connectionStatus = "Error: \(error.localizedDescription)"
365 | self.isDeviceConnected = false
366 | return
367 | }
368 |
369 | if let motion = motion {
370 | self.lastMotionData = motion
371 | self.isDeviceConnected = true
372 |
373 | // Update UI with motion data
374 | self.updateMotionData(motion)
375 | }
376 | }
377 |
378 | // Set up a backup timer to check if we're still getting data
379 | timer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in
380 | guard let self = self else { return }
381 |
382 | if !self.motionManager.isDeviceMotionAvailable {
383 | self.connectionStatus = "Device no longer available"
384 | self.isDeviceConnected = false
385 | } else if !self.motionManager.isDeviceMotionActive {
386 | self.connectionStatus = "Motion updates stopped unexpectedly"
387 | self.startMotionUpdates() // Restart updates
388 | }
389 | }
390 | }
391 |
392 | private func updateMotionData(_ motion: CMDeviceMotion) {
393 | // Convert radians to degrees
394 | self.pitch = motion.attitude.pitch * 180 / .pi
395 | self.roll = motion.attitude.roll * 180 / .pi
396 | self.yaw = motion.attitude.yaw * 180 / .pi
397 |
398 | if !isDeviceConnected {
399 | connectionStatus = "Receiving motion data"
400 | }
401 |
402 | isDeviceConnected = true
403 | }
404 |
405 | func stop() {
406 | timer?.invalidate()
407 | timer = nil
408 |
409 | availabilityTimer?.invalidate()
410 | availabilityTimer = nil
411 |
412 | updateTimer?.invalidate()
413 | updateTimer = nil
414 |
415 | if motionManager.isDeviceMotionActive {
416 | motionManager.stopDeviceMotionUpdates()
417 | connectionStatus = "Motion updates stopped"
418 | }
419 | }
420 |
421 | deinit {
422 | stop()
423 | }
424 | }
--------------------------------------------------------------------------------