SessionDetailView.swift 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891
  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. let session: CelestiaSession
  14. @State private var playbackVM = PlaybackViewModel()
  15. @State private var navigateToRecording = false
  16. @State private var detector = RecordingEnvironmentDetector()
  17. @State private var showPrepAlert = false
  18. @State private var activeWarningMsg = ""
  19. @State private var showRecordingSourcePicker = false
  20. @State private var selectedRecordingSource: RecordingSourceChoice = .iPhone
  21. @State private var recordName = ""
  22. @State private var showRecordNameEditor = false
  23. @State private var showSessionInfo = false
  24. @State private var showShareUnavailableAlert = false
  25. @State private var audioShareItem: AudioShareItem?
  26. @State private var preparedShareDirectoryURL: URL?
  27. @State private var pendingTimelineTimeMs: Double = 0
  28. @State private var noteText = ""
  29. @State private var showNoteEditor = false
  30. @State private var showPhotoActionDialog = false
  31. @State private var showImagePicker = false
  32. @State private var imagePickerSource: UIImagePickerController.SourceType = .photoLibrary
  33. @State private var selectedTimelineEvent: CelestiaTimelineEvent?
  34. @State private var timelineEditError: String?
  35. var body: some View {
  36. ZStack {
  37. Color.spaceBlack.ignoresSafeArea()
  38. ScrollView {
  39. VStack(spacing: 20) {
  40. // Section 1: Multi-Track Timeline
  41. timelineSection
  42. .padding(.horizontal, 16)
  43. .padding(.top, 12)
  44. // Section 2: Playback Controls
  45. playbackControls
  46. .padding(.horizontal, 16)
  47. // Section 3: Chrono Feed
  48. chronoFeedSection
  49. .padding(.horizontal, 16)
  50. .padding(.bottom, 32)
  51. }
  52. }
  53. }
  54. .navigationBarTitleDisplayMode(.inline)
  55. .toolbar {
  56. ToolbarItem(placement: .principal) {
  57. Button {
  58. recordName = session.title
  59. showRecordNameEditor = true
  60. } label: {
  61. HStack(spacing: 5) {
  62. Text(session.title)
  63. .font(.system(size: 14, weight: .semibold))
  64. .lineLimit(1)
  65. Image(systemName: "pencil")
  66. .font(.system(size: 10, weight: .semibold))
  67. }
  68. .foregroundStyle(Color.primary)
  69. .contentShape(Rectangle())
  70. }
  71. .buttonStyle(.plain)
  72. }
  73. ToolbarItemGroup(placement: .topBarTrailing) {
  74. Button {
  75. showSessionInfo = true
  76. } label: {
  77. Image(systemName: "info.circle")
  78. .font(.system(size: 16, weight: .regular))
  79. .foregroundStyle(Color.primary)
  80. }
  81. .accessibilityLabel("记录信息")
  82. .popover(isPresented: $showSessionInfo, arrowEdge: .top) {
  83. sessionInfoCard
  84. .padding(16)
  85. .frame(idealWidth: 360)
  86. .presentationCompactAdaptation(.popover)
  87. }
  88. Button {
  89. prepareAudioForSharing()
  90. } label: {
  91. Image(systemName: "square.and.arrow.up")
  92. .font(.system(size: 16, weight: .regular))
  93. .foregroundStyle(Color.primary)
  94. }
  95. .accessibilityLabel("分享录音")
  96. }
  97. }
  98. .sheet(item: $audioShareItem, onDismiss: cleanupSharedAudioCopy) { item in
  99. AudioShareSheet(fileURL: item.fileURL)
  100. }
  101. .alert("无法分享录音", isPresented: $showShareUnavailableAlert) {
  102. Button("知道了", role: .cancel) {}
  103. } message: {
  104. Text("当前记录没有可用的本地录音文件,请先完成录制或同步录音。")
  105. }
  106. .alert("无法完成编辑", isPresented: Binding(
  107. get: { timelineEditError != nil },
  108. set: { if !$0 { timelineEditError = nil } }
  109. )) {
  110. Button("知道了", role: .cancel) {}
  111. } message: {
  112. Text(timelineEditError ?? "请稍后重试。")
  113. }
  114. .alert("录音环境提醒", isPresented: $showPrepAlert) {
  115. Button("继续录制", role: .none) {
  116. chooseSourceAndContinue()
  117. }
  118. Button("取消", role: .cancel) {}
  119. } message: {
  120. Text(activeWarningMsg + "\n\n另外请手动确认:\n1. 手机侧边静音开关已拨至红色(静音状态)\n2. 已关闭可能在此期间响起的系统闹钟")
  121. }
  122. .fullScreenCover(isPresented: $navigateToRecording) {
  123. ActiveRecordingView(
  124. session: session,
  125. initialDuration: Double(session.durationMs) / 1000.0,
  126. recordingSource: selectedRecordingSource
  127. ) {
  128. playbackVM.totalDurationMs = Double(session.durationMs)
  129. playbackVM.preparePlayer(path: session.localAudioPath, durationMs: Double(session.durationMs))
  130. playbackVM.analyzeAudio(audioURL: AudioPathHelper.resolveURL(for: session.localAudioPath))
  131. }
  132. }
  133. .sheet(isPresented: $showRecordingSourcePicker) {
  134. RecordingSourcePickerView(devices: connectedSparkDevices) { source in
  135. selectedRecordingSource = source
  136. showRecordingSourcePicker = false
  137. navigateToRecording = true
  138. }
  139. }
  140. .sheet(isPresented: $showRecordNameEditor) {
  141. RecordNameEditorSheet(recordName: $recordName) {
  142. saveRecordName()
  143. } onCancel: {
  144. recordName = session.title
  145. showRecordNameEditor = false
  146. }
  147. }
  148. .sheet(isPresented: $showNoteEditor) {
  149. timelineNoteEditor
  150. }
  151. .sheet(isPresented: $showImagePicker) {
  152. ImagePicker(sourceType: imagePickerSource) { image in
  153. addPhoto(image, at: pendingTimelineTimeMs)
  154. }
  155. }
  156. .sheet(item: $selectedTimelineEvent) { event in
  157. TimelineEventDetailSheet(event: event) {
  158. deleteTimelineEvent(event)
  159. }
  160. }
  161. .confirmationDialog(
  162. "在 \(formattedTimelineTime(pendingTimelineTimeMs)) 添加图片",
  163. isPresented: $showPhotoActionDialog,
  164. titleVisibility: .visible
  165. ) {
  166. if UIImagePickerController.isSourceTypeAvailable(.camera) {
  167. Button("拍照") {
  168. imagePickerSource = .camera
  169. showImagePicker = true
  170. }
  171. }
  172. Button("从相册选择") {
  173. imagePickerSource = .photoLibrary
  174. showImagePicker = true
  175. }
  176. Button("取消", role: .cancel) {}
  177. } message: {
  178. Text("图片会添加到当前时间位置。")
  179. }
  180. .onAppear {
  181. playbackVM.totalDurationMs = Double(session.durationMs)
  182. playbackVM.preparePlayer(path: session.localAudioPath, durationMs: Double(session.durationMs))
  183. playbackVM.analyzeAudio(audioURL: AudioPathHelper.resolveURL(for: session.localAudioPath))
  184. }
  185. }
  186. private func prepareContinuation() {
  187. detector.checkEnvironment()
  188. if let firstWarning = detector.activeWarnings.first {
  189. activeWarningMsg = firstWarning
  190. showPrepAlert = true
  191. } else {
  192. chooseSourceAndContinue()
  193. }
  194. }
  195. private func chooseSourceAndContinue() {
  196. if connectedSparkDevices.isEmpty {
  197. selectedRecordingSource = .iPhone
  198. navigateToRecording = true
  199. } else {
  200. showRecordingSourcePicker = true
  201. }
  202. }
  203. private func saveRecordName() {
  204. let trimmedName = recordName.trimmingCharacters(in: .whitespacesAndNewlines)
  205. guard !trimmedName.isEmpty else { return }
  206. recordName = trimmedName
  207. if session.title != trimmedName {
  208. session.title = trimmedName
  209. session.isSynced = false
  210. session.syncState = .pending
  211. try? modelContext.save()
  212. }
  213. showRecordNameEditor = false
  214. }
  215. private func prepareAudioForSharing() {
  216. guard let sourceURL = AudioPathHelper.resolveURL(for: session.localAudioPath) else {
  217. showShareUnavailableAlert = true
  218. return
  219. }
  220. let shareDirectory = FileManager.default.temporaryDirectory
  221. .appendingPathComponent("CelestiaTraceShare", isDirectory: true)
  222. .appendingPathComponent(UUID().uuidString, isDirectory: true)
  223. let fileExtension = sourceURL.pathExtension
  224. let fileName = sanitizedShareFileName(from: session.title)
  225. let destinationURL = shareDirectory
  226. .appendingPathComponent(fileName)
  227. .appendingPathExtension(fileExtension)
  228. do {
  229. try FileManager.default.createDirectory(
  230. at: shareDirectory,
  231. withIntermediateDirectories: true
  232. )
  233. try FileManager.default.copyItem(at: sourceURL, to: destinationURL)
  234. preparedShareDirectoryURL = shareDirectory
  235. audioShareItem = AudioShareItem(fileURL: destinationURL)
  236. } catch {
  237. audioShareItem = nil
  238. preparedShareDirectoryURL = nil
  239. showShareUnavailableAlert = true
  240. }
  241. }
  242. private func sanitizedShareFileName(from title: String) -> String {
  243. let invalidCharacters = CharacterSet(charactersIn: "/:\\?%*|\"<>")
  244. .union(.controlCharacters)
  245. .union(.newlines)
  246. let components = title.components(separatedBy: invalidCharacters)
  247. let sanitized = components
  248. .joined(separator: "-")
  249. .trimmingCharacters(in: CharacterSet.whitespacesAndNewlines.union(
  250. CharacterSet(charactersIn: ".")
  251. ))
  252. return sanitized.isEmpty ? "现场记录" : sanitized
  253. }
  254. private func cleanupSharedAudioCopy() {
  255. guard let preparedShareDirectoryURL else { return }
  256. try? FileManager.default.removeItem(at: preparedShareDirectoryURL)
  257. self.preparedShareDirectoryURL = nil
  258. }
  259. private var connectedSparkDevices: [BoundDevice] {
  260. guard let userID = authManager.currentUser?.id else { return [] }
  261. return bleManager.connectedDevices(forUserId: userID)
  262. .sorted { $0.boundAt < $1.boundAt }
  263. }
  264. // MARK: - Session Info Card
  265. private var sessionInfoCard: some View {
  266. VStack(spacing: 14) {
  267. // Date range
  268. HStack {
  269. VStack(alignment: .leading, spacing: 4) {
  270. Label {
  271. Text("开始")
  272. .font(.system(size: 10, weight: .medium))
  273. .foregroundStyle(Color.secondary)
  274. } icon: {
  275. Image(systemName: "play.circle")
  276. .font(.system(size: 11))
  277. .foregroundStyle(Color.secondary)
  278. }
  279. Text(Self.chineseDateTimeFormatter.string(from: session.startTime))
  280. .font(.system(size: 13, weight: .medium))
  281. .foregroundStyle(Color.primary)
  282. }
  283. Spacer()
  284. VStack(alignment: .trailing, spacing: 4) {
  285. Label {
  286. Text("结束")
  287. .font(.system(size: 10, weight: .medium))
  288. .foregroundStyle(Color.secondary)
  289. } icon: {
  290. Image(systemName: "stop.circle")
  291. .font(.system(size: 11))
  292. .foregroundStyle(Color.secondary)
  293. }
  294. Text(session.endTime.map { Self.chineseDateTimeFormatter.string(from: $0) } ?? "进行中")
  295. .font(.system(size: 13, weight: .medium))
  296. .foregroundStyle(Color.primary)
  297. }
  298. }
  299. Divider()
  300. .background(Color.lineBorder)
  301. // Stats row
  302. HStack(spacing: 0) {
  303. infoStat(
  304. icon: "timer",
  305. label: "总时长",
  306. value: session.durationFormatted
  307. )
  308. infoStat(
  309. icon: "camera",
  310. label: "图片",
  311. value: "\(session.photoCount)"
  312. )
  313. infoStat(
  314. icon: "doc.text",
  315. label: "笔记",
  316. value: "\(session.noteCount)"
  317. )
  318. // Sync status
  319. VStack(spacing: 4) {
  320. Image(systemName: session.isSynced ? "cloud.fill" : "cloud")
  321. .font(.system(size: 14, weight: .light))
  322. .foregroundStyle(Color.secondary)
  323. Text(session.isSynced ? "已同步" : "未同步")
  324. .font(.system(size: 10, weight: .regular))
  325. .foregroundStyle(Color.secondary)
  326. }
  327. .frame(maxWidth: .infinity)
  328. }
  329. }
  330. .padding(14)
  331. .background(Color.cardBackground.opacity(0.15))
  332. .businessBorder(cornerRadius: 10)
  333. }
  334. private static let chineseDateTimeFormatter: DateFormatter = {
  335. let formatter = DateFormatter()
  336. formatter.locale = Locale(identifier: "zh_CN")
  337. formatter.calendar = Calendar(identifier: .gregorian)
  338. formatter.dateFormat = "yyyy年M月d日 HH:mm:ss"
  339. return formatter
  340. }()
  341. private func infoStat(icon: String, label: String, value: String) -> some View {
  342. VStack(spacing: 4) {
  343. Image(systemName: icon)
  344. .font(.system(size: 14, weight: .light))
  345. .foregroundStyle(Color.secondary)
  346. Text(value)
  347. .font(.system(size: 13, weight: .medium, design: .monospaced))
  348. .foregroundStyle(Color.primary)
  349. Text(label)
  350. .font(.system(size: 9, weight: .regular))
  351. .foregroundStyle(Color.secondary.opacity(0.8))
  352. }
  353. .frame(maxWidth: .infinity)
  354. }
  355. // MARK: - Timeline Section
  356. private var timelineSection: some View {
  357. VStack(alignment: .leading, spacing: 8) {
  358. MultiTrackTimeline(
  359. events: session.events,
  360. currentTimeMs: Binding(
  361. get: { playbackVM.currentPlaybackTimeMs },
  362. set: { playbackVM.seekTo(timeMs: $0) }
  363. ),
  364. totalDurationMs: playbackVM.totalDurationMs,
  365. waveformSamples: playbackVM.waveformSamples,
  366. silentRanges: playbackVM.silentRanges,
  367. onEmptyTrackTap: handleEmptyTrackTap,
  368. onEventTap: { event in
  369. playbackVM.seekTo(timeMs: Double(event.relativeTimeMs))
  370. selectedTimelineEvent = event
  371. HapticManager.trigger(.tapFeedback)
  372. }
  373. )
  374. .frame(height: 140)
  375. }
  376. }
  377. // MARK: - Playback Controls
  378. private var playbackControls: some View {
  379. VStack(spacing: 12) {
  380. // Time display
  381. HStack {
  382. Text(playbackVM.currentTimeFormatted)
  383. .font(.system(size: 12, weight: .regular, design: .monospaced))
  384. .foregroundStyle(Color.primary)
  385. Spacer()
  386. Text(totalTimeFormatted)
  387. .font(.system(size: 12, weight: .regular, design: .monospaced))
  388. .foregroundStyle(Color.secondary)
  389. }
  390. // Seek slider
  391. Slider(
  392. value: Binding(
  393. get: { playbackVM.progress },
  394. set: { newValue in
  395. playbackVM.seekTo(timeMs: newValue * playbackVM.totalDurationMs)
  396. }
  397. ),
  398. in: 0...1
  399. )
  400. .tint(Color.primary)
  401. // Play/Pause button
  402. HStack(spacing: 36) {
  403. // Rewind 10s
  404. Button {
  405. let target = max(0, playbackVM.currentPlaybackTimeMs - 10_000)
  406. playbackVM.seekTo(timeMs: target)
  407. } label: {
  408. Image(systemName: "gobackward.10")
  409. .font(.system(size: 18, weight: .light))
  410. .foregroundStyle(Color.secondary)
  411. }
  412. // Play/Pause (High-contrast minimalist button)
  413. Button {
  414. playbackVM.togglePlayback()
  415. HapticManager.trigger(.tapFeedback)
  416. } label: {
  417. ZStack {
  418. Circle()
  419. .fill(Color.primary)
  420. .frame(width: 48, height: 48)
  421. Image(systemName: playbackVM.isPlaying ? "pause.fill" : "play.fill")
  422. .font(.system(size: 16, weight: .bold))
  423. .foregroundStyle(Color.spaceBlack)
  424. }
  425. }
  426. // Forward 10s
  427. Button {
  428. let target = min(playbackVM.totalDurationMs, playbackVM.currentPlaybackTimeMs + 10_000)
  429. playbackVM.seekTo(timeMs: target)
  430. } label: {
  431. Image(systemName: "goforward.10")
  432. .font(.system(size: 18, weight: .light))
  433. .foregroundStyle(Color.secondary)
  434. }
  435. }
  436. .padding(.top, 4)
  437. Divider()
  438. .background(Color.lineBorder)
  439. .padding(.vertical, 4)
  440. HStack {
  441. Label {
  442. Text("跳过静音")
  443. .font(.system(size: 12, weight: .medium))
  444. .foregroundStyle(Color.primary)
  445. } icon: {
  446. Image(systemName: playbackVM.isSilenceSkipEnabled ? "waveform.badge.minus" : "waveform")
  447. .font(.system(size: 13))
  448. .foregroundStyle(playbackVM.isSilenceSkipEnabled ? Color.primary : Color.secondary)
  449. }
  450. Spacer()
  451. if playbackVM.isAnalyzingSilence {
  452. ProgressView()
  453. .scaleEffect(0.7)
  454. .frame(width: 16, height: 16)
  455. } else if !playbackVM.silentRanges.isEmpty {
  456. Text("检测到 \(playbackVM.silentRanges.count) 处静音")
  457. .font(.system(size: 11))
  458. .foregroundStyle(Color.secondary)
  459. }
  460. Toggle("", isOn: Binding(
  461. get: { playbackVM.isSilenceSkipEnabled },
  462. set: { playbackVM.isSilenceSkipEnabled = $0 }
  463. ))
  464. .toggleStyle(SwitchToggleStyle(tint: Color.primary))
  465. .labelsHidden()
  466. .scaleEffect(0.8)
  467. }
  468. }
  469. .padding(14)
  470. .background(Color.cardBackground.opacity(0.15))
  471. .businessBorder(cornerRadius: 10)
  472. }
  473. // MARK: - Chrono Feed
  474. private var chronoFeedSection: some View {
  475. VStack(alignment: .leading, spacing: 10) {
  476. sectionHeader(icon: "list.dash", title: "时序事件列表")
  477. let sorted = playbackVM.sortedEvents(from: session)
  478. if sorted.isEmpty {
  479. HStack {
  480. Spacer()
  481. VStack(spacing: 8) {
  482. Image(systemName: "tray")
  483. .font(.system(size: 24, weight: .light))
  484. .foregroundStyle(Color.secondary.opacity(0.3))
  485. Text("暂无会话事件")
  486. .font(.system(size: 12))
  487. .foregroundStyle(Color.secondary.opacity(0.5))
  488. }
  489. .padding(.vertical, 24)
  490. Spacer()
  491. }
  492. } else {
  493. LazyVStack(spacing: 8) {
  494. ForEach(sorted) { event in
  495. ChronoFeedRow(event: event) {
  496. playbackVM.seekTo(timeMs: event.relativeTimeMs)
  497. if event.eventType == "PHOTO"
  498. || event.eventType == "NOTE"
  499. || event.eventType == "MARKER" {
  500. selectedTimelineEvent = event
  501. }
  502. HapticManager.trigger(.tapFeedback)
  503. }
  504. }
  505. }
  506. }
  507. }
  508. }
  509. // MARK: - Helpers
  510. private func sectionHeader(icon: String, title: String) -> some View {
  511. HStack(spacing: 6) {
  512. Image(systemName: icon)
  513. .font(.system(size: 12, weight: .light))
  514. .foregroundStyle(Color.secondary)
  515. Text(title)
  516. .font(.system(size: 13, weight: .semibold))
  517. .foregroundStyle(Color.primary.opacity(0.8))
  518. }
  519. }
  520. private var totalTimeFormatted: String {
  521. let totalSeconds = Int(playbackVM.totalDurationMs / 1000)
  522. let hours = totalSeconds / 3600
  523. let minutes = (totalSeconds % 3600) / 60
  524. let seconds = totalSeconds % 60
  525. if hours > 0 {
  526. return String(format: "%d:%02d:%02d", hours, minutes, seconds)
  527. }
  528. return String(format: "%02d:%02d", minutes, seconds)
  529. }
  530. // MARK: - Timeline Editing
  531. private var timelineNoteEditor: some View {
  532. NavigationStack {
  533. ZStack {
  534. Color.spaceBlack.ignoresSafeArea()
  535. VStack(alignment: .leading, spacing: 16) {
  536. Label(
  537. "添加到 \(formattedTimelineTime(pendingTimelineTimeMs))",
  538. systemImage: "clock"
  539. )
  540. .font(.system(size: 12, weight: .medium))
  541. .foregroundStyle(Color.secondary)
  542. ZStack(alignment: .topLeading) {
  543. if noteText.isEmpty {
  544. Text("输入笔记内容…")
  545. .font(.system(size: 15))
  546. .foregroundStyle(Color.secondary.opacity(0.6))
  547. .padding(.horizontal, 18)
  548. .padding(.vertical, 16)
  549. .allowsHitTesting(false)
  550. }
  551. TextEditor(text: $noteText)
  552. .font(.system(size: 15))
  553. .foregroundStyle(Color.primary)
  554. .scrollContentBackground(.hidden)
  555. .padding(10)
  556. .frame(minHeight: 160)
  557. .background(Color.cardBackground.opacity(0.5))
  558. .businessBorder(cornerRadius: 8)
  559. }
  560. Spacer()
  561. }
  562. .padding(20)
  563. }
  564. .navigationTitle("添加笔记")
  565. .navigationBarTitleDisplayMode(.inline)
  566. .toolbar {
  567. ToolbarItem(placement: .cancellationAction) {
  568. Button("取消") {
  569. noteText = ""
  570. showNoteEditor = false
  571. }
  572. }
  573. ToolbarItem(placement: .confirmationAction) {
  574. Button("保存") {
  575. addNote(at: pendingTimelineTimeMs)
  576. }
  577. .disabled(noteText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
  578. }
  579. }
  580. }
  581. .presentationDetents([.medium])
  582. .presentationDragIndicator(.visible)
  583. .presentationBackground(Color.spaceBlack)
  584. }
  585. private func handleEmptyTrackTap(
  586. _ track: MultiTrackTimeline.EditableTrack,
  587. timeMs: Double
  588. ) {
  589. pendingTimelineTimeMs = min(max(timeMs, 0), playbackVM.totalDurationMs)
  590. playbackVM.seekTo(timeMs: pendingTimelineTimeMs)
  591. HapticManager.trigger(.tapFeedback)
  592. switch track {
  593. case .photo:
  594. showPhotoActionDialog = true
  595. case .note:
  596. noteText = ""
  597. showNoteEditor = true
  598. }
  599. }
  600. private func addNote(at timeMs: Double) {
  601. let trimmedText = noteText.trimmingCharacters(in: .whitespacesAndNewlines)
  602. guard !trimmedText.isEmpty else { return }
  603. let event = CelestiaTimelineEvent(
  604. relativeTimeMs: Int64(timeMs.rounded()),
  605. eventType: "NOTE"
  606. )
  607. event.textContent = trimmedText
  608. session.events.append(event)
  609. do {
  610. try saveTimelineChanges()
  611. noteText = ""
  612. showNoteEditor = false
  613. } catch {
  614. modelContext.rollback()
  615. timelineEditError = "笔记保存失败:\(error.localizedDescription)"
  616. }
  617. }
  618. private func addPhoto(_ image: UIImage, at timeMs: Double) {
  619. guard let data = image.jpegData(compressionQuality: 0.85),
  620. let documentsURL = FileManager.default.urls(
  621. for: .documentDirectory,
  622. in: .userDomainMask
  623. ).first else {
  624. timelineEditError = "无法读取所选图片。"
  625. return
  626. }
  627. let filename = "photo_\(UUID().uuidString).jpg"
  628. let fileURL = documentsURL.appendingPathComponent(filename)
  629. do {
  630. try data.write(to: fileURL, options: .atomic)
  631. let event = CelestiaTimelineEvent(
  632. relativeTimeMs: Int64(timeMs.rounded()),
  633. eventType: "PHOTO"
  634. )
  635. event.localFilePath = filename
  636. session.events.append(event)
  637. do {
  638. try saveTimelineChanges()
  639. } catch {
  640. modelContext.rollback()
  641. try? FileManager.default.removeItem(at: fileURL)
  642. throw error
  643. }
  644. } catch {
  645. timelineEditError = "图片保存失败:\(error.localizedDescription)"
  646. }
  647. }
  648. private func deleteTimelineEvent(_ event: CelestiaTimelineEvent) {
  649. let photoURL = event.eventType == "PHOTO"
  650. ? AudioPathHelper.resolveURL(for: event.localFilePath)
  651. : nil
  652. modelContext.delete(event)
  653. do {
  654. try saveTimelineChanges()
  655. if let photoURL {
  656. try? FileManager.default.removeItem(at: photoURL)
  657. }
  658. selectedTimelineEvent = nil
  659. } catch {
  660. modelContext.rollback()
  661. timelineEditError = "删除失败:\(error.localizedDescription)"
  662. }
  663. }
  664. private func saveTimelineChanges() throws {
  665. session.isSynced = false
  666. session.syncState = .pending
  667. try modelContext.save()
  668. }
  669. private func formattedTimelineTime(_ timeMs: Double) -> String {
  670. let totalSeconds = max(0, Int(timeMs / 1_000))
  671. let hours = totalSeconds / 3_600
  672. let minutes = (totalSeconds % 3_600) / 60
  673. let seconds = totalSeconds % 60
  674. if hours > 0 {
  675. return String(format: "%d:%02d:%02d", hours, minutes, seconds)
  676. }
  677. return String(format: "%02d:%02d", minutes, seconds)
  678. }
  679. }
  680. private struct TimelineEventDetailSheet: View {
  681. @Environment(\.dismiss) private var dismiss
  682. let event: CelestiaTimelineEvent
  683. let onDelete: () -> Void
  684. @State private var showDeleteConfirmation = false
  685. var body: some View {
  686. NavigationStack {
  687. ZStack {
  688. Color.spaceBlack.ignoresSafeArea()
  689. ScrollView {
  690. VStack(alignment: .leading, spacing: 16) {
  691. Label(event.relativeTimeFormatted, systemImage: "clock")
  692. .font(.system(size: 12, weight: .medium, design: .monospaced))
  693. .foregroundStyle(Color.secondary)
  694. eventContent
  695. Button(role: .destructive) {
  696. showDeleteConfirmation = true
  697. } label: {
  698. Label("删除这条记录", systemImage: "trash")
  699. .font(.system(size: 14, weight: .medium))
  700. .frame(maxWidth: .infinity)
  701. .padding(.vertical, 12)
  702. }
  703. .buttonStyle(.bordered)
  704. .tint(.red)
  705. }
  706. .padding(20)
  707. }
  708. }
  709. .navigationTitle(event.eventType == "PHOTO" ? "查看图片" : "查看笔记")
  710. .navigationBarTitleDisplayMode(.inline)
  711. .toolbar {
  712. ToolbarItem(placement: .confirmationAction) {
  713. Button("完成") {
  714. dismiss()
  715. }
  716. }
  717. }
  718. .confirmationDialog(
  719. "确定删除这条记录吗?",
  720. isPresented: $showDeleteConfirmation,
  721. titleVisibility: .visible
  722. ) {
  723. Button("删除", role: .destructive) {
  724. onDelete()
  725. dismiss()
  726. }
  727. Button("取消", role: .cancel) {}
  728. } message: {
  729. Text(event.eventType == "PHOTO" ? "本地图片文件也会被删除,此操作无法撤销。" : "此操作无法撤销。")
  730. }
  731. }
  732. .presentationDetents(event.eventType == "PHOTO" ? [.medium, .large] : [.medium])
  733. .presentationDragIndicator(.visible)
  734. .presentationBackground(Color.spaceBlack)
  735. }
  736. @ViewBuilder
  737. private var eventContent: some View {
  738. if event.eventType == "PHOTO" {
  739. if let url = AudioPathHelper.resolveURL(for: event.localFilePath),
  740. let image = UIImage(contentsOfFile: url.path) {
  741. Image(uiImage: image)
  742. .resizable()
  743. .scaledToFit()
  744. .frame(maxWidth: .infinity)
  745. .clipShape(RoundedRectangle(cornerRadius: 10))
  746. .overlay {
  747. RoundedRectangle(cornerRadius: 10)
  748. .stroke(Color.lineBorder, lineWidth: 1)
  749. }
  750. } else {
  751. ContentUnavailableView(
  752. "图片不可用",
  753. systemImage: "photo.badge.exclamationmark",
  754. description: Text("本地图片文件可能已被移动或删除。")
  755. )
  756. .frame(maxWidth: .infinity, minHeight: 180)
  757. }
  758. } else {
  759. Text(event.textContent ?? "暂无笔记内容")
  760. .font(.system(size: 15))
  761. .foregroundStyle(Color.primary)
  762. .frame(maxWidth: .infinity, alignment: .leading)
  763. .padding(16)
  764. .background(Color.cardBackground.opacity(0.5))
  765. .businessBorder(cornerRadius: 8)
  766. }
  767. }
  768. }
  769. private struct AudioShareItem: Identifiable {
  770. let id = UUID()
  771. let fileURL: URL
  772. }
  773. private struct AudioShareSheet: UIViewControllerRepresentable {
  774. let fileURL: URL
  775. func makeUIViewController(context: Context) -> UIActivityViewController {
  776. UIActivityViewController(activityItems: [fileURL], applicationActivities: nil)
  777. }
  778. func updateUIViewController(
  779. _ uiViewController: UIActivityViewController,
  780. context: Context
  781. ) {}
  782. }
  783. // MARK: - Preview
  784. #Preview {
  785. NavigationStack {
  786. SessionDetailView(session: {
  787. let s = CelestiaSession(title: "Preview 现场记录 · 6.24 14:30")
  788. s.endTime = Date().addingTimeInterval(3600)
  789. return s
  790. }())
  791. }
  792. .modelContainer(for: CelestiaSession.self, inMemory: true)
  793. }