SessionDetailView.swift 42 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146
  1. import SwiftUI
  2. import SwiftData
  3. import UIKit
  4. // MARK: - SessionDetailView
  5. /// The session detail & playback screen.
  6. /// Shows session info, multi-track timeline, playback controls,
  7. /// and a chronological feed of all events.
  8. /// Redesigned to use a minimalist wireframe business style.
  9. struct SessionDetailView: View {
  10. @Environment(\.modelContext) private var modelContext
  11. @ObservedObject private var bleManager: BLEManager = .shared
  12. @ObservedObject private var authManager: AuthManager = .shared
  13. @ObservedObject private var syncManager: SyncManager = .shared
  14. @ObservedObject private var networkMonitor: NetworkStatusMonitor = .shared
  15. let session: CelestiaSession
  16. @State private var playbackVM = PlaybackViewModel()
  17. @State private var navigateToRecording = false
  18. @State private var detector = RecordingEnvironmentDetector()
  19. @State private var showPrepAlert = false
  20. @State private var activeWarningMsg = ""
  21. @State private var showRecordingSourcePicker = false
  22. @State private var selectedRecordingSource: RecordingSourceChoice = .iPhone
  23. @State private var recordName = ""
  24. @State private var showRecordNameEditor = false
  25. @State private var showSessionInfo = false
  26. @State private var showShareUnavailableAlert = false
  27. @State private var audioShareItem: AudioShareItem?
  28. @State private var preparedShareDirectoryURL: URL?
  29. @State private var pendingTimelineTimeMs: Double = 0
  30. @State private var noteText = ""
  31. @State private var showNoteEditor = false
  32. @State private var showPhotoActionDialog = false
  33. @State private var showImagePicker = false
  34. @State private var imagePickerSource: UIImagePickerController.SourceType = .photoLibrary
  35. @State private var selectedTimelineEvent: CelestiaTimelineEvent?
  36. @State private var timelineEditError: String?
  37. @State private var showSyncAuthPrompt = false
  38. @State private var showAuthModal = false
  39. @State private var showLargeSyncConfirmation = false
  40. @State private var hasApprovedLargeTransfer = false
  41. @State private var autoSyncTask: Task<Void, Never>?
  42. var body: some View {
  43. ZStack {
  44. Color.spaceBlack.ignoresSafeArea()
  45. ScrollView {
  46. VStack(spacing: 20) {
  47. detailHeader
  48. .padding(.horizontal, 16)
  49. .padding(.top, 12)
  50. // Section 1: Multi-Track Timeline
  51. timelineSection
  52. .padding(.horizontal, 16)
  53. // Section 2: Playback Controls
  54. playbackControls
  55. .padding(.horizontal, 16)
  56. // Section 3: Chrono Feed
  57. chronoFeedSection
  58. .padding(.horizontal, 16)
  59. .padding(.bottom, 32)
  60. }
  61. }
  62. }
  63. .navigationBarTitleDisplayMode(.inline)
  64. .toolbar {
  65. ToolbarItemGroup(placement: .topBarTrailing) {
  66. Button {
  67. showSessionInfo = true
  68. } label: {
  69. Image(systemName: "info.circle")
  70. .font(.system(size: 16, weight: .regular))
  71. .foregroundStyle(Color.primary)
  72. }
  73. .accessibilityLabel("记录信息")
  74. .popover(isPresented: $showSessionInfo, arrowEdge: .top) {
  75. sessionInfoCard
  76. .padding(16)
  77. .frame(idealWidth: 360)
  78. .presentationCompactAdaptation(.popover)
  79. }
  80. Button {
  81. prepareAudioForSharing()
  82. } label: {
  83. Image(systemName: "square.and.arrow.up")
  84. .font(.system(size: 16, weight: .regular))
  85. .foregroundStyle(Color.primary)
  86. }
  87. .accessibilityLabel("分享录音")
  88. }
  89. }
  90. .sheet(item: $audioShareItem, onDismiss: cleanupSharedAudioCopy) { item in
  91. AudioShareSheet(fileURL: item.fileURL)
  92. }
  93. .alert("无法分享录音", isPresented: $showShareUnavailableAlert) {
  94. Button("知道了", role: .cancel) {}
  95. } message: {
  96. Text("当前记录没有可用的本地录音文件,请先完成录制或同步录音。")
  97. }
  98. .alert("无法完成编辑", isPresented: Binding(
  99. get: { timelineEditError != nil },
  100. set: { if !$0 { timelineEditError = nil } }
  101. )) {
  102. Button("知道了", role: .cancel) {}
  103. } message: {
  104. Text(timelineEditError ?? "请稍后重试。")
  105. }
  106. .alert("录音环境提醒", isPresented: $showPrepAlert) {
  107. Button("继续录制", role: .none) {
  108. chooseSourceAndContinue()
  109. }
  110. Button("取消", role: .cancel) {}
  111. } message: {
  112. Text(activeWarningMsg + "\n\n另外请手动确认:\n1. 手机侧边静音开关已拨至红色(静音状态)\n2. 已关闭可能在此期间响起的系统闹钟")
  113. }
  114. .fullScreenCover(isPresented: $navigateToRecording) {
  115. ActiveRecordingView(
  116. session: session,
  117. initialDuration: Double(session.durationMs) / 1000.0,
  118. recordingSource: selectedRecordingSource
  119. ) {
  120. playbackVM.totalDurationMs = Double(session.durationMs)
  121. playbackVM.preparePlayer(path: session.localAudioPath, durationMs: Double(session.durationMs))
  122. playbackVM.analyzeAudio(audioURL: AudioPathHelper.resolveURL(for: session.localAudioPath))
  123. scheduleAutoSync()
  124. }
  125. }
  126. .sheet(isPresented: $showRecordingSourcePicker) {
  127. RecordingSourcePickerView(devices: connectedSparkDevices) { source in
  128. selectedRecordingSource = source
  129. showRecordingSourcePicker = false
  130. navigateToRecording = true
  131. }
  132. }
  133. .sheet(isPresented: $showRecordNameEditor) {
  134. RecordNameEditorSheet(recordName: $recordName) {
  135. saveRecordName()
  136. } onCancel: {
  137. recordName = session.title
  138. showRecordNameEditor = false
  139. }
  140. }
  141. .sheet(isPresented: $showNoteEditor) {
  142. timelineNoteEditor
  143. }
  144. .sheet(isPresented: $showImagePicker) {
  145. ImagePicker(sourceType: imagePickerSource) { image in
  146. addPhoto(image, at: pendingTimelineTimeMs)
  147. }
  148. }
  149. .sheet(item: $selectedTimelineEvent) { event in
  150. TimelineEventDetailSheet(event: event) {
  151. deleteTimelineEvent(event)
  152. }
  153. }
  154. .sheet(isPresented: $showSyncAuthPrompt) {
  155. SyncAuthPromptModal {
  156. showSyncAuthPrompt = false
  157. showAuthModal = true
  158. }
  159. }
  160. .sheet(isPresented: $showAuthModal) {
  161. AuthModalView()
  162. }
  163. .alert("同步较大文件", isPresented: $showLargeSyncConfirmation) {
  164. Button("开始同步") {
  165. hasApprovedLargeTransfer = true
  166. performSessionSync()
  167. }
  168. Button("取消", role: .cancel) {}
  169. } message: {
  170. Text(largeTransferMessage)
  171. }
  172. .confirmationDialog(
  173. "在 \(formattedTimelineTime(pendingTimelineTimeMs)) 添加图片",
  174. isPresented: $showPhotoActionDialog,
  175. titleVisibility: .visible
  176. ) {
  177. if UIImagePickerController.isSourceTypeAvailable(.camera) {
  178. Button("拍照") {
  179. imagePickerSource = .camera
  180. showImagePicker = true
  181. }
  182. }
  183. Button("从相册选择") {
  184. imagePickerSource = .photoLibrary
  185. showImagePicker = true
  186. }
  187. Button("取消", role: .cancel) {}
  188. } message: {
  189. Text("图片会添加到当前时间位置。")
  190. }
  191. .onAppear {
  192. playbackVM.totalDurationMs = Double(session.durationMs)
  193. playbackVM.preparePlayer(path: session.localAudioPath, durationMs: Double(session.durationMs))
  194. playbackVM.analyzeAudio(audioURL: AudioPathHelper.resolveURL(for: session.localAudioPath))
  195. if session.syncState == .pending || session.syncState == .localOnly {
  196. scheduleAutoSync()
  197. }
  198. }
  199. .onChange(of: session.syncStateRaw) { _, newValue in
  200. guard let state = SessionSyncState(rawValue: newValue),
  201. state == .pending || state == .localOnly else { return }
  202. scheduleAutoSync()
  203. }
  204. .onDisappear {
  205. autoSyncTask?.cancel()
  206. }
  207. }
  208. private var detailHeader: some View {
  209. HStack(spacing: 12) {
  210. Button {
  211. recordName = session.title
  212. showRecordNameEditor = true
  213. } label: {
  214. Text(session.title)
  215. .font(.system(size: 22, weight: .semibold))
  216. .lineLimit(1)
  217. .minimumScaleFactor(0.75)
  218. .foregroundStyle(Color.primary)
  219. .contentShape(Rectangle())
  220. }
  221. .buttonStyle(.plain)
  222. .accessibilityLabel("编辑记录名称")
  223. .layoutPriority(1)
  224. Spacer(minLength: 0)
  225. compactSyncButton
  226. .fixedSize()
  227. }
  228. .frame(maxWidth: .infinity, alignment: .leading)
  229. }
  230. private func prepareContinuation() {
  231. playbackVM.pause()
  232. detector.checkEnvironment()
  233. if let firstWarning = detector.activeWarnings.first {
  234. activeWarningMsg = firstWarning
  235. showPrepAlert = true
  236. } else {
  237. chooseSourceAndContinue()
  238. }
  239. }
  240. private func chooseSourceAndContinue() {
  241. if connectedSparkDevices.isEmpty {
  242. selectedRecordingSource = .iPhone
  243. navigateToRecording = true
  244. } else {
  245. showRecordingSourcePicker = true
  246. }
  247. }
  248. private func saveRecordName() {
  249. let trimmedName = recordName.trimmingCharacters(in: .whitespacesAndNewlines)
  250. guard !trimmedName.isEmpty else { return }
  251. recordName = trimmedName
  252. if session.title != trimmedName {
  253. session.title = trimmedName
  254. session.isSynced = false
  255. session.syncState = .pending
  256. try? modelContext.save()
  257. scheduleAutoSync()
  258. }
  259. showRecordNameEditor = false
  260. }
  261. private func prepareAudioForSharing() {
  262. guard let sourceURL = AudioPathHelper.resolveURL(for: session.localAudioPath) else {
  263. showShareUnavailableAlert = true
  264. return
  265. }
  266. let shareDirectory = FileManager.default.temporaryDirectory
  267. .appendingPathComponent("CelestiaTraceShare", isDirectory: true)
  268. .appendingPathComponent(UUID().uuidString, isDirectory: true)
  269. let fileExtension = sourceURL.pathExtension
  270. let fileName = sanitizedShareFileName(from: session.title)
  271. let destinationURL = shareDirectory
  272. .appendingPathComponent(fileName)
  273. .appendingPathExtension(fileExtension)
  274. do {
  275. try FileManager.default.createDirectory(
  276. at: shareDirectory,
  277. withIntermediateDirectories: true
  278. )
  279. try FileManager.default.copyItem(at: sourceURL, to: destinationURL)
  280. preparedShareDirectoryURL = shareDirectory
  281. audioShareItem = AudioShareItem(fileURL: destinationURL)
  282. } catch {
  283. audioShareItem = nil
  284. preparedShareDirectoryURL = nil
  285. showShareUnavailableAlert = true
  286. }
  287. }
  288. private func sanitizedShareFileName(from title: String) -> String {
  289. let invalidCharacters = CharacterSet(charactersIn: "/:\\?%*|\"<>")
  290. .union(.controlCharacters)
  291. .union(.newlines)
  292. let components = title.components(separatedBy: invalidCharacters)
  293. let sanitized = components
  294. .joined(separator: "-")
  295. .trimmingCharacters(in: CharacterSet.whitespacesAndNewlines.union(
  296. CharacterSet(charactersIn: ".")
  297. ))
  298. return sanitized.isEmpty ? "现场记录" : sanitized
  299. }
  300. private func cleanupSharedAudioCopy() {
  301. guard let preparedShareDirectoryURL else { return }
  302. try? FileManager.default.removeItem(at: preparedShareDirectoryURL)
  303. self.preparedShareDirectoryURL = nil
  304. }
  305. private var connectedSparkDevices: [BoundDevice] {
  306. guard let userID = authManager.currentUser?.id else { return [] }
  307. return bleManager.connectedDevices(forUserId: userID)
  308. .sorted { $0.boundAt < $1.boundAt }
  309. }
  310. // MARK: - Session Info Card
  311. private var sessionInfoCard: some View {
  312. VStack(spacing: 14) {
  313. // Date range
  314. HStack {
  315. VStack(alignment: .leading, spacing: 4) {
  316. Label {
  317. Text("开始")
  318. .font(.system(size: 10, weight: .medium))
  319. .foregroundStyle(Color.secondary)
  320. } icon: {
  321. Image(systemName: "play.circle")
  322. .font(.system(size: 11))
  323. .foregroundStyle(Color.secondary)
  324. }
  325. VStack(alignment: .leading, spacing: 1) {
  326. Text(Self.chineseDateFormatter.string(from: session.startTime))
  327. Text(Self.chineseTimeFormatter.string(from: session.startTime))
  328. }
  329. .font(.system(size: 13, weight: .medium))
  330. .foregroundStyle(Color.primary)
  331. }
  332. Spacer()
  333. VStack(alignment: .trailing, spacing: 4) {
  334. Label {
  335. Text("结束")
  336. .font(.system(size: 10, weight: .medium))
  337. .foregroundStyle(Color.secondary)
  338. } icon: {
  339. Image(systemName: "stop.circle")
  340. .font(.system(size: 11))
  341. .foregroundStyle(Color.secondary)
  342. }
  343. if let endTime = session.endTime {
  344. VStack(alignment: .trailing, spacing: 1) {
  345. Text(Self.chineseDateFormatter.string(from: endTime))
  346. Text(Self.chineseTimeFormatter.string(from: endTime))
  347. }
  348. .font(.system(size: 13, weight: .medium))
  349. .foregroundStyle(Color.primary)
  350. } else {
  351. Text("进行中")
  352. .font(.system(size: 13, weight: .medium))
  353. .foregroundStyle(Color.primary)
  354. }
  355. }
  356. }
  357. Divider()
  358. .background(Color.lineBorder)
  359. // Stats row
  360. HStack(spacing: 0) {
  361. infoStat(
  362. icon: "timer",
  363. label: "总时长",
  364. value: session.durationFormatted
  365. )
  366. infoStat(
  367. icon: "camera",
  368. label: "图片",
  369. value: "\(session.photoCount)"
  370. )
  371. infoStat(
  372. icon: "doc.text",
  373. label: "笔记",
  374. value: "\(session.noteCount)"
  375. )
  376. }
  377. Divider()
  378. .background(Color.lineBorder)
  379. HStack(spacing: 8) {
  380. Label {
  381. Text("版本号")
  382. .font(.system(size: 11, weight: .medium))
  383. } icon: {
  384. Image(systemName: "number")
  385. .font(.system(size: 11, weight: .medium))
  386. }
  387. .foregroundStyle(Color.secondary)
  388. Spacer()
  389. Text("v\(session.localRevision)")
  390. .font(.system(size: 12, weight: .semibold, design: .monospaced))
  391. .foregroundStyle(Color.primary)
  392. }
  393. Divider()
  394. .background(Color.lineBorder)
  395. Button {
  396. showSessionInfo = false
  397. DispatchQueue.main.async {
  398. prepareContinuation()
  399. }
  400. } label: {
  401. HStack(spacing: 8) {
  402. Image(systemName: "record.circle")
  403. .font(.system(size: 14, weight: .medium))
  404. Text("续录")
  405. .font(.system(size: 13, weight: .semibold))
  406. }
  407. .foregroundStyle(Color.primary)
  408. .frame(maxWidth: .infinity)
  409. .padding(.vertical, 10)
  410. .background(Color.cardBackground.opacity(0.2))
  411. .businessBorder(cornerRadius: 8)
  412. }
  413. .buttonStyle(.plain)
  414. .accessibilityLabel("继续现场记录")
  415. }
  416. .padding(14)
  417. .background(Color.cardBackground.opacity(0.15))
  418. .businessBorder(cornerRadius: 10)
  419. }
  420. private static let chineseDateFormatter: DateFormatter = {
  421. let formatter = DateFormatter()
  422. formatter.locale = Locale(identifier: "zh_CN")
  423. formatter.calendar = Calendar(identifier: .gregorian)
  424. formatter.dateFormat = "yyyy年M月d日"
  425. return formatter
  426. }()
  427. private static let chineseTimeFormatter: DateFormatter = {
  428. let formatter = DateFormatter()
  429. formatter.locale = Locale(identifier: "zh_CN")
  430. formatter.calendar = Calendar(identifier: .gregorian)
  431. formatter.dateFormat = "HH:mm:ss"
  432. return formatter
  433. }()
  434. private var compactSyncButton: some View {
  435. VStack(spacing: 3) {
  436. Button {
  437. if isThisSessionSyncing {
  438. syncManager.pauseSync(sessionID: session.id)
  439. } else {
  440. requestSessionSync(isAutomatic: false)
  441. }
  442. } label: {
  443. Group {
  444. if isDisplayedSyncing {
  445. ZStack {
  446. Circle()
  447. .stroke(Color.secondary.opacity(0.22), lineWidth: 2)
  448. Circle()
  449. .trim(from: 0, to: max(sessionSyncProgress, 0.04))
  450. .stroke(
  451. Color.lessCosmosGold,
  452. style: StrokeStyle(lineWidth: 2, lineCap: .round)
  453. )
  454. .rotationEffect(.degrees(-90))
  455. TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { context in
  456. Image(systemName: "arrow.triangle.2.circlepath")
  457. .font(.system(size: 9, weight: .semibold))
  458. .foregroundStyle(Color.lessCosmosGold)
  459. .rotationEffect(syncRotation(at: context.date))
  460. }
  461. }
  462. } else if isDisplayedSynced {
  463. Image(systemName: "checkmark.icloud.fill")
  464. .symbolRenderingMode(.hierarchical)
  465. .foregroundStyle(Color.lessCosmosGold)
  466. } else {
  467. Image(systemName: "icloud.and.arrow.up")
  468. .symbolRenderingMode(.hierarchical)
  469. .foregroundStyle(Color.secondary)
  470. }
  471. }
  472. .font(.system(size: 17, weight: .medium))
  473. .frame(width: 32, height: 32)
  474. .contentShape(Rectangle())
  475. }
  476. .buttonStyle(.plain)
  477. .disabled(syncManager.isSyncing && !isThisSessionSyncing)
  478. .accessibilityLabel(isThisSessionSyncing ? "暂停同步" : "同步记录")
  479. .accessibilityValue(
  480. isDisplayedSyncing
  481. ? "\(syncStatusText),\(Int((sessionSyncProgress * 100).rounded()))%"
  482. : syncStatusText
  483. )
  484. Text(syncStatusText)
  485. .font(.system(size: 9, weight: .medium))
  486. .foregroundStyle(syncStatusColor)
  487. }
  488. }
  489. private var isThisSessionSyncing: Bool {
  490. syncManager.isSyncing && syncManager.activeSessionID == session.id
  491. }
  492. private var isDisplayedSyncing: Bool {
  493. isThisSessionSyncing || session.syncState == .syncing
  494. }
  495. private var isDisplayedSynced: Bool {
  496. !isDisplayedSyncing && session.isSynced && session.syncState == .synced
  497. }
  498. private var sessionSyncProgress: Double {
  499. if isThisSessionSyncing {
  500. return min(max(syncManager.syncProgress, 0), 1)
  501. }
  502. return session.syncState == .synced ? 1 : 0
  503. }
  504. private var syncStatusText: String {
  505. if isDisplayedSyncing { return "正在同步" }
  506. return isDisplayedSynced ? "已同步" : "未同步"
  507. }
  508. private var syncStatusColor: Color {
  509. isDisplayedSynced || isDisplayedSyncing ? Color.lessCosmosGold : Color.secondary
  510. }
  511. private func syncRotation(at date: Date) -> Angle {
  512. let cycle = date.timeIntervalSinceReferenceDate
  513. .truncatingRemainder(dividingBy: 1.2)
  514. return .degrees(cycle / 1.2 * 360)
  515. }
  516. private var largeTransferMessage: String {
  517. let bytes = syncManager.estimatedUploadBytes(for: session)
  518. let size = ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file)
  519. return "预计上传约 \(size),将占用约 \(size) \(networkMonitor.connectionName)流量。是否继续?"
  520. }
  521. private func requestSessionSync(isAutomatic: Bool) {
  522. guard !syncManager.isSyncing else { return }
  523. guard networkMonitor.isConnected else {
  524. if !isAutomatic {
  525. timelineEditError = "当前没有可用网络,请连接网络后重试。"
  526. }
  527. return
  528. }
  529. guard authManager.currentUser?.id != nil else {
  530. if !isAutomatic {
  531. showSyncAuthPrompt = true
  532. }
  533. return
  534. }
  535. let largeTransferThreshold: Int64 = 70 * 1_024 * 1_024
  536. if syncManager.estimatedUploadBytes(for: session) >= largeTransferThreshold,
  537. !hasApprovedLargeTransfer {
  538. showLargeSyncConfirmation = true
  539. return
  540. }
  541. performSessionSync()
  542. }
  543. private func performSessionSync() {
  544. guard let userID = authManager.currentUser?.id else { return }
  545. Task {
  546. _ = await syncManager.sync(
  547. sessions: [session],
  548. modelContext: modelContext,
  549. userID: userID
  550. )
  551. }
  552. }
  553. private func scheduleAutoSync() {
  554. guard session.endTime != nil,
  555. session.syncState != .conflict,
  556. authManager.currentUser?.id != nil else { return }
  557. autoSyncTask?.cancel()
  558. autoSyncTask = Task { @MainActor in
  559. try? await Task.sleep(for: .seconds(1.5))
  560. guard !Task.isCancelled else { return }
  561. requestSessionSync(isAutomatic: true)
  562. }
  563. }
  564. private func infoStat(icon: String, label: String, value: String) -> some View {
  565. VStack(spacing: 4) {
  566. Image(systemName: icon)
  567. .font(.system(size: 14, weight: .light))
  568. .foregroundStyle(Color.secondary)
  569. Text(value)
  570. .font(.system(size: 13, weight: .medium, design: .monospaced))
  571. .foregroundStyle(Color.primary)
  572. Text(label)
  573. .font(.system(size: 9, weight: .regular))
  574. .foregroundStyle(Color.secondary.opacity(0.8))
  575. }
  576. .frame(maxWidth: .infinity)
  577. }
  578. // MARK: - Timeline Section
  579. private var timelineSection: some View {
  580. VStack(alignment: .leading, spacing: 8) {
  581. MultiTrackTimeline(
  582. events: session.events,
  583. currentTimeMs: Binding(
  584. get: { playbackVM.currentPlaybackTimeMs },
  585. set: { playbackVM.seekTo(timeMs: $0) }
  586. ),
  587. totalDurationMs: playbackVM.totalDurationMs,
  588. waveformSamples: playbackVM.waveformSamples,
  589. silentRanges: playbackVM.silentRanges,
  590. onEmptyTrackTap: handleEmptyTrackTap,
  591. onEventTap: { event in
  592. playbackVM.seekTo(timeMs: Double(event.relativeTimeMs))
  593. selectedTimelineEvent = event
  594. HapticManager.trigger(.tapFeedback)
  595. }
  596. )
  597. .frame(height: 220)
  598. }
  599. }
  600. // MARK: - Playback Controls
  601. private var playbackControls: some View {
  602. VStack(spacing: 12) {
  603. // Time display
  604. HStack {
  605. Text(playbackVM.currentTimeFormatted)
  606. .font(.system(size: 12, weight: .regular, design: .monospaced))
  607. .foregroundStyle(Color.primary)
  608. Spacer()
  609. Text(totalTimeFormatted)
  610. .font(.system(size: 12, weight: .regular, design: .monospaced))
  611. .foregroundStyle(Color.secondary)
  612. }
  613. // Seek slider
  614. Slider(
  615. value: Binding(
  616. get: { playbackVM.progress },
  617. set: { newValue in
  618. playbackVM.seekTo(timeMs: newValue * playbackVM.totalDurationMs)
  619. }
  620. ),
  621. in: 0...1
  622. )
  623. .tint(Color.primary)
  624. // Play/Pause button
  625. HStack(spacing: 36) {
  626. // Rewind 10s
  627. Button {
  628. let target = max(0, playbackVM.currentPlaybackTimeMs - 10_000)
  629. playbackVM.seekTo(timeMs: target)
  630. } label: {
  631. Image(systemName: "gobackward.10")
  632. .font(.system(size: 18, weight: .light))
  633. .foregroundStyle(Color.secondary)
  634. }
  635. // Play/Pause (High-contrast minimalist button)
  636. Button {
  637. playbackVM.togglePlayback()
  638. HapticManager.trigger(.tapFeedback)
  639. } label: {
  640. ZStack {
  641. Circle()
  642. .fill(Color.primary)
  643. .frame(width: 48, height: 48)
  644. Image(systemName: playbackVM.isPlaying ? "pause.fill" : "play.fill")
  645. .font(.system(size: 16, weight: .bold))
  646. .foregroundStyle(Color.spaceBlack)
  647. }
  648. }
  649. // Forward 10s
  650. Button {
  651. let target = min(playbackVM.totalDurationMs, playbackVM.currentPlaybackTimeMs + 10_000)
  652. playbackVM.seekTo(timeMs: target)
  653. } label: {
  654. Image(systemName: "goforward.10")
  655. .font(.system(size: 18, weight: .light))
  656. .foregroundStyle(Color.secondary)
  657. }
  658. }
  659. .padding(.top, 4)
  660. Divider()
  661. .background(Color.lineBorder)
  662. .padding(.vertical, 4)
  663. HStack {
  664. Label {
  665. Text("跳过静音")
  666. .font(.system(size: 12, weight: .medium))
  667. .foregroundStyle(Color.primary)
  668. } icon: {
  669. Image(systemName: playbackVM.isSilenceSkipEnabled ? "waveform.badge.minus" : "waveform")
  670. .font(.system(size: 13))
  671. .foregroundStyle(playbackVM.isSilenceSkipEnabled ? Color.primary : Color.secondary)
  672. }
  673. Spacer()
  674. if playbackVM.isAnalyzingSilence {
  675. ProgressView()
  676. .scaleEffect(0.7)
  677. .frame(width: 16, height: 16)
  678. } else if !playbackVM.silentRanges.isEmpty {
  679. Text("检测到 \(playbackVM.silentRanges.count) 处静音")
  680. .font(.system(size: 11))
  681. .foregroundStyle(Color.secondary)
  682. }
  683. Toggle("", isOn: Binding(
  684. get: { playbackVM.isSilenceSkipEnabled },
  685. set: { playbackVM.isSilenceSkipEnabled = $0 }
  686. ))
  687. .toggleStyle(SwitchToggleStyle(tint: Color.primary))
  688. .labelsHidden()
  689. .scaleEffect(0.8)
  690. }
  691. }
  692. .padding(14)
  693. .background(Color.cardBackground.opacity(0.15))
  694. .businessBorder(cornerRadius: 10)
  695. }
  696. // MARK: - Chrono Feed
  697. private var chronoFeedSection: some View {
  698. VStack(alignment: .leading, spacing: 10) {
  699. sectionHeader(icon: "list.dash", title: "事件")
  700. let sorted = playbackVM.sortedEvents(from: session)
  701. if sorted.isEmpty {
  702. HStack {
  703. Spacer()
  704. VStack(spacing: 8) {
  705. Image(systemName: "tray")
  706. .font(.system(size: 24, weight: .light))
  707. .foregroundStyle(Color.secondary.opacity(0.3))
  708. Text("暂无会话事件")
  709. .font(.system(size: 12))
  710. .foregroundStyle(Color.secondary.opacity(0.5))
  711. }
  712. .padding(.vertical, 24)
  713. Spacer()
  714. }
  715. } else {
  716. LazyVStack(spacing: 8) {
  717. ForEach(sorted) { event in
  718. ChronoFeedRow(event: event) {
  719. playbackVM.seekTo(timeMs: event.relativeTimeMs)
  720. if event.eventType == "PHOTO"
  721. || event.eventType == "NOTE"
  722. || event.eventType == "MARKER" {
  723. selectedTimelineEvent = event
  724. }
  725. HapticManager.trigger(.tapFeedback)
  726. }
  727. }
  728. }
  729. }
  730. }
  731. }
  732. // MARK: - Helpers
  733. private func sectionHeader(icon: String, title: String) -> some View {
  734. HStack(spacing: 6) {
  735. Image(systemName: icon)
  736. .font(.system(size: 12, weight: .light))
  737. .foregroundStyle(Color.secondary)
  738. Text(title)
  739. .font(.system(size: 13, weight: .semibold))
  740. .foregroundStyle(Color.primary.opacity(0.8))
  741. }
  742. }
  743. private var totalTimeFormatted: String {
  744. let totalSeconds = Int(playbackVM.totalDurationMs / 1000)
  745. let hours = totalSeconds / 3600
  746. let minutes = (totalSeconds % 3600) / 60
  747. let seconds = totalSeconds % 60
  748. if hours > 0 {
  749. return String(format: "%d:%02d:%02d", hours, minutes, seconds)
  750. }
  751. return String(format: "%02d:%02d", minutes, seconds)
  752. }
  753. // MARK: - Timeline Editing
  754. private var timelineNoteEditor: some View {
  755. NavigationStack {
  756. ZStack {
  757. Color.spaceBlack.ignoresSafeArea()
  758. VStack(alignment: .leading, spacing: 16) {
  759. Label(
  760. "添加到 \(formattedTimelineTime(pendingTimelineTimeMs))",
  761. systemImage: "clock"
  762. )
  763. .font(.system(size: 12, weight: .medium))
  764. .foregroundStyle(Color.secondary)
  765. ZStack(alignment: .topLeading) {
  766. if noteText.isEmpty {
  767. Text("输入笔记内容…")
  768. .font(.system(size: 15))
  769. .foregroundStyle(Color.secondary.opacity(0.6))
  770. .padding(.horizontal, 18)
  771. .padding(.vertical, 16)
  772. .allowsHitTesting(false)
  773. }
  774. TextEditor(text: $noteText)
  775. .font(.system(size: 15))
  776. .foregroundStyle(Color.primary)
  777. .scrollContentBackground(.hidden)
  778. .padding(10)
  779. .frame(minHeight: 160)
  780. .background(Color.cardBackground.opacity(0.5))
  781. .businessBorder(cornerRadius: 8)
  782. }
  783. Spacer()
  784. }
  785. .padding(20)
  786. }
  787. .navigationTitle("添加笔记")
  788. .navigationBarTitleDisplayMode(.inline)
  789. .toolbar {
  790. ToolbarItem(placement: .cancellationAction) {
  791. Button("取消") {
  792. noteText = ""
  793. showNoteEditor = false
  794. }
  795. }
  796. ToolbarItem(placement: .confirmationAction) {
  797. Button("保存") {
  798. addNote(at: pendingTimelineTimeMs)
  799. }
  800. .disabled(noteText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
  801. }
  802. }
  803. }
  804. .presentationDetents([.medium])
  805. .presentationDragIndicator(.visible)
  806. .presentationBackground(Color.spaceBlack)
  807. }
  808. private func handleEmptyTrackTap(
  809. _ track: MultiTrackTimeline.EditableTrack,
  810. timeMs: Double
  811. ) {
  812. pendingTimelineTimeMs = min(max(timeMs, 0), playbackVM.totalDurationMs)
  813. playbackVM.seekTo(timeMs: pendingTimelineTimeMs)
  814. HapticManager.trigger(.tapFeedback)
  815. switch track {
  816. case .photo:
  817. showPhotoActionDialog = true
  818. case .note:
  819. noteText = ""
  820. showNoteEditor = true
  821. }
  822. }
  823. private func addNote(at timeMs: Double) {
  824. let trimmedText = noteText.trimmingCharacters(in: .whitespacesAndNewlines)
  825. guard !trimmedText.isEmpty else { return }
  826. let event = CelestiaTimelineEvent(
  827. relativeTimeMs: Int64(timeMs.rounded()),
  828. eventType: "NOTE"
  829. )
  830. event.textContent = trimmedText
  831. session.events.append(event)
  832. do {
  833. try saveTimelineChanges()
  834. noteText = ""
  835. showNoteEditor = false
  836. } catch {
  837. modelContext.rollback()
  838. timelineEditError = "笔记保存失败:\(error.localizedDescription)"
  839. }
  840. }
  841. private func addPhoto(_ image: UIImage, at timeMs: Double) {
  842. guard let data = image.jpegData(compressionQuality: 0.85),
  843. let documentsURL = FileManager.default.urls(
  844. for: .documentDirectory,
  845. in: .userDomainMask
  846. ).first else {
  847. timelineEditError = "无法读取所选图片。"
  848. return
  849. }
  850. let filename = "photo_\(UUID().uuidString).jpg"
  851. let fileURL = documentsURL.appendingPathComponent(filename)
  852. do {
  853. try data.write(to: fileURL, options: .atomic)
  854. let event = CelestiaTimelineEvent(
  855. relativeTimeMs: Int64(timeMs.rounded()),
  856. eventType: "PHOTO"
  857. )
  858. event.localFilePath = filename
  859. session.events.append(event)
  860. do {
  861. try saveTimelineChanges()
  862. } catch {
  863. modelContext.rollback()
  864. try? FileManager.default.removeItem(at: fileURL)
  865. throw error
  866. }
  867. } catch {
  868. timelineEditError = "图片保存失败:\(error.localizedDescription)"
  869. }
  870. }
  871. private func deleteTimelineEvent(_ event: CelestiaTimelineEvent) {
  872. let photoURL = event.eventType == "PHOTO"
  873. ? AudioPathHelper.resolveURL(for: event.localFilePath)
  874. : nil
  875. modelContext.delete(event)
  876. do {
  877. try saveTimelineChanges()
  878. if let photoURL {
  879. try? FileManager.default.removeItem(at: photoURL)
  880. }
  881. selectedTimelineEvent = nil
  882. } catch {
  883. modelContext.rollback()
  884. timelineEditError = "删除失败:\(error.localizedDescription)"
  885. }
  886. }
  887. private func saveTimelineChanges() throws {
  888. session.isSynced = false
  889. session.syncState = .pending
  890. try modelContext.save()
  891. scheduleAutoSync()
  892. }
  893. private func formattedTimelineTime(_ timeMs: Double) -> String {
  894. let totalSeconds = max(0, Int(timeMs / 1_000))
  895. let hours = totalSeconds / 3_600
  896. let minutes = (totalSeconds % 3_600) / 60
  897. let seconds = totalSeconds % 60
  898. if hours > 0 {
  899. return String(format: "%d:%02d:%02d", hours, minutes, seconds)
  900. }
  901. return String(format: "%02d:%02d", minutes, seconds)
  902. }
  903. }
  904. private struct TimelineEventDetailSheet: View {
  905. @Environment(\.dismiss) private var dismiss
  906. let event: CelestiaTimelineEvent
  907. let onDelete: () -> Void
  908. @State private var showDeleteConfirmation = false
  909. var body: some View {
  910. NavigationStack {
  911. ZStack {
  912. Color.spaceBlack.ignoresSafeArea()
  913. ScrollView {
  914. VStack(alignment: .leading, spacing: 16) {
  915. Label(event.relativeTimeFormatted, systemImage: "clock")
  916. .font(.system(size: 12, weight: .medium, design: .monospaced))
  917. .foregroundStyle(Color.secondary)
  918. eventContent
  919. Button(role: .destructive) {
  920. showDeleteConfirmation = true
  921. } label: {
  922. Label("删除这条记录", systemImage: "trash")
  923. .font(.system(size: 14, weight: .medium))
  924. .frame(maxWidth: .infinity)
  925. .padding(.vertical, 12)
  926. }
  927. .buttonStyle(.bordered)
  928. .tint(.red)
  929. }
  930. .padding(20)
  931. }
  932. }
  933. .navigationTitle(detailTitle)
  934. .navigationBarTitleDisplayMode(.inline)
  935. .toolbar {
  936. ToolbarItem(placement: .confirmationAction) {
  937. Button("完成") {
  938. dismiss()
  939. }
  940. }
  941. }
  942. .confirmationDialog(
  943. "确定删除这条记录吗?",
  944. isPresented: $showDeleteConfirmation,
  945. titleVisibility: .visible
  946. ) {
  947. Button("删除", role: .destructive) {
  948. onDelete()
  949. dismiss()
  950. }
  951. Button("取消", role: .cancel) {}
  952. } message: {
  953. Text(event.eventType == "PHOTO" ? "本地图片文件也会被删除,此操作无法撤销。" : "此操作无法撤销。")
  954. }
  955. }
  956. .presentationDetents(event.eventType == "PHOTO" ? [.medium, .large] : [.medium])
  957. .presentationDragIndicator(.visible)
  958. .presentationBackground(Color.spaceBlack)
  959. }
  960. @ViewBuilder
  961. private var eventContent: some View {
  962. if event.eventType == "PHOTO" {
  963. if let url = AudioPathHelper.resolveURL(for: event.localFilePath),
  964. let image = UIImage(contentsOfFile: url.path) {
  965. Image(uiImage: image)
  966. .resizable()
  967. .scaledToFit()
  968. .frame(maxWidth: .infinity)
  969. .clipShape(RoundedRectangle(cornerRadius: 10))
  970. .overlay {
  971. RoundedRectangle(cornerRadius: 10)
  972. .stroke(Color.lineBorder, lineWidth: 1)
  973. }
  974. } else {
  975. ContentUnavailableView(
  976. "图片不可用",
  977. systemImage: "photo.badge.exclamationmark",
  978. description: Text("本地图片文件可能已被移动或删除。")
  979. )
  980. .frame(maxWidth: .infinity, minHeight: 180)
  981. }
  982. } else {
  983. Text(event.textContent ?? "暂无笔记内容")
  984. .font(.system(size: 15))
  985. .foregroundStyle(Color.primary)
  986. .frame(maxWidth: .infinity, alignment: .leading)
  987. .padding(16)
  988. .background(Color.cardBackground.opacity(0.5))
  989. .businessBorder(cornerRadius: 8)
  990. }
  991. }
  992. private var detailTitle: String {
  993. if event.isContinuationMarker {
  994. return "续录详情"
  995. }
  996. return event.eventType == "PHOTO" ? "查看图片" : "查看笔记"
  997. }
  998. }
  999. private struct AudioShareItem: Identifiable {
  1000. let id = UUID()
  1001. let fileURL: URL
  1002. }
  1003. private struct AudioShareSheet: UIViewControllerRepresentable {
  1004. let fileURL: URL
  1005. func makeUIViewController(context: Context) -> UIActivityViewController {
  1006. UIActivityViewController(activityItems: [fileURL], applicationActivities: nil)
  1007. }
  1008. func updateUIViewController(
  1009. _ uiViewController: UIActivityViewController,
  1010. context: Context
  1011. ) {}
  1012. }
  1013. // MARK: - Preview
  1014. #Preview {
  1015. NavigationStack {
  1016. SessionDetailView(session: {
  1017. let s = CelestiaSession(title: "Preview 现场记录 · 6.24 14:30")
  1018. s.endTime = Date().addingTimeInterval(3600)
  1019. return s
  1020. }())
  1021. }
  1022. .modelContainer(for: CelestiaSession.self, inMemory: true)
  1023. }