Skip to content
This repository was archived by the owner on Apr 14, 2026. It is now read-only.

Commit ab9bd10

Browse files
committed
add audio controller
1 parent 47bc230 commit ab9bd10

4 files changed

Lines changed: 221 additions & 113 deletions

File tree

lib/ui/page/home/page/chat/widget/data_attachment.dart

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import '/ui/widget/audio_player/view.dart';
2828
import '/ui/widget/svg/svg.dart';
2929
import '/ui/widget/widget_button.dart';
3030
import '/ui/worker/cache.dart';
31-
import '/util/audio_utils.dart';
31+
import '/util/audio_utils.dart' show AudioSource;
3232

3333
/// Visual representation of a file [Attachment].
3434
class DataAttachment extends StatefulWidget {
@@ -66,10 +66,12 @@ class _DataAttachmentState extends State<DataAttachment> {
6666
if (isAudio) {
6767
if (e is LocalAttachment) {
6868
if (e.file.path != null) {
69-
return AudioPlayer(
70-
id: e.id,
71-
source: AudioSource.file(e.file.path!),
72-
filename: e.file.name,
69+
return IgnorePointer(
70+
child: AudioPlayer(
71+
id: e.id,
72+
source: AudioSource.file(e.file.path!),
73+
filename: e.file.name,
74+
),
7375
);
7476
}
7577
} else {
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
// Copyright © 2022-2026 IT ENGINEERING MANAGEMENT INC,
2+
// <https://github.com/team113>
3+
//
4+
// This program is free software: you can redistribute it and/or modify it under
5+
// the terms of the GNU Affero General Public License v3.0 as published by the
6+
// Free Software Foundation, either version 3 of the License, or (at your
7+
// option) any later version.
8+
//
9+
// This program is distributed in the hope that it will be useful, but WITHOUT
10+
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
11+
// FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License v3.0 for
12+
// more details.
13+
//
14+
// You should have received a copy of the GNU Affero General Public License v3.0
15+
// along with this program. If not, see
16+
// <https://www.gnu.org/licenses/agpl-3.0.html>.
17+
18+
import 'package:get/get.dart';
19+
20+
import '/domain/model/attachment.dart';
21+
import '/util/audio_utils.dart';
22+
import '/ui/worker/audio.dart';
23+
24+
/// Controller for audio playback that manages state for a specific audio attachment.
25+
///
26+
/// Uses [AudioWorker] to handle actual playback and synchronization.
27+
class AudioPlayerController extends GetxController {
28+
AudioPlayerController(
29+
this._audioWorker, {
30+
required this.id,
31+
required this.source,
32+
});
33+
34+
final AudioWorker _audioWorker;
35+
36+
/// Identifier for audio attachment.
37+
final AttachmentId id;
38+
39+
/// Source of audio data.
40+
final AudioSource source;
41+
42+
/// Whether this controller's audio is active in [AudioWorker].
43+
bool get isActive => _audioWorker.activeAudioId.value == id.val;
44+
45+
/// Whether audio is playing and this controller is active.
46+
bool get isPlaying => _audioWorker.isPlaying.value && isActive;
47+
48+
/// Whether audio is loading and this controller is active.
49+
bool get isLoading => _audioWorker.isLoading.value && isActive;
50+
51+
/// Current playback position. Returns [Duration.zero] if not active.
52+
Duration get position =>
53+
isActive ? _audioWorker.position.value : Duration.zero;
54+
55+
/// Total duration of audio. Returns [Duration.zero] if not active.
56+
Duration get duration =>
57+
isActive ? _audioWorker.duration.value : Duration.zero;
58+
59+
/// Sets playback position in [AudioWorker].
60+
set position(Duration e) => _audioWorker.position.value = e;
61+
62+
bool _wasPlaying = false;
63+
64+
/// Toggles playback between playing and paused states.
65+
void togglePlay() {
66+
if (isActive && isPlaying) {
67+
_audioWorker.pause();
68+
} else {
69+
_audioWorker.play(id.val, source);
70+
}
71+
}
72+
73+
/// Handles start of slider interaction.
74+
///
75+
/// Pauses playback if it was playing to allow smooth seeking.
76+
void onSliderChangeStart() async {
77+
_wasPlaying = isPlaying;
78+
if(_wasPlaying) {
79+
togglePlay();
80+
}
81+
}
82+
83+
/// Handles end of slider interaction.
84+
///
85+
/// Seeks to current slider value and resumes playback if it was
86+
/// playing before interaction started.
87+
void onSliderChangeEnd() async {
88+
await seek(getSliderValue());
89+
if (_wasPlaying) {
90+
togglePlay();
91+
}
92+
}
93+
94+
/// Seeks to specific position in milliseconds.
95+
Future<void> seek(double ms) async {
96+
await _audioWorker.seek(Duration(milliseconds: ms.toInt()));
97+
}
98+
99+
/// Returns current position in milliseconds, clamped between 0 and [duration].
100+
double getSliderValue() {
101+
final durMs = duration.inMilliseconds.toDouble();
102+
if (durMs <= 0) return 0.0;
103+
return position.inMilliseconds.toDouble().clamp(0.0, durMs);
104+
}
105+
}

lib/ui/widget/audio_player/view.dart

Lines changed: 98 additions & 104 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,13 @@
1818
import 'package:flutter/material.dart';
1919
import 'package:get/get.dart';
2020

21+
import '../animated_switcher.dart';
2122
import '../widget_button.dart';
2223
import '/domain/model/attachment.dart';
2324
import '/l10n/l10n.dart';
2425
import '/themes.dart';
25-
import '/ui/worker/audio.dart';
2626
import '/util/audio_utils.dart';
27+
import 'controller.dart';
2728

2829
/// Audio player with controls.
2930
class AudioPlayer extends StatefulWidget {
@@ -48,108 +49,108 @@ class AudioPlayer extends StatefulWidget {
4849
}
4950

5051
class _AudioPlayerState extends State<AudioPlayer> {
51-
final AudioWorker _worker = Get.find();
52-
5352
bool _hovered = false;
54-
bool _wasPlaying = false;
55-
56-
double _getSliderValue(Duration position, Duration duration) {
57-
final posMs = position.inMilliseconds.toDouble();
58-
final durMs = duration.inMilliseconds.toDouble();
59-
if (durMs <= 0) return 0.0;
60-
return posMs.clamp(0.0, durMs);
61-
}
6253

6354
@override
6455
Widget build(BuildContext context) {
6556
final style = Theme.of(context).style;
6657

67-
return Obx(() {
68-
final bool isActive = _worker.activeAudioId.value == widget.id.val;
69-
final bool isPlaying = _worker.isPlaying.value && isActive;
70-
final bool isLoading = _worker.isLoading.value && isActive;
71-
final position = _worker.position.value;
72-
final duration = _worker.duration.value;
73-
74-
return Padding(
75-
padding: const EdgeInsets.all(8.0),
76-
child: SizedBox(
77-
height: 48,
78-
child: Row(
79-
children: [
80-
MouseRegion(
81-
onEnter: (_) => setState(() => _hovered = true),
82-
onExit: (_) => setState(() => _hovered = false),
83-
child: WidgetButton(
84-
key: Key('PlayerButton${widget.id}'),
85-
onPressed: () {
86-
if (isActive && isPlaying) {
87-
_worker.pause();
88-
} else {
89-
_worker.play(widget.id.val, widget.source);
90-
}
91-
},
92-
child: AnimatedContainer(
93-
duration: const Duration(milliseconds: 200),
94-
height: 48,
95-
width: 48,
96-
decoration: BoxDecoration(
97-
shape: BoxShape.circle,
98-
color: _hovered
99-
? style.colors.backgroundAuxiliaryLighter
100-
: null,
101-
border: Border.all(width: 2, color: style.colors.primary),
58+
return GetBuilder<AudioPlayerController>(
59+
init: AudioPlayerController(
60+
Get.find(),
61+
id: widget.id,
62+
source: widget.source,
63+
),
64+
tag: widget.id.val,
65+
builder: (c) {
66+
return Padding(
67+
padding: const EdgeInsets.all(8.0),
68+
child: SizedBox(
69+
height: 48,
70+
child: Row(
71+
children: [
72+
MouseRegion(
73+
onEnter: (_) => setState(() => _hovered = true),
74+
onExit: (_) => setState(() => _hovered = false),
75+
child: WidgetButton(
76+
key: Key('PlayerButton${widget.id}'),
77+
onPressed: () {
78+
c.togglePlay();
79+
},
80+
child: AnimatedContainer(
81+
duration: const Duration(milliseconds: 200),
82+
height: 48,
83+
width: 48,
84+
decoration: BoxDecoration(
85+
shape: BoxShape.circle,
86+
color: _hovered
87+
? style.colors.backgroundAuxiliaryLighter
88+
: null,
89+
border: Border.all(
90+
width: 2,
91+
color: style.colors.primary,
92+
),
93+
),
94+
child: Obx(
95+
() => SafeAnimatedSwitcher(
96+
duration: const Duration(milliseconds: 200),
97+
child: c.isLoading
98+
? Padding(
99+
key: const ValueKey('loader'),
100+
padding: const EdgeInsets.all(8.0),
101+
child: const CircularProgressIndicator(),
102+
)
103+
: Center(
104+
key: ValueKey('icon_${c.isPlaying}'),
105+
child: Icon(
106+
c.isPlaying
107+
? Icons.pause_rounded
108+
: Icons.play_arrow_rounded,
109+
size: 36,
110+
color: const Color(0xFF1F3C5D),
111+
),
112+
),
113+
),
114+
),
102115
),
103-
child: isLoading
104-
? Padding(
105-
padding: const EdgeInsets.all(8.0),
106-
child: const CircularProgressIndicator(),
107-
)
108-
: Center(
109-
child: Icon(
110-
isPlaying
111-
? Icons.pause_rounded
112-
: Icons.play_arrow_rounded,
113-
size: 36,
114-
color: const Color(0xFF1F3C5D),
115-
),
116-
),
117116
),
118117
),
119-
),
120-
const SizedBox(width: 16),
121-
Expanded(
122-
child: Column(
123-
crossAxisAlignment: CrossAxisAlignment.start,
124-
mainAxisAlignment: MainAxisAlignment.start,
125-
children: [
126-
Text(
127-
widget.filename,
128-
style: style.fonts.small.regular.onBackground,
129-
maxLines: 1,
130-
overflow: TextOverflow.ellipsis,
131-
),
118+
const SizedBox(width: 16),
119+
Expanded(
120+
child: Column(
121+
crossAxisAlignment: CrossAxisAlignment.start,
122+
mainAxisAlignment: MainAxisAlignment.start,
123+
children: [
124+
Text(
125+
widget.filename,
126+
style: style.fonts.small.regular.onBackground,
127+
maxLines: 1,
128+
overflow: TextOverflow.ellipsis,
129+
),
132130

133-
AnimatedSwitcher(
134-
duration: const Duration(milliseconds: 300),
135-
child: isActive
136-
? KeyedSubtree(
137-
key: const ValueKey('timeline'),
138-
child: _buildTimeline(position, duration, style),
139-
)
140-
: const SizedBox.shrink(key: ValueKey('empty')),
141-
),
142-
],
131+
Obx(
132+
() => AnimatedSwitcher(
133+
duration: const Duration(milliseconds: 300),
134+
child: c.isActive
135+
? KeyedSubtree(
136+
key: const ValueKey('timeline'),
137+
child: _buildTimeline(c, style),
138+
)
139+
: const SizedBox.shrink(key: ValueKey('empty')),
140+
),
141+
),
142+
],
143+
),
143144
),
144-
),
145-
],
145+
],
146+
),
146147
),
147-
),
148-
);
149-
});
148+
);
149+
},
150+
);
150151
}
151152

152-
Widget _buildTimeline(Duration position, Duration duration, Style style) {
153+
Widget _buildTimeline(AudioPlayerController c, Style style) {
153154
return Column(
154155
children: [
155156
SliderTheme(
@@ -164,32 +165,25 @@ class _AudioPlayerState extends State<AudioPlayer> {
164165
height: 17,
165166
child: Slider(
166167
key: Key('AudioSlider${widget.id}'),
167-
onChangeStart: (_) {
168-
_wasPlaying = _worker.isPlaying.value;
169-
_worker.pause();
170-
},
171-
onChangeEnd: (_) {
172-
if (_wasPlaying) {
173-
_worker.play(widget.id.val, widget.source);
174-
}
175-
},
176-
value: _getSliderValue(position, duration),
177-
max: duration.inMilliseconds.toDouble() > 0
178-
? duration.inMilliseconds.toDouble()
168+
onChangeStart: (_) => c.onSliderChangeStart(),
169+
onChangeEnd: (v) => c.onSliderChangeEnd(),
170+
value: c.getSliderValue(),
171+
max: c.duration.inMilliseconds.toDouble() > 0
172+
? c.duration.inMilliseconds.toDouble()
179173
: 1.0,
180-
onChanged: (v) => _worker.seek(Duration(milliseconds: v.toInt())),
174+
onChanged: (v) => c.position = Duration(milliseconds: v.toInt()),
181175
),
182176
),
183177
),
184178
Row(
185179
children: [
186180
Text(
187-
position.hhMmSs(),
181+
c.position.hhMmSs(),
188182
style: style.fonts.smaller.regular.secondary,
189183
),
190184
Text(' / ', style: style.fonts.smaller.regular.secondary),
191185
Text(
192-
duration.hhMmSs(),
186+
c.duration.hhMmSs(),
193187
style: style.fonts.smaller.regular.secondary,
194188
),
195189
],

0 commit comments

Comments
 (0)