| .. | .. |
|---|
| 7 | 7 | import 'package:record/record.dart'; |
|---|
| 8 | 8 | |
|---|
| 9 | 9 | /// Manages audio recording (AAC) and playback (queue + singleton). |
|---|
| 10 | +/// |
|---|
| 11 | +/// Incoming voice chunks are queued and played sequentially. |
|---|
| 12 | +/// Manual taps play a single file (or chain from that point). |
|---|
| 10 | 13 | class AudioService { |
|---|
| 11 | 14 | AudioService._(); |
|---|
| 12 | 15 | |
|---|
| .. | .. |
|---|
| 15 | 18 | static bool _isRecording = false; |
|---|
| 16 | 19 | static String? _currentRecordingPath; |
|---|
| 17 | 20 | |
|---|
| 18 | | - // Chain playback state |
|---|
| 19 | | - static final List<String> _playbackQueue = []; |
|---|
| 20 | | - static int _queueIndex = 0; |
|---|
| 21 | | - static bool _isChainPlaying = false; |
|---|
| 21 | + // Playback queue — file paths waiting to be played |
|---|
| 22 | + static final List<String> _queue = []; |
|---|
| 23 | + static bool _isPlaying = false; |
|---|
| 24 | + |
|---|
| 25 | + // Callback when playback starts/stops — UI uses this to update play buttons |
|---|
| 26 | + static void Function()? onPlaybackStateChanged; |
|---|
| 22 | 27 | |
|---|
| 23 | 28 | // Autoplay suppression |
|---|
| 24 | 29 | static bool _isBackgrounded = false; |
|---|
| 25 | 30 | |
|---|
| 26 | 31 | /// Initialize the audio service and set up lifecycle observer. |
|---|
| 27 | 32 | static void init() { |
|---|
| 28 | | - // Listen for app lifecycle changes to suppress autoplay when backgrounded |
|---|
| 29 | 33 | WidgetsBinding.instance.addObserver(_LifecycleObserver()); |
|---|
| 30 | 34 | |
|---|
| 31 | | - // Configure audio session for playback — allows audio to continue |
|---|
| 32 | | - // when screen locks or app goes to background |
|---|
| 35 | + // Configure audio session for background playback |
|---|
| 33 | 36 | _player.setAudioContext(AudioContext( |
|---|
| 34 | 37 | iOS: AudioContextIOS( |
|---|
| 35 | 38 | category: AVAudioSessionCategory.playback, |
|---|
| .. | .. |
|---|
| 42 | 45 | ), |
|---|
| 43 | 46 | )); |
|---|
| 44 | 47 | |
|---|
| 48 | + // When a track finishes, play the next in queue |
|---|
| 45 | 49 | _player.onPlayerComplete.listen((_) { |
|---|
| 46 | | - if (_isChainPlaying) { |
|---|
| 47 | | - _playNext(); |
|---|
| 48 | | - } |
|---|
| 50 | + _onTrackComplete(); |
|---|
| 49 | 51 | }); |
|---|
| 50 | 52 | } |
|---|
| 51 | 53 | |
|---|
| 52 | | - /// Whether we are currently recording. |
|---|
| 53 | | - static bool get isRecording => _isRecording; |
|---|
| 54 | + static void _onTrackComplete() { |
|---|
| 55 | + if (_queue.isNotEmpty) { |
|---|
| 56 | + _playNextInQueue(); |
|---|
| 57 | + } else { |
|---|
| 58 | + _isPlaying = false; |
|---|
| 59 | + onPlaybackStateChanged?.call(); |
|---|
| 60 | + } |
|---|
| 61 | + } |
|---|
| 54 | 62 | |
|---|
| 55 | | - /// Whether the app is backgrounded (suppresses autoplay). |
|---|
| 63 | + static Future<void> _playNextInQueue() async { |
|---|
| 64 | + if (_queue.isEmpty) { |
|---|
| 65 | + _isPlaying = false; |
|---|
| 66 | + onPlaybackStateChanged?.call(); |
|---|
| 67 | + return; |
|---|
| 68 | + } |
|---|
| 69 | + |
|---|
| 70 | + final path = _queue.removeAt(0); |
|---|
| 71 | + try { |
|---|
| 72 | + await _player.play(DeviceFileSource(path)); |
|---|
| 73 | + _isPlaying = true; |
|---|
| 74 | + onPlaybackStateChanged?.call(); |
|---|
| 75 | + } catch (_) { |
|---|
| 76 | + // Skip broken file, try next |
|---|
| 77 | + _onTrackComplete(); |
|---|
| 78 | + } |
|---|
| 79 | + } |
|---|
| 80 | + |
|---|
| 81 | + // ── Recording ── |
|---|
| 82 | + |
|---|
| 83 | + static bool get isRecording => _isRecording; |
|---|
| 56 | 84 | static bool get isBackgrounded => _isBackgrounded; |
|---|
| 57 | 85 | |
|---|
| 58 | | - /// Start recording audio in AAC format. |
|---|
| 59 | | - /// Returns the file path where the recording will be saved. |
|---|
| 60 | 86 | static Future<String?> startRecording() async { |
|---|
| 61 | 87 | if (_isRecording) return null; |
|---|
| 62 | 88 | |
|---|
| .. | .. |
|---|
| 81 | 107 | return path; |
|---|
| 82 | 108 | } |
|---|
| 83 | 109 | |
|---|
| 84 | | - /// Stop recording and return the file path. |
|---|
| 85 | 110 | static Future<String?> stopRecording() async { |
|---|
| 86 | 111 | if (!_isRecording) return null; |
|---|
| 87 | 112 | |
|---|
| .. | .. |
|---|
| 91 | 116 | return path; |
|---|
| 92 | 117 | } |
|---|
| 93 | 118 | |
|---|
| 94 | | - /// Cancel the current recording and delete the file. |
|---|
| 95 | 119 | static Future<void> cancelRecording() async { |
|---|
| 96 | 120 | if (!_isRecording) return; |
|---|
| 97 | 121 | |
|---|
| .. | .. |
|---|
| 106 | 130 | } |
|---|
| 107 | 131 | } |
|---|
| 108 | 132 | |
|---|
| 109 | | - /// Play a single audio source (cancels any current playback). |
|---|
| 110 | | - static Future<void> playSingle(String source, |
|---|
| 111 | | - {bool cancelPrevious = true}) async { |
|---|
| 112 | | - if (cancelPrevious) { |
|---|
| 113 | | - await stopPlayback(); |
|---|
| 114 | | - } |
|---|
| 133 | + // ── Playback ── |
|---|
| 115 | 134 | |
|---|
| 116 | | - _isChainPlaying = false; |
|---|
| 117 | | - |
|---|
| 118 | | - if (source.startsWith('http://') || source.startsWith('https://')) { |
|---|
| 119 | | - await _player.play(UrlSource(source)); |
|---|
| 120 | | - } else if (source.startsWith('/')) { |
|---|
| 121 | | - await _player.play(DeviceFileSource(source)); |
|---|
| 122 | | - } else { |
|---|
| 123 | | - // Assume base64 data URI or asset |
|---|
| 124 | | - await _player.play(UrlSource(source)); |
|---|
| 125 | | - } |
|---|
| 126 | | - } |
|---|
| 127 | | - |
|---|
| 128 | | - /// Play a base64-encoded audio blob by writing to a temp file first. |
|---|
| 129 | | - /// Stops any current playback. |
|---|
| 130 | | - static Future<void> playBase64(String base64Audio) async { |
|---|
| 135 | + /// Play a single file. Stops current playback and clears the queue. |
|---|
| 136 | + static Future<void> playSingle(String source) async { |
|---|
| 131 | 137 | await stopPlayback(); |
|---|
| 132 | 138 | |
|---|
| 139 | + if (source.startsWith('/')) { |
|---|
| 140 | + await _player.play(DeviceFileSource(source)); |
|---|
| 141 | + } else { |
|---|
| 142 | + // base64 data — write to temp file first |
|---|
| 143 | + final path = await _base64ToFile(source); |
|---|
| 144 | + if (path == null) return; |
|---|
| 145 | + await _player.play(DeviceFileSource(path)); |
|---|
| 146 | + } |
|---|
| 147 | + _isPlaying = true; |
|---|
| 148 | + onPlaybackStateChanged?.call(); |
|---|
| 149 | + } |
|---|
| 150 | + |
|---|
| 151 | + /// Play a base64-encoded audio blob. Stops current playback. |
|---|
| 152 | + static Future<void> playBase64(String base64Audio) async { |
|---|
| 153 | + await stopPlayback(); |
|---|
| 133 | 154 | final path = await _base64ToFile(base64Audio); |
|---|
| 134 | 155 | if (path == null) return; |
|---|
| 135 | 156 | |
|---|
| 136 | 157 | await _player.play(DeviceFileSource(path)); |
|---|
| 158 | + _isPlaying = true; |
|---|
| 159 | + onPlaybackStateChanged?.call(); |
|---|
| 137 | 160 | } |
|---|
| 138 | 161 | |
|---|
| 139 | | - /// Queue a base64-encoded audio blob for playback. |
|---|
| 140 | | - /// If nothing is playing, starts immediately. If already playing, |
|---|
| 141 | | - /// adds to the chain queue so it plays after the current one finishes. |
|---|
| 162 | + /// Queue a base64-encoded audio blob for sequential playback. |
|---|
| 163 | + /// If nothing is playing, starts immediately. |
|---|
| 164 | + /// If already playing, appends to queue — plays after current finishes. |
|---|
| 142 | 165 | static Future<void> queueBase64(String base64Audio) async { |
|---|
| 143 | 166 | final path = await _base64ToFile(base64Audio); |
|---|
| 144 | 167 | if (path == null) return; |
|---|
| 145 | 168 | |
|---|
| 146 | | - if (_player.state == PlayerState.playing || _isChainPlaying) { |
|---|
| 147 | | - // Already playing — add to queue |
|---|
| 148 | | - _playbackQueue.add(path); |
|---|
| 169 | + if (_isPlaying) { |
|---|
| 170 | + // Already playing — just add to queue, it will play when current finishes |
|---|
| 171 | + _queue.add(path); |
|---|
| 149 | 172 | } else { |
|---|
| 150 | | - // Nothing playing — start chain |
|---|
| 151 | | - _playbackQueue.clear(); |
|---|
| 152 | | - _playbackQueue.add(path); |
|---|
| 153 | | - _queueIndex = 0; |
|---|
| 154 | | - _isChainPlaying = true; |
|---|
| 155 | | - await _playCurrent(); |
|---|
| 173 | + // Nothing playing — start immediately |
|---|
| 174 | + await _player.play(DeviceFileSource(path)); |
|---|
| 175 | + _isPlaying = true; |
|---|
| 176 | + onPlaybackStateChanged?.call(); |
|---|
| 156 | 177 | } |
|---|
| 157 | 178 | } |
|---|
| 179 | + |
|---|
| 180 | + /// Chain playback: play a list of sources sequentially. |
|---|
| 181 | + /// First one plays immediately, rest are queued. |
|---|
| 182 | + static Future<void> playChain(List<String> sources) async { |
|---|
| 183 | + if (sources.isEmpty) return; |
|---|
| 184 | + if (_isBackgrounded) return; |
|---|
| 185 | + |
|---|
| 186 | + await stopPlayback(); |
|---|
| 187 | + |
|---|
| 188 | + // Queue all except the first |
|---|
| 189 | + for (var i = 1; i < sources.length; i++) { |
|---|
| 190 | + _queue.add(sources[i]); |
|---|
| 191 | + } |
|---|
| 192 | + |
|---|
| 193 | + // Play the first one |
|---|
| 194 | + final first = sources[0]; |
|---|
| 195 | + if (first.startsWith('/')) { |
|---|
| 196 | + await _player.play(DeviceFileSource(first)); |
|---|
| 197 | + } else { |
|---|
| 198 | + final path = await _base64ToFile(first); |
|---|
| 199 | + if (path == null) return; |
|---|
| 200 | + await _player.play(DeviceFileSource(path)); |
|---|
| 201 | + } |
|---|
| 202 | + _isPlaying = true; |
|---|
| 203 | + onPlaybackStateChanged?.call(); |
|---|
| 204 | + } |
|---|
| 205 | + |
|---|
| 206 | + /// Stop all playback and clear queue. |
|---|
| 207 | + static Future<void> stopPlayback() async { |
|---|
| 208 | + _queue.clear(); |
|---|
| 209 | + _isPlaying = false; |
|---|
| 210 | + await _player.stop(); |
|---|
| 211 | + onPlaybackStateChanged?.call(); |
|---|
| 212 | + } |
|---|
| 213 | + |
|---|
| 214 | + /// Whether audio is currently playing. |
|---|
| 215 | + static bool get isPlaying => _isPlaying; |
|---|
| 216 | + |
|---|
| 217 | + // ── Helpers ── |
|---|
| 158 | 218 | |
|---|
| 159 | 219 | static Future<String?> _base64ToFile(String base64Audio) async { |
|---|
| 160 | 220 | final dir = await getTemporaryDirectory(); |
|---|
| .. | .. |
|---|
| 166 | 226 | return path; |
|---|
| 167 | 227 | } |
|---|
| 168 | 228 | |
|---|
| 169 | | - /// Chain playback: play a list of audio sources sequentially. |
|---|
| 170 | | - static Future<void> playChain(List<String> sources) async { |
|---|
| 171 | | - if (sources.isEmpty) return; |
|---|
| 172 | | - if (_isBackgrounded) return; // Suppress autoplay when backgrounded |
|---|
| 173 | | - |
|---|
| 174 | | - await stopPlayback(); |
|---|
| 175 | | - |
|---|
| 176 | | - _playbackQueue.clear(); |
|---|
| 177 | | - _playbackQueue.addAll(sources); |
|---|
| 178 | | - _queueIndex = 0; |
|---|
| 179 | | - _isChainPlaying = true; |
|---|
| 180 | | - |
|---|
| 181 | | - await _playCurrent(); |
|---|
| 182 | | - } |
|---|
| 183 | | - |
|---|
| 184 | | - static Future<void> _playCurrent() async { |
|---|
| 185 | | - if (_queueIndex >= _playbackQueue.length) { |
|---|
| 186 | | - _isChainPlaying = false; |
|---|
| 187 | | - return; |
|---|
| 188 | | - } |
|---|
| 189 | | - |
|---|
| 190 | | - final source = _playbackQueue[_queueIndex]; |
|---|
| 191 | | - if (source.startsWith('/')) { |
|---|
| 192 | | - await _player.play(DeviceFileSource(source)); |
|---|
| 193 | | - } else { |
|---|
| 194 | | - await _player.play(UrlSource(source)); |
|---|
| 195 | | - } |
|---|
| 196 | | - } |
|---|
| 197 | | - |
|---|
| 198 | | - static Future<void> _playNext() async { |
|---|
| 199 | | - _queueIndex++; |
|---|
| 200 | | - if (_queueIndex < _playbackQueue.length) { |
|---|
| 201 | | - await _playCurrent(); |
|---|
| 202 | | - } else { |
|---|
| 203 | | - _isChainPlaying = false; |
|---|
| 204 | | - } |
|---|
| 205 | | - } |
|---|
| 206 | | - |
|---|
| 207 | | - /// Stop all playback. |
|---|
| 208 | | - static Future<void> stopPlayback() async { |
|---|
| 209 | | - _isChainPlaying = false; |
|---|
| 210 | | - _playbackQueue.clear(); |
|---|
| 211 | | - await _player.stop(); |
|---|
| 212 | | - } |
|---|
| 213 | | - |
|---|
| 214 | | - /// Whether audio is currently playing. |
|---|
| 215 | | - static bool get isPlaying => |
|---|
| 216 | | - _player.state == PlayerState.playing; |
|---|
| 217 | | - |
|---|
| 218 | 229 | static List<int>? _decodeBase64(String b64) { |
|---|
| 219 | 230 | try { |
|---|
| 220 | | - // Remove data URI prefix if present |
|---|
| 221 | 231 | final cleaned = b64.contains(',') ? b64.split(',').last : b64; |
|---|
| 222 | 232 | return List<int>.from( |
|---|
| 223 | 233 | Uri.parse('data:;base64,$cleaned').data!.contentAsBytes(), |
|---|
| .. | .. |
|---|
| 227 | 237 | } |
|---|
| 228 | 238 | } |
|---|
| 229 | 239 | |
|---|
| 230 | | - /// Dispose resources. |
|---|
| 231 | 240 | static Future<void> dispose() async { |
|---|
| 232 | 241 | await cancelRecording(); |
|---|
| 233 | 242 | await stopPlayback(); |
|---|