mirror of
https://github.com/immich-app/immich.git
synced 2026-02-28 09:38:43 +03:00
feat(mobile): video zooming in asset viewer (#22036)
* wip * Functional implementation, still need to bug test. * Fixed flickering bugs * Fixed bug with drag actions interfering with zoom panning. Fixed video being zoomable when bottom sheet is shown. Code cleanup. * Add comments and simplify video controls * Clearer variable name * Fix bug where the redundant onTapDown would interfere with zooming gestures * Fix zoom not working the second time when viewing a video. * fix video of live photo retaining pan from photo portion * code cleanup and simplified widget stack --------- Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
@@ -53,6 +53,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
|||||||
|
|
||||||
final _scrollController = ScrollController();
|
final _scrollController = ScrollController();
|
||||||
late final _proxyScrollController = ProxyScrollController(scrollController: _scrollController);
|
late final _proxyScrollController = ProxyScrollController(scrollController: _scrollController);
|
||||||
|
final ValueNotifier<PhotoViewScaleState> _videoScaleStateNotifier = ValueNotifier(PhotoViewScaleState.initial);
|
||||||
|
|
||||||
double _snapOffset = 0.0;
|
double _snapOffset = 0.0;
|
||||||
double _lastScrollOffset = 0.0;
|
double _lastScrollOffset = 0.0;
|
||||||
@@ -81,6 +82,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
|||||||
_proxyScrollController.dispose();
|
_proxyScrollController.dispose();
|
||||||
_scaleBoundarySub?.cancel();
|
_scaleBoundarySub?.cancel();
|
||||||
_eventSubscription?.cancel();
|
_eventSubscription?.cancel();
|
||||||
|
_videoScaleStateNotifier.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,10 +257,11 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
|||||||
ref.read(isPlayingMotionVideoProvider.notifier).playing = true;
|
ref.read(isPlayingMotionVideoProvider.notifier).playing = true;
|
||||||
|
|
||||||
void _onScaleStateChanged(PhotoViewScaleState scaleState) {
|
void _onScaleStateChanged(PhotoViewScaleState scaleState) {
|
||||||
_isZoomed = switch (scaleState) {
|
_isZoomed =
|
||||||
PhotoViewScaleState.zoomedIn || PhotoViewScaleState.covering => true,
|
scaleState == PhotoViewScaleState.zoomedIn ||
|
||||||
_ => false,
|
scaleState == PhotoViewScaleState.covering ||
|
||||||
};
|
_videoScaleStateNotifier.value == PhotoViewScaleState.zoomedIn ||
|
||||||
|
_videoScaleStateNotifier.value == PhotoViewScaleState.covering;
|
||||||
_viewer.setZoomed(_isZoomed);
|
_viewer.setZoomed(_isZoomed);
|
||||||
|
|
||||||
if (scaleState != PhotoViewScaleState.initial) {
|
if (scaleState != PhotoViewScaleState.initial) {
|
||||||
@@ -340,34 +343,33 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return PhotoView.customChild(
|
return PhotoView.customChild(
|
||||||
|
key: ValueKey(displayAsset),
|
||||||
onDragStart: _onDragStart,
|
onDragStart: _onDragStart,
|
||||||
onDragUpdate: _onDragUpdate,
|
onDragUpdate: _onDragUpdate,
|
||||||
onDragEnd: _onDragEnd,
|
onDragEnd: _onDragEnd,
|
||||||
onDragCancel: _onDragCancel,
|
onDragCancel: _onDragCancel,
|
||||||
onTapUp: _onTapUp,
|
|
||||||
heroAttributes: heroAttributes,
|
heroAttributes: heroAttributes,
|
||||||
filterQuality: FilterQuality.high,
|
filterQuality: FilterQuality.high,
|
||||||
maxScale: 1.0,
|
|
||||||
basePosition: Alignment.center,
|
basePosition: Alignment.center,
|
||||||
disableScaleGestures: true,
|
disableScaleGestures: true,
|
||||||
scaleStateChangedCallback: _onScaleStateChanged,
|
minScale: PhotoViewComputedScale.contained,
|
||||||
|
initialScale: PhotoViewComputedScale.contained,
|
||||||
|
tightMode: true,
|
||||||
onPageBuild: _onPageBuild,
|
onPageBuild: _onPageBuild,
|
||||||
enablePanAlways: true,
|
enablePanAlways: true,
|
||||||
backgroundDecoration: backgroundDecoration,
|
backgroundDecoration: backgroundDecoration,
|
||||||
child: SizedBox(
|
child: NativeVideoViewer(
|
||||||
width: context.width,
|
key: ValueKey(displayAsset),
|
||||||
height: context.height,
|
asset: displayAsset,
|
||||||
child: NativeVideoViewer(
|
scaleStateNotifier: _videoScaleStateNotifier,
|
||||||
|
disableScaleGestures: showingDetails,
|
||||||
|
image: Image(
|
||||||
key: ValueKey(displayAsset.heroTag),
|
key: ValueKey(displayAsset.heroTag),
|
||||||
asset: displayAsset,
|
image: getFullImageProvider(displayAsset, size: context.sizeData),
|
||||||
image: Image(
|
height: context.height,
|
||||||
key: ValueKey(displayAsset),
|
width: context.width,
|
||||||
image: getFullImageProvider(displayAsset, size: context.sizeData),
|
fit: BoxFit.contain,
|
||||||
fit: BoxFit.contain,
|
alignment: Alignment.center,
|
||||||
height: context.height,
|
|
||||||
width: context.width,
|
|
||||||
alignment: Alignment.center,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import 'package:immich_mobile/domain/models/setting.model.dart';
|
|||||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||||
import 'package:immich_mobile/domain/services/setting.service.dart';
|
import 'package:immich_mobile/domain/services/setting.service.dart';
|
||||||
import 'package:immich_mobile/entities/store.entity.dart';
|
import 'package:immich_mobile/entities/store.entity.dart';
|
||||||
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||||
@@ -25,6 +26,7 @@ import 'package:immich_mobile/services/api.service.dart';
|
|||||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||||
import 'package:immich_mobile/utils/debounce.dart';
|
import 'package:immich_mobile/utils/debounce.dart';
|
||||||
import 'package:immich_mobile/utils/hooks/interval_hook.dart';
|
import 'package:immich_mobile/utils/hooks/interval_hook.dart';
|
||||||
|
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:native_video_player/native_video_player.dart';
|
import 'package:native_video_player/native_video_player.dart';
|
||||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||||
@@ -52,6 +54,8 @@ class NativeVideoViewer extends HookConsumerWidget {
|
|||||||
final bool showControls;
|
final bool showControls;
|
||||||
final int playbackDelayFactor;
|
final int playbackDelayFactor;
|
||||||
final Widget image;
|
final Widget image;
|
||||||
|
final ValueNotifier<PhotoViewScaleState>? scaleStateNotifier;
|
||||||
|
final bool disableScaleGestures;
|
||||||
|
|
||||||
const NativeVideoViewer({
|
const NativeVideoViewer({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -59,6 +63,8 @@ class NativeVideoViewer extends HookConsumerWidget {
|
|||||||
required this.image,
|
required this.image,
|
||||||
this.showControls = true,
|
this.showControls = true,
|
||||||
this.playbackDelayFactor = 1,
|
this.playbackDelayFactor = 1,
|
||||||
|
this.scaleStateNotifier,
|
||||||
|
this.disableScaleGestures = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -138,6 +144,7 @@ class NativeVideoViewer extends HookConsumerWidget {
|
|||||||
|
|
||||||
final videoSource = useMemoized<Future<VideoSource?>>(() => createSource());
|
final videoSource = useMemoized<Future<VideoSource?>>(() => createSource());
|
||||||
final aspectRatio = useState<double?>(null);
|
final aspectRatio = useState<double?>(null);
|
||||||
|
|
||||||
useMemoized(() async {
|
useMemoized(() async {
|
||||||
if (!context.mounted || aspectRatio.value != null) {
|
if (!context.mounted || aspectRatio.value != null) {
|
||||||
return null;
|
return null;
|
||||||
@@ -313,6 +320,20 @@ class NativeVideoViewer extends HookConsumerWidget {
|
|||||||
Timer(const Duration(milliseconds: 200), checkIfBuffering);
|
Timer(const Duration(milliseconds: 200), checkIfBuffering);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Size? videoContextSize(double? videoAspectRatio, BuildContext? context) {
|
||||||
|
Size? videoContextSize;
|
||||||
|
if (videoAspectRatio == null || context == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final contextAspectRatio = context.width / context.height;
|
||||||
|
if (videoAspectRatio > contextAspectRatio) {
|
||||||
|
videoContextSize = Size(context.width, context.width / aspectRatio.value!);
|
||||||
|
} else {
|
||||||
|
videoContextSize = Size(context.height * aspectRatio.value!, context.height);
|
||||||
|
}
|
||||||
|
return videoContextSize;
|
||||||
|
}
|
||||||
|
|
||||||
ref.listen(currentAssetNotifier, (_, value) {
|
ref.listen(currentAssetNotifier, (_, value) {
|
||||||
final playerController = controller.value;
|
final playerController = controller.value;
|
||||||
if (playerController != null && value != asset) {
|
if (playerController != null && value != asset) {
|
||||||
@@ -393,26 +414,31 @@ class NativeVideoViewer extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return Stack(
|
return SizedBox(
|
||||||
children: [
|
width: context.width,
|
||||||
// This remains under the video to avoid flickering
|
height: context.height,
|
||||||
// For motion videos, this is the image portion of the asset
|
child: Stack(
|
||||||
Center(key: ValueKey(asset.heroTag), child: image),
|
children: [
|
||||||
if (aspectRatio.value != null && !isCasting)
|
// Hide thumbnail once video is visible to avoid it showing in background when zooming out on video.
|
||||||
Visibility.maintain(
|
if (!isVisible.value || controller.value == null) Center(key: ValueKey(asset.heroTag), child: image),
|
||||||
key: ValueKey(asset),
|
if (aspectRatio.value != null && !isCasting && isCurrent)
|
||||||
visible: isVisible.value,
|
Visibility.maintain(
|
||||||
child: Center(
|
|
||||||
key: ValueKey(asset),
|
key: ValueKey(asset),
|
||||||
child: AspectRatio(
|
visible: isVisible.value,
|
||||||
|
child: PhotoView.customChild(
|
||||||
key: ValueKey(asset),
|
key: ValueKey(asset),
|
||||||
aspectRatio: aspectRatio.value!,
|
enableRotation: false,
|
||||||
child: isCurrent ? NativeVideoPlayerView(key: ValueKey(asset), onViewReady: initController) : null,
|
disableScaleGestures: disableScaleGestures,
|
||||||
|
// Transparent to avoid a black flash when viewer becomes visible but video isn't loaded yet.
|
||||||
|
backgroundDecoration: const BoxDecoration(color: Colors.transparent),
|
||||||
|
scaleStateChangedCallback: (state) => scaleStateNotifier?.value = state,
|
||||||
|
childSize: videoContextSize(aspectRatio.value, context),
|
||||||
|
child: NativeVideoPlayerView(key: ValueKey(asset), onViewReady: initController),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
if (showControls) const Center(child: VideoViewerControls()),
|
||||||
if (showControls) const Center(child: VideoViewerControls()),
|
],
|
||||||
],
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -81,27 +81,35 @@ class VideoViewerControls extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void toggleControlsVisibility() {
|
||||||
|
if (showBuffering) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (showControls) {
|
||||||
|
ref.read(assetViewerProvider.notifier).setControls(false);
|
||||||
|
} else {
|
||||||
|
showControlsAndStartHideTimer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
behavior: HitTestBehavior.opaque,
|
behavior: HitTestBehavior.translucent,
|
||||||
onTap: showControlsAndStartHideTimer,
|
onTap: toggleControlsVisibility,
|
||||||
child: AbsorbPointer(
|
child: IgnorePointer(
|
||||||
absorbing: !showControls,
|
ignoring: !showControls,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
if (showBuffering)
|
if (showBuffering)
|
||||||
const Center(child: DelayedLoadingIndicator(fadeInDuration: Duration(milliseconds: 400)))
|
const Center(child: DelayedLoadingIndicator(fadeInDuration: Duration(milliseconds: 400)))
|
||||||
else
|
else
|
||||||
GestureDetector(
|
CenterPlayButton(
|
||||||
onTap: () => ref.read(assetViewerProvider.notifier).setControls(false),
|
backgroundColor: Colors.black54,
|
||||||
child: CenterPlayButton(
|
iconColor: Colors.white,
|
||||||
backgroundColor: Colors.black54,
|
isFinished: state == VideoPlaybackState.completed,
|
||||||
iconColor: Colors.white,
|
isPlaying:
|
||||||
isFinished: state == VideoPlaybackState.completed,
|
state == VideoPlaybackState.playing || (cast.isCasting && cast.castState == CastState.playing),
|
||||||
isPlaying:
|
show: assetIsVideo && showControls,
|
||||||
state == VideoPlaybackState.playing || (cast.isCasting && cast.castState == CastState.playing),
|
onPressed: togglePlay,
|
||||||
show: assetIsVideo && showControls,
|
|
||||||
onPressed: togglePlay,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -21,23 +21,20 @@ class CenterPlayButton extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ColoredBox(
|
return Center(
|
||||||
color: Colors.transparent,
|
child: UnconstrainedBox(
|
||||||
child: Center(
|
child: AnimatedOpacity(
|
||||||
child: UnconstrainedBox(
|
opacity: show ? 1.0 : 0.0,
|
||||||
child: AnimatedOpacity(
|
duration: const Duration(milliseconds: 100),
|
||||||
opacity: show ? 1.0 : 0.0,
|
child: DecoratedBox(
|
||||||
duration: const Duration(milliseconds: 100),
|
decoration: BoxDecoration(color: backgroundColor, shape: BoxShape.circle),
|
||||||
child: DecoratedBox(
|
child: IconButton(
|
||||||
decoration: BoxDecoration(color: backgroundColor, shape: BoxShape.circle),
|
iconSize: 32,
|
||||||
child: IconButton(
|
padding: const EdgeInsets.all(12.0),
|
||||||
iconSize: 32,
|
icon: isFinished
|
||||||
padding: const EdgeInsets.all(12.0),
|
? Icon(Icons.replay, color: iconColor)
|
||||||
icon: isFinished
|
: AnimatedPlayPause(color: iconColor, playing: isPlaying),
|
||||||
? Icon(Icons.replay, color: iconColor)
|
onPressed: onPressed,
|
||||||
: AnimatedPlayPause(color: iconColor, playing: isPlaying),
|
|
||||||
onPressed: onPressed,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user