HomeView.swift 21 KB

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