1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2024-12-28 06:31:58 +00:00

feat(mobile): native_video_player (#12104)

* add native player library

* splitup the player

* stateful widget

* refactor: native_video_player

* fix: handle buffering

* turn on volume when video plays

* fix: aspect ratio

* fix: handle remote asset orientation

* refinements and fixes

fix orientation for remote assets

wip separate widget

separate video loader widget

fixed memory leak

optimized seeking, cleanup

debug context pop

use global key

back to one widget

fixed rebuild

wait for swipe animation to finish

smooth hero animation for remote videos

faster scroll animation

* clean up logging

* refactor aspect ratio calculation

* removed unnecessary import

* transitive dependencies

* fixed referencing uninitialized orientation

* use correct ref to build android

* higher res placeholder for local videos

* slightly lower delay

* await things

* fix controls when swiping between image and video

* linting

* extra smooth seeking, add comments

* chore: generate router page

* use current asset provider and loadAsset

* fix stack handling

* improved motion photo handling

* use visibility for motion videos

* error handling for async calls

* fix duplicate key error

* maybe fix duplicate key error

* increase delay for hero animation

* faster initialization for remote videos

* ensure dimensions for memory cards

* make aspect ratio logic reusable, optimizations

* refactor: move exif search from aspect ratio to orientation

* local orientation on ios is unreliable; prefer remote

* fix no audio in silent mode on ios

* increase bottom bar opacity to account for hdr

* remove unused import

* fix live photo play button not updating

* fix map marker -> galleryviewer

* remove video_player

* fix hdr playback on android

* fix looping

* remove unused dependencies

* update to latest player commit

* fix player controls hiding when video is not playing

* fix restart video

* stop showing motion video after ending when looping is disabled

* delay video initialization to avoid placeholder flicker

* faster animation

* shorter delay

* small delay for image -> video on android

* fix: lint

* hide stacked children when controls are hidden, avoid bottom bar dropping

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com>
This commit is contained in:
shenlong 2024-12-05 02:33:46 +05:30 committed by GitHub
parent 5060ee95c2
commit 3c38851d50
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
44 changed files with 1418 additions and 1073 deletions

View file

@ -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
}

View file

@ -35,7 +35,7 @@
<meta-data
android:name="io.flutter.embedding.android.EnableImpeller"
android:value="false" />
android:value="true" />
<meta-data
android:name="com.google.firebase.messaging.default_notification_icon"

View file

@ -16,8 +16,8 @@ subprojects {
if (project.plugins.hasPlugin("com.android.application") ||
project.plugins.hasPlugin("com.android.library")) {
project.android {
compileSdkVersion 34
buildToolsVersion "34.0.0"
compileSdkVersion 35
buildToolsVersion "35.0.0"
}
}
}

View file

@ -65,6 +65,8 @@ PODS:
- maplibre_gl (0.0.1):
- Flutter
- MapLibre (= 5.14.0-pre3)
- native_video_player (1.0.0):
- Flutter
- package_info_plus (0.4.5):
- Flutter
- path_provider_foundation (0.0.1):
@ -93,9 +95,6 @@ PODS:
- Toast (4.0.0)
- url_launcher_ios (0.0.1):
- Flutter
- video_player_avfoundation (0.0.1):
- Flutter
- FlutterMacOS
- wakelock_plus (0.0.1):
- Flutter
@ -115,6 +114,7 @@ DEPENDENCIES:
- integration_test (from `.symlinks/plugins/integration_test/ios`)
- isar_flutter_libs (from `.symlinks/plugins/isar_flutter_libs/ios`)
- maplibre_gl (from `.symlinks/plugins/maplibre_gl/ios`)
- native_video_player (from `.symlinks/plugins/native_video_player/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- path_provider_ios (from `.symlinks/plugins/path_provider_ios/ios`)
@ -124,7 +124,6 @@ DEPENDENCIES:
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- sqflite (from `.symlinks/plugins/sqflite/darwin`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
- video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/darwin`)
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
SPEC REPOS:
@ -168,6 +167,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/isar_flutter_libs/ios"
maplibre_gl:
:path: ".symlinks/plugins/maplibre_gl/ios"
native_video_player:
:path: ".symlinks/plugins/native_video_player/ios"
package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios"
path_provider_foundation:
@ -186,15 +187,13 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/sqflite/darwin"
url_launcher_ios:
:path: ".symlinks/plugins/url_launcher_ios/ios"
video_player_avfoundation:
:path: ".symlinks/plugins/video_player_avfoundation/darwin"
wakelock_plus:
:path: ".symlinks/plugins/wakelock_plus/ios"
SPEC CHECKSUMS:
background_downloader: 9f788ffc5de45acf87d6380e91ca0841066c18cf
connectivity_plus: ddd7f30999e1faaef5967c23d5b6d503d10434db
device_info_plus: 97af1d7e84681a90d0693e63169a5d50e0839a0d
device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655
@ -210,20 +209,20 @@ SPEC CHECKSUMS:
isar_flutter_libs: fdf730ca925d05687f36d7f1d355e482529ed097
MapLibre: 620fc933c1d6029b33738c905c1490d024e5d4ef
maplibre_gl: a2efec727dd340e4c65e26d2b03b584f14881fd9
package_info_plus: 58f0028419748fad15bf008b270aaa8e54380b1c
native_video_player: d12af78a1a4a8cf09775a5177d5b392def6fd23c
package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02
permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2
photo_manager: ff695c7a1dd5bc379974953a2b5c0a293f7c4c8a
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
SDWebImage: 066c47b573f408f18caa467d71deace7c0f8280d
share_plus: 8875f4f2500512ea181eef553c3e27dba5135aad
share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
Toast: 91b396c56ee72a5790816f40d3a94dd357abc196
url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe
video_player_avfoundation: 7c6c11d8470e1675df7397027218274b6d2360b3
wakelock_plus: 78ec7c5b202cab7761af8e2b2b3d0671be6c4ae1
PODFILE CHECKSUM: 64c9b5291666c0ca3caabdfe9865c141ac40321d

View file

@ -1,48 +1,59 @@
import UIKit
import shared_preferences_foundation
import Flutter
import BackgroundTasks
import Flutter
import UIKit
import path_provider_ios
import photo_manager
import permission_handler_apple
import photo_manager
import shared_preferences_foundation
@main
@objc class AppDelegate: FlutterAppDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> 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")!)
}
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.photo-manager") {
PhotoManagerPlugin.register(
with: registry.registrar(forPlugin: "org.cocoapods.photo-manager")!)
}
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
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")!)
}
}
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
}

View file

@ -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<ImmichColorPreset, ImmichTheme> _themePresetsMap = {
ImmichColorPreset.indigo: ImmichTheme(

View file

@ -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<AssetEntity> 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<int> 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<Asset> {
return where().anyOf(ids, (q, String e) => q.localIdEqualTo(e));
}
}
/// Returns `true` if this [int] is flipped 90° clockwise
bool isRotated90CW(int orientation) {
return [7, 8, -90].contains(orientation);
}
/// Returns `true` if this [int] is flipped 270° clockwise
bool isRotated270CW(int orientation) {
return [5, 6, 90].contains(orientation);
}
/// Returns `true` if this [Asset] is flipped 90° or 270° clockwise
bool isFlipped(AssetResponseDto response) {
final int orientation =
int.tryParse(response.exifInfo?.orientation ?? '0') ?? 0;
return orientation != 0 &&
(isRotated90CW(orientation) || isRotated270CW(orientation));
}

View file

@ -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;

Binary file not shown.

View file

@ -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,
);
}

View file

@ -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<int> 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),
),
),
),
),
);
},
),
),
),
);
}
}

View file

@ -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<Offset?>(null);
final currentIndex = useState(initialIndex);
final currentAsset = loadAsset(currentIndex.value);
// Update is playing motion video
ref.listen(videoPlaybackValueProvider.select((v) => v.state), (_, state) {
isPlayingVideo.value = state == VideoPlaybackState.playing;
});
final stackIndex = useState(-1);
final stack = showStack && currentAsset.stackCount > 0
? ref.watch(assetStackStateProvider(currentAsset))
: <Asset>[];
final stackElements = showStack ? [currentAsset, ...stack] : <Asset>[];
// 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<bool>(AppSettingsEnum.loopVideo);
return null;
},
[],
);
final stackIndex = useState(0);
final localPosition = useRef<Offset?>(null);
final currentIndex = useValueNotifier(initialIndex);
final loadAsset = renderList.loadAsset;
Future<void> precacheNextImage(int index) async {
if (!context.mounted) {
return;
}
void onError(Object exception, StackTrace? stackTrace) {
// swallow error silently
debugPrint('Error precaching next image: $exception, $stackTrace');
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,
),
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 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,
),
);
}
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();
final newAsset = loadAsset(value);
currentIndex.value = value;
stackIndex.value = -1;
isPlayingVideo.value = false;
stackIndex.value = 0;
// Wait for page change animation to finish
await Future.delayed(const Duration(milliseconds: 400));
// Then precache the next image
unawaited(precacheNextImage(next));
},
builder: (context, index) {
final a =
index == currentIndex.value ? asset : loadAsset(index);
final ImageProvider provider =
ImmichImage.imageProvider(asset: a);
if (a.isImage && !isPlayingVideo.value) {
return PhotoViewGalleryPageOptions(
onDragStart: (_, details, __) =>
localPosition.value = details.localPosition,
onDragUpdate: (_, details, __) =>
handleSwipeUpDown(details),
onTapDown: (_, __, ___) {
ref.read(showControlsProvider.notifier).toggle();
},
onLongPressStart: (_, __, ___) {
if (asset.livePhotoVideoId != null) {
isPlayingVideo.value = true;
}
},
imageProvider: provider,
heroAttributes: PhotoViewHeroAttributes(
tag: isFromDto
? '${currentAsset.remoteId}-$heroOffset'
: currentAsset.id + heroOffset,
transitionOnUserGestures: true,
),
filterQuality: FilterQuality.high,
tightMode: true,
minScale: PhotoViewComputedScale.contained,
errorBuilder: (context, error, stackTrace) => ImmichImage(
a,
fit: BoxFit.contain,
),
);
} else {
return PhotoViewGalleryPageOptions.customChild(
onDragStart: (_, details, __) =>
localPosition.value = details.localPosition,
onDragUpdate: (_, details, __) =>
handleSwipeUpDown(details),
heroAttributes: PhotoViewHeroAttributes(
tag: isFromDto
? '${currentAsset.remoteId}-$heroOffset'
: currentAsset.id + heroOffset,
),
filterQuality: FilterQuality.high,
maxScale: 1.0,
minScale: 1.0,
basePosition: Alignment.center,
child: 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,
);
}
}

View file

@ -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<NativeVideoPlayerController?>(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<VideoSource?> 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<Future<VideoSource?>>(() => createSource());
final aspectRatio = useState<double?>(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<bool>(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<bool>(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()),
],
);
}
}

View file

@ -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,
),
),
],
),
),
);
}
}

View file

@ -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,
);
}

View file

@ -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,

View file

@ -7,49 +7,49 @@ import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'asset_stack.provider.g.dart';
class AssetStackNotifier extends StateNotifier<List<Asset>> {
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<Asset>.from(state);
}
}
}
final assetStackStateProvider = StateNotifierProvider.autoDispose
.family<AssetStackNotifier, List<Asset>, Asset>(
(ref, asset) => AssetStackNotifier(asset, ref),
.family<AssetStackNotifier, List<Asset>, String>(
(ref, stackId) => AssetStackNotifier(stackId, ref),
);
final assetStackProvider =
FutureProvider.autoDispose.family<List<Asset>, Asset>((ref, asset) async {
// Guard [local asset]
if (asset.remoteId == null) {
return [];
}
return await ref
FutureProvider.autoDispose.family<List<Asset>, 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();
});

View file

@ -0,0 +1,23 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
/// Whether to display the video part of a motion photo
final isPlayingMotionVideoProvider =
StateNotifierProvider<IsPlayingMotionVideo, bool>((ref) {
return IsPlayingMotionVideo(ref);
});
class IsPlayingMotionVideo extends StateNotifier<bool> {
IsPlayingMotionVideo(this.ref) : super(false);
final Ref ref;
bool get playing => state;
set playing(bool value) {
state = value;
}
void toggle() {
state = !state;
}
}

View file

@ -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> 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;
}

View file

@ -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<VideoPlaybackControls> {
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<VideoPlaybackControls> {
}
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,
);
}
if (state.position == value) {
return;
}
set mute(bool value) {
state = VideoPlaybackControls(
position: state.position,
mute: value,
pause: state.pause,
);
}
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,
);
}
}

View file

@ -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<VideoPlaybackValueState, VideoPlaybackValue>((ref) {
return VideoPlaybackValueState(ref);
});
class VideoPlaybackValueState extends StateNotifier<VideoPlaybackValue> {
VideoPlaybackValueState(this.ref)
: super(
VideoPlaybackValue.uninitialized(),
);
VideoPlaybackValueState(this.ref) : super(videoPlaybackValueDefault);
final Ref ref;
@ -82,6 +92,7 @@ class VideoPlaybackValueState extends StateNotifier<VideoPlaybackValue> {
}
set position(Duration value) {
if (state.position == value) return;
state = VideoPlaybackValue(
position: value,
duration: state.duration,
@ -89,4 +100,18 @@ class VideoPlaybackValueState extends StateNotifier<VideoPlaybackValue> {
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;
}
}

View file

@ -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<ImmichLocalImageProvider> {
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<ImmichLocalImageProvider> {
// Streams in each stage of the image as we ask for it
Stream<ui.Codec> _codec(
Asset key,
Asset asset,
ImageDecoderCallback decode,
StreamController<ImageChunkEvent> 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");
}
if (asset.isImage) {
final File? file = await asset.local?.originFile;
if (file == null) {
throw StateError("Opening file for asset ${asset.fileName} failed");
ui.ImmutableBuffer? buffer;
try {
final local = asset.local;
if (local == null) {
throw StateError('Asset ${asset.fileName} has no local data');
}
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");
}
}
chunkEvents.close();
var thumbBytes = await local
.thumbnailDataWithSize(const ThumbnailSize.square(256), quality: 80);
if (thumbBytes == null) {
throw StateError("Loading thumbnail for ${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();
}
}
@override

View file

@ -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],
),
];
}

View file

@ -1079,6 +1079,64 @@ class MemoryRouteArgs {
}
}
/// generated route for
/// [NativeVideoViewerPage]
class NativeVideoViewerRoute extends PageRouteInfo<NativeVideoViewerRouteArgs> {
NativeVideoViewerRoute({
Key? key,
required Asset asset,
required Widget image,
bool showControls = true,
List<PageRouteInfo>? 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<NativeVideoViewerRouteArgs>();
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<PartnerDetailRouteArgs> {

View file

@ -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<double> 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;
}
}

View file

@ -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<void> Function()? _lastAction;
DateTime? _lastActionTime;
Future<void>? _actionFuture;
void run(FutureOr<void> 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<void>? 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<Object?>? keys,
}) =>
use(_DebouncerHook(interval: interval, keys: keys));
use(
_DebouncerHook(
interval: interval,
maxWaitTime: maxWaitTime,
keys: keys,
),
);
class _DebouncerHook extends Hook<Debouncer> {
const _DebouncerHook({
required this.interval,
this.maxWaitTime,
super.keys,
});
final Duration interval;
final Duration? maxWaitTime;
@override
HookState<Debouncer, Hook<Debouncer>> createState() => _DebouncerHookState();
}
class _DebouncerHookState extends HookState<Debouncer, _DebouncerHook> {
late final debouncer = Debouncer(interval: hook.interval);
late final debouncer = Debouncer(
interval: hook.interval,
maxWaitTime: hook.maxWaitTime,
);
@override
Debouncer build(_) => debouncer;

View file

@ -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<ChewieController> {
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<ChewieController, _ChewieControllerHook> {
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<void> _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,
);
}
*/
}

View file

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

View file

@ -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<void> migrateDatabaseIfNeeded(Isar db) async {
final int version = Store.get(StoreKey.version, 1);

View file

@ -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<void> Function() action) {
T? run<T>(T Function() action) {
if (_lastActionTime == null ||
(DateTime.now().difference(_lastActionTime!) > interval)) {
action();
final response = action();
_lastActionTime = DateTime.now();
return response;
}
return null;
}
void dispose() {

View file

@ -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<ImmichAssetGridView> {
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<ImmichAssetGridView> {
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<ImmichAssetGridView> {
@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<ImmichAssetGridView> {
_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<Asset>) selectAssets;
final Function(List<Asset>) deselectAssets;
final void Function(List<Asset>) selectAssets;
final void Function(List<Asset>) deselectAssets;
final bool Function(List<Asset>) 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<Asset> assets;
final bool selectionActive;
final Function(List<Asset>) selectAssets;
final Function(List<Asset>) deselectAssets;
final Function(List<Asset>) allAssetsSelected;
final void Function(List<Asset>) selectAssets;
final void Function(List<Asset>) deselectAssets;
final bool Function(List<Asset>) 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,

View file

@ -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<int> assetIndex;
final bool showStack;
final int stackIndex;
final ValueNotifier<int> stackIndex;
final ValueNotifier<int> 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))
: <Asset>[];
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,

View file

@ -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,
),
),

View file

@ -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!)
: "";

View file

@ -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,
),

View file

@ -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),
);
}
}

View file

@ -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)

View file

@ -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,
);
}
}

View file

@ -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);
},
),
),

View file

@ -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,

View file

@ -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,
),

View file

@ -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:

View file

@ -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