2 files changed
+240
-1
lib/features/chat/views/components/audio_player_widget.dart
new
+166
@@ -0,0 +1,166 @@
1
+import 'dart:async';
2
+
3
+import 'package:audioplayers/audioplayers.dart';
4
+import 'package:flutter/material.dart';
5
+
6
+class AudioPlayerWidget extends StatefulWidget {
7
+ final String source;
8
+ final double? height;
9
+
10
+ const AudioPlayerWidget({
11
+ super.key,
12
+ required this.source,
13
+ this.height,
14
+ });
15
+
16
+ bool get isUrl => source.startsWith('http://') || source.startsWith('https://');
17
+
18
+ @override
19
+ State<AudioPlayerWidget> createState() => _AudioPlayerWidgetState();
20
+}
21
+
22
+class _AudioPlayerWidgetState extends State<AudioPlayerWidget> {
23
+ final AudioPlayer _player = AudioPlayer();
24
+ PlayerState _state = PlayerState.stopped;
25
+ Duration _position = Duration.zero;
26
+ Duration _duration = Duration.zero;
27
+ StreamSubscription? _positionSub;
28
+ StreamSubscription? _durationSub;
29
+ StreamSubscription? _stateSub;
30
+
31
+ @override
32
+ void initState() {
33
+ super.initState();
34
+ _positionSub = _player.onPositionChanged.listen((p) {
35
+ if (mounted) setState(() => _position = p);
36
+ });
37
+ _durationSub = _player.onDurationChanged.listen((d) {
38
+ if (mounted) setState(() => _duration = d);
39
+ });
40
+ _stateSub = _player.onPlayerStateChanged.listen((s) {
41
+ if (mounted) setState(() => _state = s);
42
+ });
43
+ }
44
+
45
+ @override
46
+ void dispose() {
47
+ _positionSub?.cancel();
48
+ _durationSub?.cancel();
49
+ _stateSub?.cancel();
50
+ _player.dispose();
51
+ super.dispose();
52
+ }
53
+
54
+ Future<void> _togglePlay() async {
55
+ if (_state == PlayerState.playing) {
56
+ await _player.pause();
57
+ } else {
58
+ if (_position == _duration || _position == Duration.zero) {
59
+ if (widget.isUrl) {
60
+ await _player.play(UrlSource(widget.source));
61
+ } else {
62
+ await _player.play(DeviceFileSource(widget.source));
63
+ }
64
+ } else {
65
+ await _player.resume();
66
+ }
67
+ }
68
+ }
69
+
70
+ String _formatDuration(Duration d) {
71
+ final minutes = d.inMinutes.toString().padLeft(2, '0');
72
+ final seconds = (d.inSeconds % 60).toString().padLeft(2, '0');
73
+ return '$minutes:$seconds';
74
+ }
75
+
76
+ @override
77
+ Widget build(BuildContext context) {
78
+ final theme = Theme.of(context);
79
+ final isDark = theme.brightness == Brightness.dark;
80
+ final fileName = widget.source.split('/').last;
81
+
82
+ return Container(
83
+ height: widget.height ?? 56,
84
+ constraints: const BoxConstraints(maxWidth: 280),
85
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
86
+ decoration: BoxDecoration(
87
+ color: isDark ? const Color(0xFF2A2A2A) : const Color(0xFFF0F0F0),
88
+ borderRadius: BorderRadius.circular(12),
89
+ border: Border.all(
90
+ color: isDark ? const Color(0xFF3A3A3A) : const Color(0xFFE5E5E5),
91
+ ),
92
+ ),
93
+ child: Row(
94
+ mainAxisSize: MainAxisSize.min,
95
+ children: [
96
+ IconButton(
97
+ icon: Icon(
98
+ _state == PlayerState.playing ? Icons.pause_circle_filled : Icons.play_circle_filled,
99
+ size: 28,
100
+ color: isDark ? Colors.white : Colors.black87,
101
+ ),
102
+ onPressed: _togglePlay,
103
+ padding: EdgeInsets.zero,
104
+ constraints: const BoxConstraints(),
105
+ ),
106
+ const SizedBox(width: 8),
107
+ Expanded(
108
+ child: Column(
109
+ mainAxisAlignment: MainAxisAlignment.center,
110
+ crossAxisAlignment: CrossAxisAlignment.start,
111
+ children: [
112
+ Text(
113
+ fileName,
114
+ style: TextStyle(
115
+ fontSize: 12,
116
+ fontWeight: FontWeight.w500,
117
+ color: isDark ? Colors.white70 : Colors.black87,
118
+ ),
119
+ maxLines: 1,
120
+ overflow: TextOverflow.ellipsis,
121
+ ),
122
+ const SizedBox(height: 2),
123
+ Row(
124
+ children: [
125
+ Text(
126
+ _formatDuration(_position),
127
+ style: TextStyle(
128
+ fontSize: 10,
129
+ color: isDark ? Colors.white38 : Colors.black45,
130
+ ),
131
+ ),
132
+ Expanded(
133
+ child: Padding(
134
+ padding: const EdgeInsets.symmetric(horizontal: 6),
135
+ child: ClipRRect(
136
+ borderRadius: BorderRadius.circular(2),
137
+ child: LinearProgressIndicator(
138
+ value: _duration.inMilliseconds > 0
139
+ ? _position.inMilliseconds / _duration.inMilliseconds
140
+ : 0,
141
+ backgroundColor: isDark ? Colors.white12 : Colors.black12,
142
+ valueColor: AlwaysStoppedAnimation<Color>(
143
+ isDark ? Colors.white60 : Colors.black54,
144
+ ),
145
+ minHeight: 3,
146
+ ),
147
+ ),
148
+ ),
149
+ ),
150
+ Text(
151
+ _formatDuration(_duration),
152
+ style: TextStyle(
153
+ fontSize: 10,
154
+ color: isDark ? Colors.white38 : Colors.black45,
155
+ ),
156
+ ),
157
+ ],
158
+ ),
159
+ ],
160
+ ),
161
+ ),
162
+ ],
163
+ ),
164
+ );
165
+ }
166
+}
lib/features/chat/views/components/chat_bubble.dart
+74
-1
@@ -8,6 +8,7 @@ import '../../data/models/message.dart';
8
import 'message_action_bar.dart';
9
import 'processing_indicator.dart';
10
import 'typing_indicator.dart';
11
+import 'audio_player_widget.dart';
12
import 'reasoning_widget.dart';
13
import '../../data/tools/tool_definition.dart';
14
import '../../data/tools/tool_event.dart';
@@ -444,6 +445,18 @@ class _ThemedGptMarkdown extends StatelessWidget {
445
final bool isDark;
446
final TextStyle style;
447
448
+ static bool _isAudioUrl(String url) {
449
+ final lower = url.toLowerCase();
450
+ return lower.endsWith('.mp3') ||
451
+ lower.endsWith('.wav') ||
452
+ lower.endsWith('.ogg') ||
453
+ lower.endsWith('.flac') ||
454
+ lower.endsWith('.aac') ||
455
+ lower.endsWith('.m4a') ||
456
+ lower.endsWith('.wma') ||
457
+ lower.endsWith('.opus');
458
+ }
459
+
460
@override
461
Widget build(BuildContext context) {
462
final gptTheme = GptMarkdownThemeData(
@@ -467,7 +480,51 @@ class _ThemedGptMarkdown extends StatelessWidget {
480
481
return GptMarkdownTheme(
482
gptThemeData: gptTheme,
470
- child: GptMarkdown(content, style: style, followLinkColor: true),
483
+ child: GptMarkdown(
484
+ content,
485
+ style: style,
486
+ followLinkColor: true,
487
+ imageBuilder: _buildImageOrAudio,
488
+ onLinkTap: (url, title) {
489
+ if (_isAudioUrl(url) && context.mounted) {
490
+ showDialog(
491
+ context: context,
492
+ builder: (ctx) => AlertDialog(
493
+ contentPadding: const EdgeInsets.all(16),
494
+ content: AudioPlayerWidget(source: url),
495
+ ),
496
+ );
497
+ }
498
+ },
499
+ ),
500
+ );
501
+ }
502
+
503
+ Widget _buildImageOrAudio(BuildContext context, String url, double? width, double? height) {
504
+ if (_isAudioUrl(url)) {
505
+ return AudioPlayerWidget(source: url, height: 56);
506
+ }
507
+ return SizedBox(
508
+ width: width,
509
+ height: height,
510
+ child: Image(
511
+ image: NetworkImage(url),
512
+ loadingBuilder: (context, child, loadingProgress) {
513
+ if (loadingProgress == null) return child;
514
+ return Center(
515
+ child: CircularProgressIndicator(
516
+ value: loadingProgress.expectedTotalBytes != null
517
+ ? loadingProgress.cumulativeBytesLoaded /
518
+ loadingProgress.expectedTotalBytes!
519
+ : null,
520
+ ),
521
+ );
522
+ },
523
+ errorBuilder: (context, error, stackTrace) => Container(
524
+ color: Colors.grey[800],
525
+ child: const Center(child: Icon(Icons.broken_image, color: Colors.white54)),
526
+ ),
527
+ ),
528
);
529
}
530
}
@@ -891,6 +948,18 @@ class _AttachmentItem extends StatelessWidget {
948
mime.endsWith('.webp');
949
}
950
951
+ bool _isAudio(String path) {
952
+ final ext = path.toLowerCase();
953
+ return ext.endsWith('.mp3') ||
954
+ ext.endsWith('.wav') ||
955
+ ext.endsWith('.ogg') ||
956
+ ext.endsWith('.flac') ||
957
+ ext.endsWith('.aac') ||
958
+ ext.endsWith('.m4a') ||
959
+ ext.endsWith('.wma') ||
960
+ ext.endsWith('.opus');
961
+ }
962
+
963
void _viewImage(BuildContext context) {
964
showDialog(
965
context: context,
@@ -923,6 +992,10 @@ class _AttachmentItem extends StatelessWidget {
992
);
993
}
994
995
+ if (_isAudio(path)) {
996
+ return AudioPlayerWidget(source: path);
997
+ }
998
+
999
return Container(
1000
width: 160,
1001
padding: const EdgeInsets.all(12),