| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214 |
- import SwiftUI
- import SwiftData
- import UIKit
- // 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
- @ObservedObject private var bleManager: BLEManager = .shared
- @ObservedObject private var authManager: AuthManager = .shared
- @ObservedObject private var syncManager: SyncManager = .shared
- @ObservedObject private var networkMonitor: NetworkStatusMonitor = .shared
- 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 = ""
- @State private var showRecordingSourcePicker = false
- @State private var selectedRecordingSource: RecordingSourceChoice = .iPhone
- @State private var recordName = ""
- @State private var showRecordNameEditor = false
- @State private var showSessionInfo = false
- @State private var showShareUnavailableAlert = false
- @State private var audioShareItem: AudioShareItem?
- @State private var preparedShareDirectoryURL: URL?
- @State private var pendingTimelineTimeMs: Double = 0
- @State private var noteText = ""
- @State private var showNoteEditor = false
- @State private var showPhotoActionDialog = false
- @State private var showImagePicker = false
- @State private var imagePickerSource: UIImagePickerController.SourceType = .photoLibrary
- @State private var selectedTimelineEvent: CelestiaTimelineEvent?
- @State private var timelineEditError: String?
- @State private var showSyncAuthPrompt = false
- @State private var showAuthModal = false
- @State private var showLargeSyncConfirmation = false
- @State private var showSyncInfo = false
- @State private var hasApprovedLargeTransfer = false
- var body: some View {
- ZStack {
- Color.spaceBlack.ignoresSafeArea()
- ScrollView {
- VStack(spacing: 20) {
- detailHeader
- .padding(.horizontal, 16)
- .padding(.top, 12)
- // Section 1: Multi-Track Timeline
- timelineSection
- .padding(.horizontal, 16)
- // Section 2: Playback Controls
- playbackControls
- .padding(.horizontal, 16)
- // Section 3: Chrono Feed
- chronoFeedSection
- .padding(.horizontal, 16)
- .padding(.bottom, 32)
- }
- }
- }
- .navigationBarTitleDisplayMode(.inline)
- .toolbar {
- ToolbarItemGroup(placement: .topBarTrailing) {
- Button {
- showSessionInfo = true
- } label: {
- Image(systemName: "info.circle")
- .font(.system(size: 16, weight: .regular))
- .foregroundStyle(Color.primary)
- }
- .accessibilityLabel("记录信息")
- .popover(isPresented: $showSessionInfo, arrowEdge: .top) {
- sessionInfoCard
- .padding(16)
- .frame(idealWidth: 360)
- .presentationCompactAdaptation(.popover)
- }
- Button {
- prepareAudioForSharing()
- } label: {
- Image(systemName: "square.and.arrow.up")
- .font(.system(size: 16, weight: .regular))
- .foregroundStyle(Color.primary)
- }
- .accessibilityLabel("分享录音")
- }
- }
- .sheet(item: $audioShareItem, onDismiss: cleanupSharedAudioCopy) { item in
- AudioShareSheet(fileURL: item.fileURL)
- }
- .alert("无法分享录音", isPresented: $showShareUnavailableAlert) {
- Button("知道了", role: .cancel) {}
- } message: {
- Text("当前记录没有可用的本地录音文件,请先完成录制或同步录音。")
- }
- .alert("无法完成编辑", isPresented: Binding(
- get: { timelineEditError != nil },
- set: { if !$0 { timelineEditError = nil } }
- )) {
- Button("知道了", role: .cancel) {}
- } message: {
- Text(timelineEditError ?? "请稍后重试。")
- }
- .alert("录音环境提醒", isPresented: $showPrepAlert) {
- Button("继续录制", role: .none) {
- chooseSourceAndContinue()
- }
- Button("取消", role: .cancel) {}
- } message: {
- Text(activeWarningMsg + "\n\n另外请手动确认:\n1. 手机侧边静音开关已拨至红色(静音状态)\n2. 已关闭可能在此期间响起的系统闹钟")
- }
- .fullScreenCover(isPresented: $navigateToRecording) {
- ActiveRecordingView(
- session: session,
- initialDuration: Double(session.durationMs) / 1000.0,
- recordingSource: selectedRecordingSource
- ) {
- playbackVM.totalDurationMs = Double(session.durationMs)
- playbackVM.preparePlayer(path: session.localAudioPath, durationMs: Double(session.durationMs))
- playbackVM.analyzeAudio(audioURL: AudioPathHelper.resolveURL(for: session.localAudioPath))
- scheduleAutoSync()
- }
- }
- .sheet(isPresented: $showRecordingSourcePicker) {
- RecordingSourcePickerView(devices: connectedSparkDevices) { source in
- selectedRecordingSource = source
- showRecordingSourcePicker = false
- navigateToRecording = true
- }
- }
- .sheet(isPresented: $showRecordNameEditor) {
- RecordNameEditorSheet(recordName: $recordName) {
- saveRecordName()
- } onCancel: {
- recordName = session.title
- showRecordNameEditor = false
- }
- }
- .sheet(isPresented: $showNoteEditor) {
- timelineNoteEditor
- }
- .sheet(isPresented: $showImagePicker) {
- ImagePicker(sourceType: imagePickerSource) { image in
- addPhoto(image, at: pendingTimelineTimeMs)
- }
- }
- .sheet(item: $selectedTimelineEvent) { event in
- TimelineEventDetailSheet(event: event) {
- deleteTimelineEvent(event)
- }
- }
- .sheet(isPresented: $showSyncAuthPrompt) {
- SyncAuthPromptModal {
- showSyncAuthPrompt = false
- showAuthModal = true
- }
- }
- .sheet(isPresented: $showAuthModal) {
- AuthModalView()
- }
- .alert("同步较大文件", isPresented: $showLargeSyncConfirmation) {
- Button("开始同步") {
- hasApprovedLargeTransfer = true
- performSessionSync()
- }
- Button("取消", role: .cancel) {}
- } message: {
- Text(largeTransferMessage)
- }
- .confirmationDialog(
- "在 \(formattedTimelineTime(pendingTimelineTimeMs)) 添加图片",
- isPresented: $showPhotoActionDialog,
- titleVisibility: .visible
- ) {
- if UIImagePickerController.isSourceTypeAvailable(.camera) {
- Button("拍照") {
- imagePickerSource = .camera
- showImagePicker = true
- }
- }
- Button("从相册选择") {
- imagePickerSource = .photoLibrary
- showImagePicker = true
- }
- Button("取消", role: .cancel) {}
- } message: {
- Text("图片会添加到当前时间位置。")
- }
- .onAppear {
- playbackVM.totalDurationMs = Double(session.durationMs)
- playbackVM.preparePlayer(path: session.localAudioPath, durationMs: Double(session.durationMs))
- playbackVM.analyzeAudio(audioURL: AudioPathHelper.resolveURL(for: session.localAudioPath))
- if session.needsCloudSync {
- scheduleAutoSync()
- }
- }
- .onChange(of: session.syncStateRaw) { _, newValue in
- guard let state = SessionSyncState(rawValue: newValue),
- state == .pending || state == .localOnly,
- session.needsCloudSync else { return }
- scheduleAutoSync()
- }
- }
- private var detailHeader: some View {
- HStack(spacing: 12) {
- Button {
- recordName = session.title
- showRecordNameEditor = true
- } label: {
- Text(session.title)
- .font(.system(size: 22, weight: .semibold))
- .lineLimit(1)
- .minimumScaleFactor(0.75)
- .foregroundStyle(Color.primary)
- .contentShape(Rectangle())
- }
- .buttonStyle(.plain)
- .accessibilityLabel("编辑记录名称")
- .layoutPriority(1)
- Spacer(minLength: 0)
- compactSyncButton
- .fixedSize()
- }
- .frame(maxWidth: .infinity, alignment: .leading)
- }
- private func prepareContinuation() {
- playbackVM.pause()
- detector.checkEnvironment()
- if let firstWarning = detector.activeWarnings.first {
- activeWarningMsg = firstWarning
- showPrepAlert = true
- } else {
- chooseSourceAndContinue()
- }
- }
- private func chooseSourceAndContinue() {
- if connectedSparkDevices.isEmpty {
- selectedRecordingSource = .iPhone
- navigateToRecording = true
- } else {
- showRecordingSourcePicker = true
- }
- }
- private func saveRecordName() {
- let trimmedName = recordName.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !trimmedName.isEmpty else { return }
- recordName = trimmedName
- if session.title != trimmedName {
- session.title = trimmedName
- session.markContentModified()
- try? modelContext.save()
- scheduleAutoSync()
- }
- showRecordNameEditor = false
- }
- private func prepareAudioForSharing() {
- guard let sourceURL = AudioPathHelper.resolveURL(for: session.localAudioPath) else {
- showShareUnavailableAlert = true
- return
- }
- let shareDirectory = FileManager.default.temporaryDirectory
- .appendingPathComponent("CelestiaTraceShare", isDirectory: true)
- .appendingPathComponent(UUID().uuidString, isDirectory: true)
- let fileExtension = sourceURL.pathExtension
- let fileName = sanitizedShareFileName(from: session.title)
- let destinationURL = shareDirectory
- .appendingPathComponent(fileName)
- .appendingPathExtension(fileExtension)
- do {
- try FileManager.default.createDirectory(
- at: shareDirectory,
- withIntermediateDirectories: true
- )
- try FileManager.default.copyItem(at: sourceURL, to: destinationURL)
- preparedShareDirectoryURL = shareDirectory
- audioShareItem = AudioShareItem(fileURL: destinationURL)
- } catch {
- audioShareItem = nil
- preparedShareDirectoryURL = nil
- showShareUnavailableAlert = true
- }
- }
- private func sanitizedShareFileName(from title: String) -> String {
- let invalidCharacters = CharacterSet(charactersIn: "/:\\?%*|\"<>")
- .union(.controlCharacters)
- .union(.newlines)
- let components = title.components(separatedBy: invalidCharacters)
- let sanitized = components
- .joined(separator: "-")
- .trimmingCharacters(in: CharacterSet.whitespacesAndNewlines.union(
- CharacterSet(charactersIn: ".")
- ))
- return sanitized.isEmpty ? "现场记录" : sanitized
- }
- private func cleanupSharedAudioCopy() {
- guard let preparedShareDirectoryURL else { return }
- try? FileManager.default.removeItem(at: preparedShareDirectoryURL)
- self.preparedShareDirectoryURL = nil
- }
- private var connectedSparkDevices: [BoundDevice] {
- guard let userID = authManager.currentUser?.id else { return [] }
- return bleManager.connectedDevices(forUserId: userID)
- .sorted { $0.boundAt < $1.boundAt }
- }
- // 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)
- }
- VStack(alignment: .leading, spacing: 1) {
- Text(Self.chineseDateFormatter.string(from: session.startTime))
- Text(Self.chineseTimeFormatter.string(from: session.startTime))
- }
- .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)
- }
- if let endTime = session.endTime {
- VStack(alignment: .trailing, spacing: 1) {
- Text(Self.chineseDateFormatter.string(from: endTime))
- Text(Self.chineseTimeFormatter.string(from: endTime))
- }
- .font(.system(size: 13, weight: .medium))
- .foregroundStyle(Color.primary)
- } else {
- Text("进行中")
- .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)"
- )
- }
- Divider()
- .background(Color.lineBorder)
- HStack(spacing: 8) {
- Label {
- Text("版本号")
- .font(.system(size: 11, weight: .medium))
- } icon: {
- Image(systemName: "number")
- .font(.system(size: 11, weight: .medium))
- }
- .foregroundStyle(Color.secondary)
- Spacer()
- Text("v\(session.localRevision)")
- .font(.system(size: 12, weight: .semibold, design: .monospaced))
- .foregroundStyle(Color.primary)
- }
- Divider()
- .background(Color.lineBorder)
- Button {
- showSessionInfo = false
- DispatchQueue.main.async {
- prepareContinuation()
- }
- } label: {
- HStack(spacing: 8) {
- Image(systemName: "record.circle")
- .font(.system(size: 14, weight: .medium))
- Text("续录")
- .font(.system(size: 13, weight: .semibold))
- }
- .foregroundStyle(Color.primary)
- .frame(maxWidth: .infinity)
- .padding(.vertical, 10)
- .background(Color.cardBackground.opacity(0.2))
- .businessBorder(cornerRadius: 8)
- }
- .buttonStyle(.plain)
- .accessibilityLabel("继续现场记录")
- }
- .padding(14)
- .background(Color.cardBackground.opacity(0.15))
- .businessBorder(cornerRadius: 10)
- }
- private static let chineseDateFormatter: DateFormatter = {
- let formatter = DateFormatter()
- formatter.locale = Locale(identifier: "zh_CN")
- formatter.calendar = Calendar(identifier: .gregorian)
- formatter.dateFormat = "yyyy年M月d日"
- return formatter
- }()
- private static let chineseTimeFormatter: DateFormatter = {
- let formatter = DateFormatter()
- formatter.locale = Locale(identifier: "zh_CN")
- formatter.calendar = Calendar(identifier: .gregorian)
- formatter.dateFormat = "HH:mm:ss"
- return formatter
- }()
- private var compactSyncButton: some View {
- VStack(spacing: 3) {
- Button {
- if isThisSessionSyncing {
- syncManager.pauseSync(sessionID: session.id)
- } else if isDisplayedSynced {
- showSyncInfo = true
- } else {
- requestSessionSync(isAutomatic: false)
- }
- } label: {
- Group {
- if isDisplayedSyncing {
- ZStack {
- Circle()
- .stroke(Color.secondary.opacity(0.22), lineWidth: 2)
- Circle()
- .trim(from: 0, to: max(sessionSyncProgress, 0.04))
- .stroke(
- Color.lessCosmosGold,
- style: StrokeStyle(lineWidth: 2, lineCap: .round)
- )
- .rotationEffect(.degrees(-90))
- TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { context in
- Image(systemName: "arrow.triangle.2.circlepath")
- .font(.system(size: 9, weight: .semibold))
- .foregroundStyle(Color.lessCosmosGold)
- .rotationEffect(syncRotation(at: context.date))
- }
- }
- } else if isDisplayedSynced {
- Image(systemName: "checkmark.icloud.fill")
- .symbolRenderingMode(.hierarchical)
- .foregroundStyle(Color.lessCosmosGold)
- } else {
- Image(systemName: "icloud.and.arrow.up")
- .symbolRenderingMode(.hierarchical)
- .foregroundStyle(Color.secondary)
- }
- }
- .font(.system(size: 17, weight: .medium))
- .frame(width: 32, height: 32)
- .contentShape(Rectangle())
- }
- .buttonStyle(.plain)
- .disabled(syncManager.isSyncing && !isThisSessionSyncing)
- .accessibilityLabel(isThisSessionSyncing ? "暂停同步" : "同步记录")
- .accessibilityValue(
- isDisplayedSyncing
- ? "\(syncStatusText),\(Int((sessionSyncProgress * 100).rounded()))%"
- : syncStatusText
- )
- .popover(isPresented: $showSyncInfo, arrowEdge: .top) {
- syncInfoCard
- .padding(16)
- .frame(idealWidth: 320)
- .presentationCompactAdaptation(.popover)
- }
- Text(syncStatusText)
- .font(.system(size: 9, weight: .medium))
- .foregroundStyle(syncStatusColor)
- }
- }
- private var syncInfoCard: some View {
- VStack(alignment: .leading, spacing: 12) {
- HStack(spacing: 8) {
- Image(systemName: "checkmark.icloud.fill")
- .foregroundStyle(Color.lessCosmosGold)
- Text("同步信息")
- .font(.system(size: 15, weight: .semibold))
- .foregroundStyle(Color.primary)
- }
- Divider()
- .background(Color.lineBorder)
- syncInfoRow(
- label: "同步时间",
- value: session.lastSyncedAt.map {
- Self.syncDateTimeFormatter.string(from: $0)
- } ?? "历史记录未保存"
- )
- syncInfoRow(
- label: "云端版本",
- value: "v\(max(session.serverRevision, 1))",
- monospaced: true
- )
- if let cloudSessionID = session.cloudSessionId {
- syncInfoRow(
- label: "云端记录",
- value: cloudSessionID,
- monospaced: true
- )
- }
- }
- .padding(14)
- .background(Color.cardBackground.opacity(0.15))
- .businessBorder(cornerRadius: 10)
- }
- private func syncInfoRow(
- label: String,
- value: String,
- monospaced: Bool = false
- ) -> some View {
- HStack(alignment: .firstTextBaseline, spacing: 14) {
- Text(label)
- .font(.system(size: 11, weight: .medium))
- .foregroundStyle(Color.secondary)
- Spacer(minLength: 12)
- Text(value)
- .font(.system(size: 12, weight: .medium, design: monospaced ? .monospaced : .default))
- .foregroundStyle(Color.primary)
- .multilineTextAlignment(.trailing)
- .lineLimit(2)
- .textSelection(.enabled)
- }
- }
- private var isThisSessionSyncing: Bool {
- syncManager.isSyncing && syncManager.activeSessionID == session.id
- }
- private var isDisplayedSyncing: Bool {
- isThisSessionSyncing
- }
- private var isDisplayedSynced: Bool {
- !isDisplayedSyncing && session.hasConfirmedCloudSync
- }
- private var sessionSyncProgress: Double {
- if isThisSessionSyncing {
- return min(max(syncManager.syncProgress, 0), 1)
- }
- return session.hasConfirmedCloudSync ? 1 : 0
- }
- private var syncStatusText: String {
- if isDisplayedSyncing { return "正在同步" }
- return isDisplayedSynced ? "已同步" : "未同步"
- }
- private var syncStatusColor: Color {
- isDisplayedSynced || isDisplayedSyncing ? Color.lessCosmosGold : Color.secondary
- }
- private func syncRotation(at date: Date) -> Angle {
- let cycle = date.timeIntervalSinceReferenceDate
- .truncatingRemainder(dividingBy: 1.2)
- return .degrees(cycle / 1.2 * 360)
- }
- private static let syncDateTimeFormatter: DateFormatter = {
- let formatter = DateFormatter()
- formatter.locale = Locale(identifier: "zh_CN")
- formatter.calendar = Calendar(identifier: .gregorian)
- formatter.dateFormat = "yyyy年M月d日 HH:mm:ss"
- return formatter
- }()
- private var largeTransferMessage: String {
- let bytes = syncManager.estimatedUploadBytes(for: session)
- let size = ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file)
- return "预计上传约 \(size),将占用约 \(size) \(networkMonitor.connectionName)流量。是否继续?"
- }
- private func requestSessionSync(isAutomatic: Bool) {
- guard !syncManager.isSyncing else { return }
- guard networkMonitor.isConnected else {
- if !isAutomatic {
- timelineEditError = "当前没有可用网络,请连接网络后重试。"
- }
- return
- }
- guard authManager.currentUser?.id != nil else {
- if !isAutomatic {
- showSyncAuthPrompt = true
- }
- return
- }
- let largeTransferThreshold: Int64 = 70 * 1_024 * 1_024
- if syncManager.estimatedUploadBytes(for: session) >= largeTransferThreshold,
- !hasApprovedLargeTransfer {
- showLargeSyncConfirmation = true
- return
- }
- performSessionSync()
- }
- private func performSessionSync() {
- guard let userID = authManager.currentUser?.id else { return }
- Task {
- _ = await syncManager.sync(
- sessions: [session],
- modelContext: modelContext,
- userID: userID
- )
- }
- }
- private func scheduleAutoSync() {
- syncManager.scheduleAutomaticSync(
- for: session,
- modelContext: modelContext
- )
- }
- 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) {
- MultiTrackTimeline(
- events: session.events,
- currentTimeMs: Binding(
- get: { playbackVM.currentPlaybackTimeMs },
- set: { playbackVM.seekTo(timeMs: $0) }
- ),
- totalDurationMs: playbackVM.totalDurationMs,
- waveformSamples: playbackVM.waveformSamples,
- silentRanges: playbackVM.silentRanges,
- onEmptyTrackTap: handleEmptyTrackTap,
- onEventTap: { event in
- playbackVM.seekTo(timeMs: Double(event.relativeTimeMs))
- selectedTimelineEvent = event
- HapticManager.trigger(.tapFeedback)
- }
- )
- .frame(height: 220)
- }
- }
- // 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 * playbackVM.totalDurationMs)
- }
- ),
- 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(playbackVM.totalDurationMs, 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)
- if event.eventType == "PHOTO"
- || event.eventType == "NOTE"
- || event.eventType == "MARKER" {
- selectedTimelineEvent = event
- }
- 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(playbackVM.totalDurationMs / 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: - Timeline Editing
- private var timelineNoteEditor: some View {
- NavigationStack {
- ZStack {
- Color.spaceBlack.ignoresSafeArea()
- VStack(alignment: .leading, spacing: 16) {
- Label(
- "添加到 \(formattedTimelineTime(pendingTimelineTimeMs))",
- systemImage: "clock"
- )
- .font(.system(size: 12, weight: .medium))
- .foregroundStyle(Color.secondary)
- ZStack(alignment: .topLeading) {
- if noteText.isEmpty {
- Text("输入笔记内容…")
- .font(.system(size: 15))
- .foregroundStyle(Color.secondary.opacity(0.6))
- .padding(.horizontal, 18)
- .padding(.vertical, 16)
- .allowsHitTesting(false)
- }
- TextEditor(text: $noteText)
- .font(.system(size: 15))
- .foregroundStyle(Color.primary)
- .scrollContentBackground(.hidden)
- .padding(10)
- .frame(minHeight: 160)
- .background(Color.cardBackground.opacity(0.5))
- .businessBorder(cornerRadius: 8)
- }
- Spacer()
- }
- .padding(20)
- }
- .navigationTitle("添加笔记")
- .navigationBarTitleDisplayMode(.inline)
- .toolbar {
- ToolbarItem(placement: .cancellationAction) {
- Button("取消") {
- noteText = ""
- showNoteEditor = false
- }
- }
- ToolbarItem(placement: .confirmationAction) {
- Button("保存") {
- addNote(at: pendingTimelineTimeMs)
- }
- .disabled(noteText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
- }
- }
- }
- .presentationDetents([.medium])
- .presentationDragIndicator(.visible)
- .presentationBackground(Color.spaceBlack)
- }
- private func handleEmptyTrackTap(
- _ track: MultiTrackTimeline.EditableTrack,
- timeMs: Double
- ) {
- pendingTimelineTimeMs = min(max(timeMs, 0), playbackVM.totalDurationMs)
- playbackVM.seekTo(timeMs: pendingTimelineTimeMs)
- HapticManager.trigger(.tapFeedback)
- switch track {
- case .photo:
- showPhotoActionDialog = true
- case .note:
- noteText = ""
- showNoteEditor = true
- }
- }
- private func addNote(at timeMs: Double) {
- let trimmedText = noteText.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !trimmedText.isEmpty else { return }
- let event = CelestiaTimelineEvent(
- relativeTimeMs: Int64(timeMs.rounded()),
- eventType: "NOTE"
- )
- event.textContent = trimmedText
- session.events.append(event)
- do {
- try saveTimelineChanges()
- noteText = ""
- showNoteEditor = false
- } catch {
- modelContext.rollback()
- timelineEditError = "笔记保存失败:\(error.localizedDescription)"
- }
- }
- private func addPhoto(_ image: UIImage, at timeMs: Double) {
- guard let data = image.jpegData(compressionQuality: 0.85),
- let documentsURL = FileManager.default.urls(
- for: .documentDirectory,
- in: .userDomainMask
- ).first else {
- timelineEditError = "无法读取所选图片。"
- return
- }
- let filename = "photo_\(UUID().uuidString).jpg"
- let fileURL = documentsURL.appendingPathComponent(filename)
- do {
- try data.write(to: fileURL, options: .atomic)
- let event = CelestiaTimelineEvent(
- relativeTimeMs: Int64(timeMs.rounded()),
- eventType: "PHOTO"
- )
- event.localFilePath = filename
- session.events.append(event)
- do {
- try saveTimelineChanges()
- } catch {
- modelContext.rollback()
- try? FileManager.default.removeItem(at: fileURL)
- throw error
- }
- } catch {
- timelineEditError = "图片保存失败:\(error.localizedDescription)"
- }
- }
- private func deleteTimelineEvent(_ event: CelestiaTimelineEvent) {
- let photoURL = event.eventType == "PHOTO"
- ? AudioPathHelper.resolveURL(for: event.localFilePath)
- : nil
- modelContext.delete(event)
- do {
- try saveTimelineChanges()
- if let photoURL {
- try? FileManager.default.removeItem(at: photoURL)
- }
- selectedTimelineEvent = nil
- } catch {
- modelContext.rollback()
- timelineEditError = "删除失败:\(error.localizedDescription)"
- }
- }
- private func saveTimelineChanges() throws {
- session.markContentModified()
- try modelContext.save()
- scheduleAutoSync()
- }
- private func formattedTimelineTime(_ timeMs: Double) -> String {
- let totalSeconds = max(0, Int(timeMs / 1_000))
- let hours = totalSeconds / 3_600
- let minutes = (totalSeconds % 3_600) / 60
- let seconds = totalSeconds % 60
- if hours > 0 {
- return String(format: "%d:%02d:%02d", hours, minutes, seconds)
- }
- return String(format: "%02d:%02d", minutes, seconds)
- }
- }
- private struct TimelineEventDetailSheet: View {
- @Environment(\.dismiss) private var dismiss
- let event: CelestiaTimelineEvent
- let onDelete: () -> Void
- @State private var showDeleteConfirmation = false
- var body: some View {
- NavigationStack {
- ZStack {
- Color.spaceBlack.ignoresSafeArea()
- ScrollView {
- VStack(alignment: .leading, spacing: 16) {
- Label(event.relativeTimeFormatted, systemImage: "clock")
- .font(.system(size: 12, weight: .medium, design: .monospaced))
- .foregroundStyle(Color.secondary)
- eventContent
- Button(role: .destructive) {
- showDeleteConfirmation = true
- } label: {
- Label("删除这条记录", systemImage: "trash")
- .font(.system(size: 14, weight: .medium))
- .frame(maxWidth: .infinity)
- .padding(.vertical, 12)
- }
- .buttonStyle(.bordered)
- .tint(.red)
- }
- .padding(20)
- }
- }
- .navigationTitle(detailTitle)
- .navigationBarTitleDisplayMode(.inline)
- .toolbar {
- ToolbarItem(placement: .confirmationAction) {
- Button("完成") {
- dismiss()
- }
- }
- }
- .confirmationDialog(
- "确定删除这条记录吗?",
- isPresented: $showDeleteConfirmation,
- titleVisibility: .visible
- ) {
- Button("删除", role: .destructive) {
- onDelete()
- dismiss()
- }
- Button("取消", role: .cancel) {}
- } message: {
- Text(event.eventType == "PHOTO" ? "本地图片文件也会被删除,此操作无法撤销。" : "此操作无法撤销。")
- }
- }
- .presentationDetents(event.eventType == "PHOTO" ? [.medium, .large] : [.medium])
- .presentationDragIndicator(.visible)
- .presentationBackground(Color.spaceBlack)
- }
- @ViewBuilder
- private var eventContent: some View {
- if event.eventType == "PHOTO" {
- if let url = AudioPathHelper.resolveURL(for: event.localFilePath),
- let image = UIImage(contentsOfFile: url.path) {
- Image(uiImage: image)
- .resizable()
- .scaledToFit()
- .frame(maxWidth: .infinity)
- .clipShape(RoundedRectangle(cornerRadius: 10))
- .overlay {
- RoundedRectangle(cornerRadius: 10)
- .stroke(Color.lineBorder, lineWidth: 1)
- }
- } else {
- ContentUnavailableView(
- "图片不可用",
- systemImage: "photo.badge.exclamationmark",
- description: Text("本地图片文件可能已被移动或删除。")
- )
- .frame(maxWidth: .infinity, minHeight: 180)
- }
- } else {
- Text(event.textContent ?? "暂无笔记内容")
- .font(.system(size: 15))
- .foregroundStyle(Color.primary)
- .frame(maxWidth: .infinity, alignment: .leading)
- .padding(16)
- .background(Color.cardBackground.opacity(0.5))
- .businessBorder(cornerRadius: 8)
- }
- }
- private var detailTitle: String {
- if event.isContinuationMarker {
- return "续录详情"
- }
- return event.eventType == "PHOTO" ? "查看图片" : "查看笔记"
- }
- }
- private struct AudioShareItem: Identifiable {
- let id = UUID()
- let fileURL: URL
- }
- private struct AudioShareSheet: UIViewControllerRepresentable {
- let fileURL: URL
- func makeUIViewController(context: Context) -> UIActivityViewController {
- UIActivityViewController(activityItems: [fileURL], applicationActivities: nil)
- }
- func updateUIViewController(
- _ uiViewController: UIActivityViewController,
- context: Context
- ) {}
- }
- // 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)
- }
|