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 clips; const TtsVoicePlayer({Key? key, required this.clips}) : super(key: key); @override State createState() => _TtsVoicePlayerState(); } class _TtsVoicePlayerState extends State { static const List _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 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 _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 _togglePlayPause() async { if (_playing) { _userPaused = true; await _player.pause(); return; } _userPaused = false; if (_index == -1 || _finished) { await _playClipAt(0); return; } await _player.resume(); } Future _toggleMute() async { setState(() => _muted = !_muted); await _player.setVolume(_muted ? 0 : 1); } Future _cycleSpeed() async { final idx = _speeds.indexOf(_speed); final next = _speeds[(idx + 1) % _speeds.length]; setState(() => _speed = next); await _player.setPlaybackRate(next); } Future _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)), ), ], ), ); } }