Merge branch 'main' of github.com:immich-app/immich into workflow-ui

This commit is contained in:
Alex Tran
2025-11-23 19:29:58 +00:00
8 changed files with 116 additions and 24 deletions

View File

@@ -50,7 +50,7 @@ const double kUploadStatusCanceled = -2.0;
const int kMinMonthsToEnableScrubberSnap = 12;
const String kImmichAppStoreLink = "https://apps.apple.com/app/immich/id6449244941";
const String kImmichAppStoreLink = "https://apps.apple.com/app/immich/id1613945652";
const String kImmichPlayStoreLink = "https://play.google.com/store/apps/details?id=app.alextran.immich";
const String kImmichLatestRelease = "https://github.com/immich-app/immich/releases/latest";

View File

@@ -265,7 +265,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
row.deletedAt.isNull() &
row.isFavorite.equals(true) &
row.ownerId.equals(userId) &
row.visibility.equalsValue(AssetVisibility.timeline),
(row.visibility.equalsValue(AssetVisibility.timeline) | row.visibility.equalsValue(AssetVisibility.archive)),
groupBy: groupBy,
origin: TimelineOrigin.favorite,
);

View File

@@ -24,6 +24,16 @@ class DriftMemoryPage extends HookConsumerWidget {
const DriftMemoryPage({required this.memories, required this.memoryIndex, super.key});
static void setMemory(WidgetRef ref, DriftMemory memory) {
if (memory.assets.isNotEmpty) {
ref.read(currentAssetNotifier.notifier).setAsset(memory.assets.first);
if (memory.assets.first.isVideo) {
ref.read(videoPlaybackValueProvider.notifier).reset();
}
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final currentMemory = useState(memories[memoryIndex]);
@@ -202,6 +212,10 @@ class DriftMemoryPage extends HookConsumerWidget {
if (pageNumber < memories.length) {
currentMemoryIndex.value = pageNumber;
currentMemory.value = memories[pageNumber];
WidgetsBinding.instance.addPostFrameCallback((_) {
DriftMemoryPage.setMemory(ref, memories[pageNumber]);
});
}
currentAssetPage.value = 0;

View File

@@ -3,10 +3,9 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/memory.model.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/pages/drift_memory.page.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/memory.provider.dart';
import 'package:immich_mobile/routing/router.dart';
@@ -31,16 +30,9 @@ class DriftMemoryLane extends ConsumerWidget {
overlayColor: WidgetStateProperty.all(Colors.white.withValues(alpha: 0.1)),
onTap: (index) {
ref.read(hapticFeedbackProvider.notifier).heavyImpact();
if (memories[index].assets.isNotEmpty) {
final asset = memories[index].assets[0];
ref.read(currentAssetNotifier.notifier).setAsset(asset);
if (asset.isVideo) {
ref.read(videoPlaybackValueProvider.notifier).reset();
DriftMemoryPage.setMemory(ref, memories[index]);
}
}
context.pushRoute(DriftMemoryRoute(memories: memories, memoryIndex: index));
},
children: memories

22
pnpm-lock.yaml generated
View File

@@ -726,19 +726,22 @@ importers:
specifier: ^7.4.47
version: 7.4.47
'@photo-sphere-viewer/core':
specifier: ^5.11.5
specifier: ^5.14.0
version: 5.14.0
'@photo-sphere-viewer/equirectangular-video-adapter':
specifier: ^5.11.5
specifier: ^5.14.0
version: 5.14.0(@photo-sphere-viewer/core@5.14.0)(@photo-sphere-viewer/video-plugin@5.14.0(@photo-sphere-viewer/core@5.14.0))
'@photo-sphere-viewer/markers-plugin':
specifier: ^5.14.0
version: 5.14.0(@photo-sphere-viewer/core@5.14.0)
'@photo-sphere-viewer/resolution-plugin':
specifier: ^5.11.5
specifier: ^5.14.0
version: 5.14.0(@photo-sphere-viewer/core@5.14.0)(@photo-sphere-viewer/settings-plugin@5.14.0(@photo-sphere-viewer/core@5.14.0))
'@photo-sphere-viewer/settings-plugin':
specifier: ^5.11.5
specifier: ^5.14.0
version: 5.14.0(@photo-sphere-viewer/core@5.14.0)
'@photo-sphere-viewer/video-plugin':
specifier: ^5.11.5
specifier: ^5.14.0
version: 5.14.0(@photo-sphere-viewer/core@5.14.0)
'@types/geojson':
specifier: ^7946.0.16
@@ -3725,6 +3728,11 @@ packages:
'@photo-sphere-viewer/core': 5.14.0
'@photo-sphere-viewer/video-plugin': 5.14.0
'@photo-sphere-viewer/markers-plugin@5.14.0':
resolution: {integrity: sha512-w7txVHtLxXMS61m0EbNjgvdNXQYRh6Aa0oatft5oruKgoXLg/UlCu1mG6Btg+zrNsG05W2zl4gRM3fcWoVdneA==}
peerDependencies:
'@photo-sphere-viewer/core': 5.14.0
'@photo-sphere-viewer/resolution-plugin@5.14.0':
resolution: {integrity: sha512-PvDMX1h+8FzWdySxiorQ2bSmyBGTPsZjNNFRBqIfmb5C+01aWCIE7kuXodXGHwpXQNcOojsVX9IiX0Vz4CiW4A==}
peerDependencies:
@@ -15769,6 +15777,10 @@ snapshots:
'@photo-sphere-viewer/video-plugin': 5.14.0(@photo-sphere-viewer/core@5.14.0)
three: 0.180.0
'@photo-sphere-viewer/markers-plugin@5.14.0(@photo-sphere-viewer/core@5.14.0)':
dependencies:
'@photo-sphere-viewer/core': 5.14.0
'@photo-sphere-viewer/resolution-plugin@5.14.0(@photo-sphere-viewer/core@5.14.0)(@photo-sphere-viewer/settings-plugin@5.14.0(@photo-sphere-viewer/core@5.14.0))':
dependencies:
'@photo-sphere-viewer/core': 5.14.0

View File

@@ -31,11 +31,12 @@
"@immich/ui": "^0.45.1",
"@mapbox/mapbox-gl-rtl-text": "0.2.3",
"@mdi/js": "^7.4.47",
"@photo-sphere-viewer/core": "^5.11.5",
"@photo-sphere-viewer/equirectangular-video-adapter": "^5.11.5",
"@photo-sphere-viewer/resolution-plugin": "^5.11.5",
"@photo-sphere-viewer/settings-plugin": "^5.11.5",
"@photo-sphere-viewer/video-plugin": "^5.11.5",
"@photo-sphere-viewer/core": "^5.14.0",
"@photo-sphere-viewer/equirectangular-video-adapter": "^5.14.0",
"@photo-sphere-viewer/markers-plugin": "^5.14.0",
"@photo-sphere-viewer/resolution-plugin": "^5.14.0",
"@photo-sphere-viewer/settings-plugin": "^5.14.0",
"@photo-sphere-viewer/video-plugin": "^5.14.0",
"@types/geojson": "^7946.0.16",
"@zoom-image/core": "^0.41.0",
"@zoom-image/svelte": "^0.3.0",

View File

@@ -39,13 +39,17 @@ export const shortcutLabel = (shortcut: Shortcut) => {
/** Determines whether an event should be ignored. The event will be ignored if:
* - The element dispatching the event is not the same as the element which the event listener is attached to
* - The element dispatching the event is an input field
* - The element dispatching the event is a map canvas
*/
export const shouldIgnoreEvent = (event: KeyboardEvent | ClipboardEvent): boolean => {
if (event.target === event.currentTarget) {
return false;
}
const type = (event.target as HTMLInputElement).type;
return ['textarea', 'text', 'date', 'datetime-local', 'email', 'password'].includes(type);
return (
['textarea', 'text', 'date', 'datetime-local', 'email', 'password'].includes(type) ||
(event.target instanceof HTMLCanvasElement && event.target.classList.contains('maplibregl-canvas'))
);
};
export const matchesShortcut = (event: KeyboardEvent, shortcut: Shortcut) => {

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import { boundingBoxesArray, type Faces } from '$lib/stores/people.store';
import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store';
import {
EquirectangularAdapter,
@@ -8,11 +9,21 @@
type PluginConstructor,
} from '@photo-sphere-viewer/core';
import '@photo-sphere-viewer/core/index.css';
import { MarkersPlugin } from '@photo-sphere-viewer/markers-plugin';
import '@photo-sphere-viewer/markers-plugin/index.css';
import { ResolutionPlugin } from '@photo-sphere-viewer/resolution-plugin';
import { SettingsPlugin } from '@photo-sphere-viewer/settings-plugin';
import '@photo-sphere-viewer/settings-plugin/index.css';
import { onDestroy, onMount } from 'svelte';
// Adapted as well as possible from classlist 'border-solid border-white border-3 rounded-lg'
const FACE_BOX_SVG_STYLE = {
fill: 'rgba(0, 0, 0, 0)',
stroke: '#ffffff',
strokeWidth: '3px',
strokeLinejoin: 'round',
};
interface Props {
panorama: string | { source: string };
originalPanorama?: string | { source: string };
@@ -26,6 +37,62 @@
let container: HTMLDivElement | undefined = $state();
let viewer: Viewer;
let animationInProgress: { cancel: () => void } | undefined;
let previousFaces: Faces[] = [];
const boundingBoxesUnsubscribe = boundingBoxesArray.subscribe((faces: Faces[]) => {
// Debounce; don't do anything when the data didn't actually change.
if (faces === previousFaces) {
return;
}
previousFaces = faces;
if (animationInProgress) {
animationInProgress.cancel();
animationInProgress = undefined;
}
if (!viewer || !viewer.state.textureData || !viewer.getPlugin(MarkersPlugin)) {
return;
}
const markersPlugin = viewer.getPlugin<MarkersPlugin>(MarkersPlugin);
// croppedWidth is the size of the texture, which might be cropped to be less than 360/180 degrees.
// This is what we want because the facial recognition is done on the image, not the sphere.
const currentTextureWidth = viewer.state.textureData.panoData.croppedWidth;
markersPlugin.clearMarkers();
for (const [index, face] of faces.entries()) {
const { boundingBoxX1: x1, boundingBoxY1: y1, boundingBoxX2: x2, boundingBoxY2: y2 } = face;
const ratio = currentTextureWidth / face.imageWidth;
// Pixel values are translated to spherical coordinates and only then added to the panorama;
// no need to recalculate when the texture image changes to the original size.
markersPlugin.addMarker({
id: `face_${index}`,
polygonPixels: [
[x1 * ratio, y1 * ratio],
[x2 * ratio, y1 * ratio],
[x2 * ratio, y2 * ratio],
[x1 * ratio, y2 * ratio],
],
svgStyle: FACE_BOX_SVG_STYLE,
});
}
// Smoothly pan to the highlighted (hovered-over) face.
if (faces.length === 1) {
const { boundingBoxX1: x1, boundingBoxY1: y1, boundingBoxX2: x2, boundingBoxY2: y2, imageWidth: w } = faces[0];
const ratio = currentTextureWidth / w;
const x = ((x1 + x2) * ratio) / 2;
const y = ((y1 + y2) * ratio) / 2;
animationInProgress = viewer.animate({
textureX: x,
textureY: y,
zoom: Math.min(viewer.getZoomLevel(), 75),
speed: 500, // duration in ms
});
}
});
onMount(() => {
if (!container) {
return;
@@ -34,6 +101,7 @@
viewer = new Viewer({
adapter,
plugins: [
MarkersPlugin,
SettingsPlugin,
[
ResolutionPlugin,
@@ -68,7 +136,7 @@
zoomSpeed: 0.5,
fisheye: false,
});
const resolutionPlugin = viewer.getPlugin(ResolutionPlugin) as ResolutionPlugin;
const resolutionPlugin = viewer.getPlugin<ResolutionPlugin>(ResolutionPlugin);
const zoomHandler = ({ zoomLevel }: events.ZoomUpdatedEvent) => {
// zoomLevel range: [0, 100]
if (Math.round(zoomLevel) >= 75) {
@@ -89,6 +157,7 @@
if (viewer) {
viewer.destroy();
}
boundingBoxesUnsubscribe();
});
</script>