Compare commits

..

4 Commits

Author SHA1 Message Date
mertalev
f987c5569d avoid fatal error 2026-03-13 17:41:22 -05:00
mertalev
d903f38aad update url 2026-03-13 17:33:02 -05:00
mertalev
a970b207d7 fix rebase 2026-03-11 12:54:38 -05:00
mertalev
3d9be2477b use shared session for widgets 2026-03-11 12:46:46 -05:00
40 changed files with 1084 additions and 1299 deletions

149
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,149 @@
name: release.yml
on:
pull_request:
types: [closed]
paths:
- CHANGELOG.md
jobs:
# Maybe double check PR source branch?
merge_translations:
uses: ./.github/workflows/merge-translations.yml
permissions:
pull-requests: write
secrets:
PUSH_O_MATIC_APP_ID: ${{ secrets.PUSH_O_MATIC_APP_ID }}
PUSH_O_MATIC_APP_KEY: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
WEBLATE_TOKEN: ${{ secrets.WEBLATE_TOKEN }}
build_mobile:
uses: ./.github/workflows/build-mobile.yml
needs: merge_translations
permissions:
contents: read
secrets:
KEY_JKS: ${{ secrets.KEY_JKS }}
ALIAS: ${{ secrets.ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
ANDROID_STORE_PASSWORD: ${{ secrets.ANDROID_STORE_PASSWORD }}
# iOS secrets
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }}
APP_STORE_CONNECT_API_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY }}
IOS_CERTIFICATE_P12: ${{ secrets.IOS_CERTIFICATE_P12 }}
IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
IOS_PROVISIONING_PROFILE: ${{ secrets.IOS_PROVISIONING_PROFILE }}
IOS_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_SHARE_EXTENSION }}misc/release/notes.tmpl
IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION }}
IOS_DEVELOPMENT_PROVISIONING_PROFILE: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE }}
IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION }}
IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION }}
FASTLANE_TEAM_ID: ${{ secrets.FASTLANE_TEAM_ID }}
with:
ref: main
environment: production
prepare_release:
runs-on: ubuntu-latest
needs: build_mobile
permissions:
actions: read # To download the app artifact
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
token: ${{ steps.generate-token.outputs.token }}
persist-credentials: false
ref: main
- name: Extract changelog
id: changelog
run: |
CHANGELOG_PATH=$RUNNER_TEMP/changelog.md
sed -n '1,/^---$/p' CHANGELOG.md | head -n -1 > $CHANGELOG_PATH
echo "path=$CHANGELOG_PATH" >> $GITHUB_OUTPUT
VERSION=$(sed -n 's/^# //p' $CHANGELOG_PATH)
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Download APK
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: release-apk-signed
github-token: ${{ steps.generate-token.outputs.token }}
- name: Create draft release
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
with:
tag_name: ${{ steps.version.outputs.result }}
token: ${{ steps.generate-token.outputs.token }}
body_path: ${{ steps.changelog.outputs.path }}
draft: true
files: |
docker/docker-compose.yml
docker/docker-compose.rootless.yml
docker/example.env
docker/hwaccel.ml.yml
docker/hwaccel.transcoding.yml
docker/prometheus.yml
*.apk
- name: Rename Outline document
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
continue-on-error: true
env:
OUTLINE_API_KEY: ${{ secrets.OUTLINE_API_KEY }}
VERSION: ${{ steps.changelog.outputs.version }}
with:
github-token: ${{ steps.generate-token.outputs.token }}
script: |
const outlineKey = process.env.OUTLINE_API_KEY;
const version = process.env.VERSION;
const parentDocumentId = 'da856355-0844-43df-bd71-f8edce5382d9';
const baseUrl = 'https://outline.immich.cloud';
const listResponse = await fetch(`${baseUrl}/api/documents.list`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${outlineKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ parentDocumentId })
});
if (!listResponse.ok) {
throw new Error(`Outline list failed: ${listResponse.statusText}`);
}
const listData = await listResponse.json();
const allDocuments = listData.data || [];
const document = allDocuments.find(doc => doc.title === 'next');
if (document) {
console.log(`Found document 'next', renaming to '${version}'...`);
const updateResponse = await fetch(`${baseUrl}/api/documents.update`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${outlineKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
id: document.id,
title: version
})
});
if (!updateResponse.ok) {
throw new Error(`Failed to rename document: ${updateResponse.statusText}`);
}
} else {
console.log('No document titled "next" found to rename');
}

View File

@@ -284,11 +284,7 @@ const createDefaultOwner = (ownerId: string) => {
* Convert a TimelineAssetConfig to a full AssetResponseDto
* This matches the response from GET /api/assets/:id
*/
export function toAssetResponseDto(
asset: MockTimelineAsset,
owner?: UserResponseDto,
overrides?: Partial<Pick<AssetResponseDto, 'people' | 'unassignedFaces'>>,
): AssetResponseDto {
export function toAssetResponseDto(asset: MockTimelineAsset, owner?: UserResponseDto): AssetResponseDto {
const now = new Date().toISOString();
// Default owner if not provided
@@ -342,8 +338,8 @@ export function toAssetResponseDto(
exifInfo,
livePhotoVideoId: asset.livePhotoVideoId,
tags: [],
people: overrides?.people ?? [],
unassignedFaces: overrides?.unassignedFaces ?? [],
people: [],
unassignedFaces: [],
stack: asset.stack,
isOffline: false,
hasMetadata: true,

View File

@@ -1,9 +1,3 @@
import {
type AssetFaceResponseDto,
type AssetFaceWithoutPersonResponseDto,
type AssetResponseDto,
type PersonWithFacesResponseDto,
} from '@immich/sdk';
import { BrowserContext } from '@playwright/test';
import { randomThumbnail } from 'src/ui/generators/timeline';
@@ -131,117 +125,3 @@ export const setupFaceEditorMockApiRoutes = async (
});
});
};
export type MockFaceSpec = {
personId: string;
personName: string;
faceId: string;
boundingBoxX1: number;
boundingBoxY1: number;
boundingBoxX2: number;
boundingBoxY2: number;
};
export const createMockFaceData = (
faceSpecs: MockFaceSpec[],
imageWidth: number,
imageHeight: number,
): { people: PersonWithFacesResponseDto[]; unassignedFaces: AssetFaceWithoutPersonResponseDto[] } => {
const people: PersonWithFacesResponseDto[] = faceSpecs.map((spec) => ({
id: spec.personId,
name: spec.personName,
birthDate: null,
isHidden: false,
thumbnailPath: `/upload/thumbs/${spec.personId}.jpeg`,
updatedAt: new Date().toISOString(),
faces: [
{
id: spec.faceId,
imageWidth,
imageHeight,
boundingBoxX1: spec.boundingBoxX1,
boundingBoxY1: spec.boundingBoxY1,
boundingBoxX2: spec.boundingBoxX2,
boundingBoxY2: spec.boundingBoxY2,
},
],
}));
return { people, unassignedFaces: [] };
};
export const setupFaceOverlayMockApiRoutes = async (
context: BrowserContext,
assetDto: AssetResponseDto,
faceSpecs: MockFaceSpec[],
) => {
const faceResponseMap = new Map<string, AssetFaceResponseDto>();
for (const spec of faceSpecs) {
faceResponseMap.set(spec.faceId, {
id: spec.faceId,
imageWidth: assetDto.width ?? 3000,
imageHeight: assetDto.height ?? 4000,
boundingBoxX1: spec.boundingBoxX1,
boundingBoxY1: spec.boundingBoxY1,
boundingBoxX2: spec.boundingBoxX2,
boundingBoxY2: spec.boundingBoxY2,
person: {
id: spec.personId,
name: spec.personName,
birthDate: null,
isHidden: false,
thumbnailPath: `/upload/thumbs/${spec.personId}.jpeg`,
updatedAt: new Date().toISOString(),
},
});
}
await context.route(`**/api/assets/${assetDto.id}`, async (route, request) => {
if (request.method() !== 'GET') {
return route.fallback();
}
return route.fulfill({
status: 200,
contentType: 'application/json',
json: assetDto,
});
});
await context.route(`**/api/faces?id=${assetDto.id}`, async (route, request) => {
if (request.method() !== 'GET') {
return route.fallback();
}
return route.fulfill({
status: 200,
contentType: 'application/json',
json: [...faceResponseMap.values()],
});
});
await context.route('**/api/faces/*', async (route, request) => {
if (request.method() !== 'DELETE') {
return route.fallback();
}
const url = new URL(request.url());
const faceId = url.pathname.split('/').at(-1);
if (faceId) {
faceResponseMap.delete(faceId);
}
return route.fulfill({
status: 200,
contentType: 'text/plain',
body: 'OK',
});
});
await context.route('**/api/people/*/thumbnail', async (route) => {
if (!route.request().serviceWorker()) {
return route.continue();
}
return route.fulfill({
status: 200,
headers: { 'content-type': 'image/jpeg' },
body: await randomThumbnail('person-thumb', 1),
});
});
};

View File

@@ -1,60 +0,0 @@
import { expect, test } from '@playwright/test';
import { toAssetResponseDto } from 'src/ui/generators/timeline';
import {
createMockFaceData,
type MockFaceSpec,
setupFaceOverlayMockApiRoutes,
} from 'src/ui/mock-network/face-editor-network';
import { assetViewerUtils } from '../timeline/utils';
import { ensureDetailPanelVisible, setupAssetViewerFixture } from './utils';
test.describe.configure({ mode: 'parallel' });
test.describe('face removal auto-close', () => {
const fixture = setupAssetViewerFixture(903);
const singleFaceSpec: MockFaceSpec[] = [
{
personId: 'person-solo',
personName: 'Solo Person',
faceId: 'face-solo',
boundingBoxX1: 1000,
boundingBoxY1: 500,
boundingBoxX2: 1500,
boundingBoxY2: 1200,
},
];
test.beforeEach(async ({ context }) => {
const faceData = createMockFaceData(
singleFaceSpec,
fixture.primaryAssetDto.width ?? 3000,
fixture.primaryAssetDto.height ?? 4000,
);
const assetDtoWithFaces = toAssetResponseDto(fixture.primaryAsset, undefined, faceData);
await setupFaceOverlayMockApiRoutes(context, assetDtoWithFaces, singleFaceSpec);
});
test('person side panel closes when last face is removed', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
await ensureDetailPanelVisible(page);
const editPeopleButton = page.locator('#detail-panel').getByLabel('Edit people');
await expect(editPeopleButton).toBeVisible();
await editPeopleButton.click();
const personName = page.locator('text=Solo Person');
await expect(personName.first()).toBeVisible({ timeout: 5000 });
const deleteButton = page.getByLabel('Delete face');
await expect(deleteButton).toBeVisible();
await deleteButton.click();
const confirmButton = page.getByRole('button', { name: /confirm/i });
await expect(confirmButton).toBeVisible();
await confirmButton.click();
await expect(page.locator('text=Edit faces')).toBeHidden({ timeout: 5000 });
});
});

View File

@@ -1,100 +0,0 @@
import { type AssetResponseDto } from '@immich/sdk';
import { expect, test } from '@playwright/test';
import { toAssetResponseDto } from 'src/ui/generators/timeline';
import {
createMockStack,
createMockStackAsset,
MockStack,
setupBrokenAssetMockApiRoutes,
} from 'src/ui/mock-network/broken-asset-network';
import {
createMockPeople,
FaceCreateCapture,
MockPerson,
setupFaceEditorMockApiRoutes,
} from 'src/ui/mock-network/face-editor-network';
import { assetViewerUtils } from '../timeline/utils';
import { ensureDetailPanelVisible, setupAssetViewerFixture } from './utils';
test.describe.configure({ mode: 'parallel' });
test.describe('stack face-tag selection preservation', () => {
const fixture = setupAssetViewerFixture(910);
let mockStack: MockStack;
let primaryAssetDto: AssetResponseDto;
let secondAssetDto: AssetResponseDto;
let mockPeople: MockPerson[];
let faceCreateCapture: FaceCreateCapture;
test.beforeAll(async () => {
primaryAssetDto = toAssetResponseDto(fixture.primaryAsset);
secondAssetDto = createMockStackAsset(fixture.adminUserId);
secondAssetDto.originalFileName = 'second-stacked-asset.jpg';
mockStack = createMockStack(primaryAssetDto, [secondAssetDto], new Set());
mockPeople = createMockPeople(3);
});
test.beforeEach(async ({ context }) => {
faceCreateCapture = { requests: [] };
await setupBrokenAssetMockApiRoutes(context, mockStack);
await setupFaceEditorMockApiRoutes(context, mockPeople, faceCreateCapture);
});
test('selected stacked asset is preserved after tagging a face', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
const stackSlideshow = page.locator('#stack-slideshow');
await expect(stackSlideshow).toBeVisible();
const stackThumbnails = stackSlideshow.locator('[data-asset]');
await expect(stackThumbnails).toHaveCount(2);
await stackThumbnails.nth(1).click();
await ensureDetailPanelVisible(page);
await expect(page.locator('#detail-panel')).toContainText('second-stacked-asset.jpg');
await page.getByLabel('Tag people').click();
await page.locator('#face-selector').waitFor({ state: 'visible' });
await page.locator('#face-selector').getByText(mockPeople[0].name).click();
const confirmButton = page.getByRole('button', { name: /confirm/i });
await expect(confirmButton).toBeVisible();
await confirmButton.click();
await expect(page.locator('#face-selector')).toBeHidden();
expect(faceCreateCapture.requests).toHaveLength(1);
expect(faceCreateCapture.requests[0].assetId).toBe(secondAssetDto.id);
await expect(page.locator('#detail-panel')).toContainText('second-stacked-asset.jpg');
const selectedThumbnail = stackSlideshow.locator(`[data-asset="${secondAssetDto.id}"]`);
await expect(selectedThumbnail).toBeVisible();
});
test('primary asset stays selected after tagging a face without switching', async ({ page }) => {
await page.goto(`/photos/${fixture.primaryAsset.id}`);
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
await ensureDetailPanelVisible(page);
await expect(page.locator('#detail-panel')).toContainText(primaryAssetDto.originalFileName);
await page.getByLabel('Tag people').click();
await page.locator('#face-selector').waitFor({ state: 'visible' });
await page.locator('#face-selector').getByText(mockPeople[0].name).click();
const confirmButton = page.getByRole('button', { name: /confirm/i });
await expect(confirmButton).toBeVisible();
await confirmButton.click();
await expect(page.locator('#face-selector')).toBeHidden();
expect(faceCreateCapture.requests).toHaveLength(1);
expect(faceCreateCapture.requests[0].assetId).toBe(primaryAssetDto.id);
await expect(page.locator('#detail-panel')).toContainText(primaryAssetDto.originalFileName);
});
});

View File

@@ -1007,8 +1007,6 @@
"editor_edits_applied_success": "Edits applied successfully",
"editor_flip_horizontal": "Flip horizontal",
"editor_flip_vertical": "Flip vertical",
"editor_handle_corner": "{corner, select, top_left {Top-left} top_right {Top-right} bottom_left {Bottom-left} bottom_right {Bottom-right} other {A}} corner handle",
"editor_handle_edge": "{edge, select, top {Top} bottom {Bottom} left {Left} right {Right} other {An}} edge handle",
"editor_orientation": "Orientation",
"editor_reset_all_changes": "Reset changes",
"editor_rotate_left": "Rotate 90° counterclockwise",

View File

@@ -63,7 +63,9 @@ object HttpClientManager {
private var initialized = false
private val clientChangedListeners = mutableListOf<() -> Unit>()
private lateinit var client: OkHttpClient
@JvmStatic
lateinit var client: OkHttpClient
private set
private lateinit var appContext: Context
private lateinit var prefs: SharedPreferences
@@ -79,6 +81,9 @@ object HttpClientManager {
val isMtls: Boolean get() = keyChainAlias != null || keyStore.containsAlias(CERT_ALIAS)
val serverUrl: String? get() = if (initialized) prefs.getString(PREFS_SERVER_URLS, null)
?.let { Json.decodeFromString<List<String>>(it).firstOrNull() } else null
fun initialize(context: Context) {
if (initialized) return
synchronized(this) {
@@ -163,11 +168,6 @@ object HttpClientManager {
private var clientGlobalRef: Long = 0L
@JvmStatic
fun getClient(): OkHttpClient {
return client
}
fun getClientPointer(): Long {
if (clientGlobalRef == 0L) {
clientGlobalRef = NativeBuffer.createGlobalRef(client)

View File

@@ -32,14 +32,18 @@ data class Request(
)
@RequiresApi(Build.VERSION_CODES.Q)
inline fun ImageDecoder.Source.decodeBitmap(target: Size = Size(0, 0)): Bitmap {
fun ImageDecoder.Source.decodeBitmap(
target: Size = Size(0, 0),
allocator: Int = ImageDecoder.ALLOCATOR_DEFAULT,
colorspace: ColorSpace? = null
): Bitmap {
return ImageDecoder.decodeBitmap(this) { decoder, info, _ ->
if (target.width > 0 && target.height > 0) {
val sample = max(1, min(info.size.width / target.width, info.size.height / target.height))
decoder.setTargetSampleSize(sample)
}
decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE
decoder.setTargetColorSpace(ColorSpace.get(ColorSpace.Named.SRGB))
decoder.allocator = allocator
decoder.setTargetColorSpace(colorspace)
}
}
@@ -228,7 +232,11 @@ class LocalImagesImpl(context: Context) : LocalImageApi {
private fun decodeSource(uri: Uri, target: Size, signal: CancellationSignal): Bitmap {
signal.throwIfCanceled()
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ImageDecoder.createSource(resolver, uri).decodeBitmap(target)
ImageDecoder.createSource(resolver, uri).decodeBitmap(
target,
ImageDecoder.ALLOCATOR_SOFTWARE,
ColorSpace.get(ColorSpace.Named.SRGB)
)
} else {
val ref =
Glide.with(ctx).asBitmap().priority(Priority.IMMEDIATE).load(uri).disallowHardwareConfig()

View File

@@ -1,6 +1,10 @@
package app.alextran.immich.images
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.ImageDecoder
import android.os.Build
import android.os.CancellationSignal
import android.os.OperationCanceledException
import app.alextran.immich.INITIAL_BUFFER_SIZE
@@ -12,11 +16,11 @@ import kotlinx.coroutines.*
import okhttp3.Cache
import okhttp3.Call
import okhttp3.Callback
import okhttp3.Credentials
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.Credentials
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import org.chromium.net.CronetEngine
import org.chromium.net.CronetException
import org.chromium.net.UrlRequest
@@ -33,6 +37,21 @@ import java.nio.file.attribute.BasicFileAttributes
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executors
fun NativeByteBuffer.decodeBitmap(target: android.util.Size = android.util.Size(0, 0)): Bitmap {
try {
val byteBuffer = NativeBuffer.wrap(pointer, offset)
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ImageDecoder.createSource(byteBuffer).decodeBitmap(target = target)
} else {
val bytes = ByteArray(offset)
byteBuffer.get(bytes)
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
?: throw IOException("Failed to decode image")
}
} finally {
free()
}
}
private const val CACHE_SIZE_BYTES = 1024L * 1024 * 1024
@@ -52,7 +71,7 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
override fun requestImage(
url: String,
requestId: Long,
@Suppress("UNUSED_PARAMETER") preferEncoded: Boolean, // always returns encoded; setting has no effect on Android
preferEncoded: Boolean, // always returns encoded; setting has no effect on Android
callback: (Result<Map<String, Long>?>) -> Unit
) {
val signal = CancellationSignal()
@@ -100,7 +119,7 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
}
}
private object ImageFetcherManager {
object ImageFetcherManager {
private lateinit var appContext: Context
private lateinit var cacheDir: File
private lateinit var fetcher: ImageFetcher
@@ -148,7 +167,7 @@ private object ImageFetcherManager {
}
}
private sealed interface ImageFetcher {
internal sealed interface ImageFetcher {
fun fetch(
url: String,
signal: CancellationSignal,
@@ -161,7 +180,7 @@ private sealed interface ImageFetcher {
fun clearCache(onCleared: (Result<Long>) -> Unit)
}
private class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetcher {
internal class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetcher {
private val ctx = context
private var engine: CronetEngine
private val executor = Executors.newFixedThreadPool(4)
@@ -341,7 +360,7 @@ private class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetche
}
}
suspend fun deleteFolderAndGetSize(root: Path): Long = withContext(Dispatchers.IO) {
private suspend fun deleteFolderAndGetSize(root: Path): Long = withContext(Dispatchers.IO) {
var totalSize = 0L
Files.walkFileTree(root, object : SimpleFileVisitor<Path>() {
@@ -363,7 +382,7 @@ private class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetche
}
}
private class OkHttpImageFetcher private constructor(
internal class OkHttpImageFetcher private constructor(
private val client: OkHttpClient,
) : ImageFetcher {
private val stateLock = Any()
@@ -374,7 +393,7 @@ private class OkHttpImageFetcher private constructor(
fun create(cacheDir: File): OkHttpImageFetcher {
val dir = File(cacheDir, "okhttp")
val client = HttpClientManager.getClient().newBuilder()
val client = HttpClientManager.client.newBuilder()
.cache(Cache(File(dir, "thumbnails"), CACHE_SIZE_BYTES))
.build()

View File

@@ -1,33 +0,0 @@
package app.alextran.immich.widget
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import java.io.File
fun loadScaledBitmap(file: File, reqWidth: Int, reqHeight: Int): Bitmap? {
val options = BitmapFactory.Options().apply {
inJustDecodeBounds = true
}
BitmapFactory.decodeFile(file.absolutePath, options)
options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight)
options.inJustDecodeBounds = false
return BitmapFactory.decodeFile(file.absolutePath, options)
}
fun calculateInSampleSize(options: BitmapFactory.Options, reqWidth: Int, reqHeight: Int): Int {
val (height: Int, width: Int) = options.run { outHeight to outWidth }
var inSampleSize = 1
if (height > reqHeight || width > reqWidth) {
val halfHeight: Int = height / 2
val halfWidth: Int = width / 2
while ((halfHeight / inSampleSize) >= reqHeight && (halfWidth / inSampleSize) >= reqWidth) {
inSampleSize *= 2
}
}
return inSampleSize
}

View File

@@ -1,18 +1,12 @@
package app.alextran.immich.widget
import android.content.Context
import android.graphics.Bitmap
import android.util.Log
import androidx.datastore.preferences.core.Preferences
import androidx.glance.*
import androidx.glance.appwidget.GlanceAppWidgetManager
import androidx.glance.appwidget.state.updateAppWidgetState
import androidx.work.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
import java.util.UUID
import java.util.concurrent.TimeUnit
import androidx.glance.appwidget.state.getAppWidgetState
import androidx.glance.state.PreferencesGlanceStateDefinition
@@ -75,18 +69,8 @@ class ImageDownloadWorker(
)
}
suspend fun cancel(context: Context, appWidgetId: Int) {
fun cancel(context: Context, appWidgetId: Int) {
WorkManager.getInstance(context).cancelAllWorkByTag("$uniqueWorkName-$appWidgetId")
// delete cached image
val glanceId = GlanceAppWidgetManager(context).getGlanceIdBy(appWidgetId)
val widgetConfig = getAppWidgetState(context, PreferencesGlanceStateDefinition, glanceId)
val currentImgUUID = widgetConfig[kImageUUID]
if (!currentImgUUID.isNullOrEmpty()) {
val file = File(context.cacheDir, imageFilename(currentImgUUID))
file.delete()
}
}
}
@@ -96,43 +80,22 @@ class ImageDownloadWorker(
val widgetId = inputData.getInt(kWorkerWidgetID, -1)
val glanceId = GlanceAppWidgetManager(context).getGlanceIdBy(widgetId)
val widgetConfig = getAppWidgetState(context, PreferencesGlanceStateDefinition, glanceId)
val currentImgUUID = widgetConfig[kImageUUID]
val serverConfig = ImmichAPI.getServerConfig(context)
// clear any image caches and go to "login" state if no credentials
if (serverConfig == null) {
if (!currentImgUUID.isNullOrEmpty()) {
deleteImage(currentImgUUID)
updateWidget(
glanceId,
"",
"",
"immich://",
WidgetState.LOG_IN
)
// clear state and go to "login" if no credentials
if (!ImmichAPI.isLoggedIn(context)) {
val currentAssetId = widgetConfig[kAssetId]
if (!currentAssetId.isNullOrEmpty()) {
updateWidget(glanceId, "", "", "immich://", WidgetState.LOG_IN)
}
return Result.success()
}
// fetch new image
val entry = when (widgetType) {
WidgetType.RANDOM -> fetchRandom(serverConfig, widgetConfig)
WidgetType.MEMORIES -> fetchMemory(serverConfig)
WidgetType.RANDOM -> fetchRandom(widgetConfig)
WidgetType.MEMORIES -> fetchMemory()
}
// clear current image if it exists
if (!currentImgUUID.isNullOrEmpty()) {
deleteImage(currentImgUUID)
}
// save a new image
val imgUUID = UUID.randomUUID().toString()
saveImage(entry.image, imgUUID)
// trigger the update routine with new image uuid
updateWidget(glanceId, imgUUID, entry.subtitle, entry.deeplink)
updateWidget(glanceId, entry.assetId, entry.subtitle, entry.deeplink)
Result.success()
} catch (e: Exception) {
@@ -147,28 +110,25 @@ class ImageDownloadWorker(
private suspend fun updateWidget(
glanceId: GlanceId,
imageUUID: String,
assetId: String,
subtitle: String?,
deeplink: String?,
widgetState: WidgetState = WidgetState.SUCCESS
) {
updateAppWidgetState(context, glanceId) { prefs ->
prefs[kNow] = System.currentTimeMillis()
prefs[kImageUUID] = imageUUID
prefs[kAssetId] = assetId
prefs[kWidgetState] = widgetState.toString()
prefs[kSubtitleText] = subtitle ?: ""
prefs[kDeeplinkURL] = deeplink ?: ""
}
PhotoWidget().update(context,glanceId)
PhotoWidget().update(context, glanceId)
}
private suspend fun fetchRandom(
serverConfig: ServerConfig,
widgetConfig: Preferences
): WidgetEntry {
val api = ImmichAPI(serverConfig)
val filters = SearchFilters()
val albumId = widgetConfig[kSelectedAlbum]
val showSubtitle = widgetConfig[kShowAlbumName]
@@ -182,31 +142,27 @@ class ImageDownloadWorker(
filters.albumIds = listOf(albumId)
}
var randomSearch = api.fetchSearchResults(filters)
var randomSearch = ImmichAPI.fetchSearchResults(filters)
// handle an empty album, fallback to random
if (randomSearch.isEmpty() && albumId != null) {
randomSearch = api.fetchSearchResults(SearchFilters())
randomSearch = ImmichAPI.fetchSearchResults(SearchFilters())
subtitle = ""
}
val random = randomSearch.first()
val image = api.fetchImage(random)
ImmichAPI.fetchImage(random).free() // warm the HTTP disk cache
return WidgetEntry(
image,
random.id,
subtitle,
assetDeeplink(random)
)
}
private suspend fun fetchMemory(
serverConfig: ServerConfig
): WidgetEntry {
val api = ImmichAPI(serverConfig)
private suspend fun fetchMemory(): WidgetEntry {
val today = LocalDate.now()
val memories = api.fetchMemory(today)
val memories = ImmichAPI.fetchMemory(today)
val asset: Asset
var subtitle: String? = null
@@ -219,26 +175,15 @@ class ImageDownloadWorker(
subtitle = "$yearDiff ${if (yearDiff == 1) "year" else "years"} ago"
} else {
val filters = SearchFilters(size=1)
asset = api.fetchSearchResults(filters).first()
asset = ImmichAPI.fetchSearchResults(filters).first()
}
val image = api.fetchImage(asset)
ImmichAPI.fetchImage(asset).free() // warm the HTTP disk cache
return WidgetEntry(
image,
asset.id,
subtitle,
assetDeeplink(asset)
)
}
private suspend fun deleteImage(uuid: String) = withContext(Dispatchers.IO) {
val file = File(context.cacheDir, imageFilename(uuid))
file.delete()
}
private suspend fun saveImage(bitmap: Bitmap, uuid: String) = withContext(Dispatchers.IO) {
val file = File(context.cacheDir, imageFilename(uuid))
FileOutputStream(file).use { out ->
bitmap.compress(Bitmap.CompressFormat.JPEG, 90, out)
}
}
}

View File

@@ -1,122 +1,97 @@
package app.alextran.immich.widget
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.os.CancellationSignal
import app.alextran.immich.NativeByteBuffer
import app.alextran.immich.core.HttpClientManager
import app.alextran.immich.images.ImageFetcherManager
import app.alextran.immich.widget.model.*
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import es.antonborri.home_widget.HomeWidgetPlugin
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import java.io.OutputStreamWriter
import java.net.HttpURLConnection
import java.net.URL
import java.net.URLEncoder
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
class ImmichAPI(cfg: ServerConfig) {
companion object {
fun getServerConfig(context: Context): ServerConfig? {
val prefs = HomeWidgetPlugin.getData(context)
val serverURL = prefs.getString("widget_server_url", "") ?: ""
val sessionKey = prefs.getString("widget_auth_token", "") ?: ""
val customHeadersJSON = prefs.getString("widget_custom_headers", "") ?: ""
if (serverURL.isBlank() || sessionKey.isBlank()) {
return null
}
var customHeaders: Map<String, String> = HashMap<String, String>()
if (customHeadersJSON.isNotBlank()) {
val stringMapType = object : TypeToken<Map<String, String>>() {}.type
customHeaders = Gson().fromJson(customHeadersJSON, stringMapType)
}
return ServerConfig(
serverURL,
sessionKey,
customHeaders
)
}
}
object ImmichAPI {
private val gson = Gson()
private val serverConfig = cfg
private val serverEndpoint: String
get() = HttpClientManager.serverUrl ?: throw IllegalStateException("Not logged in")
private fun buildRequestURL(endpoint: String, params: List<Pair<String, String>> = emptyList()): URL {
val urlString = StringBuilder("${serverConfig.serverEndpoint}$endpoint?sessionKey=${serverConfig.sessionKey}")
for ((key, value) in params) {
urlString.append("&${URLEncoder.encode(key, "UTF-8")}=${URLEncoder.encode(value, "UTF-8")}")
}
return URL(urlString.toString())
private fun initialize(context: Context) {
HttpClientManager.initialize(context)
ImageFetcherManager.initialize(context)
}
private fun HttpURLConnection.applyCustomHeaders() {
serverConfig.customHeaders.forEach { (key, value) ->
setRequestProperty(key, value)
fun isLoggedIn(context: Context): Boolean {
initialize(context)
return HttpClientManager.serverUrl != null
}
private fun buildRequestURL(endpoint: String, params: List<Pair<String, String>> = emptyList()): String {
val url = StringBuilder("$serverEndpoint$endpoint")
if (params.isNotEmpty()) {
url.append("?")
url.append(params.joinToString("&") { (key, value) ->
"${java.net.URLEncoder.encode(key, "UTF-8")}=${java.net.URLEncoder.encode(value, "UTF-8")}"
})
}
return url.toString()
}
suspend fun fetchSearchResults(filters: SearchFilters): List<Asset> = withContext(Dispatchers.IO) {
val url = buildRequestURL("/search/random")
val connection = (url.openConnection() as HttpURLConnection).apply {
requestMethod = "POST"
setRequestProperty("Content-Type", "application/json")
applyCustomHeaders()
val body = gson.toJson(filters).toRequestBody("application/json".toMediaType())
val request = Request.Builder().url(url).post(body).build()
doOutput = true
HttpClientManager.client.newCall(request).execute().use { response ->
val responseBody = response.body?.string() ?: throw Exception("Empty response")
val type = object : TypeToken<List<Asset>>() {}.type
gson.fromJson(responseBody, type)
}
connection.outputStream.use {
OutputStreamWriter(it).use { writer ->
writer.write(gson.toJson(filters))
writer.flush()
}
}
val response = connection.inputStream.bufferedReader().readText()
val type = object : TypeToken<List<Asset>>() {}.type
gson.fromJson(response, type)
}
suspend fun fetchMemory(date: LocalDate): List<MemoryResult> = withContext(Dispatchers.IO) {
val iso8601 = date.format(DateTimeFormatter.ISO_LOCAL_DATE)
val url = buildRequestURL("/memories", listOf("for" to iso8601))
val connection = (url.openConnection() as HttpURLConnection).apply {
requestMethod = "GET"
applyCustomHeaders()
}
val request = Request.Builder().url(url).get().build()
val response = connection.inputStream.bufferedReader().readText()
val type = object : TypeToken<List<MemoryResult>>() {}.type
gson.fromJson(response, type)
HttpClientManager.client.newCall(request).execute().use { response ->
val responseBody = response.body?.string() ?: throw Exception("Empty response")
val type = object : TypeToken<List<MemoryResult>>() {}.type
gson.fromJson(responseBody, type)
}
}
suspend fun fetchImage(asset: Asset): Bitmap = withContext(Dispatchers.IO) {
suspend fun fetchImage(asset: Asset): NativeByteBuffer = suspendCancellableCoroutine { cont ->
val url = buildRequestURL("/assets/${asset.id}/thumbnail", listOf("size" to "preview", "edited" to "true"))
val connection = url.openConnection()
val data = connection.getInputStream().readBytes()
BitmapFactory.decodeByteArray(data, 0, data.size)
?: throw Exception("Invalid image data")
val signal = CancellationSignal()
cont.invokeOnCancellation { signal.cancel() }
ImageFetcherManager.fetch(
url,
signal,
onSuccess = { buffer -> cont.resume(buffer) },
onFailure = { e -> cont.resumeWithException(e) }
)
}
suspend fun fetchAlbums(): List<Album> = withContext(Dispatchers.IO) {
val url = buildRequestURL("/albums")
val connection = (url.openConnection() as HttpURLConnection).apply {
requestMethod = "GET"
applyCustomHeaders()
}
val request = Request.Builder().url(url).get().build()
val response = connection.inputStream.bufferedReader().readText()
val type = object : TypeToken<List<Album>>() {}.type
gson.fromJson(response, type)
HttpClientManager.client.newCall(request).execute().use { response ->
val responseBody = response.body?.string() ?: throw Exception("Empty response")
val type = object : TypeToken<List<Album>>() {}.type
gson.fromJson(responseBody, type)
}
}
}

View File

@@ -1,58 +0,0 @@
package app.alextran.immich.widget
import android.appwidget.AppWidgetManager
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import androidx.glance.appwidget.GlanceAppWidgetReceiver
import app.alextran.immich.widget.model.*
import es.antonborri.home_widget.HomeWidgetPlugin
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class MemoryReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget = PhotoWidget()
override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray
) {
super.onUpdate(context, appWidgetManager, appWidgetIds)
appWidgetIds.forEach { widgetID ->
ImageDownloadWorker.enqueuePeriodic(context, widgetID, WidgetType.MEMORIES)
}
}
override fun onReceive(context: Context, intent: Intent) {
val fromMainApp = intent.getBooleanExtra(HomeWidgetPlugin.TRIGGERED_FROM_HOME_WIDGET, false)
val provider = ComponentName(context, MemoryReceiver::class.java)
val glanceIds = AppWidgetManager.getInstance(context).getAppWidgetIds(provider)
// Launch coroutine to setup a single shot if the app requested the update
if (fromMainApp) {
glanceIds.forEach { widgetID ->
ImageDownloadWorker.singleShot(context, widgetID, WidgetType.MEMORIES)
}
}
// make sure the periodic jobs are running
glanceIds.forEach { widgetID ->
ImageDownloadWorker.enqueuePeriodic(context, widgetID, WidgetType.MEMORIES)
}
super.onReceive(context, intent)
}
override fun onDeleted(context: Context, appWidgetIds: IntArray) {
super.onDeleted(context, appWidgetIds)
CoroutineScope(Dispatchers.Default).launch {
appWidgetIds.forEach { id ->
ImageDownloadWorker.cancel(context, id)
}
}
}
}

View File

@@ -2,12 +2,12 @@ package app.alextran.immich.widget
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.util.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.*
import androidx.core.net.toUri
import androidx.datastore.preferences.core.MutablePreferences
import androidx.glance.appwidget.*
import androidx.glance.appwidget.state.getAppWidgetState
import androidx.glance.*
import androidx.glance.action.clickable
import androidx.glance.layout.*
@@ -18,30 +18,28 @@ import androidx.glance.text.TextAlign
import androidx.glance.text.TextStyle
import androidx.glance.unit.ColorProvider
import app.alextran.immich.R
import app.alextran.immich.images.decodeBitmap
import app.alextran.immich.widget.model.*
import java.io.File
class PhotoWidget : GlanceAppWidget() {
override var stateDefinition: GlanceStateDefinition<*> = PreferencesGlanceStateDefinition
override suspend fun provideGlance(context: Context, id: GlanceId) {
provideContent {
val prefs = currentState<MutablePreferences>()
val state = getAppWidgetState(context, PreferencesGlanceStateDefinition, id)
val assetId = state[kAssetId]
val subtitle = state[kSubtitleText]
val deeplinkURL = state[kDeeplinkURL]?.toUri()
val widgetState = state[kWidgetState]
val imageUUID = prefs[kImageUUID]
val subtitle = prefs[kSubtitleText]
val deeplinkURL = prefs[kDeeplinkURL]?.toUri()
val widgetState = prefs[kWidgetState]
var bitmap: Bitmap? = null
if (imageUUID != null) {
// fetch a random photo from server
val file = File(context.cacheDir, imageFilename(imageUUID))
if (file.exists()) {
bitmap = loadScaledBitmap(file, 500, 500)
}
val bitmap = if (!assetId.isNullOrEmpty() && ImmichAPI.isLoggedIn(context)) {
try {
ImmichAPI.fetchImage(Asset(assetId, AssetType.IMAGE)).decodeBitmap(Size(500, 500))
} catch (e: Exception) {
null
}
} else null
provideContent {
// WIDGET CONTENT
Box(

View File

@@ -4,14 +4,11 @@ import android.appwidget.AppWidgetManager
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import es.antonborri.home_widget.HomeWidgetPlugin
import androidx.glance.appwidget.GlanceAppWidgetReceiver
import app.alextran.immich.widget.model.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import es.antonborri.home_widget.HomeWidgetPlugin
class RandomReceiver : GlanceAppWidgetReceiver() {
abstract class WidgetReceiver(private val widgetType: WidgetType) : GlanceAppWidgetReceiver() {
override val glanceAppWidget = PhotoWidget()
override fun onUpdate(
@@ -22,25 +19,25 @@ class RandomReceiver : GlanceAppWidgetReceiver() {
super.onUpdate(context, appWidgetManager, appWidgetIds)
appWidgetIds.forEach { widgetID ->
ImageDownloadWorker.enqueuePeriodic(context, widgetID, WidgetType.RANDOM)
ImageDownloadWorker.enqueuePeriodic(context, widgetID, widgetType)
}
}
override fun onReceive(context: Context, intent: Intent) {
val fromMainApp = intent.getBooleanExtra(HomeWidgetPlugin.TRIGGERED_FROM_HOME_WIDGET, false)
val provider = ComponentName(context, RandomReceiver::class.java)
val provider = ComponentName(context, this::class.java)
val glanceIds = AppWidgetManager.getInstance(context).getAppWidgetIds(provider)
// Launch coroutine to setup a single shot if the app requested the update
if (fromMainApp) {
glanceIds.forEach { widgetID ->
ImageDownloadWorker.singleShot(context, widgetID, WidgetType.RANDOM)
ImageDownloadWorker.singleShot(context, widgetID, widgetType)
}
}
// make sure the periodic jobs are running
glanceIds.forEach { widgetID ->
ImageDownloadWorker.enqueuePeriodic(context, widgetID, WidgetType.RANDOM)
ImageDownloadWorker.enqueuePeriodic(context, widgetID, widgetType)
}
super.onReceive(context, intent)
@@ -48,10 +45,12 @@ class RandomReceiver : GlanceAppWidgetReceiver() {
override fun onDeleted(context: Context, appWidgetIds: IntArray) {
super.onDeleted(context, appWidgetIds)
CoroutineScope(Dispatchers.Default).launch {
appWidgetIds.forEach { id ->
ImageDownloadWorker.cancel(context, id)
}
appWidgetIds.forEach { id ->
ImageDownloadWorker.cancel(context, id)
}
}
}
class MemoryReceiver : WidgetReceiver(WidgetType.MEMORIES)
class RandomReceiver : WidgetReceiver(WidgetType.RANDOM)

View File

@@ -71,22 +71,18 @@ fun RandomConfiguration(context: Context, appWidgetId: Int, glanceId: GlanceId,
LaunchedEffect(Unit) {
// get albums from server
val serverCfg = ImmichAPI.getServerConfig(context)
if (serverCfg == null) {
if (!ImmichAPI.isLoggedIn(context)) {
state = WidgetConfigState.LOG_IN
return@LaunchedEffect
}
val api = ImmichAPI(serverCfg)
val currentState = getAppWidgetState(context, PreferencesGlanceStateDefinition, glanceId)
val currentAlbumId = currentState[kSelectedAlbum] ?: "NONE"
val currentAlbumName = currentState[kSelectedAlbumName] ?: "None"
var albumItems: List<DropdownItem>
try {
albumItems = api.fetchAlbums().map {
albumItems = ImmichAPI.fetchAlbums().map {
DropdownItem(it.albumName, it.id)
}

View File

@@ -1,6 +1,5 @@
package app.alextran.immich.widget.model
import android.graphics.Bitmap
import androidx.datastore.preferences.core.*
// MARK: Immich Entities
@@ -50,19 +49,13 @@ enum class WidgetConfigState {
}
data class WidgetEntry (
val image: Bitmap,
val assetId: String,
val subtitle: String?,
val deeplink: String?
)
data class ServerConfig(
val serverEndpoint: String,
val sessionKey: String,
val customHeaders: Map<String, String>
)
// MARK: Widget State Keys
val kImageUUID = stringPreferencesKey("uuid")
val kAssetId = stringPreferencesKey("assetId")
val kSubtitleText = stringPreferencesKey("subtitle")
val kNow = longPreferencesKey("now")
val kWidgetState = stringPreferencesKey("state")
@@ -75,10 +68,6 @@ const val kWorkerWidgetType = "widgetType"
const val kWorkerWidgetID = "widgetId"
const val kTriggeredFromApp = "triggeredFromApp"
fun imageFilename(id: String): String {
return "widget_image_$id.jpg"
}
fun assetDeeplink(asset: Asset): String {
return "immich://asset?id=${asset.id}"
}

View File

@@ -140,6 +140,13 @@
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
A872EC0CA71550E4AB04E049 /* Shared */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = Shared;
sourceTree = "<group>";
};
B231F52D2E93A44A00BC45D1 /* Core */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
@@ -257,6 +264,7 @@
97C146EF1CF9000F007C117D /* Products */,
0FB772A5B9601143383626CA /* Pods */,
1754452DD81DA6620E279E51 /* Frameworks */,
A872EC0CA71550E4AB04E049 /* Shared */,
);
sourceTree = "<group>";
};
@@ -362,6 +370,7 @@
F0B57D482DF764BE00DC5BCC /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
A872EC0CA71550E4AB04E049 /* Shared */,
B231F52D2E93A44A00BC45D1 /* Core */,
B2CF7F8C2DDE4EBB00744BF6 /* Sync */,
FEE084F22EC172080045228E /* Schemas */,
@@ -384,6 +393,7 @@
dependencies = (
);
fileSystemSynchronizedGroups = (
A872EC0CA71550E4AB04E049 /* Shared */,
F0B57D3D2DF764BD00DC5BCC /* WidgetExtension */,
);
name = WidgetExtension;

View File

@@ -1,5 +1,7 @@
import Foundation
#if canImport(native_video_player)
import native_video_player
#endif
let CLIENT_CERT_LABEL = "app.alextran.immich.client_identity"
let HEADERS_KEY = "immich.request_headers"
@@ -36,7 +38,7 @@ extension UserDefaults {
/// Old sessions are kept alive by Dart's FFI retain until all isolates release them.
class URLSessionManager: NSObject {
static let shared = URLSessionManager()
private(set) var session: URLSession
let delegate: URLSessionManagerDelegate
private static let cacheDir: URL = {
@@ -144,7 +146,7 @@ class URLSessionManager: NSObject {
}
}
private static func buildSession(delegate: URLSessionManagerDelegate) -> URLSession {
private static func buildSession(delegate: URLSessionDelegate) -> URLSession {
let config = URLSessionConfiguration.default
config.urlCache = urlCache
config.httpCookieStorage = cookieStorage
@@ -168,7 +170,7 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWeb
) {
handleChallenge(session, challenge, completionHandler)
}
func urlSession(
_ session: URLSession,
task: URLSessionTask,
@@ -177,7 +179,7 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWeb
) {
handleChallenge(session, challenge, completionHandler, task: task)
}
func handleChallenge(
_ session: URLSession,
_ challenge: URLAuthenticationChallenge,
@@ -190,7 +192,7 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWeb
default: completionHandler(.performDefaultHandling, nil)
}
}
private func handleClientCertificate(
_ session: URLSession,
completion: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
@@ -200,21 +202,23 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWeb
kSecAttrLabel as String: CLIENT_CERT_LABEL,
kSecReturnRef as String: true,
]
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
if status == errSecSuccess, let identity = item {
let credential = URLCredential(identity: identity as! SecIdentity,
certificates: nil,
persistence: .forSession)
#if canImport(native_video_player)
if #available(iOS 15, *) {
VideoProxyServer.shared.session = session
}
#endif
return completion(.useCredential, credential)
}
completion(.performDefaultHandling, nil)
}
private func handleBasicAuth(
_ session: URLSession,
task: URLSessionTask?,
@@ -226,9 +230,11 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWeb
else {
return completion(.performDefaultHandling, nil)
}
#if canImport(native_video_player)
if #available(iOS 15, *) {
VideoProxyServer.shared.session = session
}
#endif
let credential = URLCredential(user: user, password: password, persistence: .forSession)
completion(.useCredential, credential)
}

View File

@@ -9,6 +9,7 @@ struct ImageEntry: TimelineEntry {
var metadata: Metadata = Metadata()
struct Metadata: Codable {
var assetId: String? = nil
var subtitle: String? = nil
var error: WidgetError? = nil
var deepLink: URL? = nil
@@ -33,80 +34,39 @@ struct ImageEntry: TimelineEntry {
date: entryDate,
image: image,
metadata: EntryMetadata(
assetId: asset.id,
subtitle: subtitle,
deepLink: asset.deepLink
)
)
}
func cache(for key: String) throws {
if let containerURL = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: IMMICH_SHARE_GROUP
) {
let imageURL = containerURL.appendingPathComponent("\(key)_image.png")
let metadataURL = containerURL.appendingPathComponent(
"\(key)_metadata.json"
)
// build metadata JSON
let entryMetadata = try JSONEncoder().encode(self.metadata)
// write to disk
try self.image?.pngData()?.write(to: imageURL, options: .atomic)
try entryMetadata.write(to: metadataURL, options: .atomic)
static func saveLast(for key: String, metadata: Metadata) {
if let data = try? JSONEncoder().encode(metadata) {
UserDefaults.group.set(data, forKey: "widget_last_\(key)")
}
}
static func loadCached(for key: String, at date: Date = Date.now)
-> ImageEntry?
{
if let containerURL = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: IMMICH_SHARE_GROUP
) {
let imageURL = containerURL.appendingPathComponent("\(key)_image.png")
let metadataURL = containerURL.appendingPathComponent(
"\(key)_metadata.json"
)
guard let imageData = try? Data(contentsOf: imageURL),
let metadataJSON = try? Data(contentsOf: metadataURL),
let decodedMetadata = try? JSONDecoder().decode(
Metadata.self,
from: metadataJSON
)
else {
return nil
}
return ImageEntry(
date: date,
image: UIImage(data: imageData),
metadata: decodedMetadata
)
}
return nil
}
static func handleError(
for key: String,
api: ImmichAPI? = nil,
error: WidgetError = .fetchFailed
) -> Timeline<ImageEntry> {
var timelineEntry = ImageEntry(
date: Date.now,
image: nil,
metadata: EntryMetadata(error: error)
)
// use cache if generic failed error
// we want to show the other errors to the user since without intervention,
// it will never succeed
if error == .fetchFailed, let cachedEntry = ImageEntry.loadCached(for: key)
) async -> Timeline<ImageEntry> {
// Try to show the last image from the URL cache for transient failures
if error == .fetchFailed, let api = api,
let data = UserDefaults.group.data(forKey: "widget_last_\(key)"),
let cached = try? JSONDecoder().decode(Metadata.self, from: data),
let assetId = cached.assetId,
let image = try? await api.fetchImage(asset: Asset(id: assetId, type: .image))
{
timelineEntry = cachedEntry
let entry = ImageEntry(date: Date.now, image: image, metadata: cached)
return Timeline(entries: [entry], policy: .atEnd)
}
return Timeline(entries: [timelineEntry], policy: .atEnd)
return Timeline(
entries: [ImageEntry(date: Date.now, metadata: Metadata(error: error))],
policy: .atEnd
)
}
}

View File

@@ -2,7 +2,7 @@ import Foundation
import SwiftUI
import WidgetKit
let IMMICH_SHARE_GROUP = "group.app.immich.share"
// Constants and session configuration are in Shared/SharedURLSession.swift
enum WidgetError: Error, Codable {
case noLogin
@@ -104,87 +104,48 @@ struct Album: Codable, Equatable {
// MARK: API
class ImmichAPI {
typealias CustomHeaders = [String:String]
struct ServerConfig {
let serverEndpoint: String
let sessionKey: String
let customHeaders: CustomHeaders
}
let serverConfig: ServerConfig
let serverEndpoint: String
init() async throws {
// fetch the credentials from the UserDefaults store that dart placed here
guard let defaults = UserDefaults(suiteName: IMMICH_SHARE_GROUP),
let serverURL = defaults.string(forKey: "widget_server_url"),
let sessionKey = defaults.string(forKey: "widget_auth_token")
guard let serverURLs = UserDefaults.group.stringArray(forKey: SERVER_URLS_KEY),
let serverURL = serverURLs.first,
!serverURL.isEmpty
else {
throw WidgetError.noLogin
}
if serverURL == "" || sessionKey == "" {
throw WidgetError.noLogin
}
// custom headers come in the form of KV pairs in JSON
var customHeadersJSON = (defaults.string(forKey: "widget_custom_headers") ?? "")
var customHeaders: CustomHeaders = [:]
if customHeadersJSON != "",
let parsedHeaders = try? JSONDecoder().decode(CustomHeaders.self, from: customHeadersJSON.data(using: .utf8)!) {
customHeaders = parsedHeaders
}
serverConfig = ServerConfig(
serverEndpoint: serverURL,
sessionKey: sessionKey,
customHeaders: customHeaders
)
serverEndpoint = serverURL
}
private func buildRequestURL(
serverConfig: ServerConfig,
endpoint: String,
params: [URLQueryItem] = []
) -> URL? {
guard let baseURL = URL(string: serverConfig.serverEndpoint) else {
fatalError("Invalid base URL")
) throws(FetchError) -> URL? {
guard let baseURL = URL(string: serverEndpoint) else {
throw FetchError.invalidURL
}
// Combine the base URL and API path
let fullPath = baseURL.appendingPathComponent(
endpoint.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
)
// Add the session key as a query parameter
var components = URLComponents(
url: fullPath,
resolvingAgainstBaseURL: false
)
components?.queryItems = [
URLQueryItem(name: "sessionKey", value: serverConfig.sessionKey)
]
components?.queryItems?.append(contentsOf: params)
if !params.isEmpty {
components?.queryItems = params
}
return components?.url
}
func applyCustomHeaders(for request: inout URLRequest) {
for (header, value) in serverConfig.customHeaders {
request.addValue(value, forHTTPHeaderField: header)
}
}
func fetchSearchResults(with filters: SearchFilter = Album.NONE.filter)
async throws
-> [Asset]
{
// get URL
guard
let searchURL = buildRequestURL(
serverConfig: serverConfig,
endpoint: "/search/random"
)
let searchURL = try buildRequestURL(endpoint: "/search/random")
else {
throw URLError(.badURL)
}
@@ -193,20 +154,15 @@ class ImmichAPI {
request.httpMethod = "POST"
request.httpBody = try JSONEncoder().encode(filters)
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
applyCustomHeaders(for: &request)
let (data, _) = try await URLSession.shared.data(for: request)
// decode data
let (data, _) = try await URLSessionManager.shared.session.data(for: request)
return try JSONDecoder().decode([Asset].self, from: data)
}
func fetchMemory(for date: Date) async throws -> [MemoryResult] {
// get URL
let memoryParams = [URLQueryItem(name: "for", value: date.ISO8601Format())]
guard
let searchURL = buildRequestURL(
serverConfig: serverConfig,
let searchURL = try buildRequestURL(
endpoint: "/memories",
params: memoryParams
)
@@ -216,11 +172,8 @@ class ImmichAPI {
var request = URLRequest(url: searchURL)
request.httpMethod = "GET"
applyCustomHeaders(for: &request)
let (data, _) = try await URLSession.shared.data(for: request)
// decode data
let (data, _) = try await URLSessionManager.shared.session.data(for: request)
return try JSONDecoder().decode([MemoryResult].self, from: data)
}
@@ -229,8 +182,7 @@ class ImmichAPI {
let assetEndpoint = "/assets/" + asset.id + "/thumbnail"
guard
let fetchURL = buildRequestURL(
serverConfig: serverConfig,
let fetchURL = try buildRequestURL(
endpoint: assetEndpoint,
params: thumbnailParams
)
@@ -238,9 +190,13 @@ class ImmichAPI {
throw .invalidURL
}
guard let imageSource = CGImageSourceCreateWithURL(fetchURL as CFURL, nil)
else {
throw .invalidURL
let request = URLRequest(url: fetchURL)
guard let (data, _) = try? await URLSessionManager.shared.session.data(for: request) else {
throw .fetchFailed
}
guard let imageSource = CGImageSourceCreateWithData(data as CFData, nil) else {
throw .invalidImage
}
let decodeOptions: [NSString: Any] = [
@@ -263,23 +219,16 @@ class ImmichAPI {
}
func fetchAlbums() async throws -> [Album] {
// get URL
guard
let searchURL = buildRequestURL(
serverConfig: serverConfig,
endpoint: "/albums"
)
let searchURL = try buildRequestURL(endpoint: "/albums")
else {
throw URLError(.badURL)
}
var request = URLRequest(url: searchURL)
request.httpMethod = "GET"
applyCustomHeaders(for: &request)
let (data, _) = try await URLSession.shared.data(for: request)
// decode data
let (data, _) = try await URLSessionManager.shared.session.data(for: request)
return try JSONDecoder().decode([Album].self, from: data)
}
}

View File

@@ -1,23 +0,0 @@
//
// Utils.swift
// Runner
//
// Created by Alex Tran and Brandon Wees on 6/16/25.
//
import UIKit
extension UIImage {
/// Crops the image to ensure width and height do not exceed maxSize.
/// Keeps original aspect ratio and crops excess equally from edges (center crop).
func resized(toWidth width: CGFloat, isOpaque: Bool = true) -> UIImage? {
let canvas = CGSize(
width: width,
height: CGFloat(ceil(width / size.width * size.height))
)
let format = imageRendererFormat
format.opaque = isOpaque
return UIGraphicsImageRenderer(size: canvas, format: format).image {
_ in draw(in: CGRect(origin: .zero, size: canvas))
}
}
}

View File

@@ -24,14 +24,14 @@ struct ImmichMemoryProvider: TimelineProvider {
Task {
guard let api = try? await ImmichAPI() else {
completion(
ImageEntry.handleError(for: cacheKey, error: .noLogin).entries.first!
await ImageEntry.handleError(for: cacheKey, error: .noLogin).entries.first!
)
return
}
guard let memories = try? await api.fetchMemory(for: Date.now)
else {
completion(ImageEntry.handleError(for: cacheKey).entries.first!)
completion(await ImageEntry.handleError(for: cacheKey, api: api).entries.first!)
return
}
@@ -58,7 +58,7 @@ struct ImmichMemoryProvider: TimelineProvider {
dateOffset: 0
)
else {
completion(ImageEntry.handleError(for: cacheKey).entries.first!)
completion(await ImageEntry.handleError(for: cacheKey, api: api).entries.first!)
return
}
@@ -78,7 +78,7 @@ struct ImmichMemoryProvider: TimelineProvider {
guard let api = try? await ImmichAPI() else {
completion(
ImageEntry.handleError(for: cacheKey, error: .noLogin)
await ImageEntry.handleError(for: cacheKey, error: .noLogin)
)
return
}
@@ -129,20 +129,20 @@ struct ImmichMemoryProvider: TimelineProvider {
// Load or save a cached asset for when network conditions are bad
if search.count == 0 {
completion(
ImageEntry.handleError(for: cacheKey, error: .noAssetsAvailable)
await ImageEntry.handleError(for: cacheKey, error: .noAssetsAvailable)
)
return
}
entries.append(contentsOf: search)
} catch {
completion(ImageEntry.handleError(for: cacheKey))
completion(await ImageEntry.handleError(for: cacheKey, api: api))
return
}
}
// cache the last image
try? entries.last!.cache(for: cacheKey)
// save the last asset for fallback
ImageEntry.saveLast(for: cacheKey, metadata: entries.last!.metadata)
completion(Timeline(entries: entries, policy: .atEnd))
}

View File

@@ -65,7 +65,7 @@ struct ImmichRandomProvider: AppIntentTimelineProvider {
let cacheKey = "random_none_\(context.family.rawValue)"
guard let api = try? await ImmichAPI() else {
return ImageEntry.handleError(for: cacheKey, error: .noLogin).entries
return await ImageEntry.handleError(for: cacheKey, error: .noLogin).entries
.first!
}
@@ -79,7 +79,7 @@ struct ImmichRandomProvider: AppIntentTimelineProvider {
dateOffset: 0
)
else {
return ImageEntry.handleError(for: cacheKey).entries.first!
return await ImageEntry.handleError(for: cacheKey, api: api).entries.first!
}
return entry
@@ -102,7 +102,7 @@ struct ImmichRandomProvider: AppIntentTimelineProvider {
// If we don't have a server config, return an entry with an error
guard let api = try? await ImmichAPI() else {
return ImageEntry.handleError(for: cacheKey, error: .noLogin)
return await ImageEntry.handleError(for: cacheKey, error: .noLogin)
}
// build entries
@@ -119,16 +119,16 @@ struct ImmichRandomProvider: AppIntentTimelineProvider {
// Load or save a cached asset for when network conditions are bad
if search.count == 0 {
return ImageEntry.handleError(for: cacheKey, error: .noAssetsAvailable)
return await ImageEntry.handleError(for: cacheKey, error: .noAssetsAvailable)
}
entries.append(contentsOf: search)
} catch {
return ImageEntry.handleError(for: cacheKey)
return await ImageEntry.handleError(for: cacheKey, api: api)
}
// cache the last image
try? entries.last!.cache(for: cacheKey)
// save the last asset for fallback
ImageEntry.saveLast(for: cacheKey, metadata: entries.last!.metadata)
return Timeline(entries: entries, policy: .atEnd)
}

View File

@@ -33,12 +33,6 @@ const int kTimelineNoneSegmentSize = 120;
const int kTimelineAssetLoadBatchSize = 1024;
const int kTimelineAssetLoadOppositeSize = 64;
// Widget keys
const String appShareGroupId = "group.app.immich.share";
const String kWidgetAuthToken = "widget_auth_token";
const String kWidgetServerEndpoint = "widget_server_url";
const String kWidgetCustomHeaders = "widget_custom_headers";
// add widget identifiers here for new widgets
// these are used to force a widget refresh
// (iOSName, androidFQDN)

View File

@@ -113,14 +113,17 @@ class _AppBarBackButton extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final showingDetails = ref.watch(assetViewerProvider.select((state) => state.showingDetails));
final backgroundColor = showingDetails && !context.isDarkTheme ? Colors.white : Colors.black;
final foregroundColor = showingDetails && !context.isDarkTheme ? Colors.black : Colors.white;
return Padding(
padding: const EdgeInsets.only(left: 12.0),
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: showingDetails ? context.colorScheme.surface : Colors.transparent,
backgroundColor: backgroundColor,
shape: const CircleBorder(),
iconSize: 22,
iconColor: showingDetails ? context.colorScheme.onSurface : Colors.white,
iconColor: foregroundColor,
padding: EdgeInsets.zero,
elevation: showingDetails ? 4 : 0,
),

View File

@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter_udid/flutter_udid.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/constants.dart';
@@ -87,9 +89,8 @@ class AuthNotifier extends StateNotifier<AuthState> {
Future<void> logout() async {
try {
await _secureStorageService.delete(kSecuredPinCode);
await _widgetService.clearCredentials();
await _authService.logout();
unawaited(_widgetService.refreshWidgets());
await _ref.read(backgroundUploadServiceProvider).cancel();
_ref.read(foregroundUploadServiceProvider).cancel();
} finally {
@@ -126,9 +127,7 @@ class AuthNotifier extends StateNotifier<AuthState> {
await Store.put(StoreKey.accessToken, accessToken);
await _apiService.updateHeaders();
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
final customHeaders = Store.tryGet(StoreKey.customHeaders);
await _widgetService.writeCredentials(serverEndpoint, accessToken, customHeaders);
unawaited(_widgetService.refreshWidgets());
// Get the deviceid from the store if it exists, otherwise generate a new one
String deviceId = Store.tryGet(StoreKey.deviceId) ?? await FlutterUdid.consistentUdid;

View File

@@ -1,20 +0,0 @@
import 'package:home_widget/home_widget.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
final widgetRepositoryProvider = Provider((_) => const WidgetRepository());
class WidgetRepository {
const WidgetRepository();
Future<void> saveData(String key, String value) async {
await HomeWidget.saveWidgetData<String>(key, value);
}
Future<void> refresh(String iosName, String androidName) async {
await HomeWidget.updateWidget(iOSName: iosName, qualifiedAndroidName: androidName);
}
Future<void> setAppGroupId(String appGroupId) async {
await HomeWidget.setAppGroupId(appGroupId);
}
}

View File

@@ -1,42 +1,15 @@
import 'package:home_widget/home_widget.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/repositories/widget.repository.dart';
final widgetServiceProvider = Provider((ref) {
return WidgetService(ref.watch(widgetRepositoryProvider));
});
final widgetServiceProvider = Provider((_) => const WidgetService());
class WidgetService {
final WidgetRepository _repository;
const WidgetService(this._repository);
Future<void> writeCredentials(String serverURL, String sessionKey, String? customHeaders) async {
await _repository.setAppGroupId(appShareGroupId);
await _repository.saveData(kWidgetServerEndpoint, serverURL);
await _repository.saveData(kWidgetAuthToken, sessionKey);
if (customHeaders != null && customHeaders.isNotEmpty) {
await _repository.saveData(kWidgetCustomHeaders, customHeaders);
}
// wait 3 seconds to ensure the widget is updated, dont block
Future.delayed(const Duration(seconds: 3), refreshWidgets);
}
Future<void> clearCredentials() async {
await _repository.setAppGroupId(appShareGroupId);
await _repository.saveData(kWidgetServerEndpoint, "");
await _repository.saveData(kWidgetAuthToken, "");
await _repository.saveData(kWidgetCustomHeaders, "");
// wait 3 seconds to ensure the widget is updated, dont block
Future.delayed(const Duration(seconds: 3), refreshWidgets);
}
const WidgetService();
Future<void> refreshWidgets() async {
for (final (iOSName, androidName) in kWidgetNames) {
await _repository.refresh(iOSName, androidName);
await HomeWidget.updateWidget(iOSName: iOSName, qualifiedAndroidName: androidName);
}
}
}

View File

@@ -1,15 +1,12 @@
import 'dart:ui';
import 'package:flutter/material.dart';
/// A widget that animates implicitly between a play and a pause icon.
class AnimatedPlayPause extends StatefulWidget {
const AnimatedPlayPause({super.key, required this.playing, this.size, this.color, this.shadows});
const AnimatedPlayPause({super.key, required this.playing, this.size, this.color});
final double? size;
final bool playing;
final Color? color;
final List<Shadow>? shadows;
@override
State<StatefulWidget> createState() => AnimatedPlayPauseState();
@@ -42,32 +39,12 @@ class AnimatedPlayPauseState extends State<AnimatedPlayPause> with SingleTickerP
@override
Widget build(BuildContext context) {
final icon = AnimatedIcon(
color: widget.color,
size: widget.size,
icon: AnimatedIcons.play_pause,
progress: animationController,
);
return Center(
child: Stack(
alignment: Alignment.center,
children: [
for (final shadow in widget.shadows ?? const <Shadow>[])
Transform.translate(
offset: shadow.offset,
child: ImageFiltered(
imageFilter: ImageFilter.blur(sigmaX: shadow.blurRadius / 2, sigmaY: shadow.blurRadius / 2),
child: AnimatedIcon(
color: shadow.color,
size: widget.size,
icon: AnimatedIcons.play_pause,
progress: animationController,
),
),
),
icon,
],
child: AnimatedIcon(
color: widget.color,
size: widget.size,
icon: AnimatedIcons.play_pause,
progress: animationController,
),
);
}

View File

@@ -72,14 +72,17 @@ class VideoControls extends HookConsumerWidget {
children: [
Row(
children: [
IconButton(
iconSize: 32,
padding: const EdgeInsets.all(12),
constraints: const BoxConstraints(),
icon: isFinished
? const Icon(Icons.replay, color: Colors.white, size: 32, shadows: _controlShadows)
: AnimatedPlayPause(color: Colors.white, size: 32, playing: isPlaying, shadows: _controlShadows),
onPressed: () => _toggle(ref, isCasting),
IconTheme(
data: const IconThemeData(shadows: _controlShadows),
child: IconButton(
iconSize: 32,
padding: const EdgeInsets.all(12),
constraints: const BoxConstraints(),
icon: isFinished
? const Icon(Icons.replay, color: Colors.white, size: 32)
: AnimatedPlayPause(color: Colors.white, size: 32, playing: isPlaying),
onPressed: () => _toggle(ref, isCasting),
),
),
const Spacer(),
Text(

34
pnpm-lock.yaml generated
View File

@@ -248,7 +248,7 @@ importers:
version: 63.0.0(eslint@10.0.2(jiti@2.6.1))
exiftool-vendored:
specifier: ^35.0.0
version: 35.13.1
version: 35.10.1
globals:
specifier: ^17.0.0
version: 17.4.0
@@ -456,7 +456,7 @@ importers:
version: 4.4.0
exiftool-vendored:
specifier: ^35.0.0
version: 35.13.1
version: 35.10.1
express:
specifier: ^5.1.0
version: 5.2.1
@@ -3919,8 +3919,8 @@ packages:
peerDependencies:
'@photo-sphere-viewer/core': 5.14.1
'@photostructure/tz-lookup@11.5.0':
resolution: {integrity: sha512-0DVFriinZ7TeOnm9ytXeSL3NMFU87ZqMjgbPNkd8LgHFLcPg1BDyM1eewFYs+pPM+62S4fSP9Mtgijmn+6y95w==}
'@photostructure/tz-lookup@11.4.0':
resolution: {integrity: sha512-yrFaDbQQZVJIzpCTnoghWO8Rttu22Hg7/JkfP3CM8UKniXYzD80cuv4UAsFkzP5Z6XWceWNsQTqUJHKyGNXzLg==}
'@pkgjs/parseargs@0.11.0':
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
@@ -7210,17 +7210,17 @@ packages:
resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==}
engines: {node: '>=10'}
exiftool-vendored.exe@13.52.0:
resolution: {integrity: sha512-8KSHKluRebjm2FL4S8rtwMLMELn/64CTI5BV3zmIdLnpS5N+aJEh6t9Y7aB7YBn5CwUao0T9/rxv4BMQqusukg==}
exiftool-vendored.exe@13.51.0:
resolution: {integrity: sha512-Q49J2c4e+XSGYDJf9PYMVI/IUfUkHLRsPUeDJ2ZekEBVLuw2g7ye9x0vQGWZKwEeZTlnXol7SeBJB0wtAmzM9w==}
os: [win32]
exiftool-vendored.pl@13.52.0:
resolution: {integrity: sha512-DXsMRRNdjordn1Ckcp1h9OQJRQy9VDDOcs60H+3IP+W9zRnpSU3HqQMhAVKyHR4FzioiGDbREN9BI/M1oDNoEw==}
exiftool-vendored.pl@13.51.0:
resolution: {integrity: sha512-RhDM10w4kv5YNCvECj0aLXZXi0UWyzVo2OS4P/hpmyCHL+NGCkZ6N9z/Yc3ek0cEfCj4AiLhe8C96pnz/Fw9Yg==}
os: ['!win32']
hasBin: true
exiftool-vendored@35.13.1:
resolution: {integrity: sha512-RiXz8RrJSBQ5jiZA1yMicmE/FgEFK/4QkU2KsqmlvTvouOOgANsNWv0f0uZbf098Ee933BE4bec5YAOBT0DuIQ==}
exiftool-vendored@35.10.1:
resolution: {integrity: sha512-orD61HdNcdlegfD80wI+3JE/n+iobYPztpFqv2drLHb1rb2QEKR1QY62r+O0wZHHNIf3Bje+xjweS1hxWignQA==}
engines: {node: '>=20.0.0'}
expect-type@1.3.0:
@@ -15989,7 +15989,7 @@ snapshots:
'@photo-sphere-viewer/core': 5.14.1
three: 0.182.0
'@photostructure/tz-lookup@11.5.0': {}
'@photostructure/tz-lookup@11.4.0': {}
'@pkgjs/parseargs@0.11.0':
optional: true
@@ -19617,21 +19617,21 @@ snapshots:
signal-exit: 3.0.7
strip-final-newline: 2.0.0
exiftool-vendored.exe@13.52.0:
exiftool-vendored.exe@13.51.0:
optional: true
exiftool-vendored.pl@13.52.0: {}
exiftool-vendored.pl@13.51.0: {}
exiftool-vendored@35.13.1:
exiftool-vendored@35.10.1:
dependencies:
'@photostructure/tz-lookup': 11.5.0
'@photostructure/tz-lookup': 11.4.0
'@types/luxon': 3.7.1
batch-cluster: 17.3.1
exiftool-vendored.pl: 13.52.0
exiftool-vendored.pl: 13.51.0
he: 1.2.0
luxon: 3.7.2
optionalDependencies:
exiftool-vendored.exe: 13.52.0
exiftool-vendored.exe: 13.51.0
expect-type@1.3.0: {}

View File

@@ -10,7 +10,6 @@
import { AssetAction, ProjectionType } from '$lib/constants';
import { activityManager } from '$lib/managers/activity-manager.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { assetCacheManager } from '$lib/managers/AssetCacheManager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { editManager, EditToolType } from '$lib/managers/edit/edit-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
@@ -101,11 +100,10 @@
const stackThumbnailSize = 60;
const stackSelectedThumbnailSize = 65;
let stack: StackResponseDto | undefined = $state();
let selectedStackAsset: AssetResponseDto | undefined = $state();
let previewStackedAsset: AssetResponseDto | undefined = $state();
let stack: StackResponseDto | null = $state(null);
const asset = $derived(previewStackedAsset ?? selectedStackAsset ?? cursor.current);
const asset = $derived(previewStackedAsset ?? cursor.current);
const nextAsset = $derived(cursor.nextAsset);
const previousAsset = $derived(cursor.previousAsset);
let sharedLink = getSharedLink();
@@ -118,25 +116,17 @@
playOriginalVideo = value;
};
const selectStackedAsset = async (id: string) => {
selectedStackAsset = await assetCacheManager.getAsset({ id });
};
const refreshStack = async () => {
if (authManager.isSharedLink || !withStacked) {
return;
}
if (!cursor.current.stack) {
stack = undefined;
selectedStackAsset = undefined;
return;
if (asset.stack) {
stack = await getStack({ id: asset.stack.id });
}
stack = await getStack({ id: cursor.current.stack.id });
const primaryAsset = stack?.assets.find(({ id }) => id === stack?.primaryAssetId);
if (primaryAsset) {
await selectStackedAsset(primaryAsset.id);
if (!stack?.assets.some(({ id }) => id === asset.id)) {
stack = null;
}
};
@@ -194,21 +184,11 @@
onClose?.(asset);
};
const refreshPreservingSelection = async () => {
const id = asset.id;
assetCacheManager.invalidateAsset(id);
if (selectedStackAsset) {
await selectStackedAsset(id);
} else {
const asset = await assetCacheManager.getAsset({ id });
assetViewingStore.setAsset(asset);
}
onAssetChange?.(asset);
};
const closeEditor = async () => {
if (editManager.hasAppliedEdits) {
await refreshPreservingSelection();
const refreshedAsset = await getAssetInfo({ id: asset.id });
onAssetChange?.(refreshedAsset);
assetViewingStore.setAsset(refreshedAsset);
}
assetViewerManager.closeEditor();
};
@@ -308,6 +288,10 @@
}
};
const handleStackedAssetMouseEvent = (isMouseOver: boolean, stackedAsset: AssetResponseDto) => {
previewStackedAsset = isMouseOver ? stackedAsset : undefined;
};
const handlePreAction = (action: Action) => {
preAction?.(action);
};
@@ -320,7 +304,7 @@
break;
}
case AssetAction.REMOVE_ASSET_FROM_STACK: {
stack = action.stack ?? undefined;
stack = action.stack;
if (stack) {
cursor.current = stack.assets[0];
}
@@ -387,7 +371,7 @@
$effect(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
cursor.current;
asset;
untrack(() => handlePromiseError(refresh()));
});
@@ -551,12 +535,7 @@
{:else if viewerKind === 'CropArea'}
<CropArea {asset} />
{:else if viewerKind === 'PhotoViewer'}
<PhotoViewer
cursor={{ ...cursor, current: asset }}
{sharedLink}
{onSwipe}
onTagFace={refreshPreservingSelection}
/>
<PhotoViewer cursor={{ ...cursor, current: asset }} {sharedLink} {onSwipe} />
{:else if viewerKind === 'VideoViewer'}
<VideoViewer
{asset}
@@ -606,7 +585,7 @@
>
{#if showDetailPanel}
<div class="w-90 h-full">
<DetailPanel {asset} currentAlbum={album} onRefreshPeople={refreshPreservingSelection} />
<DetailPanel {asset} currentAlbum={album} />
</div>
{:else if assetViewerManager.isShowEditor}
<div class="w-100 h-full">
@@ -619,14 +598,10 @@
{#if stack && withStacked && !assetViewerManager.isShowEditor}
{@const stackedAssets = stack.assets}
<div id="stack-slideshow" class="absolute bottom-0 w-full col-span-4 col-start-1 pointer-events-none">
<div
role="presentation"
class="relative flex flex-row no-wrap overflow-x-auto overflow-y-hidden horizontal-scrollbar pointer-events-auto"
onmouseleave={() => (previewStackedAsset = undefined)}
>
<div class="relative flex flex-row no-wrap overflow-x-auto overflow-y-hidden horizontal-scrollbar">
{#each stackedAssets as stackedAsset (stackedAsset.id)}
<div
class={['inline-block px-1 relative transition-all pb-2']}
class={['inline-block px-1 relative transition-all pb-2 pointer-events-auto']}
style:bottom={stackedAsset.id === asset.id ? '0' : '-10px'}
>
<Thumbnail
@@ -634,25 +609,22 @@
brokenAssetClass="text-xs"
dimmed={stackedAsset.id !== asset.id}
asset={toTimelineAsset(stackedAsset)}
onClick={async () => {
await selectStackedAsset(stackedAsset.id);
onClick={() => {
cursor.current = stackedAsset;
previewStackedAsset = undefined;
}}
onMouseEvent={async ({ isMouseOver }) => {
if (isMouseOver) {
previewStackedAsset = stackedAsset;
previewStackedAsset = await assetCacheManager.getAsset({ id: stackedAsset.id });
}
}}
onMouseEvent={({ isMouseOver }) => handleStackedAssetMouseEvent(isMouseOver, stackedAsset)}
readonly
thumbnailSize={stackedAsset.id === asset.id ? stackSelectedThumbnailSize : stackThumbnailSize}
showStackedIcon={false}
disableLinkMouseOver
/>
<div class="w-full flex place-items-center place-content-center">
<div class={['w-2 h-2 rounded-full flex mt-0.5', { 'bg-white': stackedAsset.id === asset.id }]}></div>
</div>
{#if stackedAsset.id === asset.id}
<div class="w-full flex place-items-center place-content-center">
<div class="w-2 h-2 bg-white rounded-full flex mt-0.5"></div>
</div>
{/if}
</div>
{/each}
</div>

View File

@@ -20,7 +20,13 @@
import { handleError } from '$lib/utils/handle-error';
import { fromISODateTime, fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util';
import { getParentPath } from '$lib/utils/tree-utils';
import { AssetMediaSize, getAllAlbums, type AlbumResponseDto, type AssetResponseDto } from '@immich/sdk';
import {
AssetMediaSize,
getAllAlbums,
getAssetInfo,
type AlbumResponseDto,
type AssetResponseDto,
} from '@immich/sdk';
import { Icon, IconButton, LoadingSpinner, modalManager, Text } from '@immich/ui';
import {
mdiCalendar,
@@ -46,10 +52,9 @@
interface Props {
asset: AssetResponseDto;
currentAlbum?: AlbumResponseDto | null;
onRefreshPeople?: () => Promise<void>;
}
let { asset, currentAlbum = null, onRefreshPeople }: Props = $props();
let { asset, currentAlbum = null }: Props = $props();
let showAssetPath = $state(false);
let showEditFaces = $state(false);
@@ -115,6 +120,11 @@
return undefined;
};
const handleRefreshPeople = async () => {
asset = await getAssetInfo({ id: asset.id });
showEditFaces = false;
};
const getAssetFolderHref = (asset: AssetResponseDto) => {
// Remove the last part of the path to get the parent path
return Route.folders({ path: getParentPath(asset.originalPath) });
@@ -565,6 +575,6 @@
assetId={asset.id}
assetType={asset.type}
onClose={() => (showEditFaces = false)}
onRefresh={() => void onRefreshPeople?.()}
onRefresh={handleRefreshPeople}
/>
{/if}

View File

@@ -0,0 +1,79 @@
import { getResizeObserverMock } from '$lib/__mocks__/resize-observer.mock';
import CropArea from '$lib/components/asset-viewer/editor/transform-tool/crop-area.svelte';
import { transformManager } from '$lib/managers/edit/transform-manager.svelte';
import { getAssetMediaUrl } from '$lib/utils';
import { assetFactory } from '@test-data/factories/asset-factory';
import { render } from '@testing-library/svelte';
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
vi.mock('$lib/utils');
describe('CropArea', () => {
beforeAll(() => {
vi.stubGlobal('ResizeObserver', getResizeObserverMock());
vi.mocked(getAssetMediaUrl).mockReturnValue('/mock-image.jpg');
});
afterEach(() => {
transformManager.reset();
});
it('clears cursor styles on reset', () => {
const asset = assetFactory.build();
const { getByRole } = render(CropArea, { asset });
const cropArea = getByRole('button', { name: 'Crop area' });
transformManager.region = { x: 100, y: 100, width: 200, height: 200 };
transformManager.cropImageSize = { width: 1000, height: 1000 };
transformManager.cropImageScale = 1;
transformManager.updateCursor(100, 150);
expect(document.body.style.cursor).toBe('ew-resize');
expect(cropArea.style.cursor).toBe('ew-resize');
transformManager.reset();
expect(document.body.style.cursor).toBe('');
expect(cropArea.style.cursor).toBe('');
});
it('sets cursor style at x: $x, y: $y to be $cursor', () => {
const data = [
{ x: 299, y: 84, cursor: '' },
{ x: 299, y: 85, cursor: 'nesw-resize' },
{ x: 299, y: 115, cursor: 'nesw-resize' },
{ x: 299, y: 116, cursor: 'ew-resize' },
{ x: 299, y: 284, cursor: 'ew-resize' },
{ x: 299, y: 285, cursor: 'nwse-resize' },
{ x: 299, y: 300, cursor: 'nwse-resize' },
{ x: 299, y: 301, cursor: '' },
{ x: 300, y: 84, cursor: '' },
{ x: 300, y: 85, cursor: 'nesw-resize' },
{ x: 300, y: 86, cursor: 'nesw-resize' },
{ x: 300, y: 114, cursor: 'nesw-resize' },
{ x: 300, y: 115, cursor: 'nesw-resize' },
{ x: 300, y: 116, cursor: 'ew-resize' },
{ x: 300, y: 284, cursor: 'ew-resize' },
{ x: 300, y: 285, cursor: 'nwse-resize' },
{ x: 300, y: 286, cursor: 'nwse-resize' },
{ x: 300, y: 300, cursor: 'nwse-resize' },
{ x: 300, y: 301, cursor: '' },
{ x: 301, y: 300, cursor: '' },
{ x: 301, y: 301, cursor: '' },
];
const element = document.createElement('div');
for (const { x, y, cursor } of data) {
const message = `x: ${x}, y: ${y} - ${cursor}`;
transformManager.reset();
transformManager.region = { x: 100, y: 100, width: 200, height: 200 };
transformManager.cropImageSize = { width: 600, height: 600 };
transformManager.cropAreaEl = element;
transformManager.cropImageScale = 0.5;
transformManager.updateCursor(x, y);
expect(element.style.cursor, message).toBe(cursor);
expect(document.body.style.cursor, message).toBe(cursor);
}
});
});

View File

@@ -1,12 +1,9 @@
<script lang="ts">
import { ResizeBoundary, transformManager } from '$lib/managers/edit/transform-manager.svelte';
import { transformManager } from '$lib/managers/edit/transform-manager.svelte';
import { getAssetMediaUrl } from '$lib/utils';
import { getAltText } from '$lib/utils/thumbnail-util';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { AssetMediaSize, type AssetResponseDto } from '@immich/sdk';
import { Icon } from '@immich/ui';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
interface Props {
asset: AssetResponseDto;
@@ -14,9 +11,6 @@
let { asset }: Props = $props();
// viewBox 0 0 24 24 is assumed. Without rotation this icon is top-left.
const cornerIcon = 'M 12 24 L 12 12 L 24 12';
let canvasContainer = $state<HTMLElement | null>(null);
let imageSrc = $derived(
@@ -36,23 +30,16 @@
return transforms.join(' ');
});
const edges = [ResizeBoundary.Top, ResizeBoundary.Right, ResizeBoundary.Bottom, ResizeBoundary.Left];
const corners = [
ResizeBoundary.TopLeft,
ResizeBoundary.TopRight,
ResizeBoundary.BottomRight,
ResizeBoundary.BottomLeft,
];
function rotateBoundary(arr: ResizeBoundary[], input: ResizeBoundary, times: number) {
return arr[(arr.indexOf(input) + times) % 4];
}
$effect(() => {
if (!canvasContainer) {
return;
}
onMount(() => {
const resizeObserver = new ResizeObserver(() => {
transformManager.resizeCanvas();
});
resizeObserver.observe(canvasContainer!);
resizeObserver.observe(canvasContainer);
return () => {
resizeObserver.disconnect();
@@ -60,144 +47,153 @@
});
</script>
<div class="flex flex-col items-center justify-center w-full h-full p-8" bind:this={canvasContainer}>
<div
class="crop-area max-w-full max-h-full transition-transform motion-reduce:transition-none"
class:rotated={transformManager.normalizedRotation % 180 > 0}
style:rotate={transformManager.imageRotation + 'deg'}
<div class="canvas-container" bind:this={canvasContainer}>
<button
class={`crop-area ${transformManager.orientationChanged ? 'changedOriention' : ''}`}
style={`rotate:${transformManager.imageRotation}deg`}
bind:this={transformManager.cropAreaEl}
onmousedown={(e) => transformManager.handleMouseDown(e)}
onmouseup={() => transformManager.handleMouseUp()}
aria-label="Crop area"
type="button"
>
<img
draggable="false"
src={imageSrc}
alt={$getAltText(toTimelineAsset(asset))}
class="h-full select-none transition-transform motion-reduce:transition-none"
style:transform={imageTransform}
style={imageTransform ? `transform: ${imageTransform}` : ''}
/>
<div
class={[
'overlay w-full h-full absolute top-0 transition-colors motion-reduce:transition-none pointer-events-none',
transformManager.isInteracting ? 'bg-black/30' : 'bg-black/56',
]}
class={`${transformManager.isInteracting ? 'resizing' : ''} crop-frame`}
bind:this={transformManager.cropFrame}
>
<div class="grid"></div>
<div class="corner top-left"></div>
<div class="corner top-right"></div>
<div class="corner bottom-left"></div>
<div class="corner bottom-right"></div>
</div>
<div
class={`${transformManager.isInteracting ? 'light' : ''} overlay`}
bind:this={transformManager.overlayEl}
></div>
<div class="crop-frame absolute border-2 border-white" bind:this={transformManager.cropFrame}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class={[
'grid w-full h-full cursor-move transition-opacity motion-reduce:transition-none',
transformManager.isInteracting ? 'opacity-100' : 'opacity-0',
]}
onmousedown={(e) => transformManager.handleMouseDownOn(e, ResizeBoundary.None)}
></div>
{#each edges as edge (edge)}
{@const rotatedEdge = rotateBoundary(edges, edge, transformManager.normalizedRotation / 90)}
<button
class={['absolute', edge]}
style={`${edge}: -10px`}
onmousedown={(e) => transformManager.handleMouseDownOn(e, edge)}
type="button"
aria-label={$t('editor_handle_edge', { values: { edge: rotatedEdge } })}
></button>
{/each}
{#each corners as corner (corner)}
{@const rotatedCorner = rotateBoundary(corners, corner, transformManager.normalizedRotation / 90)}
<button
class={['corner', corner]}
onmousedown={(e) => transformManager.handleMouseDownOn(e, corner)}
type="button"
aria-label={$t('editor_handle_corner', { values: { corner: rotatedCorner.replace('-', '_') } })}
>
<Icon icon={cornerIcon} size="30" strokeWidth={4} strokeColor="white" color="transparent" />
</button>
{/each}
</div>
</div>
</button>
</div>
<style>
.canvas-container {
width: calc(100% - 4rem);
margin: auto;
margin-top: 2rem;
height: calc(100% - 4rem);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.crop-area {
position: relative;
display: inline-block;
outline: none;
transition: rotate 0.15s ease;
max-height: 100%;
max-width: 100%;
width: max-content;
}
.crop-area.changedOriention {
max-width: 92vh;
max-height: calc(100vw - 400px - 1.5rem);
}
.crop-frame.transition {
transition: all 0.15s ease;
}
.overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.56);
pointer-events: none;
transition: background 0.1s;
}
.overlay.light {
background: rgba(0, 0, 0, 0.3);
}
.grid {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
--color: white;
--shadow: #00000057;
background-image:
linear-gradient(var(--color) 1px, transparent 0), linear-gradient(90deg, var(--color) 1px, transparent 0),
linear-gradient(var(--shadow) 3px, transparent 0), linear-gradient(90deg, var(--shadow) 3px, transparent 0);
background-size: calc(100% / 3) calc(100% / 3);
opacity: 0;
transition: opacity 0.1s ease;
}
.left,
.right {
top: 0;
width: 20px;
.crop-frame.resizing .grid {
opacity: 1;
}
.crop-area img {
display: block;
max-width: 100%;
height: 100%;
cursor: ew-resize;
user-select: none;
transition: transform 0.15s ease;
}
.top,
.bottom {
width: 100%;
height: 20px;
cursor: ns-resize;
.crop-frame {
position: absolute;
border: 2px solid white;
box-sizing: border-box;
pointer-events: none;
}
.corner {
position: absolute;
--offset: -15px;
width: 20px;
height: 20px;
--size: 5.2px;
--mSize: calc(-0.5 * var(--size));
border: var(--size) solid white;
box-sizing: border-box;
}
.top-left {
top: var(--offset);
left: var(--offset);
cursor: nwse-resize;
top: var(--mSize);
left: var(--mSize);
border-right: none;
border-bottom: none;
}
.top-right {
top: var(--offset);
right: var(--offset);
cursor: nesw-resize;
rotate: 90deg;
}
.bottom-right {
bottom: var(--offset);
right: var(--offset);
cursor: nwse-resize;
rotate: 180deg;
top: var(--mSize);
right: var(--mSize);
border-left: none;
border-bottom: none;
}
.bottom-left {
bottom: var(--offset);
left: var(--offset);
cursor: nesw-resize;
rotate: 270deg;
bottom: var(--mSize);
left: var(--mSize);
border-right: none;
border-top: none;
}
.crop-area.rotated {
max-width: calc(100vh - 16 * var(--spacing));
max-height: calc(100vw - 400px - 16 * var(--spacing));
.left,
.right {
cursor: ns-resize;
}
.top,
.bottom {
cursor: ew-resize;
}
.top-left,
.bottom-right {
cursor: nesw-resize;
}
.top-right,
.bottom-left {
cursor: nwse-resize;
}
.bottom-right {
bottom: var(--mSize);
right: var(--mSize);
border-left: none;
border-top: none;
}
</style>

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { getPeopleThumbnailUrl } from '$lib/utils';
import { getNaturalSize, scaleToFit } from '$lib/utils/container-utils';
@@ -16,10 +17,9 @@
containerWidth: number;
containerHeight: number;
assetId: string;
onTagFace?: () => Promise<void>;
}
let { htmlElement, containerWidth, containerHeight, assetId, onTagFace }: Props = $props();
let { htmlElement, containerWidth, containerHeight, assetId }: Props = $props();
let canvasEl: HTMLCanvasElement | undefined = $state();
let canvas: Canvas | undefined = $state();
@@ -74,7 +74,6 @@
canvas.add(faceRect);
canvas.setActiveObject(faceRect);
setDefaultFaceRectanglePosition(faceRect);
};
onMount(async () => {
@@ -94,19 +93,9 @@
};
});
const setDefaultFaceRectanglePosition = (faceRect: Rect) => {
$effect(() => {
const { offsetX, offsetY } = imageContentMetrics;
faceRect.set({
top: offsetY + 200,
left: offsetX + 200,
});
faceRect.setCoords();
positionFaceSelector();
};
$effect(() => {
if (!canvas) {
return;
}
@@ -120,20 +109,14 @@
return;
}
if (!isFaceRectIntersectingCanvas(faceRect, canvas)) {
setDefaultFaceRectanglePosition(faceRect);
}
});
faceRect.set({
top: offsetY + 200,
left: offsetX + 200,
});
const isFaceRectIntersectingCanvas = (faceRect: Rect, canvas: Canvas) => {
const faceBox = faceRect.getBoundingRect();
return !(
0 > faceBox.left + faceBox.width ||
0 > faceBox.top + faceBox.height ||
canvas.width < faceBox.left ||
canvas.height < faceBox.top
);
};
faceRect.setCoords();
positionFaceSelector();
});
const cancel = () => {
isFaceEditMode.value = false;
@@ -280,7 +263,7 @@
},
});
await onTagFace?.();
await assetViewingStore.setAssetId(assetId);
} catch (error) {
handleError(error, 'Error tagging face');
} finally {

View File

@@ -32,10 +32,9 @@
onReady?: () => void;
onError?: () => void;
onSwipe?: (event: SwipeCustomEvent) => void;
onTagFace?: () => Promise<void>;
}
let { cursor, element = $bindable(), sharedLink, onReady, onError, onSwipe, onTagFace }: Props = $props();
let { cursor, element = $bindable(), sharedLink, onReady, onError, onSwipe }: Props = $props();
const { slideshowState, slideshowLook } = slideshowStore;
const asset = $derived(cursor.current);
@@ -266,12 +265,6 @@
</AdaptiveImage>
{#if isFaceEditMode.value && assetViewerManager.imgRef}
<FaceEditor
htmlElement={assetViewerManager.imgRef}
{containerWidth}
{containerHeight}
assetId={asset.id}
{onTagFace}
/>
<FaceEditor htmlElement={assetViewerManager.imgRef} {containerWidth} {containerHeight} assetId={asset.id} />
{/if}
</div>

View File

@@ -1,8 +1,8 @@
<script lang="ts">
import OnEvents from '$lib/components/OnEvents.svelte';
import { timeBeforeShowLoadingSpinner } from '$lib/constants';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { boundingBoxesArray } from '$lib/stores/people.store';
import { getPeopleThumbnailUrl, handlePromiseError } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
@@ -25,6 +25,7 @@
import { fly } from 'svelte/transition';
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
import AssignFaceSidePanel from './assign-face-side-panel.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
interface Props {
assetId: string;
@@ -178,10 +179,7 @@
peopleWithFaces = peopleWithFaces.filter((f) => f.id !== face.id);
onRefresh();
if (peopleWithFaces.length === 0) {
onClose();
}
await assetViewingStore.setAssetId(assetId);
} catch (error) {
handleError(error, $t('error_delete_face'));
}

View File

@@ -1,10 +1,9 @@
import { type EditActions, type EditToolManager } from '$lib/managers/edit/edit-manager.svelte';
import { editManager, type EditActions, type EditToolManager } from '$lib/managers/edit/edit-manager.svelte';
import { getAssetMediaUrl } from '$lib/utils';
import { getDimensions } from '$lib/utils/asset-utils';
import { normalizeTransformEdits } from '$lib/utils/editor';
import { handleError } from '$lib/utils/handle-error';
import { AssetEditAction, AssetMediaSize, MirrorAxis, type AssetResponseDto, type CropParameters } from '@immich/sdk';
import { clamp } from 'lodash-es';
import { tick } from 'svelte';
export type CropAspectRatio =
@@ -38,27 +37,17 @@ type RegionConvertParams = {
to: ImageDimensions;
};
export enum ResizeBoundary {
None = 'none',
TopLeft = 'top-left',
TopRight = 'top-right',
BottomLeft = 'bottom-left',
BottomRight = 'bottom-right',
Left = 'left',
Right = 'right',
Top = 'top',
Bottom = 'bottom',
}
class TransformManager implements EditToolManager {
canReset: boolean = $derived.by(() => this.checkEdits());
hasChanges: boolean = $state(false);
darkenLevel = $state(0.65);
isInteracting = $state(false);
isDragging = $state(false);
animationFrame = $state<ReturnType<typeof requestAnimationFrame> | null>(null);
dragAnchor = $state({ x: 0, y: 0 });
resizeSide = $state(ResizeBoundary.None);
canvasCursor = $state('default');
dragOffset = $state({ x: 0, y: 0 });
resizeSide = $state('');
imgElement = $state<HTMLImageElement | null>(null);
cropAreaEl = $state<HTMLElement | null>(null);
overlayEl = $state<HTMLElement | null>(null);
@@ -80,6 +69,7 @@ class TransformManager implements EditToolManager {
const newAngle = this.imageRotation % 360;
return newAngle < 0 ? newAngle + 360 : newAngle;
});
orientationChanged = $derived.by(() => this.normalizedRotation % 180 > 0);
edits = $derived.by(() => this.getEdits());
@@ -91,9 +81,9 @@ class TransformManager implements EditToolManager {
return;
}
const newCrop = this.recalculateCrop(aspectRatio);
const newCrop = transformManager.recalculateCrop(aspectRatio);
if (newCrop) {
this.animateCropChange(newCrop);
transformManager.animateCropChange(this.cropAreaEl, this.region, newCrop);
this.region = newCrop;
}
}
@@ -226,11 +216,17 @@ class TransformManager implements EditToolManager {
}
reset() {
this.darkenLevel = 0.65;
this.isInteracting = false;
this.animationFrame = null;
this.dragAnchor = { x: 0, y: 0 };
this.resizeSide = ResizeBoundary.None;
this.canvasCursor = 'default';
this.dragOffset = { x: 0, y: 0 };
this.resizeSide = '';
this.imgElement = null;
if (this.cropAreaEl) {
this.cropAreaEl.style.cursor = '';
}
document.body.style.cursor = '';
this.cropAreaEl = null;
this.isDragging = false;
this.overlayEl = null;
@@ -299,12 +295,12 @@ class TransformManager implements EditToolManager {
};
}
animateCropChange(to: Region, duration = 100) {
if (!this.cropFrame) {
animateCropChange(element: HTMLElement, from: Region, to: Region, duration = 100) {
const cropFrame = element.querySelector('.crop-frame') as HTMLElement;
if (!cropFrame) {
return;
}
const from = this.region;
const startTime = performance.now();
const initialCrop = { ...from };
@@ -338,6 +334,28 @@ class TransformManager implements EditToolManager {
return { newWidth, newHeight };
}
// Calculate constrained dimensions based on aspect ratio and limits
getConstrainedDimensions(
desiredWidth: number,
desiredHeight: number,
maxWidth: number,
maxHeight: number,
minSize = 50,
) {
const { newWidth, newHeight } = this.adjustDimensions(
desiredWidth,
desiredHeight,
this.cropAspectRatio,
maxWidth,
maxHeight,
minSize,
);
return {
width: Math.max(minSize, Math.min(newWidth, maxWidth)),
height: Math.max(minSize, Math.min(newHeight, maxHeight)),
};
}
adjustDimensions(
newWidth: number,
newHeight: number,
@@ -346,45 +364,49 @@ class TransformManager implements EditToolManager {
yLimit: number,
minSize: number,
) {
if (aspectRatio === 'free') {
return {
newWidth: clamp(newWidth, minSize, xLimit),
newHeight: clamp(newHeight, minSize, yLimit),
};
}
let w = newWidth;
let h = newHeight;
const [ratioWidth, ratioHeight] = aspectRatio.split(':').map(Number);
const aspectMultiplier = ratioWidth && ratioHeight ? ratioWidth / ratioHeight : w / h;
let aspectMultiplier: number;
h = w / aspectMultiplier;
// When dragging a corner, use the biggest region that fits 'inside' the mouse location.
if (h < newHeight) {
h = newHeight;
w = h * aspectMultiplier;
if (aspectRatio === 'free') {
aspectMultiplier = newWidth / newHeight;
} else {
const [widthRatio, heightRatio] = aspectRatio.split(':').map(Number);
aspectMultiplier = widthRatio && heightRatio ? widthRatio / heightRatio : newWidth / newHeight;
}
if (aspectRatio !== 'free') {
h = w / aspectMultiplier;
}
if (w > xLimit) {
w = xLimit;
h = w / aspectMultiplier;
if (aspectRatio !== 'free') {
h = w / aspectMultiplier;
}
}
if (h > yLimit) {
h = yLimit;
w = h * aspectMultiplier;
if (aspectRatio !== 'free') {
w = h * aspectMultiplier;
}
}
if (w < minSize) {
w = minSize;
h = w / aspectMultiplier;
if (aspectRatio !== 'free') {
h = w / aspectMultiplier;
}
}
if (h < minSize) {
h = minSize;
w = h * aspectMultiplier;
if (aspectRatio !== 'free') {
w = h * aspectMultiplier;
}
}
if (w / h !== aspectMultiplier) {
if (aspectRatio !== 'free' && w / h !== aspectMultiplier) {
if (w < minSize) {
h = w / aspectMultiplier;
}
@@ -406,6 +428,10 @@ class TransformManager implements EditToolManager {
this.cropFrame.style.width = `${crop.width}px`;
this.cropFrame.style.height = `${crop.height}px`;
this.drawOverlay(crop);
}
drawOverlay(crop: Region) {
if (!this.overlayEl) {
return;
}
@@ -439,6 +465,7 @@ class TransformManager implements EditToolManager {
const cropFrameEl = this.cropFrame;
cropFrameEl?.classList.add('transition');
this.region = this.normalizeCropArea(scale);
cropFrameEl?.classList.add('transition');
cropFrameEl?.addEventListener('transitionend', () => cropFrameEl?.classList.remove('transition'), {
passive: true,
});
@@ -513,7 +540,7 @@ class TransformManager implements EditToolManager {
normalizeCropArea(scale: number) {
const img = this.imgElement;
if (!img) {
return this.region;
return { ...this.region };
}
const scaleRatio = scale / this.cropImageScale;
@@ -549,17 +576,38 @@ class TransformManager implements EditToolManager {
this.draw();
}
handleMouseDownOn(e: MouseEvent, resizeBoundary: ResizeBoundary) {
if (e.button !== 0) {
handleMouseDown(e: MouseEvent) {
const canvas = this.cropAreaEl;
if (!canvas) {
return;
}
this.isInteracting = true;
this.resizeSide = resizeBoundary;
if (resizeBoundary === ResizeBoundary.None) {
this.isDragging = true;
const { mouseX, mouseY } = this.getMousePosition(e);
this.dragAnchor = { x: mouseX - this.region.x, y: mouseY - this.region.y };
const { mouseX, mouseY } = this.getMousePosition(e);
const {
onLeftBoundary,
onRightBoundary,
onTopBoundary,
onBottomBoundary,
onTopLeftCorner,
onTopRightCorner,
onBottomLeftCorner,
onBottomRightCorner,
} = this.isOnCropBoundary(mouseX, mouseY);
if (
onTopLeftCorner ||
onTopRightCorner ||
onBottomLeftCorner ||
onBottomRightCorner ||
onLeftBoundary ||
onRightBoundary ||
onTopBoundary ||
onBottomBoundary
) {
this.setResizeSide(mouseX, mouseY);
} else if (this.isInCropArea(mouseX, mouseY)) {
this.startDragging(mouseX, mouseY);
}
document.body.style.userSelect = 'none';
@@ -567,16 +615,20 @@ class TransformManager implements EditToolManager {
}
handleMouseMove(e: MouseEvent) {
if (!this.cropAreaEl) {
const canvas = this.cropAreaEl;
if (!canvas) {
return;
}
const resizeSideValue = this.resizeSide;
const { mouseX, mouseY } = this.getMousePosition(e);
if (this.isDragging) {
this.moveCrop(mouseX, mouseY);
} else if (this.resizeSide !== ResizeBoundary.None) {
} else if (resizeSideValue) {
this.resizeCrop(mouseX, mouseY);
} else {
this.updateCursor(mouseX, mouseY);
}
}
@@ -586,42 +638,131 @@ class TransformManager implements EditToolManager {
this.isInteracting = false;
this.isDragging = false;
this.resizeSide = ResizeBoundary.None;
this.resizeSide = '';
this.fadeOverlay(true); // Darken the background
}
getMousePosition(e: MouseEvent) {
if (!this.cropAreaEl) {
throw new Error('Crop area is undefined');
}
const clientRect = this.cropAreaEl.getBoundingClientRect();
let offsetX = e.clientX;
let offsetY = e.clientY;
const clienRect = this.cropAreaEl?.getBoundingClientRect();
const rotateDeg = this.normalizedRotation;
switch (this.normalizedRotation) {
case 90: {
return {
mouseX: e.clientY - clientRect.top,
mouseY: -e.clientX + clientRect.right,
};
}
case 180: {
return {
mouseX: -e.clientX + clientRect.right,
mouseY: -e.clientY + clientRect.bottom,
};
}
case 270: {
return {
mouseX: -e.clientY + clientRect.bottom,
mouseY: e.clientX - clientRect.left,
};
}
// also case 0:
default: {
return {
mouseX: e.clientX - clientRect.left,
mouseY: e.clientY - clientRect.top,
};
}
if (rotateDeg == 90) {
offsetX = e.clientY - (clienRect?.top ?? 0);
offsetY = window.innerWidth - e.clientX - (window.innerWidth - (clienRect?.right ?? 0));
} else if (rotateDeg == 180) {
offsetX = window.innerWidth - e.clientX - (window.innerWidth - (clienRect?.right ?? 0));
offsetY = window.innerHeight - e.clientY - (window.innerHeight - (clienRect?.bottom ?? 0));
} else if (rotateDeg == 270) {
offsetX = window.innerHeight - e.clientY - (window.innerHeight - (clienRect?.bottom ?? 0));
offsetY = e.clientX - (clienRect?.left ?? 0);
} else if (rotateDeg == 0) {
offsetX -= clienRect?.left ?? 0;
offsetY -= clienRect?.top ?? 0;
}
return { mouseX: offsetX, mouseY: offsetY };
}
// Boundary detection helpers
private isInRange(value: number, target: number, sensitivity: number): boolean {
return value >= target - sensitivity && value <= target + sensitivity;
}
private isWithinBounds(value: number, min: number, max: number): boolean {
return value >= min && value <= max;
}
isOnCropBoundary(mouseX: number, mouseY: number) {
const { x, y, width, height } = this.region;
const sensitivity = 10;
const cornerSensitivity = 15;
const { width: imgWidth, height: imgHeight } = this.previewImageSize;
const outOfBound = mouseX > imgWidth || mouseY > imgHeight || mouseX < 0 || mouseY < 0;
if (outOfBound) {
return {
onLeftBoundary: false,
onRightBoundary: false,
onTopBoundary: false,
onBottomBoundary: false,
onTopLeftCorner: false,
onTopRightCorner: false,
onBottomLeftCorner: false,
onBottomRightCorner: false,
};
}
const onLeftBoundary = this.isInRange(mouseX, x, sensitivity) && this.isWithinBounds(mouseY, y, y + height);
const onRightBoundary =
this.isInRange(mouseX, x + width, sensitivity) && this.isWithinBounds(mouseY, y, y + height);
const onTopBoundary = this.isInRange(mouseY, y, sensitivity) && this.isWithinBounds(mouseX, x, x + width);
const onBottomBoundary =
this.isInRange(mouseY, y + height, sensitivity) && this.isWithinBounds(mouseX, x, x + width);
const onTopLeftCorner =
this.isInRange(mouseX, x, cornerSensitivity) && this.isInRange(mouseY, y, cornerSensitivity);
const onTopRightCorner =
this.isInRange(mouseX, x + width, cornerSensitivity) && this.isInRange(mouseY, y, cornerSensitivity);
const onBottomLeftCorner =
this.isInRange(mouseX, x, cornerSensitivity) && this.isInRange(mouseY, y + height, cornerSensitivity);
const onBottomRightCorner =
this.isInRange(mouseX, x + width, cornerSensitivity) && this.isInRange(mouseY, y + height, cornerSensitivity);
return {
onLeftBoundary,
onRightBoundary,
onTopBoundary,
onBottomBoundary,
onTopLeftCorner,
onTopRightCorner,
onBottomLeftCorner,
onBottomRightCorner,
};
}
isInCropArea(mouseX: number, mouseY: number) {
const { x, y, width, height } = this.region;
return mouseX >= x && mouseX <= x + width && mouseY >= y && mouseY <= y + height;
}
setResizeSide(mouseX: number, mouseY: number) {
const {
onLeftBoundary,
onRightBoundary,
onTopBoundary,
onBottomBoundary,
onTopLeftCorner,
onTopRightCorner,
onBottomLeftCorner,
onBottomRightCorner,
} = this.isOnCropBoundary(mouseX, mouseY);
if (onTopLeftCorner) {
this.resizeSide = 'top-left';
} else if (onTopRightCorner) {
this.resizeSide = 'top-right';
} else if (onBottomLeftCorner) {
this.resizeSide = 'bottom-left';
} else if (onBottomRightCorner) {
this.resizeSide = 'bottom-right';
} else if (onLeftBoundary) {
this.resizeSide = 'left';
} else if (onRightBoundary) {
this.resizeSide = 'right';
} else if (onTopBoundary) {
this.resizeSide = 'top';
} else if (onBottomBoundary) {
this.resizeSide = 'bottom';
}
}
startDragging(mouseX: number, mouseY: number) {
this.isDragging = true;
const crop = this.region;
this.isInteracting = true;
this.dragOffset = { x: mouseX - crop.x, y: mouseY - crop.y };
this.fadeOverlay(false);
}
moveCrop(mouseX: number, mouseY: number) {
@@ -631,116 +772,102 @@ class TransformManager implements EditToolManager {
}
this.hasChanges = true;
this.region.x = clamp(mouseX - this.dragAnchor.x, 0, cropArea.clientWidth - this.region.width);
this.region.y = clamp(mouseY - this.dragAnchor.y, 0, cropArea.clientHeight - this.region.height);
const newX = Math.max(0, Math.min(mouseX - this.dragOffset.x, cropArea.clientWidth - this.region.width));
const newY = Math.max(0, Math.min(mouseY - this.dragOffset.y, cropArea.clientHeight - this.region.height));
this.region = {
...this.region,
x: newX,
y: newY,
};
this.draw();
}
resizeCrop(mouseX: number, mouseY: number) {
const canvas = this.cropAreaEl;
const currentCrop = this.region;
if (!canvas) {
const crop = this.region;
const resizeSideValue = this.resizeSide;
if (!canvas || !resizeSideValue) {
return;
}
this.isInteracting = true;
this.fadeOverlay(false);
this.hasChanges = true;
const { x, y, width, height } = currentCrop;
const { x, y, width, height } = crop;
const minSize = 50;
let newRegion = { ...currentCrop };
let newRegion = { ...crop };
let desiredWidth = width;
let desiredHeight = height;
// Width
switch (this.resizeSide) {
case ResizeBoundary.Left:
case ResizeBoundary.TopLeft:
case ResizeBoundary.BottomLeft: {
desiredWidth = Math.max(minSize, width + (x - Math.max(mouseX, 0)));
switch (resizeSideValue) {
case 'left': {
const desiredWidth = width + (x - mouseX);
if (desiredWidth >= minSize && mouseX >= 0) {
const { newWidth: w, newHeight: h } = this.keepAspectRatio(desiredWidth, height);
const finalWidth = Math.max(minSize, Math.min(w, canvas.clientWidth));
const finalHeight = Math.max(minSize, Math.min(h, canvas.clientHeight));
newRegion = {
x: Math.max(0, x + width - finalWidth),
y,
width: finalWidth,
height: finalHeight,
};
}
break;
}
case ResizeBoundary.Right:
case ResizeBoundary.TopRight:
case ResizeBoundary.BottomRight: {
desiredWidth = Math.max(minSize, Math.max(mouseX, 0) - x);
case 'right': {
const desiredWidth = mouseX - x;
if (desiredWidth >= minSize && mouseX <= canvas.clientWidth) {
const { newWidth: w, newHeight: h } = this.keepAspectRatio(desiredWidth, height);
newRegion = {
...newRegion,
width: Math.max(minSize, Math.min(w, canvas.clientWidth - x)),
height: Math.max(minSize, Math.min(h, canvas.clientHeight)),
};
}
break;
}
}
// Height
switch (this.resizeSide) {
case ResizeBoundary.Top:
case ResizeBoundary.TopLeft:
case ResizeBoundary.TopRight: {
desiredHeight = Math.max(minSize, height + (y - Math.max(mouseY, 0)));
case 'top': {
const desiredHeight = height + (y - mouseY);
if (desiredHeight >= minSize && mouseY >= 0) {
const { newWidth: w, newHeight: h } = this.adjustDimensions(
width,
desiredHeight,
this.cropAspectRatio,
canvas.clientWidth,
canvas.clientHeight,
minSize,
);
newRegion = {
x,
y: Math.max(0, y + height - h),
width: w,
height: h,
};
}
break;
}
case ResizeBoundary.Bottom:
case ResizeBoundary.BottomLeft:
case ResizeBoundary.BottomRight: {
desiredHeight = Math.max(minSize, Math.max(mouseY, 0) - y);
case 'bottom': {
const desiredHeight = mouseY - y;
if (desiredHeight >= minSize && mouseY <= canvas.clientHeight) {
const { newWidth: w, newHeight: h } = this.adjustDimensions(
width,
desiredHeight,
this.cropAspectRatio,
canvas.clientWidth,
canvas.clientHeight - y,
minSize,
);
newRegion = {
...newRegion,
width: w,
height: h,
};
}
break;
}
}
// Old
switch (this.resizeSide) {
case ResizeBoundary.Left: {
const { newWidth: w, newHeight: h } = this.keepAspectRatio(desiredWidth, height);
const finalWidth = clamp(w, minSize, canvas.clientWidth);
newRegion = {
x: Math.max(0, x + width - finalWidth),
y,
width: finalWidth,
height: clamp(h, minSize, canvas.clientHeight),
};
break;
}
case ResizeBoundary.Right: {
const { newWidth: w, newHeight: h } = this.keepAspectRatio(desiredWidth, height);
newRegion = {
...newRegion,
width: clamp(w, minSize, canvas.clientWidth - x),
height: clamp(h, minSize, canvas.clientHeight),
};
break;
}
case ResizeBoundary.Top: {
const { newWidth: w, newHeight: h } = this.adjustDimensions(
desiredWidth,
desiredHeight,
this.cropAspectRatio,
canvas.clientWidth,
canvas.clientHeight,
minSize,
);
newRegion = {
x,
y: Math.max(0, y + height - h),
width: w,
height: h,
};
break;
}
case ResizeBoundary.Bottom: {
const { newWidth: w, newHeight: h } = this.adjustDimensions(
desiredWidth,
desiredHeight,
this.cropAspectRatio,
canvas.clientWidth,
canvas.clientHeight - y,
minSize,
);
newRegion = {
...newRegion,
width: w,
height: h,
};
break;
}
case ResizeBoundary.TopLeft: {
case 'top-left': {
const desiredWidth = width + (x - Math.max(mouseX, 0));
const desiredHeight = height + (y - Math.max(mouseY, 0));
const { newWidth: w, newHeight: h } = this.adjustDimensions(
desiredWidth,
desiredHeight,
@@ -757,7 +884,9 @@ class TransformManager implements EditToolManager {
};
break;
}
case ResizeBoundary.TopRight: {
case 'top-right': {
const desiredWidth = Math.max(mouseX, 0) - x;
const desiredHeight = height + (y - Math.max(mouseY, 0));
const { newWidth: w, newHeight: h } = this.adjustDimensions(
desiredWidth,
desiredHeight,
@@ -774,7 +903,9 @@ class TransformManager implements EditToolManager {
};
break;
}
case ResizeBoundary.BottomLeft: {
case 'bottom-left': {
const desiredWidth = width + (x - Math.max(mouseX, 0));
const desiredHeight = Math.max(mouseY, 0) - y;
const { newWidth: w, newHeight: h } = this.adjustDimensions(
desiredWidth,
desiredHeight,
@@ -791,7 +922,9 @@ class TransformManager implements EditToolManager {
};
break;
}
case ResizeBoundary.BottomRight: {
case 'bottom-right': {
const desiredWidth = Math.max(mouseX, 0) - x;
const desiredHeight = Math.max(mouseY, 0) - y;
const { newWidth: w, newHeight: h } = this.adjustDimensions(
desiredWidth,
desiredHeight,
@@ -819,6 +952,95 @@ class TransformManager implements EditToolManager {
this.draw();
}
updateCursor(mouseX: number, mouseY: number) {
if (!this.cropAreaEl) {
return;
}
let {
onLeftBoundary,
onRightBoundary,
onTopBoundary,
onBottomBoundary,
onTopLeftCorner,
onTopRightCorner,
onBottomLeftCorner,
onBottomRightCorner,
} = this.isOnCropBoundary(mouseX, mouseY);
if (this.normalizedRotation == 90) {
[onTopBoundary, onRightBoundary, onBottomBoundary, onLeftBoundary] = [
onLeftBoundary,
onTopBoundary,
onRightBoundary,
onBottomBoundary,
];
[onTopLeftCorner, onTopRightCorner, onBottomRightCorner, onBottomLeftCorner] = [
onBottomLeftCorner,
onTopLeftCorner,
onTopRightCorner,
onBottomRightCorner,
];
} else if (this.normalizedRotation == 180) {
[onTopBoundary, onBottomBoundary] = [onBottomBoundary, onTopBoundary];
[onLeftBoundary, onRightBoundary] = [onRightBoundary, onLeftBoundary];
[onTopLeftCorner, onBottomRightCorner] = [onBottomRightCorner, onTopLeftCorner];
[onTopRightCorner, onBottomLeftCorner] = [onBottomLeftCorner, onTopRightCorner];
} else if (this.normalizedRotation == 270) {
[onTopBoundary, onRightBoundary, onBottomBoundary, onLeftBoundary] = [
onRightBoundary,
onBottomBoundary,
onLeftBoundary,
onTopBoundary,
];
[onTopLeftCorner, onTopRightCorner, onBottomRightCorner, onBottomLeftCorner] = [
onTopRightCorner,
onBottomRightCorner,
onBottomLeftCorner,
onTopLeftCorner,
];
}
let cursorName: string;
if (onTopLeftCorner || onBottomRightCorner) {
cursorName = 'nwse-resize';
} else if (onTopRightCorner || onBottomLeftCorner) {
cursorName = 'nesw-resize';
} else if (onLeftBoundary || onRightBoundary) {
cursorName = 'ew-resize';
} else if (onTopBoundary || onBottomBoundary) {
cursorName = 'ns-resize';
} else if (this.isInCropArea(mouseX, mouseY)) {
cursorName = 'move';
} else {
cursorName = 'default';
}
if (this.canvasCursor != cursorName && this.cropAreaEl && !editManager.isShowingConfirmDialog) {
this.canvasCursor = cursorName;
document.body.style.cursor = cursorName;
this.cropAreaEl.style.cursor = cursorName;
}
}
fadeOverlay(toDark: boolean) {
const overlay = this.overlayEl;
const cropFrame = document.querySelector('.crop-frame');
if (toDark) {
overlay?.classList.remove('light');
cropFrame?.classList.remove('resizing');
} else {
overlay?.classList.add('light');
cropFrame?.classList.add('resizing');
}
this.isInteracting = !toDark;
}
resetCrop() {
this.cropAspectRatio = 'free';
this.region = {