Skip to content

Commit 001cfd0

Browse files
client: implemented audio recordings in pupil book lending
1 parent c928ac0 commit 001cfd0

9 files changed

Lines changed: 444 additions & 122 deletions

File tree

16.8 KB
Loading

school_data_hub_flutter/lib/common/widgets/document_audio.dart

Lines changed: 140 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import 'dart:async';
2+
13
import 'package:flutter/foundation.dart';
24
import 'package:flutter/material.dart';
35
import 'package:just_audio/just_audio.dart';
@@ -15,49 +17,81 @@ class DocumentAudio extends StatefulWidget {
1517
final bool decrypt;
1618

1719
@override
18-
State<DocumentAudio> createState() => _DocumentAudioState();
20+
State<DocumentAudio> createState() => DocumentAudioState();
1921
}
2022

21-
class _DocumentAudioState extends State<DocumentAudio> {
22-
final AudioPlayer _player = AudioPlayer();
23+
class DocumentAudioState extends State<DocumentAudio> {
24+
AudioPlayer? _player;
2325
bool _isLoading = true;
2426
bool _isPlaying = false;
27+
bool _shutDown = false;
2528
Duration _position = Duration.zero;
2629
Duration _duration = Duration.zero;
2730
String? _errorMessage;
2831

32+
final List<StreamSubscription<dynamic>> _subscriptions = [];
33+
34+
/// Stops playback, cancels subscriptions, and disposes the native player.
35+
/// Must be awaited before the widget is removed from the tree (e.g. before
36+
/// popping a dialog) to avoid the just_audio_windows threading crash.
37+
Future<void> shutdown() async {
38+
if (_shutDown) return;
39+
_shutDown = true;
40+
41+
for (final sub in _subscriptions) {
42+
sub.cancel();
43+
}
44+
_subscriptions.clear();
45+
46+
final player = _player;
47+
_player = null;
48+
49+
if (player != null) {
50+
try {
51+
await player.stop();
52+
} catch (_) {}
53+
try {
54+
await player.dispose();
55+
} catch (_) {}
56+
}
57+
}
58+
2959
@override
3060
void initState() {
3161
super.initState();
32-
_loadAudio();
33-
_player.playerStateStream.listen((state) {
34-
if (!mounted) return;
35-
final playing =
36-
state.playing &&
37-
state.processingState != ProcessingState.completed;
38-
setState(() {
39-
_isPlaying = playing;
40-
});
41-
// Seek to start when playback completes so the user can replay.
42-
if (state.processingState == ProcessingState.completed) {
43-
_player.seek(Duration.zero);
44-
_player.pause();
45-
}
46-
});
47-
_player.positionStream.listen((pos) {
48-
if (mounted) {
62+
_player = AudioPlayer();
63+
_subscriptions.add(
64+
_player!.playerStateStream.listen((state) {
65+
if (_shutDown || !mounted) return;
66+
final playing =
67+
state.playing && state.processingState != ProcessingState.completed;
4968
setState(() {
50-
_position = pos;
69+
_isPlaying = playing;
5170
});
52-
}
53-
});
54-
_player.durationStream.listen((dur) {
55-
if (mounted && dur != null) {
71+
if (state.processingState == ProcessingState.completed) {
72+
_player?.seek(Duration.zero).then((_) => _player?.pause());
73+
}
74+
}),
75+
);
76+
_subscriptions.add(
77+
_player!.positionStream.listen((pos) {
78+
if (_shutDown || !mounted) return;
5679
setState(() {
57-
_duration = dur;
80+
_position = pos;
5881
});
59-
}
60-
});
82+
}),
83+
);
84+
_subscriptions.add(
85+
_player!.durationStream.listen((dur) {
86+
if (_shutDown || !mounted) return;
87+
if (dur != null) {
88+
setState(() {
89+
_duration = dur;
90+
});
91+
}
92+
}),
93+
);
94+
_loadAudio();
6195
}
6296

6397
Future<void> _loadAudio() async {
@@ -66,26 +100,25 @@ class _DocumentAudioState extends State<DocumentAudio> {
66100
documentId: widget.documentId,
67101
decrypt: widget.decrypt,
68102
);
69-
if (mounted) {
70-
if (file != null) {
71-
try {
72-
await _player.setFilePath(file.path);
73-
} catch (e) {
74-
if (kDebugMode) {
75-
print("Error setting file path: $e");
76-
}
77-
rethrow;
103+
if (_shutDown || !mounted) return;
104+
if (file != null) {
105+
try {
106+
await _player?.setFilePath(file.path);
107+
} catch (e) {
108+
if (kDebugMode) {
109+
print("Error setting file path: $e");
78110
}
79-
} else {
111+
if (_shutDown || !mounted) return;
80112
_errorMessage = 'Fehler beim Laden';
81113
}
114+
} else {
115+
_errorMessage = 'Fehler beim Laden';
82116
}
83117
} catch (e) {
84-
if (mounted) {
85-
_errorMessage = 'Fehler: $e';
86-
}
118+
if (_shutDown || !mounted) return;
119+
_errorMessage = 'Fehler: $e';
87120
} finally {
88-
if (mounted) {
121+
if (!_shutDown && mounted) {
89122
setState(() {
90123
_isLoading = false;
91124
});
@@ -95,7 +128,19 @@ class _DocumentAudioState extends State<DocumentAudio> {
95128

96129
@override
97130
void dispose() {
98-
_player.dispose();
131+
// Fallback: if shutdown() was not called explicitly, clean up now.
132+
if (!_shutDown) {
133+
_shutDown = true;
134+
for (final sub in _subscriptions) {
135+
sub.cancel();
136+
}
137+
_subscriptions.clear();
138+
final player = _player;
139+
_player = null;
140+
if (player != null) {
141+
player.stop().then((_) => player.dispose()).ignore();
142+
}
143+
}
99144
super.dispose();
100145
}
101146

@@ -159,10 +204,9 @@ class _DocumentAudioState extends State<DocumentAudio> {
159204
);
160205
}
161206

162-
final progress =
163-
_duration.inMilliseconds > 0
164-
? _position.inMilliseconds / _duration.inMilliseconds
165-
: 0.0;
207+
final progress = _duration.inMilliseconds > 0
208+
? _position.inMilliseconds / _duration.inMilliseconds
209+
: 0.0;
166210

167211
return Container(
168212
decoration: BoxDecoration(
@@ -183,9 +227,9 @@ class _DocumentAudioState extends State<DocumentAudio> {
183227
),
184228
onPressed: () {
185229
if (_isPlaying) {
186-
_player.pause();
230+
_player?.pause();
187231
} else {
188-
_player.play();
232+
_player?.play();
189233
}
190234
},
191235
iconSize: 28,
@@ -196,37 +240,62 @@ class _DocumentAudioState extends State<DocumentAudio> {
196240
child: Column(
197241
mainAxisSize: MainAxisSize.min,
198242
children: [
199-
ClipRRect(
200-
borderRadius: BorderRadius.circular(2),
201-
child: LinearProgressIndicator(
202-
value: progress,
203-
minHeight: 4,
204-
backgroundColor: AppColors.interactiveColor.withValues(
243+
SliderTheme(
244+
data: SliderTheme.of(context).copyWith(
245+
activeTrackColor: AppColors.interactiveColor,
246+
inactiveTrackColor: AppColors.interactiveColor.withValues(
205247
alpha: 0.15,
206248
),
207-
valueColor: AlwaysStoppedAnimation<Color>(
208-
AppColors.interactiveColor,
249+
thumbColor: AppColors.interactiveColor,
250+
overlayColor: AppColors.interactiveColor.withValues(
251+
alpha: 0.2,
209252
),
210-
),
211-
),
212-
const SizedBox(height: 2),
213-
Row(
214-
mainAxisAlignment: MainAxisAlignment.spaceBetween,
215-
children: [
216-
Text(
217-
_formatDuration(_position),
218-
style: const TextStyle(fontSize: 11, color: Colors.grey),
253+
trackHeight: 4,
254+
thumbShape: const RoundSliderThumbShape(
255+
enabledThumbRadius: 6,
219256
),
220-
Text(
221-
_formatDuration(_duration),
222-
style: const TextStyle(fontSize: 11, color: Colors.grey),
257+
overlayShape: const RoundSliderOverlayShape(
258+
overlayRadius: 14,
223259
),
224-
],
260+
),
261+
child: Slider(
262+
value: progress.clamp(0.0, 1.0),
263+
onChanged: (value) {
264+
if (_duration.inMilliseconds > 0) {
265+
final position = Duration(
266+
milliseconds: (value * _duration.inMilliseconds)
267+
.round(),
268+
);
269+
_player?.seek(position);
270+
}
271+
},
272+
),
273+
),
274+
Padding(
275+
padding: const EdgeInsets.symmetric(horizontal: 12),
276+
child: Row(
277+
mainAxisAlignment: MainAxisAlignment.spaceBetween,
278+
children: [
279+
Text(
280+
_formatDuration(_position),
281+
style: const TextStyle(
282+
fontSize: 11,
283+
color: Colors.grey,
284+
),
285+
),
286+
Text(
287+
_formatDuration(_duration),
288+
style: const TextStyle(
289+
fontSize: 11,
290+
color: Colors.grey,
291+
),
292+
),
293+
],
294+
),
225295
),
226296
],
227297
),
228298
),
229-
const SizedBox(width: 8),
230299
],
231300
),
232301
);

0 commit comments

Comments
 (0)