1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2025-03-01 15:11:21 +01:00

refinements and fixes

fix orientation for remote assets

wip separate widget

separate video loader widget

fixed memory leak

optimized seeking, cleanup

debug context pop

use global key

back to one widget

fixed rebuild

wait for swipe animation to finish

smooth hero animation for remote videos

faster scroll animation
This commit is contained in:
mertalev 2024-11-07 17:14:35 -05:00
parent 3272ad4a7b
commit 49c4d7cff9
No known key found for this signature in database
GPG key ID: CA85EF6600C9E8AD
12 changed files with 624 additions and 436 deletions

View file

@ -22,12 +22,8 @@ class Asset {
durationInSeconds = remote.duration.toDuration()?.inSeconds ?? 0, durationInSeconds = remote.duration.toDuration()?.inSeconds ?? 0,
type = remote.type.toAssetType(), type = remote.type.toAssetType(),
fileName = remote.originalFileName, fileName = remote.originalFileName,
height = isFlipped(remote) height = remote.exifInfo?.exifImageHeight?.toInt(),
? remote.exifInfo?.exifImageWidth?.toInt() width = remote.exifInfo?.exifImageWidth?.toInt(),
: remote.exifInfo?.exifImageHeight?.toInt(),
width = isFlipped(remote)
? remote.exifInfo?.exifImageHeight?.toInt()
: remote.exifInfo?.exifImageWidth?.toInt(),
livePhotoVideoId = remote.livePhotoVideoId, livePhotoVideoId = remote.livePhotoVideoId,
ownerId = fastHash(remote.ownerId), ownerId = fastHash(remote.ownerId),
exifInfo = exifInfo =
@ -172,6 +168,9 @@ class Asset {
@ignore @ignore
bool get isImage => type == AssetType.image; bool get isImage => type == AssetType.image;
@ignore
bool get isMotionPhoto => livePhotoVideoId != null;
@ignore @ignore
AssetState get storage { AssetState get storage {
if (isRemote && isLocal) { if (isRemote && isLocal) {
@ -192,6 +191,14 @@ class Asset {
@ignore @ignore
set byteHash(List<int> hash) => checksum = base64.encode(hash); set byteHash(List<int> hash) => checksum = base64.encode(hash);
@ignore
int? get orientatedWidth =>
exifInfo != null && exifInfo!.isFlipped ? height : width;
@ignore
int? get orientatedHeight =>
exifInfo != null && exifInfo!.isFlipped ? width : height;
@override @override
bool operator ==(other) { bool operator ==(other) {
if (other is! Asset) return false; if (other is! Asset) return false;
@ -511,21 +518,3 @@ extension AssetsHelper on IsarCollection<Asset> {
return where().anyOf(ids, (q, String e) => q.localIdEqualTo(e)); return where().anyOf(ids, (q, String e) => q.localIdEqualTo(e));
} }
} }
/// Returns `true` if this [int] is flipped 90° clockwise
bool isRotated90CW(int orientation) {
return [7, 8, -90].contains(orientation);
}
/// Returns `true` if this [int] is flipped 270° clockwise
bool isRotated270CW(int orientation) {
return [5, 6, 90].contains(orientation);
}
/// Returns `true` if this [Asset] is flipped 90° or 270° clockwise
bool isFlipped(AssetResponseDto response) {
final int orientation =
int.tryParse(response.exifInfo?.orientation ?? '0') ?? 0;
return orientation != 0 &&
(isRotated90CW(orientation) || isRotated270CW(orientation));
}

View file

@ -47,7 +47,10 @@ class ExifInfo {
String get focalLength => mm != null ? mm!.toStringAsFixed(1) : ""; String get focalLength => mm != null ? mm!.toStringAsFixed(1) : "";
@ignore @ignore
bool get isFlipped => _isOrientationFlipped(orientation); bool? _isFlipped;
@ignore
bool get isFlipped => _isFlipped ??= _isOrientationFlipped(orientation);
@ignore @ignore
double? get latitude => lat; double? get latitude => lat;

View file

@ -0,0 +1,32 @@
import 'package:flutter/cupertino.dart';
const _spring = SpringDescription(
mass: 40,
stiffness: 100,
damping: 1,
);
// https://stackoverflow.com/a/74453792
class FastScrollPhysics extends ScrollPhysics {
const FastScrollPhysics({super.parent});
@override
FastScrollPhysics applyTo(ScrollPhysics? ancestor) {
return FastScrollPhysics(parent: buildParent(ancestor));
}
@override
SpringDescription get spring => _spring;
}
class FastClampingScrollPhysics extends ClampingScrollPhysics {
const FastClampingScrollPhysics({super.parent});
@override
FastClampingScrollPhysics applyTo(ScrollPhysics? ancestor) {
return FastClampingScrollPhysics(parent: buildParent(ancestor));
}
@override
SpringDescription get spring => _spring;
}

View file

@ -11,6 +11,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/scroll_extensions.dart';
import 'package:immich_mobile/pages/common/download_panel.dart'; import 'package:immich_mobile/pages/common/download_panel.dart';
import 'package:immich_mobile/pages/common/native_video_viewer.page.dart'; import 'package:immich_mobile/pages/common/native_video_viewer.page.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart';
@ -53,21 +54,15 @@ class GalleryViewerPage extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final settings = ref.watch(appSettingsServiceProvider);
final loadAsset = renderList.loadAsset;
final totalAssets = useState(renderList.totalAssets); final totalAssets = useState(renderList.totalAssets);
final shouldLoopVideo = useState(AppSettingsEnum.loopVideo.defaultValue);
final isZoomed = useState(false); final isZoomed = useState(false);
final isPlayingVideo = useState(false); final isPlayingMotionVideo = useState(false);
final localPosition = useState<Offset?>(null);
final currentIndex = useState(initialIndex);
final currentAsset = loadAsset(currentIndex.value);
// Update is playing motion video
ref.listen(videoPlaybackValueProvider.select((v) => v.state), (_, state) {
isPlayingVideo.value = state == VideoPlaybackState.playing;
});
final stackIndex = useState(-1); final stackIndex = useState(-1);
final localPosition = useRef<Offset?>(null);
final currentIndex = useValueNotifier(initialIndex);
final loadAsset = renderList.loadAsset;
final currentAsset = loadAsset(currentIndex.value);
final stack = showStack && currentAsset.stackCount > 0 final stack = showStack && currentAsset.stackCount > 0
? ref.watch(assetStackStateProvider(currentAsset)) ? ref.watch(assetStackStateProvider(currentAsset))
: <Asset>[]; : <Asset>[];
@ -79,30 +74,23 @@ class GalleryViewerPage extends HookConsumerWidget {
? currentAsset ? currentAsset
: stackElements.elementAt(stackIndex.value); : stackElements.elementAt(stackIndex.value);
final isMotionPhoto = asset.livePhotoVideoId != null; // // Update is playing motion video
// Listen provider to prevent autoDispose when navigating to other routes from within the gallery page if (asset.isMotionPhoto) {
ref.listen(currentAssetProvider, (_, __) {}); ref.listen(
useEffect( videoPlaybackValueProvider.select(
() { (playback) => playback.state == VideoPlaybackState.playing,
// Delay state update to after the execution of build method ), (wasPlaying, isPlaying) {
Future.microtask( if (wasPlaying != null && wasPlaying && !isPlaying) {
() => ref.read(currentAssetProvider.notifier).set(asset), isPlayingMotionVideo.value = false;
); }
return null; });
}, }
[asset],
);
useEffect(
() {
shouldLoopVideo.value =
settings.getSetting<bool>(AppSettingsEnum.loopVideo);
return null;
},
[],
);
Future<void> precacheNextImage(int index) async { Future<void> precacheNextImage(int index) async {
if (!context.mounted) {
return;
}
void onError(Object exception, StackTrace? stackTrace) { void onError(Object exception, StackTrace? stackTrace) {
// swallow error silently // swallow error silently
debugPrint('Error precaching next image: $exception, $stackTrace'); debugPrint('Error precaching next image: $exception, $stackTrace');
@ -110,6 +98,7 @@ class GalleryViewerPage extends HookConsumerWidget {
try { try {
if (index < totalAssets.value && index >= 0) { if (index < totalAssets.value && index >= 0) {
log.info('Precaching next image at index $index');
final asset = loadAsset(index); final asset = loadAsset(index);
await precacheImage( await precacheImage(
ImmichImage.imageProvider(asset: asset), ImmichImage.imageProvider(asset: asset),
@ -124,6 +113,27 @@ class GalleryViewerPage extends HookConsumerWidget {
} }
} }
// Listen provider to prevent autoDispose when navigating to other routes from within the gallery page
ref.listen(currentAssetProvider, (prev, cur) {
log.info('Current asset changed from ${prev?.id} to ${cur?.id}');
});
useEffect(() {
ref.read(currentAssetProvider.notifier).set(asset);
if (ref.read(showControlsProvider)) {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
} else {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
}
// Delay this a bit so we can finish loading the page
Timer(const Duration(milliseconds: 400), () {
precacheNextImage(currentIndex.value + 1);
});
return null;
});
void showInfo() { void showInfo() {
showModalBottomSheet( showModalBottomSheet(
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
@ -182,34 +192,6 @@ class GalleryViewerPage extends HookConsumerWidget {
} }
} }
useEffect(
() {
if (ref.read(showControlsProvider)) {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
} else {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
}
isPlayingVideo.value = false;
return null;
},
[],
);
useEffect(
() {
// No need to await this
unawaited(
// Delay this a bit so we can finish loading the page
Future.delayed(const Duration(milliseconds: 400)).then(
// Precache the next image
(_) => precacheNextImage(currentIndex.value + 1),
),
);
return null;
},
[],
);
ref.listen(showControlsProvider, (_, show) { ref.listen(showControlsProvider, (_, show) {
if (show) { if (show) {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
@ -219,7 +201,12 @@ class GalleryViewerPage extends HookConsumerWidget {
}); });
Widget buildStackedChildren() { Widget buildStackedChildren() {
if (!showStack) {
return const SizedBox();
}
return ListView.builder( return ListView.builder(
key: ValueKey(currentAsset),
shrinkWrap: true, shrinkWrap: true,
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
itemCount: stackElements.length, itemCount: stackElements.length,
@ -230,7 +217,11 @@ class GalleryViewerPage extends HookConsumerWidget {
), ),
itemBuilder: (context, index) { itemBuilder: (context, index) {
final assetId = stackElements.elementAt(index).remoteId; final assetId = stackElements.elementAt(index).remoteId;
if (assetId == null) {
return const SizedBox();
}
return Padding( return Padding(
key: ValueKey(assetId),
padding: const EdgeInsets.only(right: 5), padding: const EdgeInsets.only(right: 5),
child: GestureDetector( child: GestureDetector(
onTap: () => stackIndex.value = index, onTap: () => stackIndex.value = index,
@ -252,7 +243,7 @@ class GalleryViewerPage extends HookConsumerWidget {
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
child: Image( child: Image(
fit: BoxFit.cover, fit: BoxFit.cover,
image: ImmichRemoteImageProvider(assetId: assetId!), image: ImmichRemoteImageProvider(assetId: assetId),
), ),
), ),
), ),
@ -262,6 +253,95 @@ class GalleryViewerPage extends HookConsumerWidget {
); );
} }
Object getHeroTag(Asset asset) {
return isFromDto
? '${asset.remoteId}-$heroOffset'
: asset.id + heroOffset;
}
PhotoViewGalleryPageOptions buildImage(BuildContext context, Asset asset) {
return PhotoViewGalleryPageOptions(
onDragStart: (_, details, __) {
localPosition.value = details.localPosition;
},
onDragUpdate: (_, details, __) {
handleSwipeUpDown(details);
},
onTapDown: (_, __, ___) {
ref.read(showControlsProvider.notifier).toggle();
},
onLongPressStart: (_, __, ___) {
if (asset.livePhotoVideoId != null) {
isPlayingMotionVideo.value = true;
}
},
imageProvider: ImmichImage.imageProvider(asset: asset),
heroAttributes: PhotoViewHeroAttributes(
tag: getHeroTag(asset),
transitionOnUserGestures: true,
),
filterQuality: FilterQuality.high,
tightMode: true,
minScale: PhotoViewComputedScale.contained,
errorBuilder: (context, error, stackTrace) => ImmichImage(
asset,
fit: BoxFit.contain,
),
);
}
PhotoViewGalleryPageOptions buildVideo(BuildContext context, Asset asset) {
final key = GlobalKey();
final tag = getHeroTag(asset);
return PhotoViewGalleryPageOptions.customChild(
onDragStart: (_, details, __) =>
localPosition.value = details.localPosition,
onDragUpdate: (_, details, __) => handleSwipeUpDown(details),
heroAttributes: PhotoViewHeroAttributes(
tag: tag,
transitionOnUserGestures: true,
),
filterQuality: FilterQuality.high,
initialScale: 1.0,
maxScale: 1.0,
minScale: 1.0,
basePosition: Alignment.center,
child: Hero(
tag: tag,
child: SizedBox(
width: context.width,
height: context.height,
child: NativeVideoViewerPage(
key: key,
asset: asset,
placeholder: Image(
key: ValueKey(asset),
image: ImmichImage.imageProvider(asset: asset),
fit: BoxFit.contain,
height: context.height,
width: context.width,
alignment: Alignment.center,
),
),
),
),
);
}
PhotoViewGalleryPageOptions buildAsset(BuildContext context, int index) {
final newAsset = index == currentIndex.value ? asset : loadAsset(index);
if (newAsset.isImage) {
ref.read(showControlsProvider.notifier).show = false;
}
if (newAsset.isImage && !isPlayingMotionVideo.value) {
return buildImage(context, newAsset);
}
log.info('Loading asset ${newAsset.id} (index $index) as video');
return buildVideo(context, newAsset);
}
return PopScope( return PopScope(
// Change immersive mode back to normal "edgeToEdge" mode // Change immersive mode back to normal "edgeToEdge" mode
onPopInvokedWithResult: (didPop, _) => onPopInvokedWithResult: (didPop, _) =>
@ -271,10 +351,13 @@ class GalleryViewerPage extends HookConsumerWidget {
body: Stack( body: Stack(
children: [ children: [
PhotoViewGallery.builder( PhotoViewGallery.builder(
key: ValueKey(asset),
scaleStateChangedCallback: (state) { scaleStateChangedCallback: (state) {
isZoomed.value = state != PhotoViewScaleState.initial; isZoomed.value = state != PhotoViewScaleState.initial;
ref.read(showControlsProvider.notifier).show = !isZoomed.value; ref.read(showControlsProvider.notifier).show = !isZoomed.value;
}, },
// wantKeepAlive: true,
gaplessPlayback: true,
loadingBuilder: (context, event, index) => ClipRect( loadingBuilder: (context, event, index) => ClipRect(
child: Stack( child: Stack(
fit: StackFit.expand, fit: StackFit.expand,
@ -286,6 +369,7 @@ class GalleryViewerPage extends HookConsumerWidget {
), ),
), ),
ImmichThumbnail( ImmichThumbnail(
key: ValueKey(asset),
asset: asset, asset: asset,
fit: BoxFit.contain, fit: BoxFit.contain,
), ),
@ -296,92 +380,40 @@ class GalleryViewerPage extends HookConsumerWidget {
scrollPhysics: isZoomed.value scrollPhysics: isZoomed.value
? const NeverScrollableScrollPhysics() // Don't allow paging while scrolled in ? const NeverScrollableScrollPhysics() // Don't allow paging while scrolled in
: (Platform.isIOS : (Platform.isIOS
? const ScrollPhysics() // Use bouncing physics for iOS ? const FastScrollPhysics() // Use bouncing physics for iOS
: const ClampingScrollPhysics() // Use heavy physics for Android : const FastClampingScrollPhysics() // Use heavy physics for Android
), ),
itemCount: totalAssets.value, itemCount: totalAssets.value,
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
onPageChanged: (value) async { onPageChanged: (value) {
log.info('Page changed to $value');
final next = currentIndex.value < value ? value + 1 : value - 1; final next = currentIndex.value < value ? value + 1 : value - 1;
ref.read(hapticFeedbackProvider.notifier).selectionClick(); ref.read(hapticFeedbackProvider.notifier).selectionClick();
final newAsset =
value == currentIndex.value ? asset : loadAsset(value);
if (!newAsset.isImage || newAsset.isMotionPhoto) {
ref.read(videoPlaybackValueProvider.notifier).reset();
}
currentIndex.value = value; currentIndex.value = value;
stackIndex.value = -1; stackIndex.value = -1;
isPlayingVideo.value = false; isPlayingMotionVideo.value = false;
// Wait for page change animation to finish // Delay setting the new asset to avoid a stutter in the page change animation
await Future.delayed(const Duration(milliseconds: 400)); // TODO: make the scroll animation finish more quickly, and ideally have a callback for when it's done
// Then precache the next image ref.read(currentAssetProvider.notifier).set(newAsset);
unawaited(precacheNextImage(next)); // Timer(const Duration(milliseconds: 450), () {
}, // ref.read(currentAssetProvider.notifier).set(newAsset);
builder: (context, index) { // });
final a =
index == currentIndex.value ? asset : loadAsset(index); // Wait for page change animation to finish, then precache the next image
Timer(const Duration(milliseconds: 400), () {
final ImageProvider provider = precacheNextImage(next);
ImmichImage.imageProvider(asset: a); });
if (a.isImage && !isPlayingVideo.value) {
return PhotoViewGalleryPageOptions(
onDragStart: (_, details, __) =>
localPosition.value = details.localPosition,
onDragUpdate: (_, details, __) =>
handleSwipeUpDown(details),
onTapDown: (_, __, ___) {
ref.read(showControlsProvider.notifier).toggle();
},
onLongPressStart: (_, __, ___) {
if (asset.livePhotoVideoId != null) {
isPlayingVideo.value = true;
}
},
imageProvider: provider,
heroAttributes: PhotoViewHeroAttributes(
tag: isFromDto
? '${currentAsset.remoteId}-$heroOffset'
: currentAsset.id + heroOffset,
transitionOnUserGestures: true,
),
filterQuality: FilterQuality.high,
tightMode: true,
minScale: PhotoViewComputedScale.contained,
errorBuilder: (context, error, stackTrace) => ImmichImage(
a,
fit: BoxFit.contain,
),
);
} else {
return PhotoViewGalleryPageOptions.customChild(
onDragStart: (_, details, __) =>
localPosition.value = details.localPosition,
onDragUpdate: (_, details, __) =>
handleSwipeUpDown(details),
heroAttributes: PhotoViewHeroAttributes(
tag: isFromDto
? '${currentAsset.remoteId}-$heroOffset'
: currentAsset.id + heroOffset,
),
filterQuality: FilterQuality.high,
maxScale: 1.0,
minScale: 1.0,
basePosition: Alignment.center,
child: NativeVideoViewerPage(
key: ValueKey(a),
asset: a,
isMotionVideo: a.livePhotoVideoId != null,
loopVideo: shouldLoopVideo.value,
placeholder: Image(
image: provider,
fit: BoxFit.contain,
height: context.height,
width: context.width,
alignment: Alignment.center,
),
),
);
}
}, },
builder: buildAsset,
), ),
Positioned( Positioned(
top: 0, top: 0,
@ -390,9 +422,9 @@ class GalleryViewerPage extends HookConsumerWidget {
child: GalleryAppBar( child: GalleryAppBar(
asset: asset, asset: asset,
showInfo: showInfo, showInfo: showInfo,
isPlayingVideo: isPlayingVideo.value, isPlayingVideo: isPlayingMotionVideo.value,
onToggleMotionVideo: () => onToggleMotionVideo: () =>
isPlayingVideo.value = !isPlayingVideo.value, isPlayingMotionVideo.value = !isPlayingMotionVideo.value,
), ),
), ),
Positioned( Positioned(
@ -416,7 +448,8 @@ class GalleryViewerPage extends HookConsumerWidget {
stackIndex: stackIndex.value, stackIndex: stackIndex.value,
asset: asset, asset: asset,
assetIndex: currentIndex, assetIndex: currentIndex,
showVideoPlayerControls: !asset.isImage && !isMotionPhoto, showVideoPlayerControls:
!asset.isImage && !asset.isMotionPhoto,
), ),
], ],
), ),

View file

@ -5,304 +5,429 @@ import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/current_asset.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';
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/services/api.service.dart'; import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/services/asset.service.dart'; import 'package:immich_mobile/services/asset.service.dart';
import 'package:immich_mobile/utils/hooks/interval_hook.dart';
import 'package:immich_mobile/widgets/asset_viewer/custom_video_player_controls.dart'; import 'package:immich_mobile/widgets/asset_viewer/custom_video_player_controls.dart';
import 'package:immich_mobile/widgets/common/delayed_loading_indicator.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:photo_manager/photo_manager.dart';
import 'package:wakelock_plus/wakelock_plus.dart'; import 'package:wakelock_plus/wakelock_plus.dart';
class NativeVideoViewerPage extends HookConsumerWidget { class NativeVideoViewerPage extends HookConsumerWidget {
final Asset asset; final Asset asset;
final bool isMotionVideo;
final Widget? placeholder;
final bool showControls; final bool showControls;
final Duration hideControlsTimer; final Duration hideControlsTimer;
final bool loopVideo; final Widget placeholder;
// final ValueNotifier<bool>? doInitialize;
const NativeVideoViewerPage({ const NativeVideoViewerPage({
super.key, super.key,
required this.asset, required this.asset,
this.isMotionVideo = false, required this.placeholder,
this.placeholder,
this.showControls = true, this.showControls = true,
this.hideControlsTimer = const Duration(seconds: 5), this.hideControlsTimer = const Duration(seconds: 5),
this.loopVideo = false,
}); });
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final loopVideo = ref.watch(
appSettingsServiceProvider.select(
(settings) => settings.getSetting<bool>(AppSettingsEnum.loopVideo),
),
);
final controller = useState<NativeVideoPlayerController?>(null); final controller = useState<NativeVideoPlayerController?>(null);
final lastVideoPosition = useRef(-1); final lastVideoPosition = useRef(-1);
final isBuffering = useRef(false); final isBuffering = useRef(false);
final width = useRef<double>(asset.width?.toDouble() ?? 1.0); final currentAsset = useState(ref.read(currentAssetProvider));
final height = useRef<double>(asset.height?.toDouble() ?? 1.0); final isCurrent = currentAsset.value == asset;
void checkIfBuffering([Timer? timer]) { final log = Logger('NativeVideoViewerPage');
log.info('Building NativeVideoViewerPage');
final localEntity = useMemoized(() {
if (!asset.isLocal) {
return null;
}
return AssetEntity.fromId(asset.localId!);
});
Future<double?> calculateAspectRatio() async {
if (!context.mounted) {
log.info('calculateAspectRatio: Context is not mounted');
return null;
}
log.info('Calculating aspect ratio');
late final double? orientatedWidth;
late final double? orientatedHeight;
if (asset.exifInfo != null) {
orientatedWidth = asset.orientatedWidth?.toDouble();
orientatedHeight = asset.orientatedHeight?.toDouble();
} else if (localEntity != null) {
final entity = await localEntity;
orientatedWidth = entity?.orientatedWidth.toDouble();
orientatedHeight = entity?.orientatedHeight.toDouble();
} else {
final entity = await ref.read(assetServiceProvider).loadExif(asset);
orientatedWidth = entity.orientatedWidth?.toDouble();
orientatedHeight = entity.orientatedHeight?.toDouble();
}
log.info('Calculated aspect ratio');
if (orientatedWidth != null &&
orientatedHeight != null &&
orientatedWidth > 0 &&
orientatedHeight > 0) {
return orientatedWidth / orientatedHeight;
}
return 1.0;
}
Future<VideoSource?> createSource() async {
if (!context.mounted) {
log.info('createSource: Context is not mounted');
return null;
}
if (localEntity != null && asset.livePhotoVideoId == null) {
log.info('Loading video from local storage');
final file = await (await localEntity)!.file;
if (file == null) {
throw Exception('No file found for the video');
}
final source = await VideoSource.init(
path: file.path,
type: VideoSourceType.file,
);
log.info('Loaded video from local storage');
return source;
}
log.info('Loading video from server');
// Use a network URL for the video player controller
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
final String videoUrl = asset.livePhotoVideoId != null
? '$serverEndpoint/assets/${asset.livePhotoVideoId}/video/playback'
: '$serverEndpoint/assets/${asset.remoteId}/video/playback';
final source = await VideoSource.init(
path: videoUrl,
type: VideoSourceType.network,
headers: ApiService.getRequestHeaders(),
);
log.info('Loaded video from server');
return source;
}
final videoSource = useState<VideoSource?>(null);
final aspectRatio = useState<double?>(null);
useMemoized(
() async {
if (!context.mounted) {
log.info('combined: Context is not mounted');
return null;
}
final (videoSourceRes, aspectRatioRes) =
await (createSource(), calculateAspectRatio()).wait;
if (videoSourceRes == null || aspectRatioRes == null) {
log.info('combined: Video source or aspect ratio is null');
return;
}
// if opening a remote video from a hero animation, delay initialization to avoid a stutter
if (!asset.isLocal && isCurrent) {
await Future.delayed(const Duration(milliseconds: 150));
}
videoSource.value = videoSourceRes;
aspectRatio.value = aspectRatioRes;
},
);
void checkIfBuffering() {
if (!context.mounted) { if (!context.mounted) {
timer?.cancel();
return; return;
} }
log.info('Checking if buffering');
final videoPlayback = ref.read(videoPlaybackValueProvider); final videoPlayback = ref.read(videoPlaybackValueProvider);
if ((isBuffering.value || if ((isBuffering.value ||
videoPlayback.state == VideoPlaybackState.initializing) && videoPlayback.state == VideoPlaybackState.initializing) &&
videoPlayback.state != VideoPlaybackState.buffering) { videoPlayback.state != VideoPlaybackState.buffering) {
log.info('Marking video as buffering');
ref.read(videoPlaybackValueProvider.notifier).value = ref.read(videoPlaybackValueProvider.notifier).value =
videoPlayback.copyWith(state: VideoPlaybackState.buffering); videoPlayback.copyWith(state: VideoPlaybackState.buffering);
} }
} }
// timer to mark videos as buffering if the position does not change // timer to mark videos as buffering if the position does not change
final bufferingTimer = useRef<Timer>( useInterval(const Duration(seconds: 5), checkIfBuffering);
Timer.periodic(const Duration(seconds: 5), checkIfBuffering),
);
Future<VideoSource> createSource(Asset asset) async {
if (asset.isLocal && asset.livePhotoVideoId == null) {
final entity = await asset.local!.obtainForNewProperties();
final file = await entity?.file;
if (entity == null || file == null) {
throw Exception('No file found for the video');
}
width.value = entity.orientatedWidth.toDouble();
height.value = entity.orientatedHeight.toDouble();
return await VideoSource.init(
path: file.path,
type: VideoSourceType.file,
);
} else {
final assetWithExif =
await ref.read(assetServiceProvider).loadExif(asset);
final shouldFlip = assetWithExif.exifInfo?.isFlipped ?? false;
width.value = (shouldFlip ? assetWithExif.height : assetWithExif.width)
?.toDouble() ??
width.value;
height.value = (shouldFlip ? assetWithExif.width : assetWithExif.height)
?.toDouble() ??
height.value;
// Use a network URL for the video player controller
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
final String videoUrl = asset.livePhotoVideoId != null
? '$serverEndpoint/assets/${asset.livePhotoVideoId}/video/playback'
: '$serverEndpoint/assets/${asset.remoteId}/video/playback';
return await VideoSource.init(
path: videoUrl,
type: VideoSourceType.network,
headers: ApiService.getRequestHeaders(),
);
}
}
// When the volume changes, set the volume // When the volume changes, set the volume
ref.listen(videoPlayerControlsProvider.select((value) => value.mute), ref.listen(videoPlayerControlsProvider.select((value) => value.mute),
(_, mute) { (_, mute) {
final playerController = controller.value;
if (playerController == null) {
return;
}
final playbackInfo = playerController.playbackInfo;
if (playbackInfo == null) {
return;
}
try { try {
if (mute) { if (mute && playbackInfo.volume != 0.0) {
controller.value?.setVolume(0.0); playerController.setVolume(0.0);
} else { } else if (!mute && playbackInfo.volume != 0.7) {
controller.value?.setVolume(0.7); playerController.setVolume(0.7);
} }
} catch (_) { } catch (error) {
// Consume error from the controller log.severe('Error setting volume: $error');
} }
}); });
// When the position changes, seek to the position // When the position changes, seek to the position
ref.listen(videoPlayerControlsProvider.select((value) => value.position), ref.listen(videoPlayerControlsProvider.select((value) => value.position),
(_, position) { (_, position) {
if (controller.value == null) { final playerController = controller.value;
// No seeeking if there is no video if (playerController == null) {
return;
}
final playbackInfo = playerController.playbackInfo;
if (playbackInfo == null) {
return; return;
} }
// Find the position to seek to // Find the position to seek to
final Duration seek = asset.duration * (position / 100.0); final int seek = (asset.duration * (position / 100.0)).inSeconds;
try { if (seek != playbackInfo.position) {
controller.value?.seekTo(seek.inSeconds); try {
} catch (_) { playerController.seekTo(seek);
// Consume error from the controller } catch (error) {
log.severe('Error seeking to position $position: $error');
}
} }
ref.read(videoPlaybackValueProvider.notifier).position =
Duration(seconds: seek);
}); });
// When the custom video controls paus or plays // // When the custom video controls pause or play
ref.listen(videoPlayerControlsProvider.select((value) => value.pause), ref.listen(videoPlayerControlsProvider.select((value) => value.pause),
(_, pause) { (_, pause) {
final videoController = controller.value;
if (videoController == null || !context.mounted) {
return;
}
try { try {
if (pause) { if (pause) {
controller.value?.pause(); log.info('Pausing video');
videoController.pause();
} else { } else {
controller.value?.play(); log.info('Playing video');
videoController.play();
} }
} catch (_) { } catch (error) {
// Consume error from the controller log.severe('Error pausing or playing video: $error');
} }
}); });
void updateVideoPlayback() { void onPlaybackReady() {
if (controller.value == null || !context.mounted) { final videoController = controller.value;
if (videoController == null || !isCurrent || !context.mounted) {
return;
}
log.info('Playback ready for video ${asset.id}');
try {
videoController.play();
videoController.setVolume(0.9);
} catch (error) {
log.severe('Error playing video: $error');
}
}
ref.listen(currentAssetProvider, (_, value) {
log.info(
'Changing currentAsset from ${currentAsset.value?.id} isCurrent to ${value?.id}',
);
// Delay the video playback to avoid a stutter in the swipe animation
Timer(const Duration(milliseconds: 350), () {
if (!context.mounted) {
return;
}
log.info(
'Changed currentAsset from ${currentAsset.value?.id} isCurrent to ${value?.id}',
);
currentAsset.value = value;
if (currentAsset.value == asset) {
onPlaybackReady();
}
});
});
void onPlaybackStatusChanged() {
final videoController = controller.value;
if (videoController == null || !context.mounted) {
return; return;
} }
final videoPlayback = final videoPlayback =
VideoPlaybackValue.fromNativeController(controller.value!); VideoPlaybackValue.fromNativeController(videoController);
ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback; if (videoPlayback.state == VideoPlaybackState.completed && loopVideo) {
// Check if the video is buffering return;
if (videoPlayback.state == VideoPlaybackState.playing) {
isBuffering.value =
lastVideoPosition.value == videoPlayback.position.inSeconds;
lastVideoPosition.value = videoPlayback.position.inSeconds;
} else {
isBuffering.value = false;
lastVideoPosition.value = -1;
} }
final state = videoPlayback.state; ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback;
// Enable the WakeLock while the video is playing if (videoPlayback.state == VideoPlaybackState.playing) {
if (state == VideoPlaybackState.playing) {
// Sync with the controls playing // Sync with the controls playing
WakelockPlus.enable(); WakelockPlus.enable();
log.info('Video ${asset.id} is playing; enabled wakelock');
} else { } else {
// Sync with the controls pause // Sync with the controls pause
WakelockPlus.disable(); WakelockPlus.disable();
} log.info('Video ${asset.id} is not playing; disabled wakelock');
}
void onPlaybackReady() {
try {
controller.value?.play();
controller.value?.setVolume(0.9);
} catch (_) {
// Consume error from the controller
} }
} }
void onPlaybackPositionChanged() { void onPlaybackPositionChanged() {
updateVideoPlayback(); final videoController = controller.value;
} if (videoController == null || !context.mounted) {
void onPlaybackEnded() {
try {
if (loopVideo) {
controller.value?.play();
}
} catch (_) {
// Consume error from the controller
}
}
Future<void> initController(NativeVideoPlayerController nc) async {
if (controller.value != null) {
return; return;
} }
final playbackInfo = videoController.playbackInfo;
if (playbackInfo == null) {
return;
}
ref.read(videoPlaybackValueProvider.notifier).position =
Duration(seconds: playbackInfo.position);
// Check if the video is buffering
if (playbackInfo.status == PlaybackStatus.playing) {
isBuffering.value = lastVideoPosition.value == playbackInfo.position;
lastVideoPosition.value = playbackInfo.position;
} else {
isBuffering.value = false;
lastVideoPosition.value = -1;
}
}
void onPlaybackEnded() {
final videoController = controller.value;
if (videoController == null || !context.mounted) {
return;
}
if (loopVideo) {
try {
videoController.play();
} catch (error) {
log.severe('Error looping video: $error');
}
} else {
WakelockPlus.disable();
}
}
void initController(NativeVideoPlayerController nc) {
log.info('initController for ${asset.id} started');
if (controller.value != null) {
log.info(
'initController for ${asset.id}: Controller already initialized');
return;
}
ref.read(videoPlayerControlsProvider.notifier).reset();
ref.read(videoPlaybackValueProvider.notifier).reset();
nc.onPlaybackPositionChanged.addListener(onPlaybackPositionChanged); nc.onPlaybackPositionChanged.addListener(onPlaybackPositionChanged);
nc.onPlaybackStatusChanged.addListener(onPlaybackPositionChanged); nc.onPlaybackStatusChanged.addListener(onPlaybackStatusChanged);
nc.onPlaybackReady.addListener(onPlaybackReady); nc.onPlaybackReady.addListener(onPlaybackReady);
nc.onPlaybackEnded.addListener(onPlaybackEnded); nc.onPlaybackEnded.addListener(onPlaybackEnded);
final videoSource = await createSource(asset); nc.loadVideoSource(videoSource.value!);
nc.loadVideoSource(videoSource);
log.info('initController for ${asset.id}: setting controller');
controller.value = nc; controller.value = nc;
Timer(const Duration(milliseconds: 200), checkIfBuffering); Timer(const Duration(milliseconds: 200), checkIfBuffering);
} }
useEffect( useEffect(
() { () {
Future.microtask(
() => ref.read(videoPlayerControlsProvider.notifier).reset(),
);
if (isMotionVideo) {
// ignore: prefer-extracting-callbacks
Future.microtask(() {
ref.read(showControlsProvider.notifier).show = false;
});
}
return () { return () {
bufferingTimer.value.cancel(); log.info('Cleaning up video ${asset.id}');
try { final playerController = controller.value;
controller.value?.onPlaybackPositionChanged if (playerController == null) {
.removeListener(onPlaybackPositionChanged); log.info('Controller is null');
controller.value?.onPlaybackStatusChanged return;
.removeListener(onPlaybackPositionChanged);
controller.value?.onPlaybackReady.removeListener(onPlaybackReady);
controller.value?.onPlaybackEnded.removeListener(onPlaybackEnded);
controller.value?.stop();
} catch (_) {
// Consume error from the controller
} }
try {
playerController.stop();
playerController.onPlaybackPositionChanged
.removeListener(onPlaybackPositionChanged);
playerController.onPlaybackStatusChanged
.removeListener(onPlaybackStatusChanged);
playerController.onPlaybackReady.removeListener(onPlaybackReady);
playerController.onPlaybackEnded.removeListener(onPlaybackEnded);
} catch (error) {
log.severe('Error during useEffect cleanup: $error');
}
controller.value = null;
WakelockPlus.disable();
}; };
}, },
[], [videoSource],
); );
double calculateAspectRatio() { return Stack(
if (width.value == 0 || height.value == 0) { children: [
return 1; placeholder,
} Center(
return width.value / height.value; key: ValueKey('player-${asset.hashCode}'),
} child: aspectRatio.value != null
? AspectRatio(
final size = MediaQuery.sizeOf(context); key: ValueKey(asset),
aspectRatio: aspectRatio.value!,
return SizedBox( child: isCurrent
height: size.height, ? NativeVideoPlayerView(
width: size.width, key: ValueKey(asset),
child: GestureDetector( onViewReady: initController,
behavior: HitTestBehavior.deferToChild, )
child: PopScope( : null,
onPopInvokedWithResult: (didPop, _) => ref )
.read(videoPlaybackValueProvider.notifier) : null,
.value = VideoPlaybackValue.uninitialized(), ),
child: SizedBox( // covers the video with the placeholder
height: size.height, if (showControls)
width: size.width, Center(
child: Stack( key: ValueKey('controls-${asset.hashCode}'),
children: [ child: CustomVideoPlayerControls(
Center( hideTimerDuration: hideControlsTimer,
child: AspectRatio(
aspectRatio: calculateAspectRatio(),
child: NativeVideoPlayerView(
onViewReady: initController,
),
),
),
if (showControls)
Center(
child: CustomVideoPlayerControls(
hideTimerDuration: hideControlsTimer,
),
),
Visibility(
visible: controller.value == null,
child: Stack(
children: [
if (placeholder != null) placeholder!,
const Positioned.fill(
child: Center(
child: DelayedLoadingIndicator(
fadeInDuration: Duration(milliseconds: 500),
),
),
),
],
),
),
],
), ),
), ),
), ],
),
); );
} }
} }

View file

@ -124,8 +124,7 @@ class VideoViewerPage extends HookConsumerWidget {
return PopScope( return PopScope(
onPopInvokedWithResult: (didPop, _) { onPopInvokedWithResult: (didPop, _) {
ref.read(videoPlaybackValueProvider.notifier).value = ref.read(videoPlaybackValueProvider.notifier).reset();
VideoPlaybackValue.uninitialized();
}, },
child: AnimatedSwitcher( child: AnimatedSwitcher(
duration: const Duration(milliseconds: 400), duration: const Duration(milliseconds: 400),

View file

@ -1,7 +1,7 @@
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
class VideoPlaybackControls { class VideoPlaybackControls {
VideoPlaybackControls({ const VideoPlaybackControls({
required this.position, required this.position,
required this.mute, required this.mute,
required this.pause, required this.pause,
@ -17,15 +17,14 @@ final videoPlayerControlsProvider =
return VideoPlayerControls(ref); return VideoPlayerControls(ref);
}); });
const videoPlayerControlsDefault = VideoPlaybackControls(
position: 0,
pause: false,
mute: false,
);
class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> { class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
VideoPlayerControls(this.ref) VideoPlayerControls(this.ref) : super(videoPlayerControlsDefault);
: super(
VideoPlaybackControls(
position: 0,
pause: false,
mute: false,
),
);
final Ref ref; final Ref ref;
@ -36,17 +35,17 @@ class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
} }
void reset() { void reset() {
state = VideoPlaybackControls( state = videoPlayerControlsDefault;
position: 0,
pause: false,
mute: false,
);
} }
double get position => state.position; double get position => state.position;
bool get mute => state.mute; bool get mute => state.mute;
set position(double value) { set position(double value) {
if (state.position == value) {
return;
}
state = VideoPlaybackControls( state = VideoPlaybackControls(
position: value, position: value,
mute: state.mute, mute: state.mute,
@ -55,6 +54,10 @@ class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
} }
set mute(bool value) { set mute(bool value) {
if (state.mute == value) {
return;
}
state = VideoPlaybackControls( state = VideoPlaybackControls(
position: state.position, position: state.position,
mute: value, mute: value,
@ -71,6 +74,10 @@ class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
} }
void pause() { void pause() {
if (state.pause) {
return;
}
state = VideoPlaybackControls( state = VideoPlaybackControls(
position: state.position, position: state.position,
mute: state.mute, mute: state.mute,
@ -79,6 +86,10 @@ class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
} }
void play() { void play() {
if (!state.pause) {
return;
}
state = VideoPlaybackControls( state = VideoPlaybackControls(
position: state.position, position: state.position,
mute: state.mute, mute: state.mute,
@ -95,12 +106,6 @@ class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
} }
void restart() { void restart() {
state = VideoPlaybackControls(
position: 0,
mute: state.mute,
pause: true,
);
state = VideoPlaybackControls( state = VideoPlaybackControls(
position: 0, position: 0,
mute: state.mute, mute: state.mute,

View file

@ -23,7 +23,7 @@ class VideoPlaybackValue {
/// The volume of the video /// The volume of the video
final double volume; final double volume;
VideoPlaybackValue({ const VideoPlaybackValue({
required this.position, required this.position,
required this.duration, required this.duration,
required this.state, required this.state,
@ -33,32 +33,24 @@ class VideoPlaybackValue {
factory VideoPlaybackValue.fromNativeController( factory VideoPlaybackValue.fromNativeController(
NativeVideoPlayerController controller, NativeVideoPlayerController controller,
) { ) {
PlaybackInfo? playbackInfo; final playbackInfo = controller.playbackInfo;
VideoInfo? videoInfo; final videoInfo = controller.videoInfo;
try {
playbackInfo = controller.playbackInfo; if (playbackInfo == null || videoInfo == null) {
videoInfo = controller.videoInfo; return videoPlaybackValueDefault;
} catch (_) {
// Consume error from the controller
}
late VideoPlaybackState s;
if (playbackInfo?.status == null) {
s = VideoPlaybackState.initializing;
} else if (playbackInfo?.status == PlaybackStatus.stopped &&
(playbackInfo?.positionFraction == 1 ||
playbackInfo?.positionFraction == 0)) {
s = VideoPlaybackState.completed;
} else if (playbackInfo?.status == PlaybackStatus.playing) {
s = VideoPlaybackState.playing;
} else {
s = VideoPlaybackState.paused;
} }
final VideoPlaybackState status = switch (playbackInfo.status) {
PlaybackStatus.playing => VideoPlaybackState.playing,
PlaybackStatus.paused => VideoPlaybackState.paused,
PlaybackStatus.stopped => VideoPlaybackState.completed,
};
return VideoPlaybackValue( return VideoPlaybackValue(
position: Duration(seconds: playbackInfo?.position ?? 0), position: Duration(seconds: playbackInfo.position),
duration: Duration(seconds: videoInfo?.duration ?? 0), duration: Duration(seconds: videoInfo.duration),
state: s, state: status,
volume: playbackInfo?.volume ?? 0.0, volume: playbackInfo.volume,
); );
} }
@ -85,15 +77,6 @@ class VideoPlaybackValue {
); );
} }
factory VideoPlaybackValue.uninitialized() {
return VideoPlaybackValue(
position: Duration.zero,
duration: Duration.zero,
state: VideoPlaybackState.initializing,
volume: 0.0,
);
}
VideoPlaybackValue copyWith({ VideoPlaybackValue copyWith({
Duration? position, Duration? position,
Duration? duration, Duration? duration,
@ -109,16 +92,20 @@ class VideoPlaybackValue {
} }
} }
const VideoPlaybackValue videoPlaybackValueDefault = VideoPlaybackValue(
position: Duration.zero,
duration: Duration.zero,
state: VideoPlaybackState.initializing,
volume: 0.0,
);
final videoPlaybackValueProvider = final videoPlaybackValueProvider =
StateNotifierProvider<VideoPlaybackValueState, VideoPlaybackValue>((ref) { StateNotifierProvider<VideoPlaybackValueState, VideoPlaybackValue>((ref) {
return VideoPlaybackValueState(ref); return VideoPlaybackValueState(ref);
}); });
class VideoPlaybackValueState extends StateNotifier<VideoPlaybackValue> { class VideoPlaybackValueState extends StateNotifier<VideoPlaybackValue> {
VideoPlaybackValueState(this.ref) VideoPlaybackValueState(this.ref) : super(videoPlaybackValueDefault);
: super(
VideoPlaybackValue.uninitialized(),
);
final Ref ref; final Ref ref;
@ -129,6 +116,7 @@ class VideoPlaybackValueState extends StateNotifier<VideoPlaybackValue> {
} }
set position(Duration value) { set position(Duration value) {
if (state.position == value) return;
state = VideoPlaybackValue( state = VideoPlaybackValue(
position: value, position: value,
duration: state.duration, duration: state.duration,
@ -136,4 +124,8 @@ class VideoPlaybackValueState extends StateNotifier<VideoPlaybackValue> {
volume: state.volume, volume: state.volume,
); );
} }
void reset() {
state = videoPlaybackValueDefault;
}
} }

View file

@ -0,0 +1,18 @@
import 'dart:async';
import 'dart:ui';
import 'package:flutter_hooks/flutter_hooks.dart';
// https://github.com/rrousselGit/flutter_hooks/issues/233#issuecomment-840416638
void useInterval(Duration delay, VoidCallback callback) {
final savedCallback = useRef(callback);
savedCallback.value = callback;
useEffect(
() {
final timer = Timer.periodic(delay, (_) => savedCallback.value());
return timer.cancel;
},
[delay],
);
}

View file

@ -1,5 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/show_controls.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';
@ -29,10 +28,9 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
} }
}, },
); );
final showBuffering = useState(false);
final VideoPlaybackState state = final VideoPlaybackState state =
ref.watch(videoPlaybackValueProvider).state; ref.watch(videoPlaybackValueProvider.select((value) => value.state));
final showBuffering = state == VideoPlaybackState.buffering;
/// Shows the controls and starts the timer to hide them /// Shows the controls and starts the timer to hide them
void showControlsAndStartHideTimer() { void showControlsAndStartHideTimer() {
@ -52,16 +50,9 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
showControlsAndStartHideTimer(); showControlsAndStartHideTimer();
}); });
ref.listen(videoPlaybackValueProvider.select((value) => value.state),
(_, state) {
// Show buffering
showBuffering.value = state == VideoPlaybackState.buffering;
});
/// Toggles between playing and pausing depending on the state of the video /// Toggles between playing and pausing depending on the state of the video
void togglePlay() { void togglePlay() {
showControlsAndStartHideTimer(); showControlsAndStartHideTimer();
final state = ref.read(videoPlaybackValueProvider).state;
if (state == VideoPlaybackState.playing) { if (state == VideoPlaybackState.playing) {
ref.read(videoPlayerControlsProvider.notifier).pause(); ref.read(videoPlayerControlsProvider.notifier).pause();
} else if (state == VideoPlaybackState.completed) { } else if (state == VideoPlaybackState.completed) {
@ -78,7 +69,7 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
absorbing: !ref.watch(showControlsProvider), absorbing: !ref.watch(showControlsProvider),
child: Stack( child: Stack(
children: [ children: [
if (showBuffering.value) if (showBuffering)
const Center( const Center(
child: DelayedLoadingIndicator( child: DelayedLoadingIndicator(
fadeInDuration: Duration(milliseconds: 400), fadeInDuration: Duration(milliseconds: 400),

View file

@ -15,9 +15,10 @@ class FileInfo extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final textColor = context.isDarkTheme ? Colors.white : Colors.black; final textColor = context.isDarkTheme ? Colors.white : Colors.black;
String resolution = asset.width != null && asset.height != null String resolution =
? "${asset.height} x ${asset.width} " asset.orientatedHeight != null && asset.orientatedWidth != null
: ""; ? "${asset.orientatedHeight} x ${asset.orientatedWidth} "
: "";
String fileSize = asset.exifInfo?.fileSize != null String fileSize = asset.exifInfo?.fileSize != null
? formatBytes(asset.exifInfo!.fileSize!) ? formatBytes(asset.exifInfo!.fileSize!)
: ""; : "";

View file

@ -69,16 +69,16 @@ class MemoryCard extends StatelessWidget {
return Hero( return Hero(
tag: 'memory-${asset.id}', tag: 'memory-${asset.id}',
child: NativeVideoViewerPage( child: NativeVideoViewerPage(
key: ValueKey(asset), key: ValueKey(asset.id),
asset: asset, asset: asset,
hideControlsTimer: const Duration(seconds: 2),
showControls: false,
placeholder: SizedBox.expand( placeholder: SizedBox.expand(
child: ImmichImage( child: ImmichImage(
asset, asset,
fit: fit, fit: fit,
), ),
), ),
hideControlsTimer: const Duration(seconds: 2),
showControls: false,
), ),
); );
} }