Noel S 2025-12-10 18:13:11 +07:00 committed by GitHub
commit 4ef2b1e0ac
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 104 additions and 70 deletions

@ -107,6 +107,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
// PhotoViewGallery takes care of disposing it's controllers // PhotoViewGallery takes care of disposing it's controllers
PhotoViewControllerBase? viewController; PhotoViewControllerBase? viewController;
StreamSubscription? reloadSubscription; StreamSubscription? reloadSubscription;
final ValueNotifier<PhotoViewScaleState> videoScaleStateNotifier = ValueNotifier(PhotoViewScaleState.initial);
late final int heroOffset; late final int heroOffset;
late PhotoViewControllerValue initialPhotoViewState; late PhotoViewControllerValue initialPhotoViewState;
@ -157,6 +158,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
_prevPreCacheStream?.removeListener(_dummyListener); _prevPreCacheStream?.removeListener(_dummyListener);
_nextPreCacheStream?.removeListener(_dummyListener); _nextPreCacheStream?.removeListener(_dummyListener);
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
videoScaleStateNotifier.dispose();
_stackChildrenKeepAlive?.close(); _stackChildrenKeepAlive?.close();
super.dispose(); super.dispose();
} }
@ -270,6 +272,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
void _onPageChanged(int index, PhotoViewControllerBase? controller) { void _onPageChanged(int index, PhotoViewControllerBase? controller) {
_onAssetChanged(index); _onAssetChanged(index);
viewController = controller; viewController = controller;
videoScaleStateNotifier.value = PhotoViewScaleState.initial; // reset video zoom state
} }
void _onDragStart( void _onDragStart(
@ -281,9 +284,13 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
viewController = controller; viewController = controller;
dragDownPosition = details.localPosition; dragDownPosition = details.localPosition;
initialPhotoViewState = controller.value; initialPhotoViewState = controller.value;
final isZoomed = final isZoomed =
scaleStateController.scaleState == PhotoViewScaleState.zoomedIn || scaleStateController.scaleState == PhotoViewScaleState.zoomedIn ||
scaleStateController.scaleState == PhotoViewScaleState.covering; scaleStateController.scaleState == PhotoViewScaleState.covering ||
videoScaleStateNotifier.value == PhotoViewScaleState.zoomedIn ||
videoScaleStateNotifier.value == PhotoViewScaleState.covering;
if (!showingBottomSheet && isZoomed) { if (!showingBottomSheet && isZoomed) {
blockGestures = true; blockGestures = true;
} }
@ -584,35 +591,29 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
); );
} }
GlobalKey _getVideoPlayerKey(String id) {
videoPlayerKeys.putIfAbsent(id, () => GlobalKey());
return videoPlayerKeys[id]!;
}
PhotoViewGalleryPageOptions _videoBuilder(BuildContext ctx, BaseAsset asset) { PhotoViewGalleryPageOptions _videoBuilder(BuildContext ctx, BaseAsset asset) {
return PhotoViewGalleryPageOptions.customChild( return PhotoViewGalleryPageOptions.customChild(
key: ValueKey(asset.heroTag),
onDragStart: _onDragStart, onDragStart: _onDragStart,
onDragUpdate: _onDragUpdate, onDragUpdate: _onDragUpdate,
onDragEnd: _onDragEnd, onDragEnd: _onDragEnd,
onTapDown: _onTapDown, disableScaleGestures: true,
heroAttributes: PhotoViewHeroAttributes(tag: '${asset.heroTag}_$heroOffset'), heroAttributes: PhotoViewHeroAttributes(tag: '${asset.heroTag}_$heroOffset'),
filterQuality: FilterQuality.high, filterQuality: FilterQuality.high,
maxScale: 1.0,
basePosition: Alignment.center, basePosition: Alignment.center,
child: SizedBox( tightMode: true,
width: ctx.width, child: NativeVideoViewer(
height: ctx.height, key: ValueKey(asset.heroTag),
child: NativeVideoViewer( asset: asset,
key: _getVideoPlayerKey(asset.heroTag), scaleStateNotifier: videoScaleStateNotifier,
asset: asset, disableScaleGestures: showingBottomSheet,
image: Image( image: Image(
key: ValueKey(asset), key: ValueKey(asset),
image: getFullImageProvider(asset, size: ctx.sizeData), image: getFullImageProvider(asset, size: ctx.sizeData),
fit: BoxFit.contain, height: ctx.height,
height: ctx.height, width: ctx.width,
width: ctx.width, fit: BoxFit.contain,
alignment: Alignment.center, 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/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/presentation/widgets/asset_viewer/video_viewer_controls.widget.dart';
@ -24,6 +25,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';
@ -51,6 +53,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,
@ -58,6 +62,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
@ -132,6 +138,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;
@ -387,26 +394,47 @@ class NativeVideoViewer extends HookConsumerWidget {
} }
}); });
return Stack( Size? videoContextSize;
children: [
// This remains under the video to avoid flickering if (aspectRatio.value != null) {
// For motion videos, this is the image portion of the asset final contextAspectRatio = context.width / context.height;
Center(key: ValueKey(asset.heroTag), child: image),
if (aspectRatio.value != null && !isCasting) if (aspectRatio.value! > contextAspectRatio) {
Visibility.maintain( videoContextSize = Size(context.width, context.width / aspectRatio.value!);
key: ValueKey(asset), } else {
visible: isVisible.value, videoContextSize = Size(context.height * aspectRatio.value!, context.height);
child: Center( }
}
return SizedBox(
width: context.width,
height: context.height,
child: Stack(
children: [
// Hide thumbnail once video is visible to avoid it showing in background when zooming out on video.
if (!isVisible.value || controller.value == null) Center(key: ValueKey(asset.heroTag), child: image),
if (aspectRatio.value != null && !isCasting)
Visibility.maintain(
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,
child: AspectRatio(
key: ValueKey(asset),
aspectRatio: aspectRatio.value!,
child: isCurrent ? NativeVideoPlayerView(key: ValueKey(asset), onViewReady: initController) : null,
),
), ),
), ),
), 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,
),
), ),
), ),
), ),