Files
plezy/lib/services/video_volume_controller.dart

225 lines
7.7 KiB
Dart

import 'dart:async';
import 'package:flutter/foundation.dart';
import '../mpv/mpv.dart';
import '../utils/app_logger.dart';
import 'settings_service.dart';
typedef VideoVolumePersistenceWriter = Future<void> Function(double volume);
/// Owns every logical volume transition for one video [Player].
///
/// User intent is published immediately while native writes remain strictly
/// one-at-a-time. A burst keeps only its latest pending target, so deltas are
/// accumulated against the latest requested volume rather than delayed player
/// state.
final class VideoVolumeController implements ValueListenable<double> {
VideoVolumeController({
required this.player,
required SettingsService settings,
required double initialVolume,
VideoVolumePersistenceWriter? persistVolume,
}) : _settings = settings,
_persistVolume = persistVolume ?? ((volume) => settings.write(SettingsService.volume, volume)),
_desiredVolume = ValueNotifier<double>(_clampForSettings(settings, initialVolume)),
_confirmedVolume = _clampForSettings(settings, initialVolume),
_preferredVolume = _initialPreferredVolume(settings),
_lastPersistedVolume = _initialPreferredVolume(settings) {
_volumeSubscription = player.streams.volume.listen(
_handlePlayerVolume,
onError: (Object error, StackTrace stackTrace) {
if (_disposed) return;
appLogger.w('Video volume observation failed', error: error, stackTrace: stackTrace);
},
);
}
final Player player;
final SettingsService _settings;
final VideoVolumePersistenceWriter _persistVolume;
final ValueNotifier<double> _desiredVolume;
late final StreamSubscription<double> _volumeSubscription;
_VolumeTransition? _pending;
_VolumeTransition? _activeTransition;
Completer<void>? _idleCompleter;
double _confirmedVolume;
double _preferredVolume;
double _lastPersistedVolume;
bool _draining = false;
bool _disposed = false;
static double _clampForSettings(SettingsService settings, double volume) {
return volume.clamp(0.0, settings.read(SettingsService.maxVolume).toDouble()).toDouble();
}
static double _initialPreferredVolume(SettingsService settings) {
final stored = settings.read(SettingsService.volume);
return _clampForSettings(settings, stored.isFinite ? stored : SettingsService.volume.defaultValue);
}
@override
double get value => _desiredVolume.value;
@override
void addListener(VoidCallback listener) => _desiredVolume.addListener(listener);
@override
void removeListener(VoidCallback listener) => _desiredVolume.removeListener(listener);
/// Completes when the currently scheduled apply/persistence drain is idle.
/// It never completes with an error; failures are contained and logged here.
Future<void> get idle => _idleCompleter?.future ?? Future<void>.value();
bool ownsPlayer(Player candidate) => identical(player, candidate);
/// Applies and persists a relative change against the latest user intent.
void adjust(double delta) {
if (_disposed || !delta.isFinite) return;
final target = _clamp(value + delta);
_schedule(target, persistedVolume: target);
}
/// Applies an absolute slider preview without persisting intermediate values.
void preview(double volume) {
if (_disposed || !volume.isFinite) return;
_schedule(_clamp(volume), persistedVolume: null);
}
/// Applies and persists an absolute volume selection.
void commit(double volume) {
if (_disposed || !volume.isFinite) return;
final target = _clamp(volume);
_schedule(target, persistedVolume: target);
}
/// Mutes without replacing the preferred volume with zero, or restores the
/// latest logically preferred non-zero value when already muted.
void toggleMute() {
if (_disposed) return;
if (value > 0) {
_schedule(0, persistedVolume: value);
return;
}
final preferred = _preferredVolume.isFinite && _preferredVolume > 0
? _preferredVolume
: SettingsService.volume.defaultValue;
final restored = _clamp(preferred);
_schedule(restored, persistedVolume: restored);
}
double _clamp(double volume) => _clampForSettings(_settings, volume);
void _schedule(double playerVolume, {required double? persistedVolume}) {
if (_disposed) return;
final persisted = persistedVolume == null ? null : _clamp(persistedVolume);
if (persisted != null) _preferredVolume = persisted;
_setDesiredVolume(playerVolume);
final active = _activeTransition;
if (_draining &&
_pending == null &&
active != null &&
active.playerVolume == playerVolume &&
active.persistedVolume == persisted) {
return;
}
_pending = _VolumeTransition(playerVolume: playerVolume, persistedVolume: persisted);
if (_draining) return;
_draining = true;
_idleCompleter = Completer<void>();
unawaited(_drain());
}
Future<void> _drain() async {
try {
while (!_disposed) {
final transition = _pending;
if (transition == null) break;
_pending = null;
_activeTransition = transition;
try {
await player.setVolume(transition.playerVolume);
} catch (error, stackTrace) {
if (_disposed) return;
appLogger.w('Video volume apply failed', error: error, stackTrace: stackTrace);
if (_pending == null) {
if (transition.persistedVolume != null) {
_preferredVolume = _lastPersistedVolume;
}
_setDesiredVolume(_confirmedVolume);
}
continue;
}
if (_disposed) return;
_confirmedVolume = transition.playerVolume;
// A newer user intent supersedes both this apply's UI state and its
// persistence. Drain only the latest record next.
if (_pending != null || transition.persistedVolume == null) continue;
try {
await _persistVolume(transition.persistedVolume!);
if (_disposed) return;
_lastPersistedVolume = transition.persistedVolume!;
} catch (error, stackTrace) {
if (_disposed) return;
appLogger.w('Video volume persistence failed', error: error, stackTrace: stackTrace);
}
}
} catch (error, stackTrace) {
// The expected player/persistence failures are handled above. Keep one
// final containment boundary so fire-and-forget UI commands never leak.
if (!_disposed) {
appLogger.e('Video volume transition failed unexpectedly', error: error, stackTrace: stackTrace);
}
} finally {
if (!_disposed) {
_draining = false;
final completer = _idleCompleter;
_idleCompleter = null;
if (completer != null && !completer.isCompleted) completer.complete();
}
}
}
void _handlePlayerVolume(double volume) {
if (_disposed || _draining || !volume.isFinite) return;
final observed = _clamp(volume);
_confirmedVolume = observed;
_setDesiredVolume(observed);
}
void _setDesiredVolume(double volume) {
if (_disposed || _desiredVolume.value == volume) return;
_desiredVolume.value = volume;
}
void dispose() {
if (_disposed) return;
_disposed = true;
_pending = null;
unawaited(
_volumeSubscription.cancel().catchError((Object error, StackTrace stackTrace) {
appLogger.w('Video volume observation cleanup failed', error: error, stackTrace: stackTrace);
}),
);
final completer = _idleCompleter;
_idleCompleter = null;
if (completer != null && !completer.isCompleted) completer.complete();
_desiredVolume.dispose();
}
}
final class _VolumeTransition {
const _VolumeTransition({required this.playerVolume, required this.persistedVolume});
final double playerVolume;
final double? persistedVolume;
}