You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
cloudsolutions-atoms/lib/modules/copilot/widgets/tts_voice_player.dart

238 lines
7.8 KiB
Dart

import 'dart:typed_data';
import 'package:audioplayers/audioplayers.dart';
import 'package:flutter/material.dart';
import 'package:test_sa/extensions/int_extensions.dart';
import 'package:test_sa/extensions/text_extensions.dart';
import 'package:test_sa/extensions/widget_extensions.dart';
import 'package:test_sa/new_views/app_style/app_color.dart';
import '../utils/wav_merge.dart';
/// A self-contained voice-message style player for Push-to-Talk responses,
/// with play/pause, seek, playback speed, and mute controls.
class TtsVoicePlayer extends StatefulWidget {
final List<Uint8List> clips;
const TtsVoicePlayer({Key? key, required this.clips}) : super(key: key);
@override
State<TtsVoicePlayer> createState() => _TtsVoicePlayerState();
}
class _TtsVoicePlayerState extends State<TtsVoicePlayer> {
static const List<double> _speeds = [1.0, 1.25, 1.5, 2.0, 0.75];
final AudioPlayer _player = AudioPlayer();
int _index = -1;
Duration _clipPosition = Duration.zero;
bool _playing = false;
bool _userPaused = false;
bool _muted = false;
double _speed = 1.0;
bool _sliderMoving = false;
double? _previewTotalMs;
bool _finished = false;
List<Duration> get _clipDurations => widget.clips.map(wavDuration).toList();
Duration get _totalDuration => _clipDurations.fold(Duration.zero, (a, b) => a + b);
Duration get _elapsedBeforeCurrent {
if (_index <= 0) return Duration.zero;
return _clipDurations.take(_index).fold(Duration.zero, (a, b) => a + b);
}
Duration get _totalElapsed => _finished ? Duration.zero : _elapsedBeforeCurrent + _clipPosition;
@override
void initState() {
super.initState();
_player.setReleaseMode(ReleaseMode.stop);
_player.onPositionChanged.listen((p) {
if (!_sliderMoving && mounted) setState(() => _clipPosition = p);
});
_player.onPlayerStateChanged.listen((state) {
if (mounted) setState(() => _playing = state == PlayerState.playing);
});
_player.onPlayerComplete.listen((_) => _onClipComplete());
_autoAdvanceIfNeeded();
}
@override
void didUpdateWidget(covariant TtsVoicePlayer oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.clips.length != oldWidget.clips.length) {
_autoAdvanceIfNeeded();
}
}
@override
void dispose() {
_player.dispose();
super.dispose();
}
void _autoAdvanceIfNeeded() {
if (_userPaused || widget.clips.isEmpty) return;
if (_index == -1) {
_playClipAt(0);
} else if (!_playing && _index == widget.clips.length - 2) {
// We were waiting for more clips after finishing the previous-to-last one.
_playClipAt(_index + 1);
}
}
void _onClipComplete() {
if (_userPaused) return;
if (_index + 1 < widget.clips.length) {
_playClipAt(_index + 1);
} else {
setState(() {
_playing = false;
_finished = true;
_clipPosition = Duration.zero;
});
}
}
Future<void> _playClipAt(int i) async {
if (i < 0 || i >= widget.clips.length) return;
setState(() {
_index = i;
_clipPosition = Duration.zero;
_finished = false;
});
await _player.play(BytesSource(widget.clips[i], mimeType: 'audio/wav'));
await _player.setPlaybackRate(_speed);
if (_muted) await _player.setVolume(0);
}
Future<void> _togglePlayPause() async {
if (_playing) {
_userPaused = true;
await _player.pause();
return;
}
_userPaused = false;
if (_index == -1 || _finished) {
await _playClipAt(0);
return;
}
await _player.resume();
}
Future<void> _toggleMute() async {
setState(() => _muted = !_muted);
await _player.setVolume(_muted ? 0 : 1);
}
Future<void> _cycleSpeed() async {
final idx = _speeds.indexOf(_speed);
final next = _speeds[(idx + 1) % _speeds.length];
setState(() => _speed = next);
await _player.setPlaybackRate(next);
}
Future<void> _seekToTotal(Duration target) async {
final durations = _clipDurations;
Duration acc = Duration.zero;
for (var i = 0; i < durations.length; i++) {
final clipEnd = acc + durations[i];
final isLast = i == durations.length - 1;
if (target < clipEnd || isLast) {
final offset = target - acc;
_userPaused = false;
if (i != _index || _finished) {
await _playClipAt(i);
}
await _player.seek(offset.isNegative ? Duration.zero : offset);
return;
}
acc = clipEnd;
}
}
String _format(Duration? d) {
final dur = d ?? Duration.zero;
final m = dur.inMinutes.remainder(60).toString().padLeft(2, '0');
final s = dur.inSeconds.remainder(60).toString().padLeft(2, '0');
return "$m:$s";
}
String get _speedLabel => _speed == _speed.roundToDouble() ? "${_speed.toInt()}x" : "${_speed}x";
IconData get _playIcon {
if (_playing) return Icons.pause_rounded;
if (_finished) return Icons.replay_rounded;
return Icons.play_arrow_rounded;
}
@override
Widget build(BuildContext context) {
final totalMs = _totalDuration.inMilliseconds.toDouble();
final elapsedMs = (_previewTotalMs ?? _totalElapsed.inMilliseconds.toDouble()).clamp(0, totalMs == 0 ? 1 : totalMs);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
decoration: BoxDecoration(
color: AppColor.background(context),
borderRadius: BorderRadius.circular(24),
border: Border.all(color: AppColor.border2Color(context)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
onPressed: _togglePlayPause,
style: const ButtonStyle(tapTargetSize: MaterialTapTargetSize.shrinkWrap),
constraints: const BoxConstraints(),
icon: Icon(_playIcon, color: AppColor.primary10, size: 26),
),
SliderTheme(
data: SliderTheme.of(context).copyWith(
trackHeight: 2.0,
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 5.0),
overlayShape: SliderComponentShape.noOverlay,
),
child: Slider(
value: elapsedMs.toDouble(),
min: 0,
max: totalMs == 0 ? 1 : totalMs,
activeColor: AppColor.primary10,
inactiveColor: AppColor.border2Color(context),
onChangeStart: (_) => _sliderMoving = true,
onChanged: (value) => setState(() => _previewTotalMs = value),
onChangeEnd: (value) async {
_sliderMoving = false;
_previewTotalMs = null;
await _seekToTotal(Duration(milliseconds: value.round()));
},
),
).expanded,
Text(
"${_format(Duration(milliseconds: elapsedMs.round()))}/${_format(_totalDuration)}",
style: AppTextStyles.tinyFont.copyWith(color: AppColor.textStyleColor(context)),
),
4.width,
// IconButton(
// onPressed: _cycleSpeed,
// style: const ButtonStyle(tapTargetSize: MaterialTapTargetSize.shrinkWrap),
// constraints: const BoxConstraints(),
// tooltip: "Playback speed",
// icon: Text(
// _speedLabel,
// style: AppTextStyles.tinyFont.copyWith(color: AppColor.textStyleColor(context), fontWeight: FontWeight.w600),
// ),
// ),
// IconButton(
// onPressed: _toggleMute,
// style: const ButtonStyle(tapTargetSize: MaterialTapTargetSize.shrinkWrap),
// constraints: const BoxConstraints(),
// tooltip: _muted ? "Unmute" : "Mute",
// icon: Icon(_muted ? Icons.volume_off_rounded : Icons.volume_up_rounded, size: 18, color: AppColor.icon2Color(context)),
// ),
],
),
);
}
}