Compare commits

..

7 Commits

Author SHA1 Message Date
Alex
fe4c6c6365 default thumbnail size 2024-11-19 11:49:08 -06:00
Alex
63ed6283fc feat(mobile): smaller default local thumbnail size 2024-11-18 17:41:56 -06:00
John Stef
41f138d3c8 fix(mobile): Dismissible menus (#14192)
* chore(mobile): make all search filters dismissible

* chore(mobile): make ImmichAppBarDialog dismissible

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2024-11-18 10:06:07 -06:00
Mert
6b5defc27b fix(mobile): use sets in album refresh, concurrent futures (#14193)
* use sets in album sync, concurrent futures

* batch excluded asset IDs

* update test

* take advantage of sets in Recents check

* move log statement

* smaller diff
2024-11-18 09:26:23 -06:00
renovate[bot]
2604940f09 chore(deps): pin mcr.microsoft.com/devcontainers/typescript-node docker tag to dc2c365 (#14124)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-18 14:57:43 +01:00
Michel Heusschen
32f908baf1 fix: show tags when viewing stacked assets (#14199)
fix: refresh tags when viewing stacked assets
2024-11-18 07:50:04 -05:00
Mert
944ea7dbcd fix(mobile): unnecessary rebuilds from partner share notifier (#14170)
* fix unnecessary notifications

* move equality function

* sort by id

* use same comparison for initial and later queries
2024-11-17 12:04:55 -05:00
56 changed files with 1260 additions and 1654 deletions

View File

@@ -1,2 +1,2 @@
ARG BASEIMAGE=mcr.microsoft.com/devcontainers/typescript-node:22
ARG BASEIMAGE=mcr.microsoft.com/devcontainers/typescript-node:22@sha256:dc2c3654370fe92a55daeefe9d2d95839d85bdc1f68f7fd4ab86621f49e5818a
FROM ${BASEIMAGE}

View File

@@ -11,6 +11,7 @@ import {
PersonCreateDto,
SharedLinkCreateDto,
UserAdminCreateDto,
UserPreferencesUpdateDto,
ValidateLibraryDto,
checkExistingAssets,
createAlbum,
@@ -19,6 +20,7 @@ import {
createPartner,
createPerson,
createSharedLink,
createStack,
createUserAdmin,
deleteAssets,
getAllJobsStatus,
@@ -28,10 +30,13 @@ import {
searchMetadata,
setBaseUrl,
signUpAdmin,
tagAssets,
updateAdminOnboarding,
updateAlbumUser,
updateAssets,
updateConfig,
updateMyPreferences,
upsertTags,
validate,
} from '@immich/sdk';
import { BrowserContext } from '@playwright/test';
@@ -444,6 +449,18 @@ export const utils = {
createPartner: (accessToken: string, id: string) => createPartner({ id }, { headers: asBearerAuth(accessToken) }),
updateMyPreferences: (accessToken: string, userPreferencesUpdateDto: UserPreferencesUpdateDto) =>
updateMyPreferences({ userPreferencesUpdateDto }, { headers: asBearerAuth(accessToken) }),
createStack: (accessToken: string, assetIds: string[]) =>
createStack({ stackCreateDto: { assetIds } }, { headers: asBearerAuth(accessToken) }),
upsertTags: (accessToken: string, tags: string[]) =>
upsertTags({ tagUpsertDto: { tags } }, { headers: asBearerAuth(accessToken) }),
tagAssets: (accessToken: string, tagId: string, assetIds: string[]) =>
tagAssets({ id: tagId, bulkIdsDto: { ids: assetIds } }, { headers: asBearerAuth(accessToken) }),
setAuthCookies: async (context: BrowserContext, accessToken: string, domain = '127.0.0.1') =>
await context.addCookies([
{

View File

@@ -0,0 +1,66 @@
import { AssetMediaResponseDto, LoginResponseDto } from '@immich/sdk';
import { expect, Page, test } from '@playwright/test';
import { utils } from 'src/utils';
async function ensureDetailPanelVisible(page: Page) {
await page.waitForSelector('#immich-asset-viewer');
const isVisible = await page.locator('#detail-panel').isVisible();
if (!isVisible) {
await page.keyboard.press('i');
await page.waitForSelector('#detail-panel');
}
}
test.describe('Asset Viewer stack', () => {
let admin: LoginResponseDto;
let assetOne: AssetMediaResponseDto;
let assetTwo: AssetMediaResponseDto;
test.beforeAll(async () => {
utils.initSdk();
await utils.resetDatabase();
admin = await utils.adminSetup();
await utils.updateMyPreferences(admin.accessToken, { tags: { enabled: true } });
assetOne = await utils.createAsset(admin.accessToken);
assetTwo = await utils.createAsset(admin.accessToken);
await utils.createStack(admin.accessToken, [assetOne.id, assetTwo.id]);
const tags = await utils.upsertTags(admin.accessToken, ['test/1', 'test/2']);
const tagOne = tags.find((tag) => tag.value === 'test/1')!;
const tagTwo = tags.find((tag) => tag.value === 'test/2')!;
await utils.tagAssets(admin.accessToken, tagOne.id, [assetOne.id]);
await utils.tagAssets(admin.accessToken, tagTwo.id, [assetTwo.id]);
});
test('stack slideshow is visible', async ({ page, context }) => {
await utils.setAuthCookies(context, admin.accessToken);
await page.goto(`/photos/${assetOne.id}`);
const stackAssets = page.locator('#stack-slideshow [data-asset]');
await expect(stackAssets.first()).toBeVisible();
await expect(stackAssets.nth(1)).toBeVisible();
});
test('tags of primary asset are visible', async ({ page, context }) => {
await utils.setAuthCookies(context, admin.accessToken);
await page.goto(`/photos/${assetOne.id}`);
await ensureDetailPanelVisible(page);
const tags = page.getByTestId('detail-panel-tags').getByRole('link');
await expect(tags.first()).toHaveText('test/1');
});
test('tags of second asset are visible', async ({ page, context }) => {
await utils.setAuthCookies(context, admin.accessToken);
await page.goto(`/photos/${assetOne.id}`);
await ensureDetailPanelVisible(page);
const stackAssets = page.locator('#stack-slideshow [data-asset]');
await stackAssets.nth(1).click();
const tags = page.getByTestId('detail-panel-tags').getByRole('link');
await expect(tags.first()).toHaveText('test/2');
});
});

View File

@@ -28,7 +28,7 @@ if (keystorePropertiesFile.exists()) {
}
android {
compileSdkVersion 35
compileSdkVersion 34
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
@@ -47,7 +47,7 @@ android {
defaultConfig {
applicationId "app.alextran.immich"
minSdkVersion 26
targetSdkVersion 35
targetSdkVersion 34
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
}

View File

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

View File

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

View File

@@ -65,8 +65,6 @@ 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):
@@ -117,7 +115,6 @@ 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`)
@@ -171,8 +168,6 @@ 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:
@@ -199,7 +194,7 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS:
background_downloader: 9f788ffc5de45acf87d6380e91ca0841066c18cf
connectivity_plus: ddd7f30999e1faaef5967c23d5b6d503d10434db
device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342
device_info_plus: 97af1d7e84681a90d0693e63169a5d50e0839a0d
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655
@@ -215,15 +210,14 @@ SPEC CHECKSUMS:
isar_flutter_libs: fdf730ca925d05687f36d7f1d355e482529ed097
MapLibre: 620fc933c1d6029b33738c905c1490d024e5d4ef
maplibre_gl: a2efec727dd340e4c65e26d2b03b584f14881fd9
native_video_player: d12af78a1a4a8cf09775a5177d5b392def6fd23c
package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4
package_info_plus: 58f0028419748fad15bf008b270aaa8e54380b1c
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02
permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2
photo_manager: ff695c7a1dd5bc379974953a2b5c0a293f7c4c8a
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
SDWebImage: 066c47b573f408f18caa467d71deace7c0f8280d
share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f
share_plus: 8875f4f2500512ea181eef553c3e27dba5135aad
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
@@ -234,4 +228,4 @@ SPEC CHECKSUMS:
PODFILE CHECKSUM: 64c9b5291666c0ca3caabdfe9865c141ac40321d
COCOAPODS: 1.16.0
COCOAPODS: 1.15.2

View File

@@ -1,59 +1,48 @@
import BackgroundTasks
import Flutter
import UIKit
import path_provider_ios
import permission_handler_apple
import photo_manager
import shared_preferences_foundation
import Flutter
import BackgroundTasks
import path_provider_ios
import photo_manager
import permission_handler_apple
@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
}
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")!)
// Required for flutter_local_notification
if #available(iOS 10.0, *) {
UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate
}
if !registry.hasPlugin("org.cocoapods.photo-manager") {
PhotoManagerPlugin.register(
with: registry.registrar(forPlugin: "org.cocoapods.photo-manager")!)
}
GeneratedPluginRegistrant.register(with: self)
BackgroundServicePlugin.registerBackgroundProcessing()
if !registry.hasPlugin("org.cocoapods.shared-preferences-foundation") {
SharedPreferencesPlugin.register(
with: registry.registrar(forPlugin: "org.cocoapods.shared-preferences-foundation")!)
}
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.permission-handler-apple") {
PermissionHandlerPlugin.register(with: registry.registrar(forPlugin: "org.cocoapods.permission-handler-apple")!)
}
}
}
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
}

View File

@@ -20,8 +20,8 @@ const String defaultColorPresetName = "indigo";
const Color immichBrandColorLight = Color(0xFF4150AF);
const Color immichBrandColorDark = Color(0xFFACCBFA);
const Color whiteOpacity75 = Color.fromARGB((0.75 * 255) ~/ 1, 255, 255, 255);
const Color blackOpacity90 = Color.fromARGB((0.90 * 255) ~/ 1, 0, 0, 0);
const Color red400 = Color(0xFFEF5350);
const Color grey200 = Color(0xFFEEEEEE);
final Map<ImmichColorPreset, ImmichTheme> _themePresetsMap = {
ImmichColorPreset.indigo: ImmichTheme(

View File

@@ -1,5 +1,4 @@
import 'dart:convert';
import 'dart:io';
import 'package:immich_mobile/entities/exif_info.entity.dart';
import 'package:immich_mobile/utils/hash.dart';
@@ -23,8 +22,12 @@ class Asset {
durationInSeconds = remote.duration.toDuration()?.inSeconds ?? 0,
type = remote.type.toAssetType(),
fileName = remote.originalFileName,
height = remote.exifInfo?.exifImageHeight?.toInt(),
width = remote.exifInfo?.exifImageWidth?.toInt(),
height = isFlipped(remote)
? remote.exifInfo?.exifImageWidth?.toInt()
: remote.exifInfo?.exifImageHeight?.toInt(),
width = isFlipped(remote)
? remote.exifInfo?.exifImageHeight?.toInt()
: remote.exifInfo?.exifImageWidth?.toInt(),
livePhotoVideoId = remote.livePhotoVideoId,
ownerId = fastHash(remote.ownerId),
exifInfo =
@@ -90,27 +93,6 @@ 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
@@ -168,21 +150,10 @@ class Asset {
int stackCount;
/// Returns null if the asset has no sync access to the exif info
/// Aspect ratio of the asset
@ignore
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;
}
double? get aspectRatio =>
width == null || height == null ? 0 : width! / height!;
/// `true` if this [Asset] is present on the device
@ignore
@@ -201,12 +172,6 @@ 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) {
@@ -227,50 +192,6 @@ 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;
@@ -590,3 +511,21 @@ extension AssetsHelper on IsarCollection<Asset> {
return where().anyOf(ids, (q, String e) => q.localIdEqualTo(e));
}
}
/// Returns `true` if this [int] is flipped 90° clockwise
bool isRotated90CW(int orientation) {
return [7, 8, -90].contains(orientation);
}
/// Returns `true` if this [int] is flipped 270° clockwise
bool isRotated270CW(int orientation) {
return [5, 6, 90].contains(orientation);
}
/// Returns `true` if this [Asset] is flipped 90° or 270° clockwise
bool isFlipped(AssetResponseDto response) {
final int orientation =
int.tryParse(response.exifInfo?.orientation ?? '0') ?? 0;
return orientation != 0 &&
(isRotated90CW(orientation) || isRotated270CW(orientation));
}

View File

@@ -23,7 +23,6 @@ class ExifInfo {
String? state;
String? country;
String? description;
String? orientation;
@ignore
bool get hasCoordinates =>
@@ -46,13 +45,6 @@ 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;
@@ -75,8 +67,7 @@ class ExifInfo {
city = dto.city,
state = dto.state,
country = dto.country,
description = dto.description,
orientation = dto.orientation;
description = dto.description;
ExifInfo({
this.id,
@@ -96,7 +87,6 @@ class ExifInfo {
this.state,
this.country,
this.description,
this.orientation,
});
ExifInfo copyWith({
@@ -117,7 +107,6 @@ class ExifInfo {
String? state,
String? country,
String? description,
String? orientation,
}) =>
ExifInfo(
id: id ?? this.id,
@@ -137,7 +126,6 @@ class ExifInfo {
state: state ?? this.state,
country: country ?? this.country,
description: description ?? this.description,
orientation: orientation ?? this.orientation,
);
@override
@@ -159,8 +147,7 @@ class ExifInfo {
city == other.city &&
state == other.state &&
country == other.country &&
description == other.description &&
orientation == other.orientation;
description == other.description;
}
@override
@@ -182,8 +169,7 @@ class ExifInfo {
city.hashCode ^
state.hashCode ^
country.hashCode ^
description.hashCode ^
orientation.hashCode;
description.hashCode;
@override
String toString() {
@@ -206,21 +192,10 @@ 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;

View File

@@ -87,18 +87,13 @@ const ExifInfoSchema = CollectionSchema(
name: r'model',
type: IsarType.string,
),
r'orientation': PropertySchema(
id: 14,
name: r'orientation',
type: IsarType.string,
),
r'state': PropertySchema(
id: 15,
id: 14,
name: r'state',
type: IsarType.string,
),
r'timeZone': PropertySchema(
id: 16,
id: 15,
name: r'timeZone',
type: IsarType.string,
)
@@ -159,12 +154,6 @@ int _exifInfoEstimateSize(
bytesCount += 3 + value.length * 3;
}
}
{
final value = object.orientation;
if (value != null) {
bytesCount += 3 + value.length * 3;
}
}
{
final value = object.state;
if (value != null) {
@@ -200,9 +189,8 @@ void _exifInfoSerialize(
writer.writeString(offsets[11], object.make);
writer.writeFloat(offsets[12], object.mm);
writer.writeString(offsets[13], object.model);
writer.writeString(offsets[14], object.orientation);
writer.writeString(offsets[15], object.state);
writer.writeString(offsets[16], object.timeZone);
writer.writeString(offsets[14], object.state);
writer.writeString(offsets[15], object.timeZone);
}
ExifInfo _exifInfoDeserialize(
@@ -227,9 +215,8 @@ ExifInfo _exifInfoDeserialize(
make: reader.readStringOrNull(offsets[11]),
mm: reader.readFloatOrNull(offsets[12]),
model: reader.readStringOrNull(offsets[13]),
orientation: reader.readStringOrNull(offsets[14]),
state: reader.readStringOrNull(offsets[15]),
timeZone: reader.readStringOrNull(offsets[16]),
state: reader.readStringOrNull(offsets[14]),
timeZone: reader.readStringOrNull(offsets[15]),
);
return object;
}
@@ -273,8 +260,6 @@ P _exifInfoDeserializeProp<P>(
return (reader.readStringOrNull(offset)) as P;
case 15:
return (reader.readStringOrNull(offset)) as P;
case 16:
return (reader.readStringOrNull(offset)) as P;
default:
throw IsarError('Unknown property with id $propertyId');
}
@@ -1924,155 +1909,6 @@ extension ExifInfoQueryFilter
});
}
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> orientationIsNull() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(const FilterCondition.isNull(
property: r'orientation',
));
});
}
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition>
orientationIsNotNull() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(const FilterCondition.isNotNull(
property: r'orientation',
));
});
}
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> orientationEqualTo(
String? value, {
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'orientation',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition>
orientationGreaterThan(
String? value, {
bool include = false,
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.greaterThan(
include: include,
property: r'orientation',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> orientationLessThan(
String? value, {
bool include = false,
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.lessThan(
include: include,
property: r'orientation',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> orientationBetween(
String? lower,
String? upper, {
bool includeLower = true,
bool includeUpper = true,
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.between(
property: r'orientation',
lower: lower,
includeLower: includeLower,
upper: upper,
includeUpper: includeUpper,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> orientationStartsWith(
String value, {
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.startsWith(
property: r'orientation',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> orientationEndsWith(
String value, {
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.endsWith(
property: r'orientation',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> orientationContains(
String value,
{bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.contains(
property: r'orientation',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> orientationMatches(
String pattern,
{bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.matches(
property: r'orientation',
wildcard: pattern,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> orientationIsEmpty() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'orientation',
value: '',
));
});
}
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition>
orientationIsNotEmpty() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.greaterThan(
property: r'orientation',
value: '',
));
});
}
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> stateIsNull() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(const FilterCondition.isNull(
@@ -2541,18 +2377,6 @@ extension ExifInfoQuerySortBy on QueryBuilder<ExifInfo, ExifInfo, QSortBy> {
});
}
QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> sortByOrientation() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'orientation', Sort.asc);
});
}
QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> sortByOrientationDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'orientation', Sort.desc);
});
}
QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> sortByState() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'state', Sort.asc);
@@ -2760,18 +2584,6 @@ extension ExifInfoQuerySortThenBy
});
}
QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> thenByOrientation() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'orientation', Sort.asc);
});
}
QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> thenByOrientationDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'orientation', Sort.desc);
});
}
QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> thenByState() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'state', Sort.asc);
@@ -2889,13 +2701,6 @@ extension ExifInfoQueryWhereDistinct
});
}
QueryBuilder<ExifInfo, ExifInfo, QDistinct> distinctByOrientation(
{bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
return query.addDistinctBy(r'orientation', caseSensitive: caseSensitive);
});
}
QueryBuilder<ExifInfo, ExifInfo, QDistinct> distinctByState(
{bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
@@ -3004,12 +2809,6 @@ extension ExifInfoQueryProperty
});
}
QueryBuilder<ExifInfo, String?, QQueryOperations> orientationProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'orientation');
});
}
QueryBuilder<ExifInfo, String?, QQueryOperations> stateProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'state');

View File

@@ -1,32 +0,0 @@
import 'package:flutter/cupertino.dart';
const _spring = SpringDescription(
mass: 40,
stiffness: 100,
damping: 1,
);
// https://stackoverflow.com/a/74453792
class FastScrollPhysics extends ScrollPhysics {
const FastScrollPhysics({super.parent});
@override
FastScrollPhysics applyTo(ScrollPhysics? ancestor) {
return FastScrollPhysics(parent: buildParent(ancestor));
}
@override
SpringDescription get spring => _spring;
}
class FastClampingScrollPhysics extends ClampingScrollPhysics {
const FastClampingScrollPhysics({super.parent});
@override
FastClampingScrollPhysics applyTo(ScrollPhysics? ancestor) {
return FastClampingScrollPhysics(parent: buildParent(ancestor));
}
@override
SpringDescription get spring => _spring;
}

View File

@@ -1,82 +0,0 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_stack.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/image/immich_remote_image_provider.dart';
class GalleryStackedChildren extends HookConsumerWidget {
final ValueNotifier<int> stackIndex;
const GalleryStackedChildren(this.stackIndex, {super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final asset = ref.watch(currentAssetProvider);
if (asset == null) {
return const SizedBox();
}
final stackId = asset.stackId;
if (stackId == null) {
return const SizedBox();
}
final stackElements = ref.watch(assetStackStateProvider(stackId));
return SizedBox(
height: 80,
child: ListView.builder(
shrinkWrap: true,
scrollDirection: Axis.horizontal,
itemCount: stackElements.length,
padding: const EdgeInsets.only(
left: 5,
right: 5,
bottom: 30,
),
itemBuilder: (context, index) {
final currentAsset = stackElements.elementAt(index);
final assetId = currentAsset.remoteId;
if (assetId == null) {
return const SizedBox();
}
return Padding(
key: ValueKey(currentAsset.id),
padding: const EdgeInsets.only(right: 5),
child: GestureDetector(
onTap: () {
stackIndex.value = index;
ref.read(currentAssetProvider.notifier).set(currentAsset);
},
child: Container(
width: 60,
height: 60,
decoration: index == stackIndex.value
? const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.all(Radius.circular(6)),
border: Border.fromBorderSide(
BorderSide(color: Colors.white, width: 2),
),
)
: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.all(Radius.circular(6)),
border: null,
),
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(4)),
child: Image(
fit: BoxFit.cover,
image: ImmichRemoteImageProvider(assetId: assetId),
),
),
),
),
);
},
),
);
}
}

View File

@@ -8,19 +8,18 @@ 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/native_video_viewer.page.dart';
import 'package:immich_mobile/pages/common/gallery_stacked_children.dart';
import 'package:immich_mobile/pages/common/video_viewer.page.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';
@@ -36,7 +35,6 @@ 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;
@@ -55,66 +53,79 @@ class GalleryViewerPage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final totalAssets = useState(renderList.totalAssets);
final isZoomed = useState(false);
final stackIndex = useState(0);
final localPosition = useRef<Offset?>(null);
final currentIndex = useValueNotifier(initialIndex);
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;
},
[],
);
Future<void> precacheNextImage(int index) async {
if (!context.mounted) {
return;
}
void onError(Object exception, StackTrace? stackTrace) {
// swallow error silently
log.severe('Error precaching next image: $exception, $stackTrace');
debugPrint('Error precaching next image: $exception, $stackTrace');
}
try {
if (index < totalAssets.value && index >= 0) {
final asset = loadAsset(index);
await precacheImage(
ImmichImage.imageProvider(
asset: asset,
width: context.width,
height: context.height,
),
ImmichImage.imageProvider(asset: asset),
context,
onError: onError,
);
}
} catch (e) {
// swallow error silently
log.severe('Error precaching next image: $e');
debugPrint('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)),
@@ -172,6 +183,34 @@ class GalleryViewerPage extends HookConsumerWidget {
}
}
useEffect(
() {
if (ref.read(showControlsProvider)) {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
} else {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
}
isPlayingVideo.value = false;
return null;
},
[],
);
useEffect(
() {
// No need to await this
unawaited(
// Delay this a bit so we can finish loading the page
Future.delayed(const Duration(milliseconds: 400)).then(
// Precache the next image
(_) => precacheNextImage(currentIndex.value + 1),
),
);
return null;
},
[],
);
ref.listen(showControlsProvider, (_, show) {
if (show) {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
@@ -180,88 +219,50 @@ class GalleryViewerPage extends HookConsumerWidget {
}
});
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,
Widget buildStackedChildren() {
return ListView.builder(
shrinkWrap: true,
scrollDirection: Axis.horizontal,
itemCount: stackElements.length,
padding: const EdgeInsets.only(
left: 5,
right: 5,
bottom: 30,
),
);
}
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,
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!),
),
),
),
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, _) =>
@@ -271,79 +272,128 @@ class GalleryViewerPage extends HookConsumerWidget {
body: Stack(
children: [
PhotoViewGallery.builder(
key: const ValueKey('gallery'),
scaleStateChangedCallback: (state) {
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;
}
isZoomed.value = state != PhotoViewScaleState.initial;
ref.read(showControlsProvider.notifier).show = !isZoomed.value;
},
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,
),
loadingBuilder: (context, event, index) => ClipRect(
child: Stack(
fit: StackFit.expand,
children: [
BackdropFilter(
filter: ui.ImageFilter.blur(
sigmaX: 10,
sigmaY: 10,
),
ImmichThumbnail(
key: ValueKey(asset),
asset: asset,
fit: BoxFit.contain,
),
],
),
);
},
),
ImmichThumbnail(
asset: asset,
fit: BoxFit.contain,
),
],
),
),
pageController: controller,
scrollPhysics: isZoomed.value
? const NeverScrollableScrollPhysics() // Don't allow paging while scrolled in
: (Platform.isIOS
? const FastScrollPhysics() // Use bouncing physics for iOS
: const FastClampingScrollPhysics() // Use heavy physics for Android
? const ScrollPhysics() // Use bouncing physics for iOS
: const ClampingScrollPhysics() // Use heavy physics for Android
),
itemCount: totalAssets.value,
scrollDirection: Axis.horizontal,
onPageChanged: (value) {
onPageChanged: (value) async {
final next = currentIndex.value < value ? value + 1 : value - 1;
ref.read(hapticFeedbackProvider.notifier).selectionClick();
final newAsset = loadAsset(value);
currentIndex.value = value;
stackIndex.value = 0;
stackIndex.value = -1;
isPlayingVideo.value = false;
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);
});
// 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,
),
),
);
}
},
builder: buildAsset,
),
Positioned(
top: 0,
left: 0,
right: 0,
child: GalleryAppBar(
key: const ValueKey('app-bar'),
asset: asset,
showInfo: showInfo,
isPlayingVideo: isPlayingVideo.value,
onToggleMotionVideo: () =>
isPlayingVideo.value = !isPlayingVideo.value,
),
),
Positioned(
@@ -352,15 +402,22 @@ class GalleryViewerPage extends HookConsumerWidget {
right: 0,
child: Column(
children: [
GalleryStackedChildren(stackIndex),
Visibility(
visible: stack.isNotEmpty,
child: SizedBox(
height: 80,
child: buildStackedChildren(),
),
),
BottomGalleryBar(
key: const ValueKey('bottom-bar'),
renderList: renderList,
totalAssets: totalAssets,
controller: controller,
showStack: showStack,
stackIndex: stackIndex.value,
asset: asset,
assetIndex: currentIndex,
showVideoPlayerControls: !asset.isImage && !isMotionPhoto,
),
],
),
@@ -371,14 +428,4 @@ class GalleryViewerPage extends HookConsumerWidget {
),
);
}
@pragma('vm:prefer-inline')
PhotoViewHeroAttributes _getHeroAttributes(Asset asset) {
return PhotoViewHeroAttributes(
tag: asset.isInDb
? asset.id + heroOffset
: '${asset.remoteId}-$heroOffset',
transitionOnUserGestures: true,
);
}
}

View File

@@ -1,429 +0,0 @@
import 'dart:async';
import 'dart:io';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/services/asset.service.dart';
import 'package:immich_mobile/utils/debounce.dart';
import 'package:immich_mobile/utils/hooks/interval_hook.dart';
import 'package:immich_mobile/widgets/asset_viewer/custom_video_player_controls.dart';
import 'package:logging/logging.dart';
import 'package:native_video_player/native_video_player.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
@RoutePage()
class NativeVideoViewerPage extends HookConsumerWidget {
final Asset asset;
final bool showControls;
final Widget image;
const NativeVideoViewerPage({
super.key,
required this.asset,
required this.image,
this.showControls = true,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final controller = useState<NativeVideoPlayerController?>(null);
final lastVideoPosition = useRef(-1);
final isBuffering = useRef(false);
final showMotionVideo = useState(false);
// When a video is opened through the timeline, `isCurrent` will immediately be true.
// When swiping from video A to video B, `isCurrent` will initially be true for video A and false for video B.
// If the swipe is completed, `isCurrent` will be true for video B after a delay.
// If the swipe is canceled, `currentAsset` will not have changed and video A will continue to play.
final currentAsset = useState(ref.read(currentAssetProvider));
final isCurrent = currentAsset.value == asset;
// Used to show the placeholder during hero animations for remote videos to avoid a stutter
final isVisible =
useState((Platform.isIOS && asset.isLocal) || asset.isMotionPhoto);
final log = Logger('NativeVideoViewerPage');
ref.listen(isPlayingMotionVideoProvider, (_, value) async {
final videoController = controller.value;
if (!asset.isMotionPhoto || videoController == null || !context.mounted) {
return;
}
showMotionVideo.value = value;
try {
if (value) {
await videoController.seekTo(0);
await videoController.play();
} else {
await videoController.pause();
}
} catch (error) {
log.severe('Error toggling motion video: $error');
}
});
Future<VideoSource?> createSource() async {
if (!context.mounted) {
return null;
}
try {
final local = asset.local;
if (local != null && !asset.isMotionPhoto) {
final file = await local.file;
if (file == null) {
throw Exception('No file found for the video');
}
final source = await VideoSource.init(
path: file.path,
type: VideoSourceType.file,
);
return source;
}
// Use a network URL for the video player controller
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
final String videoUrl = asset.livePhotoVideoId != null
? '$serverEndpoint/assets/${asset.livePhotoVideoId}/video/playback'
: '$serverEndpoint/assets/${asset.remoteId}/video/playback';
final source = await VideoSource.init(
path: videoUrl,
type: VideoSourceType.network,
headers: ApiService.getRequestHeaders(),
);
return source;
} catch (error) {
log.severe(
'Error creating video source for asset ${asset.fileName}: $error',
);
return null;
}
}
final videoSource = useMemoized<Future<VideoSource?>>(() => createSource());
final aspectRatio = useState<double?>(asset.aspectRatio);
useMemoized(
() async {
if (!context.mounted || aspectRatio.value != null) {
return null;
}
try {
aspectRatio.value =
await ref.read(assetServiceProvider).getAspectRatio(asset);
} catch (error) {
log.severe(
'Error getting aspect ratio for asset ${asset.fileName}: $error',
);
}
},
);
void checkIfBuffering() {
if (!context.mounted) {
return;
}
final videoPlayback = ref.read(videoPlaybackValueProvider);
if ((isBuffering.value ||
videoPlayback.state == VideoPlaybackState.initializing) &&
videoPlayback.state != VideoPlaybackState.buffering) {
ref.read(videoPlaybackValueProvider.notifier).value =
videoPlayback.copyWith(state: VideoPlaybackState.buffering);
}
}
// Timer to mark videos as buffering if the position does not change
useInterval(const Duration(seconds: 5), checkIfBuffering);
// When the volume changes, set the volume
ref.listen(videoPlayerControlsProvider.select((value) => value.mute),
(_, mute) async {
final playerController = controller.value;
if (playerController == null) {
return;
}
final playbackInfo = playerController.playbackInfo;
if (playbackInfo == null) {
return;
}
try {
if (mute && playbackInfo.volume != 0.0) {
await playerController.setVolume(0.0);
} else if (!mute && playbackInfo.volume != 0.9) {
await playerController.setVolume(0.9);
}
} catch (error) {
log.severe('Error setting volume: $error');
}
});
// When the position changes, seek to the position
// Debounce the seek to avoid seeking too often
// But also don't delay the seek too much to maintain visual feedback
final seekDebouncer = useDebouncer(
interval: const Duration(milliseconds: 100),
maxWaitTime: const Duration(milliseconds: 200),
);
Future<void> onPlayerControlsPlayChange(bool? _, bool pause) async {
final videoController = controller.value;
if (videoController == null || !context.mounted) {
return;
}
// Make sure the last seek is complete before pausing or playing
// Otherwise, `onPlaybackPositionChanged` can receive outdated events
if (seekDebouncer.isActive) {
await seekDebouncer.drain();
}
try {
if (pause) {
await videoController.pause();
} else {
await videoController.play();
}
} catch (error) {
log.severe('Error pausing or playing video: $error');
}
}
ref.listen(videoPlayerControlsProvider.select((value) => value.position),
(_, position) {
final playerController = controller.value;
if (playerController == null) {
return;
}
final playbackInfo = playerController.playbackInfo;
if (playbackInfo == null) {
return;
}
// Find the position to seek to
final seek = position ~/ 1;
if (seek != playbackInfo.position) {
seekDebouncer.run(() => playerController.seekTo(seek));
}
if (Platform.isIOS &&
seek == 0 &&
!ref.read(videoPlayerControlsProvider.notifier).paused) {
onPlayerControlsPlayChange(null, false);
}
});
// // When the custom video controls pause or play
ref.listen(
videoPlayerControlsProvider.select((value) => value.pause),
onPlayerControlsPlayChange,
);
void onPlaybackReady() async {
final videoController = controller.value;
if (videoController == null || !isCurrent || !context.mounted) {
return;
}
final videoPlayback =
VideoPlaybackValue.fromNativeController(videoController);
ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback;
try {
if (asset.isVideo || showMotionVideo.value) {
await videoController.play();
}
await videoController.setVolume(0.9);
} catch (error) {
log.severe('Error playing video: $error');
}
}
void onPlaybackStatusChanged() {
final videoController = controller.value;
if (videoController == null || !context.mounted) {
return;
}
final videoPlayback =
VideoPlaybackValue.fromNativeController(videoController);
if (videoPlayback.state == VideoPlaybackState.playing) {
// Sync with the controls playing
WakelockPlus.enable();
} else {
// Sync with the controls pause
WakelockPlus.disable();
}
ref.read(videoPlaybackValueProvider.notifier).status =
videoPlayback.state;
}
void onPlaybackPositionChanged() {
// When seeking, these events sometimes move the slider to an older position
if (seekDebouncer.isActive) {
return;
}
final videoController = controller.value;
if (videoController == null || !context.mounted) {
return;
}
final playbackInfo = videoController.playbackInfo;
if (playbackInfo == null) {
return;
}
ref.read(videoPlaybackValueProvider.notifier).position =
Duration(seconds: playbackInfo.position);
// Check if the video is buffering
if (playbackInfo.status == PlaybackStatus.playing) {
isBuffering.value = lastVideoPosition.value == playbackInfo.position;
lastVideoPosition.value = playbackInfo.position;
} else {
isBuffering.value = false;
lastVideoPosition.value = -1;
}
}
void removeListeners(NativeVideoPlayerController controller) {
controller.onPlaybackPositionChanged
.removeListener(onPlaybackPositionChanged);
controller.onPlaybackStatusChanged
.removeListener(onPlaybackStatusChanged);
controller.onPlaybackReady.removeListener(onPlaybackReady);
}
void initController(NativeVideoPlayerController nc) async {
if (controller.value != null || !context.mounted) {
return;
}
ref.read(videoPlayerControlsProvider.notifier).reset();
ref.read(videoPlaybackValueProvider.notifier).reset();
final source = await videoSource;
if (source == null) {
return;
}
nc.onPlaybackPositionChanged.addListener(onPlaybackPositionChanged);
nc.onPlaybackStatusChanged.addListener(onPlaybackStatusChanged);
nc.onPlaybackReady.addListener(onPlaybackReady);
nc.loadVideoSource(source).catchError((error) {
log.severe('Error loading video source: $error');
});
final loopVideo = ref
.read(appSettingsServiceProvider)
.getSetting<bool>(AppSettingsEnum.loopVideo);
nc.setLoop(loopVideo);
controller.value = nc;
Timer(const Duration(milliseconds: 200), checkIfBuffering);
}
ref.listen(currentAssetProvider, (_, value) {
final playerController = controller.value;
if (playerController != null && value != asset) {
removeListeners(playerController);
}
final curAsset = currentAsset.value;
if (curAsset == asset) {
return;
}
// No need to delay video playback when swiping from an image to a video
if (curAsset != null && !curAsset.isVideo) {
currentAsset.value = value;
onPlaybackReady();
return;
}
// Delay the video playback to avoid a stutter in the swipe animation
Timer(const Duration(milliseconds: 300), () {
if (!context.mounted) {
return;
}
currentAsset.value = value;
if (currentAsset.value == asset) {
onPlaybackReady();
}
});
});
useEffect(
() {
// If opening a remote video from a hero animation, delay visibility to avoid a stutter
final timer = isVisible.value
? null
: Timer(
const Duration(milliseconds: 300),
() => isVisible.value = true,
);
return () {
timer?.cancel();
final playerController = controller.value;
if (playerController == null) {
return;
}
removeListeners(playerController);
playerController.stop().catchError((error) {
log.severe('Error stopping video: $error');
});
WakelockPlus.disable();
};
},
const [],
);
return Stack(
children: [
// This remains under the video to avoid flickering
// For motion videos, this is the image portion of the asset
Center(key: ValueKey(asset.id), child: image),
if (aspectRatio.value != null)
Visibility.maintain(
key: ValueKey(asset),
visible:
(asset.isVideo || showMotionVideo.value) && isVisible.value,
child: Center(
key: ValueKey(asset),
child: AspectRatio(
key: ValueKey(asset),
aspectRatio: aspectRatio.value!,
child: isCurrent
? NativeVideoPlayerView(
key: ValueKey(asset),
onViewReady: initController,
)
: null,
),
),
),
if (showControls) const Center(child: CustomVideoPlayerControls()),
],
);
}
}

View File

@@ -0,0 +1,168 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_controller_provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
import 'package:immich_mobile/widgets/asset_viewer/video_player.dart';
import 'package:immich_mobile/widgets/common/delayed_loading_indicator.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
class VideoViewerPage extends HookConsumerWidget {
final Asset asset;
final bool isMotionVideo;
final Widget? placeholder;
final Duration hideControlsTimer;
final bool showControls;
final bool showDownloadingIndicator;
final bool loopVideo;
const VideoViewerPage({
super.key,
required this.asset,
this.isMotionVideo = false,
this.placeholder,
this.showControls = true,
this.hideControlsTimer = const Duration(seconds: 5),
this.showDownloadingIndicator = true,
this.loopVideo = false,
});
@override
build(BuildContext context, WidgetRef ref) {
final controller =
ref.watch(videoPlayerControllerProvider(asset: asset)).value;
// The last volume of the video used when mute is toggled
final lastVolume = useState(0.5);
// When the volume changes, set the volume
ref.listen(videoPlayerControlsProvider.select((value) => value.mute),
(_, mute) {
if (mute) {
controller?.setVolume(0.0);
} else {
controller?.setVolume(lastVolume.value);
}
});
// When the position changes, seek to the position
ref.listen(videoPlayerControlsProvider.select((value) => value.position),
(_, position) {
if (controller == null) {
// No seeeking if there is no video
return;
}
// Find the position to seek to
final Duration seek = controller.value.duration * (position / 100.0);
controller.seekTo(seek);
});
// When the custom video controls paus or plays
ref.listen(videoPlayerControlsProvider.select((value) => value.pause),
(lastPause, pause) {
if (pause) {
controller?.pause();
} else {
controller?.play();
}
});
// Updates the [videoPlaybackValueProvider] with the current
// position and duration of the video from the Chewie [controller]
// Also sets the error if there is an error in the playback
void updateVideoPlayback() {
final videoPlayback = VideoPlaybackValue.fromController(controller);
ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback;
final state = videoPlayback.state;
// Enable the WakeLock while the video is playing
if (state == VideoPlaybackState.playing) {
// Sync with the controls playing
WakelockPlus.enable();
} else {
// Sync with the controls pause
WakelockPlus.disable();
}
}
// Adds and removes the listener to the video player
useEffect(
() {
Future.microtask(
() => ref.read(videoPlayerControlsProvider.notifier).reset(),
);
// Guard no controller
if (controller == null) {
return null;
}
// Hide the controls
// Done in a microtask to avoid setting the state while the is building
if (!isMotionVideo) {
Future.microtask(() {
ref.read(showControlsProvider.notifier).show = false;
});
}
// Subscribes to listener
Future.microtask(() {
controller.addListener(updateVideoPlayback);
});
return () {
// Removes listener when we dispose
controller.removeListener(updateVideoPlayback);
controller.pause();
};
},
[controller],
);
final size = MediaQuery.sizeOf(context);
return PopScope(
onPopInvokedWithResult: (didPop, _) {
ref.read(videoPlaybackValueProvider.notifier).value =
VideoPlaybackValue.uninitialized();
},
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 400),
child: Stack(
children: [
Visibility(
visible: controller == null,
child: Stack(
children: [
if (placeholder != null) placeholder!,
const Positioned.fill(
child: Center(
child: DelayedLoadingIndicator(
fadeInDuration: Duration(milliseconds: 500),
),
),
),
],
),
),
if (controller != null)
SizedBox(
height: size.height,
width: size.width,
child: VideoPlayerViewer(
controller: controller,
isMotionVideo: isMotionVideo,
placeholder: placeholder,
hideControlsTimer: hideControlsTimer,
showControls: showControls,
showDownloadingIndicator: showDownloadingIndicator,
loopVideo: loopVideo,
),
),
],
),
),
);
}
}

View File

@@ -113,15 +113,11 @@ class MemoryPage extends HookConsumerWidget {
}
// Precache the asset
final size = MediaQuery.sizeOf(context);
await precacheImage(
ImmichImage.imageProvider(
asset: asset,
width: size.width,
height: size.height,
),
context,
size: size,
);
}

View File

@@ -15,8 +15,6 @@ 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';
@@ -101,11 +99,8 @@ class MapPage extends HookConsumerWidget {
useEffect(
() {
final currentAssetLink =
ref.read(currentAssetProvider.notifier).ref.keepAlive();
loadMarkers();
return currentAssetLink.close;
return null;
},
[],
);
@@ -191,10 +186,6 @@ class MapPage extends HookConsumerWidget {
GroupAssetsBy.none,
);
ref.read(currentAssetProvider.notifier).set(asset);
if (asset.isVideo) {
ref.read(showControlsProvider.notifier).show = false;
}
context.pushRoute(
GalleryViewerRoute(
initialIndex: 0,

View File

@@ -187,7 +187,7 @@ class SearchPage extends HookConsumerWidget {
showFilterBottomSheet(
context: context,
isScrollControlled: true,
isDismissible: false,
isDismissible: true,
child: FilterBottomSheetScaffold(
title: 'search_filter_location_title'.tr(),
onSearch: search,
@@ -238,7 +238,7 @@ class SearchPage extends HookConsumerWidget {
showFilterBottomSheet(
context: context,
isScrollControlled: true,
isDismissible: false,
isDismissible: true,
child: FilterBottomSheetScaffold(
title: 'search_filter_camera_title'.tr(),
onSearch: search,

View File

@@ -7,49 +7,49 @@ import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'asset_stack.provider.g.dart';
class AssetStackNotifier extends StateNotifier<List<Asset>> {
final String _stackId;
final Asset _asset;
final Ref _ref;
AssetStackNotifier(this._stackId, this._ref) : super([]) {
_fetchStack(_stackId);
AssetStackNotifier(
this._asset,
this._ref,
) : super([]) {
fetchStackChildren();
}
void _fetchStack(String stackId) async {
if (!mounted) {
return;
}
final stack = await _ref.read(assetStackProvider(stackId).future);
if (stack.isNotEmpty) {
state = stack;
void fetchStackChildren() async {
if (mounted) {
state = await _ref.read(assetStackProvider(_asset).future);
}
}
void removeChild(int index) {
if (index < state.length) {
state.removeAt(index);
state = List<Asset>.from(state);
}
}
}
final assetStackStateProvider = StateNotifierProvider.autoDispose
.family<AssetStackNotifier, List<Asset>, String>(
(ref, stackId) => AssetStackNotifier(stackId, ref),
.family<AssetStackNotifier, List<Asset>, Asset>(
(ref, asset) => AssetStackNotifier(asset, ref),
);
final assetStackProvider =
FutureProvider.autoDispose.family<List<Asset>, String>((ref, stackId) {
return ref
FutureProvider.autoDispose.family<List<Asset>, Asset>((ref, asset) async {
// Guard [local asset]
if (asset.remoteId == null) {
return [];
}
return await ref
.watch(dbProvider)
.assets
.filter()
.isArchivedEqualTo(false)
.isTrashedEqualTo(false)
.stackIdEqualTo(stackId)
// orders primary asset first as its ID is null
.sortByStackPrimaryAssetId()
.thenByFileCreatedAtDesc()
.stackPrimaryAssetIdEqualTo(asset.remoteId)
.sortByFileCreatedAtDesc()
.findAll();
});

View File

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

View File

@@ -0,0 +1,46 @@
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:video_player/video_player.dart';
part 'video_player_controller_provider.g.dart';
@riverpod
Future<VideoPlayerController> videoPlayerController(
VideoPlayerControllerRef ref, {
required Asset asset,
}) async {
late VideoPlayerController controller;
if (asset.isLocal && asset.livePhotoVideoId == null) {
// Use a local file for the video player controller
final file = await asset.local!.file;
if (file == null) {
throw Exception('No file found for the video');
}
controller = VideoPlayerController.file(file);
} else {
// Use a network URL for the video player controller
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
final String videoUrl = asset.livePhotoVideoId != null
? '$serverEndpoint/assets/${asset.livePhotoVideoId}/video/playback'
: '$serverEndpoint/assets/${asset.remoteId}/video/playback';
final url = Uri.parse(videoUrl);
controller = VideoPlayerController.networkUrl(
url,
httpHeaders: ApiService.getRequestHeaders(),
videoPlayerOptions: asset.livePhotoVideoId != null
? VideoPlayerOptions(mixWithOthers: true)
: VideoPlayerOptions(mixWithOthers: false),
);
}
await controller.initialize();
ref.onDispose(() {
controller.dispose();
});
return controller;
}

View File

@@ -0,0 +1,164 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'video_player_controller_provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$videoPlayerControllerHash() =>
r'84b2961cc2aeaf9d03255dbf9b9484619d0c24f5';
/// Copied from Dart SDK
class _SystemHash {
_SystemHash._();
static int combine(int hash, int value) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + value);
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10));
return hash ^ (hash >> 6);
}
static int finish(int hash) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3));
// ignore: parameter_assignments
hash = hash ^ (hash >> 11);
return 0x1fffffff & (hash + ((0x00003fff & hash) << 15));
}
}
/// See also [videoPlayerController].
@ProviderFor(videoPlayerController)
const videoPlayerControllerProvider = VideoPlayerControllerFamily();
/// See also [videoPlayerController].
class VideoPlayerControllerFamily
extends Family<AsyncValue<VideoPlayerController>> {
/// See also [videoPlayerController].
const VideoPlayerControllerFamily();
/// See also [videoPlayerController].
VideoPlayerControllerProvider call({
required Asset asset,
}) {
return VideoPlayerControllerProvider(
asset: asset,
);
}
@override
VideoPlayerControllerProvider getProviderOverride(
covariant VideoPlayerControllerProvider provider,
) {
return call(
asset: provider.asset,
);
}
static const Iterable<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'videoPlayerControllerProvider';
}
/// See also [videoPlayerController].
class VideoPlayerControllerProvider
extends AutoDisposeFutureProvider<VideoPlayerController> {
/// See also [videoPlayerController].
VideoPlayerControllerProvider({
required Asset asset,
}) : this._internal(
(ref) => videoPlayerController(
ref as VideoPlayerControllerRef,
asset: asset,
),
from: videoPlayerControllerProvider,
name: r'videoPlayerControllerProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$videoPlayerControllerHash,
dependencies: VideoPlayerControllerFamily._dependencies,
allTransitiveDependencies:
VideoPlayerControllerFamily._allTransitiveDependencies,
asset: asset,
);
VideoPlayerControllerProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.asset,
}) : super.internal();
final Asset asset;
@override
Override overrideWith(
FutureOr<VideoPlayerController> Function(VideoPlayerControllerRef provider)
create,
) {
return ProviderOverride(
origin: this,
override: VideoPlayerControllerProvider._internal(
(ref) => create(ref as VideoPlayerControllerRef),
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
asset: asset,
),
);
}
@override
AutoDisposeFutureProviderElement<VideoPlayerController> createElement() {
return _VideoPlayerControllerProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is VideoPlayerControllerProvider && other.asset == asset;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, asset.hashCode);
return _SystemHash.finish(hash);
}
}
mixin VideoPlayerControllerRef
on AutoDisposeFutureProviderRef<VideoPlayerController> {
/// The parameter `asset` of this provider.
Asset get asset;
}
class _VideoPlayerControllerProviderElement
extends AutoDisposeFutureProviderElement<VideoPlayerController>
with VideoPlayerControllerRef {
_VideoPlayerControllerProviderElement(super.provider);
@override
Asset get asset => (origin as VideoPlayerControllerProvider).asset;
}
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member

View File

@@ -1,8 +1,7 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
class VideoPlaybackControls {
const VideoPlaybackControls({
VideoPlaybackControls({
required this.position,
required this.mute,
required this.pause,
@@ -18,14 +17,15 @@ final videoPlayerControlsProvider =
return VideoPlayerControls(ref);
});
const videoPlayerControlsDefault = VideoPlaybackControls(
position: 0,
pause: false,
mute: false,
);
class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
VideoPlayerControls(this.ref) : super(videoPlayerControlsDefault);
VideoPlayerControls(this.ref)
: super(
VideoPlaybackControls(
position: 0,
pause: false,
mute: false,
),
);
final Ref ref;
@@ -36,18 +36,17 @@ class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
}
void reset() {
state = videoPlayerControlsDefault;
state = VideoPlaybackControls(
position: 0,
pause: false,
mute: false,
);
}
double get position => state.position;
bool get mute => state.mute;
bool get paused => state.pause;
set position(double value) {
if (state.position == value) {
return;
}
state = VideoPlaybackControls(
position: value,
mute: state.mute,
@@ -56,10 +55,6 @@ class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
}
set mute(bool value) {
if (state.mute == value) {
return;
}
state = VideoPlaybackControls(
position: state.position,
mute: value,
@@ -76,10 +71,6 @@ class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
}
void pause() {
if (state.pause) {
return;
}
state = VideoPlaybackControls(
position: state.position,
mute: state.mute,
@@ -88,10 +79,6 @@ class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
}
void play() {
if (!state.pause) {
return;
}
state = VideoPlaybackControls(
position: state.position,
mute: state.mute,
@@ -108,11 +95,16 @@ class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
}
void restart() {
state = VideoPlaybackControls(
position: 0,
mute: state.mute,
pause: true,
);
state = VideoPlaybackControls(
position: 0,
mute: state.mute,
pause: false,
);
ref.read(videoPlaybackValueProvider.notifier).position = Duration.zero;
}
}

View File

@@ -1,5 +1,5 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:native_video_player/native_video_player.dart';
import 'package:video_player/video_player.dart';
enum VideoPlaybackState {
initializing,
@@ -22,66 +22,56 @@ class VideoPlaybackValue {
/// The volume of the video
final double volume;
const VideoPlaybackValue({
VideoPlaybackValue({
required this.position,
required this.duration,
required this.state,
required this.volume,
});
factory VideoPlaybackValue.fromNativeController(
NativeVideoPlayerController controller,
) {
final playbackInfo = controller.playbackInfo;
final videoInfo = controller.videoInfo;
if (playbackInfo == null || videoInfo == null) {
return videoPlaybackValueDefault;
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;
}
final VideoPlaybackState status = switch (playbackInfo.status) {
PlaybackStatus.playing => VideoPlaybackState.playing,
PlaybackStatus.paused => VideoPlaybackState.paused,
PlaybackStatus.stopped => VideoPlaybackState.completed,
};
return VideoPlaybackValue(
position: Duration(seconds: playbackInfo.position),
duration: Duration(seconds: videoInfo.duration),
state: status,
volume: playbackInfo.volume,
position: video?.position ?? Duration.zero,
duration: video?.duration ?? Duration.zero,
state: s,
volume: video?.volume ?? 0.0,
);
}
VideoPlaybackValue copyWith({
Duration? position,
Duration? duration,
VideoPlaybackState? state,
double? volume,
}) {
factory VideoPlaybackValue.uninitialized() {
return VideoPlaybackValue(
position: position ?? this.position,
duration: duration ?? this.duration,
state: state ?? this.state,
volume: volume ?? this.volume,
position: Duration.zero,
duration: Duration.zero,
state: VideoPlaybackState.initializing,
volume: 0.0,
);
}
}
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(videoPlaybackValueDefault);
VideoPlaybackValueState(this.ref)
: super(
VideoPlaybackValue.uninitialized(),
);
final Ref ref;
@@ -92,7 +82,6 @@ class VideoPlaybackValueState extends StateNotifier<VideoPlaybackValue> {
}
set position(Duration value) {
if (state.position == value) return;
state = VideoPlaybackValue(
position: value,
duration: state.duration,
@@ -100,18 +89,4 @@ class VideoPlaybackValueState extends StateNotifier<VideoPlaybackValue> {
volume: state.volume,
);
}
set status(VideoPlaybackState value) {
if (state.state == value) return;
state = VideoPlaybackValue(
position: state.position,
duration: state.duration,
state: value,
volume: state.volume,
);
}
void reset() {
state = videoPlaybackValueDefault;
}
}

View File

@@ -7,21 +7,14 @@ 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
@@ -49,57 +42,38 @@ class ImmichLocalImageProvider extends ImageProvider<ImmichLocalImageProvider> {
// Streams in each stage of the image as we ask for it
Stream<ui.Codec> _codec(
Asset asset,
Asset key,
ImageDecoderCallback decode,
StreamController<ImageChunkEvent> chunkEvents,
) async* {
ui.ImmutableBuffer? buffer;
try {
final local = asset.local;
if (local == null) {
throw StateError('Asset ${asset.fileName} has no local data');
}
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();
// 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");
}
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();
}
@override

View File

@@ -18,8 +18,8 @@ class ImmichLocalThumbnailProvider
ImmichLocalThumbnailProvider({
required this.asset,
this.height = 256,
this.width = 256,
this.height = 128,
this.width = 128,
}) : assert(asset.local != null, 'Only usable when asset.local is set');
/// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key

View File

@@ -1,5 +1,6 @@
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/providers/album/suggested_shared_users.provider.dart';
import 'package:immich_mobile/services/partner.service.dart';
@@ -9,9 +10,19 @@ import 'package:isar/isar.dart';
class PartnerSharedWithNotifier extends StateNotifier<List<User>> {
PartnerSharedWithNotifier(Isar db, this._ps) : super([]) {
final query = db.users.filter().isPartnerSharedWithEqualTo(true);
query.findAll().then((partners) => state = partners);
query.watch().listen((partners) => state = partners);
Function eq = const ListEquality<User>().equals;
final query = db.users.filter().isPartnerSharedWithEqualTo(true).sortById();
query.findAll().then((partners) {
if (!eq(state, partners)) {
state = partners;
}
}).then((_) {
query.watch().listen((partners) {
if (!eq(state, partners)) {
state = partners;
}
});
});
}
Future<bool> updatePartner(User partner, {required bool inTimeline}) {
@@ -31,9 +42,19 @@ final partnerSharedWithProvider =
class PartnerSharedByNotifier extends StateNotifier<List<User>> {
PartnerSharedByNotifier(Isar db) : super([]) {
final query = db.users.filter().isPartnerSharedByEqualTo(true);
query.findAll().then((partners) => state = partners);
streamSub = query.watch().listen((partners) => state = partners);
Function eq = const ListEquality<User>().equals;
final query = db.users.filter().isPartnerSharedByEqualTo(true).sortById();
query.findAll().then((partners) {
if (!eq(state, partners)) {
state = partners;
}
}).then((_) {
streamSub = query.watch().listen((partners) {
if (!eq(state, partners)) {
state = partners;
}
});
});
}
late final StreamSubscription<List<User>> streamSub;

View File

@@ -14,7 +14,6 @@ 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';
@@ -273,10 +272,6 @@ class AppRouter extends RootStackRouter {
guards: [_authGuard, _duplicateGuard],
transitionsBuilder: TransitionsBuilders.slideLeft,
),
AutoRoute(
page: NativeVideoViewerRoute.page,
guards: [_authGuard, _duplicateGuard],
),
];
}

View File

@@ -1079,64 +1079,6 @@ class MemoryRouteArgs {
}
}
/// generated route for
/// [NativeVideoViewerPage]
class NativeVideoViewerRoute extends PageRouteInfo<NativeVideoViewerRouteArgs> {
NativeVideoViewerRoute({
Key? key,
required Asset asset,
required Widget image,
bool showControls = true,
List<PageRouteInfo>? children,
}) : super(
NativeVideoViewerRoute.name,
args: NativeVideoViewerRouteArgs(
key: key,
asset: asset,
image: image,
showControls: showControls,
),
initialChildren: children,
);
static const String name = 'NativeVideoViewerRoute';
static PageInfo page = PageInfo(
name,
builder: (data) {
final args = data.argsAs<NativeVideoViewerRouteArgs>();
return NativeVideoViewerPage(
key: args.key,
asset: args.asset,
image: args.image,
showControls: args.showControls,
);
},
);
}
class NativeVideoViewerRouteArgs {
const NativeVideoViewerRouteArgs({
this.key,
required this.asset,
required this.image,
this.showControls = true,
});
final Key? key;
final Asset asset;
final Widget image;
final bool showControls;
@override
String toString() {
return 'NativeVideoViewerRouteArgs{key: $key, asset: $asset, image: $image, showControls: $showControls}';
}
}
/// generated route for
/// [PartnerDetailPage]
class PartnerDetailRoute extends PageRouteInfo<PartnerDetailRouteArgs> {

View File

@@ -76,10 +76,16 @@ class AlbumService {
final Stopwatch sw = Stopwatch()..start();
bool changes = false;
try {
final List<String> excludedIds = await _backupAlbumRepository
.getIdsBySelection(BackupSelection.exclude);
final List<String> selectedIds = await _backupAlbumRepository
.getIdsBySelection(BackupSelection.select);
final (selectedIds, excludedIds, onDevice) = await (
_backupAlbumRepository
.getIdsBySelection(BackupSelection.select)
.then((value) => value.toSet()),
_backupAlbumRepository
.getIdsBySelection(BackupSelection.exclude)
.then((value) => value.toSet()),
_albumMediaRepository.getAll()
).wait;
_log.info("Found ${onDevice.length} device albums");
if (selectedIds.isEmpty) {
final numLocal = await _albumRepository.count(local: true);
if (numLocal > 0) {
@@ -87,8 +93,6 @@ class AlbumService {
}
return false;
}
final List<Album> onDevice = await _albumMediaRepository.getAll();
_log.info("Found ${onDevice.length} device albums");
Set<String>? excludedAssets;
if (excludedIds.isNotEmpty) {
if (Platform.isIOS) {
@@ -108,22 +112,19 @@ class AlbumService {
"Ignoring ${excludedIds.length} excluded albums resulting in ${onDevice.length} device albums",
);
}
final hasAll = selectedIds
.map(
(id) => onDevice.firstWhereOrNull((album) => album.localId == id),
)
.whereNotNull()
.any((a) => a.isAll);
final allAlbum = onDevice.firstWhereOrNull((album) => album.isAll);
final hasAll = allAlbum != null && selectedIds.contains(allAlbum.localId);
if (hasAll) {
if (Platform.isAndroid) {
// remove the virtual "Recent" album and keep and individual albums
// on Android, the virtual "Recent" `lastModified` value is always null
onDevice.removeWhere((e) => e.isAll);
onDevice.removeWhere((album) => album.isAll);
_log.info("'Recents' is selected, keeping all individual albums");
}
} else {
// keep only the explicitly selected albums
onDevice.removeWhere((e) => !selectedIds.contains(e.localId));
onDevice.removeWhere((album) => !selectedIds.contains(album.localId));
_log.info("'Recents' is not selected, keeping only selected albums");
}
changes =
@@ -138,15 +139,19 @@ class AlbumService {
Future<Set<String>> _loadExcludedAssetIds(
List<Album> albums,
List<String> excludedAlbumIds,
Set<String> excludedAlbumIds,
) async {
final Set<String> result = HashSet<String>();
for (Album album in albums) {
if (excludedAlbumIds.contains(album.localId)) {
final assetIds =
await _albumMediaRepository.getAssetIds(album.localId!);
result.addAll(assetIds);
}
for (final batchAlbums in albums
.where((album) => excludedAlbumIds.contains(album.localId))
.slices(5)) {
await batchAlbums
.map(
(album) => _albumMediaRepository
.getAssetIds(album.localId!)
.then((assetIds) => result.addAll(assetIds)),
)
.wait;
}
return result;
}
@@ -163,11 +168,10 @@ class AlbumService {
bool changes = false;
try {
await _userService.refreshUsers();
final List<Album> sharedAlbum =
await _albumApiRepository.getAll(shared: true);
final List<Album> ownedAlbum =
await _albumApiRepository.getAll(shared: null);
final (sharedAlbum, ownedAlbum) = await (
_albumApiRepository.getAll(shared: true),
_albumApiRepository.getAll(shared: null)
).wait;
final albums = HashSet<Album>(
equals: (a, b) => a.remoteId == b.remoteId,

View File

@@ -1,5 +1,4 @@
import 'dart:async';
import 'dart:io';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
@@ -403,29 +402,4 @@ class AssetService {
return exifInfo?.description ?? "";
}
Future<double> getAspectRatio(Asset asset) async {
// platform_manager always returns 0 for orientation on iOS, so only prefer it on Android
if (asset.isLocal && Platform.isAndroid) {
await asset.localAsync;
} else if (asset.isRemote) {
asset = await loadExif(asset);
} else if (asset.isLocal) {
await asset.localAsync;
}
final aspectRatio = asset.aspectRatio;
if (aspectRatio != null) {
return aspectRatio;
}
final width = asset.width;
final height = asset.height;
if (width != null && height != null) {
// we don't know the orientation, so assume it's normal
return width / height;
}
return 1.0;
}
}

View File

@@ -3,52 +3,20 @@ 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, this.maxWaitTime});
Debouncer({required this.interval});
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() {
_lastActionTime = DateTime.now();
final action = _lastAction;
_lastAction = null;
final result = action!();
if (result is Future) {
_actionFuture = result.whenComplete(() {
_actionFuture = null;
});
}
_lastAction?.call();
_timer = null;
}
@@ -56,48 +24,31 @@ 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,
maxWaitTime: maxWaitTime,
keys: keys,
),
);
use(_DebouncerHook(interval: interval, 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,
maxWaitTime: hook.maxWaitTime,
);
late final debouncer = Debouncer(interval: hook.interval);
@override
Debouncer build(_) => debouncer;

View File

@@ -0,0 +1,161 @@
import 'package:chewie/chewie.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:video_player/video_player.dart';
/// Provides the initialized video player controller
/// If the asset is local, use the local file
/// Otherwise, use a video player with a URL
ChewieController useChewieController({
required VideoPlayerController controller,
EdgeInsets controlsSafeAreaMinimum = const EdgeInsets.only(
bottom: 100,
),
bool showOptions = true,
bool showControlsOnInitialize = false,
bool autoPlay = true,
bool allowFullScreen = false,
bool allowedScreenSleep = false,
bool showControls = true,
bool loopVideo = false,
Widget? customControls,
Widget? placeholder,
Duration hideControlsTimer = const Duration(seconds: 1),
VoidCallback? onPlaying,
VoidCallback? onPaused,
VoidCallback? onVideoEnded,
}) {
return use(
_ChewieControllerHook(
controller: controller,
placeholder: placeholder,
showOptions: showOptions,
controlsSafeAreaMinimum: controlsSafeAreaMinimum,
autoPlay: autoPlay,
allowFullScreen: allowFullScreen,
customControls: customControls,
hideControlsTimer: hideControlsTimer,
showControlsOnInitialize: showControlsOnInitialize,
showControls: showControls,
loopVideo: loopVideo,
allowedScreenSleep: allowedScreenSleep,
onPlaying: onPlaying,
onPaused: onPaused,
onVideoEnded: onVideoEnded,
),
);
}
class _ChewieControllerHook extends Hook<ChewieController> {
final VideoPlayerController controller;
final EdgeInsets controlsSafeAreaMinimum;
final bool showOptions;
final bool showControlsOnInitialize;
final bool autoPlay;
final bool allowFullScreen;
final bool allowedScreenSleep;
final bool showControls;
final bool loopVideo;
final Widget? customControls;
final Widget? placeholder;
final Duration hideControlsTimer;
final VoidCallback? onPlaying;
final VoidCallback? onPaused;
final VoidCallback? onVideoEnded;
const _ChewieControllerHook({
required this.controller,
this.controlsSafeAreaMinimum = const EdgeInsets.only(
bottom: 100,
),
this.showOptions = true,
this.showControlsOnInitialize = false,
this.autoPlay = true,
this.allowFullScreen = false,
this.allowedScreenSleep = false,
this.showControls = true,
this.loopVideo = false,
this.customControls,
this.placeholder,
this.hideControlsTimer = const Duration(seconds: 3),
this.onPlaying,
this.onPaused,
this.onVideoEnded,
});
@override
createState() => _ChewieControllerHookState();
}
class _ChewieControllerHookState
extends HookState<ChewieController, _ChewieControllerHook> {
late ChewieController chewieController = ChewieController(
videoPlayerController: hook.controller,
controlsSafeAreaMinimum: hook.controlsSafeAreaMinimum,
showOptions: hook.showOptions,
showControlsOnInitialize: hook.showControlsOnInitialize,
autoPlay: hook.autoPlay,
allowFullScreen: hook.allowFullScreen,
allowedScreenSleep: hook.allowedScreenSleep,
showControls: hook.showControls,
looping: hook.loopVideo,
customControls: hook.customControls,
placeholder: hook.placeholder,
hideControlsTimer: hook.hideControlsTimer,
);
@override
void dispose() {
chewieController.dispose();
super.dispose();
}
@override
ChewieController build(BuildContext context) {
return chewieController;
}
/*
/// Initializes the chewie controller and video player controller
Future<void> _initialize() async {
if (hook.asset.isLocal && hook.asset.livePhotoVideoId == null) {
// Use a local file for the video player controller
final file = await hook.asset.local!.file;
if (file == null) {
throw Exception('No file found for the video');
}
videoPlayerController = VideoPlayerController.file(file);
} else {
// Use a network URL for the video player controller
final serverEndpoint = store.Store.get(store.StoreKey.serverEndpoint);
final String videoUrl = hook.asset.livePhotoVideoId != null
? '$serverEndpoint/assets/${hook.asset.livePhotoVideoId}/video/playback'
: '$serverEndpoint/assets/${hook.asset.remoteId}/video/playback';
final url = Uri.parse(videoUrl);
final accessToken = store.Store.get(StoreKey.accessToken);
videoPlayerController = VideoPlayerController.networkUrl(
url,
httpHeaders: {"x-immich-user-token": accessToken},
);
}
await videoPlayerController!.initialize();
chewieController = ChewieController(
videoPlayerController: videoPlayerController!,
controlsSafeAreaMinimum: hook.controlsSafeAreaMinimum,
showOptions: hook.showOptions,
showControlsOnInitialize: hook.showControlsOnInitialize,
autoPlay: hook.autoPlay,
allowFullScreen: hook.allowFullScreen,
allowedScreenSleep: hook.allowedScreenSleep,
showControls: hook.showControls,
customControls: hook.customControls,
placeholder: hook.placeholder,
hideControlsTimer: hook.hideControlsTimer,
);
}
*/
}

View File

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

View File

@@ -4,7 +4,7 @@ import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/utils/db.dart';
import 'package:isar/isar.dart';
const int targetVersion = 7;
const int targetVersion = 6;
Future<void> migrateDatabaseIfNeeded(Isar db) async {
final int version = Store.get(StoreKey.version, 1);

View File

@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter_hooks/flutter_hooks.dart';
/// Throttles function calls with the [interval] provided.
@@ -8,15 +10,12 @@ class Throttler {
Throttler({required this.interval});
T? run<T>(T Function() action) {
void run(FutureOr<void> Function() action) {
if (_lastActionTime == null ||
(DateTime.now().difference(_lastActionTime!) > interval)) {
final response = action();
action();
_lastActionTime = DateTime.now();
return response;
}
return null;
}
void dispose() {

View File

@@ -12,9 +12,7 @@ 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';
@@ -91,7 +89,6 @@ 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;
@@ -204,12 +201,6 @@ 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;
}
},
);
}
@@ -357,7 +348,6 @@ class ImmichAssetGridViewState extends ConsumerState<ImmichAssetGridView> {
@override
void initState() {
super.initState();
currentAssetLink = ref.read(currentAssetProvider.notifier).ref.keepAlive();
scrollToTopNotifierProvider.addListener(_scrollToTop);
scrollToDateNotifierProvider.addListener(_scrollToDate);
@@ -379,7 +369,6 @@ class ImmichAssetGridViewState extends ConsumerState<ImmichAssetGridView> {
_itemPositionsListener.itemPositions.removeListener(_positionListener);
}
_itemPositionsListener.itemPositions.removeListener(_hapticsListener);
currentAssetLink.close();
super.dispose();
}
@@ -606,13 +595,12 @@ class _Section extends StatelessWidget {
final RenderList renderList;
final bool selectionActive;
final bool dynamicLayout;
final void Function(List<Asset>) selectAssets;
final void Function(List<Asset>) deselectAssets;
final Function(List<Asset>) selectAssets;
final 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,
@@ -630,7 +618,6 @@ class _Section extends StatelessWidget {
required this.showStack,
required this.heroOffset,
required this.showStorageIndicator,
required this.onAssetTap,
});
@override
@@ -696,7 +683,6 @@ class _Section extends StatelessWidget {
selectionActive: selectionActive,
onSelect: (asset) => selectAssets([asset]),
onDeselect: (asset) => deselectAssets([asset]),
onAssetTap: onAssetTap,
),
],
);
@@ -738,9 +724,9 @@ class _Title extends StatelessWidget {
final String title;
final List<Asset> assets;
final bool selectionActive;
final void Function(List<Asset>) selectAssets;
final void Function(List<Asset>) deselectAssets;
final bool Function(List<Asset>) allAssetsSelected;
final Function(List<Asset>) selectAssets;
final Function(List<Asset>) deselectAssets;
final Function(List<Asset>) allAssetsSelected;
const _Title({
required this.title,
@@ -779,9 +765,8 @@ class _AssetRow extends StatelessWidget {
final bool showStorageIndicator;
final int heroOffset;
final bool showStack;
final void Function(Asset) onAssetTap;
final void Function(Asset)? onSelect;
final void Function(Asset)? onDeselect;
final Function(Asset)? onSelect;
final Function(Asset)? onDeselect;
final bool isSelectionActive;
const _AssetRow({
@@ -801,7 +786,6 @@ class _AssetRow extends StatelessWidget {
required this.showStack,
required this.isSelectionActive,
required this.selectedAssets,
required this.onAssetTap,
this.onSelect,
this.onDeselect,
});
@@ -854,8 +838,6 @@ class _AssetRow extends StatelessWidget {
onSelect?.call(asset);
}
} else {
final asset = renderList.loadAsset(absoluteOffset + index);
onAssetTap(asset);
context.pushRoute(
GalleryViewerRoute(
renderList: renderList,

View File

@@ -5,11 +5,11 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/immich_colors.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/album/album.provider.dart';
import 'package:immich_mobile/providers/album/current_album.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_stack.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/download.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
import 'package:immich_mobile/services/stack.service.dart';
@@ -26,10 +26,12 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_mobile/pages/editing/edit.page.dart';
class BottomGalleryBar extends ConsumerWidget {
final Asset asset;
final ValueNotifier<int> assetIndex;
final bool showStack;
final int stackIndex;
final ValueNotifier<int> totalAssets;
final bool showVideoPlayerControls;
final PageController controller;
final RenderList renderList;
@@ -37,24 +39,20 @@ 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 && stackId != null
? ref.watch(assetStackStateProvider(stackId))
final stackItems = showStack && asset.stackCount > 0
? ref.watch(assetStackStateProvider(asset))
: <Asset>[];
bool isStackPrimaryAsset = asset.stackPrimaryAssetId == null;
final navStack = AutoRouter.of(context).stackData;
@@ -66,9 +64,9 @@ class BottomGalleryBar extends ConsumerWidget {
final isInAlbum = ref.watch(currentAlbumProvider)?.isRemote ?? false;
void removeAssetFromStack() {
if (stackIndex > 0 && showStack && stackId != null) {
if (stackIndex > 0 && showStack) {
ref
.read(assetStackStateProvider(stackId).notifier)
.read(assetStackStateProvider(asset).notifier)
.removeChild(stackIndex - 1);
}
}
@@ -137,7 +135,7 @@ class BottomGalleryBar extends ConsumerWidget {
await ref
.read(stackServiceProvider)
.deleteStack(asset.stackId!, stackItems);
.deleteStack(asset.stackId!, [asset, ...stackItems]);
}
void showStackActionItems() {
@@ -326,7 +324,7 @@ class BottomGalleryBar extends ConsumerWidget {
},
];
return IgnorePointer(
ignoring: !showControls,
ignoring: !ref.watch(showControlsProvider),
child: AnimatedOpacity(
duration: const Duration(milliseconds: 100),
opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0,
@@ -335,15 +333,15 @@ class BottomGalleryBar extends ConsumerWidget {
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [Colors.black, Colors.transparent],
colors: [blackOpacity90, Colors.transparent],
),
),
position: DecorationPosition.background,
child: Padding(
padding: const EdgeInsets.only(top: 40.0),
padding: EdgeInsets.only(top: 40.0),
child: Column(
children: [
if (asset.isVideo) const VideoControls(),
if (showVideoPlayerControls) const VideoControls(),
BottomNavigationBar(
elevation: 0.0,
backgroundColor: Colors.transparent,

View File

@@ -1,47 +1,38 @@
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: 5),
this.hideTimerDuration = const Duration(seconds: 3),
});
@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 &&
state != VideoPlaybackState.completed &&
assetIsVideo) {
if (state != VideoPlaybackState.paused) {
ref.read(showControlsProvider.notifier).show = false;
}
},
);
final showBuffering = state == VideoPlaybackState.buffering;
final showBuffering = useState(false);
final VideoPlaybackState state =
ref.watch(videoPlaybackValueProvider).state;
/// Shows the controls and starts the timer to hide them
void showControlsAndStartHideTimer() {
@@ -61,9 +52,16 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
showControlsAndStartHideTimer();
});
ref.listen(videoPlaybackValueProvider.select((value) => value.state),
(_, state) {
// Show buffering
showBuffering.value = state == VideoPlaybackState.buffering;
});
/// Toggles between playing and pausing depending on the state of the video
void togglePlay() {
showControlsAndStartHideTimer();
final state = ref.read(videoPlaybackValueProvider).state;
if (state == VideoPlaybackState.playing) {
ref.read(videoPlayerControlsProvider.notifier).pause();
} else if (state == VideoPlaybackState.completed) {
@@ -77,10 +75,10 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
behavior: HitTestBehavior.opaque,
onTap: showControlsAndStartHideTimer,
child: AbsorbPointer(
absorbing: !showControls,
absorbing: !ref.watch(showControlsProvider),
child: Stack(
children: [
if (showBuffering)
if (showBuffering.value)
const Center(
child: DelayedLoadingIndicator(
fadeInDuration: Duration(milliseconds: 400),
@@ -88,14 +86,18 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
)
else
GestureDetector(
onTap: () =>
ref.read(showControlsProvider.notifier).show = false,
onTap: () {
if (state != VideoPlaybackState.playing) {
togglePlay();
}
ref.read(showControlsProvider.notifier).show = false;
},
child: CenterPlayButton(
backgroundColor: Colors.black54,
iconColor: Colors.white,
isFinished: state == VideoPlaybackState.completed,
isPlaying: state == VideoPlaybackState.playing,
show: assetIsVideo && showControls,
show: ref.watch(showControlsProvider),
onPressed: togglePlay,
),
),

View File

@@ -15,10 +15,9 @@ class FileInfo extends StatelessWidget {
Widget build(BuildContext context) {
final textColor = context.isDarkTheme ? Colors.white : Colors.black;
final height = asset.orientatedHeight ?? asset.height;
final width = asset.orientatedWidth ?? asset.width;
String resolution =
height != null && width != null ? "$height x $width " : "";
String resolution = asset.width != null && asset.height != null
? "${asset.height} x ${asset.width} "
: "";
String fileSize = asset.exifInfo?.fileSize != null
? formatBytes(asset.exifInfo!.fileSize!)
: "";

View File

@@ -4,7 +4,6 @@ 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';
@@ -20,19 +19,23 @@ 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.showInfo});
const GalleryAppBar({
super.key,
required this.asset,
required this.showInfo,
required this.onToggleMotionVideo,
required this.isPlayingVideo,
});
@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)
@@ -95,21 +98,23 @@ class GalleryAppBar extends ConsumerWidget {
}
return IgnorePointer(
ignoring: !showControls,
ignoring: !ref.watch(showControlsProvider),
child: AnimatedOpacity(
duration: const Duration(milliseconds: 100),
opacity: showControls ? 1.0 : 0.0,
opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0,
child: Container(
color: Colors.black.withOpacity(0.4),
child: TopControlAppBar(
isOwner: isOwner,
isPartner: isPartner,
isPlayingMotionVideo: isPlayingVideo,
asset: asset,
onMoreInfoPressed: showInfo,
onFavorite: toggleFavorite,
onRestorePressed: () => handleRestore(asset),
onUploadPressed: asset.isLocal ? () => handleUpload(asset) : null,
onDownloadPressed: asset.isLocal ? null : handleDownloadAsset,
onToggleMotionVideo: onToggleMotionVideo,
onAddToAlbumPressed: () => addToAlbum(asset),
onActivitiesPressed: handleActivities,
),

View File

@@ -1,22 +0,0 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/immich_colors.dart';
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
class MotionPhotoButton extends ConsumerWidget {
const MotionPhotoButton({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isPlaying = ref.watch(isPlayingMotionVideoProvider);
return IconButton(
onPressed: () {
ref.read(isPlayingMotionVideoProvider.notifier).toggle();
},
icon: isPlaying
? const Icon(Icons.motion_photos_pause_outlined, color: grey200)
: const Icon(Icons.play_circle_outline_rounded, color: grey200),
);
}
}

View File

@@ -5,7 +5,6 @@ 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({
@@ -15,6 +14,8 @@ 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,
@@ -26,10 +27,12 @@ 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;
@@ -54,6 +57,23 @@ 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: () {
@@ -155,11 +175,13 @@ 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) const MotionPhotoButton(),
if (asset.livePhotoVideoId != null) buildLivePhotoButton(),
if (asset.isLocal && !asset.isRemote) buildUploadButton(),
if (asset.isRemote && !asset.isLocal && isOwner) buildDownloadButton(),
if (asset.isRemote && (isOwner || isPartner) && !asset.isTrashed)

View File

@@ -0,0 +1,48 @@
import 'package:chewie/chewie.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/utils/hooks/chewiew_controller_hook.dart';
import 'package:immich_mobile/widgets/asset_viewer/custom_video_player_controls.dart';
import 'package:video_player/video_player.dart';
class VideoPlayerViewer extends HookConsumerWidget {
final VideoPlayerController controller;
final bool isMotionVideo;
final Widget? placeholder;
final Duration hideControlsTimer;
final bool showControls;
final bool showDownloadingIndicator;
final bool loopVideo;
const VideoPlayerViewer({
super.key,
required this.controller,
required this.isMotionVideo,
this.placeholder,
required this.hideControlsTimer,
required this.showControls,
required this.showDownloadingIndicator,
required this.loopVideo,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final chewie = useChewieController(
controller: controller,
controlsSafeAreaMinimum: const EdgeInsets.only(
bottom: 100,
),
placeholder: SizedBox.expand(child: placeholder),
customControls: CustomVideoPlayerControls(
hideTimerDuration: hideControlsTimer,
),
showControls: showControls && !isMotionVideo,
hideControlsTimer: hideControlsTimer,
loopVideo: loopVideo,
);
return Chewie(
controller: chewie,
);
}
}

View File

@@ -56,16 +56,10 @@ class VideoPosition extends HookConsumerWidget {
ref.read(videoPlayerControlsProvider.notifier).play();
}
},
onChanged: (value) {
final inSeconds =
(duration * (value / 100.0)).inSeconds;
final position = inSeconds.toDouble();
onChanged: (position) {
ref
.read(videoPlayerControlsProvider.notifier)
.position = position;
// This immediately updates the slider position without waiting for the video to update
ref.read(videoPlaybackValueProvider.notifier).position =
Duration(seconds: inSeconds);
},
),
),

View File

@@ -259,6 +259,7 @@ class ImmichAppBarDialog extends HookConsumerWidget {
}
return Dismissible(
behavior: HitTestBehavior.translucent,
direction: DismissDirection.down,
onDismissed: (_) => Navigator.of(context).pop(),
key: const Key('app_bar_dialog'),

View File

@@ -28,11 +28,12 @@ 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');
@@ -47,8 +48,6 @@ class ImmichImage extends StatelessWidget {
if (useLocal(asset)) {
return ImmichLocalImageProvider(
asset: asset,
width: width,
height: height,
);
} else {
return ImmichRemoteImageProvider(
@@ -88,8 +87,6 @@ class ImmichImage extends StatelessWidget {
},
image: ImmichImage.imageProvider(
asset: asset,
width: context.width,
height: context.height,
),
width: width,
height: height,

View File

@@ -31,7 +31,7 @@ class ImmichThumbnail extends HookWidget {
static ImageProvider imageProvider({
Asset? asset,
String? assetId,
int thumbnailSize = 256,
int thumbnailSize = 128,
}) {
if (asset == null && assetId == null) {
throw Exception('Must supply either asset or assetId');

View File

@@ -2,9 +2,9 @@ import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:immich_mobile/entities/asset.entity.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/entities/asset.entity.dart';
import 'package:immich_mobile/pages/common/video_viewer.page.dart';
import 'package:immich_mobile/utils/hooks/blurhash_hook.dart';
import 'package:immich_mobile/widgets/common/immich_image.dart';
@@ -68,20 +68,18 @@ class MemoryCard extends StatelessWidget {
} else {
return Hero(
tag: 'memory-${asset.id}',
child: SizedBox(
width: context.width,
height: context.height,
child: NativeVideoViewerPage(
key: ValueKey(asset.id),
asset: asset,
showControls: false,
image: ImmichImage(
child: VideoViewerPage(
key: ValueKey(asset),
asset: asset,
showDownloadingIndicator: false,
placeholder: SizedBox.expand(
child: ImmichImage(
asset,
width: context.width,
height: context.height,
fit: fit,
),
),
hideControlsTimer: const Duration(seconds: 2),
showControls: false,
),
);
}
@@ -139,8 +137,6 @@ class _BlurredBackdrop extends HookWidget {
image: DecorationImage(
image: ImmichImage.imageProvider(
asset: asset,
height: context.height,
width: context.width,
),
fit: BoxFit.cover,
),

View File

@@ -378,10 +378,10 @@ packages:
dependency: "direct main"
description:
name: device_info_plus
sha256: f545ffbadee826f26f2e1a0f0cbd667ae9a6011cc0f77c0f8f00a969655e6e95
sha256: db03b2d2a3fa466a4627709e1db58692c3f7f658e36a5942d342d86efedc4091
url: "https://pub.dev"
source: hosted
version: "11.1.1"
version: "11.0.0"
device_info_plus_platform_interface:
dependency: transitive
description:
@@ -450,10 +450,10 @@ packages:
dependency: "direct main"
description:
name: file_picker
sha256: aac85f20436608e01a6ffd1fdd4e746a7f33c93a2c83752e626bdfaea139b877
sha256: "167bb619cdddaa10ef2907609feb8a79c16dfa479d3afaf960f8e223f754bf12"
url: "https://pub.dev"
source: hosted
version: "8.1.3"
version: "8.1.2"
file_selector_linux:
dependency: transitive
description:
@@ -548,10 +548,10 @@ packages:
dependency: "direct main"
description:
name: flutter_local_notifications
sha256: dd6676d8c2926537eccdf9f72128bbb2a9d0814689527b17f92c248ff192eaf3
sha256: "674173fd3c9eda9d4c8528da2ce0ea69f161577495a9cc835a2a4ecd7eadeb35"
url: "https://pub.dev"
source: hosted
version: "17.2.1+2"
version: "17.2.4"
flutter_local_notifications_linux:
dependency: transitive
description:
@@ -1024,15 +1024,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.4"
native_video_player:
dependency: "direct main"
description:
path: "."
ref: "68ea203"
resolved-ref: "68ea2030ba7aceb1bc44b683ff0b742fd1a52d2f"
url: "https://github.com/immich-app/native_video_player"
source: git
version: "1.3.1"
nested:
dependency: transitive
description:
@@ -1076,10 +1067,10 @@ packages:
dependency: "direct main"
description:
name: package_info_plus
sha256: da8d9ac8c4b1df253d1a328b7bf01ae77ef132833479ab40763334db13b91cce
sha256: "894f37107424311bdae3e476552229476777b8752c5a2a2369c0cb9a2d5442ef"
url: "https://pub.dev"
source: hosted
version: "8.1.1"
version: "8.0.3"
package_info_plus_platform_interface:
dependency: transitive
description:
@@ -1348,10 +1339,10 @@ packages:
dependency: "direct main"
description:
name: share_plus
sha256: "9c9bafd4060728d7cdb2464c341743adbd79d327cb067ec7afb64583540b47c8"
sha256: fec12c3c39f01e4df1ec6ad92b6e85503c5ca64ffd6e28d18c9ffe53fcc4cb11
url: "https://pub.dev"
source: hosted
version: "10.1.2"
version: "10.0.3"
share_plus_platform_interface:
dependency: transitive
description:

View File

@@ -45,7 +45,7 @@ dependencies:
path_provider: ^2.1.2
collection: ^1.18.0
http_parser: ^4.0.2
flutter_web_auth: 0.6.0
flutter_web_auth: ^0.6.0
easy_image_viewer: ^1.4.0
isar:
version: *isar_version
@@ -64,10 +64,6 @@ dependencies:
async: ^2.11.0
dynamic_color: ^1.7.0 #package to apply system theme
background_downloader: ^8.5.5
native_video_player:
git:
url: https://github.com/immich-app/native_video_player
ref: 68ea203
#image editing packages
crop_image: ^1.0.13

View File

@@ -54,6 +54,7 @@ void main() {
.thenAnswer((_) async => []);
when(() => backupRepository.getIdsBySelection(BackupSelection.select))
.thenAnswer((_) async => []);
when(() => albumMediaRepository.getAll()).thenAnswer((_) async => []);
when(() => albumRepository.count(local: true)).thenAnswer((_) async => 1);
when(() => syncService.removeAllLocalAlbumsAndAssets())
.thenAnswer((_) async => true);

View File

@@ -127,6 +127,7 @@ export class StackRepository implements IStackRepository {
relations: {
assets: {
exifInfo: true,
tags: true,
},
},
order: {

View File

@@ -46,7 +46,7 @@
<div class="flex h-10 w-full items-center justify-between text-sm">
<h2>{$t('tags').toUpperCase()}</h2>
</div>
<section class="flex flex-wrap pt-2 gap-1">
<section class="flex flex-wrap pt-2 gap-1" data-testid="detail-panel-tags">
{#each tags as tag (tag.id)}
<div class="flex group transition-all">
<a