Files
Qlyra/lib/core/media/voice_audio_controller.dart
T

360 lines
8.6 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:ogg_opus_player/ogg_opus_player.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import '../utils/download_progress.dart';
import '../utils/logger.dart';
import '../utils/media_cache.dart';
import 'opus_ogg_index.dart';
enum VoiceAudioFailure { none, download, playback }
class VoiceAudioController {
VoiceAudioController({
required this.cacheName,
required this.resolveUrl,
required Duration fallbackDuration,
}) : duration = ValueNotifier(
fallbackDuration.inMicroseconds / Duration.microsecondsPerSecond,
);
final String cacheName;
final Future<String?> Function() resolveUrl;
static const double _endEpsilon = 0.05;
static VoiceAudioController? _active;
static int _sliceCounter = 0;
static Directory? _sliceDir;
final ValueNotifier<bool> playing = ValueNotifier(false);
final ValueNotifier<double> position = ValueNotifier(0);
final ValueNotifier<double> duration;
final ValueNotifier<VoiceAudioFailure> failure = ValueNotifier(
VoiceAudioFailure.none,
);
ValueListenable<double?> get downloadProgress =>
MediaDownloadProgress.notifier(cacheName);
ValueListenable<bool> get downloaded => MediaCache.presence(cacheName);
bool get scrubbing => _scrubbing;
File? _file;
OpusOggIndex? _index;
OggOpusPlayer? _player;
File? _slice;
Timer? _ticker;
Future<void>? _loading;
double _sliceOffset = 0;
double _speed = 1;
int _startGeneration = 0;
bool _scrubbing = false;
bool _resumeAfterScrub = false;
bool _finished = false;
bool _disposed = false;
Future<void> toggle() async {
if (playing.value) {
pause();
return;
}
await play();
}
Future<void> play() async {
if (_disposed) return;
if (!await _ensureLoaded()) return;
if (_disposed) return;
if (_active != this) {
_active?.pause();
_active = this;
}
if (_finished) {
_finished = false;
_disposePlayer();
position.value = 0;
}
final player = _player;
if (player != null) {
player.play();
_applySpeed();
playing.value = true;
_startTicker();
return;
}
final total = duration.value;
final from = total > 0 && position.value >= total - _endEpsilon
? 0.0
: position.value;
await _startAt(from);
}
void pause() {
_startGeneration++;
_player?.pause();
playing.value = false;
_stopTicker();
}
void setSpeed(double speed) {
if (_disposed) return;
_speed = speed;
_applySpeed();
}
void stopAndReset() {
if (_disposed) return;
pause();
_disposePlayer();
_finished = false;
_sliceOffset = 0;
position.value = 0;
}
void _applySpeed() {
final player = _player;
if (player == null) return;
try {
player.setPlaybackRate(_speed);
} catch (e) {
logger.w('VoiceAudioController.setSpeed($cacheName): $e');
}
}
Future<void> seekTo(double seconds) async {
scrubStart();
scrubTo(seconds);
await scrubEnd();
}
void scrubStart() {
if (_scrubbing) return;
_scrubbing = true;
_resumeAfterScrub = playing.value;
_startGeneration++;
if (playing.value) pause();
}
void scrubTo(double seconds) {
final total = duration.value;
position.value = total <= 0 ? 0 : seconds.clamp(0.0, total);
}
Future<void> scrubEnd() async {
if (!_scrubbing) return;
_scrubbing = false;
final resume = _resumeAfterScrub;
_resumeAfterScrub = false;
_finished = false;
if (_file == null) {
if (resume) await play();
return;
}
_disposePlayer();
if (resume) await _startAt(position.value);
}
Future<bool> _ensureLoaded() async {
if (_file != null) return true;
final running = _loading;
if (running != null) {
await running;
return _file != null;
}
final future = _load();
_loading = future;
try {
await future;
} finally {
_loading = null;
}
return _file != null;
}
Future<void> _load() async {
failure.value = VoiceAudioFailure.none;
try {
var file = await MediaCache.existing(cacheName);
if (file == null) {
MediaDownloadProgress.set(cacheName, 0);
try {
final url = await resolveUrl();
if (url != null && url.isNotEmpty) {
file = await MediaCache.getOrDownload(
cacheName,
url,
onProgress: (value) =>
MediaDownloadProgress.set(cacheName, value),
);
}
} finally {
MediaDownloadProgress.set(cacheName, null);
}
}
if (_disposed) return;
if (file == null) {
failure.value = VoiceAudioFailure.download;
return;
}
_file = file;
await _buildIndex(file);
} catch (e) {
logger.w('VoiceAudioController._load($cacheName): $e');
if (!_disposed) failure.value = VoiceAudioFailure.download;
}
}
Future<void> _buildIndex(File file) async {
try {
final bytes = await file.readAsBytes();
if (_disposed) return;
final index = OpusOggIndex.parse(bytes);
if (index == null) return;
_index = index;
if (index.duration > 0) duration.value = index.duration;
} catch (e) {
logger.w('VoiceAudioController: индекс не построен ($cacheName): $e');
}
}
Future<void> _startAt(double seconds) async {
final file = _file;
if (file == null) return;
final generation = ++_startGeneration;
_disposePlayer();
final total = duration.value;
if (total > 0 && seconds >= total - _endEpsilon) {
_finished = true;
playing.value = false;
position.value = total;
return;
}
var path = file.path;
var offset = 0.0;
final index = _index;
if (seconds > 0 && index != null) {
final bytes = index.sliceFrom(seconds);
if (bytes != null) {
final slice = await _writeSlice(bytes);
if (_disposed || generation != _startGeneration) return;
if (slice != null) {
path = slice.path;
offset = seconds;
}
}
}
_sliceOffset = offset;
position.value = offset;
try {
final player = OggOpusPlayer(path);
_player = player;
player.state.addListener(_onPlayerState);
player.play();
_applySpeed();
playing.value = true;
_startTicker();
} catch (e) {
logger.w('VoiceAudioController._startAt($cacheName): $e');
failure.value = VoiceAudioFailure.playback;
playing.value = false;
}
}
Future<File?> _writeSlice(Uint8List bytes) async {
try {
final dir = _sliceDir ??= await getTemporaryDirectory();
final next = File(p.join(dir.path, 'voice_slice_${_sliceCounter++}.ogg'));
await next.writeAsBytes(bytes);
final previous = _slice;
_slice = next;
await _deleteQuietly(previous);
return next;
} catch (e) {
logger.w('VoiceAudioController._writeSlice($cacheName): $e');
return null;
}
}
void _onPlayerState() {
final state = _player?.state.value;
if (state == null || _disposed) return;
if (state == PlayerState.ended) {
_finished = true;
playing.value = false;
position.value = duration.value;
_stopTicker();
return;
}
if (state == PlayerState.error) {
failure.value = VoiceAudioFailure.playback;
playing.value = false;
_stopTicker();
}
}
void _startTicker() {
_ticker ??= Timer.periodic(
const Duration(milliseconds: 50),
(_) => _onTick(),
);
}
void _stopTicker() {
_ticker?.cancel();
_ticker = null;
}
void _onTick() {
final player = _player;
if (player == null || _scrubbing || _finished) return;
final total = duration.value;
final value = _sliceOffset + player.currentPosition;
position.value = total > 0 ? value.clamp(0.0, total) : value;
}
void _disposePlayer() {
final player = _player;
_player = null;
_stopTicker();
if (player == null) return;
player.state.removeListener(_onPlayerState);
player.dispose();
}
static Future<void> _deleteQuietly(File? file) async {
if (file == null) return;
try {
if (await file.exists()) await file.delete();
} catch (_) {}
}
void dispose() {
_disposed = true;
_disposePlayer();
if (_active == this) _active = null;
final slice = _slice;
_slice = null;
_deleteQuietly(slice).ignore();
playing.dispose();
position.dispose();
duration.dispose();
failure.dispose();
}
}