SessionDetailView.swift 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399
  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(session: session) {
  80. playbackVM.totalDurationMs = Double(session.durationMs)
  81. playbackVM.preparePlayer(path: session.localAudioPath, durationMs: Double(session.durationMs))
  82. playbackVM.analyzeSilence(audioURL: session.localAudioPath.map { URL(fileURLWithPath: $0) })
  83. }
  84. }
  85. .onAppear {
  86. playbackVM.totalDurationMs = Double(session.durationMs)
  87. playbackVM.preparePlayer(path: session.localAudioPath, durationMs: Double(session.durationMs))
  88. playbackVM.analyzeSilence(audioURL: session.localAudioPath.map { URL(fileURLWithPath: $0) })
  89. }
  90. }
  91. // MARK: - Session Info Card
  92. private var sessionInfoCard: some View {
  93. VStack(spacing: 14) {
  94. // Date range
  95. HStack {
  96. VStack(alignment: .leading, spacing: 4) {
  97. Label {
  98. Text("开始")
  99. .font(.system(size: 10, weight: .medium))
  100. .foregroundStyle(Color.secondary)
  101. } icon: {
  102. Image(systemName: "play.circle")
  103. .font(.system(size: 11))
  104. .foregroundStyle(Color.secondary)
  105. }
  106. Text(session.startTime.formatted(.dateTime.month().day().hour().minute()))
  107. .font(.system(size: 13, weight: .medium))
  108. .foregroundStyle(Color.primary)
  109. }
  110. Spacer()
  111. VStack(alignment: .trailing, spacing: 4) {
  112. Label {
  113. Text("结束")
  114. .font(.system(size: 10, weight: .medium))
  115. .foregroundStyle(Color.secondary)
  116. } icon: {
  117. Image(systemName: "stop.circle")
  118. .font(.system(size: 11))
  119. .foregroundStyle(Color.secondary)
  120. }
  121. Text(session.endTime?.formatted(.dateTime.month().day().hour().minute()) ?? "进行中")
  122. .font(.system(size: 13, weight: .medium))
  123. .foregroundStyle(Color.primary)
  124. }
  125. }
  126. Divider()
  127. .background(Color.lineBorder)
  128. // Stats row
  129. HStack(spacing: 0) {
  130. infoStat(
  131. icon: "timer",
  132. label: "总时长",
  133. value: session.durationFormatted
  134. )
  135. infoStat(
  136. icon: "camera",
  137. label: "图片",
  138. value: "\(session.photoCount)"
  139. )
  140. infoStat(
  141. icon: "doc.text",
  142. label: "笔记",
  143. value: "\(session.noteCount)"
  144. )
  145. // Sync status
  146. VStack(spacing: 4) {
  147. Image(systemName: session.isSynced ? "cloud" : "cloud.dashed")
  148. .font(.system(size: 14, weight: .light))
  149. .foregroundStyle(Color.secondary)
  150. Text(session.isSynced ? "已同步" : "未同步")
  151. .font(.system(size: 10, weight: .regular))
  152. .foregroundStyle(Color.secondary)
  153. }
  154. .frame(maxWidth: .infinity)
  155. }
  156. }
  157. .padding(14)
  158. .background(Color.cardBackground.opacity(0.15))
  159. .businessBorder(cornerRadius: 10)
  160. }
  161. private func infoStat(icon: String, label: String, value: String) -> some View {
  162. VStack(spacing: 4) {
  163. Image(systemName: icon)
  164. .font(.system(size: 14, weight: .light))
  165. .foregroundStyle(Color.secondary)
  166. Text(value)
  167. .font(.system(size: 13, weight: .medium, design: .monospaced))
  168. .foregroundStyle(Color.primary)
  169. Text(label)
  170. .font(.system(size: 9, weight: .regular))
  171. .foregroundStyle(Color.secondary.opacity(0.8))
  172. }
  173. .frame(maxWidth: .infinity)
  174. }
  175. // MARK: - Timeline Section
  176. private var timelineSection: some View {
  177. VStack(alignment: .leading, spacing: 8) {
  178. sectionHeader(icon: "waveform", title: "三轨时间线")
  179. MultiTrackTimeline(
  180. events: session.events,
  181. currentTimeMs: Binding(
  182. get: { playbackVM.currentPlaybackTimeMs },
  183. set: { playbackVM.seekTo(timeMs: $0) }
  184. ),
  185. totalDurationMs: Double(session.durationMs),
  186. silentRanges: playbackVM.silentRanges
  187. )
  188. .frame(height: 140)
  189. }
  190. }
  191. // MARK: - Playback Controls
  192. private var playbackControls: some View {
  193. VStack(spacing: 12) {
  194. // Time display
  195. HStack {
  196. Text(playbackVM.currentTimeFormatted)
  197. .font(.system(size: 12, weight: .regular, design: .monospaced))
  198. .foregroundStyle(Color.primary)
  199. Spacer()
  200. Text(totalTimeFormatted)
  201. .font(.system(size: 12, weight: .regular, design: .monospaced))
  202. .foregroundStyle(Color.secondary)
  203. }
  204. // Seek slider
  205. Slider(
  206. value: Binding(
  207. get: { playbackVM.progress },
  208. set: { newValue in
  209. playbackVM.seekTo(timeMs: newValue * Double(session.durationMs))
  210. }
  211. ),
  212. in: 0...1
  213. )
  214. .tint(Color.primary)
  215. // Play/Pause button
  216. HStack(spacing: 36) {
  217. // Rewind 10s
  218. Button {
  219. let target = max(0, playbackVM.currentPlaybackTimeMs - 10_000)
  220. playbackVM.seekTo(timeMs: target)
  221. } label: {
  222. Image(systemName: "gobackward.10")
  223. .font(.system(size: 18, weight: .light))
  224. .foregroundStyle(Color.secondary)
  225. }
  226. // Play/Pause (High-contrast minimalist button)
  227. Button {
  228. playbackVM.togglePlayback()
  229. HapticManager.trigger(.tapFeedback)
  230. } label: {
  231. ZStack {
  232. Circle()
  233. .fill(Color.primary)
  234. .frame(width: 48, height: 48)
  235. Image(systemName: playbackVM.isPlaying ? "pause.fill" : "play.fill")
  236. .font(.system(size: 16, weight: .bold))
  237. .foregroundStyle(Color.spaceBlack)
  238. }
  239. }
  240. // Forward 10s
  241. Button {
  242. let target = min(Double(session.durationMs), playbackVM.currentPlaybackTimeMs + 10_000)
  243. playbackVM.seekTo(timeMs: target)
  244. } label: {
  245. Image(systemName: "goforward.10")
  246. .font(.system(size: 18, weight: .light))
  247. .foregroundStyle(Color.secondary)
  248. }
  249. }
  250. .padding(.top, 4)
  251. Divider()
  252. .background(Color.lineBorder)
  253. .padding(.vertical, 4)
  254. HStack {
  255. Label {
  256. Text("智能跳过静音")
  257. .font(.system(size: 12, weight: .medium))
  258. .foregroundStyle(Color.primary)
  259. } icon: {
  260. Image(systemName: playbackVM.isSilenceSkipEnabled ? "waveform.badge.minus" : "waveform")
  261. .font(.system(size: 13))
  262. .foregroundStyle(playbackVM.isSilenceSkipEnabled ? Color.primary : Color.secondary)
  263. }
  264. Spacer()
  265. if playbackVM.isAnalyzingSilence {
  266. ProgressView()
  267. .scaleEffect(0.7)
  268. .frame(width: 16, height: 16)
  269. } else if !playbackVM.silentRanges.isEmpty {
  270. Text("检测到 \(playbackVM.silentRanges.count) 处静音")
  271. .font(.system(size: 11))
  272. .foregroundStyle(Color.secondary)
  273. }
  274. Toggle("", isOn: Binding(
  275. get: { playbackVM.isSilenceSkipEnabled },
  276. set: { playbackVM.isSilenceSkipEnabled = $0 }
  277. ))
  278. .toggleStyle(SwitchToggleStyle(tint: Color.primary))
  279. .labelsHidden()
  280. .scaleEffect(0.8)
  281. }
  282. }
  283. .padding(14)
  284. .background(Color.cardBackground.opacity(0.15))
  285. .businessBorder(cornerRadius: 10)
  286. }
  287. // MARK: - Chrono Feed
  288. private var chronoFeedSection: some View {
  289. VStack(alignment: .leading, spacing: 10) {
  290. sectionHeader(icon: "list.dash", title: "时序事件列表")
  291. let sorted = playbackVM.sortedEvents(from: session)
  292. if sorted.isEmpty {
  293. HStack {
  294. Spacer()
  295. VStack(spacing: 8) {
  296. Image(systemName: "tray")
  297. .font(.system(size: 24, weight: .light))
  298. .foregroundStyle(Color.secondary.opacity(0.3))
  299. Text("暂无会话事件")
  300. .font(.system(size: 12))
  301. .foregroundStyle(Color.secondary.opacity(0.5))
  302. }
  303. .padding(.vertical, 24)
  304. Spacer()
  305. }
  306. } else {
  307. LazyVStack(spacing: 8) {
  308. ForEach(sorted) { event in
  309. ChronoFeedRow(event: event) {
  310. playbackVM.seekTo(timeMs: event.relativeTimeMs)
  311. HapticManager.trigger(.tapFeedback)
  312. }
  313. }
  314. }
  315. }
  316. }
  317. }
  318. // MARK: - Helpers
  319. private func sectionHeader(icon: String, title: String) -> some View {
  320. HStack(spacing: 6) {
  321. Image(systemName: icon)
  322. .font(.system(size: 12, weight: .light))
  323. .foregroundStyle(Color.secondary)
  324. Text(title)
  325. .font(.system(size: 13, weight: .semibold))
  326. .foregroundStyle(Color.primary.opacity(0.8))
  327. }
  328. }
  329. private var totalTimeFormatted: String {
  330. let totalSeconds = Int(session.durationMs / 1000)
  331. let hours = totalSeconds / 3600
  332. let minutes = (totalSeconds % 3600) / 60
  333. let seconds = totalSeconds % 60
  334. if hours > 0 {
  335. return String(format: "%d:%02d:%02d", hours, minutes, seconds)
  336. }
  337. return String(format: "%02d:%02d", minutes, seconds)
  338. }
  339. }
  340. // MARK: - Preview
  341. #Preview {
  342. NavigationStack {
  343. SessionDetailView(session: {
  344. let s = CelestiaSession(title: "Preview 现场记录 · 6.24 14:30")
  345. s.endTime = Date().addingTimeInterval(3600)
  346. return s
  347. }())
  348. }
  349. .modelContainer(for: CelestiaSession.self, inMemory: true)
  350. }