mirror of
https://github.com/immich-app/immich.git
synced 2026-04-29 20:40:38 -04:00
Merge remote-tracking branch 'origin/main' into fix/face-selection-box-position
# Conflicts: # web/src/lib/components/asset-viewer/face-editor/face-editor.svelte
This commit is contained in:
commit
3943c310c4
170
.github/workflows/release-pr.yml
vendored
170
.github/workflows/release-pr.yml
vendored
@ -1,170 +0,0 @@
|
||||
name: Manage release PR
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
bump:
|
||||
runs-on: ubuntu-latest
|
||||
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: true
|
||||
ref: main
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version-file: './server/.nvmrc'
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||
|
||||
- name: Determine release type
|
||||
id: bump-type
|
||||
uses: ietf-tools/semver-action@c90370b2958652d71c06a3484129a4d423a6d8a8 # v1.11.0
|
||||
with:
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
|
||||
- name: Bump versions
|
||||
env:
|
||||
TYPE: ${{ steps.bump-type.outputs.bump }}
|
||||
run: |
|
||||
if [ "$TYPE" == "none" ]; then
|
||||
exit 1 # TODO: Is there a cleaner way to abort the workflow?
|
||||
fi
|
||||
misc/release/pump-version.sh -s $TYPE -m true
|
||||
|
||||
- name: Manage Outline release document
|
||||
id: outline
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
env:
|
||||
OUTLINE_API_KEY: ${{ secrets.OUTLINE_API_KEY }}
|
||||
NEXT_VERSION: ${{ steps.bump-type.outputs.next }}
|
||||
with:
|
||||
github-token: ${{ steps.generate-token.outputs.token }}
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
|
||||
const outlineKey = process.env.OUTLINE_API_KEY;
|
||||
const parentDocumentId = 'da856355-0844-43df-bd71-f8edce5382d9'
|
||||
const collectionId = 'e2910656-714c-4871-8721-447d9353bd73';
|
||||
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');
|
||||
|
||||
let documentId;
|
||||
let documentUrl;
|
||||
let documentText;
|
||||
|
||||
if (!document) {
|
||||
// Create new document
|
||||
console.log('No existing document found. Creating new one...');
|
||||
const notesTmpl = fs.readFileSync('misc/release/notes.tmpl', 'utf8');
|
||||
const createResponse = await fetch(`${baseUrl}/api/documents.create`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${outlineKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: 'next',
|
||||
text: notesTmpl,
|
||||
collectionId: collectionId,
|
||||
parentDocumentId: parentDocumentId,
|
||||
publish: true
|
||||
})
|
||||
});
|
||||
|
||||
if (!createResponse.ok) {
|
||||
throw new Error(`Failed to create document: ${createResponse.statusText}`);
|
||||
}
|
||||
|
||||
const createData = await createResponse.json();
|
||||
documentId = createData.data.id;
|
||||
const urlId = createData.data.urlId;
|
||||
documentUrl = `${baseUrl}/doc/next-${urlId}`;
|
||||
documentText = createData.data.text || '';
|
||||
console.log(`Created new document: ${documentUrl}`);
|
||||
} else {
|
||||
documentId = document.id;
|
||||
const docPath = document.url;
|
||||
documentUrl = `${baseUrl}${docPath}`;
|
||||
documentText = document.text || '';
|
||||
console.log(`Found existing document: ${documentUrl}`);
|
||||
}
|
||||
|
||||
// Generate GitHub release notes
|
||||
console.log('Generating GitHub release notes...');
|
||||
const releaseNotesResponse = await github.rest.repos.generateReleaseNotes({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
tag_name: `${process.env.NEXT_VERSION}`,
|
||||
});
|
||||
|
||||
// Combine the content
|
||||
const changelog = `
|
||||
# ${process.env.NEXT_VERSION}
|
||||
|
||||
${documentText}
|
||||
|
||||
${releaseNotesResponse.data.body}
|
||||
|
||||
---
|
||||
|
||||
`
|
||||
|
||||
const existingChangelog = fs.existsSync('CHANGELOG.md') ? fs.readFileSync('CHANGELOG.md', 'utf8') : '';
|
||||
fs.writeFileSync('CHANGELOG.md', changelog + existingChangelog, 'utf8');
|
||||
|
||||
core.setOutput('document_url', documentUrl);
|
||||
|
||||
- name: Create PR
|
||||
id: create-pr
|
||||
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
|
||||
with:
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
commit-message: 'chore: release ${{ steps.bump-type.outputs.next }}'
|
||||
title: 'chore: release ${{ steps.bump-type.outputs.next }}'
|
||||
body: 'Release notes: ${{ steps.outline.outputs.document_url }}'
|
||||
labels: 'changelog:skip'
|
||||
branch: 'release/next'
|
||||
draft: true
|
||||
@ -20,7 +20,7 @@
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/micromatch": "^4.0.9",
|
||||
"@types/mock-fs": "^4.13.1",
|
||||
"@types/node": "^24.10.14",
|
||||
"@types/node": "^24.11.0",
|
||||
"@vitest/coverage-v8": "^4.0.0",
|
||||
"byte-size": "^9.0.0",
|
||||
"cli-progress": "^3.12.0",
|
||||
|
||||
@ -155,7 +155,7 @@ services:
|
||||
|
||||
redis:
|
||||
container_name: immich_redis
|
||||
image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d
|
||||
image: docker.io/valkey/valkey:9@sha256:3eeb09785cd61ec8e3be35f8804c8892080f3ca21934d628abc24ee4ed1698f6
|
||||
healthcheck:
|
||||
test: redis-cli ping || exit 1
|
||||
|
||||
|
||||
@ -56,7 +56,7 @@ services:
|
||||
|
||||
redis:
|
||||
container_name: immich_redis
|
||||
image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d
|
||||
image: docker.io/valkey/valkey:9@sha256:3eeb09785cd61ec8e3be35f8804c8892080f3ca21934d628abc24ee4ed1698f6
|
||||
healthcheck:
|
||||
test: redis-cli ping || exit 1
|
||||
restart: always
|
||||
|
||||
@ -61,7 +61,7 @@ services:
|
||||
|
||||
redis:
|
||||
container_name: immich_redis
|
||||
image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d
|
||||
image: docker.io/valkey/valkey:9@sha256:3eeb09785cd61ec8e3be35f8804c8892080f3ca21934d628abc24ee4ed1698f6
|
||||
user: '1000:1000'
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
|
||||
@ -49,7 +49,7 @@ services:
|
||||
|
||||
redis:
|
||||
container_name: immich_redis
|
||||
image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d
|
||||
image: docker.io/valkey/valkey:9@sha256:3eeb09785cd61ec8e3be35f8804c8892080f3ca21934d628abc24ee4ed1698f6
|
||||
healthcheck:
|
||||
test: redis-cli ping || exit 1
|
||||
restart: always
|
||||
|
||||
@ -230,7 +230,7 @@ The default value is `ultrafast`.
|
||||
|
||||
### Audio codec (`ffmpeg.targetAudioCodec`) {#ffmpeg.targetAudioCodec}
|
||||
|
||||
Which audio codec to use when the audio stream is being transcoded. Can be one of `mp3`, `aac`, `libopus`.
|
||||
Which audio codec to use when the audio stream is being transcoded. Can be one of `mp3`, `aac`, `opus`.
|
||||
|
||||
The default value is `aac`.
|
||||
|
||||
|
||||
@ -27,7 +27,7 @@ The default configuration looks like this:
|
||||
"ffmpeg": {
|
||||
"accel": "disabled",
|
||||
"accelDecode": false,
|
||||
"acceptedAudioCodecs": ["aac", "mp3", "libopus"],
|
||||
"acceptedAudioCodecs": ["aac", "mp3", "opus"],
|
||||
"acceptedContainers": ["mov", "ogg", "webm"],
|
||||
"acceptedVideoCodecs": ["h264"],
|
||||
"bframes": -1,
|
||||
|
||||
@ -44,7 +44,7 @@ services:
|
||||
|
||||
redis:
|
||||
container_name: immich-e2e-redis
|
||||
image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d
|
||||
image: docker.io/valkey/valkey:9@sha256:3eeb09785cd61ec8e3be35f8804c8892080f3ca21934d628abc24ee4ed1698f6
|
||||
healthcheck:
|
||||
test: redis-cli ping || exit 1
|
||||
|
||||
|
||||
@ -32,7 +32,7 @@
|
||||
"@playwright/test": "^1.44.1",
|
||||
"@socket.io/component-emitter": "^3.1.2",
|
||||
"@types/luxon": "^3.4.2",
|
||||
"@types/node": "^24.10.14",
|
||||
"@types/node": "^24.11.0",
|
||||
"@types/pg": "^8.15.1",
|
||||
"@types/pngjs": "^6.0.4",
|
||||
"@types/supertest": "^6.0.2",
|
||||
|
||||
@ -1,14 +1,13 @@
|
||||
import { AssetMediaResponseDto, LoginResponseDto } from '@immich/sdk';
|
||||
import { Page, expect, test } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import type { Socket } from 'socket.io-client';
|
||||
import { utils } from 'src/utils';
|
||||
|
||||
function imageLocator(page: Page) {
|
||||
return page.getByAltText('Image taken').locator('visible=true');
|
||||
}
|
||||
test.describe('Photo Viewer', () => {
|
||||
let admin: LoginResponseDto;
|
||||
let asset: AssetMediaResponseDto;
|
||||
let rawAsset: AssetMediaResponseDto;
|
||||
let websocket: Socket;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
utils.initSdk();
|
||||
@ -16,6 +15,11 @@ test.describe('Photo Viewer', () => {
|
||||
admin = await utils.adminSetup();
|
||||
asset = await utils.createAsset(admin.accessToken);
|
||||
rawAsset = await utils.createAsset(admin.accessToken, { assetData: { filename: 'test.arw' } });
|
||||
websocket = await utils.connectWebsocket(admin.accessToken);
|
||||
});
|
||||
|
||||
test.afterAll(() => {
|
||||
utils.disconnectWebsocket(websocket);
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ context, page }) => {
|
||||
@ -26,31 +30,51 @@ test.describe('Photo Viewer', () => {
|
||||
|
||||
test('loads original photo when zoomed', async ({ page }) => {
|
||||
await page.goto(`/photos/${asset.id}`);
|
||||
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');
|
||||
const box = await imageLocator(page).boundingBox();
|
||||
expect(box).toBeTruthy();
|
||||
const { x, y, width, height } = box!;
|
||||
await page.mouse.move(x + width / 2, y + height / 2);
|
||||
|
||||
const preview = page.getByTestId('preview').filter({ visible: true });
|
||||
await expect(preview).toHaveAttribute('src', /.+/);
|
||||
|
||||
const originalResponse = page.waitForResponse((response) => response.url().includes('/original'));
|
||||
|
||||
const { width, height } = page.viewportSize()!;
|
||||
await page.mouse.move(width / 2, height / 2);
|
||||
await page.mouse.wheel(0, -1);
|
||||
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('original');
|
||||
|
||||
await originalResponse;
|
||||
|
||||
const original = page.getByTestId('original').filter({ visible: true });
|
||||
await expect(original).toHaveAttribute('src', /original/);
|
||||
});
|
||||
|
||||
test('loads fullsize image when zoomed and original is web-incompatible', async ({ page }) => {
|
||||
await page.goto(`/photos/${rawAsset.id}`);
|
||||
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');
|
||||
const box = await imageLocator(page).boundingBox();
|
||||
expect(box).toBeTruthy();
|
||||
const { x, y, width, height } = box!;
|
||||
await page.mouse.move(x + width / 2, y + height / 2);
|
||||
|
||||
const preview = page.getByTestId('preview').filter({ visible: true });
|
||||
await expect(preview).toHaveAttribute('src', /.+/);
|
||||
|
||||
const fullsizeResponse = page.waitForResponse((response) => response.url().includes('fullsize'));
|
||||
|
||||
const { width, height } = page.viewportSize()!;
|
||||
await page.mouse.move(width / 2, height / 2);
|
||||
await page.mouse.wheel(0, -1);
|
||||
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('fullsize');
|
||||
|
||||
await fullsizeResponse;
|
||||
|
||||
const original = page.getByTestId('original').filter({ visible: true });
|
||||
await expect(original).toHaveAttribute('src', /fullsize/);
|
||||
});
|
||||
|
||||
test('reloads photo when checksum changes', async ({ page }) => {
|
||||
await page.goto(`/photos/${asset.id}`);
|
||||
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');
|
||||
const initialSrc = await imageLocator(page).getAttribute('src');
|
||||
|
||||
const preview = page.getByTestId('preview').filter({ visible: true });
|
||||
await expect(preview).toHaveAttribute('src', /.+/);
|
||||
const initialSrc = await preview.getAttribute('src');
|
||||
|
||||
const websocketEvent = utils.waitForWebsocketEvent({ event: 'assetUpdate', id: asset.id });
|
||||
await utils.replaceAsset(admin.accessToken, asset.id);
|
||||
await expect.poll(async () => await imageLocator(page).getAttribute('src')).not.toBe(initialSrc);
|
||||
await websocketEvent;
|
||||
|
||||
await expect(preview).not.toHaveAttribute('src', initialSrc!);
|
||||
});
|
||||
});
|
||||
|
||||
@ -64,7 +64,9 @@ test.describe('broken-asset responsiveness', () => {
|
||||
|
||||
test('broken asset in main viewer shows icon and uses text-base', async ({ context, page }) => {
|
||||
await context.route(
|
||||
(url) => url.pathname.includes(`/api/assets/${fixture.primaryAsset.id}/thumbnail`),
|
||||
(url) =>
|
||||
url.pathname.includes(`/api/assets/${fixture.primaryAsset.id}/thumbnail`) ||
|
||||
url.pathname.includes(`/api/assets/${fixture.primaryAsset.id}/original`),
|
||||
async (route) => {
|
||||
return route.fulfill({ status: 404 });
|
||||
},
|
||||
@ -73,7 +75,7 @@ test.describe('broken-asset responsiveness', () => {
|
||||
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||
await page.waitForSelector('#immich-asset-viewer');
|
||||
|
||||
const viewerBrokenAsset = page.locator('#immich-asset-viewer #broken-asset [data-broken-asset]');
|
||||
const viewerBrokenAsset = page.locator('[data-viewer-content] [data-broken-asset]').first();
|
||||
await expect(viewerBrokenAsset).toBeVisible();
|
||||
|
||||
await expect(viewerBrokenAsset.locator('svg')).toBeVisible();
|
||||
|
||||
@ -215,8 +215,9 @@ export const pageUtils = {
|
||||
await page.getByText('Confirm').click();
|
||||
},
|
||||
async selectDay(page: Page, day: string) {
|
||||
await page.getByTitle(day).hover();
|
||||
await page.locator('[data-group] .w-8').click();
|
||||
const section = page.getByTitle(day).locator('xpath=ancestor::section[@data-group]');
|
||||
await section.hover();
|
||||
await section.locator('.w-8').click();
|
||||
},
|
||||
async pauseTestDebug() {
|
||||
console.log('NOTE: pausing test indefinately for debug');
|
||||
|
||||
@ -177,40 +177,51 @@ export const utils = {
|
||||
},
|
||||
|
||||
resetDatabase: async (tables?: string[]) => {
|
||||
try {
|
||||
client = await utils.connectDatabase();
|
||||
client = await utils.connectDatabase();
|
||||
|
||||
tables = tables || [
|
||||
// TODO e2e test for deleting a stack, since it is quite complex
|
||||
'stack',
|
||||
'library',
|
||||
'shared_link',
|
||||
'person',
|
||||
'album',
|
||||
'asset',
|
||||
'asset_face',
|
||||
'activity',
|
||||
'api_key',
|
||||
'session',
|
||||
'user',
|
||||
'system_metadata',
|
||||
'tag',
|
||||
];
|
||||
tables = tables || [
|
||||
// TODO e2e test for deleting a stack, since it is quite complex
|
||||
'stack',
|
||||
'library',
|
||||
'shared_link',
|
||||
'person',
|
||||
'album',
|
||||
'asset',
|
||||
'asset_face',
|
||||
'activity',
|
||||
'api_key',
|
||||
'session',
|
||||
'user',
|
||||
'system_metadata',
|
||||
'tag',
|
||||
];
|
||||
|
||||
const sql: string[] = [];
|
||||
const truncateTables = tables.filter((table) => table !== 'system_metadata');
|
||||
const sql: string[] = [];
|
||||
|
||||
for (const table of tables) {
|
||||
if (table === 'system_metadata') {
|
||||
sql.push(`DELETE FROM "system_metadata" where "key" NOT IN ('reverse-geocoding-state', 'system-flags');`);
|
||||
} else {
|
||||
sql.push(`DELETE FROM "${table}" CASCADE;`);
|
||||
if (truncateTables.length > 0) {
|
||||
sql.push(`TRUNCATE "${truncateTables.join('", "')}" CASCADE;`);
|
||||
}
|
||||
|
||||
if (tables.includes('system_metadata')) {
|
||||
sql.push(`DELETE FROM "system_metadata" where "key" NOT IN ('reverse-geocoding-state', 'system-flags');`);
|
||||
}
|
||||
|
||||
const query = sql.join('\n');
|
||||
const maxRetries = 3;
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
await client.query(query);
|
||||
return;
|
||||
} catch (error: any) {
|
||||
if (error?.code === '40P01' && attempt < maxRetries) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 250 * attempt));
|
||||
continue;
|
||||
}
|
||||
console.error('Failed to reset database', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
await client.query(sql.join('\n'));
|
||||
} catch (error) {
|
||||
console.error('Failed to reset database', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@ -64,14 +64,6 @@ class OrtSession:
|
||||
def _providers_default(self) -> list[str]:
|
||||
available_providers = set(ort.get_available_providers())
|
||||
log.debug(f"Available ORT providers: {available_providers}")
|
||||
if (openvino := "OpenVINOExecutionProvider") in available_providers:
|
||||
device_ids: list[str] = ort.capi._pybind_state.get_available_openvino_device_ids()
|
||||
log.debug(f"Available OpenVINO devices: {device_ids}")
|
||||
|
||||
gpu_devices = [device_id for device_id in device_ids if device_id.startswith("GPU")]
|
||||
if not gpu_devices:
|
||||
log.warning("No GPU device found in OpenVINO. Falling back to CPU.")
|
||||
available_providers.remove(openvino)
|
||||
return [provider for provider in SUPPORTED_PROVIDERS if provider in available_providers]
|
||||
|
||||
@property
|
||||
@ -102,12 +94,19 @@ class OrtSession:
|
||||
"migraphx_fp16_enable": "1" if settings.rocm_precision == ModelPrecision.FP16 else "0",
|
||||
}
|
||||
case "OpenVINOExecutionProvider":
|
||||
openvino_dir = self.model_path.parent / "openvino"
|
||||
device = f"GPU.{settings.device_id}"
|
||||
device_ids: list[str] = ort.capi._pybind_state.get_available_openvino_device_ids()
|
||||
# Check for available devices, preferring GPU over CPU
|
||||
gpu_devices = [d for d in device_ids if d.startswith("GPU")]
|
||||
if gpu_devices:
|
||||
device_type = f"GPU.{settings.device_id}"
|
||||
log.debug(f"OpenVINO: Using GPU device {device_type}")
|
||||
else:
|
||||
device_type = "CPU"
|
||||
log.debug("OpenVINO: No GPU found, using CPU")
|
||||
options = {
|
||||
"device_type": device,
|
||||
"device_type": device_type,
|
||||
"precision": settings.openvino_precision.value,
|
||||
"cache_dir": openvino_dir.as_posix(),
|
||||
"cache_dir": (self.model_path.parent / "openvino").as_posix(),
|
||||
}
|
||||
case "CoreMLExecutionProvider":
|
||||
options = {
|
||||
@ -139,12 +138,14 @@ class OrtSession:
|
||||
sess_options.enable_cpu_mem_arena = settings.model_arena
|
||||
|
||||
# avoid thread contention between models
|
||||
# Set inter_op threads
|
||||
if settings.model_inter_op_threads > 0:
|
||||
sess_options.inter_op_num_threads = settings.model_inter_op_threads
|
||||
# these defaults work well for CPU, but bottleneck GPU
|
||||
elif settings.model_inter_op_threads == 0 and self.providers == ["CPUExecutionProvider"]:
|
||||
sess_options.inter_op_num_threads = 1
|
||||
|
||||
# Set intra_op threads
|
||||
if settings.model_intra_op_threads > 0:
|
||||
sess_options.intra_op_num_threads = settings.model_intra_op_threads
|
||||
elif settings.model_intra_op_threads == 0 and self.providers == ["CPUExecutionProvider"]:
|
||||
|
||||
@ -204,13 +204,6 @@ class TestOrtSession:
|
||||
|
||||
assert session.providers == self.OV_EP
|
||||
|
||||
@pytest.mark.ov_device_ids(["CPU"])
|
||||
@pytest.mark.providers(OV_EP)
|
||||
def test_avoids_openvino_if_gpu_not_available(self, providers: list[str], ov_device_ids: list[str]) -> None:
|
||||
session = OrtSession("ViT-B-32__openai")
|
||||
|
||||
assert session.providers == self.CPU_EP
|
||||
|
||||
@pytest.mark.providers(CUDA_EP_OUT_OF_ORDER)
|
||||
def test_sets_providers_in_correct_order(self, providers: list[str]) -> None:
|
||||
session = OrtSession("ViT-B-32__openai")
|
||||
@ -256,7 +249,8 @@ class TestOrtSession:
|
||||
{"arena_extend_strategy": "kSameAsRequested"},
|
||||
]
|
||||
|
||||
def test_sets_provider_options_for_openvino(self) -> None:
|
||||
@pytest.mark.ov_device_ids(["GPU.0", "GPU.1", "CPU"])
|
||||
def test_sets_provider_options_for_openvino(self, ov_device_ids: list[str]) -> None:
|
||||
model_path = "/cache/ViT-B-32__openai/textual/model.onnx"
|
||||
os.environ["MACHINE_LEARNING_DEVICE_ID"] = "1"
|
||||
|
||||
@ -270,7 +264,8 @@ class TestOrtSession:
|
||||
}
|
||||
]
|
||||
|
||||
def test_sets_openvino_to_fp16_if_enabled(self, mocker: MockerFixture) -> None:
|
||||
@pytest.mark.ov_device_ids(["GPU.0", "GPU.1", "CPU"])
|
||||
def test_sets_openvino_to_fp16_if_enabled(self, ov_device_ids: list[str], mocker: MockerFixture) -> None:
|
||||
model_path = "/cache/ViT-B-32__openai/textual/model.onnx"
|
||||
os.environ["MACHINE_LEARNING_DEVICE_ID"] = "1"
|
||||
mocker.patch.object(settings, "openvino_precision", ModelPrecision.FP16)
|
||||
@ -285,6 +280,19 @@ class TestOrtSession:
|
||||
}
|
||||
]
|
||||
|
||||
@pytest.mark.ov_device_ids(["CPU"])
|
||||
def test_sets_provider_options_for_openvino_cpu(self, ov_device_ids: list[str]) -> None:
|
||||
model_path = "/cache/ViT-B-32__openai/model.onnx"
|
||||
session = OrtSession(model_path, providers=["OpenVINOExecutionProvider"])
|
||||
|
||||
assert session.provider_options == [
|
||||
{
|
||||
"device_type": "CPU",
|
||||
"precision": "FP32",
|
||||
"cache_dir": "/cache/ViT-B-32__openai/openvino",
|
||||
}
|
||||
]
|
||||
|
||||
def test_sets_provider_options_for_cuda(self) -> None:
|
||||
os.environ["MACHINE_LEARNING_DEVICE_ID"] = "1"
|
||||
|
||||
@ -341,6 +349,23 @@ class TestOrtSession:
|
||||
assert session.sess_options.inter_op_num_threads == 1
|
||||
assert session.sess_options.intra_op_num_threads == 2
|
||||
|
||||
@pytest.mark.ov_device_ids(["CPU"])
|
||||
def test_sets_default_sess_options_if_openvino_cpu(self, ov_device_ids: list[str]) -> None:
|
||||
model_path = "/cache/ViT-B-32__openai/model.onnx"
|
||||
session = OrtSession(model_path, providers=["OpenVINOExecutionProvider"])
|
||||
|
||||
assert session.sess_options.execution_mode == ort.ExecutionMode.ORT_SEQUENTIAL
|
||||
assert session.sess_options.inter_op_num_threads == 0
|
||||
assert session.sess_options.intra_op_num_threads == 0
|
||||
|
||||
@pytest.mark.ov_device_ids(["GPU.0", "CPU"])
|
||||
def test_sets_default_sess_options_if_openvino_gpu(self, ov_device_ids: list[str]) -> None:
|
||||
model_path = "/cache/ViT-B-32__openai/model.onnx"
|
||||
session = OrtSession(model_path, providers=["OpenVINOExecutionProvider"])
|
||||
|
||||
assert session.sess_options.inter_op_num_threads == 0
|
||||
assert session.sess_options.intra_op_num_threads == 0
|
||||
|
||||
def test_sets_default_sess_options_does_not_set_threads_if_non_cpu_and_default_threads(self) -> None:
|
||||
session = OrtSession("ViT-B-32__openai", providers=["CUDAExecutionProvider", "CPUExecutionProvider"])
|
||||
|
||||
|
||||
@ -16,6 +16,7 @@ import app.alextran.immich.core.ImmichPlugin
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.load.ImageHeaderParser
|
||||
import com.bumptech.glide.load.ImageHeaderParserUtils
|
||||
import com.bumptech.glide.load.resource.bitmap.DefaultImageHeaderParser
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@ -81,10 +82,13 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
|
||||
}
|
||||
if (hasSpecialFormatColumn()) {
|
||||
add(SPECIAL_FORMAT_COLUMN)
|
||||
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
// Fallback: read XMP from MediaStore to detect Motion Photos
|
||||
// only needed if SPECIAL_FORMAT column isn't available
|
||||
add(MediaStore.MediaColumns.XMP)
|
||||
} else {
|
||||
// fallback to mimetype and xmp for playback style detection on older Android versions
|
||||
// both only needed if special format column is not available
|
||||
add(MediaStore.MediaColumns.MIME_TYPE)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
add(MediaStore.MediaColumns.XMP)
|
||||
}
|
||||
}
|
||||
}.toTypedArray()
|
||||
|
||||
@ -131,6 +135,7 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
|
||||
val dateAddedColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_ADDED)
|
||||
val dateModifiedColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED)
|
||||
val mediaTypeColumn = c.getColumnIndexOrThrow(MediaStore.Files.FileColumns.MEDIA_TYPE)
|
||||
val mimeTypeColumn = c.getColumnIndex(MediaStore.MediaColumns.MIME_TYPE)
|
||||
val bucketIdColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.BUCKET_ID)
|
||||
val widthColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.WIDTH)
|
||||
val heightColumn = c.getColumnIndexOrThrow(MediaStore.MediaColumns.HEIGHT)
|
||||
@ -177,19 +182,20 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
|
||||
val isFavorite = if (favoriteColumn == -1) false else c.getInt(favoriteColumn) != 0
|
||||
|
||||
val playbackStyle = detectPlaybackStyle(
|
||||
numericId, rawMediaType, specialFormatColumn, xmpColumn, c
|
||||
numericId, rawMediaType, mimeTypeColumn, specialFormatColumn, xmpColumn, c
|
||||
)
|
||||
|
||||
val isFlipped = orientation == 90 || orientation == 270
|
||||
val asset = PlatformAsset(
|
||||
id,
|
||||
name,
|
||||
assetType,
|
||||
createdAt,
|
||||
modifiedAt,
|
||||
width,
|
||||
height,
|
||||
if (isFlipped) height else width,
|
||||
if (isFlipped) width else height,
|
||||
duration,
|
||||
orientation.toLong(),
|
||||
0L,
|
||||
isFavorite,
|
||||
playbackStyle = playbackStyle,
|
||||
)
|
||||
@ -200,13 +206,14 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects the playback style for an asset using _special_format (API 33+)
|
||||
* or XMP / MIME / RIFF header fallbacks (pre-33).
|
||||
* Detects the playback style for an asset using _special_format (SDK Extension 21+)
|
||||
* or XMP / MIME / RIFF header fallbacks.
|
||||
*/
|
||||
@SuppressLint("NewApi")
|
||||
private fun detectPlaybackStyle(
|
||||
assetId: Long,
|
||||
rawMediaType: Int,
|
||||
mimeTypeColumn: Int,
|
||||
specialFormatColumn: Int,
|
||||
xmpColumn: Int,
|
||||
cursor: Cursor
|
||||
@ -231,46 +238,56 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
|
||||
return PlatformAssetPlaybackStyle.UNKNOWN
|
||||
}
|
||||
|
||||
// Pre-API 33 fallback
|
||||
val mimeType = if (mimeTypeColumn != -1) cursor.getString(mimeTypeColumn) else null
|
||||
|
||||
// GIFs are always animated and cannot be motion photos; no I/O needed
|
||||
if (mimeType == "image/gif") {
|
||||
return PlatformAssetPlaybackStyle.IMAGE_ANIMATED
|
||||
}
|
||||
|
||||
val uri = ContentUris.withAppendedId(
|
||||
MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL),
|
||||
assetId
|
||||
)
|
||||
|
||||
// Read XMP from cursor (API 30+) or ExifInterface stream (pre-30)
|
||||
// Only WebP needs a stream check to distinguish static vs animated;
|
||||
// WebP files are not used as motion photos, so skip XMP detection
|
||||
if (mimeType == "image/webp") {
|
||||
try {
|
||||
val glide = Glide.get(ctx)
|
||||
ctx.contentResolver.openInputStream(uri)?.use { stream ->
|
||||
val type = ImageHeaderParserUtils.getType(
|
||||
listOf(DefaultImageHeaderParser()),
|
||||
stream,
|
||||
glide.arrayPool
|
||||
)
|
||||
// Also check for GIF just in case MIME type is incorrect; Doesn't hurt performance
|
||||
if (type == ImageHeaderParser.ImageType.ANIMATED_WEBP || type == ImageHeaderParser.ImageType.GIF) {
|
||||
return PlatformAssetPlaybackStyle.IMAGE_ANIMATED
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to parse image header for asset $assetId", e)
|
||||
}
|
||||
// if mimeType is webp but not animated, its just an image.
|
||||
return PlatformAssetPlaybackStyle.IMAGE
|
||||
}
|
||||
|
||||
|
||||
// Read XMP from cursor (API 30+)
|
||||
val xmp: String? = if (xmpColumn != -1) {
|
||||
cursor.getBlob(xmpColumn)?.toString(Charsets.UTF_8)
|
||||
} else {
|
||||
try {
|
||||
ctx.contentResolver.openInputStream(uri)?.use { stream ->
|
||||
ExifInterface(stream).getAttribute(ExifInterface.TAG_XMP)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to read XMP for asset $assetId", e)
|
||||
null
|
||||
}
|
||||
// if xmp column is not available, we are on API 29 or below
|
||||
// theoretically there were motion photos but the Camera:MotionPhoto xmp tag
|
||||
// was only added in Android 11, so we should not have to worry about parsing XMP on older versions
|
||||
null
|
||||
}
|
||||
|
||||
if (xmp != null && "Camera:MotionPhoto" in xmp) {
|
||||
return PlatformAssetPlaybackStyle.LIVE_PHOTO
|
||||
}
|
||||
|
||||
try {
|
||||
ctx.contentResolver.openInputStream(uri)?.use { stream ->
|
||||
val glide = Glide.get(ctx)
|
||||
val type = ImageHeaderParserUtils.getType(
|
||||
glide.registry.imageHeaderParsers,
|
||||
stream,
|
||||
glide.arrayPool
|
||||
)
|
||||
if (type == ImageHeaderParser.ImageType.GIF || type == ImageHeaderParser.ImageType.ANIMATED_WEBP) {
|
||||
return PlatformAssetPlaybackStyle.IMAGE_ANIMATED
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to parse image header for asset $assetId", e)
|
||||
}
|
||||
|
||||
return PlatformAssetPlaybackStyle.IMAGE
|
||||
}
|
||||
|
||||
|
||||
@ -7,6 +7,6 @@ const String defaultColorPresetName = "indigo";
|
||||
|
||||
const Color immichBrandColorLight = Color(0xFF4150AF);
|
||||
const Color immichBrandColorDark = Color(0xFFACCBFA);
|
||||
const Color whiteOpacity75 = Color.fromARGB((0.75 * 255) ~/ 1, 255, 255, 255);
|
||||
const Color whiteOpacity75 = Color.fromRGBO(255, 255, 255, 0.75);
|
||||
const Color red400 = Color(0xFFEF5350);
|
||||
const Color grey200 = Color(0xFFEEEEEE);
|
||||
|
||||
@ -148,10 +148,12 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
|
||||
children: [
|
||||
Icon(Icons.warning_rounded, color: context.colorScheme.error, fill: 1),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
context.t.backup_error_sync_failed,
|
||||
style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.error),
|
||||
textAlign: TextAlign.center,
|
||||
Flexible(
|
||||
child: Text(
|
||||
context.t.backup_error_sync_failed,
|
||||
style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.error),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@ -344,6 +346,7 @@ class _RemainderCard extends ConsumerWidget {
|
||||
remainderCount.toString(),
|
||||
style: context.textTheme.titleLarge?.copyWith(
|
||||
color: context.colorScheme.onSurface.withAlpha(syncStatus.isRemoteSyncing ? 50 : 255),
|
||||
fontFeatures: [const FontFeature.tabularFigures()],
|
||||
),
|
||||
),
|
||||
if (syncStatus.isRemoteSyncing)
|
||||
@ -483,6 +486,7 @@ class _PreparingStatusState extends ConsumerState {
|
||||
style: context.textTheme.titleMedium?.copyWith(
|
||||
color: context.colorScheme.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontFeatures: [const FontFeature.tabularFigures()],
|
||||
),
|
||||
),
|
||||
],
|
||||
@ -507,6 +511,7 @@ class _PreparingStatusState extends ConsumerState {
|
||||
style: context.textTheme.titleMedium?.copyWith(
|
||||
color: context.primaryColor,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontFeatures: [const FontFeature.tabularFigures()],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@ -19,7 +19,6 @@ import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart';
|
||||
@ -248,11 +247,6 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||
|
||||
if (scaleState != PhotoViewScaleState.initial) {
|
||||
if (_dragStart == null) _viewer.setControls(false);
|
||||
|
||||
final heroTag = ref.read(assetViewerProvider).currentAsset?.heroTag;
|
||||
if (heroTag != null) {
|
||||
ref.read(videoPlayerProvider(heroTag).notifier).pause();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@ -61,15 +61,27 @@ class ViewerBottomBar extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
child: Container(
|
||||
color: Colors.black.withAlpha(125),
|
||||
padding: EdgeInsets.only(bottom: context.padding.bottom, top: 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (asset.isVideo) VideoControls(videoPlayerName: asset.heroTag),
|
||||
if (!isReadonlyModeEnabled)
|
||||
Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions),
|
||||
],
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.bottomCenter,
|
||||
end: Alignment.topCenter,
|
||||
colors: [Colors.black45, Colors.black12, Colors.transparent],
|
||||
stops: [0.0, 0.7, 1.0],
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (asset.isVideo) VideoControls(videoPlayerName: asset.heroTag),
|
||||
if (!isReadonlyModeEnabled)
|
||||
Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@ -10,7 +10,6 @@ import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer_controls.widget.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
|
||||
@ -186,11 +185,7 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
|
||||
final source = await _videoSource;
|
||||
if (source == null || !mounted) return;
|
||||
|
||||
unawaited(
|
||||
nc.loadVideoSource(source).catchError((error) {
|
||||
_log.severe('Error loading video source: $error');
|
||||
}),
|
||||
);
|
||||
await _notifier.load(source);
|
||||
final loopVideo = ref.read(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.loopVideo);
|
||||
await _notifier.setLoop(!widget.asset.isMotionPhoto && loopVideo);
|
||||
await _notifier.setVolume(1);
|
||||
@ -213,21 +208,28 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Prevent the provider from being disposed whilst the widget is alive.
|
||||
ref.listen(videoPlayerProvider(widget.asset.heroTag), (_, __) {});
|
||||
|
||||
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
|
||||
final status = ref.watch(videoPlayerProvider(widget.asset.heroTag).select((v) => v.status));
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
Center(child: widget.image),
|
||||
if (!isCasting)
|
||||
Visibility.maintain(
|
||||
visible: _isVideoReady,
|
||||
child: NativeVideoPlayerView(onViewReady: _initController),
|
||||
),
|
||||
if (widget.showControls) Center(child: VideoViewerControls(asset: widget.asset)),
|
||||
],
|
||||
return IgnorePointer(
|
||||
child: Stack(
|
||||
children: [
|
||||
Center(child: widget.image),
|
||||
if (!isCasting) ...[
|
||||
Visibility.maintain(
|
||||
visible: _isVideoReady,
|
||||
child: NativeVideoPlayerView(onViewReady: _initController),
|
||||
),
|
||||
Center(
|
||||
child: AnimatedOpacity(
|
||||
opacity: status == VideoPlaybackStatus.buffering ? 1.0 : 0.0,
|
||||
duration: const Duration(milliseconds: 400),
|
||||
child: const CircularProgressIndicator(),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,114 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/models/cast/cast_manager_state.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
|
||||
import 'package:immich_mobile/providers/cast.provider.dart';
|
||||
import 'package:immich_mobile/utils/hooks/timer_hook.dart';
|
||||
import 'package:immich_mobile/widgets/asset_viewer/center_play_button.dart';
|
||||
import 'package:immich_mobile/widgets/common/delayed_loading_indicator.dart';
|
||||
|
||||
class VideoViewerControls extends HookConsumerWidget {
|
||||
final BaseAsset asset;
|
||||
final Duration hideTimerDuration;
|
||||
|
||||
const VideoViewerControls({super.key, required this.asset, this.hideTimerDuration = const Duration(seconds: 5)});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final videoPlayerName = asset.heroTag;
|
||||
final assetIsVideo = asset.isVideo;
|
||||
final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls && !s.showingDetails));
|
||||
final status = ref.watch(videoPlayerProvider(videoPlayerName).select((value) => value.status));
|
||||
|
||||
final cast = ref.watch(castProvider);
|
||||
|
||||
// A timer to hide the controls
|
||||
final hideTimer = useTimer(hideTimerDuration, () {
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
final status = ref.read(videoPlayerProvider(videoPlayerName)).status;
|
||||
|
||||
// Do not hide on paused
|
||||
if (status != VideoPlaybackStatus.paused && status != VideoPlaybackStatus.completed && assetIsVideo) {
|
||||
ref.read(assetViewerProvider.notifier).setControls(false);
|
||||
}
|
||||
});
|
||||
final showBuffering = status == VideoPlaybackStatus.buffering && !cast.isCasting;
|
||||
|
||||
/// Shows the controls and starts the timer to hide them
|
||||
void showControlsAndStartHideTimer() {
|
||||
hideTimer.reset();
|
||||
ref.read(assetViewerProvider.notifier).setControls(true);
|
||||
}
|
||||
|
||||
// When playback starts, reset the hide timer
|
||||
ref.listen(videoPlayerProvider(videoPlayerName).select((v) => v.status), (previous, next) {
|
||||
if (next == VideoPlaybackStatus.playing) {
|
||||
hideTimer.reset();
|
||||
}
|
||||
});
|
||||
|
||||
/// Toggles between playing and pausing depending on the state of the video
|
||||
void togglePlay() {
|
||||
showControlsAndStartHideTimer();
|
||||
|
||||
if (cast.isCasting) {
|
||||
switch (cast.castState) {
|
||||
case CastState.playing:
|
||||
ref.read(castProvider.notifier).pause();
|
||||
case CastState.paused:
|
||||
ref.read(castProvider.notifier).play();
|
||||
default:
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final notifier = ref.read(videoPlayerProvider(videoPlayerName).notifier);
|
||||
switch (status) {
|
||||
case VideoPlaybackStatus.playing:
|
||||
notifier.pause();
|
||||
case VideoPlaybackStatus.completed:
|
||||
notifier.restart();
|
||||
default:
|
||||
notifier.play();
|
||||
}
|
||||
}
|
||||
|
||||
void toggleControlsVisibility() {
|
||||
if (showBuffering) return;
|
||||
|
||||
if (showControls) {
|
||||
ref.read(assetViewerProvider.notifier).setControls(false);
|
||||
} else {
|
||||
showControlsAndStartHideTimer();
|
||||
}
|
||||
}
|
||||
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onTap: toggleControlsVisibility,
|
||||
child: IgnorePointer(
|
||||
ignoring: !showControls,
|
||||
child: Stack(
|
||||
children: [
|
||||
if (showBuffering)
|
||||
const Center(child: DelayedLoadingIndicator(fadeInDuration: Duration(milliseconds: 400)))
|
||||
else
|
||||
CenterPlayButton(
|
||||
backgroundColor: Colors.black54,
|
||||
iconColor: Colors.white,
|
||||
isFinished: status == VideoPlaybackStatus.completed,
|
||||
isPlaying:
|
||||
status == VideoPlaybackStatus.playing || (cast.isCasting && cast.castState == CastState.playing),
|
||||
show: assetIsVideo && showControls,
|
||||
onPressed: togglePlay,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -75,17 +75,29 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
||||
child: AnimatedOpacity(
|
||||
opacity: opacity,
|
||||
duration: Durations.short2,
|
||||
child: AppBar(
|
||||
backgroundColor: showingDetails ? Colors.transparent : Colors.black.withValues(alpha: 0.5),
|
||||
leading: const _AppBarBackButton(),
|
||||
iconTheme: const IconThemeData(size: 22, color: Colors.white),
|
||||
actionsIconTheme: const IconThemeData(size: 22, color: Colors.white),
|
||||
shape: const Border(),
|
||||
actions: showingDetails || isReadonlyModeEnabled
|
||||
? null
|
||||
: isInLockedView
|
||||
? lockedViewActions
|
||||
: actions,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: showingDetails
|
||||
? null
|
||||
: const LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Colors.black45, Colors.black12, Colors.transparent],
|
||||
stops: [0.0, 0.7, 1.0],
|
||||
),
|
||||
),
|
||||
child: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
leading: const _AppBarBackButton(),
|
||||
iconTheme: const IconThemeData(size: 22, color: Colors.white),
|
||||
actionsIconTheme: const IconThemeData(size: 22, color: Colors.white),
|
||||
shape: const Border(),
|
||||
actions: showingDetails || isReadonlyModeEnabled
|
||||
? null
|
||||
: isInLockedView
|
||||
? lockedViewActions
|
||||
: actions,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@ -100,11 +100,11 @@ class AssetViewerStateNotifier extends Notifier<AssetViewerState> {
|
||||
return;
|
||||
}
|
||||
state = state.copyWith(showingDetails: showing, showingControls: showing ? true : state.showingControls);
|
||||
if (showing) {
|
||||
final heroTag = state.currentAsset?.heroTag;
|
||||
if (heroTag != null) {
|
||||
ref.read(videoPlayerProvider(heroTag).notifier).pause();
|
||||
}
|
||||
|
||||
final heroTag = state.currentAsset?.heroTag;
|
||||
if (heroTag != null) {
|
||||
final notifier = ref.read(videoPlayerProvider(heroTag).notifier);
|
||||
showing ? notifier.hold() : notifier.release();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -44,10 +44,7 @@ class VideoPlayerNotifier extends StateNotifier<VideoPlayerState> {
|
||||
NativeVideoPlayerController? _controller;
|
||||
Timer? _bufferingTimer;
|
||||
Timer? _seekTimer;
|
||||
|
||||
void attachController(NativeVideoPlayerController controller) {
|
||||
_controller = controller;
|
||||
}
|
||||
VideoPlaybackStatus? _holdStatus;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
@ -59,6 +56,19 @@ class VideoPlayerNotifier extends StateNotifier<VideoPlayerState> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void attachController(NativeVideoPlayerController controller) {
|
||||
_controller = controller;
|
||||
}
|
||||
|
||||
Future<void> load(VideoSource source) async {
|
||||
_startBufferingTimer();
|
||||
try {
|
||||
await _controller?.loadVideoSource(source);
|
||||
} catch (e) {
|
||||
_log.severe('Error loading video source: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> pause() async {
|
||||
if (_controller == null) return;
|
||||
|
||||
@ -94,16 +104,50 @@ class VideoPlayerNotifier extends StateNotifier<VideoPlayerState> {
|
||||
}
|
||||
|
||||
void seekTo(Duration position) {
|
||||
if (_controller == null) return;
|
||||
if (_controller == null || state.position == position) return;
|
||||
|
||||
state = state.copyWith(position: position);
|
||||
|
||||
_seekTimer?.cancel();
|
||||
_seekTimer = Timer(const Duration(milliseconds: 100), () {
|
||||
_controller?.seekTo(position.inMilliseconds);
|
||||
if (_seekTimer?.isActive ?? false) return;
|
||||
|
||||
_seekTimer = Timer(const Duration(milliseconds: 150), () {
|
||||
_controller?.seekTo(state.position.inMilliseconds);
|
||||
});
|
||||
}
|
||||
|
||||
void toggle() {
|
||||
_holdStatus = null;
|
||||
|
||||
switch (state.status) {
|
||||
case VideoPlaybackStatus.paused:
|
||||
play();
|
||||
case VideoPlaybackStatus.playing || VideoPlaybackStatus.buffering:
|
||||
pause();
|
||||
case VideoPlaybackStatus.completed:
|
||||
restart();
|
||||
}
|
||||
}
|
||||
|
||||
/// Pauses playback and preserves the current status for later restoration.
|
||||
void hold() {
|
||||
if (_holdStatus != null) return;
|
||||
|
||||
_holdStatus = state.status;
|
||||
pause();
|
||||
}
|
||||
|
||||
/// Restores playback to the status before [hold] was called.
|
||||
void release() {
|
||||
final status = _holdStatus;
|
||||
_holdStatus = null;
|
||||
|
||||
switch (status) {
|
||||
case VideoPlaybackStatus.playing || VideoPlaybackStatus.buffering:
|
||||
play();
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> restart() async {
|
||||
seekTo(Duration.zero);
|
||||
await play();
|
||||
@ -149,13 +193,12 @@ class VideoPlayerNotifier extends StateNotifier<VideoPlayerState> {
|
||||
final position = Duration(milliseconds: playbackInfo.position);
|
||||
if (state.position == position) return;
|
||||
|
||||
if (state.status == VideoPlaybackStatus.buffering) {
|
||||
state = state.copyWith(position: position, status: VideoPlaybackStatus.playing);
|
||||
} else {
|
||||
state = state.copyWith(position: position);
|
||||
}
|
||||
if (state.status == VideoPlaybackStatus.playing) _startBufferingTimer();
|
||||
|
||||
_startBufferingTimer();
|
||||
state = state.copyWith(
|
||||
position: position,
|
||||
status: state.status == VideoPlaybackStatus.buffering ? VideoPlaybackStatus.playing : null,
|
||||
);
|
||||
}
|
||||
|
||||
void onNativeStatusChanged() {
|
||||
@ -173,9 +216,7 @@ class VideoPlayerNotifier extends StateNotifier<VideoPlayerState> {
|
||||
onNativePlaybackEnded();
|
||||
}
|
||||
|
||||
if (state.status != newStatus) {
|
||||
state = state.copyWith(status: newStatus);
|
||||
}
|
||||
if (state.status != newStatus) state = state.copyWith(status: newStatus);
|
||||
}
|
||||
|
||||
void onNativePlaybackEnded() {
|
||||
@ -186,7 +227,7 @@ class VideoPlayerNotifier extends StateNotifier<VideoPlayerState> {
|
||||
void _startBufferingTimer() {
|
||||
_bufferingTimer?.cancel();
|
||||
_bufferingTimer = Timer(const Duration(seconds: 3), () {
|
||||
if (mounted && state.status == VideoPlaybackStatus.playing) {
|
||||
if (mounted && state.status != VideoPlaybackStatus.completed) {
|
||||
state = state.copyWith(status: VideoPlaybackStatus.buffering);
|
||||
}
|
||||
});
|
||||
|
||||
@ -91,6 +91,16 @@ class CastNotifier extends StateNotifier<CastManagerState> {
|
||||
return discovered;
|
||||
}
|
||||
|
||||
void toggle() {
|
||||
switch (state.castState) {
|
||||
case CastState.playing:
|
||||
pause();
|
||||
case CastState.paused:
|
||||
play();
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
void play() {
|
||||
_gCastService.play();
|
||||
}
|
||||
|
||||
@ -62,8 +62,6 @@ ThemeData getThemeData({required ColorScheme colorScheme, required Locale locale
|
||||
),
|
||||
chipTheme: const ChipThemeData(side: BorderSide.none),
|
||||
sliderTheme: const SliderThemeData(
|
||||
thumbShape: RoundSliderThumbShape(enabledThumbRadius: 7),
|
||||
trackHeight: 2.0,
|
||||
// ignore: deprecated_member_use
|
||||
year2023: false,
|
||||
),
|
||||
|
||||
@ -35,7 +35,7 @@ import 'package:isar/isar.dart';
|
||||
// ignore: import_rule_photo_manager
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
|
||||
const int targetVersion = 23;
|
||||
const int targetVersion = 24;
|
||||
|
||||
Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async {
|
||||
final hasVersion = Store.tryGet(StoreKey.version) != null;
|
||||
@ -105,6 +105,10 @@ Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async {
|
||||
await _populateLocalAssetPlaybackStyle(drift);
|
||||
}
|
||||
|
||||
if (version < 24 && Store.isBetaTimelineEnabled) {
|
||||
await _applyLocalAssetOrientation(drift);
|
||||
}
|
||||
|
||||
if (version < 22 && !Store.isBetaTimelineEnabled) {
|
||||
await Store.put(StoreKey.needBetaMigration, true);
|
||||
}
|
||||
@ -416,26 +420,41 @@ Future<void> _populateLocalAssetPlaybackStyle(Drift db) async {
|
||||
});
|
||||
}
|
||||
|
||||
final trashedAssetMap = await nativeApi.getTrashedAssets();
|
||||
for (final entry in trashedAssetMap.cast<String, List<Object?>>().entries) {
|
||||
final assets = entry.value.cast<PlatformAsset>();
|
||||
await db.batch((batch) {
|
||||
for (final asset in assets) {
|
||||
batch.update(
|
||||
db.trashedLocalAssetEntity,
|
||||
TrashedLocalAssetEntityCompanion(playbackStyle: Value(_toPlaybackStyle(asset.playbackStyle))),
|
||||
where: (t) => t.id.equals(asset.id),
|
||||
);
|
||||
}
|
||||
});
|
||||
if (Platform.isAndroid) {
|
||||
final trashedAssetMap = await nativeApi.getTrashedAssets();
|
||||
for (final entry in trashedAssetMap.cast<String, List<Object?>>().entries) {
|
||||
final assets = entry.value.cast<PlatformAsset>();
|
||||
await db.batch((batch) {
|
||||
for (final asset in assets) {
|
||||
batch.update(
|
||||
db.trashedLocalAssetEntity,
|
||||
TrashedLocalAssetEntityCompanion(playbackStyle: Value(_toPlaybackStyle(asset.playbackStyle))),
|
||||
where: (t) => t.id.equals(asset.id),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
dPrint(() => "[MIGRATION] Successfully populated playbackStyle for local and trashed assets");
|
||||
} else {
|
||||
dPrint(() => "[MIGRATION] Successfully populated playbackStyle for local assets");
|
||||
}
|
||||
|
||||
dPrint(() => "[MIGRATION] Successfully populated playbackStyle for local and trashed assets");
|
||||
} catch (error) {
|
||||
dPrint(() => "[MIGRATION] Error while populating playbackStyle: $error");
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _applyLocalAssetOrientation(Drift db) {
|
||||
final query = db.localAssetEntity.update()
|
||||
..where((filter) => (filter.orientation.equals(90) | (filter.orientation.equals(270))));
|
||||
return query.write(
|
||||
LocalAssetEntityCompanion.custom(
|
||||
width: db.localAssetEntity.height,
|
||||
height: db.localAssetEntity.width,
|
||||
orientation: const Variable(0),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
AssetPlaybackStyle _toPlaybackStyle(PlatformAssetPlaybackStyle style) => switch (style) {
|
||||
PlatformAssetPlaybackStyle.unknown => AssetPlaybackStyle.unknown,
|
||||
PlatformAssetPlaybackStyle.image => AssetPlaybackStyle.image,
|
||||
|
||||
@ -1,19 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/extensions/duration_extensions.dart';
|
||||
|
||||
class FormattedDuration extends StatelessWidget {
|
||||
final Duration data;
|
||||
const FormattedDuration(this.data, {super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: data.inHours > 0 ? 70 : 60, // use a fixed width to prevent jitter
|
||||
child: Text(
|
||||
data.format(),
|
||||
style: const TextStyle(fontSize: 14.0, color: Colors.white, fontWeight: FontWeight.w500),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,22 +1,116 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/widgets/asset_viewer/video_position.dart';
|
||||
import 'package:immich_mobile/constants/colors.dart';
|
||||
import 'package:immich_mobile/models/cast/cast_manager_state.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
|
||||
import 'package:immich_mobile/providers/cast.provider.dart';
|
||||
import 'package:immich_mobile/utils/hooks/timer_hook.dart';
|
||||
import 'package:immich_mobile/extensions/duration_extensions.dart';
|
||||
import 'package:immich_mobile/widgets/asset_viewer/animated_play_pause.dart';
|
||||
|
||||
/// The video controls for the [videoPlayerProvider]
|
||||
class VideoControls extends ConsumerWidget {
|
||||
class VideoControls extends HookConsumerWidget {
|
||||
final String videoPlayerName;
|
||||
|
||||
static const List<Shadow> _controlShadows = [Shadow(color: Colors.black87, blurRadius: 6, offset: Offset(0, 1))];
|
||||
|
||||
const VideoControls({super.key, required this.videoPlayerName});
|
||||
|
||||
void _toggle(WidgetRef ref, bool isCasting) {
|
||||
if (isCasting) {
|
||||
ref.read(castProvider.notifier).toggle();
|
||||
} else {
|
||||
ref.read(videoPlayerProvider(videoPlayerName).notifier).toggle();
|
||||
}
|
||||
}
|
||||
|
||||
void _onSeek(WidgetRef ref, bool isCasting, double value) {
|
||||
final seekTo = Duration(microseconds: value.toInt());
|
||||
|
||||
if (isCasting) {
|
||||
ref.read(castProvider.notifier).seekTo(seekTo);
|
||||
return;
|
||||
}
|
||||
|
||||
ref.read(videoPlayerProvider(videoPlayerName).notifier).seekTo(seekTo);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isPortrait = context.orientation == Orientation.portrait;
|
||||
return isPortrait
|
||||
? VideoPosition(videoPlayerName: videoPlayerName)
|
||||
: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 60.0),
|
||||
child: VideoPosition(videoPlayerName: videoPlayerName),
|
||||
);
|
||||
final provider = videoPlayerProvider(videoPlayerName);
|
||||
final cast = ref.watch(castProvider);
|
||||
final isCasting = cast.isCasting;
|
||||
|
||||
final (position, duration) = isCasting
|
||||
? ref.watch(castProvider.select((c) => (c.currentTime, c.duration)))
|
||||
: ref.watch(provider.select((v) => (v.position, v.duration)));
|
||||
|
||||
final videoStatus = ref.watch(provider.select((v) => v.status));
|
||||
final isPlaying = isCasting
|
||||
? cast.castState == CastState.playing
|
||||
: videoStatus == VideoPlaybackStatus.playing || videoStatus == VideoPlaybackStatus.buffering;
|
||||
final isFinished = !isCasting && videoStatus == VideoPlaybackStatus.completed;
|
||||
|
||||
final hideTimer = useTimer(const Duration(seconds: 5), () {
|
||||
if (!context.mounted) return;
|
||||
if (ref.read(provider).status == VideoPlaybackStatus.playing) {
|
||||
ref.read(assetViewerProvider.notifier).setControls(false);
|
||||
}
|
||||
});
|
||||
|
||||
ref.listen(provider.select((v) => v.status), (_, __) => hideTimer.reset());
|
||||
|
||||
final notifier = ref.read(provider.notifier);
|
||||
final isLoaded = duration != Duration.zero;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
spacing: 16,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
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(
|
||||
"${position.format()} / ${duration.format()}",
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontFeatures: [FontFeature.tabularFigures()],
|
||||
shadows: _controlShadows,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
],
|
||||
),
|
||||
Slider(
|
||||
value: min(position.inMicroseconds.toDouble(), duration.inMicroseconds.toDouble()),
|
||||
min: 0,
|
||||
max: max(duration.inMicroseconds.toDouble(), 1),
|
||||
thumbColor: Colors.white,
|
||||
activeColor: Colors.white,
|
||||
inactiveColor: whiteOpacity75,
|
||||
padding: EdgeInsets.zero,
|
||||
onChangeStart: (_) => notifier.hold(),
|
||||
onChangeEnd: (_) => notifier.release(),
|
||||
onChanged: isLoaded ? (value) => _onSeek(ref, isCasting, value) : null,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,110 +0,0 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/colors.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
|
||||
import 'package:immich_mobile/providers/cast.provider.dart';
|
||||
import 'package:immich_mobile/widgets/asset_viewer/formatted_duration.dart';
|
||||
|
||||
class VideoPosition extends HookConsumerWidget {
|
||||
final String videoPlayerName;
|
||||
|
||||
const VideoPosition({super.key, required this.videoPlayerName});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isCasting = ref.watch(castProvider).isCasting;
|
||||
|
||||
final (position, duration) = isCasting
|
||||
? ref.watch(castProvider.select((c) => (c.currentTime, c.duration)))
|
||||
: ref.watch(videoPlayerProvider(videoPlayerName).select((v) => (v.position, v.duration)));
|
||||
|
||||
final wasPlaying = useRef<bool>(true);
|
||||
return duration == Duration.zero
|
||||
? const _VideoPositionPlaceholder()
|
||||
: Column(
|
||||
children: [
|
||||
Padding(
|
||||
// align with slider's inherent padding
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [FormattedDuration(position), FormattedDuration(duration)],
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Slider(
|
||||
value: min(position.inMicroseconds / duration.inMicroseconds * 100, 100),
|
||||
min: 0,
|
||||
max: 100,
|
||||
thumbColor: Colors.white,
|
||||
activeColor: Colors.white,
|
||||
inactiveColor: whiteOpacity75,
|
||||
onChangeStart: (value) {
|
||||
final status = ref.read(videoPlayerProvider(videoPlayerName)).status;
|
||||
wasPlaying.value = status != VideoPlaybackStatus.paused;
|
||||
ref.read(videoPlayerProvider(videoPlayerName).notifier).pause();
|
||||
},
|
||||
onChangeEnd: (value) {
|
||||
if (wasPlaying.value) {
|
||||
ref.read(videoPlayerProvider(videoPlayerName).notifier).play();
|
||||
}
|
||||
},
|
||||
onChanged: (value) {
|
||||
final seekToDuration = (duration * (value / 100.0));
|
||||
|
||||
if (isCasting) {
|
||||
ref.read(castProvider.notifier).seekTo(seekToDuration);
|
||||
return;
|
||||
}
|
||||
|
||||
ref.read(videoPlayerProvider(videoPlayerName).notifier).seekTo(seekToDuration);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _VideoPositionPlaceholder extends StatelessWidget {
|
||||
const _VideoPositionPlaceholder();
|
||||
|
||||
static void _onChangedDummy(_) {}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [FormattedDuration(Duration.zero), FormattedDuration(Duration.zero)],
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Slider(
|
||||
value: 0.0,
|
||||
min: 0,
|
||||
max: 100,
|
||||
thumbColor: Colors.white,
|
||||
activeColor: Colors.white,
|
||||
inactiveColor: whiteOpacity75,
|
||||
onChanged: _onChangedDummy,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
3
mobile/openapi/lib/model/audio_codec.dart
generated
3
mobile/openapi/lib/model/audio_codec.dart
generated
@ -26,6 +26,7 @@ class AudioCodec {
|
||||
static const mp3 = AudioCodec._(r'mp3');
|
||||
static const aac = AudioCodec._(r'aac');
|
||||
static const libopus = AudioCodec._(r'libopus');
|
||||
static const opus = AudioCodec._(r'opus');
|
||||
static const pcmS16le = AudioCodec._(r'pcm_s16le');
|
||||
|
||||
/// List of all possible values in this [enum][AudioCodec].
|
||||
@ -33,6 +34,7 @@ class AudioCodec {
|
||||
mp3,
|
||||
aac,
|
||||
libopus,
|
||||
opus,
|
||||
pcmS16le,
|
||||
];
|
||||
|
||||
@ -75,6 +77,7 @@ class AudioCodecTypeTransformer {
|
||||
case r'mp3': return AudioCodec.mp3;
|
||||
case r'aac': return AudioCodec.aac;
|
||||
case r'libopus': return AudioCodec.libopus;
|
||||
case r'opus': return AudioCodec.opus;
|
||||
case r'pcm_s16le': return AudioCodec.pcmS16le;
|
||||
default:
|
||||
if (!allowNull) {
|
||||
|
||||
@ -17260,6 +17260,7 @@
|
||||
"mp3",
|
||||
"aac",
|
||||
"libopus",
|
||||
"opus",
|
||||
"pcm_s16le"
|
||||
],
|
||||
"type": "string"
|
||||
|
||||
@ -19,7 +19,7 @@
|
||||
"@oazapfts/runtime": "^1.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.10.14",
|
||||
"@types/node": "^24.11.0",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"repository": {
|
||||
|
||||
@ -7324,6 +7324,7 @@ export enum AudioCodec {
|
||||
Mp3 = "mp3",
|
||||
Aac = "aac",
|
||||
Libopus = "libopus",
|
||||
Opus = "opus",
|
||||
PcmS16Le = "pcm_s16le"
|
||||
}
|
||||
export enum VideoContainer {
|
||||
|
||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@ -63,7 +63,7 @@ importers:
|
||||
specifier: ^4.13.1
|
||||
version: 4.13.4
|
||||
'@types/node':
|
||||
specifier: ^24.10.14
|
||||
specifier: ^24.11.0
|
||||
version: 24.11.0
|
||||
'@vitest/coverage-v8':
|
||||
specifier: ^4.0.0
|
||||
@ -220,7 +220,7 @@ importers:
|
||||
specifier: ^3.4.2
|
||||
version: 3.7.1
|
||||
'@types/node':
|
||||
specifier: ^24.10.14
|
||||
specifier: ^24.11.0
|
||||
version: 24.11.0
|
||||
'@types/pg':
|
||||
specifier: ^8.15.1
|
||||
@ -323,7 +323,7 @@ importers:
|
||||
version: 1.2.0
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^24.10.14
|
||||
specifier: ^24.11.0
|
||||
version: 24.11.0
|
||||
typescript:
|
||||
specifier: ^5.3.3
|
||||
@ -645,7 +645,7 @@ importers:
|
||||
specifier: ^2.0.0
|
||||
version: 2.0.0
|
||||
'@types/node':
|
||||
specifier: ^24.10.14
|
||||
specifier: ^24.11.0
|
||||
version: 24.11.0
|
||||
'@types/nodemailer':
|
||||
specifier: ^7.0.0
|
||||
|
||||
@ -136,7 +136,7 @@
|
||||
"@types/luxon": "^3.6.2",
|
||||
"@types/mock-fs": "^4.13.1",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/node": "^24.10.14",
|
||||
"@types/node": "^24.11.0",
|
||||
"@types/nodemailer": "^7.0.0",
|
||||
"@types/picomatch": "^4.0.0",
|
||||
"@types/pngjs": "^6.0.5",
|
||||
|
||||
@ -206,7 +206,7 @@ export const defaults = Object.freeze<SystemConfig>({
|
||||
targetVideoCodec: VideoCodec.H264,
|
||||
acceptedVideoCodecs: [VideoCodec.H264],
|
||||
targetAudioCodec: AudioCodec.Aac,
|
||||
acceptedAudioCodecs: [AudioCodec.Aac, AudioCodec.Mp3, AudioCodec.LibOpus],
|
||||
acceptedAudioCodecs: [AudioCodec.Aac, AudioCodec.Mp3, AudioCodec.Opus],
|
||||
acceptedContainers: [VideoContainer.Mov, VideoContainer.Ogg, VideoContainer.Webm],
|
||||
targetResolution: '720',
|
||||
maxBitrate: '0',
|
||||
|
||||
@ -2,7 +2,7 @@ import { Duration } from 'luxon';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { SemVer } from 'semver';
|
||||
import { ApiTag, DatabaseExtension, ExifOrientation, VectorIndex } from 'src/enum';
|
||||
import { ApiTag, AudioCodec, DatabaseExtension, ExifOrientation, VectorIndex } from 'src/enum';
|
||||
|
||||
export const ErrorMessages = {
|
||||
InconsistentMediaLocation:
|
||||
@ -201,3 +201,11 @@ export const endpointTags: Record<ApiTag, string> = {
|
||||
[ApiTag.Workflows]:
|
||||
'A workflow is a set of actions that run whenever a triggering event occurs. Workflows also can include filters to further limit execution.',
|
||||
};
|
||||
|
||||
export const AUDIO_ENCODER: Record<AudioCodec, string> = {
|
||||
[AudioCodec.Aac]: 'aac',
|
||||
[AudioCodec.Mp3]: 'mp3',
|
||||
[AudioCodec.Libopus]: 'libopus',
|
||||
[AudioCodec.Opus]: 'libopus',
|
||||
[AudioCodec.PcmS16le]: 'pcm_s16le',
|
||||
};
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMinSize,
|
||||
IsInt,
|
||||
@ -92,6 +92,16 @@ export class SystemConfigFFmpegDto {
|
||||
targetAudioCodec!: AudioCodec;
|
||||
|
||||
@ValidateEnum({ enum: AudioCodec, name: 'AudioCodec', each: true, description: 'Accepted audio codecs' })
|
||||
@Transform(({ value }) => {
|
||||
if (Array.isArray(value)) {
|
||||
const libopusIndex = value.indexOf('libopus');
|
||||
if (libopusIndex !== -1) {
|
||||
value[libopusIndex] = 'opus';
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
})
|
||||
acceptedAudioCodecs!: AudioCodec[];
|
||||
|
||||
@ValidateEnum({ enum: VideoContainer, name: 'VideoContainer', each: true, description: 'Accepted containers' })
|
||||
|
||||
@ -409,7 +409,9 @@ export enum VideoCodec {
|
||||
export enum AudioCodec {
|
||||
Mp3 = 'mp3',
|
||||
Aac = 'aac',
|
||||
LibOpus = 'libopus',
|
||||
/** @deprecated Use `Opus` instead */
|
||||
Libopus = 'libopus',
|
||||
Opus = 'opus',
|
||||
PcmS16le = 'pcm_s16le',
|
||||
}
|
||||
|
||||
|
||||
@ -438,6 +438,7 @@ with
|
||||
and "stack"."primaryAssetId" != "asset"."id"
|
||||
)
|
||||
order by
|
||||
(asset."localDateTime" AT TIME ZONE 'UTC')::date desc,
|
||||
"asset"."fileCreatedAt" desc
|
||||
),
|
||||
"agg" as (
|
||||
|
||||
@ -744,6 +744,7 @@ export class AssetRepository {
|
||||
params: [DummyValue.TIME_BUCKET, { withStacked: true }, { user: { id: DummyValue.UUID } }],
|
||||
})
|
||||
getTimeBucket(timeBucket: string, options: TimeBucketOptions, auth: AuthDto) {
|
||||
const order = options.order ?? 'desc';
|
||||
const query = this.db
|
||||
.with('cte', (qb) =>
|
||||
qb
|
||||
@ -841,7 +842,8 @@ export class AssetRepository {
|
||||
)
|
||||
.$if(!!options.isTrashed, (qb) => qb.where('asset.status', '!=', AssetStatus.Deleted))
|
||||
.$if(!!options.tagId, (qb) => withTagId(qb, options.tagId!))
|
||||
.orderBy('asset.fileCreatedAt', options.order ?? 'desc'),
|
||||
.orderBy(sql`(asset."localDateTime" AT TIME ZONE 'UTC')::date`, order)
|
||||
.orderBy('asset.fileCreatedAt', order),
|
||||
)
|
||||
.with('agg', (qb) =>
|
||||
qb
|
||||
|
||||
@ -0,0 +1,65 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await sql`
|
||||
UPDATE system_metadata
|
||||
SET value = jsonb_set(
|
||||
value,
|
||||
'{ffmpeg,acceptedAudioCodecs}',
|
||||
(
|
||||
SELECT jsonb_agg(
|
||||
CASE
|
||||
WHEN elem = 'libopus' THEN 'opus'
|
||||
ELSE elem
|
||||
END
|
||||
)
|
||||
FROM jsonb_array_elements_text(value->'ffmpeg'->'acceptedAudioCodecs') elem
|
||||
)
|
||||
)
|
||||
WHERE key = 'system-config'
|
||||
AND value->'ffmpeg'->'acceptedAudioCodecs' ? 'libopus';
|
||||
`.execute(db);
|
||||
|
||||
await sql`
|
||||
UPDATE system_metadata
|
||||
SET value = jsonb_set(
|
||||
value,
|
||||
'{ffmpeg,targetAudioCodec}',
|
||||
'"opus"'::jsonb
|
||||
)
|
||||
WHERE key = 'system-config'
|
||||
AND value->'ffmpeg'->>'targetAudioCodec' = 'libopus';
|
||||
`.execute(db);
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await sql`
|
||||
UPDATE system_metadata
|
||||
SET value = jsonb_set(
|
||||
value,
|
||||
'{ffmpeg,acceptedAudioCodecs}',
|
||||
(
|
||||
SELECT jsonb_agg(
|
||||
CASE
|
||||
WHEN elem = 'opus' THEN 'libopus'
|
||||
ELSE elem
|
||||
END
|
||||
)
|
||||
FROM jsonb_array_elements_text(value->'ffmpeg'->'acceptedAudioCodecs') elem
|
||||
)
|
||||
)
|
||||
WHERE key = 'system-config'
|
||||
AND value->'ffmpeg'->'acceptedAudioCodecs' ? 'opus';
|
||||
`.execute(db);
|
||||
|
||||
await sql`
|
||||
UPDATE system_metadata
|
||||
SET value = jsonb_set(
|
||||
value,
|
||||
'{ffmpeg,targetAudioCodec}',
|
||||
'"libopus"'::jsonb
|
||||
)
|
||||
WHERE key = 'system-config'
|
||||
AND value->'ffmpeg'->>'targetAudioCodec' = 'opus';
|
||||
`.execute(db);
|
||||
}
|
||||
@ -532,7 +532,7 @@ describe(AssetService.name, () => {
|
||||
});
|
||||
|
||||
it('should immediately queue assets for deletion if trash is disabled', async () => {
|
||||
const asset = factory.asset({ isOffline: false });
|
||||
const asset = AssetFactory.create();
|
||||
|
||||
mocks.assetJob.streamForDeletedJob.mockReturnValue(makeStream([asset]));
|
||||
mocks.systemMetadata.get.mockResolvedValue({ trash: { enabled: false } });
|
||||
@ -546,7 +546,7 @@ describe(AssetService.name, () => {
|
||||
});
|
||||
|
||||
it('should queue assets for deletion after trash duration', async () => {
|
||||
const asset = factory.asset({ isOffline: false });
|
||||
const asset = AssetFactory.create();
|
||||
|
||||
mocks.assetJob.streamForDeletedJob.mockReturnValue(makeStream([asset]));
|
||||
mocks.systemMetadata.get.mockResolvedValue({ trash: { enabled: true, days: 7 } });
|
||||
@ -739,7 +739,7 @@ describe(AssetService.name, () => {
|
||||
|
||||
describe('upsertMetadata', () => {
|
||||
it('should throw a bad request exception if duplicate keys are sent', async () => {
|
||||
const asset = factory.asset();
|
||||
const asset = AssetFactory.create();
|
||||
const items = [
|
||||
{ key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id1' } },
|
||||
{ key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id1' } },
|
||||
@ -757,7 +757,7 @@ describe(AssetService.name, () => {
|
||||
|
||||
describe('upsertBulkMetadata', () => {
|
||||
it('should throw a bad request exception if duplicate keys are sent', async () => {
|
||||
const asset = factory.asset();
|
||||
const asset = AssetFactory.create();
|
||||
const items = [
|
||||
{ assetId: asset.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id1' } },
|
||||
{ assetId: asset.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id1' } },
|
||||
|
||||
@ -2571,6 +2571,50 @@ describe(MediaService.name, () => {
|
||||
expect(mocks.media.transcode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('should skip transcoding for accepted audio codecs with optimal policy if video is fine', () => {
|
||||
const acceptedCodecs = [
|
||||
{ codec: 'aac', probeStub: probeStub.audioStreamAac },
|
||||
{ codec: 'mp3', probeStub: probeStub.audioStreamMp3 },
|
||||
{ codec: 'opus', probeStub: probeStub.audioStreamOpus },
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.systemMetadata.get.mockResolvedValue({
|
||||
ffmpeg: {
|
||||
targetVideoCodec: VideoCodec.Hevc,
|
||||
transcode: TranscodePolicy.Optimal,
|
||||
targetResolution: '1080p',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it.each(acceptedCodecs)('should skip $codec', async ({ probeStub }) => {
|
||||
mocks.media.probe.mockResolvedValue(probeStub);
|
||||
await sut.handleVideoConversion({ id: 'video-id' });
|
||||
expect(mocks.media.transcode).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should use libopus audio encoder when target audio is opus', async () => {
|
||||
mocks.media.probe.mockResolvedValue(probeStub.audioStreamAac);
|
||||
mocks.systemMetadata.get.mockResolvedValue({
|
||||
ffmpeg: {
|
||||
targetAudioCodec: AudioCodec.Opus,
|
||||
transcode: TranscodePolicy.All,
|
||||
},
|
||||
});
|
||||
await sut.handleVideoConversion({ id: 'video-id' });
|
||||
expect(mocks.media.transcode).toHaveBeenCalledWith(
|
||||
'/original/path.ext',
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
inputOptions: expect.any(Array),
|
||||
outputOptions: expect.arrayContaining(['-c:a libopus']),
|
||||
twoPass: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should fail if hwaccel is enabled for an unsupported codec', async () => {
|
||||
mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer);
|
||||
mocks.systemMetadata.get.mockResolvedValue({
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { MemoryService } from 'src/services/memory.service';
|
||||
import { OnThisDayData } from 'src/types';
|
||||
import { AssetFactory } from 'test/factories/asset.factory';
|
||||
import { MemoryFactory } from 'test/factories/memory.factory';
|
||||
import { factory, newUuid, newUuids } from 'test/small.factory';
|
||||
import { newTestService, ServiceMocks } from 'test/utils';
|
||||
|
||||
@ -27,9 +29,9 @@ describe(MemoryService.name, () => {
|
||||
describe('search', () => {
|
||||
it('should search memories', async () => {
|
||||
const [userId] = newUuids();
|
||||
const asset = factory.asset();
|
||||
const memory1 = factory.memory({ ownerId: userId, assets: [asset] });
|
||||
const memory2 = factory.memory({ ownerId: userId });
|
||||
const asset = AssetFactory.create();
|
||||
const memory1 = MemoryFactory.from({ ownerId: userId }).asset(asset).build();
|
||||
const memory2 = MemoryFactory.create({ ownerId: userId });
|
||||
|
||||
mocks.memory.search.mockResolvedValue([memory1, memory2]);
|
||||
|
||||
@ -64,7 +66,7 @@ describe(MemoryService.name, () => {
|
||||
|
||||
it('should get a memory by id', async () => {
|
||||
const userId = newUuid();
|
||||
const memory = factory.memory({ ownerId: userId });
|
||||
const memory = MemoryFactory.create({ ownerId: userId });
|
||||
|
||||
mocks.memory.get.mockResolvedValue(memory);
|
||||
mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set([memory.id]));
|
||||
@ -81,7 +83,7 @@ describe(MemoryService.name, () => {
|
||||
describe('create', () => {
|
||||
it('should skip assets the user does not have access to', async () => {
|
||||
const [assetId, userId] = newUuids();
|
||||
const memory = factory.memory({ ownerId: userId });
|
||||
const memory = MemoryFactory.create({ ownerId: userId });
|
||||
|
||||
mocks.memory.create.mockResolvedValue(memory);
|
||||
|
||||
@ -109,8 +111,8 @@ describe(MemoryService.name, () => {
|
||||
|
||||
it('should create a memory', async () => {
|
||||
const [assetId, userId] = newUuids();
|
||||
const asset = factory.asset({ id: assetId, ownerId: userId });
|
||||
const memory = factory.memory({ assets: [asset] });
|
||||
const asset = AssetFactory.create({ id: assetId, ownerId: userId });
|
||||
const memory = MemoryFactory.from().asset(asset).build();
|
||||
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
|
||||
mocks.memory.create.mockResolvedValue(memory);
|
||||
@ -131,7 +133,7 @@ describe(MemoryService.name, () => {
|
||||
});
|
||||
|
||||
it('should create a memory without assets', async () => {
|
||||
const memory = factory.memory();
|
||||
const memory = MemoryFactory.create();
|
||||
|
||||
mocks.memory.create.mockResolvedValue(memory);
|
||||
|
||||
@ -155,7 +157,7 @@ describe(MemoryService.name, () => {
|
||||
});
|
||||
|
||||
it('should update a memory', async () => {
|
||||
const memory = factory.memory();
|
||||
const memory = MemoryFactory.create();
|
||||
|
||||
mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set([memory.id]));
|
||||
mocks.memory.update.mockResolvedValue(memory);
|
||||
@ -198,7 +200,7 @@ describe(MemoryService.name, () => {
|
||||
|
||||
it('should require asset access', async () => {
|
||||
const assetId = newUuid();
|
||||
const memory = factory.memory();
|
||||
const memory = MemoryFactory.create();
|
||||
|
||||
mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set([memory.id]));
|
||||
mocks.memory.get.mockResolvedValue(memory);
|
||||
@ -212,8 +214,8 @@ describe(MemoryService.name, () => {
|
||||
});
|
||||
|
||||
it('should skip assets already in the memory', async () => {
|
||||
const asset = factory.asset();
|
||||
const memory = factory.memory({ assets: [asset] });
|
||||
const asset = AssetFactory.create();
|
||||
const memory = MemoryFactory.from().asset(asset).build();
|
||||
|
||||
mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set([memory.id]));
|
||||
mocks.memory.get.mockResolvedValue(memory);
|
||||
@ -228,7 +230,7 @@ describe(MemoryService.name, () => {
|
||||
|
||||
it('should add assets', async () => {
|
||||
const assetId = newUuid();
|
||||
const memory = factory.memory();
|
||||
const memory = MemoryFactory.create();
|
||||
|
||||
mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set([memory.id]));
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetId]));
|
||||
@ -266,8 +268,8 @@ describe(MemoryService.name, () => {
|
||||
});
|
||||
|
||||
it('should remove assets', async () => {
|
||||
const memory = factory.memory();
|
||||
const asset = factory.asset();
|
||||
const memory = MemoryFactory.create();
|
||||
const asset = AssetFactory.create();
|
||||
|
||||
mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set([memory.id]));
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
|
||||
|
||||
@ -55,7 +55,7 @@ const updatedConfig = Object.freeze<SystemConfig>({
|
||||
threads: 0,
|
||||
preset: 'ultrafast',
|
||||
targetAudioCodec: AudioCodec.Aac,
|
||||
acceptedAudioCodecs: [AudioCodec.Aac, AudioCodec.Mp3, AudioCodec.LibOpus],
|
||||
acceptedAudioCodecs: [AudioCodec.Aac, AudioCodec.Mp3, AudioCodec.Opus],
|
||||
targetResolution: '720',
|
||||
targetVideoCodec: VideoCodec.H264,
|
||||
acceptedVideoCodecs: [VideoCodec.H264],
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { AUDIO_ENCODER } from 'src/constants';
|
||||
import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto';
|
||||
import { CQMode, ToneMapping, TranscodeHardwareAcceleration, TranscodeTarget, VideoCodec } from 'src/enum';
|
||||
import {
|
||||
@ -117,7 +118,7 @@ export class BaseConfig implements VideoCodecSWConfig {
|
||||
|
||||
getBaseOutputOptions(target: TranscodeTarget, videoStream: VideoStreamInfo, audioStream?: AudioStreamInfo) {
|
||||
const videoCodec = [TranscodeTarget.All, TranscodeTarget.Video].includes(target) ? this.getVideoCodec() : 'copy';
|
||||
const audioCodec = [TranscodeTarget.All, TranscodeTarget.Audio].includes(target) ? this.getAudioCodec() : 'copy';
|
||||
const audioCodec = [TranscodeTarget.All, TranscodeTarget.Audio].includes(target) ? this.getAudioEncoder() : 'copy';
|
||||
|
||||
const options = [
|
||||
`-c:v ${videoCodec}`,
|
||||
@ -305,8 +306,8 @@ export class BaseConfig implements VideoCodecSWConfig {
|
||||
return [options];
|
||||
}
|
||||
|
||||
getAudioCodec(): string {
|
||||
return this.config.targetAudioCodec;
|
||||
getAudioEncoder(): string {
|
||||
return AUDIO_ENCODER[this.config.targetAudioCodec];
|
||||
}
|
||||
|
||||
getVideoCodec(): string {
|
||||
|
||||
45
server/test/factories/memory.factory.ts
Normal file
45
server/test/factories/memory.factory.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import { Selectable } from 'kysely';
|
||||
import { MemoryType } from 'src/enum';
|
||||
import { MemoryTable } from 'src/schema/tables/memory.table';
|
||||
import { AssetFactory } from 'test/factories/asset.factory';
|
||||
import { build } from 'test/factories/builder.factory';
|
||||
import { AssetLike, FactoryBuilder, MemoryLike } from 'test/factories/types';
|
||||
import { newDate, newUuid, newUuidV7 } from 'test/small.factory';
|
||||
|
||||
export class MemoryFactory {
|
||||
#assets: AssetFactory[] = [];
|
||||
|
||||
private constructor(private readonly value: Selectable<MemoryTable>) {}
|
||||
|
||||
static create(dto: MemoryLike = {}) {
|
||||
return MemoryFactory.from(dto).build();
|
||||
}
|
||||
|
||||
static from(dto: MemoryLike = {}) {
|
||||
return new MemoryFactory({
|
||||
id: newUuid(),
|
||||
createdAt: newDate(),
|
||||
updatedAt: newDate(),
|
||||
updateId: newUuidV7(),
|
||||
deletedAt: null,
|
||||
ownerId: newUuid(),
|
||||
type: MemoryType.OnThisDay,
|
||||
data: { year: 2024 },
|
||||
isSaved: false,
|
||||
memoryAt: newDate(),
|
||||
seenAt: null,
|
||||
showAt: newDate(),
|
||||
hideAt: newDate(),
|
||||
...dto,
|
||||
});
|
||||
}
|
||||
|
||||
asset(asset: AssetLike, builder?: FactoryBuilder<AssetFactory>) {
|
||||
this.#assets.push(build(AssetFactory.from(asset), builder));
|
||||
return this;
|
||||
}
|
||||
|
||||
build() {
|
||||
return { ...this.value, assets: this.#assets.map((asset) => asset.build()) };
|
||||
}
|
||||
}
|
||||
@ -6,6 +6,7 @@ import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
|
||||
import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
|
||||
import { AssetFileTable } from 'src/schema/tables/asset-file.table';
|
||||
import { AssetTable } from 'src/schema/tables/asset.table';
|
||||
import { MemoryTable } from 'src/schema/tables/memory.table';
|
||||
import { PersonTable } from 'src/schema/tables/person.table';
|
||||
import { SharedLinkTable } from 'src/schema/tables/shared-link.table';
|
||||
import { StackTable } from 'src/schema/tables/stack.table';
|
||||
@ -24,3 +25,4 @@ export type UserLike = Partial<Selectable<UserTable>>;
|
||||
export type AssetFaceLike = Partial<Selectable<AssetFaceTable>>;
|
||||
export type PersonLike = Partial<Selectable<PersonTable>>;
|
||||
export type StackLike = Partial<Selectable<StackTable>>;
|
||||
export type MemoryLike = Partial<Selectable<MemoryTable>>;
|
||||
|
||||
8
server/test/fixtures/media.stub.ts
vendored
8
server/test/fixtures/media.stub.ts
vendored
@ -221,6 +221,14 @@ export const probeStub = {
|
||||
...probeStubDefault,
|
||||
audioStreams: [{ index: 1, codecName: 'aac', bitrate: 100 }],
|
||||
}),
|
||||
audioStreamMp3: Object.freeze<VideoInfo>({
|
||||
...probeStubDefault,
|
||||
audioStreams: [{ index: 1, codecName: 'mp3', bitrate: 100 }],
|
||||
}),
|
||||
audioStreamOpus: Object.freeze<VideoInfo>({
|
||||
...probeStubDefault,
|
||||
audioStreams: [{ index: 1, codecName: 'opus', bitrate: 100 }],
|
||||
}),
|
||||
audioStreamUnknown: Object.freeze<VideoInfo>({
|
||||
...probeStubDefault,
|
||||
audioStreams: [
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
import { Kysely } from 'kysely';
|
||||
import { AssetOrder, AssetVisibility } from 'src/enum';
|
||||
import { AssetRepository } from 'src/repositories/asset.repository';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { DB } from 'src/schema';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { newMediumService } from 'test/medium.factory';
|
||||
import { factory } from 'test/small.factory';
|
||||
import { getKyselyDB } from 'test/utils';
|
||||
|
||||
let defaultDatabase: Kysely<DB>;
|
||||
@ -22,6 +24,61 @@ beforeAll(async () => {
|
||||
});
|
||||
|
||||
describe(AssetRepository.name, () => {
|
||||
describe('getTimeBucket', () => {
|
||||
it('should order assets by local day first and fileCreatedAt within each day', async () => {
|
||||
const { ctx, sut } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const auth = factory.auth({ user: { id: user.id } });
|
||||
|
||||
const [{ asset: previousLocalDayAsset }, { asset: nextLocalDayEarlierAsset }, { asset: nextLocalDayLaterAsset }] =
|
||||
await Promise.all([
|
||||
ctx.newAsset({
|
||||
ownerId: user.id,
|
||||
fileCreatedAt: new Date('2026-03-09T00:30:00.000Z'),
|
||||
localDateTime: new Date('2026-03-08T22:30:00.000Z'),
|
||||
}),
|
||||
ctx.newAsset({
|
||||
ownerId: user.id,
|
||||
fileCreatedAt: new Date('2026-03-08T23:30:00.000Z'),
|
||||
localDateTime: new Date('2026-03-09T01:30:00.000Z'),
|
||||
}),
|
||||
ctx.newAsset({
|
||||
ownerId: user.id,
|
||||
fileCreatedAt: new Date('2026-03-08T23:45:00.000Z'),
|
||||
localDateTime: new Date('2026-03-09T01:45:00.000Z'),
|
||||
}),
|
||||
]);
|
||||
|
||||
await Promise.all([
|
||||
ctx.newExif({ assetId: previousLocalDayAsset.id, timeZone: 'UTC-2' }),
|
||||
ctx.newExif({ assetId: nextLocalDayEarlierAsset.id, timeZone: 'UTC+2' }),
|
||||
ctx.newExif({ assetId: nextLocalDayLaterAsset.id, timeZone: 'UTC+2' }),
|
||||
]);
|
||||
|
||||
const descendingBucket = await sut.getTimeBucket(
|
||||
'2026-03-01',
|
||||
{ order: AssetOrder.Desc, userIds: [user.id], visibility: AssetVisibility.Timeline },
|
||||
auth,
|
||||
);
|
||||
expect(JSON.parse(descendingBucket.assets)).toEqual(
|
||||
expect.objectContaining({
|
||||
id: [nextLocalDayLaterAsset.id, nextLocalDayEarlierAsset.id, previousLocalDayAsset.id],
|
||||
}),
|
||||
);
|
||||
|
||||
const ascendingBucket = await sut.getTimeBucket(
|
||||
'2026-03-01',
|
||||
{ order: AssetOrder.Asc, userIds: [user.id], visibility: AssetVisibility.Timeline },
|
||||
auth,
|
||||
);
|
||||
expect(JSON.parse(ascendingBucket.assets)).toEqual(
|
||||
expect.objectContaining({
|
||||
id: [previousLocalDayAsset.id, nextLocalDayEarlierAsset.id, nextLocalDayLaterAsset.id],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('upsertExif', () => {
|
||||
it('should append to locked columns', async () => {
|
||||
const { ctx, sut } = setup();
|
||||
|
||||
@ -2,39 +2,24 @@ import {
|
||||
Activity,
|
||||
Album,
|
||||
ApiKey,
|
||||
AssetFace,
|
||||
AssetFile,
|
||||
AuthApiKey,
|
||||
AuthSharedLink,
|
||||
AuthUser,
|
||||
Exif,
|
||||
Library,
|
||||
Memory,
|
||||
Partner,
|
||||
Person,
|
||||
Session,
|
||||
Stack,
|
||||
Tag,
|
||||
User,
|
||||
UserAdmin,
|
||||
} from 'src/database';
|
||||
import { MapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { AssetEditAction, AssetEditActionItem, MirrorAxis } from 'src/dtos/editing.dto';
|
||||
import { QueueStatisticsDto } from 'src/dtos/queue.dto';
|
||||
import {
|
||||
AssetFileType,
|
||||
AssetOrder,
|
||||
AssetStatus,
|
||||
AssetType,
|
||||
AssetVisibility,
|
||||
MemoryType,
|
||||
Permission,
|
||||
SourceType,
|
||||
UserMetadataKey,
|
||||
UserStatus,
|
||||
} from 'src/enum';
|
||||
import { DeepPartial, OnThisDayData, UserMetadataItem } from 'src/types';
|
||||
import { AssetFileType, AssetOrder, Permission, UserMetadataKey, UserStatus } from 'src/enum';
|
||||
import { UserMetadataItem } from 'src/types';
|
||||
import { UserFactory } from 'test/factories/user.factory';
|
||||
import { v4, v7 } from 'uuid';
|
||||
|
||||
export const newUuid = () => v4();
|
||||
@ -123,9 +108,13 @@ const authUserFactory = (authUser: Partial<AuthUser> = {}) => {
|
||||
return { id, isAdmin, name, email, quotaUsageInBytes, quotaSizeInBytes };
|
||||
};
|
||||
|
||||
const partnerFactory = (partner: Partial<Partner> = {}) => {
|
||||
const sharedBy = userFactory(partner.sharedBy || {});
|
||||
const sharedWith = userFactory(partner.sharedWith || {});
|
||||
const partnerFactory = ({
|
||||
sharedBy: sharedByProvided,
|
||||
sharedWith: sharedWithProvided,
|
||||
...partner
|
||||
}: Partial<Partner> = {}) => {
|
||||
const sharedBy = UserFactory.create(sharedByProvided ?? {});
|
||||
const sharedWith = UserFactory.create(sharedWithProvided ?? {});
|
||||
|
||||
return {
|
||||
sharedById: sharedBy.id,
|
||||
@ -168,19 +157,6 @@ const queueStatisticsFactory = (dto?: Partial<QueueStatisticsDto>) => ({
|
||||
...dto,
|
||||
});
|
||||
|
||||
const stackFactory = ({ owner, assets, ...stack }: DeepPartial<Stack> = {}): Stack => {
|
||||
const ownerId = newUuid();
|
||||
|
||||
return {
|
||||
id: newUuid(),
|
||||
primaryAssetId: assets?.[0].id ?? newUuid(),
|
||||
ownerId,
|
||||
owner: userFactory(owner ?? { id: ownerId }),
|
||||
assets: assets?.map((asset) => assetFactory(asset)) ?? [],
|
||||
...stack,
|
||||
};
|
||||
};
|
||||
|
||||
const userFactory = (user: Partial<User> = {}) => ({
|
||||
id: newUuid(),
|
||||
name: 'Test User',
|
||||
@ -238,44 +214,6 @@ const userAdminFactory = (user: Partial<UserAdmin> = {}) => {
|
||||
};
|
||||
};
|
||||
|
||||
const assetFactory = (
|
||||
asset: Omit<DeepPartial<MapAsset>, 'exifInfo' | 'owner' | 'stack' | 'tags' | 'faces' | 'files' | 'edits'> = {},
|
||||
) => {
|
||||
return {
|
||||
id: newUuid(),
|
||||
createdAt: newDate(),
|
||||
updatedAt: newDate(),
|
||||
deletedAt: null,
|
||||
updateId: newUuidV7(),
|
||||
status: AssetStatus.Active,
|
||||
checksum: newSha1(),
|
||||
deviceAssetId: '',
|
||||
deviceId: '',
|
||||
duplicateId: null,
|
||||
duration: null,
|
||||
encodedVideoPath: null,
|
||||
fileCreatedAt: newDate(),
|
||||
fileModifiedAt: newDate(),
|
||||
isExternal: false,
|
||||
isFavorite: false,
|
||||
isOffline: false,
|
||||
libraryId: null,
|
||||
livePhotoVideoId: null,
|
||||
localDateTime: newDate(),
|
||||
originalFileName: 'IMG_123.jpg',
|
||||
originalPath: `/data/12/34/IMG_123.jpg`,
|
||||
ownerId: newUuid(),
|
||||
stackId: null,
|
||||
thumbhash: null,
|
||||
type: AssetType.Image,
|
||||
visibility: AssetVisibility.Timeline,
|
||||
width: null,
|
||||
height: null,
|
||||
isEdited: false,
|
||||
...asset,
|
||||
};
|
||||
};
|
||||
|
||||
const activityFactory = (activity: Partial<Activity> = {}) => {
|
||||
const userId = activity.userId || newUuid();
|
||||
return {
|
||||
@ -283,7 +221,7 @@ const activityFactory = (activity: Partial<Activity> = {}) => {
|
||||
comment: null,
|
||||
isLiked: false,
|
||||
userId,
|
||||
user: userFactory({ id: userId }),
|
||||
user: UserFactory.create({ id: userId }),
|
||||
assetId: newUuid(),
|
||||
albumId: newUuid(),
|
||||
createdAt: newDate(),
|
||||
@ -319,24 +257,6 @@ const libraryFactory = (library: Partial<Library> = {}) => ({
|
||||
...library,
|
||||
});
|
||||
|
||||
const memoryFactory = (memory: Partial<Memory> = {}) => ({
|
||||
id: newUuid(),
|
||||
createdAt: newDate(),
|
||||
updatedAt: newDate(),
|
||||
updateId: newUuidV7(),
|
||||
deletedAt: null,
|
||||
ownerId: newUuid(),
|
||||
type: MemoryType.OnThisDay,
|
||||
data: { year: 2024 } as OnThisDayData,
|
||||
isSaved: false,
|
||||
memoryAt: newDate(),
|
||||
seenAt: null,
|
||||
showAt: newDate(),
|
||||
hideAt: newDate(),
|
||||
assets: [],
|
||||
...memory,
|
||||
});
|
||||
|
||||
const versionHistoryFactory = () => ({
|
||||
id: newUuid(),
|
||||
createdAt: newDate(),
|
||||
@ -403,49 +323,6 @@ const assetOcrFactory = (
|
||||
...ocr,
|
||||
});
|
||||
|
||||
const assetFileFactory = (file: Partial<AssetFile> = {}) => ({
|
||||
id: newUuid(),
|
||||
type: AssetFileType.Preview,
|
||||
path: '/uploads/user-id/thumbs/path.jpg',
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
...file,
|
||||
});
|
||||
|
||||
const exifFactory = (exif: Partial<Exif> = {}) => ({
|
||||
assetId: newUuid(),
|
||||
autoStackId: null,
|
||||
bitsPerSample: null,
|
||||
city: 'Austin',
|
||||
colorspace: null,
|
||||
country: 'United States of America',
|
||||
dateTimeOriginal: newDate(),
|
||||
description: '',
|
||||
exifImageHeight: 420,
|
||||
exifImageWidth: 42,
|
||||
exposureTime: null,
|
||||
fileSizeInByte: 69,
|
||||
fNumber: 1.7,
|
||||
focalLength: 4.38,
|
||||
fps: null,
|
||||
iso: 947,
|
||||
latitude: 30.267_334_570_570_195,
|
||||
longitude: -97.789_833_534_282_07,
|
||||
lensModel: null,
|
||||
livePhotoCID: null,
|
||||
make: 'Google',
|
||||
model: 'Pixel 7',
|
||||
modifyDate: newDate(),
|
||||
orientation: '1',
|
||||
profileDescription: null,
|
||||
projectionType: null,
|
||||
rating: 4,
|
||||
state: 'Texas',
|
||||
tags: ['parent/child'],
|
||||
timeZone: 'UTC-6',
|
||||
...exif,
|
||||
});
|
||||
|
||||
const tagFactory = (tag: Partial<Tag>): Tag => ({
|
||||
id: newUuid(),
|
||||
color: null,
|
||||
@ -456,25 +333,6 @@ const tagFactory = (tag: Partial<Tag>): Tag => ({
|
||||
...tag,
|
||||
});
|
||||
|
||||
const faceFactory = ({ person, ...face }: DeepPartial<AssetFace> = {}): AssetFace => ({
|
||||
assetId: newUuid(),
|
||||
boundingBoxX1: 1,
|
||||
boundingBoxX2: 2,
|
||||
boundingBoxY1: 1,
|
||||
boundingBoxY2: 2,
|
||||
deletedAt: null,
|
||||
id: newUuid(),
|
||||
imageHeight: 420,
|
||||
imageWidth: 42,
|
||||
isVisible: true,
|
||||
personId: null,
|
||||
sourceType: SourceType.MachineLearning,
|
||||
updatedAt: newDate(),
|
||||
updateId: newUuidV7(),
|
||||
person: person === null ? null : personFactory(person),
|
||||
...face,
|
||||
});
|
||||
|
||||
const assetEditFactory = (edit?: Partial<AssetEditActionItem>): AssetEditActionItem => {
|
||||
switch (edit?.action) {
|
||||
case AssetEditAction.Crop: {
|
||||
@ -529,26 +387,20 @@ const albumFactory = (album?: Partial<Omit<Album, 'assets'>>) => ({
|
||||
export const factory = {
|
||||
activity: activityFactory,
|
||||
apiKey: apiKeyFactory,
|
||||
asset: assetFactory,
|
||||
assetFile: assetFileFactory,
|
||||
assetOcr: assetOcrFactory,
|
||||
auth: authFactory,
|
||||
authApiKey: authApiKeyFactory,
|
||||
authUser: authUserFactory,
|
||||
library: libraryFactory,
|
||||
memory: memoryFactory,
|
||||
partner: partnerFactory,
|
||||
queueStatistics: queueStatisticsFactory,
|
||||
session: sessionFactory,
|
||||
stack: stackFactory,
|
||||
user: userFactory,
|
||||
userAdmin: userAdminFactory,
|
||||
versionHistory: versionHistoryFactory,
|
||||
jobAssets: {
|
||||
sidecarWrite: assetSidecarWriteFactory,
|
||||
},
|
||||
exif: exifFactory,
|
||||
face: faceFactory,
|
||||
person: personFactory,
|
||||
assetEdit: assetEditFactory,
|
||||
tag: tagFactory,
|
||||
|
||||
25
web/src/lib/actions/image-loader.svelte.ts
Normal file
25
web/src/lib/actions/image-loader.svelte.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { cancelImageUrl } from '$lib/utils/sw-messaging';
|
||||
|
||||
export function loadImage(src: string, onLoad: () => void, onError: () => void, onStart?: () => void) {
|
||||
let destroyed = false;
|
||||
|
||||
const handleLoad = () => !destroyed && onLoad();
|
||||
const handleError = () => !destroyed && onError();
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.addEventListener('load', handleLoad);
|
||||
img.addEventListener('error', handleError);
|
||||
|
||||
onStart?.();
|
||||
img.src = src;
|
||||
|
||||
return () => {
|
||||
destroyed = true;
|
||||
img.removeEventListener('load', handleLoad);
|
||||
img.removeEventListener('error', handleError);
|
||||
cancelImageUrl(src);
|
||||
img.remove();
|
||||
};
|
||||
}
|
||||
|
||||
export type LoadImageFunction = typeof loadImage;
|
||||
@ -2,21 +2,26 @@ import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { createZoomImageWheel } from '@zoom-image/core';
|
||||
|
||||
export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolean }) => {
|
||||
const zoomInstance = createZoomImageWheel(node, { maxZoom: 10, initialState: assetViewerManager.zoomState });
|
||||
const zoomInstance = createZoomImageWheel(node, {
|
||||
maxZoom: 10,
|
||||
initialState: assetViewerManager.zoomState,
|
||||
zoomTarget: null,
|
||||
});
|
||||
|
||||
const unsubscribes = [
|
||||
assetViewerManager.on({ ZoomChange: (state) => zoomInstance.setState(state) }),
|
||||
zoomInstance.subscribe(({ state }) => assetViewerManager.onZoomChange(state)),
|
||||
];
|
||||
|
||||
const stopIfDisabled = (event: Event) => {
|
||||
const onInteractionStart = (event: Event) => {
|
||||
if (options?.disabled) {
|
||||
event.stopImmediatePropagation();
|
||||
}
|
||||
assetViewerManager.cancelZoomAnimation();
|
||||
};
|
||||
|
||||
node.addEventListener('wheel', stopIfDisabled, { capture: true });
|
||||
node.addEventListener('pointerdown', stopIfDisabled, { capture: true });
|
||||
node.addEventListener('wheel', onInteractionStart, { capture: true });
|
||||
node.addEventListener('pointerdown', onInteractionStart, { capture: true });
|
||||
|
||||
node.style.overflow = 'visible';
|
||||
return {
|
||||
@ -27,8 +32,8 @@ export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolea
|
||||
for (const unsubscribe of unsubscribes) {
|
||||
unsubscribe();
|
||||
}
|
||||
node.removeEventListener('wheel', stopIfDisabled, { capture: true });
|
||||
node.removeEventListener('pointerdown', stopIfDisabled, { capture: true });
|
||||
node.removeEventListener('wheel', onInteractionStart, { capture: true });
|
||||
node.removeEventListener('pointerdown', onInteractionStart, { capture: true });
|
||||
zoomInstance.cleanup();
|
||||
},
|
||||
};
|
||||
|
||||
228
web/src/lib/components/AdaptiveImage.svelte
Normal file
228
web/src/lib/components/AdaptiveImage.svelte
Normal file
@ -0,0 +1,228 @@
|
||||
<script lang="ts">
|
||||
import { thumbhash } from '$lib/actions/thumbhash';
|
||||
import AlphaBackground from '$lib/components/AlphaBackground.svelte';
|
||||
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
|
||||
import DelayedLoadingSpinner from '$lib/components/DelayedLoadingSpinner.svelte';
|
||||
import ImageLayer from '$lib/components/ImageLayer.svelte';
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { getAssetUrls } from '$lib/utils';
|
||||
import { AdaptiveImageLoader, type QualityList } from '$lib/utils/adaptive-image-loader.svelte';
|
||||
import { scaleToCover, scaleToFit } from '$lib/utils/container-utils';
|
||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import type { AssetResponseDto, SharedLinkResponseDto } from '@immich/sdk';
|
||||
import { untrack, type Snippet } from 'svelte';
|
||||
|
||||
type Props = {
|
||||
asset: AssetResponseDto;
|
||||
sharedLink?: SharedLinkResponseDto;
|
||||
objectFit?: 'contain' | 'cover';
|
||||
container: {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
onUrlChange?: (url: string) => void;
|
||||
onImageReady?: () => void;
|
||||
onError?: () => void;
|
||||
ref?: HTMLDivElement;
|
||||
imgRef?: HTMLImageElement;
|
||||
backdrop?: Snippet;
|
||||
overlays?: Snippet;
|
||||
};
|
||||
|
||||
let {
|
||||
ref = $bindable(),
|
||||
// eslint-disable-next-line no-useless-assignment
|
||||
imgRef = $bindable(),
|
||||
asset,
|
||||
sharedLink,
|
||||
objectFit = 'contain',
|
||||
container,
|
||||
onUrlChange,
|
||||
onImageReady,
|
||||
onError,
|
||||
backdrop,
|
||||
overlays,
|
||||
}: Props = $props();
|
||||
|
||||
const afterThumbnail = (loader: AdaptiveImageLoader) => {
|
||||
if (assetViewerManager.zoom > 1) {
|
||||
loader.trigger('original');
|
||||
} else {
|
||||
loader.trigger('preview');
|
||||
}
|
||||
};
|
||||
|
||||
const buildQualityList = () => {
|
||||
const assetUrls = getAssetUrls(asset, sharedLink);
|
||||
const qualityList: QualityList = [
|
||||
{
|
||||
quality: 'thumbnail',
|
||||
url: assetUrls.thumbnail,
|
||||
onAfterLoad: afterThumbnail,
|
||||
onAfterError: afterThumbnail,
|
||||
},
|
||||
{
|
||||
quality: 'preview',
|
||||
url: assetUrls.preview,
|
||||
onAfterError: (loader) => loader.trigger('original'),
|
||||
},
|
||||
{ quality: 'original', url: assetUrls.original },
|
||||
];
|
||||
return qualityList;
|
||||
};
|
||||
|
||||
const loaderKey = $derived(`${asset.id}:${asset.thumbhash}:${sharedLink?.id}`);
|
||||
|
||||
const adaptiveImageLoader = $derived.by(() => {
|
||||
void loaderKey;
|
||||
|
||||
return untrack(
|
||||
() =>
|
||||
new AdaptiveImageLoader(buildQualityList(), {
|
||||
onImageReady,
|
||||
onError,
|
||||
onUrlChange,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
$effect.pre(() => {
|
||||
const loader = adaptiveImageLoader;
|
||||
untrack(() => assetViewerManager.resetZoomState());
|
||||
return () => loader.destroy();
|
||||
});
|
||||
|
||||
const imageDimensions = $derived.by(() => {
|
||||
const { width, height } = asset;
|
||||
if (width && width > 0 && height && height > 0) {
|
||||
return { width, height };
|
||||
}
|
||||
return { width: 1, height: 1 };
|
||||
});
|
||||
|
||||
const { width, height, left, top } = $derived.by(() => {
|
||||
const scaleFn = objectFit === 'cover' ? scaleToCover : scaleToFit;
|
||||
const { width, height } = scaleFn(imageDimensions, container);
|
||||
return {
|
||||
width: width + 'px',
|
||||
height: height + 'px',
|
||||
left: (container.width - width) / 2 + 'px',
|
||||
top: (container.height - height) / 2 + 'px',
|
||||
};
|
||||
});
|
||||
|
||||
const { status } = $derived(adaptiveImageLoader);
|
||||
const alt = $derived(status.urls.preview ? $getAltText(toTimelineAsset(asset)) : '');
|
||||
|
||||
const show = $derived.by(() => {
|
||||
const { quality, started, hasError, urls } = status;
|
||||
return {
|
||||
alphaBackground: !hasError && started,
|
||||
spinner: !asset.thumbhash && !started,
|
||||
brokenAsset: hasError,
|
||||
thumbhash: quality.thumbnail !== 'success' && quality.preview !== 'success' && quality.original !== 'success',
|
||||
thumbnail: quality.thumbnail !== 'error' && quality.preview !== 'success' && quality.original !== 'success',
|
||||
preview: quality.preview !== 'error' && quality.original !== 'success',
|
||||
original: quality.original !== 'error' && urls.original !== undefined,
|
||||
};
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
assetViewerManager.imageLoaderStatus = status;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (assetViewerManager.zoom > 1 && status.quality.original !== 'success') {
|
||||
untrack(() => void adaptiveImageLoader.trigger('original'));
|
||||
}
|
||||
});
|
||||
|
||||
let thumbnailElement = $state<HTMLImageElement>();
|
||||
let previewElement = $state<HTMLImageElement>();
|
||||
let originalElement = $state<HTMLImageElement>();
|
||||
|
||||
$effect(() => {
|
||||
const quality = status.quality;
|
||||
imgRef =
|
||||
(quality.original === 'success' ? originalElement : undefined) ??
|
||||
(quality.preview === 'success' ? previewElement : undefined) ??
|
||||
(quality.thumbnail === 'success' ? thumbnailElement : undefined);
|
||||
});
|
||||
|
||||
const zoomTransform = $derived.by(() => {
|
||||
const { currentZoom, currentPositionX, currentPositionY } = assetViewerManager.zoomState;
|
||||
if (currentZoom === 1 && currentPositionX === 0 && currentPositionY === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return `translate(${currentPositionX}px, ${currentPositionY}px) scale(${currentZoom})`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="relative h-full w-full overflow-hidden will-change-transform" bind:this={ref}>
|
||||
{@render backdrop?.()}
|
||||
|
||||
<div
|
||||
class="absolute inset-0"
|
||||
style:transform={zoomTransform}
|
||||
style:transform-origin={zoomTransform ? '0 0' : undefined}
|
||||
>
|
||||
<div class="absolute" style:left style:top style:width style:height>
|
||||
{#if show.alphaBackground}
|
||||
<AlphaBackground />
|
||||
{/if}
|
||||
|
||||
{#if show.thumbhash}
|
||||
{#if asset.thumbhash}
|
||||
<!-- Thumbhash / spinner layer -->
|
||||
<canvas use:thumbhash={{ base64ThumbHash: asset.thumbhash }} class="h-full w-full absolute"></canvas>
|
||||
{:else if show.spinner}
|
||||
<DelayedLoadingSpinner />
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if show.thumbnail}
|
||||
<ImageLayer
|
||||
{adaptiveImageLoader}
|
||||
{width}
|
||||
{height}
|
||||
quality="thumbnail"
|
||||
src={status.urls.thumbnail}
|
||||
alt=""
|
||||
role="presentation"
|
||||
bind:ref={thumbnailElement}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if show.brokenAsset}
|
||||
<BrokenAsset class="text-xl h-full w-full absolute" />
|
||||
{/if}
|
||||
|
||||
{#if show.preview}
|
||||
<ImageLayer
|
||||
{adaptiveImageLoader}
|
||||
{alt}
|
||||
{width}
|
||||
{height}
|
||||
{overlays}
|
||||
quality="preview"
|
||||
src={status.urls.preview}
|
||||
bind:ref={previewElement}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if show.original}
|
||||
<ImageLayer
|
||||
{adaptiveImageLoader}
|
||||
{alt}
|
||||
{width}
|
||||
{height}
|
||||
{overlays}
|
||||
quality="original"
|
||||
src={status.urls.original}
|
||||
bind:ref={originalElement}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
11
web/src/lib/components/AlphaBackground.svelte
Normal file
11
web/src/lib/components/AlphaBackground.svelte
Normal file
@ -0,0 +1,11 @@
|
||||
<script lang="ts">
|
||||
import type { ClassValue } from 'svelte/elements';
|
||||
|
||||
interface Props {
|
||||
class?: ClassValue;
|
||||
}
|
||||
|
||||
let { class: className = '' }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="absolute h-full w-full bg-gray-300 dark:bg-gray-700 {className}"></div>
|
||||
20
web/src/lib/components/DelayedLoadingSpinner.svelte
Normal file
20
web/src/lib/components/DelayedLoadingSpinner.svelte
Normal file
@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { LoadingSpinner } from '@immich/ui';
|
||||
</script>
|
||||
|
||||
<div class="delayed-spinner absolute flex h-full items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes delayedVisibility {
|
||||
to {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
.delayed-spinner {
|
||||
visibility: hidden;
|
||||
animation: 0s linear 0.4s forwards delayedVisibility;
|
||||
}
|
||||
</style>
|
||||
@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { isFirefox } from '$lib/utils/asset-utils';
|
||||
import { cancelImageUrl } from '$lib/utils/sw-messaging';
|
||||
import { onDestroy, untrack } from 'svelte';
|
||||
import type { HTMLImgAttributes } from 'svelte/elements';
|
||||
@ -14,6 +15,7 @@
|
||||
let { src, onStart, onLoad, onError, ref = $bindable(), ...rest }: Props = $props();
|
||||
|
||||
let capturedSource: string | undefined = $state();
|
||||
let loaded = $state(false);
|
||||
let destroyed = false;
|
||||
|
||||
$effect(() => {
|
||||
@ -32,11 +34,25 @@
|
||||
}
|
||||
});
|
||||
|
||||
const completeLoad = () => {
|
||||
if (destroyed) {
|
||||
return;
|
||||
}
|
||||
loaded = true;
|
||||
onLoad?.();
|
||||
};
|
||||
|
||||
const handleLoad = () => {
|
||||
if (destroyed || !src) {
|
||||
return;
|
||||
}
|
||||
onLoad?.();
|
||||
|
||||
if (isFirefox && ref) {
|
||||
ref.decode().then(completeLoad, completeLoad);
|
||||
return;
|
||||
}
|
||||
|
||||
completeLoad();
|
||||
};
|
||||
|
||||
const handleError = () => {
|
||||
@ -49,6 +65,13 @@
|
||||
|
||||
{#if capturedSource}
|
||||
{#key capturedSource}
|
||||
<img bind:this={ref} src={capturedSource} {...rest} onload={handleLoad} onerror={handleError} />
|
||||
<img
|
||||
bind:this={ref}
|
||||
src={capturedSource}
|
||||
{...rest}
|
||||
style:visibility={isFirefox && !loaded ? 'hidden' : undefined}
|
||||
onload={handleLoad}
|
||||
onerror={handleError}
|
||||
/>
|
||||
{/key}
|
||||
{/if}
|
||||
|
||||
47
web/src/lib/components/ImageLayer.svelte
Normal file
47
web/src/lib/components/ImageLayer.svelte
Normal file
@ -0,0 +1,47 @@
|
||||
<script lang="ts">
|
||||
import Image from '$lib/components/Image.svelte';
|
||||
import type { AdaptiveImageLoader, ImageQuality } from '$lib/utils/adaptive-image-loader.svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
type Props = {
|
||||
adaptiveImageLoader: AdaptiveImageLoader;
|
||||
quality: ImageQuality;
|
||||
src: string | undefined;
|
||||
alt?: string;
|
||||
role?: string;
|
||||
ref?: HTMLImageElement;
|
||||
width: string;
|
||||
height: string;
|
||||
overlays?: Snippet;
|
||||
};
|
||||
|
||||
let {
|
||||
adaptiveImageLoader,
|
||||
quality,
|
||||
src,
|
||||
alt = '',
|
||||
role,
|
||||
ref = $bindable(),
|
||||
width,
|
||||
height,
|
||||
overlays,
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
{#key adaptiveImageLoader}
|
||||
<div class="absolute top-0" style:width style:height>
|
||||
<Image
|
||||
{src}
|
||||
onStart={() => adaptiveImageLoader.onStart(quality)}
|
||||
onLoad={() => adaptiveImageLoader.onLoad(quality)}
|
||||
onError={() => adaptiveImageLoader.onError(quality)}
|
||||
bind:ref
|
||||
class="h-full w-full bg-transparent"
|
||||
{alt}
|
||||
{role}
|
||||
draggable={false}
|
||||
data-testid={quality}
|
||||
/>
|
||||
{@render overlays?.()}
|
||||
</div>
|
||||
{/key}
|
||||
46
web/src/lib/components/LoadingDots.svelte
Normal file
46
web/src/lib/components/LoadingDots.svelte
Normal file
@ -0,0 +1,46 @@
|
||||
<script lang="ts">
|
||||
import type { ClassValue } from 'svelte/elements';
|
||||
|
||||
interface Props {
|
||||
class?: ClassValue;
|
||||
}
|
||||
|
||||
let { class: className }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="delayed inline-flex items-center gap-1 {className}">
|
||||
{#each [0, 1, 2] as i (i)}
|
||||
<span class="dot block size-1.5 rounded-full bg-white shadow-[0_0_3px_rgba(0,0,0,0.6)]" style:--delay="{i * 0.25}s"
|
||||
></span>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.delayed {
|
||||
visibility: hidden;
|
||||
animation: delayed-visibility 0s linear 0.4s forwards;
|
||||
}
|
||||
|
||||
@keyframes delayed-visibility {
|
||||
to {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
.dot {
|
||||
animation: dot-stream 1.6s var(--delay, 0s) ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes dot-stream {
|
||||
0%,
|
||||
80%,
|
||||
100% {
|
||||
opacity: 0.3;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
40% {
|
||||
opacity: 1;
|
||||
transform: scale(1.15);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -115,7 +115,7 @@
|
||||
options={[
|
||||
{ value: AudioCodec.Aac, text: 'AAC' },
|
||||
{ value: AudioCodec.Mp3, text: 'MP3' },
|
||||
{ value: AudioCodec.Libopus, text: 'Opus' },
|
||||
{ value: AudioCodec.Opus, text: 'Opus' },
|
||||
{ value: AudioCodec.PcmS16Le, text: 'PCM (16 bit)' },
|
||||
]}
|
||||
isEdited={!isEqual(
|
||||
@ -174,7 +174,7 @@
|
||||
options={[
|
||||
{ value: AudioCodec.Aac, text: 'aac' },
|
||||
{ value: AudioCodec.Mp3, text: 'mp3' },
|
||||
{ value: AudioCodec.Libopus, text: 'opus' },
|
||||
{ value: AudioCodec.Opus, text: 'opus' },
|
||||
]}
|
||||
name="acodec"
|
||||
isEdited={configToEdit.ffmpeg.targetAudioCodec !== config.ffmpeg.targetAudioCodec}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { shortcut } from '$lib/actions/shortcut';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { updateAlbumInfo } from '@immich/sdk';
|
||||
import { Textarea } from '@immich/ui';
|
||||
@ -16,12 +17,13 @@
|
||||
|
||||
const handleFocusOut = async () => {
|
||||
try {
|
||||
await updateAlbumInfo({
|
||||
const response = await updateAlbumInfo({
|
||||
id,
|
||||
updateAlbumDto: {
|
||||
description,
|
||||
},
|
||||
});
|
||||
eventManager.emit('AlbumUpdate', response);
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_save_album'));
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { shortcut } from '$lib/actions/shortcut';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { updateAlbumInfo } from '@immich/sdk';
|
||||
import { t } from 'svelte-i18n';
|
||||
@ -21,12 +22,14 @@
|
||||
}
|
||||
|
||||
try {
|
||||
({ albumName } = await updateAlbumInfo({
|
||||
const response = await updateAlbumInfo({
|
||||
id,
|
||||
updateAlbumDto: {
|
||||
albumName: newAlbumName,
|
||||
},
|
||||
}));
|
||||
});
|
||||
({ albumName } = response);
|
||||
eventManager.emit('AlbumUpdate', response);
|
||||
onUpdate(albumName);
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_save_album'));
|
||||
|
||||
@ -17,7 +17,6 @@
|
||||
type AlbumViewSettings,
|
||||
} from '$lib/stores/preferences.store';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { userInteraction } from '$lib/stores/user.svelte';
|
||||
import { getSelectedAlbumGroupOption, sortAlbums, stringToSortOrder, type AlbumGroup } from '$lib/utils/album-utils';
|
||||
import type { ContextMenuPosition } from '$lib/utils/context-menu';
|
||||
import { normalizeSearchString } from '$lib/utils/string-utils';
|
||||
@ -233,16 +232,11 @@
|
||||
return albums;
|
||||
};
|
||||
|
||||
const onUpdate = (album: AlbumResponseDto) => {
|
||||
const onAlbumUpdate = (album: AlbumResponseDto) => {
|
||||
ownedAlbums = findAndUpdate(ownedAlbums, album);
|
||||
sharedAlbums = findAndUpdate(sharedAlbums, album);
|
||||
};
|
||||
|
||||
const onAlbumUpdate = (album: AlbumResponseDto) => {
|
||||
onUpdate(album);
|
||||
userInteraction.recentAlbums = findAndUpdate(userInteraction.recentAlbums || [], album);
|
||||
};
|
||||
|
||||
const onAlbumDelete = (album: AlbumResponseDto) => {
|
||||
ownedAlbums = ownedAlbums.filter(({ id }) => id !== album.id);
|
||||
sharedAlbums = sharedAlbums.filter(({ id }) => id !== album.id);
|
||||
@ -250,7 +244,7 @@
|
||||
|
||||
const onSharedLinkCreate = (sharedLink: SharedLinkResponseDto) => {
|
||||
if (sharedLink.album) {
|
||||
onUpdate(sharedLink.album);
|
||||
onAlbumUpdate(sharedLink.album);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
104
web/src/lib/components/asset-viewer/PreloadManager.svelte.ts
Normal file
104
web/src/lib/components/asset-viewer/PreloadManager.svelte.ts
Normal file
@ -0,0 +1,104 @@
|
||||
import { loadImage } from '$lib/actions/image-loader.svelte';
|
||||
import { getAssetUrls } from '$lib/utils';
|
||||
import { AdaptiveImageLoader, type QualityList } from '$lib/utils/adaptive-image-loader.svelte';
|
||||
import type { AssetResponseDto, SharedLinkResponseDto } from '@immich/sdk';
|
||||
|
||||
type AssetCursor = {
|
||||
current: AssetResponseDto;
|
||||
nextAsset?: AssetResponseDto;
|
||||
previousAsset?: AssetResponseDto;
|
||||
};
|
||||
|
||||
export class PreloadManager {
|
||||
private nextPreloader: AdaptiveImageLoader | undefined;
|
||||
private previousPreloader: AdaptiveImageLoader | undefined;
|
||||
|
||||
private startPreloader(
|
||||
asset: AssetResponseDto | undefined,
|
||||
sharedlink: SharedLinkResponseDto | undefined,
|
||||
): AdaptiveImageLoader | undefined {
|
||||
if (!asset) {
|
||||
return;
|
||||
}
|
||||
const urls = getAssetUrls(asset, sharedlink);
|
||||
const afterThumbnail = (loader: AdaptiveImageLoader) => loader.trigger('preview');
|
||||
const qualityList: QualityList = [
|
||||
{
|
||||
quality: 'thumbnail',
|
||||
url: urls.thumbnail,
|
||||
onAfterLoad: afterThumbnail,
|
||||
onAfterError: afterThumbnail,
|
||||
},
|
||||
{
|
||||
quality: 'preview',
|
||||
url: urls.preview,
|
||||
onAfterError: (loader) => loader.trigger('original'),
|
||||
},
|
||||
{ quality: 'original', url: urls.original },
|
||||
];
|
||||
const loader = new AdaptiveImageLoader(qualityList, undefined, loadImage);
|
||||
loader.start();
|
||||
return loader;
|
||||
}
|
||||
|
||||
private destroyPreviousPreloader() {
|
||||
this.previousPreloader?.destroy();
|
||||
this.previousPreloader = undefined;
|
||||
}
|
||||
|
||||
private destroyNextPreloader() {
|
||||
this.nextPreloader?.destroy();
|
||||
this.nextPreloader = undefined;
|
||||
}
|
||||
|
||||
cancelBeforeNavigation(direction: 'previous' | 'next') {
|
||||
switch (direction) {
|
||||
case 'next': {
|
||||
this.destroyPreviousPreloader();
|
||||
break;
|
||||
}
|
||||
case 'previous': {
|
||||
this.destroyNextPreloader();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateAfterNavigation(oldCursor: AssetCursor, newCursor: AssetCursor, sharedlink: SharedLinkResponseDto | undefined) {
|
||||
const movedForward = newCursor.current.id === oldCursor.nextAsset?.id;
|
||||
const movedBackward = newCursor.current.id === oldCursor.previousAsset?.id;
|
||||
|
||||
if (!movedBackward) {
|
||||
this.destroyPreviousPreloader();
|
||||
}
|
||||
|
||||
if (!movedForward) {
|
||||
this.destroyNextPreloader();
|
||||
}
|
||||
|
||||
if (movedForward) {
|
||||
this.nextPreloader = this.startPreloader(newCursor.nextAsset, sharedlink);
|
||||
} else if (movedBackward) {
|
||||
this.previousPreloader = this.startPreloader(newCursor.previousAsset, sharedlink);
|
||||
} else {
|
||||
this.previousPreloader = this.startPreloader(newCursor.previousAsset, sharedlink);
|
||||
this.nextPreloader = this.startPreloader(newCursor.nextAsset, sharedlink);
|
||||
}
|
||||
}
|
||||
|
||||
initializePreloads(cursor: AssetCursor, sharedlink: SharedLinkResponseDto | undefined) {
|
||||
if (cursor.nextAsset) {
|
||||
this.nextPreloader = this.startPreloader(cursor.nextAsset, sharedlink);
|
||||
}
|
||||
if (cursor.previousAsset) {
|
||||
this.previousPreloader = this.startPreloader(cursor.previousAsset, sharedlink);
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.destroyNextPreloader();
|
||||
this.destroyPreviousPreloader();
|
||||
}
|
||||
}
|
||||
|
||||
export const preloadManager = new PreloadManager();
|
||||
@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { updateAlbumInfo, type AlbumResponseDto, type AssetResponseDto } from '@immich/sdk';
|
||||
import { toastManager } from '@immich/ui';
|
||||
@ -15,12 +16,13 @@
|
||||
|
||||
const handleUpdateThumbnail = async () => {
|
||||
try {
|
||||
await updateAlbumInfo({
|
||||
const response = await updateAlbumInfo({
|
||||
id: album.id,
|
||||
updateAlbumDto: {
|
||||
albumThumbnailAssetId: asset.id,
|
||||
},
|
||||
});
|
||||
eventManager.emit('AlbumUpdate', response);
|
||||
toastManager.success($t('album_cover_updated'));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_update_album_cover'));
|
||||
|
||||
@ -34,7 +34,9 @@
|
||||
type PersonResponseDto,
|
||||
type StackResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import { ActionButton, CommandPaletteDefaultProvider, type ActionItem } from '@immich/ui';
|
||||
import { ActionButton, CommandPaletteDefaultProvider, Tooltip, type ActionItem } from '@immich/ui';
|
||||
import LoadingDots from '$lib/components/LoadingDots.svelte';
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import {
|
||||
mdiArrowLeft,
|
||||
mdiArrowRight,
|
||||
@ -104,7 +106,16 @@
|
||||
<ActionButton action={Close} />
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 overflow-x-auto dark" data-testid="asset-viewer-navbar-actions">
|
||||
<div class="flex items-center gap-2 overflow-x-auto dark" data-testid="asset-viewer-navbar-actions">
|
||||
{#if assetViewerManager.isImageLoading}
|
||||
<Tooltip text={$t('loading')}>
|
||||
{#snippet child({ props })}
|
||||
<div {...props} role="status" aria-label={$t('loading')}>
|
||||
<LoadingDots class="me-1" />
|
||||
</div>
|
||||
{/snippet}
|
||||
</Tooltip>
|
||||
{/if}
|
||||
<ActionButton action={Cast} />
|
||||
<ActionButton action={Actions.Share} />
|
||||
<ActionButton action={Actions.Offline} />
|
||||
|
||||
76
web/src/lib/components/asset-viewer/asset-viewer.spec.ts
Normal file
76
web/src/lib/components/asset-viewer/asset-viewer.spec.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import { getAnimateMock } from '$lib/__mocks__/animate.mock';
|
||||
import { getResizeObserverMock } from '$lib/__mocks__/resize-observer.mock';
|
||||
import { preferences as preferencesStore, resetSavedUser, user as userStore } from '$lib/stores/user.store';
|
||||
import { renderWithTooltips } from '$tests/helpers';
|
||||
import { updateAsset } from '@immich/sdk';
|
||||
import { assetFactory } from '@test-data/factories/asset-factory';
|
||||
import { preferencesFactory } from '@test-data/factories/preferences-factory';
|
||||
import { userAdminFactory } from '@test-data/factories/user-factory';
|
||||
import { fireEvent, waitFor } from '@testing-library/svelte';
|
||||
import AssetViewer from './asset-viewer.svelte';
|
||||
|
||||
vi.mock('$lib/managers/feature-flags-manager.svelte', () => ({
|
||||
featureFlagsManager: {
|
||||
init: vi.fn(),
|
||||
loadFeatureFlags: vi.fn(),
|
||||
value: { smartSearch: true, trash: true },
|
||||
} as never,
|
||||
}));
|
||||
|
||||
vi.mock('$lib/stores/ocr.svelte', () => ({
|
||||
ocrManager: {
|
||||
clear: vi.fn(),
|
||||
getAssetOcr: vi.fn(),
|
||||
hasOcrData: false,
|
||||
showOverlay: false,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@immich/sdk', async () => {
|
||||
const sdk = await vi.importActual<typeof import('@immich/sdk')>('@immich/sdk');
|
||||
return {
|
||||
...sdk,
|
||||
updateAsset: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('AssetViewer', () => {
|
||||
beforeAll(() => {
|
||||
Element.prototype.animate = getAnimateMock();
|
||||
vi.stubGlobal('ResizeObserver', getResizeObserverMock());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetSavedUser();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('updates the top bar favorite action after pressing favorite', async () => {
|
||||
const ownerId = 'owner-id';
|
||||
const user = userAdminFactory.build({ id: ownerId });
|
||||
const asset = assetFactory.build({ ownerId, isFavorite: false, isTrashed: false });
|
||||
|
||||
userStore.set(user);
|
||||
preferencesStore.set(preferencesFactory.build({ cast: { gCastEnabled: false } }));
|
||||
vi.mocked(updateAsset).mockResolvedValue({ ...asset, isFavorite: true });
|
||||
|
||||
const { getByLabelText, queryByLabelText } = renderWithTooltips(AssetViewer, {
|
||||
cursor: { current: asset },
|
||||
showNavigation: false,
|
||||
});
|
||||
|
||||
expect(getByLabelText('to_favorite')).toBeInTheDocument();
|
||||
expect(queryByLabelText('unfavorite')).toBeNull();
|
||||
|
||||
await fireEvent.click(getByLabelText('to_favorite'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(updateAsset).toHaveBeenCalledWith({ id: asset.id, updateAssetDto: { isFavorite: true } }),
|
||||
);
|
||||
await waitFor(() => expect(getByLabelText('unfavorite')).toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
@ -5,15 +5,17 @@
|
||||
import NextAssetAction from '$lib/components/asset-viewer/actions/next-asset-action.svelte';
|
||||
import PreviousAssetAction from '$lib/components/asset-viewer/actions/previous-asset-action.svelte';
|
||||
import AssetViewerNavBar from '$lib/components/asset-viewer/asset-viewer-nav-bar.svelte';
|
||||
import { preloadManager } from '$lib/components/asset-viewer/PreloadManager.svelte';
|
||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||
import { AssetAction, ProjectionType } from '$lib/constants';
|
||||
import { activityManager } from '$lib/managers/activity-manager.svelte';
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.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';
|
||||
import { imageManager } from '$lib/managers/ImageManager.svelte';
|
||||
import { getAssetActions } from '$lib/services/asset.service';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||
import { ocrManager } from '$lib/stores/ocr.svelte';
|
||||
import { alwaysLoadOriginalVideo } from '$lib/stores/preferences.store';
|
||||
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
|
||||
@ -36,6 +38,7 @@
|
||||
} from '@immich/sdk';
|
||||
import { CommandPaletteDefaultProvider } from '@immich/ui';
|
||||
import { onDestroy, onMount, untrack } from 'svelte';
|
||||
import type { SwipeCustomEvent } from 'svelte-gestures';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fly } from 'svelte/transition';
|
||||
import Thumbnail from '../assets/thumbnail/thumbnail.svelte';
|
||||
@ -92,20 +95,19 @@
|
||||
stopProgress: stopSlideshowProgress,
|
||||
slideshowNavigation,
|
||||
slideshowState,
|
||||
slideshowTransition,
|
||||
slideshowRepeat,
|
||||
} = slideshowStore;
|
||||
const stackThumbnailSize = 60;
|
||||
const stackSelectedThumbnailSize = 65;
|
||||
|
||||
const asset = $derived(cursor.current);
|
||||
let previewStackedAsset: AssetResponseDto | undefined = $state();
|
||||
let stack: StackResponseDto | null = $state(null);
|
||||
|
||||
const asset = $derived(previewStackedAsset ?? cursor.current);
|
||||
const nextAsset = $derived(cursor.nextAsset);
|
||||
const previousAsset = $derived(cursor.previousAsset);
|
||||
let sharedLink = getSharedLink();
|
||||
let previewStackedAsset: AssetResponseDto | undefined = $state();
|
||||
let fullscreenElement = $state<Element>();
|
||||
let unsubscribes: (() => void)[] = [];
|
||||
let stack: StackResponseDto | null = $state(null);
|
||||
|
||||
let playOriginalVideo = $state($alwaysLoadOriginalVideo);
|
||||
let slideshowStartAssetId = $state<string>();
|
||||
@ -115,7 +117,7 @@
|
||||
};
|
||||
|
||||
const refreshStack = async () => {
|
||||
if (authManager.isSharedLink) {
|
||||
if (authManager.isSharedLink || !withStacked) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -126,51 +128,56 @@
|
||||
if (!stack?.assets.some(({ id }) => id === asset.id)) {
|
||||
stack = null;
|
||||
}
|
||||
|
||||
untrack(() => {
|
||||
imageManager.preload(stack?.assets[1]);
|
||||
});
|
||||
};
|
||||
|
||||
const handleFavorite = async () => {
|
||||
if (album && album.isActivityEnabled) {
|
||||
try {
|
||||
await activityManager.toggleLike();
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_change_favorite'));
|
||||
}
|
||||
if (!album || !album.isActivityEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await activityManager.toggleLike();
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_change_favorite'));
|
||||
}
|
||||
};
|
||||
|
||||
const onAssetUpdate = (updatedAsset: AssetResponseDto) => {
|
||||
if (asset.id === updatedAsset.id) {
|
||||
cursor = { ...cursor, current: updatedAsset };
|
||||
}
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
syncAssetViewerOpenClass(true);
|
||||
unsubscribes.push(
|
||||
slideshowState.subscribe((value) => {
|
||||
if (value === SlideshowState.PlaySlideshow) {
|
||||
slideshowHistory.reset();
|
||||
slideshowHistory.queue(toTimelineAsset(asset));
|
||||
handlePromiseError(handlePlaySlideshow());
|
||||
} else if (value === SlideshowState.StopSlideshow) {
|
||||
handlePromiseError(handleStopSlideshow());
|
||||
}
|
||||
}),
|
||||
slideshowNavigation.subscribe((value) => {
|
||||
if (value === SlideshowNavigation.Shuffle) {
|
||||
slideshowHistory.reset();
|
||||
slideshowHistory.queue(toTimelineAsset(asset));
|
||||
}
|
||||
}),
|
||||
);
|
||||
const slideshowStateUnsubscribe = slideshowState.subscribe((value) => {
|
||||
if (value === SlideshowState.PlaySlideshow) {
|
||||
slideshowHistory.reset();
|
||||
slideshowHistory.queue(toTimelineAsset(asset));
|
||||
handlePromiseError(handlePlaySlideshow());
|
||||
} else if (value === SlideshowState.StopSlideshow) {
|
||||
handlePromiseError(handleStopSlideshow());
|
||||
}
|
||||
});
|
||||
|
||||
const slideshowNavigationUnsubscribe = slideshowNavigation.subscribe((value) => {
|
||||
if (value === SlideshowNavigation.Shuffle) {
|
||||
slideshowHistory.reset();
|
||||
slideshowHistory.queue(toTimelineAsset(asset));
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
slideshowStateUnsubscribe();
|
||||
slideshowNavigationUnsubscribe();
|
||||
};
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
for (const unsubscribe of unsubscribes) {
|
||||
unsubscribe();
|
||||
}
|
||||
|
||||
activityManager.reset();
|
||||
assetViewerManager.closeEditor();
|
||||
syncAssetViewerOpenClass(false);
|
||||
preloadManager.destroy();
|
||||
});
|
||||
|
||||
const closeViewer = () => {
|
||||
@ -187,8 +194,7 @@
|
||||
};
|
||||
|
||||
const tracker = new InvocationTracker();
|
||||
|
||||
const navigateAsset = (order?: 'previous' | 'next', e?: Event) => {
|
||||
const navigateAsset = (order?: 'previous' | 'next') => {
|
||||
if (!order) {
|
||||
if ($slideshowState === SlideshowState.PlaySlideshow) {
|
||||
order = $slideshowNavigation === SlideshowNavigation.AscendingOrder ? 'previous' : 'next';
|
||||
@ -197,16 +203,19 @@
|
||||
}
|
||||
}
|
||||
|
||||
e?.stopPropagation();
|
||||
imageManager.cancel(asset);
|
||||
preloadManager.cancelBeforeNavigation(order);
|
||||
|
||||
if (tracker.isActive()) {
|
||||
return;
|
||||
}
|
||||
|
||||
void tracker.invoke(async () => {
|
||||
const isShuffle =
|
||||
$slideshowState === SlideshowState.PlaySlideshow && $slideshowNavigation === SlideshowNavigation.Shuffle;
|
||||
|
||||
let hasNext: boolean;
|
||||
|
||||
if ($slideshowState === SlideshowState.PlaySlideshow && $slideshowNavigation === SlideshowNavigation.Shuffle) {
|
||||
if (isShuffle) {
|
||||
hasNext = order === 'previous' ? slideshowHistory.previous() : slideshowHistory.next();
|
||||
if (!hasNext) {
|
||||
const asset = await onRandom?.();
|
||||
@ -220,17 +229,22 @@
|
||||
order === 'previous' ? await navigateToAsset(cursor.previousAsset) : await navigateToAsset(cursor.nextAsset);
|
||||
}
|
||||
|
||||
if ($slideshowState === SlideshowState.PlaySlideshow) {
|
||||
if (hasNext) {
|
||||
$restartSlideshowProgress = true;
|
||||
} else if ($slideshowRepeat && slideshowStartAssetId) {
|
||||
// Loop back to starting asset
|
||||
await setAssetId(slideshowStartAssetId);
|
||||
$restartSlideshowProgress = true;
|
||||
} else {
|
||||
await handleStopSlideshow();
|
||||
}
|
||||
if ($slideshowState !== SlideshowState.PlaySlideshow) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasNext) {
|
||||
$restartSlideshowProgress = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if ($slideshowRepeat && slideshowStartAssetId) {
|
||||
await setAssetId(slideshowStartAssetId);
|
||||
$restartSlideshowProgress = true;
|
||||
return;
|
||||
}
|
||||
|
||||
await handleStopSlideshow();
|
||||
}, $t('error_while_navigating'));
|
||||
};
|
||||
|
||||
@ -274,12 +288,14 @@
|
||||
}
|
||||
};
|
||||
|
||||
const handleStackedAssetMouseEvent = (isMouseOver: boolean, asset: AssetResponseDto) => {
|
||||
previewStackedAsset = isMouseOver ? asset : undefined;
|
||||
const handleStackedAssetMouseEvent = (isMouseOver: boolean, stackedAsset: AssetResponseDto) => {
|
||||
previewStackedAsset = isMouseOver ? stackedAsset : undefined;
|
||||
};
|
||||
|
||||
const handlePreAction = (action: Action) => {
|
||||
preAction?.(action);
|
||||
};
|
||||
|
||||
const handleAction = async (action: Action) => {
|
||||
switch (action.type) {
|
||||
case AssetAction.DELETE:
|
||||
@ -352,17 +368,31 @@
|
||||
await ocrManager.getAssetOcr(asset.id);
|
||||
}
|
||||
};
|
||||
|
||||
$effect(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
asset;
|
||||
untrack(() => handlePromiseError(refresh()));
|
||||
imageManager.preload(cursor.nextAsset);
|
||||
imageManager.preload(cursor.previousAsset);
|
||||
});
|
||||
|
||||
let lastCursor = $state<AssetCursor>();
|
||||
|
||||
$effect(() => {
|
||||
if (cursor.current.id === lastCursor?.current.id) {
|
||||
return;
|
||||
}
|
||||
if (lastCursor) {
|
||||
preloadManager.updateAfterNavigation(lastCursor, cursor, sharedLink);
|
||||
}
|
||||
if (!lastCursor) {
|
||||
preloadManager.initializePreloads(cursor, sharedLink);
|
||||
}
|
||||
lastCursor = cursor;
|
||||
});
|
||||
|
||||
const viewerKind = $derived.by(() => {
|
||||
if (previewStackedAsset) {
|
||||
return previewStackedAsset.type === AssetTypeEnum.Image ? 'StackPhotoViewer' : 'StackVideoViewer';
|
||||
return previewStackedAsset.type === AssetTypeEnum.Image ? 'PhotoViewer' : 'StackVideoViewer';
|
||||
}
|
||||
if (asset.type === AssetTypeEnum.Video) {
|
||||
return 'VideoViewer';
|
||||
@ -396,16 +426,35 @@
|
||||
ocrManager.hasOcrData,
|
||||
);
|
||||
|
||||
const { Tag } = $derived(getAssetActions($t, asset));
|
||||
const { Tag, TagPeople } = $derived(getAssetActions($t, asset));
|
||||
const showDetailPanel = $derived(
|
||||
asset.hasMetadata &&
|
||||
$slideshowState === SlideshowState.None &&
|
||||
assetViewerManager.isShowDetailPanel &&
|
||||
!assetViewerManager.isShowEditor,
|
||||
);
|
||||
|
||||
const onSwipe = (event: SwipeCustomEvent) => {
|
||||
if (assetViewerManager.zoom > 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (ocrManager.showOverlay) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.detail.direction === 'left') {
|
||||
navigateAsset('next');
|
||||
}
|
||||
|
||||
if (event.detail.direction === 'right') {
|
||||
navigateAsset('previous');
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<CommandPaletteDefaultProvider name={$t('assets')} actions={[Tag]} />
|
||||
<CommandPaletteDefaultProvider name={$t('assets')} actions={[Tag, TagPeople]} />
|
||||
<OnEvents {onAssetUpdate} />
|
||||
|
||||
<svelte:document bind:fullscreenElement />
|
||||
|
||||
@ -448,23 +497,15 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && previousAsset}
|
||||
{#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && !isFaceEditMode.value && previousAsset}
|
||||
<div class="my-auto col-span-1 col-start-1 row-span-full row-start-1 justify-self-start">
|
||||
<PreviousAssetAction onPreviousAsset={() => navigateAsset('previous')} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Asset Viewer -->
|
||||
<div class="z-[-1] relative col-start-1 col-span-4 row-start-1 row-span-full">
|
||||
{#if viewerKind === 'StackPhotoViewer'}
|
||||
<PhotoViewer
|
||||
cursor={{ ...cursor, current: previewStackedAsset! }}
|
||||
onPreviousAsset={() => navigateAsset('previous')}
|
||||
onNextAsset={() => navigateAsset('next')}
|
||||
haveFadeTransition={false}
|
||||
{sharedLink}
|
||||
/>
|
||||
{:else if viewerKind === 'StackVideoViewer'}
|
||||
<div data-viewer-content class="z-[-1] relative col-start-1 col-span-4 row-start-1 row-span-full">
|
||||
{#if viewerKind === 'StackVideoViewer'}
|
||||
<VideoViewer
|
||||
asset={previewStackedAsset!}
|
||||
cacheKey={previewStackedAsset!.thumbhash}
|
||||
@ -494,13 +535,7 @@
|
||||
{:else if viewerKind === 'CropArea'}
|
||||
<CropArea {asset} />
|
||||
{:else if viewerKind === 'PhotoViewer'}
|
||||
<PhotoViewer
|
||||
{cursor}
|
||||
onPreviousAsset={() => navigateAsset('previous')}
|
||||
onNextAsset={() => navigateAsset('next')}
|
||||
{sharedLink}
|
||||
haveFadeTransition={$slideshowState !== SlideshowState.None && $slideshowTransition}
|
||||
/>
|
||||
<PhotoViewer cursor={{ ...cursor, current: asset }} {sharedLink} {onSwipe} />
|
||||
{:else if viewerKind === 'VideoViewer'}
|
||||
<VideoViewer
|
||||
{asset}
|
||||
@ -535,7 +570,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && nextAsset}
|
||||
{#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && !isFaceEditMode.value && nextAsset}
|
||||
<div class="my-auto col-span-1 col-start-4 row-span-full row-start-1 justify-self-end">
|
||||
<NextAssetAction onNextAsset={() => navigateAsset('next')} />
|
||||
</div>
|
||||
|
||||
@ -36,4 +36,44 @@ describe('CropArea', () => {
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||
import { getPeopleThumbnailUrl } from '$lib/utils';
|
||||
import { getContentMetrics, getNaturalSize } from '$lib/utils/container-utils';
|
||||
import { getNaturalSize, scaleToFit } from '$lib/utils/container-utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { createFace, getAllPeople, type PersonResponseDto } from '@immich/sdk';
|
||||
import { Button, Input, modalManager, toastManager } from '@immich/ui';
|
||||
@ -82,22 +82,28 @@
|
||||
await getPeople();
|
||||
});
|
||||
|
||||
const setDefaultFaceRectanglePosition = (faceRect: Rect) => {
|
||||
const metrics = getContentMetrics(htmlElement);
|
||||
const imageBoundingBox = {
|
||||
top: metrics.offsetY,
|
||||
left: metrics.offsetX,
|
||||
width: metrics.contentWidth,
|
||||
height: metrics.contentHeight,
|
||||
const imageContentMetrics = $derived.by(() => {
|
||||
const natural = getNaturalSize(htmlElement);
|
||||
const container = { width: containerWidth, height: containerHeight };
|
||||
const { width: contentWidth, height: contentHeight } = scaleToFit(natural, container);
|
||||
return {
|
||||
contentWidth,
|
||||
contentHeight,
|
||||
offsetX: (containerWidth - contentWidth) / 2,
|
||||
offsetY: (containerHeight - contentHeight) / 2,
|
||||
};
|
||||
});
|
||||
|
||||
faceRect.set({
|
||||
top: imageBoundingBox.top + 200,
|
||||
left: imageBoundingBox.left + 200,
|
||||
});
|
||||
const setDefaultFaceRectanglePosition = (faceRect: Rect) => {
|
||||
const { offsetX, offsetY } = imageContentMetrics;
|
||||
|
||||
faceRect.setCoords();
|
||||
positionFaceSelector();
|
||||
faceRect.set({
|
||||
top: offsetY + 200,
|
||||
left: offsetX + 200,
|
||||
});
|
||||
|
||||
faceRect.setCoords();
|
||||
positionFaceSelector();
|
||||
};
|
||||
|
||||
$effect(() => {
|
||||
@ -230,13 +236,13 @@
|
||||
}
|
||||
|
||||
const { left, top, width, height } = faceRect.getBoundingRect();
|
||||
const metrics = getContentMetrics(htmlElement);
|
||||
const { offsetX, offsetY, contentWidth, contentHeight } = imageContentMetrics;
|
||||
const natural = getNaturalSize(htmlElement);
|
||||
|
||||
const scaleX = natural.width / metrics.contentWidth;
|
||||
const scaleY = natural.height / metrics.contentHeight;
|
||||
const imageX = (left - metrics.offsetX) * scaleX;
|
||||
const imageY = (top - metrics.offsetY) * scaleY;
|
||||
const scaleX = natural.width / contentWidth;
|
||||
const scaleY = natural.height / contentHeight;
|
||||
const imageX = (left - offsetX) * scaleX;
|
||||
const imageY = (top - offsetY) * scaleY;
|
||||
|
||||
return {
|
||||
imageWidth: natural.width,
|
||||
|
||||
@ -1,66 +1,56 @@
|
||||
<script lang="ts">
|
||||
import { shortcuts } from '$lib/actions/shortcut';
|
||||
import { thumbhash } from '$lib/actions/thumbhash';
|
||||
import { zoomImageAction } from '$lib/actions/zoom-image';
|
||||
import AdaptiveImage from '$lib/components/AdaptiveImage.svelte';
|
||||
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte';
|
||||
import OcrBoundingBox from '$lib/components/asset-viewer/ocr-bounding-box.svelte';
|
||||
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
|
||||
import AssetViewerEvents from '$lib/components/AssetViewerEvents.svelte';
|
||||
import { assetViewerFadeDuration } from '$lib/constants';
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { castManager } from '$lib/managers/cast-manager.svelte';
|
||||
import { imageManager } from '$lib/managers/ImageManager.svelte';
|
||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||
import { ocrManager } from '$lib/stores/ocr.svelte';
|
||||
import { boundingBoxesArray, type Faces } from '$lib/stores/people.store';
|
||||
import { SlideshowLook, SlideshowState, slideshowLookCssMapping, slideshowStore } from '$lib/stores/slideshow.store';
|
||||
import { getAssetUrl, targetImageSize as getTargetImageSize, handlePromiseError } from '$lib/utils';
|
||||
import { SlideshowLook, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
|
||||
import { handlePromiseError } from '$lib/utils';
|
||||
import { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils';
|
||||
import { type ContentMetrics, getContentMetrics } from '$lib/utils/container-utils';
|
||||
import { getNaturalSize, scaleToFit, type ContentMetrics } from '$lib/utils/container-utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { getOcrBoundingBoxes } from '$lib/utils/ocr-utils';
|
||||
import { getBoundingBox } from '$lib/utils/people-utils';
|
||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import { AssetMediaSize, type SharedLinkResponseDto } from '@immich/sdk';
|
||||
import { LoadingSpinner, toastManager } from '@immich/ui';
|
||||
import { type SharedLinkResponseDto } from '@immich/sdk';
|
||||
import { toastManager } from '@immich/ui';
|
||||
import { onDestroy, untrack } from 'svelte';
|
||||
import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
import type { AssetCursor } from './asset-viewer.svelte';
|
||||
|
||||
interface Props {
|
||||
cursor: AssetCursor;
|
||||
element?: HTMLDivElement | undefined;
|
||||
haveFadeTransition?: boolean;
|
||||
sharedLink?: SharedLinkResponseDto | undefined;
|
||||
onPreviousAsset?: (() => void) | null;
|
||||
onNextAsset?: (() => void) | null;
|
||||
element?: HTMLDivElement;
|
||||
sharedLink?: SharedLinkResponseDto;
|
||||
onReady?: () => void;
|
||||
onError?: () => void;
|
||||
onSwipe?: (event: SwipeCustomEvent) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
cursor,
|
||||
element = $bindable(),
|
||||
haveFadeTransition = true,
|
||||
sharedLink = undefined,
|
||||
onPreviousAsset = null,
|
||||
onNextAsset = null,
|
||||
}: Props = $props();
|
||||
let { cursor, element = $bindable(), sharedLink, onReady, onError, onSwipe }: Props = $props();
|
||||
|
||||
const { slideshowState, slideshowLook } = slideshowStore;
|
||||
const asset = $derived(cursor.current);
|
||||
|
||||
let imageLoaded: boolean = $state(false);
|
||||
let originalImageLoaded: boolean = $state(false);
|
||||
let imageError: boolean = $state(false);
|
||||
let visibleImageReady: boolean = $state(false);
|
||||
|
||||
let loader = $state<HTMLImageElement>();
|
||||
|
||||
let previousAssetId: string | undefined;
|
||||
$effect.pre(() => {
|
||||
void asset.id;
|
||||
const id = asset.id;
|
||||
if (id === previousAssetId) {
|
||||
return;
|
||||
}
|
||||
previousAssetId = id;
|
||||
untrack(() => {
|
||||
assetViewerManager.resetZoomState();
|
||||
visibleImageReady = false;
|
||||
$boundingBoxesArray = [];
|
||||
});
|
||||
});
|
||||
@ -69,25 +59,30 @@
|
||||
$boundingBoxesArray = [];
|
||||
});
|
||||
|
||||
let containerWidth = $state(0);
|
||||
let containerHeight = $state(0);
|
||||
|
||||
const container = $derived({
|
||||
width: containerWidth,
|
||||
height: containerHeight,
|
||||
});
|
||||
|
||||
const overlayMetrics = $derived.by((): ContentMetrics => {
|
||||
if (!assetViewerManager.imgRef || !visibleImageReady) {
|
||||
return { contentWidth: 0, contentHeight: 0, offsetX: 0, offsetY: 0 };
|
||||
}
|
||||
|
||||
const { contentWidth, contentHeight, offsetX, offsetY } = getContentMetrics(assetViewerManager.imgRef);
|
||||
const { currentZoom, currentPositionX, currentPositionY } = assetViewerManager.zoomState;
|
||||
|
||||
const natural = getNaturalSize(assetViewerManager.imgRef);
|
||||
const scaled = scaleToFit(natural, container);
|
||||
return {
|
||||
contentWidth: contentWidth * currentZoom,
|
||||
contentHeight: contentHeight * currentZoom,
|
||||
offsetX: offsetX * currentZoom + currentPositionX,
|
||||
offsetY: offsetY * currentZoom + currentPositionY,
|
||||
contentWidth: scaled.width,
|
||||
contentHeight: scaled.height,
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
};
|
||||
});
|
||||
|
||||
let ocrBoxes = $derived(ocrManager.showOverlay ? getOcrBoundingBoxes(ocrManager.data, overlayMetrics) : []);
|
||||
|
||||
let isOcrActive = $derived(ocrManager.showOverlay);
|
||||
const ocrBoxes = $derived(ocrManager.showOverlay ? getOcrBoundingBoxes(ocrManager.data, overlayMetrics) : []);
|
||||
|
||||
const onCopy = async () => {
|
||||
if (!canCopyImageToClipboard() || !assetViewerManager.imgRef) {
|
||||
@ -103,7 +98,8 @@
|
||||
};
|
||||
|
||||
const onZoom = () => {
|
||||
assetViewerManager.zoom = assetViewerManager.zoom > 1 ? 1 : 2;
|
||||
const targetZoom = assetViewerManager.zoom > 1 ? 1 : 2;
|
||||
assetViewerManager.animatedZoom(targetZoom);
|
||||
};
|
||||
|
||||
const onPlaySlideshow = () => ($slideshowState = SlideshowState.PlaySlideshow);
|
||||
@ -124,29 +120,15 @@
|
||||
handlePromiseError(onCopy());
|
||||
};
|
||||
|
||||
const onSwipe = (event: SwipeCustomEvent) => {
|
||||
if (assetViewerManager.zoom > 1) {
|
||||
return;
|
||||
}
|
||||
let currentPreviewUrl = $state<string>();
|
||||
|
||||
if (ocrManager.showOverlay) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (onNextAsset && event.detail.direction === 'left') {
|
||||
onNextAsset();
|
||||
}
|
||||
|
||||
if (onPreviousAsset && event.detail.direction === 'right') {
|
||||
onPreviousAsset();
|
||||
}
|
||||
const onUrlChange = (url: string) => {
|
||||
currentPreviewUrl = url;
|
||||
};
|
||||
|
||||
const targetImageSize = $derived(getTargetImageSize(asset, originalImageLoaded || assetViewerManager.zoom > 1));
|
||||
|
||||
$effect(() => {
|
||||
if (imageLoaderUrl) {
|
||||
void cast(imageLoaderUrl);
|
||||
if (currentPreviewUrl) {
|
||||
void cast(currentPreviewUrl);
|
||||
}
|
||||
});
|
||||
|
||||
@ -164,38 +146,10 @@
|
||||
}
|
||||
};
|
||||
|
||||
const onload = () => {
|
||||
imageLoaded = true;
|
||||
originalImageLoaded = targetImageSize === AssetMediaSize.Fullsize || targetImageSize === 'original';
|
||||
};
|
||||
|
||||
const onerror = () => {
|
||||
imageError = imageLoaded = true;
|
||||
};
|
||||
|
||||
onDestroy(() => imageManager.cancel(asset, targetImageSize));
|
||||
|
||||
let imageLoaderUrl = $derived(
|
||||
getAssetUrl({ asset, sharedLink, forceOriginal: originalImageLoaded || assetViewerManager.zoom > 1 }),
|
||||
const blurredSlideshow = $derived(
|
||||
$slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground && !!asset.thumbhash,
|
||||
);
|
||||
|
||||
let containerWidth = $state(0);
|
||||
let containerHeight = $state(0);
|
||||
|
||||
let lastUrl: string | undefined;
|
||||
|
||||
$effect(() => {
|
||||
if (lastUrl && lastUrl !== imageLoaderUrl) {
|
||||
untrack(() => {
|
||||
imageLoaded = false;
|
||||
originalImageLoaded = false;
|
||||
imageError = false;
|
||||
visibleImageReady = false;
|
||||
});
|
||||
}
|
||||
lastUrl = imageLoaderUrl;
|
||||
});
|
||||
|
||||
const faceToNameMap = $derived.by(() => {
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||
const map = new Map<Faces, string>();
|
||||
@ -215,9 +169,16 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const natural = getNaturalSize(assetViewerManager.imgRef);
|
||||
const scaled = scaleToFit(natural, container);
|
||||
const { currentZoom, currentPositionX, currentPositionY } = assetViewerManager.zoomState;
|
||||
|
||||
const contentOffsetX = (container.width - scaled.width) / 2;
|
||||
const contentOffsetY = (container.height - scaled.height) / 2;
|
||||
|
||||
const containerRect = element.getBoundingClientRect();
|
||||
const mouseX = event.clientX - containerRect.left;
|
||||
const mouseY = event.clientY - containerRect.top;
|
||||
const mouseX = (event.clientX - containerRect.left - contentOffsetX * currentZoom - currentPositionX) / currentZoom;
|
||||
const mouseY = (event.clientY - containerRect.top - contentOffsetY * currentZoom - currentPositionY) / currentZoom;
|
||||
|
||||
const faceBoxes = getBoundingBox(faces, overlayMetrics);
|
||||
|
||||
@ -243,12 +204,7 @@
|
||||
{ shortcut: { key: 'c', meta: true }, onShortcut: onCopyShortcut, preventDefault: false },
|
||||
]}
|
||||
/>
|
||||
{#if imageError}
|
||||
<div id="broken-asset" class="h-full w-full">
|
||||
<BrokenAsset class="text-xl h-full w-full" />
|
||||
</div>
|
||||
{/if}
|
||||
<img bind:this={loader} style="display:none" src={imageLoaderUrl} alt="" aria-hidden="true" {onload} {onerror} />
|
||||
|
||||
<div
|
||||
bind:this={element}
|
||||
class="relative h-full w-full select-none"
|
||||
@ -258,36 +214,34 @@
|
||||
ondblclick={onZoom}
|
||||
onmousemove={handleImageMouseMove}
|
||||
onmouseleave={handleImageMouseLeave}
|
||||
use:zoomImageAction={{ disabled: isFaceEditMode.value || ocrManager.showOverlay }}
|
||||
{...useSwipe((event) => onSwipe?.(event))}
|
||||
>
|
||||
{#if !imageLoaded}
|
||||
<div id="spinner" class="flex h-full items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
{:else if !imageError}
|
||||
<div
|
||||
use:zoomImageAction={{ disabled: isOcrActive }}
|
||||
{...useSwipe(onSwipe)}
|
||||
class="h-full w-full"
|
||||
transition:fade={{ duration: haveFadeTransition ? assetViewerFadeDuration : 0 }}
|
||||
>
|
||||
{#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground}
|
||||
<img
|
||||
src={imageLoaderUrl}
|
||||
alt=""
|
||||
class="-z-1 absolute top-0 start-0 object-cover h-full w-full blur-lg"
|
||||
draggable="false"
|
||||
/>
|
||||
<AdaptiveImage
|
||||
{asset}
|
||||
{sharedLink}
|
||||
{container}
|
||||
objectFit={$slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.Cover ? 'cover' : 'contain'}
|
||||
{onUrlChange}
|
||||
onImageReady={() => {
|
||||
visibleImageReady = true;
|
||||
onReady?.();
|
||||
}}
|
||||
onError={() => {
|
||||
onError?.();
|
||||
onReady?.();
|
||||
}}
|
||||
bind:imgRef={assetViewerManager.imgRef}
|
||||
>
|
||||
{#snippet backdrop()}
|
||||
{#if blurredSlideshow}
|
||||
<canvas
|
||||
use:thumbhash={{ base64ThumbHash: asset.thumbhash! }}
|
||||
class="absolute top-0 left-0 inset-s-0 h-dvh w-dvw"
|
||||
></canvas>
|
||||
{/if}
|
||||
<img
|
||||
bind:this={assetViewerManager.imgRef}
|
||||
src={imageLoaderUrl}
|
||||
onload={() => (visibleImageReady = true)}
|
||||
alt={$getAltText(toTimelineAsset(asset))}
|
||||
class="h-full w-full {$slideshowState === SlideshowState.None
|
||||
? 'object-contain'
|
||||
: slideshowLookCssMapping[$slideshowLook]}"
|
||||
draggable="false"
|
||||
/>
|
||||
{/snippet}
|
||||
{#snippet overlays()}
|
||||
{#each getBoundingBox($boundingBoxesArray, overlayMetrics) as boundingbox, index (boundingbox.id)}
|
||||
<div
|
||||
class="absolute border-solid border-white border-3 rounded-lg"
|
||||
@ -307,23 +261,10 @@
|
||||
{#each ocrBoxes as ocrBox (ocrBox.id)}
|
||||
<OcrBoundingBox {ocrBox} />
|
||||
{/each}
|
||||
</div>
|
||||
{/snippet}
|
||||
</AdaptiveImage>
|
||||
|
||||
{#if isFaceEditMode.value}
|
||||
<FaceEditor htmlElement={assetViewerManager.imgRef} {containerWidth} {containerHeight} assetId={asset.id} />
|
||||
{/if}
|
||||
{#if isFaceEditMode.value && assetViewerManager.imgRef}
|
||||
<FaceEditor htmlElement={assetViewerManager.imgRef} {containerWidth} {containerHeight} assetId={asset.id} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes delayedVisibility {
|
||||
to {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
#broken-asset,
|
||||
#spinner {
|
||||
visibility: hidden;
|
||||
animation: 0s linear 0.4s forwards delayedVisibility;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -334,27 +334,27 @@
|
||||
|
||||
<!-- Favorite asset star -->
|
||||
{#if !authManager.isSharedLink && asset.isFavorite}
|
||||
<div class="z-2 absolute bottom-2 start-2">
|
||||
<div class="z-2 absolute bottom-2 inset-s-2">
|
||||
<Icon data-icon-favorite icon={mdiHeart} size="24" class="text-white" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !!assetOwner}
|
||||
<div class="z-2 absolute bottom-1 end-2 max-w-[50%]">
|
||||
<p class="text-xs font-medium text-white drop-shadow-lg max-w-[100%] truncate">
|
||||
<div class="z-2 absolute bottom-1 inset-e-2 max-w-[50%]">
|
||||
<p class="text-xs font-medium text-white drop-shadow-lg max-w-full truncate">
|
||||
{assetOwner.name}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !authManager.isSharedLink && showArchiveIcon && asset.visibility === AssetVisibility.Archive}
|
||||
<div class={['z-2 absolute start-2', asset.isFavorite ? 'bottom-10' : 'bottom-2']}>
|
||||
<div class={['z-2 absolute inset-s-2', asset.isFavorite ? 'bottom-10' : 'bottom-2']}>
|
||||
<Icon data-icon-archive icon={mdiArchiveArrowDownOutline} size="24" class="text-white" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if asset.isImage && asset.projectionType === ProjectionType.EQUIRECTANGULAR}
|
||||
<div class="z-2 absolute end-0 top-0 flex place-items-center gap-1 text-xs font-medium text-white">
|
||||
<div class="z-2 absolute inset-e-0 top-0 flex place-items-center gap-1 text-xs font-medium text-white">
|
||||
<span class="pe-2 pt-2">
|
||||
<Icon icon={mdiRotate360} size="24" />
|
||||
</span>
|
||||
@ -362,7 +362,7 @@
|
||||
{/if}
|
||||
|
||||
{#if asset.isImage && asset.duration && !asset.duration.includes('0:00:00.000')}
|
||||
<div class="z-2 absolute end-0 top-0 flex place-items-center gap-1 text-xs font-medium text-white">
|
||||
<div class="z-2 absolute inset-e-0 top-0 flex place-items-center gap-1 text-xs font-medium text-white">
|
||||
<span class="pe-2 pt-2">
|
||||
<Icon icon={mouseOver ? mdiMotionPauseOutline : mdiFileGifBox} size="24" />
|
||||
</span>
|
||||
@ -374,7 +374,7 @@
|
||||
<div
|
||||
class={[
|
||||
'z-2 absolute flex place-items-center gap-1 text-xs font-medium text-white',
|
||||
asset.isImage && !asset.livePhotoVideoId ? 'top-0 end-0' : 'top-7 end-1',
|
||||
asset.isImage && !asset.livePhotoVideoId ? 'top-0 inset-e-0' : 'top-7 inset-e-1',
|
||||
]}
|
||||
>
|
||||
<span class="pe-2 pt-2 flex place-items-center gap-1">
|
||||
|
||||
@ -89,10 +89,10 @@
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class="absolute end-0 top-0 flex place-items-center gap-1 text-xs font-medium text-white text-shadow-[1px_1px_6px_rgb(0_0_0)]"
|
||||
class="@container absolute inset-x-0 top-0 flex justify-end place-items-center gap-1 text-xs font-medium text-white text-shadow-[1px_1px_6px_rgb(0_0_0)]"
|
||||
>
|
||||
{#if showTime}
|
||||
<span class="pt-2">
|
||||
<span class="hidden @min-[100px]:inline pt-2">
|
||||
{#if remainingSeconds < 60}
|
||||
{Duration.fromObject({ seconds: remainingSeconds }).toFormat('m:ss')}
|
||||
{:else if remainingSeconds < 3600}
|
||||
@ -104,7 +104,11 @@
|
||||
{/if}
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<span class="pe-2 pt-2 drop-shadow-[1px_1px_6px_rgb(0_0_0)]" onmouseenter={onMouseEnter} onmouseleave={onMouseLeave}>
|
||||
<span
|
||||
class="pe-2 pt-2 @max-[99px]:scale-75 @max-[99px]:pe-1 @max-[99px]:pt-1 drop-shadow-[1px_1px_6px_rgb(0_0_0)]"
|
||||
onmouseenter={onMouseEnter}
|
||||
onmouseleave={onMouseLeave}
|
||||
>
|
||||
{#if enablePlayback}
|
||||
{#if loading}
|
||||
<LoadingSpinner size="large" />
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
import { getAssetMediaUrl } from '$lib/utils';
|
||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||
import { AssetMediaSize } from '@immich/sdk';
|
||||
import { LoadingSpinner } from '@immich/ui';
|
||||
import DelayedLoadingSpinner from '$lib/components/DelayedLoadingSpinner.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
@ -44,9 +44,7 @@
|
||||
{/if}
|
||||
|
||||
{#if !imageLoaded}
|
||||
<div id="spinner" class="flex h-full items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
<DelayedLoadingSpinner />
|
||||
{:else if imageLoaded}
|
||||
<div transition:fade={{ duration: assetViewerFadeDuration }} class="h-full w-full">
|
||||
<img
|
||||
@ -57,15 +55,3 @@
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
@keyframes delayedVisibility {
|
||||
to {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
#spinner {
|
||||
visibility: hidden;
|
||||
animation: 0s linear 0.4s forwards delayedVisibility;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -2,8 +2,6 @@
|
||||
import { clickOutside } from '$lib/actions/click-outside';
|
||||
import { languageManager } from '$lib/managers/language-manager.svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { quintOut } from 'svelte/easing';
|
||||
import { slide } from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
isVisible?: boolean;
|
||||
@ -14,6 +12,7 @@
|
||||
ariaLabel?: string | undefined;
|
||||
ariaLabelledBy?: string | undefined;
|
||||
ariaActiveDescendant?: string | undefined;
|
||||
menuScrollView?: HTMLDivElement | undefined;
|
||||
menuElement?: HTMLUListElement | undefined;
|
||||
onClose?: (() => void) | undefined;
|
||||
children?: Snippet;
|
||||
@ -28,6 +27,7 @@
|
||||
ariaLabel = undefined,
|
||||
ariaLabelledBy = undefined,
|
||||
ariaActiveDescendant = undefined,
|
||||
menuScrollView = $bindable(),
|
||||
menuElement = $bindable(),
|
||||
onClose = undefined,
|
||||
children,
|
||||
@ -37,33 +37,43 @@
|
||||
|
||||
const layoutDirection = $derived(languageManager.rtl ? swap(direction) : direction);
|
||||
const position = $derived.by(() => {
|
||||
if (!menuElement) {
|
||||
if (!menuScrollView || !menuElement) {
|
||||
return { left: 0, top: 0 };
|
||||
}
|
||||
|
||||
const rect = menuElement.getBoundingClientRect();
|
||||
const rect = menuScrollView.getBoundingClientRect();
|
||||
const directionWidth = layoutDirection === 'left' ? rect.width : 0;
|
||||
const menuHeight = Math.min(menuElement.clientHeight, height) || 0;
|
||||
|
||||
const left = Math.max(8, Math.min(window.innerWidth - rect.width, x - directionWidth));
|
||||
const top = Math.max(8, Math.min(window.innerHeight - menuHeight, y));
|
||||
const maxHeight = window.innerHeight - top - 8;
|
||||
const margin = 8;
|
||||
|
||||
return { left, top, maxHeight };
|
||||
const left = Math.max(margin, Math.min(windowInnerWidth - rect.width - margin, x - directionWidth));
|
||||
const top = Math.max(margin, Math.min(windowInnerHeight - menuElement.clientHeight, y));
|
||||
const maxHeight = windowInnerHeight - top - margin;
|
||||
|
||||
const needScrollBar = menuElement.clientHeight > maxHeight;
|
||||
|
||||
return { left, top, maxHeight, needScrollBar };
|
||||
});
|
||||
|
||||
// We need to bind clientHeight since the bounding box may return a height
|
||||
// of zero when starting the 'slide' animation.
|
||||
let height: number = $state(0);
|
||||
let windowInnerHeight: number = $state(0);
|
||||
let windowInnerWidth: number = $state(0);
|
||||
</script>
|
||||
|
||||
<svelte:window bind:innerWidth={windowInnerWidth} bind:innerHeight={windowInnerHeight} />
|
||||
|
||||
<div
|
||||
bind:clientHeight={height}
|
||||
class="fixed min-w-50 w-max max-w-75 overflow-hidden rounded-lg shadow-lg z-1 immich-scrollbar"
|
||||
bind:this={menuScrollView}
|
||||
class={[
|
||||
'duration-250 ease-in-out fixed min-w-50 w-max max-w-75 rounded-lg shadow-lg bg-slate-100 z-1 immich-scrollbar',
|
||||
position.needScrollBar ? 'overflow-auto' : 'overflow-hidden',
|
||||
]}
|
||||
style:left="{position.left}px"
|
||||
style:top="{position.top}px"
|
||||
transition:slide={{ duration: 250, easing: quintOut }}
|
||||
style:max-height={isVisible ? `${position.maxHeight}px` : '0px'}
|
||||
style:transition-property="max-height"
|
||||
style:scrollbar-color="rgba(85, 86, 87, 0.408) transparent"
|
||||
use:clickOutside={{ onOutclick: onClose }}
|
||||
tabindex="-1"
|
||||
>
|
||||
<ul
|
||||
{id}
|
||||
@ -71,8 +81,7 @@
|
||||
aria-label={ariaLabel}
|
||||
aria-labelledby={ariaLabelledBy}
|
||||
bind:this={menuElement}
|
||||
class="flex flex-col transition-all duration-250 ease-in-out outline-none overflow-auto immich-scrollbar"
|
||||
style:max-height={isVisible ? `${position.maxHeight}px` : '0px'}
|
||||
class="flex flex-col outline-none"
|
||||
role="menu"
|
||||
tabindex="-1"
|
||||
>
|
||||
|
||||
@ -2,11 +2,11 @@
|
||||
import Combobox, { type ComboBoxOption } from '$lib/components/shared-components/combobox.svelte';
|
||||
import { preferences } from '$lib/stores/user.store';
|
||||
import { getAllTags, type TagResponseDto } from '@immich/sdk';
|
||||
import { Checkbox, Icon, Label, Text } from '@immich/ui';
|
||||
import { mdiClose } from '@mdi/js';
|
||||
import { Checkbox, Label, Text } from '@immich/ui';
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import TagPill from '../tag-pill.svelte';
|
||||
|
||||
interface Props {
|
||||
selectedTags: SvelteSet<string> | null;
|
||||
@ -73,24 +73,7 @@
|
||||
{#each selectedTags ?? [] as tagId (tagId)}
|
||||
{@const tag = tagMap[tagId]}
|
||||
{#if tag}
|
||||
<div class="flex group transition-all">
|
||||
<span
|
||||
class="inline-block h-min whitespace-nowrap ps-3 pe-1 group-hover:ps-3 py-1 text-center align-baseline leading-none text-gray-100 dark:text-immich-dark-gray bg-primary roudned-s-full hover:bg-immich-primary/80 dark:hover:bg-immich-dark-primary/80 transition-all"
|
||||
>
|
||||
<p class="text-sm">
|
||||
{tag.value}
|
||||
</p>
|
||||
</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="text-gray-100 dark:text-immich-dark-gray bg-immich-primary/95 dark:bg-immich-dark-primary/95 rounded-e-full place-items-center place-content-center pe-2 ps-1 py-1 hover:bg-immich-primary/80 dark:hover:bg-immich-dark-primary/80 transition-all"
|
||||
title={$t('remove_tag')}
|
||||
onclick={() => handleRemove(tagId)}
|
||||
>
|
||||
<Icon icon={mdiClose} />
|
||||
</button>
|
||||
</div>
|
||||
<TagPill label={tag.value} onRemove={() => handleRemove(tagId)} />
|
||||
{/if}
|
||||
{/each}
|
||||
</section>
|
||||
|
||||
@ -3,17 +3,12 @@
|
||||
import { userInteraction } from '$lib/stores/user.svelte';
|
||||
import { getAssetMediaUrl } from '$lib/utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { getAllAlbums, type AlbumResponseDto } from '@immich/sdk';
|
||||
import { onMount } from 'svelte';
|
||||
import { getAllAlbums } from '@immich/sdk';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
let albums: AlbumResponseDto[] = $state([]);
|
||||
let albums = $state(userInteraction.recentAlbums);
|
||||
|
||||
onMount(async () => {
|
||||
if (userInteraction.recentAlbums) {
|
||||
albums = userInteraction.recentAlbums;
|
||||
return;
|
||||
}
|
||||
const refreshAlbums = async () => {
|
||||
try {
|
||||
const allAlbums = await getAllAlbums({});
|
||||
albums = allAlbums.sort((a, b) => (a.updatedAt > b.updatedAt ? -1 : 1)).slice(0, 3);
|
||||
@ -21,6 +16,12 @@
|
||||
} catch (error) {
|
||||
handleError(error, $t('failed_to_load_assets'));
|
||||
}
|
||||
};
|
||||
|
||||
$effect(() => {
|
||||
if (!userInteraction.recentAlbums) {
|
||||
void refreshAlbums();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
31
web/src/lib/components/shared-components/tag-pill.svelte
Normal file
31
web/src/lib/components/shared-components/tag-pill.svelte
Normal file
@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
import { Icon } from '@immich/ui';
|
||||
import { mdiClose } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
type Props = {
|
||||
label: string;
|
||||
onRemove: () => void;
|
||||
};
|
||||
|
||||
let { label, onRemove }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex group transition-all">
|
||||
<span
|
||||
class="inline-block h-min whitespace-nowrap ps-3 pe-1 group-hover:ps-3 py-1 text-center align-baseline leading-none text-gray-100 dark:text-immich-dark-gray bg-primary rounded-s-full hover:bg-immich-primary/80 dark:hover:bg-immich-dark-primary/80 transition-all"
|
||||
>
|
||||
<p class="text-sm">
|
||||
{label}
|
||||
</p>
|
||||
</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="text-gray-100 dark:text-immich-dark-gray bg-immich-primary/95 dark:bg-immich-dark-primary/95 rounded-e-full place-items-center place-content-center pe-2 ps-1 py-1 hover:bg-immich-primary/80 dark:hover:bg-immich-dark-primary/80 transition-all"
|
||||
title={$t('remove_tag')}
|
||||
onclick={onRemove}
|
||||
>
|
||||
<Icon icon={mdiClose} />
|
||||
</button>
|
||||
</div>
|
||||
@ -1,99 +0,0 @@
|
||||
import { imageManager } from '$lib/managers/ImageManager.svelte';
|
||||
import { getAssetMediaUrl } from '$lib/utils';
|
||||
import { cancelImageUrl } from '$lib/utils/sw-messaging';
|
||||
import { AssetMediaSize } from '@immich/sdk';
|
||||
import { assetFactory } from '@test-data/factories/asset-factory';
|
||||
|
||||
vi.mock('$lib/utils/sw-messaging', () => ({
|
||||
cancelImageUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('$lib/utils', () => ({
|
||||
getAssetMediaUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('ImageManager', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('preload', () => {
|
||||
it('creates an Image with the correct URL', () => {
|
||||
vi.mocked(getAssetMediaUrl).mockReturnValue('/api/assets/123/media');
|
||||
const asset = assetFactory.build();
|
||||
|
||||
imageManager.preload(asset);
|
||||
|
||||
expect(getAssetMediaUrl).toHaveBeenCalledWith({
|
||||
id: asset.id,
|
||||
size: AssetMediaSize.Preview,
|
||||
cacheKey: asset.thumbhash,
|
||||
});
|
||||
});
|
||||
|
||||
it('does nothing for undefined asset', () => {
|
||||
imageManager.preload(undefined);
|
||||
expect(getAssetMediaUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing when getAssetMediaUrl returns falsy', () => {
|
||||
vi.mocked(getAssetMediaUrl).mockReturnValue('');
|
||||
const asset = assetFactory.build();
|
||||
|
||||
imageManager.preload(asset);
|
||||
|
||||
expect(getAssetMediaUrl).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses the specified size', () => {
|
||||
vi.mocked(getAssetMediaUrl).mockReturnValue('/api/assets/123/media');
|
||||
const asset = assetFactory.build();
|
||||
|
||||
imageManager.preload(asset, AssetMediaSize.Thumbnail);
|
||||
|
||||
expect(getAssetMediaUrl).toHaveBeenCalledWith({
|
||||
id: asset.id,
|
||||
size: AssetMediaSize.Thumbnail,
|
||||
cacheKey: asset.thumbhash,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('cancel', () => {
|
||||
it('calls cancelImageUrl with the correct URL', () => {
|
||||
vi.mocked(getAssetMediaUrl).mockReturnValue('/api/assets/123/media');
|
||||
const asset = assetFactory.build();
|
||||
|
||||
imageManager.cancel(asset, AssetMediaSize.Preview);
|
||||
|
||||
expect(cancelImageUrl).toHaveBeenCalledWith('/api/assets/123/media');
|
||||
});
|
||||
|
||||
it('does nothing for undefined asset', () => {
|
||||
imageManager.cancel(undefined);
|
||||
expect(getAssetMediaUrl).not.toHaveBeenCalled();
|
||||
expect(cancelImageUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('cancels all sizes when size is "all"', () => {
|
||||
vi.mocked(getAssetMediaUrl).mockImplementation(({ size }) => `/api/assets/123/${size}`);
|
||||
const asset = assetFactory.build();
|
||||
|
||||
imageManager.cancel(asset, 'all');
|
||||
|
||||
expect(getAssetMediaUrl).toHaveBeenCalledTimes(Object.values(AssetMediaSize).length);
|
||||
for (const size of Object.values(AssetMediaSize)) {
|
||||
expect(cancelImageUrl).toHaveBeenCalledWith(`/api/assets/123/${size}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not call cancelImageUrl when URL is falsy', () => {
|
||||
vi.mocked(getAssetMediaUrl).mockReturnValue('');
|
||||
const asset = assetFactory.build();
|
||||
|
||||
imageManager.cancel(asset, AssetMediaSize.Preview);
|
||||
|
||||
expect(cancelImageUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,37 +0,0 @@
|
||||
import { getAssetMediaUrl } from '$lib/utils';
|
||||
import { cancelImageUrl } from '$lib/utils/sw-messaging';
|
||||
import { AssetMediaSize, type AssetResponseDto } from '@immich/sdk';
|
||||
|
||||
type AllAssetMediaSize = AssetMediaSize | 'all';
|
||||
|
||||
class ImageManager {
|
||||
preload(asset: AssetResponseDto | undefined, size: AssetMediaSize = AssetMediaSize.Preview) {
|
||||
if (!asset) {
|
||||
return;
|
||||
}
|
||||
|
||||
const url = getAssetMediaUrl({ id: asset.id, size, cacheKey: asset.thumbhash });
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
|
||||
const img = new Image();
|
||||
img.src = url;
|
||||
}
|
||||
|
||||
cancel(asset: AssetResponseDto | undefined, size: AllAssetMediaSize = AssetMediaSize.Preview) {
|
||||
if (!asset) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sizes = size === 'all' ? Object.values(AssetMediaSize) : [size];
|
||||
for (const size of sizes) {
|
||||
const url = getAssetMediaUrl({ id: asset.id, size, cacheKey: asset.thumbhash });
|
||||
if (url) {
|
||||
cancelImageUrl(url);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const imageManager = new ImageManager();
|
||||
@ -1,7 +1,9 @@
|
||||
import type { ImageLoaderStatus } from '$lib/utils/adaptive-image-loader.svelte';
|
||||
import { canCopyImageToClipboard } from '$lib/utils/asset-utils';
|
||||
import { BaseEventManager } from '$lib/utils/base-event-manager.svelte';
|
||||
import { PersistedLocalStorage } from '$lib/utils/persisted';
|
||||
import type { ZoomImageWheelState } from '@zoom-image/core';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
|
||||
const isShowDetailPanel = new PersistedLocalStorage<boolean>('asset-viewer-state', false);
|
||||
|
||||
@ -21,12 +23,27 @@ export type Events = {
|
||||
|
||||
export class AssetViewerManager extends BaseEventManager<Events> {
|
||||
#zoomState = $state(createDefaultZoomState());
|
||||
#animationFrameId: number | null = null;
|
||||
|
||||
imgRef = $state<HTMLImageElement | undefined>();
|
||||
imageLoaderStatus = $state<ImageLoaderStatus | undefined>();
|
||||
#isImageLoading = $derived.by(() => {
|
||||
const quality = this.imageLoaderStatus?.quality;
|
||||
if (!quality) {
|
||||
return false;
|
||||
}
|
||||
const previewOrOriginalReady = quality.preview === 'success' || quality.original === 'success';
|
||||
const loadingOriginal = this.zoom > 1 && quality.original !== 'success';
|
||||
return !previewOrOriginalReady || loadingOriginal;
|
||||
});
|
||||
isShowActivityPanel = $state(false);
|
||||
isPlayingMotionPhoto = $state(false);
|
||||
isShowEditor = $state(false);
|
||||
|
||||
get isImageLoading() {
|
||||
return this.#isImageLoading;
|
||||
}
|
||||
|
||||
get isShowDetailPanel() {
|
||||
return isShowDetailPanel.current;
|
||||
}
|
||||
@ -45,6 +62,7 @@ export class AssetViewerManager extends BaseEventManager<Events> {
|
||||
}
|
||||
|
||||
set zoom(zoom: number) {
|
||||
this.cancelZoomAnimation();
|
||||
this.zoomState = { ...this.zoomState, currentZoom: zoom };
|
||||
}
|
||||
|
||||
@ -69,7 +87,35 @@ export class AssetViewerManager extends BaseEventManager<Events> {
|
||||
this.#zoomState = state;
|
||||
}
|
||||
|
||||
cancelZoomAnimation() {
|
||||
if (this.#animationFrameId !== null) {
|
||||
cancelAnimationFrame(this.#animationFrameId);
|
||||
this.#animationFrameId = null;
|
||||
}
|
||||
}
|
||||
|
||||
animatedZoom(targetZoom: number, duration = 300) {
|
||||
this.cancelZoomAnimation();
|
||||
|
||||
const startZoom = this.#zoomState.currentZoom;
|
||||
const startTime = performance.now();
|
||||
|
||||
const frame = (currentTime: number) => {
|
||||
const elapsed = currentTime - startTime;
|
||||
const linearProgress = Math.min(elapsed / duration, 1);
|
||||
const easedProgress = cubicOut(linearProgress);
|
||||
const interpolatedZoom = startZoom + (targetZoom - startZoom) * easedProgress;
|
||||
|
||||
this.zoomState = { ...this.#zoomState, currentZoom: interpolatedZoom };
|
||||
|
||||
this.#animationFrameId = linearProgress < 1 ? requestAnimationFrame(frame) : null;
|
||||
};
|
||||
|
||||
this.#animationFrameId = requestAnimationFrame(frame);
|
||||
}
|
||||
|
||||
resetZoomState() {
|
||||
this.cancelZoomAnimation();
|
||||
this.zoomState = createDefaultZoomState();
|
||||
}
|
||||
|
||||
|
||||
@ -677,7 +677,7 @@ class TransformManager implements EditToolManager {
|
||||
const { x, y, width, height } = this.region;
|
||||
const sensitivity = 10;
|
||||
const cornerSensitivity = 15;
|
||||
const { width: imgWidth, height: imgHeight } = this.cropImageSize;
|
||||
const { width: imgWidth, height: imgHeight } = this.previewImageSize;
|
||||
|
||||
const outOfBound = mouseX > imgWidth || mouseY > imgHeight || mouseX < 0 || mouseY < 0;
|
||||
if (outOfBound) {
|
||||
|
||||
@ -39,6 +39,7 @@ export type Events = {
|
||||
AssetsTag: [string[]];
|
||||
|
||||
AlbumAddAssets: [{ assetIds: string[]; albumIds: string[] }];
|
||||
AlbumCreate: [AlbumResponseDto];
|
||||
AlbumUpdate: [AlbumResponseDto];
|
||||
AlbumDelete: [AlbumResponseDto];
|
||||
AlbumShare: [];
|
||||
|
||||
@ -286,6 +286,17 @@ describe('TimelineManager', () => {
|
||||
expect(timelineManager.assetCount).toEqual(1);
|
||||
});
|
||||
|
||||
it('ignores new assets that do not match the tag filter', async () => {
|
||||
await timelineManager.updateOptions({ tagId: 'tag-1' });
|
||||
|
||||
const matching = deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build({ tags: ['tag-1'] }));
|
||||
const unrelated = deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build({ tags: ['tag-2'] }));
|
||||
|
||||
timelineManager.upsertAssets([matching, unrelated]);
|
||||
|
||||
expect(await getAssets(timelineManager)).toEqual([matching]);
|
||||
});
|
||||
|
||||
// disabled due to the wasm Justified Layout import
|
||||
it('ignores trashed assets when isTrashed is true', async () => {
|
||||
const asset = deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build({ isTrashed: false }));
|
||||
|
||||
@ -596,6 +596,7 @@ export class TimelineManager extends VirtualScrollManager {
|
||||
isMismatched(this.#options.visibility, asset.visibility) ||
|
||||
isMismatched(this.#options.isFavorite, asset.isFavorite) ||
|
||||
isMismatched(this.#options.isTrashed, asset.isTrashed) ||
|
||||
(this.#options.tagId && asset.tags && !asset.tags.includes(this.#options.tagId)) ||
|
||||
(this.#options.assetFilter !== undefined && !this.#options.assetFilter.has(asset.id))
|
||||
);
|
||||
}
|
||||
|
||||
@ -18,6 +18,7 @@ export type Direction = 'earlier' | 'later';
|
||||
export type TimelineAsset = {
|
||||
id: string;
|
||||
ownerId: string;
|
||||
tags?: string[];
|
||||
ratio: number;
|
||||
thumbhash: string | null;
|
||||
localDateTime: TimelineDateTime;
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
AlbumModalRowType,
|
||||
isSelectableRowType,
|
||||
} from '$lib/components/shared-components/album-selection/album-selection-utils';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { albumViewSettings } from '$lib/stores/preferences.store';
|
||||
import { createAlbum, getAllAlbums, type AlbumResponseDto } from '@immich/sdk';
|
||||
import { Button, Icon, Modal, ModalBody, ModalFooter, Text } from '@immich/ui';
|
||||
@ -43,6 +44,7 @@
|
||||
|
||||
const onNewAlbum = async (name: string) => {
|
||||
const album = await createAlbum({ createAlbumDto: { albumName: name } });
|
||||
eventManager.emit('AlbumCreate', album);
|
||||
onClose([album]);
|
||||
};
|
||||
|
||||
|
||||
@ -2,12 +2,13 @@
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { tagAssets } from '$lib/utils/asset-utils';
|
||||
import { getAllTags, upsertTags, type TagResponseDto } from '@immich/sdk';
|
||||
import { FormModal, Icon } from '@immich/ui';
|
||||
import { mdiClose, mdiTag } from '@mdi/js';
|
||||
import { FormModal } from '@immich/ui';
|
||||
import { mdiTag } from '@mdi/js';
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import Combobox, { type ComboBoxOption } from '../components/shared-components/combobox.svelte';
|
||||
import TagPill from '../components/shared-components/tag-pill.svelte';
|
||||
|
||||
interface Props {
|
||||
onClose: (updated?: boolean) => void;
|
||||
@ -81,24 +82,7 @@
|
||||
{#each selectedIds as tagId (tagId)}
|
||||
{@const tag = tagMap[tagId]}
|
||||
{#if tag}
|
||||
<div class="flex group transition-all">
|
||||
<span
|
||||
class="inline-block h-min whitespace-nowrap ps-3 pe-1 group-hover:ps-3 py-1 text-center align-baseline leading-none text-gray-100 dark:text-immich-dark-gray bg-primary rounded-s-full hover:bg-immich-primary/80 dark:hover:bg-immich-dark-primary/80 transition-all"
|
||||
>
|
||||
<p class="text-sm">
|
||||
{tag.value}
|
||||
</p>
|
||||
</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="text-gray-100 dark:text-immich-dark-gray bg-immich-primary/95 dark:bg-immich-dark-primary/95 rounded-e-full place-items-center place-content-center pe-2 ps-1 py-1 hover:bg-immich-primary/80 dark:hover:bg-immich-dark-primary/80 transition-all"
|
||||
title={$t('remove_tag')}
|
||||
onclick={() => handleRemove(tagId)}
|
||||
>
|
||||
<Icon icon={mdiClose} />
|
||||
</button>
|
||||
</div>
|
||||
<TagPill label={tag.value} onRemove={() => handleRemove(tagId)} />
|
||||
{/if}
|
||||
{/each}
|
||||
</section>
|
||||
|
||||
@ -40,6 +40,7 @@
|
||||
{ key: ['s'], action: $t('stack_selected_photos') },
|
||||
{ key: ['l'], action: $t('add_to_album') },
|
||||
{ key: ['t'], action: $t('tag_assets') },
|
||||
{ key: ['p'], action: $t('tag_people') },
|
||||
{ key: ['⇧', 'a'], action: $t('archive_or_unarchive_photo') },
|
||||
{ key: ['⇧', 'd'], action: $t('download') },
|
||||
{ key: ['Space'], action: $t('play_or_pause_video') },
|
||||
|
||||
@ -5,6 +5,7 @@ import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import AssetAddToAlbumModal from '$lib/modals/AssetAddToAlbumModal.svelte';
|
||||
import AssetTagModal from '$lib/modals/AssetTagModal.svelte';
|
||||
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
|
||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||
import { user as authUser, preferences } from '$lib/stores/user.store';
|
||||
import type { AssetControlContext } from '$lib/types';
|
||||
import { getSharedLink, sleep } from '$lib/utils';
|
||||
@ -31,6 +32,7 @@ import {
|
||||
mdiDatabaseRefreshOutline,
|
||||
mdiDownload,
|
||||
mdiDownloadBox,
|
||||
mdiFaceRecognition,
|
||||
mdiHeadSyncOutline,
|
||||
mdiHeart,
|
||||
mdiHeartOutline,
|
||||
@ -223,6 +225,17 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) =
|
||||
shortcuts: { key: 't' },
|
||||
};
|
||||
|
||||
const TagPeople: ActionItem = {
|
||||
title: $t('tag_people'),
|
||||
icon: mdiFaceRecognition,
|
||||
type: $t('assets'),
|
||||
$if: () => isOwner && asset.type === AssetTypeEnum.Image && !asset.isTrashed,
|
||||
onAction: () => {
|
||||
isFaceEditMode.value = !isFaceEditMode.value;
|
||||
},
|
||||
shortcuts: { key: 'p' },
|
||||
};
|
||||
|
||||
const Edit: ActionItem = {
|
||||
title: $t('editor'),
|
||||
icon: mdiTune,
|
||||
@ -279,6 +292,7 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) =
|
||||
ZoomOut,
|
||||
Copy,
|
||||
Tag,
|
||||
TagPeople,
|
||||
Edit,
|
||||
RefreshFacesJob,
|
||||
RefreshMetadataJob,
|
||||
|
||||
@ -22,10 +22,17 @@ const defaultUserInteraction: UserInteractions = {
|
||||
|
||||
export const userInteraction = $state<UserInteractions>(defaultUserInteraction);
|
||||
|
||||
const resetRecentAlbums = () => {
|
||||
userInteraction.recentAlbums = undefined;
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
Object.assign(userInteraction, defaultUserInteraction);
|
||||
};
|
||||
|
||||
eventManager.on({
|
||||
AlbumCreate: () => resetRecentAlbums(),
|
||||
AlbumUpdate: () => resetRecentAlbums(),
|
||||
AlbumDelete: () => resetRecentAlbums(),
|
||||
AuthLogout: () => reset(),
|
||||
});
|
||||
|
||||
@ -186,6 +186,14 @@ export const getAssetUrl = ({
|
||||
return getAssetMediaUrl({ id, size, cacheKey });
|
||||
};
|
||||
|
||||
export function getAssetUrls(asset: AssetResponseDto, sharedLink?: SharedLinkResponseDto) {
|
||||
return {
|
||||
thumbnail: getAssetMediaUrl({ id: asset.id, cacheKey: asset.thumbhash, size: AssetMediaSize.Thumbnail }),
|
||||
preview: getAssetUrl({ asset, sharedLink })!,
|
||||
original: getAssetUrl({ asset, sharedLink, forceOriginal: true })!,
|
||||
};
|
||||
}
|
||||
|
||||
const forceUseOriginal = (asset: AssetResponseDto) => {
|
||||
return asset.type === AssetTypeEnum.Image && asset.duration && !asset.duration.includes('0:00:00.000');
|
||||
};
|
||||
|
||||
304
web/src/lib/utils/adaptive-image-loader.spec.ts
Normal file
304
web/src/lib/utils/adaptive-image-loader.spec.ts
Normal file
@ -0,0 +1,304 @@
|
||||
import { AdaptiveImageLoader, type QualityList } from '$lib/utils/adaptive-image-loader.svelte';
|
||||
import { cancelImageUrl } from '$lib/utils/sw-messaging';
|
||||
|
||||
vi.mock('$lib/utils/sw-messaging', () => ({
|
||||
cancelImageUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
function createQualityList(overrides?: {
|
||||
onAfterLoad?: Record<string, (loader: AdaptiveImageLoader) => void>;
|
||||
onAfterError?: Record<string, (loader: AdaptiveImageLoader) => void>;
|
||||
}): QualityList {
|
||||
return [
|
||||
{
|
||||
quality: 'thumbnail',
|
||||
url: '/thumbnail.jpg',
|
||||
onAfterLoad: overrides?.onAfterLoad?.thumbnail,
|
||||
onAfterError: overrides?.onAfterError?.thumbnail,
|
||||
},
|
||||
{
|
||||
quality: 'preview',
|
||||
url: '/preview.jpg',
|
||||
onAfterLoad: overrides?.onAfterLoad?.preview,
|
||||
onAfterError: overrides?.onAfterError?.preview,
|
||||
},
|
||||
{
|
||||
quality: 'original',
|
||||
url: '/original.jpg',
|
||||
onAfterLoad: overrides?.onAfterLoad?.original,
|
||||
onAfterError: overrides?.onAfterError?.original,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
describe('AdaptiveImageLoader', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('initializes with thumbnail URL set', () => {
|
||||
const loader = new AdaptiveImageLoader(createQualityList());
|
||||
expect(loader.status.urls.thumbnail).toBe('/thumbnail.jpg');
|
||||
expect(loader.status.urls.preview).toBeUndefined();
|
||||
expect(loader.status.urls.original).toBeUndefined();
|
||||
});
|
||||
|
||||
it('initializes all qualities as unloaded', () => {
|
||||
const loader = new AdaptiveImageLoader(createQualityList());
|
||||
expect(loader.status.quality.thumbnail).toBe('unloaded');
|
||||
expect(loader.status.quality.preview).toBe('unloaded');
|
||||
expect(loader.status.quality.original).toBe('unloaded');
|
||||
});
|
||||
});
|
||||
|
||||
describe('onStart', () => {
|
||||
it('sets started to true', () => {
|
||||
const loader = new AdaptiveImageLoader(createQualityList());
|
||||
expect(loader.status.started).toBe(false);
|
||||
loader.onStart('thumbnail');
|
||||
expect(loader.status.started).toBe(true);
|
||||
});
|
||||
|
||||
it('is a no-op after destroy', () => {
|
||||
const loader = new AdaptiveImageLoader(createQualityList());
|
||||
loader.destroy();
|
||||
loader.onStart('thumbnail');
|
||||
expect(loader.status.started).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onLoad', () => {
|
||||
it('sets quality to success and calls callbacks', () => {
|
||||
const onUrlChange = vi.fn();
|
||||
const onImageReady = vi.fn();
|
||||
const loader = new AdaptiveImageLoader(createQualityList(), { onUrlChange, onImageReady });
|
||||
|
||||
loader.onLoad('thumbnail');
|
||||
|
||||
expect(loader.status.quality.thumbnail).toBe('success');
|
||||
expect(onUrlChange).toHaveBeenCalledWith('/thumbnail.jpg');
|
||||
expect(onImageReady).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('calls onAfterLoad callback', () => {
|
||||
const onAfterLoad = vi.fn();
|
||||
const qualityList = createQualityList({ onAfterLoad: { thumbnail: onAfterLoad } });
|
||||
const loader = new AdaptiveImageLoader(qualityList);
|
||||
|
||||
loader.onLoad('thumbnail');
|
||||
|
||||
expect(onAfterLoad).toHaveBeenCalledWith(loader);
|
||||
});
|
||||
|
||||
it('ignores load if URL is not set', () => {
|
||||
const onImageReady = vi.fn();
|
||||
const loader = new AdaptiveImageLoader(createQualityList(), { onImageReady });
|
||||
|
||||
loader.onLoad('preview');
|
||||
|
||||
expect(loader.status.quality.preview).toBe('unloaded');
|
||||
expect(onImageReady).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores load if a higher quality is already loaded', () => {
|
||||
const onUrlChange = vi.fn();
|
||||
const loader = new AdaptiveImageLoader(createQualityList(), { onUrlChange });
|
||||
|
||||
loader.onLoad('thumbnail');
|
||||
loader.trigger('preview');
|
||||
loader.onLoad('preview');
|
||||
|
||||
onUrlChange.mockClear();
|
||||
loader.onLoad('thumbnail');
|
||||
|
||||
expect(onUrlChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('is a no-op after destroy', () => {
|
||||
const onImageReady = vi.fn();
|
||||
const loader = new AdaptiveImageLoader(createQualityList(), { onImageReady });
|
||||
|
||||
loader.destroy();
|
||||
loader.onLoad('thumbnail');
|
||||
|
||||
expect(onImageReady).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('onError', () => {
|
||||
it('sets quality to error and clears URL', () => {
|
||||
const onError = vi.fn();
|
||||
const loader = new AdaptiveImageLoader(createQualityList(), { onError });
|
||||
|
||||
loader.onError('thumbnail');
|
||||
|
||||
expect(loader.status.quality.thumbnail).toBe('error');
|
||||
expect(loader.status.urls.thumbnail).toBeUndefined();
|
||||
expect(loader.status.hasError).toBe(true);
|
||||
expect(onError).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('calls onAfterError callback', () => {
|
||||
const onAfterError = vi.fn();
|
||||
const qualityList = createQualityList({ onAfterError: { thumbnail: onAfterError } });
|
||||
const loader = new AdaptiveImageLoader(qualityList);
|
||||
|
||||
loader.onError('thumbnail');
|
||||
|
||||
expect(onAfterError).toHaveBeenCalledWith(loader);
|
||||
});
|
||||
|
||||
it('is a no-op after destroy', () => {
|
||||
const onError = vi.fn();
|
||||
const loader = new AdaptiveImageLoader(createQualityList(), { onError });
|
||||
|
||||
loader.destroy();
|
||||
loader.onError('thumbnail');
|
||||
|
||||
expect(onError).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('trigger', () => {
|
||||
it('sets the URL for the quality', () => {
|
||||
const loader = new AdaptiveImageLoader(createQualityList());
|
||||
|
||||
loader.trigger('preview');
|
||||
|
||||
expect(loader.status.urls.preview).toBe('/preview.jpg');
|
||||
});
|
||||
|
||||
it('returns true if URL is already set', () => {
|
||||
const loader = new AdaptiveImageLoader(createQualityList());
|
||||
|
||||
expect(loader.trigger('thumbnail')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when triggering a new quality', () => {
|
||||
const loader = new AdaptiveImageLoader(createQualityList());
|
||||
|
||||
expect(loader.trigger('preview')).toBe(false);
|
||||
});
|
||||
|
||||
it('clears hasError when triggering', () => {
|
||||
const loader = new AdaptiveImageLoader(createQualityList());
|
||||
|
||||
loader.onError('thumbnail');
|
||||
expect(loader.status.hasError).toBe(true);
|
||||
|
||||
loader.trigger('preview');
|
||||
expect(loader.status.hasError).toBe(false);
|
||||
});
|
||||
|
||||
it('calls imageLoader when provided', () => {
|
||||
const imageLoader = vi.fn(() => vi.fn());
|
||||
const loader = new AdaptiveImageLoader(createQualityList(), undefined, imageLoader);
|
||||
|
||||
loader.trigger('preview');
|
||||
|
||||
expect(imageLoader).toHaveBeenCalledWith(
|
||||
'/preview.jpg',
|
||||
expect.any(Function),
|
||||
expect.any(Function),
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns false after destroy', () => {
|
||||
const loader = new AdaptiveImageLoader(createQualityList());
|
||||
|
||||
loader.destroy();
|
||||
|
||||
expect(loader.trigger('preview')).toBe(false);
|
||||
});
|
||||
|
||||
it('calls onAfterError if URL is empty', () => {
|
||||
const onAfterError = vi.fn();
|
||||
const qualityList = createQualityList({ onAfterError: { preview: onAfterError } });
|
||||
(qualityList[1] as { url: string }).url = '';
|
||||
const loader = new AdaptiveImageLoader(qualityList);
|
||||
|
||||
expect(loader.trigger('preview')).toBe(false);
|
||||
expect(onAfterError).toHaveBeenCalledWith(loader);
|
||||
});
|
||||
});
|
||||
|
||||
describe('start', () => {
|
||||
it('throws if no imageLoader is provided', () => {
|
||||
const loader = new AdaptiveImageLoader(createQualityList());
|
||||
expect(() => loader.start()).toThrow('Start requires imageLoader to be specified');
|
||||
});
|
||||
|
||||
it('calls imageLoader with thumbnail URL', () => {
|
||||
const imageLoader = vi.fn(() => vi.fn());
|
||||
const loader = new AdaptiveImageLoader(createQualityList(), undefined, imageLoader);
|
||||
|
||||
loader.start();
|
||||
|
||||
expect(imageLoader).toHaveBeenCalledWith(
|
||||
'/thumbnail.jpg',
|
||||
expect.any(Function),
|
||||
expect.any(Function),
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('destroy', () => {
|
||||
it('cancels all image URLs when no imageLoader', () => {
|
||||
const loader = new AdaptiveImageLoader(createQualityList());
|
||||
|
||||
loader.destroy();
|
||||
|
||||
expect(cancelImageUrl).toHaveBeenCalledWith('/thumbnail.jpg');
|
||||
expect(cancelImageUrl).toHaveBeenCalledWith('/preview.jpg');
|
||||
expect(cancelImageUrl).toHaveBeenCalledWith('/original.jpg');
|
||||
});
|
||||
|
||||
it('calls destroy functions when imageLoader is provided', () => {
|
||||
const destroyFn = vi.fn();
|
||||
const imageLoader = vi.fn(() => destroyFn);
|
||||
const loader = new AdaptiveImageLoader(createQualityList(), undefined, imageLoader);
|
||||
|
||||
loader.start();
|
||||
loader.destroy();
|
||||
|
||||
expect(destroyFn).toHaveBeenCalledOnce();
|
||||
expect(cancelImageUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('progressive loading flow', () => {
|
||||
it('thumbnail load triggers preview via onAfterLoad', () => {
|
||||
const triggerSpy = vi.fn();
|
||||
const qualityList = createQualityList({
|
||||
onAfterLoad: {
|
||||
thumbnail: (loader) => {
|
||||
triggerSpy();
|
||||
loader.trigger('preview');
|
||||
},
|
||||
},
|
||||
});
|
||||
const loader = new AdaptiveImageLoader(qualityList);
|
||||
|
||||
loader.onLoad('thumbnail');
|
||||
|
||||
expect(triggerSpy).toHaveBeenCalledOnce();
|
||||
expect(loader.status.urls.preview).toBe('/preview.jpg');
|
||||
});
|
||||
|
||||
it('thumbnail error triggers preview via onAfterError', () => {
|
||||
const qualityList = createQualityList({
|
||||
onAfterError: {
|
||||
thumbnail: (loader) => loader.trigger('preview'),
|
||||
},
|
||||
});
|
||||
const loader = new AdaptiveImageLoader(qualityList);
|
||||
|
||||
loader.onError('thumbnail');
|
||||
|
||||
expect(loader.status.urls.preview).toBe('/preview.jpg');
|
||||
});
|
||||
});
|
||||
});
|
||||
164
web/src/lib/utils/adaptive-image-loader.svelte.ts
Normal file
164
web/src/lib/utils/adaptive-image-loader.svelte.ts
Normal file
@ -0,0 +1,164 @@
|
||||
import type { LoadImageFunction } from '$lib/actions/image-loader.svelte';
|
||||
import { cancelImageUrl } from '$lib/utils/sw-messaging';
|
||||
|
||||
export type ImageQuality = 'thumbnail' | 'preview' | 'original';
|
||||
|
||||
export type ImageStatus = 'unloaded' | 'success' | 'error';
|
||||
|
||||
export type ImageLoaderStatus = {
|
||||
urls: Record<ImageQuality, string | undefined>;
|
||||
quality: Record<ImageQuality, ImageStatus>;
|
||||
started: boolean;
|
||||
hasError: boolean;
|
||||
};
|
||||
|
||||
type ImageLoaderCallbacks = {
|
||||
onUrlChange?: (url: string) => void;
|
||||
onImageReady?: () => void;
|
||||
onError?: () => void;
|
||||
};
|
||||
|
||||
export type QualityConfig = {
|
||||
url: string;
|
||||
quality: ImageQuality;
|
||||
onAfterLoad?: (loader: AdaptiveImageLoader) => void;
|
||||
onAfterError?: (loader: AdaptiveImageLoader) => void;
|
||||
};
|
||||
|
||||
export type QualityList = [
|
||||
QualityConfig & { quality: 'thumbnail' },
|
||||
QualityConfig & { quality: 'preview' },
|
||||
QualityConfig & { quality: 'original' },
|
||||
];
|
||||
|
||||
export class AdaptiveImageLoader {
|
||||
private destroyFunctions: (() => void)[] = [];
|
||||
private qualityConfigs: Record<ImageQuality, QualityConfig>;
|
||||
private highestLoadedQualityIndex = -1;
|
||||
private destroyed = false;
|
||||
|
||||
status = $state<ImageLoaderStatus>({
|
||||
started: false,
|
||||
hasError: false,
|
||||
urls: { thumbnail: undefined, preview: undefined, original: undefined },
|
||||
quality: { thumbnail: 'unloaded', preview: 'unloaded', original: 'unloaded' },
|
||||
});
|
||||
|
||||
constructor(
|
||||
private readonly qualityList: QualityList,
|
||||
private readonly callbacks?: ImageLoaderCallbacks,
|
||||
private readonly imageLoader?: LoadImageFunction,
|
||||
) {
|
||||
this.qualityConfigs = {
|
||||
thumbnail: qualityList[0],
|
||||
preview: qualityList[1],
|
||||
original: qualityList[2],
|
||||
};
|
||||
this.status.urls.thumbnail = qualityList[0].url;
|
||||
}
|
||||
|
||||
start() {
|
||||
if (!this.imageLoader) {
|
||||
throw new Error('Start requires imageLoader to be specified');
|
||||
}
|
||||
|
||||
this.destroyFunctions.push(
|
||||
this.imageLoader(
|
||||
this.qualityList[0].url,
|
||||
() => this.onLoad('thumbnail'),
|
||||
() => this.onError('thumbnail'),
|
||||
() => this.onStart('thumbnail'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
onStart(_: ImageQuality) {
|
||||
if (this.destroyed) {
|
||||
return;
|
||||
}
|
||||
this.status.started = true;
|
||||
}
|
||||
|
||||
onLoad(quality: ImageQuality) {
|
||||
if (this.destroyed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const config = this.qualityConfigs[quality];
|
||||
|
||||
if (!this.status.urls[quality]) {
|
||||
return;
|
||||
}
|
||||
|
||||
const index = this.qualityList.indexOf(config);
|
||||
if (index <= this.highestLoadedQualityIndex) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.highestLoadedQualityIndex = index;
|
||||
this.status.quality[quality] = 'success';
|
||||
this.callbacks?.onUrlChange?.(this.qualityConfigs[quality].url);
|
||||
this.callbacks?.onImageReady?.();
|
||||
|
||||
config.onAfterLoad?.(this);
|
||||
}
|
||||
|
||||
onError(quality: ImageQuality) {
|
||||
if (this.destroyed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const config = this.qualityConfigs[quality];
|
||||
|
||||
this.status.hasError = true;
|
||||
this.status.quality[quality] = 'error';
|
||||
this.status.urls[quality] = undefined;
|
||||
this.callbacks?.onError?.();
|
||||
|
||||
config.onAfterError?.(this);
|
||||
}
|
||||
|
||||
trigger(quality: ImageQuality) {
|
||||
if (this.destroyed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const url = this.qualityConfigs[quality].url;
|
||||
if (!url) {
|
||||
this.qualityConfigs[quality].onAfterError?.(this);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.status.urls[quality]) {
|
||||
return true;
|
||||
}
|
||||
|
||||
this.status.hasError = false;
|
||||
this.status.urls[quality] = url;
|
||||
if (this.imageLoader) {
|
||||
this.destroyFunctions.push(
|
||||
this.imageLoader(
|
||||
url,
|
||||
() => this.onLoad(quality),
|
||||
() => this.onError(quality),
|
||||
() => this.onStart(quality),
|
||||
),
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.destroyed = true;
|
||||
if (this.imageLoader) {
|
||||
for (const destroy of this.destroyFunctions) {
|
||||
destroy();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
for (const config of Object.values(this.qualityConfigs)) {
|
||||
cancelImageUrl(config.url);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import {
|
||||
AlbumFilter,
|
||||
@ -29,6 +30,7 @@ export const createAlbum = async (name?: string, assetIds?: string[]) => {
|
||||
assetIds,
|
||||
},
|
||||
});
|
||||
eventManager.emit('AlbumCreate', newAlbum);
|
||||
return newAlbum;
|
||||
} catch (error) {
|
||||
const $t = get(t);
|
||||
|
||||
@ -224,6 +224,8 @@ const supportedImageMimeTypes = new Set([
|
||||
'image/webp',
|
||||
]);
|
||||
|
||||
export const isFirefox = typeof navigator !== 'undefined' && navigator.userAgent.includes('Firefox');
|
||||
|
||||
async function addSupportedMimeTypes(): Promise<void> {
|
||||
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent); // https://stackoverflow.com/a/23522755
|
||||
if (isSafari) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user