import SwiftUI import SwiftData // MARK: - SessionDetailView /// The session detail & playback screen. /// Shows session info, multi-track timeline, playback controls, /// and a chronological feed of all events. /// Redesigned to use a minimalist wireframe business style. struct SessionDetailView: View { @Environment(\.modelContext) private var modelContext let session: CelestiaSession @State private var playbackVM = PlaybackViewModel() @State private var navigateToRecording = false @State private var detector = RecordingEnvironmentDetector() @State private var showPrepAlert = false @State private var activeWarningMsg = "" var body: some View { ZStack { Color.spaceBlack.ignoresSafeArea() ScrollView { VStack(spacing: 20) { // Section 1: Session Info Card sessionInfoCard .padding(.horizontal, 16) .padding(.top, 12) // Section 2: Multi-Track Timeline timelineSection .padding(.horizontal, 16) // Section 3: Playback Controls playbackControls .padding(.horizontal, 16) // Section 4: Chrono Feed chronoFeedSection .padding(.horizontal, 16) .padding(.bottom, 32) } } } .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .principal) { Text(session.title) .font(.system(size: 14, weight: .semibold)) .foregroundStyle(Color.primary) .lineLimit(1) } ToolbarItem(placement: .topBarTrailing) { Button { detector.checkEnvironment() if let firstWarning = detector.activeWarnings.first { activeWarningMsg = firstWarning showPrepAlert = true } else { navigateToRecording = true } } label: { HStack(spacing: 4) { Image(systemName: "plus") .font(.system(size: 11, weight: .medium)) Text("续录") .font(.system(size: 12, weight: .regular)) } .foregroundStyle(Color.primary) .padding(.horizontal, 10) .padding(.vertical, 4) .background(Color.primary.opacity(0.02)) .businessBorder(cornerRadius: 6) } } } .alert("录音环境提醒", isPresented: $showPrepAlert) { Button("继续录制", role: .none) { navigateToRecording = true } Button("取消", role: .cancel) {} } message: { Text(activeWarningMsg + "\n\n另外请手动确认:\n1. 手机侧边静音开关已拨至红色(静音状态)\n2. 已关闭可能在此期间响起的系统闹钟") } .fullScreenCover(isPresented: $navigateToRecording) { ActiveRecordingView( session: session, initialDuration: Double(session.durationMs) / 1000.0 ) { playbackVM.totalDurationMs = Double(session.durationMs) playbackVM.preparePlayer(path: session.localAudioPath, durationMs: Double(session.durationMs)) playbackVM.analyzeSilence(audioURL: AudioPathHelper.resolveURL(for: session.localAudioPath)) } } .onAppear { playbackVM.totalDurationMs = Double(session.durationMs) playbackVM.preparePlayer(path: session.localAudioPath, durationMs: Double(session.durationMs)) playbackVM.analyzeSilence(audioURL: AudioPathHelper.resolveURL(for: session.localAudioPath)) } } // MARK: - Session Info Card private var sessionInfoCard: some View { VStack(spacing: 14) { // Date range HStack { VStack(alignment: .leading, spacing: 4) { Label { Text("开始") .font(.system(size: 10, weight: .medium)) .foregroundStyle(Color.secondary) } icon: { Image(systemName: "play.circle") .font(.system(size: 11)) .foregroundStyle(Color.secondary) } Text(session.startTime.formatted(.dateTime.month().day().hour().minute())) .font(.system(size: 13, weight: .medium)) .foregroundStyle(Color.primary) } Spacer() VStack(alignment: .trailing, spacing: 4) { Label { Text("结束") .font(.system(size: 10, weight: .medium)) .foregroundStyle(Color.secondary) } icon: { Image(systemName: "stop.circle") .font(.system(size: 11)) .foregroundStyle(Color.secondary) } Text(session.endTime?.formatted(.dateTime.month().day().hour().minute()) ?? "进行中") .font(.system(size: 13, weight: .medium)) .foregroundStyle(Color.primary) } } Divider() .background(Color.lineBorder) // Stats row HStack(spacing: 0) { infoStat( icon: "timer", label: "总时长", value: session.durationFormatted ) infoStat( icon: "camera", label: "图片", value: "\(session.photoCount)" ) infoStat( icon: "doc.text", label: "笔记", value: "\(session.noteCount)" ) // Sync status VStack(spacing: 4) { Image(systemName: session.isSynced ? "cloud" : "cloud.dashed") .font(.system(size: 14, weight: .light)) .foregroundStyle(Color.secondary) Text(session.isSynced ? "已同步" : "未同步") .font(.system(size: 10, weight: .regular)) .foregroundStyle(Color.secondary) } .frame(maxWidth: .infinity) } } .padding(14) .background(Color.cardBackground.opacity(0.15)) .businessBorder(cornerRadius: 10) } private func infoStat(icon: String, label: String, value: String) -> some View { VStack(spacing: 4) { Image(systemName: icon) .font(.system(size: 14, weight: .light)) .foregroundStyle(Color.secondary) Text(value) .font(.system(size: 13, weight: .medium, design: .monospaced)) .foregroundStyle(Color.primary) Text(label) .font(.system(size: 9, weight: .regular)) .foregroundStyle(Color.secondary.opacity(0.8)) } .frame(maxWidth: .infinity) } // MARK: - Timeline Section private var timelineSection: some View { VStack(alignment: .leading, spacing: 8) { sectionHeader(icon: "waveform", title: "三轨时间线") MultiTrackTimeline( events: session.events, currentTimeMs: Binding( get: { playbackVM.currentPlaybackTimeMs }, set: { playbackVM.seekTo(timeMs: $0) } ), totalDurationMs: Double(session.durationMs), silentRanges: playbackVM.silentRanges ) .frame(height: 140) } } // MARK: - Playback Controls private var playbackControls: some View { VStack(spacing: 12) { // Time display HStack { Text(playbackVM.currentTimeFormatted) .font(.system(size: 12, weight: .regular, design: .monospaced)) .foregroundStyle(Color.primary) Spacer() Text(totalTimeFormatted) .font(.system(size: 12, weight: .regular, design: .monospaced)) .foregroundStyle(Color.secondary) } // Seek slider Slider( value: Binding( get: { playbackVM.progress }, set: { newValue in playbackVM.seekTo(timeMs: newValue * Double(session.durationMs)) } ), in: 0...1 ) .tint(Color.primary) // Play/Pause button HStack(spacing: 36) { // Rewind 10s Button { let target = max(0, playbackVM.currentPlaybackTimeMs - 10_000) playbackVM.seekTo(timeMs: target) } label: { Image(systemName: "gobackward.10") .font(.system(size: 18, weight: .light)) .foregroundStyle(Color.secondary) } // Play/Pause (High-contrast minimalist button) Button { playbackVM.togglePlayback() HapticManager.trigger(.tapFeedback) } label: { ZStack { Circle() .fill(Color.primary) .frame(width: 48, height: 48) Image(systemName: playbackVM.isPlaying ? "pause.fill" : "play.fill") .font(.system(size: 16, weight: .bold)) .foregroundStyle(Color.spaceBlack) } } // Forward 10s Button { let target = min(Double(session.durationMs), playbackVM.currentPlaybackTimeMs + 10_000) playbackVM.seekTo(timeMs: target) } label: { Image(systemName: "goforward.10") .font(.system(size: 18, weight: .light)) .foregroundStyle(Color.secondary) } } .padding(.top, 4) Divider() .background(Color.lineBorder) .padding(.vertical, 4) HStack { Label { Text("智能跳过静音") .font(.system(size: 12, weight: .medium)) .foregroundStyle(Color.primary) } icon: { Image(systemName: playbackVM.isSilenceSkipEnabled ? "waveform.badge.minus" : "waveform") .font(.system(size: 13)) .foregroundStyle(playbackVM.isSilenceSkipEnabled ? Color.primary : Color.secondary) } Spacer() if playbackVM.isAnalyzingSilence { ProgressView() .scaleEffect(0.7) .frame(width: 16, height: 16) } else if !playbackVM.silentRanges.isEmpty { Text("检测到 \(playbackVM.silentRanges.count) 处静音") .font(.system(size: 11)) .foregroundStyle(Color.secondary) } Toggle("", isOn: Binding( get: { playbackVM.isSilenceSkipEnabled }, set: { playbackVM.isSilenceSkipEnabled = $0 } )) .toggleStyle(SwitchToggleStyle(tint: Color.primary)) .labelsHidden() .scaleEffect(0.8) } } .padding(14) .background(Color.cardBackground.opacity(0.15)) .businessBorder(cornerRadius: 10) } // MARK: - Chrono Feed private var chronoFeedSection: some View { VStack(alignment: .leading, spacing: 10) { sectionHeader(icon: "list.dash", title: "时序事件列表") let sorted = playbackVM.sortedEvents(from: session) if sorted.isEmpty { HStack { Spacer() VStack(spacing: 8) { Image(systemName: "tray") .font(.system(size: 24, weight: .light)) .foregroundStyle(Color.secondary.opacity(0.3)) Text("暂无会话事件") .font(.system(size: 12)) .foregroundStyle(Color.secondary.opacity(0.5)) } .padding(.vertical, 24) Spacer() } } else { LazyVStack(spacing: 8) { ForEach(sorted) { event in ChronoFeedRow(event: event) { playbackVM.seekTo(timeMs: event.relativeTimeMs) HapticManager.trigger(.tapFeedback) } } } } } } // MARK: - Helpers private func sectionHeader(icon: String, title: String) -> some View { HStack(spacing: 6) { Image(systemName: icon) .font(.system(size: 12, weight: .light)) .foregroundStyle(Color.secondary) Text(title) .font(.system(size: 13, weight: .semibold)) .foregroundStyle(Color.primary.opacity(0.8)) } } private var totalTimeFormatted: String { let totalSeconds = Int(session.durationMs / 1000) let hours = totalSeconds / 3600 let minutes = (totalSeconds % 3600) / 60 let seconds = totalSeconds % 60 if hours > 0 { return String(format: "%d:%02d:%02d", hours, minutes, seconds) } return String(format: "%02d:%02d", minutes, seconds) } } // MARK: - Preview #Preview { NavigationStack { SessionDetailView(session: { let s = CelestiaSession(title: "Preview 现场记录 · 6.24 14:30") s.endTime = Date().addingTimeInterval(3600) return s }()) } .modelContainer(for: CelestiaSession.self, inMemory: true) }