diff --git a/mobile/android/app/build.gradle b/mobile/android/app/build.gradle index 506ee9d1a4b0a..0ec511d9f125e 100644 --- a/mobile/android/app/build.gradle +++ b/mobile/android/app/build.gradle @@ -28,7 +28,7 @@ if (keystorePropertiesFile.exists()) { } android { - compileSdkVersion 34 + compileSdkVersion 35 compileOptions { sourceCompatibility JavaVersion.VERSION_17 @@ -47,7 +47,7 @@ android { defaultConfig { applicationId "app.alextran.immich" minSdkVersion 26 - targetSdkVersion 34 + targetSdkVersion 35 versionCode flutterVersionCode.toInteger() versionName flutterVersionName } diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index c85ce136844bc..8f239015dd62a 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -35,7 +35,7 @@ + android:value="true" /> Bool { - // Required for flutter_local_notification - if #available(iOS 10.0, *) { - UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate + // Required for flutter_local_notification + if #available(iOS 10.0, *) { + UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate + } + + do { + try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default) + try AVAudioSession.sharedInstance().setActive(true) + } catch { + print("Failed to set audio session category. Error: \(error)") + } + + GeneratedPluginRegistrant.register(with: self) + BackgroundServicePlugin.registerBackgroundProcessing() + + BackgroundServicePlugin.register(with: self.registrar(forPlugin: "BackgroundServicePlugin")!) + + BackgroundServicePlugin.setPluginRegistrantCallback { registry in + if !registry.hasPlugin("org.cocoapods.path-provider-ios") { + FLTPathProviderPlugin.register( + with: registry.registrar(forPlugin: "org.cocoapods.path-provider-ios")!) + } + + if !registry.hasPlugin("org.cocoapods.photo-manager") { + PhotoManagerPlugin.register( + with: registry.registrar(forPlugin: "org.cocoapods.photo-manager")!) } - GeneratedPluginRegistrant.register(with: self) - BackgroundServicePlugin.registerBackgroundProcessing() - - BackgroundServicePlugin.register(with: self.registrar(forPlugin: "BackgroundServicePlugin")!) - - BackgroundServicePlugin.setPluginRegistrantCallback { registry in - if !registry.hasPlugin("org.cocoapods.path-provider-ios") { - FLTPathProviderPlugin.register(with: registry.registrar(forPlugin: "org.cocoapods.path-provider-ios")!) - } - - if !registry.hasPlugin("org.cocoapods.photo-manager") { - PhotoManagerPlugin.register(with: registry.registrar(forPlugin: "org.cocoapods.photo-manager")!) - } - - if !registry.hasPlugin("org.cocoapods.shared-preferences-foundation") { - SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "org.cocoapods.shared-preferences-foundation")!) - } - - if !registry.hasPlugin("org.cocoapods.permission-handler-apple") { - PermissionHandlerPlugin.register(with: registry.registrar(forPlugin: "org.cocoapods.permission-handler-apple")!) - } + if !registry.hasPlugin("org.cocoapods.shared-preferences-foundation") { + SharedPreferencesPlugin.register( + with: registry.registrar(forPlugin: "org.cocoapods.shared-preferences-foundation")!) } - - return super.application(application, didFinishLaunchingWithOptions: launchOptions) + + if !registry.hasPlugin("org.cocoapods.permission-handler-apple") { + PermissionHandlerPlugin.register( + with: registry.registrar(forPlugin: "org.cocoapods.permission-handler-apple")!) + } + } + + return super.application(application, didFinishLaunchingWithOptions: launchOptions) } - + } diff --git a/mobile/lib/constants/immich_colors.dart b/mobile/lib/constants/immich_colors.dart index a49e783602b4d..847887de8c6b6 100644 --- a/mobile/lib/constants/immich_colors.dart +++ b/mobile/lib/constants/immich_colors.dart @@ -20,8 +20,8 @@ const String defaultColorPresetName = "indigo"; const Color immichBrandColorLight = Color(0xFF4150AF); const Color immichBrandColorDark = Color(0xFFACCBFA); const Color whiteOpacity75 = Color.fromARGB((0.75 * 255) ~/ 1, 255, 255, 255); -const Color blackOpacity90 = Color.fromARGB((0.90 * 255) ~/ 1, 0, 0, 0); const Color red400 = Color(0xFFEF5350); +const Color grey200 = Color(0xFFEEEEEE); final Map _themePresetsMap = { ImmichColorPreset.indigo: ImmichTheme( diff --git a/mobile/lib/entities/asset.entity.dart b/mobile/lib/entities/asset.entity.dart index 182c10307fdef..4bec35970a44f 100644 --- a/mobile/lib/entities/asset.entity.dart +++ b/mobile/lib/entities/asset.entity.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:io'; import 'package:immich_mobile/entities/exif_info.entity.dart'; import 'package:immich_mobile/utils/hash.dart'; @@ -22,12 +23,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 = @@ -93,6 +90,27 @@ class Asset { set local(AssetEntity? assetEntity) => _local = assetEntity; + @ignore + bool _didUpdateLocal = false; + + @ignore + Future get localAsync async { + final local = this.local; + if (local == null) { + throw Exception('Asset $fileName has no local data'); + } + + final updatedLocal = + _didUpdateLocal ? local : await local.obtainForNewProperties(); + if (updatedLocal == null) { + throw Exception('Could not fetch local data for $fileName'); + } + + this.local = updatedLocal; + _didUpdateLocal = true; + return updatedLocal; + } + Id id = Isar.autoIncrement; /// stores the raw SHA1 bytes as a base64 String @@ -150,10 +168,21 @@ class Asset { int stackCount; - /// Aspect ratio of the asset + /// Returns null if the asset has no sync access to the exif info @ignore - double? get aspectRatio => - width == null || height == null ? 0 : width! / height!; + double? get aspectRatio { + final orientatedWidth = this.orientatedWidth; + final orientatedHeight = this.orientatedHeight; + + if (orientatedWidth != null && + orientatedHeight != null && + orientatedWidth > 0 && + orientatedHeight > 0) { + return orientatedWidth.toDouble() / orientatedHeight.toDouble(); + } + + return null; + } /// `true` if this [Asset] is present on the device @ignore @@ -172,6 +201,12 @@ class Asset { @ignore bool get isImage => type == AssetType.image; + @ignore + bool get isVideo => type == AssetType.video; + + @ignore + bool get isMotionPhoto => livePhotoVideoId != null; + @ignore AssetState get storage { if (isRemote && isLocal) { @@ -192,6 +227,50 @@ class Asset { @ignore set byteHash(List hash) => checksum = base64.encode(hash); + /// Returns null if the asset has no sync access to the exif info + @ignore + @pragma('vm:prefer-inline') + bool? get isFlipped { + final exifInfo = this.exifInfo; + if (exifInfo != null) { + return exifInfo.isFlipped; + } + + if (_didUpdateLocal && Platform.isAndroid) { + final local = this.local; + if (local == null) { + throw Exception('Asset $fileName has no local data'); + } + return local.orientation == 90 || local.orientation == 270; + } + + return null; + } + + /// Returns null if the asset has no sync access to the exif info + @ignore + @pragma('vm:prefer-inline') + int? get orientatedHeight { + final isFlipped = this.isFlipped; + if (isFlipped == null) { + return null; + } + + return isFlipped ? width : height; + } + + /// Returns null if the asset has no sync access to the exif info + @ignore + @pragma('vm:prefer-inline') + int? get orientatedWidth { + final isFlipped = this.isFlipped; + if (isFlipped == null) { + return null; + } + + return isFlipped ? height : width; + } + @override bool operator ==(other) { if (other is! Asset) return false; @@ -511,21 +590,3 @@ extension AssetsHelper on IsarCollection { 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)); -} diff --git a/mobile/lib/entities/exif_info.entity.dart b/mobile/lib/entities/exif_info.entity.dart index 63d06f5d2c1aa..c46f3dddc15e0 100644 --- a/mobile/lib/entities/exif_info.entity.dart +++ b/mobile/lib/entities/exif_info.entity.dart @@ -23,6 +23,7 @@ class ExifInfo { String? state; String? country; String? description; + String? orientation; @ignore bool get hasCoordinates => @@ -45,6 +46,13 @@ class ExifInfo { @ignore String get focalLength => mm != null ? mm!.toStringAsFixed(1) : ""; + @ignore + bool? _isFlipped; + + @ignore + @pragma('vm:prefer-inline') + bool get isFlipped => _isFlipped ??= _isOrientationFlipped(orientation); + @ignore double? get latitude => lat; @@ -67,7 +75,8 @@ class ExifInfo { city = dto.city, state = dto.state, country = dto.country, - description = dto.description; + description = dto.description, + orientation = dto.orientation; ExifInfo({ this.id, @@ -87,6 +96,7 @@ class ExifInfo { this.state, this.country, this.description, + this.orientation, }); ExifInfo copyWith({ @@ -107,6 +117,7 @@ class ExifInfo { String? state, String? country, String? description, + String? orientation, }) => ExifInfo( id: id ?? this.id, @@ -126,6 +137,7 @@ class ExifInfo { state: state ?? this.state, country: country ?? this.country, description: description ?? this.description, + orientation: orientation ?? this.orientation, ); @override @@ -147,7 +159,8 @@ class ExifInfo { city == other.city && state == other.state && country == other.country && - description == other.description; + description == other.description && + orientation == other.orientation; } @override @@ -169,7 +182,8 @@ class ExifInfo { city.hashCode ^ state.hashCode ^ country.hashCode ^ - description.hashCode; + description.hashCode ^ + orientation.hashCode; @override String toString() { @@ -192,10 +206,21 @@ class ExifInfo { state: $state, country: $country, description: $description, + orientation: $orientation }"""; } } +bool _isOrientationFlipped(String? orientation) { + final value = orientation != null ? int.tryParse(orientation) : null; + if (value == null) { + return false; + } + final isRotated90CW = value == 5 || value == 6 || value == 90; + final isRotated270CW = value == 7 || value == 8 || value == -90; + return isRotated90CW || isRotated270CW; +} + double? _exposureTimeToSeconds(String? s) { if (s == null) { return null; diff --git a/mobile/lib/entities/exif_info.entity.g.dart b/mobile/lib/entities/exif_info.entity.g.dart index 015983abf289f..0b744e5f20ae6 100644 --- a/mobile/lib/entities/exif_info.entity.g.dart +++ b/mobile/lib/entities/exif_info.entity.g.dart @@ -87,13 +87,18 @@ const ExifInfoSchema = CollectionSchema( name: r'model', type: IsarType.string, ), - r'state': PropertySchema( + r'orientation': PropertySchema( id: 14, + name: r'orientation', + type: IsarType.string, + ), + r'state': PropertySchema( + id: 15, name: r'state', type: IsarType.string, ), r'timeZone': PropertySchema( - id: 15, + id: 16, name: r'timeZone', type: IsarType.string, ) @@ -154,6 +159,12 @@ int _exifInfoEstimateSize( bytesCount += 3 + value.length * 3; } } + { + final value = object.orientation; + if (value != null) { + bytesCount += 3 + value.length * 3; + } + } { final value = object.state; if (value != null) { @@ -189,8 +200,9 @@ void _exifInfoSerialize( writer.writeString(offsets[11], object.make); writer.writeFloat(offsets[12], object.mm); writer.writeString(offsets[13], object.model); - writer.writeString(offsets[14], object.state); - writer.writeString(offsets[15], object.timeZone); + writer.writeString(offsets[14], object.orientation); + writer.writeString(offsets[15], object.state); + writer.writeString(offsets[16], object.timeZone); } ExifInfo _exifInfoDeserialize( @@ -215,8 +227,9 @@ ExifInfo _exifInfoDeserialize( make: reader.readStringOrNull(offsets[11]), mm: reader.readFloatOrNull(offsets[12]), model: reader.readStringOrNull(offsets[13]), - state: reader.readStringOrNull(offsets[14]), - timeZone: reader.readStringOrNull(offsets[15]), + orientation: reader.readStringOrNull(offsets[14]), + state: reader.readStringOrNull(offsets[15]), + timeZone: reader.readStringOrNull(offsets[16]), ); return object; } @@ -260,6 +273,8 @@ P _exifInfoDeserializeProp

( return (reader.readStringOrNull(offset)) as P; case 15: return (reader.readStringOrNull(offset)) as P; + case 16: + return (reader.readStringOrNull(offset)) as P; default: throw IsarError('Unknown property with id $propertyId'); } @@ -1909,6 +1924,155 @@ extension ExifInfoQueryFilter }); } + QueryBuilder orientationIsNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNull( + property: r'orientation', + )); + }); + } + + QueryBuilder + orientationIsNotNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNotNull( + property: r'orientation', + )); + }); + } + + QueryBuilder orientationEqualTo( + String? value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'orientation', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + orientationGreaterThan( + String? value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'orientation', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder orientationLessThan( + String? value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'orientation', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder orientationBetween( + String? lower, + String? upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'orientation', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder orientationStartsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.startsWith( + property: r'orientation', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder orientationEndsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.endsWith( + property: r'orientation', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder orientationContains( + String value, + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.contains( + property: r'orientation', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder orientationMatches( + String pattern, + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.matches( + property: r'orientation', + wildcard: pattern, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder orientationIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'orientation', + value: '', + )); + }); + } + + QueryBuilder + orientationIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + property: r'orientation', + value: '', + )); + }); + } + QueryBuilder stateIsNull() { return QueryBuilder.apply(this, (query) { return query.addFilterCondition(const FilterCondition.isNull( @@ -2377,6 +2541,18 @@ extension ExifInfoQuerySortBy on QueryBuilder { }); } + QueryBuilder sortByOrientation() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'orientation', Sort.asc); + }); + } + + QueryBuilder sortByOrientationDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'orientation', Sort.desc); + }); + } + QueryBuilder sortByState() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'state', Sort.asc); @@ -2584,6 +2760,18 @@ extension ExifInfoQuerySortThenBy }); } + QueryBuilder thenByOrientation() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'orientation', Sort.asc); + }); + } + + QueryBuilder thenByOrientationDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'orientation', Sort.desc); + }); + } + QueryBuilder thenByState() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'state', Sort.asc); @@ -2701,6 +2889,13 @@ extension ExifInfoQueryWhereDistinct }); } + QueryBuilder distinctByOrientation( + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'orientation', caseSensitive: caseSensitive); + }); + } + QueryBuilder distinctByState( {bool caseSensitive = true}) { return QueryBuilder.apply(this, (query) { @@ -2809,6 +3004,12 @@ extension ExifInfoQueryProperty }); } + QueryBuilder orientationProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'orientation'); + }); + } + QueryBuilder stateProperty() { return QueryBuilder.apply(this, (query) { return query.addPropertyName(r'state'); diff --git a/mobile/lib/extensions/scroll_extensions.dart b/mobile/lib/extensions/scroll_extensions.dart new file mode 100644 index 0000000000000..5bbd73163a6b8 --- /dev/null +++ b/mobile/lib/extensions/scroll_extensions.dart @@ -0,0 +1,38 @@ +import 'package:flutter/cupertino.dart'; + +// 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 => const SpringDescription( + mass: 40, + stiffness: 100, + damping: 1, + ); +} + +class FastClampingScrollPhysics extends ClampingScrollPhysics { + const FastClampingScrollPhysics({super.parent}); + + @override + FastClampingScrollPhysics applyTo(ScrollPhysics? ancestor) { + return FastClampingScrollPhysics(parent: buildParent(ancestor)); + } + + @override + SpringDescription get spring => const SpringDescription( + // When swiping between videos on Android, the placeholder of the first opened video + // can briefly be seen and cause a flicker effect if the video begins to initialize + // before the animation finishes - probably a bug in PhotoViewGallery's animation handling + // Making the animation faster is not just stylistic, but also helps to avoid this flicker + mass: 80, + stiffness: 100, + damping: 1, + ); +} diff --git a/mobile/lib/pages/common/gallery_stacked_children.dart b/mobile/lib/pages/common/gallery_stacked_children.dart new file mode 100644 index 0000000000000..eafc3250494f9 --- /dev/null +++ b/mobile/lib/pages/common/gallery_stacked_children.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/providers/asset_viewer/asset_stack.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart'; +import 'package:immich_mobile/providers/image/immich_remote_image_provider.dart'; + +class GalleryStackedChildren extends HookConsumerWidget { + final ValueNotifier stackIndex; + + const GalleryStackedChildren(this.stackIndex, {super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final asset = ref.watch(currentAssetProvider); + if (asset == null) { + return const SizedBox(); + } + + final stackId = asset.stackId; + if (stackId == null) { + return const SizedBox(); + } + + final stackElements = ref.watch(assetStackStateProvider(stackId)); + final showControls = ref.watch(showControlsProvider); + + return IgnorePointer( + ignoring: !showControls, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 100), + opacity: showControls ? 1.0 : 0.0, + child: SizedBox( + height: 80, + child: ListView.builder( + shrinkWrap: true, + scrollDirection: Axis.horizontal, + itemCount: stackElements.length, + padding: const EdgeInsets.only( + left: 5, + right: 5, + bottom: 30, + ), + itemBuilder: (context, index) { + final currentAsset = stackElements.elementAt(index); + final assetId = currentAsset.remoteId; + if (assetId == null) { + return const SizedBox(); + } + + return Padding( + key: ValueKey(currentAsset.id), + padding: const EdgeInsets.only(right: 5), + child: GestureDetector( + onTap: () { + stackIndex.value = index; + ref.read(currentAssetProvider.notifier).set(currentAsset); + }, + child: Container( + width: 60, + height: 60, + decoration: index == stackIndex.value + ? const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.all(Radius.circular(6)), + border: Border.fromBorderSide( + BorderSide(color: Colors.white, width: 2), + ), + ) + : const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.all(Radius.circular(6)), + border: null, + ), + child: ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(4)), + child: Image( + fit: BoxFit.cover, + image: ImmichRemoteImageProvider(assetId: assetId), + ), + ), + ), + ), + ); + }, + ), + ), + ), + ); + } +} diff --git a/mobile/lib/pages/common/gallery_viewer.page.dart b/mobile/lib/pages/common/gallery_viewer.page.dart index 5747332587a91..2ea446ea71cfb 100644 --- a/mobile/lib/pages/common/gallery_viewer.page.dart +++ b/mobile/lib/pages/common/gallery_viewer.page.dart @@ -8,18 +8,19 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart' hide Store; 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/video_viewer.page.dart'; +import 'package:immich_mobile/pages/common/native_video_viewer.page.dart'; +import 'package:immich_mobile/pages/common/gallery_stacked_children.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_stack.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; -import 'package:immich_mobile/providers/image/immich_remote_image_provider.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; import 'package:immich_mobile/widgets/asset_viewer/advanced_bottom_sheet.dart'; @@ -35,6 +36,7 @@ import 'package:immich_mobile/widgets/photo_view/src/utils/photo_view_hero_attri @RoutePage() // ignore: must_be_immutable +/// Expects [currentAssetProvider] to be set before navigating to this page class GalleryViewerPage extends HookConsumerWidget { final int initialIndex; final int heroOffset; @@ -53,79 +55,66 @@ 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(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 stack = showStack && currentAsset.stackCount > 0 - ? ref.watch(assetStackStateProvider(currentAsset)) - : []; - final stackElements = showStack ? [currentAsset, ...stack] : []; - // Assets from response DTOs do not have an isar id, querying which would give us the default autoIncrement id - final isFromDto = currentAsset.id == noDbId; - - Asset asset = stackIndex.value == -1 - ? 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(AppSettingsEnum.loopVideo); - return null; - }, - [], - ); + final stackIndex = useState(0); + final localPosition = useRef(null); + final currentIndex = useValueNotifier(initialIndex); + final loadAsset = renderList.loadAsset; Future precacheNextImage(int index) async { + if (!context.mounted) { + return; + } + void onError(Object exception, StackTrace? stackTrace) { // swallow error silently - debugPrint('Error precaching next image: $exception, $stackTrace'); + log.severe('Error precaching next image: $exception, $stackTrace'); } try { if (index < totalAssets.value && index >= 0) { final asset = loadAsset(index); await precacheImage( - ImmichImage.imageProvider(asset: asset), + ImmichImage.imageProvider( + asset: asset, + width: context.width, + height: context.height, + ), context, onError: onError, ); } } catch (e) { // swallow error silently - debugPrint('Error precaching next image: $e'); + log.severe('Error precaching next image: $e'); context.maybePop(); } } + useEffect( + () { + 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; + }, + const [], + ); + void showInfo() { + final asset = ref.read(currentAssetProvider); + if (asset == null) { + return; + } showModalBottomSheet( shape: const RoundedRectangleBorder( borderRadius: BorderRadius.all(Radius.circular(15.0)), @@ -183,86 +172,100 @@ 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) { + if (show || Platform.isIOS) { SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); - } else { - SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive); + return; } + + // This prevents the bottom bar from "dropping" while the controls are being hidden + Timer(const Duration(milliseconds: 100), () { + SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive); + }); }); - Widget buildStackedChildren() { - return ListView.builder( - shrinkWrap: true, - scrollDirection: Axis.horizontal, - itemCount: stackElements.length, - padding: const EdgeInsets.only( - left: 5, - right: 5, - bottom: 30, + 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: asset.isMotionPhoto + ? (_, __, ___) { + ref.read(isPlayingMotionVideoProvider.notifier).playing = true; + } + : null, + imageProvider: ImmichImage.imageProvider(asset: asset), + heroAttributes: _getHeroAttributes(asset), + filterQuality: FilterQuality.high, + tightMode: true, + minScale: PhotoViewComputedScale.contained, + errorBuilder: (context, error, stackTrace) => ImmichImage( + asset, + fit: BoxFit.contain, ), - itemBuilder: (context, index) { - final assetId = stackElements.elementAt(index).remoteId; - return Padding( - padding: const EdgeInsets.only(right: 5), - child: GestureDetector( - onTap: () => stackIndex.value = index, - child: Container( - width: 60, - height: 60, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(6), - border: (stackIndex.value == -1 && index == 0) || - index == stackIndex.value - ? Border.all( - color: Colors.white, - width: 2, - ) - : null, - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(4), - child: Image( - fit: BoxFit.cover, - image: ImmichRemoteImageProvider(assetId: assetId!), - ), - ), + ); + } + + PhotoViewGalleryPageOptions buildVideo(BuildContext context, Asset asset) { + // This key is to prevent the video player from being re-initialized during the hero animation + final key = GlobalKey(); + return PhotoViewGalleryPageOptions.customChild( + onDragStart: (_, details, __) => + localPosition.value = details.localPosition, + onDragUpdate: (_, details, __) => handleSwipeUpDown(details), + heroAttributes: _getHeroAttributes(asset), + filterQuality: FilterQuality.high, + initialScale: 1.0, + maxScale: 1.0, + minScale: 1.0, + basePosition: Alignment.center, + child: SizedBox( + width: context.width, + height: context.height, + child: NativeVideoViewerPage( + key: key, + asset: asset, + image: Image( + key: ValueKey(asset), + image: ImmichImage.imageProvider( + asset: asset, + width: context.width, + height: context.height, ), + fit: BoxFit.contain, + height: context.height, + width: context.width, + alignment: Alignment.center, ), - ); - }, + ), + ), ); } + PhotoViewGalleryPageOptions buildAsset(BuildContext context, int index) { + ref.read(isPlayingMotionVideoProvider.notifier).playing = false; + var newAsset = loadAsset(index); + final stackId = newAsset.stackId; + if (stackId != null && currentIndex.value == index) { + final stackElements = + ref.read(assetStackStateProvider(newAsset.stackId!)); + if (stackIndex.value < stackElements.length) { + newAsset = stackElements.elementAt(stackIndex.value); + } + } + + if (newAsset.isImage && !newAsset.isMotionPhoto) { + return buildImage(context, newAsset); + } + return buildVideo(context, newAsset); + } + return PopScope( // Change immersive mode back to normal "edgeToEdge" mode onPopInvokedWithResult: (didPop, _) => @@ -272,128 +275,79 @@ class GalleryViewerPage extends HookConsumerWidget { body: Stack( children: [ PhotoViewGallery.builder( + key: const ValueKey('gallery'), scaleStateChangedCallback: (state) { - isZoomed.value = state != PhotoViewScaleState.initial; - ref.read(showControlsProvider.notifier).show = !isZoomed.value; + final asset = ref.read(currentAssetProvider); + if (asset == null) { + return; + } + + if (asset.isImage && !ref.read(isPlayingMotionVideoProvider)) { + isZoomed.value = state != PhotoViewScaleState.initial; + ref.read(showControlsProvider.notifier).show = + !isZoomed.value; + } }, - loadingBuilder: (context, event, index) => ClipRect( - child: Stack( - fit: StackFit.expand, - children: [ - BackdropFilter( - filter: ui.ImageFilter.blur( - sigmaX: 10, - sigmaY: 10, + gaplessPlayback: true, + loadingBuilder: (context, event, index) { + final asset = loadAsset(index); + return ClipRect( + child: Stack( + fit: StackFit.expand, + children: [ + BackdropFilter( + filter: ui.ImageFilter.blur( + sigmaX: 10, + sigmaY: 10, + ), ), - ), - ImmichThumbnail( - asset: asset, - fit: BoxFit.contain, - ), - ], - ), - ), + ImmichThumbnail( + key: ValueKey(asset), + asset: asset, + fit: BoxFit.contain, + ), + ], + ), + ); + }, pageController: controller, 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) { final next = currentIndex.value < value ? value + 1 : value - 1; ref.read(hapticFeedbackProvider.notifier).selectionClick(); - currentIndex.value = value; - stackIndex.value = -1; - isPlayingVideo.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 newAsset = loadAsset(value); - final ImageProvider provider = - ImmichImage.imageProvider(asset: a); + currentIndex.value = value; + stackIndex.value = 0; - 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: VideoViewerPage( - 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, - ), - ), - ); + ref.read(currentAssetProvider.notifier).set(newAsset); + if (newAsset.isVideo || newAsset.isMotionPhoto) { + ref.read(videoPlaybackValueProvider.notifier).reset(); } + + // Wait for page change animation to finish, then precache the next image + Timer(const Duration(milliseconds: 400), () { + precacheNextImage(next); + }); }, + builder: buildAsset, ), Positioned( top: 0, left: 0, right: 0, child: GalleryAppBar( - asset: asset, + key: const ValueKey('app-bar'), showInfo: showInfo, - isPlayingVideo: isPlayingVideo.value, - onToggleMotionVideo: () => - isPlayingVideo.value = !isPlayingVideo.value, ), ), Positioned( @@ -402,22 +356,15 @@ class GalleryViewerPage extends HookConsumerWidget { right: 0, child: Column( children: [ - Visibility( - visible: stack.isNotEmpty, - child: SizedBox( - height: 80, - child: buildStackedChildren(), - ), - ), + GalleryStackedChildren(stackIndex), BottomGalleryBar( + key: const ValueKey('bottom-bar'), renderList: renderList, totalAssets: totalAssets, controller: controller, showStack: showStack, - stackIndex: stackIndex.value, - asset: asset, + stackIndex: stackIndex, assetIndex: currentIndex, - showVideoPlayerControls: !asset.isImage && !isMotionPhoto, ), ], ), @@ -428,4 +375,14 @@ class GalleryViewerPage extends HookConsumerWidget { ), ); } + + @pragma('vm:prefer-inline') + PhotoViewHeroAttributes _getHeroAttributes(Asset asset) { + return PhotoViewHeroAttributes( + tag: asset.isInDb + ? asset.id + heroOffset + : '${asset.remoteId}-$heroOffset', + transitionOnUserGestures: true, + ); + } } diff --git a/mobile/lib/pages/common/native_video_viewer.page.dart b/mobile/lib/pages/common/native_video_viewer.page.dart new file mode 100644 index 0000000000000..536c7f6303c6a --- /dev/null +++ b/mobile/lib/pages/common/native_video_viewer.page.dart @@ -0,0 +1,411 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +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/app_settings.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/video_player_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/debounce.dart'; +import 'package:immich_mobile/utils/hooks/interval_hook.dart'; +import 'package:immich_mobile/widgets/asset_viewer/custom_video_player_controls.dart'; +import 'package:logging/logging.dart'; +import 'package:native_video_player/native_video_player.dart'; +import 'package:wakelock_plus/wakelock_plus.dart'; + +@RoutePage() +class NativeVideoViewerPage extends HookConsumerWidget { + final Asset asset; + final bool showControls; + final Widget image; + + const NativeVideoViewerPage({ + super.key, + required this.asset, + required this.image, + this.showControls = true, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final controller = useState(null); + final lastVideoPosition = useRef(-1); + final isBuffering = useRef(false); + final showMotionVideo = useState(false); + + // When a video is opened through the timeline, `isCurrent` will immediately be true. + // When swiping from video A to video B, `isCurrent` will initially be true for video A and false for video B. + // If the swipe is completed, `isCurrent` will be true for video B after a delay. + // If the swipe is canceled, `currentAsset` will not have changed and video A will continue to play. + final currentAsset = useState(ref.read(currentAssetProvider)); + final isCurrent = currentAsset.value == asset; + + // Used to show the placeholder during hero animations for remote videos to avoid a stutter + final isVisible = + useState((Platform.isIOS && asset.isLocal) || asset.isMotionPhoto); + + final log = Logger('NativeVideoViewerPage'); + + ref.listen(isPlayingMotionVideoProvider, (_, value) async { + final videoController = controller.value; + if (!asset.isMotionPhoto || videoController == null || !context.mounted) { + return; + } + + showMotionVideo.value = value; + try { + if (value) { + await videoController.seekTo(0); + await videoController.play(); + } else { + await videoController.pause(); + } + } catch (error) { + log.severe('Error toggling motion video: $error'); + } + }); + + Future createSource() async { + if (!context.mounted) { + return null; + } + + try { + final local = asset.local; + if (local != null && !asset.isMotionPhoto) { + final file = await local.file; + if (file == null) { + throw Exception('No file found for the video'); + } + + final source = await VideoSource.init( + path: file.path, + type: VideoSourceType.file, + ); + return source; + } + + // 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(), + ); + return source; + } catch (error) { + log.severe( + 'Error creating video source for asset ${asset.fileName}: $error', + ); + return null; + } + } + + final videoSource = useMemoized>(() => createSource()); + final aspectRatio = useState(asset.aspectRatio); + useMemoized( + () async { + if (!context.mounted || aspectRatio.value != null) { + return null; + } + + try { + aspectRatio.value = + await ref.read(assetServiceProvider).getAspectRatio(asset); + } catch (error) { + log.severe( + 'Error getting aspect ratio for asset ${asset.fileName}: $error', + ); + } + }, + ); + + void checkIfBuffering() { + if (!context.mounted) { + return; + } + + final videoPlayback = ref.read(videoPlaybackValueProvider); + if ((isBuffering.value || + videoPlayback.state == VideoPlaybackState.initializing) && + videoPlayback.state != VideoPlaybackState.buffering) { + ref.read(videoPlaybackValueProvider.notifier).value = + videoPlayback.copyWith(state: VideoPlaybackState.buffering); + } + } + + // Timer to mark videos as buffering if the position does not change + useInterval(const Duration(seconds: 5), checkIfBuffering); + + // When the position changes, seek to the position + // Debounce the seek to avoid seeking too often + // But also don't delay the seek too much to maintain visual feedback + final seekDebouncer = useDebouncer( + interval: const Duration(milliseconds: 100), + maxWaitTime: const Duration(milliseconds: 200), + ); + ref.listen(videoPlayerControlsProvider, (oldControls, newControls) async { + final playerController = controller.value; + if (playerController == null) { + return; + } + + final playbackInfo = playerController.playbackInfo; + if (playbackInfo == null) { + return; + } + + final oldSeek = (oldControls?.position ?? 0) ~/ 1; + final newSeek = newControls.position ~/ 1; + if (oldSeek != newSeek || newControls.restarted) { + seekDebouncer.run(() => playerController.seekTo(newSeek)); + } + + if (oldControls?.pause != newControls.pause || newControls.restarted) { + // Make sure the last seek is complete before pausing or playing + // Otherwise, `onPlaybackPositionChanged` can receive outdated events + if (seekDebouncer.isActive) { + await seekDebouncer.drain(); + } + + try { + if (newControls.pause) { + await playerController.pause(); + } else { + await playerController.play(); + } + } catch (error) { + log.severe('Error pausing or playing video: $error'); + } + } + }); + + void onPlaybackReady() async { + final videoController = controller.value; + if (videoController == null || !isCurrent || !context.mounted) { + return; + } + + final videoPlayback = + VideoPlaybackValue.fromNativeController(videoController); + ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback; + + try { + if (asset.isVideo || showMotionVideo.value) { + await videoController.play(); + } + await videoController.setVolume(0.9); + } catch (error) { + log.severe('Error playing video: $error'); + } + } + + void onPlaybackStatusChanged() { + final videoController = controller.value; + if (videoController == null || !context.mounted) { + return; + } + + final videoPlayback = + VideoPlaybackValue.fromNativeController(videoController); + if (videoPlayback.state == VideoPlaybackState.playing) { + // Sync with the controls playing + WakelockPlus.enable(); + } else { + // Sync with the controls pause + WakelockPlus.disable(); + } + + ref.read(videoPlaybackValueProvider.notifier).status = + videoPlayback.state; + } + + void onPlaybackPositionChanged() { + // When seeking, these events sometimes move the slider to an older position + if (seekDebouncer.isActive) { + return; + } + + 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 (showMotionVideo.value && + videoController.playbackInfo?.status == PlaybackStatus.stopped && + !ref + .read(appSettingsServiceProvider) + .getSetting(AppSettingsEnum.loopVideo)) { + ref.read(isPlayingMotionVideoProvider.notifier).playing = false; + } + } + + void removeListeners(NativeVideoPlayerController controller) { + controller.onPlaybackPositionChanged + .removeListener(onPlaybackPositionChanged); + controller.onPlaybackStatusChanged + .removeListener(onPlaybackStatusChanged); + controller.onPlaybackReady.removeListener(onPlaybackReady); + controller.onPlaybackEnded.removeListener(onPlaybackEnded); + } + + void initController(NativeVideoPlayerController nc) async { + if (controller.value != null || !context.mounted) { + return; + } + ref.read(videoPlayerControlsProvider.notifier).reset(); + ref.read(videoPlaybackValueProvider.notifier).reset(); + + final source = await videoSource; + if (source == null) { + return; + } + + nc.onPlaybackPositionChanged.addListener(onPlaybackPositionChanged); + nc.onPlaybackStatusChanged.addListener(onPlaybackStatusChanged); + nc.onPlaybackReady.addListener(onPlaybackReady); + nc.onPlaybackEnded.addListener(onPlaybackEnded); + + nc.loadVideoSource(source).catchError((error) { + log.severe('Error loading video source: $error'); + }); + final loopVideo = ref + .read(appSettingsServiceProvider) + .getSetting(AppSettingsEnum.loopVideo); + nc.setLoop(loopVideo); + + controller.value = nc; + Timer(const Duration(milliseconds: 200), checkIfBuffering); + } + + ref.listen(currentAssetProvider, (_, value) { + final playerController = controller.value; + if (playerController != null && value != asset) { + removeListeners(playerController); + } + + final curAsset = currentAsset.value; + if (curAsset == asset) { + return; + } + + final imageToVideo = curAsset != null && !curAsset.isVideo; + + // No need to delay video playback when swiping from an image to a video + if (imageToVideo && Platform.isIOS) { + currentAsset.value = value; + onPlaybackReady(); + return; + } + + // Delay the video playback to avoid a stutter in the swipe animation + Timer( + Platform.isIOS + ? const Duration(milliseconds: 300) + : imageToVideo + ? const Duration(milliseconds: 200) + : const Duration(milliseconds: 400), () { + if (!context.mounted) { + return; + } + + currentAsset.value = value; + if (currentAsset.value == asset) { + onPlaybackReady(); + } + }); + }); + + useEffect( + () { + // If opening a remote video from a hero animation, delay visibility to avoid a stutter + final timer = isVisible.value + ? null + : Timer( + const Duration(milliseconds: 300), + () => isVisible.value = true, + ); + + return () { + timer?.cancel(); + final playerController = controller.value; + if (playerController == null) { + return; + } + removeListeners(playerController); + playerController.stop().catchError((error) { + log.fine('Error stopping video: $error'); + }); + + WakelockPlus.disable(); + }; + }, + const [], + ); + + return Stack( + children: [ + // This remains under the video to avoid flickering + // For motion videos, this is the image portion of the asset + Center(key: ValueKey(asset.id), child: image), + if (aspectRatio.value != null) + Visibility.maintain( + key: ValueKey(asset), + visible: + (asset.isVideo || showMotionVideo.value) && isVisible.value, + child: Center( + key: ValueKey(asset), + child: AspectRatio( + key: ValueKey(asset), + aspectRatio: aspectRatio.value!, + child: isCurrent + ? NativeVideoPlayerView( + key: ValueKey(asset), + onViewReady: initController, + ) + : null, + ), + ), + ), + if (showControls) const Center(child: CustomVideoPlayerControls()), + ], + ); + } +} diff --git a/mobile/lib/pages/common/video_viewer.page.dart b/mobile/lib/pages/common/video_viewer.page.dart deleted file mode 100644 index 774d4eb31ec6e..0000000000000 --- a/mobile/lib/pages/common/video_viewer.page.dart +++ /dev/null @@ -1,167 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart'; -import 'package:immich_mobile/providers/asset_viewer/video_player_controller_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/widgets/asset_viewer/video_player.dart'; -import 'package:immich_mobile/widgets/common/delayed_loading_indicator.dart'; -import 'package:wakelock_plus/wakelock_plus.dart'; - -class VideoViewerPage extends HookConsumerWidget { - final Asset asset; - final bool isMotionVideo; - final Widget? placeholder; - final Duration hideControlsTimer; - final bool showControls; - final bool showDownloadingIndicator; - final bool loopVideo; - - const VideoViewerPage({ - super.key, - required this.asset, - this.isMotionVideo = false, - this.placeholder, - this.showControls = true, - this.hideControlsTimer = const Duration(seconds: 5), - this.showDownloadingIndicator = true, - this.loopVideo = false, - }); - - @override - build(BuildContext context, WidgetRef ref) { - final controller = - ref.watch(videoPlayerControllerProvider(asset: asset)).value; - // The last volume of the video used when mute is toggled - final lastVolume = useState(0.5); - - // When the volume changes, set the volume - ref.listen(videoPlayerControlsProvider.select((value) => value.mute), - (_, mute) { - if (mute) { - controller?.setVolume(0.0); - } else { - controller?.setVolume(lastVolume.value); - } - }); - - // When the position changes, seek to the position - ref.listen(videoPlayerControlsProvider.select((value) => value.position), - (_, position) { - if (controller == null) { - // No seeeking if there is no video - return; - } - - // Find the position to seek to - final Duration seek = controller.value.duration * (position / 100.0); - controller.seekTo(seek); - }); - - // When the custom video controls paus or plays - ref.listen(videoPlayerControlsProvider.select((value) => value.pause), - (lastPause, pause) { - if (pause) { - controller?.pause(); - } else { - controller?.play(); - } - }); - - // Updates the [videoPlaybackValueProvider] with the current - // position and duration of the video from the Chewie [controller] - // Also sets the error if there is an error in the playback - void updateVideoPlayback() { - final videoPlayback = VideoPlaybackValue.fromController(controller); - ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback; - final state = videoPlayback.state; - - // Enable the WakeLock while the video is playing - if (state == VideoPlaybackState.playing) { - // Sync with the controls playing - WakelockPlus.enable(); - } else { - // Sync with the controls pause - WakelockPlus.disable(); - } - } - - // Adds and removes the listener to the video player - useEffect( - () { - Future.microtask( - () => ref.read(videoPlayerControlsProvider.notifier).reset(), - ); - // Guard no controller - if (controller == null) { - return null; - } - - // Hide the controls - // Done in a microtask to avoid setting the state while the is building - if (!isMotionVideo) { - Future.microtask(() { - ref.read(showControlsProvider.notifier).show = false; - }); - } - - // Subscribes to listener - Future.microtask(() { - controller.addListener(updateVideoPlayback); - }); - return () { - // Removes listener when we dispose - controller.removeListener(updateVideoPlayback); - controller.pause(); - }; - }, - [controller], - ); - - return PopScope( - onPopInvokedWithResult: (didPop, _) { - ref.read(videoPlaybackValueProvider.notifier).value = - VideoPlaybackValue.uninitialized(); - }, - child: AnimatedSwitcher( - duration: const Duration(milliseconds: 400), - child: Stack( - children: [ - Visibility( - visible: controller == null, - child: Stack( - children: [ - if (placeholder != null) placeholder!, - const Positioned.fill( - child: Center( - child: DelayedLoadingIndicator( - fadeInDuration: Duration(milliseconds: 500), - ), - ), - ), - ], - ), - ), - if (controller != null) - SizedBox( - height: context.height, - width: context.width, - child: VideoPlayerViewer( - controller: controller, - isMotionVideo: isMotionVideo, - placeholder: placeholder, - hideControlsTimer: hideControlsTimer, - showControls: showControls, - showDownloadingIndicator: showDownloadingIndicator, - loopVideo: loopVideo, - ), - ), - ], - ), - ), - ); - } -} diff --git a/mobile/lib/pages/photos/memory.page.dart b/mobile/lib/pages/photos/memory.page.dart index 3f86f5be082c0..74a94ed6ee084 100644 --- a/mobile/lib/pages/photos/memory.page.dart +++ b/mobile/lib/pages/photos/memory.page.dart @@ -113,11 +113,15 @@ class MemoryPage extends HookConsumerWidget { } // Precache the asset + final size = MediaQuery.sizeOf(context); await precacheImage( ImmichImage.imageProvider( asset: asset, + width: size.width, + height: size.height, ), context, + size: size, ); } diff --git a/mobile/lib/pages/search/map/map.page.dart b/mobile/lib/pages/search/map/map.page.dart index 8000c7e339282..10fe8de541506 100644 --- a/mobile/lib/pages/search/map/map.page.dart +++ b/mobile/lib/pages/search/map/map.page.dart @@ -15,6 +15,8 @@ import 'package:immich_mobile/extensions/latlngbounds_extension.dart'; import 'package:immich_mobile/extensions/maplibrecontroller_extensions.dart'; import 'package:immich_mobile/models/map/map_event.model.dart'; import 'package:immich_mobile/models/map/map_marker.model.dart'; +import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart'; import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/providers/map/map_marker.provider.dart'; import 'package:immich_mobile/providers/map/map_state.provider.dart'; @@ -99,8 +101,11 @@ class MapPage extends HookConsumerWidget { useEffect( () { + final currentAssetLink = + ref.read(currentAssetProvider.notifier).ref.keepAlive(); + loadMarkers(); - return null; + return currentAssetLink.close; }, [], ); @@ -186,6 +191,10 @@ class MapPage extends HookConsumerWidget { GroupAssetsBy.none, ); + ref.read(currentAssetProvider.notifier).set(asset); + if (asset.isVideo) { + ref.read(showControlsProvider.notifier).show = false; + } context.pushRoute( GalleryViewerRoute( initialIndex: 0, diff --git a/mobile/lib/providers/asset_viewer/asset_stack.provider.dart b/mobile/lib/providers/asset_viewer/asset_stack.provider.dart index c3e4414b3935a..407aef16109e1 100644 --- a/mobile/lib/providers/asset_viewer/asset_stack.provider.dart +++ b/mobile/lib/providers/asset_viewer/asset_stack.provider.dart @@ -7,49 +7,49 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'asset_stack.provider.g.dart'; class AssetStackNotifier extends StateNotifier> { - final Asset _asset; + final String _stackId; final Ref _ref; - AssetStackNotifier( - this._asset, - this._ref, - ) : super([]) { - fetchStackChildren(); + AssetStackNotifier(this._stackId, this._ref) : super([]) { + _fetchStack(_stackId); } - void fetchStackChildren() async { - if (mounted) { - state = await _ref.read(assetStackProvider(_asset).future); + void _fetchStack(String stackId) async { + if (!mounted) { + return; + } + + final stack = await _ref.read(assetStackProvider(stackId).future); + if (stack.isNotEmpty) { + state = stack; } } void removeChild(int index) { if (index < state.length) { state.removeAt(index); + state = List.from(state); } } } final assetStackStateProvider = StateNotifierProvider.autoDispose - .family, Asset>( - (ref, asset) => AssetStackNotifier(asset, ref), + .family, String>( + (ref, stackId) => AssetStackNotifier(stackId, ref), ); final assetStackProvider = - FutureProvider.autoDispose.family, Asset>((ref, asset) async { - // Guard [local asset] - if (asset.remoteId == null) { - return []; - } - - return await ref + FutureProvider.autoDispose.family, String>((ref, stackId) { + return ref .watch(dbProvider) .assets .filter() .isArchivedEqualTo(false) .isTrashedEqualTo(false) - .stackPrimaryAssetIdEqualTo(asset.remoteId) - .sortByFileCreatedAtDesc() + .stackIdEqualTo(stackId) + // orders primary asset first as its ID is null + .sortByStackPrimaryAssetId() + .thenByFileCreatedAtDesc() .findAll(); }); diff --git a/mobile/lib/providers/asset_viewer/is_motion_video_playing.provider.dart b/mobile/lib/providers/asset_viewer/is_motion_video_playing.provider.dart new file mode 100644 index 0000000000000..4af061f9548c1 --- /dev/null +++ b/mobile/lib/providers/asset_viewer/is_motion_video_playing.provider.dart @@ -0,0 +1,23 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +/// Whether to display the video part of a motion photo +final isPlayingMotionVideoProvider = + StateNotifierProvider((ref) { + return IsPlayingMotionVideo(ref); +}); + +class IsPlayingMotionVideo extends StateNotifier { + IsPlayingMotionVideo(this.ref) : super(false); + + final Ref ref; + + bool get playing => state; + + set playing(bool value) { + state = value; + } + + void toggle() { + state = !state; + } +} diff --git a/mobile/lib/providers/asset_viewer/video_player_controller_provider.dart b/mobile/lib/providers/asset_viewer/video_player_controller_provider.dart deleted file mode 100644 index 969e181cbb0ad..0000000000000 --- a/mobile/lib/providers/asset_viewer/video_player_controller_provider.dart +++ /dev/null @@ -1,46 +0,0 @@ -import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; -import 'package:immich_mobile/services/api.service.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; -import 'package:video_player/video_player.dart'; - -part 'video_player_controller_provider.g.dart'; - -@riverpod -Future videoPlayerController( - VideoPlayerControllerRef ref, { - required Asset asset, -}) async { - late VideoPlayerController controller; - if (asset.isLocal && asset.livePhotoVideoId == null) { - // Use a local file for the video player controller - final file = await asset.local!.file; - if (file == null) { - throw Exception('No file found for the video'); - } - controller = VideoPlayerController.file(file); - } else { - // 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 url = Uri.parse(videoUrl); - controller = VideoPlayerController.networkUrl( - url, - httpHeaders: ApiService.getRequestHeaders(), - videoPlayerOptions: asset.livePhotoVideoId != null - ? VideoPlayerOptions(mixWithOthers: true) - : VideoPlayerOptions(mixWithOthers: false), - ); - } - - await controller.initialize(); - - ref.onDispose(() { - controller.dispose(); - }); - - return controller; -} diff --git a/mobile/lib/providers/asset_viewer/video_player_controller_provider.g.dart b/mobile/lib/providers/asset_viewer/video_player_controller_provider.g.dart deleted file mode 100644 index 00ad37648a85e..0000000000000 --- a/mobile/lib/providers/asset_viewer/video_player_controller_provider.g.dart +++ /dev/null @@ -1,164 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'video_player_controller_provider.dart'; - -// ************************************************************************** -// RiverpodGenerator -// ************************************************************************** - -String _$videoPlayerControllerHash() => - r'84b2961cc2aeaf9d03255dbf9b9484619d0c24f5'; - -/// Copied from Dart SDK -class _SystemHash { - _SystemHash._(); - - static int combine(int hash, int value) { - // ignore: parameter_assignments - hash = 0x1fffffff & (hash + value); - // ignore: parameter_assignments - hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); - return hash ^ (hash >> 6); - } - - static int finish(int hash) { - // ignore: parameter_assignments - hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); - // ignore: parameter_assignments - hash = hash ^ (hash >> 11); - return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); - } -} - -/// See also [videoPlayerController]. -@ProviderFor(videoPlayerController) -const videoPlayerControllerProvider = VideoPlayerControllerFamily(); - -/// See also [videoPlayerController]. -class VideoPlayerControllerFamily - extends Family> { - /// See also [videoPlayerController]. - const VideoPlayerControllerFamily(); - - /// See also [videoPlayerController]. - VideoPlayerControllerProvider call({ - required Asset asset, - }) { - return VideoPlayerControllerProvider( - asset: asset, - ); - } - - @override - VideoPlayerControllerProvider getProviderOverride( - covariant VideoPlayerControllerProvider provider, - ) { - return call( - asset: provider.asset, - ); - } - - static const Iterable? _dependencies = null; - - @override - Iterable? get dependencies => _dependencies; - - static const Iterable? _allTransitiveDependencies = null; - - @override - Iterable? get allTransitiveDependencies => - _allTransitiveDependencies; - - @override - String? get name => r'videoPlayerControllerProvider'; -} - -/// See also [videoPlayerController]. -class VideoPlayerControllerProvider - extends AutoDisposeFutureProvider { - /// See also [videoPlayerController]. - VideoPlayerControllerProvider({ - required Asset asset, - }) : this._internal( - (ref) => videoPlayerController( - ref as VideoPlayerControllerRef, - asset: asset, - ), - from: videoPlayerControllerProvider, - name: r'videoPlayerControllerProvider', - debugGetCreateSourceHash: - const bool.fromEnvironment('dart.vm.product') - ? null - : _$videoPlayerControllerHash, - dependencies: VideoPlayerControllerFamily._dependencies, - allTransitiveDependencies: - VideoPlayerControllerFamily._allTransitiveDependencies, - asset: asset, - ); - - VideoPlayerControllerProvider._internal( - super._createNotifier, { - required super.name, - required super.dependencies, - required super.allTransitiveDependencies, - required super.debugGetCreateSourceHash, - required super.from, - required this.asset, - }) : super.internal(); - - final Asset asset; - - @override - Override overrideWith( - FutureOr Function(VideoPlayerControllerRef provider) - create, - ) { - return ProviderOverride( - origin: this, - override: VideoPlayerControllerProvider._internal( - (ref) => create(ref as VideoPlayerControllerRef), - from: from, - name: null, - dependencies: null, - allTransitiveDependencies: null, - debugGetCreateSourceHash: null, - asset: asset, - ), - ); - } - - @override - AutoDisposeFutureProviderElement createElement() { - return _VideoPlayerControllerProviderElement(this); - } - - @override - bool operator ==(Object other) { - return other is VideoPlayerControllerProvider && other.asset == asset; - } - - @override - int get hashCode { - var hash = _SystemHash.combine(0, runtimeType.hashCode); - hash = _SystemHash.combine(hash, asset.hashCode); - - return _SystemHash.finish(hash); - } -} - -mixin VideoPlayerControllerRef - on AutoDisposeFutureProviderRef { - /// The parameter `asset` of this provider. - Asset get asset; -} - -class _VideoPlayerControllerProviderElement - extends AutoDisposeFutureProviderElement - with VideoPlayerControllerRef { - _VideoPlayerControllerProviderElement(super.provider); - - @override - Asset get asset => (origin as VideoPlayerControllerProvider).asset; -} -// ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/mobile/lib/providers/asset_viewer/video_player_controls_provider.dart b/mobile/lib/providers/asset_viewer/video_player_controls_provider.dart index d15b26ea20994..69be91480ffc2 100644 --- a/mobile/lib/providers/asset_viewer/video_player_controls_provider.dart +++ b/mobile/lib/providers/asset_viewer/video_player_controls_provider.dart @@ -1,15 +1,16 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; class VideoPlaybackControls { - VideoPlaybackControls({ + const VideoPlaybackControls({ required this.position, - required this.mute, required this.pause, + this.restarted = false, }); final double position; - final bool mute; final bool pause; + final bool restarted; } final videoPlayerControlsProvider = @@ -17,15 +18,11 @@ final videoPlayerControlsProvider = return VideoPlayerControls(ref); }); +const videoPlayerControlsDefault = + VideoPlaybackControls(position: 0, pause: false); + class VideoPlayerControls extends StateNotifier { - VideoPlayerControls(this.ref) - : super( - VideoPlaybackControls( - position: 0, - pause: false, - mute: false, - ), - ); + VideoPlayerControls(this.ref) : super(videoPlayerControlsDefault); final Ref ref; @@ -36,75 +33,48 @@ class VideoPlayerControls extends StateNotifier { } void reset() { - state = VideoPlaybackControls( - position: 0, - pause: false, - mute: false, - ); + state = videoPlayerControlsDefault; } double get position => state.position; - bool get mute => state.mute; + bool get paused => state.pause; set position(double value) { - state = VideoPlaybackControls( - position: value, - mute: state.mute, - pause: state.pause, - ); - } - - set mute(bool value) { - state = VideoPlaybackControls( - position: state.position, - mute: value, - pause: state.pause, - ); - } + if (state.position == value) { + return; + } - void toggleMute() { - state = VideoPlaybackControls( - position: state.position, - mute: !state.mute, - pause: state.pause, - ); + state = VideoPlaybackControls(position: value, pause: state.pause); } void pause() { - state = VideoPlaybackControls( - position: state.position, - mute: state.mute, - pause: true, - ); + if (state.pause) { + return; + } + + state = VideoPlaybackControls(position: state.position, pause: true); } void play() { - state = VideoPlaybackControls( - position: state.position, - mute: state.mute, - pause: false, - ); + if (!state.pause) { + return; + } + + state = VideoPlaybackControls(position: state.position, pause: false); } void togglePlay() { - state = VideoPlaybackControls( - position: state.position, - mute: state.mute, - pause: !state.pause, - ); + state = + VideoPlaybackControls(position: state.position, pause: !state.pause); } void restart() { - state = VideoPlaybackControls( - position: 0, - mute: state.mute, - pause: true, - ); - - state = VideoPlaybackControls( - position: 0, - mute: state.mute, - pause: false, - ); + state = + const VideoPlaybackControls(position: 0, pause: false, restarted: true); + ref.read(videoPlaybackValueProvider.notifier).value = + ref.read(videoPlaybackValueProvider.notifier).value.copyWith( + state: VideoPlaybackState.playing, + position: Duration.zero, + ); } } diff --git a/mobile/lib/providers/asset_viewer/video_player_value_provider.dart b/mobile/lib/providers/asset_viewer/video_player_value_provider.dart index ebdf739ef03de..1a3c54e9e9293 100644 --- a/mobile/lib/providers/asset_viewer/video_player_value_provider.dart +++ b/mobile/lib/providers/asset_viewer/video_player_value_provider.dart @@ -1,5 +1,5 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:video_player/video_player.dart'; +import 'package:native_video_player/native_video_player.dart'; enum VideoPlaybackState { initializing, @@ -22,56 +22,66 @@ class VideoPlaybackValue { /// The volume of the video final double volume; - VideoPlaybackValue({ + const VideoPlaybackValue({ required this.position, required this.duration, required this.state, required this.volume, }); - factory VideoPlaybackValue.fromController(VideoPlayerController? controller) { - final video = controller?.value; - late VideoPlaybackState s; - if (video == null) { - s = VideoPlaybackState.initializing; - } else if (video.isCompleted) { - s = VideoPlaybackState.completed; - } else if (video.isPlaying) { - s = VideoPlaybackState.playing; - } else if (video.isBuffering) { - s = VideoPlaybackState.buffering; - } else { - s = VideoPlaybackState.paused; + factory VideoPlaybackValue.fromNativeController( + NativeVideoPlayerController controller, + ) { + 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: video?.position ?? Duration.zero, - duration: video?.duration ?? Duration.zero, - state: s, - volume: video?.volume ?? 0.0, + position: Duration(seconds: playbackInfo.position), + duration: Duration(seconds: videoInfo.duration), + state: status, + volume: playbackInfo.volume, ); } - factory VideoPlaybackValue.uninitialized() { + VideoPlaybackValue copyWith({ + Duration? position, + Duration? duration, + VideoPlaybackState? state, + double? volume, + }) { return VideoPlaybackValue( - position: Duration.zero, - duration: Duration.zero, - state: VideoPlaybackState.initializing, - volume: 0.0, + position: position ?? this.position, + duration: duration ?? this.duration, + state: state ?? this.state, + volume: volume ?? this.volume, ); } } +const VideoPlaybackValue videoPlaybackValueDefault = VideoPlaybackValue( + position: Duration.zero, + duration: Duration.zero, + state: VideoPlaybackState.initializing, + volume: 0.0, +); + final videoPlaybackValueProvider = StateNotifierProvider((ref) { return VideoPlaybackValueState(ref); }); class VideoPlaybackValueState extends StateNotifier { - VideoPlaybackValueState(this.ref) - : super( - VideoPlaybackValue.uninitialized(), - ); + VideoPlaybackValueState(this.ref) : super(videoPlaybackValueDefault); final Ref ref; @@ -82,6 +92,7 @@ class VideoPlaybackValueState extends StateNotifier { } set position(Duration value) { + if (state.position == value) return; state = VideoPlaybackValue( position: value, duration: state.duration, @@ -89,4 +100,18 @@ class VideoPlaybackValueState extends StateNotifier { volume: state.volume, ); } + + set status(VideoPlaybackState value) { + if (state.state == value) return; + state = VideoPlaybackValue( + position: state.position, + duration: state.duration, + state: value, + volume: state.volume, + ); + } + + void reset() { + state = videoPlaybackValueDefault; + } } diff --git a/mobile/lib/providers/image/immich_local_image_provider.dart b/mobile/lib/providers/image/immich_local_image_provider.dart index bbfaf12a4f445..36fd3334b9442 100644 --- a/mobile/lib/providers/image/immich_local_image_provider.dart +++ b/mobile/lib/providers/image/immich_local_image_provider.dart @@ -7,14 +7,21 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/painting.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:logging/logging.dart'; import 'package:photo_manager/photo_manager.dart' show ThumbnailSize; /// The local image provider for an asset class ImmichLocalImageProvider extends ImageProvider { final Asset asset; + // only used for videos + final double width; + final double height; + final Logger log = Logger('ImmichLocalImageProvider'); ImmichLocalImageProvider({ required this.asset, + required this.width, + required this.height, }) : assert(asset.local != null, 'Only usable when asset.local is set'); /// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key @@ -42,38 +49,57 @@ class ImmichLocalImageProvider extends ImageProvider { // Streams in each stage of the image as we ask for it Stream _codec( - Asset key, + Asset asset, ImageDecoderCallback decode, StreamController chunkEvents, ) async* { - // Load a small thumbnail - final thumbBytes = await asset.local?.thumbnailDataWithSize( - const ThumbnailSize.square(256), - quality: 80, - ); - if (thumbBytes != null) { - final buffer = await ui.ImmutableBuffer.fromUint8List(thumbBytes); - final codec = await decode(buffer); - yield codec; - } else { - debugPrint("Loading thumb for ${asset.fileName} failed"); - } + ui.ImmutableBuffer? buffer; + try { + final local = asset.local; + if (local == null) { + throw StateError('Asset ${asset.fileName} has no local data'); + } - if (asset.isImage) { - final File? file = await asset.local?.originFile; - if (file == null) { - throw StateError("Opening file for asset ${asset.fileName} failed"); + var thumbBytes = await local + .thumbnailDataWithSize(const ThumbnailSize.square(256), quality: 80); + if (thumbBytes == null) { + throw StateError("Loading thumbnail for ${asset.fileName} failed"); } - try { - final buffer = await ui.ImmutableBuffer.fromFilePath(file.path); - final codec = await decode(buffer); - yield codec; - } catch (error) { - throw StateError("Loading asset ${asset.fileName} failed"); + buffer = await ui.ImmutableBuffer.fromUint8List(thumbBytes); + thumbBytes = null; + yield await decode(buffer); + buffer = null; + + switch (asset.type) { + case AssetType.image: + final File? file = await local.originFile; + if (file == null) { + throw StateError("Opening file for asset ${asset.fileName} failed"); + } + buffer = await ui.ImmutableBuffer.fromFilePath(file.path); + yield await decode(buffer); + buffer = null; + break; + case AssetType.video: + final size = ThumbnailSize(width.ceil(), height.ceil()); + thumbBytes = await local.thumbnailDataWithSize(size); + if (thumbBytes == null) { + throw StateError("Failed to load preview for ${asset.fileName}"); + } + buffer = await ui.ImmutableBuffer.fromUint8List(thumbBytes); + thumbBytes = null; + yield await decode(buffer); + buffer = null; + break; + default: + throw StateError('Unsupported asset type ${asset.type}'); } + } catch (error, stack) { + log.severe('Error loading local image ${asset.fileName}', error, stack); + buffer?.dispose(); + } finally { + chunkEvents.close(); } - - chunkEvents.close(); } @override diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index b001c6bdd6d33..785d23a7ad83e 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -14,6 +14,7 @@ import 'package:immich_mobile/pages/backup/backup_controller.page.dart'; import 'package:immich_mobile/pages/backup/backup_options.page.dart'; import 'package:immich_mobile/pages/backup/failed_backup_status.page.dart'; import 'package:immich_mobile/pages/albums/albums.page.dart'; +import 'package:immich_mobile/pages/common/native_video_viewer.page.dart'; import 'package:immich_mobile/pages/library/local_albums.page.dart'; import 'package:immich_mobile/pages/library/people/people_collection.page.dart'; import 'package:immich_mobile/pages/library/places/places_collection.page.dart'; @@ -272,6 +273,10 @@ class AppRouter extends RootStackRouter { guards: [_authGuard, _duplicateGuard], transitionsBuilder: TransitionsBuilders.slideLeft, ), + AutoRoute( + page: NativeVideoViewerRoute.page, + guards: [_authGuard, _duplicateGuard], + ), ]; } diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index ea7d385e85626..48ee4db5fd2b1 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -1079,6 +1079,64 @@ class MemoryRouteArgs { } } +/// generated route for +/// [NativeVideoViewerPage] +class NativeVideoViewerRoute extends PageRouteInfo { + NativeVideoViewerRoute({ + Key? key, + required Asset asset, + required Widget image, + bool showControls = true, + List? children, + }) : super( + NativeVideoViewerRoute.name, + args: NativeVideoViewerRouteArgs( + key: key, + asset: asset, + image: image, + showControls: showControls, + ), + initialChildren: children, + ); + + static const String name = 'NativeVideoViewerRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + final args = data.argsAs(); + return NativeVideoViewerPage( + key: args.key, + asset: args.asset, + image: args.image, + showControls: args.showControls, + ); + }, + ); +} + +class NativeVideoViewerRouteArgs { + const NativeVideoViewerRouteArgs({ + this.key, + required this.asset, + required this.image, + this.showControls = true, + }); + + final Key? key; + + final Asset asset; + + final Widget image; + + final bool showControls; + + @override + String toString() { + return 'NativeVideoViewerRouteArgs{key: $key, asset: $asset, image: $image, showControls: $showControls}'; + } +} + /// generated route for /// [PartnerDetailPage] class PartnerDetailRoute extends PageRouteInfo { diff --git a/mobile/lib/services/asset.service.dart b/mobile/lib/services/asset.service.dart index b2cad4dc828eb..7d27d1b27b0ea 100644 --- a/mobile/lib/services/asset.service.dart +++ b/mobile/lib/services/asset.service.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; @@ -402,4 +403,29 @@ class AssetService { return exifInfo?.description ?? ""; } + + Future getAspectRatio(Asset asset) async { + // platform_manager always returns 0 for orientation on iOS, so only prefer it on Android + if (asset.isLocal && Platform.isAndroid) { + await asset.localAsync; + } else if (asset.isRemote) { + asset = await loadExif(asset); + } else if (asset.isLocal) { + await asset.localAsync; + } + + final aspectRatio = asset.aspectRatio; + if (aspectRatio != null) { + return aspectRatio; + } + + final width = asset.width; + final height = asset.height; + if (width != null && height != null) { + // we don't know the orientation, so assume it's normal + return width / height; + } + + return 1.0; + } } diff --git a/mobile/lib/utils/debounce.dart b/mobile/lib/utils/debounce.dart index ca5f8fc2beef0..78870151a6e78 100644 --- a/mobile/lib/utils/debounce.dart +++ b/mobile/lib/utils/debounce.dart @@ -3,20 +3,52 @@ import 'dart:async'; import 'package:flutter_hooks/flutter_hooks.dart'; /// Used to debounce function calls with the [interval] provided. +/// If [maxWaitTime] is provided, the first [run] call as well as the next call since [maxWaitTime] has passed will be immediately executed, even if [interval] is not satisfied. class Debouncer { - Debouncer({required this.interval}); + Debouncer({required this.interval, this.maxWaitTime}); final Duration interval; + final Duration? maxWaitTime; Timer? _timer; FutureOr Function()? _lastAction; + DateTime? _lastActionTime; + Future? _actionFuture; void run(FutureOr Function() action) { _lastAction = action; _timer?.cancel(); + + if (maxWaitTime != null && + // _actionFuture == null && // TODO: should this check be here? + (_lastActionTime == null || + DateTime.now().difference(_lastActionTime!) > maxWaitTime!)) { + _callAndRest(); + return; + } _timer = Timer(interval, _callAndRest); } + Future? drain() { + if (_timer != null && _timer!.isActive) { + _timer!.cancel(); + if (_lastAction != null) { + _callAndRest(); + } + } + return _actionFuture; + } + + @pragma('vm:prefer-inline') void _callAndRest() { - _lastAction?.call(); + _lastActionTime = DateTime.now(); + final action = _lastAction; + _lastAction = null; + + final result = action!(); + if (result is Future) { + _actionFuture = result.whenComplete(() { + _actionFuture = null; + }); + } _timer = null; } @@ -24,31 +56,48 @@ class Debouncer { _timer?.cancel(); _timer = null; _lastAction = null; + _lastActionTime = null; + _actionFuture = null; } + + bool get isActive => + _actionFuture != null || (_timer != null && _timer!.isActive); } /// Creates a [Debouncer] that will be disposed automatically. If no [interval] is provided, a /// default interval of 300ms is used to debounce the function calls Debouncer useDebouncer({ Duration interval = const Duration(milliseconds: 300), + Duration? maxWaitTime, List? keys, }) => - use(_DebouncerHook(interval: interval, keys: keys)); + use( + _DebouncerHook( + interval: interval, + maxWaitTime: maxWaitTime, + keys: keys, + ), + ); class _DebouncerHook extends Hook { const _DebouncerHook({ required this.interval, + this.maxWaitTime, super.keys, }); final Duration interval; + final Duration? maxWaitTime; @override HookState> createState() => _DebouncerHookState(); } class _DebouncerHookState extends HookState { - late final debouncer = Debouncer(interval: hook.interval); + late final debouncer = Debouncer( + interval: hook.interval, + maxWaitTime: hook.maxWaitTime, + ); @override Debouncer build(_) => debouncer; diff --git a/mobile/lib/utils/hooks/chewiew_controller_hook.dart b/mobile/lib/utils/hooks/chewiew_controller_hook.dart deleted file mode 100644 index 2868e896cf2f4..0000000000000 --- a/mobile/lib/utils/hooks/chewiew_controller_hook.dart +++ /dev/null @@ -1,161 +0,0 @@ -import 'package:chewie/chewie.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:video_player/video_player.dart'; - -/// Provides the initialized video player controller -/// If the asset is local, use the local file -/// Otherwise, use a video player with a URL -ChewieController useChewieController({ - required VideoPlayerController controller, - EdgeInsets controlsSafeAreaMinimum = const EdgeInsets.only( - bottom: 100, - ), - bool showOptions = true, - bool showControlsOnInitialize = false, - bool autoPlay = true, - bool allowFullScreen = false, - bool allowedScreenSleep = false, - bool showControls = true, - bool loopVideo = false, - Widget? customControls, - Widget? placeholder, - Duration hideControlsTimer = const Duration(seconds: 1), - VoidCallback? onPlaying, - VoidCallback? onPaused, - VoidCallback? onVideoEnded, -}) { - return use( - _ChewieControllerHook( - controller: controller, - placeholder: placeholder, - showOptions: showOptions, - controlsSafeAreaMinimum: controlsSafeAreaMinimum, - autoPlay: autoPlay, - allowFullScreen: allowFullScreen, - customControls: customControls, - hideControlsTimer: hideControlsTimer, - showControlsOnInitialize: showControlsOnInitialize, - showControls: showControls, - loopVideo: loopVideo, - allowedScreenSleep: allowedScreenSleep, - onPlaying: onPlaying, - onPaused: onPaused, - onVideoEnded: onVideoEnded, - ), - ); -} - -class _ChewieControllerHook extends Hook { - final VideoPlayerController controller; - final EdgeInsets controlsSafeAreaMinimum; - final bool showOptions; - final bool showControlsOnInitialize; - final bool autoPlay; - final bool allowFullScreen; - final bool allowedScreenSleep; - final bool showControls; - final bool loopVideo; - final Widget? customControls; - final Widget? placeholder; - final Duration hideControlsTimer; - final VoidCallback? onPlaying; - final VoidCallback? onPaused; - final VoidCallback? onVideoEnded; - - const _ChewieControllerHook({ - required this.controller, - this.controlsSafeAreaMinimum = const EdgeInsets.only( - bottom: 100, - ), - this.showOptions = true, - this.showControlsOnInitialize = false, - this.autoPlay = true, - this.allowFullScreen = false, - this.allowedScreenSleep = false, - this.showControls = true, - this.loopVideo = false, - this.customControls, - this.placeholder, - this.hideControlsTimer = const Duration(seconds: 3), - this.onPlaying, - this.onPaused, - this.onVideoEnded, - }); - - @override - createState() => _ChewieControllerHookState(); -} - -class _ChewieControllerHookState - extends HookState { - late ChewieController chewieController = ChewieController( - videoPlayerController: hook.controller, - controlsSafeAreaMinimum: hook.controlsSafeAreaMinimum, - showOptions: hook.showOptions, - showControlsOnInitialize: hook.showControlsOnInitialize, - autoPlay: hook.autoPlay, - allowFullScreen: hook.allowFullScreen, - allowedScreenSleep: hook.allowedScreenSleep, - showControls: hook.showControls, - looping: hook.loopVideo, - customControls: hook.customControls, - placeholder: hook.placeholder, - hideControlsTimer: hook.hideControlsTimer, - ); - - @override - void dispose() { - chewieController.dispose(); - super.dispose(); - } - - @override - ChewieController build(BuildContext context) { - return chewieController; - } - - /* - /// Initializes the chewie controller and video player controller - Future _initialize() async { - if (hook.asset.isLocal && hook.asset.livePhotoVideoId == null) { - // Use a local file for the video player controller - final file = await hook.asset.local!.file; - if (file == null) { - throw Exception('No file found for the video'); - } - videoPlayerController = VideoPlayerController.file(file); - } else { - // Use a network URL for the video player controller - final serverEndpoint = store.Store.get(store.StoreKey.serverEndpoint); - final String videoUrl = hook.asset.livePhotoVideoId != null - ? '$serverEndpoint/assets/${hook.asset.livePhotoVideoId}/video/playback' - : '$serverEndpoint/assets/${hook.asset.remoteId}/video/playback'; - - final url = Uri.parse(videoUrl); - final accessToken = store.Store.get(StoreKey.accessToken); - - videoPlayerController = VideoPlayerController.networkUrl( - url, - httpHeaders: {"x-immich-user-token": accessToken}, - ); - } - - await videoPlayerController!.initialize(); - - chewieController = ChewieController( - videoPlayerController: videoPlayerController!, - controlsSafeAreaMinimum: hook.controlsSafeAreaMinimum, - showOptions: hook.showOptions, - showControlsOnInitialize: hook.showControlsOnInitialize, - autoPlay: hook.autoPlay, - allowFullScreen: hook.allowFullScreen, - allowedScreenSleep: hook.allowedScreenSleep, - showControls: hook.showControls, - customControls: hook.customControls, - placeholder: hook.placeholder, - hideControlsTimer: hook.hideControlsTimer, - ); - } - */ -} diff --git a/mobile/lib/utils/hooks/interval_hook.dart b/mobile/lib/utils/hooks/interval_hook.dart new file mode 100644 index 0000000000000..0c346065f7210 --- /dev/null +++ b/mobile/lib/utils/hooks/interval_hook.dart @@ -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], + ); +} diff --git a/mobile/lib/utils/migration.dart b/mobile/lib/utils/migration.dart index 2b02a5ff8f290..67ff060075383 100644 --- a/mobile/lib/utils/migration.dart +++ b/mobile/lib/utils/migration.dart @@ -4,7 +4,7 @@ import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/utils/db.dart'; import 'package:isar/isar.dart'; -const int targetVersion = 6; +const int targetVersion = 7; Future migrateDatabaseIfNeeded(Isar db) async { final int version = Store.get(StoreKey.version, 1); diff --git a/mobile/lib/utils/throttle.dart b/mobile/lib/utils/throttle.dart index 9a54e01fc195c..bc0dcf9e2fe2a 100644 --- a/mobile/lib/utils/throttle.dart +++ b/mobile/lib/utils/throttle.dart @@ -1,5 +1,3 @@ -import 'dart:async'; - import 'package:flutter_hooks/flutter_hooks.dart'; /// Throttles function calls with the [interval] provided. @@ -10,12 +8,15 @@ class Throttler { Throttler({required this.interval}); - void run(FutureOr Function() action) { + T? run(T Function() action) { if (_lastActionTime == null || (DateTime.now().difference(_lastActionTime!) > interval)) { - action(); + final response = action(); _lastActionTime = DateTime.now(); + return response; } + + return null; } void dispose() { diff --git a/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart b/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart index 38e499b5dec8e..5670aa388f9ee 100644 --- a/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart +++ b/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart @@ -12,7 +12,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/collection_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; +import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/scroll_notifier.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart'; import 'package:immich_mobile/widgets/asset_grid/asset_drag_region.dart'; import 'package:immich_mobile/widgets/asset_grid/thumbnail_image.dart'; import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart'; @@ -89,6 +91,7 @@ class ImmichAssetGridViewState extends ConsumerState { ScrollOffsetController(); final ItemPositionsListener _itemPositionsListener = ItemPositionsListener.create(); + late final KeepAliveLink currentAssetLink; /// The timestamp when the haptic feedback was last invoked int _hapticFeedbackTS = 0; @@ -201,6 +204,12 @@ class ImmichAssetGridViewState extends ConsumerState { allAssetsSelected: _allAssetsSelected, showStack: widget.showStack, heroOffset: widget.heroOffset, + onAssetTap: (asset) { + ref.read(currentAssetProvider.notifier).set(asset); + if (asset.isVideo) { + ref.read(showControlsProvider.notifier).show = false; + } + }, ); } @@ -348,6 +357,7 @@ class ImmichAssetGridViewState extends ConsumerState { @override void initState() { super.initState(); + currentAssetLink = ref.read(currentAssetProvider.notifier).ref.keepAlive(); scrollToTopNotifierProvider.addListener(_scrollToTop); scrollToDateNotifierProvider.addListener(_scrollToDate); @@ -369,6 +379,7 @@ class ImmichAssetGridViewState extends ConsumerState { _itemPositionsListener.itemPositions.removeListener(_positionListener); } _itemPositionsListener.itemPositions.removeListener(_hapticsListener); + currentAssetLink.close(); super.dispose(); } @@ -595,12 +606,13 @@ class _Section extends StatelessWidget { final RenderList renderList; final bool selectionActive; final bool dynamicLayout; - final Function(List) selectAssets; - final Function(List) deselectAssets; + final void Function(List) selectAssets; + final void Function(List) deselectAssets; final bool Function(List) allAssetsSelected; final bool showStack; final int heroOffset; final bool showStorageIndicator; + final void Function(Asset) onAssetTap; const _Section({ required this.section, @@ -618,6 +630,7 @@ class _Section extends StatelessWidget { required this.showStack, required this.heroOffset, required this.showStorageIndicator, + required this.onAssetTap, }); @override @@ -683,6 +696,7 @@ class _Section extends StatelessWidget { selectionActive: selectionActive, onSelect: (asset) => selectAssets([asset]), onDeselect: (asset) => deselectAssets([asset]), + onAssetTap: onAssetTap, ), ], ); @@ -724,9 +738,9 @@ class _Title extends StatelessWidget { final String title; final List assets; final bool selectionActive; - final Function(List) selectAssets; - final Function(List) deselectAssets; - final Function(List) allAssetsSelected; + final void Function(List) selectAssets; + final void Function(List) deselectAssets; + final bool Function(List) allAssetsSelected; const _Title({ required this.title, @@ -765,8 +779,9 @@ class _AssetRow extends StatelessWidget { final bool showStorageIndicator; final int heroOffset; final bool showStack; - final Function(Asset)? onSelect; - final Function(Asset)? onDeselect; + final void Function(Asset) onAssetTap; + final void Function(Asset)? onSelect; + final void Function(Asset)? onDeselect; final bool isSelectionActive; const _AssetRow({ @@ -786,6 +801,7 @@ class _AssetRow extends StatelessWidget { required this.showStack, required this.isSelectionActive, required this.selectedAssets, + required this.onAssetTap, this.onSelect, this.onDeselect, }); @@ -838,6 +854,8 @@ class _AssetRow extends StatelessWidget { onSelect?.call(asset); } } else { + final asset = renderList.loadAsset(absoluteOffset + index); + onAssetTap(asset); context.pushRoute( GalleryViewerRoute( renderList: renderList, diff --git a/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart b/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart index 82ca295d8abbf..256141dc7d273 100644 --- a/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart +++ b/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart @@ -5,11 +5,11 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/constants/immich_colors.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/providers/album/album.provider.dart'; import 'package:immich_mobile/providers/album/current_album.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_stack.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/download.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart'; import 'package:immich_mobile/services/stack.service.dart'; @@ -26,12 +26,10 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/pages/editing/edit.page.dart'; class BottomGalleryBar extends ConsumerWidget { - final Asset asset; final ValueNotifier assetIndex; final bool showStack; - final int stackIndex; + final ValueNotifier stackIndex; final ValueNotifier totalAssets; - final bool showVideoPlayerControls; final PageController controller; final RenderList renderList; @@ -39,20 +37,24 @@ class BottomGalleryBar extends ConsumerWidget { super.key, required this.showStack, required this.stackIndex, - required this.asset, required this.assetIndex, required this.controller, required this.totalAssets, - required this.showVideoPlayerControls, required this.renderList, }); @override Widget build(BuildContext context, WidgetRef ref) { + final asset = ref.watch(currentAssetProvider); + if (asset == null) { + return const SizedBox(); + } final isOwner = asset.ownerId == ref.watch(currentUserProvider)?.isarId; + final showControls = ref.watch(showControlsProvider); + final stackId = asset.stackId; - final stackItems = showStack && asset.stackCount > 0 - ? ref.watch(assetStackStateProvider(asset)) + final stackItems = showStack && stackId != null + ? ref.watch(assetStackStateProvider(stackId)) : []; bool isStackPrimaryAsset = asset.stackPrimaryAssetId == null; final navStack = AutoRouter.of(context).stackData; @@ -64,10 +66,10 @@ class BottomGalleryBar extends ConsumerWidget { final isInAlbum = ref.watch(currentAlbumProvider)?.isRemote ?? false; void removeAssetFromStack() { - if (stackIndex > 0 && showStack) { + if (stackIndex.value > 0 && showStack && stackId != null) { ref - .read(assetStackStateProvider(asset).notifier) - .removeChild(stackIndex - 1); + .read(assetStackStateProvider(stackId).notifier) + .removeChild(stackIndex.value - 1); } } @@ -135,7 +137,7 @@ class BottomGalleryBar extends ConsumerWidget { await ref .read(stackServiceProvider) - .deleteStack(asset.stackId!, [asset, ...stackItems]); + .deleteStack(asset.stackId!, stackItems); } void showStackActionItems() { @@ -324,16 +326,16 @@ class BottomGalleryBar extends ConsumerWidget { }, ]; return IgnorePointer( - ignoring: !ref.watch(showControlsProvider), + ignoring: !showControls, child: AnimatedOpacity( duration: const Duration(milliseconds: 100), - opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0, + opacity: showControls ? 1.0 : 0.0, child: DecoratedBox( decoration: const BoxDecoration( gradient: LinearGradient( begin: Alignment.bottomCenter, end: Alignment.topCenter, - colors: [blackOpacity90, Colors.transparent], + colors: [Colors.black, Colors.transparent], ), ), position: DecorationPosition.background, @@ -341,7 +343,7 @@ class BottomGalleryBar extends ConsumerWidget { padding: const EdgeInsets.only(top: 40.0), child: Column( children: [ - if (showVideoPlayerControls) const VideoControls(), + if (asset.isVideo) const VideoControls(), BottomNavigationBar( elevation: 0.0, backgroundColor: Colors.transparent, diff --git a/mobile/lib/widgets/asset_viewer/custom_video_player_controls.dart b/mobile/lib/widgets/asset_viewer/custom_video_player_controls.dart index a34fcb9baf5e0..d759b0d80b3e6 100644 --- a/mobile/lib/widgets/asset_viewer/custom_video_player_controls.dart +++ b/mobile/lib/widgets/asset_viewer/custom_video_player_controls.dart @@ -1,38 +1,48 @@ 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/current_asset.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_value_provider.dart'; +import 'package:immich_mobile/utils/hooks/timer_hook.dart'; import 'package:immich_mobile/widgets/asset_viewer/center_play_button.dart'; import 'package:immich_mobile/widgets/common/delayed_loading_indicator.dart'; -import 'package:immich_mobile/utils/hooks/timer_hook.dart'; class CustomVideoPlayerControls extends HookConsumerWidget { final Duration hideTimerDuration; const CustomVideoPlayerControls({ super.key, - this.hideTimerDuration = const Duration(seconds: 3), + this.hideTimerDuration = const Duration(seconds: 5), }); @override Widget build(BuildContext context, WidgetRef ref) { + final assetIsVideo = ref.watch( + currentAssetProvider.select((asset) => asset != null && asset.isVideo), + ); + final showControls = ref.watch(showControlsProvider); + final VideoPlaybackState state = + ref.watch(videoPlaybackValueProvider.select((value) => value.state)); + // A timer to hide the controls final hideTimer = useTimer( hideTimerDuration, () { + if (!context.mounted) { + return; + } final state = ref.read(videoPlaybackValueProvider).state; + // Do not hide on paused - if (state != VideoPlaybackState.paused) { + if (state != VideoPlaybackState.paused && + state != VideoPlaybackState.completed && + assetIsVideo) { ref.read(showControlsProvider.notifier).show = false; } }, ); - - final showBuffering = useState(false); - final VideoPlaybackState state = - ref.watch(videoPlaybackValueProvider).state; + final showBuffering = state == VideoPlaybackState.buffering; /// Shows the controls and starts the timer to hide them void showControlsAndStartHideTimer() { @@ -40,28 +50,15 @@ class CustomVideoPlayerControls extends HookConsumerWidget { ref.read(showControlsProvider.notifier).show = true; } - // When we mute, show the controls - ref.listen(videoPlayerControlsProvider.select((v) => v.mute), - (previous, next) { - showControlsAndStartHideTimer(); - }); - // When we change position, show or hide timer ref.listen(videoPlayerControlsProvider.select((v) => v.position), (previous, next) { 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) { @@ -75,10 +72,10 @@ class CustomVideoPlayerControls extends HookConsumerWidget { behavior: HitTestBehavior.opaque, onTap: showControlsAndStartHideTimer, child: AbsorbPointer( - absorbing: !ref.watch(showControlsProvider), + absorbing: !showControls, child: Stack( children: [ - if (showBuffering.value) + if (showBuffering) const Center( child: DelayedLoadingIndicator( fadeInDuration: Duration(milliseconds: 400), @@ -86,18 +83,14 @@ class CustomVideoPlayerControls extends HookConsumerWidget { ) else GestureDetector( - onTap: () { - if (state != VideoPlaybackState.playing) { - togglePlay(); - } - ref.read(showControlsProvider.notifier).show = false; - }, + onTap: () => + ref.read(showControlsProvider.notifier).show = false, child: CenterPlayButton( backgroundColor: Colors.black54, iconColor: Colors.white, isFinished: state == VideoPlaybackState.completed, isPlaying: state == VideoPlaybackState.playing, - show: ref.watch(showControlsProvider), + show: assetIsVideo && showControls, onPressed: togglePlay, ), ), diff --git a/mobile/lib/widgets/asset_viewer/detail_panel/file_info.dart b/mobile/lib/widgets/asset_viewer/detail_panel/file_info.dart index 3c650bdc6a2e3..0dd3305302c41 100644 --- a/mobile/lib/widgets/asset_viewer/detail_panel/file_info.dart +++ b/mobile/lib/widgets/asset_viewer/detail_panel/file_info.dart @@ -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} " - : ""; + final height = asset.orientatedHeight ?? asset.height; + final width = asset.orientatedWidth ?? asset.width; + String resolution = + height != null && width != null ? "$height x $width " : ""; String fileSize = asset.exifInfo?.fileSize != null ? formatBytes(asset.exifInfo!.fileSize!) : ""; diff --git a/mobile/lib/widgets/asset_viewer/gallery_app_bar.dart b/mobile/lib/widgets/asset_viewer/gallery_app_bar.dart index f400224e0a0be..f7e2158ea981d 100644 --- a/mobile/lib/widgets/asset_viewer/gallery_app_bar.dart +++ b/mobile/lib/widgets/asset_viewer/gallery_app_bar.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/providers/album/current_album.provider.dart'; +import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/widgets/album/add_to_album_bottom_sheet.dart'; import 'package:immich_mobile/providers/asset_viewer/download.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart'; @@ -19,23 +20,19 @@ import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; class GalleryAppBar extends ConsumerWidget { - final Asset asset; final void Function() showInfo; - final void Function() onToggleMotionVideo; - final bool isPlayingVideo; - const GalleryAppBar({ - super.key, - required this.asset, - required this.showInfo, - required this.onToggleMotionVideo, - required this.isPlayingVideo, - }); + const GalleryAppBar({super.key, required this.showInfo}); @override Widget build(BuildContext context, WidgetRef ref) { + final asset = ref.watch(currentAssetProvider); + if (asset == null) { + return const SizedBox(); + } final album = ref.watch(currentAlbumProvider); final isOwner = asset.ownerId == ref.watch(currentUserProvider)?.isarId; + final showControls = ref.watch(showControlsProvider); final isPartner = ref .watch(partnerSharedWithProvider) @@ -98,23 +95,21 @@ class GalleryAppBar extends ConsumerWidget { } return IgnorePointer( - ignoring: !ref.watch(showControlsProvider), + ignoring: !showControls, child: AnimatedOpacity( duration: const Duration(milliseconds: 100), - opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0, + opacity: showControls ? 1.0 : 0.0, child: Container( color: Colors.black.withOpacity(0.4), child: TopControlAppBar( isOwner: isOwner, isPartner: isPartner, - isPlayingMotionVideo: isPlayingVideo, asset: asset, onMoreInfoPressed: showInfo, onFavorite: toggleFavorite, onRestorePressed: () => handleRestore(asset), onUploadPressed: asset.isLocal ? () => handleUpload(asset) : null, onDownloadPressed: asset.isLocal ? null : handleDownloadAsset, - onToggleMotionVideo: onToggleMotionVideo, onAddToAlbumPressed: () => addToAlbum(asset), onActivitiesPressed: handleActivities, ), diff --git a/mobile/lib/widgets/asset_viewer/motion_photo_button.dart b/mobile/lib/widgets/asset_viewer/motion_photo_button.dart new file mode 100644 index 0000000000000..e4dd3555545a7 --- /dev/null +++ b/mobile/lib/widgets/asset_viewer/motion_photo_button.dart @@ -0,0 +1,22 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/immich_colors.dart'; +import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; + +class MotionPhotoButton extends ConsumerWidget { + const MotionPhotoButton({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isPlaying = ref.watch(isPlayingMotionVideoProvider); + + return IconButton( + onPressed: () { + ref.read(isPlayingMotionVideoProvider.notifier).toggle(); + }, + icon: isPlaying + ? const Icon(Icons.motion_photos_pause_outlined, color: grey200) + : const Icon(Icons.play_circle_outline_rounded, color: grey200), + ); + } +} diff --git a/mobile/lib/widgets/asset_viewer/top_control_app_bar.dart b/mobile/lib/widgets/asset_viewer/top_control_app_bar.dart index 984b61f50cc05..2bdbb72ec03ac 100644 --- a/mobile/lib/widgets/asset_viewer/top_control_app_bar.dart +++ b/mobile/lib/widgets/asset_viewer/top_control_app_bar.dart @@ -5,6 +5,7 @@ import 'package:immich_mobile/providers/activity_statistics.provider.dart'; import 'package:immich_mobile/providers/album/current_album.provider.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/providers/asset.provider.dart'; +import 'package:immich_mobile/widgets/asset_viewer/motion_photo_button.dart'; class TopControlAppBar extends HookConsumerWidget { const TopControlAppBar({ @@ -14,8 +15,6 @@ class TopControlAppBar extends HookConsumerWidget { required this.onDownloadPressed, required this.onAddToAlbumPressed, required this.onRestorePressed, - required this.onToggleMotionVideo, - required this.isPlayingMotionVideo, required this.onFavorite, required this.onUploadPressed, required this.isOwner, @@ -27,12 +26,10 @@ class TopControlAppBar extends HookConsumerWidget { final Function onMoreInfoPressed; final VoidCallback? onUploadPressed; final VoidCallback? onDownloadPressed; - final VoidCallback onToggleMotionVideo; final VoidCallback onAddToAlbumPressed; final VoidCallback onRestorePressed; final VoidCallback onActivitiesPressed; final Function(Asset) onFavorite; - final bool isPlayingMotionVideo; final bool isOwner; final bool isPartner; @@ -57,23 +54,6 @@ class TopControlAppBar extends HookConsumerWidget { ); } - Widget buildLivePhotoButton() { - return IconButton( - onPressed: () { - onToggleMotionVideo(); - }, - icon: isPlayingMotionVideo - ? Icon( - Icons.motion_photos_pause_outlined, - color: Colors.grey[200], - ) - : Icon( - Icons.play_circle_outline_rounded, - color: Colors.grey[200], - ), - ); - } - Widget buildMoreInfoButton() { return IconButton( onPressed: () { @@ -175,13 +155,11 @@ class TopControlAppBar extends HookConsumerWidget { foregroundColor: Colors.grey[100], backgroundColor: Colors.transparent, leading: buildBackButton(), - actionsIconTheme: const IconThemeData( - size: iconSize, - ), + actionsIconTheme: const IconThemeData(size: iconSize), shape: const Border(), actions: [ if (asset.isRemote && isOwner) buildFavoriteButton(a), - if (asset.livePhotoVideoId != null) buildLivePhotoButton(), + if (asset.livePhotoVideoId != null) const MotionPhotoButton(), if (asset.isLocal && !asset.isRemote) buildUploadButton(), if (asset.isRemote && !asset.isLocal && isOwner) buildDownloadButton(), if (asset.isRemote && (isOwner || isPartner) && !asset.isTrashed) diff --git a/mobile/lib/widgets/asset_viewer/video_player.dart b/mobile/lib/widgets/asset_viewer/video_player.dart deleted file mode 100644 index ebf158b59a5fb..0000000000000 --- a/mobile/lib/widgets/asset_viewer/video_player.dart +++ /dev/null @@ -1,48 +0,0 @@ -import 'package:chewie/chewie.dart'; -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/utils/hooks/chewiew_controller_hook.dart'; -import 'package:immich_mobile/widgets/asset_viewer/custom_video_player_controls.dart'; -import 'package:video_player/video_player.dart'; - -class VideoPlayerViewer extends HookConsumerWidget { - final VideoPlayerController controller; - final bool isMotionVideo; - final Widget? placeholder; - final Duration hideControlsTimer; - final bool showControls; - final bool showDownloadingIndicator; - final bool loopVideo; - - const VideoPlayerViewer({ - super.key, - required this.controller, - required this.isMotionVideo, - this.placeholder, - required this.hideControlsTimer, - required this.showControls, - required this.showDownloadingIndicator, - required this.loopVideo, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final chewie = useChewieController( - controller: controller, - controlsSafeAreaMinimum: const EdgeInsets.only( - bottom: 100, - ), - placeholder: SizedBox.expand(child: placeholder), - customControls: CustomVideoPlayerControls( - hideTimerDuration: hideControlsTimer, - ), - showControls: showControls && !isMotionVideo, - hideControlsTimer: hideControlsTimer, - loopVideo: loopVideo, - ); - - return Chewie( - controller: chewie, - ); - } -} diff --git a/mobile/lib/widgets/asset_viewer/video_position.dart b/mobile/lib/widgets/asset_viewer/video_position.dart index ef309b9c8561a..b1f70b868685a 100644 --- a/mobile/lib/widgets/asset_viewer/video_position.dart +++ b/mobile/lib/widgets/asset_viewer/video_position.dart @@ -56,10 +56,16 @@ class VideoPosition extends HookConsumerWidget { ref.read(videoPlayerControlsProvider.notifier).play(); } }, - onChanged: (position) { + onChanged: (value) { + final inSeconds = + (duration * (value / 100.0)).inSeconds; + final position = inSeconds.toDouble(); ref .read(videoPlayerControlsProvider.notifier) .position = position; + // This immediately updates the slider position without waiting for the video to update + ref.read(videoPlaybackValueProvider.notifier).position = + Duration(seconds: inSeconds); }, ), ), diff --git a/mobile/lib/widgets/common/immich_image.dart b/mobile/lib/widgets/common/immich_image.dart index 5946dee453ad5..ab0f2584b554c 100644 --- a/mobile/lib/widgets/common/immich_image.dart +++ b/mobile/lib/widgets/common/immich_image.dart @@ -28,12 +28,11 @@ class ImmichImage extends StatelessWidget { // either by using the asset ID or the asset itself /// [asset] is the Asset to request, or else use [assetId] to get a remote /// image provider - /// Use [isThumbnail] and [thumbnailSize] if you'd like to request a thumbnail - /// The size of the square thumbnail to request. Ignored if isThumbnail - /// is not true static ImageProvider imageProvider({ Asset? asset, String? assetId, + double width = 1080, + double height = 1920, }) { if (asset == null && assetId == null) { throw Exception('Must supply either asset or assetId'); @@ -48,6 +47,8 @@ class ImmichImage extends StatelessWidget { if (useLocal(asset)) { return ImmichLocalImageProvider( asset: asset, + width: width, + height: height, ); } else { return ImmichRemoteImageProvider( @@ -87,6 +88,8 @@ class ImmichImage extends StatelessWidget { }, image: ImmichImage.imageProvider( asset: asset, + width: context.width, + height: context.height, ), width: width, height: height, diff --git a/mobile/lib/widgets/memories/memory_card.dart b/mobile/lib/widgets/memories/memory_card.dart index fb7cc882a0d31..4954d0bfccc8d 100644 --- a/mobile/lib/widgets/memories/memory_card.dart +++ b/mobile/lib/widgets/memories/memory_card.dart @@ -2,9 +2,9 @@ import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/pages/common/video_viewer.page.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/pages/common/native_video_viewer.page.dart'; import 'package:immich_mobile/utils/hooks/blurhash_hook.dart'; import 'package:immich_mobile/widgets/common/immich_image.dart'; @@ -68,18 +68,20 @@ class MemoryCard extends StatelessWidget { } else { return Hero( tag: 'memory-${asset.id}', - child: VideoViewerPage( - key: ValueKey(asset), - asset: asset, - showDownloadingIndicator: false, - placeholder: SizedBox.expand( - child: ImmichImage( + child: SizedBox( + width: context.width, + height: context.height, + child: NativeVideoViewerPage( + key: ValueKey(asset.id), + asset: asset, + showControls: false, + image: ImmichImage( asset, + width: context.width, + height: context.height, fit: fit, ), ), - hideControlsTimer: const Duration(seconds: 2), - showControls: false, ), ); } @@ -137,6 +139,8 @@ class _BlurredBackdrop extends HookWidget { image: DecorationImage( image: ImmichImage.imageProvider( asset: asset, + height: context.height, + width: context.width, ), fit: BoxFit.cover, ), diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 9dc53e42b97b4..9203dcdf825e5 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -214,14 +214,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.3" - chewie: - dependency: "direct main" - description: - name: chewie - sha256: "2243e41e79e865d426d9dd9c1a9624aa33c4ad11de2d0cd680f826e2cd30e879" - url: "https://pub.dev" - source: hosted - version: "1.8.3" ci: dependency: transitive description: @@ -318,14 +310,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" - cupertino_icons: - dependency: transitive - description: - name: cupertino_icons - sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 - url: "https://pub.dev" - source: hosted - version: "1.0.8" custom_lint: dependency: "direct dev" description: @@ -378,10 +362,10 @@ packages: dependency: "direct main" description: name: device_info_plus - sha256: db03b2d2a3fa466a4627709e1db58692c3f7f658e36a5942d342d86efedc4091 + sha256: f545ffbadee826f26f2e1a0f0cbd667ae9a6011cc0f77c0f8f00a969655e6e95 url: "https://pub.dev" source: hosted - version: "11.0.0" + version: "11.1.1" device_info_plus_platform_interface: dependency: transitive description: @@ -450,10 +434,10 @@ packages: dependency: "direct main" description: name: file_picker - sha256: "167bb619cdddaa10ef2907609feb8a79c16dfa479d3afaf960f8e223f754bf12" + sha256: aac85f20436608e01a6ffd1fdd4e746a7f33c93a2c83752e626bdfaea139b877 url: "https://pub.dev" source: hosted - version: "8.1.2" + version: "8.1.3" file_selector_linux: dependency: transitive description: @@ -548,10 +532,10 @@ packages: dependency: "direct main" description: name: flutter_local_notifications - sha256: "674173fd3c9eda9d4c8528da2ce0ea69f161577495a9cc835a2a4ecd7eadeb35" + sha256: dd6676d8c2926537eccdf9f72128bbb2a9d0814689527b17f92c248ff192eaf3 url: "https://pub.dev" source: hosted - version: "17.2.4" + version: "17.2.1+2" flutter_local_notifications_linux: dependency: transitive description: @@ -1024,14 +1008,15 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.4" - nested: - dependency: transitive + native_video_player: + dependency: "direct main" description: - name: nested - sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" - url: "https://pub.dev" - source: hosted - version: "1.0.0" + path: "." + ref: ac78487 + resolved-ref: ac78487b9a87c9e72cd15b428270a905ac551f29 + url: "https://github.com/immich-app/native_video_player" + source: git + version: "1.3.1" nm: dependency: transitive description: @@ -1067,10 +1052,10 @@ packages: dependency: "direct main" description: name: package_info_plus - sha256: "894f37107424311bdae3e476552229476777b8752c5a2a2369c0cb9a2d5442ef" + sha256: da8d9ac8c4b1df253d1a328b7bf01ae77ef132833479ab40763334db13b91cce url: "https://pub.dev" source: hosted - version: "8.0.3" + version: "8.1.1" package_info_plus_platform_interface: dependency: transitive description: @@ -1255,14 +1240,6 @@ packages: url: "https://pub.dev" source: hosted version: "5.0.2" - provider: - dependency: transitive - description: - name: provider - sha256: c8a055ee5ce3fd98d6fc872478b03823ffdb448699c6ebdbbc71d59b596fd48c - url: "https://pub.dev" - source: hosted - version: "6.1.2" pub_semver: dependency: transitive description: @@ -1339,10 +1316,10 @@ packages: dependency: "direct main" description: name: share_plus - sha256: fec12c3c39f01e4df1ec6ad92b6e85503c5ca64ffd6e28d18c9ffe53fcc4cb11 + sha256: "9c9bafd4060728d7cdb2464c341743adbd79d327cb067ec7afb64583540b47c8" url: "https://pub.dev" source: hosted - version: "10.0.3" + version: "10.1.2" share_plus_platform_interface: dependency: transitive description: @@ -1708,46 +1685,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" - video_player: - dependency: "direct main" - description: - name: video_player - sha256: "4a8c3492d734f7c39c2588a3206707a05ee80cef52e8c7f3b2078d430c84bc17" - url: "https://pub.dev" - source: hosted - version: "2.9.2" - video_player_android: - dependency: "direct main" - description: - name: video_player_android - sha256: "4de50df9ee786f5891d3281e1e633d7b142ef1acf47392592eb91cba5d355849" - url: "https://pub.dev" - source: hosted - version: "2.6.0" - video_player_avfoundation: - dependency: transitive - description: - name: video_player_avfoundation - sha256: d1e9a824f2b324000dc8fb2dcb2a3285b6c1c7c487521c63306cc5b394f68a7c - url: "https://pub.dev" - source: hosted - version: "2.6.1" - video_player_platform_interface: - dependency: transitive - description: - name: video_player_platform_interface - sha256: "236454725fafcacf98f0f39af0d7c7ab2ce84762e3b63f2cbb3ef9a7e0550bc6" - url: "https://pub.dev" - source: hosted - version: "6.2.2" - video_player_web: - dependency: transitive - description: - name: video_player_web - sha256: "6dcdd298136523eaf7dfc31abaf0dfba9aa8a8dbc96670e87e9d42b6f2caf774" - url: "https://pub.dev" - source: hosted - version: "2.3.2" vm_service: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 235c58ce63e84..a037f9b947206 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -25,9 +25,6 @@ dependencies: intl: ^0.19.0 auto_route: ^9.2.0 fluttertoast: ^8.2.4 - video_player: ^2.9.2 - video_player_android: 2.6.0 - chewie: ^1.7.4 socket_io_client: ^2.0.3+1 maplibre_gl: 0.19.0+2 geolocator: ^11.0.0 # used to move to current location in map view @@ -45,7 +42,7 @@ dependencies: path_provider: ^2.1.2 collection: ^1.18.0 http_parser: ^4.0.2 - flutter_web_auth: ^0.6.0 + flutter_web_auth: 0.6.0 easy_image_viewer: ^1.4.0 isar: version: *isar_version @@ -64,6 +61,10 @@ dependencies: async: ^2.11.0 dynamic_color: ^1.7.0 #package to apply system theme background_downloader: ^8.5.5 + native_video_player: + git: + url: https://github.com/immich-app/native_video_player + ref: ac78487 #image editing packages crop_image: ^1.0.13