1// AudioMessageView.swift
2// SophaxChat
3//
4// Playback bubble for encrypted voice messages.
5
6import SwiftUI
7import AVFoundation
8
9@MainActor
10final class AudioPlayer: NSObject, ObservableObject, AVAudioPlayerDelegate {
11
12 @Published var isPlaying: Bool = false
13 @Published var progress: Double = 0 // 0–1
14 @Published var elapsed: Double = 0 // seconds
15
16 private var player: AVAudioPlayer?
17 private var timer: Timer?
18
19 func load(_ data: Data) {
20 try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .default)
21 try? AVAudioSession.sharedInstance().setActive(true)
22 player = try? AVAudioPlayer(data: data)
23 player?.delegate = self
24 player?.prepareToPlay()
25 }
26
27 func togglePlay() {
28 guard let p = player else { return }
29 if isPlaying {
30 p.pause()
31 stopTimer()
32 isPlaying = false
33 } else {
34 p.play()
35 startTimer()
36 isPlaying = true
37 }
38 }
39
40 func stop() {
41 player?.stop()
42 player?.currentTime = 0
43 stopTimer()
44 isPlaying = false
45 progress = 0
46 elapsed = 0
47 }
48
49 // MARK: - AVAudioPlayerDelegate
50
51 nonisolated func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
52 Task { @MainActor in
53 self.stopTimer()
54 self.isPlaying = false
55 self.progress = 0
56 self.elapsed = 0
57 self.player?.currentTime = 0
58 }
59 }
60
61 // MARK: - Private
62
63 private func startTimer() {
64 timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in
65 Task { @MainActor [weak self] in
66 guard let self, let p = self.player else { return }
67 self.elapsed = p.currentTime
68 self.progress = p.duration > 0 ? p.currentTime / p.duration : 0
69 }
70 }
71 }
72
73 private func stopTimer() {
74 timer?.invalidate()
75 timer = nil
76 }
77}
78
79struct AudioMessageView: View {
80 let audioData: Data
81 let duration: Double // seconds
82 let isSent: Bool
83
84 @StateObject private var player = AudioPlayer()
85
86 var body: some View {
87 HStack(spacing: 10) {
88 // Play/pause button
89 Button {
90 player.togglePlay()
91 } label: {
92 Image(systemName: player.isPlaying ? "pause.circle.fill" : "play.circle.fill")
93 .font(.system(size: 32))
94 .foregroundStyle(isSent ? .white : .accentColor)
95 }
96 .buttonStyle(.plain)
97
98 VStack(alignment: .leading, spacing: 4) {
99 // Progress bar
100 GeometryReader { geo in
101 ZStack(alignment: .leading) {
102 Capsule()
103 .fill(isSent ? Color.white.opacity(0.35) : Color.secondary.opacity(0.25))
104 Capsule()
105 .fill(isSent ? Color.white : Color.accentColor)
106 .frame(width: geo.size.width * player.progress)
107 }
108 }
109 .frame(height: 4)
110
111 // Time
112 Text(formattedTime)
113 .font(.caption2)
114 .foregroundStyle(isSent ? .white.opacity(0.8) : .secondary)
115 }
116 }
117 .padding(.horizontal, 12)
118 .padding(.vertical, 10)
119 .onAppear { player.load(audioData) }
120 .onDisappear { player.stop() }
121 }
122
123 private var formattedTime: String {
124 let seconds = player.isPlaying ? player.elapsed : duration
125 let m = Int(seconds) / 60
126 let s = Int(seconds) % 60
127 return String(format: "%d:%02d", m, s)
128 }
129}