HomeView.swift 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533
  1. import SwiftUI
  2. import SwiftData
  3. import AVFoundation
  4. // MARK: - HomeView
  5. /// The main launch screen of CelestiaTrace.
  6. /// Features a minimalist, line-drawn aesthetic with a central record button
  7. /// and a subtle technical grid background.
  8. struct HomeView: View {
  9. @Environment(\.modelContext) private var modelContext
  10. @ObservedObject private var bleManager: BLEManager = .shared
  11. @ObservedObject private var authManager: AuthManager = .shared
  12. @Query(sort: \CelestiaSession.startTime, order: .reverse)
  13. private var sessions: [CelestiaSession]
  14. @State private var activeRecording: ActiveRecordingPresentation?
  15. @State private var completedRecordingSession: CelestiaSession?
  16. @State private var detector = RecordingEnvironmentDetector()
  17. @State private var dismissedWarnings: Set<String> = []
  18. @State private var showRecordingSourcePicker = false
  19. @State private var pendingRecordingSource: RecordingSourceChoice?
  20. var onRecordingFinished: ((CelestiaSession) -> Void)? = nil
  21. var body: some View {
  22. NavigationStack {
  23. ZStack {
  24. // Dynamic clean background
  25. Color.spaceBlack
  26. .ignoresSafeArea()
  27. // Subtle technical grid background
  28. businessGrid
  29. // Main content
  30. VStack(spacing: 0) {
  31. topBar
  32. .padding(.top, 16)
  33. if let firstWarning = detector.activeWarnings.first(where: { !dismissedWarnings.contains($0) }) {
  34. DiscreetWarningBanner(warning: firstWarning) {
  35. withAnimation {
  36. _ = dismissedWarnings.insert(firstWarning)
  37. }
  38. }
  39. .padding(.top, 12)
  40. }
  41. Spacer()
  42. recordSection
  43. Spacer()
  44. }
  45. .padding(.horizontal, 24)
  46. }
  47. .navigationBarHidden(true)
  48. .onAppear {
  49. detector.checkEnvironment()
  50. restoreActiveRecordingIfNeeded()
  51. }
  52. .onOpenURL(perform: handleRecordingURL)
  53. .fullScreenCover(item: $activeRecording, onDismiss: {
  54. if let session = completedRecordingSession {
  55. completedRecordingSession = nil
  56. onRecordingFinished?(session)
  57. }
  58. }) { recording in
  59. ActiveRecordingView(
  60. session: recording.session,
  61. initialDuration: recording.initialDuration,
  62. recordingSource: recording.source,
  63. initialAction: recording.initialAction
  64. ) {
  65. completedRecordingSession = recording.session
  66. }
  67. }
  68. .sheet(isPresented: $showRecordingSourcePicker, onDismiss: {
  69. guard let source = pendingRecordingSource else { return }
  70. pendingRecordingSource = nil
  71. createSessionAndNavigate(source: source)
  72. }) {
  73. RecordingSourcePickerView(devices: connectedSparkDevices) { source in
  74. pendingRecordingSource = source
  75. showRecordingSourcePicker = false
  76. }
  77. }
  78. }
  79. }
  80. // MARK: - Top Bar
  81. private var topBar: some View {
  82. HStack(alignment: .top) {
  83. VStack(alignment: .leading, spacing: 4) {
  84. Text("星痕现场记录")
  85. .font(.system(size: 11, weight: .bold, design: .monospaced))
  86. .foregroundStyle(Color.primary.opacity(0.8))
  87. .tracking(3)
  88. Text(currentDateFormatted)
  89. .font(.system(size: 13, weight: .regular))
  90. .foregroundStyle(Color.secondary)
  91. }
  92. Spacer()
  93. }
  94. }
  95. // MARK: - Record Section
  96. private var recordSection: some View {
  97. VStack(spacing: 24) {
  98. PulsingRecordButton {
  99. beginNewRecording()
  100. }
  101. Button {
  102. beginNewRecording()
  103. } label: {
  104. Text("开始")
  105. .font(.system(size: 16, weight: .medium))
  106. .foregroundStyle(Color.primary.opacity(0.88))
  107. .tracking(0.4)
  108. }
  109. .buttonStyle(.plain)
  110. }
  111. }
  112. // MARK: - Technical Grid Background
  113. private var businessGrid: some View {
  114. Canvas { context, size in
  115. let step: CGFloat = 40
  116. let cols = Int(size.width / step)
  117. let rows = Int(size.height / step)
  118. for col in 0...cols {
  119. let x = CGFloat(col) * step
  120. var path = Path()
  121. path.move(to: CGPoint(x: x, y: 0))
  122. path.addLine(to: CGPoint(x: x, y: size.height))
  123. context.stroke(path, with: .color(Color.primary.opacity(0.015)), lineWidth: 0.5)
  124. }
  125. for row in 0...rows {
  126. let y = CGFloat(row) * step
  127. var path = Path()
  128. path.move(to: CGPoint(x: 0, y: y))
  129. path.addLine(to: CGPoint(x: size.width, y: y))
  130. context.stroke(path, with: .color(Color.primary.opacity(0.015)), lineWidth: 0.5)
  131. }
  132. }
  133. .ignoresSafeArea()
  134. .allowsHitTesting(false)
  135. }
  136. // MARK: - Actions
  137. private func beginNewRecording() {
  138. if connectedSparkDevices.isEmpty {
  139. createSessionAndNavigate(source: .iPhone)
  140. } else {
  141. showRecordingSourcePicker = true
  142. }
  143. }
  144. private func createSessionAndNavigate(source: RecordingSourceChoice) {
  145. let formatter = DateFormatter()
  146. formatter.dateFormat = "yyyyMMdd_HHmm"
  147. let title = "\(formatter.string(from: Date()))_现场记录"
  148. let session = CelestiaSession(title: title)
  149. modelContext.insert(session)
  150. try? modelContext.save()
  151. RecordingRecoveryStore.begin(sessionID: session.id, source: source)
  152. HapticManager.trigger(.recordStart)
  153. activeRecording = ActiveRecordingPresentation(
  154. session: session,
  155. source: source,
  156. initialDuration: 0,
  157. initialAction: nil
  158. )
  159. }
  160. private func restoreActiveRecordingIfNeeded(
  161. sessionID requestedSessionID: UUID? = nil,
  162. action: RecordingLiveActivityAction? = nil
  163. ) {
  164. guard activeRecording == nil else { return }
  165. let persistedID = RecordingRecoveryStore.activeSessionID
  166. let activityID = RecordingLiveActivityManager.shared.recoverableSessionID
  167. let session: CelestiaSession?
  168. if let requestedSessionID {
  169. // An explicit Live Activity action must never fall through to a
  170. // different unfinished recording (especially for the stop action).
  171. session = sessions.first {
  172. $0.id == requestedSessionID && $0.endTime == nil
  173. }
  174. } else {
  175. session = [persistedID, activityID]
  176. .compactMap { $0 }
  177. .compactMap { candidateID in
  178. sessions.first {
  179. $0.id == candidateID && $0.endTime == nil
  180. }
  181. }
  182. .first
  183. ?? sessions.first {
  184. $0.endTime == nil
  185. && Date().timeIntervalSince($0.startTime) < 24 * 60 * 60
  186. }
  187. }
  188. guard let session else {
  189. if requestedSessionID == nil, persistedID != nil {
  190. RecordingRecoveryStore.clear()
  191. }
  192. return
  193. }
  194. let source = RecordingRecoveryStore.source(for: session.id) ?? .iPhone
  195. RecordingRecoveryStore.begin(sessionID: session.id, source: source, preservingSegment: true)
  196. Task { @MainActor in
  197. await recoverPendingSegment(for: session)
  198. let duration = await recordedDuration(for: session.localAudioPath)
  199. guard activeRecording == nil, session.endTime == nil else { return }
  200. activeRecording = ActiveRecordingPresentation(
  201. session: session,
  202. source: source,
  203. initialDuration: duration,
  204. initialAction: action
  205. )
  206. }
  207. }
  208. private func recoverPendingSegment(for session: CelestiaSession) async {
  209. guard let pendingPath = RecordingRecoveryStore.pendingSegmentPath(for: session.id),
  210. let pendingURL = AudioPathHelper.resolveURL(for: pendingPath) else {
  211. return
  212. }
  213. let existingURL = AudioPathHelper.resolveURL(for: session.localAudioPath)
  214. if existingURL?.standardizedFileURL == pendingURL.standardizedFileURL {
  215. RecordingRecoveryStore.clearPendingSegment(for: session.id)
  216. return
  217. }
  218. let recoveredURL = await AudioMerger.mergeAudioFiles(
  219. firstURL: existingURL,
  220. secondURL: pendingURL
  221. )
  222. session.localAudioPath = AudioPathHelper.relativePath(from: recoveredURL.path)
  223. session.markContentModified()
  224. try? modelContext.save()
  225. RecordingRecoveryStore.clearPendingSegment(for: session.id)
  226. }
  227. private func recordedDuration(for path: String?) async -> TimeInterval {
  228. guard let url = AudioPathHelper.resolveURL(for: path) else { return 0 }
  229. let asset = AVURLAsset(url: url)
  230. guard let duration = try? await asset.load(.duration) else { return 0 }
  231. let seconds = duration.seconds
  232. return seconds.isFinite && seconds > 0 ? seconds : 0
  233. }
  234. private func handleRecordingURL(_ url: URL) {
  235. guard let route = RecordingLiveActivityRoute(url: url) else { return }
  236. restoreActiveRecordingIfNeeded(
  237. sessionID: route.sessionID,
  238. action: route.action
  239. )
  240. }
  241. private var connectedSparkDevices: [BoundDevice] {
  242. guard let userID = authManager.currentUser?.id else { return [] }
  243. return bleManager.connectedDevices(forUserId: userID)
  244. .sorted { $0.boundAt < $1.boundAt }
  245. }
  246. // MARK: - Helpers
  247. private var currentDateFormatted: String {
  248. let formatter = DateFormatter()
  249. formatter.locale = Locale(identifier: "zh_Hans")
  250. formatter.dateFormat = "yyyy年M月d日 EEEE"
  251. return formatter.string(from: Date())
  252. }
  253. }
  254. private struct ActiveRecordingPresentation: Identifiable {
  255. let id = UUID()
  256. let session: CelestiaSession
  257. let source: RecordingSourceChoice
  258. let initialDuration: TimeInterval
  259. let initialAction: RecordingLiveActivityAction?
  260. }
  261. enum RecordingLiveActivityAction: String {
  262. case photo
  263. case note
  264. case togglePause = "toggle-pause"
  265. case stop
  266. }
  267. struct RecordingLiveActivityRoute {
  268. let sessionID: UUID
  269. let action: RecordingLiveActivityAction?
  270. init?(url: URL) {
  271. guard url.scheme == "celestiatrace", url.host == "recording" else { return nil }
  272. let components = url.pathComponents.filter { $0 != "/" }
  273. guard let first = components.first, let sessionID = UUID(uuidString: first) else {
  274. return nil
  275. }
  276. self.sessionID = sessionID
  277. self.action = components.dropFirst().first.flatMap(RecordingLiveActivityAction.init(rawValue:))
  278. }
  279. }
  280. enum RecordingRecoveryStore {
  281. private static let defaults = UserDefaults.standard
  282. private static let sessionIDKey = "activeRecording.sessionID"
  283. private static let sourceKindKey = "activeRecording.sourceKind"
  284. private static let sourceDeviceIDKey = "activeRecording.sourceDeviceID"
  285. private static let sourceNameKey = "activeRecording.sourceName"
  286. private static let pendingSegmentPathKey = "activeRecording.pendingSegmentPath"
  287. static var activeSessionID: UUID? {
  288. defaults.string(forKey: sessionIDKey).flatMap(UUID.init(uuidString:))
  289. }
  290. static func begin(
  291. sessionID: UUID,
  292. source: RecordingSourceChoice,
  293. preservingSegment: Bool = false
  294. ) {
  295. if !preservingSegment || activeSessionID != sessionID {
  296. defaults.removeObject(forKey: pendingSegmentPathKey)
  297. }
  298. defaults.set(sessionID.uuidString, forKey: sessionIDKey)
  299. switch source {
  300. case .iPhone:
  301. defaults.set("iphone", forKey: sourceKindKey)
  302. defaults.removeObject(forKey: sourceDeviceIDKey)
  303. defaults.removeObject(forKey: sourceNameKey)
  304. case .spark(let deviceID, let displayName):
  305. defaults.set("spark", forKey: sourceKindKey)
  306. defaults.set(deviceID, forKey: sourceDeviceIDKey)
  307. defaults.set(displayName, forKey: sourceNameKey)
  308. }
  309. }
  310. static func source(for sessionID: UUID) -> RecordingSourceChoice? {
  311. guard activeSessionID == sessionID else { return nil }
  312. switch defaults.string(forKey: sourceKindKey) {
  313. case "iphone":
  314. return .iPhone
  315. case "spark":
  316. guard let deviceID = defaults.string(forKey: sourceDeviceIDKey),
  317. let name = defaults.string(forKey: sourceNameKey) else { return nil }
  318. return .spark(deviceID: deviceID, displayName: name)
  319. default:
  320. return nil
  321. }
  322. }
  323. static func setPendingSegment(_ url: URL, for sessionID: UUID) {
  324. guard activeSessionID == sessionID else { return }
  325. defaults.set(AudioPathHelper.relativePath(from: url.path), forKey: pendingSegmentPathKey)
  326. }
  327. static func pendingSegmentPath(for sessionID: UUID) -> String? {
  328. guard activeSessionID == sessionID else { return nil }
  329. return defaults.string(forKey: pendingSegmentPathKey)
  330. }
  331. static func clearPendingSegment(for sessionID: UUID) {
  332. guard activeSessionID == sessionID else { return }
  333. defaults.removeObject(forKey: pendingSegmentPathKey)
  334. }
  335. static func clear(sessionID: UUID? = nil) {
  336. if let sessionID, activeSessionID != sessionID { return }
  337. [
  338. sessionIDKey,
  339. sourceKindKey,
  340. sourceDeviceIDKey,
  341. sourceNameKey,
  342. pendingSegmentPathKey
  343. ].forEach(defaults.removeObject(forKey:))
  344. }
  345. }
  346. // MARK: - Recording source picker
  347. /// Shared by new recordings and continuation recordings. When at least one
  348. /// connected Spark exists, the first Spark is selected by default.
  349. struct RecordingSourcePickerView: View {
  350. @Environment(\.dismiss) private var dismiss
  351. let devices: [BoundDevice]
  352. let onConfirm: (RecordingSourceChoice) -> Void
  353. @State private var selection: RecordingSourceChoice
  354. init(devices: [BoundDevice], onConfirm: @escaping (RecordingSourceChoice) -> Void) {
  355. self.devices = devices
  356. self.onConfirm = onConfirm
  357. if let first = devices.first {
  358. _selection = State(initialValue: .spark(deviceID: first.id, displayName: first.name))
  359. } else {
  360. _selection = State(initialValue: .iPhone)
  361. }
  362. }
  363. var body: some View {
  364. NavigationStack {
  365. ZStack {
  366. Color.spaceBlack.ignoresSafeArea()
  367. VStack(alignment: .leading, spacing: 14) {
  368. Text("选择本次现场记录使用的录音设备")
  369. .font(.system(size: 13))
  370. .foregroundStyle(Color.secondary)
  371. ForEach(Array(devices.enumerated()), id: \.element.id) { index, device in
  372. sourceRow(
  373. source: .spark(deviceID: device.id, displayName: device.name),
  374. title: device.name,
  375. subtitle: sparkSubtitle(device),
  376. badge: index == 0 ? "默认" : nil
  377. )
  378. }
  379. sourceRow(
  380. source: .iPhone,
  381. title: "iPhone 麦克风",
  382. subtitle: "使用手机内置或当前系统音频输入",
  383. badge: devices.isEmpty ? "默认" : nil
  384. )
  385. Button {
  386. onConfirm(selection)
  387. } label: {
  388. Text("开始现场记录")
  389. .font(.system(size: 14, weight: .semibold))
  390. .foregroundStyle(Color.spaceBlack)
  391. .frame(maxWidth: .infinity)
  392. .padding(.vertical, 12)
  393. .background(Color.primary)
  394. .cornerRadius(8)
  395. }
  396. .padding(.top, 8)
  397. Spacer()
  398. }
  399. .padding(20)
  400. }
  401. .navigationTitle("录音设备")
  402. .navigationBarTitleDisplayMode(.inline)
  403. .toolbar {
  404. ToolbarItem(placement: .cancellationAction) {
  405. Button("取消") { dismiss() }
  406. .foregroundStyle(Color.secondary)
  407. }
  408. }
  409. }
  410. .presentationDetents([.medium, .large])
  411. .presentationBackground(Color.spaceBlack)
  412. }
  413. private func sourceRow(
  414. source: RecordingSourceChoice,
  415. title: String,
  416. subtitle: String,
  417. badge: String?
  418. ) -> some View {
  419. Button {
  420. selection = source
  421. } label: {
  422. HStack(spacing: 12) {
  423. Image(systemName: source.systemImage)
  424. .font(.system(size: 19, weight: .light))
  425. .foregroundStyle(Color.primary)
  426. .frame(width: 34)
  427. VStack(alignment: .leading, spacing: 4) {
  428. HStack(spacing: 7) {
  429. Text(title)
  430. .font(.system(size: 14, weight: .semibold))
  431. .foregroundStyle(Color.primary)
  432. if let badge {
  433. Text(badge)
  434. .font(.system(size: 9, weight: .bold))
  435. .foregroundStyle(Color.spaceBlack)
  436. .padding(.horizontal, 6)
  437. .padding(.vertical, 2)
  438. .background(Color.primary)
  439. .cornerRadius(4)
  440. }
  441. }
  442. Text(subtitle)
  443. .font(.system(size: 11))
  444. .foregroundStyle(Color.secondary)
  445. }
  446. Spacer()
  447. Image(systemName: selection == source ? "checkmark.circle.fill" : "circle")
  448. .font(.system(size: 18))
  449. .foregroundStyle(selection == source ? Color.primary : Color.secondary)
  450. }
  451. .padding(14)
  452. .background(Color.cardBackground.opacity(selection == source ? 0.55 : 0.25))
  453. .businessBorder(cornerRadius: 10)
  454. }
  455. .buttonStyle(.plain)
  456. }
  457. private func sparkSubtitle(_ device: BoundDevice) -> String {
  458. var parts = ["已连接"]
  459. if let battery = device.batteryLevel { parts.append("电量 \(battery)%") }
  460. if let free = device.freeStorageMB { parts.append("剩余 \(free) MB") }
  461. return parts.joined(separator: " · ")
  462. }
  463. }
  464. // MARK: - Preview
  465. #Preview {
  466. HomeView()
  467. .modelContainer(for: CelestiaSession.self, inMemory: true)
  468. }