mirror of
https://github.com/immich-app/immich.git
synced 2024-12-28 06:31:58 +00:00
feat(mobile): native_video_player (#12104)
* add native player library * splitup the player * stateful widget * refactor: native_video_player * fix: handle buffering * turn on volume when video plays * fix: aspect ratio * fix: handle remote asset orientation * refinements and fixes fix orientation for remote assets wip separate widget separate video loader widget fixed memory leak optimized seeking, cleanup debug context pop use global key back to one widget fixed rebuild wait for swipe animation to finish smooth hero animation for remote videos faster scroll animation * clean up logging * refactor aspect ratio calculation * removed unnecessary import * transitive dependencies * fixed referencing uninitialized orientation * use correct ref to build android * higher res placeholder for local videos * slightly lower delay * await things * fix controls when swiping between image and video * linting * extra smooth seeking, add comments * chore: generate router page * use current asset provider and loadAsset * fix stack handling * improved motion photo handling * use visibility for motion videos * error handling for async calls * fix duplicate key error * maybe fix duplicate key error * increase delay for hero animation * faster initialization for remote videos * ensure dimensions for memory cards * make aspect ratio logic reusable, optimizations * refactor: move exif search from aspect ratio to orientation * local orientation on ios is unreliable; prefer remote * fix no audio in silent mode on ios * increase bottom bar opacity to account for hdr * remove unused import * fix live photo play button not updating * fix map marker -> galleryviewer * remove video_player * fix hdr playback on android * fix looping * remove unused dependencies * update to latest player commit * fix player controls hiding when video is not playing * fix restart video * stop showing motion video after ending when looping is disabled * delay video initialization to avoid placeholder flicker * faster animation * shorter delay * small delay for image -> video on android * fix: lint * hide stacked children when controls are hidden, avoid bottom bar dropping --------- Co-authored-by: Alex <alex.tran1502@gmail.com> Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com>
This commit is contained in:
parent
5060ee95c2
commit
3c38851d50
44 changed files with 1418 additions and 1073 deletions
|
@ -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):
|
||||
|
@ -93,9 +95,6 @@ PODS:
|
|||
- Toast (4.0.0)
|
||||
- url_launcher_ios (0.0.1):
|
||||
- Flutter
|
||||
- video_player_avfoundation (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- wakelock_plus (0.0.1):
|
||||
- Flutter
|
||||
|
||||
|
@ -115,6 +114,7 @@ DEPENDENCIES:
|
|||
- integration_test (from `.symlinks/plugins/integration_test/ios`)
|
||||
- isar_flutter_libs (from `.symlinks/plugins/isar_flutter_libs/ios`)
|
||||
- maplibre_gl (from `.symlinks/plugins/maplibre_gl/ios`)
|
||||
- native_video_player (from `.symlinks/plugins/native_video_player/ios`)
|
||||
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
||||
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
||||
- path_provider_ios (from `.symlinks/plugins/path_provider_ios/ios`)
|
||||
|
@ -124,7 +124,6 @@ DEPENDENCIES:
|
|||
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||
- sqflite (from `.symlinks/plugins/sqflite/darwin`)
|
||||
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
|
||||
- video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/darwin`)
|
||||
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
|
||||
|
||||
SPEC REPOS:
|
||||
|
@ -168,6 +167,8 @@ EXTERNAL SOURCES:
|
|||
:path: ".symlinks/plugins/isar_flutter_libs/ios"
|
||||
maplibre_gl:
|
||||
:path: ".symlinks/plugins/maplibre_gl/ios"
|
||||
native_video_player:
|
||||
:path: ".symlinks/plugins/native_video_player/ios"
|
||||
package_info_plus:
|
||||
:path: ".symlinks/plugins/package_info_plus/ios"
|
||||
path_provider_foundation:
|
||||
|
@ -186,15 +187,13 @@ EXTERNAL SOURCES:
|
|||
:path: ".symlinks/plugins/sqflite/darwin"
|
||||
url_launcher_ios:
|
||||
:path: ".symlinks/plugins/url_launcher_ios/ios"
|
||||
video_player_avfoundation:
|
||||
:path: ".symlinks/plugins/video_player_avfoundation/darwin"
|
||||
wakelock_plus:
|
||||
:path: ".symlinks/plugins/wakelock_plus/ios"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
background_downloader: 9f788ffc5de45acf87d6380e91ca0841066c18cf
|
||||
connectivity_plus: ddd7f30999e1faaef5967c23d5b6d503d10434db
|
||||
device_info_plus: 97af1d7e84681a90d0693e63169a5d50e0839a0d
|
||||
device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342
|
||||
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
|
||||
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
|
||||
file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655
|
||||
|
@ -210,20 +209,20 @@ SPEC CHECKSUMS:
|
|||
isar_flutter_libs: fdf730ca925d05687f36d7f1d355e482529ed097
|
||||
MapLibre: 620fc933c1d6029b33738c905c1490d024e5d4ef
|
||||
maplibre_gl: a2efec727dd340e4c65e26d2b03b584f14881fd9
|
||||
package_info_plus: 58f0028419748fad15bf008b270aaa8e54380b1c
|
||||
native_video_player: d12af78a1a4a8cf09775a5177d5b392def6fd23c
|
||||
package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4
|
||||
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
|
||||
path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02
|
||||
permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2
|
||||
photo_manager: ff695c7a1dd5bc379974953a2b5c0a293f7c4c8a
|
||||
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
|
||||
SDWebImage: 066c47b573f408f18caa467d71deace7c0f8280d
|
||||
share_plus: 8875f4f2500512ea181eef553c3e27dba5135aad
|
||||
share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f
|
||||
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
|
||||
sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec
|
||||
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
|
||||
Toast: 91b396c56ee72a5790816f40d3a94dd357abc196
|
||||
url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe
|
||||
video_player_avfoundation: 7c6c11d8470e1675df7397027218274b6d2360b3
|
||||
wakelock_plus: 78ec7c5b202cab7761af8e2b2b3d0671be6c4ae1
|
||||
|
||||
PODFILE CHECKSUM: 64c9b5291666c0ca3caabdfe9865c141ac40321d
|
||||
|
|
|
@ -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;
|
||||
|
|
BIN
mobile/lib/entities/exif_info.entity.g.dart
generated
BIN
mobile/lib/entities/exif_info.entity.g.dart
generated
Binary file not shown.
38
mobile/lib/extensions/scroll_extensions.dart
Normal file
38
mobile/lib/extensions/scroll_extensions.dart
Normal file
|
@ -0,0 +1,38 @@
|
|||
import 'package:flutter/cupertino.dart';
|
||||
|
||||
// https://stackoverflow.com/a/74453792
|
||||
class FastScrollPhysics extends ScrollPhysics {
|
||||
const FastScrollPhysics({super.parent});
|
||||
|
||||
@override
|
||||
FastScrollPhysics applyTo(ScrollPhysics? ancestor) {
|
||||
return FastScrollPhysics(parent: buildParent(ancestor));
|
||||
}
|
||||
|
||||
@override
|
||||
SpringDescription get spring => const SpringDescription(
|
||||
mass: 40,
|
||||
stiffness: 100,
|
||||
damping: 1,
|
||||
);
|
||||
}
|
||||
|
||||
class FastClampingScrollPhysics extends ClampingScrollPhysics {
|
||||
const FastClampingScrollPhysics({super.parent});
|
||||
|
||||
@override
|
||||
FastClampingScrollPhysics applyTo(ScrollPhysics? ancestor) {
|
||||
return FastClampingScrollPhysics(parent: buildParent(ancestor));
|
||||
}
|
||||
|
||||
@override
|
||||
SpringDescription get spring => const SpringDescription(
|
||||
// When swiping between videos on Android, the placeholder of the first opened video
|
||||
// can briefly be seen and cause a flicker effect if the video begins to initialize
|
||||
// before the animation finishes - probably a bug in PhotoViewGallery's animation handling
|
||||
// Making the animation faster is not just stylistic, but also helps to avoid this flicker
|
||||
mass: 80,
|
||||
stiffness: 100,
|
||||
damping: 1,
|
||||
);
|
||||
}
|
91
mobile/lib/pages/common/gallery_stacked_children.dart
Normal file
91
mobile/lib/pages/common/gallery_stacked_children.dart
Normal file
|
@ -0,0 +1,91 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_stack.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
|
||||
import 'package:immich_mobile/providers/image/immich_remote_image_provider.dart';
|
||||
|
||||
class GalleryStackedChildren extends HookConsumerWidget {
|
||||
final ValueNotifier<int> stackIndex;
|
||||
|
||||
const GalleryStackedChildren(this.stackIndex, {super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final asset = ref.watch(currentAssetProvider);
|
||||
if (asset == null) {
|
||||
return const SizedBox();
|
||||
}
|
||||
|
||||
final stackId = asset.stackId;
|
||||
if (stackId == null) {
|
||||
return const SizedBox();
|
||||
}
|
||||
|
||||
final stackElements = ref.watch(assetStackStateProvider(stackId));
|
||||
final showControls = ref.watch(showControlsProvider);
|
||||
|
||||
return IgnorePointer(
|
||||
ignoring: !showControls,
|
||||
child: AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 100),
|
||||
opacity: showControls ? 1.0 : 0.0,
|
||||
child: SizedBox(
|
||||
height: 80,
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: stackElements.length,
|
||||
padding: const EdgeInsets.only(
|
||||
left: 5,
|
||||
right: 5,
|
||||
bottom: 30,
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
final currentAsset = stackElements.elementAt(index);
|
||||
final assetId = currentAsset.remoteId;
|
||||
if (assetId == null) {
|
||||
return const SizedBox();
|
||||
}
|
||||
|
||||
return Padding(
|
||||
key: ValueKey(currentAsset.id),
|
||||
padding: const EdgeInsets.only(right: 5),
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
stackIndex.value = index;
|
||||
ref.read(currentAssetProvider.notifier).set(currentAsset);
|
||||
},
|
||||
child: Container(
|
||||
width: 60,
|
||||
height: 60,
|
||||
decoration: index == stackIndex.value
|
||||
? const BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.all(Radius.circular(6)),
|
||||
border: Border.fromBorderSide(
|
||||
BorderSide(color: Colors.white, width: 2),
|
||||
),
|
||||
)
|
||||
: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.all(Radius.circular(6)),
|
||||
border: null,
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(4)),
|
||||
child: Image(
|
||||
fit: BoxFit.cover,
|
||||
image: ImmichRemoteImageProvider(assetId: assetId),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -8,18 +8,19 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/scroll_extensions.dart';
|
||||
import 'package:immich_mobile/pages/common/download_panel.dart';
|
||||
import 'package:immich_mobile/pages/common/video_viewer.page.dart';
|
||||
import 'package:immich_mobile/pages/common/native_video_viewer.page.dart';
|
||||
import 'package:immich_mobile/pages/common/gallery_stacked_children.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_stack.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
||||
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
|
||||
import 'package:immich_mobile/providers/image/immich_remote_image_provider.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart';
|
||||
import 'package:immich_mobile/widgets/asset_viewer/advanced_bottom_sheet.dart';
|
||||
|
@ -35,6 +36,7 @@ import 'package:immich_mobile/widgets/photo_view/src/utils/photo_view_hero_attri
|
|||
|
||||
@RoutePage()
|
||||
// ignore: must_be_immutable
|
||||
/// Expects [currentAssetProvider] to be set before navigating to this page
|
||||
class GalleryViewerPage extends HookConsumerWidget {
|
||||
final int initialIndex;
|
||||
final int heroOffset;
|
||||
|
@ -53,79 +55,66 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final settings = ref.watch(appSettingsServiceProvider);
|
||||
final loadAsset = renderList.loadAsset;
|
||||
final totalAssets = useState(renderList.totalAssets);
|
||||
final shouldLoopVideo = useState(AppSettingsEnum.loopVideo.defaultValue);
|
||||
final isZoomed = useState(false);
|
||||
final isPlayingVideo = useState(false);
|
||||
final localPosition = useState<Offset?>(null);
|
||||
final currentIndex = useState(initialIndex);
|
||||
final currentAsset = loadAsset(currentIndex.value);
|
||||
|
||||
// Update is playing motion video
|
||||
ref.listen(videoPlaybackValueProvider.select((v) => v.state), (_, state) {
|
||||
isPlayingVideo.value = state == VideoPlaybackState.playing;
|
||||
});
|
||||
|
||||
final stackIndex = useState(-1);
|
||||
final stack = showStack && currentAsset.stackCount > 0
|
||||
? ref.watch(assetStackStateProvider(currentAsset))
|
||||
: <Asset>[];
|
||||
final stackElements = showStack ? [currentAsset, ...stack] : <Asset>[];
|
||||
// Assets from response DTOs do not have an isar id, querying which would give us the default autoIncrement id
|
||||
final isFromDto = currentAsset.id == noDbId;
|
||||
|
||||
Asset asset = stackIndex.value == -1
|
||||
? currentAsset
|
||||
: stackElements.elementAt(stackIndex.value);
|
||||
|
||||
final isMotionPhoto = asset.livePhotoVideoId != null;
|
||||
// Listen provider to prevent autoDispose when navigating to other routes from within the gallery page
|
||||
ref.listen(currentAssetProvider, (_, __) {});
|
||||
useEffect(
|
||||
() {
|
||||
// Delay state update to after the execution of build method
|
||||
Future.microtask(
|
||||
() => ref.read(currentAssetProvider.notifier).set(asset),
|
||||
);
|
||||
return null;
|
||||
},
|
||||
[asset],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
shouldLoopVideo.value =
|
||||
settings.getSetting<bool>(AppSettingsEnum.loopVideo);
|
||||
return null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
final stackIndex = useState(0);
|
||||
final localPosition = useRef<Offset?>(null);
|
||||
final currentIndex = useValueNotifier(initialIndex);
|
||||
final loadAsset = renderList.loadAsset;
|
||||
|
||||
Future<void> precacheNextImage(int index) async {
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
void onError(Object exception, StackTrace? stackTrace) {
|
||||
// swallow error silently
|
||||
debugPrint('Error precaching next image: $exception, $stackTrace');
|
||||
log.severe('Error precaching next image: $exception, $stackTrace');
|
||||
}
|
||||
|
||||
try {
|
||||
if (index < totalAssets.value && index >= 0) {
|
||||
final asset = loadAsset(index);
|
||||
await precacheImage(
|
||||
ImmichImage.imageProvider(asset: asset),
|
||||
ImmichImage.imageProvider(
|
||||
asset: asset,
|
||||
width: context.width,
|
||||
height: context.height,
|
||||
),
|
||||
context,
|
||||
onError: onError,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
// swallow error silently
|
||||
debugPrint('Error precaching next image: $e');
|
||||
log.severe('Error precaching next image: $e');
|
||||
context.maybePop();
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
if (ref.read(showControlsProvider)) {
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||
} else {
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
|
||||
}
|
||||
|
||||
// Delay this a bit so we can finish loading the page
|
||||
Timer(const Duration(milliseconds: 400), () {
|
||||
precacheNextImage(currentIndex.value + 1);
|
||||
});
|
||||
|
||||
return null;
|
||||
},
|
||||
const [],
|
||||
);
|
||||
|
||||
void showInfo() {
|
||||
final asset = ref.read(currentAssetProvider);
|
||||
if (asset == null) {
|
||||
return;
|
||||
}
|
||||
showModalBottomSheet(
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(15.0)),
|
||||
|
@ -183,86 +172,100 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||
}
|
||||
}
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
if (ref.read(showControlsProvider)) {
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||
} else {
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
|
||||
}
|
||||
isPlayingVideo.value = false;
|
||||
return null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
// No need to await this
|
||||
unawaited(
|
||||
// Delay this a bit so we can finish loading the page
|
||||
Future.delayed(const Duration(milliseconds: 400)).then(
|
||||
// Precache the next image
|
||||
(_) => precacheNextImage(currentIndex.value + 1),
|
||||
),
|
||||
);
|
||||
return null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
ref.listen(showControlsProvider, (_, show) {
|
||||
if (show) {
|
||||
if (show || Platform.isIOS) {
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||
} else {
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
|
||||
return;
|
||||
}
|
||||
|
||||
// This prevents the bottom bar from "dropping" while the controls are being hidden
|
||||
Timer(const Duration(milliseconds: 100), () {
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
|
||||
});
|
||||
});
|
||||
|
||||
Widget buildStackedChildren() {
|
||||
return ListView.builder(
|
||||
shrinkWrap: true,
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: stackElements.length,
|
||||
padding: const EdgeInsets.only(
|
||||
left: 5,
|
||||
right: 5,
|
||||
bottom: 30,
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
final assetId = stackElements.elementAt(index).remoteId;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 5),
|
||||
child: GestureDetector(
|
||||
onTap: () => stackIndex.value = index,
|
||||
child: Container(
|
||||
width: 60,
|
||||
height: 60,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: (stackIndex.value == -1 && index == 0) ||
|
||||
index == stackIndex.value
|
||||
? Border.all(
|
||||
color: Colors.white,
|
||||
width: 2,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: Image(
|
||||
fit: BoxFit.cover,
|
||||
image: ImmichRemoteImageProvider(assetId: assetId!),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
PhotoViewGalleryPageOptions buildImage(BuildContext context, Asset asset) {
|
||||
return PhotoViewGalleryPageOptions(
|
||||
onDragStart: (_, details, __) {
|
||||
localPosition.value = details.localPosition;
|
||||
},
|
||||
onDragUpdate: (_, details, __) {
|
||||
handleSwipeUpDown(details);
|
||||
},
|
||||
onTapDown: (_, __, ___) {
|
||||
ref.read(showControlsProvider.notifier).toggle();
|
||||
},
|
||||
onLongPressStart: asset.isMotionPhoto
|
||||
? (_, __, ___) {
|
||||
ref.read(isPlayingMotionVideoProvider.notifier).playing = true;
|
||||
}
|
||||
: null,
|
||||
imageProvider: ImmichImage.imageProvider(asset: asset),
|
||||
heroAttributes: _getHeroAttributes(asset),
|
||||
filterQuality: FilterQuality.high,
|
||||
tightMode: true,
|
||||
minScale: PhotoViewComputedScale.contained,
|
||||
errorBuilder: (context, error, stackTrace) => ImmichImage(
|
||||
asset,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
PhotoViewGalleryPageOptions buildVideo(BuildContext context, Asset asset) {
|
||||
// This key is to prevent the video player from being re-initialized during the hero animation
|
||||
final key = GlobalKey();
|
||||
return PhotoViewGalleryPageOptions.customChild(
|
||||
onDragStart: (_, details, __) =>
|
||||
localPosition.value = details.localPosition,
|
||||
onDragUpdate: (_, details, __) => handleSwipeUpDown(details),
|
||||
heroAttributes: _getHeroAttributes(asset),
|
||||
filterQuality: FilterQuality.high,
|
||||
initialScale: 1.0,
|
||||
maxScale: 1.0,
|
||||
minScale: 1.0,
|
||||
basePosition: Alignment.center,
|
||||
child: SizedBox(
|
||||
width: context.width,
|
||||
height: context.height,
|
||||
child: NativeVideoViewerPage(
|
||||
key: key,
|
||||
asset: asset,
|
||||
image: Image(
|
||||
key: ValueKey(asset),
|
||||
image: ImmichImage.imageProvider(
|
||||
asset: asset,
|
||||
width: context.width,
|
||||
height: context.height,
|
||||
),
|
||||
fit: BoxFit.contain,
|
||||
height: context.height,
|
||||
width: context.width,
|
||||
alignment: Alignment.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
PhotoViewGalleryPageOptions buildAsset(BuildContext context, int index) {
|
||||
ref.read(isPlayingMotionVideoProvider.notifier).playing = false;
|
||||
var newAsset = loadAsset(index);
|
||||
final stackId = newAsset.stackId;
|
||||
if (stackId != null && currentIndex.value == index) {
|
||||
final stackElements =
|
||||
ref.read(assetStackStateProvider(newAsset.stackId!));
|
||||
if (stackIndex.value < stackElements.length) {
|
||||
newAsset = stackElements.elementAt(stackIndex.value);
|
||||
}
|
||||
}
|
||||
|
||||
if (newAsset.isImage && !newAsset.isMotionPhoto) {
|
||||
return buildImage(context, newAsset);
|
||||
}
|
||||
return buildVideo(context, newAsset);
|
||||
}
|
||||
|
||||
return PopScope(
|
||||
// Change immersive mode back to normal "edgeToEdge" mode
|
||||
onPopInvokedWithResult: (didPop, _) =>
|
||||
|
@ -272,128 +275,79 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||
body: Stack(
|
||||
children: [
|
||||
PhotoViewGallery.builder(
|
||||
key: const ValueKey('gallery'),
|
||||
scaleStateChangedCallback: (state) {
|
||||
isZoomed.value = state != PhotoViewScaleState.initial;
|
||||
ref.read(showControlsProvider.notifier).show = !isZoomed.value;
|
||||
final asset = ref.read(currentAssetProvider);
|
||||
if (asset == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (asset.isImage && !ref.read(isPlayingMotionVideoProvider)) {
|
||||
isZoomed.value = state != PhotoViewScaleState.initial;
|
||||
ref.read(showControlsProvider.notifier).show =
|
||||
!isZoomed.value;
|
||||
}
|
||||
},
|
||||
loadingBuilder: (context, event, index) => ClipRect(
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
BackdropFilter(
|
||||
filter: ui.ImageFilter.blur(
|
||||
sigmaX: 10,
|
||||
sigmaY: 10,
|
||||
gaplessPlayback: true,
|
||||
loadingBuilder: (context, event, index) {
|
||||
final asset = loadAsset(index);
|
||||
return ClipRect(
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
BackdropFilter(
|
||||
filter: ui.ImageFilter.blur(
|
||||
sigmaX: 10,
|
||||
sigmaY: 10,
|
||||
),
|
||||
),
|
||||
),
|
||||
ImmichThumbnail(
|
||||
asset: asset,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
ImmichThumbnail(
|
||||
key: ValueKey(asset),
|
||||
asset: asset,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
pageController: controller,
|
||||
scrollPhysics: isZoomed.value
|
||||
? const NeverScrollableScrollPhysics() // Don't allow paging while scrolled in
|
||||
: (Platform.isIOS
|
||||
? const ScrollPhysics() // Use bouncing physics for iOS
|
||||
: const ClampingScrollPhysics() // Use heavy physics for Android
|
||||
? const FastScrollPhysics() // Use bouncing physics for iOS
|
||||
: const FastClampingScrollPhysics() // Use heavy physics for Android
|
||||
),
|
||||
itemCount: totalAssets.value,
|
||||
scrollDirection: Axis.horizontal,
|
||||
onPageChanged: (value) async {
|
||||
onPageChanged: (value) {
|
||||
final next = currentIndex.value < value ? value + 1 : value - 1;
|
||||
|
||||
ref.read(hapticFeedbackProvider.notifier).selectionClick();
|
||||
|
||||
final newAsset = loadAsset(value);
|
||||
|
||||
currentIndex.value = value;
|
||||
stackIndex.value = -1;
|
||||
isPlayingVideo.value = false;
|
||||
stackIndex.value = 0;
|
||||
|
||||
// Wait for page change animation to finish
|
||||
await Future.delayed(const Duration(milliseconds: 400));
|
||||
// Then precache the next image
|
||||
unawaited(precacheNextImage(next));
|
||||
},
|
||||
builder: (context, index) {
|
||||
final a =
|
||||
index == currentIndex.value ? asset : loadAsset(index);
|
||||
|
||||
final ImageProvider provider =
|
||||
ImmichImage.imageProvider(asset: a);
|
||||
|
||||
if (a.isImage && !isPlayingVideo.value) {
|
||||
return PhotoViewGalleryPageOptions(
|
||||
onDragStart: (_, details, __) =>
|
||||
localPosition.value = details.localPosition,
|
||||
onDragUpdate: (_, details, __) =>
|
||||
handleSwipeUpDown(details),
|
||||
onTapDown: (_, __, ___) {
|
||||
ref.read(showControlsProvider.notifier).toggle();
|
||||
},
|
||||
onLongPressStart: (_, __, ___) {
|
||||
if (asset.livePhotoVideoId != null) {
|
||||
isPlayingVideo.value = true;
|
||||
}
|
||||
},
|
||||
imageProvider: provider,
|
||||
heroAttributes: PhotoViewHeroAttributes(
|
||||
tag: isFromDto
|
||||
? '${currentAsset.remoteId}-$heroOffset'
|
||||
: currentAsset.id + heroOffset,
|
||||
transitionOnUserGestures: true,
|
||||
),
|
||||
filterQuality: FilterQuality.high,
|
||||
tightMode: true,
|
||||
minScale: PhotoViewComputedScale.contained,
|
||||
errorBuilder: (context, error, stackTrace) => ImmichImage(
|
||||
a,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return PhotoViewGalleryPageOptions.customChild(
|
||||
onDragStart: (_, details, __) =>
|
||||
localPosition.value = details.localPosition,
|
||||
onDragUpdate: (_, details, __) =>
|
||||
handleSwipeUpDown(details),
|
||||
heroAttributes: PhotoViewHeroAttributes(
|
||||
tag: isFromDto
|
||||
? '${currentAsset.remoteId}-$heroOffset'
|
||||
: currentAsset.id + heroOffset,
|
||||
),
|
||||
filterQuality: FilterQuality.high,
|
||||
maxScale: 1.0,
|
||||
minScale: 1.0,
|
||||
basePosition: Alignment.center,
|
||||
child: VideoViewerPage(
|
||||
key: ValueKey(a),
|
||||
asset: a,
|
||||
isMotionVideo: a.livePhotoVideoId != null,
|
||||
loopVideo: shouldLoopVideo.value,
|
||||
placeholder: Image(
|
||||
image: provider,
|
||||
fit: BoxFit.contain,
|
||||
height: context.height,
|
||||
width: context.width,
|
||||
alignment: Alignment.center,
|
||||
),
|
||||
),
|
||||
);
|
||||
ref.read(currentAssetProvider.notifier).set(newAsset);
|
||||
if (newAsset.isVideo || newAsset.isMotionPhoto) {
|
||||
ref.read(videoPlaybackValueProvider.notifier).reset();
|
||||
}
|
||||
|
||||
// Wait for page change animation to finish, then precache the next image
|
||||
Timer(const Duration(milliseconds: 400), () {
|
||||
precacheNextImage(next);
|
||||
});
|
||||
},
|
||||
builder: buildAsset,
|
||||
),
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: GalleryAppBar(
|
||||
asset: asset,
|
||||
key: const ValueKey('app-bar'),
|
||||
showInfo: showInfo,
|
||||
isPlayingVideo: isPlayingVideo.value,
|
||||
onToggleMotionVideo: () =>
|
||||
isPlayingVideo.value = !isPlayingVideo.value,
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
|
@ -402,22 +356,15 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||
right: 0,
|
||||
child: Column(
|
||||
children: [
|
||||
Visibility(
|
||||
visible: stack.isNotEmpty,
|
||||
child: SizedBox(
|
||||
height: 80,
|
||||
child: buildStackedChildren(),
|
||||
),
|
||||
),
|
||||
GalleryStackedChildren(stackIndex),
|
||||
BottomGalleryBar(
|
||||
key: const ValueKey('bottom-bar'),
|
||||
renderList: renderList,
|
||||
totalAssets: totalAssets,
|
||||
controller: controller,
|
||||
showStack: showStack,
|
||||
stackIndex: stackIndex.value,
|
||||
asset: asset,
|
||||
stackIndex: stackIndex,
|
||||
assetIndex: currentIndex,
|
||||
showVideoPlayerControls: !asset.isImage && !isMotionPhoto,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
@ -428,4 +375,14 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||
),
|
||||
);
|
||||
}
|
||||
|
||||
@pragma('vm:prefer-inline')
|
||||
PhotoViewHeroAttributes _getHeroAttributes(Asset asset) {
|
||||
return PhotoViewHeroAttributes(
|
||||
tag: asset.isInDb
|
||||
? asset.id + heroOffset
|
||||
: '${asset.remoteId}-$heroOffset',
|
||||
transitionOnUserGestures: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
411
mobile/lib/pages/common/native_video_viewer.page.dart
Normal file
411
mobile/lib/pages/common/native_video_viewer.page.dart
Normal file
|
@ -0,0 +1,411 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/services/asset.service.dart';
|
||||
import 'package:immich_mobile/utils/debounce.dart';
|
||||
import 'package:immich_mobile/utils/hooks/interval_hook.dart';
|
||||
import 'package:immich_mobile/widgets/asset_viewer/custom_video_player_controls.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:native_video_player/native_video_player.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
|
||||
@RoutePage()
|
||||
class NativeVideoViewerPage extends HookConsumerWidget {
|
||||
final Asset asset;
|
||||
final bool showControls;
|
||||
final Widget image;
|
||||
|
||||
const NativeVideoViewerPage({
|
||||
super.key,
|
||||
required this.asset,
|
||||
required this.image,
|
||||
this.showControls = true,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final controller = useState<NativeVideoPlayerController?>(null);
|
||||
final lastVideoPosition = useRef(-1);
|
||||
final isBuffering = useRef(false);
|
||||
final showMotionVideo = useState(false);
|
||||
|
||||
// When a video is opened through the timeline, `isCurrent` will immediately be true.
|
||||
// When swiping from video A to video B, `isCurrent` will initially be true for video A and false for video B.
|
||||
// If the swipe is completed, `isCurrent` will be true for video B after a delay.
|
||||
// If the swipe is canceled, `currentAsset` will not have changed and video A will continue to play.
|
||||
final currentAsset = useState(ref.read(currentAssetProvider));
|
||||
final isCurrent = currentAsset.value == asset;
|
||||
|
||||
// Used to show the placeholder during hero animations for remote videos to avoid a stutter
|
||||
final isVisible =
|
||||
useState((Platform.isIOS && asset.isLocal) || asset.isMotionPhoto);
|
||||
|
||||
final log = Logger('NativeVideoViewerPage');
|
||||
|
||||
ref.listen(isPlayingMotionVideoProvider, (_, value) async {
|
||||
final videoController = controller.value;
|
||||
if (!asset.isMotionPhoto || videoController == null || !context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
showMotionVideo.value = value;
|
||||
try {
|
||||
if (value) {
|
||||
await videoController.seekTo(0);
|
||||
await videoController.play();
|
||||
} else {
|
||||
await videoController.pause();
|
||||
}
|
||||
} catch (error) {
|
||||
log.severe('Error toggling motion video: $error');
|
||||
}
|
||||
});
|
||||
|
||||
Future<VideoSource?> createSource() async {
|
||||
if (!context.mounted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
final local = asset.local;
|
||||
if (local != null && !asset.isMotionPhoto) {
|
||||
final file = await local.file;
|
||||
if (file == null) {
|
||||
throw Exception('No file found for the video');
|
||||
}
|
||||
|
||||
final source = await VideoSource.init(
|
||||
path: file.path,
|
||||
type: VideoSourceType.file,
|
||||
);
|
||||
return source;
|
||||
}
|
||||
|
||||
// Use a network URL for the video player controller
|
||||
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
|
||||
final String videoUrl = asset.livePhotoVideoId != null
|
||||
? '$serverEndpoint/assets/${asset.livePhotoVideoId}/video/playback'
|
||||
: '$serverEndpoint/assets/${asset.remoteId}/video/playback';
|
||||
|
||||
final source = await VideoSource.init(
|
||||
path: videoUrl,
|
||||
type: VideoSourceType.network,
|
||||
headers: ApiService.getRequestHeaders(),
|
||||
);
|
||||
return source;
|
||||
} catch (error) {
|
||||
log.severe(
|
||||
'Error creating video source for asset ${asset.fileName}: $error',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
final videoSource = useMemoized<Future<VideoSource?>>(() => createSource());
|
||||
final aspectRatio = useState<double?>(asset.aspectRatio);
|
||||
useMemoized(
|
||||
() async {
|
||||
if (!context.mounted || aspectRatio.value != null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
aspectRatio.value =
|
||||
await ref.read(assetServiceProvider).getAspectRatio(asset);
|
||||
} catch (error) {
|
||||
log.severe(
|
||||
'Error getting aspect ratio for asset ${asset.fileName}: $error',
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
void checkIfBuffering() {
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
final videoPlayback = ref.read(videoPlaybackValueProvider);
|
||||
if ((isBuffering.value ||
|
||||
videoPlayback.state == VideoPlaybackState.initializing) &&
|
||||
videoPlayback.state != VideoPlaybackState.buffering) {
|
||||
ref.read(videoPlaybackValueProvider.notifier).value =
|
||||
videoPlayback.copyWith(state: VideoPlaybackState.buffering);
|
||||
}
|
||||
}
|
||||
|
||||
// Timer to mark videos as buffering if the position does not change
|
||||
useInterval(const Duration(seconds: 5), checkIfBuffering);
|
||||
|
||||
// When the position changes, seek to the position
|
||||
// Debounce the seek to avoid seeking too often
|
||||
// But also don't delay the seek too much to maintain visual feedback
|
||||
final seekDebouncer = useDebouncer(
|
||||
interval: const Duration(milliseconds: 100),
|
||||
maxWaitTime: const Duration(milliseconds: 200),
|
||||
);
|
||||
ref.listen(videoPlayerControlsProvider, (oldControls, newControls) async {
|
||||
final playerController = controller.value;
|
||||
if (playerController == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final playbackInfo = playerController.playbackInfo;
|
||||
if (playbackInfo == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final oldSeek = (oldControls?.position ?? 0) ~/ 1;
|
||||
final newSeek = newControls.position ~/ 1;
|
||||
if (oldSeek != newSeek || newControls.restarted) {
|
||||
seekDebouncer.run(() => playerController.seekTo(newSeek));
|
||||
}
|
||||
|
||||
if (oldControls?.pause != newControls.pause || newControls.restarted) {
|
||||
// Make sure the last seek is complete before pausing or playing
|
||||
// Otherwise, `onPlaybackPositionChanged` can receive outdated events
|
||||
if (seekDebouncer.isActive) {
|
||||
await seekDebouncer.drain();
|
||||
}
|
||||
|
||||
try {
|
||||
if (newControls.pause) {
|
||||
await playerController.pause();
|
||||
} else {
|
||||
await playerController.play();
|
||||
}
|
||||
} catch (error) {
|
||||
log.severe('Error pausing or playing video: $error');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void onPlaybackReady() async {
|
||||
final videoController = controller.value;
|
||||
if (videoController == null || !isCurrent || !context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
final videoPlayback =
|
||||
VideoPlaybackValue.fromNativeController(videoController);
|
||||
ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback;
|
||||
|
||||
try {
|
||||
if (asset.isVideo || showMotionVideo.value) {
|
||||
await videoController.play();
|
||||
}
|
||||
await videoController.setVolume(0.9);
|
||||
} catch (error) {
|
||||
log.severe('Error playing video: $error');
|
||||
}
|
||||
}
|
||||
|
||||
void onPlaybackStatusChanged() {
|
||||
final videoController = controller.value;
|
||||
if (videoController == null || !context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
final videoPlayback =
|
||||
VideoPlaybackValue.fromNativeController(videoController);
|
||||
if (videoPlayback.state == VideoPlaybackState.playing) {
|
||||
// Sync with the controls playing
|
||||
WakelockPlus.enable();
|
||||
} else {
|
||||
// Sync with the controls pause
|
||||
WakelockPlus.disable();
|
||||
}
|
||||
|
||||
ref.read(videoPlaybackValueProvider.notifier).status =
|
||||
videoPlayback.state;
|
||||
}
|
||||
|
||||
void onPlaybackPositionChanged() {
|
||||
// When seeking, these events sometimes move the slider to an older position
|
||||
if (seekDebouncer.isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
final videoController = controller.value;
|
||||
if (videoController == null || !context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
final playbackInfo = videoController.playbackInfo;
|
||||
if (playbackInfo == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
ref.read(videoPlaybackValueProvider.notifier).position =
|
||||
Duration(seconds: playbackInfo.position);
|
||||
|
||||
// Check if the video is buffering
|
||||
if (playbackInfo.status == PlaybackStatus.playing) {
|
||||
isBuffering.value = lastVideoPosition.value == playbackInfo.position;
|
||||
lastVideoPosition.value = playbackInfo.position;
|
||||
} else {
|
||||
isBuffering.value = false;
|
||||
lastVideoPosition.value = -1;
|
||||
}
|
||||
}
|
||||
|
||||
void onPlaybackEnded() {
|
||||
final videoController = controller.value;
|
||||
if (videoController == null || !context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (showMotionVideo.value &&
|
||||
videoController.playbackInfo?.status == PlaybackStatus.stopped &&
|
||||
!ref
|
||||
.read(appSettingsServiceProvider)
|
||||
.getSetting<bool>(AppSettingsEnum.loopVideo)) {
|
||||
ref.read(isPlayingMotionVideoProvider.notifier).playing = false;
|
||||
}
|
||||
}
|
||||
|
||||
void removeListeners(NativeVideoPlayerController controller) {
|
||||
controller.onPlaybackPositionChanged
|
||||
.removeListener(onPlaybackPositionChanged);
|
||||
controller.onPlaybackStatusChanged
|
||||
.removeListener(onPlaybackStatusChanged);
|
||||
controller.onPlaybackReady.removeListener(onPlaybackReady);
|
||||
controller.onPlaybackEnded.removeListener(onPlaybackEnded);
|
||||
}
|
||||
|
||||
void initController(NativeVideoPlayerController nc) async {
|
||||
if (controller.value != null || !context.mounted) {
|
||||
return;
|
||||
}
|
||||
ref.read(videoPlayerControlsProvider.notifier).reset();
|
||||
ref.read(videoPlaybackValueProvider.notifier).reset();
|
||||
|
||||
final source = await videoSource;
|
||||
if (source == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
nc.onPlaybackPositionChanged.addListener(onPlaybackPositionChanged);
|
||||
nc.onPlaybackStatusChanged.addListener(onPlaybackStatusChanged);
|
||||
nc.onPlaybackReady.addListener(onPlaybackReady);
|
||||
nc.onPlaybackEnded.addListener(onPlaybackEnded);
|
||||
|
||||
nc.loadVideoSource(source).catchError((error) {
|
||||
log.severe('Error loading video source: $error');
|
||||
});
|
||||
final loopVideo = ref
|
||||
.read(appSettingsServiceProvider)
|
||||
.getSetting<bool>(AppSettingsEnum.loopVideo);
|
||||
nc.setLoop(loopVideo);
|
||||
|
||||
controller.value = nc;
|
||||
Timer(const Duration(milliseconds: 200), checkIfBuffering);
|
||||
}
|
||||
|
||||
ref.listen(currentAssetProvider, (_, value) {
|
||||
final playerController = controller.value;
|
||||
if (playerController != null && value != asset) {
|
||||
removeListeners(playerController);
|
||||
}
|
||||
|
||||
final curAsset = currentAsset.value;
|
||||
if (curAsset == asset) {
|
||||
return;
|
||||
}
|
||||
|
||||
final imageToVideo = curAsset != null && !curAsset.isVideo;
|
||||
|
||||
// No need to delay video playback when swiping from an image to a video
|
||||
if (imageToVideo && Platform.isIOS) {
|
||||
currentAsset.value = value;
|
||||
onPlaybackReady();
|
||||
return;
|
||||
}
|
||||
|
||||
// Delay the video playback to avoid a stutter in the swipe animation
|
||||
Timer(
|
||||
Platform.isIOS
|
||||
? const Duration(milliseconds: 300)
|
||||
: imageToVideo
|
||||
? const Duration(milliseconds: 200)
|
||||
: const Duration(milliseconds: 400), () {
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentAsset.value = value;
|
||||
if (currentAsset.value == asset) {
|
||||
onPlaybackReady();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
// If opening a remote video from a hero animation, delay visibility to avoid a stutter
|
||||
final timer = isVisible.value
|
||||
? null
|
||||
: Timer(
|
||||
const Duration(milliseconds: 300),
|
||||
() => isVisible.value = true,
|
||||
);
|
||||
|
||||
return () {
|
||||
timer?.cancel();
|
||||
final playerController = controller.value;
|
||||
if (playerController == null) {
|
||||
return;
|
||||
}
|
||||
removeListeners(playerController);
|
||||
playerController.stop().catchError((error) {
|
||||
log.fine('Error stopping video: $error');
|
||||
});
|
||||
|
||||
WakelockPlus.disable();
|
||||
};
|
||||
},
|
||||
const [],
|
||||
);
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
// This remains under the video to avoid flickering
|
||||
// For motion videos, this is the image portion of the asset
|
||||
Center(key: ValueKey(asset.id), child: image),
|
||||
if (aspectRatio.value != null)
|
||||
Visibility.maintain(
|
||||
key: ValueKey(asset),
|
||||
visible:
|
||||
(asset.isVideo || showMotionVideo.value) && isVisible.value,
|
||||
child: Center(
|
||||
key: ValueKey(asset),
|
||||
child: AspectRatio(
|
||||
key: ValueKey(asset),
|
||||
aspectRatio: aspectRatio.value!,
|
||||
child: isCurrent
|
||||
? NativeVideoPlayerView(
|
||||
key: ValueKey(asset),
|
||||
onViewReady: initController,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (showControls) const Center(child: CustomVideoPlayerControls()),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,167 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_controller_provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
||||
import 'package:immich_mobile/widgets/asset_viewer/video_player.dart';
|
||||
import 'package:immich_mobile/widgets/common/delayed_loading_indicator.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
|
||||
class VideoViewerPage extends HookConsumerWidget {
|
||||
final Asset asset;
|
||||
final bool isMotionVideo;
|
||||
final Widget? placeholder;
|
||||
final Duration hideControlsTimer;
|
||||
final bool showControls;
|
||||
final bool showDownloadingIndicator;
|
||||
final bool loopVideo;
|
||||
|
||||
const VideoViewerPage({
|
||||
super.key,
|
||||
required this.asset,
|
||||
this.isMotionVideo = false,
|
||||
this.placeholder,
|
||||
this.showControls = true,
|
||||
this.hideControlsTimer = const Duration(seconds: 5),
|
||||
this.showDownloadingIndicator = true,
|
||||
this.loopVideo = false,
|
||||
});
|
||||
|
||||
@override
|
||||
build(BuildContext context, WidgetRef ref) {
|
||||
final controller =
|
||||
ref.watch(videoPlayerControllerProvider(asset: asset)).value;
|
||||
// The last volume of the video used when mute is toggled
|
||||
final lastVolume = useState(0.5);
|
||||
|
||||
// When the volume changes, set the volume
|
||||
ref.listen(videoPlayerControlsProvider.select((value) => value.mute),
|
||||
(_, mute) {
|
||||
if (mute) {
|
||||
controller?.setVolume(0.0);
|
||||
} else {
|
||||
controller?.setVolume(lastVolume.value);
|
||||
}
|
||||
});
|
||||
|
||||
// When the position changes, seek to the position
|
||||
ref.listen(videoPlayerControlsProvider.select((value) => value.position),
|
||||
(_, position) {
|
||||
if (controller == null) {
|
||||
// No seeeking if there is no video
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the position to seek to
|
||||
final Duration seek = controller.value.duration * (position / 100.0);
|
||||
controller.seekTo(seek);
|
||||
});
|
||||
|
||||
// When the custom video controls paus or plays
|
||||
ref.listen(videoPlayerControlsProvider.select((value) => value.pause),
|
||||
(lastPause, pause) {
|
||||
if (pause) {
|
||||
controller?.pause();
|
||||
} else {
|
||||
controller?.play();
|
||||
}
|
||||
});
|
||||
|
||||
// Updates the [videoPlaybackValueProvider] with the current
|
||||
// position and duration of the video from the Chewie [controller]
|
||||
// Also sets the error if there is an error in the playback
|
||||
void updateVideoPlayback() {
|
||||
final videoPlayback = VideoPlaybackValue.fromController(controller);
|
||||
ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback;
|
||||
final state = videoPlayback.state;
|
||||
|
||||
// Enable the WakeLock while the video is playing
|
||||
if (state == VideoPlaybackState.playing) {
|
||||
// Sync with the controls playing
|
||||
WakelockPlus.enable();
|
||||
} else {
|
||||
// Sync with the controls pause
|
||||
WakelockPlus.disable();
|
||||
}
|
||||
}
|
||||
|
||||
// Adds and removes the listener to the video player
|
||||
useEffect(
|
||||
() {
|
||||
Future.microtask(
|
||||
() => ref.read(videoPlayerControlsProvider.notifier).reset(),
|
||||
);
|
||||
// Guard no controller
|
||||
if (controller == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Hide the controls
|
||||
// Done in a microtask to avoid setting the state while the is building
|
||||
if (!isMotionVideo) {
|
||||
Future.microtask(() {
|
||||
ref.read(showControlsProvider.notifier).show = false;
|
||||
});
|
||||
}
|
||||
|
||||
// Subscribes to listener
|
||||
Future.microtask(() {
|
||||
controller.addListener(updateVideoPlayback);
|
||||
});
|
||||
return () {
|
||||
// Removes listener when we dispose
|
||||
controller.removeListener(updateVideoPlayback);
|
||||
controller.pause();
|
||||
};
|
||||
},
|
||||
[controller],
|
||||
);
|
||||
|
||||
return PopScope(
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
ref.read(videoPlaybackValueProvider.notifier).value =
|
||||
VideoPlaybackValue.uninitialized();
|
||||
},
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 400),
|
||||
child: Stack(
|
||||
children: [
|
||||
Visibility(
|
||||
visible: controller == null,
|
||||
child: Stack(
|
||||
children: [
|
||||
if (placeholder != null) placeholder!,
|
||||
const Positioned.fill(
|
||||
child: Center(
|
||||
child: DelayedLoadingIndicator(
|
||||
fadeInDuration: Duration(milliseconds: 500),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (controller != null)
|
||||
SizedBox(
|
||||
height: context.height,
|
||||
width: context.width,
|
||||
child: VideoPlayerViewer(
|
||||
controller: controller,
|
||||
isMotionVideo: isMotionVideo,
|
||||
placeholder: placeholder,
|
||||
hideControlsTimer: hideControlsTimer,
|
||||
showControls: showControls,
|
||||
showDownloadingIndicator: showDownloadingIndicator,
|
||||
loopVideo: loopVideo,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
Binary file not shown.
|
@ -1,15 +1,16 @@
|
|||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
||||
|
||||
class VideoPlaybackControls {
|
||||
VideoPlaybackControls({
|
||||
const VideoPlaybackControls({
|
||||
required this.position,
|
||||
required this.mute,
|
||||
required this.pause,
|
||||
this.restarted = false,
|
||||
});
|
||||
|
||||
final double position;
|
||||
final bool mute;
|
||||
final bool pause;
|
||||
final bool restarted;
|
||||
}
|
||||
|
||||
final videoPlayerControlsProvider =
|
||||
|
@ -17,15 +18,11 @@ final videoPlayerControlsProvider =
|
|||
return VideoPlayerControls(ref);
|
||||
});
|
||||
|
||||
const videoPlayerControlsDefault =
|
||||
VideoPlaybackControls(position: 0, pause: false);
|
||||
|
||||
class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
|
||||
VideoPlayerControls(this.ref)
|
||||
: super(
|
||||
VideoPlaybackControls(
|
||||
position: 0,
|
||||
pause: false,
|
||||
mute: false,
|
||||
),
|
||||
);
|
||||
VideoPlayerControls(this.ref) : super(videoPlayerControlsDefault);
|
||||
|
||||
final Ref ref;
|
||||
|
||||
|
@ -36,75 +33,48 @@ class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
|
|||
}
|
||||
|
||||
void reset() {
|
||||
state = VideoPlaybackControls(
|
||||
position: 0,
|
||||
pause: false,
|
||||
mute: false,
|
||||
);
|
||||
state = videoPlayerControlsDefault;
|
||||
}
|
||||
|
||||
double get position => state.position;
|
||||
bool get mute => state.mute;
|
||||
bool get paused => state.pause;
|
||||
|
||||
set position(double value) {
|
||||
state = VideoPlaybackControls(
|
||||
position: value,
|
||||
mute: state.mute,
|
||||
pause: state.pause,
|
||||
);
|
||||
}
|
||||
if (state.position == value) {
|
||||
return;
|
||||
}
|
||||
|
||||
set mute(bool value) {
|
||||
state = VideoPlaybackControls(
|
||||
position: state.position,
|
||||
mute: value,
|
||||
pause: state.pause,
|
||||
);
|
||||
}
|
||||
|
||||
void toggleMute() {
|
||||
state = VideoPlaybackControls(
|
||||
position: state.position,
|
||||
mute: !state.mute,
|
||||
pause: state.pause,
|
||||
);
|
||||
state = VideoPlaybackControls(position: value, pause: state.pause);
|
||||
}
|
||||
|
||||
void pause() {
|
||||
state = VideoPlaybackControls(
|
||||
position: state.position,
|
||||
mute: state.mute,
|
||||
pause: true,
|
||||
);
|
||||
if (state.pause) {
|
||||
return;
|
||||
}
|
||||
|
||||
state = VideoPlaybackControls(position: state.position, pause: true);
|
||||
}
|
||||
|
||||
void play() {
|
||||
state = VideoPlaybackControls(
|
||||
position: state.position,
|
||||
mute: state.mute,
|
||||
pause: false,
|
||||
);
|
||||
if (!state.pause) {
|
||||
return;
|
||||
}
|
||||
|
||||
state = VideoPlaybackControls(position: state.position, pause: false);
|
||||
}
|
||||
|
||||
void togglePlay() {
|
||||
state = VideoPlaybackControls(
|
||||
position: state.position,
|
||||
mute: state.mute,
|
||||
pause: !state.pause,
|
||||
);
|
||||
state =
|
||||
VideoPlaybackControls(position: state.position, pause: !state.pause);
|
||||
}
|
||||
|
||||
void restart() {
|
||||
state = VideoPlaybackControls(
|
||||
position: 0,
|
||||
mute: state.mute,
|
||||
pause: true,
|
||||
);
|
||||
|
||||
state = VideoPlaybackControls(
|
||||
position: 0,
|
||||
mute: state.mute,
|
||||
pause: false,
|
||||
);
|
||||
state =
|
||||
const VideoPlaybackControls(position: 0, pause: false, restarted: true);
|
||||
ref.read(videoPlaybackValueProvider.notifier).value =
|
||||
ref.read(videoPlaybackValueProvider.notifier).value.copyWith(
|
||||
state: VideoPlaybackState.playing,
|
||||
position: Duration.zero,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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> stackIndex;
|
||||
final ValueNotifier<int> totalAssets;
|
||||
final bool showVideoPlayerControls;
|
||||
final PageController controller;
|
||||
final RenderList renderList;
|
||||
|
||||
|
@ -39,20 +37,24 @@ class BottomGalleryBar extends ConsumerWidget {
|
|||
super.key,
|
||||
required this.showStack,
|
||||
required this.stackIndex,
|
||||
required this.asset,
|
||||
required this.assetIndex,
|
||||
required this.controller,
|
||||
required this.totalAssets,
|
||||
required this.showVideoPlayerControls,
|
||||
required this.renderList,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final asset = ref.watch(currentAssetProvider);
|
||||
if (asset == null) {
|
||||
return const SizedBox();
|
||||
}
|
||||
final isOwner = asset.ownerId == ref.watch(currentUserProvider)?.isarId;
|
||||
final showControls = ref.watch(showControlsProvider);
|
||||
final stackId = asset.stackId;
|
||||
|
||||
final stackItems = showStack && asset.stackCount > 0
|
||||
? ref.watch(assetStackStateProvider(asset))
|
||||
final stackItems = showStack && stackId != null
|
||||
? ref.watch(assetStackStateProvider(stackId))
|
||||
: <Asset>[];
|
||||
bool isStackPrimaryAsset = asset.stackPrimaryAssetId == null;
|
||||
final navStack = AutoRouter.of(context).stackData;
|
||||
|
@ -64,10 +66,10 @@ class BottomGalleryBar extends ConsumerWidget {
|
|||
final isInAlbum = ref.watch(currentAlbumProvider)?.isRemote ?? false;
|
||||
|
||||
void removeAssetFromStack() {
|
||||
if (stackIndex > 0 && showStack) {
|
||||
if (stackIndex.value > 0 && showStack && stackId != null) {
|
||||
ref
|
||||
.read(assetStackStateProvider(asset).notifier)
|
||||
.removeChild(stackIndex - 1);
|
||||
.read(assetStackStateProvider(stackId).notifier)
|
||||
.removeChild(stackIndex.value - 1);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -135,7 +137,7 @@ class BottomGalleryBar extends ConsumerWidget {
|
|||
|
||||
await ref
|
||||
.read(stackServiceProvider)
|
||||
.deleteStack(asset.stackId!, [asset, ...stackItems]);
|
||||
.deleteStack(asset.stackId!, stackItems);
|
||||
}
|
||||
|
||||
void showStackActionItems() {
|
||||
|
@ -324,16 +326,16 @@ class BottomGalleryBar extends ConsumerWidget {
|
|||
},
|
||||
];
|
||||
return IgnorePointer(
|
||||
ignoring: !ref.watch(showControlsProvider),
|
||||
ignoring: !showControls,
|
||||
child: AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 100),
|
||||
opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0,
|
||||
opacity: showControls ? 1.0 : 0.0,
|
||||
child: DecoratedBox(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.bottomCenter,
|
||||
end: Alignment.topCenter,
|
||||
colors: [blackOpacity90, Colors.transparent],
|
||||
colors: [Colors.black, Colors.transparent],
|
||||
),
|
||||
),
|
||||
position: DecorationPosition.background,
|
||||
|
@ -341,7 +343,7 @@ class BottomGalleryBar extends ConsumerWidget {
|
|||
padding: const EdgeInsets.only(top: 40.0),
|
||||
child: Column(
|
||||
children: [
|
||||
if (showVideoPlayerControls) const VideoControls(),
|
||||
if (asset.isVideo) const VideoControls(),
|
||||
BottomNavigationBar(
|
||||
elevation: 0.0,
|
||||
backgroundColor: Colors.transparent,
|
||||
|
|
|
@ -1,38 +1,48 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
||||
import 'package:immich_mobile/utils/hooks/timer_hook.dart';
|
||||
import 'package:immich_mobile/widgets/asset_viewer/center_play_button.dart';
|
||||
import 'package:immich_mobile/widgets/common/delayed_loading_indicator.dart';
|
||||
import 'package:immich_mobile/utils/hooks/timer_hook.dart';
|
||||
|
||||
class CustomVideoPlayerControls extends HookConsumerWidget {
|
||||
final Duration hideTimerDuration;
|
||||
|
||||
const CustomVideoPlayerControls({
|
||||
super.key,
|
||||
this.hideTimerDuration = const Duration(seconds: 3),
|
||||
this.hideTimerDuration = const Duration(seconds: 5),
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final assetIsVideo = ref.watch(
|
||||
currentAssetProvider.select((asset) => asset != null && asset.isVideo),
|
||||
);
|
||||
final showControls = ref.watch(showControlsProvider);
|
||||
final VideoPlaybackState state =
|
||||
ref.watch(videoPlaybackValueProvider.select((value) => value.state));
|
||||
|
||||
// A timer to hide the controls
|
||||
final hideTimer = useTimer(
|
||||
hideTimerDuration,
|
||||
() {
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
final state = ref.read(videoPlaybackValueProvider).state;
|
||||
|
||||
// Do not hide on paused
|
||||
if (state != VideoPlaybackState.paused) {
|
||||
if (state != VideoPlaybackState.paused &&
|
||||
state != VideoPlaybackState.completed &&
|
||||
assetIsVideo) {
|
||||
ref.read(showControlsProvider.notifier).show = false;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
final showBuffering = useState(false);
|
||||
final VideoPlaybackState state =
|
||||
ref.watch(videoPlaybackValueProvider).state;
|
||||
final showBuffering = state == VideoPlaybackState.buffering;
|
||||
|
||||
/// Shows the controls and starts the timer to hide them
|
||||
void showControlsAndStartHideTimer() {
|
||||
|
@ -40,28 +50,15 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
|
|||
ref.read(showControlsProvider.notifier).show = true;
|
||||
}
|
||||
|
||||
// When we mute, show the controls
|
||||
ref.listen(videoPlayerControlsProvider.select((v) => v.mute),
|
||||
(previous, next) {
|
||||
showControlsAndStartHideTimer();
|
||||
});
|
||||
|
||||
// When we change position, show or hide timer
|
||||
ref.listen(videoPlayerControlsProvider.select((v) => v.position),
|
||||
(previous, next) {
|
||||
showControlsAndStartHideTimer();
|
||||
});
|
||||
|
||||
ref.listen(videoPlaybackValueProvider.select((value) => value.state),
|
||||
(_, state) {
|
||||
// Show buffering
|
||||
showBuffering.value = state == VideoPlaybackState.buffering;
|
||||
});
|
||||
|
||||
/// Toggles between playing and pausing depending on the state of the video
|
||||
void togglePlay() {
|
||||
showControlsAndStartHideTimer();
|
||||
final state = ref.read(videoPlaybackValueProvider).state;
|
||||
if (state == VideoPlaybackState.playing) {
|
||||
ref.read(videoPlayerControlsProvider.notifier).pause();
|
||||
} else if (state == VideoPlaybackState.completed) {
|
||||
|
@ -75,10 +72,10 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
|
|||
behavior: HitTestBehavior.opaque,
|
||||
onTap: showControlsAndStartHideTimer,
|
||||
child: AbsorbPointer(
|
||||
absorbing: !ref.watch(showControlsProvider),
|
||||
absorbing: !showControls,
|
||||
child: Stack(
|
||||
children: [
|
||||
if (showBuffering.value)
|
||||
if (showBuffering)
|
||||
const Center(
|
||||
child: DelayedLoadingIndicator(
|
||||
fadeInDuration: Duration(milliseconds: 400),
|
||||
|
@ -86,18 +83,14 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
|
|||
)
|
||||
else
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
if (state != VideoPlaybackState.playing) {
|
||||
togglePlay();
|
||||
}
|
||||
ref.read(showControlsProvider.notifier).show = false;
|
||||
},
|
||||
onTap: () =>
|
||||
ref.read(showControlsProvider.notifier).show = false,
|
||||
child: CenterPlayButton(
|
||||
backgroundColor: Colors.black54,
|
||||
iconColor: Colors.white,
|
||||
isFinished: state == VideoPlaybackState.completed,
|
||||
isPlaying: state == VideoPlaybackState.playing,
|
||||
show: ref.watch(showControlsProvider),
|
||||
show: assetIsVideo && showControls,
|
||||
onPressed: togglePlay,
|
||||
),
|
||||
),
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
|
|
|
@ -214,14 +214,6 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.3"
|
||||
chewie:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: chewie
|
||||
sha256: "2243e41e79e865d426d9dd9c1a9624aa33c4ad11de2d0cd680f826e2cd30e879"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.8.3"
|
||||
ci:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -318,14 +310,6 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
cupertino_icons:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: cupertino_icons
|
||||
sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.8"
|
||||
custom_lint:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
|
@ -378,10 +362,10 @@ packages:
|
|||
dependency: "direct main"
|
||||
description:
|
||||
name: device_info_plus
|
||||
sha256: db03b2d2a3fa466a4627709e1db58692c3f7f658e36a5942d342d86efedc4091
|
||||
sha256: f545ffbadee826f26f2e1a0f0cbd667ae9a6011cc0f77c0f8f00a969655e6e95
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "11.0.0"
|
||||
version: "11.1.1"
|
||||
device_info_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -450,10 +434,10 @@ packages:
|
|||
dependency: "direct main"
|
||||
description:
|
||||
name: file_picker
|
||||
sha256: "167bb619cdddaa10ef2907609feb8a79c16dfa479d3afaf960f8e223f754bf12"
|
||||
sha256: aac85f20436608e01a6ffd1fdd4e746a7f33c93a2c83752e626bdfaea139b877
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.1.2"
|
||||
version: "8.1.3"
|
||||
file_selector_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -548,10 +532,10 @@ packages:
|
|||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_local_notifications
|
||||
sha256: "674173fd3c9eda9d4c8528da2ce0ea69f161577495a9cc835a2a4ecd7eadeb35"
|
||||
sha256: dd6676d8c2926537eccdf9f72128bbb2a9d0814689527b17f92c248ff192eaf3
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "17.2.4"
|
||||
version: "17.2.1+2"
|
||||
flutter_local_notifications_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -1024,14 +1008,15 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.4"
|
||||
nested:
|
||||
dependency: transitive
|
||||
native_video_player:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: nested
|
||||
sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
path: "."
|
||||
ref: ac78487
|
||||
resolved-ref: ac78487b9a87c9e72cd15b428270a905ac551f29
|
||||
url: "https://github.com/immich-app/native_video_player"
|
||||
source: git
|
||||
version: "1.3.1"
|
||||
nm:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -1067,10 +1052,10 @@ packages:
|
|||
dependency: "direct main"
|
||||
description:
|
||||
name: package_info_plus
|
||||
sha256: "894f37107424311bdae3e476552229476777b8752c5a2a2369c0cb9a2d5442ef"
|
||||
sha256: da8d9ac8c4b1df253d1a328b7bf01ae77ef132833479ab40763334db13b91cce
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.0.3"
|
||||
version: "8.1.1"
|
||||
package_info_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -1255,14 +1240,6 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.0.2"
|
||||
provider:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: provider
|
||||
sha256: c8a055ee5ce3fd98d6fc872478b03823ffdb448699c6ebdbbc71d59b596fd48c
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.2"
|
||||
pub_semver:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -1339,10 +1316,10 @@ packages:
|
|||
dependency: "direct main"
|
||||
description:
|
||||
name: share_plus
|
||||
sha256: fec12c3c39f01e4df1ec6ad92b6e85503c5ca64ffd6e28d18c9ffe53fcc4cb11
|
||||
sha256: "9c9bafd4060728d7cdb2464c341743adbd79d327cb067ec7afb64583540b47c8"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "10.0.3"
|
||||
version: "10.1.2"
|
||||
share_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -1708,46 +1685,6 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
video_player:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: video_player
|
||||
sha256: "4a8c3492d734f7c39c2588a3206707a05ee80cef52e8c7f3b2078d430c84bc17"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.9.2"
|
||||
video_player_android:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: video_player_android
|
||||
sha256: "4de50df9ee786f5891d3281e1e633d7b142ef1acf47392592eb91cba5d355849"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.6.0"
|
||||
video_player_avfoundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: video_player_avfoundation
|
||||
sha256: d1e9a824f2b324000dc8fb2dcb2a3285b6c1c7c487521c63306cc5b394f68a7c
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.6.1"
|
||||
video_player_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: video_player_platform_interface
|
||||
sha256: "236454725fafcacf98f0f39af0d7c7ab2ce84762e3b63f2cbb3ef9a7e0550bc6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.2.2"
|
||||
video_player_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: video_player_web
|
||||
sha256: "6dcdd298136523eaf7dfc31abaf0dfba9aa8a8dbc96670e87e9d42b6f2caf774"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.2"
|
||||
vm_service:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
|
@ -25,9 +25,6 @@ dependencies:
|
|||
intl: ^0.19.0
|
||||
auto_route: ^9.2.0
|
||||
fluttertoast: ^8.2.4
|
||||
video_player: ^2.9.2
|
||||
video_player_android: 2.6.0
|
||||
chewie: ^1.7.4
|
||||
socket_io_client: ^2.0.3+1
|
||||
maplibre_gl: 0.19.0+2
|
||||
geolocator: ^11.0.0 # used to move to current location in map view
|
||||
|
@ -45,7 +42,7 @@ dependencies:
|
|||
path_provider: ^2.1.2
|
||||
collection: ^1.18.0
|
||||
http_parser: ^4.0.2
|
||||
flutter_web_auth: ^0.6.0
|
||||
flutter_web_auth: 0.6.0
|
||||
easy_image_viewer: ^1.4.0
|
||||
isar:
|
||||
version: *isar_version
|
||||
|
@ -64,6 +61,10 @@ dependencies:
|
|||
async: ^2.11.0
|
||||
dynamic_color: ^1.7.0 #package to apply system theme
|
||||
background_downloader: ^8.5.5
|
||||
native_video_player:
|
||||
git:
|
||||
url: https://github.com/immich-app/native_video_player
|
||||
ref: ac78487
|
||||
|
||||
#image editing packages
|
||||
crop_image: ^1.0.13
|
||||
|
|
Loading…
Reference in a new issue