- Implement three-pane Xcode-style layout with toggleable sidebars - Refactor into modular components for better maintainability - Replace tabbed interface with iMessage-style message bubbles - Redesign audio player for 70% height reduction with horizontal layout - Update to native macOS colors and remove custom backgrounds - Increase minimum window size to 1000x700 for better usability
436 lines
16 KiB
Swift
436 lines
16 KiB
Swift
import SwiftUI
|
|
import AVFoundation
|
|
|
|
class WaveformGenerator {
|
|
static func generateWaveformSamples(from url: URL, sampleCount: Int = 200) async -> [Float] {
|
|
guard let audioFile = try? AVAudioFile(forReading: url) else { return [] }
|
|
let format = audioFile.processingFormat
|
|
let frameCount = UInt32(audioFile.length)
|
|
let stride = max(1, Int(frameCount) / sampleCount)
|
|
let bufferSize = min(UInt32(4096), frameCount)
|
|
|
|
guard let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: bufferSize) else { return [] }
|
|
|
|
do {
|
|
var maxValues = [Float](repeating: 0.0, count: sampleCount)
|
|
var sampleIndex = 0
|
|
var framePosition: AVAudioFramePosition = 0
|
|
|
|
while sampleIndex < sampleCount && framePosition < AVAudioFramePosition(frameCount) {
|
|
audioFile.framePosition = framePosition
|
|
try audioFile.read(into: buffer)
|
|
|
|
if let channelData = buffer.floatChannelData?[0], buffer.frameLength > 0 {
|
|
maxValues[sampleIndex] = abs(channelData[0])
|
|
sampleIndex += 1
|
|
}
|
|
|
|
framePosition += AVAudioFramePosition(stride)
|
|
}
|
|
|
|
if let maxSample = maxValues.max(), maxSample > 0 {
|
|
return maxValues.map { $0 / maxSample }
|
|
}
|
|
return maxValues
|
|
} catch {
|
|
print("Error reading audio file: \(error)")
|
|
return []
|
|
}
|
|
}
|
|
}
|
|
|
|
class AudioPlayerManager: ObservableObject {
|
|
private var audioPlayer: AVAudioPlayer?
|
|
private var timer: Timer?
|
|
@Published var isPlaying = false
|
|
@Published var currentTime: TimeInterval = 0
|
|
@Published var duration: TimeInterval = 0
|
|
@Published var waveformSamples: [Float] = []
|
|
@Published var isLoadingWaveform = false
|
|
|
|
func loadAudio(from url: URL) {
|
|
do {
|
|
audioPlayer = try AVAudioPlayer(contentsOf: url)
|
|
audioPlayer?.prepareToPlay()
|
|
duration = audioPlayer?.duration ?? 0
|
|
isLoadingWaveform = true
|
|
|
|
Task {
|
|
let samples = await WaveformGenerator.generateWaveformSamples(from: url)
|
|
await MainActor.run {
|
|
self.waveformSamples = samples
|
|
self.isLoadingWaveform = false
|
|
}
|
|
}
|
|
} catch {
|
|
print("Error loading audio: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
|
|
func play() {
|
|
audioPlayer?.play()
|
|
isPlaying = true
|
|
startTimer()
|
|
}
|
|
|
|
func pause() {
|
|
audioPlayer?.pause()
|
|
isPlaying = false
|
|
stopTimer()
|
|
}
|
|
|
|
func seek(to time: TimeInterval) {
|
|
audioPlayer?.currentTime = time
|
|
currentTime = time
|
|
}
|
|
|
|
private func startTimer() {
|
|
timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in
|
|
guard let self = self else { return }
|
|
self.currentTime = self.audioPlayer?.currentTime ?? 0
|
|
if self.currentTime >= self.duration {
|
|
self.pause()
|
|
self.seek(to: 0)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func stopTimer() {
|
|
timer?.invalidate()
|
|
timer = nil
|
|
}
|
|
|
|
deinit {
|
|
stopTimer()
|
|
}
|
|
}
|
|
|
|
struct WaveformView: View {
|
|
let samples: [Float]
|
|
let currentTime: TimeInterval
|
|
let duration: TimeInterval
|
|
let isLoading: Bool
|
|
var onSeek: (Double) -> Void
|
|
@State private var isHovering = false
|
|
@State private var hoverLocation: CGFloat = 0
|
|
|
|
var body: some View {
|
|
GeometryReader { geometry in
|
|
ZStack(alignment: .leading) {
|
|
if isLoading {
|
|
HStack {
|
|
ProgressView()
|
|
.controlSize(.small)
|
|
Text("Loading...")
|
|
.font(.system(size: 10))
|
|
.foregroundColor(.secondary)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
} else {
|
|
HStack(spacing: 0.5) {
|
|
ForEach(0..<samples.count, id: \.self) { index in
|
|
WaveformBar(
|
|
sample: samples[index],
|
|
isPlayed: CGFloat(index) / CGFloat(samples.count) <= CGFloat(currentTime / duration),
|
|
totalBars: samples.count,
|
|
geometryWidth: geometry.size.width,
|
|
isHovering: isHovering,
|
|
hoverProgress: hoverLocation / geometry.size.width
|
|
)
|
|
}
|
|
}
|
|
.opacity(0.6)
|
|
.frame(maxHeight: .infinity)
|
|
.padding(.horizontal, 2)
|
|
|
|
if isHovering {
|
|
Text(formatTime(duration * Double(hoverLocation / geometry.size.width)))
|
|
.font(.system(size: 10, weight: .medium))
|
|
.monospacedDigit()
|
|
.foregroundColor(.white)
|
|
.padding(.horizontal, 6)
|
|
.padding(.vertical, 3)
|
|
.background(Capsule().fill(Color.accentColor))
|
|
.offset(x: max(0, min(hoverLocation - 25, geometry.size.width - 50)))
|
|
.offset(y: -26)
|
|
|
|
Rectangle()
|
|
.fill(Color.accentColor)
|
|
.frame(width: 2)
|
|
.frame(maxHeight: .infinity)
|
|
.offset(x: hoverLocation)
|
|
}
|
|
}
|
|
}
|
|
.contentShape(Rectangle())
|
|
.gesture(
|
|
DragGesture(minimumDistance: 0)
|
|
.onChanged { value in
|
|
if !isLoading {
|
|
hoverLocation = value.location.x
|
|
onSeek(Double(value.location.x / geometry.size.width) * duration)
|
|
}
|
|
}
|
|
)
|
|
.onHover { hovering in
|
|
if !isLoading {
|
|
withAnimation(.easeInOut(duration: 0.2)) {
|
|
isHovering = hovering
|
|
}
|
|
}
|
|
}
|
|
.onContinuousHover { phase in
|
|
if !isLoading {
|
|
if case .active(let location) = phase {
|
|
hoverLocation = location.x
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.frame(height: 32)
|
|
}
|
|
|
|
private func formatTime(_ time: TimeInterval) -> String {
|
|
let minutes = Int(time) / 60
|
|
let seconds = Int(time) % 60
|
|
return String(format: "%d:%02d", minutes, seconds)
|
|
}
|
|
}
|
|
|
|
struct WaveformBar: View {
|
|
let sample: Float
|
|
let isPlayed: Bool
|
|
let totalBars: Int
|
|
let geometryWidth: CGFloat
|
|
let isHovering: Bool
|
|
let hoverProgress: CGFloat
|
|
|
|
private var isNearHover: Bool {
|
|
let barPosition = geometryWidth / CGFloat(totalBars)
|
|
let hoverPosition = hoverProgress * geometryWidth
|
|
return abs(barPosition - hoverPosition) < 20
|
|
}
|
|
|
|
var body: some View {
|
|
Capsule()
|
|
.fill(
|
|
LinearGradient(
|
|
colors: [
|
|
isPlayed ? Color.accentColor : Color.accentColor.opacity(0.3),
|
|
isPlayed ? Color.accentColor.opacity(0.8) : Color.accentColor.opacity(0.2)
|
|
],
|
|
startPoint: .bottom,
|
|
endPoint: .top
|
|
)
|
|
)
|
|
.frame(
|
|
width: max((geometryWidth / CGFloat(totalBars)) - 0.5, 1),
|
|
height: max(CGFloat(sample) * 24, 2)
|
|
)
|
|
.scaleEffect(y: isHovering && isNearHover ? 1.15 : 1.0)
|
|
.animation(.interpolatingSpring(stiffness: 300, damping: 15), value: isHovering && isNearHover)
|
|
}
|
|
}
|
|
|
|
struct AudioPlayerView: View {
|
|
let url: URL
|
|
@StateObject private var playerManager = AudioPlayerManager()
|
|
@State private var isHovering = false
|
|
@State private var isRetranscribing = false
|
|
@State private var showRetranscribeSuccess = false
|
|
@State private var showRetranscribeError = false
|
|
@State private var errorMessage = ""
|
|
@EnvironmentObject private var whisperState: WhisperState
|
|
@Environment(\.modelContext) private var modelContext
|
|
|
|
private var transcriptionService: AudioTranscriptionService {
|
|
AudioTranscriptionService(modelContext: modelContext, whisperState: whisperState)
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(spacing: 8) {
|
|
WaveformView(
|
|
samples: playerManager.waveformSamples,
|
|
currentTime: playerManager.currentTime,
|
|
duration: playerManager.duration,
|
|
isLoading: playerManager.isLoadingWaveform,
|
|
onSeek: { playerManager.seek(to: $0) }
|
|
)
|
|
.padding(.horizontal, 10)
|
|
|
|
HStack(spacing: 8) {
|
|
Text(formatTime(playerManager.currentTime))
|
|
.font(.system(size: 11, weight: .medium))
|
|
.monospacedDigit()
|
|
.foregroundColor(.secondary)
|
|
|
|
Spacer()
|
|
|
|
HStack(spacing: 8) {
|
|
Button(action: showInFinder) {
|
|
Circle()
|
|
.fill(Color.orange.opacity(0.1))
|
|
.frame(width: 32, height: 32)
|
|
.overlay(
|
|
Image(systemName: "folder")
|
|
.font(.system(size: 14, weight: .semibold))
|
|
.foregroundStyle(Color.orange)
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.help("Show in Finder")
|
|
|
|
Button(action: {
|
|
if playerManager.isPlaying {
|
|
playerManager.pause()
|
|
} else {
|
|
playerManager.play()
|
|
}
|
|
}) {
|
|
Circle()
|
|
.fill(Color.accentColor.opacity(0.1))
|
|
.frame(width: 32, height: 32)
|
|
.overlay(
|
|
Image(systemName: playerManager.isPlaying ? "pause.fill" : "play.fill")
|
|
.font(.system(size: 14, weight: .semibold))
|
|
.foregroundStyle(Color.accentColor)
|
|
.contentTransition(.symbolEffect(.replace.downUp))
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.scaleEffect(isHovering ? 1.05 : 1.0)
|
|
.onHover { hovering in
|
|
withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) {
|
|
isHovering = hovering
|
|
}
|
|
}
|
|
|
|
Button(action: retranscribeAudio) {
|
|
Circle()
|
|
.fill(Color.green.opacity(0.1))
|
|
.frame(width: 32, height: 32)
|
|
.overlay(
|
|
Group {
|
|
if isRetranscribing {
|
|
ProgressView()
|
|
.controlSize(.small)
|
|
} else if showRetranscribeSuccess {
|
|
Image(systemName: "checkmark")
|
|
.font(.system(size: 14, weight: .semibold))
|
|
.foregroundStyle(Color.green)
|
|
} else {
|
|
Image(systemName: "arrow.clockwise")
|
|
.font(.system(size: 14, weight: .semibold))
|
|
.foregroundStyle(Color.green)
|
|
}
|
|
}
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.disabled(isRetranscribing)
|
|
.help("Retranscribe this audio")
|
|
}
|
|
|
|
Spacer()
|
|
|
|
Text(formatTime(playerManager.duration))
|
|
.font(.system(size: 11, weight: .medium))
|
|
.monospacedDigit()
|
|
.foregroundColor(.secondary)
|
|
}
|
|
.padding(.horizontal, 10)
|
|
}
|
|
.padding(.top, 8)
|
|
.padding(.bottom, 6)
|
|
.onAppear {
|
|
playerManager.loadAudio(from: url)
|
|
}
|
|
.overlay(
|
|
VStack {
|
|
if showRetranscribeSuccess {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: "checkmark.circle.fill")
|
|
.foregroundColor(.green)
|
|
Text("Retranscription successful")
|
|
.font(.system(size: 14, weight: .medium))
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 10)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 8)
|
|
.fill(Color.green.opacity(0.1))
|
|
.stroke(Color.green.opacity(0.2), lineWidth: 1)
|
|
)
|
|
.transition(.move(edge: .top).combined(with: .opacity))
|
|
}
|
|
|
|
if showRetranscribeError {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: "exclamationmark.circle.fill")
|
|
.foregroundColor(.red)
|
|
Text(errorMessage.isEmpty ? "Retranscription failed" : errorMessage)
|
|
.font(.system(size: 14, weight: .medium))
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 10)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 8)
|
|
.fill(Color.red.opacity(0.1))
|
|
.stroke(Color.red.opacity(0.2), lineWidth: 1)
|
|
)
|
|
.transition(.move(edge: .top).combined(with: .opacity))
|
|
}
|
|
|
|
Spacer()
|
|
}
|
|
.padding(.top, 16)
|
|
.animation(.spring(response: 0.3, dampingFraction: 0.7), value: showRetranscribeSuccess)
|
|
.animation(.spring(response: 0.3, dampingFraction: 0.7), value: showRetranscribeError)
|
|
)
|
|
}
|
|
|
|
private func formatTime(_ time: TimeInterval) -> String {
|
|
let minutes = Int(time) / 60
|
|
let seconds = Int(time) % 60
|
|
return String(format: "%d:%02d", minutes, seconds)
|
|
}
|
|
|
|
private func showInFinder() {
|
|
NSWorkspace.shared.selectFile(url.path, inFileViewerRootedAtPath: url.deletingLastPathComponent().path)
|
|
}
|
|
|
|
private func retranscribeAudio() {
|
|
guard let currentTranscriptionModel = whisperState.currentTranscriptionModel else {
|
|
errorMessage = "No transcription model selected"
|
|
showRetranscribeError = true
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
|
|
withAnimation { showRetranscribeError = false }
|
|
}
|
|
return
|
|
}
|
|
|
|
isRetranscribing = true
|
|
|
|
Task {
|
|
do {
|
|
let _ = try await transcriptionService.retranscribeAudio(from: url, using: currentTranscriptionModel)
|
|
await MainActor.run {
|
|
isRetranscribing = false
|
|
showRetranscribeSuccess = true
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
|
|
withAnimation { showRetranscribeSuccess = false }
|
|
}
|
|
}
|
|
} catch {
|
|
await MainActor.run {
|
|
isRetranscribing = false
|
|
errorMessage = error.localizedDescription
|
|
showRetranscribeError = true
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
|
|
withAnimation { showRetranscribeError = false }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} |