mirror of
https://github.com/immich-app/immich
synced 2025-10-17 18:19:27 +00:00
Merge branch 'main' into cool-app-bar
This commit is contained in:
commit
b1baeb2f1e
49 changed files with 1148 additions and 241 deletions
|
|
@ -87,7 +87,8 @@ data class PlatformAsset (
|
||||||
val updatedAt: Long? = null,
|
val updatedAt: Long? = null,
|
||||||
val width: Long? = null,
|
val width: Long? = null,
|
||||||
val height: Long? = null,
|
val height: Long? = null,
|
||||||
val durationInSeconds: Long
|
val durationInSeconds: Long,
|
||||||
|
val orientation: Long
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
companion object {
|
companion object {
|
||||||
|
|
@ -100,7 +101,8 @@ data class PlatformAsset (
|
||||||
val width = pigeonVar_list[5] as Long?
|
val width = pigeonVar_list[5] as Long?
|
||||||
val height = pigeonVar_list[6] as Long?
|
val height = pigeonVar_list[6] as Long?
|
||||||
val durationInSeconds = pigeonVar_list[7] as Long
|
val durationInSeconds = pigeonVar_list[7] as Long
|
||||||
return PlatformAsset(id, name, type, createdAt, updatedAt, width, height, durationInSeconds)
|
val orientation = pigeonVar_list[8] as Long
|
||||||
|
return PlatformAsset(id, name, type, createdAt, updatedAt, width, height, durationInSeconds, orientation)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fun toList(): List<Any?> {
|
fun toList(): List<Any?> {
|
||||||
|
|
@ -113,6 +115,7 @@ data class PlatformAsset (
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
durationInSeconds,
|
durationInSeconds,
|
||||||
|
orientation,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
override fun equals(other: Any?): Boolean {
|
override fun equals(other: Any?): Boolean {
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,8 @@ open class NativeSyncApiImplBase(context: Context) {
|
||||||
MediaStore.MediaColumns.BUCKET_ID,
|
MediaStore.MediaColumns.BUCKET_ID,
|
||||||
MediaStore.MediaColumns.WIDTH,
|
MediaStore.MediaColumns.WIDTH,
|
||||||
MediaStore.MediaColumns.HEIGHT,
|
MediaStore.MediaColumns.HEIGHT,
|
||||||
MediaStore.MediaColumns.DURATION
|
MediaStore.MediaColumns.DURATION,
|
||||||
|
MediaStore.MediaColumns.ORIENTATION,
|
||||||
)
|
)
|
||||||
|
|
||||||
const val HASH_BUFFER_SIZE = 2 * 1024 * 1024
|
const val HASH_BUFFER_SIZE = 2 * 1024 * 1024
|
||||||
|
|
@ -74,6 +75,8 @@ open class NativeSyncApiImplBase(context: Context) {
|
||||||
val widthColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.WIDTH)
|
val widthColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.WIDTH)
|
||||||
val heightColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.HEIGHT)
|
val heightColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.HEIGHT)
|
||||||
val durationColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.DURATION)
|
val durationColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.DURATION)
|
||||||
|
val orientationColumn =
|
||||||
|
c.getColumnIndexOrThrow(MediaStore.MediaColumns.ORIENTATION)
|
||||||
|
|
||||||
while (c.moveToNext()) {
|
while (c.moveToNext()) {
|
||||||
val id = c.getLong(idColumn).toString()
|
val id = c.getLong(idColumn).toString()
|
||||||
|
|
@ -101,6 +104,7 @@ open class NativeSyncApiImplBase(context: Context) {
|
||||||
val duration = if (mediaType == MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE) 0
|
val duration = if (mediaType == MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE) 0
|
||||||
else c.getLong(durationColumn) / 1000
|
else c.getLong(durationColumn) / 1000
|
||||||
val bucketId = c.getString(bucketIdColumn)
|
val bucketId = c.getString(bucketIdColumn)
|
||||||
|
val orientation = c.getInt(orientationColumn)
|
||||||
|
|
||||||
val asset = PlatformAsset(
|
val asset = PlatformAsset(
|
||||||
id,
|
id,
|
||||||
|
|
@ -110,7 +114,8 @@ open class NativeSyncApiImplBase(context: Context) {
|
||||||
modifiedAt,
|
modifiedAt,
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
duration
|
duration,
|
||||||
|
orientation.toLong(),
|
||||||
)
|
)
|
||||||
yield(AssetResult.ValidAsset(asset, bucketId))
|
yield(AssetResult.ValidAsset(asset, bucketId))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
2
mobile/drift_schemas/main/drift_schema_v1.json
generated
2
mobile/drift_schemas/main/drift_schema_v1.json
generated
File diff suppressed because one or more lines are too long
|
|
@ -138,6 +138,7 @@ struct PlatformAsset: Hashable {
|
||||||
var width: Int64? = nil
|
var width: Int64? = nil
|
||||||
var height: Int64? = nil
|
var height: Int64? = nil
|
||||||
var durationInSeconds: Int64
|
var durationInSeconds: Int64
|
||||||
|
var orientation: Int64
|
||||||
|
|
||||||
|
|
||||||
// swift-format-ignore: AlwaysUseLowerCamelCase
|
// swift-format-ignore: AlwaysUseLowerCamelCase
|
||||||
|
|
@ -150,6 +151,7 @@ struct PlatformAsset: Hashable {
|
||||||
let width: Int64? = nilOrValue(pigeonVar_list[5])
|
let width: Int64? = nilOrValue(pigeonVar_list[5])
|
||||||
let height: Int64? = nilOrValue(pigeonVar_list[6])
|
let height: Int64? = nilOrValue(pigeonVar_list[6])
|
||||||
let durationInSeconds = pigeonVar_list[7] as! Int64
|
let durationInSeconds = pigeonVar_list[7] as! Int64
|
||||||
|
let orientation = pigeonVar_list[8] as! Int64
|
||||||
|
|
||||||
return PlatformAsset(
|
return PlatformAsset(
|
||||||
id: id,
|
id: id,
|
||||||
|
|
@ -159,7 +161,8 @@ struct PlatformAsset: Hashable {
|
||||||
updatedAt: updatedAt,
|
updatedAt: updatedAt,
|
||||||
width: width,
|
width: width,
|
||||||
height: height,
|
height: height,
|
||||||
durationInSeconds: durationInSeconds
|
durationInSeconds: durationInSeconds,
|
||||||
|
orientation: orientation
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
func toList() -> [Any?] {
|
func toList() -> [Any?] {
|
||||||
|
|
@ -172,6 +175,7 @@ struct PlatformAsset: Hashable {
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
durationInSeconds,
|
durationInSeconds,
|
||||||
|
orientation,
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
static func == (lhs: PlatformAsset, rhs: PlatformAsset) -> Bool {
|
static func == (lhs: PlatformAsset, rhs: PlatformAsset) -> Bool {
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,8 @@ extension PHAsset {
|
||||||
updatedAt: modificationDate.map { Int64($0.timeIntervalSince1970) },
|
updatedAt: modificationDate.map { Int64($0.timeIntervalSince1970) },
|
||||||
width: Int64(pixelWidth),
|
width: Int64(pixelWidth),
|
||||||
height: Int64(pixelHeight),
|
height: Int64(pixelHeight),
|
||||||
durationInSeconds: Int64(duration)
|
durationInSeconds: Int64(duration),
|
||||||
|
orientation: 0
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -169,7 +170,8 @@ class NativeSyncApiImpl: NativeSyncApi {
|
||||||
id: asset.localIdentifier,
|
id: asset.localIdentifier,
|
||||||
name: "",
|
name: "",
|
||||||
type: 0,
|
type: 0,
|
||||||
durationInSeconds: 0
|
durationInSeconds: 0,
|
||||||
|
orientation: 0
|
||||||
)
|
)
|
||||||
if (updatedAssets.contains(AssetWrapper(with: predicate))) {
|
if (updatedAssets.contains(AssetWrapper(with: predicate))) {
|
||||||
continue
|
continue
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ sealed class BaseAsset {
|
||||||
final int? height;
|
final int? height;
|
||||||
final int? durationInSeconds;
|
final int? durationInSeconds;
|
||||||
final bool isFavorite;
|
final bool isFavorite;
|
||||||
|
final String? livePhotoVideoId;
|
||||||
|
|
||||||
const BaseAsset({
|
const BaseAsset({
|
||||||
required this.name,
|
required this.name,
|
||||||
|
|
@ -36,18 +37,12 @@ sealed class BaseAsset {
|
||||||
this.height,
|
this.height,
|
||||||
this.durationInSeconds,
|
this.durationInSeconds,
|
||||||
this.isFavorite = false,
|
this.isFavorite = false,
|
||||||
|
this.livePhotoVideoId,
|
||||||
});
|
});
|
||||||
|
|
||||||
bool get isImage => type == AssetType.image;
|
bool get isImage => type == AssetType.image;
|
||||||
bool get isVideo => type == AssetType.video;
|
bool get isVideo => type == AssetType.video;
|
||||||
|
|
||||||
double? get aspectRatio {
|
|
||||||
if (width != null && height != null && height! > 0) {
|
|
||||||
return width! / height!;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool get hasRemote =>
|
bool get hasRemote =>
|
||||||
storage == AssetState.remote || storage == AssetState.merged;
|
storage == AssetState.remote || storage == AssetState.merged;
|
||||||
bool get hasLocal =>
|
bool get hasLocal =>
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ part of 'base_asset.model.dart';
|
||||||
class LocalAsset extends BaseAsset {
|
class LocalAsset extends BaseAsset {
|
||||||
final String id;
|
final String id;
|
||||||
final String? remoteId;
|
final String? remoteId;
|
||||||
|
final int orientation;
|
||||||
|
|
||||||
const LocalAsset({
|
const LocalAsset({
|
||||||
required this.id,
|
required this.id,
|
||||||
|
|
@ -16,6 +17,8 @@ class LocalAsset extends BaseAsset {
|
||||||
super.height,
|
super.height,
|
||||||
super.durationInSeconds,
|
super.durationInSeconds,
|
||||||
super.isFavorite = false,
|
super.isFavorite = false,
|
||||||
|
super.livePhotoVideoId,
|
||||||
|
this.orientation = 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -38,6 +41,7 @@ class LocalAsset extends BaseAsset {
|
||||||
durationInSeconds: ${durationInSeconds ?? "<NA>"},
|
durationInSeconds: ${durationInSeconds ?? "<NA>"},
|
||||||
remoteId: ${remoteId ?? "<NA>"}
|
remoteId: ${remoteId ?? "<NA>"}
|
||||||
isFavorite: $isFavorite,
|
isFavorite: $isFavorite,
|
||||||
|
orientation: $orientation,
|
||||||
}''';
|
}''';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -45,11 +49,12 @@ class LocalAsset extends BaseAsset {
|
||||||
bool operator ==(Object other) {
|
bool operator ==(Object other) {
|
||||||
if (other is! LocalAsset) return false;
|
if (other is! LocalAsset) return false;
|
||||||
if (identical(this, other)) return true;
|
if (identical(this, other)) return true;
|
||||||
return super == other && id == other.id && remoteId == other.remoteId;
|
return super == other && id == other.id && orientation == other.orientation;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode => super.hashCode ^ id.hashCode ^ remoteId.hashCode;
|
int get hashCode =>
|
||||||
|
super.hashCode ^ id.hashCode ^ remoteId.hashCode ^ orientation.hashCode;
|
||||||
|
|
||||||
LocalAsset copyWith({
|
LocalAsset copyWith({
|
||||||
String? id,
|
String? id,
|
||||||
|
|
@ -63,6 +68,7 @@ class LocalAsset extends BaseAsset {
|
||||||
int? height,
|
int? height,
|
||||||
int? durationInSeconds,
|
int? durationInSeconds,
|
||||||
bool? isFavorite,
|
bool? isFavorite,
|
||||||
|
int? orientation,
|
||||||
}) {
|
}) {
|
||||||
return LocalAsset(
|
return LocalAsset(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
|
|
@ -76,6 +82,7 @@ class LocalAsset extends BaseAsset {
|
||||||
height: height ?? this.height,
|
height: height ?? this.height,
|
||||||
durationInSeconds: durationInSeconds ?? this.durationInSeconds,
|
durationInSeconds: durationInSeconds ?? this.durationInSeconds,
|
||||||
isFavorite: isFavorite ?? this.isFavorite,
|
isFavorite: isFavorite ?? this.isFavorite,
|
||||||
|
orientation: orientation ?? this.orientation,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ class RemoteAsset extends BaseAsset {
|
||||||
super.isFavorite = false,
|
super.isFavorite = false,
|
||||||
this.thumbHash,
|
this.thumbHash,
|
||||||
this.visibility = AssetVisibility.timeline,
|
this.visibility = AssetVisibility.timeline,
|
||||||
|
super.livePhotoVideoId,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -65,7 +66,6 @@ class RemoteAsset extends BaseAsset {
|
||||||
return super == other &&
|
return super == other &&
|
||||||
id == other.id &&
|
id == other.id &&
|
||||||
ownerId == other.ownerId &&
|
ownerId == other.ownerId &&
|
||||||
localId == other.localId &&
|
|
||||||
thumbHash == other.thumbHash &&
|
thumbHash == other.thumbHash &&
|
||||||
visibility == other.visibility;
|
visibility == other.visibility;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ enum Setting<T> {
|
||||||
groupAssetsBy<int>(StoreKey.groupAssetsBy, 0),
|
groupAssetsBy<int>(StoreKey.groupAssetsBy, 0),
|
||||||
showStorageIndicator<bool>(StoreKey.storageIndicator, true),
|
showStorageIndicator<bool>(StoreKey.storageIndicator, true),
|
||||||
loadOriginal<bool>(StoreKey.loadOriginal, false),
|
loadOriginal<bool>(StoreKey.loadOriginal, false),
|
||||||
|
loadOriginalVideo<bool>(StoreKey.loadOriginalVideo, false),
|
||||||
preferRemoteImage<bool>(StoreKey.preferRemoteImage, false),
|
preferRemoteImage<bool>(StoreKey.preferRemoteImage, false),
|
||||||
advancedTroubleshooting<bool>(StoreKey.advancedTroubleshooting, false),
|
advancedTroubleshooting<bool>(StoreKey.advancedTroubleshooting, false),
|
||||||
;
|
;
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,20 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/exif.model.dart';
|
import 'package:immich_mobile/domain/models/exif.model.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/utils/exif.converter.dart';
|
||||||
|
import 'package:platform/platform.dart';
|
||||||
|
|
||||||
class AssetService {
|
class AssetService {
|
||||||
final RemoteAssetRepository _remoteAssetRepository;
|
final RemoteAssetRepository _remoteAssetRepository;
|
||||||
final DriftLocalAssetRepository _localAssetRepository;
|
final DriftLocalAssetRepository _localAssetRepository;
|
||||||
|
final Platform _platform;
|
||||||
|
|
||||||
const AssetService({
|
const AssetService({
|
||||||
required RemoteAssetRepository remoteAssetRepository,
|
required RemoteAssetRepository remoteAssetRepository,
|
||||||
required DriftLocalAssetRepository localAssetRepository,
|
required DriftLocalAssetRepository localAssetRepository,
|
||||||
}) : _remoteAssetRepository = remoteAssetRepository,
|
}) : _remoteAssetRepository = remoteAssetRepository,
|
||||||
_localAssetRepository = localAssetRepository;
|
_localAssetRepository = localAssetRepository,
|
||||||
|
_platform = const LocalPlatform();
|
||||||
|
|
||||||
Stream<BaseAsset?> watchAsset(BaseAsset asset) {
|
Stream<BaseAsset?> watchAsset(BaseAsset asset) {
|
||||||
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).id;
|
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).id;
|
||||||
|
|
@ -21,10 +25,40 @@ class AssetService {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<ExifInfo?> getExif(BaseAsset asset) async {
|
Future<ExifInfo?> getExif(BaseAsset asset) async {
|
||||||
if (asset is LocalAsset || asset is! RemoteAsset) {
|
if (!asset.hasRemote) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return _remoteAssetRepository.getExif(asset.id);
|
final id =
|
||||||
|
asset is LocalAsset ? asset.remoteId! : (asset as RemoteAsset).id;
|
||||||
|
return _remoteAssetRepository.getExif(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<double> getAspectRatio(BaseAsset asset) async {
|
||||||
|
bool isFlipped;
|
||||||
|
double? width;
|
||||||
|
double? height;
|
||||||
|
|
||||||
|
if (asset.hasRemote) {
|
||||||
|
final exif = await getExif(asset);
|
||||||
|
isFlipped = ExifDtoConverter.isOrientationFlipped(exif?.orientation);
|
||||||
|
width = exif?.width ?? asset.width?.toDouble();
|
||||||
|
height = exif?.height ?? asset.height?.toDouble();
|
||||||
|
} else if (asset is LocalAsset) {
|
||||||
|
isFlipped = _platform.isAndroid &&
|
||||||
|
(asset.orientation == 90 || asset.orientation == 270);
|
||||||
|
width = asset.width?.toDouble();
|
||||||
|
height = asset.height?.toDouble();
|
||||||
|
} else {
|
||||||
|
isFlipped = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final orientedWidth = isFlipped ? height : width;
|
||||||
|
final orientedHeight = isFlipped ? width : height;
|
||||||
|
if (orientedWidth != null && orientedHeight != null && orientedHeight > 0) {
|
||||||
|
return orientedWidth / orientedHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 1.0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@ class HashService {
|
||||||
final toHash = <_AssetToPath>[];
|
final toHash = <_AssetToPath>[];
|
||||||
|
|
||||||
for (final asset in assetsToHash) {
|
for (final asset in assetsToHash) {
|
||||||
final file = await _storageRepository.getFileForAsset(asset);
|
final file = await _storageRepository.getFileForAsset(asset.id);
|
||||||
if (file == null) {
|
if (file == null) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -359,6 +359,7 @@ extension on Iterable<PlatformAsset> {
|
||||||
width: e.width,
|
width: e.width,
|
||||||
height: e.height,
|
height: e.height,
|
||||||
durationInSeconds: e.durationInSeconds,
|
durationInSeconds: e.durationInSeconds,
|
||||||
|
orientation: e.orientation,
|
||||||
),
|
),
|
||||||
).toList();
|
).toList();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,11 @@ class TimelineFactory {
|
||||||
class TimelineService {
|
class TimelineService {
|
||||||
final TimelineAssetSource _assetSource;
|
final TimelineAssetSource _assetSource;
|
||||||
final TimelineBucketSource _bucketSource;
|
final TimelineBucketSource _bucketSource;
|
||||||
|
final AsyncMutex _mutex = AsyncMutex();
|
||||||
|
int _bufferOffset = 0;
|
||||||
|
List<BaseAsset> _buffer = [];
|
||||||
|
StreamSubscription? _bucketSubscription;
|
||||||
|
|
||||||
int _totalAssets = 0;
|
int _totalAssets = 0;
|
||||||
int get totalAssets => _totalAssets;
|
int get totalAssets => _totalAssets;
|
||||||
|
|
||||||
|
|
@ -117,24 +122,41 @@ class TimelineService {
|
||||||
}) : _assetSource = assetSource,
|
}) : _assetSource = assetSource,
|
||||||
_bucketSource = bucketSource {
|
_bucketSource = bucketSource {
|
||||||
_bucketSubscription = _bucketSource().listen((buckets) {
|
_bucketSubscription = _bucketSource().listen((buckets) {
|
||||||
_totalAssets =
|
_mutex.run(() async {
|
||||||
|
final totalAssets =
|
||||||
buckets.fold<int>(0, (acc, bucket) => acc + bucket.assetCount);
|
buckets.fold<int>(0, (acc, bucket) => acc + bucket.assetCount);
|
||||||
unawaited(_reloadBucket());
|
|
||||||
|
if (totalAssets == 0) {
|
||||||
|
_bufferOffset = 0;
|
||||||
|
_buffer.clear();
|
||||||
|
} else {
|
||||||
|
final int offset;
|
||||||
|
final int count;
|
||||||
|
// When the buffer is empty or the old bufferOffset is greater than the new total assets,
|
||||||
|
// we need to reset the buffer and load the first batch of assets.
|
||||||
|
if (_bufferOffset >= totalAssets || _buffer.isEmpty) {
|
||||||
|
offset = 0;
|
||||||
|
count = kTimelineAssetLoadBatchSize;
|
||||||
|
} else {
|
||||||
|
offset = _bufferOffset;
|
||||||
|
count = math.min(
|
||||||
|
_buffer.length,
|
||||||
|
totalAssets - _bufferOffset,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_buffer = await _assetSource(offset, count);
|
||||||
|
_bufferOffset = offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
// change the state's total assets count only after the buffer is reloaded
|
||||||
|
_totalAssets = totalAssets;
|
||||||
|
EventStream.shared.emit(const TimelineReloadEvent());
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
final AsyncMutex _mutex = AsyncMutex();
|
|
||||||
int _bufferOffset = 0;
|
|
||||||
List<BaseAsset> _buffer = [];
|
|
||||||
StreamSubscription? _bucketSubscription;
|
|
||||||
|
|
||||||
Stream<List<Bucket>> Function() get watchBuckets => _bucketSource;
|
Stream<List<Bucket>> Function() get watchBuckets => _bucketSource;
|
||||||
|
|
||||||
Future<void> _reloadBucket() => _mutex.run(() async {
|
|
||||||
_buffer = await _assetSource(_bufferOffset, _buffer.length);
|
|
||||||
EventStream.shared.emit(const TimelineReloadEvent());
|
|
||||||
});
|
|
||||||
|
|
||||||
Future<List<BaseAsset>> loadAssets(int index, int count) =>
|
Future<List<BaseAsset>> loadAssets(int index, int count) =>
|
||||||
_mutex.run(() => _loadAssets(index, count));
|
_mutex.run(() => _loadAssets(index, count));
|
||||||
|
|
||||||
|
|
@ -163,18 +185,20 @@ class TimelineService {
|
||||||
: (len > kTimelineAssetLoadBatchSize ? index : index + count - len),
|
: (len > kTimelineAssetLoadBatchSize ? index : index + count - len),
|
||||||
);
|
);
|
||||||
|
|
||||||
final assets = await _assetSource(start, len);
|
_buffer = await _assetSource(start, len);
|
||||||
_buffer = assets;
|
|
||||||
_bufferOffset = start;
|
_bufferOffset = start;
|
||||||
|
|
||||||
return getAssets(index, count);
|
return getAssets(index, count);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool hasRange(int index, int count) =>
|
bool hasRange(int index, int count) =>
|
||||||
index >= _bufferOffset && index + count <= _bufferOffset + _buffer.length;
|
index >= 0 &&
|
||||||
|
index < _totalAssets &&
|
||||||
|
index >= _bufferOffset &&
|
||||||
|
index + count <= _bufferOffset + _buffer.length &&
|
||||||
|
index + count <= _totalAssets;
|
||||||
|
|
||||||
List<BaseAsset> getAssets(int index, int count) {
|
List<BaseAsset> getAssets(int index, int count) {
|
||||||
assert(index + count <= totalAssets);
|
|
||||||
if (!hasRange(index, count)) {
|
if (!hasRange(index, count)) {
|
||||||
throw RangeError('TimelineService::getAssets Index out of range');
|
throw RangeError('TimelineService::getAssets Index out of range');
|
||||||
}
|
}
|
||||||
|
|
@ -184,14 +208,16 @@ class TimelineService {
|
||||||
|
|
||||||
// Pre-cache assets around the given index for asset viewer
|
// Pre-cache assets around the given index for asset viewer
|
||||||
Future<void> preCacheAssets(int index) =>
|
Future<void> preCacheAssets(int index) =>
|
||||||
_mutex.run(() => _loadAssets(index, 5));
|
_mutex.run(() => _loadAssets(index, math.min(5, _totalAssets - index)));
|
||||||
|
|
||||||
BaseAsset getRandomAsset() =>
|
BaseAsset getRandomAsset() =>
|
||||||
_buffer.elementAt(math.Random().nextInt(_buffer.length));
|
_buffer.elementAt(math.Random().nextInt(_buffer.length));
|
||||||
|
|
||||||
BaseAsset getAsset(int index) {
|
BaseAsset getAsset(int index) {
|
||||||
if (!hasRange(index, 1)) {
|
if (!hasRange(index, 1)) {
|
||||||
throw RangeError('TimelineService::getAsset Index out of range');
|
throw RangeError(
|
||||||
|
'TimelineService::getAsset Index $index not in buffer range [$_bufferOffset, ${_bufferOffset + _buffer.length})',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return _buffer.elementAt(index - _bufferOffset);
|
return _buffer.elementAt(index - _bufferOffset);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ class LocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin {
|
||||||
// Only used during backup to mirror the favorite status of the asset in the server
|
// Only used during backup to mirror the favorite status of the asset in the server
|
||||||
BoolColumn get isFavorite => boolean().withDefault(const Constant(false))();
|
BoolColumn get isFavorite => boolean().withDefault(const Constant(false))();
|
||||||
|
|
||||||
|
IntColumn get orientation => integer().withDefault(const Constant(0))();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Set<Column> get primaryKey => {id};
|
Set<Column> get primaryKey => {id};
|
||||||
}
|
}
|
||||||
|
|
@ -31,5 +33,6 @@ extension LocalAssetEntityDataDomainEx on LocalAssetEntityData {
|
||||||
height: height,
|
height: height,
|
||||||
width: width,
|
width: width,
|
||||||
remoteId: null,
|
remoteId: null,
|
||||||
|
orientation: orientation,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ typedef $$LocalAssetEntityTableCreateCompanionBuilder
|
||||||
required String id,
|
required String id,
|
||||||
i0.Value<String?> checksum,
|
i0.Value<String?> checksum,
|
||||||
i0.Value<bool> isFavorite,
|
i0.Value<bool> isFavorite,
|
||||||
|
i0.Value<int> orientation,
|
||||||
});
|
});
|
||||||
typedef $$LocalAssetEntityTableUpdateCompanionBuilder
|
typedef $$LocalAssetEntityTableUpdateCompanionBuilder
|
||||||
= i1.LocalAssetEntityCompanion Function({
|
= i1.LocalAssetEntityCompanion Function({
|
||||||
|
|
@ -33,6 +34,7 @@ typedef $$LocalAssetEntityTableUpdateCompanionBuilder
|
||||||
i0.Value<String> id,
|
i0.Value<String> id,
|
||||||
i0.Value<String?> checksum,
|
i0.Value<String?> checksum,
|
||||||
i0.Value<bool> isFavorite,
|
i0.Value<bool> isFavorite,
|
||||||
|
i0.Value<int> orientation,
|
||||||
});
|
});
|
||||||
|
|
||||||
class $$LocalAssetEntityTableFilterComposer
|
class $$LocalAssetEntityTableFilterComposer
|
||||||
|
|
@ -76,6 +78,10 @@ class $$LocalAssetEntityTableFilterComposer
|
||||||
|
|
||||||
i0.ColumnFilters<bool> get isFavorite => $composableBuilder(
|
i0.ColumnFilters<bool> get isFavorite => $composableBuilder(
|
||||||
column: $table.isFavorite, builder: (column) => i0.ColumnFilters(column));
|
column: $table.isFavorite, builder: (column) => i0.ColumnFilters(column));
|
||||||
|
|
||||||
|
i0.ColumnFilters<int> get orientation => $composableBuilder(
|
||||||
|
column: $table.orientation,
|
||||||
|
builder: (column) => i0.ColumnFilters(column));
|
||||||
}
|
}
|
||||||
|
|
||||||
class $$LocalAssetEntityTableOrderingComposer
|
class $$LocalAssetEntityTableOrderingComposer
|
||||||
|
|
@ -120,6 +126,10 @@ class $$LocalAssetEntityTableOrderingComposer
|
||||||
i0.ColumnOrderings<bool> get isFavorite => $composableBuilder(
|
i0.ColumnOrderings<bool> get isFavorite => $composableBuilder(
|
||||||
column: $table.isFavorite,
|
column: $table.isFavorite,
|
||||||
builder: (column) => i0.ColumnOrderings(column));
|
builder: (column) => i0.ColumnOrderings(column));
|
||||||
|
|
||||||
|
i0.ColumnOrderings<int> get orientation => $composableBuilder(
|
||||||
|
column: $table.orientation,
|
||||||
|
builder: (column) => i0.ColumnOrderings(column));
|
||||||
}
|
}
|
||||||
|
|
||||||
class $$LocalAssetEntityTableAnnotationComposer
|
class $$LocalAssetEntityTableAnnotationComposer
|
||||||
|
|
@ -160,6 +170,9 @@ class $$LocalAssetEntityTableAnnotationComposer
|
||||||
|
|
||||||
i0.GeneratedColumn<bool> get isFavorite => $composableBuilder(
|
i0.GeneratedColumn<bool> get isFavorite => $composableBuilder(
|
||||||
column: $table.isFavorite, builder: (column) => column);
|
column: $table.isFavorite, builder: (column) => column);
|
||||||
|
|
||||||
|
i0.GeneratedColumn<int> get orientation => $composableBuilder(
|
||||||
|
column: $table.orientation, builder: (column) => column);
|
||||||
}
|
}
|
||||||
|
|
||||||
class $$LocalAssetEntityTableTableManager extends i0.RootTableManager<
|
class $$LocalAssetEntityTableTableManager extends i0.RootTableManager<
|
||||||
|
|
@ -201,6 +214,7 @@ class $$LocalAssetEntityTableTableManager extends i0.RootTableManager<
|
||||||
i0.Value<String> id = const i0.Value.absent(),
|
i0.Value<String> id = const i0.Value.absent(),
|
||||||
i0.Value<String?> checksum = const i0.Value.absent(),
|
i0.Value<String?> checksum = const i0.Value.absent(),
|
||||||
i0.Value<bool> isFavorite = const i0.Value.absent(),
|
i0.Value<bool> isFavorite = const i0.Value.absent(),
|
||||||
|
i0.Value<int> orientation = const i0.Value.absent(),
|
||||||
}) =>
|
}) =>
|
||||||
i1.LocalAssetEntityCompanion(
|
i1.LocalAssetEntityCompanion(
|
||||||
name: name,
|
name: name,
|
||||||
|
|
@ -213,6 +227,7 @@ class $$LocalAssetEntityTableTableManager extends i0.RootTableManager<
|
||||||
id: id,
|
id: id,
|
||||||
checksum: checksum,
|
checksum: checksum,
|
||||||
isFavorite: isFavorite,
|
isFavorite: isFavorite,
|
||||||
|
orientation: orientation,
|
||||||
),
|
),
|
||||||
createCompanionCallback: ({
|
createCompanionCallback: ({
|
||||||
required String name,
|
required String name,
|
||||||
|
|
@ -225,6 +240,7 @@ class $$LocalAssetEntityTableTableManager extends i0.RootTableManager<
|
||||||
required String id,
|
required String id,
|
||||||
i0.Value<String?> checksum = const i0.Value.absent(),
|
i0.Value<String?> checksum = const i0.Value.absent(),
|
||||||
i0.Value<bool> isFavorite = const i0.Value.absent(),
|
i0.Value<bool> isFavorite = const i0.Value.absent(),
|
||||||
|
i0.Value<int> orientation = const i0.Value.absent(),
|
||||||
}) =>
|
}) =>
|
||||||
i1.LocalAssetEntityCompanion.insert(
|
i1.LocalAssetEntityCompanion.insert(
|
||||||
name: name,
|
name: name,
|
||||||
|
|
@ -237,6 +253,7 @@ class $$LocalAssetEntityTableTableManager extends i0.RootTableManager<
|
||||||
id: id,
|
id: id,
|
||||||
checksum: checksum,
|
checksum: checksum,
|
||||||
isFavorite: isFavorite,
|
isFavorite: isFavorite,
|
||||||
|
orientation: orientation,
|
||||||
),
|
),
|
||||||
withReferenceMapper: (p0) => p0
|
withReferenceMapper: (p0) => p0
|
||||||
.map((e) => (e.readTable(table), i0.BaseReferences(db, table, e)))
|
.map((e) => (e.readTable(table), i0.BaseReferences(db, table, e)))
|
||||||
|
|
@ -337,6 +354,14 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
|
||||||
defaultConstraints: i0.GeneratedColumn.constraintIsAlways(
|
defaultConstraints: i0.GeneratedColumn.constraintIsAlways(
|
||||||
'CHECK ("is_favorite" IN (0, 1))'),
|
'CHECK ("is_favorite" IN (0, 1))'),
|
||||||
defaultValue: const i4.Constant(false));
|
defaultValue: const i4.Constant(false));
|
||||||
|
static const i0.VerificationMeta _orientationMeta =
|
||||||
|
const i0.VerificationMeta('orientation');
|
||||||
|
@override
|
||||||
|
late final i0.GeneratedColumn<int> orientation = i0.GeneratedColumn<int>(
|
||||||
|
'orientation', aliasedName, false,
|
||||||
|
type: i0.DriftSqlType.int,
|
||||||
|
requiredDuringInsert: false,
|
||||||
|
defaultValue: const i4.Constant(0));
|
||||||
@override
|
@override
|
||||||
List<i0.GeneratedColumn> get $columns => [
|
List<i0.GeneratedColumn> get $columns => [
|
||||||
name,
|
name,
|
||||||
|
|
@ -348,7 +373,8 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
|
||||||
durationInSeconds,
|
durationInSeconds,
|
||||||
id,
|
id,
|
||||||
checksum,
|
checksum,
|
||||||
isFavorite
|
isFavorite,
|
||||||
|
orientation
|
||||||
];
|
];
|
||||||
@override
|
@override
|
||||||
String get aliasedName => _alias ?? actualTableName;
|
String get aliasedName => _alias ?? actualTableName;
|
||||||
|
|
@ -404,6 +430,12 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
|
||||||
isFavorite.isAcceptableOrUnknown(
|
isFavorite.isAcceptableOrUnknown(
|
||||||
data['is_favorite']!, _isFavoriteMeta));
|
data['is_favorite']!, _isFavoriteMeta));
|
||||||
}
|
}
|
||||||
|
if (data.containsKey('orientation')) {
|
||||||
|
context.handle(
|
||||||
|
_orientationMeta,
|
||||||
|
orientation.isAcceptableOrUnknown(
|
||||||
|
data['orientation']!, _orientationMeta));
|
||||||
|
}
|
||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -435,6 +467,8 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
|
||||||
.read(i0.DriftSqlType.string, data['${effectivePrefix}checksum']),
|
.read(i0.DriftSqlType.string, data['${effectivePrefix}checksum']),
|
||||||
isFavorite: attachedDatabase.typeMapping
|
isFavorite: attachedDatabase.typeMapping
|
||||||
.read(i0.DriftSqlType.bool, data['${effectivePrefix}is_favorite'])!,
|
.read(i0.DriftSqlType.bool, data['${effectivePrefix}is_favorite'])!,
|
||||||
|
orientation: attachedDatabase.typeMapping
|
||||||
|
.read(i0.DriftSqlType.int, data['${effectivePrefix}orientation'])!,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -463,6 +497,7 @@ class LocalAssetEntityData extends i0.DataClass
|
||||||
final String id;
|
final String id;
|
||||||
final String? checksum;
|
final String? checksum;
|
||||||
final bool isFavorite;
|
final bool isFavorite;
|
||||||
|
final int orientation;
|
||||||
const LocalAssetEntityData(
|
const LocalAssetEntityData(
|
||||||
{required this.name,
|
{required this.name,
|
||||||
required this.type,
|
required this.type,
|
||||||
|
|
@ -473,7 +508,8 @@ class LocalAssetEntityData extends i0.DataClass
|
||||||
this.durationInSeconds,
|
this.durationInSeconds,
|
||||||
required this.id,
|
required this.id,
|
||||||
this.checksum,
|
this.checksum,
|
||||||
required this.isFavorite});
|
required this.isFavorite,
|
||||||
|
required this.orientation});
|
||||||
@override
|
@override
|
||||||
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
|
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
|
||||||
final map = <String, i0.Expression>{};
|
final map = <String, i0.Expression>{};
|
||||||
|
|
@ -498,6 +534,7 @@ class LocalAssetEntityData extends i0.DataClass
|
||||||
map['checksum'] = i0.Variable<String>(checksum);
|
map['checksum'] = i0.Variable<String>(checksum);
|
||||||
}
|
}
|
||||||
map['is_favorite'] = i0.Variable<bool>(isFavorite);
|
map['is_favorite'] = i0.Variable<bool>(isFavorite);
|
||||||
|
map['orientation'] = i0.Variable<int>(orientation);
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -516,6 +553,7 @@ class LocalAssetEntityData extends i0.DataClass
|
||||||
id: serializer.fromJson<String>(json['id']),
|
id: serializer.fromJson<String>(json['id']),
|
||||||
checksum: serializer.fromJson<String?>(json['checksum']),
|
checksum: serializer.fromJson<String?>(json['checksum']),
|
||||||
isFavorite: serializer.fromJson<bool>(json['isFavorite']),
|
isFavorite: serializer.fromJson<bool>(json['isFavorite']),
|
||||||
|
orientation: serializer.fromJson<int>(json['orientation']),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@override
|
@override
|
||||||
|
|
@ -533,6 +571,7 @@ class LocalAssetEntityData extends i0.DataClass
|
||||||
'id': serializer.toJson<String>(id),
|
'id': serializer.toJson<String>(id),
|
||||||
'checksum': serializer.toJson<String?>(checksum),
|
'checksum': serializer.toJson<String?>(checksum),
|
||||||
'isFavorite': serializer.toJson<bool>(isFavorite),
|
'isFavorite': serializer.toJson<bool>(isFavorite),
|
||||||
|
'orientation': serializer.toJson<int>(orientation),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -546,7 +585,8 @@ class LocalAssetEntityData extends i0.DataClass
|
||||||
i0.Value<int?> durationInSeconds = const i0.Value.absent(),
|
i0.Value<int?> durationInSeconds = const i0.Value.absent(),
|
||||||
String? id,
|
String? id,
|
||||||
i0.Value<String?> checksum = const i0.Value.absent(),
|
i0.Value<String?> checksum = const i0.Value.absent(),
|
||||||
bool? isFavorite}) =>
|
bool? isFavorite,
|
||||||
|
int? orientation}) =>
|
||||||
i1.LocalAssetEntityData(
|
i1.LocalAssetEntityData(
|
||||||
name: name ?? this.name,
|
name: name ?? this.name,
|
||||||
type: type ?? this.type,
|
type: type ?? this.type,
|
||||||
|
|
@ -560,6 +600,7 @@ class LocalAssetEntityData extends i0.DataClass
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
checksum: checksum.present ? checksum.value : this.checksum,
|
checksum: checksum.present ? checksum.value : this.checksum,
|
||||||
isFavorite: isFavorite ?? this.isFavorite,
|
isFavorite: isFavorite ?? this.isFavorite,
|
||||||
|
orientation: orientation ?? this.orientation,
|
||||||
);
|
);
|
||||||
LocalAssetEntityData copyWithCompanion(i1.LocalAssetEntityCompanion data) {
|
LocalAssetEntityData copyWithCompanion(i1.LocalAssetEntityCompanion data) {
|
||||||
return LocalAssetEntityData(
|
return LocalAssetEntityData(
|
||||||
|
|
@ -576,6 +617,8 @@ class LocalAssetEntityData extends i0.DataClass
|
||||||
checksum: data.checksum.present ? data.checksum.value : this.checksum,
|
checksum: data.checksum.present ? data.checksum.value : this.checksum,
|
||||||
isFavorite:
|
isFavorite:
|
||||||
data.isFavorite.present ? data.isFavorite.value : this.isFavorite,
|
data.isFavorite.present ? data.isFavorite.value : this.isFavorite,
|
||||||
|
orientation:
|
||||||
|
data.orientation.present ? data.orientation.value : this.orientation,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -591,14 +634,15 @@ class LocalAssetEntityData extends i0.DataClass
|
||||||
..write('durationInSeconds: $durationInSeconds, ')
|
..write('durationInSeconds: $durationInSeconds, ')
|
||||||
..write('id: $id, ')
|
..write('id: $id, ')
|
||||||
..write('checksum: $checksum, ')
|
..write('checksum: $checksum, ')
|
||||||
..write('isFavorite: $isFavorite')
|
..write('isFavorite: $isFavorite, ')
|
||||||
|
..write('orientation: $orientation')
|
||||||
..write(')'))
|
..write(')'))
|
||||||
.toString();
|
.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode => Object.hash(name, type, createdAt, updatedAt, width,
|
int get hashCode => Object.hash(name, type, createdAt, updatedAt, width,
|
||||||
height, durationInSeconds, id, checksum, isFavorite);
|
height, durationInSeconds, id, checksum, isFavorite, orientation);
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) =>
|
bool operator ==(Object other) =>
|
||||||
identical(this, other) ||
|
identical(this, other) ||
|
||||||
|
|
@ -612,7 +656,8 @@ class LocalAssetEntityData extends i0.DataClass
|
||||||
other.durationInSeconds == this.durationInSeconds &&
|
other.durationInSeconds == this.durationInSeconds &&
|
||||||
other.id == this.id &&
|
other.id == this.id &&
|
||||||
other.checksum == this.checksum &&
|
other.checksum == this.checksum &&
|
||||||
other.isFavorite == this.isFavorite);
|
other.isFavorite == this.isFavorite &&
|
||||||
|
other.orientation == this.orientation);
|
||||||
}
|
}
|
||||||
|
|
||||||
class LocalAssetEntityCompanion
|
class LocalAssetEntityCompanion
|
||||||
|
|
@ -627,6 +672,7 @@ class LocalAssetEntityCompanion
|
||||||
final i0.Value<String> id;
|
final i0.Value<String> id;
|
||||||
final i0.Value<String?> checksum;
|
final i0.Value<String?> checksum;
|
||||||
final i0.Value<bool> isFavorite;
|
final i0.Value<bool> isFavorite;
|
||||||
|
final i0.Value<int> orientation;
|
||||||
const LocalAssetEntityCompanion({
|
const LocalAssetEntityCompanion({
|
||||||
this.name = const i0.Value.absent(),
|
this.name = const i0.Value.absent(),
|
||||||
this.type = const i0.Value.absent(),
|
this.type = const i0.Value.absent(),
|
||||||
|
|
@ -638,6 +684,7 @@ class LocalAssetEntityCompanion
|
||||||
this.id = const i0.Value.absent(),
|
this.id = const i0.Value.absent(),
|
||||||
this.checksum = const i0.Value.absent(),
|
this.checksum = const i0.Value.absent(),
|
||||||
this.isFavorite = const i0.Value.absent(),
|
this.isFavorite = const i0.Value.absent(),
|
||||||
|
this.orientation = const i0.Value.absent(),
|
||||||
});
|
});
|
||||||
LocalAssetEntityCompanion.insert({
|
LocalAssetEntityCompanion.insert({
|
||||||
required String name,
|
required String name,
|
||||||
|
|
@ -650,6 +697,7 @@ class LocalAssetEntityCompanion
|
||||||
required String id,
|
required String id,
|
||||||
this.checksum = const i0.Value.absent(),
|
this.checksum = const i0.Value.absent(),
|
||||||
this.isFavorite = const i0.Value.absent(),
|
this.isFavorite = const i0.Value.absent(),
|
||||||
|
this.orientation = const i0.Value.absent(),
|
||||||
}) : name = i0.Value(name),
|
}) : name = i0.Value(name),
|
||||||
type = i0.Value(type),
|
type = i0.Value(type),
|
||||||
id = i0.Value(id);
|
id = i0.Value(id);
|
||||||
|
|
@ -664,6 +712,7 @@ class LocalAssetEntityCompanion
|
||||||
i0.Expression<String>? id,
|
i0.Expression<String>? id,
|
||||||
i0.Expression<String>? checksum,
|
i0.Expression<String>? checksum,
|
||||||
i0.Expression<bool>? isFavorite,
|
i0.Expression<bool>? isFavorite,
|
||||||
|
i0.Expression<int>? orientation,
|
||||||
}) {
|
}) {
|
||||||
return i0.RawValuesInsertable({
|
return i0.RawValuesInsertable({
|
||||||
if (name != null) 'name': name,
|
if (name != null) 'name': name,
|
||||||
|
|
@ -676,6 +725,7 @@ class LocalAssetEntityCompanion
|
||||||
if (id != null) 'id': id,
|
if (id != null) 'id': id,
|
||||||
if (checksum != null) 'checksum': checksum,
|
if (checksum != null) 'checksum': checksum,
|
||||||
if (isFavorite != null) 'is_favorite': isFavorite,
|
if (isFavorite != null) 'is_favorite': isFavorite,
|
||||||
|
if (orientation != null) 'orientation': orientation,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -689,7 +739,8 @@ class LocalAssetEntityCompanion
|
||||||
i0.Value<int?>? durationInSeconds,
|
i0.Value<int?>? durationInSeconds,
|
||||||
i0.Value<String>? id,
|
i0.Value<String>? id,
|
||||||
i0.Value<String?>? checksum,
|
i0.Value<String?>? checksum,
|
||||||
i0.Value<bool>? isFavorite}) {
|
i0.Value<bool>? isFavorite,
|
||||||
|
i0.Value<int>? orientation}) {
|
||||||
return i1.LocalAssetEntityCompanion(
|
return i1.LocalAssetEntityCompanion(
|
||||||
name: name ?? this.name,
|
name: name ?? this.name,
|
||||||
type: type ?? this.type,
|
type: type ?? this.type,
|
||||||
|
|
@ -701,6 +752,7 @@ class LocalAssetEntityCompanion
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
checksum: checksum ?? this.checksum,
|
checksum: checksum ?? this.checksum,
|
||||||
isFavorite: isFavorite ?? this.isFavorite,
|
isFavorite: isFavorite ?? this.isFavorite,
|
||||||
|
orientation: orientation ?? this.orientation,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -738,6 +790,9 @@ class LocalAssetEntityCompanion
|
||||||
if (isFavorite.present) {
|
if (isFavorite.present) {
|
||||||
map['is_favorite'] = i0.Variable<bool>(isFavorite.value);
|
map['is_favorite'] = i0.Variable<bool>(isFavorite.value);
|
||||||
}
|
}
|
||||||
|
if (orientation.present) {
|
||||||
|
map['orientation'] = i0.Variable<int>(orientation.value);
|
||||||
|
}
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -753,7 +808,8 @@ class LocalAssetEntityCompanion
|
||||||
..write('durationInSeconds: $durationInSeconds, ')
|
..write('durationInSeconds: $durationInSeconds, ')
|
||||||
..write('id: $id, ')
|
..write('id: $id, ')
|
||||||
..write('checksum: $checksum, ')
|
..write('checksum: $checksum, ')
|
||||||
..write('isFavorite: $isFavorite')
|
..write('isFavorite: $isFavorite, ')
|
||||||
|
..write('orientation: $orientation')
|
||||||
..write(')'))
|
..write(')'))
|
||||||
.toString();
|
.toString();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,8 @@ mergedAsset: SELECT * FROM
|
||||||
rae.is_favorite,
|
rae.is_favorite,
|
||||||
rae.thumb_hash,
|
rae.thumb_hash,
|
||||||
rae.checksum,
|
rae.checksum,
|
||||||
rae.owner_id
|
rae.owner_id,
|
||||||
|
0 as orientation
|
||||||
FROM
|
FROM
|
||||||
remote_asset_entity rae
|
remote_asset_entity rae
|
||||||
LEFT JOIN
|
LEFT JOIN
|
||||||
|
|
@ -37,7 +38,8 @@ mergedAsset: SELECT * FROM
|
||||||
lae.is_favorite,
|
lae.is_favorite,
|
||||||
NULL as thumb_hash,
|
NULL as thumb_hash,
|
||||||
lae.checksum,
|
lae.checksum,
|
||||||
NULL as owner_id
|
NULL as owner_id,
|
||||||
|
lae.orientation
|
||||||
FROM
|
FROM
|
||||||
local_asset_entity lae
|
local_asset_entity lae
|
||||||
LEFT JOIN
|
LEFT JOIN
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ class MergedAssetDrift extends i1.ModularAccessor {
|
||||||
final generatedlimit = $write(limit, startIndex: $arrayStartIndex);
|
final generatedlimit = $write(limit, startIndex: $arrayStartIndex);
|
||||||
$arrayStartIndex += generatedlimit.amountOfVariables;
|
$arrayStartIndex += generatedlimit.amountOfVariables;
|
||||||
return customSelect(
|
return customSelect(
|
||||||
'SELECT * FROM (SELECT rae.id AS remote_id, lae.id AS local_id, rae.name, rae.type, rae.created_at, rae.updated_at, rae.width, rae.height, rae.duration_in_seconds, rae.is_favorite, rae.thumb_hash, rae.checksum, rae.owner_id FROM remote_asset_entity AS rae LEFT JOIN local_asset_entity AS lae ON rae.checksum = lae.checksum WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandedvar1) UNION ALL SELECT NULL AS remote_id, lae.id AS local_id, lae.name, lae.type, lae.created_at, lae.updated_at, lae.width, lae.height, lae.duration_in_seconds, lae.is_favorite, NULL AS thumb_hash, lae.checksum, NULL AS owner_id FROM local_asset_entity AS lae LEFT JOIN remote_asset_entity AS rae ON rae.checksum = lae.checksum WHERE rae.id IS NULL) ORDER BY created_at DESC ${generatedlimit.sql}',
|
'SELECT * FROM (SELECT rae.id AS remote_id, lae.id AS local_id, rae.name, rae.type, rae.created_at, rae.updated_at, rae.width, rae.height, rae.duration_in_seconds, rae.is_favorite, rae.thumb_hash, rae.checksum, rae.owner_id, 0 AS orientation FROM remote_asset_entity AS rae LEFT JOIN local_asset_entity AS lae ON rae.checksum = lae.checksum WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandedvar1) UNION ALL SELECT NULL AS remote_id, lae.id AS local_id, lae.name, lae.type, lae.created_at, lae.updated_at, lae.width, lae.height, lae.duration_in_seconds, lae.is_favorite, NULL AS thumb_hash, lae.checksum, NULL AS owner_id, lae.orientation FROM local_asset_entity AS lae LEFT JOIN remote_asset_entity AS rae ON rae.checksum = lae.checksum WHERE rae.id IS NULL) ORDER BY created_at DESC ${generatedlimit.sql}',
|
||||||
variables: [
|
variables: [
|
||||||
for (var $ in var1) i0.Variable<String>($),
|
for (var $ in var1) i0.Variable<String>($),
|
||||||
...generatedlimit.introducedVariables
|
...generatedlimit.introducedVariables
|
||||||
|
|
@ -42,6 +42,7 @@ class MergedAssetDrift extends i1.ModularAccessor {
|
||||||
thumbHash: row.readNullable<String>('thumb_hash'),
|
thumbHash: row.readNullable<String>('thumb_hash'),
|
||||||
checksum: row.readNullable<String>('checksum'),
|
checksum: row.readNullable<String>('checksum'),
|
||||||
ownerId: row.readNullable<String>('owner_id'),
|
ownerId: row.readNullable<String>('owner_id'),
|
||||||
|
orientation: row.read<int>('orientation'),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -87,6 +88,7 @@ class MergedAssetResult {
|
||||||
final String? thumbHash;
|
final String? thumbHash;
|
||||||
final String? checksum;
|
final String? checksum;
|
||||||
final String? ownerId;
|
final String? ownerId;
|
||||||
|
final int orientation;
|
||||||
MergedAssetResult({
|
MergedAssetResult({
|
||||||
this.remoteId,
|
this.remoteId,
|
||||||
this.localId,
|
this.localId,
|
||||||
|
|
@ -101,6 +103,7 @@ class MergedAssetResult {
|
||||||
this.thumbHash,
|
this.thumbHash,
|
||||||
this.checksum,
|
this.checksum,
|
||||||
this.ownerId,
|
this.ownerId,
|
||||||
|
required this.orientation,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -281,6 +281,7 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
|
||||||
height: Value(asset.height),
|
height: Value(asset.height),
|
||||||
durationInSeconds: Value(asset.durationInSeconds),
|
durationInSeconds: Value(asset.durationInSeconds),
|
||||||
id: asset.id,
|
id: asset.id,
|
||||||
|
orientation: Value(asset.orientation),
|
||||||
checksum: const Value(null),
|
checksum: const Value(null),
|
||||||
);
|
);
|
||||||
batch.insert<$LocalAssetEntityTable, LocalAssetEntityData>(
|
batch.insert<$LocalAssetEntityTable, LocalAssetEntityData>(
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
|
||||||
Stream<LocalAsset?> watchAsset(String id) {
|
Stream<LocalAsset?> watchAsset(String id) {
|
||||||
final query = _db.localAssetEntity
|
final query = _db.localAssetEntity
|
||||||
.select()
|
.select()
|
||||||
.addColumns([_db.localAssetEntity.id]).join([
|
.addColumns([_db.remoteAssetEntity.id]).join([
|
||||||
leftOuterJoin(
|
leftOuterJoin(
|
||||||
_db.remoteAssetEntity,
|
_db.remoteAssetEntity,
|
||||||
_db.localAssetEntity.checksum.equalsExp(_db.remoteAssetEntity.checksum),
|
_db.localAssetEntity.checksum.equalsExp(_db.remoteAssetEntity.checksum),
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,22 @@
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:photo_manager/photo_manager.dart';
|
import 'package:photo_manager/photo_manager.dart';
|
||||||
|
|
||||||
class StorageRepository {
|
class StorageRepository {
|
||||||
const StorageRepository();
|
const StorageRepository();
|
||||||
|
|
||||||
Future<File?> getFileForAsset(LocalAsset asset) async {
|
Future<File?> getFileForAsset(String assetId) async {
|
||||||
final log = Logger('StorageRepository');
|
final log = Logger('StorageRepository');
|
||||||
File? file;
|
File? file;
|
||||||
try {
|
try {
|
||||||
final entity = await AssetEntity.fromId(asset.id);
|
final entity = await AssetEntity.fromId(assetId);
|
||||||
file = await entity?.originFile;
|
file = await entity?.originFile;
|
||||||
if (file == null) {
|
if (file == null) {
|
||||||
log.warning(
|
log.warning("Cannot get file for asset $assetId");
|
||||||
"Cannot get file for asset ${asset.id}, name: ${asset.name}, created on: ${asset.createdAt}",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch (error, stackTrace) {
|
} catch (error, stackTrace) {
|
||||||
log.warning(
|
log.warning("Error getting file for asset $assetId", error, stackTrace);
|
||||||
"Error getting file for asset ${asset.id}, name: ${asset.name}, created on: ${asset.createdAt}",
|
|
||||||
error,
|
|
||||||
stackTrace,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return file;
|
return file;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
||||||
height: row.height,
|
height: row.height,
|
||||||
isFavorite: row.isFavorite,
|
isFavorite: row.isFavorite,
|
||||||
durationInSeconds: row.durationInSeconds,
|
durationInSeconds: row.durationInSeconds,
|
||||||
|
orientation: row.orientation,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
).get();
|
).get();
|
||||||
|
|
|
||||||
5
mobile/lib/platform/native_sync_api.g.dart
generated
5
mobile/lib/platform/native_sync_api.g.dart
generated
|
|
@ -40,6 +40,7 @@ class PlatformAsset {
|
||||||
this.width,
|
this.width,
|
||||||
this.height,
|
this.height,
|
||||||
required this.durationInSeconds,
|
required this.durationInSeconds,
|
||||||
|
required this.orientation,
|
||||||
});
|
});
|
||||||
|
|
||||||
String id;
|
String id;
|
||||||
|
|
@ -58,6 +59,8 @@ class PlatformAsset {
|
||||||
|
|
||||||
int durationInSeconds;
|
int durationInSeconds;
|
||||||
|
|
||||||
|
int orientation;
|
||||||
|
|
||||||
List<Object?> _toList() {
|
List<Object?> _toList() {
|
||||||
return <Object?>[
|
return <Object?>[
|
||||||
id,
|
id,
|
||||||
|
|
@ -68,6 +71,7 @@ class PlatformAsset {
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
durationInSeconds,
|
durationInSeconds,
|
||||||
|
orientation,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -86,6 +90,7 @@ class PlatformAsset {
|
||||||
width: result[5] as int?,
|
width: result[5] as int?,
|
||||||
height: result[6] as int?,
|
height: result[6] as int?,
|
||||||
durationInSeconds: result[7]! as int,
|
durationInSeconds: result[7]! as int,
|
||||||
|
orientation: result[8]! as int,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ class DriftArchivePage extends StatelessWidget {
|
||||||
child: Timeline(
|
child: Timeline(
|
||||||
appBar: MesmerizingSliverAppBar(
|
appBar: MesmerizingSliverAppBar(
|
||||||
title: 'archive'.t(context: context),
|
title: 'archive'.t(context: context),
|
||||||
icon: Icons.archive_outlined, // Icon for the archive page
|
icon: Icons.archive_outlined,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import 'dart:async';
|
||||||
import 'package:auto_route/auto_route.dart';
|
import 'package:auto_route/auto_route.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
|
|
@ -11,8 +12,11 @@ import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.sta
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_bar.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_bar.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/top_app_bar.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/asset_viewer/top_app_bar.widget.dart';
|
||||||
|
import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
|
||||||
|
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||||
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
|
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
|
||||||
|
|
@ -78,6 +82,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||||
Offset dragDownPosition = Offset.zero;
|
Offset dragDownPosition = Offset.zero;
|
||||||
int totalAssets = 0;
|
int totalAssets = 0;
|
||||||
BuildContext? scaffoldContext;
|
BuildContext? scaffoldContext;
|
||||||
|
Map<String, GlobalKey> videoPlayerKeys = {};
|
||||||
|
|
||||||
// Delayed operations that should be cancelled on disposal
|
// Delayed operations that should be cancelled on disposal
|
||||||
final List<Timer> _delayedOperations = [];
|
final List<Timer> _delayedOperations = [];
|
||||||
|
|
@ -158,6 +163,11 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||||
void _onAssetChanged(int index) {
|
void _onAssetChanged(int index) {
|
||||||
final asset = ref.read(timelineServiceProvider).getAsset(index);
|
final asset = ref.read(timelineServiceProvider).getAsset(index);
|
||||||
ref.read(currentAssetNotifier.notifier).setAsset(asset);
|
ref.read(currentAssetNotifier.notifier).setAsset(asset);
|
||||||
|
if (asset.isVideo) {
|
||||||
|
ref.read(videoPlaybackValueProvider.notifier).reset();
|
||||||
|
ref.read(videoPlayerControlsProvider.notifier).pause();
|
||||||
|
}
|
||||||
|
|
||||||
unawaited(ref.read(timelineServiceProvider).preCacheAssets(index));
|
unawaited(ref.read(timelineServiceProvider).preCacheAssets(index));
|
||||||
_cancelTimers();
|
_cancelTimers();
|
||||||
// This will trigger the pre-caching of adjacent assets ensuring
|
// This will trigger the pre-caching of adjacent assets ensuring
|
||||||
|
|
@ -455,11 +465,25 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _onScaleStateChanged(PhotoViewScaleState scaleState) {
|
||||||
|
if (scaleState != PhotoViewScaleState.initial) {
|
||||||
|
ref.read(videoPlayerControlsProvider.notifier).pause();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
PhotoViewGalleryPageOptions _assetBuilder(BuildContext ctx, int index) {
|
PhotoViewGalleryPageOptions _assetBuilder(BuildContext ctx, int index) {
|
||||||
scaffoldContext ??= ctx;
|
scaffoldContext ??= ctx;
|
||||||
final asset = ref.read(timelineServiceProvider).getAsset(index);
|
final asset = ref.read(timelineServiceProvider).getAsset(index);
|
||||||
final size = Size(ctx.width, ctx.height);
|
|
||||||
|
|
||||||
|
if (asset.isImage) {
|
||||||
|
return _imageBuilder(ctx, asset);
|
||||||
|
}
|
||||||
|
|
||||||
|
return _videoBuilder(ctx, asset);
|
||||||
|
}
|
||||||
|
|
||||||
|
PhotoViewGalleryPageOptions _imageBuilder(BuildContext ctx, BaseAsset asset) {
|
||||||
|
final size = Size(ctx.width, ctx.height);
|
||||||
return PhotoViewGalleryPageOptions(
|
return PhotoViewGalleryPageOptions(
|
||||||
key: ValueKey(asset.heroTag),
|
key: ValueKey(asset.heroTag),
|
||||||
imageProvider: getFullImageProvider(asset, size: size),
|
imageProvider: getFullImageProvider(asset, size: size),
|
||||||
|
|
@ -486,6 +510,43 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
GlobalKey _getVideoPlayerKey(String id) {
|
||||||
|
videoPlayerKeys.putIfAbsent(id, () => GlobalKey());
|
||||||
|
return videoPlayerKeys[id]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
PhotoViewGalleryPageOptions _videoBuilder(BuildContext ctx, BaseAsset asset) {
|
||||||
|
return PhotoViewGalleryPageOptions.customChild(
|
||||||
|
onDragStart: _onDragStart,
|
||||||
|
onDragUpdate: _onDragUpdate,
|
||||||
|
onDragEnd: _onDragEnd,
|
||||||
|
onTapDown: _onTapDown,
|
||||||
|
heroAttributes: PhotoViewHeroAttributes(tag: asset.heroTag),
|
||||||
|
filterQuality: FilterQuality.high,
|
||||||
|
initialScale: PhotoViewComputedScale.contained * 0.99,
|
||||||
|
maxScale: 1.0,
|
||||||
|
minScale: PhotoViewComputedScale.contained * 0.99,
|
||||||
|
basePosition: Alignment.center,
|
||||||
|
child: SizedBox(
|
||||||
|
width: ctx.width,
|
||||||
|
height: ctx.height,
|
||||||
|
child: NativeVideoViewer(
|
||||||
|
key: _getVideoPlayerKey(asset.heroTag),
|
||||||
|
asset: asset,
|
||||||
|
image: Image(
|
||||||
|
key: ValueKey(asset),
|
||||||
|
image:
|
||||||
|
getFullImageProvider(asset, size: Size(ctx.width, ctx.height)),
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
height: ctx.height,
|
||||||
|
width: ctx.width,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
void _onPop<T>(bool didPop, T? result) {
|
void _onPop<T>(bool didPop, T? result) {
|
||||||
ref.read(currentAssetNotifier.notifier).dispose();
|
ref.read(currentAssetNotifier.notifier).dispose();
|
||||||
}
|
}
|
||||||
|
|
@ -518,6 +579,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||||
itemCount: totalAssets,
|
itemCount: totalAssets,
|
||||||
onPageChanged: _onPageChanged,
|
onPageChanged: _onPageChanged,
|
||||||
onPageBuild: _onPageBuild,
|
onPageBuild: _onPageBuild,
|
||||||
|
scaleStateChangedCallback: _onScaleStateChanged,
|
||||||
builder: _assetBuilder,
|
builder: _assetBuilder,
|
||||||
backgroundDecoration: BoxDecoration(color: backgroundColor),
|
backgroundDecoration: BoxDecoration(color: backgroundColor),
|
||||||
enablePanAlways: true,
|
enablePanAlways: true,
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
|
||||||
class AssetViewerState {
|
class AssetViewerState {
|
||||||
|
|
@ -63,6 +64,13 @@ class AssetViewerStateNotifier extends AutoDisposeNotifier<AssetViewerState> {
|
||||||
showingBottomSheet: showing,
|
showingBottomSheet: showing,
|
||||||
showingControls: showing ? true : state.showingControls,
|
showingControls: showing ? true : state.showingControls,
|
||||||
);
|
);
|
||||||
|
if (showing) {
|
||||||
|
ref.read(videoPlayerControlsProvider.notifier).pause();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void setControls(bool isShowing) {
|
||||||
|
state = state.copyWith(showingControls: isShowing);
|
||||||
}
|
}
|
||||||
|
|
||||||
void toggleControls() {
|
void toggleControls() {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_b
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
||||||
import 'package:immich_mobile/providers/user.provider.dart';
|
import 'package:immich_mobile/providers/user.provider.dart';
|
||||||
|
import 'package:immich_mobile/widgets/asset_viewer/video_controls.dart';
|
||||||
|
|
||||||
class ViewerBottomBar extends ConsumerWidget {
|
class ViewerBottomBar extends ConsumerWidget {
|
||||||
const ViewerBottomBar({super.key});
|
const ViewerBottomBar({super.key});
|
||||||
|
|
@ -65,12 +66,18 @@ class ViewerBottomBar extends ConsumerWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Container(
|
child: Container(
|
||||||
height: 80,
|
height: asset.isVideo ? 160 : 80,
|
||||||
color: Colors.black.withAlpha(125),
|
color: Colors.black.withAlpha(125),
|
||||||
child: Row(
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
if (asset.isVideo) const VideoControls(),
|
||||||
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
children: actions,
|
children: actions,
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,429 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
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/domain/models/asset/base_asset.model.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/setting.model.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||||
|
import 'package:immich_mobile/entities/store.entity.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
||||||
|
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||||
|
import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer_controls.widget.dart';
|
||||||
|
import 'package:immich_mobile/providers/app_settings.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/providers/cast.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
|
||||||
|
import 'package:immich_mobile/services/api.service.dart';
|
||||||
|
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||||
|
import 'package:immich_mobile/utils/debounce.dart';
|
||||||
|
import 'package:immich_mobile/utils/hooks/interval_hook.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:native_video_player/native_video_player.dart';
|
||||||
|
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||||
|
|
||||||
|
class NativeVideoViewer extends HookConsumerWidget {
|
||||||
|
final BaseAsset asset;
|
||||||
|
final bool showControls;
|
||||||
|
final int playbackDelayFactor;
|
||||||
|
final Widget image;
|
||||||
|
|
||||||
|
const NativeVideoViewer({
|
||||||
|
super.key,
|
||||||
|
required this.asset,
|
||||||
|
required this.image,
|
||||||
|
this.showControls = true,
|
||||||
|
this.playbackDelayFactor = 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final controller = useState<NativeVideoPlayerController?>(null);
|
||||||
|
final lastVideoPosition = useRef(-1);
|
||||||
|
final isBuffering = useRef(false);
|
||||||
|
|
||||||
|
// Used to track whether the video should play when the app
|
||||||
|
// is brought back to the foreground
|
||||||
|
final shouldPlayOnForeground = useRef(true);
|
||||||
|
|
||||||
|
// 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(currentAssetNotifier));
|
||||||
|
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.hasLocal);
|
||||||
|
|
||||||
|
final log = Logger('NativeVideoViewerPage');
|
||||||
|
|
||||||
|
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
|
||||||
|
|
||||||
|
Future<VideoSource?> createSource() async {
|
||||||
|
if (!context.mounted) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (asset.hasLocal && asset.livePhotoVideoId == null) {
|
||||||
|
final id = asset is LocalAsset
|
||||||
|
? (asset as LocalAsset).id
|
||||||
|
: (asset as RemoteAsset).localId!;
|
||||||
|
final file = await const StorageRepository().getFileForAsset(id);
|
||||||
|
if (file == null) {
|
||||||
|
throw Exception('No file found for the video');
|
||||||
|
}
|
||||||
|
|
||||||
|
final source = await VideoSource.init(
|
||||||
|
path: file.path,
|
||||||
|
type: VideoSourceType.file,
|
||||||
|
);
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
|
||||||
|
final remoteId = (asset as RemoteAsset).id;
|
||||||
|
|
||||||
|
// Use a network URL for the video player controller
|
||||||
|
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
|
||||||
|
final isOriginalVideo =
|
||||||
|
ref.read(settingsProvider).get<bool>(Setting.loadOriginalVideo);
|
||||||
|
final String postfixUrl =
|
||||||
|
isOriginalVideo ? 'original' : 'video/playback';
|
||||||
|
final String videoUrl = asset.livePhotoVideoId != null
|
||||||
|
? '$serverEndpoint/assets/${asset.livePhotoVideoId}/$postfixUrl'
|
||||||
|
: '$serverEndpoint/assets/$remoteId/$postfixUrl';
|
||||||
|
|
||||||
|
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.name}: $error',
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final videoSource = useMemoized<Future<VideoSource?>>(() => createSource());
|
||||||
|
final aspectRatio = useState<double?>(null);
|
||||||
|
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.name}: $error',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[asset.heroTag],
|
||||||
|
);
|
||||||
|
|
||||||
|
void checkIfBuffering() {
|
||||||
|
if (!context.mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final videoPlayback = ref.read(videoPlaybackValueProvider);
|
||||||
|
if ((isBuffering.value ||
|
||||||
|
videoPlayback.state == VideoPlaybackState.initializing) &&
|
||||||
|
videoPlayback.state != VideoPlaybackState.buffering) {
|
||||||
|
ref.read(videoPlaybackValueProvider.notifier).value =
|
||||||
|
videoPlayback.copyWith(state: VideoPlaybackState.buffering);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timer to mark videos as buffering if the position does not change
|
||||||
|
useInterval(const Duration(seconds: 5), checkIfBuffering);
|
||||||
|
|
||||||
|
// When the position changes, seek to the position
|
||||||
|
// Debounce the seek to avoid seeking too often
|
||||||
|
// But also don't delay the seek too much to maintain visual feedback
|
||||||
|
final seekDebouncer = useDebouncer(
|
||||||
|
interval: const Duration(milliseconds: 100),
|
||||||
|
maxWaitTime: const Duration(milliseconds: 200),
|
||||||
|
);
|
||||||
|
ref.listen(videoPlayerControlsProvider, (oldControls, newControls) async {
|
||||||
|
final playerController = controller.value;
|
||||||
|
if (playerController == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final playbackInfo = playerController.playbackInfo;
|
||||||
|
if (playbackInfo == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final oldSeek = (oldControls?.position ?? 0) ~/ 1;
|
||||||
|
final newSeek = newControls.position ~/ 1;
|
||||||
|
if (oldSeek != newSeek || newControls.restarted) {
|
||||||
|
seekDebouncer.run(() => playerController.seekTo(newSeek));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oldControls?.pause != newControls.pause || newControls.restarted) {
|
||||||
|
// Make sure the last seek is complete before pausing or playing
|
||||||
|
// Otherwise, `onPlaybackPositionChanged` can receive outdated events
|
||||||
|
if (seekDebouncer.isActive) {
|
||||||
|
await seekDebouncer.drain();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (newControls.pause) {
|
||||||
|
await playerController.pause();
|
||||||
|
} else {
|
||||||
|
await playerController.play();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log.severe('Error pausing or playing video: $error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
void onPlaybackReady() async {
|
||||||
|
final videoController = controller.value;
|
||||||
|
if (videoController == null || !isCurrent || !context.mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final videoPlayback =
|
||||||
|
VideoPlaybackValue.fromNativeController(videoController);
|
||||||
|
ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback;
|
||||||
|
|
||||||
|
if (ref.read(assetViewerProvider.select((s) => s.showingBottomSheet))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await videoController.play();
|
||||||
|
await videoController.setVolume(0.9);
|
||||||
|
} catch (error) {
|
||||||
|
log.severe('Error playing video: $error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void onPlaybackStatusChanged() {
|
||||||
|
final videoController = controller.value;
|
||||||
|
if (videoController == null || !context.mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final videoPlayback =
|
||||||
|
VideoPlaybackValue.fromNativeController(videoController);
|
||||||
|
if (videoPlayback.state == VideoPlaybackState.playing) {
|
||||||
|
// Sync with the controls playing
|
||||||
|
WakelockPlus.enable();
|
||||||
|
} else {
|
||||||
|
// Sync with the controls pause
|
||||||
|
WakelockPlus.disable();
|
||||||
|
}
|
||||||
|
|
||||||
|
ref.read(videoPlaybackValueProvider.notifier).status =
|
||||||
|
videoPlayback.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
void onPlaybackPositionChanged() {
|
||||||
|
// When seeking, these events sometimes move the slider to an older position
|
||||||
|
if (seekDebouncer.isActive) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final videoController = controller.value;
|
||||||
|
if (videoController == null || !context.mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final playbackInfo = videoController.playbackInfo;
|
||||||
|
if (playbackInfo == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ref.read(videoPlaybackValueProvider.notifier).position =
|
||||||
|
Duration(seconds: playbackInfo.position);
|
||||||
|
|
||||||
|
// Check if the video is buffering
|
||||||
|
if (playbackInfo.status == PlaybackStatus.playing) {
|
||||||
|
isBuffering.value = lastVideoPosition.value == playbackInfo.position;
|
||||||
|
lastVideoPosition.value = playbackInfo.position;
|
||||||
|
} else {
|
||||||
|
isBuffering.value = false;
|
||||||
|
lastVideoPosition.value = -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void onPlaybackEnded() {
|
||||||
|
final videoController = controller.value;
|
||||||
|
if (videoController == null || !context.mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (videoController.playbackInfo?.status == PlaybackStatus.stopped &&
|
||||||
|
!ref
|
||||||
|
.read(appSettingsServiceProvider)
|
||||||
|
.getSetting<bool>(AppSettingsEnum.loopVideo)) {
|
||||||
|
ref.read(isPlayingMotionVideoProvider.notifier).playing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void removeListeners(NativeVideoPlayerController controller) {
|
||||||
|
controller.onPlaybackPositionChanged
|
||||||
|
.removeListener(onPlaybackPositionChanged);
|
||||||
|
controller.onPlaybackStatusChanged
|
||||||
|
.removeListener(onPlaybackStatusChanged);
|
||||||
|
controller.onPlaybackReady.removeListener(onPlaybackReady);
|
||||||
|
controller.onPlaybackEnded.removeListener(onPlaybackEnded);
|
||||||
|
}
|
||||||
|
|
||||||
|
void initController(NativeVideoPlayerController nc) async {
|
||||||
|
if (controller.value != null || !context.mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ref.read(videoPlayerControlsProvider.notifier).reset();
|
||||||
|
ref.read(videoPlaybackValueProvider.notifier).reset();
|
||||||
|
|
||||||
|
final source = await videoSource;
|
||||||
|
if (source == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
nc.onPlaybackPositionChanged.addListener(onPlaybackPositionChanged);
|
||||||
|
nc.onPlaybackStatusChanged.addListener(onPlaybackStatusChanged);
|
||||||
|
nc.onPlaybackReady.addListener(onPlaybackReady);
|
||||||
|
nc.onPlaybackEnded.addListener(onPlaybackEnded);
|
||||||
|
|
||||||
|
nc.loadVideoSource(source).catchError((error) {
|
||||||
|
log.severe('Error loading video source: $error');
|
||||||
|
});
|
||||||
|
final loopVideo = ref
|
||||||
|
.read(appSettingsServiceProvider)
|
||||||
|
.getSetting<bool>(AppSettingsEnum.loopVideo);
|
||||||
|
nc.setLoop(loopVideo);
|
||||||
|
|
||||||
|
controller.value = nc;
|
||||||
|
Timer(const Duration(milliseconds: 200), checkIfBuffering);
|
||||||
|
}
|
||||||
|
|
||||||
|
ref.listen(currentAssetNotifier, (_, value) {
|
||||||
|
final playerController = controller.value;
|
||||||
|
if (playerController != null && value != asset) {
|
||||||
|
removeListeners(playerController);
|
||||||
|
}
|
||||||
|
|
||||||
|
final curAsset = currentAsset.value;
|
||||||
|
if (curAsset == asset) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final imageToVideo = curAsset != null && !curAsset.isVideo;
|
||||||
|
|
||||||
|
// No need to delay video playback when swiping from an image to a video
|
||||||
|
if (imageToVideo && Platform.isIOS) {
|
||||||
|
currentAsset.value = value;
|
||||||
|
onPlaybackReady();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delay the video playback to avoid a stutter in the swipe animation
|
||||||
|
// Note, in some circumstances a longer delay is needed (eg: memories),
|
||||||
|
// the playbackDelayFactor can be used for this
|
||||||
|
// This delay seems like a hacky way to resolve underlying bugs in video
|
||||||
|
// playback, but other resolutions failed thus far
|
||||||
|
Timer(
|
||||||
|
Platform.isIOS
|
||||||
|
? Duration(milliseconds: 300 * playbackDelayFactor)
|
||||||
|
: imageToVideo
|
||||||
|
? Duration(milliseconds: 200 * playbackDelayFactor)
|
||||||
|
: Duration(milliseconds: 400 * playbackDelayFactor), () {
|
||||||
|
if (!context.mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentAsset.value = value;
|
||||||
|
if (currentAsset.value == asset) {
|
||||||
|
onPlaybackReady();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() {
|
||||||
|
// If opening a remote video from a hero animation, delay visibility to avoid a stutter
|
||||||
|
final timer = isVisible.value
|
||||||
|
? null
|
||||||
|
: Timer(
|
||||||
|
const Duration(milliseconds: 300),
|
||||||
|
() => isVisible.value = true,
|
||||||
|
);
|
||||||
|
|
||||||
|
return () {
|
||||||
|
timer?.cancel();
|
||||||
|
final playerController = controller.value;
|
||||||
|
if (playerController == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
removeListeners(playerController);
|
||||||
|
playerController.stop().catchError((error) {
|
||||||
|
log.fine('Error stopping video: $error');
|
||||||
|
});
|
||||||
|
|
||||||
|
WakelockPlus.disable();
|
||||||
|
};
|
||||||
|
},
|
||||||
|
const [],
|
||||||
|
);
|
||||||
|
|
||||||
|
useOnAppLifecycleStateChange((_, state) async {
|
||||||
|
if (state == AppLifecycleState.resumed && shouldPlayOnForeground.value) {
|
||||||
|
controller.value?.play();
|
||||||
|
} else if (state == AppLifecycleState.paused) {
|
||||||
|
final videoPlaying = await controller.value?.isPlaying();
|
||||||
|
if (videoPlaying ?? true) {
|
||||||
|
shouldPlayOnForeground.value = true;
|
||||||
|
controller.value?.pause();
|
||||||
|
} else {
|
||||||
|
shouldPlayOnForeground.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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.heroTag), child: image),
|
||||||
|
if (aspectRatio.value != null && !isCasting)
|
||||||
|
Visibility.maintain(
|
||||||
|
key: ValueKey(asset),
|
||||||
|
visible: 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: VideoViewerControls()),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,131 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/models/cast/cast_manager_state.dart';
|
||||||
|
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||||
|
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/cast.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.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';
|
||||||
|
|
||||||
|
class VideoViewerControls extends HookConsumerWidget {
|
||||||
|
final Duration hideTimerDuration;
|
||||||
|
|
||||||
|
const VideoViewerControls({
|
||||||
|
super.key,
|
||||||
|
this.hideTimerDuration = const Duration(seconds: 5),
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final assetIsVideo = ref.watch(
|
||||||
|
currentAssetNotifier.select((asset) => asset != null && asset.isVideo),
|
||||||
|
);
|
||||||
|
bool showControls =
|
||||||
|
ref.watch(assetViewerProvider.select((s) => s.showingControls));
|
||||||
|
final showBottomSheet =
|
||||||
|
ref.watch(assetViewerProvider.select((s) => s.showingBottomSheet));
|
||||||
|
if (showBottomSheet) {
|
||||||
|
showControls = false;
|
||||||
|
}
|
||||||
|
final VideoPlaybackState state =
|
||||||
|
ref.watch(videoPlaybackValueProvider.select((value) => value.state));
|
||||||
|
|
||||||
|
final cast = ref.watch(castProvider);
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
ref.read(assetViewerProvider.notifier).setControls(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
final showBuffering =
|
||||||
|
state == VideoPlaybackState.buffering && !cast.isCasting;
|
||||||
|
|
||||||
|
/// Shows the controls and starts the timer to hide them
|
||||||
|
void showControlsAndStartHideTimer() {
|
||||||
|
hideTimer.reset();
|
||||||
|
ref.read(assetViewerProvider.notifier).setControls(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// When we change position, show or hide timer
|
||||||
|
ref.listen(videoPlayerControlsProvider.select((v) => v.position),
|
||||||
|
(previous, next) {
|
||||||
|
showControlsAndStartHideTimer();
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Toggles between playing and pausing depending on the state of the video
|
||||||
|
void togglePlay() {
|
||||||
|
showControlsAndStartHideTimer();
|
||||||
|
|
||||||
|
if (cast.isCasting) {
|
||||||
|
if (cast.castState == CastState.playing) {
|
||||||
|
ref.read(castProvider.notifier).pause();
|
||||||
|
} else if (cast.castState == CastState.paused) {
|
||||||
|
ref.read(castProvider.notifier).play();
|
||||||
|
} else if (cast.castState == CastState.idle) {
|
||||||
|
// resend the play command since its finished
|
||||||
|
final asset = ref.read(currentAssetNotifier);
|
||||||
|
if (asset == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// ref.read(castProvider.notifier).loadMedia(asset, true);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state == VideoPlaybackState.playing) {
|
||||||
|
ref.read(videoPlayerControlsProvider.notifier).pause();
|
||||||
|
} else if (state == VideoPlaybackState.completed) {
|
||||||
|
ref.read(videoPlayerControlsProvider.notifier).restart();
|
||||||
|
} else {
|
||||||
|
ref.read(videoPlayerControlsProvider.notifier).play();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onTap: showControlsAndStartHideTimer,
|
||||||
|
child: AbsorbPointer(
|
||||||
|
absorbing: !showControls,
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
if (showBuffering)
|
||||||
|
const Center(
|
||||||
|
child: DelayedLoadingIndicator(
|
||||||
|
fadeInDuration: Duration(milliseconds: 400),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () =>
|
||||||
|
ref.read(assetViewerProvider.notifier).setControls(false),
|
||||||
|
child: CenterPlayButton(
|
||||||
|
backgroundColor: Colors.black54,
|
||||||
|
iconColor: Colors.white,
|
||||||
|
isFinished: state == VideoPlaybackState.completed,
|
||||||
|
isPlaying: state == VideoPlaybackState.playing ||
|
||||||
|
(cast.isCasting && cast.castState == CastState.playing),
|
||||||
|
show: assetIsVideo && showControls,
|
||||||
|
onPressed: togglePlay,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -175,7 +175,7 @@ class LocalFullImageProvider extends ImageProvider<LocalFullImageProvider> {
|
||||||
LocalFullImageProvider key,
|
LocalFullImageProvider key,
|
||||||
ImageDecoderCallback decode,
|
ImageDecoderCallback decode,
|
||||||
) async* {
|
) async* {
|
||||||
final file = await _storageRepository.getFileForAsset(key.asset);
|
final file = await _storageRepository.getFileForAsset(key.asset.id);
|
||||||
if (file == null) {
|
if (file == null) {
|
||||||
throw StateError("Opening file for asset ${key.asset.name} failed");
|
throw StateError("Opening file for asset ${key.asset.name} failed");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
|
import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/images/full_image.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/images/full_image.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
||||||
import 'package:immich_mobile/utils/hooks/blurhash_hook.dart';
|
import 'package:immich_mobile/utils/hooks/blurhash_hook.dart';
|
||||||
|
|
@ -67,26 +68,20 @@ class DriftMemoryCard extends StatelessWidget {
|
||||||
} else {
|
} else {
|
||||||
return Hero(
|
return Hero(
|
||||||
tag: 'memory-${asset.id}',
|
tag: 'memory-${asset.id}',
|
||||||
// child: SizedBox(
|
child: SizedBox(
|
||||||
// width: context.width,
|
width: context.width,
|
||||||
// height: context.height,
|
height: context.height,
|
||||||
// child: NativeVideoViewerPage(
|
child: NativeVideoViewer(
|
||||||
// key: ValueKey(asset.id),
|
key: ValueKey(asset.id),
|
||||||
// asset: asset,
|
asset: asset,
|
||||||
// showControls: false,
|
showControls: false,
|
||||||
// playbackDelayFactor: 2,
|
playbackDelayFactor: 2,
|
||||||
// image: ImmichImage(
|
image: FullImage(
|
||||||
// asset,
|
|
||||||
// width: context.width,
|
|
||||||
// height: context.height,
|
|
||||||
// fit: BoxFit.contain,
|
|
||||||
// ),
|
|
||||||
// ),
|
|
||||||
// ),
|
|
||||||
child: FullImage(
|
|
||||||
asset,
|
asset,
|
||||||
fit: fit,
|
size: Size(context.width, context.height),
|
||||||
size: const Size(double.infinity, double.infinity),
|
fit: BoxFit.contain,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||||
import 'package:immich_mobile/widgets/common/immich_sliver_app_bar.dart';
|
import 'package:immich_mobile/widgets/common/immich_sliver_app_bar.dart';
|
||||||
|
import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart';
|
||||||
import 'package:immich_mobile/widgets/common/selection_sliver_app_bar.dart';
|
import 'package:immich_mobile/widgets/common/selection_sliver_app_bar.dart';
|
||||||
|
|
||||||
class Timeline extends StatelessWidget {
|
class Timeline extends StatelessWidget {
|
||||||
|
|
@ -105,6 +106,10 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||||
onData: (segments) {
|
onData: (segments) {
|
||||||
final childCount = (segments.lastOrNull?.lastIndex ?? -1) + 1;
|
final childCount = (segments.lastOrNull?.lastIndex ?? -1) + 1;
|
||||||
final statusBarHeight = context.padding.top;
|
final statusBarHeight = context.padding.top;
|
||||||
|
final double appBarExpandedHeight =
|
||||||
|
widget.appBar != null && widget.appBar is MesmerizingSliverAppBar
|
||||||
|
? 200
|
||||||
|
: 0;
|
||||||
final totalAppBarHeight = statusBarHeight + kToolbarHeight;
|
final totalAppBarHeight = statusBarHeight + kToolbarHeight;
|
||||||
const scrubberBottomPadding = 100.0;
|
const scrubberBottomPadding = 100.0;
|
||||||
|
|
||||||
|
|
@ -117,7 +122,8 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||||
timelineHeight: maxHeight,
|
timelineHeight: maxHeight,
|
||||||
topPadding: totalAppBarHeight + 10,
|
topPadding: totalAppBarHeight + 10,
|
||||||
bottomPadding: context.padding.bottom + scrubberBottomPadding,
|
bottomPadding: context.padding.bottom + scrubberBottomPadding,
|
||||||
monthSegmentSnappingOffset: widget.topSliverWidgetHeight,
|
monthSegmentSnappingOffset:
|
||||||
|
widget.topSliverWidgetHeight ?? 0 + appBarExpandedHeight,
|
||||||
child: CustomScrollView(
|
child: CustomScrollView(
|
||||||
primary: true,
|
primary: true,
|
||||||
cacheExtent: maxHeight * 2,
|
cacheExtent: maxHeight * 2,
|
||||||
|
|
|
||||||
|
|
@ -50,10 +50,9 @@ class _MesmerizingSliverAppBarState
|
||||||
final isMultiSelectEnabled =
|
final isMultiSelectEnabled =
|
||||||
ref.watch(multiSelectProvider.select((s) => s.isEnabled));
|
ref.watch(multiSelectProvider.select((s) => s.isEnabled));
|
||||||
|
|
||||||
return SliverAnimatedOpacity(
|
return isMultiSelectEnabled
|
||||||
duration: Durations.medium1,
|
? const SliverToBoxAdapter(child: SizedBox())
|
||||||
opacity: isMultiSelectEnabled ? 0 : 1,
|
: SliverAppBar(
|
||||||
sliver: SliverAppBar(
|
|
||||||
expandedHeight: 300.0,
|
expandedHeight: 300.0,
|
||||||
floating: false,
|
floating: false,
|
||||||
pinned: true,
|
pinned: true,
|
||||||
|
|
@ -87,10 +86,10 @@ class _MesmerizingSliverAppBarState
|
||||||
context.pop();
|
context.pop();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
flexibleSpace: LayoutBuilder(
|
flexibleSpace: Builder(
|
||||||
builder: (context, constraints) {
|
builder: (context) {
|
||||||
final settings = context
|
final settings = context.dependOnInheritedWidgetOfExactType<
|
||||||
.dependOnInheritedWidgetOfExactType<FlexibleSpaceBarSettings>();
|
FlexibleSpaceBarSettings>();
|
||||||
final scrollProgress = _calculateScrollProgress(settings);
|
final scrollProgress = _calculateScrollProgress(settings);
|
||||||
|
|
||||||
// Update scroll progress for the leading button
|
// Update scroll progress for the leading button
|
||||||
|
|
@ -126,7 +125,6 @@ class _MesmerizingSliverAppBarState
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ class PlatformAsset {
|
||||||
final int? width;
|
final int? width;
|
||||||
final int? height;
|
final int? height;
|
||||||
final int durationInSeconds;
|
final int durationInSeconds;
|
||||||
|
final int orientation;
|
||||||
|
|
||||||
const PlatformAsset({
|
const PlatformAsset({
|
||||||
required this.id,
|
required this.id,
|
||||||
|
|
@ -33,6 +34,7 @@ class PlatformAsset {
|
||||||
this.width,
|
this.width,
|
||||||
this.height,
|
this.height,
|
||||||
this.durationInSeconds = 0,
|
this.durationInSeconds = 0,
|
||||||
|
this.orientation = 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,7 @@ void main() {
|
||||||
.thenAnswer((_) async => [album]);
|
.thenAnswer((_) async => [album]);
|
||||||
when(() => mockAlbumRepo.getAssetsToHash(album.id))
|
when(() => mockAlbumRepo.getAssetsToHash(album.id))
|
||||||
.thenAnswer((_) async => [asset]);
|
.thenAnswer((_) async => [asset]);
|
||||||
when(() => mockStorageRepo.getFileForAsset(asset))
|
when(() => mockStorageRepo.getFileForAsset(asset.id))
|
||||||
.thenAnswer((_) async => null);
|
.thenAnswer((_) async => null);
|
||||||
|
|
||||||
await sut.hashAssets();
|
await sut.hashAssets();
|
||||||
|
|
@ -89,7 +89,7 @@ void main() {
|
||||||
.thenAnswer((_) async => [album]);
|
.thenAnswer((_) async => [album]);
|
||||||
when(() => mockAlbumRepo.getAssetsToHash(album.id))
|
when(() => mockAlbumRepo.getAssetsToHash(album.id))
|
||||||
.thenAnswer((_) async => [asset]);
|
.thenAnswer((_) async => [asset]);
|
||||||
when(() => mockStorageRepo.getFileForAsset(asset))
|
when(() => mockStorageRepo.getFileForAsset(asset.id))
|
||||||
.thenAnswer((_) async => mockFile);
|
.thenAnswer((_) async => mockFile);
|
||||||
when(() => mockNativeApi.hashPaths(['image-path'])).thenAnswer(
|
when(() => mockNativeApi.hashPaths(['image-path'])).thenAnswer(
|
||||||
(_) async => [hash],
|
(_) async => [hash],
|
||||||
|
|
@ -116,7 +116,7 @@ void main() {
|
||||||
.thenAnswer((_) async => [album]);
|
.thenAnswer((_) async => [album]);
|
||||||
when(() => mockAlbumRepo.getAssetsToHash(album.id))
|
when(() => mockAlbumRepo.getAssetsToHash(album.id))
|
||||||
.thenAnswer((_) async => [asset]);
|
.thenAnswer((_) async => [asset]);
|
||||||
when(() => mockStorageRepo.getFileForAsset(asset))
|
when(() => mockStorageRepo.getFileForAsset(asset.id))
|
||||||
.thenAnswer((_) async => mockFile);
|
.thenAnswer((_) async => mockFile);
|
||||||
when(() => mockNativeApi.hashPaths(['image-path']))
|
when(() => mockNativeApi.hashPaths(['image-path']))
|
||||||
.thenAnswer((_) async => [null]);
|
.thenAnswer((_) async => [null]);
|
||||||
|
|
@ -141,7 +141,7 @@ void main() {
|
||||||
.thenAnswer((_) async => [album]);
|
.thenAnswer((_) async => [album]);
|
||||||
when(() => mockAlbumRepo.getAssetsToHash(album.id))
|
when(() => mockAlbumRepo.getAssetsToHash(album.id))
|
||||||
.thenAnswer((_) async => [asset]);
|
.thenAnswer((_) async => [asset]);
|
||||||
when(() => mockStorageRepo.getFileForAsset(asset))
|
when(() => mockStorageRepo.getFileForAsset(asset.id))
|
||||||
.thenAnswer((_) async => mockFile);
|
.thenAnswer((_) async => mockFile);
|
||||||
|
|
||||||
final invalidHash = Uint8List.fromList([1, 2, 3]);
|
final invalidHash = Uint8List.fromList([1, 2, 3]);
|
||||||
|
|
@ -180,9 +180,9 @@ void main() {
|
||||||
.thenAnswer((_) async => [album]);
|
.thenAnswer((_) async => [album]);
|
||||||
when(() => mockAlbumRepo.getAssetsToHash(album.id))
|
when(() => mockAlbumRepo.getAssetsToHash(album.id))
|
||||||
.thenAnswer((_) async => [asset1, asset2]);
|
.thenAnswer((_) async => [asset1, asset2]);
|
||||||
when(() => mockStorageRepo.getFileForAsset(asset1))
|
when(() => mockStorageRepo.getFileForAsset(asset1.id))
|
||||||
.thenAnswer((_) async => mockFile1);
|
.thenAnswer((_) async => mockFile1);
|
||||||
when(() => mockStorageRepo.getFileForAsset(asset2))
|
when(() => mockStorageRepo.getFileForAsset(asset2.id))
|
||||||
.thenAnswer((_) async => mockFile2);
|
.thenAnswer((_) async => mockFile2);
|
||||||
|
|
||||||
final hash = Uint8List.fromList(List.generate(20, (i) => i));
|
final hash = Uint8List.fromList(List.generate(20, (i) => i));
|
||||||
|
|
@ -220,9 +220,9 @@ void main() {
|
||||||
.thenAnswer((_) async => [album]);
|
.thenAnswer((_) async => [album]);
|
||||||
when(() => mockAlbumRepo.getAssetsToHash(album.id))
|
when(() => mockAlbumRepo.getAssetsToHash(album.id))
|
||||||
.thenAnswer((_) async => [asset1, asset2]);
|
.thenAnswer((_) async => [asset1, asset2]);
|
||||||
when(() => mockStorageRepo.getFileForAsset(asset1))
|
when(() => mockStorageRepo.getFileForAsset(asset1.id))
|
||||||
.thenAnswer((_) async => mockFile1);
|
.thenAnswer((_) async => mockFile1);
|
||||||
when(() => mockStorageRepo.getFileForAsset(asset2))
|
when(() => mockStorageRepo.getFileForAsset(asset2.id))
|
||||||
.thenAnswer((_) async => mockFile2);
|
.thenAnswer((_) async => mockFile2);
|
||||||
|
|
||||||
final hash = Uint8List.fromList(List.generate(20, (i) => i));
|
final hash = Uint8List.fromList(List.generate(20, (i) => i));
|
||||||
|
|
@ -252,9 +252,9 @@ void main() {
|
||||||
.thenAnswer((_) async => [album]);
|
.thenAnswer((_) async => [album]);
|
||||||
when(() => mockAlbumRepo.getAssetsToHash(album.id))
|
when(() => mockAlbumRepo.getAssetsToHash(album.id))
|
||||||
.thenAnswer((_) async => [asset1, asset2]);
|
.thenAnswer((_) async => [asset1, asset2]);
|
||||||
when(() => mockStorageRepo.getFileForAsset(asset1))
|
when(() => mockStorageRepo.getFileForAsset(asset1.id))
|
||||||
.thenAnswer((_) async => mockFile1);
|
.thenAnswer((_) async => mockFile1);
|
||||||
when(() => mockStorageRepo.getFileForAsset(asset2))
|
when(() => mockStorageRepo.getFileForAsset(asset2.id))
|
||||||
.thenAnswer((_) async => mockFile2);
|
.thenAnswer((_) async => mockFile2);
|
||||||
|
|
||||||
final validHash = Uint8List.fromList(List.generate(20, (i) => i));
|
final validHash = Uint8List.fromList(List.generate(20, (i) => i));
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { Kysely, sql } from 'kysely';
|
||||||
|
|
||||||
|
export async function up(db: Kysely<any>): Promise<void> {
|
||||||
|
await sql`UPDATE "migration_overrides" SET "value" = '{"type":"index","name":"idx_originalfilename_trigram","sql":"CREATE INDEX \\"idx_originalfilename_trigram\\" ON \\"assets\\" USING gin (f_unaccent(\\"originalFileName\\") gin_trgm_ops);"}'::jsonb WHERE "name" = 'index_idx_originalfilename_trigram';`.execute(db);
|
||||||
|
await sql`UPDATE "migration_overrides" SET "value" = '{"type":"index","name":"idx_local_date_time_month","sql":"CREATE INDEX \\"idx_local_date_time_month\\" ON \\"assets\\" ((date_trunc(''MONTH''::text, (\\"localDateTime\\" AT TIME ZONE ''UTC''::text)) AT TIME ZONE ''UTC''::text));"}'::jsonb WHERE "name" = 'index_idx_local_date_time_month';`.execute(db);
|
||||||
|
await sql`UPDATE "migration_overrides" SET "value" = '{"type":"index","name":"idx_local_date_time","sql":"CREATE INDEX \\"idx_local_date_time\\" ON \\"assets\\" (((\\"localDateTime\\" at time zone ''UTC'')::date));"}'::jsonb WHERE "name" = 'index_idx_local_date_time';`.execute(db);
|
||||||
|
await sql`UPDATE "migration_overrides" SET "value" = '{"type":"index","name":"UQ_assets_owner_library_checksum","sql":"CREATE UNIQUE INDEX \\"UQ_assets_owner_library_checksum\\" ON \\"assets\\" (\\"ownerId\\", \\"libraryId\\", \\"checksum\\") WHERE (\\"libraryId\\" IS NOT NULL);"}'::jsonb WHERE "name" = 'index_UQ_assets_owner_library_checksum';`.execute(db);
|
||||||
|
await sql`UPDATE "migration_overrides" SET "value" = '{"type":"index","name":"UQ_assets_owner_checksum","sql":"CREATE UNIQUE INDEX \\"UQ_assets_owner_checksum\\" ON \\"assets\\" (\\"ownerId\\", \\"checksum\\") WHERE (\\"libraryId\\" IS NULL);"}'::jsonb WHERE "name" = 'index_UQ_assets_owner_checksum';`.execute(db);
|
||||||
|
await sql`UPDATE "migration_overrides" SET "value" = '{"type":"index","name":"IDX_activity_like","sql":"CREATE UNIQUE INDEX \\"IDX_activity_like\\" ON \\"activity\\" (\\"assetId\\", \\"userId\\", \\"albumId\\") WHERE (\\"isLiked\\" = true);"}'::jsonb WHERE "name" = 'index_IDX_activity_like';`.execute(db);
|
||||||
|
await sql`UPDATE "migration_overrides" SET "value" = '{"type":"index","name":"face_index","sql":"CREATE INDEX \\"face_index\\" ON \\"face_search\\" USING hnsw (embedding vector_cosine_ops) WITH (ef_construction = 300, m = 16);"}'::jsonb WHERE "name" = 'index_face_index';`.execute(db);
|
||||||
|
await sql`UPDATE "migration_overrides" SET "value" = '{"type":"index","name":"IDX_geodata_gist_earthcoord","sql":"CREATE INDEX \\"IDX_geodata_gist_earthcoord\\" ON \\"geodata_places\\" (ll_to_earth_public(latitude, longitude));"}'::jsonb WHERE "name" = 'index_IDX_geodata_gist_earthcoord';`.execute(db);
|
||||||
|
await sql`UPDATE "migration_overrides" SET "value" = '{"type":"index","name":"idx_geodata_places_name","sql":"CREATE INDEX \\"idx_geodata_places_name\\" ON \\"geodata_places\\" USING gin (f_unaccent(\\"name\\") gin_trgm_ops);"}'::jsonb WHERE "name" = 'index_idx_geodata_places_name';`.execute(db);
|
||||||
|
await sql`UPDATE "migration_overrides" SET "value" = '{"type":"index","name":"idx_geodata_places_admin2_name","sql":"CREATE INDEX \\"idx_geodata_places_admin2_name\\" ON \\"geodata_places\\" USING gin (f_unaccent(\\"admin2Name\\") gin_trgm_ops);"}'::jsonb WHERE "name" = 'index_idx_geodata_places_admin2_name';`.execute(db);
|
||||||
|
await sql`UPDATE "migration_overrides" SET "value" = '{"type":"index","name":"idx_geodata_places_admin1_name","sql":"CREATE INDEX \\"idx_geodata_places_admin1_name\\" ON \\"geodata_places\\" USING gin (f_unaccent(\\"admin1Name\\") gin_trgm_ops);"}'::jsonb WHERE "name" = 'index_idx_geodata_places_admin1_name';`.execute(db);
|
||||||
|
await sql`UPDATE "migration_overrides" SET "value" = '{"type":"index","name":"idx_geodata_places_alternate_names","sql":"CREATE INDEX \\"idx_geodata_places_alternate_names\\" ON \\"geodata_places\\" USING gin (f_unaccent(\\"alternateNames\\") gin_trgm_ops);"}'::jsonb WHERE "name" = 'index_idx_geodata_places_alternate_names';`.execute(db);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(db: Kysely<any>): Promise<void> {
|
||||||
|
await sql`UPDATE "migration_overrides" SET "value" = '{"sql":"CREATE INDEX \\"idx_originalfilename_trigram\\" ON \\"assets\\" USING gin (f_unaccent(\\"originalFileName\\") gin_trgm_ops)","name":"idx_originalfilename_trigram","type":"index"}'::jsonb WHERE "name" = 'index_idx_originalfilename_trigram';`.execute(db);
|
||||||
|
await sql`UPDATE "migration_overrides" SET "value" = '{"sql":"CREATE INDEX \\"idx_local_date_time_month\\" ON \\"assets\\" ((date_trunc(''MONTH''::text, (\\"localDateTime\\" AT TIME ZONE ''UTC''::text)) AT TIME ZONE ''UTC''::text))","name":"idx_local_date_time_month","type":"index"}'::jsonb WHERE "name" = 'index_idx_local_date_time_month';`.execute(db);
|
||||||
|
await sql`UPDATE "migration_overrides" SET "value" = '{"sql":"CREATE INDEX \\"idx_local_date_time\\" ON \\"assets\\" (((\\"localDateTime\\" at time zone ''UTC'')::date))","name":"idx_local_date_time","type":"index"}'::jsonb WHERE "name" = 'index_idx_local_date_time';`.execute(db);
|
||||||
|
await sql`UPDATE "migration_overrides" SET "value" = '{"sql":"CREATE UNIQUE INDEX \\"UQ_assets_owner_library_checksum\\" ON \\"assets\\" (\\"ownerId\\", \\"libraryId\\", \\"checksum\\") WHERE (\\"libraryId\\" IS NOT NULL)","name":"UQ_assets_owner_library_checksum","type":"index"}'::jsonb WHERE "name" = 'index_UQ_assets_owner_library_checksum';`.execute(db);
|
||||||
|
await sql`UPDATE "migration_overrides" SET "value" = '{"sql":"CREATE UNIQUE INDEX \\"UQ_assets_owner_checksum\\" ON \\"assets\\" (\\"ownerId\\", \\"checksum\\") WHERE (\\"libraryId\\" IS NULL)","name":"UQ_assets_owner_checksum","type":"index"}'::jsonb WHERE "name" = 'index_UQ_assets_owner_checksum';`.execute(db);
|
||||||
|
await sql`UPDATE "migration_overrides" SET "value" = '{"sql":"CREATE UNIQUE INDEX \\"IDX_activity_like\\" ON \\"activity\\" (\\"assetId\\", \\"userId\\", \\"albumId\\") WHERE (\\"isLiked\\" = true)","name":"IDX_activity_like","type":"index"}'::jsonb WHERE "name" = 'index_IDX_activity_like';`.execute(db);
|
||||||
|
await sql`UPDATE "migration_overrides" SET "value" = '{"sql":"CREATE INDEX \\"face_index\\" ON \\"face_search\\" USING hnsw (embedding vector_cosine_ops) WITH (ef_construction = 300, m = 16)","name":"face_index","type":"index"}'::jsonb WHERE "name" = 'index_face_index';`.execute(db);
|
||||||
|
await sql`UPDATE "migration_overrides" SET "value" = '{"sql":"CREATE INDEX \\"IDX_geodata_gist_earthcoord\\" ON \\"geodata_places\\" (ll_to_earth_public(latitude, longitude))","name":"IDX_geodata_gist_earthcoord","type":"index"}'::jsonb WHERE "name" = 'index_IDX_geodata_gist_earthcoord';`.execute(db);
|
||||||
|
await sql`UPDATE "migration_overrides" SET "value" = '{"sql":"CREATE INDEX \\"idx_geodata_places_name\\" ON \\"geodata_places\\" USING gin (f_unaccent(\\"name\\") gin_trgm_ops)","name":"idx_geodata_places_name","type":"index"}'::jsonb WHERE "name" = 'index_idx_geodata_places_name';`.execute(db);
|
||||||
|
await sql`UPDATE "migration_overrides" SET "value" = '{"sql":"CREATE INDEX \\"idx_geodata_places_admin2_name\\" ON \\"geodata_places\\" USING gin (f_unaccent(\\"admin2Name\\") gin_trgm_ops)","name":"idx_geodata_places_admin2_name","type":"index"}'::jsonb WHERE "name" = 'index_idx_geodata_places_admin2_name';`.execute(db);
|
||||||
|
await sql`UPDATE "migration_overrides" SET "value" = '{"sql":"CREATE INDEX \\"idx_geodata_places_admin1_name\\" ON \\"geodata_places\\" USING gin (f_unaccent(\\"admin1Name\\") gin_trgm_ops)","name":"idx_geodata_places_admin1_name","type":"index"}'::jsonb WHERE "name" = 'index_idx_geodata_places_admin1_name';`.execute(db);
|
||||||
|
await sql`UPDATE "migration_overrides" SET "value" = '{"sql":"CREATE INDEX \\"idx_geodata_places_alternate_names\\" ON \\"geodata_places\\" USING gin (f_unaccent(\\"alternateNames\\") gin_trgm_ops)","name":"idx_geodata_places_alternate_names","type":"index"}'::jsonb WHERE "name" = 'index_idx_geodata_places_alternate_names';`.execute(db);
|
||||||
|
}
|
||||||
|
|
@ -5,15 +5,6 @@ import { compareTriggers } from 'src/sql-tools/comparers/trigger.comparer';
|
||||||
import { compare } from 'src/sql-tools/helpers';
|
import { compare } from 'src/sql-tools/helpers';
|
||||||
import { Comparer, DatabaseTable, Reason, SchemaDiff } from 'src/sql-tools/types';
|
import { Comparer, DatabaseTable, Reason, SchemaDiff } from 'src/sql-tools/types';
|
||||||
|
|
||||||
const newTable = (name: string) => ({
|
|
||||||
name,
|
|
||||||
columns: [],
|
|
||||||
indexes: [],
|
|
||||||
constraints: [],
|
|
||||||
triggers: [],
|
|
||||||
synchronize: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const compareTables: Comparer<DatabaseTable> = {
|
export const compareTables: Comparer<DatabaseTable> = {
|
||||||
onMissing: (source) => [
|
onMissing: (source) => [
|
||||||
{
|
{
|
||||||
|
|
@ -21,23 +12,20 @@ export const compareTables: Comparer<DatabaseTable> = {
|
||||||
table: source,
|
table: source,
|
||||||
reason: Reason.MissingInTarget,
|
reason: Reason.MissingInTarget,
|
||||||
},
|
},
|
||||||
// TODO merge constraints into table create record when possible
|
|
||||||
...compareTable(source, newTable(source.name), { columns: false }),
|
|
||||||
],
|
],
|
||||||
onExtra: (target) => [
|
onExtra: (target) => [
|
||||||
...compareTable(newTable(target.name), target, { columns: false }),
|
|
||||||
{
|
{
|
||||||
type: 'TableDrop',
|
type: 'TableDrop',
|
||||||
tableName: target.name,
|
tableName: target.name,
|
||||||
reason: Reason.MissingInSource,
|
reason: Reason.MissingInSource,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
onCompare: (source, target) => compareTable(source, target, { columns: true }),
|
onCompare: (source, target) => compareTable(source, target),
|
||||||
};
|
};
|
||||||
|
|
||||||
const compareTable = (source: DatabaseTable, target: DatabaseTable, options: { columns?: boolean }): SchemaDiff[] => {
|
const compareTable = (source: DatabaseTable, target: DatabaseTable): SchemaDiff[] => {
|
||||||
return [
|
return [
|
||||||
...(options.columns ? compare(source.columns, target.columns, {}, compareColumns) : []),
|
...compare(source.columns, target.columns, {}, compareColumns),
|
||||||
...compare(source.indexes, target.indexes, {}, compareIndexes),
|
...compare(source.indexes, target.indexes, {}, compareIndexes),
|
||||||
...compare(source.constraints, target.constraints, {}, compareConstraints),
|
...compare(source.constraints, target.constraints, {}, compareConstraints),
|
||||||
...compare(source.triggers, target.triggers, {}, compareTriggers),
|
...compare(source.triggers, target.triggers, {}, compareTriggers),
|
||||||
|
|
|
||||||
|
|
@ -20,12 +20,13 @@ export const transformConstraints: SqlTransformer = (ctx, item) => {
|
||||||
const withAction = (constraint: { onDelete?: ActionType; onUpdate?: ActionType }) =>
|
const withAction = (constraint: { onDelete?: ActionType; onUpdate?: ActionType }) =>
|
||||||
` ON UPDATE ${constraint.onUpdate ?? ActionType.NO_ACTION} ON DELETE ${constraint.onDelete ?? ActionType.NO_ACTION}`;
|
` ON UPDATE ${constraint.onUpdate ?? ActionType.NO_ACTION} ON DELETE ${constraint.onDelete ?? ActionType.NO_ACTION}`;
|
||||||
|
|
||||||
export const asConstraintAdd = (constraint: DatabaseConstraint): string | string[] => {
|
export const asConstraintBody = (constraint: DatabaseConstraint): string => {
|
||||||
const base = `ALTER TABLE "${constraint.tableName}" ADD CONSTRAINT "${constraint.name}"`;
|
const base = `CONSTRAINT "${constraint.name}"`;
|
||||||
|
|
||||||
switch (constraint.type) {
|
switch (constraint.type) {
|
||||||
case ConstraintType.PRIMARY_KEY: {
|
case ConstraintType.PRIMARY_KEY: {
|
||||||
const columnNames = asColumnList(constraint.columnNames);
|
const columnNames = asColumnList(constraint.columnNames);
|
||||||
return `${base} PRIMARY KEY (${columnNames});`;
|
return `${base} PRIMARY KEY (${columnNames})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
case ConstraintType.FOREIGN_KEY: {
|
case ConstraintType.FOREIGN_KEY: {
|
||||||
|
|
@ -33,26 +34,29 @@ export const asConstraintAdd = (constraint: DatabaseConstraint): string | string
|
||||||
const referenceColumnNames = asColumnList(constraint.referenceColumnNames);
|
const referenceColumnNames = asColumnList(constraint.referenceColumnNames);
|
||||||
return (
|
return (
|
||||||
`${base} FOREIGN KEY (${columnNames}) REFERENCES "${constraint.referenceTableName}" (${referenceColumnNames})` +
|
`${base} FOREIGN KEY (${columnNames}) REFERENCES "${constraint.referenceTableName}" (${referenceColumnNames})` +
|
||||||
withAction(constraint) +
|
withAction(constraint)
|
||||||
';'
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
case ConstraintType.UNIQUE: {
|
case ConstraintType.UNIQUE: {
|
||||||
const columnNames = asColumnList(constraint.columnNames);
|
const columnNames = asColumnList(constraint.columnNames);
|
||||||
return `${base} UNIQUE (${columnNames});`;
|
return `${base} UNIQUE (${columnNames})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
case ConstraintType.CHECK: {
|
case ConstraintType.CHECK: {
|
||||||
return `${base} CHECK (${constraint.expression});`;
|
return `${base} CHECK (${constraint.expression})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
default: {
|
default: {
|
||||||
return [];
|
throw new Error(`Unknown constraint type: ${(constraint as any).type}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const asConstraintAdd = (constraint: DatabaseConstraint): string | string[] => {
|
||||||
|
return `ALTER TABLE "${constraint.tableName}" ADD ${asConstraintBody(constraint)};`;
|
||||||
|
};
|
||||||
|
|
||||||
export const asConstraintDrop = (tableName: string, constraintName: string): string => {
|
export const asConstraintDrop = (tableName: string, constraintName: string): string => {
|
||||||
return `ALTER TABLE "${tableName}" DROP CONSTRAINT "${constraintName}";`;
|
return `ALTER TABLE "${tableName}" DROP CONSTRAINT "${constraintName}";`;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ describe(transformIndexes.name, () => {
|
||||||
},
|
},
|
||||||
reason: 'unknown',
|
reason: 'unknown',
|
||||||
}),
|
}),
|
||||||
).toEqual('CREATE INDEX "IDX_test" ON "table1" ("column1")');
|
).toEqual('CREATE INDEX "IDX_test" ON "table1" ("column1");');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should create an unique index', () => {
|
it('should create an unique index', () => {
|
||||||
|
|
@ -35,7 +35,7 @@ describe(transformIndexes.name, () => {
|
||||||
},
|
},
|
||||||
reason: 'unknown',
|
reason: 'unknown',
|
||||||
}),
|
}),
|
||||||
).toEqual('CREATE UNIQUE INDEX "IDX_test" ON "table1" ("column1")');
|
).toEqual('CREATE UNIQUE INDEX "IDX_test" ON "table1" ("column1");');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should create an index with a custom expression', () => {
|
it('should create an index with a custom expression', () => {
|
||||||
|
|
@ -51,7 +51,7 @@ describe(transformIndexes.name, () => {
|
||||||
},
|
},
|
||||||
reason: 'unknown',
|
reason: 'unknown',
|
||||||
}),
|
}),
|
||||||
).toEqual('CREATE INDEX "IDX_test" ON "table1" ("id" IS NOT NULL)');
|
).toEqual('CREATE INDEX "IDX_test" ON "table1" ("id" IS NOT NULL);');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should create an index with a where clause', () => {
|
it('should create an index with a where clause', () => {
|
||||||
|
|
@ -68,7 +68,7 @@ describe(transformIndexes.name, () => {
|
||||||
},
|
},
|
||||||
reason: 'unknown',
|
reason: 'unknown',
|
||||||
}),
|
}),
|
||||||
).toEqual('CREATE INDEX "IDX_test" ON "table1" ("id") WHERE ("id" IS NOT NULL)');
|
).toEqual('CREATE INDEX "IDX_test" ON "table1" ("id") WHERE ("id" IS NOT NULL);');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should create an index with a custom expression', () => {
|
it('should create an index with a custom expression', () => {
|
||||||
|
|
@ -85,7 +85,7 @@ describe(transformIndexes.name, () => {
|
||||||
},
|
},
|
||||||
reason: 'unknown',
|
reason: 'unknown',
|
||||||
}),
|
}),
|
||||||
).toEqual('CREATE INDEX "IDX_test" ON "table1" USING gin ("id" IS NOT NULL)');
|
).toEqual('CREATE INDEX "IDX_test" ON "table1" USING gin ("id" IS NOT NULL);');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ export const asIndexCreate = (index: DatabaseIndex): string => {
|
||||||
sql += ` WHERE ${index.where}`;
|
sql += ` WHERE ${index.where}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return sql;
|
return sql + ';';
|
||||||
};
|
};
|
||||||
|
|
||||||
export const asIndexDrop = (indexName: string): string => {
|
export const asIndexDrop = (indexName: string): string => {
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,69 @@
|
||||||
import { BaseContext } from 'src/sql-tools/contexts/base-context';
|
import { BaseContext } from 'src/sql-tools/contexts/base-context';
|
||||||
import { transformTables } from 'src/sql-tools/transformers/table.transformer';
|
import { transformTables } from 'src/sql-tools/transformers/table.transformer';
|
||||||
|
import { ConstraintType, DatabaseTable } from 'src/sql-tools/types';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
const ctx = new BaseContext({});
|
const ctx = new BaseContext({});
|
||||||
|
|
||||||
|
const table1: DatabaseTable = {
|
||||||
|
name: 'table1',
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
name: 'column1',
|
||||||
|
tableName: 'table1',
|
||||||
|
primary: true,
|
||||||
|
type: 'character varying',
|
||||||
|
nullable: true,
|
||||||
|
isArray: false,
|
||||||
|
synchronize: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'column2',
|
||||||
|
tableName: 'table1',
|
||||||
|
type: 'character varying',
|
||||||
|
nullable: true,
|
||||||
|
isArray: false,
|
||||||
|
synchronize: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
indexes: [
|
||||||
|
{
|
||||||
|
name: 'index1',
|
||||||
|
tableName: 'table1',
|
||||||
|
columnNames: ['column2'],
|
||||||
|
unique: false,
|
||||||
|
synchronize: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
constraints: [
|
||||||
|
{
|
||||||
|
name: 'constraint1',
|
||||||
|
tableName: 'table1',
|
||||||
|
columnNames: ['column1'],
|
||||||
|
type: ConstraintType.PRIMARY_KEY,
|
||||||
|
synchronize: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'constraint2',
|
||||||
|
tableName: 'table1',
|
||||||
|
columnNames: ['column1'],
|
||||||
|
type: ConstraintType.FOREIGN_KEY,
|
||||||
|
referenceTableName: 'table2',
|
||||||
|
referenceColumnNames: ['parentId'],
|
||||||
|
synchronize: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'constraint3',
|
||||||
|
tableName: 'table1',
|
||||||
|
columnNames: ['column1'],
|
||||||
|
type: ConstraintType.UNIQUE,
|
||||||
|
synchronize: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
triggers: [],
|
||||||
|
synchronize: true,
|
||||||
|
};
|
||||||
|
|
||||||
describe(transformTables.name, () => {
|
describe(transformTables.name, () => {
|
||||||
describe('TableDrop', () => {
|
describe('TableDrop', () => {
|
||||||
it('should work', () => {
|
it('should work', () => {
|
||||||
|
|
@ -22,26 +82,19 @@ describe(transformTables.name, () => {
|
||||||
expect(
|
expect(
|
||||||
transformTables(ctx, {
|
transformTables(ctx, {
|
||||||
type: 'TableCreate',
|
type: 'TableCreate',
|
||||||
table: {
|
table: table1,
|
||||||
name: 'table1',
|
|
||||||
columns: [
|
|
||||||
{
|
|
||||||
tableName: 'table1',
|
|
||||||
name: 'column1',
|
|
||||||
type: 'character varying',
|
|
||||||
nullable: true,
|
|
||||||
isArray: false,
|
|
||||||
synchronize: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
indexes: [],
|
|
||||||
constraints: [],
|
|
||||||
triggers: [],
|
|
||||||
synchronize: true,
|
|
||||||
},
|
|
||||||
reason: 'unknown',
|
reason: 'unknown',
|
||||||
}),
|
}),
|
||||||
).toEqual([`CREATE TABLE "table1" ("column1" character varying);`]);
|
).toEqual([
|
||||||
|
`CREATE TABLE "table1" (
|
||||||
|
"column1" character varying,
|
||||||
|
"column2" character varying,
|
||||||
|
CONSTRAINT "constraint1" PRIMARY KEY ("column1"),
|
||||||
|
CONSTRAINT "constraint2" FOREIGN KEY ("column1") REFERENCES "table2" ("parentId") ON UPDATE NO ACTION ON DELETE NO ACTION,
|
||||||
|
CONSTRAINT "constraint3" UNIQUE ("column1")
|
||||||
|
);`,
|
||||||
|
`CREATE INDEX "index1" ON "table1" ("column2");`,
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle a non-nullable column', () => {
|
it('should handle a non-nullable column', () => {
|
||||||
|
|
@ -67,7 +120,11 @@ describe(transformTables.name, () => {
|
||||||
},
|
},
|
||||||
reason: 'unknown',
|
reason: 'unknown',
|
||||||
}),
|
}),
|
||||||
).toEqual([`CREATE TABLE "table1" ("column1" character varying NOT NULL);`]);
|
).toEqual([
|
||||||
|
`CREATE TABLE "table1" (
|
||||||
|
"column1" character varying NOT NULL
|
||||||
|
);`,
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle a default value', () => {
|
it('should handle a default value', () => {
|
||||||
|
|
@ -94,7 +151,11 @@ describe(transformTables.name, () => {
|
||||||
},
|
},
|
||||||
reason: 'unknown',
|
reason: 'unknown',
|
||||||
}),
|
}),
|
||||||
).toEqual([`CREATE TABLE "table1" ("column1" character varying DEFAULT uuid_generate_v4());`]);
|
).toEqual([
|
||||||
|
`CREATE TABLE "table1" (
|
||||||
|
"column1" character varying DEFAULT uuid_generate_v4()
|
||||||
|
);`,
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle a string with a fixed length', () => {
|
it('should handle a string with a fixed length', () => {
|
||||||
|
|
@ -121,7 +182,11 @@ describe(transformTables.name, () => {
|
||||||
},
|
},
|
||||||
reason: 'unknown',
|
reason: 'unknown',
|
||||||
}),
|
}),
|
||||||
).toEqual([`CREATE TABLE "table1" ("column1" character varying(2));`]);
|
).toEqual([
|
||||||
|
`CREATE TABLE "table1" (
|
||||||
|
"column1" character varying(2)
|
||||||
|
);`,
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle an array type', () => {
|
it('should handle an array type', () => {
|
||||||
|
|
@ -147,7 +212,11 @@ describe(transformTables.name, () => {
|
||||||
},
|
},
|
||||||
reason: 'unknown',
|
reason: 'unknown',
|
||||||
}),
|
}),
|
||||||
).toEqual([`CREATE TABLE "table1" ("column1" character varying[]);`]);
|
).toEqual([
|
||||||
|
`CREATE TABLE "table1" (
|
||||||
|
"column1" character varying[]
|
||||||
|
);`,
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
import { asColumnComment, getColumnModifiers, getColumnType } from 'src/sql-tools/helpers';
|
import { asColumnComment, getColumnModifiers, getColumnType } from 'src/sql-tools/helpers';
|
||||||
import { asColumnAlter } from 'src/sql-tools/transformers/column.transformer';
|
import { asColumnAlter } from 'src/sql-tools/transformers/column.transformer';
|
||||||
|
import { asConstraintBody } from 'src/sql-tools/transformers/constraint.transformer';
|
||||||
|
import { asIndexCreate } from 'src/sql-tools/transformers/index.transformer';
|
||||||
|
import { asTriggerCreate } from 'src/sql-tools/transformers/trigger.transformer';
|
||||||
import { SqlTransformer } from 'src/sql-tools/transformers/types';
|
import { SqlTransformer } from 'src/sql-tools/transformers/types';
|
||||||
import { DatabaseTable } from 'src/sql-tools/types';
|
import { DatabaseTable } from 'src/sql-tools/types';
|
||||||
|
|
||||||
|
|
@ -19,26 +22,41 @@ export const transformTables: SqlTransformer = (ctx, item) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const asTableCreate = (table: DatabaseTable): string[] => {
|
const asTableCreate = (table: DatabaseTable) => {
|
||||||
const tableName = table.name;
|
const tableName = table.name;
|
||||||
const columnsTypes = table.columns
|
|
||||||
.map((column) => `"${column.name}" ${getColumnType(column)}` + getColumnModifiers(column))
|
const items: string[] = [];
|
||||||
.join(', ');
|
for (const column of table.columns) {
|
||||||
const items = [`CREATE TABLE "${tableName}" (${columnsTypes});`];
|
items.push(`"${column.name}" ${getColumnType(column)}${getColumnModifiers(column)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const constraint of table.constraints) {
|
||||||
|
items.push(asConstraintBody(constraint));
|
||||||
|
}
|
||||||
|
|
||||||
|
const sql = [`CREATE TABLE "${tableName}" (\n ${items.join(',\n ')}\n);`];
|
||||||
|
|
||||||
for (const column of table.columns) {
|
for (const column of table.columns) {
|
||||||
if (column.comment) {
|
if (column.comment) {
|
||||||
items.push(asColumnComment(tableName, column.name, column.comment));
|
sql.push(asColumnComment(tableName, column.name, column.comment));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (column.storage) {
|
if (column.storage) {
|
||||||
items.push(...asColumnAlter(tableName, column.name, { storage: column.storage }));
|
sql.push(...asColumnAlter(tableName, column.name, { storage: column.storage }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return items;
|
for (const index of table.indexes) {
|
||||||
|
sql.push(asIndexCreate(index));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const trigger of table.triggers) {
|
||||||
|
sql.push(asTriggerCreate(trigger));
|
||||||
|
}
|
||||||
|
|
||||||
|
return sql;
|
||||||
};
|
};
|
||||||
|
|
||||||
const asTableDrop = (tableName: string): string => {
|
const asTableDrop = (tableName: string) => {
|
||||||
return `DROP TABLE "${tableName}";`;
|
return `DROP TABLE "${tableName}";`;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@
|
||||||
prompt: isLocked ? $t('remove_from_locked_folder_confirmation') : $t('move_to_locked_folder_confirmation'),
|
prompt: isLocked ? $t('remove_from_locked_folder_confirmation') : $t('move_to_locked_folder_confirmation'),
|
||||||
confirmText: $t('move'),
|
confirmText: $t('move'),
|
||||||
confirmColor: isLocked ? 'danger' : 'primary',
|
confirmColor: isLocked ? 'danger' : 'primary',
|
||||||
|
icon: isLocked ? mdiLockOpenVariantOutline : mdiLockOutline,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!isConfirmed) {
|
if (!isConfirmed) {
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@
|
||||||
prompt: unlock ? $t('remove_from_locked_folder_confirmation') : $t('move_to_locked_folder_confirmation'),
|
prompt: unlock ? $t('remove_from_locked_folder_confirmation') : $t('move_to_locked_folder_confirmation'),
|
||||||
confirmText: $t('move'),
|
confirmText: $t('move'),
|
||||||
confirmColor: unlock ? 'danger' : 'primary',
|
confirmColor: unlock ? 'danger' : 'primary',
|
||||||
|
icon: unlock ? mdiLockOpenVariantOutline : mdiLockOutline,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!isConfirmed) {
|
if (!isConfirmed) {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import ConfirmModal from '$lib/modals/ConfirmModal.svelte';
|
import ConfirmModal from '$lib/modals/ConfirmModal.svelte';
|
||||||
import { showDeleteModal } from '$lib/stores/preferences.store';
|
import { showDeleteModal } from '$lib/stores/preferences.store';
|
||||||
import { Checkbox, Label } from '@immich/ui';
|
import { Checkbox, Label } from '@immich/ui';
|
||||||
|
import { mdiDeleteForeverOutline } from '@mdi/js';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -26,6 +27,7 @@
|
||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
title={$t('permanently_delete_assets_count', { values: { count: size } })}
|
title={$t('permanently_delete_assets_count', { values: { count: size } })}
|
||||||
confirmText={$t('delete')}
|
confirmText={$t('delete')}
|
||||||
|
icon={mdiDeleteForeverOutline}
|
||||||
onClose={(confirmed) => (confirmed ? handleConfirm() : onCancel())}
|
onClose={(confirmed) => (confirmed ? handleConfirm() : onCancel())}
|
||||||
>
|
>
|
||||||
{#snippet promptSnippet()}
|
{#snippet promptSnippet()}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import ConfirmModal from '$lib/modals/ConfirmModal.svelte';
|
import ConfirmModal from '$lib/modals/ConfirmModal.svelte';
|
||||||
|
import { mdiCalendarEditOutline } from '@mdi/js';
|
||||||
import { DateTime, Duration } from 'luxon';
|
import { DateTime, Duration } from 'luxon';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import DateInput from '../elements/date-input.svelte';
|
import DateInput from '../elements/date-input.svelte';
|
||||||
|
|
@ -178,6 +179,7 @@
|
||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
confirmColor="primary"
|
confirmColor="primary"
|
||||||
{title}
|
{title}
|
||||||
|
icon={mdiCalendarEditOutline}
|
||||||
prompt="Please select a new date:"
|
prompt="Please select a new date:"
|
||||||
disabled={!date.isValid}
|
disabled={!date.isValid}
|
||||||
onClose={(confirmed) => (confirmed ? handleConfirm() : onCancel())}
|
onClose={(confirmed) => (confirmed ? handleConfirm() : onCancel())}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
import { delay } from '$lib/utils/asset-utils';
|
import { delay } from '$lib/utils/asset-utils';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import { searchPlaces, type AssetResponseDto, type PlacesResponseDto } from '@immich/sdk';
|
import { searchPlaces, type AssetResponseDto, type PlacesResponseDto } from '@immich/sdk';
|
||||||
|
import { mdiMapMarkerMultipleOutline } from '@mdi/js';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import { get } from 'svelte/store';
|
import { get } from 'svelte/store';
|
||||||
interface Point {
|
interface Point {
|
||||||
|
|
@ -113,6 +114,7 @@
|
||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
confirmColor="primary"
|
confirmColor="primary"
|
||||||
title={$t('change_location')}
|
title={$t('change_location')}
|
||||||
|
icon={mdiMapMarkerMultipleOutline}
|
||||||
size="medium"
|
size="medium"
|
||||||
onClose={(confirmed) => (confirmed ? handleConfirm() : onCancel())}
|
onClose={(confirmed) => (confirmed ? handleConfirm() : onCancel())}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@
|
||||||
mdiFeatureSearchOutline,
|
mdiFeatureSearchOutline,
|
||||||
mdiKeyOutline,
|
mdiKeyOutline,
|
||||||
mdiLockSmart,
|
mdiLockSmart,
|
||||||
mdiOnepassword,
|
mdiFormTextboxPassword,
|
||||||
mdiServerOutline,
|
mdiServerOutline,
|
||||||
mdiTwoFactorAuthentication,
|
mdiTwoFactorAuthentication,
|
||||||
} from '@mdi/js';
|
} from '@mdi/js';
|
||||||
|
|
@ -124,7 +124,12 @@
|
||||||
</SettingAccordion>
|
</SettingAccordion>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<SettingAccordion icon={mdiOnepassword} key="password" title={$t('password')} subtitle={$t('change_your_password')}>
|
<SettingAccordion
|
||||||
|
icon={mdiFormTextboxPassword}
|
||||||
|
key="password"
|
||||||
|
title={$t('password')}
|
||||||
|
subtitle={$t('change_your_password')}
|
||||||
|
>
|
||||||
<ChangePasswordSettings />
|
<ChangePasswordSettings />
|
||||||
</SettingAccordion>
|
</SettingAccordion>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import ConfirmModal from '$lib/modals/ConfirmModal.svelte';
|
import ConfirmModal from '$lib/modals/ConfirmModal.svelte';
|
||||||
import { Input } from '@immich/ui';
|
import { Input } from '@immich/ui';
|
||||||
|
import { mdiText } from '@mdi/js';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -15,6 +16,7 @@
|
||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
confirmColor="primary"
|
confirmColor="primary"
|
||||||
title={$t('edit_description')}
|
title={$t('edit_description')}
|
||||||
|
icon={mdiText}
|
||||||
prompt={$t('edit_description_prompt')}
|
prompt={$t('edit_description_prompt')}
|
||||||
onClose={(confirmed) => (confirmed ? onClose(description) : onClose())}
|
onClose={(confirmed) => (confirmed ? onClose(description) : onClose())}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
confirmColor?: Color;
|
confirmColor?: Color;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
size?: 'small' | 'medium';
|
size?: 'small' | 'medium';
|
||||||
|
icon?: string;
|
||||||
onClose: (confirmed: boolean) => void;
|
onClose: (confirmed: boolean) => void;
|
||||||
promptSnippet?: Snippet;
|
promptSnippet?: Snippet;
|
||||||
}
|
}
|
||||||
|
|
@ -21,6 +22,7 @@
|
||||||
confirmColor = 'danger',
|
confirmColor = 'danger',
|
||||||
disabled = false,
|
disabled = false,
|
||||||
size = 'small',
|
size = 'small',
|
||||||
|
icon = undefined,
|
||||||
onClose,
|
onClose,
|
||||||
promptSnippet,
|
promptSnippet,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
@ -30,7 +32,7 @@
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Modal {title} onClose={() => onClose(false)} {size}>
|
<Modal {title} {icon} onClose={() => onClose(false)} {size}>
|
||||||
<ModalBody>
|
<ModalBody>
|
||||||
{#if promptSnippet}{@render promptSnippet()}{:else}
|
{#if promptSnippet}{@render promptSnippet()}{:else}
|
||||||
<p>{prompt}</p>
|
<p>{prompt}</p>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue