400 lines
10 KiB
Swift
Executable File
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()
|
|
}
|
|
}
|