mirror of
https://github.com/immich-app/immich.git
synced 2026-03-13 22:06:53 -07:00
Compare commits
4 Commits
fix-stack-
...
feat/mobil
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f987c5569d | ||
|
|
d903f38aad | ||
|
|
a970b207d7 | ||
|
|
3d9be2477b |
149
.github/workflows/release.yml
vendored
Normal file
149
.github/workflows/release.yml
vendored
Normal 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');
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
34
pnpm-lock.yaml
generated
@@ -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: {}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user