mirror of
https://github.com/immich-app/immich.git
synced 2026-03-22 11:09:21 +03:00
Consolidate video state into a single asset-scoped provider, and reduce dependency on global state generally. Overall this should fix a few timing issues and race conditions with videos specifically, and make future changes in this area easier.
201 lines
5.0 KiB
Dart
201 lines
5.0 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
import 'package:logging/logging.dart';
|
|
import 'package:native_video_player/native_video_player.dart';
|
|
import 'package:wakelock_plus/wakelock_plus.dart';
|
|
|
|
enum VideoPlaybackStatus { paused, playing, buffering, completed }
|
|
|
|
class VideoPlayerState {
|
|
final Duration position;
|
|
final Duration duration;
|
|
final VideoPlaybackStatus status;
|
|
|
|
const VideoPlayerState({required this.position, required this.duration, required this.status});
|
|
|
|
VideoPlayerState copyWith({Duration? position, Duration? duration, VideoPlaybackStatus? status}) {
|
|
return VideoPlayerState(
|
|
position: position ?? this.position,
|
|
duration: duration ?? this.duration,
|
|
status: status ?? this.status,
|
|
);
|
|
}
|
|
}
|
|
|
|
const _defaultState = VideoPlayerState(
|
|
position: Duration.zero,
|
|
duration: Duration.zero,
|
|
status: VideoPlaybackStatus.paused,
|
|
);
|
|
|
|
final videoPlayerProvider = StateNotifierProvider.autoDispose.family<VideoPlayerNotifier, VideoPlayerState, String>((
|
|
ref,
|
|
name,
|
|
) {
|
|
return VideoPlayerNotifier();
|
|
});
|
|
|
|
class VideoPlayerNotifier extends StateNotifier<VideoPlayerState> {
|
|
static final _log = Logger('VideoPlayerNotifier');
|
|
|
|
VideoPlayerNotifier() : super(_defaultState);
|
|
|
|
NativeVideoPlayerController? _controller;
|
|
Timer? _bufferingTimer;
|
|
Timer? _seekTimer;
|
|
|
|
void attachController(NativeVideoPlayerController controller) {
|
|
_controller = controller;
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_bufferingTimer?.cancel();
|
|
_seekTimer?.cancel();
|
|
WakelockPlus.disable();
|
|
_controller = null;
|
|
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> pause() async {
|
|
if (_controller == null) return;
|
|
|
|
_bufferingTimer?.cancel();
|
|
|
|
try {
|
|
await _controller!.pause();
|
|
await _flushSeek();
|
|
} catch (e) {
|
|
_log.severe('Error pausing video: $e');
|
|
}
|
|
}
|
|
|
|
Future<void> play() async {
|
|
if (_controller == null) return;
|
|
|
|
try {
|
|
await _flushSeek();
|
|
await _controller!.play();
|
|
} catch (e) {
|
|
_log.severe('Error playing video: $e');
|
|
}
|
|
|
|
_startBufferingTimer();
|
|
}
|
|
|
|
Future<void> _flushSeek() async {
|
|
final timer = _seekTimer;
|
|
if (timer == null || !timer.isActive) return;
|
|
|
|
timer.cancel();
|
|
await _controller?.seekTo(state.position.inMilliseconds);
|
|
}
|
|
|
|
void seekTo(Duration position) {
|
|
if (_controller == null) return;
|
|
|
|
state = state.copyWith(position: position);
|
|
|
|
_seekTimer?.cancel();
|
|
_seekTimer = Timer(const Duration(milliseconds: 100), () {
|
|
_controller?.seekTo(position.inMilliseconds);
|
|
});
|
|
}
|
|
|
|
Future<void> restart() async {
|
|
seekTo(Duration.zero);
|
|
await play();
|
|
}
|
|
|
|
Future<void> setVolume(double volume) async {
|
|
try {
|
|
await _controller?.setVolume(volume);
|
|
} catch (e) {
|
|
_log.severe('Error setting volume: $e');
|
|
}
|
|
}
|
|
|
|
Future<void> setLoop(bool loop) async {
|
|
try {
|
|
await _controller?.setLoop(loop);
|
|
} catch (e) {
|
|
_log.severe('Error setting loop: $e');
|
|
}
|
|
}
|
|
|
|
void onNativePlaybackReady() {
|
|
if (!mounted) return;
|
|
|
|
final playbackInfo = _controller?.playbackInfo;
|
|
final videoInfo = _controller?.videoInfo;
|
|
|
|
if (playbackInfo == null || videoInfo == null) return;
|
|
|
|
state = state.copyWith(
|
|
position: Duration(milliseconds: playbackInfo.position),
|
|
duration: Duration(milliseconds: videoInfo.duration),
|
|
status: _mapStatus(playbackInfo.status),
|
|
);
|
|
}
|
|
|
|
void onNativePositionChanged() {
|
|
if (!mounted || (_seekTimer?.isActive ?? false)) return;
|
|
|
|
final playbackInfo = _controller?.playbackInfo;
|
|
if (playbackInfo == null) return;
|
|
|
|
final position = Duration(milliseconds: playbackInfo.position);
|
|
if (state.position == position) return;
|
|
|
|
if (state.status == VideoPlaybackStatus.buffering) {
|
|
state = state.copyWith(position: position, status: VideoPlaybackStatus.playing);
|
|
} else {
|
|
state = state.copyWith(position: position);
|
|
}
|
|
|
|
_startBufferingTimer();
|
|
}
|
|
|
|
void onNativeStatusChanged() {
|
|
if (!mounted) return;
|
|
|
|
final playbackInfo = _controller?.playbackInfo;
|
|
if (playbackInfo == null) return;
|
|
|
|
final newStatus = _mapStatus(playbackInfo.status);
|
|
switch (newStatus) {
|
|
case VideoPlaybackStatus.playing:
|
|
WakelockPlus.enable();
|
|
_startBufferingTimer();
|
|
default:
|
|
onNativePlaybackEnded();
|
|
}
|
|
|
|
if (state.status != newStatus) {
|
|
state = state.copyWith(status: newStatus);
|
|
}
|
|
}
|
|
|
|
void onNativePlaybackEnded() {
|
|
WakelockPlus.disable();
|
|
_bufferingTimer?.cancel();
|
|
}
|
|
|
|
void _startBufferingTimer() {
|
|
_bufferingTimer?.cancel();
|
|
_bufferingTimer = Timer(const Duration(seconds: 3), () {
|
|
if (mounted && state.status == VideoPlaybackStatus.playing) {
|
|
state = state.copyWith(status: VideoPlaybackStatus.buffering);
|
|
}
|
|
});
|
|
}
|
|
|
|
static VideoPlaybackStatus _mapStatus(PlaybackStatus status) => switch (status) {
|
|
PlaybackStatus.playing => VideoPlaybackStatus.playing,
|
|
PlaybackStatus.paused => VideoPlaybackStatus.paused,
|
|
PlaybackStatus.stopped => VideoPlaybackStatus.completed,
|
|
};
|
|
}
|