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.
240 lines
7.7 KiB
Dart
240 lines
7.7 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)),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
|