Refactors video player controller

format

fixing video

format

Working

format
This commit is contained in:
Marty Fuhry
2024-03-01 15:37:37 -05:00
parent adb541e627
commit f151e6cead
13 changed files with 488 additions and 365 deletions
@@ -96,14 +96,17 @@ class _ChewieControllerHookState
@override @override
void initHook() async { void initHook() async {
print('CHEWIE CONTROLLER > creating chewie $hashCode');
super.initHook(); super.initHook();
_initialize().whenComplete(() => setState(() {})); _initialize().whenComplete(() => setState(() {}));
} }
@override @override
void dispose() { void dispose() {
chewieController?.dispose(); print('CHEWIE CONTROLLER > disposing chewie $hashCode');
videoPlayerController?.pause();
videoPlayerController?.dispose(); videoPlayerController?.dispose();
chewieController?.dispose();
super.dispose(); super.dispose();
} }
@@ -1,10 +1,15 @@
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
class VideoPlaybackControls { class VideoPlaybackControls {
VideoPlaybackControls({required this.position, required this.mute}); VideoPlaybackControls({
required this.position,
required this.mute,
required this.pause,
});
final double position; final double position;
final bool mute; final bool mute;
final bool pause;
} }
final videoPlayerControlsProvider = final videoPlayerControlsProvider =
@@ -17,6 +22,7 @@ class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
: super( : super(
VideoPlaybackControls( VideoPlaybackControls(
position: 0, position: 0,
pause: false,
mute: false, mute: false,
), ),
); );
@@ -33,14 +39,50 @@ class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
bool get mute => state.mute; bool get mute => state.mute;
set position(double value) { set position(double value) {
state = VideoPlaybackControls(position: value, mute: state.mute); state = VideoPlaybackControls(
position: value,
mute: state.mute,
pause: state.pause,
);
} }
set mute(bool value) { set mute(bool value) {
state = VideoPlaybackControls(position: state.position, mute: value); state = VideoPlaybackControls(
position: state.position,
mute: value,
pause: state.pause,
);
} }
void toggleMute() { void toggleMute() {
state = VideoPlaybackControls(position: state.position, mute: !state.mute); state = VideoPlaybackControls(
position: state.position,
mute: !state.mute,
pause: state.pause,
);
}
void pause() {
state = VideoPlaybackControls(
position: state.position,
mute: state.mute,
pause: true,
);
}
void play() {
state = VideoPlaybackControls(
position: state.position,
mute: state.mute,
pause: false,
);
}
void togglePlay() {
state = VideoPlaybackControls(
position: state.position,
mute: state.mute,
pause: !state.pause,
);
} }
} }
@@ -1,10 +1,65 @@
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:video_player/video_player.dart';
enum VideoPlaybackState {
initializing,
paused,
playing,
buffering,
completed,
}
class VideoPlaybackValue { class VideoPlaybackValue {
VideoPlaybackValue({required this.position, required this.duration}); /// The current position of the video
final Duration position; final Duration position;
/// The total duration of the video
final Duration duration; final Duration duration;
/// The current state of the video playback
final VideoPlaybackState state;
/// The volume of the video
final double volume;
VideoPlaybackValue({
required this.position,
required this.duration,
required this.state,
required this.volume,
});
factory VideoPlaybackValue.fromController(VideoPlayerController? controller) {
final video = controller?.value;
late VideoPlaybackState s;
if (video == null) {
s = VideoPlaybackState.initializing;
} else if (video.isCompleted) {
s = VideoPlaybackState.completed;
} else if (video.isPlaying) {
s = VideoPlaybackState.playing;
} else if (video.isBuffering) {
s = VideoPlaybackState.buffering;
} else {
s = VideoPlaybackState.paused;
}
return VideoPlaybackValue(
position: video?.position ?? Duration.zero,
duration: video?.duration ?? Duration.zero,
state: s,
volume: video?.volume ?? 0.0,
);
}
factory VideoPlaybackValue.uninitialized() {
return VideoPlaybackValue(
position: Duration.zero,
duration: Duration.zero,
state: VideoPlaybackState.initializing,
volume: 0.0,
);
}
} }
final videoPlaybackValueProvider = final videoPlaybackValueProvider =
@@ -15,10 +70,7 @@ final videoPlaybackValueProvider =
class VideoPlaybackValueState extends StateNotifier<VideoPlaybackValue> { class VideoPlaybackValueState extends StateNotifier<VideoPlaybackValue> {
VideoPlaybackValueState(this.ref) VideoPlaybackValueState(this.ref)
: super( : super(
VideoPlaybackValue( VideoPlaybackValue.uninitialized(),
position: Duration.zero,
duration: Duration.zero,
),
); );
final Ref ref; final Ref ref;
@@ -30,6 +82,11 @@ class VideoPlaybackValueState extends StateNotifier<VideoPlaybackValue> {
} }
set position(Duration value) { set position(Duration value) {
state = VideoPlaybackValue(position: value, duration: state.duration); state = VideoPlaybackValue(
position: value,
duration: state.duration,
state: state.state,
volume: state.volume,
);
} }
} }
@@ -10,10 +10,12 @@ import 'package:immich_mobile/modules/asset_viewer/providers/asset_stack.provide
import 'package:immich_mobile/modules/asset_viewer/providers/image_viewer_page_state.provider.dart'; import 'package:immich_mobile/modules/asset_viewer/providers/image_viewer_page_state.provider.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/show_controls.provider.dart'; import 'package:immich_mobile/modules/asset_viewer/providers/show_controls.provider.dart';
import 'package:immich_mobile/modules/asset_viewer/services/asset_stack.service.dart'; import 'package:immich_mobile/modules/asset_viewer/services/asset_stack.service.dart';
import 'package:immich_mobile/modules/backup/providers/manual_upload.provider.dart'; import 'package:immich_mobile/modules/asset_viewer/ui/video_controls.dart';
import 'package:immich_mobile/modules/home/ui/upload_dialog.dart'; import 'package:immich_mobile/modules/home/ui/delete_dialog.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart'; import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
import 'package:immich_mobile/shared/providers/user.provider.dart'; import 'package:immich_mobile/shared/providers/user.provider.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart'; import 'package:immich_mobile/shared/ui/immich_toast.dart';
@@ -21,6 +23,8 @@ class BottomGalleryBar extends ConsumerWidget {
final Asset asset; final Asset asset;
final bool showStack; final bool showStack;
final int stackIndex; final int stackIndex;
final int totalAssets;
final bool showVideoPlayerControls;
final PageController controller; final PageController controller;
const BottomGalleryBar({ const BottomGalleryBar({
@@ -29,6 +33,8 @@ class BottomGalleryBar extends ConsumerWidget {
required this.stackIndex, required this.stackIndex,
required this.asset, required this.asset,
required this.controller, required this.controller,
required this.totalAssets,
required this.showVideoPlayerControls,
}); });
@override @override
@@ -40,7 +46,12 @@ class BottomGalleryBar extends ConsumerWidget {
: <Asset>[]; : <Asset>[];
final stackElements = showStack ? [asset, ...stack] : <Asset>[]; final stackElements = showStack ? [asset, ...stack] : <Asset>[];
bool isParent = stackIndex == -1 || stackIndex == 0; bool isParent = stackIndex == -1 || stackIndex == 0;
final navStack = AutoRouter.of(context).stackData;
final isTrashEnabled =
ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash));
final isFromTrash = isTrashEnabled &&
navStack.length > 2 &&
navStack.elementAt(navStack.length - 2).name == TrashRoute.name;
// !!!! itemsList and actionlist should always be in sync // !!!! itemsList and actionlist should always be in sync
final itemsList = [ final itemsList = [
BottomNavigationBarItem( BottomNavigationBarItem(
@@ -87,11 +98,10 @@ class BottomGalleryBar extends ConsumerWidget {
ref ref
.read(assetStackStateProvider(asset).notifier) .read(assetStackStateProvider(asset).notifier)
.removeChild(stackIndex - 1); .removeChild(stackIndex - 1);
stackIndex.value = stackIndex.value - 1;
} }
} }
void handleDelete(Asset deleteAsset) async { void handleDelete() async {
// Cannot delete readOnly / external assets. They are handled through library offline jobs // Cannot delete readOnly / external assets. They are handled through library offline jobs
if (asset.isReadOnly) { if (asset.isReadOnly) {
ImmichToast.show( ImmichToast.show(
@@ -104,7 +114,7 @@ class BottomGalleryBar extends ConsumerWidget {
} }
Future<bool> onDelete(bool force) async { Future<bool> onDelete(bool force) async {
final isDeleted = await ref.read(assetProvider.notifier).deleteAssets( final isDeleted = await ref.read(assetProvider.notifier).deleteAssets(
{deleteAsset}, {asset},
force: force, force: force,
); );
if (isDeleted && isParent) { if (isDeleted && isParent) {
@@ -127,7 +137,7 @@ class BottomGalleryBar extends ConsumerWidget {
final isDeleted = await onDelete(false); final isDeleted = await onDelete(false);
if (isDeleted) { if (isDeleted) {
// Can only trash assets stored in server. Local assets are always permanently removed for now // Can only trash assets stored in server. Local assets are always permanently removed for now
if (context.mounted && deleteAsset.isRemote && isParent) { if (context.mounted && asset.isRemote && isParent) {
ImmichToast.show( ImmichToast.show(
durationInSecond: 1, durationInSecond: 1,
context: context, context: context,
@@ -178,7 +188,7 @@ class BottomGalleryBar extends ConsumerWidget {
.read(assetStackServiceProvider) .read(assetStackServiceProvider)
.updateStackParent( .updateStackParent(
asset, asset,
stackElements.elementAt(stackIndex.value), stackElements.elementAt(stackIndex),
); );
ctx.pop(); ctx.pop();
context.popRoute(); context.popRoute();
@@ -213,7 +223,7 @@ class BottomGalleryBar extends ConsumerWidget {
await ref.read(assetStackServiceProvider).updateStack( await ref.read(assetStackServiceProvider).updateStack(
asset, asset,
childrenToRemove: [ childrenToRemove: [
stackElements.elementAt(stackIndex.value), stackElements.elementAt(stackIndex),
], ],
); );
removeAssetFromStack(); removeAssetFromStack();
@@ -273,21 +283,6 @@ class BottomGalleryBar extends ConsumerWidget {
removeAssetFromStack(); removeAssetFromStack();
} }
handleUpload(Asset asset) {
showDialog(
context: context,
builder: (BuildContext _) {
return UploadDialog(
onUpload: () {
ref
.read(manualUploadProvider.notifier)
.uploadAssets(context, [asset]);
},
);
},
);
}
handleDownload() { handleDownload() {
if (asset.isLocal) { if (asset.isLocal) {
return; return;
@@ -323,17 +318,10 @@ class BottomGalleryBar extends ConsumerWidget {
opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0, opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0,
child: Column( child: Column(
children: [ children: [
if (stack.isNotEmpty) Visibility(
Padding( visible: showVideoPlayerControls,
padding: const EdgeInsets.only( child: const VideoControls(),
left: 10, ),
bottom: 30,
),
child: SizedBox(
height: 40,
child: buildStackedChildren(),
),
),
BottomNavigationBar( BottomNavigationBar(
backgroundColor: Colors.black.withOpacity(0.4), backgroundColor: Colors.black.withOpacity(0.4),
unselectedIconTheme: const IconThemeData(color: Colors.white), unselectedIconTheme: const IconThemeData(color: Colors.white),
@@ -0,0 +1,102 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/show_controls.provider.dart';
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/delayed_loading_indicator.dart';
import 'package:immich_mobile/shared/ui/hooks/timer_hook.dart';
class CustomVideoPlayerControls extends HookConsumerWidget {
final Duration hideTimerDuration;
const CustomVideoPlayerControls({
super.key,
this.hideTimerDuration = const Duration(seconds: 3),
});
@override
Widget build(BuildContext context, WidgetRef ref) {
// A timer to hide the controls
final hideTimer = useTimer(
hideTimerDuration,
() {
ref.read(showControlsProvider.notifier).show = false;
},
);
final showBuffering = useState(false);
final VideoPlaybackState state =
ref.watch(videoPlaybackValueProvider).state;
/// Shows the controls and starts the timer to hide them
void showControlsAndStartHideTimer() {
hideTimer.reset();
ref.read(showControlsProvider.notifier).show = true;
}
// When we mute, show the controls
ref.listen(videoPlayerControlsProvider.select((v) => v.mute),
(previous, next) {
showControlsAndStartHideTimer();
});
// When we change position, show or hide timer
ref.listen(videoPlayerControlsProvider.select((v) => v.position),
(previous, next) {
showControlsAndStartHideTimer();
});
ref.listen(videoPlaybackValueProvider.select((value) => value.state),
(_, state) {
// Show buffering
showBuffering.value = state == VideoPlaybackState.buffering;
// Synchronize player with video state
if (state == VideoPlaybackState.playing) {
ref.read(videoPlayerControlsProvider.notifier).play();
} else if (state == VideoPlaybackState.paused) {
ref.read(videoPlayerControlsProvider.notifier).pause();
}
});
/// Toggles between playing and pausing depending on the state of the video
void togglePlay() {
showControlsAndStartHideTimer();
ref.read(videoPlayerControlsProvider.notifier).togglePlay();
}
return GestureDetector(
onTap: () => showControlsAndStartHideTimer(),
child: AbsorbPointer(
absorbing: !ref.watch(showControlsProvider),
child: Stack(
children: [
if (showBuffering.value)
const Center(
child: DelayedLoadingIndicator(
fadeInDuration: Duration(milliseconds: 400),
),
),
GestureDetector(
onTap: () {
if (state != VideoPlaybackState.playing) {
togglePlay();
}
},
child: CenterPlayButton(
backgroundColor: Colors.black54,
iconColor: Colors.white,
isFinished: state == VideoPlaybackState.completed,
isPlaying: state == VideoPlaybackState.playing,
show: ref.watch(showControlsProvider),
onPressed: togglePlay,
),
),
],
),
),
);
}
}
@@ -12,72 +12,78 @@ class VideoControls extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final player = ref.watch(videoPlaybackValueProvider); final duration =
print('player is $player'); ref.watch(videoPlaybackValueProvider.select((v) => v.duration));
final duration = player.duration; final position =
final position = player.position; ref.watch(videoPlaybackValueProvider.select((v) => v.position));
return AnimatedOpacity( return AnimatedOpacity(
opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0, opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0,
duration: const Duration(milliseconds: 100), duration: const Duration(milliseconds: 100),
child: Container( child: OrientationBuilder(
color: Colors.black.withOpacity(0.4), builder: (context, orientation) => Container(
child: Padding( padding: EdgeInsets.symmetric(
padding: MediaQuery.of(context).orientation == Orientation.portrait horizontal: orientation == Orientation.portrait ? 12.0 : 64.0,
? const EdgeInsets.symmetric(horizontal: 12.0) ),
: const EdgeInsets.symmetric(horizontal: 64.0), color: Colors.black.withOpacity(0.4),
child: Row( child: Padding(
children: [ padding: MediaQuery.of(context).orientation == Orientation.portrait
Text( ? const EdgeInsets.symmetric(horizontal: 12.0)
_formatDuration(position), : const EdgeInsets.symmetric(horizontal: 64.0),
style: TextStyle( child: Row(
fontSize: 14.0, children: [
color: Colors.white.withOpacity(.75), Text(
fontWeight: FontWeight.normal, _formatDuration(position),
style: TextStyle(
fontSize: 14.0,
color: Colors.white.withOpacity(.75),
fontWeight: FontWeight.normal,
),
), ),
), Expanded(
Expanded( child: Slider(
child: Slider( value: duration == Duration.zero
value: player.duration == Duration.zero ? 0.0
? 0.0 : min(
: min( position.inMicroseconds /
player.position.inMicroseconds / duration.inMicroseconds *
player.duration.inMicroseconds * 100,
100, 100,
100, ),
), min: 0,
min: 0, max: 100,
max: 100, thumbColor: Colors.white,
thumbColor: Colors.white, activeColor: Colors.white,
activeColor: Colors.white, inactiveColor: Colors.white.withOpacity(0.75),
inactiveColor: Colors.white.withOpacity(0.75), onChanged: (position) {
onChanged: (position) { ref.read(videoPlayerControlsProvider.notifier).position =
ref.read(videoPlayerControlsProvider.notifier).position = position;
position; },
}, ),
), ),
), Text(
Text( _formatDuration(duration),
_formatDuration(duration), style: TextStyle(
style: TextStyle( fontSize: 14.0,
fontSize: 14.0, color: Colors.white.withOpacity(.75),
color: Colors.white.withOpacity(.75), fontWeight: FontWeight.normal,
fontWeight: FontWeight.normal, ),
), ),
), IconButton(
IconButton( icon: Icon(
icon: Icon( ref.watch(
ref.watch( videoPlayerControlsProvider.select((value) => value.mute),
videoPlayerControlsProvider.select((value) => value.mute), )
) ? Icons.volume_off
? Icons.volume_off : Icons.volume_up,
: Icons.volume_up, ),
onPressed: () => ref
.read(videoPlayerControlsProvider.notifier)
.toggleMute(),
color: Colors.white,
), ),
onPressed: () => ],
ref.read(videoPlayerControlsProvider.notifier).toggleMute(), ),
color: Colors.white,
),
],
), ),
), ),
), ),
@@ -1,209 +0,0 @@
import 'dart:async';
import 'package:chewie/chewie.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/show_controls.provider.dart';
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/delayed_loading_indicator.dart';
import 'package:video_player/video_player.dart';
class VideoPlayerControls extends ConsumerStatefulWidget {
const VideoPlayerControls({
super.key,
});
@override
VideoPlayerControlsState createState() => VideoPlayerControlsState();
}
class VideoPlayerControlsState extends ConsumerState<VideoPlayerControls>
with SingleTickerProviderStateMixin {
late VideoPlayerController controller;
late VideoPlayerValue _latestValue;
bool _displayBufferingIndicator = false;
double? _latestVolume;
Timer? _hideTimer;
ChewieController? _chewieController;
ChewieController get chewieController => _chewieController!;
@override
Widget build(BuildContext context) {
ref.listen(videoPlayerControlsProvider.select((value) => value.mute),
(_, value) {
_mute(value);
_cancelAndRestartTimer();
});
ref.listen(videoPlayerControlsProvider.select((value) => value.position),
(_, position) {
_seekTo(position);
_cancelAndRestartTimer();
});
if (_latestValue.hasError) {
return chewieController.errorBuilder?.call(
context,
chewieController.videoPlayerController.value.errorDescription!,
) ??
const Center(
child: Icon(
Icons.error,
color: Colors.white,
size: 42,
),
);
}
return GestureDetector(
onTap: () => _cancelAndRestartTimer(),
child: AbsorbPointer(
absorbing: !ref.watch(showControlsProvider),
child: Stack(
children: [
if (_displayBufferingIndicator)
const Center(
child: DelayedLoadingIndicator(
fadeInDuration: Duration(milliseconds: 400),
),
)
else
_buildHitArea(),
],
),
),
);
}
@override
void dispose() {
_dispose();
super.dispose();
}
void _dispose() {
controller.removeListener(_updateState);
_hideTimer?.cancel();
}
@override
void didChangeDependencies() {
final oldController = _chewieController;
_chewieController = ChewieController.of(context);
controller = chewieController.videoPlayerController;
_latestValue = controller.value;
if (oldController != chewieController) {
_dispose();
_initialize();
}
super.didChangeDependencies();
}
Widget _buildHitArea() {
final bool isFinished = _latestValue.position >= _latestValue.duration;
return GestureDetector(
onTap: () {
if (!_latestValue.isPlaying) {
_playPause();
}
ref.read(showControlsProvider.notifier).show = false;
},
child: CenterPlayButton(
backgroundColor: Colors.black54,
iconColor: Colors.white,
isFinished: isFinished,
isPlaying: controller.value.isPlaying,
show: ref.watch(showControlsProvider),
onPressed: _playPause,
),
);
}
void _cancelAndRestartTimer() {
_hideTimer?.cancel();
_startHideTimer();
ref.read(showControlsProvider.notifier).show = true;
}
Future<void> _initialize() async {
ref.read(showControlsProvider.notifier).show = false;
_mute(ref.read(videoPlayerControlsProvider.select((value) => value.mute)));
_latestValue = controller.value;
controller.addListener(_updateState);
if (controller.value.isPlaying || chewieController.autoPlay) {
_startHideTimer();
}
}
void _playPause() {
final isFinished = _latestValue.position >= _latestValue.duration;
setState(() {
if (controller.value.isPlaying) {
ref.read(showControlsProvider.notifier).show = true;
_hideTimer?.cancel();
controller.pause();
} else {
_cancelAndRestartTimer();
if (!controller.value.isInitialized) {
controller.initialize().then((_) {
controller.play();
});
} else {
if (isFinished) {
controller.seekTo(Duration.zero);
}
controller.play();
}
}
});
}
void _startHideTimer() {
final hideControlsTimer = chewieController.hideControlsTimer;
_hideTimer?.cancel();
_hideTimer = Timer(hideControlsTimer, () {
ref.read(showControlsProvider.notifier).show = false;
});
}
void _updateState() {
if (!mounted) return;
_displayBufferingIndicator = controller.value.isBuffering;
setState(() {
_latestValue = controller.value;
ref.read(videoPlaybackValueProvider.notifier).value = VideoPlaybackValue(
position: _latestValue.position,
duration: _latestValue.duration,
);
});
}
void _mute(bool mute) {
if (mute) {
_latestVolume = controller.value.volume;
controller.setVolume(0);
} else {
controller.setVolume(_latestVolume ?? 0.5);
}
}
void _seekTo(double position) {
final Duration pos = controller.value.duration * (position / 100.0);
if (pos != controller.value.position) {
controller.seekTo(pos);
}
}
}
@@ -2,12 +2,10 @@ import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'dart:math'; import 'dart:math';
import 'dart:ui' as ui; import 'dart:ui' as ui;
import 'package:easy_localization/easy_localization.dart';
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store; import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/album/providers/current_album.provider.dart'; import 'package:immich_mobile/modules/album/providers/current_album.provider.dart';
@@ -17,7 +15,6 @@ import 'package:immich_mobile/modules/asset_viewer/providers/current_asset.provi
import 'package:immich_mobile/modules/asset_viewer/providers/show_controls.provider.dart'; import 'package:immich_mobile/modules/asset_viewer/providers/show_controls.provider.dart';
import 'package:immich_mobile/modules/album/ui/add_to_album_bottom_sheet.dart'; import 'package:immich_mobile/modules/album/ui/add_to_album_bottom_sheet.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/image_viewer_page_state.provider.dart'; import 'package:immich_mobile/modules/asset_viewer/providers/image_viewer_page_state.provider.dart';
import 'package:immich_mobile/modules/asset_viewer/services/asset_stack.service.dart';
import 'package:immich_mobile/modules/asset_viewer/ui/advanced_bottom_sheet.dart'; import 'package:immich_mobile/modules/asset_viewer/ui/advanced_bottom_sheet.dart';
import 'package:immich_mobile/modules/asset_viewer/ui/bottom_gallery_bar.dart'; import 'package:immich_mobile/modules/asset_viewer/ui/bottom_gallery_bar.dart';
import 'package:immich_mobile/modules/asset_viewer/ui/exif_bottom_sheet.dart'; import 'package:immich_mobile/modules/asset_viewer/ui/exif_bottom_sheet.dart';
@@ -27,14 +24,11 @@ import 'package:immich_mobile/modules/backup/providers/manual_upload.provider.da
import 'package:immich_mobile/modules/home/ui/upload_dialog.dart'; import 'package:immich_mobile/modules/home/ui/upload_dialog.dart';
import 'package:immich_mobile/modules/partner/providers/partner.provider.dart'; import 'package:immich_mobile/modules/partner/providers/partner.provider.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/modules/home/ui/delete_dialog.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart'; import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart'; import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
import 'package:immich_mobile/shared/providers/user.provider.dart'; import 'package:immich_mobile/shared/providers/user.provider.dart';
import 'package:immich_mobile/shared/ui/immich_image.dart'; import 'package:immich_mobile/shared/ui/immich_image.dart';
import 'package:immich_mobile/shared/ui/immich_thumbnail.dart'; import 'package:immich_mobile/shared/ui/immich_thumbnail.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart';
import 'package:immich_mobile/shared/ui/photo_view/photo_view_gallery.dart'; import 'package:immich_mobile/shared/ui/photo_view/photo_view_gallery.dart';
import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_computed_scale.dart'; import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_computed_scale.dart';
import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_scale_state.dart'; import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_scale_state.dart';
@@ -74,16 +68,10 @@ class GalleryViewerPage extends HookConsumerWidget {
final isLoadOriginal = useState(AppSettingsEnum.loadOriginal.defaultValue); final isLoadOriginal = useState(AppSettingsEnum.loadOriginal.defaultValue);
final isZoomed = useState<bool>(false); final isZoomed = useState<bool>(false);
final isPlayingMotionVideo = useState(false); final isPlayingMotionVideo = useState(false);
final isPlayingVideo = useState(false);
Offset? localPosition; Offset? localPosition;
final currentIndex = useState(initialIndex); final currentIndex = useState(initialIndex);
final currentAsset = loadAsset(currentIndex.value); final currentAsset = loadAsset(currentIndex.value);
final isTrashEnabled =
ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash));
final navStack = AutoRouter.of(context).stackData;
final isFromTrash = isTrashEnabled &&
navStack.length > 2 &&
navStack.elementAt(navStack.length - 2).name == TrashRoute.name;
final stackIndex = useState(-1); final stackIndex = useState(-1);
final stack = showStack && currentAsset.stackChildrenCount > 0 final stack = showStack && currentAsset.stackChildrenCount > 0
? ref.watch(assetStackStateProvider(currentAsset)) ? ref.watch(assetStackStateProvider(currentAsset))
@@ -102,7 +90,6 @@ class GalleryViewerPage extends HookConsumerWidget {
.map((e) => e.isarId) .map((e) => e.isarId)
.contains(asset.ownerId); .contains(asset.ownerId);
// Listen provider to prevent autoDispose when navigating to other routes from within the gallery page // Listen provider to prevent autoDispose when navigating to other routes from within the gallery page
ref.listen(currentAssetProvider, (_, __) {}); ref.listen(currentAssetProvider, (_, __) {});
useEffect( useEffect(
@@ -223,6 +210,21 @@ class GalleryViewerPage extends HookConsumerWidget {
} }
} }
handleUpload(Asset asset) {
showDialog(
context: context,
builder: (BuildContext _) {
return UploadDialog(
onUpload: () {
ref
.read(manualUploadProvider.notifier)
.uploadAssets(context, [asset]);
},
);
},
);
}
buildAppBar() { buildAppBar() {
return IgnorePointer( return IgnorePointer(
ignoring: !ref.watch(showControlsProvider), ignoring: !ref.watch(showControlsProvider),
@@ -238,8 +240,7 @@ class GalleryViewerPage extends HookConsumerWidget {
asset: asset, asset: asset,
onMoreInfoPressed: showInfo, onMoreInfoPressed: showInfo,
onFavorite: toggleFavorite, onFavorite: toggleFavorite,
onUploadPressed: onUploadPressed: asset.isLocal ? () => handleUpload(asset) : null,
asset.isLocal ? () => handleUpload(asset) : null,
onDownloadPressed: asset.isLocal onDownloadPressed: asset.isLocal
? null ? null
: () => : () =>
@@ -258,10 +259,6 @@ class GalleryViewerPage extends HookConsumerWidget {
); );
} }
// TODO: Migrate to a custom bottom bar and handle long press to delete
Widget buildBottomBar() {
}
useEffect( useEffect(
() { () {
if (ref.read(showControlsProvider)) { if (ref.read(showControlsProvider)) {
@@ -297,11 +294,16 @@ class GalleryViewerPage extends HookConsumerWidget {
} }
}); });
Widget buildStackedChildren() { Widget buildStackedChildren() {
return ListView.builder( return ListView.builder(
shrinkWrap: true, shrinkWrap: true,
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
itemCount: stackElements.length, itemCount: stackElements.length,
padding: const EdgeInsets.only(
left: 10,
right: 10,
bottom: 30,
),
itemBuilder: (context, index) { itemBuilder: (context, index) {
final assetId = stackElements.elementAt(index).remoteId; final assetId = stackElements.elementAt(index).remoteId;
return Padding( return Padding(
@@ -434,13 +436,7 @@ class GalleryViewerPage extends HookConsumerWidget {
minScale: 1.0, minScale: 1.0,
basePosition: Alignment.center, basePosition: Alignment.center,
child: VideoViewerPage( child: VideoViewerPage(
onPlaying: () { key: ValueKey(a),
isPlayingVideo.value = true;
},
onPaused: () =>
WidgetsBinding.instance.addPostFrameCallback(
(_) => isPlayingVideo.value = false,
),
asset: a, asset: a,
isMotionVideo: isPlayingMotionVideo.value, isMotionVideo: isPlayingMotionVideo.value,
placeholder: Image( placeholder: Image(
@@ -472,9 +468,24 @@ class GalleryViewerPage extends HookConsumerWidget {
right: 0, right: 0,
child: Column( child: Column(
children: [ children: [
buildStackedChildren(), Visibility(
BottomGalleryBar( visible: stack.isNotEmpty,
child: SizedBox(
height: 40,
child: buildStackedChildren(),
),
), ),
BottomGalleryBar(
totalAssets: totalAssets,
controller: controller,
showStack: showStack,
stackIndex: stackIndex.value,
asset: asset,
showVideoPlayerControls:
!asset.isImage && !isPlayingMotionVideo.value,
),
],
),
), ),
], ],
), ),
@@ -2,15 +2,18 @@ import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:chewie/chewie.dart'; import 'package:chewie/chewie.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/asset_viewer/hooks/chewiew_controller_hook.dart'; import 'package:immich_mobile/modules/asset_viewer/hooks/chewiew_controller_hook.dart';
import 'package:immich_mobile/modules/asset_viewer/ui/video_controls.dart'; import 'package:immich_mobile/modules/asset_viewer/providers/show_controls.provider.dart';
import 'package:immich_mobile/modules/asset_viewer/ui/video_player_controls.dart'; 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/custom_video_player_controls.dart';
import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/ui/delayed_loading_indicator.dart'; import 'package:immich_mobile/shared/ui/delayed_loading_indicator.dart';
@RoutePage() @RoutePage()
// ignore: must_be_immutable // ignore: must_be_immutable
class VideoViewerPage extends HookWidget { class VideoViewerPage extends HookConsumerWidget {
final Asset asset; final Asset asset;
final bool isMotionVideo; final bool isMotionVideo;
final Widget? placeholder; final Widget? placeholder;
@@ -35,25 +38,108 @@ class VideoViewerPage extends HookWidget {
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context, WidgetRef ref) {
final controller = useChewieController( final controller = useChewieController(
asset, asset,
controlsSafeAreaMinimum: const EdgeInsets.only( controlsSafeAreaMinimum: const EdgeInsets.only(
bottom: 100, bottom: 100,
), ),
placeholder: SizedBox.expand(child: placeholder), placeholder: SizedBox.expand(child: placeholder),
customControls: CustomVideoPlayerControls(
hideTimerDuration: hideControlsTimer,
),
showControls: showControls && !isMotionVideo, showControls: showControls && !isMotionVideo,
hideControlsTimer: hideControlsTimer, hideControlsTimer: hideControlsTimer,
customControls: const VideoPlayerControls(),
onPlaying: onPlaying, onPlaying: onPlaying,
onPaused: onPaused, onPaused: onPaused,
onVideoEnded: onVideoEnded, onVideoEnded: onVideoEnded,
); );
// Loading // The last volume of the video used when mute is toggled
final lastVolume = useState(0.0);
// When the volume changes, set the volume
ref.listen(videoPlayerControlsProvider.select((value) => value.mute),
(_, mute) {
if (mute) {
controller?.setVolume(0.0);
} else {
controller?.setVolume(lastVolume.value);
}
});
// When the position changes, seek to the position
ref.listen(videoPlayerControlsProvider.select((value) => value.position),
(_, position) {
final video = controller?.videoPlayerController.value;
if (video == null) {
// No seeeking if there is no video
return;
}
// Find the position to seek to
final Duration seek = video.duration * (position / 100.0);
controller?.seekTo(seek);
});
// When the custom video controls paus or plays
ref.listen(videoPlayerControlsProvider.select((value) => value.pause),
(_, pause) {
if (pause) {
controller?.pause();
} else {
controller?.play();
}
});
// Updates the [videoPlaybackValueProvider] with the current
// position and duration of the video from the Chewie [controller]
// Also sets the error if there is an error in the playback
void updateVideoPlayback() {
ref.read(videoPlaybackValueProvider.notifier).value =
VideoPlaybackValue.fromController(
controller?.videoPlayerController,
);
}
// Hide the controls when we load
useEffect(
() {
ref.read(showControlsProvider.notifier).show = false;
return null;
},
[],
);
// Adds and removes the listener to the video player
useEffect(
() {
// Guard no controller
if (controller == null) {
return null;
}
final video = controller.videoPlayerController.value;
// Hold initial volume
lastVolume.value = video.volume;
// Subscribes to listener
controller.videoPlayerController.addListener(updateVideoPlayback);
return () {
// Removes listener when we dispose
controller.videoPlayerController.removeListener(updateVideoPlayback);
};
},
[controller],
);
final size = MediaQuery.of(context).size; final size = MediaQuery.of(context).size;
print('showControls $showControls and isMotion $isMotionVideo');
return PopScope( return PopScope(
onPopInvoked: (pop) {
ref.read(videoPlaybackValueProvider.notifier).value =
VideoPlaybackValue.uninitialized();
},
child: AnimatedSwitcher( child: AnimatedSwitcher(
duration: const Duration(milliseconds: 400), duration: const Duration(milliseconds: 400),
child: Stack( child: Stack(
@@ -81,19 +167,6 @@ class VideoViewerPage extends HookWidget {
controller: controller, controller: controller,
), ),
), ),
if (controller != null)
Positioned(
bottom: 0,
left: 0,
right: 0,
child: SizedBox(
width: size.width,
child: Visibility(
visible: true,
child: const VideoControls(),
),
),
),
], ],
), ),
), ),
@@ -69,6 +69,7 @@ class MemoryCard extends StatelessWidget {
return Hero( return Hero(
tag: 'memory-${asset.id}', tag: 'memory-${asset.id}',
child: VideoViewerPage( child: VideoViewerPage(
key: ValueKey(asset),
asset: asset, asset: asset,
showDownloadingIndicator: false, showDownloadingIndicator: false,
placeholder: ImmichImage( placeholder: ImmichImage(
@@ -0,0 +1,48 @@
import 'package:async/async.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
RestartableTimer useTimer(
Duration duration,
void Function() callback,
) {
return use(
_TimerHook(
duration: duration,
callback: callback,
),
);
}
class _TimerHook extends Hook<RestartableTimer> {
final Duration duration;
final void Function() callback;
const _TimerHook({
required this.duration,
required this.callback,
});
@override
HookState<RestartableTimer, Hook<RestartableTimer>> createState() =>
_TimerHookState();
}
class _TimerHookState extends HookState<RestartableTimer, _TimerHook> {
late RestartableTimer timer;
@override
void initHook() {
super.initHook();
timer = RestartableTimer(hook.duration, hook.callback);
}
@override
RestartableTimer build(BuildContext context) {
return timer;
}
@override
void dispose() {
timer.cancel();
super.dispose();
}
}
+1 -1
View File
@@ -50,7 +50,7 @@ packages:
source: hosted source: hosted
version: "2.4.2" version: "2.4.2"
async: async:
dependency: transitive dependency: "direct main"
description: description:
name: async name: async
sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c"
+1
View File
@@ -58,6 +58,7 @@ dependencies:
timezone: ^0.9.2 timezone: ^0.9.2
octo_image: ^2.0.0 octo_image: ^2.0.0
thumbhash: 0.1.0+1 thumbhash: 0.1.0+1
async: ^2.11.0
openapi: openapi:
path: openapi path: openapi