mirror of
https://github.com/immich-app/immich.git
synced 2026-07-08 05:17:54 -07:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 41a2fa8a44 | |||
| 7a9b9e01be | |||
| 0cbe07c4d4 | |||
| 45ea248e8b | |||
| 8cbf0db3b3 | |||
| a225c962de | |||
| daabab8c1c | |||
| a4f28dafb2 |
@@ -78,10 +78,10 @@ jobs:
|
||||
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0
|
||||
if: ${{ !github.event.pull_request.head.repo.fork }}
|
||||
with:
|
||||
registry: ghcr.io
|
||||
@@ -96,7 +96,7 @@ jobs:
|
||||
|
||||
- name: Generate docker image tags
|
||||
id: metadata
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
|
||||
with:
|
||||
flavor: |
|
||||
latest=false
|
||||
@@ -107,7 +107,7 @@ jobs:
|
||||
type=raw,value=latest,enable=${{ github.event_name == 'release' && !github.event.release.prerelease }}
|
||||
|
||||
- name: Build and push image
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
with:
|
||||
file: packages/cli/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
uses: github/codeql-action/init@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
@@ -70,7 +70,7 @@ jobs:
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
uses: github/codeql-action/autobuild@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||
@@ -83,6 +83,6 @@ jobs:
|
||||
# ./location_of_script_within_repo/buildscript.sh
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
uses: github/codeql-action/analyze@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
|
||||
with:
|
||||
category: '/language:${{matrix.language}}'
|
||||
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
suffix: ['', '-cuda', '-rocm', '-openvino', '-armnn', '-rknn']
|
||||
steps:
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -90,7 +90,7 @@ jobs:
|
||||
suffix: ['']
|
||||
steps:
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
|
||||
@@ -30,7 +30,7 @@ This environment includes the services below. Additional details are available i
|
||||
- Redis
|
||||
- PostgreSQL development database with exposed port `5432` so you can use any database client to access it
|
||||
|
||||
All the services are packaged to run as with single Docker Compose command.
|
||||
All the services are packaged to run with a single Docker Compose command.
|
||||
|
||||
:::tip mise
|
||||
[mise](https://mise.jdx.dev) is used throughout the project to manage tool versions and run tasks. [Install mise](https://mise.jdx.dev/installing-mise.html), then from the repo root run `mise trust` and `mise install` to get all required tools. Tasks for each service can be run from the repo root using `mise //namespace:task` (e.g. `mise //server:lint`). To list all available tasks, run `mise tasks ls --all`.
|
||||
@@ -41,7 +41,7 @@ All the services are packaged to run as with single Docker Compose command.
|
||||
1. Clone the project repo.
|
||||
2. Run `cp docker/example.env docker/.env`.
|
||||
3. Edit `docker/.env` to provide values for the required variable `UPLOAD_LOCATION`.
|
||||
4. Install dependencies - `pnpm i`
|
||||
4. Install dependencies - `mise x -- pnpm i`
|
||||
5. From the root directory, run:
|
||||
|
||||
```bash title="Start development server"
|
||||
@@ -52,7 +52,7 @@ mise dev
|
||||
|
||||
All the services will be started with hot-reloading enabled for a quick feedback loop.
|
||||
|
||||
You can access the web from `http://your-machine-ip:3000` or `http://localhost:3000` and access the server from the mobile app at `http://your-machine-ip:3000/api`
|
||||
You can access the web from `http://your-machine-ip:3000` or `http://localhost:3000` and access the server from the mobile app at `http://your-machine-ip:3000`
|
||||
|
||||
**Notes:**
|
||||
|
||||
|
||||
@@ -70,7 +70,8 @@
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
enableGPUValidationMode = "1"
|
||||
allowLocationSimulation = "YES">
|
||||
allowLocationSimulation = "YES"
|
||||
queueDebuggingEnabled = "NO">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
|
||||
@@ -1,19 +1,65 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
enum AspectRatioPreset {
|
||||
free(ratio: null, label: 'Free', icon: Icons.crop_free_rounded),
|
||||
square(ratio: 1.0, label: '1:1', icon: Icons.crop_square_rounded),
|
||||
ratio16x9(ratio: 16 / 9, label: '16:9', icon: Icons.crop_16_9_rounded),
|
||||
ratio3x2(ratio: 3 / 2, label: '3:2', icon: Icons.crop_3_2_rounded),
|
||||
ratio7x5(ratio: 7 / 5, label: '7:5', icon: Icons.crop_7_5_rounded),
|
||||
ratio9x16(ratio: 9 / 16, label: '9:16', icon: Icons.crop_16_9_rounded, iconRotated: true),
|
||||
ratio2x3(ratio: 2 / 3, label: '2:3', icon: Icons.crop_3_2_rounded, iconRotated: true),
|
||||
ratio5x7(ratio: 5 / 7, label: '5:7', icon: Icons.crop_7_5_rounded, iconRotated: true);
|
||||
class CropAspectRatio {
|
||||
final int? numerator;
|
||||
final int? denominator;
|
||||
|
||||
final double? ratio;
|
||||
final String label;
|
||||
final IconData icon;
|
||||
final bool iconRotated;
|
||||
final String? customLabel;
|
||||
final IconData? icon;
|
||||
|
||||
const AspectRatioPreset({required this.ratio, required this.label, required this.icon, this.iconRotated = false});
|
||||
const CropAspectRatio({this.numerator, this.denominator, this.customLabel, this.icon});
|
||||
|
||||
static const free = CropAspectRatio(customLabel: "Free", icon: Icons.crop_free);
|
||||
static const original = CropAspectRatio(customLabel: "Original", icon: Icons.crop_original);
|
||||
|
||||
String get label {
|
||||
return customLabel ?? (numerator != null && denominator != null ? '$numerator:$denominator' : 'Free');
|
||||
}
|
||||
|
||||
bool get hasFlippedVariant => numerator != denominator;
|
||||
double? get ratio => (numerator != null && denominator != null) ? numerator! / denominator! : null;
|
||||
|
||||
CropAspectRatio get flipped {
|
||||
return CropAspectRatio(numerator: denominator, denominator: numerator, customLabel: customLabel, icon: icon);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return other is CropAspectRatio &&
|
||||
other.numerator == numerator &&
|
||||
other.denominator == denominator &&
|
||||
other.customLabel == customLabel &&
|
||||
other.icon == icon;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return numerator.hashCode ^ denominator.hashCode ^ customLabel.hashCode ^ icon.hashCode;
|
||||
}
|
||||
}
|
||||
|
||||
const aspectRatioFree = CropAspectRatio(customLabel: "Free", icon: Icons.crop_free);
|
||||
const aspectRatioOriginal = CropAspectRatio(customLabel: "Original", icon: Icons.crop_original);
|
||||
|
||||
final aspectRatioPresets = [
|
||||
CropAspectRatio.free,
|
||||
CropAspectRatio.original,
|
||||
|
||||
const CropAspectRatio(numerator: 1, denominator: 1),
|
||||
|
||||
// lanscape
|
||||
const CropAspectRatio(numerator: 16, denominator: 9),
|
||||
const CropAspectRatio(numerator: 3, denominator: 2),
|
||||
const CropAspectRatio(numerator: 7, denominator: 5),
|
||||
const CropAspectRatio(numerator: 4, denominator: 3),
|
||||
|
||||
// portrait
|
||||
const CropAspectRatio(numerator: 16, denominator: 9).flipped,
|
||||
const CropAspectRatio(numerator: 3, denominator: 2).flipped,
|
||||
const CropAspectRatio(numerator: 7, denominator: 5).flipped,
|
||||
const CropAspectRatio(numerator: 4, denominator: 3).flipped,
|
||||
];
|
||||
|
||||
@@ -27,7 +27,6 @@ sealed class BaseAsset {
|
||||
final int? height;
|
||||
final int? durationMs;
|
||||
final bool isFavorite;
|
||||
final String? livePhotoVideoId;
|
||||
final bool isEdited;
|
||||
|
||||
const BaseAsset({
|
||||
@@ -40,32 +39,15 @@ sealed class BaseAsset {
|
||||
this.height,
|
||||
this.durationMs,
|
||||
this.isFavorite = false,
|
||||
this.livePhotoVideoId,
|
||||
required this.isEdited,
|
||||
});
|
||||
|
||||
bool get isImage => type == AssetType.image;
|
||||
bool get isVideo => type == AssetType.video;
|
||||
|
||||
bool get isMotionPhoto => livePhotoVideoId != null;
|
||||
bool get isMotionPhoto => playbackStyle == AssetPlaybackStyle.livePhoto;
|
||||
bool get isAnimatedImage => playbackStyle == AssetPlaybackStyle.imageAnimated;
|
||||
|
||||
AssetPlaybackStyle get playbackStyle {
|
||||
if (isVideo) {
|
||||
return AssetPlaybackStyle.video;
|
||||
}
|
||||
if (isMotionPhoto) {
|
||||
return AssetPlaybackStyle.livePhoto;
|
||||
}
|
||||
if (isImage && durationMs != null && durationMs! > 0) {
|
||||
return AssetPlaybackStyle.imageAnimated;
|
||||
}
|
||||
if (isImage) {
|
||||
return AssetPlaybackStyle.image;
|
||||
}
|
||||
return AssetPlaybackStyle.unknown;
|
||||
}
|
||||
|
||||
Duration get duration {
|
||||
final durationMs = this.durationMs;
|
||||
if (durationMs != null) {
|
||||
@@ -86,6 +68,7 @@ sealed class BaseAsset {
|
||||
String? get localId;
|
||||
String? get remoteId;
|
||||
String get heroTag;
|
||||
AssetPlaybackStyle get playbackStyle;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
|
||||
@@ -25,7 +25,6 @@ class LocalAsset extends BaseAsset {
|
||||
super.height,
|
||||
super.durationMs,
|
||||
super.isFavorite = false,
|
||||
super.livePhotoVideoId,
|
||||
this.orientation = 0,
|
||||
required this.playbackStyle,
|
||||
this.adjustmentTime,
|
||||
|
||||
@@ -10,6 +10,7 @@ class RemoteAsset extends BaseAsset {
|
||||
final AssetVisibility visibility;
|
||||
final String ownerId;
|
||||
final String? stackId;
|
||||
final String? livePhotoVideoId;
|
||||
final DateTime? uploadedAt;
|
||||
final DateTime? deletedAt;
|
||||
|
||||
@@ -29,7 +30,7 @@ class RemoteAsset extends BaseAsset {
|
||||
super.isFavorite = false,
|
||||
this.thumbHash,
|
||||
this.visibility = AssetVisibility.timeline,
|
||||
super.livePhotoVideoId,
|
||||
this.livePhotoVideoId,
|
||||
this.stackId,
|
||||
required super.isEdited,
|
||||
this.deletedAt,
|
||||
@@ -38,6 +39,23 @@ class RemoteAsset extends BaseAsset {
|
||||
@override
|
||||
String? get localId => localAssetId;
|
||||
|
||||
@override
|
||||
AssetPlaybackStyle get playbackStyle {
|
||||
if (isVideo) {
|
||||
return AssetPlaybackStyle.video;
|
||||
}
|
||||
if (livePhotoVideoId != null) {
|
||||
return AssetPlaybackStyle.livePhoto;
|
||||
}
|
||||
if (isImage && durationMs != null && durationMs! > 0) {
|
||||
return AssetPlaybackStyle.imageAnimated;
|
||||
}
|
||||
if (isImage) {
|
||||
return AssetPlaybackStyle.image;
|
||||
}
|
||||
return AssetPlaybackStyle.unknown;
|
||||
}
|
||||
|
||||
@override
|
||||
String? get remoteId => id;
|
||||
|
||||
@@ -90,6 +108,7 @@ class RemoteAsset extends BaseAsset {
|
||||
thumbHash == other.thumbHash &&
|
||||
visibility == other.visibility &&
|
||||
stackId == other.stackId &&
|
||||
livePhotoVideoId == other.livePhotoVideoId &&
|
||||
uploadedAt == other.uploadedAt &&
|
||||
deletedAt == other.deletedAt;
|
||||
}
|
||||
@@ -103,6 +122,7 @@ class RemoteAsset extends BaseAsset {
|
||||
thumbHash.hashCode ^
|
||||
visibility.hashCode ^
|
||||
stackId.hashCode ^
|
||||
livePhotoVideoId.hashCode ^
|
||||
uploadedAt.hashCode ^
|
||||
deletedAt.hashCode;
|
||||
|
||||
|
||||
@@ -26,6 +26,10 @@ class AssetService {
|
||||
return _localRepository.getByChecksum(checksum);
|
||||
}
|
||||
|
||||
Future<LocalAsset?> getLocalAsset(String id) {
|
||||
return _localRepository.get(id);
|
||||
}
|
||||
|
||||
Future<RemoteAsset?> getRemoteAssetByChecksum(String checksum) {
|
||||
return _remoteRepository.getByChecksum(checksum);
|
||||
}
|
||||
|
||||
@@ -85,8 +85,12 @@ class TimelineFactory {
|
||||
TimelineService fromAssetsWithBuckets(List<BaseAsset> assets, TimelineOrigin type) =>
|
||||
TimelineService(_timelineRepository.fromAssetsWithBuckets(assets, type));
|
||||
|
||||
TimelineService map(List<String> userIds, TimelineMapOptions options) =>
|
||||
TimelineService(_timelineRepository.map(userIds, options, groupBy));
|
||||
/// Creates a TimelineService for serving geographical map queries, such assets within bounded locations
|
||||
TimelineService geographicMap(
|
||||
List<String> userIds,
|
||||
TimelineMapOptions Function() currentOptions,
|
||||
Stream<TimelineMapOptions> optionsStream,
|
||||
) => TimelineService(_timelineRepository.geographicMap(userIds, currentOptions, optionsStream, groupBy));
|
||||
}
|
||||
|
||||
class TimelineService {
|
||||
|
||||
@@ -33,6 +33,7 @@ class StorageRepository {
|
||||
return file;
|
||||
}
|
||||
|
||||
// TODO(agg23): Unify these methods
|
||||
Future<File?> getMotionFileForAsset(LocalAsset asset) async {
|
||||
File? file;
|
||||
final log = Logger('StorageRepository');
|
||||
|
||||
@@ -509,9 +509,21 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
||||
return query.map((row) => row.toDto()).get();
|
||||
}
|
||||
|
||||
TimelineQuery map(List<String> userIds, TimelineMapOptions options, GroupAssetsBy groupBy) => (
|
||||
bucketSource: () => _watchMapBucket(userIds, options, groupBy: groupBy),
|
||||
assetSource: (offset, count) => _getMapBucketAssets(userIds, options, offset: offset, count: count),
|
||||
/// Creates a geographic map query that can dynamically filter on changing [TimelineMapOptions]
|
||||
/// (most notably the active map bounds)
|
||||
TimelineQuery geographicMap(
|
||||
List<String> userIds,
|
||||
TimelineMapOptions Function() currentOptions,
|
||||
Stream<TimelineMapOptions> optionsStream,
|
||||
GroupAssetsBy groupBy,
|
||||
) => (
|
||||
bucketSource: () => Stream.value(currentOptions())
|
||||
.followedBy(optionsStream)
|
||||
.switchMap(
|
||||
// Any error would kill the stream for all options; make sure the stream stays alive
|
||||
(options) => _watchMapBucket(userIds, options, groupBy: groupBy).handleError((_) {}),
|
||||
),
|
||||
assetSource: (offset, count) => _getMapBucketAssets(userIds, currentOptions(), offset: offset, count: count),
|
||||
origin: TimelineOrigin.map,
|
||||
);
|
||||
|
||||
|
||||
@@ -114,7 +114,6 @@ class _AssetPropertiesSectionState extends ConsumerState<_AssetPropertiesSection
|
||||
_PropertyItem(label: 'Height', value: asset.height?.toString()),
|
||||
_PropertyItem(label: 'Duration', value: asset.durationMs != null ? '${asset.durationMs} ms' : null),
|
||||
_PropertyItem(label: 'Is Favorite', value: asset.isFavorite.toString()),
|
||||
_PropertyItem(label: 'Live Photo Video ID', value: asset.livePhotoVideoId),
|
||||
_PropertyItem(label: 'Is Edited', value: asset.isEdited.toString()),
|
||||
]);
|
||||
}
|
||||
@@ -151,6 +150,7 @@ class _AssetPropertiesSectionState extends ConsumerState<_AssetPropertiesSection
|
||||
_PropertyItem(label: 'Thumb Hash', value: asset.thumbHash),
|
||||
_PropertyItem(label: 'Visibility', value: asset.visibility.toString()),
|
||||
_PropertyItem(label: 'Stack ID', value: asset.stackId),
|
||||
_PropertyItem(label: 'Live Photo Video ID', value: asset.livePhotoVideoId),
|
||||
];
|
||||
|
||||
properties.insertAll(4, additionalProps);
|
||||
|
||||
@@ -154,7 +154,7 @@ class _DriftEditImagePageState extends ConsumerState<DriftEditImagePage> with Ti
|
||||
}
|
||||
|
||||
class _AspectRatioButton extends StatelessWidget {
|
||||
final AspectRatioPreset ratio;
|
||||
final CropAspectRatio ratio;
|
||||
final bool isSelected;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
@@ -162,15 +162,16 @@ class _AspectRatioButton extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = isSelected ? context.primaryColor : context.themeData.iconTheme.color;
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
children: [
|
||||
IconButton(
|
||||
iconSize: 36,
|
||||
icon: Transform.rotate(
|
||||
angle: ratio.iconRotated ? pi / 2 : 0,
|
||||
child: Icon(ratio.icon, color: isSelected ? context.primaryColor : context.themeData.iconTheme.color),
|
||||
),
|
||||
icon: ratio.ratio != null
|
||||
? _AspectRatioRect(ratio: ratio.ratio!, color: color)
|
||||
: Icon(ratio.icon, color: color),
|
||||
onPressed: onPressed,
|
||||
),
|
||||
Text(ratio.label, style: context.textTheme.displayMedium),
|
||||
@@ -179,6 +180,32 @@ class _AspectRatioButton extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _AspectRatioRect extends StatelessWidget {
|
||||
final double ratio;
|
||||
final Color? color;
|
||||
|
||||
const _AspectRatioRect({required this.ratio, required this.color});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: 28,
|
||||
height: 28,
|
||||
child: Center(
|
||||
child: AspectRatio(
|
||||
aspectRatio: ratio,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: color ?? Colors.transparent, width: 3),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(4)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AspectRatioSelector extends ConsumerWidget {
|
||||
const _AspectRatioSelector();
|
||||
|
||||
@@ -187,22 +214,16 @@ class _AspectRatioSelector extends ConsumerWidget {
|
||||
final editorState = ref.watch(editorStateProvider);
|
||||
final editorNotifier = ref.read(editorStateProvider.notifier);
|
||||
|
||||
// the whole crop view is rotated, so we need to swap the aspect ratio when the rotation is 90 or 270 degrees
|
||||
double? selectedAspectRatio = editorState.aspectRatio;
|
||||
if (editorState.rotationAngle % 180 != 0 && selectedAspectRatio != null) {
|
||||
selectedAspectRatio = 1 / selectedAspectRatio;
|
||||
}
|
||||
|
||||
return SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: AspectRatioPreset.values.map((entry) {
|
||||
children: aspectRatioPresets.map((entry) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
child: _AspectRatioButton(
|
||||
ratio: entry,
|
||||
isSelected: selectedAspectRatio == entry.ratio,
|
||||
onPressed: () => editorNotifier.setAspectRatio(entry.ratio),
|
||||
isSelected: editorState.aspectRatio == entry,
|
||||
onPressed: () => editorNotifier.setAspectRatio(entry),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
@@ -357,8 +378,22 @@ class _EditorPreviewState extends ConsumerState<_EditorPreview> with TickerProvi
|
||||
final editorState = ref.watch(editorStateProvider);
|
||||
final editorNotifier = ref.read(editorStateProvider.notifier);
|
||||
|
||||
ref.listen(editorStateProvider, (_, current) {
|
||||
cropController.aspectRatio = current.aspectRatio;
|
||||
ref.listen(editorStateProvider, (previous, current) {
|
||||
// Only re-apply the aspect ratio when it changes, otherwise the crop rect will shrink on every rotation
|
||||
if (previous?.aspectRatio != current.aspectRatio) {
|
||||
double? ratio;
|
||||
|
||||
ratio = switch (current.aspectRatio) {
|
||||
CropAspectRatio.original => current.originalWidth / current.originalHeight,
|
||||
_ => current.aspectRatio.ratio,
|
||||
};
|
||||
|
||||
if (current.rotationAngle % 180 != 0) {
|
||||
ratio = ratio != null ? 1 / ratio : null;
|
||||
}
|
||||
|
||||
cropController.aspectRatio = ratio;
|
||||
}
|
||||
|
||||
if (cropController.crop != current.crop) {
|
||||
cropController.crop = current.crop;
|
||||
@@ -386,7 +421,9 @@ class _EditorPreviewState extends ConsumerState<_EditorPreview> with TickerProvi
|
||||
1.0,
|
||||
1.0,
|
||||
),
|
||||
child: Container(
|
||||
child: AnimatedContainer(
|
||||
duration: editorState.animationDuration,
|
||||
curve: Curves.easeInOut,
|
||||
padding: const EdgeInsets.all(10),
|
||||
width: (editorState.rotationAngle % 180 == 0) ? baseWidth : baseHeight,
|
||||
height: (editorState.rotationAngle % 180 == 0) ? baseHeight : baseWidth,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/aspect_ratios.dart';
|
||||
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
|
||||
import 'package:immich_mobile/domain/models/exif.model.dart';
|
||||
import 'package:immich_mobile/utils/editor.utils.dart';
|
||||
@@ -60,13 +61,8 @@ class EditorProvider extends Notifier<EditorState> {
|
||||
state = state.copyWith(crop: crop, hasUnsavedEdits: true);
|
||||
}
|
||||
|
||||
void setAspectRatio(double? aspectRatio) {
|
||||
if (aspectRatio != null && state.rotationAngle % 180 != 0) {
|
||||
// When rotated 90 or 270 degrees, swap width and height for aspect ratio calculations
|
||||
aspectRatio = 1 / aspectRatio;
|
||||
}
|
||||
|
||||
state = state.copyWith(aspectRatio: aspectRatio);
|
||||
void setAspectRatio(CropAspectRatio preset) {
|
||||
state = state.copyWith(aspectRatio: preset, hasUnsavedEdits: true);
|
||||
}
|
||||
|
||||
void resetEdits() {
|
||||
@@ -76,19 +72,19 @@ class EditorProvider extends Notifier<EditorState> {
|
||||
flipHorizontal: false,
|
||||
flipVertical: false,
|
||||
crop: const Rect.fromLTRB(0, 0, 1, 1),
|
||||
aspectRatio: null,
|
||||
aspectRatio: CropAspectRatio.free,
|
||||
hasUnsavedEdits: true,
|
||||
);
|
||||
}
|
||||
|
||||
void rotateCCW() {
|
||||
_animateRotation(state.rotationAngle - 90);
|
||||
state = state.copyWith(hasUnsavedEdits: true);
|
||||
state = state.copyWith(aspectRatio: state.aspectRatio.flipped, hasUnsavedEdits: true);
|
||||
}
|
||||
|
||||
void rotateCW() {
|
||||
_animateRotation(state.rotationAngle + 90);
|
||||
state = state.copyWith(hasUnsavedEdits: true);
|
||||
state = state.copyWith(aspectRatio: state.aspectRatio.flipped, hasUnsavedEdits: true);
|
||||
}
|
||||
|
||||
void flipHorizontally() {
|
||||
@@ -117,7 +113,7 @@ class EditorState {
|
||||
final bool flipHorizontal;
|
||||
final bool flipVertical;
|
||||
final Rect crop;
|
||||
final double? aspectRatio;
|
||||
final CropAspectRatio aspectRatio;
|
||||
|
||||
final int originalWidth;
|
||||
final int originalHeight;
|
||||
@@ -132,7 +128,7 @@ class EditorState {
|
||||
bool? flipHorizontal,
|
||||
bool? flipVertical,
|
||||
Rect? crop,
|
||||
this.aspectRatio,
|
||||
CropAspectRatio? aspectRatio,
|
||||
int? originalWidth,
|
||||
int? originalHeight,
|
||||
Duration? animationDuration,
|
||||
@@ -145,6 +141,7 @@ class EditorState {
|
||||
originalWidth = originalWidth ?? 0,
|
||||
originalHeight = originalHeight ?? 0,
|
||||
crop = crop ?? const Rect.fromLTRB(0, 0, 1, 1),
|
||||
aspectRatio = aspectRatio ?? CropAspectRatio.free,
|
||||
hasUnsavedEdits = hasUnsavedEdits ?? false;
|
||||
|
||||
EditorState copyWith({
|
||||
@@ -152,7 +149,7 @@ class EditorState {
|
||||
int? rotationAngle,
|
||||
bool? flipHorizontal,
|
||||
bool? flipVertical,
|
||||
double? aspectRatio = double.infinity,
|
||||
CropAspectRatio? aspectRatio,
|
||||
int? originalWidth,
|
||||
int? originalHeight,
|
||||
Duration? animationDuration,
|
||||
@@ -164,7 +161,7 @@ class EditorState {
|
||||
rotationAngle: rotationAngle ?? this.rotationAngle,
|
||||
flipHorizontal: flipHorizontal ?? this.flipHorizontal,
|
||||
flipVertical: flipVertical ?? this.flipVertical,
|
||||
aspectRatio: aspectRatio == double.infinity ? this.aspectRatio : aspectRatio,
|
||||
aspectRatio: aspectRatio ?? this.aspectRatio,
|
||||
animationDuration: animationDuration ?? this.animationDuration,
|
||||
originalWidth: originalWidth ?? this.originalWidth,
|
||||
originalHeight: originalHeight ?? this.originalHeight,
|
||||
|
||||
@@ -122,9 +122,14 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
|
||||
);
|
||||
}
|
||||
|
||||
if (videoAsset.hasLocal && videoAsset.livePhotoVideoId == null) {
|
||||
final id = videoAsset is LocalAsset ? videoAsset.id : (videoAsset as RemoteAsset).localId!;
|
||||
final file = await StorageRepository().getFileForAsset(id);
|
||||
// Attempt to retrieve LocalAsset, falling back to remote if it cannot be found
|
||||
final localAsset = await _localPlaybackAsset(videoAsset);
|
||||
|
||||
if (localAsset != null) {
|
||||
final file = localAsset.isMotionPhoto
|
||||
? await StorageRepository().getMotionFileForAsset(localAsset)
|
||||
: await StorageRepository().getFileForAsset(localAsset.id);
|
||||
|
||||
if (!mounted) {
|
||||
return null;
|
||||
}
|
||||
@@ -141,14 +146,13 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
|
||||
);
|
||||
}
|
||||
|
||||
final remoteId = (videoAsset as RemoteAsset).id;
|
||||
final remoteAsset = videoAsset as RemoteAsset;
|
||||
|
||||
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
|
||||
final isOriginalVideo = ref.read(appConfigProvider).viewer.loadOriginalVideo;
|
||||
final String postfixUrl = isOriginalVideo ? 'original' : 'video/playback';
|
||||
final String videoUrl = videoAsset.livePhotoVideoId != null
|
||||
? '$serverEndpoint/assets/${videoAsset.livePhotoVideoId}/$postfixUrl'
|
||||
: '$serverEndpoint/assets/$remoteId/$postfixUrl';
|
||||
final String assetId = remoteAsset.livePhotoVideoId ?? remoteAsset.id;
|
||||
final String videoUrl = '$serverEndpoint/assets/$assetId/$postfixUrl';
|
||||
|
||||
return VideoSource.init(path: videoUrl, type: VideoSourceType.network, headers: ApiService.getRequestHeaders());
|
||||
} catch (error) {
|
||||
@@ -157,6 +161,43 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
|
||||
}
|
||||
}
|
||||
|
||||
Future<LocalAsset?> _localPlaybackAsset(BaseAsset baseAsset) async {
|
||||
if (!baseAsset.hasLocal) {
|
||||
return null;
|
||||
}
|
||||
|
||||
LocalAsset? localAsset;
|
||||
|
||||
if (baseAsset is LocalAsset) {
|
||||
localAsset = baseAsset;
|
||||
} else {
|
||||
final localId = (baseAsset as RemoteAsset).localId;
|
||||
localAsset = localId != null ? await ref.read(assetServiceProvider).getLocalAsset(localId) : null;
|
||||
}
|
||||
|
||||
if (localAsset == null) {
|
||||
_log.severe(
|
||||
'Invariant violation: asset ${baseAsset.name} (${baseAsset.localId}) is marked `hasLocal` but local asset could not be retrieved',
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Clients (local) may not correctly recognize a given asset as a motion photo. This allows for a scenario where both remote and local
|
||||
// have the same asset (hash), but only the remote properly recognizes it as a motion asset
|
||||
// If this scenario occurs, fall back to using the remote asset
|
||||
if (baseAsset.isMotionPhoto && !localAsset.isMotionPhoto) {
|
||||
// Platform mismatch for motion photo, use remote instead
|
||||
_log.warning(
|
||||
'Mismatched local and remote motion states on ${baseAsset.name} (${baseAsset.localId}), local = ${localAsset.isMotionPhoto}, remote = ${baseAsset.isMotionPhoto}',
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return localAsset;
|
||||
}
|
||||
|
||||
void _onPlaybackReady() async {
|
||||
if (!mounted || !widget.isCurrent) {
|
||||
return;
|
||||
|
||||
@@ -35,7 +35,6 @@ class _ScopedMapTimeline extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// TODO: this causes the timeline to switch to flicker to "loading" state and back. This is both janky and inefficient.
|
||||
return ProviderScope(
|
||||
overrides: [
|
||||
timelineServiceProvider.overrideWith((ref) {
|
||||
@@ -44,13 +43,16 @@ class _ScopedMapTimeline extends StatelessWidget {
|
||||
throw Exception('User must be logged in to access archive');
|
||||
}
|
||||
|
||||
final users = ref.watch(mapStateProvider).withPartners
|
||||
? ref.watch(timelineUsersProvider).valueOrNull ?? [user.id]
|
||||
: [user.id];
|
||||
final withPartners = ref.watch(mapStateProvider.select((s) => s.withPartners));
|
||||
final users = withPartners ? ref.watch(timelineUsersProvider).valueOrNull ?? [user.id] : [user.id];
|
||||
|
||||
final timelineService = ref
|
||||
.watch(timelineFactoryProvider)
|
||||
.map(users, ref.watch(mapStateProvider).toOptions());
|
||||
.geographicMap(
|
||||
users,
|
||||
() => ref.read(mapStateProvider).toOptions(),
|
||||
ref.read(mapStateProvider.notifier).optionsStream,
|
||||
);
|
||||
ref.onDispose(timelineService.dispose);
|
||||
return timelineService;
|
||||
}),
|
||||
|
||||
@@ -297,8 +297,7 @@ class _AssetTypeIcons extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final remoteAsset = asset is RemoteAsset ? asset as RemoteAsset : null;
|
||||
final isLivePhoto = remoteAsset?.livePhotoVideoId != null;
|
||||
final isLivePhoto = asset.isMotionPhoto;
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/events.model.dart';
|
||||
@@ -63,11 +65,16 @@ class MapState {
|
||||
class MapStateNotifier extends Notifier<MapState> {
|
||||
MapStateNotifier();
|
||||
|
||||
final StreamController<TimelineMapOptions> _optionsController = StreamController.broadcast();
|
||||
|
||||
Stream<TimelineMapOptions> get optionsStream => _optionsController.stream;
|
||||
|
||||
bool setBounds(LatLngBounds bounds) {
|
||||
if (state.bounds == bounds) {
|
||||
return false;
|
||||
}
|
||||
state = state.copyWith(bounds: bounds);
|
||||
_optionsController.add(state.toOptions());
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -82,12 +89,14 @@ class MapStateNotifier extends Notifier<MapState> {
|
||||
void switchFavoriteOnly(bool isFavoriteOnly) {
|
||||
ref.read(settingsProvider).write(.mapShowFavoriteOnly, isFavoriteOnly);
|
||||
state = state.copyWith(onlyFavorites: isFavoriteOnly);
|
||||
_optionsController.add(state.toOptions());
|
||||
EventStream.shared.emit(const MapMarkerReloadEvent());
|
||||
}
|
||||
|
||||
void switchIncludeArchived(bool isIncludeArchived) {
|
||||
ref.read(settingsProvider).write(.mapIncludeArchived, isIncludeArchived);
|
||||
state = state.copyWith(includeArchived: isIncludeArchived);
|
||||
_optionsController.add(state.toOptions());
|
||||
EventStream.shared.emit(const MapMarkerReloadEvent());
|
||||
}
|
||||
|
||||
@@ -100,11 +109,13 @@ class MapStateNotifier extends Notifier<MapState> {
|
||||
void setRelativeTime(int relativeDays) {
|
||||
ref.read(settingsProvider).write(.mapRelativeDate, relativeDays);
|
||||
state = state.copyWith(relativeDays: relativeDays);
|
||||
_optionsController.add(state.toOptions());
|
||||
EventStream.shared.emit(const MapMarkerReloadEvent());
|
||||
}
|
||||
|
||||
@override
|
||||
MapState build() {
|
||||
ref.onDispose(_optionsController.close);
|
||||
final mapConfig = ref.read(appConfigProvider.select((config) => config.map));
|
||||
return MapState(
|
||||
themeMode: mapConfig.themeMode,
|
||||
|
||||
@@ -28,8 +28,8 @@ class SystemConfigIntegrityChecksumJob {
|
||||
/// Percentage limit of the integrity checksum job
|
||||
///
|
||||
/// Minimum value: 0
|
||||
/// Maximum value: 9007199254740991
|
||||
int percentageLimit;
|
||||
/// Maximum value: 1
|
||||
double percentageLimit;
|
||||
|
||||
/// How long the integrity checksum job may run for
|
||||
///
|
||||
@@ -75,7 +75,7 @@ class SystemConfigIntegrityChecksumJob {
|
||||
return SystemConfigIntegrityChecksumJob(
|
||||
cronExpression: mapValueOfType<String>(json, r'cronExpression')!,
|
||||
enabled: mapValueOfType<bool>(json, r'enabled')!,
|
||||
percentageLimit: mapValueOfType<int>(json, r'percentageLimit')!,
|
||||
percentageLimit: mapValueOfType<double>(json, r'percentageLimit')!,
|
||||
timeLimit: mapValueOfType<int>(json, r'timeLimit')!,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -70,4 +70,36 @@ void main() {
|
||||
expect((assets.first as RemoteAsset).id, asset.id);
|
||||
});
|
||||
});
|
||||
|
||||
group('live photos', () {
|
||||
test('remote-only live photo contains livePhotoVideoId and is marked as a motion photo', () async {
|
||||
final user = await ctx.newUser();
|
||||
final asset = await ctx.newRemoteAsset(ownerId: user.id, livePhotoVideoId: 'motion-photo-1');
|
||||
|
||||
final assets = await sut.main([user.id], .day).assetSource(0, 10);
|
||||
|
||||
expect(assets, hasLength(1));
|
||||
final remote = assets.single as RemoteAsset;
|
||||
expect(remote.id, asset.id);
|
||||
expect(remote.livePhotoVideoId, 'motion-photo-1');
|
||||
expect(remote.isMotionPhoto, isTrue);
|
||||
expect(remote.localId, isNull);
|
||||
});
|
||||
|
||||
test('merged live photo resolves localId and is marked as a motion photo', () async {
|
||||
final user = await ctx.newUser();
|
||||
const checksum = 'shared-live-photo-checksum';
|
||||
final asset = await ctx.newRemoteAsset(ownerId: user.id, checksum: checksum, livePhotoVideoId: 'motion-photo-2');
|
||||
final local = await ctx.newLocalAsset(checksum: checksum);
|
||||
|
||||
final assets = await sut.main([user.id], .day).assetSource(0, 10);
|
||||
|
||||
expect(assets, hasLength(1));
|
||||
final remote = assets.single as RemoteAsset;
|
||||
expect(remote.id, asset.id);
|
||||
expect(remote.livePhotoVideoId, 'motion-photo-2');
|
||||
expect(remote.isMotionPhoto, isTrue);
|
||||
expect(remote.localId, local.id);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -25917,9 +25917,10 @@
|
||||
},
|
||||
"percentageLimit": {
|
||||
"description": "Percentage limit of the integrity checksum job",
|
||||
"maximum": 9007199254740991,
|
||||
"format": "double",
|
||||
"maximum": 1,
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
"type": "number"
|
||||
},
|
||||
"timeLimit": {
|
||||
"description": "How long the integrity checksum job may run for",
|
||||
|
||||
@@ -74,7 +74,12 @@ const SystemConfigIntegrityJobSchema = z
|
||||
|
||||
const SystemConfigIntegrityChecksumJobSchema = SystemConfigIntegrityJobSchema.extend({
|
||||
timeLimit: z.int().nonnegative().describe('How long the integrity checksum job may run for'),
|
||||
percentageLimit: z.int().nonnegative().describe('Percentage limit of the integrity checksum job'),
|
||||
percentageLimit: z
|
||||
.float32()
|
||||
.nonnegative()
|
||||
.max(1)
|
||||
.describe('Percentage limit of the integrity checksum job')
|
||||
.meta({ format: 'double' }),
|
||||
})
|
||||
.describe('Integrity checksum job config')
|
||||
.meta({ id: 'SystemConfigIntegrityChecksumJob' });
|
||||
|
||||
@@ -407,7 +407,7 @@ export class DatabaseBackupService {
|
||||
progressCb?.('restore', progress);
|
||||
});
|
||||
|
||||
await pipeline(sqlStream, progressSource, psql, progressSink);
|
||||
await pipeline(sqlStream, createSqlOwnerTransformStream(databaseUsername), progressSource, psql, progressSink);
|
||||
|
||||
try {
|
||||
progressCb?.('migrations', 0.9);
|
||||
@@ -572,3 +572,69 @@ function createSqlProgressStreams(cb: (progress: number) => void) {
|
||||
|
||||
return [source, sink];
|
||||
}
|
||||
|
||||
function createSqlOwnerTransformStream(databaseUsername: string) {
|
||||
const OWNER_MARKER_START = new TextEncoder().encode('OWNER TO ');
|
||||
const DATA_MARKER_START = new TextEncoder().encode('FROM stdin');
|
||||
const LINE_END = new TextEncoder().encode(';');
|
||||
|
||||
const owner = new TextEncoder().encode(databaseUsername);
|
||||
|
||||
let ownerSequenceIndex = 0;
|
||||
|
||||
let replacingOwnerIndex = 0;
|
||||
let replacingOwner = false;
|
||||
|
||||
let readingDataIndex = 0;
|
||||
|
||||
let dataPart = false;
|
||||
|
||||
return new PassThrough({
|
||||
transform(chunk, _encoding, callback) {
|
||||
let result = chunk;
|
||||
if (!dataPart) {
|
||||
for (let index = 0; index < result.length; index++) {
|
||||
if (replacingOwner) {
|
||||
if (result[index] === LINE_END[0]) {
|
||||
result = Buffer.concat([result.slice(0, index), owner.slice(replacingOwnerIndex), result.slice(index)]);
|
||||
replacingOwnerIndex = owner.length;
|
||||
} else {
|
||||
result[index] = owner[replacingOwnerIndex];
|
||||
replacingOwnerIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
if (replacingOwnerIndex === owner.length) {
|
||||
replacingOwner = false;
|
||||
}
|
||||
|
||||
if (result[index] === OWNER_MARKER_START[ownerSequenceIndex]) {
|
||||
ownerSequenceIndex++;
|
||||
} else {
|
||||
ownerSequenceIndex = 0;
|
||||
}
|
||||
|
||||
if (ownerSequenceIndex === OWNER_MARKER_START.length) {
|
||||
ownerSequenceIndex = 0;
|
||||
replacingOwner = true;
|
||||
replacingOwnerIndex = 0;
|
||||
}
|
||||
|
||||
if (result[index] === DATA_MARKER_START[readingDataIndex]) {
|
||||
readingDataIndex++;
|
||||
} else {
|
||||
readingDataIndex = 0;
|
||||
}
|
||||
|
||||
if (readingDataIndex === DATA_MARKER_START.length) {
|
||||
dataPart = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.push(result);
|
||||
callback();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user