mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 02addf7491 | |||
| 56f14162f6 | |||
| 8abbbc49cf | |||
| 4eb08eee18 | |||
| 0560f98c2d | |||
| 49ad411d50 | |||
| 2478cc40f4 | |||
| 44eeb1e088 | |||
| a868ae3ad0 | |||
| acac0d4f37 | |||
| 8c40a28fef | |||
| b2081eda1e | |||
| 9670c853c6 | |||
| cc2dacb308 | |||
| 15fc6b18f3 | |||
| a284e38890 | |||
| 05010c3a84 | |||
| 4da3d68a67 | |||
| 20c639e52a | |||
| 6deb97d5bc | |||
| b282d83e95 | |||
| 5bc08f8654 | |||
| f54924d46a | |||
| dffe4d1d5c | |||
| 7f47cdd645 | |||
| 625b30c50a | |||
| 8619d14eca | |||
| 062546c168 | |||
| ea668d6b22 | |||
| f06af2c600 |
@@ -24,8 +24,7 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Check for breaking API changes
|
- name: Check for breaking API changes
|
||||||
# sha is pinning to a commit instead of a tag since the action does not tag versions
|
uses: oasdiff/oasdiff-action/breaking@65fef71494258f00f911d7a71edb0482c5378899 # v0.0.30
|
||||||
uses: oasdiff/oasdiff-action/breaking@ccb863950ce437a50f8f1a40d2a1112117e06ce4
|
|
||||||
with:
|
with:
|
||||||
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
|
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
|
||||||
revision: open-api/immich-openapi-specs.json
|
revision: open-api/immich-openapi-specs.json
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ jobs:
|
|||||||
|
|
||||||
# Initializes the CodeQL tools for scanning.
|
# Initializes the CodeQL tools for scanning.
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3
|
uses: github/codeql-action/init@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||||
@@ -70,7 +70,7 @@ jobs:
|
|||||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||||
# If this step fails, then you should remove it and run the build manually (see below)
|
# If this step fails, then you should remove it and run the build manually (see below)
|
||||||
- name: Autobuild
|
- name: Autobuild
|
||||||
uses: github/codeql-action/autobuild@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3
|
uses: github/codeql-action/autobuild@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
|
||||||
|
|
||||||
# ℹ️ Command-line programs to run using the OS shell.
|
# ℹ️ Command-line programs to run using the OS shell.
|
||||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||||
@@ -83,6 +83,6 @@ jobs:
|
|||||||
# ./location_of_script_within_repo/buildscript.sh
|
# ./location_of_script_within_repo/buildscript.sh
|
||||||
|
|
||||||
- name: Perform CodeQL Analysis
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3
|
uses: github/codeql-action/analyze@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
|
||||||
with:
|
with:
|
||||||
category: '/language:${{matrix.language}}'
|
category: '/language:${{matrix.language}}'
|
||||||
|
|||||||
+1
-1
@@ -20,7 +20,7 @@
|
|||||||
"@types/lodash-es": "^4.17.12",
|
"@types/lodash-es": "^4.17.12",
|
||||||
"@types/micromatch": "^4.0.9",
|
"@types/micromatch": "^4.0.9",
|
||||||
"@types/mock-fs": "^4.13.1",
|
"@types/mock-fs": "^4.13.1",
|
||||||
"@types/node": "^24.10.13",
|
"@types/node": "^24.10.14",
|
||||||
"@vitest/coverage-v8": "^3.0.0",
|
"@vitest/coverage-v8": "^3.0.0",
|
||||||
"byte-size": "^9.0.0",
|
"byte-size": "^9.0.0",
|
||||||
"cli-progress": "^3.12.0",
|
"cli-progress": "^3.12.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[tools]
|
[tools]
|
||||||
terragrunt = "0.98.0"
|
terragrunt = "0.99.4"
|
||||||
opentofu = "1.11.4"
|
opentofu = "1.11.5"
|
||||||
|
|
||||||
[tasks."tg:fmt"]
|
[tasks."tg:fmt"]
|
||||||
run = "terragrunt hclfmt"
|
run = "terragrunt hclfmt"
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ services:
|
|||||||
|
|
||||||
redis:
|
redis:
|
||||||
container_name: immich_redis
|
container_name: immich_redis
|
||||||
image: docker.io/valkey/valkey:9@sha256:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e
|
image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: redis-cli ping || exit 1
|
test: redis-cli ping || exit 1
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ services:
|
|||||||
|
|
||||||
redis:
|
redis:
|
||||||
container_name: immich_redis
|
container_name: immich_redis
|
||||||
image: docker.io/valkey/valkey:9@sha256:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e
|
image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: redis-cli ping || exit 1
|
test: redis-cli ping || exit 1
|
||||||
restart: always
|
restart: always
|
||||||
@@ -85,7 +85,7 @@ services:
|
|||||||
container_name: immich_prometheus
|
container_name: immich_prometheus
|
||||||
ports:
|
ports:
|
||||||
- 9090:9090
|
- 9090:9090
|
||||||
image: prom/prometheus@sha256:1f0f50f06acaceb0f5670d2c8a658a599affe7b0d8e78b898c1035653849a702
|
image: prom/prometheus@sha256:4a61322ac1103a0e3aea2a61ef1718422a48fa046441f299d71e660a3bc71ae9
|
||||||
volumes:
|
volumes:
|
||||||
- ./prometheus.yml:/etc/prometheus/prometheus.yml
|
- ./prometheus.yml:/etc/prometheus/prometheus.yml
|
||||||
- prometheus-data:/prometheus
|
- prometheus-data:/prometheus
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ services:
|
|||||||
|
|
||||||
redis:
|
redis:
|
||||||
container_name: immich_redis
|
container_name: immich_redis
|
||||||
image: docker.io/valkey/valkey:9@sha256:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e
|
image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d
|
||||||
user: '1000:1000'
|
user: '1000:1000'
|
||||||
security_opt:
|
security_opt:
|
||||||
- no-new-privileges:true
|
- no-new-privileges:true
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ services:
|
|||||||
|
|
||||||
redis:
|
redis:
|
||||||
container_name: immich_redis
|
container_name: immich_redis
|
||||||
image: docker.io/valkey/valkey:9@sha256:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e
|
image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: redis-cli ping || exit 1
|
test: redis-cli ping || exit 1
|
||||||
restart: always
|
restart: always
|
||||||
|
|||||||
@@ -67,7 +67,8 @@ graph TD
|
|||||||
C --> D["Thumbnail Generation (Large, small, blurred and person)"]
|
C --> D["Thumbnail Generation (Large, small, blurred and person)"]
|
||||||
D --> E[Smart Search]
|
D --> E[Smart Search]
|
||||||
D --> F[Face Detection]
|
D --> F[Face Detection]
|
||||||
D --> G[Video Transcoding]
|
D --> G[OCR]
|
||||||
E --> H[Duplicate Detection]
|
D --> H[Video Transcoding]
|
||||||
F --> I[Facial Recognition]
|
E --> I[Duplicate Detection]
|
||||||
|
F --> J[Facial Recognition]
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ services:
|
|||||||
|
|
||||||
redis:
|
redis:
|
||||||
container_name: immich-e2e-redis
|
container_name: immich-e2e-redis
|
||||||
image: docker.io/valkey/valkey:9@sha256:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e
|
image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: redis-cli ping || exit 1
|
test: redis-cli ping || exit 1
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -32,7 +32,7 @@
|
|||||||
"@playwright/test": "^1.44.1",
|
"@playwright/test": "^1.44.1",
|
||||||
"@socket.io/component-emitter": "^3.1.2",
|
"@socket.io/component-emitter": "^3.1.2",
|
||||||
"@types/luxon": "^3.4.2",
|
"@types/luxon": "^3.4.2",
|
||||||
"@types/node": "^24.10.13",
|
"@types/node": "^24.10.14",
|
||||||
"@types/pg": "^8.15.1",
|
"@types/pg": "^8.15.1",
|
||||||
"@types/pngjs": "^6.0.4",
|
"@types/pngjs": "^6.0.4",
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^6.0.2",
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
import { AssetMediaResponseDto, LoginResponseDto } from '@immich/sdk';
|
|
||||||
import { expect, Page, test } from '@playwright/test';
|
|
||||||
import { utils } from 'src/utils';
|
|
||||||
|
|
||||||
async function ensureDetailPanelVisible(page: Page) {
|
|
||||||
await page.waitForSelector('#immich-asset-viewer');
|
|
||||||
|
|
||||||
const isVisible = await page.locator('#detail-panel').isVisible();
|
|
||||||
if (!isVisible) {
|
|
||||||
await page.keyboard.press('i');
|
|
||||||
await page.waitForSelector('#detail-panel');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
test.describe('Asset Viewer stack', () => {
|
|
||||||
let admin: LoginResponseDto;
|
|
||||||
let assetOne: AssetMediaResponseDto;
|
|
||||||
let assetTwo: AssetMediaResponseDto;
|
|
||||||
|
|
||||||
test.beforeAll(async () => {
|
|
||||||
utils.initSdk();
|
|
||||||
await utils.resetDatabase();
|
|
||||||
admin = await utils.adminSetup();
|
|
||||||
await utils.updateMyPreferences(admin.accessToken, { tags: { enabled: true } });
|
|
||||||
|
|
||||||
assetOne = await utils.createAsset(admin.accessToken);
|
|
||||||
assetTwo = await utils.createAsset(admin.accessToken);
|
|
||||||
await utils.createStack(admin.accessToken, [assetOne.id, assetTwo.id]);
|
|
||||||
|
|
||||||
const tags = await utils.upsertTags(admin.accessToken, ['test/1', 'test/2']);
|
|
||||||
const tagOne = tags.find((tag) => tag.value === 'test/1')!;
|
|
||||||
const tagTwo = tags.find((tag) => tag.value === 'test/2')!;
|
|
||||||
await utils.tagAssets(admin.accessToken, tagOne.id, [assetOne.id]);
|
|
||||||
await utils.tagAssets(admin.accessToken, tagTwo.id, [assetTwo.id]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('stack slideshow is visible', async ({ page, context }) => {
|
|
||||||
await utils.setAuthCookies(context, admin.accessToken);
|
|
||||||
await page.goto(`/photos/${assetOne.id}`);
|
|
||||||
|
|
||||||
const stackAssets = page.locator('#stack-slideshow [data-asset]');
|
|
||||||
await expect(stackAssets.first()).toBeVisible();
|
|
||||||
await expect(stackAssets.nth(1)).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('tags of primary asset are visible', async ({ page, context }) => {
|
|
||||||
await utils.setAuthCookies(context, admin.accessToken);
|
|
||||||
await page.goto(`/photos/${assetOne.id}`);
|
|
||||||
await ensureDetailPanelVisible(page);
|
|
||||||
|
|
||||||
const tags = page.getByTestId('detail-panel-tags').getByRole('link');
|
|
||||||
await expect(tags.first()).toHaveText('test/1');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('tags of second asset are visible', async ({ page, context }) => {
|
|
||||||
await utils.setAuthCookies(context, admin.accessToken);
|
|
||||||
await page.goto(`/photos/${assetOne.id}`);
|
|
||||||
await ensureDetailPanelVisible(page);
|
|
||||||
|
|
||||||
const stackAssets = page.locator('#stack-slideshow [data-asset]');
|
|
||||||
await stackAssets.nth(1).click();
|
|
||||||
|
|
||||||
const tags = page.getByTestId('detail-panel-tags').getByRole('link');
|
|
||||||
await expect(tags.first()).toHaveText('test/2');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import { faker } from '@faker-js/faker';
|
||||||
|
import { AssetTypeEnum, AssetVisibility, type AssetResponseDto, type StackResponseDto } from '@immich/sdk';
|
||||||
|
import { BrowserContext } from '@playwright/test';
|
||||||
|
import { randomPreview, randomThumbnail } from 'src/ui/generators/timeline';
|
||||||
|
|
||||||
|
export type MockStack = {
|
||||||
|
id: string;
|
||||||
|
primaryAssetId: string;
|
||||||
|
assets: AssetResponseDto[];
|
||||||
|
brokenAssetIds: Set<string>;
|
||||||
|
assetMap: Map<string, AssetResponseDto>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createMockStackAsset = (ownerId: string): AssetResponseDto => {
|
||||||
|
const assetId = faker.string.uuid();
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
return {
|
||||||
|
id: assetId,
|
||||||
|
deviceAssetId: `device-${assetId}`,
|
||||||
|
ownerId,
|
||||||
|
owner: {
|
||||||
|
id: ownerId,
|
||||||
|
email: 'admin@immich.cloud',
|
||||||
|
name: 'Admin',
|
||||||
|
profileImagePath: '',
|
||||||
|
profileChangedAt: now,
|
||||||
|
avatarColor: 'blue' as never,
|
||||||
|
},
|
||||||
|
libraryId: `library-${ownerId}`,
|
||||||
|
deviceId: `device-${ownerId}`,
|
||||||
|
type: AssetTypeEnum.Image,
|
||||||
|
originalPath: `/original/${assetId}.jpg`,
|
||||||
|
originalFileName: `${assetId}.jpg`,
|
||||||
|
originalMimeType: 'image/jpeg',
|
||||||
|
thumbhash: null,
|
||||||
|
fileCreatedAt: now,
|
||||||
|
fileModifiedAt: now,
|
||||||
|
localDateTime: now,
|
||||||
|
updatedAt: now,
|
||||||
|
createdAt: now,
|
||||||
|
isFavorite: false,
|
||||||
|
isArchived: false,
|
||||||
|
isTrashed: false,
|
||||||
|
visibility: AssetVisibility.Timeline,
|
||||||
|
duration: '0:00:00.00000',
|
||||||
|
exifInfo: {
|
||||||
|
make: null,
|
||||||
|
model: null,
|
||||||
|
exifImageWidth: 3000,
|
||||||
|
exifImageHeight: 4000,
|
||||||
|
fileSizeInByte: null,
|
||||||
|
orientation: null,
|
||||||
|
dateTimeOriginal: now,
|
||||||
|
modifyDate: null,
|
||||||
|
timeZone: null,
|
||||||
|
lensModel: null,
|
||||||
|
fNumber: null,
|
||||||
|
focalLength: null,
|
||||||
|
iso: null,
|
||||||
|
exposureTime: null,
|
||||||
|
latitude: null,
|
||||||
|
longitude: null,
|
||||||
|
city: null,
|
||||||
|
country: null,
|
||||||
|
state: null,
|
||||||
|
description: null,
|
||||||
|
},
|
||||||
|
livePhotoVideoId: null,
|
||||||
|
tags: [],
|
||||||
|
people: [],
|
||||||
|
unassignedFaces: [],
|
||||||
|
stack: null,
|
||||||
|
isOffline: false,
|
||||||
|
hasMetadata: true,
|
||||||
|
duplicateId: null,
|
||||||
|
resized: true,
|
||||||
|
checksum: faker.string.alphanumeric({ length: 28 }),
|
||||||
|
width: 3000,
|
||||||
|
height: 4000,
|
||||||
|
isEdited: false,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createMockStack = (
|
||||||
|
primaryAssetDto: AssetResponseDto,
|
||||||
|
additionalAssets: AssetResponseDto[],
|
||||||
|
brokenAssetIds?: Set<string>,
|
||||||
|
): MockStack => {
|
||||||
|
const stackId = faker.string.uuid();
|
||||||
|
const allAssets = [primaryAssetDto, ...additionalAssets];
|
||||||
|
const resolvedBrokenIds = brokenAssetIds ?? new Set(additionalAssets.map((a) => a.id));
|
||||||
|
const assetMap = new Map(allAssets.map((a) => [a.id, a]));
|
||||||
|
|
||||||
|
primaryAssetDto.stack = {
|
||||||
|
id: stackId,
|
||||||
|
assetCount: allAssets.length,
|
||||||
|
primaryAssetId: primaryAssetDto.id,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: stackId,
|
||||||
|
primaryAssetId: primaryAssetDto.id,
|
||||||
|
assets: allAssets,
|
||||||
|
brokenAssetIds: resolvedBrokenIds,
|
||||||
|
assetMap,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setupBrokenAssetMockApiRoutes = async (context: BrowserContext, mockStack: MockStack) => {
|
||||||
|
await context.route('**/api/stacks/*', async (route, request) => {
|
||||||
|
if (request.method() !== 'GET') {
|
||||||
|
return route.fallback();
|
||||||
|
}
|
||||||
|
const stackResponse: StackResponseDto = {
|
||||||
|
id: mockStack.id,
|
||||||
|
primaryAssetId: mockStack.primaryAssetId,
|
||||||
|
assets: mockStack.assets,
|
||||||
|
};
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
json: stackResponse,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await context.route('**/api/assets/*', async (route, request) => {
|
||||||
|
if (request.method() !== 'GET') {
|
||||||
|
return route.fallback();
|
||||||
|
}
|
||||||
|
const url = new URL(request.url());
|
||||||
|
const segments = url.pathname.split('/');
|
||||||
|
const assetId = segments.at(-1);
|
||||||
|
if (assetId && mockStack.assetMap.has(assetId)) {
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
json: mockStack.assetMap.get(assetId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return route.fallback();
|
||||||
|
});
|
||||||
|
|
||||||
|
await context.route('**/api/assets/*/thumbnail?size=*', async (route, request) => {
|
||||||
|
if (!route.request().serviceWorker()) {
|
||||||
|
return route.continue();
|
||||||
|
}
|
||||||
|
const pattern = /\/api\/assets\/(?<assetId>[^/]+)\/thumbnail\?size=(?<size>preview|thumbnail)/;
|
||||||
|
const match = request.url().match(pattern);
|
||||||
|
if (!match?.groups || !mockStack.assetMap.has(match.groups.assetId)) {
|
||||||
|
return route.fallback();
|
||||||
|
}
|
||||||
|
if (mockStack.brokenAssetIds.has(match.groups.assetId)) {
|
||||||
|
return route.fulfill({ status: 404 });
|
||||||
|
}
|
||||||
|
const asset = mockStack.assetMap.get(match.groups.assetId)!;
|
||||||
|
const ratio = (asset.exifInfo?.exifImageWidth ?? 3000) / (asset.exifInfo?.exifImageHeight ?? 4000);
|
||||||
|
const body =
|
||||||
|
match.groups.size === 'preview'
|
||||||
|
? await randomPreview(match.groups.assetId, ratio)
|
||||||
|
: await randomThumbnail(match.groups.assetId, ratio);
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'image/jpeg' },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { BrowserContext } from '@playwright/test';
|
||||||
|
import { randomThumbnail } from 'src/ui/generators/timeline';
|
||||||
|
|
||||||
|
// Minimal valid H.264 MP4 (8x8px, 1 frame) that browsers can decode to get videoWidth/videoHeight
|
||||||
|
const MINIMAL_MP4_BASE64 =
|
||||||
|
'AAAAIGZ0eXBpc29tAAACAGlzb21pc28yYXZjMW1wNDEAAAAIZnJlZQAAAr9tZGF0AAACoAYF//+c' +
|
||||||
|
'3EXpvebZSLeWLNgg2SPu73gyNjQgLSBjb3JlIDEyNSAtIEguMjY0L01QRUctNCBBVkMgY29kZWMg' +
|
||||||
|
'LSBDb3B5bGVmdCAyMDAzLTIwMTIgLSBodHRwOi8vd3d3LnZpZGVvbGFuLm9yZy94MjY0Lmh0bWwg' +
|
||||||
|
'LSBvcHRpb25zOiBjYWJhYz0xIHJlZj0zIGRlYmxvY2s9MTowOjAgYW5hbHlzZT0weDM6MHgxMTMg' +
|
||||||
|
'bWU9aGV4IHN1Ym1lPTcgcHN5PTEgcHN5X3JkPTEuMDA6MC4wMCBtaXhlZF9yZWY9MSBtZV9yYW5n' +
|
||||||
|
'ZT0xNiBjaHJvbWFfbWU9MSB0cmVsbGlzPTEgOHg4ZGN0PTEgY3FtPTAgZGVhZHpvbmU9MjEsMTEg' +
|
||||||
|
'ZmFzdF9wc2tpcD0xIGNocm9tYV9xcF9vZmZzZXQ9LTIgdGhyZWFkcz02IGxvb2thaGVhZF90aHJl' +
|
||||||
|
'YWRzPTEgc2xpY2VkX3RocmVhZHM9MCBucj0wIGRlY2ltYXRlPTEgaW50ZXJsYWNlZD0wIGJsdXJh' +
|
||||||
|
'eV9jb21wYXQ9MCBjb25zdHJhaW5lZF9pbnRyYT0wIGJmcmFtZXM9MyBiX3B5cmFtaWQ9MiBiX2Fk' +
|
||||||
|
'YXB0PTEgYl9iaWFzPTAgZGlyZWN0PTEgd2VpZ2h0Yj0xIG9wZW5fZ29wPTAgd2VpZ2h0cD0yIGtl' +
|
||||||
|
'eWludD0yNTAga2V5aW50X21pbj0yNCBzY2VuZWN1dD00MCBpbnRyYV9yZWZyZXNoPTAgcmNfbG9v' +
|
||||||
|
'a2FoZWFkPTQwIHJjPWNyZiBtYnRyZWU9MSBjcmY9MjMuMCBxY29tcD0wLjYwIHFwbWluPTAgcXBt' +
|
||||||
|
'YXg9NjkgcXBzdGVwPTQgaXBfcmF0aW89MS40MCBhcT0xOjEuMDAAgAAAAA9liIQAV/0TAAYdeBTX' +
|
||||||
|
'zg8AAALvbW9vdgAAAGxtdmhkAAAAAAAAAAAAAAAAAAAD6AAAACoAAQAAAQAAAAAAAAAAAAAAAAEAAAAA' +
|
||||||
|
'AAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAA' +
|
||||||
|
'Ahl0cmFrAAAAXHRraGQAAAAPAAAAAAAAAAAAAAABAAAAAAAAACoAAAAAAAAAAAAAAAAAAAAAAAEAAAAA' +
|
||||||
|
'AAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAAgAAAAIAAAAAAAkZWR0cwAAABxlbHN0AAAAAAAA' +
|
||||||
|
'AAEAAAAqAAAAAAABAAAAAAGRbWRpYQAAACBtZGhkAAAAAAAAAAAAAAAAAAAwAAAAAgBVxAAAAAAA' +
|
||||||
|
'LWhkbHIAAAAAAAAAAHZpZGUAAAAAAAAAAAAAAABWaWRlb0hhbmRsZXIAAAABPG1pbmYAAAAUdm1oZAAA' +
|
||||||
|
'AAEAAAAAAAAAAAAAACRkaW5mAAAAHGRyZWYAAAAAAAAAAQAAAAx1cmwgAAAAAQAAAPxzdGJsAAAAmHN0' +
|
||||||
|
'c2QAAAAAAAAAAQAAAIhhdmMxAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAgACABIAAAASAAAAAAAAAAB' +
|
||||||
|
'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGP//AAAAMmF2Y0MBZAAK/+EAGWdkAAqs' +
|
||||||
|
'2V+WXAWyAAADAAIAAAMAYB4kSywBAAZo6+PLIsAAAAAYc3R0cwAAAAAAAAABAAAAAQAAAgAAAAAcc3Rz' +
|
||||||
|
'YwAAAAAAAAABAAAAAQAAAAEAAAABAAAAFHN0c3oAAAAAAAACtwAAAAEAAAAUc3RjbwAAAAAAAAABAAAA' +
|
||||||
|
'MAAAAGJ1ZHRhAAAAWm1ldGEAAAAAAAAAIWhkbHIAAAAAAAAAAG1kaXJhcHBsAAAAAAAAAAAAAAAALWls' +
|
||||||
|
'c3QAAAAlqXRvbwAAAB1kYXRhAAAAAQAAAABMYXZmNTQuNjMuMTA0';
|
||||||
|
|
||||||
|
export const MINIMAL_MP4_BUFFER = Buffer.from(MINIMAL_MP4_BASE64, 'base64');
|
||||||
|
|
||||||
|
export type MockPerson = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
birthDate: string | null;
|
||||||
|
isHidden: boolean;
|
||||||
|
thumbnailPath: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createMockPeople = (count: number): MockPerson[] => {
|
||||||
|
const names = [
|
||||||
|
'Alice Johnson',
|
||||||
|
'Bob Smith',
|
||||||
|
'Charlie Brown',
|
||||||
|
'Diana Prince',
|
||||||
|
'Eve Adams',
|
||||||
|
'Frank Castle',
|
||||||
|
'Grace Lee',
|
||||||
|
'Hank Pym',
|
||||||
|
'Iris West',
|
||||||
|
'Jack Ryan',
|
||||||
|
];
|
||||||
|
return Array.from({ length: count }, (_, index) => ({
|
||||||
|
id: `person-${index}`,
|
||||||
|
name: names[index % names.length],
|
||||||
|
birthDate: null,
|
||||||
|
isHidden: false,
|
||||||
|
thumbnailPath: `/upload/thumbs/person-${index}.jpeg`,
|
||||||
|
updatedAt: '2025-01-01T00:00:00.000Z',
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FaceCreateCapture = {
|
||||||
|
requests: Array<{
|
||||||
|
assetId: string;
|
||||||
|
personId: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
imageWidth: number;
|
||||||
|
imageHeight: number;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setupFaceEditorMockApiRoutes = async (
|
||||||
|
context: BrowserContext,
|
||||||
|
mockPeople: MockPerson[],
|
||||||
|
faceCreateCapture: FaceCreateCapture,
|
||||||
|
) => {
|
||||||
|
await context.route('**/api/people?*', async (route, request) => {
|
||||||
|
if (request.method() !== 'GET') {
|
||||||
|
return route.fallback();
|
||||||
|
}
|
||||||
|
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
json: {
|
||||||
|
hasNextPage: false,
|
||||||
|
hidden: 0,
|
||||||
|
people: mockPeople,
|
||||||
|
total: mockPeople.length,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await context.route('**/api/faces', async (route, request) => {
|
||||||
|
if (request.method() !== 'POST') {
|
||||||
|
return route.fallback();
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = request.postDataJSON();
|
||||||
|
faceCreateCapture.requests.push(body);
|
||||||
|
|
||||||
|
return route.fulfill({
|
||||||
|
status: 201,
|
||||||
|
contentType: 'text/plain',
|
||||||
|
body: 'OK',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await context.route('**/api/people/*/thumbnail', async (route) => {
|
||||||
|
if (!route.request().serviceWorker()) {
|
||||||
|
return route.continue();
|
||||||
|
}
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'image/jpeg' },
|
||||||
|
body: await randomThumbnail('person-thumb', 1),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
TimelineData,
|
TimelineData,
|
||||||
} from 'src/ui/generators/timeline';
|
} from 'src/ui/generators/timeline';
|
||||||
import { sleep } from 'src/ui/specs/timeline/utils';
|
import { sleep } from 'src/ui/specs/timeline/utils';
|
||||||
|
import { MINIMAL_MP4_BUFFER } from './face-editor-network';
|
||||||
|
|
||||||
export class TimelineTestContext {
|
export class TimelineTestContext {
|
||||||
slowBucket = false;
|
slowBucket = false;
|
||||||
@@ -135,6 +136,14 @@ export const setupTimelineMockApiRoutes = async (
|
|||||||
return route.continue();
|
return route.continue();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await context.route('**/api/assets/*/video/playback*', async (route) => {
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'video/mp4' },
|
||||||
|
body: MINIMAL_MP4_BUFFER,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
await context.route('**/api/albums/**', async (route, request) => {
|
await context.route('**/api/albums/**', async (route, request) => {
|
||||||
const albumsMatch = request.url().match(/\/api\/albums\/(?<albumId>[^/?]+)/);
|
const albumsMatch = request.url().match(/\/api\/albums\/(?<albumId>[^/?]+)/);
|
||||||
if (albumsMatch) {
|
if (albumsMatch) {
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import { toAssetResponseDto } from 'src/ui/generators/timeline';
|
||||||
|
import {
|
||||||
|
createMockStack,
|
||||||
|
createMockStackAsset,
|
||||||
|
MockStack,
|
||||||
|
setupBrokenAssetMockApiRoutes,
|
||||||
|
} from 'src/ui/mock-network/broken-asset-network';
|
||||||
|
import { assetViewerUtils } from '../timeline/utils';
|
||||||
|
import { setupAssetViewerFixture } from './utils';
|
||||||
|
|
||||||
|
test.describe.configure({ mode: 'parallel' });
|
||||||
|
test.describe('broken-asset responsiveness', () => {
|
||||||
|
const fixture = setupAssetViewerFixture(889);
|
||||||
|
let mockStack: MockStack;
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
const primaryAssetDto = toAssetResponseDto(fixture.primaryAsset);
|
||||||
|
|
||||||
|
const brokenAssets = [
|
||||||
|
createMockStackAsset(fixture.adminUserId),
|
||||||
|
createMockStackAsset(fixture.adminUserId),
|
||||||
|
createMockStackAsset(fixture.adminUserId),
|
||||||
|
];
|
||||||
|
|
||||||
|
mockStack = createMockStack(primaryAssetDto, brokenAssets);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.beforeEach(async ({ context }) => {
|
||||||
|
await setupBrokenAssetMockApiRoutes(context, mockStack);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('broken asset in stack strip hides icon at small size', async ({ page }) => {
|
||||||
|
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
||||||
|
|
||||||
|
const stackSlideshow = page.locator('#stack-slideshow');
|
||||||
|
await expect(stackSlideshow).toBeVisible();
|
||||||
|
|
||||||
|
const brokenAssets = stackSlideshow.locator('[data-broken-asset]');
|
||||||
|
await expect(brokenAssets.first()).toBeVisible();
|
||||||
|
await expect(brokenAssets).toHaveCount(mockStack.brokenAssetIds.size);
|
||||||
|
|
||||||
|
for (const brokenAsset of await brokenAssets.all()) {
|
||||||
|
await expect(brokenAsset.locator('svg')).not.toBeVisible();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('broken asset in stack strip uses text-xs class', async ({ page }) => {
|
||||||
|
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
||||||
|
|
||||||
|
const stackSlideshow = page.locator('#stack-slideshow');
|
||||||
|
await expect(stackSlideshow).toBeVisible();
|
||||||
|
|
||||||
|
const brokenAssets = stackSlideshow.locator('[data-broken-asset]');
|
||||||
|
await expect(brokenAssets.first()).toBeVisible();
|
||||||
|
|
||||||
|
for (const brokenAsset of await brokenAssets.all()) {
|
||||||
|
const messageSpan = brokenAsset.locator('span');
|
||||||
|
await expect(messageSpan).toHaveClass(/text-xs/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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`),
|
||||||
|
async (route) => {
|
||||||
|
return route.fulfill({ status: 404 });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
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]');
|
||||||
|
await expect(viewerBrokenAsset).toBeVisible();
|
||||||
|
|
||||||
|
await expect(viewerBrokenAsset.locator('svg')).toBeVisible();
|
||||||
|
|
||||||
|
const messageSpan = viewerBrokenAsset.locator('span');
|
||||||
|
await expect(messageSpan).toHaveClass(/text-base/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
import { expect, Page, test } from '@playwright/test';
|
||||||
|
import { SeededRandom, selectRandom, TimelineAssetConfig } from 'src/ui/generators/timeline';
|
||||||
|
import {
|
||||||
|
createMockPeople,
|
||||||
|
FaceCreateCapture,
|
||||||
|
MockPerson,
|
||||||
|
setupFaceEditorMockApiRoutes,
|
||||||
|
} from 'src/ui/mock-network/face-editor-network';
|
||||||
|
import { assetViewerUtils } from '../timeline/utils';
|
||||||
|
import { setupAssetViewerFixture } from './utils';
|
||||||
|
|
||||||
|
const waitForSelectorTransition = async (page: Page) => {
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => {
|
||||||
|
const selector = document.querySelector('#face-selector') as HTMLElement | null;
|
||||||
|
if (!selector) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return selector.getAnimations({ subtree: false }).every((animation) => animation.playState === 'finished');
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
{ timeout: 1000, polling: 50 },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openFaceEditor = async (page: Page, asset: TimelineAssetConfig) => {
|
||||||
|
await page.goto(`/photos/${asset.id}`);
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, asset);
|
||||||
|
await page.keyboard.press('i');
|
||||||
|
await page.locator('#detail-panel').waitFor({ state: 'visible' });
|
||||||
|
await page.getByLabel('Tag people').click();
|
||||||
|
await page.locator('#face-selector').waitFor({ state: 'visible' });
|
||||||
|
await waitForSelectorTransition(page);
|
||||||
|
};
|
||||||
|
|
||||||
|
test.describe.configure({ mode: 'parallel' });
|
||||||
|
test.describe('face-editor', () => {
|
||||||
|
const fixture = setupAssetViewerFixture(777);
|
||||||
|
const rng = new SeededRandom(777);
|
||||||
|
let mockPeople: MockPerson[];
|
||||||
|
let faceCreateCapture: FaceCreateCapture;
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
mockPeople = createMockPeople(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.beforeEach(async ({ context }) => {
|
||||||
|
faceCreateCapture = { requests: [] };
|
||||||
|
await setupFaceEditorMockApiRoutes(context, mockPeople, faceCreateCapture);
|
||||||
|
});
|
||||||
|
|
||||||
|
type ScreenRect = { top: number; left: number; width: number; height: number };
|
||||||
|
|
||||||
|
const getFaceBoxRect = async (page: Page): Promise<ScreenRect> => {
|
||||||
|
const dataEl = page.locator('#face-editor-data');
|
||||||
|
await expect(dataEl).toHaveAttribute('data-face-left', /^-?\d+/);
|
||||||
|
await expect(dataEl).toHaveAttribute('data-face-top', /^-?\d+/);
|
||||||
|
await expect(dataEl).toHaveAttribute('data-face-width', /^[1-9]/);
|
||||||
|
await expect(dataEl).toHaveAttribute('data-face-height', /^[1-9]/);
|
||||||
|
const canvasBox = await page.locator('#face-editor').boundingBox();
|
||||||
|
if (!canvasBox) {
|
||||||
|
throw new Error('Canvas element not found');
|
||||||
|
}
|
||||||
|
const left = Number(await dataEl.getAttribute('data-face-left'));
|
||||||
|
const top = Number(await dataEl.getAttribute('data-face-top'));
|
||||||
|
const width = Number(await dataEl.getAttribute('data-face-width'));
|
||||||
|
const height = Number(await dataEl.getAttribute('data-face-height'));
|
||||||
|
return {
|
||||||
|
top: canvasBox.y + top,
|
||||||
|
left: canvasBox.x + left,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSelectorRect = async (page: Page): Promise<ScreenRect> => {
|
||||||
|
const box = await page.locator('#face-selector').boundingBox();
|
||||||
|
if (!box) {
|
||||||
|
throw new Error('Face selector element not found');
|
||||||
|
}
|
||||||
|
return { top: box.y, left: box.x, width: box.width, height: box.height };
|
||||||
|
};
|
||||||
|
|
||||||
|
const computeOverlapArea = (a: ScreenRect, b: ScreenRect): number => {
|
||||||
|
const overlapX = Math.max(0, Math.min(a.left + a.width, b.left + b.width) - Math.max(a.left, b.left));
|
||||||
|
const overlapY = Math.max(0, Math.min(a.top + a.height, b.top + b.height) - Math.max(a.top, b.top));
|
||||||
|
return overlapX * overlapY;
|
||||||
|
};
|
||||||
|
|
||||||
|
const dragFaceBox = async (page: Page, deltaX: number, deltaY: number) => {
|
||||||
|
const faceBox = await getFaceBoxRect(page);
|
||||||
|
const centerX = faceBox.left + faceBox.width / 2;
|
||||||
|
const centerY = faceBox.top + faceBox.height / 2;
|
||||||
|
await page.mouse.move(centerX, centerY);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(centerX + deltaX, centerY + deltaY, { steps: 5 });
|
||||||
|
await page.mouse.up();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
};
|
||||||
|
|
||||||
|
test('Face editor opens with person list', async ({ page }) => {
|
||||||
|
const asset = selectRandom(fixture.assets, rng);
|
||||||
|
await openFaceEditor(page, asset);
|
||||||
|
|
||||||
|
await expect(page.locator('#face-selector')).toBeVisible();
|
||||||
|
await expect(page.locator('#face-editor')).toBeVisible();
|
||||||
|
|
||||||
|
for (const person of mockPeople) {
|
||||||
|
await expect(page.locator('#face-selector').getByText(person.name)).toBeVisible();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Search filters people by name', async ({ page }) => {
|
||||||
|
const asset = selectRandom(fixture.assets, rng);
|
||||||
|
await openFaceEditor(page, asset);
|
||||||
|
|
||||||
|
const searchInput = page.locator('#face-selector input');
|
||||||
|
await searchInput.fill('Alice');
|
||||||
|
|
||||||
|
await expect(page.locator('#face-selector').getByText('Alice Johnson')).toBeVisible();
|
||||||
|
await expect(page.locator('#face-selector').getByText('Bob Smith')).toBeHidden();
|
||||||
|
|
||||||
|
await searchInput.clear();
|
||||||
|
|
||||||
|
for (const person of mockPeople) {
|
||||||
|
await expect(page.locator('#face-selector').getByText(person.name)).toBeVisible();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Search with no results shows empty message', async ({ page }) => {
|
||||||
|
const asset = selectRandom(fixture.assets, rng);
|
||||||
|
await openFaceEditor(page, asset);
|
||||||
|
|
||||||
|
const searchInput = page.locator('#face-selector input');
|
||||||
|
await searchInput.fill('Nonexistent Person XYZ');
|
||||||
|
|
||||||
|
for (const person of mockPeople) {
|
||||||
|
await expect(page.locator('#face-selector').getByText(person.name)).toBeHidden();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Selecting a person shows confirmation dialog', async ({ page }) => {
|
||||||
|
const asset = selectRandom(fixture.assets, rng);
|
||||||
|
await openFaceEditor(page, asset);
|
||||||
|
|
||||||
|
const personToTag = mockPeople[0];
|
||||||
|
await page.locator('#face-selector').getByText(personToTag.name).click();
|
||||||
|
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Confirming tag calls createFace API and closes editor', async ({ page }) => {
|
||||||
|
const asset = selectRandom(fixture.assets, rng);
|
||||||
|
await openFaceEditor(page, asset);
|
||||||
|
|
||||||
|
const personToTag = mockPeople[0];
|
||||||
|
await page.locator('#face-selector').getByText(personToTag.name).click();
|
||||||
|
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: /confirm/i }).click();
|
||||||
|
|
||||||
|
await expect(page.locator('#face-selector')).toBeHidden();
|
||||||
|
await expect(page.locator('#face-editor')).toBeHidden();
|
||||||
|
|
||||||
|
expect(faceCreateCapture.requests).toHaveLength(1);
|
||||||
|
expect(faceCreateCapture.requests[0].assetId).toBe(asset.id);
|
||||||
|
expect(faceCreateCapture.requests[0].personId).toBe(personToTag.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Cancel button closes face editor', async ({ page }) => {
|
||||||
|
const asset = selectRandom(fixture.assets, rng);
|
||||||
|
await openFaceEditor(page, asset);
|
||||||
|
|
||||||
|
await expect(page.locator('#face-selector')).toBeVisible();
|
||||||
|
await expect(page.locator('#face-editor')).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /cancel/i }).click();
|
||||||
|
|
||||||
|
await expect(page.locator('#face-selector')).toBeHidden();
|
||||||
|
await expect(page.locator('#face-editor')).toBeHidden();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Selector does not overlap face box on initial open', async ({ page }) => {
|
||||||
|
const asset = selectRandom(fixture.assets, rng);
|
||||||
|
await openFaceEditor(page, asset);
|
||||||
|
|
||||||
|
const faceBox = await getFaceBoxRect(page);
|
||||||
|
const selectorBox = await getSelectorRect(page);
|
||||||
|
const overlap = computeOverlapArea(faceBox, selectorBox);
|
||||||
|
|
||||||
|
expect(overlap).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Selector repositions without overlap after dragging face box down', async ({ page }) => {
|
||||||
|
const asset = selectRandom(fixture.assets, rng);
|
||||||
|
await openFaceEditor(page, asset);
|
||||||
|
|
||||||
|
await dragFaceBox(page, 0, 150);
|
||||||
|
|
||||||
|
const faceBox = await getFaceBoxRect(page);
|
||||||
|
const selectorBox = await getSelectorRect(page);
|
||||||
|
const overlap = computeOverlapArea(faceBox, selectorBox);
|
||||||
|
|
||||||
|
expect(overlap).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Selector repositions without overlap after dragging face box right', async ({ page }) => {
|
||||||
|
const asset = selectRandom(fixture.assets, rng);
|
||||||
|
await openFaceEditor(page, asset);
|
||||||
|
|
||||||
|
await dragFaceBox(page, 200, 0);
|
||||||
|
|
||||||
|
const faceBox = await getFaceBoxRect(page);
|
||||||
|
const selectorBox = await getSelectorRect(page);
|
||||||
|
const overlap = computeOverlapArea(faceBox, selectorBox);
|
||||||
|
|
||||||
|
expect(overlap).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Selector repositions without overlap after dragging face box to top-left corner', async ({ page }) => {
|
||||||
|
const asset = selectRandom(fixture.assets, rng);
|
||||||
|
await openFaceEditor(page, asset);
|
||||||
|
|
||||||
|
await dragFaceBox(page, -300, -300);
|
||||||
|
|
||||||
|
const faceBox = await getFaceBoxRect(page);
|
||||||
|
const selectorBox = await getSelectorRect(page);
|
||||||
|
const overlap = computeOverlapArea(faceBox, selectorBox);
|
||||||
|
|
||||||
|
expect(overlap).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Selector repositions without overlap after dragging face box to bottom-right', async ({ page }) => {
|
||||||
|
const asset = selectRandom(fixture.assets, rng);
|
||||||
|
await openFaceEditor(page, asset);
|
||||||
|
|
||||||
|
await dragFaceBox(page, 300, 300);
|
||||||
|
|
||||||
|
const faceBox = await getFaceBoxRect(page);
|
||||||
|
const selectorBox = await getSelectorRect(page);
|
||||||
|
const overlap = computeOverlapArea(faceBox, selectorBox);
|
||||||
|
|
||||||
|
expect(overlap).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Selector stays within viewport bounds', async ({ page }) => {
|
||||||
|
const asset = selectRandom(fixture.assets, rng);
|
||||||
|
await openFaceEditor(page, asset);
|
||||||
|
|
||||||
|
const viewportSize = page.viewportSize()!;
|
||||||
|
const selectorBox = await getSelectorRect(page);
|
||||||
|
|
||||||
|
expect(selectorBox.top).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(selectorBox.left).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(selectorBox.top + selectorBox.height).toBeLessThanOrEqual(viewportSize.height);
|
||||||
|
expect(selectorBox.left + selectorBox.width).toBeLessThanOrEqual(viewportSize.width);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Selector stays within viewport after dragging to edge', async ({ page }) => {
|
||||||
|
const asset = selectRandom(fixture.assets, rng);
|
||||||
|
await openFaceEditor(page, asset);
|
||||||
|
|
||||||
|
await dragFaceBox(page, -400, -400);
|
||||||
|
|
||||||
|
const viewportSize = page.viewportSize()!;
|
||||||
|
const selectorBox = await getSelectorRect(page);
|
||||||
|
|
||||||
|
expect(selectorBox.top).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(selectorBox.left).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(selectorBox.top + selectorBox.height).toBeLessThanOrEqual(viewportSize.height);
|
||||||
|
expect(selectorBox.left + selectorBox.width).toBeLessThanOrEqual(viewportSize.width);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Face box is draggable on the canvas', async ({ page }) => {
|
||||||
|
const asset = selectRandom(fixture.assets, rng);
|
||||||
|
await openFaceEditor(page, asset);
|
||||||
|
|
||||||
|
const beforeDrag = await getFaceBoxRect(page);
|
||||||
|
await dragFaceBox(page, 100, 50);
|
||||||
|
const afterDrag = await getFaceBoxRect(page);
|
||||||
|
|
||||||
|
expect(afterDrag.left).toBeGreaterThan(beforeDrag.left + 50);
|
||||||
|
expect(afterDrag.top).toBeGreaterThan(beforeDrag.top + 20);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { faker } from '@faker-js/faker';
|
||||||
|
import type { AssetResponseDto } from '@immich/sdk';
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import { toAssetResponseDto } from 'src/ui/generators/timeline';
|
||||||
|
import {
|
||||||
|
createMockStack,
|
||||||
|
createMockStackAsset,
|
||||||
|
MockStack,
|
||||||
|
setupBrokenAssetMockApiRoutes,
|
||||||
|
} from 'src/ui/mock-network/broken-asset-network';
|
||||||
|
import { assetViewerUtils } from '../timeline/utils';
|
||||||
|
import { enableTagsPreference, ensureDetailPanelVisible, setupAssetViewerFixture } from './utils';
|
||||||
|
|
||||||
|
test.describe.configure({ mode: 'parallel' });
|
||||||
|
test.describe('asset-viewer stack', () => {
|
||||||
|
const fixture = setupAssetViewerFixture(888);
|
||||||
|
let mockStack: MockStack;
|
||||||
|
let primaryAssetDto: AssetResponseDto;
|
||||||
|
let secondAssetDto: AssetResponseDto;
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
primaryAssetDto = toAssetResponseDto(fixture.primaryAsset);
|
||||||
|
primaryAssetDto.tags = [
|
||||||
|
{
|
||||||
|
id: faker.string.uuid(),
|
||||||
|
name: '1',
|
||||||
|
value: 'test/1',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
secondAssetDto = createMockStackAsset(fixture.adminUserId);
|
||||||
|
secondAssetDto.tags = [
|
||||||
|
{
|
||||||
|
id: faker.string.uuid(),
|
||||||
|
name: '2',
|
||||||
|
value: 'test/2',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
mockStack = createMockStack(primaryAssetDto, [secondAssetDto], new Set());
|
||||||
|
});
|
||||||
|
|
||||||
|
test.beforeEach(async ({ context }) => {
|
||||||
|
await setupBrokenAssetMockApiRoutes(context, mockStack);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stack slideshow is visible', async ({ page }) => {
|
||||||
|
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||||
|
await assetViewerUtils.waitForViewerLoad(page, fixture.primaryAsset);
|
||||||
|
|
||||||
|
const stackSlideshow = page.locator('#stack-slideshow');
|
||||||
|
await expect(stackSlideshow).toBeVisible();
|
||||||
|
|
||||||
|
const stackAssets = stackSlideshow.locator('[data-asset]');
|
||||||
|
await expect(stackAssets).toHaveCount(mockStack.assets.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tags of primary asset are visible', async ({ context, page }) => {
|
||||||
|
await enableTagsPreference(context);
|
||||||
|
|
||||||
|
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||||
|
await ensureDetailPanelVisible(page);
|
||||||
|
|
||||||
|
const tags = page.getByTestId('detail-panel-tags').getByRole('link');
|
||||||
|
await expect(tags.first()).toHaveText('test/1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tags of second asset are visible', async ({ context, page }) => {
|
||||||
|
await enableTagsPreference(context);
|
||||||
|
|
||||||
|
await page.goto(`/photos/${fixture.primaryAsset.id}`);
|
||||||
|
await ensureDetailPanelVisible(page);
|
||||||
|
|
||||||
|
const stackAssets = page.locator('#stack-slideshow [data-asset]');
|
||||||
|
await stackAssets.nth(1).click();
|
||||||
|
|
||||||
|
const tags = page.getByTestId('detail-panel-tags').getByRole('link');
|
||||||
|
await expect(tags.first()).toHaveText('test/2');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { faker } from '@faker-js/faker';
|
||||||
|
import type { AssetResponseDto } from '@immich/sdk';
|
||||||
|
import { BrowserContext, Page, test } from '@playwright/test';
|
||||||
|
import {
|
||||||
|
Changes,
|
||||||
|
createDefaultTimelineConfig,
|
||||||
|
generateTimelineData,
|
||||||
|
SeededRandom,
|
||||||
|
selectRandom,
|
||||||
|
TimelineAssetConfig,
|
||||||
|
TimelineData,
|
||||||
|
toAssetResponseDto,
|
||||||
|
} from 'src/ui/generators/timeline';
|
||||||
|
import { setupBaseMockApiRoutes } from 'src/ui/mock-network/base-network';
|
||||||
|
import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/ui/mock-network/timeline-network';
|
||||||
|
import { utils } from 'src/utils';
|
||||||
|
|
||||||
|
export type AssetViewerTestFixture = {
|
||||||
|
adminUserId: string;
|
||||||
|
timelineRestData: TimelineData;
|
||||||
|
assets: TimelineAssetConfig[];
|
||||||
|
testContext: TimelineTestContext;
|
||||||
|
changes: Changes;
|
||||||
|
primaryAsset: TimelineAssetConfig;
|
||||||
|
primaryAssetDto: AssetResponseDto;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function setupAssetViewerFixture(seed: number): AssetViewerTestFixture {
|
||||||
|
const rng = new SeededRandom(seed);
|
||||||
|
const testContext = new TimelineTestContext();
|
||||||
|
|
||||||
|
const fixture: AssetViewerTestFixture = {
|
||||||
|
adminUserId: undefined!,
|
||||||
|
timelineRestData: undefined!,
|
||||||
|
assets: [],
|
||||||
|
testContext,
|
||||||
|
changes: {
|
||||||
|
albumAdditions: [],
|
||||||
|
assetDeletions: [],
|
||||||
|
assetArchivals: [],
|
||||||
|
assetFavorites: [],
|
||||||
|
},
|
||||||
|
primaryAsset: undefined!,
|
||||||
|
primaryAssetDto: undefined!,
|
||||||
|
};
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
test.fail(
|
||||||
|
process.env.PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS !== '1',
|
||||||
|
'This test requires env var: PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1',
|
||||||
|
);
|
||||||
|
utils.initSdk();
|
||||||
|
fixture.adminUserId = faker.string.uuid();
|
||||||
|
testContext.adminId = fixture.adminUserId;
|
||||||
|
fixture.timelineRestData = generateTimelineData({
|
||||||
|
...createDefaultTimelineConfig(),
|
||||||
|
ownerId: fixture.adminUserId,
|
||||||
|
});
|
||||||
|
for (const timeBucket of fixture.timelineRestData.buckets.values()) {
|
||||||
|
fixture.assets.push(...timeBucket);
|
||||||
|
}
|
||||||
|
|
||||||
|
fixture.primaryAsset = selectRandom(
|
||||||
|
fixture.assets.filter((a) => a.isImage),
|
||||||
|
rng,
|
||||||
|
);
|
||||||
|
fixture.primaryAssetDto = toAssetResponseDto(fixture.primaryAsset);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.beforeEach(async ({ context }) => {
|
||||||
|
await setupBaseMockApiRoutes(context, fixture.adminUserId);
|
||||||
|
await setupTimelineMockApiRoutes(context, fixture.timelineRestData, fixture.changes, fixture.testContext);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach(() => {
|
||||||
|
fixture.testContext.slowBucket = false;
|
||||||
|
fixture.changes.albumAdditions = [];
|
||||||
|
fixture.changes.assetDeletions = [];
|
||||||
|
fixture.changes.assetArchivals = [];
|
||||||
|
fixture.changes.assetFavorites = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
return fixture;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureDetailPanelVisible(page: Page) {
|
||||||
|
await page.waitForSelector('#immich-asset-viewer');
|
||||||
|
|
||||||
|
const isVisible = await page.locator('#detail-panel').isVisible();
|
||||||
|
if (!isVisible) {
|
||||||
|
await page.keyboard.press('i');
|
||||||
|
await page.waitForSelector('#detail-panel');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function enableTagsPreference(context: BrowserContext) {
|
||||||
|
await context.route('**/users/me/preferences', async (route) => {
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
json: {
|
||||||
|
albums: { defaultAssetOrder: 'desc' },
|
||||||
|
folders: { enabled: false, sidebarWeb: false },
|
||||||
|
memories: { enabled: true, duration: 5 },
|
||||||
|
people: { enabled: true, sidebarWeb: false },
|
||||||
|
sharedLinks: { enabled: true, sidebarWeb: false },
|
||||||
|
ratings: { enabled: false },
|
||||||
|
tags: { enabled: true, sidebarWeb: false },
|
||||||
|
emailNotifications: { enabled: true, albumInvite: true, albumUpdate: true },
|
||||||
|
download: { archiveSize: 4_294_967_296, includeEmbeddedVideos: false },
|
||||||
|
purchase: { showSupportBadge: true, hideBuyButtonUntil: '2100-02-12T00:00:00.000Z' },
|
||||||
|
cast: { gCastEnabled: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
+4
-4
@@ -871,8 +871,8 @@
|
|||||||
"current_pin_code": "Current PIN code",
|
"current_pin_code": "Current PIN code",
|
||||||
"current_server_address": "Current server address",
|
"current_server_address": "Current server address",
|
||||||
"custom_date": "Custom date",
|
"custom_date": "Custom date",
|
||||||
"custom_locale": "Custom Locale",
|
"custom_locale": "Custom locale",
|
||||||
"custom_locale_description": "Format dates and numbers based on the language and the region",
|
"custom_locale_description": "Format dates, times, and numbers based on the selected language and region",
|
||||||
"custom_url": "Custom URL",
|
"custom_url": "Custom URL",
|
||||||
"cutoff_date_description": "Keep photos from the last…",
|
"cutoff_date_description": "Keep photos from the last…",
|
||||||
"cutoff_day": "{count, plural, one {day} other {days}}",
|
"cutoff_day": "{count, plural, one {day} other {days}}",
|
||||||
@@ -895,8 +895,6 @@
|
|||||||
"deduplication_criteria_2": "Count of EXIF data",
|
"deduplication_criteria_2": "Count of EXIF data",
|
||||||
"deduplication_info": "Deduplication Info",
|
"deduplication_info": "Deduplication Info",
|
||||||
"deduplication_info_description": "To automatically preselect assets and remove duplicates in bulk, we look at:",
|
"deduplication_info_description": "To automatically preselect assets and remove duplicates in bulk, we look at:",
|
||||||
"default_locale": "Default Locale",
|
|
||||||
"default_locale_description": "Format dates and numbers based on your browser locale",
|
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"delete_action_confirmation_message": "Are you sure you want to delete this asset? This action will move the asset to the server's trash and will prompt if you want to delete it locally",
|
"delete_action_confirmation_message": "Are you sure you want to delete this asset? This action will move the asset to the server's trash and will prompt if you want to delete it locally",
|
||||||
"delete_action_prompt": "{count} deleted",
|
"delete_action_prompt": "{count} deleted",
|
||||||
@@ -2338,6 +2336,8 @@
|
|||||||
"url": "URL",
|
"url": "URL",
|
||||||
"usage": "Usage",
|
"usage": "Usage",
|
||||||
"use_biometric": "Use biometric",
|
"use_biometric": "Use biometric",
|
||||||
|
"use_browser_locale": "Use browser locale",
|
||||||
|
"use_browser_locale_description": "Format dates, times, and numbers based on your browser locale",
|
||||||
"use_current_connection": "Use current connection",
|
"use_current_connection": "Use current connection",
|
||||||
"use_custom_date_range": "Use custom date range instead",
|
"use_custom_date_range": "Use custom date range instead",
|
||||||
"user": "User",
|
"user": "User",
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ config_roots = [
|
|||||||
[tools]
|
[tools]
|
||||||
node = "24.13.1"
|
node = "24.13.1"
|
||||||
flutter = "3.35.7"
|
flutter = "3.35.7"
|
||||||
pnpm = "10.30.0"
|
pnpm = "10.30.3"
|
||||||
terragrunt = "0.98.0"
|
terragrunt = "0.99.4"
|
||||||
opentofu = "1.11.4"
|
opentofu = "1.11.5"
|
||||||
java = "21.0.2"
|
java = "21.0.2"
|
||||||
|
|
||||||
[tools."github:CQLabs/homebrew-dcm"]
|
[tools."github:CQLabs/homebrew-dcm"]
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import android.database.Cursor
|
|||||||
import androidx.exifinterface.media.ExifInterface
|
import androidx.exifinterface.media.ExifInterface
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.os.ext.SdkExtensions
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
@@ -78,15 +79,22 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
|
|||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
add(MediaStore.MediaColumns.IS_FAVORITE)
|
add(MediaStore.MediaColumns.IS_FAVORITE)
|
||||||
}
|
}
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
if (hasSpecialFormatColumn()) {
|
||||||
add(SPECIAL_FORMAT_COLUMN)
|
add(SPECIAL_FORMAT_COLUMN)
|
||||||
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
// Fallback: read XMP from MediaStore to detect Motion Photos
|
// Fallback: read XMP from MediaStore to detect Motion Photos
|
||||||
|
// only needed if SPECIAL_FORMAT column isn't available
|
||||||
add(MediaStore.MediaColumns.XMP)
|
add(MediaStore.MediaColumns.XMP)
|
||||||
}
|
}
|
||||||
}.toTypedArray()
|
}.toTypedArray()
|
||||||
|
|
||||||
const val HASH_BUFFER_SIZE = 2 * 1024 * 1024
|
const val HASH_BUFFER_SIZE = 2 * 1024 * 1024
|
||||||
|
|
||||||
|
// _special_format requires S Extensions 21+
|
||||||
|
// https://developer.android.com/reference/android/provider/MediaStore.Files.FileColumns#SPECIAL_FORMAT
|
||||||
|
private fun hasSpecialFormatColumn(): Boolean =
|
||||||
|
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
|
||||||
|
SdkExtensions.getExtensionVersion(Build.VERSION_CODES.S) >= 21
|
||||||
}
|
}
|
||||||
|
|
||||||
protected fun getCursor(
|
protected fun getCursor(
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import 'package:immich_mobile/providers/asset_viewer/asset_stack.provider.dart';
|
|||||||
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
|
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
|
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
|
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/cast.provider.dart';
|
import 'package:immich_mobile/providers/cast.provider.dart';
|
||||||
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
|
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
|
||||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||||
@@ -367,9 +366,6 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||||||
stackIndex.value = 0;
|
stackIndex.value = 0;
|
||||||
|
|
||||||
ref.read(currentAssetProvider.notifier).set(newAsset);
|
ref.read(currentAssetProvider.notifier).set(newAsset);
|
||||||
if (newAsset.isVideo || newAsset.isMotionPhoto) {
|
|
||||||
ref.read(videoPlaybackValueProvider.notifier).reset();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for page change animation to finish, then precache the next image
|
// Wait for page change animation to finish, then precache the next image
|
||||||
Timer(const Duration(milliseconds: 400), () {
|
Timer(const Duration(milliseconds: 400), () {
|
||||||
|
|||||||
@@ -11,18 +11,14 @@ import 'package:immich_mobile/entities/store.entity.dart';
|
|||||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
|
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
|
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
|
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/cast.provider.dart';
|
import 'package:immich_mobile/providers/cast.provider.dart';
|
||||||
import 'package:immich_mobile/services/api.service.dart';
|
import 'package:immich_mobile/services/api.service.dart';
|
||||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||||
import 'package:immich_mobile/services/asset.service.dart';
|
import 'package:immich_mobile/services/asset.service.dart';
|
||||||
import 'package:immich_mobile/utils/debounce.dart';
|
|
||||||
import 'package:immich_mobile/utils/hooks/interval_hook.dart';
|
|
||||||
import 'package:immich_mobile/widgets/asset_viewer/custom_video_player_controls.dart';
|
import 'package:immich_mobile/widgets/asset_viewer/custom_video_player_controls.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:native_video_player/native_video_player.dart';
|
import 'package:native_video_player/native_video_player.dart';
|
||||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
|
||||||
|
|
||||||
@RoutePage()
|
@RoutePage()
|
||||||
class NativeVideoViewerPage extends HookConsumerWidget {
|
class NativeVideoViewerPage extends HookConsumerWidget {
|
||||||
@@ -42,18 +38,10 @@ class NativeVideoViewerPage extends HookConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final videoId = asset.id.toString();
|
||||||
final controller = useState<NativeVideoPlayerController?>(null);
|
final controller = useState<NativeVideoPlayerController?>(null);
|
||||||
final lastVideoPosition = useRef(-1);
|
|
||||||
final isBuffering = useRef(false);
|
|
||||||
|
|
||||||
// Used to track whether the video should play when the app
|
|
||||||
// is brought back to the foreground
|
|
||||||
final shouldPlayOnForeground = useRef(true);
|
final shouldPlayOnForeground = useRef(true);
|
||||||
|
|
||||||
// When a video is opened through the timeline, `isCurrent` will immediately be true.
|
|
||||||
// When swiping from video A to video B, `isCurrent` will initially be true for video A and false for video B.
|
|
||||||
// If the swipe is completed, `isCurrent` will be true for video B after a delay.
|
|
||||||
// If the swipe is canceled, `currentAsset` will not have changed and video A will continue to play.
|
|
||||||
final currentAsset = useState(ref.read(currentAssetProvider));
|
final currentAsset = useState(ref.read(currentAssetProvider));
|
||||||
final isCurrent = currentAsset.value == asset;
|
final isCurrent = currentAsset.value == asset;
|
||||||
|
|
||||||
@@ -117,127 +105,45 @@ class NativeVideoViewerPage extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
void checkIfBuffering() {
|
|
||||||
if (!context.mounted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final videoPlayback = ref.read(videoPlaybackValueProvider);
|
|
||||||
if ((isBuffering.value || videoPlayback.state == VideoPlaybackState.initializing) &&
|
|
||||||
videoPlayback.state != VideoPlaybackState.buffering) {
|
|
||||||
ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback.copyWith(
|
|
||||||
state: VideoPlaybackState.buffering,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Timer to mark videos as buffering if the position does not change
|
|
||||||
useInterval(const Duration(seconds: 5), checkIfBuffering);
|
|
||||||
|
|
||||||
// When the position changes, seek to the position
|
|
||||||
// Debounce the seek to avoid seeking too often
|
|
||||||
// But also don't delay the seek too much to maintain visual feedback
|
|
||||||
final seekDebouncer = useDebouncer(
|
|
||||||
interval: const Duration(milliseconds: 100),
|
|
||||||
maxWaitTime: const Duration(milliseconds: 200),
|
|
||||||
);
|
|
||||||
ref.listen(videoPlayerControlsProvider, (oldControls, newControls) {
|
|
||||||
final playerController = controller.value;
|
|
||||||
if (playerController == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final playbackInfo = playerController.playbackInfo;
|
|
||||||
if (playbackInfo == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final oldSeek = oldControls?.position.inMilliseconds;
|
|
||||||
final newSeek = newControls.position.inMilliseconds;
|
|
||||||
if (oldSeek != newSeek || newControls.restarted) {
|
|
||||||
seekDebouncer.run(() => playerController.seekTo(newSeek));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (oldControls?.pause != newControls.pause || newControls.restarted) {
|
|
||||||
unawaited(_onPauseChange(context, playerController, seekDebouncer, newControls.pause));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
void onPlaybackReady() async {
|
void onPlaybackReady() async {
|
||||||
final videoController = controller.value;
|
final videoController = controller.value;
|
||||||
if (videoController == null || !isCurrent || !context.mounted) {
|
if (videoController == null || !isCurrent || !context.mounted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final videoPlayback = VideoPlaybackValue.fromNativeController(videoController);
|
final notifier = ref.read(videoPlayerProvider(videoId).notifier);
|
||||||
ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback;
|
notifier.onNativePlaybackReady();
|
||||||
|
|
||||||
isVideoReady.value = true;
|
isVideoReady.value = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final autoPlayVideo = ref.read(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.autoPlayVideo);
|
final autoPlayVideo = ref.read(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.autoPlayVideo);
|
||||||
if (autoPlayVideo) {
|
if (autoPlayVideo) {
|
||||||
await videoController.play();
|
await notifier.play();
|
||||||
}
|
}
|
||||||
await videoController.setVolume(0.9);
|
await notifier.setVolume(1);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.severe('Error playing video: $error');
|
log.severe('Error playing video: $error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void onPlaybackStatusChanged() {
|
void onPlaybackStatusChanged() {
|
||||||
final videoController = controller.value;
|
if (!context.mounted) return;
|
||||||
if (videoController == null || !context.mounted) {
|
ref.read(videoPlayerProvider(videoId).notifier).onNativeStatusChanged();
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final videoPlayback = VideoPlaybackValue.fromNativeController(videoController);
|
|
||||||
if (videoPlayback.state == VideoPlaybackState.playing) {
|
|
||||||
// Sync with the controls playing
|
|
||||||
WakelockPlus.enable();
|
|
||||||
} else {
|
|
||||||
// Sync with the controls pause
|
|
||||||
WakelockPlus.disable();
|
|
||||||
}
|
|
||||||
|
|
||||||
ref.read(videoPlaybackValueProvider.notifier).status = videoPlayback.state;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void onPlaybackPositionChanged() {
|
void onPlaybackPositionChanged() {
|
||||||
// When seeking, these events sometimes move the slider to an older position
|
if (!context.mounted) return;
|
||||||
if (seekDebouncer.isActive) {
|
ref.read(videoPlayerProvider(videoId).notifier).onNativePositionChanged();
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final videoController = controller.value;
|
|
||||||
if (videoController == null || !context.mounted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final playbackInfo = videoController.playbackInfo;
|
|
||||||
if (playbackInfo == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ref.read(videoPlaybackValueProvider.notifier).position = Duration(milliseconds: playbackInfo.position);
|
|
||||||
|
|
||||||
// Check if the video is buffering
|
|
||||||
if (playbackInfo.status == PlaybackStatus.playing) {
|
|
||||||
isBuffering.value = lastVideoPosition.value == playbackInfo.position;
|
|
||||||
lastVideoPosition.value = playbackInfo.position;
|
|
||||||
} else {
|
|
||||||
isBuffering.value = false;
|
|
||||||
lastVideoPosition.value = -1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void onPlaybackEnded() {
|
void onPlaybackEnded() {
|
||||||
final videoController = controller.value;
|
if (!context.mounted) return;
|
||||||
if (videoController == null || !context.mounted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (videoController.playbackInfo?.status == PlaybackStatus.stopped &&
|
ref.read(videoPlayerProvider(videoId).notifier).onNativePlaybackEnded();
|
||||||
|
|
||||||
|
final videoController = controller.value;
|
||||||
|
if (videoController?.playbackInfo?.status == PlaybackStatus.stopped &&
|
||||||
!ref.read(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.loopVideo)) {
|
!ref.read(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.loopVideo)) {
|
||||||
ref.read(isPlayingMotionVideoProvider.notifier).playing = false;
|
ref.read(isPlayingMotionVideoProvider.notifier).playing = false;
|
||||||
}
|
}
|
||||||
@@ -254,14 +160,15 @@ class NativeVideoViewerPage extends HookConsumerWidget {
|
|||||||
if (controller.value != null || !context.mounted) {
|
if (controller.value != null || !context.mounted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ref.read(videoPlayerControlsProvider.notifier).reset();
|
|
||||||
ref.read(videoPlaybackValueProvider.notifier).reset();
|
|
||||||
|
|
||||||
final source = await videoSource;
|
final source = await videoSource;
|
||||||
if (source == null) {
|
if (source == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final notifier = ref.read(videoPlayerProvider(videoId).notifier);
|
||||||
|
notifier.attachController(nc);
|
||||||
|
|
||||||
nc.onPlaybackPositionChanged.addListener(onPlaybackPositionChanged);
|
nc.onPlaybackPositionChanged.addListener(onPlaybackPositionChanged);
|
||||||
nc.onPlaybackStatusChanged.addListener(onPlaybackStatusChanged);
|
nc.onPlaybackStatusChanged.addListener(onPlaybackStatusChanged);
|
||||||
nc.onPlaybackReady.addListener(onPlaybackReady);
|
nc.onPlaybackReady.addListener(onPlaybackReady);
|
||||||
@@ -273,10 +180,9 @@ class NativeVideoViewerPage extends HookConsumerWidget {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
final loopVideo = ref.read(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.loopVideo);
|
final loopVideo = ref.read(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.loopVideo);
|
||||||
unawaited(nc.setLoop(loopVideo));
|
await notifier.setLoop(loopVideo);
|
||||||
|
|
||||||
controller.value = nc;
|
controller.value = nc;
|
||||||
Timer(const Duration(milliseconds: 200), checkIfBuffering);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ref.listen(currentAssetProvider, (_, value) {
|
ref.listen(currentAssetProvider, (_, value) {
|
||||||
@@ -300,10 +206,6 @@ class NativeVideoViewerPage extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Delay the video playback to avoid a stutter in the swipe animation
|
// Delay the video playback to avoid a stutter in the swipe animation
|
||||||
// Note, in some circumstances a longer delay is needed (eg: memories),
|
|
||||||
// the playbackDelayFactor can be used for this
|
|
||||||
// This delay seems like a hacky way to resolve underlying bugs in video
|
|
||||||
// playback, but other resolutions failed thus far
|
|
||||||
Timer(
|
Timer(
|
||||||
Platform.isIOS
|
Platform.isIOS
|
||||||
? Duration(milliseconds: 300 * playbackDelayFactor)
|
? Duration(milliseconds: 300 * playbackDelayFactor)
|
||||||
@@ -337,19 +239,18 @@ class NativeVideoViewerPage extends HookConsumerWidget {
|
|||||||
playerController.stop().catchError((error) {
|
playerController.stop().catchError((error) {
|
||||||
log.fine('Error stopping video: $error');
|
log.fine('Error stopping video: $error');
|
||||||
});
|
});
|
||||||
|
|
||||||
WakelockPlus.disable();
|
|
||||||
};
|
};
|
||||||
}, const []);
|
}, const []);
|
||||||
|
|
||||||
useOnAppLifecycleStateChange((_, state) async {
|
useOnAppLifecycleStateChange((_, state) async {
|
||||||
|
final notifier = ref.read(videoPlayerProvider(videoId).notifier);
|
||||||
if (state == AppLifecycleState.resumed && shouldPlayOnForeground.value) {
|
if (state == AppLifecycleState.resumed && shouldPlayOnForeground.value) {
|
||||||
await controller.value?.play();
|
await notifier.play();
|
||||||
} else if (state == AppLifecycleState.paused) {
|
} else if (state == AppLifecycleState.paused) {
|
||||||
final videoPlaying = await controller.value?.isPlaying();
|
final videoPlaying = await controller.value?.isPlaying();
|
||||||
if (videoPlaying ?? true) {
|
if (videoPlaying ?? true) {
|
||||||
shouldPlayOnForeground.value = true;
|
shouldPlayOnForeground.value = true;
|
||||||
await controller.value?.pause();
|
await notifier.pause();
|
||||||
} else {
|
} else {
|
||||||
shouldPlayOnForeground.value = false;
|
shouldPlayOnForeground.value = false;
|
||||||
}
|
}
|
||||||
@@ -374,39 +275,8 @@ class NativeVideoViewerPage extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (showControls) const Center(child: CustomVideoPlayerControls()),
|
if (showControls) Center(child: CustomVideoPlayerControls(videoId: videoId)),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onPauseChange(
|
|
||||||
BuildContext context,
|
|
||||||
NativeVideoPlayerController controller,
|
|
||||||
Debouncer seekDebouncer,
|
|
||||||
bool isPaused,
|
|
||||||
) async {
|
|
||||||
if (!context.mounted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Make sure the last seek is complete before pausing or playing
|
|
||||||
// Otherwise, `onPlaybackPositionChanged` can receive outdated events
|
|
||||||
if (seekDebouncer.isActive) {
|
|
||||||
await seekDebouncer.drain();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!context.mounted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (isPaused) {
|
|
||||||
await controller.pause();
|
|
||||||
} else {
|
|
||||||
await controller.play();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
log.severe('Error pausing or playing video: $error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||||
import 'package:immich_mobile/models/memories/memory.model.dart';
|
import 'package:immich_mobile/models/memories/memory.model.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
|
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
|
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
|
||||||
import 'package:immich_mobile/widgets/common/immich_image.dart';
|
import 'package:immich_mobile/widgets/common/immich_image.dart';
|
||||||
import 'package:immich_mobile/widgets/memories/memory_bottom_info.dart';
|
import 'package:immich_mobile/widgets/memories/memory_bottom_info.dart';
|
||||||
@@ -166,9 +165,6 @@ class MemoryPage extends HookConsumerWidget {
|
|||||||
final asset = currentMemory.value.assets[otherIndex];
|
final asset = currentMemory.value.assets[otherIndex];
|
||||||
currentAsset.value = asset;
|
currentAsset.value = asset;
|
||||||
ref.read(currentAssetProvider.notifier).set(asset);
|
ref.read(currentAssetProvider.notifier).set(asset);
|
||||||
if (asset.isVideo || asset.isMotionPhoto) {
|
|
||||||
ref.read(videoPlaybackValueProvider.notifier).reset();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Notification listener is used instead of OnPageChanged callback since OnPageChanged is called
|
/* Notification listener is used instead of OnPageChanged callback since OnPageChanged is called
|
||||||
|
|||||||
@@ -7,16 +7,15 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/memory.model.dart';
|
import 'package:immich_mobile/domain/models/memory.model.dart';
|
||||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||||
|
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/memory/memory_bottom_info.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/memory/memory_bottom_info.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/memory/memory_card.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/memory/memory_card.widget.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
|
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
|
||||||
import 'package:immich_mobile/widgets/memories/memory_epilogue.dart';
|
import 'package:immich_mobile/widgets/memories/memory_epilogue.dart';
|
||||||
import 'package:immich_mobile/widgets/memories/memory_progress_indicator.dart';
|
import 'package:immich_mobile/widgets/memories/memory_progress_indicator.dart';
|
||||||
|
|
||||||
/// Expects [currentAssetNotifier] to be set before navigating to this page
|
/// Expects the current asset to be set via [assetViewerProvider] before navigating to this page
|
||||||
@RoutePage()
|
@RoutePage()
|
||||||
class DriftMemoryPage extends HookConsumerWidget {
|
class DriftMemoryPage extends HookConsumerWidget {
|
||||||
final List<DriftMemory> memories;
|
final List<DriftMemory> memories;
|
||||||
@@ -26,11 +25,7 @@ class DriftMemoryPage extends HookConsumerWidget {
|
|||||||
|
|
||||||
static void setMemory(WidgetRef ref, DriftMemory memory) {
|
static void setMemory(WidgetRef ref, DriftMemory memory) {
|
||||||
if (memory.assets.isNotEmpty) {
|
if (memory.assets.isNotEmpty) {
|
||||||
ref.read(currentAssetNotifier.notifier).setAsset(memory.assets.first);
|
ref.read(assetViewerProvider.notifier).setAsset(memory.assets.first);
|
||||||
|
|
||||||
if (memory.assets.first.isVideo) {
|
|
||||||
ref.read(videoPlaybackValueProvider.notifier).reset();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,11 +167,7 @@ class DriftMemoryPage extends HookConsumerWidget {
|
|||||||
|
|
||||||
final asset = currentMemory.value.assets[otherIndex];
|
final asset = currentMemory.value.assets[otherIndex];
|
||||||
currentAsset.value = asset;
|
currentAsset.value = asset;
|
||||||
ref.read(currentAssetNotifier.notifier).setAsset(asset);
|
ref.read(assetViewerProvider.notifier).setAsset(asset);
|
||||||
// if (asset.isVideo || asset.isMotionPhoto) {
|
|
||||||
if (asset.isVideo) {
|
|
||||||
ref.read(videoPlaybackValueProvider.notifier).reset();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Notification listener is used instead of OnPageChanged callback since OnPageChanged is called
|
/* Notification listener is used instead of OnPageChanged callback since OnPageChanged is called
|
||||||
@@ -273,7 +264,12 @@ class DriftMemoryPage extends HookConsumerWidget {
|
|||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
color: Colors.black,
|
color: Colors.black,
|
||||||
child: DriftMemoryCard(asset: asset, title: title, showTitle: index == 0),
|
child: DriftMemoryCard(
|
||||||
|
asset: asset,
|
||||||
|
title: title,
|
||||||
|
showTitle: index == 0,
|
||||||
|
isCurrent: mIndex == currentMemoryIndex.value && index == currentAssetPage.value,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: Row(
|
child: Row(
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||||
import 'package:immich_mobile/providers/routes.provider.dart';
|
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||||
@@ -49,7 +49,7 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
List<Widget> _buildMenuChildren() {
|
List<Widget> _buildMenuChildren() {
|
||||||
final asset = ref.read(currentAssetNotifier);
|
final asset = ref.read(assetViewerProvider).currentAsset;
|
||||||
if (asset == null) return [];
|
if (asset == null) return [];
|
||||||
|
|
||||||
final user = ref.read(currentUserProvider);
|
final user = ref.read(currentUserProvider);
|
||||||
@@ -103,7 +103,7 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _openAlbumSelector() {
|
void _openAlbumSelector() {
|
||||||
final currentAsset = ref.read(currentAssetNotifier);
|
final currentAsset = ref.read(assetViewerProvider).currentAsset;
|
||||||
if (currentAsset == null) {
|
if (currentAsset == null) {
|
||||||
ImmichToast.show(context: context, msg: "Cannot load asset information.", toastType: ToastType.error);
|
ImmichToast.show(context: context, msg: "Cannot load asset information.", toastType: ToastType.error);
|
||||||
return;
|
return;
|
||||||
@@ -133,7 +133,7 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _addCurrentAssetToAlbum(RemoteAlbum album) async {
|
Future<void> _addCurrentAssetToAlbum(RemoteAlbum album) async {
|
||||||
final latest = ref.read(currentAssetNotifier);
|
final latest = ref.read(assetViewerProvider).currentAsset;
|
||||||
|
|
||||||
if (latest == null) {
|
if (latest == null) {
|
||||||
ImmichToast.show(context: context, msg: "Cannot load asset information.", toastType: ToastType.error);
|
ImmichToast.show(context: context, msg: "Cannot load asset information.", toastType: ToastType.error);
|
||||||
@@ -169,7 +169,7 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final asset = ref.watch(currentAssetNotifier);
|
final asset = ref.watch(assetViewerProvider.select((s) => s.currentAsset));
|
||||||
if (asset == null) {
|
if (asset == null) {
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
|
|
||||||
class EditImageActionButton extends ConsumerWidget {
|
class EditImageActionButton extends ConsumerWidget {
|
||||||
@@ -12,7 +12,7 @@ class EditImageActionButton extends ConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final currentAsset = ref.watch(currentAssetNotifier);
|
final currentAsset = ref.watch(assetViewerProvider.select((s) => s.currentAsset));
|
||||||
|
|
||||||
onPress() {
|
onPress() {
|
||||||
if (currentAsset == null) {
|
if (currentAsset == null) {
|
||||||
|
|||||||
+2
-2
@@ -7,7 +7,7 @@ import 'package:immich_mobile/extensions/translate_extensions.dart';
|
|||||||
import 'package:immich_mobile/models/activities/activity.model.dart';
|
import 'package:immich_mobile/models/activities/activity.model.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/providers/activity.provider.dart';
|
import 'package:immich_mobile/providers/activity.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
|
||||||
import 'package:immich_mobile/providers/user.provider.dart';
|
import 'package:immich_mobile/providers/user.provider.dart';
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ class LikeActivityActionButton extends ConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final album = ref.watch(currentRemoteAlbumProvider);
|
final album = ref.watch(currentRemoteAlbumProvider);
|
||||||
final asset = ref.watch(currentAssetNotifier) as RemoteAsset?;
|
final asset = ref.watch(assetViewerProvider.select((s) => s.currentAsset)) as RemoteAsset?;
|
||||||
final user = ref.watch(currentUserProvider);
|
final user = ref.watch(currentUserProvider);
|
||||||
|
|
||||||
final activities = ref.watch(albumActivityProvider(album?.id ?? "", asset?.id));
|
final activities = ref.watch(albumActivityProvider(album?.id ?? "", asset?.id));
|
||||||
|
|||||||
+1
-1
@@ -8,7 +8,7 @@ import 'package:immich_mobile/extensions/translate_extensions.dart';
|
|||||||
import 'package:immich_mobile/models/search/search_filter.model.dart';
|
import 'package:immich_mobile/models/search/search_filter.model.dart';
|
||||||
import 'package:immich_mobile/presentation/pages/search/paginated_search.provider.dart';
|
import 'package:immich_mobile/presentation/pages/search/paginated_search.provider.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
|
|
||||||
class SimilarPhotosActionButton extends ConsumerWidget {
|
class SimilarPhotosActionButton extends ConsumerWidget {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dar
|
|||||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||||
import 'package:immich_mobile/providers/user.provider.dart';
|
import 'package:immich_mobile/providers/user.provider.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
@@ -809,7 +809,7 @@ class CreateAlbumButton extends ConsumerWidget {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final asset = ref.read(currentAssetNotifier);
|
final asset = ref.read(assetViewerProvider).currentAsset;
|
||||||
|
|
||||||
if (asset == null) {
|
if (asset == null) {
|
||||||
ImmichToast.show(context: context, msg: "Cannot load asset information.", toastType: ToastType.error);
|
ImmichToast.show(context: context, msg: "Cannot load asset information.", toastType: ToastType.error);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/appears_in_details.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/appears_in_details.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/date_time_details.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/date_time_details.widget.dart';
|
||||||
@@ -11,16 +12,15 @@ import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/te
|
|||||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||||
|
|
||||||
class AssetDetails extends ConsumerWidget {
|
class AssetDetails extends ConsumerWidget {
|
||||||
|
final BaseAsset asset;
|
||||||
final double minHeight;
|
final double minHeight;
|
||||||
|
|
||||||
const AssetDetails({required this.minHeight, super.key});
|
const AssetDetails({super.key, required this.asset, required this.minHeight});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final asset = ref.watch(currentAssetNotifier);
|
final exifInfo = ref.watch(assetExifProvider(asset)).valueOrNull;
|
||||||
if (asset == null) {
|
|
||||||
return const SizedBox.shrink();
|
|
||||||
}
|
|
||||||
return Container(
|
return Container(
|
||||||
constraints: BoxConstraints(minHeight: minHeight),
|
constraints: BoxConstraints(minHeight: minHeight),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -31,12 +31,12 @@ class AssetDetails extends ConsumerWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const DragHandle(),
|
const DragHandle(),
|
||||||
const DateTimeDetails(),
|
DateTimeDetails(asset: asset, exifInfo: exifInfo),
|
||||||
const PeopleDetails(),
|
PeopleDetails(asset: asset),
|
||||||
const LocationDetails(),
|
LocationDetails(asset: asset, exifInfo: exifInfo),
|
||||||
const TechnicalDetails(),
|
TechnicalDetails(asset: asset, exifInfo: exifInfo),
|
||||||
const RatingDetails(),
|
RatingDetails(exifInfo: exifInfo),
|
||||||
const AppearsInDetails(),
|
AppearsInDetails(asset: asset),
|
||||||
SizedBox(height: context.padding.bottom + 48),
|
SizedBox(height: context.padding.bottom + 48),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
+9
-11
@@ -8,27 +8,25 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
|||||||
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/album/album_tile.dart';
|
import 'package:immich_mobile/presentation/widgets/album/album_tile.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/user.provider.dart';
|
import 'package:immich_mobile/providers/user.provider.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
|
|
||||||
class AppearsInDetails extends ConsumerWidget {
|
class AppearsInDetails extends ConsumerWidget {
|
||||||
const AppearsInDetails({super.key});
|
final BaseAsset asset;
|
||||||
|
|
||||||
|
const AppearsInDetails({super.key, required this.asset});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final asset = ref.watch(currentAssetNotifier);
|
if (!asset.hasRemote) return const SizedBox.shrink();
|
||||||
if (asset == null || !asset.hasRemote) return const SizedBox.shrink();
|
|
||||||
|
|
||||||
String? remoteAssetId;
|
final remoteAssetId = switch (asset) {
|
||||||
if (asset is RemoteAsset) {
|
RemoteAsset(:final id) => id,
|
||||||
remoteAssetId = asset.id;
|
LocalAsset(:final remoteAssetId) => remoteAssetId,
|
||||||
} else if (asset is LocalAsset) {
|
};
|
||||||
remoteAssetId = asset.remoteAssetId;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (remoteAssetId == null) return const SizedBox.shrink();
|
if (remoteAssetId == null) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
|||||||
+8
-10
@@ -10,7 +10,6 @@ import 'package:immich_mobile/extensions/duration_extensions.dart';
|
|||||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/user.provider.dart';
|
import 'package:immich_mobile/providers/user.provider.dart';
|
||||||
import 'package:immich_mobile/utils/timezone.dart';
|
import 'package:immich_mobile/utils/timezone.dart';
|
||||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||||
@@ -18,14 +17,15 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
|||||||
const _kSeparator = ' • ';
|
const _kSeparator = ' • ';
|
||||||
|
|
||||||
class DateTimeDetails extends ConsumerWidget {
|
class DateTimeDetails extends ConsumerWidget {
|
||||||
const DateTimeDetails({super.key});
|
final BaseAsset asset;
|
||||||
|
final ExifInfo? exifInfo;
|
||||||
|
|
||||||
|
const DateTimeDetails({super.key, required this.asset, this.exifInfo});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final asset = ref.watch(currentAssetNotifier);
|
final asset = this.asset;
|
||||||
if (asset == null) return const SizedBox.shrink();
|
final exifInfo = this.exifInfo;
|
||||||
|
|
||||||
final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
|
|
||||||
final isOwner = ref.watch(currentUserProvider)?.id == (asset is RemoteAsset ? asset.ownerId : null);
|
final isOwner = ref.watch(currentUserProvider)?.id == (asset is RemoteAsset ? asset.ownerId : null);
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
@@ -106,9 +106,7 @@ class _SheetAssetDescriptionState extends ConsumerState<_SheetAssetDescription>
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final currentExifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
|
final currentDescription = widget.exif.description ?? '';
|
||||||
|
|
||||||
final currentDescription = currentExifInfo?.description ?? '';
|
|
||||||
final hintText = (widget.isEditable ? 'exif_bottom_sheet_description' : 'exif_bottom_sheet_no_description').t(
|
final hintText = (widget.isEditable ? 'exif_bottom_sheet_description' : 'exif_bottom_sheet_no_description').t(
|
||||||
context: context,
|
context: context,
|
||||||
);
|
);
|
||||||
@@ -134,7 +132,7 @@ class _SheetAssetDescriptionState extends ConsumerState<_SheetAssetDescription>
|
|||||||
errorBorder: InputBorder.none,
|
errorBorder: InputBorder.none,
|
||||||
focusedErrorBorder: InputBorder.none,
|
focusedErrorBorder: InputBorder.none,
|
||||||
),
|
),
|
||||||
onTapOutside: (_) => saveDescription(currentExifInfo?.description),
|
onTapOutside: (_) => saveDescription(widget.exif.description),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
+14
-14
@@ -8,12 +8,14 @@ import 'package:immich_mobile/extensions/theme_extensions.dart';
|
|||||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
|
||||||
import 'package:immich_mobile/widgets/asset_viewer/detail_panel/exif_map.dart';
|
import 'package:immich_mobile/widgets/asset_viewer/detail_panel/exif_map.dart';
|
||||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||||
|
|
||||||
class LocationDetails extends ConsumerStatefulWidget {
|
class LocationDetails extends ConsumerStatefulWidget {
|
||||||
const LocationDetails({super.key});
|
final BaseAsset asset;
|
||||||
|
final ExifInfo? exifInfo;
|
||||||
|
|
||||||
|
const LocationDetails({super.key, required this.asset, this.exifInfo});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ConsumerState createState() => _LocationDetailsState();
|
ConsumerState createState() => _LocationDetailsState();
|
||||||
@@ -40,17 +42,15 @@ class _LocationDetailsState extends ConsumerState<LocationDetails> {
|
|||||||
_mapController = controller;
|
_mapController = controller;
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onExifChanged(AsyncValue<ExifInfo?>? previous, AsyncValue<ExifInfo?> current) {
|
|
||||||
final currentExif = current.valueOrNull;
|
|
||||||
if (currentExif != null && currentExif.hasCoordinates) {
|
|
||||||
_mapController?.moveCamera(CameraUpdate.newLatLng(LatLng(currentExif.latitude!, currentExif.longitude!)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void didUpdateWidget(LocationDetails oldWidget) {
|
||||||
super.initState();
|
super.didUpdateWidget(oldWidget);
|
||||||
ref.listenManual(currentAssetExifProvider, _onExifChanged, fireImmediately: true);
|
if (widget.exifInfo != oldWidget.exifInfo) {
|
||||||
|
final exif = widget.exifInfo;
|
||||||
|
if (exif != null && exif.hasCoordinates) {
|
||||||
|
_mapController?.moveCamera(CameraUpdate.newLatLng(LatLng(exif.latitude!, exif.longitude!)));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void editLocation() async {
|
void editLocation() async {
|
||||||
@@ -59,8 +59,8 @@ class _LocationDetailsState extends ConsumerState<LocationDetails> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final asset = ref.watch(currentAssetNotifier);
|
final asset = widget.asset;
|
||||||
final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
|
final exifInfo = widget.exifInfo;
|
||||||
final hasCoordinates = exifInfo?.hasCoordinates ?? false;
|
final hasCoordinates = exifInfo?.hasCoordinates ?? false;
|
||||||
|
|
||||||
// Guard local assets
|
// Guard local assets
|
||||||
|
|||||||
+6
-10
@@ -7,7 +7,6 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
|||||||
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/people/person_edit_name_modal.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/people/person_edit_name_modal.widget.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/infrastructure/people.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/people.provider.dart';
|
||||||
import 'package:immich_mobile/providers/routes.provider.dart';
|
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
|
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
|
||||||
@@ -15,17 +14,14 @@ import 'package:immich_mobile/routing/router.dart';
|
|||||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||||
import 'package:immich_mobile/utils/people.utils.dart';
|
import 'package:immich_mobile/utils/people.utils.dart';
|
||||||
|
|
||||||
class PeopleDetails extends ConsumerStatefulWidget {
|
class PeopleDetails extends ConsumerWidget {
|
||||||
const PeopleDetails({super.key});
|
final BaseAsset asset;
|
||||||
|
|
||||||
|
const PeopleDetails({super.key, required this.asset});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ConsumerState createState() => _PeopleDetailsState();
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
}
|
final asset = this.asset;
|
||||||
|
|
||||||
class _PeopleDetailsState extends ConsumerState<PeopleDetails> {
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final asset = ref.watch(currentAssetNotifier);
|
|
||||||
if (asset is! RemoteAsset) {
|
if (asset is! RemoteAsset) {
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-4
@@ -1,16 +1,18 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/constants/enums.dart';
|
import 'package:immich_mobile/constants/enums.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/exif.model.dart';
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/rating_bar.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/asset_viewer/rating_bar.widget.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart';
|
||||||
|
|
||||||
class RatingDetails extends ConsumerWidget {
|
class RatingDetails extends ConsumerWidget {
|
||||||
const RatingDetails({super.key});
|
final ExifInfo? exifInfo;
|
||||||
|
|
||||||
|
const RatingDetails({super.key, this.exifInfo});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
@@ -20,8 +22,6 @@ class RatingDetails extends ConsumerWidget {
|
|||||||
|
|
||||||
if (!isRatingEnabled) return const SizedBox.shrink();
|
if (!isRatingEnabled) return const SizedBox.shrink();
|
||||||
|
|
||||||
final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
|
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(left: 16.0, top: 16.0),
|
padding: const EdgeInsets.only(left: 16.0, top: 16.0),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|||||||
+5
-6
@@ -6,21 +6,20 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
|||||||
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
|
||||||
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||||
import 'package:immich_mobile/utils/bytes_units.dart';
|
import 'package:immich_mobile/utils/bytes_units.dart';
|
||||||
|
|
||||||
const _kSeparator = ' • ';
|
const _kSeparator = ' • ';
|
||||||
|
|
||||||
class TechnicalDetails extends ConsumerWidget {
|
class TechnicalDetails extends ConsumerWidget {
|
||||||
const TechnicalDetails({super.key});
|
final BaseAsset asset;
|
||||||
|
final ExifInfo? exifInfo;
|
||||||
|
|
||||||
|
const TechnicalDetails({super.key, required this.asset, this.exifInfo});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final asset = ref.watch(currentAssetNotifier);
|
final exifInfo = this.exifInfo;
|
||||||
if (asset == null) return const SizedBox.shrink();
|
|
||||||
|
|
||||||
final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
|
|
||||||
final cameraTitle = _getCameraInfoTitle(exifInfo);
|
final cameraTitle = _getCameraInfoTitle(exifInfo);
|
||||||
final lensTitle = exifInfo?.lens != null && exifInfo!.lens!.isNotEmpty ? exifInfo.lens : null;
|
final lensTitle = exifInfo?.lens != null && exifInfo!.lens!.isNotEmpty ? exifInfo.lens : null;
|
||||||
|
|
||||||
|
|||||||
@@ -12,16 +12,15 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
|||||||
import 'package:immich_mobile/extensions/scroll_extensions.dart';
|
import 'package:immich_mobile/extensions/scroll_extensions.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart';
|
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.widget.dart';
|
||||||
|
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.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/app_settings.provider.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
|
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_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/services/app_settings.service.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||||
import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart';
|
import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart';
|
||||||
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
|
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
|
||||||
@@ -53,8 +52,6 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
|||||||
|
|
||||||
final _scrollController = ScrollController();
|
final _scrollController = ScrollController();
|
||||||
late final _proxyScrollController = ProxyScrollController(scrollController: _scrollController);
|
late final _proxyScrollController = ProxyScrollController(scrollController: _scrollController);
|
||||||
final ValueNotifier<PhotoViewScaleState> _videoScaleStateNotifier = ValueNotifier(PhotoViewScaleState.initial);
|
|
||||||
|
|
||||||
double _snapOffset = 0.0;
|
double _snapOffset = 0.0;
|
||||||
|
|
||||||
DragStartDetails? _dragStart;
|
DragStartDetails? _dragStart;
|
||||||
@@ -79,7 +76,6 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
|||||||
_proxyScrollController.dispose();
|
_proxyScrollController.dispose();
|
||||||
_scaleBoundarySub?.cancel();
|
_scaleBoundarySub?.cancel();
|
||||||
_eventSubscription?.cancel();
|
_eventSubscription?.cancel();
|
||||||
_videoScaleStateNotifier.dispose();
|
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,17 +245,16 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
|||||||
ref.read(isPlayingMotionVideoProvider.notifier).playing = true;
|
ref.read(isPlayingMotionVideoProvider.notifier).playing = true;
|
||||||
|
|
||||||
void _onScaleStateChanged(PhotoViewScaleState scaleState) {
|
void _onScaleStateChanged(PhotoViewScaleState scaleState) {
|
||||||
_isZoomed =
|
_isZoomed = scaleState == PhotoViewScaleState.zoomedIn || scaleState == PhotoViewScaleState.covering;
|
||||||
scaleState == PhotoViewScaleState.zoomedIn ||
|
|
||||||
scaleState == PhotoViewScaleState.covering ||
|
|
||||||
_videoScaleStateNotifier.value == PhotoViewScaleState.zoomedIn ||
|
|
||||||
_videoScaleStateNotifier.value == PhotoViewScaleState.covering;
|
|
||||||
_viewer.setZoomed(_isZoomed);
|
_viewer.setZoomed(_isZoomed);
|
||||||
|
|
||||||
if (scaleState != PhotoViewScaleState.initial) {
|
if (scaleState != PhotoViewScaleState.initial) {
|
||||||
if (_dragStart == null) _viewer.setControls(false);
|
if (_dragStart == null) _viewer.setControls(false);
|
||||||
|
|
||||||
ref.read(videoPlayerControlsProvider.notifier).pause();
|
final heroTag = ref.read(assetViewerProvider).currentAsset?.heroTag;
|
||||||
|
if (heroTag != null) {
|
||||||
|
ref.read(videoPlayerProvider(heroTag).notifier).pause();
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,22 +289,20 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
|||||||
_listenForScaleBoundaries(controller);
|
_listenForScaleBoundaries(controller);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildPhotoView(
|
Widget _buildPhotoView({
|
||||||
BaseAsset displayAsset,
|
required BaseAsset asset,
|
||||||
BaseAsset asset, {
|
required PhotoViewHeroAttributes? heroAttributes,
|
||||||
required bool isCurrentPage,
|
required bool isCurrent,
|
||||||
required bool showingDetails,
|
|
||||||
required bool isPlayingMotionVideo,
|
required bool isPlayingMotionVideo,
|
||||||
required BoxDecoration backgroundDecoration,
|
required BoxDecoration backgroundDecoration,
|
||||||
}) {
|
}) {
|
||||||
final heroAttributes = isCurrentPage ? PhotoViewHeroAttributes(tag: '${asset.heroTag}_${widget.heroOffset}') : null;
|
|
||||||
|
|
||||||
if (displayAsset.isImage && !isPlayingMotionVideo) {
|
|
||||||
final size = context.sizeData;
|
final size = context.sizeData;
|
||||||
|
|
||||||
|
if (asset.isImage && !isPlayingMotionVideo) {
|
||||||
return PhotoView(
|
return PhotoView(
|
||||||
key: Key(displayAsset.heroTag),
|
key: Key(asset.heroTag),
|
||||||
index: widget.index,
|
index: widget.index,
|
||||||
imageProvider: getFullImageProvider(displayAsset, size: size),
|
imageProvider: getFullImageProvider(asset, size: size),
|
||||||
heroAttributes: heroAttributes,
|
heroAttributes: heroAttributes,
|
||||||
loadingBuilder: (context, progress, index) => const Center(child: ImmichLoadingIndicator()),
|
loadingBuilder: (context, progress, index) => const Center(child: ImmichLoadingIndicator()),
|
||||||
backgroundDecoration: backgroundDecoration,
|
backgroundDecoration: backgroundDecoration,
|
||||||
@@ -317,7 +310,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
|||||||
filterQuality: FilterQuality.high,
|
filterQuality: FilterQuality.high,
|
||||||
tightMode: true,
|
tightMode: true,
|
||||||
enablePanAlways: true,
|
enablePanAlways: true,
|
||||||
disableScaleGestures: showingDetails,
|
disableScaleGestures: _showingDetails,
|
||||||
scaleStateChangedCallback: _onScaleStateChanged,
|
scaleStateChangedCallback: _onScaleStateChanged,
|
||||||
onPageBuild: _onPageBuild,
|
onPageBuild: _onPageBuild,
|
||||||
onDragStart: _onDragStart,
|
onDragStart: _onDragStart,
|
||||||
@@ -325,25 +318,30 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
|||||||
onDragEnd: _onDragEnd,
|
onDragEnd: _onDragEnd,
|
||||||
onDragCancel: _onDragCancel,
|
onDragCancel: _onDragCancel,
|
||||||
onTapUp: _onTapUp,
|
onTapUp: _onTapUp,
|
||||||
onLongPressStart: displayAsset.isMotionPhoto ? _onLongPress : null,
|
onLongPressStart: asset.isMotionPhoto ? _onLongPress : null,
|
||||||
errorBuilder: (_, __, ___) => SizedBox(
|
errorBuilder: (_, __, ___) => SizedBox(
|
||||||
width: size.width,
|
width: size.width,
|
||||||
height: size.height,
|
height: size.height,
|
||||||
child: Thumbnail.fromAsset(asset: displayAsset, fit: BoxFit.contain),
|
child: Thumbnail.fromAsset(asset: asset, fit: BoxFit.contain),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return PhotoView.customChild(
|
return PhotoView.customChild(
|
||||||
key: Key(displayAsset.heroTag),
|
key: Key(asset.heroTag),
|
||||||
|
childSize: asset.width != null && asset.height != null
|
||||||
|
? Size(asset.width!.toDouble(), asset.height!.toDouble())
|
||||||
|
: null,
|
||||||
onDragStart: _onDragStart,
|
onDragStart: _onDragStart,
|
||||||
onDragUpdate: _onDragUpdate,
|
onDragUpdate: _onDragUpdate,
|
||||||
onDragEnd: _onDragEnd,
|
onDragEnd: _onDragEnd,
|
||||||
onDragCancel: _onDragCancel,
|
onDragCancel: _onDragCancel,
|
||||||
|
onTapUp: _onTapUp,
|
||||||
|
scaleStateChangedCallback: _onScaleStateChanged,
|
||||||
heroAttributes: heroAttributes,
|
heroAttributes: heroAttributes,
|
||||||
filterQuality: FilterQuality.high,
|
filterQuality: FilterQuality.high,
|
||||||
basePosition: Alignment.center,
|
basePosition: Alignment.center,
|
||||||
disableScaleGestures: true,
|
disableScaleGestures: _showingDetails,
|
||||||
minScale: PhotoViewComputedScale.contained,
|
minScale: PhotoViewComputedScale.contained,
|
||||||
initialScale: PhotoViewComputedScale.contained,
|
initialScale: PhotoViewComputedScale.contained,
|
||||||
tightMode: true,
|
tightMode: true,
|
||||||
@@ -351,14 +349,11 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
|||||||
enablePanAlways: true,
|
enablePanAlways: true,
|
||||||
backgroundDecoration: backgroundDecoration,
|
backgroundDecoration: backgroundDecoration,
|
||||||
child: NativeVideoViewer(
|
child: NativeVideoViewer(
|
||||||
key: _NativeVideoViewerKey(displayAsset.heroTag),
|
key: _NativeVideoViewerKey(asset.heroTag),
|
||||||
asset: displayAsset,
|
asset: asset,
|
||||||
scaleStateNotifier: _videoScaleStateNotifier,
|
isCurrent: isCurrent,
|
||||||
disableScaleGestures: showingDetails,
|
|
||||||
image: Image(
|
image: Image(
|
||||||
image: getFullImageProvider(displayAsset, size: context.sizeData),
|
image: getFullImageProvider(asset, size: size),
|
||||||
height: context.height,
|
|
||||||
width: context.width,
|
|
||||||
fit: BoxFit.contain,
|
fit: BoxFit.contain,
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
),
|
),
|
||||||
@@ -384,6 +379,8 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
|||||||
displayAsset = stackChildren.elementAt(stackIndex);
|
displayAsset = stackChildren.elementAt(stackIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final isCurrent = currentHeroTag == displayAsset.heroTag;
|
||||||
|
|
||||||
final viewportWidth = MediaQuery.widthOf(context);
|
final viewportWidth = MediaQuery.widthOf(context);
|
||||||
final viewportHeight = MediaQuery.heightOf(context);
|
final viewportHeight = MediaQuery.heightOf(context);
|
||||||
final imageHeight = _getImageHeight(viewportWidth, viewportHeight, displayAsset);
|
final imageHeight = _getImageHeight(viewportWidth, viewportHeight, displayAsset);
|
||||||
@@ -397,16 +394,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
|||||||
_proxyScrollController.snapPosition.snapOffset = _snapOffset;
|
_proxyScrollController.snapPosition.snapOffset = _snapOffset;
|
||||||
}
|
}
|
||||||
|
|
||||||
return ProviderScope(
|
return Stack(
|
||||||
overrides: [
|
|
||||||
currentAssetNotifier.overrideWith(() => ScopedAssetNotifier(asset)),
|
|
||||||
currentAssetExifProvider.overrideWith((ref) {
|
|
||||||
final a = ref.watch(currentAssetNotifier);
|
|
||||||
if (a == null) return Future.value(null);
|
|
||||||
return ref.watch(assetServiceProvider).getExif(a);
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
child: Stack(
|
|
||||||
children: [
|
children: [
|
||||||
Offstage(
|
Offstage(
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
@@ -424,10 +412,11 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
|||||||
width: viewportWidth,
|
width: viewportWidth,
|
||||||
height: viewportHeight,
|
height: viewportHeight,
|
||||||
child: _buildPhotoView(
|
child: _buildPhotoView(
|
||||||
displayAsset,
|
asset: displayAsset,
|
||||||
asset,
|
heroAttributes: isCurrent
|
||||||
isCurrentPage: currentHeroTag == asset.heroTag,
|
? PhotoViewHeroAttributes(tag: '${asset.heroTag}_${widget.heroOffset}')
|
||||||
showingDetails: _showingDetails,
|
: null,
|
||||||
|
isCurrent: isCurrent,
|
||||||
isPlayingMotionVideo: isPlayingMotionVideo,
|
isPlayingMotionVideo: isPlayingMotionVideo,
|
||||||
backgroundDecoration: BoxDecoration(color: _showingDetails ? Colors.black : Colors.transparent),
|
backgroundDecoration: BoxDecoration(color: _showingDetails ? Colors.black : Colors.transparent),
|
||||||
),
|
),
|
||||||
@@ -445,7 +434,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
|||||||
child: AnimatedOpacity(
|
child: AnimatedOpacity(
|
||||||
opacity: _showingDetails ? 1.0 : 0.0,
|
opacity: _showingDetails ? 1.0 : 0.0,
|
||||||
duration: Durations.short2,
|
duration: Durations.short2,
|
||||||
child: AssetDetails(minHeight: viewportHeight - snapTarget),
|
child: AssetDetails(asset: displayAsset, minHeight: viewportHeight - snapTarget),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -454,8 +443,14 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
if (stackChildren != null && stackChildren.isNotEmpty)
|
||||||
|
Positioned(
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: context.padding.bottom,
|
||||||
|
child: AssetStackRow(stack: stackChildren),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,42 +1,29 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart';
|
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
|
||||||
|
|
||||||
class AssetStackRow extends ConsumerWidget {
|
class AssetStackRow extends ConsumerWidget {
|
||||||
const AssetStackRow({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final asset = ref.watch(assetViewerProvider.select((state) => state.currentAsset));
|
|
||||||
if (asset == null) {
|
|
||||||
return const SizedBox.shrink();
|
|
||||||
}
|
|
||||||
|
|
||||||
final stackChildren = ref.watch(stackChildrenNotifier(asset)).valueOrNull;
|
|
||||||
if (stackChildren == null || stackChildren.isEmpty) {
|
|
||||||
return const SizedBox.shrink();
|
|
||||||
}
|
|
||||||
|
|
||||||
final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails));
|
|
||||||
if (showingDetails) {
|
|
||||||
return const SizedBox.shrink();
|
|
||||||
}
|
|
||||||
return _StackList(stack: stackChildren);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _StackList extends ConsumerWidget {
|
|
||||||
final List<RemoteAsset> stack;
|
final List<RemoteAsset> stack;
|
||||||
|
|
||||||
const _StackList({required this.stack});
|
const AssetStackRow({super.key, required this.stack});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
return Center(
|
if (stack.isEmpty) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
final showingControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
|
||||||
|
double opacity = ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity)) * (showingControls ? 1 : 0);
|
||||||
|
|
||||||
|
return IgnorePointer(
|
||||||
|
ignoring: opacity < 1.0,
|
||||||
|
child: AnimatedOpacity(
|
||||||
|
opacity: opacity,
|
||||||
|
duration: Durations.short2,
|
||||||
|
child: Center(
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -51,6 +38,8 @@ class _StackList extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -67,8 +56,9 @@ class _StackItem extends ConsumerStatefulWidget {
|
|||||||
|
|
||||||
class _StackItemState extends ConsumerState<_StackItem> {
|
class _StackItemState extends ConsumerState<_StackItem> {
|
||||||
void _onTap() {
|
void _onTap() {
|
||||||
ref.read(currentAssetNotifier.notifier).setAsset(widget.asset);
|
final notifier = ref.read(assetViewerProvider.notifier);
|
||||||
ref.read(assetViewerProvider.notifier).setStackIndex(widget.index);
|
notifier.setAsset(widget.asset);
|
||||||
|
notifier.setStackIndex(widget.index);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -17,13 +17,10 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/download_statu
|
|||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_page.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_page.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_preloader.dart';
|
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_preloader.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart';
|
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_bottom_app_bar.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_bottom_app_bar.widget.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/cast.provider.dart';
|
import 'package:immich_mobile/providers/cast.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||||
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
|
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
|
||||||
@@ -72,15 +69,7 @@ class AssetViewer extends ConsumerStatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static void _setAsset(WidgetRef ref, BaseAsset asset) {
|
static void _setAsset(WidgetRef ref, BaseAsset asset) {
|
||||||
// Always holds the current asset from the timeline
|
|
||||||
ref.read(assetViewerProvider.notifier).setAsset(asset);
|
ref.read(assetViewerProvider.notifier).setAsset(asset);
|
||||||
// The currentAssetNotifier actually holds the current asset that is displayed
|
|
||||||
// which could be stack children as well
|
|
||||||
ref.read(currentAssetNotifier.notifier).setAsset(asset);
|
|
||||||
if (asset.isVideo || asset.isMotionPhoto) {
|
|
||||||
ref.read(videoPlaybackValueProvider.notifier).reset();
|
|
||||||
ref.read(videoPlayerControlsProvider.notifier).pause();
|
|
||||||
}
|
|
||||||
// Hide controls by default for videos
|
// Hide controls by default for videos
|
||||||
if (asset.isVideo) ref.read(assetViewerProvider.notifier).setControls(false);
|
if (asset.isVideo) ref.read(assetViewerProvider.notifier).setControls(false);
|
||||||
}
|
}
|
||||||
@@ -91,6 +80,8 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
|||||||
late final _pageController = PageController(initialPage: widget.initialIndex);
|
late final _pageController = PageController(initialPage: widget.initialIndex);
|
||||||
late final _preloader = AssetPreloader(timelineService: ref.read(timelineServiceProvider), mounted: () => mounted);
|
late final _preloader = AssetPreloader(timelineService: ref.read(timelineServiceProvider), mounted: () => mounted);
|
||||||
|
|
||||||
|
late int _currentPage = widget.initialIndex;
|
||||||
|
|
||||||
StreamSubscription? _reloadSubscription;
|
StreamSubscription? _reloadSubscription;
|
||||||
KeepAliveLink? _stackChildrenKeepAlive;
|
KeepAliveLink? _stackChildrenKeepAlive;
|
||||||
|
|
||||||
@@ -102,7 +93,9 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
|||||||
final target = page + direction;
|
final target = page + direction;
|
||||||
final maxPage = ref.read(timelineServiceProvider).totalAssets - 1;
|
final maxPage = ref.read(timelineServiceProvider).totalAssets - 1;
|
||||||
if (target >= 0 && target <= maxPage) {
|
if (target >= 0 && target <= maxPage) {
|
||||||
|
_currentPage = target;
|
||||||
_pageController.jumpToPage(target);
|
_pageController.jumpToPage(target);
|
||||||
|
_onAssetChanged(target);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,7 +103,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
|
||||||
final asset = ref.read(currentAssetNotifier);
|
final asset = ref.read(assetViewerProvider).currentAsset;
|
||||||
assert(asset != null, "Current asset should not be null when opening the AssetViewer");
|
assert(asset != null, "Current asset should not be null when opening the AssetViewer");
|
||||||
if (asset != null) _stackChildrenKeepAlive = ref.read(stackChildrenNotifier(asset).notifier).ref.keepAlive();
|
if (asset != null) _stackChildrenKeepAlive = ref.read(stackChildrenNotifier(asset).notifier).ref.keepAlive();
|
||||||
|
|
||||||
@@ -134,6 +127,26 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The normal onPageChange callback listens to OnScrollUpdate events, and will
|
||||||
|
// round the current page and update whenever that value changes. In practise,
|
||||||
|
// this means that the page will change when swiped half way, and may flip
|
||||||
|
// whilst dragging.
|
||||||
|
//
|
||||||
|
// Changing the page at the end of a scroll should be more robust, and allow
|
||||||
|
// the page to be dragged more than half way whilst keeping the current video
|
||||||
|
// playing, and preventing the video on the next page from becoming ready
|
||||||
|
// unnecessarily.
|
||||||
|
bool _onScrollEnd(ScrollEndNotification notification) {
|
||||||
|
if (notification.depth != 0) return false;
|
||||||
|
|
||||||
|
final page = _pageController.page?.round();
|
||||||
|
if (page != null && page != _currentPage) {
|
||||||
|
_currentPage = page;
|
||||||
|
_onAssetChanged(page);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
void _onAssetInit(Duration timeStamp) {
|
void _onAssetInit(Duration timeStamp) {
|
||||||
_preloader.preload(widget.initialIndex, context.sizeData);
|
_preloader.preload(widget.initialIndex, context.sizeData);
|
||||||
_handleCasting();
|
_handleCasting();
|
||||||
@@ -153,7 +166,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
|||||||
|
|
||||||
void _handleCasting() {
|
void _handleCasting() {
|
||||||
if (!ref.read(castProvider).isCasting) return;
|
if (!ref.read(castProvider).isCasting) return;
|
||||||
final asset = ref.read(currentAssetNotifier);
|
final asset = ref.read(assetViewerProvider).currentAsset;
|
||||||
if (asset == null) return;
|
if (asset == null) return;
|
||||||
|
|
||||||
if (asset is RemoteAsset) {
|
if (asset is RemoteAsset) {
|
||||||
@@ -195,17 +208,19 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var index = _pageController.page?.round() ?? 0;
|
var index = _pageController.page?.round() ?? 0;
|
||||||
final currentAsset = ref.read(currentAssetNotifier);
|
final currentAsset = ref.read(assetViewerProvider).currentAsset;
|
||||||
if (currentAsset != null) {
|
if (currentAsset != null) {
|
||||||
final newIndex = timelineService.getIndex(currentAsset.heroTag);
|
final newIndex = timelineService.getIndex(currentAsset.heroTag);
|
||||||
if (newIndex != null && newIndex != index) {
|
if (newIndex != null && newIndex != index) {
|
||||||
index = newIndex;
|
index = newIndex;
|
||||||
|
_currentPage = index;
|
||||||
_pageController.jumpToPage(index);
|
_pageController.jumpToPage(index);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (index >= totalAssets) {
|
if (index >= totalAssets) {
|
||||||
index = totalAssets - 1;
|
index = totalAssets - 1;
|
||||||
|
_currentPage = index;
|
||||||
_pageController.jumpToPage(index);
|
_pageController.jumpToPage(index);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,7 +236,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
|||||||
final newAsset = await timelineService.getAssetAsync(index);
|
final newAsset = await timelineService.getAssetAsync(index);
|
||||||
if (newAsset == null) return;
|
if (newAsset == null) return;
|
||||||
|
|
||||||
final currentAsset = ref.read(currentAssetNotifier);
|
final currentAsset = ref.read(assetViewerProvider).currentAsset;
|
||||||
|
|
||||||
// Do not reload if the asset has not changed
|
// Do not reload if the asset has not changed
|
||||||
if (newAsset.heroTag == currentAsset?.heroTag) return;
|
if (newAsset.heroTag == currentAsset?.heroTag) return;
|
||||||
@@ -258,10 +273,9 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
|||||||
_setSystemUIMode(controls, details);
|
_setSystemUIMode(controls, details);
|
||||||
});
|
});
|
||||||
|
|
||||||
return PopScope(
|
return Scaffold(
|
||||||
onPopInvokedWithResult: (didPop, result) => ref.read(currentAssetNotifier.notifier).dispose(),
|
|
||||||
child: Scaffold(
|
|
||||||
backgroundColor: backgroundColor,
|
backgroundColor: backgroundColor,
|
||||||
|
resizeToAvoidBottomInset: false,
|
||||||
appBar: const ViewerTopAppBar(),
|
appBar: const ViewerTopAppBar(),
|
||||||
extendBody: true,
|
extendBody: true,
|
||||||
extendBodyBehindAppBar: true,
|
extendBodyBehindAppBar: true,
|
||||||
@@ -276,7 +290,9 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
|||||||
bottomNavigationBar: const ViewerBottomAppBar(),
|
bottomNavigationBar: const ViewerBottomAppBar(),
|
||||||
body: Stack(
|
body: Stack(
|
||||||
children: [
|
children: [
|
||||||
PhotoViewGestureDetectorScope(
|
NotificationListener<ScrollEndNotification>(
|
||||||
|
onNotification: _onScrollEnd,
|
||||||
|
child: PhotoViewGestureDetectorScope(
|
||||||
axis: Axis.horizontal,
|
axis: Axis.horizontal,
|
||||||
child: PageView.builder(
|
child: PageView.builder(
|
||||||
controller: _pageController,
|
controller: _pageController,
|
||||||
@@ -286,11 +302,11 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
|||||||
? const FastScrollPhysics()
|
? const FastScrollPhysics()
|
||||||
: const FastClampingScrollPhysics(),
|
: const FastClampingScrollPhysics(),
|
||||||
itemCount: ref.read(timelineServiceProvider).totalAssets,
|
itemCount: ref.read(timelineServiceProvider).totalAssets,
|
||||||
onPageChanged: (index) => _onAssetChanged(index),
|
|
||||||
itemBuilder: (context, index) =>
|
itemBuilder: (context, index) =>
|
||||||
AssetPage(index: index, heroOffset: _heroOffset, onTapNavigate: _onTapNavigate),
|
AssetPage(index: index, heroOffset: _heroOffset, onTapNavigate: _onTapNavigate),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
if (!CurrentPlatform.isIOS)
|
if (!CurrentPlatform.isIOS)
|
||||||
IgnorePointer(
|
IgnorePointer(
|
||||||
child: AnimatedContainer(
|
child: AnimatedContainer(
|
||||||
@@ -301,7 +317,6 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/edit_image_act
|
|||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/add_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/add_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
|
||||||
import 'package:immich_mobile/providers/routes.provider.dart';
|
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||||
import 'package:immich_mobile/providers/user.provider.dart';
|
import 'package:immich_mobile/providers/user.provider.dart';
|
||||||
@@ -21,7 +20,7 @@ class ViewerBottomBar extends ConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final asset = ref.watch(currentAssetNotifier);
|
final asset = ref.watch(assetViewerProvider.select((s) => s.currentAsset));
|
||||||
if (asset == null) {
|
if (asset == null) {
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
@@ -65,9 +64,9 @@ class ViewerBottomBar extends ConsumerWidget {
|
|||||||
color: Colors.black.withAlpha(125),
|
color: Colors.black.withAlpha(125),
|
||||||
padding: EdgeInsets.only(bottom: context.padding.bottom, top: 16),
|
padding: EdgeInsets.only(bottom: context.padding.bottom, top: 16),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.end,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
if (asset.isVideo) const VideoControls(),
|
if (asset.isVideo) VideoControls(videoPlayerName: asset.heroTag),
|
||||||
if (!isReadonlyModeEnabled)
|
if (!isReadonlyModeEnabled)
|
||||||
Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions),
|
Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,111 +1,110 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/setting.model.dart';
|
import 'package:immich_mobile/domain/models/setting.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||||
import 'package:immich_mobile/domain/services/setting.service.dart';
|
import 'package:immich_mobile/domain/services/setting.service.dart';
|
||||||
import 'package:immich_mobile/entities/store.entity.dart';
|
import 'package:immich_mobile/entities/store.entity.dart';
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
|
||||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.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/presentation/widgets/asset_viewer/video_viewer_controls.widget.dart';
|
||||||
import 'package:immich_mobile/providers/app_settings.provider.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/is_motion_video_playing.provider.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
|
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/cast.provider.dart';
|
import 'package:immich_mobile/providers/cast.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
|
||||||
import 'package:immich_mobile/services/api.service.dart';
|
import 'package:immich_mobile/services/api.service.dart';
|
||||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||||
import 'package:immich_mobile/utils/debounce.dart';
|
|
||||||
import 'package:immich_mobile/utils/hooks/interval_hook.dart';
|
|
||||||
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
|
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:native_video_player/native_video_player.dart';
|
import 'package:native_video_player/native_video_player.dart';
|
||||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
|
||||||
|
|
||||||
bool _isCurrentAsset(BaseAsset asset, BaseAsset? currentAsset) {
|
class NativeVideoViewer extends ConsumerStatefulWidget {
|
||||||
if (asset is RemoteAsset) {
|
|
||||||
return switch (currentAsset) {
|
|
||||||
RemoteAsset remoteAsset => remoteAsset.id == asset.id,
|
|
||||||
LocalAsset localAsset => localAsset.remoteId == asset.id,
|
|
||||||
_ => false,
|
|
||||||
};
|
|
||||||
} else if (asset is LocalAsset) {
|
|
||||||
return switch (currentAsset) {
|
|
||||||
RemoteAsset remoteAsset => remoteAsset.localId == asset.id,
|
|
||||||
LocalAsset localAsset => localAsset.id == asset.id,
|
|
||||||
_ => false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
class NativeVideoViewer extends HookConsumerWidget {
|
|
||||||
static final log = Logger('NativeVideoViewer');
|
|
||||||
final BaseAsset asset;
|
final BaseAsset asset;
|
||||||
|
final bool isCurrent;
|
||||||
final bool showControls;
|
final bool showControls;
|
||||||
final int playbackDelayFactor;
|
|
||||||
final Widget image;
|
final Widget image;
|
||||||
final ValueNotifier<PhotoViewScaleState>? scaleStateNotifier;
|
|
||||||
final bool disableScaleGestures;
|
|
||||||
|
|
||||||
const NativeVideoViewer({
|
const NativeVideoViewer({
|
||||||
super.key,
|
super.key,
|
||||||
required this.asset,
|
required this.asset,
|
||||||
required this.image,
|
required this.image,
|
||||||
|
this.isCurrent = false,
|
||||||
this.showControls = true,
|
this.showControls = true,
|
||||||
this.playbackDelayFactor = 1,
|
|
||||||
this.scaleStateNotifier,
|
|
||||||
this.disableScaleGestures = false,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
ConsumerState<NativeVideoViewer> createState() => _NativeVideoViewerState();
|
||||||
final controller = useState<NativeVideoPlayerController?>(null);
|
}
|
||||||
final lastVideoPosition = useRef(-1);
|
|
||||||
final isBuffering = useRef(false);
|
|
||||||
|
|
||||||
// Used to track whether the video should play when the app
|
class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with WidgetsBindingObserver {
|
||||||
// is brought back to the foreground
|
static final _log = Logger('NativeVideoViewer');
|
||||||
final shouldPlayOnForeground = useRef(true);
|
|
||||||
|
|
||||||
// When a video is opened through the timeline, `isCurrent` will immediately be true.
|
NativeVideoPlayerController? _controller;
|
||||||
// When swiping from video A to video B, `isCurrent` will initially be true for video A and false for video B.
|
late final Future<VideoSource?> _videoSource;
|
||||||
// If the swipe is completed, `isCurrent` will be true for video B after a delay.
|
Timer? _loadTimer;
|
||||||
// If the swipe is canceled, `currentAsset` will not have changed and video A will continue to play.
|
bool _isVideoReady = false;
|
||||||
final currentAsset = useState(ref.read(currentAssetNotifier));
|
bool _shouldPlayOnForeground = true;
|
||||||
final isCurrent = _isCurrentAsset(asset, currentAsset.value);
|
|
||||||
|
|
||||||
// Used to show the placeholder during hero animations for remote videos to avoid a stutter
|
VideoPlayerNotifier get _notifier => ref.read(videoPlayerProvider(widget.asset.heroTag).notifier);
|
||||||
final isVisible = useState(Platform.isIOS && asset.hasLocal);
|
|
||||||
|
|
||||||
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
|
@override
|
||||||
|
void initState() {
|
||||||
Future<VideoSource?> createSource() async {
|
super.initState();
|
||||||
if (!context.mounted) {
|
WidgetsBinding.instance.addObserver(this);
|
||||||
return null;
|
_videoSource = _createSource();
|
||||||
}
|
}
|
||||||
|
|
||||||
final videoAsset = await ref.read(assetServiceProvider).getAsset(asset) ?? asset;
|
@override
|
||||||
if (!context.mounted) {
|
void didUpdateWidget(NativeVideoViewer oldWidget) {
|
||||||
return null;
|
super.didUpdateWidget(oldWidget);
|
||||||
|
|
||||||
|
if (widget.isCurrent == oldWidget.isCurrent || _controller == null) return;
|
||||||
|
|
||||||
|
if (!widget.isCurrent) {
|
||||||
|
_loadTimer?.cancel();
|
||||||
|
_notifier.pause();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prevent unnecessary loading when swiping between assets.
|
||||||
|
_loadTimer = Timer(const Duration(milliseconds: 200), _loadVideo);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
|
_loadTimer?.cancel();
|
||||||
|
_removeListeners();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeAppLifecycleState(AppLifecycleState state) async {
|
||||||
|
switch (state) {
|
||||||
|
case AppLifecycleState.resumed:
|
||||||
|
if (_shouldPlayOnForeground) await _notifier.play();
|
||||||
|
case AppLifecycleState.paused:
|
||||||
|
_shouldPlayOnForeground = await _controller?.isPlaying() ?? true;
|
||||||
|
if (_shouldPlayOnForeground) await _notifier.pause();
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<VideoSource?> _createSource() async {
|
||||||
|
if (!mounted) return null;
|
||||||
|
|
||||||
|
final videoAsset = await ref.read(assetServiceProvider).getAsset(widget.asset) ?? widget.asset;
|
||||||
|
if (!mounted) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (videoAsset.hasLocal && videoAsset.livePhotoVideoId == null) {
|
if (videoAsset.hasLocal && videoAsset.livePhotoVideoId == null) {
|
||||||
final id = videoAsset is LocalAsset ? videoAsset.id : (videoAsset as RemoteAsset).localId!;
|
final id = videoAsset is LocalAsset ? videoAsset.id : (videoAsset as RemoteAsset).localId!;
|
||||||
final file = await StorageRepository().getFileForAsset(id);
|
final file = await StorageRepository().getFileForAsset(id);
|
||||||
if (!context.mounted) {
|
if (!mounted) return null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (file == null) {
|
if (file == null) {
|
||||||
throw Exception('No file found for the video');
|
throw Exception('No file found for the video');
|
||||||
@@ -113,16 +112,14 @@ class NativeVideoViewer extends HookConsumerWidget {
|
|||||||
|
|
||||||
// Pass a file:// URI so Android's Uri.parse doesn't
|
// Pass a file:// URI so Android's Uri.parse doesn't
|
||||||
// interpret characters like '#' as fragment identifiers.
|
// interpret characters like '#' as fragment identifiers.
|
||||||
final source = await VideoSource.init(
|
return VideoSource.init(
|
||||||
path: CurrentPlatform.isAndroid ? file.uri.toString() : file.path,
|
path: CurrentPlatform.isAndroid ? file.uri.toString() : file.path,
|
||||||
type: VideoSourceType.file,
|
type: VideoSourceType.file,
|
||||||
);
|
);
|
||||||
return source;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final remoteId = (videoAsset as RemoteAsset).id;
|
final remoteId = (videoAsset as RemoteAsset).id;
|
||||||
|
|
||||||
// Use a network URL for the video player controller
|
|
||||||
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
|
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
|
||||||
final isOriginalVideo = ref.read(settingsProvider).get<bool>(Setting.loadOriginalVideo);
|
final isOriginalVideo = ref.read(settingsProvider).get<bool>(Setting.loadOriginalVideo);
|
||||||
final String postfixUrl = isOriginalVideo ? 'original' : 'video/playback';
|
final String postfixUrl = isOriginalVideo ? 'original' : 'video/playback';
|
||||||
@@ -130,340 +127,107 @@ class NativeVideoViewer extends HookConsumerWidget {
|
|||||||
? '$serverEndpoint/assets/${videoAsset.livePhotoVideoId}/$postfixUrl'
|
? '$serverEndpoint/assets/${videoAsset.livePhotoVideoId}/$postfixUrl'
|
||||||
: '$serverEndpoint/assets/$remoteId/$postfixUrl';
|
: '$serverEndpoint/assets/$remoteId/$postfixUrl';
|
||||||
|
|
||||||
final source = await VideoSource.init(
|
return VideoSource.init(path: videoUrl, type: VideoSourceType.network, headers: ApiService.getRequestHeaders());
|
||||||
path: videoUrl,
|
|
||||||
type: VideoSourceType.network,
|
|
||||||
headers: ApiService.getRequestHeaders(),
|
|
||||||
);
|
|
||||||
return source;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.severe('Error creating video source for asset ${videoAsset.name}: $error');
|
_log.severe('Error creating video source for asset ${videoAsset.name}: $error');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final videoSource = useMemoized<Future<VideoSource?>>(() => createSource());
|
void _onPlaybackReady() async {
|
||||||
final aspectRatio = useState<double?>(null);
|
if (!mounted || !widget.isCurrent) return;
|
||||||
|
|
||||||
useMemoized(() async {
|
_notifier.onNativePlaybackReady();
|
||||||
if (!context.mounted || aspectRatio.value != null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
// onPlaybackReady may be called multiple times, usually when more data
|
||||||
aspectRatio.value = await ref.read(assetServiceProvider).getAspectRatio(asset);
|
// loads. If this is not the first time that the player has become ready, we
|
||||||
} catch (error) {
|
// should not autoplay.
|
||||||
log.severe('Error getting aspect ratio for asset ${asset.name}: $error');
|
if (_isVideoReady) return;
|
||||||
}
|
|
||||||
}, [asset.heroTag]);
|
|
||||||
|
|
||||||
void checkIfBuffering() {
|
setState(() => _isVideoReady = true);
|
||||||
if (!context.mounted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final videoPlayback = ref.read(videoPlaybackValueProvider);
|
if (ref.read(assetViewerProvider).showingDetails) return;
|
||||||
if ((isBuffering.value || videoPlayback.state == VideoPlaybackState.initializing) &&
|
|
||||||
videoPlayback.state != VideoPlaybackState.buffering) {
|
|
||||||
ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback.copyWith(
|
|
||||||
state: VideoPlaybackState.buffering,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Timer to mark videos as buffering if the position does not change
|
|
||||||
useInterval(const Duration(seconds: 5), checkIfBuffering);
|
|
||||||
|
|
||||||
// When the position changes, seek to the position
|
|
||||||
// Debounce the seek to avoid seeking too often
|
|
||||||
// But also don't delay the seek too much to maintain visual feedback
|
|
||||||
final seekDebouncer = useDebouncer(
|
|
||||||
interval: const Duration(milliseconds: 100),
|
|
||||||
maxWaitTime: const Duration(milliseconds: 200),
|
|
||||||
);
|
|
||||||
ref.listen(videoPlayerControlsProvider, (oldControls, newControls) {
|
|
||||||
final playerController = controller.value;
|
|
||||||
if (playerController == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final playbackInfo = playerController.playbackInfo;
|
|
||||||
if (playbackInfo == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final oldSeek = oldControls?.position.inMilliseconds;
|
|
||||||
final newSeek = newControls.position.inMilliseconds;
|
|
||||||
if (oldSeek != newSeek || newControls.restarted) {
|
|
||||||
seekDebouncer.run(() => playerController.seekTo(newSeek));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (oldControls?.pause != newControls.pause || newControls.restarted) {
|
|
||||||
unawaited(_onPauseChange(context, playerController, seekDebouncer, newControls.pause));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
void onPlaybackReady() async {
|
|
||||||
final videoController = controller.value;
|
|
||||||
if (videoController == null || !isCurrent || !context.mounted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final videoPlayback = VideoPlaybackValue.fromNativeController(videoController);
|
|
||||||
ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback;
|
|
||||||
|
|
||||||
if (ref.read(assetViewerProvider.select((s) => s.showingDetails))) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
final autoPlayVideo = AppSetting.get(Setting.autoPlayVideo);
|
final autoPlayVideo = AppSetting.get(Setting.autoPlayVideo);
|
||||||
if (autoPlayVideo) {
|
if (autoPlayVideo) await _notifier.play();
|
||||||
await videoController.play();
|
|
||||||
}
|
|
||||||
await videoController.setVolume(0.9);
|
|
||||||
} catch (error) {
|
|
||||||
log.severe('Error playing video: $error');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void onPlaybackStatusChanged() {
|
void _onPlaybackEnded() {
|
||||||
final videoController = controller.value;
|
if (!mounted) return;
|
||||||
if (videoController == null || !context.mounted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final videoPlayback = VideoPlaybackValue.fromNativeController(videoController);
|
_notifier.onNativePlaybackEnded();
|
||||||
if (videoPlayback.state == VideoPlaybackState.playing) {
|
|
||||||
// Sync with the controls playing
|
|
||||||
WakelockPlus.enable();
|
|
||||||
} else {
|
|
||||||
// Sync with the controls pause
|
|
||||||
WakelockPlus.disable();
|
|
||||||
}
|
|
||||||
|
|
||||||
ref.read(videoPlaybackValueProvider.notifier).status = videoPlayback.state;
|
if (_controller?.playbackInfo?.status == PlaybackStatus.stopped) {
|
||||||
}
|
|
||||||
|
|
||||||
void onPlaybackPositionChanged() {
|
|
||||||
// When seeking, these events sometimes move the slider to an older position
|
|
||||||
if (seekDebouncer.isActive) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final videoController = controller.value;
|
|
||||||
if (videoController == null || !context.mounted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final playbackInfo = videoController.playbackInfo;
|
|
||||||
if (playbackInfo == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ref.read(videoPlaybackValueProvider.notifier).position = Duration(milliseconds: playbackInfo.position);
|
|
||||||
|
|
||||||
// Check if the video is buffering
|
|
||||||
if (playbackInfo.status == PlaybackStatus.playing) {
|
|
||||||
isBuffering.value = lastVideoPosition.value == playbackInfo.position;
|
|
||||||
lastVideoPosition.value = playbackInfo.position;
|
|
||||||
} else {
|
|
||||||
isBuffering.value = false;
|
|
||||||
lastVideoPosition.value = -1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void onPlaybackEnded() {
|
|
||||||
final videoController = controller.value;
|
|
||||||
if (videoController == null || !context.mounted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (videoController.playbackInfo?.status == PlaybackStatus.stopped) {
|
|
||||||
ref.read(isPlayingMotionVideoProvider.notifier).playing = false;
|
ref.read(isPlayingMotionVideoProvider.notifier).playing = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void removeListeners(NativeVideoPlayerController controller) {
|
void _onPlaybackPositionChanged() {
|
||||||
controller.onPlaybackPositionChanged.removeListener(onPlaybackPositionChanged);
|
if (!mounted) return;
|
||||||
controller.onPlaybackStatusChanged.removeListener(onPlaybackStatusChanged);
|
_notifier.onNativePositionChanged();
|
||||||
controller.onPlaybackReady.removeListener(onPlaybackReady);
|
|
||||||
controller.onPlaybackEnded.removeListener(onPlaybackEnded);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void initController(NativeVideoPlayerController nc) async {
|
void _onPlaybackStatusChanged() {
|
||||||
if (controller.value != null || !context.mounted) {
|
if (!mounted) return;
|
||||||
return;
|
_notifier.onNativeStatusChanged();
|
||||||
}
|
|
||||||
ref.read(videoPlayerControlsProvider.notifier).reset();
|
|
||||||
ref.read(videoPlaybackValueProvider.notifier).reset();
|
|
||||||
|
|
||||||
final source = await videoSource;
|
|
||||||
if (source == null || !context.mounted) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
nc.onPlaybackPositionChanged.addListener(onPlaybackPositionChanged);
|
void _removeListeners() {
|
||||||
nc.onPlaybackStatusChanged.addListener(onPlaybackStatusChanged);
|
_controller?.onPlaybackPositionChanged.removeListener(_onPlaybackPositionChanged);
|
||||||
nc.onPlaybackReady.addListener(onPlaybackReady);
|
_controller?.onPlaybackStatusChanged.removeListener(_onPlaybackStatusChanged);
|
||||||
nc.onPlaybackEnded.addListener(onPlaybackEnded);
|
_controller?.onPlaybackReady.removeListener(_onPlaybackReady);
|
||||||
|
_controller?.onPlaybackEnded.removeListener(_onPlaybackEnded);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _loadVideo() async {
|
||||||
|
final nc = _controller;
|
||||||
|
if (nc == null || nc.videoSource != null || !mounted) return;
|
||||||
|
|
||||||
|
final source = await _videoSource;
|
||||||
|
if (source == null || !mounted) return;
|
||||||
|
|
||||||
unawaited(
|
unawaited(
|
||||||
nc.loadVideoSource(source).catchError((error) {
|
nc.loadVideoSource(source).catchError((error) {
|
||||||
log.severe('Error loading video source: $error');
|
_log.severe('Error loading video source: $error');
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
final loopVideo = ref.read(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.loopVideo);
|
final loopVideo = ref.read(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.loopVideo);
|
||||||
unawaited(nc.setLoop(!asset.isMotionPhoto && loopVideo));
|
await _notifier.setLoop(!widget.asset.isMotionPhoto && loopVideo);
|
||||||
|
await _notifier.setVolume(1);
|
||||||
controller.value = nc;
|
|
||||||
Timer(const Duration(milliseconds: 200), checkIfBuffering);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Size? videoContextSize(double? videoAspectRatio, BuildContext? context) {
|
void _initController(NativeVideoPlayerController nc) {
|
||||||
Size? videoContextSize;
|
if (_controller != null || !mounted) return;
|
||||||
if (videoAspectRatio == null || context == null) {
|
|
||||||
return null;
|
_notifier.attachController(nc);
|
||||||
}
|
|
||||||
final contextAspectRatio = context.width / context.height;
|
nc.onPlaybackPositionChanged.addListener(_onPlaybackPositionChanged);
|
||||||
if (videoAspectRatio > contextAspectRatio) {
|
nc.onPlaybackStatusChanged.addListener(_onPlaybackStatusChanged);
|
||||||
videoContextSize = Size(context.width, context.width / aspectRatio.value!);
|
nc.onPlaybackReady.addListener(_onPlaybackReady);
|
||||||
} else {
|
nc.onPlaybackEnded.addListener(_onPlaybackEnded);
|
||||||
videoContextSize = Size(context.height * aspectRatio.value!, context.height);
|
|
||||||
}
|
_controller = nc;
|
||||||
return videoContextSize;
|
|
||||||
|
if (widget.isCurrent) _loadVideo();
|
||||||
}
|
}
|
||||||
|
|
||||||
ref.listen(currentAssetNotifier, (_, value) {
|
@override
|
||||||
final playerController = controller.value;
|
Widget build(BuildContext context) {
|
||||||
if (playerController != null && value != asset) {
|
// Prevent the provider from being disposed whilst the widget is alive.
|
||||||
removeListeners(playerController);
|
ref.listen(videoPlayerProvider(widget.asset.heroTag), (_, __) {});
|
||||||
}
|
|
||||||
|
|
||||||
if (value != null) {
|
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
|
||||||
isVisible.value = _isCurrentAsset(value, asset);
|
|
||||||
}
|
|
||||||
final curAsset = currentAsset.value;
|
|
||||||
if (curAsset == asset) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final imageToVideo = curAsset != null && !curAsset.isVideo;
|
return Stack(
|
||||||
|
|
||||||
// No need to delay video playback when swiping from an image to a video
|
|
||||||
if (imageToVideo && Platform.isIOS) {
|
|
||||||
currentAsset.value = value;
|
|
||||||
onPlaybackReady();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delay the video playback to avoid a stutter in the swipe animation
|
|
||||||
// Note, in some circumstances a longer delay is needed (eg: memories),
|
|
||||||
// the playbackDelayFactor can be used for this
|
|
||||||
// This delay seems like a hacky way to resolve underlying bugs in video
|
|
||||||
// playback, but other resolutions failed thus far
|
|
||||||
Timer(
|
|
||||||
Platform.isIOS
|
|
||||||
? Duration(milliseconds: 300 * playbackDelayFactor)
|
|
||||||
: imageToVideo
|
|
||||||
? Duration(milliseconds: 200 * playbackDelayFactor)
|
|
||||||
: Duration(milliseconds: 400 * playbackDelayFactor),
|
|
||||||
() {
|
|
||||||
if (!context.mounted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
currentAsset.value = value;
|
|
||||||
if (currentAsset.value == asset) {
|
|
||||||
onPlaybackReady();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() {
|
|
||||||
// If opening a remote video from a hero animation, delay visibility to avoid a stutter
|
|
||||||
final timer = isVisible.value ? null : Timer(const Duration(milliseconds: 300), () => isVisible.value = true);
|
|
||||||
|
|
||||||
return () {
|
|
||||||
timer?.cancel();
|
|
||||||
final playerController = controller.value;
|
|
||||||
if (playerController == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
removeListeners(playerController);
|
|
||||||
playerController.stop().catchError((error) {
|
|
||||||
log.fine('Error stopping video: $error');
|
|
||||||
});
|
|
||||||
|
|
||||||
WakelockPlus.disable();
|
|
||||||
};
|
|
||||||
}, const []);
|
|
||||||
|
|
||||||
useOnAppLifecycleStateChange((_, state) async {
|
|
||||||
if (state == AppLifecycleState.resumed && shouldPlayOnForeground.value) {
|
|
||||||
await controller.value?.play();
|
|
||||||
} else if (state == AppLifecycleState.paused) {
|
|
||||||
final videoPlaying = await controller.value?.isPlaying();
|
|
||||||
if (videoPlaying ?? true) {
|
|
||||||
shouldPlayOnForeground.value = true;
|
|
||||||
await controller.value?.pause();
|
|
||||||
} else {
|
|
||||||
shouldPlayOnForeground.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return SizedBox(
|
|
||||||
width: context.width,
|
|
||||||
height: context.height,
|
|
||||||
child: Stack(
|
|
||||||
children: [
|
children: [
|
||||||
// Hide thumbnail once video is visible to avoid it showing in background when zooming out on video.
|
Center(child: widget.image),
|
||||||
if (!isVisible.value || controller.value == null) Center(child: image),
|
if (!isCasting)
|
||||||
if (aspectRatio.value != null && !isCasting && isCurrent)
|
|
||||||
Visibility.maintain(
|
Visibility.maintain(
|
||||||
visible: isVisible.value,
|
visible: _isVideoReady,
|
||||||
child: PhotoView.customChild(
|
child: NativeVideoPlayerView(onViewReady: _initController),
|
||||||
enableRotation: false,
|
|
||||||
disableScaleGestures: disableScaleGestures,
|
|
||||||
// Transparent to avoid a black flash when viewer becomes visible but video isn't loaded yet.
|
|
||||||
backgroundDecoration: const BoxDecoration(color: Colors.transparent),
|
|
||||||
scaleStateChangedCallback: (state) => scaleStateNotifier?.value = state,
|
|
||||||
childSize: videoContextSize(aspectRatio.value, context),
|
|
||||||
child: NativeVideoPlayerView(onViewReady: initController),
|
|
||||||
),
|
),
|
||||||
),
|
if (widget.showControls) Center(child: VideoViewerControls(asset: widget.asset)),
|
||||||
if (showControls) const Center(child: VideoViewerControls()),
|
|
||||||
],
|
],
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onPauseChange(
|
|
||||||
BuildContext context,
|
|
||||||
NativeVideoPlayerController controller,
|
|
||||||
Debouncer seekDebouncer,
|
|
||||||
bool isPaused,
|
|
||||||
) async {
|
|
||||||
if (!context.mounted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Make sure the last seek is complete before pausing or playing
|
|
||||||
// Otherwise, `onPlaybackPositionChanged` can receive outdated events
|
|
||||||
if (seekDebouncer.isActive) {
|
|
||||||
await seekDebouncer.drain();
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (isPaused) {
|
|
||||||
await controller.pause();
|
|
||||||
} else {
|
|
||||||
await controller.play();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
log.severe('Error pausing or playing video: $error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,26 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.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/models/cast/cast_manager_state.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
|
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/cast.provider.dart';
|
import 'package:immich_mobile/providers/cast.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
|
||||||
import 'package:immich_mobile/utils/hooks/timer_hook.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/asset_viewer/center_play_button.dart';
|
||||||
import 'package:immich_mobile/widgets/common/delayed_loading_indicator.dart';
|
import 'package:immich_mobile/widgets/common/delayed_loading_indicator.dart';
|
||||||
|
|
||||||
class VideoViewerControls extends HookConsumerWidget {
|
class VideoViewerControls extends HookConsumerWidget {
|
||||||
|
final BaseAsset asset;
|
||||||
final Duration hideTimerDuration;
|
final Duration hideTimerDuration;
|
||||||
|
|
||||||
const VideoViewerControls({super.key, this.hideTimerDuration = const Duration(seconds: 5)});
|
const VideoViewerControls({super.key, required this.asset, this.hideTimerDuration = const Duration(seconds: 5)});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final assetIsVideo = ref.watch(currentAssetNotifier.select((asset) => asset != null && asset.isVideo));
|
final videoPlayerName = asset.heroTag;
|
||||||
bool showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
|
final assetIsVideo = asset.isVideo;
|
||||||
final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails));
|
final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls && !s.showingDetails));
|
||||||
if (showingDetails) {
|
final status = ref.watch(videoPlayerProvider(videoPlayerName).select((value) => value.status));
|
||||||
showControls = false;
|
|
||||||
}
|
|
||||||
final VideoPlaybackState state = ref.watch(videoPlaybackValueProvider.select((value) => value.state));
|
|
||||||
|
|
||||||
final cast = ref.watch(castProvider);
|
final cast = ref.watch(castProvider);
|
||||||
|
|
||||||
@@ -32,14 +29,14 @@ class VideoViewerControls extends HookConsumerWidget {
|
|||||||
if (!context.mounted) {
|
if (!context.mounted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final state = ref.read(videoPlaybackValueProvider).state;
|
final status = ref.read(videoPlayerProvider(videoPlayerName)).status;
|
||||||
|
|
||||||
// Do not hide on paused
|
// Do not hide on paused
|
||||||
if (state != VideoPlaybackState.paused && state != VideoPlaybackState.completed && assetIsVideo) {
|
if (status != VideoPlaybackStatus.paused && status != VideoPlaybackStatus.completed && assetIsVideo) {
|
||||||
ref.read(assetViewerProvider.notifier).setControls(false);
|
ref.read(assetViewerProvider.notifier).setControls(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
final showBuffering = state == VideoPlaybackState.buffering && !cast.isCasting;
|
final showBuffering = status == VideoPlaybackStatus.buffering && !cast.isCasting;
|
||||||
|
|
||||||
/// Shows the controls and starts the timer to hide them
|
/// Shows the controls and starts the timer to hide them
|
||||||
void showControlsAndStartHideTimer() {
|
void showControlsAndStartHideTimer() {
|
||||||
@@ -47,9 +44,11 @@ class VideoViewerControls extends HookConsumerWidget {
|
|||||||
ref.read(assetViewerProvider.notifier).setControls(true);
|
ref.read(assetViewerProvider.notifier).setControls(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// When we change position, show or hide timer
|
// When playback starts, reset the hide timer
|
||||||
ref.listen(videoPlayerControlsProvider.select((v) => v.position), (previous, next) {
|
ref.listen(videoPlayerProvider(videoPlayerName).select((v) => v.status), (previous, next) {
|
||||||
showControlsAndStartHideTimer();
|
if (next == VideoPlaybackStatus.playing) {
|
||||||
|
hideTimer.reset();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Toggles between playing and pausing depending on the state of the video
|
/// Toggles between playing and pausing depending on the state of the video
|
||||||
@@ -57,34 +56,30 @@ class VideoViewerControls extends HookConsumerWidget {
|
|||||||
showControlsAndStartHideTimer();
|
showControlsAndStartHideTimer();
|
||||||
|
|
||||||
if (cast.isCasting) {
|
if (cast.isCasting) {
|
||||||
if (cast.castState == CastState.playing) {
|
switch (cast.castState) {
|
||||||
|
case CastState.playing:
|
||||||
ref.read(castProvider.notifier).pause();
|
ref.read(castProvider.notifier).pause();
|
||||||
} else if (cast.castState == CastState.paused) {
|
case CastState.paused:
|
||||||
ref.read(castProvider.notifier).play();
|
ref.read(castProvider.notifier).play();
|
||||||
} else if (cast.castState == CastState.idle) {
|
default:
|
||||||
// resend the play command since its finished
|
|
||||||
final asset = ref.read(currentAssetNotifier);
|
|
||||||
if (asset == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// ref.read(castProvider.notifier).loadMedia(asset, true);
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state == VideoPlaybackState.playing) {
|
final notifier = ref.read(videoPlayerProvider(videoPlayerName).notifier);
|
||||||
ref.read(videoPlayerControlsProvider.notifier).pause();
|
switch (status) {
|
||||||
} else if (state == VideoPlaybackState.completed) {
|
case VideoPlaybackStatus.playing:
|
||||||
ref.read(videoPlayerControlsProvider.notifier).restart();
|
notifier.pause();
|
||||||
} else {
|
case VideoPlaybackStatus.completed:
|
||||||
ref.read(videoPlayerControlsProvider.notifier).play();
|
notifier.restart();
|
||||||
|
default:
|
||||||
|
notifier.play();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void toggleControlsVisibility() {
|
void toggleControlsVisibility() {
|
||||||
if (showBuffering) {
|
if (showBuffering) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (showControls) {
|
if (showControls) {
|
||||||
ref.read(assetViewerProvider.notifier).setControls(false);
|
ref.read(assetViewerProvider.notifier).setControls(false);
|
||||||
} else {
|
} else {
|
||||||
@@ -105,9 +100,9 @@ class VideoViewerControls extends HookConsumerWidget {
|
|||||||
CenterPlayButton(
|
CenterPlayButton(
|
||||||
backgroundColor: Colors.black54,
|
backgroundColor: Colors.black54,
|
||||||
iconColor: Colors.white,
|
iconColor: Colors.white,
|
||||||
isFinished: state == VideoPlaybackState.completed,
|
isFinished: status == VideoPlaybackStatus.completed,
|
||||||
isPlaying:
|
isPlaying:
|
||||||
state == VideoPlaybackState.playing || (cast.isCasting && cast.castState == CastState.playing),
|
status == VideoPlaybackStatus.playing || (cast.isCasting && cast.castState == CastState.playing),
|
||||||
show: assetIsVideo && showControls,
|
show: assetIsVideo && showControls,
|
||||||
onPressed: togglePlay,
|
onPressed: togglePlay,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.widget.dart';
|
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_bar.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_bar.widget.dart';
|
||||||
|
|
||||||
class ViewerBottomAppBar extends ConsumerWidget {
|
class ViewerBottomAppBar extends ConsumerWidget {
|
||||||
@@ -9,24 +8,12 @@ class ViewerBottomAppBar extends ConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
double opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity));
|
final showingControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
|
||||||
final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
|
double opacity = ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity)) * (showingControls ? 1 : 0);
|
||||||
|
|
||||||
if (!showControls) {
|
|
||||||
opacity = 0.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
return IgnorePointer(
|
return IgnorePointer(
|
||||||
ignoring: opacity < 1.0,
|
ignoring: opacity < 1.0,
|
||||||
child: AnimatedOpacity(
|
child: AnimatedOpacity(opacity: opacity, duration: Durations.short2, child: const ViewerBottomBar()),
|
||||||
opacity: opacity,
|
|
||||||
duration: Durations.short2,
|
|
||||||
child: const Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [AssetStackRow(), ViewerBottomBar()],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
|||||||
import 'package:immich_mobile/domain/models/setting.model.dart';
|
import 'package:immich_mobile/domain/models/setting.model.dart';
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/providers/cast.provider.dart';
|
import 'package:immich_mobile/providers/cast.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||||
@@ -21,7 +21,7 @@ class ViewerKebabMenu extends ConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final asset = ref.watch(currentAssetNotifier);
|
final asset = ref.watch(assetViewerProvider.select((s) => s.currentAsset));
|
||||||
if (asset == null) {
|
if (asset == null) {
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,10 +8,9 @@ import 'package:immich_mobile/routing/router.dart';
|
|||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart';
|
||||||
import 'package:immich_mobile/providers/activity.provider.dart';
|
import 'package:immich_mobile/providers/activity.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
|
||||||
import 'package:immich_mobile/providers/routes.provider.dart';
|
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||||
@@ -22,7 +21,7 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final asset = ref.watch(currentAssetNotifier);
|
final asset = ref.watch(assetViewerProvider.select((s) => s.currentAsset));
|
||||||
if (asset == null) {
|
if (asset == null) {
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
@@ -35,16 +34,13 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
|||||||
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
|
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
|
||||||
|
|
||||||
final showingDetails = ref.watch(assetViewerProvider.select((state) => state.showingDetails));
|
final showingDetails = ref.watch(assetViewerProvider.select((state) => state.showingDetails));
|
||||||
double opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity));
|
|
||||||
final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
|
|
||||||
|
|
||||||
if (album != null && album.isActivityEnabled && album.isShared && asset is RemoteAsset) {
|
if (album != null && album.isActivityEnabled && album.isShared && asset is RemoteAsset) {
|
||||||
ref.watch(albumActivityProvider(album.id, asset.id));
|
ref.watch(albumActivityProvider(album.id, asset.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!showControls) {
|
final showingControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
|
||||||
opacity = 0.0;
|
double opacity = ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity)) * (showingControls ? 1 : 0);
|
||||||
}
|
|
||||||
|
|
||||||
final originalTheme = context.themeData;
|
final originalTheme = context.themeData;
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import 'package:immich_mobile/domain/models/setting.model.dart';
|
|||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/duration_extensions.dart';
|
import 'package:immich_mobile/extensions/duration_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
|
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
|
||||||
import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart';
|
import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart';
|
||||||
|
|||||||
@@ -13,12 +13,14 @@ class DriftMemoryCard extends StatelessWidget {
|
|||||||
final RemoteAsset asset;
|
final RemoteAsset asset;
|
||||||
final String title;
|
final String title;
|
||||||
final bool showTitle;
|
final bool showTitle;
|
||||||
|
final bool isCurrent;
|
||||||
final Function()? onVideoEnded;
|
final Function()? onVideoEnded;
|
||||||
|
|
||||||
const DriftMemoryCard({
|
const DriftMemoryCard({
|
||||||
required this.asset,
|
required this.asset,
|
||||||
required this.title,
|
required this.title,
|
||||||
required this.showTitle,
|
required this.showTitle,
|
||||||
|
this.isCurrent = false,
|
||||||
this.onVideoEnded,
|
this.onVideoEnded,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
@@ -37,33 +39,35 @@ class DriftMemoryCard extends StatelessWidget {
|
|||||||
SizedBox.expand(child: _BlurredBackdrop(asset: asset)),
|
SizedBox.expand(child: _BlurredBackdrop(asset: asset)),
|
||||||
LayoutBuilder(
|
LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
|
final r = asset.width != null && asset.height != null
|
||||||
|
? asset.width! / asset.height!
|
||||||
|
: constraints.maxWidth / constraints.maxHeight;
|
||||||
|
|
||||||
// Determine the fit using the aspect ratio
|
// Determine the fit using the aspect ratio
|
||||||
BoxFit fit = BoxFit.contain;
|
BoxFit fit = BoxFit.contain;
|
||||||
if (asset.width != null && asset.height != null) {
|
if (asset.width != null && asset.height != null) {
|
||||||
final aspectRatio = asset.width! / asset.height!;
|
|
||||||
final phoneAspectRatio = constraints.maxWidth / constraints.maxHeight;
|
final phoneAspectRatio = constraints.maxWidth / constraints.maxHeight;
|
||||||
// Look for a 25% difference in either direction
|
// Look for a 25% difference in either direction
|
||||||
if (phoneAspectRatio * .75 < aspectRatio && phoneAspectRatio * 1.25 > aspectRatio) {
|
if (phoneAspectRatio * .75 < r && phoneAspectRatio * 1.25 > r) {
|
||||||
// Cover to look nice if we have nearly the same aspect ratio
|
// Cover to look nice if we have nearly the same aspect ratio
|
||||||
fit = BoxFit.cover;
|
fit = BoxFit.cover;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (asset.isImage) {
|
if (asset.isImage) return FullImage(asset, fit: fit, size: const Size(double.infinity, double.infinity));
|
||||||
return FullImage(asset, fit: fit, size: const Size(double.infinity, double.infinity));
|
|
||||||
} else {
|
return Center(
|
||||||
return SizedBox(
|
child: AspectRatio(
|
||||||
width: context.width,
|
aspectRatio: r,
|
||||||
height: context.height,
|
|
||||||
child: NativeVideoViewer(
|
child: NativeVideoViewer(
|
||||||
key: ValueKey(asset.id),
|
key: ValueKey(asset.id),
|
||||||
asset: asset,
|
asset: asset,
|
||||||
|
isCurrent: isCurrent,
|
||||||
showControls: false,
|
showControls: false,
|
||||||
playbackDelayFactor: 2,
|
image: FullImage(asset, size: context.sizeData, fit: BoxFit.contain),
|
||||||
image: FullImage(asset, size: Size(context.width, context.height), fit: BoxFit.contain),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
if (showTitle)
|
if (showTitle)
|
||||||
|
|||||||
+22
-6
@@ -1,5 +1,7 @@
|
|||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
|
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
|
||||||
class AssetViewerState {
|
class AssetViewerState {
|
||||||
@@ -68,6 +70,12 @@ class AssetViewerState {
|
|||||||
class AssetViewerStateNotifier extends Notifier<AssetViewerState> {
|
class AssetViewerStateNotifier extends Notifier<AssetViewerState> {
|
||||||
@override
|
@override
|
||||||
AssetViewerState build() {
|
AssetViewerState build() {
|
||||||
|
ref.listen(_watchedCurrentAssetProvider, (_, next) {
|
||||||
|
final updated = next.valueOrNull;
|
||||||
|
if (updated != null) {
|
||||||
|
state = state.copyWith(currentAsset: updated);
|
||||||
|
}
|
||||||
|
});
|
||||||
return const AssetViewerState();
|
return const AssetViewerState();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,10 +83,8 @@ class AssetViewerStateNotifier extends Notifier<AssetViewerState> {
|
|||||||
state = const AssetViewerState();
|
state = const AssetViewerState();
|
||||||
}
|
}
|
||||||
|
|
||||||
void setAsset(BaseAsset? asset) {
|
void setAsset(BaseAsset asset) {
|
||||||
if (asset == state.currentAsset) {
|
if (asset == state.currentAsset) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
state = state.copyWith(currentAsset: asset, stackIndex: 0);
|
state = state.copyWith(currentAsset: asset, stackIndex: 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,7 +101,10 @@ class AssetViewerStateNotifier extends Notifier<AssetViewerState> {
|
|||||||
}
|
}
|
||||||
state = state.copyWith(showingDetails: showing, showingControls: showing ? true : state.showingControls);
|
state = state.copyWith(showingDetails: showing, showingControls: showing ? true : state.showingControls);
|
||||||
if (showing) {
|
if (showing) {
|
||||||
ref.read(videoPlayerControlsProvider.notifier).pause();
|
final heroTag = state.currentAsset?.heroTag;
|
||||||
|
if (heroTag != null) {
|
||||||
|
ref.read(videoPlayerProvider(heroTag).notifier).pause();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,3 +135,10 @@ class AssetViewerStateNotifier extends Notifier<AssetViewerState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final assetViewerProvider = NotifierProvider<AssetViewerStateNotifier, AssetViewerState>(AssetViewerStateNotifier.new);
|
final assetViewerProvider = NotifierProvider<AssetViewerStateNotifier, AssetViewerState>(AssetViewerStateNotifier.new);
|
||||||
|
|
||||||
|
final _watchedCurrentAssetProvider = StreamProvider<BaseAsset?>((ref) {
|
||||||
|
ref.watch(assetViewerProvider.select((s) => s.currentAsset?.heroTag));
|
||||||
|
final asset = ref.read(assetViewerProvider).currentAsset;
|
||||||
|
if (asset == null) return const Stream.empty();
|
||||||
|
return ref.read(assetServiceProvider).watchAsset(asset);
|
||||||
|
});
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
|
||||||
|
|
||||||
class VideoPlaybackControls {
|
|
||||||
const VideoPlaybackControls({required this.position, required this.pause, this.restarted = false});
|
|
||||||
|
|
||||||
final Duration position;
|
|
||||||
final bool pause;
|
|
||||||
final bool restarted;
|
|
||||||
}
|
|
||||||
|
|
||||||
final videoPlayerControlsProvider = StateNotifierProvider<VideoPlayerControls, VideoPlaybackControls>((ref) {
|
|
||||||
return VideoPlayerControls(ref);
|
|
||||||
});
|
|
||||||
|
|
||||||
const videoPlayerControlsDefault = VideoPlaybackControls(position: Duration.zero, pause: false);
|
|
||||||
|
|
||||||
class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
|
|
||||||
VideoPlayerControls(this.ref) : super(videoPlayerControlsDefault);
|
|
||||||
|
|
||||||
final Ref ref;
|
|
||||||
|
|
||||||
VideoPlaybackControls get value => state;
|
|
||||||
|
|
||||||
set value(VideoPlaybackControls value) {
|
|
||||||
state = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
void reset() {
|
|
||||||
state = videoPlayerControlsDefault;
|
|
||||||
}
|
|
||||||
|
|
||||||
Duration get position => state.position;
|
|
||||||
bool get paused => state.pause;
|
|
||||||
|
|
||||||
set position(Duration value) {
|
|
||||||
if (state.position == value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
state = VideoPlaybackControls(position: value, pause: state.pause);
|
|
||||||
}
|
|
||||||
|
|
||||||
void pause() {
|
|
||||||
if (state.pause) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
state = VideoPlaybackControls(position: state.position, pause: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
void play() {
|
|
||||||
if (!state.pause) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
state = VideoPlaybackControls(position: state.position, pause: false);
|
|
||||||
}
|
|
||||||
|
|
||||||
void togglePlay() {
|
|
||||||
state = VideoPlaybackControls(position: state.position, pause: !state.pause);
|
|
||||||
}
|
|
||||||
|
|
||||||
void restart() {
|
|
||||||
state = const VideoPlaybackControls(position: Duration.zero, pause: false, restarted: true);
|
|
||||||
ref.read(videoPlaybackValueProvider.notifier).value = ref
|
|
||||||
.read(videoPlaybackValueProvider.notifier)
|
|
||||||
.value
|
|
||||||
.copyWith(state: VideoPlaybackState.playing, position: Duration.zero);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:native_video_player/native_video_player.dart';
|
||||||
|
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||||
|
|
||||||
|
enum VideoPlaybackStatus { paused, playing, buffering, completed }
|
||||||
|
|
||||||
|
class VideoPlayerState {
|
||||||
|
final Duration position;
|
||||||
|
final Duration duration;
|
||||||
|
final VideoPlaybackStatus status;
|
||||||
|
|
||||||
|
const VideoPlayerState({required this.position, required this.duration, required this.status});
|
||||||
|
|
||||||
|
VideoPlayerState copyWith({Duration? position, Duration? duration, VideoPlaybackStatus? status}) {
|
||||||
|
return VideoPlayerState(
|
||||||
|
position: position ?? this.position,
|
||||||
|
duration: duration ?? this.duration,
|
||||||
|
status: status ?? this.status,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const _defaultState = VideoPlayerState(
|
||||||
|
position: Duration.zero,
|
||||||
|
duration: Duration.zero,
|
||||||
|
status: VideoPlaybackStatus.paused,
|
||||||
|
);
|
||||||
|
|
||||||
|
final videoPlayerProvider = StateNotifierProvider.autoDispose.family<VideoPlayerNotifier, VideoPlayerState, String>((
|
||||||
|
ref,
|
||||||
|
name,
|
||||||
|
) {
|
||||||
|
return VideoPlayerNotifier();
|
||||||
|
});
|
||||||
|
|
||||||
|
class VideoPlayerNotifier extends StateNotifier<VideoPlayerState> {
|
||||||
|
static final _log = Logger('VideoPlayerNotifier');
|
||||||
|
|
||||||
|
VideoPlayerNotifier() : super(_defaultState);
|
||||||
|
|
||||||
|
NativeVideoPlayerController? _controller;
|
||||||
|
Timer? _bufferingTimer;
|
||||||
|
Timer? _seekTimer;
|
||||||
|
|
||||||
|
void attachController(NativeVideoPlayerController controller) {
|
||||||
|
_controller = controller;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_bufferingTimer?.cancel();
|
||||||
|
_seekTimer?.cancel();
|
||||||
|
WakelockPlus.disable();
|
||||||
|
_controller = null;
|
||||||
|
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> pause() async {
|
||||||
|
if (_controller == null) return;
|
||||||
|
|
||||||
|
_bufferingTimer?.cancel();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _controller!.pause();
|
||||||
|
await _flushSeek();
|
||||||
|
} catch (e) {
|
||||||
|
_log.severe('Error pausing video: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> play() async {
|
||||||
|
if (_controller == null) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _flushSeek();
|
||||||
|
await _controller!.play();
|
||||||
|
} catch (e) {
|
||||||
|
_log.severe('Error playing video: $e');
|
||||||
|
}
|
||||||
|
|
||||||
|
_startBufferingTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _flushSeek() async {
|
||||||
|
final timer = _seekTimer;
|
||||||
|
if (timer == null || !timer.isActive) return;
|
||||||
|
|
||||||
|
timer.cancel();
|
||||||
|
await _controller?.seekTo(state.position.inMilliseconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
void seekTo(Duration position) {
|
||||||
|
if (_controller == null) return;
|
||||||
|
|
||||||
|
state = state.copyWith(position: position);
|
||||||
|
|
||||||
|
_seekTimer?.cancel();
|
||||||
|
_seekTimer = Timer(const Duration(milliseconds: 100), () {
|
||||||
|
_controller?.seekTo(position.inMilliseconds);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> restart() async {
|
||||||
|
seekTo(Duration.zero);
|
||||||
|
await play();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setVolume(double volume) async {
|
||||||
|
try {
|
||||||
|
await _controller?.setVolume(volume);
|
||||||
|
} catch (e) {
|
||||||
|
_log.severe('Error setting volume: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setLoop(bool loop) async {
|
||||||
|
try {
|
||||||
|
await _controller?.setLoop(loop);
|
||||||
|
} catch (e) {
|
||||||
|
_log.severe('Error setting loop: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void onNativePlaybackReady() {
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
final playbackInfo = _controller?.playbackInfo;
|
||||||
|
final videoInfo = _controller?.videoInfo;
|
||||||
|
|
||||||
|
if (playbackInfo == null || videoInfo == null) return;
|
||||||
|
|
||||||
|
state = state.copyWith(
|
||||||
|
position: Duration(milliseconds: playbackInfo.position),
|
||||||
|
duration: Duration(milliseconds: videoInfo.duration),
|
||||||
|
status: _mapStatus(playbackInfo.status),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void onNativePositionChanged() {
|
||||||
|
if (!mounted || (_seekTimer?.isActive ?? false)) return;
|
||||||
|
|
||||||
|
final playbackInfo = _controller?.playbackInfo;
|
||||||
|
if (playbackInfo == null) return;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
_startBufferingTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
void onNativeStatusChanged() {
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
final playbackInfo = _controller?.playbackInfo;
|
||||||
|
if (playbackInfo == null) return;
|
||||||
|
|
||||||
|
final newStatus = _mapStatus(playbackInfo.status);
|
||||||
|
switch (newStatus) {
|
||||||
|
case VideoPlaybackStatus.playing:
|
||||||
|
WakelockPlus.enable();
|
||||||
|
_startBufferingTimer();
|
||||||
|
default:
|
||||||
|
onNativePlaybackEnded();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.status != newStatus) {
|
||||||
|
state = state.copyWith(status: newStatus);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void onNativePlaybackEnded() {
|
||||||
|
WakelockPlus.disable();
|
||||||
|
_bufferingTimer?.cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _startBufferingTimer() {
|
||||||
|
_bufferingTimer?.cancel();
|
||||||
|
_bufferingTimer = Timer(const Duration(seconds: 3), () {
|
||||||
|
if (mounted && state.status == VideoPlaybackStatus.playing) {
|
||||||
|
state = state.copyWith(status: VideoPlaybackStatus.buffering);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static VideoPlaybackStatus _mapStatus(PlaybackStatus status) => switch (status) {
|
||||||
|
PlaybackStatus.playing => VideoPlaybackStatus.playing,
|
||||||
|
PlaybackStatus.paused => VideoPlaybackStatus.paused,
|
||||||
|
PlaybackStatus.stopped => VideoPlaybackStatus.completed,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
import 'package:native_video_player/native_video_player.dart';
|
|
||||||
|
|
||||||
enum VideoPlaybackState { initializing, paused, playing, buffering, completed }
|
|
||||||
|
|
||||||
class VideoPlaybackValue {
|
|
||||||
/// The current position of the video
|
|
||||||
final Duration position;
|
|
||||||
|
|
||||||
/// The total duration of the video
|
|
||||||
final Duration duration;
|
|
||||||
|
|
||||||
/// The current state of the video playback
|
|
||||||
final VideoPlaybackState state;
|
|
||||||
|
|
||||||
/// The volume of the video
|
|
||||||
final double volume;
|
|
||||||
|
|
||||||
const VideoPlaybackValue({required this.position, required this.duration, required this.state, required this.volume});
|
|
||||||
|
|
||||||
factory VideoPlaybackValue.fromNativeController(NativeVideoPlayerController controller) {
|
|
||||||
final playbackInfo = controller.playbackInfo;
|
|
||||||
final videoInfo = controller.videoInfo;
|
|
||||||
|
|
||||||
if (playbackInfo == null || videoInfo == null) {
|
|
||||||
return videoPlaybackValueDefault;
|
|
||||||
}
|
|
||||||
|
|
||||||
final VideoPlaybackState status = switch (playbackInfo.status) {
|
|
||||||
PlaybackStatus.playing => VideoPlaybackState.playing,
|
|
||||||
PlaybackStatus.paused => VideoPlaybackState.paused,
|
|
||||||
PlaybackStatus.stopped => VideoPlaybackState.completed,
|
|
||||||
};
|
|
||||||
|
|
||||||
return VideoPlaybackValue(
|
|
||||||
position: Duration(milliseconds: playbackInfo.position),
|
|
||||||
duration: Duration(milliseconds: videoInfo.duration),
|
|
||||||
state: status,
|
|
||||||
volume: playbackInfo.volume,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
VideoPlaybackValue copyWith({Duration? position, Duration? duration, VideoPlaybackState? state, double? volume}) {
|
|
||||||
return VideoPlaybackValue(
|
|
||||||
position: position ?? this.position,
|
|
||||||
duration: duration ?? this.duration,
|
|
||||||
state: state ?? this.state,
|
|
||||||
volume: volume ?? this.volume,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const VideoPlaybackValue videoPlaybackValueDefault = VideoPlaybackValue(
|
|
||||||
position: Duration.zero,
|
|
||||||
duration: Duration.zero,
|
|
||||||
state: VideoPlaybackState.initializing,
|
|
||||||
volume: 0.0,
|
|
||||||
);
|
|
||||||
|
|
||||||
final videoPlaybackValueProvider = StateNotifierProvider<VideoPlaybackValueState, VideoPlaybackValue>((ref) {
|
|
||||||
return VideoPlaybackValueState(ref);
|
|
||||||
});
|
|
||||||
|
|
||||||
class VideoPlaybackValueState extends StateNotifier<VideoPlaybackValue> {
|
|
||||||
VideoPlaybackValueState(this.ref) : super(videoPlaybackValueDefault);
|
|
||||||
|
|
||||||
final Ref ref;
|
|
||||||
|
|
||||||
VideoPlaybackValue get value => state;
|
|
||||||
|
|
||||||
set value(VideoPlaybackValue value) {
|
|
||||||
state = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
set position(Duration value) {
|
|
||||||
if (state.position == value) return;
|
|
||||||
state = VideoPlaybackValue(position: value, duration: state.duration, state: state.state, volume: state.volume);
|
|
||||||
}
|
|
||||||
|
|
||||||
set status(VideoPlaybackState value) {
|
|
||||||
if (state.state == value) return;
|
|
||||||
state = VideoPlaybackValue(position: state.position, duration: state.duration, state: value, volume: state.volume);
|
|
||||||
}
|
|
||||||
|
|
||||||
void reset() {
|
|
||||||
state = videoPlaybackValueDefault;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,9 +8,9 @@ import 'package:immich_mobile/constants/enums.dart';
|
|||||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
import 'package:immich_mobile/domain/services/asset.service.dart';
|
import 'package:immich_mobile/domain/services/asset.service.dart';
|
||||||
import 'package:immich_mobile/models/download/livephotos_medatada.model.dart';
|
import 'package:immich_mobile/models/download/livephotos_medatada.model.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart' show assetExifProvider;
|
||||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||||
import 'package:immich_mobile/providers/user.provider.dart';
|
import 'package:immich_mobile/providers/user.provider.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
@@ -123,7 +123,7 @@ class ActionNotifier extends Notifier<void> {
|
|||||||
Set<BaseAsset> _getAssets(ActionSource source) {
|
Set<BaseAsset> _getAssets(ActionSource source) {
|
||||||
return switch (source) {
|
return switch (source) {
|
||||||
ActionSource.timeline => ref.read(multiSelectProvider).selectedAssets,
|
ActionSource.timeline => ref.read(multiSelectProvider).selectedAssets,
|
||||||
ActionSource.viewer => switch (ref.read(currentAssetNotifier)) {
|
ActionSource.viewer => switch (ref.read(assetViewerProvider).currentAsset) {
|
||||||
BaseAsset asset => {asset},
|
BaseAsset asset => {asset},
|
||||||
null => const {},
|
null => const {},
|
||||||
},
|
},
|
||||||
@@ -307,7 +307,10 @@ class ActionNotifier extends Notifier<void> {
|
|||||||
// does not update the currentAsset which means
|
// does not update the currentAsset which means
|
||||||
// the exif provider will not be refreshed automatically
|
// the exif provider will not be refreshed automatically
|
||||||
if (source == ActionSource.viewer) {
|
if (source == ActionSource.viewer) {
|
||||||
ref.invalidate(currentAssetExifProvider);
|
final currentAsset = ref.read(assetViewerProvider).currentAsset;
|
||||||
|
if (currentAsset != null) {
|
||||||
|
ref.invalidate(assetExifProvider(currentAsset));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return ActionResult(count: ids.length, success: true);
|
return ActionResult(count: ids.length, success: true);
|
||||||
@@ -409,7 +412,6 @@ class ActionNotifier extends Notifier<void> {
|
|||||||
if (source == ActionSource.viewer) {
|
if (source == ActionSource.viewer) {
|
||||||
final updatedParent = await _assetService.getRemoteAsset(assets.first.id);
|
final updatedParent = await _assetService.getRemoteAsset(assets.first.id);
|
||||||
if (updatedParent != null) {
|
if (updatedParent != null) {
|
||||||
ref.read(currentAssetNotifier.notifier).setAsset(updatedParent);
|
|
||||||
ref.read(assetViewerProvider.notifier).setAsset(updatedParent);
|
ref.read(assetViewerProvider.notifier).setAsset(updatedParent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,52 +1,8 @@
|
|||||||
import 'dart:async';
|
|
||||||
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/exif.model.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||||
|
|
||||||
final currentAssetNotifier = AutoDisposeNotifierProvider<CurrentAssetNotifier, BaseAsset?>(CurrentAssetNotifier.new);
|
final assetExifProvider = FutureProvider.autoDispose.family<ExifInfo?, BaseAsset>((ref, asset) {
|
||||||
|
return ref.watch(assetServiceProvider).getExif(asset);
|
||||||
class CurrentAssetNotifier extends AutoDisposeNotifier<BaseAsset?> {
|
|
||||||
KeepAliveLink? _keepAliveLink;
|
|
||||||
StreamSubscription<BaseAsset?>? _assetSubscription;
|
|
||||||
|
|
||||||
@override
|
|
||||||
BaseAsset? build() => null;
|
|
||||||
|
|
||||||
void setAsset(BaseAsset asset) {
|
|
||||||
_keepAliveLink?.close();
|
|
||||||
_assetSubscription?.cancel();
|
|
||||||
state = asset;
|
|
||||||
_assetSubscription = ref.watch(assetServiceProvider).watchAsset(asset).listen((updatedAsset) {
|
|
||||||
if (updatedAsset != null) {
|
|
||||||
state = updatedAsset;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
_keepAliveLink = ref.keepAlive();
|
|
||||||
}
|
|
||||||
|
|
||||||
void dispose() {
|
|
||||||
_keepAliveLink?.close();
|
|
||||||
_assetSubscription?.cancel();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class ScopedAssetNotifier extends CurrentAssetNotifier {
|
|
||||||
final BaseAsset _asset;
|
|
||||||
|
|
||||||
ScopedAssetNotifier(this._asset);
|
|
||||||
|
|
||||||
@override
|
|
||||||
BaseAsset? build() {
|
|
||||||
setAsset(_asset);
|
|
||||||
return _asset;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final currentAssetExifProvider = FutureProvider.autoDispose((ref) {
|
|
||||||
final currentAsset = ref.watch(currentAssetNotifier);
|
|
||||||
if (currentAsset == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return ref.watch(assetServiceProvider).getExif(currentAsset);
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
import 'dart:async';
|
|
||||||
import 'dart:ui';
|
|
||||||
|
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
|
||||||
|
|
||||||
// https://github.com/rrousselGit/flutter_hooks/issues/233#issuecomment-840416638
|
|
||||||
void useInterval(Duration delay, VoidCallback callback) {
|
|
||||||
final savedCallback = useRef(callback);
|
|
||||||
savedCallback.value = callback;
|
|
||||||
|
|
||||||
useEffect(() {
|
|
||||||
final timer = Timer.periodic(delay, (_) => savedCallback.value());
|
|
||||||
return timer.cancel;
|
|
||||||
}, [delay]);
|
|
||||||
}
|
|
||||||
@@ -333,7 +333,7 @@ class BottomGalleryBar extends ConsumerWidget {
|
|||||||
padding: const EdgeInsets.only(top: 40.0),
|
padding: const EdgeInsets.only(top: 40.0),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
if (asset.isVideo) const VideoControls(),
|
if (asset.isVideo) VideoControls(videoPlayerName: asset.id.toString()),
|
||||||
BottomNavigationBar(
|
BottomNavigationBar(
|
||||||
elevation: 0.0,
|
elevation: 0.0,
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
|
|||||||
@@ -3,23 +3,27 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
import 'package:immich_mobile/models/cast/cast_manager_state.dart';
|
import 'package:immich_mobile/models/cast/cast_manager_state.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
|
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
|
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
|
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/cast.provider.dart';
|
import 'package:immich_mobile/providers/cast.provider.dart';
|
||||||
import 'package:immich_mobile/utils/hooks/timer_hook.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/asset_viewer/center_play_button.dart';
|
||||||
import 'package:immich_mobile/widgets/common/delayed_loading_indicator.dart';
|
import 'package:immich_mobile/widgets/common/delayed_loading_indicator.dart';
|
||||||
|
|
||||||
class CustomVideoPlayerControls extends HookConsumerWidget {
|
class CustomVideoPlayerControls extends HookConsumerWidget {
|
||||||
|
final String videoId;
|
||||||
final Duration hideTimerDuration;
|
final Duration hideTimerDuration;
|
||||||
|
|
||||||
const CustomVideoPlayerControls({super.key, this.hideTimerDuration = const Duration(seconds: 5)});
|
const CustomVideoPlayerControls({
|
||||||
|
super.key,
|
||||||
|
required this.videoId,
|
||||||
|
this.hideTimerDuration = const Duration(seconds: 5),
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final assetIsVideo = ref.watch(currentAssetProvider.select((asset) => asset != null && asset.isVideo));
|
final assetIsVideo = ref.watch(currentAssetProvider.select((asset) => asset != null && asset.isVideo));
|
||||||
final showControls = ref.watch(showControlsProvider);
|
final showControls = ref.watch(showControlsProvider);
|
||||||
final VideoPlaybackState state = ref.watch(videoPlaybackValueProvider.select((value) => value.state));
|
final status = ref.watch(videoPlayerProvider(videoId).select((value) => value.status));
|
||||||
|
|
||||||
final cast = ref.watch(castProvider);
|
final cast = ref.watch(castProvider);
|
||||||
|
|
||||||
@@ -28,14 +32,14 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
|
|||||||
if (!context.mounted) {
|
if (!context.mounted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final state = ref.read(videoPlaybackValueProvider).state;
|
final s = ref.read(videoPlayerProvider(videoId)).status;
|
||||||
|
|
||||||
// Do not hide on paused
|
// Do not hide on paused
|
||||||
if (state != VideoPlaybackState.paused && state != VideoPlaybackState.completed && assetIsVideo) {
|
if (s != VideoPlaybackStatus.paused && s != VideoPlaybackStatus.completed && assetIsVideo) {
|
||||||
ref.read(showControlsProvider.notifier).show = false;
|
ref.read(showControlsProvider.notifier).show = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
final showBuffering = state == VideoPlaybackState.buffering && !cast.isCasting;
|
final showBuffering = status == VideoPlaybackStatus.buffering && !cast.isCasting;
|
||||||
|
|
||||||
/// Shows the controls and starts the timer to hide them
|
/// Shows the controls and starts the timer to hide them
|
||||||
void showControlsAndStartHideTimer() {
|
void showControlsAndStartHideTimer() {
|
||||||
@@ -43,9 +47,11 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
|
|||||||
ref.read(showControlsProvider.notifier).show = true;
|
ref.read(showControlsProvider.notifier).show = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// When we change position, show or hide timer
|
// When playback starts, reset the hide timer
|
||||||
ref.listen(videoPlayerControlsProvider.select((v) => v.position), (previous, next) {
|
ref.listen(videoPlayerProvider(videoId).select((v) => v.status), (previous, next) {
|
||||||
showControlsAndStartHideTimer();
|
if (next == VideoPlaybackStatus.playing) {
|
||||||
|
hideTimer.reset();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Toggles between playing and pausing depending on the state of the video
|
/// Toggles between playing and pausing depending on the state of the video
|
||||||
@@ -68,12 +74,13 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state == VideoPlaybackState.playing) {
|
final notifier = ref.read(videoPlayerProvider(videoId).notifier);
|
||||||
ref.read(videoPlayerControlsProvider.notifier).pause();
|
if (status == VideoPlaybackStatus.playing) {
|
||||||
} else if (state == VideoPlaybackState.completed) {
|
notifier.pause();
|
||||||
ref.read(videoPlayerControlsProvider.notifier).restart();
|
} else if (status == VideoPlaybackStatus.completed) {
|
||||||
|
notifier.restart();
|
||||||
} else {
|
} else {
|
||||||
ref.read(videoPlayerControlsProvider.notifier).play();
|
notifier.play();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,9 +99,9 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
|
|||||||
child: CenterPlayButton(
|
child: CenterPlayButton(
|
||||||
backgroundColor: Colors.black54,
|
backgroundColor: Colors.black54,
|
||||||
iconColor: Colors.white,
|
iconColor: Colors.white,
|
||||||
isFinished: state == VideoPlaybackState.completed,
|
isFinished: status == VideoPlaybackStatus.completed,
|
||||||
isPlaying:
|
isPlaying:
|
||||||
state == VideoPlaybackState.playing || (cast.isCasting && cast.castState == CastState.playing),
|
status == VideoPlaybackStatus.playing || (cast.isCasting && cast.castState == CastState.playing),
|
||||||
show: assetIsVideo && showControls,
|
show: assetIsVideo && showControls,
|
||||||
onPressed: togglePlay,
|
onPressed: togglePlay,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -3,15 +3,20 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/widgets/asset_viewer/video_position.dart';
|
import 'package:immich_mobile/widgets/asset_viewer/video_position.dart';
|
||||||
|
|
||||||
/// The video controls for the [videoPlayerControlsProvider]
|
/// The video controls for the [videoPlayerProvider]
|
||||||
class VideoControls extends ConsumerWidget {
|
class VideoControls extends ConsumerWidget {
|
||||||
const VideoControls({super.key});
|
final String videoPlayerName;
|
||||||
|
|
||||||
|
const VideoControls({super.key, required this.videoPlayerName});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final isPortrait = context.orientation == Orientation.portrait;
|
final isPortrait = context.orientation == Orientation.portrait;
|
||||||
return isPortrait
|
return isPortrait
|
||||||
? const VideoPosition()
|
? VideoPosition(videoPlayerName: videoPlayerName)
|
||||||
: const Padding(padding: EdgeInsets.symmetric(horizontal: 60.0), child: VideoPosition());
|
: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 60.0),
|
||||||
|
child: VideoPosition(videoPlayerName: videoPlayerName),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,14 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/constants/colors.dart';
|
import 'package:immich_mobile/constants/colors.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
|
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/cast.provider.dart';
|
import 'package:immich_mobile/providers/cast.provider.dart';
|
||||||
import 'package:immich_mobile/widgets/asset_viewer/formatted_duration.dart';
|
import 'package:immich_mobile/widgets/asset_viewer/formatted_duration.dart';
|
||||||
|
|
||||||
class VideoPosition extends HookConsumerWidget {
|
class VideoPosition extends HookConsumerWidget {
|
||||||
const VideoPosition({super.key});
|
final String videoPlayerName;
|
||||||
|
|
||||||
|
const VideoPosition({super.key, required this.videoPlayerName});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
@@ -18,7 +19,7 @@ class VideoPosition extends HookConsumerWidget {
|
|||||||
|
|
||||||
final (position, duration) = isCasting
|
final (position, duration) = isCasting
|
||||||
? ref.watch(castProvider.select((c) => (c.currentTime, c.duration)))
|
? ref.watch(castProvider.select((c) => (c.currentTime, c.duration)))
|
||||||
: ref.watch(videoPlaybackValueProvider.select((v) => (v.position, v.duration)));
|
: ref.watch(videoPlayerProvider(videoPlayerName).select((v) => (v.position, v.duration)));
|
||||||
|
|
||||||
final wasPlaying = useRef<bool>(true);
|
final wasPlaying = useRef<bool>(true);
|
||||||
return duration == Duration.zero
|
return duration == Duration.zero
|
||||||
@@ -44,13 +45,13 @@ class VideoPosition extends HookConsumerWidget {
|
|||||||
activeColor: Colors.white,
|
activeColor: Colors.white,
|
||||||
inactiveColor: whiteOpacity75,
|
inactiveColor: whiteOpacity75,
|
||||||
onChangeStart: (value) {
|
onChangeStart: (value) {
|
||||||
final state = ref.read(videoPlaybackValueProvider).state;
|
final status = ref.read(videoPlayerProvider(videoPlayerName)).status;
|
||||||
wasPlaying.value = state != VideoPlaybackState.paused;
|
wasPlaying.value = status != VideoPlaybackStatus.paused;
|
||||||
ref.read(videoPlayerControlsProvider.notifier).pause();
|
ref.read(videoPlayerProvider(videoPlayerName).notifier).pause();
|
||||||
},
|
},
|
||||||
onChangeEnd: (value) {
|
onChangeEnd: (value) {
|
||||||
if (wasPlaying.value) {
|
if (wasPlaying.value) {
|
||||||
ref.read(videoPlayerControlsProvider.notifier).play();
|
ref.read(videoPlayerProvider(videoPlayerName).notifier).play();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
@@ -61,10 +62,7 @@ class VideoPosition extends HookConsumerWidget {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ref.read(videoPlayerControlsProvider.notifier).position = seekToDuration;
|
ref.read(videoPlayerProvider(videoPlayerName).notifier).seekTo(seekToDuration);
|
||||||
|
|
||||||
// This immediately updates the slider position without waiting for the video to update
|
|
||||||
ref.read(videoPlaybackValueProvider.notifier).position = seekToDuration;
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
import 'package:immich_mobile/models/memories/memory.model.dart';
|
import 'package:immich_mobile/models/memories/memory.model.dart';
|
||||||
import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart';
|
import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
|
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/memory.provider.dart';
|
import 'package:immich_mobile/providers/memory.provider.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
|
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
|
||||||
@@ -34,9 +33,6 @@ class MemoryLane extends HookConsumerWidget {
|
|||||||
if (memories[memoryIndex].assets.isNotEmpty) {
|
if (memories[memoryIndex].assets.isNotEmpty) {
|
||||||
final asset = memories[memoryIndex].assets[0];
|
final asset = memories[memoryIndex].assets[0];
|
||||||
ref.read(currentAssetProvider.notifier).set(asset);
|
ref.read(currentAssetProvider.notifier).set(asset);
|
||||||
if (asset.isVideo || asset.isMotionPhoto) {
|
|
||||||
ref.read(videoPlaybackValueProvider.notifier).reset();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
context.pushRoute(MemoryRoute(memories: memories, memoryIndex: memoryIndex));
|
context.pushRoute(MemoryRoute(memories: memories, memoryIndex: memoryIndex));
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -420,7 +420,11 @@ class PhotoViewCoreState extends State<PhotoViewCore>
|
|||||||
|
|
||||||
Widget _buildChild() {
|
Widget _buildChild() {
|
||||||
return widget.hasCustomChild
|
return widget.hasCustomChild
|
||||||
? widget.customChild!
|
? SizedBox(
|
||||||
|
width: scaleBoundaries.childSize.width * scale,
|
||||||
|
height: scaleBoundaries.childSize.height * scale,
|
||||||
|
child: widget.customChild!,
|
||||||
|
)
|
||||||
: Image(
|
: Image(
|
||||||
key: widget.heroAttributes?.tag != null ? ObjectKey(widget.heroAttributes!.tag) : null,
|
key: widget.heroAttributes?.tag != null ? ObjectKey(widget.heroAttributes!.tag) : null,
|
||||||
image: widget.imageProvider!,
|
image: widget.imageProvider!,
|
||||||
@@ -428,7 +432,7 @@ class PhotoViewCoreState extends State<PhotoViewCore>
|
|||||||
gaplessPlayback: widget.gaplessPlayback ?? false,
|
gaplessPlayback: widget.gaplessPlayback ?? false,
|
||||||
filterQuality: widget.filterQuality,
|
filterQuality: widget.filterQuality,
|
||||||
width: scaleBoundaries.childSize.width * scale,
|
width: scaleBoundaries.childSize.width * scale,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.contain,
|
||||||
isAntiAlias: widget.filterQuality == FilterQuality.high,
|
isAntiAlias: widget.filterQuality == FilterQuality.high,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
-1
@@ -121,7 +121,6 @@ Class | Method | HTTP request | Description
|
|||||||
*AssetsApi* | [**updateBulkAssetMetadata**](doc//AssetsApi.md#updatebulkassetmetadata) | **PUT** /assets/metadata | Upsert asset metadata
|
*AssetsApi* | [**updateBulkAssetMetadata**](doc//AssetsApi.md#updatebulkassetmetadata) | **PUT** /assets/metadata | Upsert asset metadata
|
||||||
*AssetsApi* | [**uploadAsset**](doc//AssetsApi.md#uploadasset) | **POST** /assets | Upload asset
|
*AssetsApi* | [**uploadAsset**](doc//AssetsApi.md#uploadasset) | **POST** /assets | Upload asset
|
||||||
*AssetsApi* | [**viewAsset**](doc//AssetsApi.md#viewasset) | **GET** /assets/{id}/thumbnail | View asset thumbnail
|
*AssetsApi* | [**viewAsset**](doc//AssetsApi.md#viewasset) | **GET** /assets/{id}/thumbnail | View asset thumbnail
|
||||||
*AssetsApi* | [**viewAssetTile**](doc//AssetsApi.md#viewassettile) | **GET** /assets/{id}/tiles/{level}/{col}/{row} | View an asset tile
|
|
||||||
*AuthenticationApi* | [**changePassword**](doc//AuthenticationApi.md#changepassword) | **POST** /auth/change-password | Change password
|
*AuthenticationApi* | [**changePassword**](doc//AuthenticationApi.md#changepassword) | **POST** /auth/change-password | Change password
|
||||||
*AuthenticationApi* | [**changePinCode**](doc//AuthenticationApi.md#changepincode) | **PUT** /auth/pin-code | Change pin code
|
*AuthenticationApi* | [**changePinCode**](doc//AuthenticationApi.md#changepincode) | **PUT** /auth/pin-code | Change pin code
|
||||||
*AuthenticationApi* | [**finishOAuth**](doc//AuthenticationApi.md#finishoauth) | **POST** /oauth/callback | Finish OAuth
|
*AuthenticationApi* | [**finishOAuth**](doc//AuthenticationApi.md#finishoauth) | **POST** /oauth/callback | Finish OAuth
|
||||||
|
|||||||
Generated
-87
@@ -1836,91 +1836,4 @@ class AssetsApi {
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// View an asset tile
|
|
||||||
///
|
|
||||||
/// Download a specific tile from an image at the specified level - must currently be 0 - and position
|
|
||||||
///
|
|
||||||
/// Note: This method returns the HTTP [Response].
|
|
||||||
///
|
|
||||||
/// Parameters:
|
|
||||||
///
|
|
||||||
/// * [num] col (required):
|
|
||||||
///
|
|
||||||
/// * [String] id (required):
|
|
||||||
///
|
|
||||||
/// * [num] level (required):
|
|
||||||
///
|
|
||||||
/// * [num] row (required):
|
|
||||||
///
|
|
||||||
/// * [String] key:
|
|
||||||
///
|
|
||||||
/// * [String] slug:
|
|
||||||
Future<Response> viewAssetTileWithHttpInfo(num col, String id, num level, num row, { String? key, String? slug, }) async {
|
|
||||||
// ignore: prefer_const_declarations
|
|
||||||
final apiPath = r'/assets/{id}/tiles/{level}/{col}/{row}'
|
|
||||||
.replaceAll('{col}', col.toString())
|
|
||||||
.replaceAll('{id}', id)
|
|
||||||
.replaceAll('{level}', level.toString())
|
|
||||||
.replaceAll('{row}', row.toString());
|
|
||||||
|
|
||||||
// ignore: prefer_final_locals
|
|
||||||
Object? postBody;
|
|
||||||
|
|
||||||
final queryParams = <QueryParam>[];
|
|
||||||
final headerParams = <String, String>{};
|
|
||||||
final formParams = <String, String>{};
|
|
||||||
|
|
||||||
if (key != null) {
|
|
||||||
queryParams.addAll(_queryParams('', 'key', key));
|
|
||||||
}
|
|
||||||
if (slug != null) {
|
|
||||||
queryParams.addAll(_queryParams('', 'slug', slug));
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentTypes = <String>[];
|
|
||||||
|
|
||||||
|
|
||||||
return apiClient.invokeAPI(
|
|
||||||
apiPath,
|
|
||||||
'GET',
|
|
||||||
queryParams,
|
|
||||||
postBody,
|
|
||||||
headerParams,
|
|
||||||
formParams,
|
|
||||||
contentTypes.isEmpty ? null : contentTypes.first,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// View an asset tile
|
|
||||||
///
|
|
||||||
/// Download a specific tile from an image at the specified level - must currently be 0 - and position
|
|
||||||
///
|
|
||||||
/// Parameters:
|
|
||||||
///
|
|
||||||
/// * [num] col (required):
|
|
||||||
///
|
|
||||||
/// * [String] id (required):
|
|
||||||
///
|
|
||||||
/// * [num] level (required):
|
|
||||||
///
|
|
||||||
/// * [num] row (required):
|
|
||||||
///
|
|
||||||
/// * [String] key:
|
|
||||||
///
|
|
||||||
/// * [String] slug:
|
|
||||||
Future<MultipartFile?> viewAssetTile(num col, String id, num level, num row, { String? key, String? slug, }) async {
|
|
||||||
final response = await viewAssetTileWithHttpInfo(col, id, level, row, key: key, slug: slug, );
|
|
||||||
if (response.statusCode >= HttpStatus.badRequest) {
|
|
||||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
|
||||||
}
|
|
||||||
// When a remote server returns no body with a status of 204, we shall not decode it.
|
|
||||||
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
|
||||||
// FormatException when trying to decode an empty string.
|
|
||||||
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
|
||||||
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'MultipartFile',) as MultipartFile;
|
|
||||||
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -1241,8 +1241,8 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
path: "."
|
path: "."
|
||||||
ref: e132bc3
|
ref: "0cc59ab"
|
||||||
resolved-ref: e132bc3ecc6a6d8fc2089d96f849c8a13129500e
|
resolved-ref: "0cc59abad8216d4bb26d959252c6aca1a8d05ef9"
|
||||||
url: "https://github.com/immich-app/native_video_player"
|
url: "https://github.com/immich-app/native_video_player"
|
||||||
source: git
|
source: git
|
||||||
version: "1.3.1"
|
version: "1.3.1"
|
||||||
|
|||||||
+1
-1
@@ -57,7 +57,7 @@ dependencies:
|
|||||||
native_video_player:
|
native_video_player:
|
||||||
git:
|
git:
|
||||||
url: https://github.com/immich-app/native_video_player
|
url: https://github.com/immich-app/native_video_player
|
||||||
ref: 'e132bc3'
|
ref: '0cc59ab'
|
||||||
network_info_plus: ^6.1.3
|
network_info_plus: ^6.1.3
|
||||||
octo_image: ^2.1.0
|
octo_image: ^2.1.0
|
||||||
openapi:
|
openapi:
|
||||||
|
|||||||
@@ -4397,103 +4397,6 @@
|
|||||||
"x-immich-state": "Stable"
|
"x-immich-state": "Stable"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/assets/{id}/tiles/{level}/{col}/{row}": {
|
|
||||||
"get": {
|
|
||||||
"description": "Download a specific tile from an image at the specified level - must currently be 0 - and position",
|
|
||||||
"operationId": "viewAssetTile",
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"name": "col",
|
|
||||||
"required": true,
|
|
||||||
"in": "path",
|
|
||||||
"schema": {
|
|
||||||
"type": "number"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "id",
|
|
||||||
"required": true,
|
|
||||||
"in": "path",
|
|
||||||
"schema": {
|
|
||||||
"format": "uuid",
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "key",
|
|
||||||
"required": false,
|
|
||||||
"in": "query",
|
|
||||||
"schema": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "level",
|
|
||||||
"required": true,
|
|
||||||
"in": "path",
|
|
||||||
"schema": {
|
|
||||||
"type": "number"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "row",
|
|
||||||
"required": true,
|
|
||||||
"in": "path",
|
|
||||||
"schema": {
|
|
||||||
"type": "number"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "slug",
|
|
||||||
"required": false,
|
|
||||||
"in": "query",
|
|
||||||
"schema": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"content": {
|
|
||||||
"application/octet-stream": {
|
|
||||||
"schema": {
|
|
||||||
"format": "binary",
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"description": ""
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"security": [
|
|
||||||
{
|
|
||||||
"bearer": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cookie": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"api_key": []
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"summary": "View an asset tile",
|
|
||||||
"tags": [
|
|
||||||
"Assets"
|
|
||||||
],
|
|
||||||
"x-immich-history": [
|
|
||||||
{
|
|
||||||
"version": "v2.7.0",
|
|
||||||
"state": "Added"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"version": "v2.7.0",
|
|
||||||
"state": "Stable"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"x-immich-permission": "asset.view",
|
|
||||||
"x-immich-state": "Stable"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/assets/{id}/video/playback": {
|
"/assets/{id}/video/playback": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Streams the video file for the specified asset. This endpoint also supports byte range requests.",
|
"description": "Streams the video file for the specified asset. This endpoint also supports byte range requests.",
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
"@oazapfts/runtime": "^1.0.2"
|
"@oazapfts/runtime": "^1.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^24.10.13",
|
"@types/node": "^24.10.14",
|
||||||
"typescript": "^5.3.3"
|
"typescript": "^5.3.3"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -4313,27 +4313,6 @@ export function viewAsset({ edited, id, key, size, slug }: {
|
|||||||
...opts
|
...opts
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
/**
|
|
||||||
* View an asset tile
|
|
||||||
*/
|
|
||||||
export function viewAssetTile({ col, id, key, level, row, slug }: {
|
|
||||||
col: number;
|
|
||||||
id: string;
|
|
||||||
key?: string;
|
|
||||||
level: number;
|
|
||||||
row: number;
|
|
||||||
slug?: string;
|
|
||||||
}, opts?: Oazapfts.RequestOpts) {
|
|
||||||
return oazapfts.ok(oazapfts.fetchBlob<{
|
|
||||||
status: 200;
|
|
||||||
data: Blob;
|
|
||||||
}>(`/assets/${encodeURIComponent(id)}/tiles/${encodeURIComponent(level)}/${encodeURIComponent(col)}/${encodeURIComponent(row)}${QS.query(QS.explode({
|
|
||||||
key,
|
|
||||||
slug
|
|
||||||
}))}`, {
|
|
||||||
...opts
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
/**
|
/**
|
||||||
* Play asset video
|
* Play asset video
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -55,13 +55,6 @@ export const getAssetThumbnailPath = (id: string) => `/assets/${id}/thumbnail`;
|
|||||||
export const getAssetPlaybackPath = (id: string) =>
|
export const getAssetPlaybackPath = (id: string) =>
|
||||||
`/assets/${id}/video/playback`;
|
`/assets/${id}/video/playback`;
|
||||||
|
|
||||||
export const getAssetTilePath = (
|
|
||||||
id: string,
|
|
||||||
level: number,
|
|
||||||
col: number,
|
|
||||||
row: number
|
|
||||||
) => `/assets/${id}/tiles/${level}/${col}/${row}`;
|
|
||||||
|
|
||||||
export const getUserProfileImagePath = (userId: string) =>
|
export const getUserProfileImagePath = (userId: string) =>
|
||||||
`/users/${userId}/profile-image`;
|
`/users/${userId}/profile-image`;
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
"version": "2.5.6",
|
"version": "2.5.6",
|
||||||
"description": "Monorepo for Immich",
|
"description": "Monorepo for Immich",
|
||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "pnpm@10.30.0+sha512.2b5753de015d480eeb88f5b5b61e0051f05b4301808a82ec8b840c9d2adf7748eb352c83f5c1593ca703ff1017295bc3fdd3119abb9686efc96b9fcb18200937",
|
"packageManager": "pnpm@10.30.3+sha512.c961d1e0a2d8e354ecaa5166b822516668b7f44cb5bd95122d590dd81922f606f5473b6d23ec4a5be05e7fcd18e8488d47d978bbe981872f1145d06e9a740017",
|
||||||
"engines": {
|
"engines": {
|
||||||
"pnpm": ">=10.0.0"
|
"pnpm": ">=10.0.0"
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+996
-920
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
FROM ghcr.io/immich-app/base-server-dev:202601131104@sha256:8d907eb3fe10dba4a1e034fd0060ea68c01854d92fcc9debc6b868b98f888ba7 AS builder
|
FROM ghcr.io/immich-app/base-server-dev:202603031112@sha256:837536db5fd9e432f0f474ef9b61712fe3b3815821c3e4edf5e5b0b1f1ed30ad AS builder
|
||||||
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
||||||
CI=1 \
|
CI=1 \
|
||||||
COREPACK_HOME=/tmp \
|
COREPACK_HOME=/tmp \
|
||||||
@@ -71,7 +71,7 @@ RUN --mount=type=cache,id=pnpm-plugins,target=/buildcache/pnpm-store \
|
|||||||
--mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \
|
--mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \
|
||||||
cd plugins && mise run build
|
cd plugins && mise run build
|
||||||
|
|
||||||
FROM ghcr.io/immich-app/base-server-prod:202601131104@sha256:c649c5838b6348836d27db6d49cadbbc6157feae7a1a237180c3dec03577ba8f
|
FROM ghcr.io/immich-app/base-server-prod:202603031112@sha256:bb8c8645ee61977140121e56ba09db7ae656a7506f9a6af1be8461b4d81fdf03
|
||||||
|
|
||||||
WORKDIR /usr/src/app
|
WORKDIR /usr/src/app
|
||||||
ENV NODE_ENV=production \
|
ENV NODE_ENV=production \
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# dev build
|
# dev build
|
||||||
FROM ghcr.io/immich-app/base-server-dev:202601131104@sha256:8d907eb3fe10dba4a1e034fd0060ea68c01854d92fcc9debc6b868b98f888ba7 AS dev
|
FROM ghcr.io/immich-app/base-server-dev:202603031112@sha256:837536db5fd9e432f0f474ef9b61712fe3b3815821c3e4edf5e5b0b1f1ed30ad AS dev
|
||||||
|
|
||||||
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
||||||
CI=1 \
|
CI=1 \
|
||||||
|
|||||||
+1
-1
@@ -136,7 +136,7 @@
|
|||||||
"@types/luxon": "^3.6.2",
|
"@types/luxon": "^3.6.2",
|
||||||
"@types/mock-fs": "^4.13.1",
|
"@types/mock-fs": "^4.13.1",
|
||||||
"@types/multer": "^2.0.0",
|
"@types/multer": "^2.0.0",
|
||||||
"@types/node": "^24.10.13",
|
"@types/node": "^24.10.14",
|
||||||
"@types/nodemailer": "^7.0.0",
|
"@types/nodemailer": "^7.0.0",
|
||||||
"@types/picomatch": "^4.0.0",
|
"@types/picomatch": "^4.0.0",
|
||||||
"@types/pngjs": "^6.0.5",
|
"@types/pngjs": "^6.0.5",
|
||||||
|
|||||||
@@ -62,7 +62,6 @@ export const LOGIN_URL = '/auth/login?autoLaunch=0';
|
|||||||
export const excludePaths = ['/.well-known/immich', '/custom.css', '/favicon.ico'];
|
export const excludePaths = ['/.well-known/immich', '/custom.css', '/favicon.ico'];
|
||||||
|
|
||||||
export const FACE_THUMBNAIL_SIZE = 250;
|
export const FACE_THUMBNAIL_SIZE = 250;
|
||||||
export const TILE_TARGET_SIZE = 1024;
|
|
||||||
|
|
||||||
type ModelInfo = { dimSize: number };
|
type ModelInfo = { dimSize: number };
|
||||||
export const CLIP_MODEL_INFO: Record<string, ModelInfo> = {
|
export const CLIP_MODEL_INFO: Record<string, ModelInfo> = {
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
Next,
|
Next,
|
||||||
Param,
|
Param,
|
||||||
ParseFilePipe,
|
ParseFilePipe,
|
||||||
ParseIntPipe,
|
|
||||||
Post,
|
Post,
|
||||||
Put,
|
Put,
|
||||||
Query,
|
Query,
|
||||||
@@ -186,29 +185,6 @@ export class AssetMediaController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id/tiles/:level/:col/:row')
|
|
||||||
@FileResponse()
|
|
||||||
@Authenticated({ permission: Permission.AssetView, sharedLink: true })
|
|
||||||
@Endpoint({
|
|
||||||
summary: 'View an asset tile',
|
|
||||||
description: 'Download a specific tile from an image at the specified level - must currently be 0 - and position',
|
|
||||||
history: new HistoryBuilder().added('v2.7.0').stable('v2.7.0'),
|
|
||||||
})
|
|
||||||
async viewAssetTile(
|
|
||||||
@Auth() auth: AuthDto,
|
|
||||||
@Param() { id }: UUIDParamDto,
|
|
||||||
@Param('level', ParseIntPipe) level: number,
|
|
||||||
@Param('col', ParseIntPipe) col: number,
|
|
||||||
@Param('row', ParseIntPipe) row: number,
|
|
||||||
@Res() res: Response,
|
|
||||||
@Next() next: NextFunction,
|
|
||||||
) {
|
|
||||||
if (level !== 0) {
|
|
||||||
throw new Error(`Invalid level ${level}`);
|
|
||||||
}
|
|
||||||
await sendFile(res, next, () => this.service.viewAssetTile(auth, id, level, col, row), this.logger);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get(':id/video/playback')
|
@Get(':id/video/playback')
|
||||||
@FileResponse()
|
@FileResponse()
|
||||||
@Authenticated({ permission: Permission.AssetView, sharedLink: true })
|
@Authenticated({ permission: Permission.AssetView, sharedLink: true })
|
||||||
|
|||||||
@@ -120,10 +120,6 @@ export class StorageCore {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static getTilesFolder(asset: ThumbnailPathEntity) {
|
|
||||||
return StorageCore.getNestedPath(StorageFolder.Thumbnails, asset.ownerId, `${asset.id}_tiles`);
|
|
||||||
}
|
|
||||||
|
|
||||||
static getEncodedVideoPath(asset: ThumbnailPathEntity) {
|
static getEncodedVideoPath(asset: ThumbnailPathEntity) {
|
||||||
return StorageCore.getNestedPath(StorageFolder.EncodedVideo, asset.ownerId, `${asset.id}.mp4`);
|
return StorageCore.getNestedPath(StorageFolder.EncodedVideo, asset.ownerId, `${asset.id}.mp4`);
|
||||||
}
|
}
|
||||||
@@ -157,16 +153,6 @@ export class StorageCore {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async moveAssetTiles(asset: StorageAsset) {
|
|
||||||
const oldDir = getAssetFile(asset.files, AssetFileType.Tiles, { isEdited: false });
|
|
||||||
return this.moveFile({
|
|
||||||
entityId: asset.id,
|
|
||||||
pathType: AssetPathType.Tiles,
|
|
||||||
oldPath: oldDir?.path || null,
|
|
||||||
newPath: StorageCore.getTilesFolder(asset),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async moveAssetVideo(asset: StorageAsset) {
|
async moveAssetVideo(asset: StorageAsset) {
|
||||||
return this.moveFile({
|
return this.moveFile({
|
||||||
entityId: asset.id,
|
entityId: asset.id,
|
||||||
|
|||||||
@@ -45,8 +45,6 @@ export enum AssetFileType {
|
|||||||
Preview = 'preview',
|
Preview = 'preview',
|
||||||
Thumbnail = 'thumbnail',
|
Thumbnail = 'thumbnail',
|
||||||
Sidecar = 'sidecar',
|
Sidecar = 'sidecar',
|
||||||
/** Folder structure containing tiles of the image */
|
|
||||||
Tiles = 'tiles',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum AlbumUserRole {
|
export enum AlbumUserRole {
|
||||||
@@ -373,8 +371,6 @@ export enum ManualJobName {
|
|||||||
|
|
||||||
export enum AssetPathType {
|
export enum AssetPathType {
|
||||||
Original = 'original',
|
Original = 'original',
|
||||||
/** Folder structure containing tiles of the image */
|
|
||||||
Tiles = 'tiles',
|
|
||||||
EncodedVideo = 'encoded_video',
|
EncodedVideo = 'encoded_video',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1128,19 +1128,6 @@ export class AssetRepository {
|
|||||||
.executeTakeFirstOrThrow();
|
.executeTakeFirstOrThrow();
|
||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql({ params: [DummyValue.UUID] })
|
|
||||||
async getForTiles(id: string) {
|
|
||||||
// TODO: we don't actually need original path and file name. Plain 'select asset_file.path from asset_file where type = tiles and assetId = id;'?
|
|
||||||
return this.db
|
|
||||||
.selectFrom('asset')
|
|
||||||
.where('asset.id', '=', id)
|
|
||||||
.leftJoin('asset_file', (join) =>
|
|
||||||
join.onRef('asset.id', '=', 'asset_file.assetId').on('asset_file.type', '=', AssetFileType.Tiles),
|
|
||||||
)
|
|
||||||
.select(['asset.originalPath', 'asset.originalFileName', 'asset_file.path as path'])
|
|
||||||
.executeTakeFirstOrThrow();
|
|
||||||
}
|
|
||||||
|
|
||||||
@GenerateSql({ params: [DummyValue.UUID] })
|
@GenerateSql({ params: [DummyValue.UUID] })
|
||||||
async getForVideo(id: string) {
|
async getForVideo(id: string) {
|
||||||
return this.db
|
return this.db
|
||||||
|
|||||||
@@ -182,24 +182,6 @@ export class MediaRepository {
|
|||||||
await decoded.toFile(output);
|
await decoded.toFile(output);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* For output file path 'output.dz', this creates an 'output.dzi' file and 'output_files/0' directory containing tiles
|
|
||||||
*/
|
|
||||||
async generateTiles(input: string | Buffer, options: GenerateThumbnailOptions, output: string): Promise<void> {
|
|
||||||
// size is intended tile size, don't resize input image.
|
|
||||||
const pipeline = await this.getImageDecodingPipeline(input, { ...options, size: undefined });
|
|
||||||
await pipeline
|
|
||||||
.toFormat(options.format) // TODO: set quality and chroma ss?
|
|
||||||
.tile({
|
|
||||||
depth: 'one',
|
|
||||||
size: options.size,
|
|
||||||
})
|
|
||||||
.toFile(output);
|
|
||||||
// TODO: move <uuid>_tiles_files/0 dir to <uuid>_tiles
|
|
||||||
// TODO: delete <uuid>_tiles_files/vips-properties.xml
|
|
||||||
// TODO: delete <uuid>_tiles.dzi
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getImageDecodingPipeline(input: string | Buffer, options: DecodeToBufferOptions) {
|
private async getImageDecodingPipeline(input: string | Buffer, options: DecodeToBufferOptions) {
|
||||||
let pipeline = sharp(input, {
|
let pipeline = sharp(input, {
|
||||||
// some invalid images can still be processed by sharp, but we want to fail on them by default to avoid crashes
|
// some invalid images can still be processed by sharp, but we want to fail on them by default to avoid crashes
|
||||||
|
|||||||
@@ -720,38 +720,6 @@ describe(AssetMediaService.name, () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getAssetTile', () => {
|
|
||||||
it('should require asset.view permissions', async () => {
|
|
||||||
await expect(sut.viewAssetTile(authStub.admin, 'id', 0, 0, 0)).rejects.toBeInstanceOf(BadRequestException);
|
|
||||||
|
|
||||||
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']), undefined);
|
|
||||||
expect(mocks.access.asset.checkAlbumAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']));
|
|
||||||
expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw an error if the asset tiles dir could not be found', async () => {
|
|
||||||
const asset = AssetFactory.create();
|
|
||||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
|
|
||||||
mocks.asset.getForTiles.mockResolvedValue({ ...asset, path: null });
|
|
||||||
|
|
||||||
await expect(sut.viewAssetTile(authStub.admin, asset.id, 0, 0, 0)).rejects.toBeInstanceOf(NotFoundException);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should get tile file', async () => {
|
|
||||||
const asset = AssetFactory.from().file({ type: AssetFileType.Tiles, path: '/path/to/asset_tiles' }).build();
|
|
||||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
|
|
||||||
mocks.asset.getForTiles.mockResolvedValue({ ...asset, path: asset.files[0].path });
|
|
||||||
await expect(sut.viewAssetTile(authStub.admin, asset.id, 0, 0, 0)).resolves.toEqual(
|
|
||||||
new ImmichFileResponse({
|
|
||||||
path: `${asset.files[0].path}_files/0/0_0.jpeg`,
|
|
||||||
cacheControl: CacheControl.PrivateWithCache,
|
|
||||||
contentType: 'image/jpeg',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(mocks.asset.getForTiles).toHaveBeenCalledWith(asset.id);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('playbackVideo', () => {
|
describe('playbackVideo', () => {
|
||||||
it('should require asset.view permissions', async () => {
|
it('should require asset.view permissions', async () => {
|
||||||
await expect(sut.playbackVideo(authStub.admin, 'id')).rejects.toBeInstanceOf(BadRequestException);
|
await expect(sut.playbackVideo(authStub.admin, 'id')).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
|||||||
@@ -262,26 +262,6 @@ export class AssetMediaService extends BaseService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async viewAssetTile(auth: AuthDto, id: string, level: number, col: number, row: number): Promise<ImmichFileResponse> {
|
|
||||||
await this.requireAccess({ auth, permission: Permission.AssetView, ids: [id] });
|
|
||||||
|
|
||||||
// TODO: get tile info { width, height } and check against col, row to return NotFound instead of 500 when tile can't be found in sendFile.
|
|
||||||
const { path } = await this.assetRepository.getForTiles(id);
|
|
||||||
|
|
||||||
if (!path) {
|
|
||||||
throw new NotFoundException('Asset tiles not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
// By definition of the tiles format, it's always .jpeg; should ImageFormat.Jpeg be used?
|
|
||||||
const tilePath = `${path}_files/${level}/${col}_${row}.jpeg`;
|
|
||||||
|
|
||||||
return new ImmichFileResponse({
|
|
||||||
path: tilePath,
|
|
||||||
contentType: mimeTypes.lookup(tilePath),
|
|
||||||
cacheControl: CacheControl.PrivateWithCache,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async playbackVideo(auth: AuthDto, id: string): Promise<ImmichFileResponse> {
|
async playbackVideo(auth: AuthDto, id: string): Promise<ImmichFileResponse> {
|
||||||
await this.requireAccess({ auth, permission: Permission.AssetView, ids: [id] });
|
await this.requireAccess({ auth, permission: Permission.AssetView, ids: [id] });
|
||||||
|
|
||||||
|
|||||||
@@ -566,6 +566,8 @@ describe(AssetService.name, () => {
|
|||||||
.file({ type: AssetFileType.Thumbnail })
|
.file({ type: AssetFileType.Thumbnail })
|
||||||
.file({ type: AssetFileType.Preview })
|
.file({ type: AssetFileType.Preview })
|
||||||
.file({ type: AssetFileType.FullSize })
|
.file({ type: AssetFileType.FullSize })
|
||||||
|
.file({ type: AssetFileType.Preview, isEdited: true })
|
||||||
|
.file({ type: AssetFileType.Thumbnail, isEdited: true })
|
||||||
.build();
|
.build();
|
||||||
mocks.assetJob.getForAssetDeletion.mockResolvedValue(asset);
|
mocks.assetJob.getForAssetDeletion.mockResolvedValue(asset);
|
||||||
|
|
||||||
|
|||||||
@@ -1246,7 +1246,8 @@ describe(MediaService.name, () => {
|
|||||||
expect.any(String),
|
expect.any(String),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(mocks.media.copyTagGroup).toHaveBeenCalledExactlyOnceWith('XMP-GPano', asset.originalPath, expect.any(String));
|
expect(mocks.media.copyTagGroup).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mocks.media.copyTagGroup).toHaveBeenCalledWith('XMP-GPano', asset.originalPath, expect.any(String));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should respect encoding options when generating full-size preview', async () => {
|
it('should respect encoding options when generating full-size preview', async () => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { SystemConfig } from 'src/config';
|
import { SystemConfig } from 'src/config';
|
||||||
import { FACE_THUMBNAIL_SIZE, JOBS_ASSET_PAGINATION_SIZE, TILE_TARGET_SIZE } from 'src/constants';
|
import { FACE_THUMBNAIL_SIZE, JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
|
||||||
import { ImagePathOptions, StorageCore, ThumbnailPathEntity } from 'src/cores/storage.core';
|
import { ImagePathOptions, StorageCore, ThumbnailPathEntity } from 'src/cores/storage.core';
|
||||||
import { AssetFile, Exif } from 'src/database';
|
import { AssetFile, Exif } from 'src/database';
|
||||||
import { OnEvent, OnJob } from 'src/decorators';
|
import { OnEvent, OnJob } from 'src/decorators';
|
||||||
@@ -33,7 +33,6 @@ import {
|
|||||||
DecodeToBufferOptions,
|
DecodeToBufferOptions,
|
||||||
GenerateThumbnailOptions,
|
GenerateThumbnailOptions,
|
||||||
ImageDimensions,
|
ImageDimensions,
|
||||||
ImageOptions,
|
|
||||||
JobItem,
|
JobItem,
|
||||||
JobOf,
|
JobOf,
|
||||||
VideoFormat,
|
VideoFormat,
|
||||||
@@ -164,7 +163,6 @@ export class MediaService extends BaseService {
|
|||||||
await this.storageCore.moveAssetImage(asset, AssetFileType.FullSize, image.fullsize.format);
|
await this.storageCore.moveAssetImage(asset, AssetFileType.FullSize, image.fullsize.format);
|
||||||
await this.storageCore.moveAssetImage(asset, AssetFileType.Preview, image.preview.format);
|
await this.storageCore.moveAssetImage(asset, AssetFileType.Preview, image.preview.format);
|
||||||
await this.storageCore.moveAssetImage(asset, AssetFileType.Thumbnail, image.thumbnail.format);
|
await this.storageCore.moveAssetImage(asset, AssetFileType.Thumbnail, image.thumbnail.format);
|
||||||
await this.storageCore.moveAssetTiles(asset);
|
|
||||||
await this.storageCore.moveAssetVideo(asset);
|
await this.storageCore.moveAssetVideo(asset);
|
||||||
|
|
||||||
return JobStatus.Success;
|
return JobStatus.Success;
|
||||||
@@ -278,8 +276,8 @@ export class MediaService extends BaseService {
|
|||||||
const extractEmbedded = image.extractEmbedded && mimeTypes.isRaw(asset.originalFileName);
|
const extractEmbedded = image.extractEmbedded && mimeTypes.isRaw(asset.originalFileName);
|
||||||
const extracted = extractEmbedded ? await this.extractImage(asset.originalPath, image.preview.size) : null;
|
const extracted = extractEmbedded ? await this.extractImage(asset.originalPath, image.preview.size) : null;
|
||||||
const generateFullsize =
|
const generateFullsize =
|
||||||
(image.fullsize.enabled && !mimeTypes.isWebSupportedImage(asset.originalPath)) ||
|
((image.fullsize.enabled || asset.exifInfo.projectionType === 'EQUIRECTANGULAR') &&
|
||||||
asset.exifInfo.projectionType === 'EQUIRECTANGULAR' ||
|
!mimeTypes.isWebSupportedImage(asset.originalPath)) ||
|
||||||
useEdits;
|
useEdits;
|
||||||
const convertFullsize = generateFullsize && (!extracted || !mimeTypes.isWebSupportedImage(` .${extracted.format}`));
|
const convertFullsize = generateFullsize && (!extracted || !mimeTypes.isWebSupportedImage(` .${extracted.format}`));
|
||||||
|
|
||||||
@@ -385,60 +383,23 @@ export class MediaService extends BaseService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: probably extract to helper method
|
|
||||||
// TODO: handle cropped panoramas. Tile as normal but save some offset?
|
|
||||||
let tileInfo: UpsertFileOptions | undefined;
|
|
||||||
if (asset.exifInfo.projectionType === 'EQUIRECTANGULAR') {
|
|
||||||
// TODO: get uncropped width from asset (FullPanoWidthPixels if present). -> TODO find out why i wrote this down as a todo
|
|
||||||
const originalSize = asset.exifInfo.exifImageWidth!;
|
|
||||||
|
|
||||||
// Get the number of tiles at the exact target size, rounded up (to at least 1 tile).
|
|
||||||
const numTilesExact = Math.ceil(originalSize / TILE_TARGET_SIZE);
|
|
||||||
// Then round up to the nearest power of 2 (photo-sphere-viewer requirement).
|
|
||||||
const numTiles = Math.pow(2, Math.ceil(Math.log2(numTilesExact)));
|
|
||||||
const tileSize = Math.ceil(originalSize / numTiles);
|
|
||||||
|
|
||||||
tileInfo = {
|
|
||||||
assetId: asset.id,
|
|
||||||
type: AssetFileType.Tiles,
|
|
||||||
path: StorageCore.getTilesFolder(asset),
|
|
||||||
isEdited: false,
|
|
||||||
isProgressive: false,
|
|
||||||
isTransparent: false,
|
|
||||||
};
|
|
||||||
const tilesOptions = {
|
|
||||||
...baseOptions,
|
|
||||||
quality: image.preview.quality,
|
|
||||||
format: ImageFormat.Jpeg,
|
|
||||||
size: tileSize,
|
|
||||||
};
|
|
||||||
promises.push(this.mediaRepository.generateTiles(data, tilesOptions, tileInfo.path));
|
|
||||||
|
|
||||||
console.log('Tile info for DB:', {
|
|
||||||
width: originalSize,
|
|
||||||
cols: numTiles,
|
|
||||||
rows: numTiles / 2,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const outputs = await Promise.all(promises);
|
const outputs = await Promise.all(promises);
|
||||||
|
|
||||||
if (asset.exifInfo.projectionType === 'EQUIRECTANGULAR') {
|
if (asset.exifInfo.projectionType === 'EQUIRECTANGULAR') {
|
||||||
await this.mediaRepository.copyTagGroup('XMP-GPano', asset.originalPath, previewFile.path);
|
const promises = [
|
||||||
|
this.mediaRepository.copyTagGroup('XMP-GPano', asset.originalPath, previewFile.path),
|
||||||
|
fullsizeFile
|
||||||
|
? this.mediaRepository.copyTagGroup('XMP-GPano', asset.originalPath, fullsizeFile.path)
|
||||||
|
: Promise.resolve(),
|
||||||
|
];
|
||||||
|
await Promise.all(promises);
|
||||||
}
|
}
|
||||||
|
|
||||||
const decodedDimensions = { width: info.width, height: info.height };
|
const decodedDimensions = { width: info.width, height: info.height };
|
||||||
const fullsizeDimensions = useEdits ? getOutputDimensions(asset.edits, decodedDimensions) : decodedDimensions;
|
const fullsizeDimensions = useEdits ? getOutputDimensions(asset.edits, decodedDimensions) : decodedDimensions;
|
||||||
const files = [previewFile, thumbnailFile];
|
|
||||||
if (fullsizeFile) {
|
|
||||||
files.push(fullsizeFile);
|
|
||||||
}
|
|
||||||
if (tileInfo) {
|
|
||||||
files.push(tileInfo);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
files,
|
files: fullsizeFile ? [previewFile, thumbnailFile, fullsizeFile] : [previewFile, thumbnailFile],
|
||||||
thumbhash: outputs[0] as Buffer,
|
thumbhash: outputs[0] as Buffer,
|
||||||
fullsizeDimensions,
|
fullsizeDimensions,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -21,12 +21,11 @@ export const getAssetFiles = (files: AssetFile[]) => ({
|
|||||||
fullsizeFile: getAssetFile(files, AssetFileType.FullSize, { isEdited: false }),
|
fullsizeFile: getAssetFile(files, AssetFileType.FullSize, { isEdited: false }),
|
||||||
previewFile: getAssetFile(files, AssetFileType.Preview, { isEdited: false }),
|
previewFile: getAssetFile(files, AssetFileType.Preview, { isEdited: false }),
|
||||||
thumbnailFile: getAssetFile(files, AssetFileType.Thumbnail, { isEdited: false }),
|
thumbnailFile: getAssetFile(files, AssetFileType.Thumbnail, { isEdited: false }),
|
||||||
tilesPath: getAssetFile(files, AssetFileType.Tiles, { isEdited: false }),
|
|
||||||
sidecarFile: getAssetFile(files, AssetFileType.Sidecar, { isEdited: false }),
|
sidecarFile: getAssetFile(files, AssetFileType.Sidecar, { isEdited: false }),
|
||||||
|
|
||||||
editedFullsizeFile: getAssetFile(files, AssetFileType.FullSize, { isEdited: true }),
|
editedFullsizeFile: getAssetFile(files, AssetFileType.FullSize, { isEdited: true }),
|
||||||
editedPreviewFile: getAssetFile(files, AssetFileType.Preview, { isEdited: true }),
|
editedPreviewFile: getAssetFile(files, AssetFileType.Preview, { isEdited: true }),
|
||||||
editedThumbnailFile: getAssetFile(files, AssetFileType.Preview, { isEdited: true }),
|
editedThumbnailFile: getAssetFile(files, AssetFileType.Thumbnail, { isEdited: true }),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const addAssets = async (
|
export const addAssets = async (
|
||||||
|
|||||||
@@ -404,6 +404,7 @@ export function searchAssetBuilder(kysely: Kysely<DB>, options: AssetSearchBuild
|
|||||||
.$if(!!options.isNotInAlbum && (!options.albumIds || options.albumIds.length === 0), (qb) =>
|
.$if(!!options.isNotInAlbum && (!options.albumIds || options.albumIds.length === 0), (qb) =>
|
||||||
qb.where((eb) => eb.not(eb.exists((eb) => eb.selectFrom('album_asset').whereRef('assetId', '=', 'asset.id')))),
|
qb.where((eb) => eb.not(eb.exists((eb) => eb.selectFrom('album_asset').whereRef('assetId', '=', 'asset.id')))),
|
||||||
)
|
)
|
||||||
|
.$if(options.withStacked === false, (qb) => qb.where('asset.stackId', 'is', null))
|
||||||
.$if(!!options.withExif, withExifInner)
|
.$if(!!options.withExif, withExifInner)
|
||||||
.$if(!!(options.withFaces || options.withPeople), (qb) => qb.select(withFacesAndPeople))
|
.$if(!!(options.withFaces || options.withPeople), (qb) => qb.select(withFacesAndPeople))
|
||||||
.$if(!options.withDeleted, (qb) => qb.where('asset.deletedAt', 'is', null));
|
.$if(!options.withDeleted, (qb) => qb.where('asset.deletedAt', 'is', null));
|
||||||
|
|||||||
@@ -88,4 +88,24 @@ describe(SearchService.name, () => {
|
|||||||
expect(result).toEqual({ total: 0 });
|
expect(result).toEqual({ total: 0 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('withStacked option', () => {
|
||||||
|
it('should exclude stacked assets when withStacked is false', async () => {
|
||||||
|
const { sut, ctx } = setup();
|
||||||
|
const { user } = await ctx.newUser();
|
||||||
|
|
||||||
|
const { asset: primaryAsset } = await ctx.newAsset({ ownerId: user.id });
|
||||||
|
const { asset: stackedAsset } = await ctx.newAsset({ ownerId: user.id });
|
||||||
|
const { asset: unstackedAsset } = await ctx.newAsset({ ownerId: user.id });
|
||||||
|
|
||||||
|
await ctx.newStack({ ownerId: user.id }, [primaryAsset.id, stackedAsset.id]);
|
||||||
|
|
||||||
|
const auth = factory.auth({ user: { id: user.id } });
|
||||||
|
|
||||||
|
const response = await sut.searchMetadata(auth, { withStacked: false });
|
||||||
|
|
||||||
|
expect(response.assets.items.length).toBe(1);
|
||||||
|
expect(response.assets.items[0].id).toBe(unstackedAsset.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -53,7 +53,6 @@ export const newAssetRepositoryMock = (): Mocked<RepositoryInterface<AssetReposi
|
|||||||
getForOriginal: vitest.fn(),
|
getForOriginal: vitest.fn(),
|
||||||
getForOriginals: vitest.fn(),
|
getForOriginals: vitest.fn(),
|
||||||
getForThumbnail: vitest.fn(),
|
getForThumbnail: vitest.fn(),
|
||||||
getForTiles: vitest.fn(),
|
|
||||||
getForVideo: vitest.fn(),
|
getForVideo: vitest.fn(),
|
||||||
getForEdit: vitest.fn(),
|
getForEdit: vitest.fn(),
|
||||||
getForOcr: vitest.fn(),
|
getForOcr: vitest.fn(),
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { Mocked, vitest } from 'vitest';
|
|||||||
export const newMediaRepositoryMock = (): Mocked<RepositoryInterface<MediaRepository>> => {
|
export const newMediaRepositoryMock = (): Mocked<RepositoryInterface<MediaRepository>> => {
|
||||||
return {
|
return {
|
||||||
generateThumbnail: vitest.fn().mockImplementation(() => Promise.resolve()),
|
generateThumbnail: vitest.fn().mockImplementation(() => Promise.resolve()),
|
||||||
generateTiles: vitest.fn().mockImplementation(() => Promise.resolve()),
|
|
||||||
writeExif: vitest.fn().mockImplementation(() => Promise.resolve()),
|
writeExif: vitest.fn().mockImplementation(() => Promise.resolve()),
|
||||||
copyTagGroup: vitest.fn().mockImplementation(() => Promise.resolve()),
|
copyTagGroup: vitest.fn().mockImplementation(() => Promise.resolve()),
|
||||||
generateThumbhash: vitest.fn().mockResolvedValue(Buffer.from('')),
|
generateThumbhash: vitest.fn().mockResolvedValue(Buffer.from('')),
|
||||||
|
|||||||
@@ -31,7 +31,6 @@
|
|||||||
"@mapbox/mapbox-gl-rtl-text": "0.3.0",
|
"@mapbox/mapbox-gl-rtl-text": "0.3.0",
|
||||||
"@mdi/js": "^7.4.47",
|
"@mdi/js": "^7.4.47",
|
||||||
"@photo-sphere-viewer/core": "^5.14.0",
|
"@photo-sphere-viewer/core": "^5.14.0",
|
||||||
"@photo-sphere-viewer/equirectangular-tiles-adapter": "^5.14.1",
|
|
||||||
"@photo-sphere-viewer/equirectangular-video-adapter": "^5.14.0",
|
"@photo-sphere-viewer/equirectangular-video-adapter": "^5.14.0",
|
||||||
"@photo-sphere-viewer/markers-plugin": "^5.14.0",
|
"@photo-sphere-viewer/markers-plugin": "^5.14.0",
|
||||||
"@photo-sphere-viewer/resolution-plugin": "^5.14.0",
|
"@photo-sphere-viewer/resolution-plugin": "^5.14.0",
|
||||||
|
|||||||
@@ -1,156 +0,0 @@
|
|||||||
type Config = IntersectionObserverActionProperties & {
|
|
||||||
observer?: IntersectionObserver;
|
|
||||||
};
|
|
||||||
type TrackedProperties = {
|
|
||||||
root?: Element | Document | null;
|
|
||||||
threshold?: number | number[];
|
|
||||||
top?: string;
|
|
||||||
right?: string;
|
|
||||||
bottom?: string;
|
|
||||||
left?: string;
|
|
||||||
};
|
|
||||||
type OnIntersectCallback = (entryOrElement: IntersectionObserverEntry | HTMLElement) => unknown;
|
|
||||||
type OnSeparateCallback = (element: HTMLElement) => unknown;
|
|
||||||
type IntersectionObserverActionProperties = {
|
|
||||||
key?: string;
|
|
||||||
disabled?: boolean;
|
|
||||||
/** Function to execute when the element leaves the viewport */
|
|
||||||
onSeparate?: OnSeparateCallback;
|
|
||||||
/** Function to execute when the element enters the viewport */
|
|
||||||
onIntersect?: OnIntersectCallback;
|
|
||||||
|
|
||||||
root?: Element | Document | null;
|
|
||||||
threshold?: number | number[];
|
|
||||||
top?: string;
|
|
||||||
right?: string;
|
|
||||||
bottom?: string;
|
|
||||||
left?: string;
|
|
||||||
};
|
|
||||||
type TaskKey = HTMLElement | string;
|
|
||||||
|
|
||||||
function isEquivalent(a: TrackedProperties, b: TrackedProperties) {
|
|
||||||
return (
|
|
||||||
a?.bottom === b?.bottom &&
|
|
||||||
a?.top === b?.top &&
|
|
||||||
a?.left === b?.left &&
|
|
||||||
a?.right == b?.right &&
|
|
||||||
a?.threshold === b?.threshold &&
|
|
||||||
a?.root === b?.root
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const elementToConfig = new Map<TaskKey, Config>();
|
|
||||||
|
|
||||||
const observe = (key: HTMLElement | string, target: HTMLElement, properties: IntersectionObserverActionProperties) => {
|
|
||||||
if (!target.isConnected) {
|
|
||||||
elementToConfig.get(key)?.observer?.unobserve(target);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const {
|
|
||||||
root,
|
|
||||||
threshold,
|
|
||||||
top = '0px',
|
|
||||||
right = '0px',
|
|
||||||
bottom = '0px',
|
|
||||||
left = '0px',
|
|
||||||
onSeparate,
|
|
||||||
onIntersect,
|
|
||||||
} = properties;
|
|
||||||
const rootMargin = `${top} ${right} ${bottom} ${left}`;
|
|
||||||
const observer = new IntersectionObserver(
|
|
||||||
(entries: IntersectionObserverEntry[]) => {
|
|
||||||
// This IntersectionObserver is limited to observing a single element, the one the
|
|
||||||
// action is attached to. If there are multiple entries, it means that this
|
|
||||||
// observer is being notified of multiple events that have occurred quickly together,
|
|
||||||
// and the latest element is the one we are interested in.
|
|
||||||
|
|
||||||
entries.sort((a, b) => a.time - b.time);
|
|
||||||
|
|
||||||
const latestEntry = entries.pop();
|
|
||||||
if (latestEntry?.isIntersecting) {
|
|
||||||
onIntersect?.(latestEntry);
|
|
||||||
} else {
|
|
||||||
onSeparate?.(target);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
rootMargin,
|
|
||||||
threshold,
|
|
||||||
root,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
observer.observe(target);
|
|
||||||
elementToConfig.set(key, { ...properties, observer });
|
|
||||||
};
|
|
||||||
|
|
||||||
function configure(key: HTMLElement | string, element: HTMLElement, properties: IntersectionObserverActionProperties) {
|
|
||||||
if (properties.disabled) {
|
|
||||||
const config = elementToConfig.get(key);
|
|
||||||
const { observer } = config || {};
|
|
||||||
observer?.unobserve(element);
|
|
||||||
elementToConfig.delete(key);
|
|
||||||
} else {
|
|
||||||
elementToConfig.set(key, properties);
|
|
||||||
observe(key, element, properties);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function _intersectionObserver(
|
|
||||||
key: HTMLElement | string,
|
|
||||||
element: HTMLElement,
|
|
||||||
properties: IntersectionObserverActionProperties,
|
|
||||||
) {
|
|
||||||
configure(key, element, properties);
|
|
||||||
return {
|
|
||||||
update(properties: IntersectionObserverActionProperties) {
|
|
||||||
const config = elementToConfig.get(key);
|
|
||||||
if (!config) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (isEquivalent(config, properties)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
configure(key, element, properties);
|
|
||||||
},
|
|
||||||
destroy: () => {
|
|
||||||
const config = elementToConfig.get(key);
|
|
||||||
const { observer } = config || {};
|
|
||||||
observer?.unobserve(element);
|
|
||||||
elementToConfig.delete(key);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Monitors an element's visibility in the viewport and calls functions when it enters or leaves (based on a threshold).
|
|
||||||
* @param element
|
|
||||||
* @param properties One or multiple configurations for the IntersectionObserver(s)
|
|
||||||
* @returns
|
|
||||||
*/
|
|
||||||
export function intersectionObserver(
|
|
||||||
element: HTMLElement,
|
|
||||||
properties: IntersectionObserverActionProperties | IntersectionObserverActionProperties[],
|
|
||||||
) {
|
|
||||||
// svelte doesn't allow multiple use:action directives of the same kind on the same element,
|
|
||||||
// so accept an array when multiple configurations are needed.
|
|
||||||
if (Array.isArray(properties)) {
|
|
||||||
if (!properties.every((p) => p.key)) {
|
|
||||||
throw new Error('Multiple configurations must specify key');
|
|
||||||
}
|
|
||||||
const observers = properties.map((p) => _intersectionObserver(p.key as string, element, p));
|
|
||||||
return {
|
|
||||||
update: (properties: IntersectionObserverActionProperties[]) => {
|
|
||||||
for (const [i, props] of properties.entries()) {
|
|
||||||
observers[i].update(props);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
destroy: () => {
|
|
||||||
for (const observer of observers) {
|
|
||||||
observer.destroy();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return _intersectionObserver(properties.key || element, element, properties);
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
export type OnResizeCallback = (resizeEvent: { target: HTMLElement; width: number; height: number }) => void;
|
|
||||||
|
|
||||||
let observer: ResizeObserver;
|
|
||||||
let callbacks: WeakMap<HTMLElement, OnResizeCallback>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Installs a resizeObserver on the given element - when the element changes
|
|
||||||
* size, invokes a callback function with the width/height. Intended as a
|
|
||||||
* replacement for bind:clientWidth and bind:clientHeight in svelte4 which use
|
|
||||||
* an iframe to measure the size of the element, which can be bad for
|
|
||||||
* performance and memory usage. In svelte5, they adapted bind:clientHeight and
|
|
||||||
* bind:clientWidth to use an internal resize observer.
|
|
||||||
*
|
|
||||||
* TODO: When svelte5 is ready, go back to bind:clientWidth and
|
|
||||||
* bind:clientHeight.
|
|
||||||
*/
|
|
||||||
export function resizeObserver(element: HTMLElement, onResize: OnResizeCallback) {
|
|
||||||
if (!observer) {
|
|
||||||
callbacks = new WeakMap();
|
|
||||||
observer = new ResizeObserver((entries) => {
|
|
||||||
for (const entry of entries) {
|
|
||||||
const onResize = callbacks.get(entry.target as HTMLElement);
|
|
||||||
if (onResize) {
|
|
||||||
onResize({
|
|
||||||
target: entry.target as HTMLElement,
|
|
||||||
width: entry.borderBoxSize[0].inlineSize,
|
|
||||||
height: entry.borderBoxSize[0].blockSize,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
callbacks.set(element, onResize);
|
|
||||||
observer.observe(element);
|
|
||||||
|
|
||||||
return {
|
|
||||||
destroy: () => {
|
|
||||||
callbacks.delete(element);
|
|
||||||
observer.unobserve(element);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { imageManager } from '$lib/managers/ImageManager.svelte';
|
import { cancelImageUrl } from '$lib/utils/sw-messaging';
|
||||||
import { onDestroy, untrack } from 'svelte';
|
import { onDestroy, untrack } from 'svelte';
|
||||||
import type { HTMLImgAttributes } from 'svelte/elements';
|
import type { HTMLImgAttributes } from 'svelte/elements';
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
destroyed = true;
|
destroyed = true;
|
||||||
if (capturedSource !== undefined) {
|
if (capturedSource !== undefined) {
|
||||||
imageManager.cancelPreloadUrl(capturedSource);
|
cancelImageUrl(capturedSource);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
import { SettingInputFieldType } from '$lib/constants';
|
import { SettingInputFieldType } from '$lib/constants';
|
||||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||||
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
|
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
|
||||||
import { getQueueName } from '$lib/utils';
|
|
||||||
import { QueueName, type SystemConfigJobDto } from '@immich/sdk';
|
import { QueueName, type SystemConfigJobDto } from '@immich/sdk';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
@@ -30,6 +29,27 @@
|
|||||||
function isSystemConfigJobDto(jobName: string): jobName is keyof SystemConfigJobDto {
|
function isSystemConfigJobDto(jobName: string): jobName is keyof SystemConfigJobDto {
|
||||||
return jobName in configToEdit.job;
|
return jobName in configToEdit.job;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const queueTitles: Record<QueueName, string> = $derived({
|
||||||
|
[QueueName.ThumbnailGeneration]: $t('admin.thumbnail_generation_job'),
|
||||||
|
[QueueName.MetadataExtraction]: $t('admin.metadata_extraction_job'),
|
||||||
|
[QueueName.Sidecar]: $t('admin.sidecar_job'),
|
||||||
|
[QueueName.SmartSearch]: $t('admin.machine_learning_smart_search'),
|
||||||
|
[QueueName.DuplicateDetection]: $t('admin.machine_learning_duplicate_detection'),
|
||||||
|
[QueueName.FaceDetection]: $t('admin.face_detection'),
|
||||||
|
[QueueName.FacialRecognition]: $t('admin.machine_learning_facial_recognition'),
|
||||||
|
[QueueName.VideoConversion]: $t('admin.video_conversion_job'),
|
||||||
|
[QueueName.StorageTemplateMigration]: $t('admin.storage_template_migration'),
|
||||||
|
[QueueName.Migration]: $t('admin.migration_job'),
|
||||||
|
[QueueName.BackgroundTask]: $t('admin.background_task_job'),
|
||||||
|
[QueueName.Search]: $t('search'),
|
||||||
|
[QueueName.Library]: $t('external_libraries'),
|
||||||
|
[QueueName.Notifications]: $t('notifications'),
|
||||||
|
[QueueName.BackupDatabase]: $t('admin.backup_database'),
|
||||||
|
[QueueName.Ocr]: $t('admin.machine_learning_ocr'),
|
||||||
|
[QueueName.Workflow]: $t('workflows'),
|
||||||
|
[QueueName.Editor]: $t('editor'),
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -41,7 +61,7 @@
|
|||||||
<SettingInputField
|
<SettingInputField
|
||||||
inputType={SettingInputFieldType.NUMBER}
|
inputType={SettingInputFieldType.NUMBER}
|
||||||
{disabled}
|
{disabled}
|
||||||
label={$t('admin.job_concurrency', { values: { job: $getQueueName(queueName) } })}
|
label={$t('admin.job_concurrency', { values: { job: queueTitles[queueName] } })}
|
||||||
description=""
|
description=""
|
||||||
bind:value={configToEdit.job[queueName].concurrency}
|
bind:value={configToEdit.job[queueName].concurrency}
|
||||||
required={true}
|
required={true}
|
||||||
@@ -50,7 +70,7 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<SettingInputField
|
<SettingInputField
|
||||||
inputType={SettingInputFieldType.NUMBER}
|
inputType={SettingInputFieldType.NUMBER}
|
||||||
label={$t('admin.job_concurrency', { values: { job: $getQueueName(queueName) } })}
|
label={$t('admin.job_concurrency', { values: { job: queueTitles[queueName] } })}
|
||||||
description=""
|
description=""
|
||||||
value={1}
|
value={1}
|
||||||
disabled={true}
|
disabled={true}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
|
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
|
||||||
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||||
|
import { languageManager } from '$lib/managers/language-manager.svelte';
|
||||||
import { Route } from '$lib/route';
|
import { Route } from '$lib/route';
|
||||||
import { getGlobalActions } from '$lib/services/app.service';
|
import { getGlobalActions } from '$lib/services/app.service';
|
||||||
import { getAssetActions, handleReplaceAsset } from '$lib/services/asset.service';
|
import { getAssetActions, handleReplaceAsset } from '$lib/services/asset.service';
|
||||||
@@ -36,6 +37,7 @@
|
|||||||
import { ActionButton, CommandPaletteDefaultProvider, type ActionItem } from '@immich/ui';
|
import { ActionButton, CommandPaletteDefaultProvider, type ActionItem } from '@immich/ui';
|
||||||
import {
|
import {
|
||||||
mdiArrowLeft,
|
mdiArrowLeft,
|
||||||
|
mdiArrowRight,
|
||||||
mdiCompare,
|
mdiCompare,
|
||||||
mdiDotsVertical,
|
mdiDotsVertical,
|
||||||
mdiImageSearch,
|
mdiImageSearch,
|
||||||
@@ -84,7 +86,7 @@
|
|||||||
const Close: ActionItem = $derived({
|
const Close: ActionItem = $derived({
|
||||||
title: $t('go_back'),
|
title: $t('go_back'),
|
||||||
type: $t('assets'),
|
type: $t('assets'),
|
||||||
icon: mdiArrowLeft,
|
icon: languageManager.rtl ? mdiArrowRight : mdiArrowLeft,
|
||||||
$if: () => !!onClose,
|
$if: () => !!onClose,
|
||||||
onAction: () => onClose?.(),
|
onAction: () => onClose?.(),
|
||||||
shortcuts: [{ key: 'Escape' }],
|
shortcuts: [{ key: 'Escape' }],
|
||||||
|
|||||||
@@ -30,7 +30,6 @@
|
|||||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||||
import {
|
import {
|
||||||
AssetTypeEnum,
|
AssetTypeEnum,
|
||||||
getAllAlbums,
|
|
||||||
getAssetInfo,
|
getAssetInfo,
|
||||||
getStack,
|
getStack,
|
||||||
type AlbumResponseDto,
|
type AlbumResponseDto,
|
||||||
@@ -105,7 +104,6 @@
|
|||||||
const asset = $derived(cursor.current);
|
const asset = $derived(cursor.current);
|
||||||
const nextAsset = $derived(cursor.nextAsset);
|
const nextAsset = $derived(cursor.nextAsset);
|
||||||
const previousAsset = $derived(cursor.previousAsset);
|
const previousAsset = $derived(cursor.previousAsset);
|
||||||
let appearsInAlbums: AlbumResponseDto[] = $state([]);
|
|
||||||
let sharedLink = getSharedLink();
|
let sharedLink = getSharedLink();
|
||||||
let previewStackedAsset: AssetResponseDto | undefined = $state();
|
let previewStackedAsset: AssetResponseDto | undefined = $state();
|
||||||
let fullscreenElement = $state<Element>();
|
let fullscreenElement = $state<Element>();
|
||||||
@@ -147,7 +145,7 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(() => {
|
||||||
syncAssetViewerOpenClass(true);
|
syncAssetViewerOpenClass(true);
|
||||||
unsubscribes.push(
|
unsubscribes.push(
|
||||||
slideshowState.subscribe((value) => {
|
slideshowState.subscribe((value) => {
|
||||||
@@ -166,8 +164,6 @@
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
await onAlbumAddAssets();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
@@ -180,18 +176,6 @@
|
|||||||
syncAssetViewerOpenClass(false);
|
syncAssetViewerOpenClass(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
const onAlbumAddAssets = async () => {
|
|
||||||
if (authManager.isSharedLink) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
appearsInAlbums = await getAllAlbums({ assetId: asset.id });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error getting album that asset belong to', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeViewer = () => {
|
const closeViewer = () => {
|
||||||
onClose?.(asset);
|
onClose?.(asset);
|
||||||
};
|
};
|
||||||
@@ -363,7 +347,6 @@
|
|||||||
|
|
||||||
const refresh = async () => {
|
const refresh = async () => {
|
||||||
await refreshStack();
|
await refreshStack();
|
||||||
await onAlbumAddAssets();
|
|
||||||
ocrManager.clear();
|
ocrManager.clear();
|
||||||
if (!sharedLink) {
|
if (!sharedLink) {
|
||||||
if (previewStackedAsset) {
|
if (previewStackedAsset) {
|
||||||
@@ -441,7 +424,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<CommandPaletteDefaultProvider name={$t('assets')} actions={[Tag]} />
|
<CommandPaletteDefaultProvider name={$t('assets')} actions={[Tag]} />
|
||||||
<OnEvents {onAssetReplace} {onAssetUpdate} {onAlbumAddAssets} />
|
<OnEvents {onAssetReplace} {onAssetUpdate} />
|
||||||
|
|
||||||
<svelte:document bind:fullscreenElement />
|
<svelte:document bind:fullscreenElement />
|
||||||
|
|
||||||
@@ -586,7 +569,7 @@
|
|||||||
>
|
>
|
||||||
{#if showDetailPanel}
|
{#if showDetailPanel}
|
||||||
<div class="w-90 h-full">
|
<div class="w-90 h-full">
|
||||||
<DetailPanel {asset} currentAlbum={album} albums={appearsInAlbums} />
|
<DetailPanel {asset} currentAlbum={album} />
|
||||||
</div>
|
</div>
|
||||||
{:else if assetViewerManager.isShowEditor}
|
{:else if assetViewerManager.isShowEditor}
|
||||||
<div class="w-100 h-full">
|
<div class="w-100 h-full">
|
||||||
|
|||||||
@@ -17,9 +17,16 @@
|
|||||||
import { getAssetMediaUrl, getPeopleThumbnailUrl } from '$lib/utils';
|
import { getAssetMediaUrl, getPeopleThumbnailUrl } from '$lib/utils';
|
||||||
import { delay, getDimensions } from '$lib/utils/asset-utils';
|
import { delay, getDimensions } from '$lib/utils/asset-utils';
|
||||||
import { getByteUnitString } from '$lib/utils/byte-units';
|
import { getByteUnitString } from '$lib/utils/byte-units';
|
||||||
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import { fromISODateTime, fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util';
|
import { fromISODateTime, fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util';
|
||||||
import { getParentPath } from '$lib/utils/tree-utils';
|
import { getParentPath } from '$lib/utils/tree-utils';
|
||||||
import { AssetMediaSize, getAssetInfo, type AlbumResponseDto, type AssetResponseDto } from '@immich/sdk';
|
import {
|
||||||
|
AssetMediaSize,
|
||||||
|
getAllAlbums,
|
||||||
|
getAssetInfo,
|
||||||
|
type AlbumResponseDto,
|
||||||
|
type AssetResponseDto,
|
||||||
|
} from '@immich/sdk';
|
||||||
import { Icon, IconButton, LoadingSpinner, modalManager, Text } from '@immich/ui';
|
import { Icon, IconButton, LoadingSpinner, modalManager, Text } from '@immich/ui';
|
||||||
import {
|
import {
|
||||||
mdiCalendar,
|
mdiCalendar,
|
||||||
@@ -38,16 +45,16 @@
|
|||||||
import { slide } from 'svelte/transition';
|
import { slide } from 'svelte/transition';
|
||||||
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
|
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
|
||||||
import PersonSidePanel from '../faces-page/person-side-panel.svelte';
|
import PersonSidePanel from '../faces-page/person-side-panel.svelte';
|
||||||
|
import OnEvents from '../OnEvents.svelte';
|
||||||
import UserAvatar from '../shared-components/user-avatar.svelte';
|
import UserAvatar from '../shared-components/user-avatar.svelte';
|
||||||
import AlbumListItemDetails from './album-list-item-details.svelte';
|
import AlbumListItemDetails from './album-list-item-details.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
asset: AssetResponseDto;
|
asset: AssetResponseDto;
|
||||||
albums?: AlbumResponseDto[];
|
|
||||||
currentAlbum?: AlbumResponseDto | null;
|
currentAlbum?: AlbumResponseDto | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { asset, albums = [], currentAlbum = null }: Props = $props();
|
let { asset, currentAlbum = null }: Props = $props();
|
||||||
|
|
||||||
let showAssetPath = $state(false);
|
let showAssetPath = $state(false);
|
||||||
let showEditFaces = $state(false);
|
let showEditFaces = $state(false);
|
||||||
@@ -74,14 +81,33 @@
|
|||||||
let previousId: string | undefined = $state();
|
let previousId: string | undefined = $state();
|
||||||
let previousRoute = $derived(currentAlbum?.id ? Route.viewAlbum(currentAlbum) : Route.photos());
|
let previousRoute = $derived(currentAlbum?.id ? Route.viewAlbum(currentAlbum) : Route.photos());
|
||||||
|
|
||||||
|
const refreshAlbums = async () => {
|
||||||
|
if (authManager.isSharedLink) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await getAllAlbums({ assetId: asset.id });
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error, 'Error getting asset album membership');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let albums = $derived(refreshAlbums());
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!previousId) {
|
if (!previousId) {
|
||||||
previousId = asset.id;
|
previousId = asset.id;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
if (asset.id !== previousId) {
|
|
||||||
|
if (asset.id === previousId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
showEditFaces = false;
|
showEditFaces = false;
|
||||||
previousId = asset.id;
|
previousId = asset.id;
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const getMegapixel = (width: number, height: number): number | undefined => {
|
const getMegapixel = (width: number, height: number): number | undefined => {
|
||||||
@@ -119,6 +145,8 @@
|
|||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<OnEvents onAlbumAddAssets={() => (albums = refreshAlbums())} />
|
||||||
|
|
||||||
<section class="relative p-2">
|
<section class="relative p-2">
|
||||||
<div class="flex place-items-center gap-2">
|
<div class="flex place-items-center gap-2">
|
||||||
<IconButton
|
<IconButton
|
||||||
@@ -502,7 +530,8 @@
|
|||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if albums.length > 0}
|
{#await albums then albums}
|
||||||
|
{#if albums.length > 0}
|
||||||
<section class="px-6 py-6 dark:text-immich-dark-fg">
|
<section class="px-6 py-6 dark:text-immich-dark-fg">
|
||||||
<div class="pb-4">
|
<div class="pb-4">
|
||||||
<Text size="small" color="muted">{$t('appears_in')}</Text>
|
<Text size="small" color="muted">{$t('appears_in')}</Text>
|
||||||
@@ -532,7 +561,8 @@
|
|||||||
</a>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
{/await}
|
||||||
|
|
||||||
{#if $preferences?.tags?.enabled}
|
{#if $preferences?.tags?.enabled}
|
||||||
<section class="relative px-2 pb-12 dark:bg-immich-dark-bg dark:text-immich-dark-fg">
|
<section class="relative px-2 pb-12 dark:bg-immich-dark-bg dark:text-immich-dark-fg">
|
||||||
|
|||||||
@@ -134,7 +134,7 @@
|
|||||||
></div>
|
></div>
|
||||||
{/if}
|
{/if}
|
||||||
</Button>
|
</Button>
|
||||||
<span class="text-sm text-white text-left">{ratio.label}</span>
|
<span class="text-sm text-white">{ratio.label}</span>
|
||||||
</HStack>
|
</HStack>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user