vOOice/VoiceInk/Views/AudioPlayerView.swift
2025-02-22 11:52:41 +05:45

321 lines
11 KiB
Swift

import SwiftUI
import AVFoundation
class WaveformGenerator {
static func generateWaveformSamples(from url: URL, sampleCount: Int = 200) -> [Float] {
guard let audioFile = try? AVAudioFile(forReading: url) else { return [] }
let format = audioFile.processingFormat
// Calculate frame count and read size
let frameCount = UInt32(audioFile.length)
let samplesPerFrame = frameCount / UInt32(sampleCount)
var samples = [Float](repeating: 0.0, count: sampleCount)
guard let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: frameCount) else { return [] }
do {
try audioFile.read(into: buffer)
// Get the raw audio data
guard let channelData = buffer.floatChannelData?[0] else { return [] }
// Process the samples
for i in 0..<sampleCount {
let startFrame = UInt32(i) * samplesPerFrame
let endFrame = min(startFrame + samplesPerFrame, frameCount)
var maxAmplitude: Float = 0.0
// Find the highest amplitude in this segment
for frame in startFrame..<endFrame {
let amplitude = abs(channelData[Int(frame)])
maxAmplitude = max(maxAmplitude, amplitude)
}
samples[i] = maxAmplitude
}
// Normalize the samples
if let maxSample = samples.max(), maxSample > 0 {
samples = samples.map { $0 / maxSample }
}
return samples
} catch {
print("Error reading audio file: \(error)")
return []
}
}
}
class AudioPlayerManager: ObservableObject {
private var audioPlayer: AVAudioPlayer?
@Published var isPlaying = false
@Published var currentTime: TimeInterval = 0
@Published var duration: TimeInterval = 0
@Published var waveformSamples: [Float] = []
private var timer: Timer?
func loadAudio(from url: URL) {
do {
audioPlayer = try AVAudioPlayer(contentsOf: url)
audioPlayer?.prepareToPlay()
duration = audioPlayer?.duration ?? 0
// Generate waveform data
waveformSamples = WaveformGenerator.generateWaveformSamples(from: url)
} 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
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) {
// Removed the glass-morphic background and its overlays
// Waveform container
HStack(spacing: 1) {
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
)
}
}
.frame(maxHeight: .infinity)
.padding(.horizontal, 2)
// Hover time indicator
if isHovering {
// Time bubble
Text(formatTime(duration * Double(hoverLocation / geometry.size.width)))
.font(.system(size: 12, weight: .medium))
.monospacedDigit()
.foregroundColor(.white)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(
Capsule()
.fill(Color.accentColor)
.shadow(color: Color.black.opacity(0.1), radius: 3, x: 0, y: 2)
)
.offset(x: max(0, min(hoverLocation - 30, geometry.size.width - 60)))
.offset(y: -30)
// Progress line
Rectangle()
.fill(Color.accentColor)
.frame(width: 2)
.frame(maxHeight: .infinity)
.offset(x: hoverLocation)
.transition(.opacity)
}
}
.contentShape(Rectangle())
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { value in
hoverLocation = value.location.x
let progress = max(0, min(value.location.x / geometry.size.width, 1))
onSeek(Double(progress) * duration)
}
)
.onHover { hovering in
withAnimation(.easeInOut(duration: 0.2)) {
isHovering = hovering
}
}
.onContinuousHover { phase in
switch phase {
case .active(let location):
hoverLocation = location.x
case .ended:
break
}
}
}
.frame(height: 56)
}
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 barProgress: CGFloat {
CGFloat(sample)
}
private var isNearHover: Bool {
let barPosition = CGFloat(geometryWidth) / CGFloat(totalBars)
let hoverPosition = hoverProgress * geometryWidth
return abs(barPosition - hoverPosition) < 20
}
var body: some View {
Capsule()
.fill(
LinearGradient(
gradient: Gradient(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)) - 1, 1),
height: max(barProgress * 40, 3)
)
.scaleEffect(y: isHovering && isNearHover ? 1.2 : 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 showingTooltip = false
var body: some View {
VStack(spacing: 16) {
// Title and duration
HStack {
HStack(spacing: 6) {
Image(systemName: "waveform")
.foregroundStyle(Color.accentColor)
Text("Recording")
.font(.system(size: 14, weight: .medium))
}
.foregroundColor(.secondary)
Spacer()
Text(formatTime(playerManager.duration))
.font(.system(size: 14, weight: .medium))
.monospacedDigit()
.foregroundColor(.secondary)
}
// Waveform and controls container
VStack(spacing: 16) {
// Waveform
WaveformView(
samples: playerManager.waveformSamples,
currentTime: playerManager.currentTime,
duration: playerManager.duration,
onSeek: { time in
playerManager.seek(to: time)
}
)
// Controls
HStack(spacing: 20) {
// Play/Pause button
Button(action: {
if playerManager.isPlaying {
playerManager.pause()
} else {
playerManager.play()
}
}) {
Circle()
.fill(Color.accentColor.opacity(0.1))
.frame(width: 44, height: 44)
.overlay(
Image(systemName: playerManager.isPlaying ? "pause.fill" : "play.fill")
.font(.system(size: 18, 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
}
}
// Time
Text(formatTime(playerManager.currentTime))
.font(.system(size: 14, weight: .medium))
.monospacedDigit()
.foregroundColor(.secondary)
}
}
}
.padding(.vertical, 12)
.padding(.horizontal, 16)
.onAppear {
playerManager.loadAudio(from: url)
}
}
private func formatTime(_ time: TimeInterval) -> String {
let minutes = Int(time) / 60
let seconds = Int(time) % 60
return String(format: "%d:%02d", minutes, seconds)
}
}