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
PhotoViewControllerBase? viewController;
StreamSubscription? reloadSubscription;
final ValueNotifier<PhotoViewScaleState> videoScaleStateNotifier = ValueNotifier(PhotoViewScaleState.initial);
late final int heroOffset;
late PhotoViewControllerValue initialPhotoViewState;
@ -157,6 +158,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
_prevPreCacheStream?.removeListener(_dummyListener);
_nextPreCacheStream?.removeListener(_dummyListener);
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
videoScaleStateNotifier.dispose();
_stackChildrenKeepAlive?.close();
super.dispose();
}
@ -270,6 +272,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
void _onPageChanged(int index, PhotoViewControllerBase? controller) {
_onAssetChanged(index);
viewController = controller;
videoScaleStateNotifier.value = PhotoViewScaleState.initial; // reset video zoom state
}
void _onDragStart(
@ -281,9 +284,13 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
viewController = controller;
dragDownPosition = details.localPosition;
initialPhotoViewState = controller.value;
final isZoomed =
scaleStateController.scaleState == PhotoViewScaleState.zoomedIn ||
scaleStateController.scaleState == PhotoViewScaleState.covering;
scaleStateController.scaleState == PhotoViewScaleState.covering ||
videoScaleStateNotifier.value == PhotoViewScaleState.zoomedIn ||
videoScaleStateNotifier.value == PhotoViewScaleState.covering;
if (!showingBottomSheet && isZoomed) {
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) {
return PhotoViewGalleryPageOptions.customChild(
key: ValueKey(asset.heroTag),
onDragStart: _onDragStart,
onDragUpdate: _onDragUpdate,
onDragEnd: _onDragEnd,
onTapDown: _onTapDown,
disableScaleGestures: true,
heroAttributes: PhotoViewHeroAttributes(tag: '${asset.heroTag}_$heroOffset'),
filterQuality: FilterQuality.high,
maxScale: 1.0,
basePosition: Alignment.center,
child: SizedBox(
width: ctx.width,
height: ctx.height,
child: NativeVideoViewer(
key: _getVideoPlayerKey(asset.heroTag),
asset: asset,
image: Image(
key: ValueKey(asset),
image: getFullImageProvider(asset, size: ctx.sizeData),
fit: BoxFit.contain,
height: ctx.height,
width: ctx.width,
alignment: Alignment.center,
),
tightMode: true,
child: NativeVideoViewer(
key: ValueKey(asset.heroTag),
asset: asset,
scaleStateNotifier: videoScaleStateNotifier,
disableScaleGestures: showingBottomSheet,
image: Image(
key: ValueKey(asset),
image: getFullImageProvider(asset, size: ctx.sizeData),
height: ctx.height,
width: ctx.width,
fit: BoxFit.contain,
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/services/setting.service.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/presentation/widgets/asset_viewer/asset_viewer.state.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/utils/debounce.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:native_video_player/native_video_player.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
@ -51,6 +53,8 @@ class NativeVideoViewer extends HookConsumerWidget {
final bool showControls;
final int playbackDelayFactor;
final Widget image;
final ValueNotifier<PhotoViewScaleState>? scaleStateNotifier;
final bool disableScaleGestures;
const NativeVideoViewer({
super.key,
@ -58,6 +62,8 @@ class NativeVideoViewer extends HookConsumerWidget {
required this.image,
this.showControls = true,
this.playbackDelayFactor = 1,
this.scaleStateNotifier,
this.disableScaleGestures = false,
});
@override
@ -132,6 +138,7 @@ class NativeVideoViewer extends HookConsumerWidget {
final videoSource = useMemoized<Future<VideoSource?>>(() => createSource());
final aspectRatio = useState<double?>(null);
useMemoized(() async {
if (!context.mounted || aspectRatio.value != null) {
return null;
@ -387,26 +394,47 @@ class NativeVideoViewer extends HookConsumerWidget {
}
});
return Stack(
children: [
// This remains under the video to avoid flickering
// For motion videos, this is the image portion of the asset
Center(key: ValueKey(asset.heroTag), child: image),
if (aspectRatio.value != null && !isCasting)
Visibility.maintain(
key: ValueKey(asset),
visible: isVisible.value,
child: Center(
Size? videoContextSize;
if (aspectRatio.value != null) {
final contextAspectRatio = context.width / context.height;
if (aspectRatio.value! > contextAspectRatio) {
videoContextSize = Size(context.width, context.width / aspectRatio.value!);
} else {
videoContextSize = Size(context.height * aspectRatio.value!, context.height);
}
}
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),
child: AspectRatio(
visible: isVisible.value,
child: PhotoView.customChild(
key: ValueKey(asset),
aspectRatio: aspectRatio.value!,
child: isCurrent ? NativeVideoPlayerView(key: ValueKey(asset), onViewReady: initController) : null,
enableRotation: false,
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(
behavior: HitTestBehavior.opaque,
onTap: showControlsAndStartHideTimer,
child: AbsorbPointer(
absorbing: !showControls,
behavior: HitTestBehavior.translucent,
onTap: toggleControlsVisibility,
child: IgnorePointer(
ignoring: !showControls,
child: Stack(
children: [
if (showBuffering)
const Center(child: DelayedLoadingIndicator(fadeInDuration: Duration(milliseconds: 400)))
else
GestureDetector(
onTap: () => ref.read(assetViewerProvider.notifier).setControls(false),
child: CenterPlayButton(
backgroundColor: Colors.black54,
iconColor: Colors.white,
isFinished: state == VideoPlaybackState.completed,
isPlaying:
state == VideoPlaybackState.playing || (cast.isCasting && cast.castState == CastState.playing),
show: assetIsVideo && showControls,
onPressed: togglePlay,
),
CenterPlayButton(
backgroundColor: Colors.black54,
iconColor: Colors.white,
isFinished: state == VideoPlaybackState.completed,
isPlaying:
state == VideoPlaybackState.playing || (cast.isCasting && cast.castState == CastState.playing),
show: assetIsVideo && showControls,
onPressed: togglePlay,
),
],
),

@ -21,23 +21,20 @@ class CenterPlayButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ColoredBox(
color: Colors.transparent,
child: Center(
child: UnconstrainedBox(
child: AnimatedOpacity(
opacity: show ? 1.0 : 0.0,
duration: const Duration(milliseconds: 100),
child: DecoratedBox(
decoration: BoxDecoration(color: backgroundColor, shape: BoxShape.circle),
child: IconButton(
iconSize: 32,
padding: const EdgeInsets.all(12.0),
icon: isFinished
? Icon(Icons.replay, color: iconColor)
: AnimatedPlayPause(color: iconColor, playing: isPlaying),
onPressed: onPressed,
),
return Center(
child: UnconstrainedBox(
child: AnimatedOpacity(
opacity: show ? 1.0 : 0.0,
duration: const Duration(milliseconds: 100),
child: DecoratedBox(
decoration: BoxDecoration(color: backgroundColor, shape: BoxShape.circle),
child: IconButton(
iconSize: 32,
padding: const EdgeInsets.all(12.0),
icon: isFinished
? Icon(Icons.replay, color: iconColor)
: AnimatedPlayPause(color: iconColor, playing: isPlaying),
onPressed: onPressed,
),
),
),