Matthias Nott
2026-03-21 25119a9b148a291ba0af4f9f70801d12f2309147
fix: audio chain playback, empty bubbles, playback state reset

- Rewrote AudioService with proper queue-based chain playback
- Incoming voice chunks queue sequentially (no more interrupting)
- Play button resets to play icon when track finishes
- Tap playing bubble to stop (toggle works correctly)
- onPlaybackStateChanged callback for UI state sync
- Empty text bubbles filtered on load (leftover voice chunks)
- Images persist across restarts (imageBase64 kept in storage)
- Voice messages without audio show as text after restart
4 files modified
changed files
lib/models/message.dart patch | view | blame | history
lib/screens/chat_screen.dart patch | view | blame | history
lib/services/audio_service.dart patch | view | blame | history
lib/services/message_store.dart patch | view | blame | history
lib/models/message.dart
....@@ -151,4 +151,10 @@
151151 type == MessageType.voice &&
152152 (audioUri == null || audioUri!.isEmpty) &&
153153 content.isEmpty;
154
+
155
+ /// Returns true if this is a text message with no content (empty bubble).
156
+ bool get isEmptyText =>
157
+ type == MessageType.text &&
158
+ content.trim().isEmpty &&
159
+ imageBase64 == null;
154160 }
lib/screens/chat_screen.dart
....@@ -56,6 +56,18 @@
5656 final prefs = await SharedPreferences.getInstance();
5757 _lastSeq = prefs.getInt('lastSeq') ?? 0;
5858 if (!mounted) return;
59
+
60
+ // Listen for playback state changes to reset play button UI
61
+ AudioService.onPlaybackStateChanged = () {
62
+ if (mounted) {
63
+ setState(() {
64
+ if (!AudioService.isPlaying) {
65
+ _playingMessageId = null;
66
+ }
67
+ });
68
+ }
69
+ };
70
+
5971 _initConnection();
6072 }
6173
lib/services/audio_service.dart
....@@ -7,6 +7,9 @@
77 import 'package:record/record.dart';
88
99 /// 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).
1013 class AudioService {
1114 AudioService._();
1215
....@@ -15,21 +18,21 @@
1518 static bool _isRecording = false;
1619 static String? _currentRecordingPath;
1720
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;
2227
2328 // Autoplay suppression
2429 static bool _isBackgrounded = false;
2530
2631 /// Initialize the audio service and set up lifecycle observer.
2732 static void init() {
28
- // Listen for app lifecycle changes to suppress autoplay when backgrounded
2933 WidgetsBinding.instance.addObserver(_LifecycleObserver());
3034
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
3336 _player.setAudioContext(AudioContext(
3437 iOS: AudioContextIOS(
3538 category: AVAudioSessionCategory.playback,
....@@ -42,21 +45,44 @@
4245 ),
4346 ));
4447
48
+ // When a track finishes, play the next in queue
4549 _player.onPlayerComplete.listen((_) {
46
- if (_isChainPlaying) {
47
- _playNext();
48
- }
50
+ _onTrackComplete();
4951 });
5052 }
5153
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
+ }
5462
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;
5684 static bool get isBackgrounded => _isBackgrounded;
5785
58
- /// Start recording audio in AAC format.
59
- /// Returns the file path where the recording will be saved.
6086 static Future<String?> startRecording() async {
6187 if (_isRecording) return null;
6288
....@@ -81,7 +107,6 @@
81107 return path;
82108 }
83109
84
- /// Stop recording and return the file path.
85110 static Future<String?> stopRecording() async {
86111 if (!_isRecording) return null;
87112
....@@ -91,7 +116,6 @@
91116 return path;
92117 }
93118
94
- /// Cancel the current recording and delete the file.
95119 static Future<void> cancelRecording() async {
96120 if (!_isRecording) return;
97121
....@@ -106,55 +130,91 @@
106130 }
107131 }
108132
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 ──
115134
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 {
131137 await stopPlayback();
132138
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();
133154 final path = await _base64ToFile(base64Audio);
134155 if (path == null) return;
135156
136157 await _player.play(DeviceFileSource(path));
158
+ _isPlaying = true;
159
+ onPlaybackStateChanged?.call();
137160 }
138161
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.
142165 static Future<void> queueBase64(String base64Audio) async {
143166 final path = await _base64ToFile(base64Audio);
144167 if (path == null) return;
145168
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);
149172 } 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();
156177 }
157178 }
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 ──
158218
159219 static Future<String?> _base64ToFile(String base64Audio) async {
160220 final dir = await getTemporaryDirectory();
....@@ -166,58 +226,8 @@
166226 return path;
167227 }
168228
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
-
218229 static List<int>? _decodeBase64(String b64) {
219230 try {
220
- // Remove data URI prefix if present
221231 final cleaned = b64.contains(',') ? b64.split(',').last : b64;
222232 return List<int>.from(
223233 Uri.parse('data:;base64,$cleaned').data!.contentAsBytes(),
....@@ -227,7 +237,6 @@
227237 }
228238 }
229239
230
- /// Dispose resources.
231240 static Future<void> dispose() async {
232241 await cancelRecording();
233242 await stopPlayback();
lib/services/message_store.dart
....@@ -83,7 +83,7 @@
8383 final List<dynamic> jsonList = jsonDecode(jsonStr) as List<dynamic>;
8484 final allMessages = jsonList
8585 .map((j) => _messageFromJson(j as Map<String, dynamic>))
86
- .where((m) => !m.isEmptyVoice) // Filter out voice msgs with no content
86
+ .where((m) => !m.isEmptyVoice && !m.isEmptyText)
8787 .toList();
8888
8989 // Paginate from the end (newest messages first in storage)
....@@ -107,7 +107,7 @@
107107 final List<dynamic> jsonList = jsonDecode(jsonStr) as List<dynamic>;
108108 return jsonList
109109 .map((j) => _messageFromJson(j as Map<String, dynamic>))
110
- .where((m) => !m.isEmptyVoice)
110
+ .where((m) => !m.isEmptyVoice && !m.isEmptyText)
111111 .toList();
112112 } catch (e) {
113113 return [];