optimized seeking, cleanup

This commit is contained in:
Mert Alev 2024-11-04 15:25:20 -05:00
parent b461318641
commit 284f8c035e
7 changed files with 146 additions and 190 deletions

View File

@ -47,7 +47,7 @@ class GalleryViewerPage extends HookConsumerWidget {
this.initialIndex = 0, this.initialIndex = 0,
this.heroOffset = 0, this.heroOffset = 0,
this.showStack = false, this.showStack = false,
}) : controller = PageController(initialPage: initialIndex, keepPage: false); }) : controller = PageController(initialPage: initialIndex);
final PageController controller; final PageController controller;
@ -328,7 +328,9 @@ class GalleryViewerPage extends HookConsumerWidget {
final ImageProvider provider = final ImageProvider provider =
ImmichImage.imageProvider(asset: a); ImmichImage.imageProvider(asset: a);
ref.read(videoPlaybackValueProvider.notifier).reset();
if (a.isImage && !isPlayingVideo.value) { if (a.isImage && !isPlayingVideo.value) {
ref.read(showControlsProvider.notifier).show = false;
return PhotoViewGalleryPageOptions( return PhotoViewGalleryPageOptions(
onDragStart: (_, details, __) { onDragStart: (_, details, __) {
log.info('Drag start'); log.info('Drag start');
@ -363,13 +365,11 @@ class GalleryViewerPage extends HookConsumerWidget {
); );
} else { } else {
log.info('Loading asset ${a.id} (index $index) as video'); log.info('Loading asset ${a.id} (index $index) as video');
ref.read(videoPlaybackValueProvider.notifier).value =
VideoPlaybackValue.uninitialized();
return PhotoViewGalleryPageOptions.customChild( return PhotoViewGalleryPageOptions.customChild(
// onDragStart: (_, details, __) => onDragStart: (_, details, __) =>
// localPosition.value = details.localPosition, localPosition.value = details.localPosition,
// onDragUpdate: (_, details, __) => onDragUpdate: (_, details, __) =>
// handleSwipeUpDown(details), handleSwipeUpDown(details),
// heroAttributes: PhotoViewHeroAttributes( // heroAttributes: PhotoViewHeroAttributes(
// tag: isFromDto // tag: isFromDto
// ? '${currentAsset.remoteId}-$heroOffset' // ? '${currentAsset.remoteId}-$heroOffset'

View File

@ -173,9 +173,8 @@ class NativeVideoLoader extends HookConsumerWidget {
child: GestureDetector( child: GestureDetector(
behavior: HitTestBehavior.deferToChild, behavior: HitTestBehavior.deferToChild,
child: PopScope( child: PopScope(
onPopInvokedWithResult: (didPop, _) => ref onPopInvokedWithResult: (didPop, _) =>
.read(videoPlaybackValueProvider.notifier) ref.read(videoPlaybackValueProvider.notifier).reset(),
.value = VideoPlaybackValue.uninitialized(),
child: SizedBox( child: SizedBox(
height: size.height, height: size.height,
width: size.width, width: size.width,

View File

@ -12,23 +12,6 @@ import 'package:logging/logging.dart';
import 'package:native_video_player/native_video_player.dart'; import 'package:native_video_player/native_video_player.dart';
import 'package:wakelock_plus/wakelock_plus.dart'; import 'package:wakelock_plus/wakelock_plus.dart';
// @override
// void dispose() {
// bufferingTimer.value.cancel();
// try {
// controller.value?.onPlaybackPositionChanged
// .removeListener(onPlaybackPositionChanged);
// controller.value?.onPlaybackStatusChanged
// .removeListener(onPlaybackPositionChanged);
// controller.value?.onPlaybackReady.removeListener(onPlaybackReady);
// controller.value?.onPlaybackEnded.removeListener(onPlaybackEnded);
// controller.value?.stop();
// } catch (_) {
// // Consume error from the controller
// }
// super.dispose();
// }
class NativeVideoViewerPage extends HookConsumerWidget { class NativeVideoViewerPage extends HookConsumerWidget {
final VideoSource videoSource; final VideoSource videoSource;
final double aspectRatio; final double aspectRatio;
@ -75,7 +58,7 @@ class NativeVideoViewerPage extends HookConsumerWidget {
} }
// timer to mark videos as buffering if the position does not change // timer to mark videos as buffering if the position does not change
// useInterval(const Duration(seconds: 5), checkIfBuffering); useInterval(const Duration(seconds: 5), checkIfBuffering);
// When the volume changes, set the volume // When the volume changes, set the volume
ref.listen(videoPlayerControlsProvider.select((value) => value.mute), ref.listen(videoPlayerControlsProvider.select((value) => value.mute),
@ -86,19 +69,22 @@ class NativeVideoViewerPage extends HookConsumerWidget {
return; return;
} }
final playbackInfo = playerController.playbackInfo;
if (playbackInfo == null) {
log.info('No playback info to update');
return;
}
try { try {
if (mute) { if (mute && playbackInfo.volume != 0.0) {
log.info('Muting video'); log.info('Muting video');
playerController.setVolume(0.0); playerController.setVolume(0.0);
log.info('Muted video'); } else if (!mute && playbackInfo.volume != 0.7) {
} else {
log.info('Unmuting video'); log.info('Unmuting video');
playerController.setVolume(0.7); playerController.setVolume(0.7);
log.info('Unmuted video');
} }
} catch (error) { } catch (error) {
log.severe('Error setting volume: $error'); log.severe('Error setting volume: $error');
// Consume error from the controller
} }
}); });
@ -108,20 +94,28 @@ class NativeVideoViewerPage extends HookConsumerWidget {
final playerController = controller.value; final playerController = controller.value;
if (playerController == null) { if (playerController == null) {
log.info('No controller to seek to'); log.info('No controller to seek to');
// No seeeking if there is no video return;
}
final playbackInfo = playerController.playbackInfo;
if (playbackInfo == null) {
log.info('No playback info to update');
return; return;
} }
// Find the position to seek to // Find the position to seek to
final Duration seek = duration * (position / 100.0); final int seek = (duration * (position / 100.0)).inSeconds;
try { if (seek != playbackInfo.position) {
log.info('Seeking to position: ${seek.inSeconds}'); log.info('Seeking to position: $seek from ${playbackInfo.position}');
playerController.seekTo(seek.inSeconds); try {
log.info('Seeked to position: ${seek.inSeconds}'); playerController.seekTo(seek);
} catch (error) { } catch (error) {
log.severe('Error seeking to position $position: $error'); log.severe('Error seeking to position $position: $error');
// Consume error from the controller }
} }
ref.read(videoPlaybackValueProvider.notifier).position =
Duration(seconds: seek);
}); });
// // When the custom video controls pause or play // // When the custom video controls pause or play
@ -131,15 +125,14 @@ class NativeVideoViewerPage extends HookConsumerWidget {
if (pause) { if (pause) {
log.info('Pausing video'); log.info('Pausing video');
controller.value?.pause(); controller.value?.pause();
log.info('Paused video'); WakelockPlus.disable();
} else { } else {
log.info('Playing video'); log.info('Playing video');
controller.value?.play(); controller.value?.play();
log.info('Played video'); WakelockPlus.enable();
} }
} catch (error) { } catch (error) {
log.severe('Error pausing or playing video: $error'); log.severe('Error pausing or playing video: $error');
// Consume error from the controller
} }
}); });
@ -148,69 +141,77 @@ class NativeVideoViewerPage extends HookConsumerWidget {
log.info('onPlaybackReady: Playing video'); log.info('onPlaybackReady: Playing video');
controller.value?.play(); controller.value?.play();
controller.value?.setVolume(0.9); controller.value?.setVolume(0.9);
log.info('onPlaybackReady: Played video'); WakelockPlus.enable();
} catch (error) { } catch (error) {
log.severe('Error playing video: $error'); log.severe('Error playing video: $error');
// Consume error from the controller
} }
} }
void onPlaybackPositionChanged() { void onPlaybackStatusChanged() {
if (controller.value == null || !context.mounted) { final videoController = controller.value;
if (videoController == null || !context.mounted) {
log.info('No controller to update'); log.info('No controller to update');
return; return;
} }
log.info('Updating video playback');
final videoPlayback = final videoPlayback =
VideoPlaybackValue.fromNativeController(controller.value!); VideoPlaybackValue.fromNativeController(controller.value!);
ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback; ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback;
log.info('Updated video playback');
if (videoPlayback.state == VideoPlaybackState.playing) {
// Sync with the controls playing
WakelockPlus.enable();
log.info('Video is playing; enabled wakelock');
} else {
// Sync with the controls pause
WakelockPlus.disable();
log.info('Video is not playing; disabled wakelock');
}
}
void onPlaybackPositionChanged() {
final videoController = controller.value;
if (videoController == null || !context.mounted) {
log.info('No controller to update');
return;
}
final playbackInfo = videoController.playbackInfo;
if (playbackInfo == null) {
log.info('No playback info to update');
return;
}
ref.read(videoPlaybackValueProvider.notifier).position =
Duration(seconds: playbackInfo.position);
// Check if the video is buffering // Check if the video is buffering
if (videoPlayback.state == VideoPlaybackState.playing) { if (playbackInfo.status == PlaybackStatus.playing) {
log.info('Updating video: checking if playing video is buffering');
isBuffering.value =
lastVideoPosition.value == videoPlayback.position.inSeconds;
lastVideoPosition.value = videoPlayback.position.inSeconds;
log.info('Updating playing video position'); log.info('Updating playing video position');
isBuffering.value = lastVideoPosition.value == playbackInfo.position;
lastVideoPosition.value = playbackInfo.position;
} else { } else {
log.info('Updating video: video is not playing'); log.info('Updating non-playing video position');
isBuffering.value = false; isBuffering.value = false;
lastVideoPosition.value = -1; lastVideoPosition.value = -1;
log.info('Updated non-playing video position');
}
final state = videoPlayback.state;
// Enable the WakeLock while the video is playing
if (state == VideoPlaybackState.playing) {
log.info('Syncing with the controls playing');
// Sync with the controls playing
// WakelockPlus.enable();
log.info('Synced with the controls playing');
} else {
log.info('Syncing with the controls pause');
// Sync with the controls pause
// WakelockPlus.disable();
log.info('Synced with the controls pause');
} }
} }
void onPlaybackEnded() { void onPlaybackEnded() {
try { log.info('onPlaybackEnded: Video ended');
log.info('onPlaybackEnded: Video ended'); if (loopVideo) {
if (loopVideo) { log.info('onPlaybackEnded: Looping video');
log.info('onPlaybackEnded: Looping video'); try {
controller.value?.play(); controller.value?.play();
log.info('onPlaybackEnded: Looped video'); } catch (error) {
log.severe('Error looping video: $error');
} }
} catch (error) { } else {
log.severe('Error looping video: $error'); WakelockPlus.disable();
// Consume error from the controller
} }
} }
Future<void> initController(NativeVideoPlayerController nc) async { void initController(NativeVideoPlayerController nc) {
if (controller.value != null) { if (controller.value != null) {
log.info('initController: Controller already initialized'); log.info('initController: Controller already initialized');
return; return;
@ -218,27 +219,21 @@ class NativeVideoViewerPage extends HookConsumerWidget {
log.info('initController: adding onPlaybackPositionChanged listener'); log.info('initController: adding onPlaybackPositionChanged listener');
nc.onPlaybackPositionChanged.addListener(onPlaybackPositionChanged); nc.onPlaybackPositionChanged.addListener(onPlaybackPositionChanged);
log.info('initController: added onPlaybackPositionChanged listener');
log.info('initController: adding onPlaybackStatusChanged listener'); log.info('initController: adding onPlaybackStatusChanged listener');
nc.onPlaybackStatusChanged.addListener(onPlaybackPositionChanged); nc.onPlaybackStatusChanged.addListener(onPlaybackStatusChanged);
log.info('initController: added onPlaybackStatusChanged listener');
log.info('initController: adding onPlaybackReady listener'); log.info('initController: adding onPlaybackReady listener');
nc.onPlaybackReady.addListener(onPlaybackReady); nc.onPlaybackReady.addListener(onPlaybackReady);
log.info('initController: added onPlaybackReady listener');
log.info('initController: adding onPlaybackEnded listener'); log.info('initController: adding onPlaybackEnded listener');
nc.onPlaybackEnded.addListener(onPlaybackEnded); nc.onPlaybackEnded.addListener(onPlaybackEnded);
log.info('initController: added onPlaybackEnded listener');
log.info('initController: loading video source'); log.info('initController: loading video source');
nc.loadVideoSource(videoSource); nc.loadVideoSource(videoSource);
log.info('initController: loaded video source');
log.info('initController: setting controller'); log.info('initController: setting controller');
controller.value = nc; controller.value = nc;
log.info('initController: set controller');
Timer(const Duration(milliseconds: 200), checkIfBuffering); Timer(const Duration(milliseconds: 200), checkIfBuffering);
} }
@ -246,55 +241,45 @@ class NativeVideoViewerPage extends HookConsumerWidget {
() { () {
log.info('useEffect: resetting video player controls'); log.info('useEffect: resetting video player controls');
ref.read(videoPlayerControlsProvider.notifier).reset(); ref.read(videoPlayerControlsProvider.notifier).reset();
log.info('useEffect: resetting video player controls');
if (isMotionVideo) { if (isMotionVideo) {
// ignore: prefer-extracting-callbacks // ignore: prefer-extracting-callbacks
log.info('useEffect: disabled showing video player controls'); log.info('useEffect: disabling showing video player controls');
ref.read(showControlsProvider.notifier).show = false; ref.read(showControlsProvider.notifier).show = false;
log.info('useEffect: disabled showing video player controls');
} }
return () { return () {
final playerController = controller.value;
if (playerController == null) {
log.info('No controller to dispose');
return;
}
try { try {
final playerController = controller.value; log.info('Stopping video');
if (playerController == null) { playerController.stop();
log.info('No controller to dispose');
return;
}
log.info('Removing onPlaybackPositionChanged listener'); log.info('Removing onPlaybackPositionChanged listener');
playerController.onPlaybackPositionChanged playerController.onPlaybackPositionChanged
.removeListener(onPlaybackPositionChanged); .removeListener(onPlaybackPositionChanged);
log.info('Removed onPlaybackPositionChanged listener');
log.info('Removing onPlaybackStatusChanged listener'); log.info('Removing onPlaybackStatusChanged listener');
playerController.onPlaybackStatusChanged playerController.onPlaybackStatusChanged
.removeListener(onPlaybackPositionChanged); .removeListener(onPlaybackStatusChanged);
log.info('Removed onPlaybackStatusChanged listener');
log.info('Removing onPlaybackReady listener'); log.info('Removing onPlaybackReady listener');
playerController.onPlaybackReady.removeListener(onPlaybackReady); playerController.onPlaybackReady.removeListener(onPlaybackReady);
log.info('Removed onPlaybackReady listener');
log.info('Removing onPlaybackEnded listener'); log.info('Removing onPlaybackEnded listener');
playerController.onPlaybackEnded.removeListener(onPlaybackEnded); playerController.onPlaybackEnded.removeListener(onPlaybackEnded);
log.info('Removed onPlaybackEnded listener');
log.info('Stopping video');
playerController.stop();
log.info('Stopped video');
log.info('Disposing controller');
controller.value = null;
log.info('Disposed controller');
// log.info('Disabling Wakelock');
// WakelockPlus.disable();
// log.info('Disabled Wakelock');
} catch (error) { } catch (error) {
log.severe('Error during useEffect cleanup: $error'); log.severe('Error during useEffect cleanup: $error');
// Consume error from the controller
} }
log.info('Disposing controller');
controller.value = null;
log.info('Disabling Wakelock');
WakelockPlus.disable();
}; };
}, },
[videoSource], [videoSource],

View File

@ -124,8 +124,7 @@ class VideoViewerPage extends HookConsumerWidget {
return PopScope( return PopScope(
onPopInvokedWithResult: (didPop, _) { onPopInvokedWithResult: (didPop, _) {
ref.read(videoPlaybackValueProvider.notifier).value = ref.read(videoPlaybackValueProvider.notifier).reset();
VideoPlaybackValue.uninitialized();
}, },
child: AnimatedSwitcher( child: AnimatedSwitcher(
duration: const Duration(milliseconds: 400), duration: const Duration(milliseconds: 400),

View File

@ -1,7 +1,7 @@
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
class VideoPlaybackControls { class VideoPlaybackControls {
VideoPlaybackControls({ const VideoPlaybackControls({
required this.position, required this.position,
required this.mute, required this.mute,
required this.pause, required this.pause,
@ -17,15 +17,14 @@ final videoPlayerControlsProvider =
return VideoPlayerControls(ref); return VideoPlayerControls(ref);
}); });
const videoPlayerControlsDefault = VideoPlaybackControls(
position: 0,
pause: false,
mute: false,
);
class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> { class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
VideoPlayerControls(this.ref) VideoPlayerControls(this.ref) : super(videoPlayerControlsDefault);
: super(
VideoPlaybackControls(
position: 0,
pause: false,
mute: false,
),
);
final Ref ref; final Ref ref;
@ -36,15 +35,7 @@ class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
} }
void reset() { void reset() {
if (state.position == 0 && !state.mute && !state.pause) { state = videoPlayerControlsDefault;
return;
}
state = VideoPlaybackControls(
position: 0,
pause: false,
mute: false,
);
} }
double get position => state.position; double get position => state.position;
@ -115,14 +106,6 @@ class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
} }
void restart() { void restart() {
if (state.position > 0 || !state.pause) {
state = VideoPlaybackControls(
position: 0,
mute: state.mute,
pause: false,
);
}
state = VideoPlaybackControls( state = VideoPlaybackControls(
position: 0, position: 0,
mute: state.mute, mute: state.mute,

View File

@ -23,7 +23,7 @@ class VideoPlaybackValue {
/// The volume of the video /// The volume of the video
final double volume; final double volume;
VideoPlaybackValue({ const VideoPlaybackValue({
required this.position, required this.position,
required this.duration, required this.duration,
required this.state, required this.state,
@ -33,32 +33,31 @@ class VideoPlaybackValue {
factory VideoPlaybackValue.fromNativeController( factory VideoPlaybackValue.fromNativeController(
NativeVideoPlayerController controller, NativeVideoPlayerController controller,
) { ) {
PlaybackInfo? playbackInfo; final playbackInfo = controller.playbackInfo;
VideoInfo? videoInfo; final videoInfo = controller.videoInfo;
try {
playbackInfo = controller.playbackInfo; if (playbackInfo == null || videoInfo == null) {
videoInfo = controller.videoInfo; return videoPlaybackValueDefault;
} catch (_) {
// Consume error from the controller
} }
late VideoPlaybackState s;
if (playbackInfo?.status == null) { late final VideoPlaybackState status;
s = VideoPlaybackState.initializing; switch (playbackInfo.status) {
} else if (playbackInfo?.status == PlaybackStatus.stopped && case PlaybackStatus.playing:
(playbackInfo?.positionFraction == 1 || status = VideoPlaybackState.playing;
playbackInfo?.positionFraction == 0)) { break;
s = VideoPlaybackState.completed; case PlaybackStatus.paused:
} else if (playbackInfo?.status == PlaybackStatus.playing) { status = VideoPlaybackState.paused;
s = VideoPlaybackState.playing; break;
} else { case PlaybackStatus.stopped:
s = VideoPlaybackState.paused; status = VideoPlaybackState.completed;
break;
} }
return VideoPlaybackValue( return VideoPlaybackValue(
position: Duration(seconds: playbackInfo?.position ?? 0), position: Duration(seconds: playbackInfo.position),
duration: Duration(seconds: videoInfo?.duration ?? 0), duration: Duration(seconds: videoInfo.duration),
state: s, state: status,
volume: playbackInfo?.volume ?? 0.0, volume: playbackInfo.volume,
); );
} }
@ -85,15 +84,6 @@ class VideoPlaybackValue {
); );
} }
factory VideoPlaybackValue.uninitialized() {
return VideoPlaybackValue(
position: Duration.zero,
duration: Duration.zero,
state: VideoPlaybackState.initializing,
volume: 0.0,
);
}
VideoPlaybackValue copyWith({ VideoPlaybackValue copyWith({
Duration? position, Duration? position,
Duration? duration, Duration? duration,
@ -109,16 +99,20 @@ class VideoPlaybackValue {
} }
} }
const VideoPlaybackValue videoPlaybackValueDefault = VideoPlaybackValue(
position: Duration.zero,
duration: Duration.zero,
state: VideoPlaybackState.initializing,
volume: 0.0,
);
final videoPlaybackValueProvider = final videoPlaybackValueProvider =
StateNotifierProvider<VideoPlaybackValueState, VideoPlaybackValue>((ref) { StateNotifierProvider<VideoPlaybackValueState, VideoPlaybackValue>((ref) {
return VideoPlaybackValueState(ref); return VideoPlaybackValueState(ref);
}); });
class VideoPlaybackValueState extends StateNotifier<VideoPlaybackValue> { class VideoPlaybackValueState extends StateNotifier<VideoPlaybackValue> {
VideoPlaybackValueState(this.ref) VideoPlaybackValueState(this.ref) : super(videoPlaybackValueDefault);
: super(
VideoPlaybackValue.uninitialized(),
);
final Ref ref; final Ref ref;
@ -129,6 +123,7 @@ class VideoPlaybackValueState extends StateNotifier<VideoPlaybackValue> {
} }
set position(Duration value) { set position(Duration value) {
if (state.position == value) return;
state = VideoPlaybackValue( state = VideoPlaybackValue(
position: value, position: value,
duration: state.duration, duration: state.duration,
@ -136,4 +131,8 @@ class VideoPlaybackValueState extends StateNotifier<VideoPlaybackValue> {
volume: state.volume, volume: state.volume,
); );
} }
void reset() {
state = videoPlaybackValueDefault;
}
} }

View File

@ -1,5 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
@ -29,10 +28,9 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
} }
}, },
); );
final showBuffering = useState(false);
final VideoPlaybackState state = final VideoPlaybackState state =
ref.watch(videoPlaybackValueProvider).state; ref.watch(videoPlaybackValueProvider.select((value) => value.state));
final showBuffering = state == VideoPlaybackState.buffering;
/// Shows the controls and starts the timer to hide them /// Shows the controls and starts the timer to hide them
void showControlsAndStartHideTimer() { void showControlsAndStartHideTimer() {
@ -52,16 +50,9 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
showControlsAndStartHideTimer(); showControlsAndStartHideTimer();
}); });
ref.listen(videoPlaybackValueProvider.select((value) => value.state),
(_, state) {
// Show buffering
showBuffering.value = state == VideoPlaybackState.buffering;
});
/// Toggles between playing and pausing depending on the state of the video /// Toggles between playing and pausing depending on the state of the video
void togglePlay() { void togglePlay() {
showControlsAndStartHideTimer(); showControlsAndStartHideTimer();
final state = ref.read(videoPlaybackValueProvider).state;
if (state == VideoPlaybackState.playing) { if (state == VideoPlaybackState.playing) {
ref.read(videoPlayerControlsProvider.notifier).pause(); ref.read(videoPlayerControlsProvider.notifier).pause();
} else if (state == VideoPlaybackState.completed) { } else if (state == VideoPlaybackState.completed) {
@ -78,7 +69,7 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
absorbing: !ref.watch(showControlsProvider), absorbing: !ref.watch(showControlsProvider),
child: Stack( child: Stack(
children: [ children: [
if (showBuffering.value) if (showBuffering)
const Center( const Center(
child: DelayedLoadingIndicator( child: DelayedLoadingIndicator(
fadeInDuration: Duration(milliseconds: 400), fadeInDuration: Duration(milliseconds: 400),