Files
gart/code/Hex-main/Hex/Features/History/HistoryFeature.swift
T

400 lines
10 KiB
Swift
Executable File

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<TranscriptionHistory>.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<Void>.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<Action> {
.run { [transcriptPersistence] _ in
for transcript in transcripts {
try? await transcriptPersistence.deleteAudio(transcript)
}
}
}
var body: some ReducerOf<Self> {
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<Void, Error>?
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<HistoryFeature>
@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()
}
}