SessionDetailView.swift 15 KB

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