import AVFoundation import AppKit import ComposableArchitecture import Dependencies import HexCore import Inject import SwiftUI private let historyLogger = HexLog.history // MARK: - Date Extensions extension Date { func relativeFormatted() -> String { let calendar = Calendar.current let now = Date() if calendar.isDateInToday(self) { return "Today" } else if calendar.isDateInYesterday(self) { return "Yesterday" } else if let daysAgo = calendar.dateComponents([.day], from: self, to: now).day, daysAgo < 7 { let formatter = DateFormatter() formatter.dateFormat = "EEEE" // Day of week return formatter.string(from: self) } else { let formatter = DateFormatter() formatter.dateStyle = .medium formatter.timeStyle = .none return formatter.string(from: self) } } } // MARK: - Models extension SharedReaderKey where Self == FileStorageKey.Default { static var transcriptionHistory: Self { Self[ .fileStorage(.transcriptionHistoryURL), default: .init() ] } } // MARK: - Storage Migration extension URL { static var transcriptionHistoryURL: URL { get { URL.hexMigratedFileURL(named: "transcription_history.json") } } } class AudioPlayerController: NSObject, AVAudioPlayerDelegate { private var player: AVAudioPlayer? private let (playbackFinishedStream, playbackFinishedContinuation) = AsyncStream.makeStream() func play(url: URL) throws { let player = try AVAudioPlayer(contentsOf: url) player.delegate = self self.player = player player.play() } func stop() { player?.stop() player = nil finishPlayback() } func waitForPlaybackToFinish() async { for await _ in playbackFinishedStream {} } // AVAudioPlayerDelegate method func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { guard self.player === player else { return } self.player = nil finishPlayback() } private func finishPlayback() { playbackFinishedContinuation.finish() } } // MARK: - History Feature @Reducer struct HistoryFeature { @ObservableState struct State: Equatable { @Shared(.transcriptionHistory) var transcriptionHistory: TranscriptionHistory var playingTranscriptID: UUID? var playbackID: UUID? var audioPlayerController: AudioPlayerController? mutating func stopAudioPlayback() { audioPlayerController?.stop() audioPlayerController = nil playingTranscriptID = nil playbackID = nil } } enum Action { case playTranscript(UUID) case stopPlayback case copyToClipboard(String) case deleteTranscript(UUID) case deleteAllTranscripts case confirmDeleteAll case playbackFinished(UUID) case navigateToSettings } @Dependency(\.pasteboard) var pasteboard @Dependency(\.transcriptPersistence) var transcriptPersistence private func deleteAudioEffect(for transcripts: [Transcript]) -> Effect { .run { [transcriptPersistence] _ in for transcript in transcripts { try? await transcriptPersistence.deleteAudio(transcript) } } } var body: some ReducerOf { Reduce { state, action in switch action { case let .playTranscript(id): if state.playingTranscriptID == id { // Stop playback if tapping the same transcript state.stopAudioPlayback() return .none } // Stop any existing playback state.stopAudioPlayback() // Find the transcript and play its audio guard let transcript = state.transcriptionHistory.history.first(where: { $0.id == id }) else { return .none } do { let controller = AudioPlayerController() try controller.play(url: transcript.audioPath) let playbackID = UUID() state.audioPlayerController = controller state.playingTranscriptID = id state.playbackID = playbackID return .run { send in await controller.waitForPlaybackToFinish() await send(.playbackFinished(playbackID)) } } catch { historyLogger.error("Failed to play audio: \(error.localizedDescription)") return .none } case .stopPlayback: state.stopAudioPlayback() return .none case let .playbackFinished(playbackID): guard state.playbackID == playbackID else { return .none } state.stopAudioPlayback() return .none case let .copyToClipboard(text): return .run { [pasteboard] _ in await pasteboard.copy(text) } case let .deleteTranscript(id): guard let index = state.transcriptionHistory.history.firstIndex(where: { $0.id == id }) else { return .none } let transcript = state.transcriptionHistory.history[index] if state.playingTranscriptID == id { state.stopAudioPlayback() } _ = state.$transcriptionHistory.withLock { history in history.history.remove(at: index) } return deleteAudioEffect(for: [transcript]) case .deleteAllTranscripts: return .send(.confirmDeleteAll) case .confirmDeleteAll: let transcripts = state.transcriptionHistory.history state.stopAudioPlayback() state.$transcriptionHistory.withLock { history in history.history.removeAll() } return deleteAudioEffect(for: transcripts) case .navigateToSettings: // This will be handled by the parent reducer return .none } } } } struct TranscriptView: View { let transcript: Transcript let isPlaying: Bool let onPlay: () -> Void let onCopy: () -> Void let onDelete: () -> Void var body: some View { VStack(alignment: .leading, spacing: 0) { Text(transcript.text) .font(.body) .lineLimit(nil) .fixedSize(horizontal: false, vertical: true) .padding(.trailing, 40) // Space for buttons .padding(12) Divider() HStack { HStack(spacing: 6) { // App icon and name if let bundleID = transcript.sourceAppBundleID, let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleID) { Image(nsImage: NSWorkspace.shared.icon(forFile: appURL.path)) .resizable() .frame(width: 14, height: 14) if let appName = transcript.sourceAppName { Text(appName) } Text("•") } Image(systemName: "clock") Text(transcript.timestamp.relativeFormatted()) Text("•") Text(transcript.timestamp.formatted(date: .omitted, time: .shortened)) Text("•") Text(String(format: "%.1fs", transcript.duration)) } .font(.subheadline) .foregroundStyle(.secondary) Spacer() HStack(spacing: 10) { Button { onCopy() showCopyAnimation() } label: { HStack(spacing: 4) { Image(systemName: showCopied ? "checkmark" : "doc.on.doc.fill") if showCopied { Text("Copied").font(.caption) } } } .buttonStyle(.plain) .foregroundStyle(showCopied ? .green : .secondary) .help("Copy to clipboard") Button(action: onPlay) { Image(systemName: isPlaying ? "stop.fill" : "play.fill") } .buttonStyle(.plain) .foregroundStyle(isPlaying ? .blue : .secondary) .help(isPlaying ? "Stop playback" : "Play audio") Button(action: onDelete) { Image(systemName: "trash.fill") } .buttonStyle(.plain) .foregroundStyle(.secondary) .help("Delete transcript") } .font(.subheadline) } .frame(height: 20) .padding(.horizontal, 12) .padding(.vertical, 6) } .background( RoundedRectangle(cornerRadius: 8) .fill(Color(.windowBackgroundColor).opacity(0.5)) .overlay( RoundedRectangle(cornerRadius: 8) .strokeBorder(Color.secondary.opacity(0.2), lineWidth: 1) ) ) .onDisappear { // Clean up any running task when view disappears copyTask?.cancel() } } @State private var showCopied = false @State private var copyTask: Task? private func showCopyAnimation() { copyTask?.cancel() copyTask = Task { withAnimation { showCopied = true } try await Task.sleep(for: .seconds(1.5)) withAnimation { showCopied = false } } } } #Preview { TranscriptView( transcript: Transcript(timestamp: Date(), text: "Hello, world!", audioPath: URL(fileURLWithPath: "/Users/langton/Downloads/test.m4a"), duration: 1.0), isPlaying: false, onPlay: {}, onCopy: {}, onDelete: {} ) } struct HistoryView: View { @ObserveInjection var inject let store: StoreOf @State private var showingDeleteConfirmation = false @Shared(.hexSettings) var hexSettings: HexSettings var body: some View { Group { if !hexSettings.saveTranscriptionHistory { ContentUnavailableView { Label("History Disabled", systemImage: "clock.arrow.circlepath") } description: { Text("Transcription history is currently disabled.") } actions: { Button("Enable in Settings") { store.send(.navigateToSettings) } } } else if store.transcriptionHistory.history.isEmpty { ContentUnavailableView { Label("No Transcriptions", systemImage: "text.bubble") } description: { Text("Your transcription history will appear here.") } } else { ScrollView { LazyVStack(spacing: 12) { ForEach(store.transcriptionHistory.history) { transcript in TranscriptView( transcript: transcript, isPlaying: store.playingTranscriptID == transcript.id, onPlay: { store.send(.playTranscript(transcript.id)) }, onCopy: { store.send(.copyToClipboard(transcript.text)) }, onDelete: { store.send(.deleteTranscript(transcript.id)) } ) } } .padding() } .toolbar { Button(role: .destructive, action: { showingDeleteConfirmation = true }) { Label("Delete All", systemImage: "trash") } } .alert("Delete All Transcripts", isPresented: $showingDeleteConfirmation) { Button("Delete All", role: .destructive) { store.send(.confirmDeleteAll) } Button("Cancel", role: .cancel) {} } message: { Text("Are you sure you want to delete all transcripts? This action cannot be undone.") } } }.enableInjection() } }