1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-25 10:43:13 +02:00

refactor(mobile): Use hooks to manage Chewie controller for video (#7008)

* video loading delayed

* Chewie controller implemented in hook

* fixing look and feel

* Finalizing delay and animations

* Fixes issue with immersive mode showing immediately in videos

* format fix

* Fixes bug where video controls would hide immediately after showing while playing and reverts hide controls timer to 5 seconds

* Fixed rebase issues

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
martyfuhry 2024-02-23 00:18:02 -05:00 committed by GitHub
parent b3b6426695
commit 3cd3411c1f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 313 additions and 238 deletions

View File

@ -0,0 +1,179 @@
import 'dart:async';
import 'package:chewie/chewie.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:video_player/video_player.dart';
import 'package:immich_mobile/shared/models/store.dart' as store;
import 'package:wakelock_plus/wakelock_plus.dart';
/// Provides the initialized video player controller
/// If the asset is local, use the local file
/// Otherwise, use a video player with a URL
ChewieController? useChewieController(
Asset asset, {
EdgeInsets controlsSafeAreaMinimum = const EdgeInsets.only(
bottom: 100,
),
bool showOptions = true,
bool showControlsOnInitialize = false,
bool autoPlay = true,
bool autoInitialize = true,
bool allowFullScreen = false,
bool allowedScreenSleep = false,
bool showControls = true,
Widget? customControls,
Widget? placeholder,
Duration hideControlsTimer = const Duration(seconds: 1),
VoidCallback? onPlaying,
VoidCallback? onPaused,
VoidCallback? onVideoEnded,
}) {
return use(
_ChewieControllerHook(
asset: asset,
placeholder: placeholder,
showOptions: showOptions,
controlsSafeAreaMinimum: controlsSafeAreaMinimum,
autoPlay: autoPlay,
allowFullScreen: allowFullScreen,
customControls: customControls,
hideControlsTimer: hideControlsTimer,
showControlsOnInitialize: showControlsOnInitialize,
showControls: showControls,
autoInitialize: autoInitialize,
allowedScreenSleep: allowedScreenSleep,
onPlaying: onPlaying,
onPaused: onPaused,
onVideoEnded: onVideoEnded,
),
);
}
class _ChewieControllerHook extends Hook<ChewieController?> {
final Asset asset;
final EdgeInsets controlsSafeAreaMinimum;
final bool showOptions;
final bool showControlsOnInitialize;
final bool autoPlay;
final bool autoInitialize;
final bool allowFullScreen;
final bool allowedScreenSleep;
final bool showControls;
final Widget? customControls;
final Widget? placeholder;
final Duration hideControlsTimer;
final VoidCallback? onPlaying;
final VoidCallback? onPaused;
final VoidCallback? onVideoEnded;
const _ChewieControllerHook({
required this.asset,
this.controlsSafeAreaMinimum = const EdgeInsets.only(
bottom: 100,
),
this.showOptions = true,
this.showControlsOnInitialize = false,
this.autoPlay = true,
this.autoInitialize = true,
this.allowFullScreen = false,
this.allowedScreenSleep = false,
this.showControls = true,
this.customControls,
this.placeholder,
this.hideControlsTimer = const Duration(seconds: 3),
this.onPlaying,
this.onPaused,
this.onVideoEnded,
});
@override
createState() => _ChewieControllerHookState();
}
class _ChewieControllerHookState
extends HookState<ChewieController?, _ChewieControllerHook> {
ChewieController? chewieController;
VideoPlayerController? videoPlayerController;
@override
void initHook() async {
super.initHook();
unawaited(_initialize());
}
@override
void dispose() {
chewieController?.dispose();
videoPlayerController?.dispose();
super.dispose();
}
@override
ChewieController? build(BuildContext context) {
return chewieController;
}
/// Initializes the chewie controller and video player controller
Future<void> _initialize() async {
if (hook.asset.isLocal && hook.asset.livePhotoVideoId == null) {
// Use a local file for the video player controller
final file = await hook.asset.local!.file;
if (file == null) {
throw Exception('No file found for the video');
}
videoPlayerController = VideoPlayerController.file(file);
} else {
// Use a network URL for the video player controller
final serverEndpoint = store.Store.get(store.StoreKey.serverEndpoint);
final String videoUrl = hook.asset.livePhotoVideoId != null
? '$serverEndpoint/asset/file/${hook.asset.livePhotoVideoId}'
: '$serverEndpoint/asset/file/${hook.asset.remoteId}';
final url = Uri.parse(videoUrl);
final accessToken = store.Store.get(StoreKey.accessToken);
videoPlayerController = VideoPlayerController.networkUrl(
url,
httpHeaders: {"x-immich-user-token": accessToken},
);
}
videoPlayerController!.addListener(() {
final value = videoPlayerController!.value;
if (value.isPlaying) {
WakelockPlus.enable();
hook.onPlaying?.call();
} else if (!value.isPlaying) {
WakelockPlus.disable();
hook.onPaused?.call();
}
if (value.position == value.duration) {
WakelockPlus.disable();
hook.onVideoEnded?.call();
}
});
await videoPlayerController!.initialize();
setState(() {
chewieController = ChewieController(
videoPlayerController: videoPlayerController!,
controlsSafeAreaMinimum: hook.controlsSafeAreaMinimum,
showOptions: hook.showOptions,
showControlsOnInitialize: hook.showControlsOnInitialize,
autoPlay: hook.autoPlay,
autoInitialize: hook.autoInitialize,
allowFullScreen: hook.allowFullScreen,
allowedScreenSleep: hook.allowedScreenSleep,
showControls: hook.showControls,
customControls: hook.customControls,
placeholder: hook.placeholder,
hideControlsTimer: hook.hideControlsTimer,
);
});
}
}

View File

@ -7,7 +7,7 @@ import 'package:immich_mobile/modules/asset_viewer/providers/show_controls.provi
import 'package:immich_mobile/modules/asset_viewer/providers/video_player_controls_provider.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/video_player_value_provider.dart';
import 'package:immich_mobile/modules/asset_viewer/ui/center_play_button.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
import 'package:immich_mobile/shared/ui/delayed_loading_indicator.dart';
import 'package:video_player/video_player.dart';
class VideoPlayerControls extends ConsumerStatefulWidget {
@ -66,7 +66,9 @@ class VideoPlayerControlsState extends ConsumerState<VideoPlayerControls>
children: [
if (_displayBufferingIndicator)
const Center(
child: ImmichLoadingIndicator(),
child: DelayedLoadingIndicator(
fadeInDuration: Duration(milliseconds: 400),
),
)
else
_buildHitArea(),
@ -79,6 +81,7 @@ class VideoPlayerControlsState extends ConsumerState<VideoPlayerControls>
@override
void dispose() {
_dispose();
super.dispose();
}
@ -92,6 +95,7 @@ class VideoPlayerControlsState extends ConsumerState<VideoPlayerControls>
final oldController = _chewieController;
_chewieController = ChewieController.of(context);
controller = chewieController.videoPlayerController;
_latestValue = controller.value;
if (oldController != chewieController) {
_dispose();
@ -106,12 +110,10 @@ class VideoPlayerControlsState extends ConsumerState<VideoPlayerControls>
return GestureDetector(
onTap: () {
if (_latestValue.isPlaying) {
ref.read(showControlsProvider.notifier).show = false;
} else {
if (!_latestValue.isPlaying) {
_playPause();
ref.read(showControlsProvider.notifier).show = false;
}
ref.read(showControlsProvider.notifier).show = false;
},
child: CenterPlayButton(
backgroundColor: Colors.black54,
@ -131,10 +133,11 @@ class VideoPlayerControlsState extends ConsumerState<VideoPlayerControls>
}
Future<void> _initialize() async {
ref.read(showControlsProvider.notifier).show = false;
_mute(ref.read(videoPlayerControlsProvider.select((value) => value.mute)));
controller.addListener(_updateState);
_latestValue = controller.value;
controller.addListener(_updateState);
if (controller.value.isPlaying || chewieController.autoPlay) {
_startHideTimer();
@ -167,9 +170,8 @@ class VideoPlayerControlsState extends ConsumerState<VideoPlayerControls>
}
void _startHideTimer() {
final hideControlsTimer = chewieController.hideControlsTimer.isNegative
? ChewieController.defaultHideControlsTimer
: chewieController.hideControlsTimer;
final hideControlsTimer = chewieController.hideControlsTimer;
_hideTimer?.cancel();
_hideTimer = Timer(hideControlsTimer, () {
ref.read(showControlsProvider.notifier).show = false;
});

View File

@ -704,6 +704,18 @@ class GalleryViewerPage extends HookConsumerWidget {
);
}
useEffect(
() {
if (ref.read(showControlsProvider)) {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
} else {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
}
return null;
},
[],
);
ref.listen(showControlsProvider, (_, show) {
if (show) {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
@ -794,7 +806,9 @@ class GalleryViewerPage extends HookConsumerWidget {
minScale: 1.0,
basePosition: Alignment.center,
child: VideoViewerPage(
onPlaying: () => isPlayingVideo.value = true,
onPlaying: () {
isPlayingVideo.value = true;
},
onPaused: () =>
WidgetsBinding.instance.addPostFrameCallback(
(_) => isPlayingVideo.value = false,

View File

@ -1,23 +1,15 @@
import 'dart:io';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:chewie/chewie.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/asset_viewer/models/image_viewer_page_state.model.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/image_viewer_page_state.provider.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:immich_mobile/modules/asset_viewer/hooks/chewiew_controller_hook.dart';
import 'package:immich_mobile/modules/asset_viewer/ui/video_player_controls.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
import 'package:photo_manager/photo_manager.dart';
import 'package:video_player/video_player.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
import 'package:immich_mobile/shared/ui/delayed_loading_indicator.dart';
@RoutePage()
// ignore: must_be_immutable
class VideoViewerPage extends HookConsumerWidget {
class VideoViewerPage extends HookWidget {
final Asset asset;
final bool isMotionVideo;
final Widget? placeholder;
@ -42,211 +34,49 @@ class VideoViewerPage extends HookConsumerWidget {
});
@override
Widget build(BuildContext context, WidgetRef ref) {
if (asset.isLocal && asset.livePhotoVideoId == null) {
final AsyncValue<File> videoFile = ref.watch(_fileFamily(asset.local!));
return AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: videoFile.when(
data: (data) => VideoPlayer(
file: data,
isMotionVideo: false,
onVideoEnded: () {},
),
error: (error, stackTrace) => Icon(
Icons.image_not_supported_outlined,
color: context.primaryColor,
),
loading: () => showDownloadingIndicator
? const Center(child: ImmichLoadingIndicator())
: Container(),
),
);
}
final downloadAssetStatus =
ref.watch(imageViewerStateProvider).downloadAssetStatus;
final String videoUrl = isMotionVideo
? '${Store.get(StoreKey.serverEndpoint)}/asset/file/${asset.livePhotoVideoId}'
: '${Store.get(StoreKey.serverEndpoint)}/asset/file/${asset.remoteId}';
return Stack(
children: [
VideoPlayer(
url: videoUrl,
accessToken: Store.get(StoreKey.accessToken),
isMotionVideo: isMotionVideo,
onVideoEnded: onVideoEnded,
onPaused: onPaused,
onPlaying: onPlaying,
placeholder: placeholder,
hideControlsTimer: hideControlsTimer,
showControls: showControls,
showDownloadingIndicator: showDownloadingIndicator,
),
AnimatedOpacity(
duration: const Duration(milliseconds: 400),
opacity: (downloadAssetStatus == DownloadAssetStatus.loading &&
showDownloadingIndicator)
? 1.0
: 0.0,
child: SizedBox(
height: context.height,
width: context.width,
child: const Center(
child: ImmichLoadingIndicator(),
),
),
),
],
);
}
}
final _fileFamily =
FutureProvider.family<File, AssetEntity>((ref, entity) async {
final file = await entity.file;
if (file == null) {
throw Exception();
}
return file;
});
class VideoPlayer extends StatefulWidget {
final String? url;
final String? accessToken;
final File? file;
final bool isMotionVideo;
final VoidCallback? onVideoEnded;
final Duration hideControlsTimer;
final bool showControls;
final Function()? onPlaying;
final Function()? onPaused;
/// The placeholder to show while the video is loading
/// usually, a thumbnail of the video
final Widget? placeholder;
final bool showDownloadingIndicator;
const VideoPlayer({
super.key,
this.url,
this.accessToken,
this.file,
this.onVideoEnded,
required this.isMotionVideo,
this.onPlaying,
this.onPaused,
this.placeholder,
this.hideControlsTimer = const Duration(
seconds: 5,
),
this.showControls = true,
this.showDownloadingIndicator = true,
});
@override
State<VideoPlayer> createState() => _VideoPlayerState();
}
class _VideoPlayerState extends State<VideoPlayer> {
late VideoPlayerController videoPlayerController;
ChewieController? chewieController;
@override
void initState() {
super.initState();
initializePlayer();
videoPlayerController.addListener(() {
if (videoPlayerController.value.isInitialized) {
if (videoPlayerController.value.isPlaying) {
WakelockPlus.enable();
widget.onPlaying?.call();
} else if (!videoPlayerController.value.isPlaying) {
WakelockPlus.disable();
widget.onPaused?.call();
}
if (videoPlayerController.value.position ==
videoPlayerController.value.duration) {
WakelockPlus.disable();
widget.onVideoEnded?.call();
}
}
});
}
Future<void> initializePlayer() async {
try {
videoPlayerController = widget.file == null
? VideoPlayerController.networkUrl(
Uri.parse(widget.url!),
httpHeaders: {"x-immich-user-token": widget.accessToken ?? ""},
)
: VideoPlayerController.file(widget.file!);
await videoPlayerController.initialize();
_createChewieController();
setState(() {});
} catch (e) {
debugPrint("ERROR initialize video player $e");
}
}
_createChewieController() {
chewieController = ChewieController(
Widget build(BuildContext context) {
final controller = useChewieController(
asset,
controlsSafeAreaMinimum: const EdgeInsets.only(
bottom: 100,
),
showOptions: true,
showControlsOnInitialize: false,
videoPlayerController: videoPlayerController,
autoPlay: true,
autoInitialize: true,
allowFullScreen: false,
allowedScreenSleep: false,
showControls: widget.showControls && !widget.isMotionVideo,
placeholder: placeholder,
showControls: showControls && !isMotionVideo,
hideControlsTimer: hideControlsTimer,
customControls: const VideoPlayerControls(),
hideControlsTimer: widget.hideControlsTimer,
onPlaying: onPlaying,
onPaused: onPaused,
onVideoEnded: onVideoEnded,
);
// Loading
return PopScope(
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 400),
child: Builder(
builder: (context) {
if (controller == null) {
return Stack(
children: [
if (placeholder != null) placeholder!,
const DelayedLoadingIndicator(
fadeInDuration: Duration(milliseconds: 500),
),
],
);
}
final size = MediaQuery.of(context).size;
return SizedBox(
height: size.height,
width: size.width,
child: Chewie(
controller: controller,
),
);
},
),
),
);
}
@override
void dispose() {
super.dispose();
videoPlayerController.pause();
videoPlayerController.dispose();
chewieController?.dispose();
}
@override
Widget build(BuildContext context) {
if (chewieController?.videoPlayerController.value.isInitialized == true) {
return SizedBox(
height: context.height,
width: context.width,
child: Chewie(
controller: chewieController!,
),
);
} else {
return SizedBox(
height: context.height,
width: context.width,
child: Center(
child: Stack(
children: [
if (widget.placeholder != null) widget.placeholder!,
if (widget.showDownloadingIndicator)
const Center(
child: ImmichLoadingIndicator(),
),
],
),
),
);
}
}
}

View File

@ -1393,7 +1393,7 @@ class VideoViewerRoute extends PageRouteInfo<VideoViewerRouteArgs> {
void Function()? onPaused,
Widget? placeholder,
bool showControls = true,
Duration hideControlsTimer = const Duration(seconds: 5),
Duration hideControlsTimer = const Duration(milliseconds: 1500),
bool showDownloadingIndicator = true,
List<PageRouteInfo>? children,
}) : super(
@ -1429,7 +1429,7 @@ class VideoViewerRouteArgs {
this.onPaused,
this.placeholder,
this.showControls = true,
this.hideControlsTimer = const Duration(seconds: 5),
this.hideControlsTimer = const Duration(milliseconds: 1500),
this.showDownloadingIndicator = true,
});

View File

@ -171,6 +171,11 @@ class Asset {
int? stackCount;
/// Aspect ratio of the asset
@ignore
double? get aspectRatio =>
width == null || height == null ? 0 : width! / height!;
/// `true` if this [Asset] is present on the device
@ignore
bool get isLocal => localId != null;

View File

@ -0,0 +1,40 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
class DelayedLoadingIndicator extends StatelessWidget {
/// The delay to avoid showing the loading indicator
final Duration delay;
/// Defaults to using the [ImmichLoadingIndicator]
final Widget? child;
/// An optional fade in duration to animate the loading
final Duration? fadeInDuration;
const DelayedLoadingIndicator({
super.key,
this.delay = const Duration(seconds: 3),
this.child,
this.fadeInDuration,
});
@override
Widget build(BuildContext context) {
return AnimatedSwitcher(
duration: fadeInDuration ?? Duration.zero,
child: FutureBuilder(
future: Future.delayed(delay),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done) {
return child ??
const ImmichLoadingIndicator(
key: ValueKey('loading'),
);
}
return Container(key: const ValueKey('hiding'));
},
),
);
}
}

View File

@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
import 'package:immich_mobile/shared/ui/delayed_loading_indicator.dart';
final _loadingEntry = OverlayEntry(
builder: (context) => SizedBox.square(
@ -9,7 +9,12 @@ final _loadingEntry = OverlayEntry(
child: DecoratedBox(
decoration:
BoxDecoration(color: context.colorScheme.surface.withAlpha(200)),
child: const Center(child: ImmichLoadingIndicator()),
child: const Center(
child: DelayedLoadingIndicator(
delay: Duration(seconds: 1),
fadeInDuration: Duration(milliseconds: 400),
),
),
),
),
);
@ -27,19 +32,19 @@ class _LoadingOverlay extends Hook<ValueNotifier<bool>> {
class _LoadingOverlayState
extends HookState<ValueNotifier<bool>, _LoadingOverlay> {
late final _isProcessing = ValueNotifier(false)..addListener(_listener);
OverlayEntry? overlayEntry;
late final _isLoading = ValueNotifier(false)..addListener(_listener);
OverlayEntry? _loadingOverlay;
void _listener() {
setState(() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_isProcessing.value) {
overlayEntry?.remove();
overlayEntry = _loadingEntry;
if (_isLoading.value) {
_loadingOverlay?.remove();
_loadingOverlay = _loadingEntry;
Overlay.of(context).insert(_loadingEntry);
} else {
overlayEntry?.remove();
overlayEntry = null;
_loadingOverlay?.remove();
_loadingOverlay = null;
}
});
});
@ -47,17 +52,17 @@ class _LoadingOverlayState
@override
ValueNotifier<bool> build(BuildContext context) {
return _isProcessing;
return _isLoading;
}
@override
void dispose() {
_isProcessing.dispose();
_isLoading.dispose();
super.dispose();
}
@override
Object? get debugValue => _isProcessing.value;
Object? get debugValue => _isLoading.value;
@override
String get debugLabel => 'useProcessingOverlay<>';