mirror of
https://github.com/immich-app/immich.git
synced 2025-12-06 12:51:32 -08:00
Compare commits
43 Commits
feat/sessi
...
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 {
|
||||
compileSdkVersion 34
|
||||
compileSdkVersion 35
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
@@ -47,7 +47,7 @@ android {
|
||||
defaultConfig {
|
||||
applicationId "app.alextran.immich"
|
||||
minSdkVersion 26
|
||||
targetSdkVersion 34
|
||||
targetSdkVersion 35
|
||||
versionCode flutterVersionCode.toInteger()
|
||||
versionName flutterVersionName
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
|
||||
<meta-data
|
||||
android:name="io.flutter.embedding.android.EnableImpeller"
|
||||
android:value="false" />
|
||||
android:value="true" />
|
||||
|
||||
<meta-data
|
||||
android:name="com.google.firebase.messaging.default_notification_icon"
|
||||
|
||||
@@ -16,8 +16,8 @@ subprojects {
|
||||
if (project.plugins.hasPlugin("com.android.application") ||
|
||||
project.plugins.hasPlugin("com.android.library")) {
|
||||
project.android {
|
||||
compileSdkVersion 34
|
||||
buildToolsVersion "34.0.0"
|
||||
compileSdkVersion 35
|
||||
buildToolsVersion "35.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,8 @@ PODS:
|
||||
- maplibre_gl (0.0.1):
|
||||
- Flutter
|
||||
- MapLibre (= 5.14.0-pre3)
|
||||
- native_video_player (1.0.0):
|
||||
- Flutter
|
||||
- package_info_plus (0.4.5):
|
||||
- Flutter
|
||||
- path_provider_foundation (0.0.1):
|
||||
@@ -115,6 +117,7 @@ DEPENDENCIES:
|
||||
- integration_test (from `.symlinks/plugins/integration_test/ios`)
|
||||
- isar_flutter_libs (from `.symlinks/plugins/isar_flutter_libs/ios`)
|
||||
- maplibre_gl (from `.symlinks/plugins/maplibre_gl/ios`)
|
||||
- native_video_player (from `.symlinks/plugins/native_video_player/ios`)
|
||||
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
||||
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
||||
- path_provider_ios (from `.symlinks/plugins/path_provider_ios/ios`)
|
||||
@@ -168,6 +171,8 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/isar_flutter_libs/ios"
|
||||
maplibre_gl:
|
||||
:path: ".symlinks/plugins/maplibre_gl/ios"
|
||||
native_video_player:
|
||||
:path: ".symlinks/plugins/native_video_player/ios"
|
||||
package_info_plus:
|
||||
:path: ".symlinks/plugins/package_info_plus/ios"
|
||||
path_provider_foundation:
|
||||
@@ -194,7 +199,7 @@ EXTERNAL SOURCES:
|
||||
SPEC CHECKSUMS:
|
||||
background_downloader: 9f788ffc5de45acf87d6380e91ca0841066c18cf
|
||||
connectivity_plus: ddd7f30999e1faaef5967c23d5b6d503d10434db
|
||||
device_info_plus: 97af1d7e84681a90d0693e63169a5d50e0839a0d
|
||||
device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342
|
||||
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
|
||||
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
|
||||
file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655
|
||||
@@ -210,14 +215,15 @@ SPEC CHECKSUMS:
|
||||
isar_flutter_libs: fdf730ca925d05687f36d7f1d355e482529ed097
|
||||
MapLibre: 620fc933c1d6029b33738c905c1490d024e5d4ef
|
||||
maplibre_gl: a2efec727dd340e4c65e26d2b03b584f14881fd9
|
||||
package_info_plus: 58f0028419748fad15bf008b270aaa8e54380b1c
|
||||
native_video_player: d12af78a1a4a8cf09775a5177d5b392def6fd23c
|
||||
package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4
|
||||
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
|
||||
path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02
|
||||
permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2
|
||||
photo_manager: ff695c7a1dd5bc379974953a2b5c0a293f7c4c8a
|
||||
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
|
||||
SDWebImage: 066c47b573f408f18caa467d71deace7c0f8280d
|
||||
share_plus: 8875f4f2500512ea181eef553c3e27dba5135aad
|
||||
share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f
|
||||
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
|
||||
sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec
|
||||
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
|
||||
@@ -228,4 +234,4 @@ SPEC CHECKSUMS:
|
||||
|
||||
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 Flutter
|
||||
import UIKit
|
||||
import path_provider_ios
|
||||
import photo_manager
|
||||
import permission_handler_apple
|
||||
import photo_manager
|
||||
import shared_preferences_foundation
|
||||
|
||||
@main
|
||||
@objc class AppDelegate: FlutterAppDelegate {
|
||||
|
||||
|
||||
override func application(
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||
) -> Bool {
|
||||
|
||||
// Required for flutter_local_notification
|
||||
if #available(iOS 10.0, *) {
|
||||
UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate
|
||||
// Required for flutter_local_notification
|
||||
if #available(iOS 10.0, *) {
|
||||
UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate
|
||||
}
|
||||
|
||||
do {
|
||||
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default)
|
||||
try AVAudioSession.sharedInstance().setActive(true)
|
||||
} catch {
|
||||
print("Failed to set audio session category. Error: \(error)")
|
||||
}
|
||||
|
||||
GeneratedPluginRegistrant.register(with: self)
|
||||
BackgroundServicePlugin.registerBackgroundProcessing()
|
||||
|
||||
BackgroundServicePlugin.register(with: self.registrar(forPlugin: "BackgroundServicePlugin")!)
|
||||
|
||||
BackgroundServicePlugin.setPluginRegistrantCallback { registry in
|
||||
if !registry.hasPlugin("org.cocoapods.path-provider-ios") {
|
||||
FLTPathProviderPlugin.register(
|
||||
with: registry.registrar(forPlugin: "org.cocoapods.path-provider-ios")!)
|
||||
}
|
||||
|
||||
GeneratedPluginRegistrant.register(with: self)
|
||||
BackgroundServicePlugin.registerBackgroundProcessing()
|
||||
|
||||
BackgroundServicePlugin.register(with: self.registrar(forPlugin: "BackgroundServicePlugin")!)
|
||||
|
||||
BackgroundServicePlugin.setPluginRegistrantCallback { registry in
|
||||
if !registry.hasPlugin("org.cocoapods.path-provider-ios") {
|
||||
FLTPathProviderPlugin.register(with: registry.registrar(forPlugin: "org.cocoapods.path-provider-ios")!)
|
||||
}
|
||||
|
||||
if !registry.hasPlugin("org.cocoapods.photo-manager") {
|
||||
PhotoManagerPlugin.register(with: registry.registrar(forPlugin: "org.cocoapods.photo-manager")!)
|
||||
}
|
||||
|
||||
if !registry.hasPlugin("org.cocoapods.shared-preferences-foundation") {
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "org.cocoapods.shared-preferences-foundation")!)
|
||||
}
|
||||
|
||||
if !registry.hasPlugin("org.cocoapods.permission-handler-apple") {
|
||||
PermissionHandlerPlugin.register(with: registry.registrar(forPlugin: "org.cocoapods.permission-handler-apple")!)
|
||||
}
|
||||
if !registry.hasPlugin("org.cocoapods.photo-manager") {
|
||||
PhotoManagerPlugin.register(
|
||||
with: registry.registrar(forPlugin: "org.cocoapods.photo-manager")!)
|
||||
}
|
||||
|
||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
|
||||
if !registry.hasPlugin("org.cocoapods.shared-preferences-foundation") {
|
||||
SharedPreferencesPlugin.register(
|
||||
with: registry.registrar(forPlugin: "org.cocoapods.shared-preferences-foundation")!)
|
||||
}
|
||||
|
||||
if !registry.hasPlugin("org.cocoapods.permission-handler-apple") {
|
||||
PermissionHandlerPlugin.register(
|
||||
with: registry.registrar(forPlugin: "org.cocoapods.permission-handler-apple")!)
|
||||
}
|
||||
}
|
||||
|
||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -20,8 +20,8 @@ const String defaultColorPresetName = "indigo";
|
||||
const Color immichBrandColorLight = Color(0xFF4150AF);
|
||||
const Color immichBrandColorDark = Color(0xFFACCBFA);
|
||||
const Color whiteOpacity75 = Color.fromARGB((0.75 * 255) ~/ 1, 255, 255, 255);
|
||||
const Color blackOpacity90 = Color.fromARGB((0.90 * 255) ~/ 1, 0, 0, 0);
|
||||
const Color red400 = Color(0xFFEF5350);
|
||||
const Color grey200 = Color(0xFFEEEEEE);
|
||||
|
||||
final Map<ImmichColorPreset, ImmichTheme> _themePresetsMap = {
|
||||
ImmichColorPreset.indigo: ImmichTheme(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:immich_mobile/entities/exif_info.entity.dart';
|
||||
import 'package:immich_mobile/utils/hash.dart';
|
||||
@@ -22,12 +23,8 @@ class Asset {
|
||||
durationInSeconds = remote.duration.toDuration()?.inSeconds ?? 0,
|
||||
type = remote.type.toAssetType(),
|
||||
fileName = remote.originalFileName,
|
||||
height = isFlipped(remote)
|
||||
? remote.exifInfo?.exifImageWidth?.toInt()
|
||||
: remote.exifInfo?.exifImageHeight?.toInt(),
|
||||
width = isFlipped(remote)
|
||||
? remote.exifInfo?.exifImageHeight?.toInt()
|
||||
: remote.exifInfo?.exifImageWidth?.toInt(),
|
||||
height = remote.exifInfo?.exifImageHeight?.toInt(),
|
||||
width = remote.exifInfo?.exifImageWidth?.toInt(),
|
||||
livePhotoVideoId = remote.livePhotoVideoId,
|
||||
ownerId = fastHash(remote.ownerId),
|
||||
exifInfo =
|
||||
@@ -93,6 +90,27 @@ class Asset {
|
||||
|
||||
set local(AssetEntity? assetEntity) => _local = assetEntity;
|
||||
|
||||
@ignore
|
||||
bool _didUpdateLocal = false;
|
||||
|
||||
@ignore
|
||||
Future<AssetEntity> get localAsync async {
|
||||
final local = this.local;
|
||||
if (local == null) {
|
||||
throw Exception('Asset $fileName has no local data');
|
||||
}
|
||||
|
||||
final updatedLocal =
|
||||
_didUpdateLocal ? local : await local.obtainForNewProperties();
|
||||
if (updatedLocal == null) {
|
||||
throw Exception('Could not fetch local data for $fileName');
|
||||
}
|
||||
|
||||
this.local = updatedLocal;
|
||||
_didUpdateLocal = true;
|
||||
return updatedLocal;
|
||||
}
|
||||
|
||||
Id id = Isar.autoIncrement;
|
||||
|
||||
/// stores the raw SHA1 bytes as a base64 String
|
||||
@@ -150,10 +168,21 @@ class Asset {
|
||||
|
||||
int stackCount;
|
||||
|
||||
/// Aspect ratio of the asset
|
||||
/// Returns null if the asset has no sync access to the exif info
|
||||
@ignore
|
||||
double? get aspectRatio =>
|
||||
width == null || height == null ? 0 : width! / height!;
|
||||
double? get aspectRatio {
|
||||
final orientatedWidth = this.orientatedWidth;
|
||||
final orientatedHeight = this.orientatedHeight;
|
||||
|
||||
if (orientatedWidth != null &&
|
||||
orientatedHeight != null &&
|
||||
orientatedWidth > 0 &&
|
||||
orientatedHeight > 0) {
|
||||
return orientatedWidth.toDouble() / orientatedHeight.toDouble();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// `true` if this [Asset] is present on the device
|
||||
@ignore
|
||||
@@ -172,6 +201,12 @@ class Asset {
|
||||
@ignore
|
||||
bool get isImage => type == AssetType.image;
|
||||
|
||||
@ignore
|
||||
bool get isVideo => type == AssetType.video;
|
||||
|
||||
@ignore
|
||||
bool get isMotionPhoto => livePhotoVideoId != null;
|
||||
|
||||
@ignore
|
||||
AssetState get storage {
|
||||
if (isRemote && isLocal) {
|
||||
@@ -192,6 +227,50 @@ class Asset {
|
||||
@ignore
|
||||
set byteHash(List<int> hash) => checksum = base64.encode(hash);
|
||||
|
||||
/// Returns null if the asset has no sync access to the exif info
|
||||
@ignore
|
||||
@pragma('vm:prefer-inline')
|
||||
bool? get isFlipped {
|
||||
final exifInfo = this.exifInfo;
|
||||
if (exifInfo != null) {
|
||||
return exifInfo.isFlipped;
|
||||
}
|
||||
|
||||
if (_didUpdateLocal && Platform.isAndroid) {
|
||||
final local = this.local;
|
||||
if (local == null) {
|
||||
throw Exception('Asset $fileName has no local data');
|
||||
}
|
||||
return local.orientation == 90 || local.orientation == 270;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Returns null if the asset has no sync access to the exif info
|
||||
@ignore
|
||||
@pragma('vm:prefer-inline')
|
||||
int? get orientatedHeight {
|
||||
final isFlipped = this.isFlipped;
|
||||
if (isFlipped == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return isFlipped ? width : height;
|
||||
}
|
||||
|
||||
/// Returns null if the asset has no sync access to the exif info
|
||||
@ignore
|
||||
@pragma('vm:prefer-inline')
|
||||
int? get orientatedWidth {
|
||||
final isFlipped = this.isFlipped;
|
||||
if (isFlipped == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return isFlipped ? height : width;
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(other) {
|
||||
if (other is! Asset) return false;
|
||||
@@ -511,21 +590,3 @@ extension AssetsHelper on IsarCollection<Asset> {
|
||||
return where().anyOf(ids, (q, String e) => q.localIdEqualTo(e));
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if this [int] is flipped 90° clockwise
|
||||
bool isRotated90CW(int orientation) {
|
||||
return [7, 8, -90].contains(orientation);
|
||||
}
|
||||
|
||||
/// Returns `true` if this [int] is flipped 270° clockwise
|
||||
bool isRotated270CW(int orientation) {
|
||||
return [5, 6, 90].contains(orientation);
|
||||
}
|
||||
|
||||
/// Returns `true` if this [Asset] is flipped 90° or 270° clockwise
|
||||
bool isFlipped(AssetResponseDto response) {
|
||||
final int orientation =
|
||||
int.tryParse(response.exifInfo?.orientation ?? '0') ?? 0;
|
||||
return orientation != 0 &&
|
||||
(isRotated90CW(orientation) || isRotated270CW(orientation));
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ class ExifInfo {
|
||||
String? state;
|
||||
String? country;
|
||||
String? description;
|
||||
String? orientation;
|
||||
|
||||
@ignore
|
||||
bool get hasCoordinates =>
|
||||
@@ -45,6 +46,13 @@ class ExifInfo {
|
||||
@ignore
|
||||
String get focalLength => mm != null ? mm!.toStringAsFixed(1) : "";
|
||||
|
||||
@ignore
|
||||
bool? _isFlipped;
|
||||
|
||||
@ignore
|
||||
@pragma('vm:prefer-inline')
|
||||
bool get isFlipped => _isFlipped ??= _isOrientationFlipped(orientation);
|
||||
|
||||
@ignore
|
||||
double? get latitude => lat;
|
||||
|
||||
@@ -67,7 +75,8 @@ class ExifInfo {
|
||||
city = dto.city,
|
||||
state = dto.state,
|
||||
country = dto.country,
|
||||
description = dto.description;
|
||||
description = dto.description,
|
||||
orientation = dto.orientation;
|
||||
|
||||
ExifInfo({
|
||||
this.id,
|
||||
@@ -87,6 +96,7 @@ class ExifInfo {
|
||||
this.state,
|
||||
this.country,
|
||||
this.description,
|
||||
this.orientation,
|
||||
});
|
||||
|
||||
ExifInfo copyWith({
|
||||
@@ -107,6 +117,7 @@ class ExifInfo {
|
||||
String? state,
|
||||
String? country,
|
||||
String? description,
|
||||
String? orientation,
|
||||
}) =>
|
||||
ExifInfo(
|
||||
id: id ?? this.id,
|
||||
@@ -126,6 +137,7 @@ class ExifInfo {
|
||||
state: state ?? this.state,
|
||||
country: country ?? this.country,
|
||||
description: description ?? this.description,
|
||||
orientation: orientation ?? this.orientation,
|
||||
);
|
||||
|
||||
@override
|
||||
@@ -147,7 +159,8 @@ class ExifInfo {
|
||||
city == other.city &&
|
||||
state == other.state &&
|
||||
country == other.country &&
|
||||
description == other.description;
|
||||
description == other.description &&
|
||||
orientation == other.orientation;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -169,7 +182,8 @@ class ExifInfo {
|
||||
city.hashCode ^
|
||||
state.hashCode ^
|
||||
country.hashCode ^
|
||||
description.hashCode;
|
||||
description.hashCode ^
|
||||
orientation.hashCode;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
@@ -192,10 +206,21 @@ class ExifInfo {
|
||||
state: $state,
|
||||
country: $country,
|
||||
description: $description,
|
||||
orientation: $orientation
|
||||
}""";
|
||||
}
|
||||
}
|
||||
|
||||
bool _isOrientationFlipped(String? orientation) {
|
||||
final value = orientation != null ? int.tryParse(orientation) : null;
|
||||
if (value == null) {
|
||||
return false;
|
||||
}
|
||||
final isRotated90CW = value == 5 || value == 6 || value == 90;
|
||||
final isRotated270CW = value == 7 || value == 8 || value == -90;
|
||||
return isRotated90CW || isRotated270CW;
|
||||
}
|
||||
|
||||
double? _exposureTimeToSeconds(String? s) {
|
||||
if (s == null) {
|
||||
return null;
|
||||
|
||||
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',
|
||||
type: IsarType.string,
|
||||
),
|
||||
r'state': PropertySchema(
|
||||
r'orientation': PropertySchema(
|
||||
id: 14,
|
||||
name: r'orientation',
|
||||
type: IsarType.string,
|
||||
),
|
||||
r'state': PropertySchema(
|
||||
id: 15,
|
||||
name: r'state',
|
||||
type: IsarType.string,
|
||||
),
|
||||
r'timeZone': PropertySchema(
|
||||
id: 15,
|
||||
id: 16,
|
||||
name: r'timeZone',
|
||||
type: IsarType.string,
|
||||
)
|
||||
@@ -154,6 +159,12 @@ int _exifInfoEstimateSize(
|
||||
bytesCount += 3 + value.length * 3;
|
||||
}
|
||||
}
|
||||
{
|
||||
final value = object.orientation;
|
||||
if (value != null) {
|
||||
bytesCount += 3 + value.length * 3;
|
||||
}
|
||||
}
|
||||
{
|
||||
final value = object.state;
|
||||
if (value != null) {
|
||||
@@ -189,8 +200,9 @@ void _exifInfoSerialize(
|
||||
writer.writeString(offsets[11], object.make);
|
||||
writer.writeFloat(offsets[12], object.mm);
|
||||
writer.writeString(offsets[13], object.model);
|
||||
writer.writeString(offsets[14], object.state);
|
||||
writer.writeString(offsets[15], object.timeZone);
|
||||
writer.writeString(offsets[14], object.orientation);
|
||||
writer.writeString(offsets[15], object.state);
|
||||
writer.writeString(offsets[16], object.timeZone);
|
||||
}
|
||||
|
||||
ExifInfo _exifInfoDeserialize(
|
||||
@@ -215,8 +227,9 @@ ExifInfo _exifInfoDeserialize(
|
||||
make: reader.readStringOrNull(offsets[11]),
|
||||
mm: reader.readFloatOrNull(offsets[12]),
|
||||
model: reader.readStringOrNull(offsets[13]),
|
||||
state: reader.readStringOrNull(offsets[14]),
|
||||
timeZone: reader.readStringOrNull(offsets[15]),
|
||||
orientation: reader.readStringOrNull(offsets[14]),
|
||||
state: reader.readStringOrNull(offsets[15]),
|
||||
timeZone: reader.readStringOrNull(offsets[16]),
|
||||
);
|
||||
return object;
|
||||
}
|
||||
@@ -260,6 +273,8 @@ P _exifInfoDeserializeProp<P>(
|
||||
return (reader.readStringOrNull(offset)) as P;
|
||||
case 15:
|
||||
return (reader.readStringOrNull(offset)) as P;
|
||||
case 16:
|
||||
return (reader.readStringOrNull(offset)) as P;
|
||||
default:
|
||||
throw IsarError('Unknown property with id $propertyId');
|
||||
}
|
||||
@@ -1909,6 +1924,155 @@ extension ExifInfoQueryFilter
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<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() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
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() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
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() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
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(
|
||||
{bool caseSensitive = true}) {
|
||||
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() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
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_hooks/flutter_hooks.dart' hide Store;
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/scroll_extensions.dart';
|
||||
import 'package:immich_mobile/pages/common/download_panel.dart';
|
||||
import 'package:immich_mobile/pages/common/video_viewer.page.dart';
|
||||
import 'package:immich_mobile/pages/common/native_video_viewer.page.dart';
|
||||
import 'package:immich_mobile/pages/common/gallery_stacked_children.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_stack.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
||||
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
|
||||
import 'package:immich_mobile/providers/image/immich_remote_image_provider.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart';
|
||||
import 'package:immich_mobile/widgets/asset_viewer/advanced_bottom_sheet.dart';
|
||||
@@ -35,6 +36,7 @@ import 'package:immich_mobile/widgets/photo_view/src/utils/photo_view_hero_attri
|
||||
|
||||
@RoutePage()
|
||||
// ignore: must_be_immutable
|
||||
/// Expects [currentAssetProvider] to be set before navigating to this page
|
||||
class GalleryViewerPage extends HookConsumerWidget {
|
||||
final int initialIndex;
|
||||
final int heroOffset;
|
||||
@@ -53,79 +55,66 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final settings = ref.watch(appSettingsServiceProvider);
|
||||
final loadAsset = renderList.loadAsset;
|
||||
final totalAssets = useState(renderList.totalAssets);
|
||||
final shouldLoopVideo = useState(AppSettingsEnum.loopVideo.defaultValue);
|
||||
final isZoomed = useState(false);
|
||||
final isPlayingVideo = useState(false);
|
||||
final localPosition = useState<Offset?>(null);
|
||||
final currentIndex = useState(initialIndex);
|
||||
final currentAsset = loadAsset(currentIndex.value);
|
||||
|
||||
// Update is playing motion video
|
||||
ref.listen(videoPlaybackValueProvider.select((v) => v.state), (_, state) {
|
||||
isPlayingVideo.value = state == VideoPlaybackState.playing;
|
||||
});
|
||||
|
||||
final stackIndex = useState(-1);
|
||||
final stack = showStack && currentAsset.stackCount > 0
|
||||
? ref.watch(assetStackStateProvider(currentAsset))
|
||||
: <Asset>[];
|
||||
final stackElements = showStack ? [currentAsset, ...stack] : <Asset>[];
|
||||
// Assets from response DTOs do not have an isar id, querying which would give us the default autoIncrement id
|
||||
final isFromDto = currentAsset.id == noDbId;
|
||||
|
||||
Asset asset = stackIndex.value == -1
|
||||
? currentAsset
|
||||
: stackElements.elementAt(stackIndex.value);
|
||||
|
||||
final isMotionPhoto = asset.livePhotoVideoId != null;
|
||||
// Listen provider to prevent autoDispose when navigating to other routes from within the gallery page
|
||||
ref.listen(currentAssetProvider, (_, __) {});
|
||||
useEffect(
|
||||
() {
|
||||
// Delay state update to after the execution of build method
|
||||
Future.microtask(
|
||||
() => ref.read(currentAssetProvider.notifier).set(asset),
|
||||
);
|
||||
return null;
|
||||
},
|
||||
[asset],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
shouldLoopVideo.value =
|
||||
settings.getSetting<bool>(AppSettingsEnum.loopVideo);
|
||||
return null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
final stackIndex = useState(0);
|
||||
final localPosition = useRef<Offset?>(null);
|
||||
final currentIndex = useValueNotifier(initialIndex);
|
||||
final loadAsset = renderList.loadAsset;
|
||||
|
||||
Future<void> precacheNextImage(int index) async {
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
void onError(Object exception, StackTrace? stackTrace) {
|
||||
// swallow error silently
|
||||
debugPrint('Error precaching next image: $exception, $stackTrace');
|
||||
log.severe('Error precaching next image: $exception, $stackTrace');
|
||||
}
|
||||
|
||||
try {
|
||||
if (index < totalAssets.value && index >= 0) {
|
||||
final asset = loadAsset(index);
|
||||
await precacheImage(
|
||||
ImmichImage.imageProvider(asset: asset),
|
||||
ImmichImage.imageProvider(
|
||||
asset: asset,
|
||||
width: context.width,
|
||||
height: context.height,
|
||||
),
|
||||
context,
|
||||
onError: onError,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
// swallow error silently
|
||||
debugPrint('Error precaching next image: $e');
|
||||
log.severe('Error precaching next image: $e');
|
||||
context.maybePop();
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
if (ref.read(showControlsProvider)) {
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||
} else {
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
|
||||
}
|
||||
|
||||
// Delay this a bit so we can finish loading the page
|
||||
Timer(const Duration(milliseconds: 400), () {
|
||||
precacheNextImage(currentIndex.value + 1);
|
||||
});
|
||||
|
||||
return null;
|
||||
},
|
||||
const [],
|
||||
);
|
||||
|
||||
void showInfo() {
|
||||
final asset = ref.read(currentAssetProvider);
|
||||
if (asset == null) {
|
||||
return;
|
||||
}
|
||||
showModalBottomSheet(
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(15.0)),
|
||||
@@ -183,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) {
|
||||
if (show) {
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||
@@ -219,50 +180,88 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
}
|
||||
});
|
||||
|
||||
Widget buildStackedChildren() {
|
||||
return ListView.builder(
|
||||
shrinkWrap: true,
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: stackElements.length,
|
||||
padding: const EdgeInsets.only(
|
||||
left: 5,
|
||||
right: 5,
|
||||
bottom: 30,
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
final assetId = stackElements.elementAt(index).remoteId;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 5),
|
||||
child: GestureDetector(
|
||||
onTap: () => stackIndex.value = index,
|
||||
child: Container(
|
||||
width: 60,
|
||||
height: 60,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: (stackIndex.value == -1 && index == 0) ||
|
||||
index == stackIndex.value
|
||||
? Border.all(
|
||||
color: Colors.white,
|
||||
width: 2,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: Image(
|
||||
fit: BoxFit.cover,
|
||||
image: ImmichRemoteImageProvider(assetId: assetId!),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
PhotoViewGalleryPageOptions buildImage(BuildContext context, Asset asset) {
|
||||
return PhotoViewGalleryPageOptions(
|
||||
onDragStart: (_, details, __) {
|
||||
localPosition.value = details.localPosition;
|
||||
},
|
||||
onDragUpdate: (_, details, __) {
|
||||
handleSwipeUpDown(details);
|
||||
},
|
||||
onTapDown: (_, __, ___) {
|
||||
ref.read(showControlsProvider.notifier).toggle();
|
||||
},
|
||||
onLongPressStart: asset.isMotionPhoto
|
||||
? (_, __, ___) {
|
||||
ref.read(isPlayingMotionVideoProvider.notifier).playing = true;
|
||||
}
|
||||
: null,
|
||||
imageProvider: ImmichImage.imageProvider(asset: asset),
|
||||
heroAttributes: _getHeroAttributes(asset),
|
||||
filterQuality: FilterQuality.high,
|
||||
tightMode: true,
|
||||
minScale: PhotoViewComputedScale.contained,
|
||||
errorBuilder: (context, error, stackTrace) => ImmichImage(
|
||||
asset,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
PhotoViewGalleryPageOptions buildVideo(BuildContext context, Asset asset) {
|
||||
// This key is to prevent the video player from being re-initialized during the hero animation
|
||||
final key = GlobalKey();
|
||||
return PhotoViewGalleryPageOptions.customChild(
|
||||
onDragStart: (_, details, __) =>
|
||||
localPosition.value = details.localPosition,
|
||||
onDragUpdate: (_, details, __) => handleSwipeUpDown(details),
|
||||
heroAttributes: _getHeroAttributes(asset),
|
||||
filterQuality: FilterQuality.high,
|
||||
initialScale: 1.0,
|
||||
maxScale: 1.0,
|
||||
minScale: 1.0,
|
||||
basePosition: Alignment.center,
|
||||
child: SizedBox(
|
||||
width: context.width,
|
||||
height: context.height,
|
||||
child: NativeVideoViewerPage(
|
||||
key: key,
|
||||
asset: asset,
|
||||
image: Image(
|
||||
key: ValueKey(asset),
|
||||
image: ImmichImage.imageProvider(
|
||||
asset: asset,
|
||||
width: context.width,
|
||||
height: context.height,
|
||||
),
|
||||
fit: BoxFit.contain,
|
||||
height: context.height,
|
||||
width: context.width,
|
||||
alignment: Alignment.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
PhotoViewGalleryPageOptions buildAsset(BuildContext context, int index) {
|
||||
ref.read(isPlayingMotionVideoProvider.notifier).playing = false;
|
||||
var newAsset = loadAsset(index);
|
||||
final stackId = newAsset.stackId;
|
||||
if (stackId != null && currentIndex.value == index) {
|
||||
final stackElements =
|
||||
ref.read(assetStackStateProvider(newAsset.stackId!));
|
||||
if (stackIndex.value < stackElements.length) {
|
||||
newAsset = stackElements.elementAt(stackIndex.value);
|
||||
}
|
||||
}
|
||||
|
||||
if (newAsset.isImage && !newAsset.isMotionPhoto) {
|
||||
return buildImage(context, newAsset);
|
||||
}
|
||||
return buildVideo(context, newAsset);
|
||||
}
|
||||
|
||||
return PopScope(
|
||||
// Change immersive mode back to normal "edgeToEdge" mode
|
||||
onPopInvokedWithResult: (didPop, _) =>
|
||||
@@ -272,128 +271,79 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
body: Stack(
|
||||
children: [
|
||||
PhotoViewGallery.builder(
|
||||
key: const ValueKey('gallery'),
|
||||
scaleStateChangedCallback: (state) {
|
||||
isZoomed.value = state != PhotoViewScaleState.initial;
|
||||
ref.read(showControlsProvider.notifier).show = !isZoomed.value;
|
||||
final asset = ref.read(currentAssetProvider);
|
||||
if (asset == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (asset.isImage && !ref.read(isPlayingMotionVideoProvider)) {
|
||||
isZoomed.value = state != PhotoViewScaleState.initial;
|
||||
ref.read(showControlsProvider.notifier).show =
|
||||
!isZoomed.value;
|
||||
}
|
||||
},
|
||||
loadingBuilder: (context, event, index) => ClipRect(
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
BackdropFilter(
|
||||
filter: ui.ImageFilter.blur(
|
||||
sigmaX: 10,
|
||||
sigmaY: 10,
|
||||
gaplessPlayback: true,
|
||||
loadingBuilder: (context, event, index) {
|
||||
final asset = loadAsset(index);
|
||||
return ClipRect(
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
BackdropFilter(
|
||||
filter: ui.ImageFilter.blur(
|
||||
sigmaX: 10,
|
||||
sigmaY: 10,
|
||||
),
|
||||
),
|
||||
),
|
||||
ImmichThumbnail(
|
||||
asset: asset,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
ImmichThumbnail(
|
||||
key: ValueKey(asset),
|
||||
asset: asset,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
pageController: controller,
|
||||
scrollPhysics: isZoomed.value
|
||||
? const NeverScrollableScrollPhysics() // Don't allow paging while scrolled in
|
||||
: (Platform.isIOS
|
||||
? const ScrollPhysics() // Use bouncing physics for iOS
|
||||
: const ClampingScrollPhysics() // Use heavy physics for Android
|
||||
? const FastScrollPhysics() // Use bouncing physics for iOS
|
||||
: const FastClampingScrollPhysics() // Use heavy physics for Android
|
||||
),
|
||||
itemCount: totalAssets.value,
|
||||
scrollDirection: Axis.horizontal,
|
||||
onPageChanged: (value) async {
|
||||
onPageChanged: (value) {
|
||||
final next = currentIndex.value < value ? value + 1 : value - 1;
|
||||
|
||||
ref.read(hapticFeedbackProvider.notifier).selectionClick();
|
||||
|
||||
final newAsset = loadAsset(value);
|
||||
|
||||
currentIndex.value = value;
|
||||
stackIndex.value = -1;
|
||||
isPlayingVideo.value = false;
|
||||
stackIndex.value = 0;
|
||||
|
||||
// Wait for page change animation to finish
|
||||
await Future.delayed(const Duration(milliseconds: 400));
|
||||
// Then precache the next image
|
||||
unawaited(precacheNextImage(next));
|
||||
},
|
||||
builder: (context, index) {
|
||||
final a =
|
||||
index == currentIndex.value ? asset : loadAsset(index);
|
||||
|
||||
final ImageProvider provider =
|
||||
ImmichImage.imageProvider(asset: a);
|
||||
|
||||
if (a.isImage && !isPlayingVideo.value) {
|
||||
return PhotoViewGalleryPageOptions(
|
||||
onDragStart: (_, details, __) =>
|
||||
localPosition.value = details.localPosition,
|
||||
onDragUpdate: (_, details, __) =>
|
||||
handleSwipeUpDown(details),
|
||||
onTapDown: (_, __, ___) {
|
||||
ref.read(showControlsProvider.notifier).toggle();
|
||||
},
|
||||
onLongPressStart: (_, __, ___) {
|
||||
if (asset.livePhotoVideoId != null) {
|
||||
isPlayingVideo.value = true;
|
||||
}
|
||||
},
|
||||
imageProvider: provider,
|
||||
heroAttributes: PhotoViewHeroAttributes(
|
||||
tag: isFromDto
|
||||
? '${currentAsset.remoteId}-$heroOffset'
|
||||
: currentAsset.id + heroOffset,
|
||||
transitionOnUserGestures: true,
|
||||
),
|
||||
filterQuality: FilterQuality.high,
|
||||
tightMode: true,
|
||||
minScale: PhotoViewComputedScale.contained,
|
||||
errorBuilder: (context, error, stackTrace) => ImmichImage(
|
||||
a,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return PhotoViewGalleryPageOptions.customChild(
|
||||
onDragStart: (_, details, __) =>
|
||||
localPosition.value = details.localPosition,
|
||||
onDragUpdate: (_, details, __) =>
|
||||
handleSwipeUpDown(details),
|
||||
heroAttributes: PhotoViewHeroAttributes(
|
||||
tag: isFromDto
|
||||
? '${currentAsset.remoteId}-$heroOffset'
|
||||
: currentAsset.id + heroOffset,
|
||||
),
|
||||
filterQuality: FilterQuality.high,
|
||||
maxScale: 1.0,
|
||||
minScale: 1.0,
|
||||
basePosition: Alignment.center,
|
||||
child: VideoViewerPage(
|
||||
key: ValueKey(a),
|
||||
asset: a,
|
||||
isMotionVideo: a.livePhotoVideoId != null,
|
||||
loopVideo: shouldLoopVideo.value,
|
||||
placeholder: Image(
|
||||
image: provider,
|
||||
fit: BoxFit.contain,
|
||||
height: context.height,
|
||||
width: context.width,
|
||||
alignment: Alignment.center,
|
||||
),
|
||||
),
|
||||
);
|
||||
ref.read(currentAssetProvider.notifier).set(newAsset);
|
||||
if (newAsset.isVideo || newAsset.isMotionPhoto) {
|
||||
ref.read(videoPlaybackValueProvider.notifier).reset();
|
||||
}
|
||||
|
||||
// Wait for page change animation to finish, then precache the next image
|
||||
Timer(const Duration(milliseconds: 400), () {
|
||||
precacheNextImage(next);
|
||||
});
|
||||
},
|
||||
builder: buildAsset,
|
||||
),
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: GalleryAppBar(
|
||||
asset: asset,
|
||||
key: const ValueKey('app-bar'),
|
||||
showInfo: showInfo,
|
||||
isPlayingVideo: isPlayingVideo.value,
|
||||
onToggleMotionVideo: () =>
|
||||
isPlayingVideo.value = !isPlayingVideo.value,
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
@@ -402,22 +352,15 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
right: 0,
|
||||
child: Column(
|
||||
children: [
|
||||
Visibility(
|
||||
visible: stack.isNotEmpty,
|
||||
child: SizedBox(
|
||||
height: 80,
|
||||
child: buildStackedChildren(),
|
||||
),
|
||||
),
|
||||
GalleryStackedChildren(stackIndex),
|
||||
BottomGalleryBar(
|
||||
key: const ValueKey('bottom-bar'),
|
||||
renderList: renderList,
|
||||
totalAssets: totalAssets,
|
||||
controller: controller,
|
||||
showStack: showStack,
|
||||
stackIndex: stackIndex.value,
|
||||
asset: asset,
|
||||
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
|
||||
final size = MediaQuery.sizeOf(context);
|
||||
await precacheImage(
|
||||
ImmichImage.imageProvider(
|
||||
asset: asset,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
),
|
||||
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/models/map/map_event.model.dart';
|
||||
import 'package:immich_mobile/models/map/map_marker.model.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
|
||||
import 'package:immich_mobile/providers/db.provider.dart';
|
||||
import 'package:immich_mobile/providers/map/map_marker.provider.dart';
|
||||
import 'package:immich_mobile/providers/map/map_state.provider.dart';
|
||||
@@ -99,8 +101,11 @@ class MapPage extends HookConsumerWidget {
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
final currentAssetLink =
|
||||
ref.read(currentAssetProvider.notifier).ref.keepAlive();
|
||||
|
||||
loadMarkers();
|
||||
return null;
|
||||
return currentAssetLink.close;
|
||||
},
|
||||
[],
|
||||
);
|
||||
@@ -186,6 +191,10 @@ class MapPage extends HookConsumerWidget {
|
||||
GroupAssetsBy.none,
|
||||
);
|
||||
|
||||
ref.read(currentAssetProvider.notifier).set(asset);
|
||||
if (asset.isVideo) {
|
||||
ref.read(showControlsProvider.notifier).show = false;
|
||||
}
|
||||
context.pushRoute(
|
||||
GalleryViewerRoute(
|
||||
initialIndex: 0,
|
||||
|
||||
@@ -7,49 +7,49 @@ import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
part 'asset_stack.provider.g.dart';
|
||||
|
||||
class AssetStackNotifier extends StateNotifier<List<Asset>> {
|
||||
final Asset _asset;
|
||||
final String _stackId;
|
||||
final Ref _ref;
|
||||
|
||||
AssetStackNotifier(
|
||||
this._asset,
|
||||
this._ref,
|
||||
) : super([]) {
|
||||
fetchStackChildren();
|
||||
AssetStackNotifier(this._stackId, this._ref) : super([]) {
|
||||
_fetchStack(_stackId);
|
||||
}
|
||||
|
||||
void fetchStackChildren() async {
|
||||
if (mounted) {
|
||||
state = await _ref.read(assetStackProvider(_asset).future);
|
||||
void _fetchStack(String stackId) async {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
final stack = await _ref.read(assetStackProvider(stackId).future);
|
||||
if (stack.isNotEmpty) {
|
||||
state = stack;
|
||||
}
|
||||
}
|
||||
|
||||
void removeChild(int index) {
|
||||
if (index < state.length) {
|
||||
state.removeAt(index);
|
||||
state = List<Asset>.from(state);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final assetStackStateProvider = StateNotifierProvider.autoDispose
|
||||
.family<AssetStackNotifier, List<Asset>, Asset>(
|
||||
(ref, asset) => AssetStackNotifier(asset, ref),
|
||||
.family<AssetStackNotifier, List<Asset>, String>(
|
||||
(ref, stackId) => AssetStackNotifier(stackId, ref),
|
||||
);
|
||||
|
||||
final assetStackProvider =
|
||||
FutureProvider.autoDispose.family<List<Asset>, Asset>((ref, asset) async {
|
||||
// Guard [local asset]
|
||||
if (asset.remoteId == null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return await ref
|
||||
FutureProvider.autoDispose.family<List<Asset>, String>((ref, stackId) {
|
||||
return ref
|
||||
.watch(dbProvider)
|
||||
.assets
|
||||
.filter()
|
||||
.isArchivedEqualTo(false)
|
||||
.isTrashedEqualTo(false)
|
||||
.stackPrimaryAssetIdEqualTo(asset.remoteId)
|
||||
.sortByFileCreatedAtDesc()
|
||||
.stackIdEqualTo(stackId)
|
||||
// orders primary asset first as its ID is null
|
||||
.sortByStackPrimaryAssetId()
|
||||
.thenByFileCreatedAtDesc()
|
||||
.findAll();
|
||||
});
|
||||
|
||||
|
||||
@@ -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:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
||||
|
||||
class VideoPlaybackControls {
|
||||
VideoPlaybackControls({
|
||||
const VideoPlaybackControls({
|
||||
required this.position,
|
||||
required this.mute,
|
||||
required this.pause,
|
||||
@@ -17,15 +18,14 @@ final videoPlayerControlsProvider =
|
||||
return VideoPlayerControls(ref);
|
||||
});
|
||||
|
||||
const videoPlayerControlsDefault = VideoPlaybackControls(
|
||||
position: 0,
|
||||
pause: false,
|
||||
mute: false,
|
||||
);
|
||||
|
||||
class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
|
||||
VideoPlayerControls(this.ref)
|
||||
: super(
|
||||
VideoPlaybackControls(
|
||||
position: 0,
|
||||
pause: false,
|
||||
mute: false,
|
||||
),
|
||||
);
|
||||
VideoPlayerControls(this.ref) : super(videoPlayerControlsDefault);
|
||||
|
||||
final Ref ref;
|
||||
|
||||
@@ -36,17 +36,18 @@ class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
|
||||
}
|
||||
|
||||
void reset() {
|
||||
state = VideoPlaybackControls(
|
||||
position: 0,
|
||||
pause: false,
|
||||
mute: false,
|
||||
);
|
||||
state = videoPlayerControlsDefault;
|
||||
}
|
||||
|
||||
double get position => state.position;
|
||||
bool get mute => state.mute;
|
||||
bool get paused => state.pause;
|
||||
|
||||
set position(double value) {
|
||||
if (state.position == value) {
|
||||
return;
|
||||
}
|
||||
|
||||
state = VideoPlaybackControls(
|
||||
position: value,
|
||||
mute: state.mute,
|
||||
@@ -55,6 +56,10 @@ class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
|
||||
}
|
||||
|
||||
set mute(bool value) {
|
||||
if (state.mute == value) {
|
||||
return;
|
||||
}
|
||||
|
||||
state = VideoPlaybackControls(
|
||||
position: state.position,
|
||||
mute: value,
|
||||
@@ -71,6 +76,10 @@ class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
|
||||
}
|
||||
|
||||
void pause() {
|
||||
if (state.pause) {
|
||||
return;
|
||||
}
|
||||
|
||||
state = VideoPlaybackControls(
|
||||
position: state.position,
|
||||
mute: state.mute,
|
||||
@@ -79,6 +88,10 @@ class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
|
||||
}
|
||||
|
||||
void play() {
|
||||
if (!state.pause) {
|
||||
return;
|
||||
}
|
||||
|
||||
state = VideoPlaybackControls(
|
||||
position: state.position,
|
||||
mute: state.mute,
|
||||
@@ -95,16 +108,11 @@ class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
|
||||
}
|
||||
|
||||
void restart() {
|
||||
state = VideoPlaybackControls(
|
||||
position: 0,
|
||||
mute: state.mute,
|
||||
pause: true,
|
||||
);
|
||||
|
||||
state = VideoPlaybackControls(
|
||||
position: 0,
|
||||
mute: state.mute,
|
||||
pause: false,
|
||||
);
|
||||
ref.read(videoPlaybackValueProvider.notifier).position = Duration.zero;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
import 'package:native_video_player/native_video_player.dart';
|
||||
|
||||
enum VideoPlaybackState {
|
||||
initializing,
|
||||
@@ -22,56 +22,66 @@ class VideoPlaybackValue {
|
||||
/// The volume of the video
|
||||
final double volume;
|
||||
|
||||
VideoPlaybackValue({
|
||||
const VideoPlaybackValue({
|
||||
required this.position,
|
||||
required this.duration,
|
||||
required this.state,
|
||||
required this.volume,
|
||||
});
|
||||
|
||||
factory VideoPlaybackValue.fromController(VideoPlayerController? controller) {
|
||||
final video = controller?.value;
|
||||
late VideoPlaybackState s;
|
||||
if (video == null) {
|
||||
s = VideoPlaybackState.initializing;
|
||||
} else if (video.isCompleted) {
|
||||
s = VideoPlaybackState.completed;
|
||||
} else if (video.isPlaying) {
|
||||
s = VideoPlaybackState.playing;
|
||||
} else if (video.isBuffering) {
|
||||
s = VideoPlaybackState.buffering;
|
||||
} else {
|
||||
s = VideoPlaybackState.paused;
|
||||
factory VideoPlaybackValue.fromNativeController(
|
||||
NativeVideoPlayerController controller,
|
||||
) {
|
||||
final playbackInfo = controller.playbackInfo;
|
||||
final videoInfo = controller.videoInfo;
|
||||
|
||||
if (playbackInfo == null || videoInfo == null) {
|
||||
return videoPlaybackValueDefault;
|
||||
}
|
||||
|
||||
final VideoPlaybackState status = switch (playbackInfo.status) {
|
||||
PlaybackStatus.playing => VideoPlaybackState.playing,
|
||||
PlaybackStatus.paused => VideoPlaybackState.paused,
|
||||
PlaybackStatus.stopped => VideoPlaybackState.completed,
|
||||
};
|
||||
|
||||
return VideoPlaybackValue(
|
||||
position: video?.position ?? Duration.zero,
|
||||
duration: video?.duration ?? Duration.zero,
|
||||
state: s,
|
||||
volume: video?.volume ?? 0.0,
|
||||
position: Duration(seconds: playbackInfo.position),
|
||||
duration: Duration(seconds: videoInfo.duration),
|
||||
state: status,
|
||||
volume: playbackInfo.volume,
|
||||
);
|
||||
}
|
||||
|
||||
factory VideoPlaybackValue.uninitialized() {
|
||||
VideoPlaybackValue copyWith({
|
||||
Duration? position,
|
||||
Duration? duration,
|
||||
VideoPlaybackState? state,
|
||||
double? volume,
|
||||
}) {
|
||||
return VideoPlaybackValue(
|
||||
position: Duration.zero,
|
||||
duration: Duration.zero,
|
||||
state: VideoPlaybackState.initializing,
|
||||
volume: 0.0,
|
||||
position: position ?? this.position,
|
||||
duration: duration ?? this.duration,
|
||||
state: state ?? this.state,
|
||||
volume: volume ?? this.volume,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const VideoPlaybackValue videoPlaybackValueDefault = VideoPlaybackValue(
|
||||
position: Duration.zero,
|
||||
duration: Duration.zero,
|
||||
state: VideoPlaybackState.initializing,
|
||||
volume: 0.0,
|
||||
);
|
||||
|
||||
final videoPlaybackValueProvider =
|
||||
StateNotifierProvider<VideoPlaybackValueState, VideoPlaybackValue>((ref) {
|
||||
return VideoPlaybackValueState(ref);
|
||||
});
|
||||
|
||||
class VideoPlaybackValueState extends StateNotifier<VideoPlaybackValue> {
|
||||
VideoPlaybackValueState(this.ref)
|
||||
: super(
|
||||
VideoPlaybackValue.uninitialized(),
|
||||
);
|
||||
VideoPlaybackValueState(this.ref) : super(videoPlaybackValueDefault);
|
||||
|
||||
final Ref ref;
|
||||
|
||||
@@ -82,6 +92,7 @@ class VideoPlaybackValueState extends StateNotifier<VideoPlaybackValue> {
|
||||
}
|
||||
|
||||
set position(Duration value) {
|
||||
if (state.position == value) return;
|
||||
state = VideoPlaybackValue(
|
||||
position: value,
|
||||
duration: state.duration,
|
||||
@@ -89,4 +100,18 @@ class VideoPlaybackValueState extends StateNotifier<VideoPlaybackValue> {
|
||||
volume: state.volume,
|
||||
);
|
||||
}
|
||||
|
||||
set status(VideoPlaybackState value) {
|
||||
if (state.state == value) return;
|
||||
state = VideoPlaybackValue(
|
||||
position: state.position,
|
||||
duration: state.duration,
|
||||
state: value,
|
||||
volume: state.volume,
|
||||
);
|
||||
}
|
||||
|
||||
void reset() {
|
||||
state = videoPlaybackValueDefault;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,14 +7,21 @@ import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/painting.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:photo_manager/photo_manager.dart' show ThumbnailSize;
|
||||
|
||||
/// The local image provider for an asset
|
||||
class ImmichLocalImageProvider extends ImageProvider<ImmichLocalImageProvider> {
|
||||
final Asset asset;
|
||||
// only used for videos
|
||||
final double width;
|
||||
final double height;
|
||||
final Logger log = Logger('ImmichLocalImageProvider');
|
||||
|
||||
ImmichLocalImageProvider({
|
||||
required this.asset,
|
||||
required this.width,
|
||||
required this.height,
|
||||
}) : assert(asset.local != null, 'Only usable when asset.local is set');
|
||||
|
||||
/// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key
|
||||
@@ -42,38 +49,57 @@ class ImmichLocalImageProvider extends ImageProvider<ImmichLocalImageProvider> {
|
||||
|
||||
// Streams in each stage of the image as we ask for it
|
||||
Stream<ui.Codec> _codec(
|
||||
Asset key,
|
||||
Asset asset,
|
||||
ImageDecoderCallback decode,
|
||||
StreamController<ImageChunkEvent> chunkEvents,
|
||||
) async* {
|
||||
// Load a small thumbnail
|
||||
final thumbBytes = await asset.local?.thumbnailDataWithSize(
|
||||
const ThumbnailSize.square(256),
|
||||
quality: 80,
|
||||
);
|
||||
if (thumbBytes != null) {
|
||||
final buffer = await ui.ImmutableBuffer.fromUint8List(thumbBytes);
|
||||
final codec = await decode(buffer);
|
||||
yield codec;
|
||||
} else {
|
||||
debugPrint("Loading thumb for ${asset.fileName} failed");
|
||||
}
|
||||
|
||||
if (asset.isImage) {
|
||||
final File? file = await asset.local?.originFile;
|
||||
if (file == null) {
|
||||
throw StateError("Opening file for asset ${asset.fileName} failed");
|
||||
ui.ImmutableBuffer? buffer;
|
||||
try {
|
||||
final local = asset.local;
|
||||
if (local == null) {
|
||||
throw StateError('Asset ${asset.fileName} has no local data');
|
||||
}
|
||||
try {
|
||||
final buffer = await ui.ImmutableBuffer.fromFilePath(file.path);
|
||||
final codec = await decode(buffer);
|
||||
yield codec;
|
||||
} catch (error) {
|
||||
throw StateError("Loading asset ${asset.fileName} failed");
|
||||
}
|
||||
}
|
||||
|
||||
chunkEvents.close();
|
||||
var thumbBytes = await local
|
||||
.thumbnailDataWithSize(const ThumbnailSize.square(256), quality: 80);
|
||||
if (thumbBytes == null) {
|
||||
throw StateError("Loading thumbnail for ${asset.fileName} failed");
|
||||
}
|
||||
buffer = await ui.ImmutableBuffer.fromUint8List(thumbBytes);
|
||||
thumbBytes = null;
|
||||
yield await decode(buffer);
|
||||
buffer = null;
|
||||
|
||||
switch (asset.type) {
|
||||
case AssetType.image:
|
||||
final File? file = await local.originFile;
|
||||
if (file == null) {
|
||||
throw StateError("Opening file for asset ${asset.fileName} failed");
|
||||
}
|
||||
buffer = await ui.ImmutableBuffer.fromFilePath(file.path);
|
||||
yield await decode(buffer);
|
||||
buffer = null;
|
||||
break;
|
||||
case AssetType.video:
|
||||
final size = ThumbnailSize(width.ceil(), height.ceil());
|
||||
thumbBytes = await local.thumbnailDataWithSize(size);
|
||||
if (thumbBytes == null) {
|
||||
throw StateError("Failed to load preview for ${asset.fileName}");
|
||||
}
|
||||
buffer = await ui.ImmutableBuffer.fromUint8List(thumbBytes);
|
||||
thumbBytes = null;
|
||||
yield await decode(buffer);
|
||||
buffer = null;
|
||||
break;
|
||||
default:
|
||||
throw StateError('Unsupported asset type ${asset.type}');
|
||||
}
|
||||
} catch (error, stack) {
|
||||
log.severe('Error loading local image ${asset.fileName}', error, stack);
|
||||
buffer?.dispose();
|
||||
} finally {
|
||||
chunkEvents.close();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -14,6 +14,7 @@ import 'package:immich_mobile/pages/backup/backup_controller.page.dart';
|
||||
import 'package:immich_mobile/pages/backup/backup_options.page.dart';
|
||||
import 'package:immich_mobile/pages/backup/failed_backup_status.page.dart';
|
||||
import 'package:immich_mobile/pages/albums/albums.page.dart';
|
||||
import 'package:immich_mobile/pages/common/native_video_viewer.page.dart';
|
||||
import 'package:immich_mobile/pages/library/local_albums.page.dart';
|
||||
import 'package:immich_mobile/pages/library/people/people_collection.page.dart';
|
||||
import 'package:immich_mobile/pages/library/places/places_collection.page.dart';
|
||||
@@ -272,6 +273,10 @@ class AppRouter extends RootStackRouter {
|
||||
guards: [_authGuard, _duplicateGuard],
|
||||
transitionsBuilder: TransitionsBuilders.slideLeft,
|
||||
),
|
||||
AutoRoute(
|
||||
page: NativeVideoViewerRoute.page,
|
||||
guards: [_authGuard, _duplicateGuard],
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -1079,6 +1079,64 @@ class MemoryRouteArgs {
|
||||
}
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [NativeVideoViewerPage]
|
||||
class NativeVideoViewerRoute extends PageRouteInfo<NativeVideoViewerRouteArgs> {
|
||||
NativeVideoViewerRoute({
|
||||
Key? key,
|
||||
required Asset asset,
|
||||
required Widget image,
|
||||
bool showControls = true,
|
||||
List<PageRouteInfo>? children,
|
||||
}) : super(
|
||||
NativeVideoViewerRoute.name,
|
||||
args: NativeVideoViewerRouteArgs(
|
||||
key: key,
|
||||
asset: asset,
|
||||
image: image,
|
||||
showControls: showControls,
|
||||
),
|
||||
initialChildren: children,
|
||||
);
|
||||
|
||||
static const String name = 'NativeVideoViewerRoute';
|
||||
|
||||
static PageInfo page = PageInfo(
|
||||
name,
|
||||
builder: (data) {
|
||||
final args = data.argsAs<NativeVideoViewerRouteArgs>();
|
||||
return NativeVideoViewerPage(
|
||||
key: args.key,
|
||||
asset: args.asset,
|
||||
image: args.image,
|
||||
showControls: args.showControls,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class NativeVideoViewerRouteArgs {
|
||||
const NativeVideoViewerRouteArgs({
|
||||
this.key,
|
||||
required this.asset,
|
||||
required this.image,
|
||||
this.showControls = true,
|
||||
});
|
||||
|
||||
final Key? key;
|
||||
|
||||
final Asset asset;
|
||||
|
||||
final Widget image;
|
||||
|
||||
final bool showControls;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'NativeVideoViewerRouteArgs{key: $key, asset: $asset, image: $image, showControls: $showControls}';
|
||||
}
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [PartnerDetailPage]
|
||||
class PartnerDetailRoute extends PageRouteInfo<PartnerDetailRouteArgs> {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -402,4 +403,29 @@ class AssetService {
|
||||
|
||||
return exifInfo?.description ?? "";
|
||||
}
|
||||
|
||||
Future<double> getAspectRatio(Asset asset) async {
|
||||
// platform_manager always returns 0 for orientation on iOS, so only prefer it on Android
|
||||
if (asset.isLocal && Platform.isAndroid) {
|
||||
await asset.localAsync;
|
||||
} else if (asset.isRemote) {
|
||||
asset = await loadExif(asset);
|
||||
} else if (asset.isLocal) {
|
||||
await asset.localAsync;
|
||||
}
|
||||
|
||||
final aspectRatio = asset.aspectRatio;
|
||||
if (aspectRatio != null) {
|
||||
return aspectRatio;
|
||||
}
|
||||
|
||||
final width = asset.width;
|
||||
final height = asset.height;
|
||||
if (width != null && height != null) {
|
||||
// we don't know the orientation, so assume it's normal
|
||||
return width / height;
|
||||
}
|
||||
|
||||
return 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,20 +3,52 @@ import 'dart:async';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
|
||||
/// Used to debounce function calls with the [interval] provided.
|
||||
/// If [maxWaitTime] is provided, the first [run] call as well as the next call since [maxWaitTime] has passed will be immediately executed, even if [interval] is not satisfied.
|
||||
class Debouncer {
|
||||
Debouncer({required this.interval});
|
||||
Debouncer({required this.interval, this.maxWaitTime});
|
||||
final Duration interval;
|
||||
final Duration? maxWaitTime;
|
||||
Timer? _timer;
|
||||
FutureOr<void> Function()? _lastAction;
|
||||
DateTime? _lastActionTime;
|
||||
Future<void>? _actionFuture;
|
||||
|
||||
void run(FutureOr<void> Function() action) {
|
||||
_lastAction = action;
|
||||
_timer?.cancel();
|
||||
|
||||
if (maxWaitTime != null &&
|
||||
// _actionFuture == null && // TODO: should this check be here?
|
||||
(_lastActionTime == null ||
|
||||
DateTime.now().difference(_lastActionTime!) > maxWaitTime!)) {
|
||||
_callAndRest();
|
||||
return;
|
||||
}
|
||||
_timer = Timer(interval, _callAndRest);
|
||||
}
|
||||
|
||||
Future<void>? drain() {
|
||||
if (_timer != null && _timer!.isActive) {
|
||||
_timer!.cancel();
|
||||
if (_lastAction != null) {
|
||||
_callAndRest();
|
||||
}
|
||||
}
|
||||
return _actionFuture;
|
||||
}
|
||||
|
||||
@pragma('vm:prefer-inline')
|
||||
void _callAndRest() {
|
||||
_lastAction?.call();
|
||||
_lastActionTime = DateTime.now();
|
||||
final action = _lastAction;
|
||||
_lastAction = null;
|
||||
|
||||
final result = action!();
|
||||
if (result is Future) {
|
||||
_actionFuture = result.whenComplete(() {
|
||||
_actionFuture = null;
|
||||
});
|
||||
}
|
||||
_timer = null;
|
||||
}
|
||||
|
||||
@@ -24,31 +56,48 @@ class Debouncer {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
_lastAction = null;
|
||||
_lastActionTime = null;
|
||||
_actionFuture = null;
|
||||
}
|
||||
|
||||
bool get isActive =>
|
||||
_actionFuture != null || (_timer != null && _timer!.isActive);
|
||||
}
|
||||
|
||||
/// Creates a [Debouncer] that will be disposed automatically. If no [interval] is provided, a
|
||||
/// default interval of 300ms is used to debounce the function calls
|
||||
Debouncer useDebouncer({
|
||||
Duration interval = const Duration(milliseconds: 300),
|
||||
Duration? maxWaitTime,
|
||||
List<Object?>? keys,
|
||||
}) =>
|
||||
use(_DebouncerHook(interval: interval, keys: keys));
|
||||
use(
|
||||
_DebouncerHook(
|
||||
interval: interval,
|
||||
maxWaitTime: maxWaitTime,
|
||||
keys: keys,
|
||||
),
|
||||
);
|
||||
|
||||
class _DebouncerHook extends Hook<Debouncer> {
|
||||
const _DebouncerHook({
|
||||
required this.interval,
|
||||
this.maxWaitTime,
|
||||
super.keys,
|
||||
});
|
||||
|
||||
final Duration interval;
|
||||
final Duration? maxWaitTime;
|
||||
|
||||
@override
|
||||
HookState<Debouncer, Hook<Debouncer>> createState() => _DebouncerHookState();
|
||||
}
|
||||
|
||||
class _DebouncerHookState extends HookState<Debouncer, _DebouncerHook> {
|
||||
late final debouncer = Debouncer(interval: hook.interval);
|
||||
late final debouncer = Debouncer(
|
||||
interval: hook.interval,
|
||||
maxWaitTime: hook.maxWaitTime,
|
||||
);
|
||||
|
||||
@override
|
||||
Debouncer build(_) => debouncer;
|
||||
|
||||
@@ -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:isar/isar.dart';
|
||||
|
||||
const int targetVersion = 6;
|
||||
const int targetVersion = 7;
|
||||
|
||||
Future<void> migrateDatabaseIfNeeded(Isar db) async {
|
||||
final int version = Store.get(StoreKey.version, 1);
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
|
||||
/// Throttles function calls with the [interval] provided.
|
||||
@@ -10,12 +8,15 @@ class Throttler {
|
||||
|
||||
Throttler({required this.interval});
|
||||
|
||||
void run(FutureOr<void> Function() action) {
|
||||
T? run<T>(T Function() action) {
|
||||
if (_lastActionTime == null ||
|
||||
(DateTime.now().difference(_lastActionTime!) > interval)) {
|
||||
action();
|
||||
final response = action();
|
||||
_lastActionTime = DateTime.now();
|
||||
return response;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
|
||||
@@ -12,7 +12,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/collection_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/scroll_notifier.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
|
||||
import 'package:immich_mobile/widgets/asset_grid/asset_drag_region.dart';
|
||||
import 'package:immich_mobile/widgets/asset_grid/thumbnail_image.dart';
|
||||
import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart';
|
||||
@@ -89,6 +91,7 @@ class ImmichAssetGridViewState extends ConsumerState<ImmichAssetGridView> {
|
||||
ScrollOffsetController();
|
||||
final ItemPositionsListener _itemPositionsListener =
|
||||
ItemPositionsListener.create();
|
||||
late final KeepAliveLink currentAssetLink;
|
||||
|
||||
/// The timestamp when the haptic feedback was last invoked
|
||||
int _hapticFeedbackTS = 0;
|
||||
@@ -201,6 +204,12 @@ class ImmichAssetGridViewState extends ConsumerState<ImmichAssetGridView> {
|
||||
allAssetsSelected: _allAssetsSelected,
|
||||
showStack: widget.showStack,
|
||||
heroOffset: widget.heroOffset,
|
||||
onAssetTap: (asset) {
|
||||
ref.read(currentAssetProvider.notifier).set(asset);
|
||||
if (asset.isVideo) {
|
||||
ref.read(showControlsProvider.notifier).show = false;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -348,6 +357,7 @@ class ImmichAssetGridViewState extends ConsumerState<ImmichAssetGridView> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
currentAssetLink = ref.read(currentAssetProvider.notifier).ref.keepAlive();
|
||||
scrollToTopNotifierProvider.addListener(_scrollToTop);
|
||||
scrollToDateNotifierProvider.addListener(_scrollToDate);
|
||||
|
||||
@@ -369,6 +379,7 @@ class ImmichAssetGridViewState extends ConsumerState<ImmichAssetGridView> {
|
||||
_itemPositionsListener.itemPositions.removeListener(_positionListener);
|
||||
}
|
||||
_itemPositionsListener.itemPositions.removeListener(_hapticsListener);
|
||||
currentAssetLink.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -595,12 +606,13 @@ class _Section extends StatelessWidget {
|
||||
final RenderList renderList;
|
||||
final bool selectionActive;
|
||||
final bool dynamicLayout;
|
||||
final Function(List<Asset>) selectAssets;
|
||||
final Function(List<Asset>) deselectAssets;
|
||||
final void Function(List<Asset>) selectAssets;
|
||||
final void Function(List<Asset>) deselectAssets;
|
||||
final bool Function(List<Asset>) allAssetsSelected;
|
||||
final bool showStack;
|
||||
final int heroOffset;
|
||||
final bool showStorageIndicator;
|
||||
final void Function(Asset) onAssetTap;
|
||||
|
||||
const _Section({
|
||||
required this.section,
|
||||
@@ -618,6 +630,7 @@ class _Section extends StatelessWidget {
|
||||
required this.showStack,
|
||||
required this.heroOffset,
|
||||
required this.showStorageIndicator,
|
||||
required this.onAssetTap,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -683,6 +696,7 @@ class _Section extends StatelessWidget {
|
||||
selectionActive: selectionActive,
|
||||
onSelect: (asset) => selectAssets([asset]),
|
||||
onDeselect: (asset) => deselectAssets([asset]),
|
||||
onAssetTap: onAssetTap,
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -724,9 +738,9 @@ class _Title extends StatelessWidget {
|
||||
final String title;
|
||||
final List<Asset> assets;
|
||||
final bool selectionActive;
|
||||
final Function(List<Asset>) selectAssets;
|
||||
final Function(List<Asset>) deselectAssets;
|
||||
final Function(List<Asset>) allAssetsSelected;
|
||||
final void Function(List<Asset>) selectAssets;
|
||||
final void Function(List<Asset>) deselectAssets;
|
||||
final bool Function(List<Asset>) allAssetsSelected;
|
||||
|
||||
const _Title({
|
||||
required this.title,
|
||||
@@ -765,8 +779,9 @@ class _AssetRow extends StatelessWidget {
|
||||
final bool showStorageIndicator;
|
||||
final int heroOffset;
|
||||
final bool showStack;
|
||||
final Function(Asset)? onSelect;
|
||||
final Function(Asset)? onDeselect;
|
||||
final void Function(Asset) onAssetTap;
|
||||
final void Function(Asset)? onSelect;
|
||||
final void Function(Asset)? onDeselect;
|
||||
final bool isSelectionActive;
|
||||
|
||||
const _AssetRow({
|
||||
@@ -786,6 +801,7 @@ class _AssetRow extends StatelessWidget {
|
||||
required this.showStack,
|
||||
required this.isSelectionActive,
|
||||
required this.selectedAssets,
|
||||
required this.onAssetTap,
|
||||
this.onSelect,
|
||||
this.onDeselect,
|
||||
});
|
||||
@@ -838,6 +854,8 @@ class _AssetRow extends StatelessWidget {
|
||||
onSelect?.call(asset);
|
||||
}
|
||||
} else {
|
||||
final asset = renderList.loadAsset(absoluteOffset + index);
|
||||
onAssetTap(asset);
|
||||
context.pushRoute(
|
||||
GalleryViewerRoute(
|
||||
renderList: renderList,
|
||||
|
||||
@@ -5,11 +5,11 @@ import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/immich_colors.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/providers/album/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/album/current_album.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_stack.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/download.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
|
||||
import 'package:immich_mobile/services/stack.service.dart';
|
||||
@@ -26,12 +26,10 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
import 'package:immich_mobile/pages/editing/edit.page.dart';
|
||||
|
||||
class BottomGalleryBar extends ConsumerWidget {
|
||||
final Asset asset;
|
||||
final ValueNotifier<int> assetIndex;
|
||||
final bool showStack;
|
||||
final int stackIndex;
|
||||
final ValueNotifier<int> totalAssets;
|
||||
final bool showVideoPlayerControls;
|
||||
final PageController controller;
|
||||
final RenderList renderList;
|
||||
|
||||
@@ -39,20 +37,24 @@ class BottomGalleryBar extends ConsumerWidget {
|
||||
super.key,
|
||||
required this.showStack,
|
||||
required this.stackIndex,
|
||||
required this.asset,
|
||||
required this.assetIndex,
|
||||
required this.controller,
|
||||
required this.totalAssets,
|
||||
required this.showVideoPlayerControls,
|
||||
required this.renderList,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final asset = ref.watch(currentAssetProvider);
|
||||
if (asset == null) {
|
||||
return const SizedBox();
|
||||
}
|
||||
final isOwner = asset.ownerId == ref.watch(currentUserProvider)?.isarId;
|
||||
final showControls = ref.watch(showControlsProvider);
|
||||
final stackId = asset.stackId;
|
||||
|
||||
final stackItems = showStack && asset.stackCount > 0
|
||||
? ref.watch(assetStackStateProvider(asset))
|
||||
final stackItems = showStack && stackId != null
|
||||
? ref.watch(assetStackStateProvider(stackId))
|
||||
: <Asset>[];
|
||||
bool isStackPrimaryAsset = asset.stackPrimaryAssetId == null;
|
||||
final navStack = AutoRouter.of(context).stackData;
|
||||
@@ -64,9 +66,9 @@ class BottomGalleryBar extends ConsumerWidget {
|
||||
final isInAlbum = ref.watch(currentAlbumProvider)?.isRemote ?? false;
|
||||
|
||||
void removeAssetFromStack() {
|
||||
if (stackIndex > 0 && showStack) {
|
||||
if (stackIndex > 0 && showStack && stackId != null) {
|
||||
ref
|
||||
.read(assetStackStateProvider(asset).notifier)
|
||||
.read(assetStackStateProvider(stackId).notifier)
|
||||
.removeChild(stackIndex - 1);
|
||||
}
|
||||
}
|
||||
@@ -135,7 +137,7 @@ class BottomGalleryBar extends ConsumerWidget {
|
||||
|
||||
await ref
|
||||
.read(stackServiceProvider)
|
||||
.deleteStack(asset.stackId!, [asset, ...stackItems]);
|
||||
.deleteStack(asset.stackId!, stackItems);
|
||||
}
|
||||
|
||||
void showStackActionItems() {
|
||||
@@ -324,7 +326,7 @@ class BottomGalleryBar extends ConsumerWidget {
|
||||
},
|
||||
];
|
||||
return IgnorePointer(
|
||||
ignoring: !ref.watch(showControlsProvider),
|
||||
ignoring: !showControls,
|
||||
child: AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 100),
|
||||
opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0,
|
||||
@@ -333,15 +335,15 @@ class BottomGalleryBar extends ConsumerWidget {
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.bottomCenter,
|
||||
end: Alignment.topCenter,
|
||||
colors: [blackOpacity90, Colors.transparent],
|
||||
colors: [Colors.black, Colors.transparent],
|
||||
),
|
||||
),
|
||||
position: DecorationPosition.background,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(top: 40.0),
|
||||
padding: const EdgeInsets.only(top: 40.0),
|
||||
child: Column(
|
||||
children: [
|
||||
if (showVideoPlayerControls) const VideoControls(),
|
||||
if (asset.isVideo) const VideoControls(),
|
||||
BottomNavigationBar(
|
||||
elevation: 0.0,
|
||||
backgroundColor: Colors.transparent,
|
||||
|
||||
@@ -1,38 +1,47 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
||||
import 'package:immich_mobile/utils/hooks/timer_hook.dart';
|
||||
import 'package:immich_mobile/widgets/asset_viewer/center_play_button.dart';
|
||||
import 'package:immich_mobile/widgets/common/delayed_loading_indicator.dart';
|
||||
import 'package:immich_mobile/utils/hooks/timer_hook.dart';
|
||||
|
||||
class CustomVideoPlayerControls extends HookConsumerWidget {
|
||||
final Duration hideTimerDuration;
|
||||
|
||||
const CustomVideoPlayerControls({
|
||||
super.key,
|
||||
this.hideTimerDuration = const Duration(seconds: 3),
|
||||
this.hideTimerDuration = const Duration(seconds: 5),
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final assetIsVideo = ref.watch(
|
||||
currentAssetProvider.select((asset) => asset != null && asset.isVideo),
|
||||
);
|
||||
final showControls = ref.watch(showControlsProvider);
|
||||
final VideoPlaybackState state =
|
||||
ref.watch(videoPlaybackValueProvider.select((value) => value.state));
|
||||
|
||||
// A timer to hide the controls
|
||||
final hideTimer = useTimer(
|
||||
hideTimerDuration,
|
||||
() {
|
||||
final state = ref.read(videoPlaybackValueProvider).state;
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Do not hide on paused
|
||||
if (state != VideoPlaybackState.paused) {
|
||||
if (state != VideoPlaybackState.paused &&
|
||||
state != VideoPlaybackState.completed &&
|
||||
assetIsVideo) {
|
||||
ref.read(showControlsProvider.notifier).show = false;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
final showBuffering = useState(false);
|
||||
final VideoPlaybackState state =
|
||||
ref.watch(videoPlaybackValueProvider).state;
|
||||
final showBuffering = state == VideoPlaybackState.buffering;
|
||||
|
||||
/// Shows the controls and starts the timer to hide them
|
||||
void showControlsAndStartHideTimer() {
|
||||
@@ -52,16 +61,9 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
|
||||
showControlsAndStartHideTimer();
|
||||
});
|
||||
|
||||
ref.listen(videoPlaybackValueProvider.select((value) => value.state),
|
||||
(_, state) {
|
||||
// Show buffering
|
||||
showBuffering.value = state == VideoPlaybackState.buffering;
|
||||
});
|
||||
|
||||
/// Toggles between playing and pausing depending on the state of the video
|
||||
void togglePlay() {
|
||||
showControlsAndStartHideTimer();
|
||||
final state = ref.read(videoPlaybackValueProvider).state;
|
||||
if (state == VideoPlaybackState.playing) {
|
||||
ref.read(videoPlayerControlsProvider.notifier).pause();
|
||||
} else if (state == VideoPlaybackState.completed) {
|
||||
@@ -75,10 +77,10 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: showControlsAndStartHideTimer,
|
||||
child: AbsorbPointer(
|
||||
absorbing: !ref.watch(showControlsProvider),
|
||||
absorbing: !showControls,
|
||||
child: Stack(
|
||||
children: [
|
||||
if (showBuffering.value)
|
||||
if (showBuffering)
|
||||
const Center(
|
||||
child: DelayedLoadingIndicator(
|
||||
fadeInDuration: Duration(milliseconds: 400),
|
||||
@@ -86,18 +88,14 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
|
||||
)
|
||||
else
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
if (state != VideoPlaybackState.playing) {
|
||||
togglePlay();
|
||||
}
|
||||
ref.read(showControlsProvider.notifier).show = false;
|
||||
},
|
||||
onTap: () =>
|
||||
ref.read(showControlsProvider.notifier).show = false,
|
||||
child: CenterPlayButton(
|
||||
backgroundColor: Colors.black54,
|
||||
iconColor: Colors.white,
|
||||
isFinished: state == VideoPlaybackState.completed,
|
||||
isPlaying: state == VideoPlaybackState.playing,
|
||||
show: ref.watch(showControlsProvider),
|
||||
show: assetIsVideo && showControls,
|
||||
onPressed: togglePlay,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -15,9 +15,10 @@ class FileInfo extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final textColor = context.isDarkTheme ? Colors.white : Colors.black;
|
||||
|
||||
String resolution = asset.width != null && asset.height != null
|
||||
? "${asset.height} x ${asset.width} "
|
||||
: "";
|
||||
final height = asset.orientatedHeight ?? asset.height;
|
||||
final width = asset.orientatedWidth ?? asset.width;
|
||||
String resolution =
|
||||
height != null && width != null ? "$height x $width " : "";
|
||||
String fileSize = asset.exifInfo?.fileSize != null
|
||||
? formatBytes(asset.exifInfo!.fileSize!)
|
||||
: "";
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/providers/album/current_album.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
|
||||
import 'package:immich_mobile/widgets/album/add_to_album_bottom_sheet.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/download.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
|
||||
@@ -19,23 +20,19 @@ import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
|
||||
class GalleryAppBar extends ConsumerWidget {
|
||||
final Asset asset;
|
||||
final void Function() showInfo;
|
||||
final void Function() onToggleMotionVideo;
|
||||
final bool isPlayingVideo;
|
||||
|
||||
const GalleryAppBar({
|
||||
super.key,
|
||||
required this.asset,
|
||||
required this.showInfo,
|
||||
required this.onToggleMotionVideo,
|
||||
required this.isPlayingVideo,
|
||||
});
|
||||
const GalleryAppBar({super.key, required this.showInfo});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final asset = ref.watch(currentAssetProvider);
|
||||
if (asset == null) {
|
||||
return const SizedBox();
|
||||
}
|
||||
final album = ref.watch(currentAlbumProvider);
|
||||
final isOwner = asset.ownerId == ref.watch(currentUserProvider)?.isarId;
|
||||
final showControls = ref.watch(showControlsProvider);
|
||||
|
||||
final isPartner = ref
|
||||
.watch(partnerSharedWithProvider)
|
||||
@@ -98,23 +95,21 @@ class GalleryAppBar extends ConsumerWidget {
|
||||
}
|
||||
|
||||
return IgnorePointer(
|
||||
ignoring: !ref.watch(showControlsProvider),
|
||||
ignoring: !showControls,
|
||||
child: AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 100),
|
||||
opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0,
|
||||
opacity: showControls ? 1.0 : 0.0,
|
||||
child: Container(
|
||||
color: Colors.black.withOpacity(0.4),
|
||||
child: TopControlAppBar(
|
||||
isOwner: isOwner,
|
||||
isPartner: isPartner,
|
||||
isPlayingMotionVideo: isPlayingVideo,
|
||||
asset: asset,
|
||||
onMoreInfoPressed: showInfo,
|
||||
onFavorite: toggleFavorite,
|
||||
onRestorePressed: () => handleRestore(asset),
|
||||
onUploadPressed: asset.isLocal ? () => handleUpload(asset) : null,
|
||||
onDownloadPressed: asset.isLocal ? null : handleDownloadAsset,
|
||||
onToggleMotionVideo: onToggleMotionVideo,
|
||||
onAddToAlbumPressed: () => addToAlbum(asset),
|
||||
onActivitiesPressed: handleActivities,
|
||||
),
|
||||
|
||||
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/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/providers/asset.provider.dart';
|
||||
import 'package:immich_mobile/widgets/asset_viewer/motion_photo_button.dart';
|
||||
|
||||
class TopControlAppBar extends HookConsumerWidget {
|
||||
const TopControlAppBar({
|
||||
@@ -14,8 +15,6 @@ class TopControlAppBar extends HookConsumerWidget {
|
||||
required this.onDownloadPressed,
|
||||
required this.onAddToAlbumPressed,
|
||||
required this.onRestorePressed,
|
||||
required this.onToggleMotionVideo,
|
||||
required this.isPlayingMotionVideo,
|
||||
required this.onFavorite,
|
||||
required this.onUploadPressed,
|
||||
required this.isOwner,
|
||||
@@ -27,12 +26,10 @@ class TopControlAppBar extends HookConsumerWidget {
|
||||
final Function onMoreInfoPressed;
|
||||
final VoidCallback? onUploadPressed;
|
||||
final VoidCallback? onDownloadPressed;
|
||||
final VoidCallback onToggleMotionVideo;
|
||||
final VoidCallback onAddToAlbumPressed;
|
||||
final VoidCallback onRestorePressed;
|
||||
final VoidCallback onActivitiesPressed;
|
||||
final Function(Asset) onFavorite;
|
||||
final bool isPlayingMotionVideo;
|
||||
final bool isOwner;
|
||||
final bool isPartner;
|
||||
|
||||
@@ -57,23 +54,6 @@ class TopControlAppBar extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildLivePhotoButton() {
|
||||
return IconButton(
|
||||
onPressed: () {
|
||||
onToggleMotionVideo();
|
||||
},
|
||||
icon: isPlayingMotionVideo
|
||||
? Icon(
|
||||
Icons.motion_photos_pause_outlined,
|
||||
color: Colors.grey[200],
|
||||
)
|
||||
: Icon(
|
||||
Icons.play_circle_outline_rounded,
|
||||
color: Colors.grey[200],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildMoreInfoButton() {
|
||||
return IconButton(
|
||||
onPressed: () {
|
||||
@@ -175,13 +155,11 @@ class TopControlAppBar extends HookConsumerWidget {
|
||||
foregroundColor: Colors.grey[100],
|
||||
backgroundColor: Colors.transparent,
|
||||
leading: buildBackButton(),
|
||||
actionsIconTheme: const IconThemeData(
|
||||
size: iconSize,
|
||||
),
|
||||
actionsIconTheme: const IconThemeData(size: iconSize),
|
||||
shape: const Border(),
|
||||
actions: [
|
||||
if (asset.isRemote && isOwner) buildFavoriteButton(a),
|
||||
if (asset.livePhotoVideoId != null) buildLivePhotoButton(),
|
||||
if (asset.livePhotoVideoId != null) const MotionPhotoButton(),
|
||||
if (asset.isLocal && !asset.isRemote) buildUploadButton(),
|
||||
if (asset.isRemote && !asset.isLocal && isOwner) buildDownloadButton(),
|
||||
if (asset.isRemote && (isOwner || isPartner) && !asset.isTrashed)
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
onChanged: (position) {
|
||||
onChanged: (value) {
|
||||
final inSeconds =
|
||||
(duration * (value / 100.0)).inSeconds;
|
||||
final position = inSeconds.toDouble();
|
||||
ref
|
||||
.read(videoPlayerControlsProvider.notifier)
|
||||
.position = position;
|
||||
// This immediately updates the slider position without waiting for the video to update
|
||||
ref.read(videoPlaybackValueProvider.notifier).position =
|
||||
Duration(seconds: inSeconds);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
@@ -28,12 +28,11 @@ class ImmichImage extends StatelessWidget {
|
||||
// either by using the asset ID or the asset itself
|
||||
/// [asset] is the Asset to request, or else use [assetId] to get a remote
|
||||
/// image provider
|
||||
/// Use [isThumbnail] and [thumbnailSize] if you'd like to request a thumbnail
|
||||
/// The size of the square thumbnail to request. Ignored if isThumbnail
|
||||
/// is not true
|
||||
static ImageProvider imageProvider({
|
||||
Asset? asset,
|
||||
String? assetId,
|
||||
double width = 1080,
|
||||
double height = 1920,
|
||||
}) {
|
||||
if (asset == null && assetId == null) {
|
||||
throw Exception('Must supply either asset or assetId');
|
||||
@@ -48,6 +47,8 @@ class ImmichImage extends StatelessWidget {
|
||||
if (useLocal(asset)) {
|
||||
return ImmichLocalImageProvider(
|
||||
asset: asset,
|
||||
width: width,
|
||||
height: height,
|
||||
);
|
||||
} else {
|
||||
return ImmichRemoteImageProvider(
|
||||
@@ -87,6 +88,8 @@ class ImmichImage extends StatelessWidget {
|
||||
},
|
||||
image: ImmichImage.imageProvider(
|
||||
asset: asset,
|
||||
width: context.width,
|
||||
height: context.height,
|
||||
),
|
||||
width: width,
|
||||
height: height,
|
||||
|
||||
@@ -2,9 +2,9 @@ import 'dart:ui';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/pages/common/video_viewer.page.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/pages/common/native_video_viewer.page.dart';
|
||||
import 'package:immich_mobile/utils/hooks/blurhash_hook.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_image.dart';
|
||||
|
||||
@@ -68,18 +68,20 @@ class MemoryCard extends StatelessWidget {
|
||||
} else {
|
||||
return Hero(
|
||||
tag: 'memory-${asset.id}',
|
||||
child: VideoViewerPage(
|
||||
key: ValueKey(asset),
|
||||
asset: asset,
|
||||
showDownloadingIndicator: false,
|
||||
placeholder: SizedBox.expand(
|
||||
child: ImmichImage(
|
||||
child: SizedBox(
|
||||
width: context.width,
|
||||
height: context.height,
|
||||
child: NativeVideoViewerPage(
|
||||
key: ValueKey(asset.id),
|
||||
asset: asset,
|
||||
showControls: false,
|
||||
image: ImmichImage(
|
||||
asset,
|
||||
width: context.width,
|
||||
height: context.height,
|
||||
fit: fit,
|
||||
),
|
||||
),
|
||||
hideControlsTimer: const Duration(seconds: 2),
|
||||
showControls: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -137,6 +139,8 @@ class _BlurredBackdrop extends HookWidget {
|
||||
image: DecorationImage(
|
||||
image: ImmichImage.imageProvider(
|
||||
asset: asset,
|
||||
height: context.height,
|
||||
width: context.width,
|
||||
),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
|
||||
@@ -378,10 +378,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: device_info_plus
|
||||
sha256: db03b2d2a3fa466a4627709e1db58692c3f7f658e36a5942d342d86efedc4091
|
||||
sha256: f545ffbadee826f26f2e1a0f0cbd667ae9a6011cc0f77c0f8f00a969655e6e95
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "11.0.0"
|
||||
version: "11.1.1"
|
||||
device_info_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -450,10 +450,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: file_picker
|
||||
sha256: "167bb619cdddaa10ef2907609feb8a79c16dfa479d3afaf960f8e223f754bf12"
|
||||
sha256: aac85f20436608e01a6ffd1fdd4e746a7f33c93a2c83752e626bdfaea139b877
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.1.2"
|
||||
version: "8.1.3"
|
||||
file_selector_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -548,10 +548,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_local_notifications
|
||||
sha256: "674173fd3c9eda9d4c8528da2ce0ea69f161577495a9cc835a2a4ecd7eadeb35"
|
||||
sha256: dd6676d8c2926537eccdf9f72128bbb2a9d0814689527b17f92c248ff192eaf3
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "17.2.4"
|
||||
version: "17.2.1+2"
|
||||
flutter_local_notifications_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1024,6 +1024,15 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1067,10 +1076,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: package_info_plus
|
||||
sha256: "894f37107424311bdae3e476552229476777b8752c5a2a2369c0cb9a2d5442ef"
|
||||
sha256: da8d9ac8c4b1df253d1a328b7bf01ae77ef132833479ab40763334db13b91cce
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.0.3"
|
||||
version: "8.1.1"
|
||||
package_info_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1339,10 +1348,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: share_plus
|
||||
sha256: fec12c3c39f01e4df1ec6ad92b6e85503c5ca64ffd6e28d18c9ffe53fcc4cb11
|
||||
sha256: "9c9bafd4060728d7cdb2464c341743adbd79d327cb067ec7afb64583540b47c8"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "10.0.3"
|
||||
version: "10.1.2"
|
||||
share_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -45,7 +45,7 @@ dependencies:
|
||||
path_provider: ^2.1.2
|
||||
collection: ^1.18.0
|
||||
http_parser: ^4.0.2
|
||||
flutter_web_auth: ^0.6.0
|
||||
flutter_web_auth: 0.6.0
|
||||
easy_image_viewer: ^1.4.0
|
||||
isar:
|
||||
version: *isar_version
|
||||
@@ -64,6 +64,10 @@ dependencies:
|
||||
async: ^2.11.0
|
||||
dynamic_color: ^1.7.0 #package to apply system theme
|
||||
background_downloader: ^8.5.5
|
||||
native_video_player:
|
||||
git:
|
||||
url: https://github.com/immich-app/native_video_player
|
||||
ref: 68ea203
|
||||
|
||||
#image editing packages
|
||||
crop_image: ^1.0.13
|
||||
|
||||
Reference in New Issue
Block a user