@tduncan / Cortana / commits / e513421

Added audio player widget & markdown audio support.

Co-authored-by: abdulmominsakib <abdulmominsakib@users.noreply.github.com>

opencode-agent[bot] committed May 27, 2026 at 17:15 UTC e51342195a576f6f7df1604dc508c7a1840466b9
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),