mirror of
https://github.com/immich-app/immich.git
synced 2024-12-29 15:11:58 +00: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:
parent
3272ad4a7b
commit
49c4d7cff9
12 changed files with 624 additions and 436 deletions
|
@ -22,12 +22,8 @@ class Asset {
|
|||
durationInSeconds = remote.duration.toDuration()?.inSeconds ?? 0,
|
||||
type = remote.type.toAssetType(),
|
||||
fileName = remote.originalFileName,
|
||||
height = isFlipped(remote)
|
||||
? remote.exifInfo?.exifImageWidth?.toInt()
|
||||
: remote.exifInfo?.exifImageHeight?.toInt(),
|
||||
width = isFlipped(remote)
|
||||
? remote.exifInfo?.exifImageHeight?.toInt()
|
||||
: remote.exifInfo?.exifImageWidth?.toInt(),
|
||||
height = remote.exifInfo?.exifImageHeight?.toInt(),
|
||||
width = remote.exifInfo?.exifImageWidth?.toInt(),
|
||||
livePhotoVideoId = remote.livePhotoVideoId,
|
||||
ownerId = fastHash(remote.ownerId),
|
||||
exifInfo =
|
||||
|
@ -172,6 +168,9 @@ class Asset {
|
|||
@ignore
|
||||
bool get isImage => type == AssetType.image;
|
||||
|
||||
@ignore
|
||||
bool get isMotionPhoto => livePhotoVideoId != null;
|
||||
|
||||
@ignore
|
||||
AssetState get storage {
|
||||
if (isRemote && isLocal) {
|
||||
|
@ -192,6 +191,14 @@ class Asset {
|
|||
@ignore
|
||||
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
|
||||
bool operator ==(other) {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
/// 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));
|
||||
}
|
||||
|
|
|
@ -47,7 +47,10 @@ class ExifInfo {
|
|||
String get focalLength => mm != null ? mm!.toStringAsFixed(1) : "";
|
||||
|
||||
@ignore
|
||||
bool get isFlipped => _isOrientationFlipped(orientation);
|
||||
bool? _isFlipped;
|
||||
|
||||
@ignore
|
||||
bool get isFlipped => _isFlipped ??= _isOrientationFlipped(orientation);
|
||||
|
||||
@ignore
|
||||
double? get latitude => lat;
|
||||
|
|
32
mobile/lib/extensions/scroll_extensions.dart
Normal file
32
mobile/lib/extensions/scroll_extensions.dart
Normal 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;
|
||||
}
|
|
@ -11,6 +11,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||
import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.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/native_video_viewer.page.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
|
@ -53,21 +54,15 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final settings = ref.watch(appSettingsServiceProvider);
|
||||
final loadAsset = renderList.loadAsset;
|
||||
final totalAssets = useState(renderList.totalAssets);
|
||||
final shouldLoopVideo = useState(AppSettingsEnum.loopVideo.defaultValue);
|
||||
final isZoomed = useState(false);
|
||||
final isPlayingVideo = 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 isPlayingMotionVideo = useState(false);
|
||||
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
|
||||
? ref.watch(assetStackStateProvider(currentAsset))
|
||||
: <Asset>[];
|
||||
|
@ -79,30 +74,23 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||
? currentAsset
|
||||
: stackElements.elementAt(stackIndex.value);
|
||||
|
||||
final isMotionPhoto = asset.livePhotoVideoId != null;
|
||||
// Listen provider to prevent autoDispose when navigating to other routes from within the gallery page
|
||||
ref.listen(currentAssetProvider, (_, __) {});
|
||||
useEffect(
|
||||
() {
|
||||
// Delay state update to after the execution of build method
|
||||
Future.microtask(
|
||||
() => ref.read(currentAssetProvider.notifier).set(asset),
|
||||
);
|
||||
return null;
|
||||
},
|
||||
[asset],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
shouldLoopVideo.value =
|
||||
settings.getSetting<bool>(AppSettingsEnum.loopVideo);
|
||||
return null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
// // Update is playing motion video
|
||||
if (asset.isMotionPhoto) {
|
||||
ref.listen(
|
||||
videoPlaybackValueProvider.select(
|
||||
(playback) => playback.state == VideoPlaybackState.playing,
|
||||
), (wasPlaying, isPlaying) {
|
||||
if (wasPlaying != null && wasPlaying && !isPlaying) {
|
||||
isPlayingMotionVideo.value = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> precacheNextImage(int index) async {
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
void onError(Object exception, StackTrace? stackTrace) {
|
||||
// swallow error silently
|
||||
debugPrint('Error precaching next image: $exception, $stackTrace');
|
||||
|
@ -110,6 +98,7 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||
|
||||
try {
|
||||
if (index < totalAssets.value && index >= 0) {
|
||||
log.info('Precaching next image at index $index');
|
||||
final asset = loadAsset(index);
|
||||
await precacheImage(
|
||||
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() {
|
||||
showModalBottomSheet(
|
||||
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) {
|
||||
if (show) {
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||
|
@ -219,7 +201,12 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||
});
|
||||
|
||||
Widget buildStackedChildren() {
|
||||
if (!showStack) {
|
||||
return const SizedBox();
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
key: ValueKey(currentAsset),
|
||||
shrinkWrap: true,
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: stackElements.length,
|
||||
|
@ -230,7 +217,11 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||
),
|
||||
itemBuilder: (context, index) {
|
||||
final assetId = stackElements.elementAt(index).remoteId;
|
||||
if (assetId == null) {
|
||||
return const SizedBox();
|
||||
}
|
||||
return Padding(
|
||||
key: ValueKey(assetId),
|
||||
padding: const EdgeInsets.only(right: 5),
|
||||
child: GestureDetector(
|
||||
onTap: () => stackIndex.value = index,
|
||||
|
@ -252,7 +243,7 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||
borderRadius: BorderRadius.circular(4),
|
||||
child: Image(
|
||||
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(
|
||||
// Change immersive mode back to normal "edgeToEdge" mode
|
||||
onPopInvokedWithResult: (didPop, _) =>
|
||||
|
@ -271,10 +351,13 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||
body: Stack(
|
||||
children: [
|
||||
PhotoViewGallery.builder(
|
||||
key: ValueKey(asset),
|
||||
scaleStateChangedCallback: (state) {
|
||||
isZoomed.value = state != PhotoViewScaleState.initial;
|
||||
ref.read(showControlsProvider.notifier).show = !isZoomed.value;
|
||||
},
|
||||
// wantKeepAlive: true,
|
||||
gaplessPlayback: true,
|
||||
loadingBuilder: (context, event, index) => ClipRect(
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
|
@ -286,6 +369,7 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||
),
|
||||
),
|
||||
ImmichThumbnail(
|
||||
key: ValueKey(asset),
|
||||
asset: asset,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
|
@ -296,92 +380,40 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||
scrollPhysics: isZoomed.value
|
||||
? const NeverScrollableScrollPhysics() // Don't allow paging while scrolled in
|
||||
: (Platform.isIOS
|
||||
? const ScrollPhysics() // Use bouncing physics for iOS
|
||||
: const ClampingScrollPhysics() // Use heavy physics for Android
|
||||
? const FastScrollPhysics() // Use bouncing physics for iOS
|
||||
: const FastClampingScrollPhysics() // Use heavy physics for Android
|
||||
),
|
||||
itemCount: totalAssets.value,
|
||||
scrollDirection: Axis.horizontal,
|
||||
onPageChanged: (value) async {
|
||||
onPageChanged: (value) {
|
||||
log.info('Page changed to $value');
|
||||
final next = currentIndex.value < value ? value + 1 : value - 1;
|
||||
|
||||
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;
|
||||
stackIndex.value = -1;
|
||||
isPlayingVideo.value = false;
|
||||
isPlayingMotionVideo.value = false;
|
||||
|
||||
// Wait for page change animation to finish
|
||||
await Future.delayed(const Duration(milliseconds: 400));
|
||||
// Then precache the next image
|
||||
unawaited(precacheNextImage(next));
|
||||
},
|
||||
builder: (context, index) {
|
||||
final a =
|
||||
index == currentIndex.value ? asset : loadAsset(index);
|
||||
|
||||
final ImageProvider provider =
|
||||
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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
// Delay setting the new asset to avoid a stutter in the page change animation
|
||||
// TODO: make the scroll animation finish more quickly, and ideally have a callback for when it's done
|
||||
ref.read(currentAssetProvider.notifier).set(newAsset);
|
||||
// Timer(const Duration(milliseconds: 450), () {
|
||||
// ref.read(currentAssetProvider.notifier).set(newAsset);
|
||||
// });
|
||||
|
||||
// Wait for page change animation to finish, then precache the next image
|
||||
Timer(const Duration(milliseconds: 400), () {
|
||||
precacheNextImage(next);
|
||||
});
|
||||
},
|
||||
builder: buildAsset,
|
||||
),
|
||||
Positioned(
|
||||
top: 0,
|
||||
|
@ -390,9 +422,9 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||
child: GalleryAppBar(
|
||||
asset: asset,
|
||||
showInfo: showInfo,
|
||||
isPlayingVideo: isPlayingVideo.value,
|
||||
isPlayingVideo: isPlayingMotionVideo.value,
|
||||
onToggleMotionVideo: () =>
|
||||
isPlayingVideo.value = !isPlayingVideo.value,
|
||||
isPlayingMotionVideo.value = !isPlayingMotionVideo.value,
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
|
@ -416,7 +448,8 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||
stackIndex: stackIndex.value,
|
||||
asset: asset,
|
||||
assetIndex: currentIndex,
|
||||
showVideoPlayerControls: !asset.isImage && !isMotionPhoto,
|
||||
showVideoPlayerControls:
|
||||
!asset.isImage && !asset.isMotionPhoto,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
@ -5,304 +5,429 @@ import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
|
|||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/entities/asset.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_value_provider.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/utils/hooks/interval_hook.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:photo_manager/photo_manager.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
|
||||
class NativeVideoViewerPage extends HookConsumerWidget {
|
||||
final Asset asset;
|
||||
final bool isMotionVideo;
|
||||
final Widget? placeholder;
|
||||
final bool showControls;
|
||||
final Duration hideControlsTimer;
|
||||
final bool loopVideo;
|
||||
final Widget placeholder;
|
||||
// final ValueNotifier<bool>? doInitialize;
|
||||
|
||||
const NativeVideoViewerPage({
|
||||
super.key,
|
||||
required this.asset,
|
||||
this.isMotionVideo = false,
|
||||
this.placeholder,
|
||||
required this.placeholder,
|
||||
this.showControls = true,
|
||||
this.hideControlsTimer = const Duration(seconds: 5),
|
||||
this.loopVideo = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final loopVideo = ref.watch(
|
||||
appSettingsServiceProvider.select(
|
||||
(settings) => settings.getSetting<bool>(AppSettingsEnum.loopVideo),
|
||||
),
|
||||
);
|
||||
final controller = useState<NativeVideoPlayerController?>(null);
|
||||
final lastVideoPosition = useRef(-1);
|
||||
final isBuffering = useRef(false);
|
||||
final width = useRef<double>(asset.width?.toDouble() ?? 1.0);
|
||||
final height = useRef<double>(asset.height?.toDouble() ?? 1.0);
|
||||
final currentAsset = useState(ref.read(currentAssetProvider));
|
||||
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) {
|
||||
timer?.cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
log.info('Checking if buffering');
|
||||
final videoPlayback = ref.read(videoPlaybackValueProvider);
|
||||
if ((isBuffering.value ||
|
||||
videoPlayback.state == VideoPlaybackState.initializing) &&
|
||||
videoPlayback.state != VideoPlaybackState.buffering) {
|
||||
log.info('Marking video as buffering');
|
||||
ref.read(videoPlaybackValueProvider.notifier).value =
|
||||
videoPlayback.copyWith(state: VideoPlaybackState.buffering);
|
||||
}
|
||||
}
|
||||
|
||||
// timer to mark videos as buffering if the position does not change
|
||||
final bufferingTimer = useRef<Timer>(
|
||||
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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
useInterval(const Duration(seconds: 5), checkIfBuffering);
|
||||
|
||||
// When the volume changes, set the volume
|
||||
ref.listen(videoPlayerControlsProvider.select((value) => value.mute),
|
||||
(_, mute) {
|
||||
final playerController = controller.value;
|
||||
if (playerController == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final playbackInfo = playerController.playbackInfo;
|
||||
if (playbackInfo == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (mute) {
|
||||
controller.value?.setVolume(0.0);
|
||||
} else {
|
||||
controller.value?.setVolume(0.7);
|
||||
if (mute && playbackInfo.volume != 0.0) {
|
||||
playerController.setVolume(0.0);
|
||||
} else if (!mute && playbackInfo.volume != 0.7) {
|
||||
playerController.setVolume(0.7);
|
||||
}
|
||||
} catch (_) {
|
||||
// Consume error from the controller
|
||||
} catch (error) {
|
||||
log.severe('Error setting volume: $error');
|
||||
}
|
||||
});
|
||||
|
||||
// When the position changes, seek to the position
|
||||
ref.listen(videoPlayerControlsProvider.select((value) => value.position),
|
||||
(_, position) {
|
||||
if (controller.value == null) {
|
||||
// No seeeking if there is no video
|
||||
final playerController = controller.value;
|
||||
if (playerController == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final playbackInfo = playerController.playbackInfo;
|
||||
if (playbackInfo == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the position to seek to
|
||||
final Duration seek = asset.duration * (position / 100.0);
|
||||
try {
|
||||
controller.value?.seekTo(seek.inSeconds);
|
||||
} catch (_) {
|
||||
// Consume error from the controller
|
||||
final int seek = (asset.duration * (position / 100.0)).inSeconds;
|
||||
if (seek != playbackInfo.position) {
|
||||
try {
|
||||
playerController.seekTo(seek);
|
||||
} 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),
|
||||
(_, pause) {
|
||||
final videoController = controller.value;
|
||||
if (videoController == null || !context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (pause) {
|
||||
controller.value?.pause();
|
||||
log.info('Pausing video');
|
||||
videoController.pause();
|
||||
} else {
|
||||
controller.value?.play();
|
||||
log.info('Playing video');
|
||||
videoController.play();
|
||||
}
|
||||
} catch (_) {
|
||||
// Consume error from the controller
|
||||
} catch (error) {
|
||||
log.severe('Error pausing or playing video: $error');
|
||||
}
|
||||
});
|
||||
|
||||
void updateVideoPlayback() {
|
||||
if (controller.value == null || !context.mounted) {
|
||||
void onPlaybackReady() {
|
||||
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;
|
||||
}
|
||||
|
||||
final videoPlayback =
|
||||
VideoPlaybackValue.fromNativeController(controller.value!);
|
||||
ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback;
|
||||
// Check if the video is buffering
|
||||
if (videoPlayback.state == VideoPlaybackState.playing) {
|
||||
isBuffering.value =
|
||||
lastVideoPosition.value == videoPlayback.position.inSeconds;
|
||||
lastVideoPosition.value = videoPlayback.position.inSeconds;
|
||||
} else {
|
||||
isBuffering.value = false;
|
||||
lastVideoPosition.value = -1;
|
||||
VideoPlaybackValue.fromNativeController(videoController);
|
||||
if (videoPlayback.state == VideoPlaybackState.completed && loopVideo) {
|
||||
return;
|
||||
}
|
||||
final state = videoPlayback.state;
|
||||
ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback;
|
||||
|
||||
// Enable the WakeLock while the video is playing
|
||||
if (state == VideoPlaybackState.playing) {
|
||||
if (videoPlayback.state == VideoPlaybackState.playing) {
|
||||
// Sync with the controls playing
|
||||
WakelockPlus.enable();
|
||||
log.info('Video ${asset.id} is playing; enabled wakelock');
|
||||
} else {
|
||||
// Sync with the controls pause
|
||||
WakelockPlus.disable();
|
||||
}
|
||||
}
|
||||
|
||||
void onPlaybackReady() {
|
||||
try {
|
||||
controller.value?.play();
|
||||
controller.value?.setVolume(0.9);
|
||||
} catch (_) {
|
||||
// Consume error from the controller
|
||||
log.info('Video ${asset.id} is not playing; disabled wakelock');
|
||||
}
|
||||
}
|
||||
|
||||
void onPlaybackPositionChanged() {
|
||||
updateVideoPlayback();
|
||||
}
|
||||
|
||||
void onPlaybackEnded() {
|
||||
try {
|
||||
if (loopVideo) {
|
||||
controller.value?.play();
|
||||
}
|
||||
} catch (_) {
|
||||
// Consume error from the controller
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> initController(NativeVideoPlayerController nc) async {
|
||||
if (controller.value != null) {
|
||||
final videoController = controller.value;
|
||||
if (videoController == null || !context.mounted) {
|
||||
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.onPlaybackStatusChanged.addListener(onPlaybackPositionChanged);
|
||||
nc.onPlaybackStatusChanged.addListener(onPlaybackStatusChanged);
|
||||
nc.onPlaybackReady.addListener(onPlaybackReady);
|
||||
nc.onPlaybackEnded.addListener(onPlaybackEnded);
|
||||
|
||||
final videoSource = await createSource(asset);
|
||||
nc.loadVideoSource(videoSource);
|
||||
nc.loadVideoSource(videoSource.value!);
|
||||
|
||||
log.info('initController for ${asset.id}: setting controller');
|
||||
controller.value = nc;
|
||||
Timer(const Duration(milliseconds: 200), checkIfBuffering);
|
||||
}
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
Future.microtask(
|
||||
() => ref.read(videoPlayerControlsProvider.notifier).reset(),
|
||||
);
|
||||
|
||||
if (isMotionVideo) {
|
||||
// ignore: prefer-extracting-callbacks
|
||||
Future.microtask(() {
|
||||
ref.read(showControlsProvider.notifier).show = false;
|
||||
});
|
||||
}
|
||||
|
||||
return () {
|
||||
bufferingTimer.value.cancel();
|
||||
try {
|
||||
controller.value?.onPlaybackPositionChanged
|
||||
.removeListener(onPlaybackPositionChanged);
|
||||
controller.value?.onPlaybackStatusChanged
|
||||
.removeListener(onPlaybackPositionChanged);
|
||||
controller.value?.onPlaybackReady.removeListener(onPlaybackReady);
|
||||
controller.value?.onPlaybackEnded.removeListener(onPlaybackEnded);
|
||||
controller.value?.stop();
|
||||
} catch (_) {
|
||||
// Consume error from the controller
|
||||
log.info('Cleaning up video ${asset.id}');
|
||||
final playerController = controller.value;
|
||||
if (playerController == null) {
|
||||
log.info('Controller is null');
|
||||
return;
|
||||
}
|
||||
|
||||
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() {
|
||||
if (width.value == 0 || height.value == 0) {
|
||||
return 1;
|
||||
}
|
||||
return width.value / height.value;
|
||||
}
|
||||
|
||||
final size = MediaQuery.sizeOf(context);
|
||||
|
||||
return SizedBox(
|
||||
height: size.height,
|
||||
width: size.width,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.deferToChild,
|
||||
child: PopScope(
|
||||
onPopInvokedWithResult: (didPop, _) => ref
|
||||
.read(videoPlaybackValueProvider.notifier)
|
||||
.value = VideoPlaybackValue.uninitialized(),
|
||||
child: SizedBox(
|
||||
height: size.height,
|
||||
width: size.width,
|
||||
child: Stack(
|
||||
children: [
|
||||
Center(
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
return Stack(
|
||||
children: [
|
||||
placeholder,
|
||||
Center(
|
||||
key: ValueKey('player-${asset.hashCode}'),
|
||||
child: aspectRatio.value != null
|
||||
? AspectRatio(
|
||||
key: ValueKey(asset),
|
||||
aspectRatio: aspectRatio.value!,
|
||||
child: isCurrent
|
||||
? NativeVideoPlayerView(
|
||||
key: ValueKey(asset),
|
||||
onViewReady: initController,
|
||||
)
|
||||
: null,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
// covers the video with the placeholder
|
||||
if (showControls)
|
||||
Center(
|
||||
key: ValueKey('controls-${asset.hashCode}'),
|
||||
child: CustomVideoPlayerControls(
|
||||
hideTimerDuration: hideControlsTimer,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -124,8 +124,7 @@ class VideoViewerPage extends HookConsumerWidget {
|
|||
|
||||
return PopScope(
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
ref.read(videoPlaybackValueProvider.notifier).value =
|
||||
VideoPlaybackValue.uninitialized();
|
||||
ref.read(videoPlaybackValueProvider.notifier).reset();
|
||||
},
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 400),
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
class VideoPlaybackControls {
|
||||
VideoPlaybackControls({
|
||||
const VideoPlaybackControls({
|
||||
required this.position,
|
||||
required this.mute,
|
||||
required this.pause,
|
||||
|
@ -17,15 +17,14 @@ final videoPlayerControlsProvider =
|
|||
return VideoPlayerControls(ref);
|
||||
});
|
||||
|
||||
const videoPlayerControlsDefault = VideoPlaybackControls(
|
||||
position: 0,
|
||||
pause: false,
|
||||
mute: false,
|
||||
);
|
||||
|
||||
class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
|
||||
VideoPlayerControls(this.ref)
|
||||
: super(
|
||||
VideoPlaybackControls(
|
||||
position: 0,
|
||||
pause: false,
|
||||
mute: false,
|
||||
),
|
||||
);
|
||||
VideoPlayerControls(this.ref) : super(videoPlayerControlsDefault);
|
||||
|
||||
final Ref ref;
|
||||
|
||||
|
@ -36,17 +35,17 @@ class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
|
|||
}
|
||||
|
||||
void reset() {
|
||||
state = VideoPlaybackControls(
|
||||
position: 0,
|
||||
pause: false,
|
||||
mute: false,
|
||||
);
|
||||
state = videoPlayerControlsDefault;
|
||||
}
|
||||
|
||||
double get position => state.position;
|
||||
bool get mute => state.mute;
|
||||
|
||||
set position(double value) {
|
||||
if (state.position == value) {
|
||||
return;
|
||||
}
|
||||
|
||||
state = VideoPlaybackControls(
|
||||
position: value,
|
||||
mute: state.mute,
|
||||
|
@ -55,6 +54,10 @@ class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
|
|||
}
|
||||
|
||||
set mute(bool value) {
|
||||
if (state.mute == value) {
|
||||
return;
|
||||
}
|
||||
|
||||
state = VideoPlaybackControls(
|
||||
position: state.position,
|
||||
mute: value,
|
||||
|
@ -71,6 +74,10 @@ class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
|
|||
}
|
||||
|
||||
void pause() {
|
||||
if (state.pause) {
|
||||
return;
|
||||
}
|
||||
|
||||
state = VideoPlaybackControls(
|
||||
position: state.position,
|
||||
mute: state.mute,
|
||||
|
@ -79,6 +86,10 @@ class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
|
|||
}
|
||||
|
||||
void play() {
|
||||
if (!state.pause) {
|
||||
return;
|
||||
}
|
||||
|
||||
state = VideoPlaybackControls(
|
||||
position: state.position,
|
||||
mute: state.mute,
|
||||
|
@ -95,12 +106,6 @@ class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
|
|||
}
|
||||
|
||||
void restart() {
|
||||
state = VideoPlaybackControls(
|
||||
position: 0,
|
||||
mute: state.mute,
|
||||
pause: true,
|
||||
);
|
||||
|
||||
state = VideoPlaybackControls(
|
||||
position: 0,
|
||||
mute: state.mute,
|
||||
|
|
|
@ -23,7 +23,7 @@ class VideoPlaybackValue {
|
|||
/// The volume of the video
|
||||
final double volume;
|
||||
|
||||
VideoPlaybackValue({
|
||||
const VideoPlaybackValue({
|
||||
required this.position,
|
||||
required this.duration,
|
||||
required this.state,
|
||||
|
@ -33,32 +33,24 @@ class VideoPlaybackValue {
|
|||
factory VideoPlaybackValue.fromNativeController(
|
||||
NativeVideoPlayerController controller,
|
||||
) {
|
||||
PlaybackInfo? playbackInfo;
|
||||
VideoInfo? videoInfo;
|
||||
try {
|
||||
playbackInfo = controller.playbackInfo;
|
||||
videoInfo = controller.videoInfo;
|
||||
} 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 playbackInfo = controller.playbackInfo;
|
||||
final videoInfo = controller.videoInfo;
|
||||
|
||||
if (playbackInfo == null || videoInfo == null) {
|
||||
return videoPlaybackValueDefault;
|
||||
}
|
||||
|
||||
final VideoPlaybackState status = switch (playbackInfo.status) {
|
||||
PlaybackStatus.playing => VideoPlaybackState.playing,
|
||||
PlaybackStatus.paused => VideoPlaybackState.paused,
|
||||
PlaybackStatus.stopped => VideoPlaybackState.completed,
|
||||
};
|
||||
|
||||
return VideoPlaybackValue(
|
||||
position: Duration(seconds: playbackInfo?.position ?? 0),
|
||||
duration: Duration(seconds: videoInfo?.duration ?? 0),
|
||||
state: s,
|
||||
volume: playbackInfo?.volume ?? 0.0,
|
||||
position: Duration(seconds: playbackInfo.position),
|
||||
duration: Duration(seconds: videoInfo.duration),
|
||||
state: status,
|
||||
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({
|
||||
Duration? position,
|
||||
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 =
|
||||
StateNotifierProvider<VideoPlaybackValueState, VideoPlaybackValue>((ref) {
|
||||
return VideoPlaybackValueState(ref);
|
||||
});
|
||||
|
||||
class VideoPlaybackValueState extends StateNotifier<VideoPlaybackValue> {
|
||||
VideoPlaybackValueState(this.ref)
|
||||
: super(
|
||||
VideoPlaybackValue.uninitialized(),
|
||||
);
|
||||
VideoPlaybackValueState(this.ref) : super(videoPlaybackValueDefault);
|
||||
|
||||
final Ref ref;
|
||||
|
||||
|
@ -129,6 +116,7 @@ class VideoPlaybackValueState extends StateNotifier<VideoPlaybackValue> {
|
|||
}
|
||||
|
||||
set position(Duration value) {
|
||||
if (state.position == value) return;
|
||||
state = VideoPlaybackValue(
|
||||
position: value,
|
||||
duration: state.duration,
|
||||
|
@ -136,4 +124,8 @@ class VideoPlaybackValueState extends StateNotifier<VideoPlaybackValue> {
|
|||
volume: state.volume,
|
||||
);
|
||||
}
|
||||
|
||||
void reset() {
|
||||
state = videoPlaybackValueDefault;
|
||||
}
|
||||
}
|
||||
|
|
18
mobile/lib/utils/hooks/interval_hook.dart
Normal file
18
mobile/lib/utils/hooks/interval_hook.dart
Normal 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],
|
||||
);
|
||||
}
|
|
@ -1,5 +1,4 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.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/video_player_controls_provider.dart';
|
||||
|
@ -29,10 +28,9 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
|
|||
}
|
||||
},
|
||||
);
|
||||
|
||||
final showBuffering = useState(false);
|
||||
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
|
||||
void showControlsAndStartHideTimer() {
|
||||
|
@ -52,16 +50,9 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
|
|||
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
|
||||
void togglePlay() {
|
||||
showControlsAndStartHideTimer();
|
||||
final state = ref.read(videoPlaybackValueProvider).state;
|
||||
if (state == VideoPlaybackState.playing) {
|
||||
ref.read(videoPlayerControlsProvider.notifier).pause();
|
||||
} else if (state == VideoPlaybackState.completed) {
|
||||
|
@ -78,7 +69,7 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
|
|||
absorbing: !ref.watch(showControlsProvider),
|
||||
child: Stack(
|
||||
children: [
|
||||
if (showBuffering.value)
|
||||
if (showBuffering)
|
||||
const Center(
|
||||
child: DelayedLoadingIndicator(
|
||||
fadeInDuration: Duration(milliseconds: 400),
|
||||
|
|
|
@ -15,9 +15,10 @@ class FileInfo extends StatelessWidget {
|
|||
Widget build(BuildContext context) {
|
||||
final textColor = context.isDarkTheme ? Colors.white : Colors.black;
|
||||
|
||||
String resolution = asset.width != null && asset.height != null
|
||||
? "${asset.height} x ${asset.width} "
|
||||
: "";
|
||||
String resolution =
|
||||
asset.orientatedHeight != null && asset.orientatedWidth != null
|
||||
? "${asset.orientatedHeight} x ${asset.orientatedWidth} "
|
||||
: "";
|
||||
String fileSize = asset.exifInfo?.fileSize != null
|
||||
? formatBytes(asset.exifInfo!.fileSize!)
|
||||
: "";
|
||||
|
|
|
@ -69,16 +69,16 @@ class MemoryCard extends StatelessWidget {
|
|||
return Hero(
|
||||
tag: 'memory-${asset.id}',
|
||||
child: NativeVideoViewerPage(
|
||||
key: ValueKey(asset),
|
||||
key: ValueKey(asset.id),
|
||||
asset: asset,
|
||||
hideControlsTimer: const Duration(seconds: 2),
|
||||
showControls: false,
|
||||
placeholder: SizedBox.expand(
|
||||
child: ImmichImage(
|
||||
asset,
|
||||
fit: fit,
|
||||
),
|
||||
),
|
||||
hideControlsTimer: const Duration(seconds: 2),
|
||||
showControls: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue