refactor: simplify video zooming (#26527)

fix: simplify video zooming

# Conflicts:
#	mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart
#	mobile/lib/presentation/widgets/asset_viewer/video_viewer.widget.dart

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
This commit is contained in:
shenlong
2026-03-02 23:23:49 +05:30
committed by GitHub
parent dffe4d1d5c
commit f54924d46a
4 changed files with 55 additions and 92 deletions
@@ -14,15 +14,15 @@ import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details.wi
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.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';
import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer_controls.widget.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart'; import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart';
import 'package:immich_mobile/widgets/photo_view/photo_view.dart'; import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
@@ -53,7 +53,6 @@ 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;
@@ -79,7 +78,6 @@ class _AssetPageState extends ConsumerState<AssetPage> {
_proxyScrollController.dispose(); _proxyScrollController.dispose();
_scaleBoundarySub?.cancel(); _scaleBoundarySub?.cancel();
_eventSubscription?.cancel(); _eventSubscription?.cancel();
_videoScaleStateNotifier.dispose();
super.dispose(); super.dispose();
} }
@@ -249,17 +247,14 @@ 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 = _isZoomed = switch (scaleState) {
scaleState == PhotoViewScaleState.zoomedIn || PhotoViewScaleState.zoomedIn || PhotoViewScaleState.covering => true,
scaleState == PhotoViewScaleState.covering || _ => false,
_videoScaleStateNotifier.value == PhotoViewScaleState.zoomedIn || };
_videoScaleStateNotifier.value == PhotoViewScaleState.covering;
_viewer.setZoomed(_isZoomed); _viewer.setZoomed(_isZoomed);
if (scaleState != PhotoViewScaleState.initial) { if (scaleState != PhotoViewScaleState.initial) {
if (_dragStart == null) _viewer.setControls(false); if (_dragStart == null) _viewer.setControls(false);
ref.read(videoPlayerControlsProvider.notifier).pause();
return; return;
} }
@@ -334,35 +329,40 @@ class _AssetPageState extends ConsumerState<AssetPage> {
); );
} }
return PhotoView.customChild( return Stack(
children: [
PhotoView.customChild(
key: Key(displayAsset.heroTag), key: Key(displayAsset.heroTag),
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,
basePosition: Alignment.center, basePosition: Alignment.center,
disableScaleGestures: true, disableScaleGestures: showingDetails,
minScale: PhotoViewComputedScale.contained, scaleStateChangedCallback: _onScaleStateChanged,
initialScale: PhotoViewComputedScale.contained,
tightMode: true,
onPageBuild: _onPageBuild, onPageBuild: _onPageBuild,
enablePanAlways: true, enablePanAlways: true,
backgroundDecoration: backgroundDecoration, backgroundDecoration: backgroundDecoration,
child: SizedBox(
width: context.width,
height: context.height,
child: NativeVideoViewer( child: NativeVideoViewer(
key: _NativeVideoViewerKey(displayAsset.heroTag), key: _NativeVideoViewerKey(displayAsset.heroTag),
asset: displayAsset, asset: displayAsset,
scaleStateNotifier: _videoScaleStateNotifier,
disableScaleGestures: showingDetails,
image: Image( image: Image(
image: getFullImageProvider(displayAsset, size: context.sizeData), image: getFullImageProvider(displayAsset, size: context.sizeData),
fit: BoxFit.contain,
height: context.height, height: context.height,
width: context.width, width: context.width,
fit: BoxFit.contain,
alignment: Alignment.center, alignment: Alignment.center,
), ),
), ),
),
),
const Center(child: VideoViewerControls()),
],
); );
} }
@@ -18,8 +18,8 @@ import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_page.widge
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_preloader.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_preloader.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.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';
import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_bottom_app_bar.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_bottom_app_bar.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
import 'package:immich_mobile/providers/cast.provider.dart'; import 'package:immich_mobile/providers/cast.provider.dart';
@@ -9,11 +9,9 @@ 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';
import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer_controls.widget.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
@@ -26,7 +24,6 @@ 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';
@@ -51,21 +48,10 @@ bool _isCurrentAsset(BaseAsset asset, BaseAsset? currentAsset) {
class NativeVideoViewer extends HookConsumerWidget { class NativeVideoViewer extends HookConsumerWidget {
static final log = Logger('NativeVideoViewer'); static final log = Logger('NativeVideoViewer');
final BaseAsset asset; final BaseAsset asset;
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, required this.asset, required this.image, this.playbackDelayFactor = 1});
super.key,
required this.asset,
required this.image,
this.showControls = true,
this.playbackDelayFactor = 1,
this.scaleStateNotifier,
this.disableScaleGestures = false,
});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
@@ -144,7 +130,6 @@ 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;
@@ -320,20 +305,6 @@ 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) {
@@ -414,29 +385,22 @@ class NativeVideoViewer extends HookConsumerWidget {
} }
}); });
return SizedBox( return Stack(
width: context.width,
height: context.height,
child: Stack(
children: [ children: [
// Hide thumbnail once video is visible to avoid it showing in background when zooming out on video. // This remains under the video to avoid flickering
if (!isVisible.value || controller.value == null) Center(child: image), // For motion videos, this is the image portion of the asset
if (aspectRatio.value != null && !isCasting && isCurrent) Center(child: image),
if (aspectRatio.value != null && !isCasting)
Visibility.maintain( Visibility.maintain(
visible: isVisible.value, visible: isVisible.value,
child: PhotoView.customChild( child: Center(
enableRotation: false, child: AspectRatio(
disableScaleGestures: disableScaleGestures, aspectRatio: aspectRatio.value!,
// Transparent to avoid a black flash when viewer becomes visible but video isn't loaded yet. child: isCurrent ? NativeVideoPlayerView(onViewReady: initController) : null,
backgroundDecoration: const BoxDecoration(color: Colors.transparent), ),
scaleStateChangedCallback: (state) => scaleStateNotifier?.value = state,
childSize: videoContextSize(aspectRatio.value, context),
child: NativeVideoPlayerView(onViewReady: initController),
), ),
), ),
if (showControls) const Center(child: VideoViewerControls()),
], ],
),
); );
} }
@@ -58,7 +58,6 @@ class DriftMemoryCard extends StatelessWidget {
child: NativeVideoViewer( child: NativeVideoViewer(
key: ValueKey(asset.id), key: ValueKey(asset.id),
asset: asset, asset: asset,
showControls: false,
playbackDelayFactor: 2, playbackDelayFactor: 2,
image: FullImage(asset, size: Size(context.width, context.height), fit: BoxFit.contain), image: FullImage(asset, size: Size(context.width, context.height), fit: BoxFit.contain),
), ),