mirror of
https://github.com/immich-app/immich.git
synced 2025-12-14 08:40:42 -08:00
Compare commits
43 Commits
postgres-q
...
no-video-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d414c2789a | ||
|
|
b15e01e2b6 | ||
|
|
fa3156e6ee | ||
|
|
2ece3d3822 | ||
|
|
128f19efa5 | ||
|
|
20b1572b8e | ||
|
|
2ab1bbabac | ||
|
|
7e74b8d1bb | ||
|
|
dec514bd6d | ||
|
|
0a77a65044 | ||
|
|
4d1d902773 | ||
|
|
60715059f7 | ||
|
|
aa890c0858 | ||
|
|
03211c43f9 | ||
|
|
381fe5c2fd | ||
|
|
937fb4e30e | ||
|
|
71e058af2e | ||
|
|
6c8f7b7e6d | ||
|
|
b0a2a6ac13 | ||
|
|
d1c7ed5464 | ||
|
|
3b9a3d4037 | ||
|
|
613ce513cd | ||
|
|
caee381721 | ||
|
|
e59912e16e | ||
|
|
0010eda67f | ||
|
|
190dbb0042 | ||
|
|
dbca16e352 | ||
|
|
64e23a3b5c | ||
|
|
5766551447 | ||
|
|
7d5294f7ef | ||
|
|
973b146d06 | ||
|
|
3da28a4685 | ||
|
|
de61abb3aa | ||
|
|
bef9a1eae7 | ||
|
|
49c4d7cff9 | ||
|
|
3272ad4a7b | ||
|
|
46a8e9084a | ||
|
|
5e8ee6dc5f | ||
|
|
9c470def18 | ||
|
|
5ebac69647 | ||
|
|
6f3ceb58b8 | ||
|
|
a346a37743 | ||
|
|
a8994ffb22 |
@@ -28,7 +28,7 @@ if (keystorePropertiesFile.exists()) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
compileSdkVersion 34
|
compileSdkVersion 35
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility JavaVersion.VERSION_17
|
sourceCompatibility JavaVersion.VERSION_17
|
||||||
@@ -47,7 +47,7 @@ android {
|
|||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId "app.alextran.immich"
|
applicationId "app.alextran.immich"
|
||||||
minSdkVersion 26
|
minSdkVersion 26
|
||||||
targetSdkVersion 34
|
targetSdkVersion 35
|
||||||
versionCode flutterVersionCode.toInteger()
|
versionCode flutterVersionCode.toInteger()
|
||||||
versionName flutterVersionName
|
versionName flutterVersionName
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
|
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="io.flutter.embedding.android.EnableImpeller"
|
android:name="io.flutter.embedding.android.EnableImpeller"
|
||||||
android:value="false" />
|
android:value="true" />
|
||||||
|
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="com.google.firebase.messaging.default_notification_icon"
|
android:name="com.google.firebase.messaging.default_notification_icon"
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ subprojects {
|
|||||||
if (project.plugins.hasPlugin("com.android.application") ||
|
if (project.plugins.hasPlugin("com.android.application") ||
|
||||||
project.plugins.hasPlugin("com.android.library")) {
|
project.plugins.hasPlugin("com.android.library")) {
|
||||||
project.android {
|
project.android {
|
||||||
compileSdkVersion 34
|
compileSdkVersion 35
|
||||||
buildToolsVersion "34.0.0"
|
buildToolsVersion "35.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,8 @@ PODS:
|
|||||||
- maplibre_gl (0.0.1):
|
- maplibre_gl (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- MapLibre (= 5.14.0-pre3)
|
- MapLibre (= 5.14.0-pre3)
|
||||||
|
- native_video_player (1.0.0):
|
||||||
|
- Flutter
|
||||||
- package_info_plus (0.4.5):
|
- package_info_plus (0.4.5):
|
||||||
- Flutter
|
- Flutter
|
||||||
- path_provider_foundation (0.0.1):
|
- path_provider_foundation (0.0.1):
|
||||||
@@ -115,6 +117,7 @@ DEPENDENCIES:
|
|||||||
- integration_test (from `.symlinks/plugins/integration_test/ios`)
|
- integration_test (from `.symlinks/plugins/integration_test/ios`)
|
||||||
- isar_flutter_libs (from `.symlinks/plugins/isar_flutter_libs/ios`)
|
- isar_flutter_libs (from `.symlinks/plugins/isar_flutter_libs/ios`)
|
||||||
- maplibre_gl (from `.symlinks/plugins/maplibre_gl/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`)
|
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
||||||
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
||||||
- path_provider_ios (from `.symlinks/plugins/path_provider_ios/ios`)
|
- path_provider_ios (from `.symlinks/plugins/path_provider_ios/ios`)
|
||||||
@@ -168,6 +171,8 @@ EXTERNAL SOURCES:
|
|||||||
:path: ".symlinks/plugins/isar_flutter_libs/ios"
|
:path: ".symlinks/plugins/isar_flutter_libs/ios"
|
||||||
maplibre_gl:
|
maplibre_gl:
|
||||||
:path: ".symlinks/plugins/maplibre_gl/ios"
|
:path: ".symlinks/plugins/maplibre_gl/ios"
|
||||||
|
native_video_player:
|
||||||
|
:path: ".symlinks/plugins/native_video_player/ios"
|
||||||
package_info_plus:
|
package_info_plus:
|
||||||
:path: ".symlinks/plugins/package_info_plus/ios"
|
:path: ".symlinks/plugins/package_info_plus/ios"
|
||||||
path_provider_foundation:
|
path_provider_foundation:
|
||||||
@@ -194,7 +199,7 @@ EXTERNAL SOURCES:
|
|||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
background_downloader: 9f788ffc5de45acf87d6380e91ca0841066c18cf
|
background_downloader: 9f788ffc5de45acf87d6380e91ca0841066c18cf
|
||||||
connectivity_plus: ddd7f30999e1faaef5967c23d5b6d503d10434db
|
connectivity_plus: ddd7f30999e1faaef5967c23d5b6d503d10434db
|
||||||
device_info_plus: 97af1d7e84681a90d0693e63169a5d50e0839a0d
|
device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342
|
||||||
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
|
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
|
||||||
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
|
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
|
||||||
file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655
|
file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655
|
||||||
@@ -210,14 +215,15 @@ SPEC CHECKSUMS:
|
|||||||
isar_flutter_libs: fdf730ca925d05687f36d7f1d355e482529ed097
|
isar_flutter_libs: fdf730ca925d05687f36d7f1d355e482529ed097
|
||||||
MapLibre: 620fc933c1d6029b33738c905c1490d024e5d4ef
|
MapLibre: 620fc933c1d6029b33738c905c1490d024e5d4ef
|
||||||
maplibre_gl: a2efec727dd340e4c65e26d2b03b584f14881fd9
|
maplibre_gl: a2efec727dd340e4c65e26d2b03b584f14881fd9
|
||||||
package_info_plus: 58f0028419748fad15bf008b270aaa8e54380b1c
|
native_video_player: d12af78a1a4a8cf09775a5177d5b392def6fd23c
|
||||||
|
package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4
|
||||||
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
|
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
|
||||||
path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02
|
path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02
|
||||||
permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2
|
permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2
|
||||||
photo_manager: ff695c7a1dd5bc379974953a2b5c0a293f7c4c8a
|
photo_manager: ff695c7a1dd5bc379974953a2b5c0a293f7c4c8a
|
||||||
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
|
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
|
||||||
SDWebImage: 066c47b573f408f18caa467d71deace7c0f8280d
|
SDWebImage: 066c47b573f408f18caa467d71deace7c0f8280d
|
||||||
share_plus: 8875f4f2500512ea181eef553c3e27dba5135aad
|
share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f
|
||||||
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
|
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
|
||||||
sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec
|
sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec
|
||||||
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
|
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
|
||||||
@@ -228,4 +234,4 @@ SPEC CHECKSUMS:
|
|||||||
|
|
||||||
PODFILE CHECKSUM: 64c9b5291666c0ca3caabdfe9865c141ac40321d
|
PODFILE CHECKSUM: 64c9b5291666c0ca3caabdfe9865c141ac40321d
|
||||||
|
|
||||||
COCOAPODS: 1.15.2
|
COCOAPODS: 1.16.0
|
||||||
|
|||||||
@@ -1,48 +1,59 @@
|
|||||||
import UIKit
|
|
||||||
import shared_preferences_foundation
|
|
||||||
import Flutter
|
|
||||||
import BackgroundTasks
|
import BackgroundTasks
|
||||||
|
import Flutter
|
||||||
|
import UIKit
|
||||||
import path_provider_ios
|
import path_provider_ios
|
||||||
import photo_manager
|
|
||||||
import permission_handler_apple
|
import permission_handler_apple
|
||||||
|
import photo_manager
|
||||||
|
import shared_preferences_foundation
|
||||||
|
|
||||||
@main
|
@main
|
||||||
@objc class AppDelegate: FlutterAppDelegate {
|
@objc class AppDelegate: FlutterAppDelegate {
|
||||||
|
|
||||||
override func application(
|
override func application(
|
||||||
_ application: UIApplication,
|
_ application: UIApplication,
|
||||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
|
|
||||||
// Required for flutter_local_notification
|
// Required for flutter_local_notification
|
||||||
if #available(iOS 10.0, *) {
|
if #available(iOS 10.0, *) {
|
||||||
UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate
|
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)
|
if !registry.hasPlugin("org.cocoapods.photo-manager") {
|
||||||
BackgroundServicePlugin.registerBackgroundProcessing()
|
PhotoManagerPlugin.register(
|
||||||
|
with: registry.registrar(forPlugin: "org.cocoapods.photo-manager")!)
|
||||||
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")!)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ const String defaultColorPresetName = "indigo";
|
|||||||
const Color immichBrandColorLight = Color(0xFF4150AF);
|
const Color immichBrandColorLight = Color(0xFF4150AF);
|
||||||
const Color immichBrandColorDark = Color(0xFFACCBFA);
|
const Color immichBrandColorDark = Color(0xFFACCBFA);
|
||||||
const Color whiteOpacity75 = Color.fromARGB((0.75 * 255) ~/ 1, 255, 255, 255);
|
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 red400 = Color(0xFFEF5350);
|
||||||
|
const Color grey200 = Color(0xFFEEEEEE);
|
||||||
|
|
||||||
final Map<ImmichColorPreset, ImmichTheme> _themePresetsMap = {
|
final Map<ImmichColorPreset, ImmichTheme> _themePresetsMap = {
|
||||||
ImmichColorPreset.indigo: ImmichTheme(
|
ImmichColorPreset.indigo: ImmichTheme(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:immich_mobile/entities/exif_info.entity.dart';
|
import 'package:immich_mobile/entities/exif_info.entity.dart';
|
||||||
import 'package:immich_mobile/utils/hash.dart';
|
import 'package:immich_mobile/utils/hash.dart';
|
||||||
@@ -22,12 +23,8 @@ class Asset {
|
|||||||
durationInSeconds = remote.duration.toDuration()?.inSeconds ?? 0,
|
durationInSeconds = remote.duration.toDuration()?.inSeconds ?? 0,
|
||||||
type = remote.type.toAssetType(),
|
type = remote.type.toAssetType(),
|
||||||
fileName = remote.originalFileName,
|
fileName = remote.originalFileName,
|
||||||
height = isFlipped(remote)
|
height = remote.exifInfo?.exifImageHeight?.toInt(),
|
||||||
? remote.exifInfo?.exifImageWidth?.toInt()
|
width = remote.exifInfo?.exifImageWidth?.toInt(),
|
||||||
: remote.exifInfo?.exifImageHeight?.toInt(),
|
|
||||||
width = isFlipped(remote)
|
|
||||||
? remote.exifInfo?.exifImageHeight?.toInt()
|
|
||||||
: remote.exifInfo?.exifImageWidth?.toInt(),
|
|
||||||
livePhotoVideoId = remote.livePhotoVideoId,
|
livePhotoVideoId = remote.livePhotoVideoId,
|
||||||
ownerId = fastHash(remote.ownerId),
|
ownerId = fastHash(remote.ownerId),
|
||||||
exifInfo =
|
exifInfo =
|
||||||
@@ -93,6 +90,27 @@ class Asset {
|
|||||||
|
|
||||||
set local(AssetEntity? assetEntity) => _local = assetEntity;
|
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;
|
Id id = Isar.autoIncrement;
|
||||||
|
|
||||||
/// stores the raw SHA1 bytes as a base64 String
|
/// stores the raw SHA1 bytes as a base64 String
|
||||||
@@ -150,10 +168,21 @@ class Asset {
|
|||||||
|
|
||||||
int stackCount;
|
int stackCount;
|
||||||
|
|
||||||
/// Aspect ratio of the asset
|
/// Returns null if the asset has no sync access to the exif info
|
||||||
@ignore
|
@ignore
|
||||||
double? get aspectRatio =>
|
double? get aspectRatio {
|
||||||
width == null || height == null ? 0 : width! / height!;
|
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
|
/// `true` if this [Asset] is present on the device
|
||||||
@ignore
|
@ignore
|
||||||
@@ -172,6 +201,12 @@ class Asset {
|
|||||||
@ignore
|
@ignore
|
||||||
bool get isImage => type == AssetType.image;
|
bool get isImage => type == AssetType.image;
|
||||||
|
|
||||||
|
@ignore
|
||||||
|
bool get isVideo => type == AssetType.video;
|
||||||
|
|
||||||
|
@ignore
|
||||||
|
bool get isMotionPhoto => livePhotoVideoId != null;
|
||||||
|
|
||||||
@ignore
|
@ignore
|
||||||
AssetState get storage {
|
AssetState get storage {
|
||||||
if (isRemote && isLocal) {
|
if (isRemote && isLocal) {
|
||||||
@@ -192,6 +227,50 @@ class Asset {
|
|||||||
@ignore
|
@ignore
|
||||||
set byteHash(List<int> hash) => checksum = base64.encode(hash);
|
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
|
@override
|
||||||
bool operator ==(other) {
|
bool operator ==(other) {
|
||||||
if (other is! Asset) return false;
|
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));
|
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));
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ class ExifInfo {
|
|||||||
String? state;
|
String? state;
|
||||||
String? country;
|
String? country;
|
||||||
String? description;
|
String? description;
|
||||||
|
String? orientation;
|
||||||
|
|
||||||
@ignore
|
@ignore
|
||||||
bool get hasCoordinates =>
|
bool get hasCoordinates =>
|
||||||
@@ -45,6 +46,13 @@ class ExifInfo {
|
|||||||
@ignore
|
@ignore
|
||||||
String get focalLength => mm != null ? mm!.toStringAsFixed(1) : "";
|
String get focalLength => mm != null ? mm!.toStringAsFixed(1) : "";
|
||||||
|
|
||||||
|
@ignore
|
||||||
|
bool? _isFlipped;
|
||||||
|
|
||||||
|
@ignore
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
bool get isFlipped => _isFlipped ??= _isOrientationFlipped(orientation);
|
||||||
|
|
||||||
@ignore
|
@ignore
|
||||||
double? get latitude => lat;
|
double? get latitude => lat;
|
||||||
|
|
||||||
@@ -67,7 +75,8 @@ class ExifInfo {
|
|||||||
city = dto.city,
|
city = dto.city,
|
||||||
state = dto.state,
|
state = dto.state,
|
||||||
country = dto.country,
|
country = dto.country,
|
||||||
description = dto.description;
|
description = dto.description,
|
||||||
|
orientation = dto.orientation;
|
||||||
|
|
||||||
ExifInfo({
|
ExifInfo({
|
||||||
this.id,
|
this.id,
|
||||||
@@ -87,6 +96,7 @@ class ExifInfo {
|
|||||||
this.state,
|
this.state,
|
||||||
this.country,
|
this.country,
|
||||||
this.description,
|
this.description,
|
||||||
|
this.orientation,
|
||||||
});
|
});
|
||||||
|
|
||||||
ExifInfo copyWith({
|
ExifInfo copyWith({
|
||||||
@@ -107,6 +117,7 @@ class ExifInfo {
|
|||||||
String? state,
|
String? state,
|
||||||
String? country,
|
String? country,
|
||||||
String? description,
|
String? description,
|
||||||
|
String? orientation,
|
||||||
}) =>
|
}) =>
|
||||||
ExifInfo(
|
ExifInfo(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
@@ -126,6 +137,7 @@ class ExifInfo {
|
|||||||
state: state ?? this.state,
|
state: state ?? this.state,
|
||||||
country: country ?? this.country,
|
country: country ?? this.country,
|
||||||
description: description ?? this.description,
|
description: description ?? this.description,
|
||||||
|
orientation: orientation ?? this.orientation,
|
||||||
);
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -147,7 +159,8 @@ class ExifInfo {
|
|||||||
city == other.city &&
|
city == other.city &&
|
||||||
state == other.state &&
|
state == other.state &&
|
||||||
country == other.country &&
|
country == other.country &&
|
||||||
description == other.description;
|
description == other.description &&
|
||||||
|
orientation == other.orientation;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -169,7 +182,8 @@ class ExifInfo {
|
|||||||
city.hashCode ^
|
city.hashCode ^
|
||||||
state.hashCode ^
|
state.hashCode ^
|
||||||
country.hashCode ^
|
country.hashCode ^
|
||||||
description.hashCode;
|
description.hashCode ^
|
||||||
|
orientation.hashCode;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
@@ -192,10 +206,21 @@ class ExifInfo {
|
|||||||
state: $state,
|
state: $state,
|
||||||
country: $country,
|
country: $country,
|
||||||
description: $description,
|
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) {
|
double? _exposureTimeToSeconds(String? s) {
|
||||||
if (s == null) {
|
if (s == null) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
213
mobile/lib/entities/exif_info.entity.g.dart
generated
213
mobile/lib/entities/exif_info.entity.g.dart
generated
@@ -87,13 +87,18 @@ const ExifInfoSchema = CollectionSchema(
|
|||||||
name: r'model',
|
name: r'model',
|
||||||
type: IsarType.string,
|
type: IsarType.string,
|
||||||
),
|
),
|
||||||
r'state': PropertySchema(
|
r'orientation': PropertySchema(
|
||||||
id: 14,
|
id: 14,
|
||||||
|
name: r'orientation',
|
||||||
|
type: IsarType.string,
|
||||||
|
),
|
||||||
|
r'state': PropertySchema(
|
||||||
|
id: 15,
|
||||||
name: r'state',
|
name: r'state',
|
||||||
type: IsarType.string,
|
type: IsarType.string,
|
||||||
),
|
),
|
||||||
r'timeZone': PropertySchema(
|
r'timeZone': PropertySchema(
|
||||||
id: 15,
|
id: 16,
|
||||||
name: r'timeZone',
|
name: r'timeZone',
|
||||||
type: IsarType.string,
|
type: IsarType.string,
|
||||||
)
|
)
|
||||||
@@ -154,6 +159,12 @@ int _exifInfoEstimateSize(
|
|||||||
bytesCount += 3 + value.length * 3;
|
bytesCount += 3 + value.length * 3;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
final value = object.orientation;
|
||||||
|
if (value != null) {
|
||||||
|
bytesCount += 3 + value.length * 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
{
|
{
|
||||||
final value = object.state;
|
final value = object.state;
|
||||||
if (value != null) {
|
if (value != null) {
|
||||||
@@ -189,8 +200,9 @@ void _exifInfoSerialize(
|
|||||||
writer.writeString(offsets[11], object.make);
|
writer.writeString(offsets[11], object.make);
|
||||||
writer.writeFloat(offsets[12], object.mm);
|
writer.writeFloat(offsets[12], object.mm);
|
||||||
writer.writeString(offsets[13], object.model);
|
writer.writeString(offsets[13], object.model);
|
||||||
writer.writeString(offsets[14], object.state);
|
writer.writeString(offsets[14], object.orientation);
|
||||||
writer.writeString(offsets[15], object.timeZone);
|
writer.writeString(offsets[15], object.state);
|
||||||
|
writer.writeString(offsets[16], object.timeZone);
|
||||||
}
|
}
|
||||||
|
|
||||||
ExifInfo _exifInfoDeserialize(
|
ExifInfo _exifInfoDeserialize(
|
||||||
@@ -215,8 +227,9 @@ ExifInfo _exifInfoDeserialize(
|
|||||||
make: reader.readStringOrNull(offsets[11]),
|
make: reader.readStringOrNull(offsets[11]),
|
||||||
mm: reader.readFloatOrNull(offsets[12]),
|
mm: reader.readFloatOrNull(offsets[12]),
|
||||||
model: reader.readStringOrNull(offsets[13]),
|
model: reader.readStringOrNull(offsets[13]),
|
||||||
state: reader.readStringOrNull(offsets[14]),
|
orientation: reader.readStringOrNull(offsets[14]),
|
||||||
timeZone: reader.readStringOrNull(offsets[15]),
|
state: reader.readStringOrNull(offsets[15]),
|
||||||
|
timeZone: reader.readStringOrNull(offsets[16]),
|
||||||
);
|
);
|
||||||
return object;
|
return object;
|
||||||
}
|
}
|
||||||
@@ -260,6 +273,8 @@ P _exifInfoDeserializeProp<P>(
|
|||||||
return (reader.readStringOrNull(offset)) as P;
|
return (reader.readStringOrNull(offset)) as P;
|
||||||
case 15:
|
case 15:
|
||||||
return (reader.readStringOrNull(offset)) as P;
|
return (reader.readStringOrNull(offset)) as P;
|
||||||
|
case 16:
|
||||||
|
return (reader.readStringOrNull(offset)) as P;
|
||||||
default:
|
default:
|
||||||
throw IsarError('Unknown property with id $propertyId');
|
throw IsarError('Unknown property with id $propertyId');
|
||||||
}
|
}
|
||||||
@@ -1909,6 +1924,155 @@ extension ExifInfoQueryFilter
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> orientationIsNull() {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addFilterCondition(const FilterCondition.isNull(
|
||||||
|
property: r'orientation',
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition>
|
||||||
|
orientationIsNotNull() {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addFilterCondition(const FilterCondition.isNotNull(
|
||||||
|
property: r'orientation',
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> orientationEqualTo(
|
||||||
|
String? value, {
|
||||||
|
bool caseSensitive = true,
|
||||||
|
}) {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addFilterCondition(FilterCondition.equalTo(
|
||||||
|
property: r'orientation',
|
||||||
|
value: value,
|
||||||
|
caseSensitive: caseSensitive,
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition>
|
||||||
|
orientationGreaterThan(
|
||||||
|
String? value, {
|
||||||
|
bool include = false,
|
||||||
|
bool caseSensitive = true,
|
||||||
|
}) {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addFilterCondition(FilterCondition.greaterThan(
|
||||||
|
include: include,
|
||||||
|
property: r'orientation',
|
||||||
|
value: value,
|
||||||
|
caseSensitive: caseSensitive,
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> orientationLessThan(
|
||||||
|
String? value, {
|
||||||
|
bool include = false,
|
||||||
|
bool caseSensitive = true,
|
||||||
|
}) {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addFilterCondition(FilterCondition.lessThan(
|
||||||
|
include: include,
|
||||||
|
property: r'orientation',
|
||||||
|
value: value,
|
||||||
|
caseSensitive: caseSensitive,
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> orientationBetween(
|
||||||
|
String? lower,
|
||||||
|
String? upper, {
|
||||||
|
bool includeLower = true,
|
||||||
|
bool includeUpper = true,
|
||||||
|
bool caseSensitive = true,
|
||||||
|
}) {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addFilterCondition(FilterCondition.between(
|
||||||
|
property: r'orientation',
|
||||||
|
lower: lower,
|
||||||
|
includeLower: includeLower,
|
||||||
|
upper: upper,
|
||||||
|
includeUpper: includeUpper,
|
||||||
|
caseSensitive: caseSensitive,
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> orientationStartsWith(
|
||||||
|
String value, {
|
||||||
|
bool caseSensitive = true,
|
||||||
|
}) {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addFilterCondition(FilterCondition.startsWith(
|
||||||
|
property: r'orientation',
|
||||||
|
value: value,
|
||||||
|
caseSensitive: caseSensitive,
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> orientationEndsWith(
|
||||||
|
String value, {
|
||||||
|
bool caseSensitive = true,
|
||||||
|
}) {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addFilterCondition(FilterCondition.endsWith(
|
||||||
|
property: r'orientation',
|
||||||
|
value: value,
|
||||||
|
caseSensitive: caseSensitive,
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> orientationContains(
|
||||||
|
String value,
|
||||||
|
{bool caseSensitive = true}) {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addFilterCondition(FilterCondition.contains(
|
||||||
|
property: r'orientation',
|
||||||
|
value: value,
|
||||||
|
caseSensitive: caseSensitive,
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> orientationMatches(
|
||||||
|
String pattern,
|
||||||
|
{bool caseSensitive = true}) {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addFilterCondition(FilterCondition.matches(
|
||||||
|
property: r'orientation',
|
||||||
|
wildcard: pattern,
|
||||||
|
caseSensitive: caseSensitive,
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> orientationIsEmpty() {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addFilterCondition(FilterCondition.equalTo(
|
||||||
|
property: r'orientation',
|
||||||
|
value: '',
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition>
|
||||||
|
orientationIsNotEmpty() {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addFilterCondition(FilterCondition.greaterThan(
|
||||||
|
property: r'orientation',
|
||||||
|
value: '',
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> stateIsNull() {
|
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> stateIsNull() {
|
||||||
return QueryBuilder.apply(this, (query) {
|
return QueryBuilder.apply(this, (query) {
|
||||||
return query.addFilterCondition(const FilterCondition.isNull(
|
return query.addFilterCondition(const FilterCondition.isNull(
|
||||||
@@ -2377,6 +2541,18 @@ extension ExifInfoQuerySortBy on QueryBuilder<ExifInfo, ExifInfo, QSortBy> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> sortByOrientation() {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addSortBy(r'orientation', Sort.asc);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> sortByOrientationDesc() {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addSortBy(r'orientation', Sort.desc);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> sortByState() {
|
QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> sortByState() {
|
||||||
return QueryBuilder.apply(this, (query) {
|
return QueryBuilder.apply(this, (query) {
|
||||||
return query.addSortBy(r'state', Sort.asc);
|
return query.addSortBy(r'state', Sort.asc);
|
||||||
@@ -2584,6 +2760,18 @@ extension ExifInfoQuerySortThenBy
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> thenByOrientation() {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addSortBy(r'orientation', Sort.asc);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> thenByOrientationDesc() {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addSortBy(r'orientation', Sort.desc);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> thenByState() {
|
QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> thenByState() {
|
||||||
return QueryBuilder.apply(this, (query) {
|
return QueryBuilder.apply(this, (query) {
|
||||||
return query.addSortBy(r'state', Sort.asc);
|
return query.addSortBy(r'state', Sort.asc);
|
||||||
@@ -2701,6 +2889,13 @@ extension ExifInfoQueryWhereDistinct
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
QueryBuilder<ExifInfo, ExifInfo, QDistinct> distinctByOrientation(
|
||||||
|
{bool caseSensitive = true}) {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addDistinctBy(r'orientation', caseSensitive: caseSensitive);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
QueryBuilder<ExifInfo, ExifInfo, QDistinct> distinctByState(
|
QueryBuilder<ExifInfo, ExifInfo, QDistinct> distinctByState(
|
||||||
{bool caseSensitive = true}) {
|
{bool caseSensitive = true}) {
|
||||||
return QueryBuilder.apply(this, (query) {
|
return QueryBuilder.apply(this, (query) {
|
||||||
@@ -2809,6 +3004,12 @@ extension ExifInfoQueryProperty
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
QueryBuilder<ExifInfo, String?, QQueryOperations> orientationProperty() {
|
||||||
|
return QueryBuilder.apply(this, (query) {
|
||||||
|
return query.addPropertyName(r'orientation');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
QueryBuilder<ExifInfo, String?, QQueryOperations> stateProperty() {
|
QueryBuilder<ExifInfo, String?, QQueryOperations> stateProperty() {
|
||||||
return QueryBuilder.apply(this, (query) {
|
return QueryBuilder.apply(this, (query) {
|
||||||
return query.addPropertyName(r'state');
|
return query.addPropertyName(r'state');
|
||||||
|
|||||||
32
mobile/lib/extensions/scroll_extensions.dart
Normal file
32
mobile/lib/extensions/scroll_extensions.dart
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import 'package:flutter/cupertino.dart';
|
||||||
|
|
||||||
|
const _spring = SpringDescription(
|
||||||
|
mass: 40,
|
||||||
|
stiffness: 100,
|
||||||
|
damping: 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
// https://stackoverflow.com/a/74453792
|
||||||
|
class FastScrollPhysics extends ScrollPhysics {
|
||||||
|
const FastScrollPhysics({super.parent});
|
||||||
|
|
||||||
|
@override
|
||||||
|
FastScrollPhysics applyTo(ScrollPhysics? ancestor) {
|
||||||
|
return FastScrollPhysics(parent: buildParent(ancestor));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
SpringDescription get spring => _spring;
|
||||||
|
}
|
||||||
|
|
||||||
|
class FastClampingScrollPhysics extends ClampingScrollPhysics {
|
||||||
|
const FastClampingScrollPhysics({super.parent});
|
||||||
|
|
||||||
|
@override
|
||||||
|
FastClampingScrollPhysics applyTo(ScrollPhysics? ancestor) {
|
||||||
|
return FastClampingScrollPhysics(parent: buildParent(ancestor));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
SpringDescription get spring => _spring;
|
||||||
|
}
|
||||||
82
mobile/lib/pages/common/gallery_stacked_children.dart
Normal file
82
mobile/lib/pages/common/gallery_stacked_children.dart
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
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/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));
|
||||||
|
|
||||||
|
return 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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,18 +8,19 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
|
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/constants/constants.dart';
|
|
||||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
|
import 'package:immich_mobile/extensions/scroll_extensions.dart';
|
||||||
import 'package:immich_mobile/pages/common/download_panel.dart';
|
import 'package:immich_mobile/pages/common/download_panel.dart';
|
||||||
import 'package:immich_mobile/pages/common/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/app_settings.provider.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/asset_stack.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/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/show_controls.provider.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
||||||
import 'package:immich_mobile/providers/haptic_feedback.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/services/app_settings.service.dart';
|
||||||
import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart';
|
import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart';
|
||||||
import 'package:immich_mobile/widgets/asset_viewer/advanced_bottom_sheet.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()
|
@RoutePage()
|
||||||
// ignore: must_be_immutable
|
// ignore: must_be_immutable
|
||||||
|
/// Expects [currentAssetProvider] to be set before navigating to this page
|
||||||
class GalleryViewerPage extends HookConsumerWidget {
|
class GalleryViewerPage extends HookConsumerWidget {
|
||||||
final int initialIndex;
|
final int initialIndex;
|
||||||
final int heroOffset;
|
final int heroOffset;
|
||||||
@@ -53,79 +55,66 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final settings = ref.watch(appSettingsServiceProvider);
|
|
||||||
final loadAsset = renderList.loadAsset;
|
|
||||||
final totalAssets = useState(renderList.totalAssets);
|
final totalAssets = useState(renderList.totalAssets);
|
||||||
final shouldLoopVideo = useState(AppSettingsEnum.loopVideo.defaultValue);
|
|
||||||
final isZoomed = useState(false);
|
final isZoomed = useState(false);
|
||||||
final isPlayingVideo = useState(false);
|
final stackIndex = useState(0);
|
||||||
final localPosition = useState<Offset?>(null);
|
final localPosition = useRef<Offset?>(null);
|
||||||
final currentIndex = useState(initialIndex);
|
final currentIndex = useValueNotifier(initialIndex);
|
||||||
final currentAsset = loadAsset(currentIndex.value);
|
final loadAsset = renderList.loadAsset;
|
||||||
|
|
||||||
// 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;
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
Future<void> precacheNextImage(int index) async {
|
Future<void> precacheNextImage(int index) async {
|
||||||
|
if (!context.mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
void onError(Object exception, StackTrace? stackTrace) {
|
void onError(Object exception, StackTrace? stackTrace) {
|
||||||
// swallow error silently
|
// swallow error silently
|
||||||
debugPrint('Error precaching next image: $exception, $stackTrace');
|
log.severe('Error precaching next image: $exception, $stackTrace');
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (index < totalAssets.value && index >= 0) {
|
if (index < totalAssets.value && index >= 0) {
|
||||||
final asset = loadAsset(index);
|
final asset = loadAsset(index);
|
||||||
await precacheImage(
|
await precacheImage(
|
||||||
ImmichImage.imageProvider(asset: asset),
|
ImmichImage.imageProvider(
|
||||||
|
asset: asset,
|
||||||
|
width: context.width,
|
||||||
|
height: context.height,
|
||||||
|
),
|
||||||
context,
|
context,
|
||||||
onError: onError,
|
onError: onError,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// swallow error silently
|
// swallow error silently
|
||||||
debugPrint('Error precaching next image: $e');
|
log.severe('Error precaching next image: $e');
|
||||||
context.maybePop();
|
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() {
|
void showInfo() {
|
||||||
|
final asset = ref.read(currentAssetProvider);
|
||||||
|
if (asset == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
shape: const RoundedRectangleBorder(
|
shape: const RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.all(Radius.circular(15.0)),
|
borderRadius: BorderRadius.all(Radius.circular(15.0)),
|
||||||
@@ -183,34 +172,6 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(
|
|
||||||
() {
|
|
||||||
if (ref.read(showControlsProvider)) {
|
|
||||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
|
||||||
} else {
|
|
||||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
|
|
||||||
}
|
|
||||||
isPlayingVideo.value = false;
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(
|
|
||||||
() {
|
|
||||||
// No need to await this
|
|
||||||
unawaited(
|
|
||||||
// Delay this a bit so we can finish loading the page
|
|
||||||
Future.delayed(const Duration(milliseconds: 400)).then(
|
|
||||||
// Precache the next image
|
|
||||||
(_) => precacheNextImage(currentIndex.value + 1),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
ref.listen(showControlsProvider, (_, show) {
|
ref.listen(showControlsProvider, (_, show) {
|
||||||
if (show) {
|
if (show) {
|
||||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||||
@@ -219,50 +180,88 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
Widget buildStackedChildren() {
|
PhotoViewGalleryPageOptions buildImage(BuildContext context, Asset asset) {
|
||||||
return ListView.builder(
|
return PhotoViewGalleryPageOptions(
|
||||||
shrinkWrap: true,
|
onDragStart: (_, details, __) {
|
||||||
scrollDirection: Axis.horizontal,
|
localPosition.value = details.localPosition;
|
||||||
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!),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
|
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(
|
return PopScope(
|
||||||
// Change immersive mode back to normal "edgeToEdge" mode
|
// Change immersive mode back to normal "edgeToEdge" mode
|
||||||
onPopInvokedWithResult: (didPop, _) =>
|
onPopInvokedWithResult: (didPop, _) =>
|
||||||
@@ -272,128 +271,79 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||||||
body: Stack(
|
body: Stack(
|
||||||
children: [
|
children: [
|
||||||
PhotoViewGallery.builder(
|
PhotoViewGallery.builder(
|
||||||
|
key: const ValueKey('gallery'),
|
||||||
scaleStateChangedCallback: (state) {
|
scaleStateChangedCallback: (state) {
|
||||||
isZoomed.value = state != PhotoViewScaleState.initial;
|
final asset = ref.read(currentAssetProvider);
|
||||||
ref.read(showControlsProvider.notifier).show = !isZoomed.value;
|
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(
|
gaplessPlayback: true,
|
||||||
child: Stack(
|
loadingBuilder: (context, event, index) {
|
||||||
fit: StackFit.expand,
|
final asset = loadAsset(index);
|
||||||
children: [
|
return ClipRect(
|
||||||
BackdropFilter(
|
child: Stack(
|
||||||
filter: ui.ImageFilter.blur(
|
fit: StackFit.expand,
|
||||||
sigmaX: 10,
|
children: [
|
||||||
sigmaY: 10,
|
BackdropFilter(
|
||||||
|
filter: ui.ImageFilter.blur(
|
||||||
|
sigmaX: 10,
|
||||||
|
sigmaY: 10,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
ImmichThumbnail(
|
||||||
ImmichThumbnail(
|
key: ValueKey(asset),
|
||||||
asset: asset,
|
asset: asset,
|
||||||
fit: BoxFit.contain,
|
fit: BoxFit.contain,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
|
},
|
||||||
pageController: controller,
|
pageController: controller,
|
||||||
scrollPhysics: isZoomed.value
|
scrollPhysics: isZoomed.value
|
||||||
? const NeverScrollableScrollPhysics() // Don't allow paging while scrolled in
|
? const NeverScrollableScrollPhysics() // Don't allow paging while scrolled in
|
||||||
: (Platform.isIOS
|
: (Platform.isIOS
|
||||||
? const ScrollPhysics() // Use bouncing physics for iOS
|
? const FastScrollPhysics() // Use bouncing physics for iOS
|
||||||
: const ClampingScrollPhysics() // Use heavy physics for Android
|
: const FastClampingScrollPhysics() // Use heavy physics for Android
|
||||||
),
|
),
|
||||||
itemCount: totalAssets.value,
|
itemCount: totalAssets.value,
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
onPageChanged: (value) async {
|
onPageChanged: (value) {
|
||||||
final next = currentIndex.value < value ? value + 1 : value - 1;
|
final next = currentIndex.value < value ? value + 1 : value - 1;
|
||||||
|
|
||||||
ref.read(hapticFeedbackProvider.notifier).selectionClick();
|
ref.read(hapticFeedbackProvider.notifier).selectionClick();
|
||||||
|
|
||||||
|
final newAsset = loadAsset(value);
|
||||||
|
|
||||||
currentIndex.value = value;
|
currentIndex.value = value;
|
||||||
stackIndex.value = -1;
|
stackIndex.value = 0;
|
||||||
isPlayingVideo.value = false;
|
|
||||||
|
|
||||||
// Wait for page change animation to finish
|
ref.read(currentAssetProvider.notifier).set(newAsset);
|
||||||
await Future.delayed(const Duration(milliseconds: 400));
|
if (newAsset.isVideo || newAsset.isMotionPhoto) {
|
||||||
// Then precache the next image
|
ref.read(videoPlaybackValueProvider.notifier).reset();
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wait for page change animation to finish, then precache the next image
|
||||||
|
Timer(const Duration(milliseconds: 400), () {
|
||||||
|
precacheNextImage(next);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
builder: buildAsset,
|
||||||
),
|
),
|
||||||
Positioned(
|
Positioned(
|
||||||
top: 0,
|
top: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
child: GalleryAppBar(
|
child: GalleryAppBar(
|
||||||
asset: asset,
|
key: const ValueKey('app-bar'),
|
||||||
showInfo: showInfo,
|
showInfo: showInfo,
|
||||||
isPlayingVideo: isPlayingVideo.value,
|
|
||||||
onToggleMotionVideo: () =>
|
|
||||||
isPlayingVideo.value = !isPlayingVideo.value,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Positioned(
|
Positioned(
|
||||||
@@ -402,22 +352,15 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||||||
right: 0,
|
right: 0,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Visibility(
|
GalleryStackedChildren(stackIndex),
|
||||||
visible: stack.isNotEmpty,
|
|
||||||
child: SizedBox(
|
|
||||||
height: 80,
|
|
||||||
child: buildStackedChildren(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
BottomGalleryBar(
|
BottomGalleryBar(
|
||||||
|
key: const ValueKey('bottom-bar'),
|
||||||
renderList: renderList,
|
renderList: renderList,
|
||||||
totalAssets: totalAssets,
|
totalAssets: totalAssets,
|
||||||
controller: controller,
|
controller: controller,
|
||||||
showStack: showStack,
|
showStack: showStack,
|
||||||
stackIndex: stackIndex.value,
|
stackIndex: stackIndex.value,
|
||||||
asset: asset,
|
|
||||||
assetIndex: currentIndex,
|
assetIndex: currentIndex,
|
||||||
showVideoPlayerControls: !asset.isImage && !isMotionPhoto,
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -428,4 +371,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,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
429
mobile/lib/pages/common/native_video_viewer.page.dart
Normal file
429
mobile/lib/pages/common/native_video_viewer.page.dart
Normal file
@@ -0,0 +1,429 @@
|
|||||||
|
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 volume changes, set the volume
|
||||||
|
ref.listen(videoPlayerControlsProvider.select((value) => value.mute),
|
||||||
|
(_, mute) async {
|
||||||
|
final playerController = controller.value;
|
||||||
|
if (playerController == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final playbackInfo = playerController.playbackInfo;
|
||||||
|
if (playbackInfo == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (mute && playbackInfo.volume != 0.0) {
|
||||||
|
await playerController.setVolume(0.0);
|
||||||
|
} else if (!mute && playbackInfo.volume != 0.9) {
|
||||||
|
await playerController.setVolume(0.9);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log.severe('Error setting volume: $error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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),
|
||||||
|
);
|
||||||
|
Future<void> onPlayerControlsPlayChange(bool? _, bool pause) async {
|
||||||
|
final videoController = controller.value;
|
||||||
|
if (videoController == null || !context.mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (pause) {
|
||||||
|
await videoController.pause();
|
||||||
|
} else {
|
||||||
|
await videoController.play();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log.severe('Error pausing or playing video: $error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ref.listen(videoPlayerControlsProvider.select((value) => value.position),
|
||||||
|
(_, position) {
|
||||||
|
final playerController = controller.value;
|
||||||
|
if (playerController == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final playbackInfo = playerController.playbackInfo;
|
||||||
|
if (playbackInfo == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the position to seek to
|
||||||
|
final seek = position ~/ 1;
|
||||||
|
if (seek != playbackInfo.position) {
|
||||||
|
seekDebouncer.run(() => playerController.seekTo(seek));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Platform.isIOS &&
|
||||||
|
seek == 0 &&
|
||||||
|
!ref.read(videoPlayerControlsProvider.notifier).paused) {
|
||||||
|
onPlayerControlsPlayChange(null, false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// // When the custom video controls pause or play
|
||||||
|
ref.listen(
|
||||||
|
videoPlayerControlsProvider.select((value) => value.pause),
|
||||||
|
onPlayerControlsPlayChange,
|
||||||
|
);
|
||||||
|
|
||||||
|
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 removeListeners(NativeVideoPlayerController controller) {
|
||||||
|
controller.onPlaybackPositionChanged
|
||||||
|
.removeListener(onPlaybackPositionChanged);
|
||||||
|
controller.onPlaybackStatusChanged
|
||||||
|
.removeListener(onPlaybackStatusChanged);
|
||||||
|
controller.onPlaybackReady.removeListener(onPlaybackReady);
|
||||||
|
}
|
||||||
|
|
||||||
|
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.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No need to delay video playback when swiping from an image to a video
|
||||||
|
if (curAsset != null && !curAsset.isVideo) {
|
||||||
|
currentAsset.value = value;
|
||||||
|
onPlaybackReady();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delay the video playback to avoid a stutter in the swipe animation
|
||||||
|
Timer(const Duration(milliseconds: 300), () {
|
||||||
|
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.severe('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()),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,168 +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/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],
|
|
||||||
);
|
|
||||||
|
|
||||||
final size = MediaQuery.sizeOf(context);
|
|
||||||
|
|
||||||
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: size.height,
|
|
||||||
width: size.width,
|
|
||||||
child: VideoPlayerViewer(
|
|
||||||
controller: controller,
|
|
||||||
isMotionVideo: isMotionVideo,
|
|
||||||
placeholder: placeholder,
|
|
||||||
hideControlsTimer: hideControlsTimer,
|
|
||||||
showControls: showControls,
|
|
||||||
showDownloadingIndicator: showDownloadingIndicator,
|
|
||||||
loopVideo: loopVideo,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -113,11 +113,15 @@ class MemoryPage extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Precache the asset
|
// Precache the asset
|
||||||
|
final size = MediaQuery.sizeOf(context);
|
||||||
await precacheImage(
|
await precacheImage(
|
||||||
ImmichImage.imageProvider(
|
ImmichImage.imageProvider(
|
||||||
asset: asset,
|
asset: asset,
|
||||||
|
width: size.width,
|
||||||
|
height: size.height,
|
||||||
),
|
),
|
||||||
context,
|
context,
|
||||||
|
size: size,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import 'package:immich_mobile/extensions/latlngbounds_extension.dart';
|
|||||||
import 'package:immich_mobile/extensions/maplibrecontroller_extensions.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_event.model.dart';
|
||||||
import 'package:immich_mobile/models/map/map_marker.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/db.provider.dart';
|
||||||
import 'package:immich_mobile/providers/map/map_marker.provider.dart';
|
import 'package:immich_mobile/providers/map/map_marker.provider.dart';
|
||||||
import 'package:immich_mobile/providers/map/map_state.provider.dart';
|
import 'package:immich_mobile/providers/map/map_state.provider.dart';
|
||||||
@@ -99,8 +101,11 @@ class MapPage extends HookConsumerWidget {
|
|||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
() {
|
() {
|
||||||
|
final currentAssetLink =
|
||||||
|
ref.read(currentAssetProvider.notifier).ref.keepAlive();
|
||||||
|
|
||||||
loadMarkers();
|
loadMarkers();
|
||||||
return null;
|
return currentAssetLink.close;
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
@@ -186,6 +191,10 @@ class MapPage extends HookConsumerWidget {
|
|||||||
GroupAssetsBy.none,
|
GroupAssetsBy.none,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
ref.read(currentAssetProvider.notifier).set(asset);
|
||||||
|
if (asset.isVideo) {
|
||||||
|
ref.read(showControlsProvider.notifier).show = false;
|
||||||
|
}
|
||||||
context.pushRoute(
|
context.pushRoute(
|
||||||
GalleryViewerRoute(
|
GalleryViewerRoute(
|
||||||
initialIndex: 0,
|
initialIndex: 0,
|
||||||
|
|||||||
@@ -7,49 +7,49 @@ import 'package:riverpod_annotation/riverpod_annotation.dart';
|
|||||||
part 'asset_stack.provider.g.dart';
|
part 'asset_stack.provider.g.dart';
|
||||||
|
|
||||||
class AssetStackNotifier extends StateNotifier<List<Asset>> {
|
class AssetStackNotifier extends StateNotifier<List<Asset>> {
|
||||||
final Asset _asset;
|
final String _stackId;
|
||||||
final Ref _ref;
|
final Ref _ref;
|
||||||
|
|
||||||
AssetStackNotifier(
|
AssetStackNotifier(this._stackId, this._ref) : super([]) {
|
||||||
this._asset,
|
_fetchStack(_stackId);
|
||||||
this._ref,
|
|
||||||
) : super([]) {
|
|
||||||
fetchStackChildren();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void fetchStackChildren() async {
|
void _fetchStack(String stackId) async {
|
||||||
if (mounted) {
|
if (!mounted) {
|
||||||
state = await _ref.read(assetStackProvider(_asset).future);
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final stack = await _ref.read(assetStackProvider(stackId).future);
|
||||||
|
if (stack.isNotEmpty) {
|
||||||
|
state = stack;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void removeChild(int index) {
|
void removeChild(int index) {
|
||||||
if (index < state.length) {
|
if (index < state.length) {
|
||||||
state.removeAt(index);
|
state.removeAt(index);
|
||||||
|
state = List<Asset>.from(state);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final assetStackStateProvider = StateNotifierProvider.autoDispose
|
final assetStackStateProvider = StateNotifierProvider.autoDispose
|
||||||
.family<AssetStackNotifier, List<Asset>, Asset>(
|
.family<AssetStackNotifier, List<Asset>, String>(
|
||||||
(ref, asset) => AssetStackNotifier(asset, ref),
|
(ref, stackId) => AssetStackNotifier(stackId, ref),
|
||||||
);
|
);
|
||||||
|
|
||||||
final assetStackProvider =
|
final assetStackProvider =
|
||||||
FutureProvider.autoDispose.family<List<Asset>, Asset>((ref, asset) async {
|
FutureProvider.autoDispose.family<List<Asset>, String>((ref, stackId) {
|
||||||
// Guard [local asset]
|
return ref
|
||||||
if (asset.remoteId == null) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return await ref
|
|
||||||
.watch(dbProvider)
|
.watch(dbProvider)
|
||||||
.assets
|
.assets
|
||||||
.filter()
|
.filter()
|
||||||
.isArchivedEqualTo(false)
|
.isArchivedEqualTo(false)
|
||||||
.isTrashedEqualTo(false)
|
.isTrashedEqualTo(false)
|
||||||
.stackPrimaryAssetIdEqualTo(asset.remoteId)
|
.stackIdEqualTo(stackId)
|
||||||
.sortByFileCreatedAtDesc()
|
// orders primary asset first as its ID is null
|
||||||
|
.sortByStackPrimaryAssetId()
|
||||||
|
.thenByFileCreatedAtDesc()
|
||||||
.findAll();
|
.findAll();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
|
||||||
|
|
||||||
part of 'video_player_controller_provider.dart';
|
|
||||||
|
|
||||||
// **************************************************************************
|
|
||||||
// RiverpodGenerator
|
|
||||||
// **************************************************************************
|
|
||||||
|
|
||||||
String _$videoPlayerControllerHash() =>
|
|
||||||
r'84b2961cc2aeaf9d03255dbf9b9484619d0c24f5';
|
|
||||||
|
|
||||||
/// Copied from Dart SDK
|
|
||||||
class _SystemHash {
|
|
||||||
_SystemHash._();
|
|
||||||
|
|
||||||
static int combine(int hash, int value) {
|
|
||||||
// ignore: parameter_assignments
|
|
||||||
hash = 0x1fffffff & (hash + value);
|
|
||||||
// ignore: parameter_assignments
|
|
||||||
hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10));
|
|
||||||
return hash ^ (hash >> 6);
|
|
||||||
}
|
|
||||||
|
|
||||||
static int finish(int hash) {
|
|
||||||
// ignore: parameter_assignments
|
|
||||||
hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3));
|
|
||||||
// ignore: parameter_assignments
|
|
||||||
hash = hash ^ (hash >> 11);
|
|
||||||
return 0x1fffffff & (hash + ((0x00003fff & hash) << 15));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// See also [videoPlayerController].
|
|
||||||
@ProviderFor(videoPlayerController)
|
|
||||||
const videoPlayerControllerProvider = VideoPlayerControllerFamily();
|
|
||||||
|
|
||||||
/// See also [videoPlayerController].
|
|
||||||
class VideoPlayerControllerFamily
|
|
||||||
extends Family<AsyncValue<VideoPlayerController>> {
|
|
||||||
/// See also [videoPlayerController].
|
|
||||||
const VideoPlayerControllerFamily();
|
|
||||||
|
|
||||||
/// See also [videoPlayerController].
|
|
||||||
VideoPlayerControllerProvider call({
|
|
||||||
required Asset asset,
|
|
||||||
}) {
|
|
||||||
return VideoPlayerControllerProvider(
|
|
||||||
asset: asset,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
VideoPlayerControllerProvider getProviderOverride(
|
|
||||||
covariant VideoPlayerControllerProvider provider,
|
|
||||||
) {
|
|
||||||
return call(
|
|
||||||
asset: provider.asset,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
static const Iterable<ProviderOrFamily>? _dependencies = null;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
|
|
||||||
|
|
||||||
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
|
|
||||||
_allTransitiveDependencies;
|
|
||||||
|
|
||||||
@override
|
|
||||||
String? get name => r'videoPlayerControllerProvider';
|
|
||||||
}
|
|
||||||
|
|
||||||
/// See also [videoPlayerController].
|
|
||||||
class VideoPlayerControllerProvider
|
|
||||||
extends AutoDisposeFutureProvider<VideoPlayerController> {
|
|
||||||
/// See also [videoPlayerController].
|
|
||||||
VideoPlayerControllerProvider({
|
|
||||||
required Asset asset,
|
|
||||||
}) : this._internal(
|
|
||||||
(ref) => videoPlayerController(
|
|
||||||
ref as VideoPlayerControllerRef,
|
|
||||||
asset: asset,
|
|
||||||
),
|
|
||||||
from: videoPlayerControllerProvider,
|
|
||||||
name: r'videoPlayerControllerProvider',
|
|
||||||
debugGetCreateSourceHash:
|
|
||||||
const bool.fromEnvironment('dart.vm.product')
|
|
||||||
? null
|
|
||||||
: _$videoPlayerControllerHash,
|
|
||||||
dependencies: VideoPlayerControllerFamily._dependencies,
|
|
||||||
allTransitiveDependencies:
|
|
||||||
VideoPlayerControllerFamily._allTransitiveDependencies,
|
|
||||||
asset: asset,
|
|
||||||
);
|
|
||||||
|
|
||||||
VideoPlayerControllerProvider._internal(
|
|
||||||
super._createNotifier, {
|
|
||||||
required super.name,
|
|
||||||
required super.dependencies,
|
|
||||||
required super.allTransitiveDependencies,
|
|
||||||
required super.debugGetCreateSourceHash,
|
|
||||||
required super.from,
|
|
||||||
required this.asset,
|
|
||||||
}) : super.internal();
|
|
||||||
|
|
||||||
final Asset asset;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Override overrideWith(
|
|
||||||
FutureOr<VideoPlayerController> Function(VideoPlayerControllerRef provider)
|
|
||||||
create,
|
|
||||||
) {
|
|
||||||
return ProviderOverride(
|
|
||||||
origin: this,
|
|
||||||
override: VideoPlayerControllerProvider._internal(
|
|
||||||
(ref) => create(ref as VideoPlayerControllerRef),
|
|
||||||
from: from,
|
|
||||||
name: null,
|
|
||||||
dependencies: null,
|
|
||||||
allTransitiveDependencies: null,
|
|
||||||
debugGetCreateSourceHash: null,
|
|
||||||
asset: asset,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
AutoDisposeFutureProviderElement<VideoPlayerController> createElement() {
|
|
||||||
return _VideoPlayerControllerProviderElement(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool operator ==(Object other) {
|
|
||||||
return other is VideoPlayerControllerProvider && other.asset == asset;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
int get hashCode {
|
|
||||||
var hash = _SystemHash.combine(0, runtimeType.hashCode);
|
|
||||||
hash = _SystemHash.combine(hash, asset.hashCode);
|
|
||||||
|
|
||||||
return _SystemHash.finish(hash);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mixin VideoPlayerControllerRef
|
|
||||||
on AutoDisposeFutureProviderRef<VideoPlayerController> {
|
|
||||||
/// The parameter `asset` of this provider.
|
|
||||||
Asset get asset;
|
|
||||||
}
|
|
||||||
|
|
||||||
class _VideoPlayerControllerProviderElement
|
|
||||||
extends AutoDisposeFutureProviderElement<VideoPlayerController>
|
|
||||||
with VideoPlayerControllerRef {
|
|
||||||
_VideoPlayerControllerProviderElement(super.provider);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Asset get asset => (origin as VideoPlayerControllerProvider).asset;
|
|
||||||
}
|
|
||||||
// ignore_for_file: type=lint
|
|
||||||
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
|
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
||||||
|
|
||||||
class VideoPlaybackControls {
|
class VideoPlaybackControls {
|
||||||
VideoPlaybackControls({
|
const VideoPlaybackControls({
|
||||||
required this.position,
|
required this.position,
|
||||||
required this.mute,
|
required this.mute,
|
||||||
required this.pause,
|
required this.pause,
|
||||||
@@ -17,15 +18,14 @@ final videoPlayerControlsProvider =
|
|||||||
return VideoPlayerControls(ref);
|
return VideoPlayerControls(ref);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const videoPlayerControlsDefault = VideoPlaybackControls(
|
||||||
|
position: 0,
|
||||||
|
pause: false,
|
||||||
|
mute: false,
|
||||||
|
);
|
||||||
|
|
||||||
class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
|
class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
|
||||||
VideoPlayerControls(this.ref)
|
VideoPlayerControls(this.ref) : super(videoPlayerControlsDefault);
|
||||||
: super(
|
|
||||||
VideoPlaybackControls(
|
|
||||||
position: 0,
|
|
||||||
pause: false,
|
|
||||||
mute: false,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
final Ref ref;
|
final Ref ref;
|
||||||
|
|
||||||
@@ -36,17 +36,18 @@ class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void reset() {
|
void reset() {
|
||||||
state = VideoPlaybackControls(
|
state = videoPlayerControlsDefault;
|
||||||
position: 0,
|
|
||||||
pause: false,
|
|
||||||
mute: false,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
double get position => state.position;
|
double get position => state.position;
|
||||||
bool get mute => state.mute;
|
bool get mute => state.mute;
|
||||||
|
bool get paused => state.pause;
|
||||||
|
|
||||||
set position(double value) {
|
set position(double value) {
|
||||||
|
if (state.position == value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
state = VideoPlaybackControls(
|
state = VideoPlaybackControls(
|
||||||
position: value,
|
position: value,
|
||||||
mute: state.mute,
|
mute: state.mute,
|
||||||
@@ -55,6 +56,10 @@ class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
set mute(bool value) {
|
set mute(bool value) {
|
||||||
|
if (state.mute == value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
state = VideoPlaybackControls(
|
state = VideoPlaybackControls(
|
||||||
position: state.position,
|
position: state.position,
|
||||||
mute: value,
|
mute: value,
|
||||||
@@ -71,6 +76,10 @@ class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void pause() {
|
void pause() {
|
||||||
|
if (state.pause) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
state = VideoPlaybackControls(
|
state = VideoPlaybackControls(
|
||||||
position: state.position,
|
position: state.position,
|
||||||
mute: state.mute,
|
mute: state.mute,
|
||||||
@@ -79,6 +88,10 @@ class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void play() {
|
void play() {
|
||||||
|
if (!state.pause) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
state = VideoPlaybackControls(
|
state = VideoPlaybackControls(
|
||||||
position: state.position,
|
position: state.position,
|
||||||
mute: state.mute,
|
mute: state.mute,
|
||||||
@@ -95,16 +108,11 @@ class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void restart() {
|
void restart() {
|
||||||
state = VideoPlaybackControls(
|
|
||||||
position: 0,
|
|
||||||
mute: state.mute,
|
|
||||||
pause: true,
|
|
||||||
);
|
|
||||||
|
|
||||||
state = VideoPlaybackControls(
|
state = VideoPlaybackControls(
|
||||||
position: 0,
|
position: 0,
|
||||||
mute: state.mute,
|
mute: state.mute,
|
||||||
pause: false,
|
pause: false,
|
||||||
);
|
);
|
||||||
|
ref.read(videoPlaybackValueProvider.notifier).position = Duration.zero;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
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 {
|
enum VideoPlaybackState {
|
||||||
initializing,
|
initializing,
|
||||||
@@ -22,56 +22,66 @@ class VideoPlaybackValue {
|
|||||||
/// The volume of the video
|
/// The volume of the video
|
||||||
final double volume;
|
final double volume;
|
||||||
|
|
||||||
VideoPlaybackValue({
|
const VideoPlaybackValue({
|
||||||
required this.position,
|
required this.position,
|
||||||
required this.duration,
|
required this.duration,
|
||||||
required this.state,
|
required this.state,
|
||||||
required this.volume,
|
required this.volume,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory VideoPlaybackValue.fromController(VideoPlayerController? controller) {
|
factory VideoPlaybackValue.fromNativeController(
|
||||||
final video = controller?.value;
|
NativeVideoPlayerController controller,
|
||||||
late VideoPlaybackState s;
|
) {
|
||||||
if (video == null) {
|
final playbackInfo = controller.playbackInfo;
|
||||||
s = VideoPlaybackState.initializing;
|
final videoInfo = controller.videoInfo;
|
||||||
} else if (video.isCompleted) {
|
|
||||||
s = VideoPlaybackState.completed;
|
if (playbackInfo == null || videoInfo == null) {
|
||||||
} else if (video.isPlaying) {
|
return videoPlaybackValueDefault;
|
||||||
s = VideoPlaybackState.playing;
|
|
||||||
} else if (video.isBuffering) {
|
|
||||||
s = VideoPlaybackState.buffering;
|
|
||||||
} else {
|
|
||||||
s = VideoPlaybackState.paused;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final VideoPlaybackState status = switch (playbackInfo.status) {
|
||||||
|
PlaybackStatus.playing => VideoPlaybackState.playing,
|
||||||
|
PlaybackStatus.paused => VideoPlaybackState.paused,
|
||||||
|
PlaybackStatus.stopped => VideoPlaybackState.completed,
|
||||||
|
};
|
||||||
|
|
||||||
return VideoPlaybackValue(
|
return VideoPlaybackValue(
|
||||||
position: video?.position ?? Duration.zero,
|
position: Duration(seconds: playbackInfo.position),
|
||||||
duration: video?.duration ?? Duration.zero,
|
duration: Duration(seconds: videoInfo.duration),
|
||||||
state: s,
|
state: status,
|
||||||
volume: video?.volume ?? 0.0,
|
volume: playbackInfo.volume,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
factory VideoPlaybackValue.uninitialized() {
|
VideoPlaybackValue copyWith({
|
||||||
|
Duration? position,
|
||||||
|
Duration? duration,
|
||||||
|
VideoPlaybackState? state,
|
||||||
|
double? volume,
|
||||||
|
}) {
|
||||||
return VideoPlaybackValue(
|
return VideoPlaybackValue(
|
||||||
position: Duration.zero,
|
position: position ?? this.position,
|
||||||
duration: Duration.zero,
|
duration: duration ?? this.duration,
|
||||||
state: VideoPlaybackState.initializing,
|
state: state ?? this.state,
|
||||||
volume: 0.0,
|
volume: volume ?? this.volume,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const VideoPlaybackValue videoPlaybackValueDefault = VideoPlaybackValue(
|
||||||
|
position: Duration.zero,
|
||||||
|
duration: Duration.zero,
|
||||||
|
state: VideoPlaybackState.initializing,
|
||||||
|
volume: 0.0,
|
||||||
|
);
|
||||||
|
|
||||||
final videoPlaybackValueProvider =
|
final videoPlaybackValueProvider =
|
||||||
StateNotifierProvider<VideoPlaybackValueState, VideoPlaybackValue>((ref) {
|
StateNotifierProvider<VideoPlaybackValueState, VideoPlaybackValue>((ref) {
|
||||||
return VideoPlaybackValueState(ref);
|
return VideoPlaybackValueState(ref);
|
||||||
});
|
});
|
||||||
|
|
||||||
class VideoPlaybackValueState extends StateNotifier<VideoPlaybackValue> {
|
class VideoPlaybackValueState extends StateNotifier<VideoPlaybackValue> {
|
||||||
VideoPlaybackValueState(this.ref)
|
VideoPlaybackValueState(this.ref) : super(videoPlaybackValueDefault);
|
||||||
: super(
|
|
||||||
VideoPlaybackValue.uninitialized(),
|
|
||||||
);
|
|
||||||
|
|
||||||
final Ref ref;
|
final Ref ref;
|
||||||
|
|
||||||
@@ -82,6 +92,7 @@ class VideoPlaybackValueState extends StateNotifier<VideoPlaybackValue> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
set position(Duration value) {
|
set position(Duration value) {
|
||||||
|
if (state.position == value) return;
|
||||||
state = VideoPlaybackValue(
|
state = VideoPlaybackValue(
|
||||||
position: value,
|
position: value,
|
||||||
duration: state.duration,
|
duration: state.duration,
|
||||||
@@ -89,4 +100,18 @@ class VideoPlaybackValueState extends StateNotifier<VideoPlaybackValue> {
|
|||||||
volume: state.volume,
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,14 +7,21 @@ import 'package:cached_network_image/cached_network_image.dart';
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/painting.dart';
|
import 'package:flutter/painting.dart';
|
||||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
import 'package:photo_manager/photo_manager.dart' show ThumbnailSize;
|
import 'package:photo_manager/photo_manager.dart' show ThumbnailSize;
|
||||||
|
|
||||||
/// The local image provider for an asset
|
/// The local image provider for an asset
|
||||||
class ImmichLocalImageProvider extends ImageProvider<ImmichLocalImageProvider> {
|
class ImmichLocalImageProvider extends ImageProvider<ImmichLocalImageProvider> {
|
||||||
final Asset asset;
|
final Asset asset;
|
||||||
|
// only used for videos
|
||||||
|
final double width;
|
||||||
|
final double height;
|
||||||
|
final Logger log = Logger('ImmichLocalImageProvider');
|
||||||
|
|
||||||
ImmichLocalImageProvider({
|
ImmichLocalImageProvider({
|
||||||
required this.asset,
|
required this.asset,
|
||||||
|
required this.width,
|
||||||
|
required this.height,
|
||||||
}) : assert(asset.local != null, 'Only usable when asset.local is set');
|
}) : assert(asset.local != null, 'Only usable when asset.local is set');
|
||||||
|
|
||||||
/// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key
|
/// 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
|
// Streams in each stage of the image as we ask for it
|
||||||
Stream<ui.Codec> _codec(
|
Stream<ui.Codec> _codec(
|
||||||
Asset key,
|
Asset asset,
|
||||||
ImageDecoderCallback decode,
|
ImageDecoderCallback decode,
|
||||||
StreamController<ImageChunkEvent> chunkEvents,
|
StreamController<ImageChunkEvent> chunkEvents,
|
||||||
) async* {
|
) async* {
|
||||||
// Load a small thumbnail
|
ui.ImmutableBuffer? buffer;
|
||||||
final thumbBytes = await asset.local?.thumbnailDataWithSize(
|
try {
|
||||||
const ThumbnailSize.square(256),
|
final local = asset.local;
|
||||||
quality: 80,
|
if (local == null) {
|
||||||
);
|
throw StateError('Asset ${asset.fileName} has no local data');
|
||||||
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");
|
|
||||||
}
|
}
|
||||||
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
|
@override
|
||||||
|
|||||||
@@ -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/backup_options.page.dart';
|
||||||
import 'package:immich_mobile/pages/backup/failed_backup_status.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/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/local_albums.page.dart';
|
||||||
import 'package:immich_mobile/pages/library/people/people_collection.page.dart';
|
import 'package:immich_mobile/pages/library/people/people_collection.page.dart';
|
||||||
import 'package:immich_mobile/pages/library/places/places_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],
|
guards: [_authGuard, _duplicateGuard],
|
||||||
transitionsBuilder: TransitionsBuilders.slideLeft,
|
transitionsBuilder: TransitionsBuilders.slideLeft,
|
||||||
),
|
),
|
||||||
|
AutoRoute(
|
||||||
|
page: NativeVideoViewerRoute.page,
|
||||||
|
guards: [_authGuard, _duplicateGuard],
|
||||||
|
),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
/// generated route for
|
||||||
/// [PartnerDetailPage]
|
/// [PartnerDetailPage]
|
||||||
class PartnerDetailRoute extends PageRouteInfo<PartnerDetailRouteArgs> {
|
class PartnerDetailRoute extends PageRouteInfo<PartnerDetailRouteArgs> {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
@@ -402,4 +403,29 @@ class AssetService {
|
|||||||
|
|
||||||
return exifInfo?.description ?? "";
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,20 +3,52 @@ import 'dart:async';
|
|||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
|
||||||
/// Used to debounce function calls with the [interval] provided.
|
/// 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 {
|
class Debouncer {
|
||||||
Debouncer({required this.interval});
|
Debouncer({required this.interval, this.maxWaitTime});
|
||||||
final Duration interval;
|
final Duration interval;
|
||||||
|
final Duration? maxWaitTime;
|
||||||
Timer? _timer;
|
Timer? _timer;
|
||||||
FutureOr<void> Function()? _lastAction;
|
FutureOr<void> Function()? _lastAction;
|
||||||
|
DateTime? _lastActionTime;
|
||||||
|
Future<void>? _actionFuture;
|
||||||
|
|
||||||
void run(FutureOr<void> Function() action) {
|
void run(FutureOr<void> Function() action) {
|
||||||
_lastAction = action;
|
_lastAction = action;
|
||||||
_timer?.cancel();
|
_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);
|
_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() {
|
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;
|
_timer = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,31 +56,48 @@ class Debouncer {
|
|||||||
_timer?.cancel();
|
_timer?.cancel();
|
||||||
_timer = null;
|
_timer = null;
|
||||||
_lastAction = 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
|
/// 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
|
/// default interval of 300ms is used to debounce the function calls
|
||||||
Debouncer useDebouncer({
|
Debouncer useDebouncer({
|
||||||
Duration interval = const Duration(milliseconds: 300),
|
Duration interval = const Duration(milliseconds: 300),
|
||||||
|
Duration? maxWaitTime,
|
||||||
List<Object?>? keys,
|
List<Object?>? keys,
|
||||||
}) =>
|
}) =>
|
||||||
use(_DebouncerHook(interval: interval, keys: keys));
|
use(
|
||||||
|
_DebouncerHook(
|
||||||
|
interval: interval,
|
||||||
|
maxWaitTime: maxWaitTime,
|
||||||
|
keys: keys,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
class _DebouncerHook extends Hook<Debouncer> {
|
class _DebouncerHook extends Hook<Debouncer> {
|
||||||
const _DebouncerHook({
|
const _DebouncerHook({
|
||||||
required this.interval,
|
required this.interval,
|
||||||
|
this.maxWaitTime,
|
||||||
super.keys,
|
super.keys,
|
||||||
});
|
});
|
||||||
|
|
||||||
final Duration interval;
|
final Duration interval;
|
||||||
|
final Duration? maxWaitTime;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
HookState<Debouncer, Hook<Debouncer>> createState() => _DebouncerHookState();
|
HookState<Debouncer, Hook<Debouncer>> createState() => _DebouncerHookState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _DebouncerHookState extends HookState<Debouncer, _DebouncerHook> {
|
class _DebouncerHookState extends HookState<Debouncer, _DebouncerHook> {
|
||||||
late final debouncer = Debouncer(interval: hook.interval);
|
late final debouncer = Debouncer(
|
||||||
|
interval: hook.interval,
|
||||||
|
maxWaitTime: hook.maxWaitTime,
|
||||||
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Debouncer build(_) => debouncer;
|
Debouncer build(_) => debouncer;
|
||||||
|
|||||||
@@ -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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
}
|
|
||||||
18
mobile/lib/utils/hooks/interval_hook.dart
Normal file
18
mobile/lib/utils/hooks/interval_hook.dart
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:ui';
|
||||||
|
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
|
||||||
|
// https://github.com/rrousselGit/flutter_hooks/issues/233#issuecomment-840416638
|
||||||
|
void useInterval(Duration delay, VoidCallback callback) {
|
||||||
|
final savedCallback = useRef(callback);
|
||||||
|
savedCallback.value = callback;
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() {
|
||||||
|
final timer = Timer.periodic(delay, (_) => savedCallback.value());
|
||||||
|
return timer.cancel;
|
||||||
|
},
|
||||||
|
[delay],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import 'package:immich_mobile/entities/store.entity.dart';
|
|||||||
import 'package:immich_mobile/utils/db.dart';
|
import 'package:immich_mobile/utils/db.dart';
|
||||||
import 'package:isar/isar.dart';
|
import 'package:isar/isar.dart';
|
||||||
|
|
||||||
const int targetVersion = 6;
|
const int targetVersion = 7;
|
||||||
|
|
||||||
Future<void> migrateDatabaseIfNeeded(Isar db) async {
|
Future<void> migrateDatabaseIfNeeded(Isar db) async {
|
||||||
final int version = Store.get(StoreKey.version, 1);
|
final int version = Store.get(StoreKey.version, 1);
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import 'dart:async';
|
|
||||||
|
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
|
||||||
/// Throttles function calls with the [interval] provided.
|
/// Throttles function calls with the [interval] provided.
|
||||||
@@ -10,12 +8,15 @@ class Throttler {
|
|||||||
|
|
||||||
Throttler({required this.interval});
|
Throttler({required this.interval});
|
||||||
|
|
||||||
void run(FutureOr<void> Function() action) {
|
T? run<T>(T Function() action) {
|
||||||
if (_lastActionTime == null ||
|
if (_lastActionTime == null ||
|
||||||
(DateTime.now().difference(_lastActionTime!) > interval)) {
|
(DateTime.now().difference(_lastActionTime!) > interval)) {
|
||||||
action();
|
final response = action();
|
||||||
_lastActionTime = DateTime.now();
|
_lastActionTime = DateTime.now();
|
||||||
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
|||||||
@@ -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/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/collection_extensions.dart';
|
import 'package:immich_mobile/extensions/collection_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/theme_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/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/asset_drag_region.dart';
|
||||||
import 'package:immich_mobile/widgets/asset_grid/thumbnail_image.dart';
|
import 'package:immich_mobile/widgets/asset_grid/thumbnail_image.dart';
|
||||||
import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart';
|
import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart';
|
||||||
@@ -89,6 +91,7 @@ class ImmichAssetGridViewState extends ConsumerState<ImmichAssetGridView> {
|
|||||||
ScrollOffsetController();
|
ScrollOffsetController();
|
||||||
final ItemPositionsListener _itemPositionsListener =
|
final ItemPositionsListener _itemPositionsListener =
|
||||||
ItemPositionsListener.create();
|
ItemPositionsListener.create();
|
||||||
|
late final KeepAliveLink currentAssetLink;
|
||||||
|
|
||||||
/// The timestamp when the haptic feedback was last invoked
|
/// The timestamp when the haptic feedback was last invoked
|
||||||
int _hapticFeedbackTS = 0;
|
int _hapticFeedbackTS = 0;
|
||||||
@@ -201,6 +204,12 @@ class ImmichAssetGridViewState extends ConsumerState<ImmichAssetGridView> {
|
|||||||
allAssetsSelected: _allAssetsSelected,
|
allAssetsSelected: _allAssetsSelected,
|
||||||
showStack: widget.showStack,
|
showStack: widget.showStack,
|
||||||
heroOffset: widget.heroOffset,
|
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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
currentAssetLink = ref.read(currentAssetProvider.notifier).ref.keepAlive();
|
||||||
scrollToTopNotifierProvider.addListener(_scrollToTop);
|
scrollToTopNotifierProvider.addListener(_scrollToTop);
|
||||||
scrollToDateNotifierProvider.addListener(_scrollToDate);
|
scrollToDateNotifierProvider.addListener(_scrollToDate);
|
||||||
|
|
||||||
@@ -369,6 +379,7 @@ class ImmichAssetGridViewState extends ConsumerState<ImmichAssetGridView> {
|
|||||||
_itemPositionsListener.itemPositions.removeListener(_positionListener);
|
_itemPositionsListener.itemPositions.removeListener(_positionListener);
|
||||||
}
|
}
|
||||||
_itemPositionsListener.itemPositions.removeListener(_hapticsListener);
|
_itemPositionsListener.itemPositions.removeListener(_hapticsListener);
|
||||||
|
currentAssetLink.close();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -595,12 +606,13 @@ class _Section extends StatelessWidget {
|
|||||||
final RenderList renderList;
|
final RenderList renderList;
|
||||||
final bool selectionActive;
|
final bool selectionActive;
|
||||||
final bool dynamicLayout;
|
final bool dynamicLayout;
|
||||||
final Function(List<Asset>) selectAssets;
|
final void Function(List<Asset>) selectAssets;
|
||||||
final Function(List<Asset>) deselectAssets;
|
final void Function(List<Asset>) deselectAssets;
|
||||||
final bool Function(List<Asset>) allAssetsSelected;
|
final bool Function(List<Asset>) allAssetsSelected;
|
||||||
final bool showStack;
|
final bool showStack;
|
||||||
final int heroOffset;
|
final int heroOffset;
|
||||||
final bool showStorageIndicator;
|
final bool showStorageIndicator;
|
||||||
|
final void Function(Asset) onAssetTap;
|
||||||
|
|
||||||
const _Section({
|
const _Section({
|
||||||
required this.section,
|
required this.section,
|
||||||
@@ -618,6 +630,7 @@ class _Section extends StatelessWidget {
|
|||||||
required this.showStack,
|
required this.showStack,
|
||||||
required this.heroOffset,
|
required this.heroOffset,
|
||||||
required this.showStorageIndicator,
|
required this.showStorageIndicator,
|
||||||
|
required this.onAssetTap,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -683,6 +696,7 @@ class _Section extends StatelessWidget {
|
|||||||
selectionActive: selectionActive,
|
selectionActive: selectionActive,
|
||||||
onSelect: (asset) => selectAssets([asset]),
|
onSelect: (asset) => selectAssets([asset]),
|
||||||
onDeselect: (asset) => deselectAssets([asset]),
|
onDeselect: (asset) => deselectAssets([asset]),
|
||||||
|
onAssetTap: onAssetTap,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -724,9 +738,9 @@ class _Title extends StatelessWidget {
|
|||||||
final String title;
|
final String title;
|
||||||
final List<Asset> assets;
|
final List<Asset> assets;
|
||||||
final bool selectionActive;
|
final bool selectionActive;
|
||||||
final Function(List<Asset>) selectAssets;
|
final void Function(List<Asset>) selectAssets;
|
||||||
final Function(List<Asset>) deselectAssets;
|
final void Function(List<Asset>) deselectAssets;
|
||||||
final Function(List<Asset>) allAssetsSelected;
|
final bool Function(List<Asset>) allAssetsSelected;
|
||||||
|
|
||||||
const _Title({
|
const _Title({
|
||||||
required this.title,
|
required this.title,
|
||||||
@@ -765,8 +779,9 @@ class _AssetRow extends StatelessWidget {
|
|||||||
final bool showStorageIndicator;
|
final bool showStorageIndicator;
|
||||||
final int heroOffset;
|
final int heroOffset;
|
||||||
final bool showStack;
|
final bool showStack;
|
||||||
final Function(Asset)? onSelect;
|
final void Function(Asset) onAssetTap;
|
||||||
final Function(Asset)? onDeselect;
|
final void Function(Asset)? onSelect;
|
||||||
|
final void Function(Asset)? onDeselect;
|
||||||
final bool isSelectionActive;
|
final bool isSelectionActive;
|
||||||
|
|
||||||
const _AssetRow({
|
const _AssetRow({
|
||||||
@@ -786,6 +801,7 @@ class _AssetRow extends StatelessWidget {
|
|||||||
required this.showStack,
|
required this.showStack,
|
||||||
required this.isSelectionActive,
|
required this.isSelectionActive,
|
||||||
required this.selectedAssets,
|
required this.selectedAssets,
|
||||||
|
required this.onAssetTap,
|
||||||
this.onSelect,
|
this.onSelect,
|
||||||
this.onDeselect,
|
this.onDeselect,
|
||||||
});
|
});
|
||||||
@@ -838,6 +854,8 @@ class _AssetRow extends StatelessWidget {
|
|||||||
onSelect?.call(asset);
|
onSelect?.call(asset);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
final asset = renderList.loadAsset(absoluteOffset + index);
|
||||||
|
onAssetTap(asset);
|
||||||
context.pushRoute(
|
context.pushRoute(
|
||||||
GalleryViewerRoute(
|
GalleryViewerRoute(
|
||||||
renderList: renderList,
|
renderList: renderList,
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ import 'package:easy_localization/easy_localization.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:fluttertoast/fluttertoast.dart';
|
import 'package:fluttertoast/fluttertoast.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.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/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/providers/album/album.provider.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/album/current_album.provider.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/asset_stack.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/download.provider.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
|
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
|
||||||
import 'package:immich_mobile/services/stack.service.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';
|
import 'package:immich_mobile/pages/editing/edit.page.dart';
|
||||||
|
|
||||||
class BottomGalleryBar extends ConsumerWidget {
|
class BottomGalleryBar extends ConsumerWidget {
|
||||||
final Asset asset;
|
|
||||||
final ValueNotifier<int> assetIndex;
|
final ValueNotifier<int> assetIndex;
|
||||||
final bool showStack;
|
final bool showStack;
|
||||||
final int stackIndex;
|
final int stackIndex;
|
||||||
final ValueNotifier<int> totalAssets;
|
final ValueNotifier<int> totalAssets;
|
||||||
final bool showVideoPlayerControls;
|
|
||||||
final PageController controller;
|
final PageController controller;
|
||||||
final RenderList renderList;
|
final RenderList renderList;
|
||||||
|
|
||||||
@@ -39,20 +37,24 @@ class BottomGalleryBar extends ConsumerWidget {
|
|||||||
super.key,
|
super.key,
|
||||||
required this.showStack,
|
required this.showStack,
|
||||||
required this.stackIndex,
|
required this.stackIndex,
|
||||||
required this.asset,
|
|
||||||
required this.assetIndex,
|
required this.assetIndex,
|
||||||
required this.controller,
|
required this.controller,
|
||||||
required this.totalAssets,
|
required this.totalAssets,
|
||||||
required this.showVideoPlayerControls,
|
|
||||||
required this.renderList,
|
required this.renderList,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
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 isOwner = asset.ownerId == ref.watch(currentUserProvider)?.isarId;
|
||||||
|
final showControls = ref.watch(showControlsProvider);
|
||||||
|
final stackId = asset.stackId;
|
||||||
|
|
||||||
final stackItems = showStack && asset.stackCount > 0
|
final stackItems = showStack && stackId != null
|
||||||
? ref.watch(assetStackStateProvider(asset))
|
? ref.watch(assetStackStateProvider(stackId))
|
||||||
: <Asset>[];
|
: <Asset>[];
|
||||||
bool isStackPrimaryAsset = asset.stackPrimaryAssetId == null;
|
bool isStackPrimaryAsset = asset.stackPrimaryAssetId == null;
|
||||||
final navStack = AutoRouter.of(context).stackData;
|
final navStack = AutoRouter.of(context).stackData;
|
||||||
@@ -64,9 +66,9 @@ class BottomGalleryBar extends ConsumerWidget {
|
|||||||
final isInAlbum = ref.watch(currentAlbumProvider)?.isRemote ?? false;
|
final isInAlbum = ref.watch(currentAlbumProvider)?.isRemote ?? false;
|
||||||
|
|
||||||
void removeAssetFromStack() {
|
void removeAssetFromStack() {
|
||||||
if (stackIndex > 0 && showStack) {
|
if (stackIndex > 0 && showStack && stackId != null) {
|
||||||
ref
|
ref
|
||||||
.read(assetStackStateProvider(asset).notifier)
|
.read(assetStackStateProvider(stackId).notifier)
|
||||||
.removeChild(stackIndex - 1);
|
.removeChild(stackIndex - 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -135,7 +137,7 @@ class BottomGalleryBar extends ConsumerWidget {
|
|||||||
|
|
||||||
await ref
|
await ref
|
||||||
.read(stackServiceProvider)
|
.read(stackServiceProvider)
|
||||||
.deleteStack(asset.stackId!, [asset, ...stackItems]);
|
.deleteStack(asset.stackId!, stackItems);
|
||||||
}
|
}
|
||||||
|
|
||||||
void showStackActionItems() {
|
void showStackActionItems() {
|
||||||
@@ -324,7 +326,7 @@ class BottomGalleryBar extends ConsumerWidget {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
return IgnorePointer(
|
return IgnorePointer(
|
||||||
ignoring: !ref.watch(showControlsProvider),
|
ignoring: !showControls,
|
||||||
child: AnimatedOpacity(
|
child: AnimatedOpacity(
|
||||||
duration: const Duration(milliseconds: 100),
|
duration: const Duration(milliseconds: 100),
|
||||||
opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0,
|
opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0,
|
||||||
@@ -333,15 +335,15 @@ class BottomGalleryBar extends ConsumerWidget {
|
|||||||
gradient: LinearGradient(
|
gradient: LinearGradient(
|
||||||
begin: Alignment.bottomCenter,
|
begin: Alignment.bottomCenter,
|
||||||
end: Alignment.topCenter,
|
end: Alignment.topCenter,
|
||||||
colors: [blackOpacity90, Colors.transparent],
|
colors: [Colors.black, Colors.transparent],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
position: DecorationPosition.background,
|
position: DecorationPosition.background,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: EdgeInsets.only(top: 40.0),
|
padding: const EdgeInsets.only(top: 40.0),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
if (showVideoPlayerControls) const VideoControls(),
|
if (asset.isVideo) const VideoControls(),
|
||||||
BottomNavigationBar(
|
BottomNavigationBar(
|
||||||
elevation: 0.0,
|
elevation: 0.0,
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
|
|||||||
@@ -1,38 +1,47 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
|
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
|
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
||||||
|
import 'package:immich_mobile/utils/hooks/timer_hook.dart';
|
||||||
import 'package:immich_mobile/widgets/asset_viewer/center_play_button.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/widgets/common/delayed_loading_indicator.dart';
|
||||||
import 'package:immich_mobile/utils/hooks/timer_hook.dart';
|
|
||||||
|
|
||||||
class CustomVideoPlayerControls extends HookConsumerWidget {
|
class CustomVideoPlayerControls extends HookConsumerWidget {
|
||||||
final Duration hideTimerDuration;
|
final Duration hideTimerDuration;
|
||||||
|
|
||||||
const CustomVideoPlayerControls({
|
const CustomVideoPlayerControls({
|
||||||
super.key,
|
super.key,
|
||||||
this.hideTimerDuration = const Duration(seconds: 3),
|
this.hideTimerDuration = const Duration(seconds: 5),
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
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
|
// A timer to hide the controls
|
||||||
final hideTimer = useTimer(
|
final hideTimer = useTimer(
|
||||||
hideTimerDuration,
|
hideTimerDuration,
|
||||||
() {
|
() {
|
||||||
final state = ref.read(videoPlaybackValueProvider).state;
|
if (!context.mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Do not hide on paused
|
// Do not hide on paused
|
||||||
if (state != VideoPlaybackState.paused) {
|
if (state != VideoPlaybackState.paused &&
|
||||||
|
state != VideoPlaybackState.completed &&
|
||||||
|
assetIsVideo) {
|
||||||
ref.read(showControlsProvider.notifier).show = false;
|
ref.read(showControlsProvider.notifier).show = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
final showBuffering = state == VideoPlaybackState.buffering;
|
||||||
final showBuffering = useState(false);
|
|
||||||
final VideoPlaybackState state =
|
|
||||||
ref.watch(videoPlaybackValueProvider).state;
|
|
||||||
|
|
||||||
/// Shows the controls and starts the timer to hide them
|
/// Shows the controls and starts the timer to hide them
|
||||||
void showControlsAndStartHideTimer() {
|
void showControlsAndStartHideTimer() {
|
||||||
@@ -52,16 +61,9 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
|
|||||||
showControlsAndStartHideTimer();
|
showControlsAndStartHideTimer();
|
||||||
});
|
});
|
||||||
|
|
||||||
ref.listen(videoPlaybackValueProvider.select((value) => value.state),
|
|
||||||
(_, state) {
|
|
||||||
// Show buffering
|
|
||||||
showBuffering.value = state == VideoPlaybackState.buffering;
|
|
||||||
});
|
|
||||||
|
|
||||||
/// Toggles between playing and pausing depending on the state of the video
|
/// Toggles between playing and pausing depending on the state of the video
|
||||||
void togglePlay() {
|
void togglePlay() {
|
||||||
showControlsAndStartHideTimer();
|
showControlsAndStartHideTimer();
|
||||||
final state = ref.read(videoPlaybackValueProvider).state;
|
|
||||||
if (state == VideoPlaybackState.playing) {
|
if (state == VideoPlaybackState.playing) {
|
||||||
ref.read(videoPlayerControlsProvider.notifier).pause();
|
ref.read(videoPlayerControlsProvider.notifier).pause();
|
||||||
} else if (state == VideoPlaybackState.completed) {
|
} else if (state == VideoPlaybackState.completed) {
|
||||||
@@ -75,10 +77,10 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
|
|||||||
behavior: HitTestBehavior.opaque,
|
behavior: HitTestBehavior.opaque,
|
||||||
onTap: showControlsAndStartHideTimer,
|
onTap: showControlsAndStartHideTimer,
|
||||||
child: AbsorbPointer(
|
child: AbsorbPointer(
|
||||||
absorbing: !ref.watch(showControlsProvider),
|
absorbing: !showControls,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
if (showBuffering.value)
|
if (showBuffering)
|
||||||
const Center(
|
const Center(
|
||||||
child: DelayedLoadingIndicator(
|
child: DelayedLoadingIndicator(
|
||||||
fadeInDuration: Duration(milliseconds: 400),
|
fadeInDuration: Duration(milliseconds: 400),
|
||||||
@@ -86,18 +88,14 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
|
|||||||
)
|
)
|
||||||
else
|
else
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: () {
|
onTap: () =>
|
||||||
if (state != VideoPlaybackState.playing) {
|
ref.read(showControlsProvider.notifier).show = false,
|
||||||
togglePlay();
|
|
||||||
}
|
|
||||||
ref.read(showControlsProvider.notifier).show = false;
|
|
||||||
},
|
|
||||||
child: CenterPlayButton(
|
child: CenterPlayButton(
|
||||||
backgroundColor: Colors.black54,
|
backgroundColor: Colors.black54,
|
||||||
iconColor: Colors.white,
|
iconColor: Colors.white,
|
||||||
isFinished: state == VideoPlaybackState.completed,
|
isFinished: state == VideoPlaybackState.completed,
|
||||||
isPlaying: state == VideoPlaybackState.playing,
|
isPlaying: state == VideoPlaybackState.playing,
|
||||||
show: ref.watch(showControlsProvider),
|
show: assetIsVideo && showControls,
|
||||||
onPressed: togglePlay,
|
onPressed: togglePlay,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -15,9 +15,10 @@ class FileInfo extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final textColor = context.isDarkTheme ? Colors.white : Colors.black;
|
final textColor = context.isDarkTheme ? Colors.white : Colors.black;
|
||||||
|
|
||||||
String resolution = asset.width != null && asset.height != null
|
final height = asset.orientatedHeight ?? asset.height;
|
||||||
? "${asset.height} x ${asset.width} "
|
final width = asset.orientatedWidth ?? asset.width;
|
||||||
: "";
|
String resolution =
|
||||||
|
height != null && width != null ? "$height x $width " : "";
|
||||||
String fileSize = asset.exifInfo?.fileSize != null
|
String fileSize = asset.exifInfo?.fileSize != null
|
||||||
? formatBytes(asset.exifInfo!.fileSize!)
|
? formatBytes(asset.exifInfo!.fileSize!)
|
||||||
: "";
|
: "";
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:fluttertoast/fluttertoast.dart';
|
import 'package:fluttertoast/fluttertoast.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/providers/album/current_album.provider.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/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/download.provider.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/show_controls.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';
|
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||||
|
|
||||||
class GalleryAppBar extends ConsumerWidget {
|
class GalleryAppBar extends ConsumerWidget {
|
||||||
final Asset asset;
|
|
||||||
final void Function() showInfo;
|
final void Function() showInfo;
|
||||||
final void Function() onToggleMotionVideo;
|
|
||||||
final bool isPlayingVideo;
|
|
||||||
|
|
||||||
const GalleryAppBar({
|
const GalleryAppBar({super.key, required this.showInfo});
|
||||||
super.key,
|
|
||||||
required this.asset,
|
|
||||||
required this.showInfo,
|
|
||||||
required this.onToggleMotionVideo,
|
|
||||||
required this.isPlayingVideo,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final asset = ref.watch(currentAssetProvider);
|
||||||
|
if (asset == null) {
|
||||||
|
return const SizedBox();
|
||||||
|
}
|
||||||
final album = ref.watch(currentAlbumProvider);
|
final album = ref.watch(currentAlbumProvider);
|
||||||
final isOwner = asset.ownerId == ref.watch(currentUserProvider)?.isarId;
|
final isOwner = asset.ownerId == ref.watch(currentUserProvider)?.isarId;
|
||||||
|
final showControls = ref.watch(showControlsProvider);
|
||||||
|
|
||||||
final isPartner = ref
|
final isPartner = ref
|
||||||
.watch(partnerSharedWithProvider)
|
.watch(partnerSharedWithProvider)
|
||||||
@@ -98,23 +95,21 @@ class GalleryAppBar extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return IgnorePointer(
|
return IgnorePointer(
|
||||||
ignoring: !ref.watch(showControlsProvider),
|
ignoring: !showControls,
|
||||||
child: AnimatedOpacity(
|
child: AnimatedOpacity(
|
||||||
duration: const Duration(milliseconds: 100),
|
duration: const Duration(milliseconds: 100),
|
||||||
opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0,
|
opacity: showControls ? 1.0 : 0.0,
|
||||||
child: Container(
|
child: Container(
|
||||||
color: Colors.black.withOpacity(0.4),
|
color: Colors.black.withOpacity(0.4),
|
||||||
child: TopControlAppBar(
|
child: TopControlAppBar(
|
||||||
isOwner: isOwner,
|
isOwner: isOwner,
|
||||||
isPartner: isPartner,
|
isPartner: isPartner,
|
||||||
isPlayingMotionVideo: isPlayingVideo,
|
|
||||||
asset: asset,
|
asset: asset,
|
||||||
onMoreInfoPressed: showInfo,
|
onMoreInfoPressed: showInfo,
|
||||||
onFavorite: toggleFavorite,
|
onFavorite: toggleFavorite,
|
||||||
onRestorePressed: () => handleRestore(asset),
|
onRestorePressed: () => handleRestore(asset),
|
||||||
onUploadPressed: asset.isLocal ? () => handleUpload(asset) : null,
|
onUploadPressed: asset.isLocal ? () => handleUpload(asset) : null,
|
||||||
onDownloadPressed: asset.isLocal ? null : handleDownloadAsset,
|
onDownloadPressed: asset.isLocal ? null : handleDownloadAsset,
|
||||||
onToggleMotionVideo: onToggleMotionVideo,
|
|
||||||
onAddToAlbumPressed: () => addToAlbum(asset),
|
onAddToAlbumPressed: () => addToAlbum(asset),
|
||||||
onActivitiesPressed: handleActivities,
|
onActivitiesPressed: handleActivities,
|
||||||
),
|
),
|
||||||
|
|||||||
22
mobile/lib/widgets/asset_viewer/motion_photo_button.dart
Normal file
22
mobile/lib/widgets/asset_viewer/motion_photo_button.dart
Normal 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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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/providers/album/current_album.provider.dart';
|
||||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||||
import 'package:immich_mobile/providers/asset.provider.dart';
|
import 'package:immich_mobile/providers/asset.provider.dart';
|
||||||
|
import 'package:immich_mobile/widgets/asset_viewer/motion_photo_button.dart';
|
||||||
|
|
||||||
class TopControlAppBar extends HookConsumerWidget {
|
class TopControlAppBar extends HookConsumerWidget {
|
||||||
const TopControlAppBar({
|
const TopControlAppBar({
|
||||||
@@ -14,8 +15,6 @@ class TopControlAppBar extends HookConsumerWidget {
|
|||||||
required this.onDownloadPressed,
|
required this.onDownloadPressed,
|
||||||
required this.onAddToAlbumPressed,
|
required this.onAddToAlbumPressed,
|
||||||
required this.onRestorePressed,
|
required this.onRestorePressed,
|
||||||
required this.onToggleMotionVideo,
|
|
||||||
required this.isPlayingMotionVideo,
|
|
||||||
required this.onFavorite,
|
required this.onFavorite,
|
||||||
required this.onUploadPressed,
|
required this.onUploadPressed,
|
||||||
required this.isOwner,
|
required this.isOwner,
|
||||||
@@ -27,12 +26,10 @@ class TopControlAppBar extends HookConsumerWidget {
|
|||||||
final Function onMoreInfoPressed;
|
final Function onMoreInfoPressed;
|
||||||
final VoidCallback? onUploadPressed;
|
final VoidCallback? onUploadPressed;
|
||||||
final VoidCallback? onDownloadPressed;
|
final VoidCallback? onDownloadPressed;
|
||||||
final VoidCallback onToggleMotionVideo;
|
|
||||||
final VoidCallback onAddToAlbumPressed;
|
final VoidCallback onAddToAlbumPressed;
|
||||||
final VoidCallback onRestorePressed;
|
final VoidCallback onRestorePressed;
|
||||||
final VoidCallback onActivitiesPressed;
|
final VoidCallback onActivitiesPressed;
|
||||||
final Function(Asset) onFavorite;
|
final Function(Asset) onFavorite;
|
||||||
final bool isPlayingMotionVideo;
|
|
||||||
final bool isOwner;
|
final bool isOwner;
|
||||||
final bool isPartner;
|
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() {
|
Widget buildMoreInfoButton() {
|
||||||
return IconButton(
|
return IconButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
@@ -175,13 +155,11 @@ class TopControlAppBar extends HookConsumerWidget {
|
|||||||
foregroundColor: Colors.grey[100],
|
foregroundColor: Colors.grey[100],
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
leading: buildBackButton(),
|
leading: buildBackButton(),
|
||||||
actionsIconTheme: const IconThemeData(
|
actionsIconTheme: const IconThemeData(size: iconSize),
|
||||||
size: iconSize,
|
|
||||||
),
|
|
||||||
shape: const Border(),
|
shape: const Border(),
|
||||||
actions: [
|
actions: [
|
||||||
if (asset.isRemote && isOwner) buildFavoriteButton(a),
|
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.isLocal && !asset.isRemote) buildUploadButton(),
|
||||||
if (asset.isRemote && !asset.isLocal && isOwner) buildDownloadButton(),
|
if (asset.isRemote && !asset.isLocal && isOwner) buildDownloadButton(),
|
||||||
if (asset.isRemote && (isOwner || isPartner) && !asset.isTrashed)
|
if (asset.isRemote && (isOwner || isPartner) && !asset.isTrashed)
|
||||||
|
|||||||
@@ -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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -56,10 +56,16 @@ class VideoPosition extends HookConsumerWidget {
|
|||||||
ref.read(videoPlayerControlsProvider.notifier).play();
|
ref.read(videoPlayerControlsProvider.notifier).play();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onChanged: (position) {
|
onChanged: (value) {
|
||||||
|
final inSeconds =
|
||||||
|
(duration * (value / 100.0)).inSeconds;
|
||||||
|
final position = inSeconds.toDouble();
|
||||||
ref
|
ref
|
||||||
.read(videoPlayerControlsProvider.notifier)
|
.read(videoPlayerControlsProvider.notifier)
|
||||||
.position = position;
|
.position = position;
|
||||||
|
// This immediately updates the slider position without waiting for the video to update
|
||||||
|
ref.read(videoPlaybackValueProvider.notifier).position =
|
||||||
|
Duration(seconds: inSeconds);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -28,12 +28,11 @@ class ImmichImage extends StatelessWidget {
|
|||||||
// either by using the asset ID or the asset itself
|
// either by using the asset ID or the asset itself
|
||||||
/// [asset] is the Asset to request, or else use [assetId] to get a remote
|
/// [asset] is the Asset to request, or else use [assetId] to get a remote
|
||||||
/// image provider
|
/// 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({
|
static ImageProvider imageProvider({
|
||||||
Asset? asset,
|
Asset? asset,
|
||||||
String? assetId,
|
String? assetId,
|
||||||
|
double width = 1080,
|
||||||
|
double height = 1920,
|
||||||
}) {
|
}) {
|
||||||
if (asset == null && assetId == null) {
|
if (asset == null && assetId == null) {
|
||||||
throw Exception('Must supply either asset or assetId');
|
throw Exception('Must supply either asset or assetId');
|
||||||
@@ -48,6 +47,8 @@ class ImmichImage extends StatelessWidget {
|
|||||||
if (useLocal(asset)) {
|
if (useLocal(asset)) {
|
||||||
return ImmichLocalImageProvider(
|
return ImmichLocalImageProvider(
|
||||||
asset: asset,
|
asset: asset,
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return ImmichRemoteImageProvider(
|
return ImmichRemoteImageProvider(
|
||||||
@@ -87,6 +88,8 @@ class ImmichImage extends StatelessWidget {
|
|||||||
},
|
},
|
||||||
image: ImmichImage.imageProvider(
|
image: ImmichImage.imageProvider(
|
||||||
asset: asset,
|
asset: asset,
|
||||||
|
width: context.width,
|
||||||
|
height: context.height,
|
||||||
),
|
),
|
||||||
width: width,
|
width: width,
|
||||||
height: height,
|
height: height,
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import 'dart:ui';
|
|||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.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/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/utils/hooks/blurhash_hook.dart';
|
||||||
import 'package:immich_mobile/widgets/common/immich_image.dart';
|
import 'package:immich_mobile/widgets/common/immich_image.dart';
|
||||||
|
|
||||||
@@ -68,18 +68,20 @@ class MemoryCard extends StatelessWidget {
|
|||||||
} else {
|
} else {
|
||||||
return Hero(
|
return Hero(
|
||||||
tag: 'memory-${asset.id}',
|
tag: 'memory-${asset.id}',
|
||||||
child: VideoViewerPage(
|
child: SizedBox(
|
||||||
key: ValueKey(asset),
|
width: context.width,
|
||||||
asset: asset,
|
height: context.height,
|
||||||
showDownloadingIndicator: false,
|
child: NativeVideoViewerPage(
|
||||||
placeholder: SizedBox.expand(
|
key: ValueKey(asset.id),
|
||||||
child: ImmichImage(
|
asset: asset,
|
||||||
|
showControls: false,
|
||||||
|
image: ImmichImage(
|
||||||
asset,
|
asset,
|
||||||
|
width: context.width,
|
||||||
|
height: context.height,
|
||||||
fit: fit,
|
fit: fit,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
hideControlsTimer: const Duration(seconds: 2),
|
|
||||||
showControls: false,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -137,6 +139,8 @@ class _BlurredBackdrop extends HookWidget {
|
|||||||
image: DecorationImage(
|
image: DecorationImage(
|
||||||
image: ImmichImage.imageProvider(
|
image: ImmichImage.imageProvider(
|
||||||
asset: asset,
|
asset: asset,
|
||||||
|
height: context.height,
|
||||||
|
width: context.width,
|
||||||
),
|
),
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -378,10 +378,10 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: device_info_plus
|
name: device_info_plus
|
||||||
sha256: db03b2d2a3fa466a4627709e1db58692c3f7f658e36a5942d342d86efedc4091
|
sha256: f545ffbadee826f26f2e1a0f0cbd667ae9a6011cc0f77c0f8f00a969655e6e95
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "11.0.0"
|
version: "11.1.1"
|
||||||
device_info_plus_platform_interface:
|
device_info_plus_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -450,10 +450,10 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: file_picker
|
name: file_picker
|
||||||
sha256: "167bb619cdddaa10ef2907609feb8a79c16dfa479d3afaf960f8e223f754bf12"
|
sha256: aac85f20436608e01a6ffd1fdd4e746a7f33c93a2c83752e626bdfaea139b877
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "8.1.2"
|
version: "8.1.3"
|
||||||
file_selector_linux:
|
file_selector_linux:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -548,10 +548,10 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: flutter_local_notifications
|
name: flutter_local_notifications
|
||||||
sha256: "674173fd3c9eda9d4c8528da2ce0ea69f161577495a9cc835a2a4ecd7eadeb35"
|
sha256: dd6676d8c2926537eccdf9f72128bbb2a9d0814689527b17f92c248ff192eaf3
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "17.2.4"
|
version: "17.2.1+2"
|
||||||
flutter_local_notifications_linux:
|
flutter_local_notifications_linux:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1024,6 +1024,15 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.4"
|
version: "1.0.4"
|
||||||
|
native_video_player:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
path: "."
|
||||||
|
ref: "68ea203"
|
||||||
|
resolved-ref: "68ea2030ba7aceb1bc44b683ff0b742fd1a52d2f"
|
||||||
|
url: "https://github.com/immich-app/native_video_player"
|
||||||
|
source: git
|
||||||
|
version: "1.3.1"
|
||||||
nested:
|
nested:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1067,10 +1076,10 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: package_info_plus
|
name: package_info_plus
|
||||||
sha256: "894f37107424311bdae3e476552229476777b8752c5a2a2369c0cb9a2d5442ef"
|
sha256: da8d9ac8c4b1df253d1a328b7bf01ae77ef132833479ab40763334db13b91cce
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "8.0.3"
|
version: "8.1.1"
|
||||||
package_info_plus_platform_interface:
|
package_info_plus_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1339,10 +1348,10 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: share_plus
|
name: share_plus
|
||||||
sha256: fec12c3c39f01e4df1ec6ad92b6e85503c5ca64ffd6e28d18c9ffe53fcc4cb11
|
sha256: "9c9bafd4060728d7cdb2464c341743adbd79d327cb067ec7afb64583540b47c8"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "10.0.3"
|
version: "10.1.2"
|
||||||
share_plus_platform_interface:
|
share_plus_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ dependencies:
|
|||||||
path_provider: ^2.1.2
|
path_provider: ^2.1.2
|
||||||
collection: ^1.18.0
|
collection: ^1.18.0
|
||||||
http_parser: ^4.0.2
|
http_parser: ^4.0.2
|
||||||
flutter_web_auth: ^0.6.0
|
flutter_web_auth: 0.6.0
|
||||||
easy_image_viewer: ^1.4.0
|
easy_image_viewer: ^1.4.0
|
||||||
isar:
|
isar:
|
||||||
version: *isar_version
|
version: *isar_version
|
||||||
@@ -64,6 +64,10 @@ dependencies:
|
|||||||
async: ^2.11.0
|
async: ^2.11.0
|
||||||
dynamic_color: ^1.7.0 #package to apply system theme
|
dynamic_color: ^1.7.0 #package to apply system theme
|
||||||
background_downloader: ^8.5.5
|
background_downloader: ^8.5.5
|
||||||
|
native_video_player:
|
||||||
|
git:
|
||||||
|
url: https://github.com/immich-app/native_video_player
|
||||||
|
ref: 68ea203
|
||||||
|
|
||||||
#image editing packages
|
#image editing packages
|
||||||
crop_image: ^1.0.13
|
crop_image: ^1.0.13
|
||||||
|
|||||||
Reference in New Issue
Block a user