SessionDetailView.swift 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433
  1. import SwiftUI
  2. import SwiftData
  3. // MARK: - SessionDetailView
  4. /// The session detail & playback screen.
  5. /// Shows session info, multi-track timeline, playback controls,
  6. /// and a chronological feed of all events.
  7. /// Redesigned to use a minimalist wireframe business style.
  8. struct SessionDetailView: View {
  9. @Environment(\.modelContext) private var modelContext
  10. @ObservedObject private var bleManager: BLEManager = .shared
  11. @ObservedObject private var authManager: AuthManager = .shared
  12. let session: CelestiaSession
  13. @State private var playbackVM = PlaybackViewModel()
  14. @State private var navigateToRecording = false
  15. @State private var detector = RecordingEnvironmentDetector()
  16. @State private var showPrepAlert = false
  17. @State private var activeWarningMsg = ""
  18. @State private var showRecordingSourcePicker = false
  19. @State private var selectedRecordingSource: RecordingSourceChoice = .iPhone
  20. var body: some View {
  21. ZStack {
  22. Color.spaceBlack.ignoresSafeArea()
  23. ScrollView {
  24. VStack(spacing: 20) {
  25. // Section 1: Session Info Card
  26. sessionInfoCard
  27. .padding(.horizontal, 16)
  28. .padding(.top, 12)
  29. // Section 2: Multi-Track Timeline
  30. timelineSection
  31. .padding(.horizontal, 16)
  32. // Section 3: Playback Controls
  33. playbackControls
  34. .padding(.horizontal, 16)
  35. // Section 4: Chrono Feed
  36. chronoFeedSection
  37. .padding(.horizontal, 16)
  38. .padding(.bottom, 32)
  39. }
  40. }
  41. }
  42. .navigationBarTitleDisplayMode(.inline)
  43. .toolbar {
  44. ToolbarItem(placement: .principal) {
  45. Text(session.title)
  46. .font(.system(size: 14, weight: .semibold))
  47. .foregroundStyle(Color.primary)
  48. .lineLimit(1)
  49. }
  50. ToolbarItem(placement: .topBarTrailing) {
  51. Button {
  52. prepareContinuation()
  53. } label: {
  54. HStack(spacing: 4) {
  55. Image(systemName: "plus")
  56. .font(.system(size: 11, weight: .medium))
  57. Text("续录")
  58. .font(.system(size: 12, weight: .regular))
  59. }
  60. .foregroundStyle(Color.primary)
  61. .padding(.horizontal, 10)
  62. .padding(.vertical, 4)
  63. .background(Color.primary.opacity(0.02))
  64. .businessBorder(cornerRadius: 6)
  65. }
  66. }
  67. }
  68. .alert("录音环境提醒", isPresented: $showPrepAlert) {
  69. Button("继续录制", role: .none) {
  70. chooseSourceAndContinue()
  71. }
  72. Button("取消", role: .cancel) {}
  73. } message: {
  74. Text(activeWarningMsg + "\n\n另外请手动确认:\n1. 手机侧边静音开关已拨至红色(静音状态)\n2. 已关闭可能在此期间响起的系统闹钟")
  75. }
  76. .fullScreenCover(isPresented: $navigateToRecording) {
  77. ActiveRecordingView(
  78. session: session,
  79. initialDuration: Double(session.durationMs) / 1000.0,
  80. recordingSource: selectedRecordingSource
  81. ) {
  82. playbackVM.totalDurationMs = Double(session.durationMs)
  83. playbackVM.preparePlayer(path: session.localAudioPath, durationMs: Double(session.durationMs))
  84. playbackVM.analyzeSilence(audioURL: AudioPathHelper.resolveURL(for: session.localAudioPath))
  85. }
  86. }
  87. .sheet(isPresented: $showRecordingSourcePicker) {
  88. RecordingSourcePickerView(devices: connectedSparkDevices) { source in
  89. selectedRecordingSource = source
  90. showRecordingSourcePicker = false
  91. navigateToRecording = true
  92. }
  93. }
  94. .onAppear {
  95. playbackVM.totalDurationMs = Double(session.durationMs)
  96. playbackVM.preparePlayer(path: session.localAudioPath, durationMs: Double(session.durationMs))
  97. playbackVM.analyzeSilence(audioURL: AudioPathHelper.resolveURL(for: session.localAudioPath))
  98. }
  99. }
  100. private func prepareContinuation() {
  101. detector.checkEnvironment()
  102. if let firstWarning = detector.activeWarnings.first {
  103. activeWarningMsg = firstWarning
  104. showPrepAlert = true
  105. } else {
  106. chooseSourceAndContinue()
  107. }
  108. }
  109. private func chooseSourceAndContinue() {
  110. if connectedSparkDevices.isEmpty {
  111. selectedRecordingSource = .iPhone
  112. navigateToRecording = true
  113. } else {
  114. showRecordingSourcePicker = true
  115. }
  116. }
  117. private var connectedSparkDevices: [BoundDevice] {
  118. guard let userID = authManager.currentUser?.id else { return [] }
  119. return bleManager.connectedDevices(forUserId: userID)
  120. .sorted { $0.boundAt < $1.boundAt }
  121. }
  122. // MARK: - Session Info Card
  123. private var sessionInfoCard: some View {
  124. VStack(spacing: 14) {
  125. // Date range
  126. HStack {
  127. VStack(alignment: .leading, spacing: 4) {
  128. Label {
  129. Text("开始")
  130. .font(.system(size: 10, weight: .medium))
  131. .foregroundStyle(Color.secondary)
  132. } icon: {
  133. Image(systemName: "play.circle")
  134. .font(.system(size: 11))
  135. .foregroundStyle(Color.secondary)
  136. }
  137. Text(session.startTime.formatted(.dateTime.month().day().hour().minute()))
  138. .font(.system(size: 13, weight: .medium))
  139. .foregroundStyle(Color.primary)
  140. }
  141. Spacer()
  142. VStack(alignment: .trailing, spacing: 4) {
  143. Label {
  144. Text("结束")
  145. .font(.system(size: 10, weight: .medium))
  146. .foregroundStyle(Color.secondary)
  147. } icon: {
  148. Image(systemName: "stop.circle")
  149. .font(.system(size: 11))
  150. .foregroundStyle(Color.secondary)
  151. }
  152. Text(session.endTime?.formatted(.dateTime.month().day().hour().minute()) ?? "进行中")
  153. .font(.system(size: 13, weight: .medium))
  154. .foregroundStyle(Color.primary)
  155. }
  156. }
  157. Divider()
  158. .background(Color.lineBorder)
  159. // Stats row
  160. HStack(spacing: 0) {
  161. infoStat(
  162. icon: "timer",
  163. label: "总时长",
  164. value: session.durationFormatted
  165. )
  166. infoStat(
  167. icon: "camera",
  168. label: "图片",
  169. value: "\(session.photoCount)"
  170. )
  171. infoStat(
  172. icon: "doc.text",
  173. label: "笔记",
  174. value: "\(session.noteCount)"
  175. )
  176. // Sync status
  177. VStack(spacing: 4) {
  178. Image(systemName: session.isSynced ? "cloud" : "cloud.dashed")
  179. .font(.system(size: 14, weight: .light))
  180. .foregroundStyle(Color.secondary)
  181. Text(session.isSynced ? "已同步" : "未同步")
  182. .font(.system(size: 10, weight: .regular))
  183. .foregroundStyle(Color.secondary)
  184. }
  185. .frame(maxWidth: .infinity)
  186. }
  187. }
  188. .padding(14)
  189. .background(Color.cardBackground.opacity(0.15))
  190. .businessBorder(cornerRadius: 10)
  191. }
  192. private func infoStat(icon: String, label: String, value: String) -> some View {
  193. VStack(spacing: 4) {
  194. Image(systemName: icon)
  195. .font(.system(size: 14, weight: .light))
  196. .foregroundStyle(Color.secondary)
  197. Text(value)
  198. .font(.system(size: 13, weight: .medium, design: .monospaced))
  199. .foregroundStyle(Color.primary)
  200. Text(label)
  201. .font(.system(size: 9, weight: .regular))
  202. .foregroundStyle(Color.secondary.opacity(0.8))
  203. }
  204. .frame(maxWidth: .infinity)
  205. }
  206. // MARK: - Timeline Section
  207. private var timelineSection: some View {
  208. VStack(alignment: .leading, spacing: 8) {
  209. sectionHeader(icon: "waveform", title: "三轨时间线")
  210. MultiTrackTimeline(
  211. events: session.events,
  212. currentTimeMs: Binding(
  213. get: { playbackVM.currentPlaybackTimeMs },
  214. set: { playbackVM.seekTo(timeMs: $0) }
  215. ),
  216. totalDurationMs: Double(session.durationMs),
  217. silentRanges: playbackVM.silentRanges
  218. )
  219. .frame(height: 140)
  220. }
  221. }
  222. // MARK: - Playback Controls
  223. private var playbackControls: some View {
  224. VStack(spacing: 12) {
  225. // Time display
  226. HStack {
  227. Text(playbackVM.currentTimeFormatted)
  228. .font(.system(size: 12, weight: .regular, design: .monospaced))
  229. .foregroundStyle(Color.primary)
  230. Spacer()
  231. Text(totalTimeFormatted)
  232. .font(.system(size: 12, weight: .regular, design: .monospaced))
  233. .foregroundStyle(Color.secondary)
  234. }
  235. // Seek slider
  236. Slider(
  237. value: Binding(
  238. get: { playbackVM.progress },
  239. set: { newValue in
  240. playbackVM.seekTo(timeMs: newValue * Double(session.durationMs))
  241. }
  242. ),
  243. in: 0...1
  244. )
  245. .tint(Color.primary)
  246. // Play/Pause button
  247. HStack(spacing: 36) {
  248. // Rewind 10s
  249. Button {
  250. let target = max(0, playbackVM.currentPlaybackTimeMs - 10_000)
  251. playbackVM.seekTo(timeMs: target)
  252. } label: {
  253. Image(systemName: "gobackward.10")
  254. .font(.system(size: 18, weight: .light))
  255. .foregroundStyle(Color.secondary)
  256. }
  257. // Play/Pause (High-contrast minimalist button)
  258. Button {
  259. playbackVM.togglePlayback()
  260. HapticManager.trigger(.tapFeedback)
  261. } label: {
  262. ZStack {
  263. Circle()
  264. .fill(Color.primary)
  265. .frame(width: 48, height: 48)
  266. Image(systemName: playbackVM.isPlaying ? "pause.fill" : "play.fill")
  267. .font(.system(size: 16, weight: .bold))
  268. .foregroundStyle(Color.spaceBlack)
  269. }
  270. }
  271. // Forward 10s
  272. Button {
  273. let target = min(Double(session.durationMs), playbackVM.currentPlaybackTimeMs + 10_000)
  274. playbackVM.seekTo(timeMs: target)
  275. } label: {
  276. Image(systemName: "goforward.10")
  277. .font(.system(size: 18, weight: .light))
  278. .foregroundStyle(Color.secondary)
  279. }
  280. }
  281. .padding(.top, 4)
  282. Divider()
  283. .background(Color.lineBorder)
  284. .padding(.vertical, 4)
  285. HStack {
  286. Label {
  287. Text("智能跳过静音")
  288. .font(.system(size: 12, weight: .medium))
  289. .foregroundStyle(Color.primary)
  290. } icon: {
  291. Image(systemName: playbackVM.isSilenceSkipEnabled ? "waveform.badge.minus" : "waveform")
  292. .font(.system(size: 13))
  293. .foregroundStyle(playbackVM.isSilenceSkipEnabled ? Color.primary : Color.secondary)
  294. }
  295. Spacer()
  296. if playbackVM.isAnalyzingSilence {
  297. ProgressView()
  298. .scaleEffect(0.7)
  299. .frame(width: 16, height: 16)
  300. } else if !playbackVM.silentRanges.isEmpty {
  301. Text("检测到 \(playbackVM.silentRanges.count) 处静音")
  302. .font(.system(size: 11))
  303. .foregroundStyle(Color.secondary)
  304. }
  305. Toggle("", isOn: Binding(
  306. get: { playbackVM.isSilenceSkipEnabled },
  307. set: { playbackVM.isSilenceSkipEnabled = $0 }
  308. ))
  309. .toggleStyle(SwitchToggleStyle(tint: Color.primary))
  310. .labelsHidden()
  311. .scaleEffect(0.8)
  312. }
  313. }
  314. .padding(14)
  315. .background(Color.cardBackground.opacity(0.15))
  316. .businessBorder(cornerRadius: 10)
  317. }
  318. // MARK: - Chrono Feed
  319. private var chronoFeedSection: some View {
  320. VStack(alignment: .leading, spacing: 10) {
  321. sectionHeader(icon: "list.dash", title: "时序事件列表")
  322. let sorted = playbackVM.sortedEvents(from: session)
  323. if sorted.isEmpty {
  324. HStack {
  325. Spacer()
  326. VStack(spacing: 8) {
  327. Image(systemName: "tray")
  328. .font(.system(size: 24, weight: .light))
  329. .foregroundStyle(Color.secondary.opacity(0.3))
  330. Text("暂无会话事件")
  331. .font(.system(size: 12))
  332. .foregroundStyle(Color.secondary.opacity(0.5))
  333. }
  334. .padding(.vertical, 24)
  335. Spacer()
  336. }
  337. } else {
  338. LazyVStack(spacing: 8) {
  339. ForEach(sorted) { event in
  340. ChronoFeedRow(event: event) {
  341. playbackVM.seekTo(timeMs: event.relativeTimeMs)
  342. HapticManager.trigger(.tapFeedback)
  343. }
  344. }
  345. }
  346. }
  347. }
  348. }
  349. // MARK: - Helpers
  350. private func sectionHeader(icon: String, title: String) -> some View {
  351. HStack(spacing: 6) {
  352. Image(systemName: icon)
  353. .font(.system(size: 12, weight: .light))
  354. .foregroundStyle(Color.secondary)
  355. Text(title)
  356. .font(.system(size: 13, weight: .semibold))
  357. .foregroundStyle(Color.primary.opacity(0.8))
  358. }
  359. }
  360. private var totalTimeFormatted: String {
  361. let totalSeconds = Int(session.durationMs / 1000)
  362. let hours = totalSeconds / 3600
  363. let minutes = (totalSeconds % 3600) / 60
  364. let seconds = totalSeconds % 60
  365. if hours > 0 {
  366. return String(format: "%d:%02d:%02d", hours, minutes, seconds)
  367. }
  368. return String(format: "%02d:%02d", minutes, seconds)
  369. }
  370. }
  371. // MARK: - Preview
  372. #Preview {
  373. NavigationStack {
  374. SessionDetailView(session: {
  375. let s = CelestiaSession(title: "Preview 现场记录 · 6.24 14:30")
  376. s.endTime = Date().addingTimeInterval(3600)
  377. return s
  378. }())
  379. }
  380. .modelContainer(for: CelestiaSession.self, inMemory: true)
  381. }