mirror of
https://github.com/immich-app/immich.git
synced 2026-02-05 01:09:14 +03:00
Refactors video player controller
format fixing video format Working format
This commit is contained in:
@@ -96,14 +96,17 @@ class _ChewieControllerHookState
|
||||
|
||||
@override
|
||||
void initHook() async {
|
||||
print('CHEWIE CONTROLLER > creating chewie $hashCode');
|
||||
super.initHook();
|
||||
_initialize().whenComplete(() => setState(() {}));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
chewieController?.dispose();
|
||||
print('CHEWIE CONTROLLER > disposing chewie $hashCode');
|
||||
videoPlayerController?.pause();
|
||||
videoPlayerController?.dispose();
|
||||
chewieController?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
class VideoPlaybackControls {
|
||||
VideoPlaybackControls({required this.position, required this.mute});
|
||||
VideoPlaybackControls({
|
||||
required this.position,
|
||||
required this.mute,
|
||||
required this.pause,
|
||||
});
|
||||
|
||||
final double position;
|
||||
final bool mute;
|
||||
final bool pause;
|
||||
}
|
||||
|
||||
final videoPlayerControlsProvider =
|
||||
@@ -17,6 +22,7 @@ class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
|
||||
: super(
|
||||
VideoPlaybackControls(
|
||||
position: 0,
|
||||
pause: false,
|
||||
mute: false,
|
||||
),
|
||||
);
|
||||
@@ -33,14 +39,50 @@ class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
|
||||
bool get mute => state.mute;
|
||||
|
||||
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) {
|
||||
state = VideoPlaybackControls(position: state.position, mute: value);
|
||||
state = VideoPlaybackControls(
|
||||
position: state.position,
|
||||
mute: value,
|
||||
pause: state.pause,
|
||||
);
|
||||
}
|
||||
|
||||
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:video_player/video_player.dart';
|
||||
|
||||
enum VideoPlaybackState {
|
||||
initializing,
|
||||
paused,
|
||||
playing,
|
||||
buffering,
|
||||
completed,
|
||||
}
|
||||
|
||||
class VideoPlaybackValue {
|
||||
VideoPlaybackValue({required this.position, required this.duration});
|
||||
|
||||
/// The current position of the video
|
||||
final Duration position;
|
||||
|
||||
/// The total duration of the video
|
||||
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 =
|
||||
@@ -15,10 +70,7 @@ final videoPlaybackValueProvider =
|
||||
class VideoPlaybackValueState extends StateNotifier<VideoPlaybackValue> {
|
||||
VideoPlaybackValueState(this.ref)
|
||||
: super(
|
||||
VideoPlaybackValue(
|
||||
position: Duration.zero,
|
||||
duration: Duration.zero,
|
||||
),
|
||||
VideoPlaybackValue.uninitialized(),
|
||||
);
|
||||
|
||||
final Ref ref;
|
||||
@@ -30,6 +82,11 @@ class VideoPlaybackValueState extends StateNotifier<VideoPlaybackValue> {
|
||||
}
|
||||
|
||||
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/show_controls.provider.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/home/ui/upload_dialog.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/ui/video_controls.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/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/ui/immich_toast.dart';
|
||||
|
||||
@@ -21,6 +23,8 @@ class BottomGalleryBar extends ConsumerWidget {
|
||||
final Asset asset;
|
||||
final bool showStack;
|
||||
final int stackIndex;
|
||||
final int totalAssets;
|
||||
final bool showVideoPlayerControls;
|
||||
final PageController controller;
|
||||
|
||||
const BottomGalleryBar({
|
||||
@@ -29,6 +33,8 @@ class BottomGalleryBar extends ConsumerWidget {
|
||||
required this.stackIndex,
|
||||
required this.asset,
|
||||
required this.controller,
|
||||
required this.totalAssets,
|
||||
required this.showVideoPlayerControls,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -40,7 +46,12 @@ class BottomGalleryBar extends ConsumerWidget {
|
||||
: <Asset>[];
|
||||
final stackElements = showStack ? [asset, ...stack] : <Asset>[];
|
||||
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
|
||||
final itemsList = [
|
||||
BottomNavigationBarItem(
|
||||
@@ -87,11 +98,10 @@ class BottomGalleryBar extends ConsumerWidget {
|
||||
ref
|
||||
.read(assetStackStateProvider(asset).notifier)
|
||||
.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
|
||||
if (asset.isReadOnly) {
|
||||
ImmichToast.show(
|
||||
@@ -104,7 +114,7 @@ class BottomGalleryBar extends ConsumerWidget {
|
||||
}
|
||||
Future<bool> onDelete(bool force) async {
|
||||
final isDeleted = await ref.read(assetProvider.notifier).deleteAssets(
|
||||
{deleteAsset},
|
||||
{asset},
|
||||
force: force,
|
||||
);
|
||||
if (isDeleted && isParent) {
|
||||
@@ -127,7 +137,7 @@ class BottomGalleryBar extends ConsumerWidget {
|
||||
final isDeleted = await onDelete(false);
|
||||
if (isDeleted) {
|
||||
// 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(
|
||||
durationInSecond: 1,
|
||||
context: context,
|
||||
@@ -178,7 +188,7 @@ class BottomGalleryBar extends ConsumerWidget {
|
||||
.read(assetStackServiceProvider)
|
||||
.updateStackParent(
|
||||
asset,
|
||||
stackElements.elementAt(stackIndex.value),
|
||||
stackElements.elementAt(stackIndex),
|
||||
);
|
||||
ctx.pop();
|
||||
context.popRoute();
|
||||
@@ -213,7 +223,7 @@ class BottomGalleryBar extends ConsumerWidget {
|
||||
await ref.read(assetStackServiceProvider).updateStack(
|
||||
asset,
|
||||
childrenToRemove: [
|
||||
stackElements.elementAt(stackIndex.value),
|
||||
stackElements.elementAt(stackIndex),
|
||||
],
|
||||
);
|
||||
removeAssetFromStack();
|
||||
@@ -273,21 +283,6 @@ class BottomGalleryBar extends ConsumerWidget {
|
||||
removeAssetFromStack();
|
||||
}
|
||||
|
||||
handleUpload(Asset asset) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext _) {
|
||||
return UploadDialog(
|
||||
onUpload: () {
|
||||
ref
|
||||
.read(manualUploadProvider.notifier)
|
||||
.uploadAssets(context, [asset]);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
handleDownload() {
|
||||
if (asset.isLocal) {
|
||||
return;
|
||||
@@ -323,17 +318,10 @@ class BottomGalleryBar extends ConsumerWidget {
|
||||
opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0,
|
||||
child: Column(
|
||||
children: [
|
||||
if (stack.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 10,
|
||||
bottom: 30,
|
||||
),
|
||||
child: SizedBox(
|
||||
height: 40,
|
||||
child: buildStackedChildren(),
|
||||
),
|
||||
),
|
||||
Visibility(
|
||||
visible: showVideoPlayerControls,
|
||||
child: const VideoControls(),
|
||||
),
|
||||
BottomNavigationBar(
|
||||
backgroundColor: Colors.black.withOpacity(0.4),
|
||||
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
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final player = ref.watch(videoPlaybackValueProvider);
|
||||
print('player is $player');
|
||||
final duration = player.duration;
|
||||
final position = player.position;
|
||||
final duration =
|
||||
ref.watch(videoPlaybackValueProvider.select((v) => v.duration));
|
||||
final position =
|
||||
ref.watch(videoPlaybackValueProvider.select((v) => v.position));
|
||||
|
||||
return AnimatedOpacity(
|
||||
opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0,
|
||||
duration: const Duration(milliseconds: 100),
|
||||
child: Container(
|
||||
color: Colors.black.withOpacity(0.4),
|
||||
child: Padding(
|
||||
padding: MediaQuery.of(context).orientation == Orientation.portrait
|
||||
? const EdgeInsets.symmetric(horizontal: 12.0)
|
||||
: const EdgeInsets.symmetric(horizontal: 64.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
_formatDuration(position),
|
||||
style: TextStyle(
|
||||
fontSize: 14.0,
|
||||
color: Colors.white.withOpacity(.75),
|
||||
fontWeight: FontWeight.normal,
|
||||
child: OrientationBuilder(
|
||||
builder: (context, orientation) => Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: orientation == Orientation.portrait ? 12.0 : 64.0,
|
||||
),
|
||||
color: Colors.black.withOpacity(0.4),
|
||||
child: Padding(
|
||||
padding: MediaQuery.of(context).orientation == Orientation.portrait
|
||||
? const EdgeInsets.symmetric(horizontal: 12.0)
|
||||
: const EdgeInsets.symmetric(horizontal: 64.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
_formatDuration(position),
|
||||
style: TextStyle(
|
||||
fontSize: 14.0,
|
||||
color: Colors.white.withOpacity(.75),
|
||||
fontWeight: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Slider(
|
||||
value: player.duration == Duration.zero
|
||||
? 0.0
|
||||
: min(
|
||||
player.position.inMicroseconds /
|
||||
player.duration.inMicroseconds *
|
||||
100,
|
||||
100,
|
||||
),
|
||||
min: 0,
|
||||
max: 100,
|
||||
thumbColor: Colors.white,
|
||||
activeColor: Colors.white,
|
||||
inactiveColor: Colors.white.withOpacity(0.75),
|
||||
onChanged: (position) {
|
||||
ref.read(videoPlayerControlsProvider.notifier).position =
|
||||
position;
|
||||
},
|
||||
Expanded(
|
||||
child: Slider(
|
||||
value: duration == Duration.zero
|
||||
? 0.0
|
||||
: min(
|
||||
position.inMicroseconds /
|
||||
duration.inMicroseconds *
|
||||
100,
|
||||
100,
|
||||
),
|
||||
min: 0,
|
||||
max: 100,
|
||||
thumbColor: Colors.white,
|
||||
activeColor: Colors.white,
|
||||
inactiveColor: Colors.white.withOpacity(0.75),
|
||||
onChanged: (position) {
|
||||
ref.read(videoPlayerControlsProvider.notifier).position =
|
||||
position;
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
_formatDuration(duration),
|
||||
style: TextStyle(
|
||||
fontSize: 14.0,
|
||||
color: Colors.white.withOpacity(.75),
|
||||
fontWeight: FontWeight.normal,
|
||||
Text(
|
||||
_formatDuration(duration),
|
||||
style: TextStyle(
|
||||
fontSize: 14.0,
|
||||
color: Colors.white.withOpacity(.75),
|
||||
fontWeight: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
ref.watch(
|
||||
videoPlayerControlsProvider.select((value) => value.mute),
|
||||
)
|
||||
? Icons.volume_off
|
||||
: Icons.volume_up,
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
ref.watch(
|
||||
videoPlayerControlsProvider.select((value) => value.mute),
|
||||
)
|
||||
? Icons.volume_off
|
||||
: 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:math';
|
||||
import 'dart:ui' as ui;
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.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/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/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/bottom_gallery_bar.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/partner/providers/partner.provider.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/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/ui/immich_image.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/src/photo_view_computed_scale.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 isZoomed = useState<bool>(false);
|
||||
final isPlayingMotionVideo = useState(false);
|
||||
final isPlayingVideo = useState(false);
|
||||
Offset? localPosition;
|
||||
final currentIndex = useState(initialIndex);
|
||||
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 stack = showStack && currentAsset.stackChildrenCount > 0
|
||||
? ref.watch(assetStackStateProvider(currentAsset))
|
||||
@@ -102,7 +90,6 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
.map((e) => e.isarId)
|
||||
.contains(asset.ownerId);
|
||||
|
||||
|
||||
// Listen provider to prevent autoDispose when navigating to other routes from within the gallery page
|
||||
ref.listen(currentAssetProvider, (_, __) {});
|
||||
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() {
|
||||
return IgnorePointer(
|
||||
ignoring: !ref.watch(showControlsProvider),
|
||||
@@ -238,8 +240,7 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
asset: asset,
|
||||
onMoreInfoPressed: showInfo,
|
||||
onFavorite: toggleFavorite,
|
||||
onUploadPressed:
|
||||
asset.isLocal ? () => handleUpload(asset) : null,
|
||||
onUploadPressed: asset.isLocal ? () => handleUpload(asset) : null,
|
||||
onDownloadPressed: asset.isLocal
|
||||
? 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(
|
||||
() {
|
||||
if (ref.read(showControlsProvider)) {
|
||||
@@ -297,11 +294,16 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
}
|
||||
});
|
||||
|
||||
Widget buildStackedChildren() {
|
||||
Widget buildStackedChildren() {
|
||||
return ListView.builder(
|
||||
shrinkWrap: true,
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: stackElements.length,
|
||||
padding: const EdgeInsets.only(
|
||||
left: 10,
|
||||
right: 10,
|
||||
bottom: 30,
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
final assetId = stackElements.elementAt(index).remoteId;
|
||||
return Padding(
|
||||
@@ -434,13 +436,7 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
minScale: 1.0,
|
||||
basePosition: Alignment.center,
|
||||
child: VideoViewerPage(
|
||||
onPlaying: () {
|
||||
isPlayingVideo.value = true;
|
||||
},
|
||||
onPaused: () =>
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) => isPlayingVideo.value = false,
|
||||
),
|
||||
key: ValueKey(a),
|
||||
asset: a,
|
||||
isMotionVideo: isPlayingMotionVideo.value,
|
||||
placeholder: Image(
|
||||
@@ -472,9 +468,24 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
right: 0,
|
||||
child: Column(
|
||||
children: [
|
||||
buildStackedChildren(),
|
||||
BottomGalleryBar(
|
||||
Visibility(
|
||||
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:chewie/chewie.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/ui/video_controls.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/ui/video_player_controls.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/custom_video_player_controls.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/ui/delayed_loading_indicator.dart';
|
||||
|
||||
@RoutePage()
|
||||
// ignore: must_be_immutable
|
||||
class VideoViewerPage extends HookWidget {
|
||||
class VideoViewerPage extends HookConsumerWidget {
|
||||
final Asset asset;
|
||||
final bool isMotionVideo;
|
||||
final Widget? placeholder;
|
||||
@@ -35,25 +38,108 @@ class VideoViewerPage extends HookWidget {
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final controller = useChewieController(
|
||||
asset,
|
||||
controlsSafeAreaMinimum: const EdgeInsets.only(
|
||||
bottom: 100,
|
||||
),
|
||||
placeholder: SizedBox.expand(child: placeholder),
|
||||
customControls: CustomVideoPlayerControls(
|
||||
hideTimerDuration: hideControlsTimer,
|
||||
),
|
||||
showControls: showControls && !isMotionVideo,
|
||||
hideControlsTimer: hideControlsTimer,
|
||||
customControls: const VideoPlayerControls(),
|
||||
onPlaying: onPlaying,
|
||||
onPaused: onPaused,
|
||||
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;
|
||||
print('showControls $showControls and isMotion $isMotionVideo');
|
||||
return PopScope(
|
||||
onPopInvoked: (pop) {
|
||||
ref.read(videoPlaybackValueProvider.notifier).value =
|
||||
VideoPlaybackValue.uninitialized();
|
||||
},
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 400),
|
||||
child: Stack(
|
||||
@@ -81,19 +167,6 @@ class VideoViewerPage extends HookWidget {
|
||||
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(
|
||||
tag: 'memory-${asset.id}',
|
||||
child: VideoViewerPage(
|
||||
key: ValueKey(asset),
|
||||
asset: asset,
|
||||
showDownloadingIndicator: false,
|
||||
placeholder: ImmichImage(
|
||||
|
||||
48
mobile/lib/shared/ui/hooks/timer_hook.dart
Normal file
48
mobile/lib/shared/ui/hooks/timer_hook.dart
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -50,7 +50,7 @@ packages:
|
||||
source: hosted
|
||||
version: "2.4.2"
|
||||
async:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: async
|
||||
sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c"
|
||||
|
||||
@@ -58,6 +58,7 @@ dependencies:
|
||||
timezone: ^0.9.2
|
||||
octo_image: ^2.0.0
|
||||
thumbhash: 0.1.0+1
|
||||
async: ^2.11.0
|
||||
|
||||
openapi:
|
||||
path: openapi
|
||||
|
||||
Reference in New Issue
Block a user