import SwiftUI import SwiftData import AVFoundation // MARK: - HomeView /// The main launch screen of CelestiaTrace. /// Features a minimalist, line-drawn aesthetic with a central record button /// and a subtle technical grid background. struct HomeView: View { @Environment(\.modelContext) private var modelContext @ObservedObject private var bleManager: BLEManager = .shared @ObservedObject private var authManager: AuthManager = .shared @Query(sort: \CelestiaSession.startTime, order: .reverse) private var sessions: [CelestiaSession] @State private var activeRecording: ActiveRecordingPresentation? @State private var completedRecordingSession: CelestiaSession? @State private var detector = RecordingEnvironmentDetector() @State private var dismissedWarnings: Set = [] @State private var showRecordingSourcePicker = false @State private var pendingRecordingSource: RecordingSourceChoice? var onRecordingFinished: ((CelestiaSession) -> Void)? = nil var body: some View { NavigationStack { ZStack { // Dynamic clean background Color.spaceBlack .ignoresSafeArea() // Subtle technical grid background businessGrid // Main content VStack(spacing: 0) { topBar .padding(.top, 16) if let firstWarning = detector.activeWarnings.first(where: { !dismissedWarnings.contains($0) }) { DiscreetWarningBanner(warning: firstWarning) { withAnimation { _ = dismissedWarnings.insert(firstWarning) } } .padding(.top, 12) } Spacer() recordSection Spacer() } .padding(.horizontal, 24) } .navigationBarHidden(true) .onAppear { detector.checkEnvironment() restoreActiveRecordingIfNeeded() } .onOpenURL(perform: handleRecordingURL) .fullScreenCover(item: $activeRecording, onDismiss: { if let session = completedRecordingSession { completedRecordingSession = nil onRecordingFinished?(session) } }) { recording in ActiveRecordingView( session: recording.session, initialDuration: recording.initialDuration, recordingSource: recording.source, initialAction: recording.initialAction ) { completedRecordingSession = recording.session } } .sheet(isPresented: $showRecordingSourcePicker, onDismiss: { guard let source = pendingRecordingSource else { return } pendingRecordingSource = nil createSessionAndNavigate(source: source) }) { RecordingSourcePickerView(devices: connectedSparkDevices) { source in pendingRecordingSource = source showRecordingSourcePicker = false } } } } // MARK: - Top Bar private var topBar: some View { HStack(alignment: .top) { VStack(alignment: .leading, spacing: 4) { Text("星痕现场记录") .font(.system(size: 11, weight: .bold, design: .monospaced)) .foregroundStyle(Color.primary.opacity(0.8)) .tracking(3) Text(currentDateFormatted) .font(.system(size: 13, weight: .regular)) .foregroundStyle(Color.secondary) } Spacer() } } // MARK: - Record Section private var recordSection: some View { VStack(spacing: 24) { PulsingRecordButton { beginNewRecording() } Button { beginNewRecording() } label: { Text("开始") .font(.system(size: 16, weight: .medium)) .foregroundStyle(Color.primary.opacity(0.88)) .tracking(0.4) } .buttonStyle(.plain) } } // MARK: - Technical Grid Background private var businessGrid: some View { Canvas { context, size in let step: CGFloat = 40 let cols = Int(size.width / step) let rows = Int(size.height / step) for col in 0...cols { let x = CGFloat(col) * step var path = Path() path.move(to: CGPoint(x: x, y: 0)) path.addLine(to: CGPoint(x: x, y: size.height)) context.stroke(path, with: .color(Color.primary.opacity(0.015)), lineWidth: 0.5) } for row in 0...rows { let y = CGFloat(row) * step var path = Path() path.move(to: CGPoint(x: 0, y: y)) path.addLine(to: CGPoint(x: size.width, y: y)) context.stroke(path, with: .color(Color.primary.opacity(0.015)), lineWidth: 0.5) } } .ignoresSafeArea() .allowsHitTesting(false) } // MARK: - Actions private func beginNewRecording() { if connectedSparkDevices.isEmpty { createSessionAndNavigate(source: .iPhone) } else { showRecordingSourcePicker = true } } private func createSessionAndNavigate(source: RecordingSourceChoice) { let formatter = DateFormatter() formatter.dateFormat = "yyyyMMdd_HHmm" let title = "\(formatter.string(from: Date()))_现场记录" let session = CelestiaSession(title: title) modelContext.insert(session) try? modelContext.save() RecordingRecoveryStore.begin(sessionID: session.id, source: source) HapticManager.trigger(.recordStart) activeRecording = ActiveRecordingPresentation( session: session, source: source, initialDuration: 0, initialAction: nil ) } private func restoreActiveRecordingIfNeeded( sessionID requestedSessionID: UUID? = nil, action: RecordingLiveActivityAction? = nil ) { guard activeRecording == nil else { return } let persistedID = RecordingRecoveryStore.activeSessionID let activityID = RecordingLiveActivityManager.shared.recoverableSessionID let session: CelestiaSession? if let requestedSessionID { // An explicit Live Activity action must never fall through to a // different unfinished recording (especially for the stop action). session = sessions.first { $0.id == requestedSessionID && $0.endTime == nil } } else { session = [persistedID, activityID] .compactMap { $0 } .compactMap { candidateID in sessions.first { $0.id == candidateID && $0.endTime == nil } } .first ?? sessions.first { $0.endTime == nil && Date().timeIntervalSince($0.startTime) < 24 * 60 * 60 } } guard let session else { if requestedSessionID == nil, persistedID != nil { RecordingRecoveryStore.clear() } return } let source = RecordingRecoveryStore.source(for: session.id) ?? .iPhone RecordingRecoveryStore.begin(sessionID: session.id, source: source, preservingSegment: true) Task { @MainActor in await recoverPendingSegment(for: session) let duration = await recordedDuration(for: session.localAudioPath) guard activeRecording == nil, session.endTime == nil else { return } activeRecording = ActiveRecordingPresentation( session: session, source: source, initialDuration: duration, initialAction: action ) } } private func recoverPendingSegment(for session: CelestiaSession) async { guard let pendingPath = RecordingRecoveryStore.pendingSegmentPath(for: session.id), let pendingURL = AudioPathHelper.resolveURL(for: pendingPath) else { return } let existingURL = AudioPathHelper.resolveURL(for: session.localAudioPath) if existingURL?.standardizedFileURL == pendingURL.standardizedFileURL { RecordingRecoveryStore.clearPendingSegment(for: session.id) return } let recoveredURL = await AudioMerger.mergeAudioFiles( firstURL: existingURL, secondURL: pendingURL ) session.localAudioPath = AudioPathHelper.relativePath(from: recoveredURL.path) session.markContentModified() try? modelContext.save() RecordingRecoveryStore.clearPendingSegment(for: session.id) } private func recordedDuration(for path: String?) async -> TimeInterval { guard let url = AudioPathHelper.resolveURL(for: path) else { return 0 } let asset = AVURLAsset(url: url) guard let duration = try? await asset.load(.duration) else { return 0 } let seconds = duration.seconds return seconds.isFinite && seconds > 0 ? seconds : 0 } private func handleRecordingURL(_ url: URL) { guard let route = RecordingLiveActivityRoute(url: url) else { return } restoreActiveRecordingIfNeeded( sessionID: route.sessionID, action: route.action ) } private var connectedSparkDevices: [BoundDevice] { guard let userID = authManager.currentUser?.id else { return [] } return bleManager.connectedDevices(forUserId: userID) .sorted { $0.boundAt < $1.boundAt } } // MARK: - Helpers private var currentDateFormatted: String { let formatter = DateFormatter() formatter.locale = Locale(identifier: "zh_Hans") formatter.dateFormat = "yyyy年M月d日 EEEE" return formatter.string(from: Date()) } } private struct ActiveRecordingPresentation: Identifiable { let id = UUID() let session: CelestiaSession let source: RecordingSourceChoice let initialDuration: TimeInterval let initialAction: RecordingLiveActivityAction? } enum RecordingLiveActivityAction: String { case photo case note case togglePause = "toggle-pause" case stop } struct RecordingLiveActivityRoute { let sessionID: UUID let action: RecordingLiveActivityAction? init?(url: URL) { guard url.scheme == "celestiatrace", url.host == "recording" else { return nil } let components = url.pathComponents.filter { $0 != "/" } guard let first = components.first, let sessionID = UUID(uuidString: first) else { return nil } self.sessionID = sessionID self.action = components.dropFirst().first.flatMap(RecordingLiveActivityAction.init(rawValue:)) } } enum RecordingRecoveryStore { private static let defaults = UserDefaults.standard private static let sessionIDKey = "activeRecording.sessionID" private static let sourceKindKey = "activeRecording.sourceKind" private static let sourceDeviceIDKey = "activeRecording.sourceDeviceID" private static let sourceNameKey = "activeRecording.sourceName" private static let pendingSegmentPathKey = "activeRecording.pendingSegmentPath" static var activeSessionID: UUID? { defaults.string(forKey: sessionIDKey).flatMap(UUID.init(uuidString:)) } static func begin( sessionID: UUID, source: RecordingSourceChoice, preservingSegment: Bool = false ) { if !preservingSegment || activeSessionID != sessionID { defaults.removeObject(forKey: pendingSegmentPathKey) } defaults.set(sessionID.uuidString, forKey: sessionIDKey) switch source { case .iPhone: defaults.set("iphone", forKey: sourceKindKey) defaults.removeObject(forKey: sourceDeviceIDKey) defaults.removeObject(forKey: sourceNameKey) case .spark(let deviceID, let displayName): defaults.set("spark", forKey: sourceKindKey) defaults.set(deviceID, forKey: sourceDeviceIDKey) defaults.set(displayName, forKey: sourceNameKey) } } static func source(for sessionID: UUID) -> RecordingSourceChoice? { guard activeSessionID == sessionID else { return nil } switch defaults.string(forKey: sourceKindKey) { case "iphone": return .iPhone case "spark": guard let deviceID = defaults.string(forKey: sourceDeviceIDKey), let name = defaults.string(forKey: sourceNameKey) else { return nil } return .spark(deviceID: deviceID, displayName: name) default: return nil } } static func setPendingSegment(_ url: URL, for sessionID: UUID) { guard activeSessionID == sessionID else { return } defaults.set(AudioPathHelper.relativePath(from: url.path), forKey: pendingSegmentPathKey) } static func pendingSegmentPath(for sessionID: UUID) -> String? { guard activeSessionID == sessionID else { return nil } return defaults.string(forKey: pendingSegmentPathKey) } static func clearPendingSegment(for sessionID: UUID) { guard activeSessionID == sessionID else { return } defaults.removeObject(forKey: pendingSegmentPathKey) } static func clear(sessionID: UUID? = nil) { if let sessionID, activeSessionID != sessionID { return } [ sessionIDKey, sourceKindKey, sourceDeviceIDKey, sourceNameKey, pendingSegmentPathKey ].forEach(defaults.removeObject(forKey:)) } } // MARK: - Recording source picker /// Shared by new recordings and continuation recordings. When at least one /// connected Spark exists, the first Spark is selected by default. struct RecordingSourcePickerView: View { @Environment(\.dismiss) private var dismiss let devices: [BoundDevice] let onConfirm: (RecordingSourceChoice) -> Void @State private var selection: RecordingSourceChoice init(devices: [BoundDevice], onConfirm: @escaping (RecordingSourceChoice) -> Void) { self.devices = devices self.onConfirm = onConfirm if let first = devices.first { _selection = State(initialValue: .spark(deviceID: first.id, displayName: first.name)) } else { _selection = State(initialValue: .iPhone) } } var body: some View { NavigationStack { ZStack { Color.spaceBlack.ignoresSafeArea() VStack(alignment: .leading, spacing: 14) { Text("选择本次现场记录使用的录音设备") .font(.system(size: 13)) .foregroundStyle(Color.secondary) ForEach(Array(devices.enumerated()), id: \.element.id) { index, device in sourceRow( source: .spark(deviceID: device.id, displayName: device.name), title: device.name, subtitle: sparkSubtitle(device), badge: index == 0 ? "默认" : nil ) } sourceRow( source: .iPhone, title: "iPhone 麦克风", subtitle: "使用手机内置或当前系统音频输入", badge: devices.isEmpty ? "默认" : nil ) Button { onConfirm(selection) } label: { Text("开始现场记录") .font(.system(size: 14, weight: .semibold)) .foregroundStyle(Color.spaceBlack) .frame(maxWidth: .infinity) .padding(.vertical, 12) .background(Color.primary) .cornerRadius(8) } .padding(.top, 8) Spacer() } .padding(20) } .navigationTitle("录音设备") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("取消") { dismiss() } .foregroundStyle(Color.secondary) } } } .presentationDetents([.medium, .large]) .presentationBackground(Color.spaceBlack) } private func sourceRow( source: RecordingSourceChoice, title: String, subtitle: String, badge: String? ) -> some View { Button { selection = source } label: { HStack(spacing: 12) { Image(systemName: source.systemImage) .font(.system(size: 19, weight: .light)) .foregroundStyle(Color.primary) .frame(width: 34) VStack(alignment: .leading, spacing: 4) { HStack(spacing: 7) { Text(title) .font(.system(size: 14, weight: .semibold)) .foregroundStyle(Color.primary) if let badge { Text(badge) .font(.system(size: 9, weight: .bold)) .foregroundStyle(Color.spaceBlack) .padding(.horizontal, 6) .padding(.vertical, 2) .background(Color.primary) .cornerRadius(4) } } Text(subtitle) .font(.system(size: 11)) .foregroundStyle(Color.secondary) } Spacer() Image(systemName: selection == source ? "checkmark.circle.fill" : "circle") .font(.system(size: 18)) .foregroundStyle(selection == source ? Color.primary : Color.secondary) } .padding(14) .background(Color.cardBackground.opacity(selection == source ? 0.55 : 0.25)) .businessBorder(cornerRadius: 10) } .buttonStyle(.plain) } private func sparkSubtitle(_ device: BoundDevice) -> String { var parts = ["已连接"] if let battery = device.batteryLevel { parts.append("电量 \(battery)%") } if let free = device.freeStorageMB { parts.append("剩余 \(free) MB") } return parts.joined(separator: " · ") } } // MARK: - Preview #Preview { HomeView() .modelContainer(for: CelestiaSession.self, inMemory: true) }