mirror of
https://github.com/immich-app/immich.git
synced 2026-03-15 06:38:36 -07:00
Compare commits
37 Commits
postgres-s
...
fix/mobile
| 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 | ||
|
|
9dd2633e0c | ||
|
|
13a514c189 | ||
|
|
b0c9120bb6 | ||
|
|
bc4265416d | ||
|
|
d4434f2276 | ||
|
|
f4e156494f | ||
|
|
84abad564e |
3
.github/workflows/check-openapi.yml
vendored
3
.github/workflows/check-openapi.yml
vendored
@@ -24,8 +24,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- 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@ccb863950ce437a50f8f1a40d2a1112117e06ce4
|
||||
uses: oasdiff/oasdiff-action/breaking@65fef71494258f00f911d7a71edb0482c5378899 # v0.0.30
|
||||
with:
|
||||
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
|
||||
revision: open-api/immich-openapi-specs.json
|
||||
|
||||
6
.github/workflows/codeql-analysis.yml
vendored
6
.github/workflows/codeql-analysis.yml
vendored
@@ -57,7 +57,7 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3
|
||||
uses: github/codeql-action/init@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# 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).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- 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.
|
||||
# 📚 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
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3
|
||||
uses: github/codeql-action/analyze@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
|
||||
with:
|
||||
category: '/language:${{matrix.language}}'
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/micromatch": "^4.0.9",
|
||||
"@types/mock-fs": "^4.13.1",
|
||||
"@types/node": "^24.10.13",
|
||||
"@types/node": "^24.10.14",
|
||||
"@vitest/coverage-v8": "^3.0.0",
|
||||
"byte-size": "^9.0.0",
|
||||
"cli-progress": "^3.12.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[tools]
|
||||
terragrunt = "0.98.0"
|
||||
opentofu = "1.11.4"
|
||||
terragrunt = "0.99.4"
|
||||
opentofu = "1.11.5"
|
||||
|
||||
[tasks."tg:fmt"]
|
||||
run = "terragrunt hclfmt"
|
||||
|
||||
@@ -155,7 +155,7 @@ services:
|
||||
|
||||
redis:
|
||||
container_name: immich_redis
|
||||
image: docker.io/valkey/valkey:9@sha256:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e
|
||||
image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d
|
||||
healthcheck:
|
||||
test: redis-cli ping || exit 1
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ services:
|
||||
|
||||
redis:
|
||||
container_name: immich_redis
|
||||
image: docker.io/valkey/valkey:9@sha256:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e
|
||||
image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d
|
||||
healthcheck:
|
||||
test: redis-cli ping || exit 1
|
||||
restart: always
|
||||
@@ -85,7 +85,7 @@ services:
|
||||
container_name: immich_prometheus
|
||||
ports:
|
||||
- 9090:9090
|
||||
image: prom/prometheus@sha256:1f0f50f06acaceb0f5670d2c8a658a599affe7b0d8e78b898c1035653849a702
|
||||
image: prom/prometheus@sha256:4a61322ac1103a0e3aea2a61ef1718422a48fa046441f299d71e660a3bc71ae9
|
||||
volumes:
|
||||
- ./prometheus.yml:/etc/prometheus/prometheus.yml
|
||||
- prometheus-data:/prometheus
|
||||
|
||||
@@ -61,7 +61,7 @@ services:
|
||||
|
||||
redis:
|
||||
container_name: immich_redis
|
||||
image: docker.io/valkey/valkey:9@sha256:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e
|
||||
image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d
|
||||
user: '1000:1000'
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
|
||||
@@ -49,7 +49,7 @@ services:
|
||||
|
||||
redis:
|
||||
container_name: immich_redis
|
||||
image: docker.io/valkey/valkey:9@sha256:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e
|
||||
image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d
|
||||
healthcheck:
|
||||
test: redis-cli ping || exit 1
|
||||
restart: always
|
||||
|
||||
@@ -67,7 +67,8 @@ graph TD
|
||||
C --> D["Thumbnail Generation (Large, small, blurred and person)"]
|
||||
D --> E[Smart Search]
|
||||
D --> F[Face Detection]
|
||||
D --> G[Video Transcoding]
|
||||
E --> H[Duplicate Detection]
|
||||
F --> I[Facial Recognition]
|
||||
D --> G[OCR]
|
||||
D --> H[Video Transcoding]
|
||||
E --> I[Duplicate Detection]
|
||||
F --> J[Facial Recognition]
|
||||
```
|
||||
|
||||
@@ -11,7 +11,6 @@ services:
|
||||
immich-server:
|
||||
container_name: immich-e2e-server
|
||||
image: immich-server:latest
|
||||
shm_size: 128mb
|
||||
build:
|
||||
context: ../
|
||||
dockerfile: server/Dockerfile
|
||||
@@ -45,7 +44,7 @@ services:
|
||||
|
||||
redis:
|
||||
container_name: immich-e2e-redis
|
||||
image: docker.io/valkey/valkey:9@sha256:930b41430fb727f533c5982fe509b6f04233e26d0f7354e04de4b0d5c706e44e
|
||||
image: docker.io/valkey/valkey:9@sha256:2bce660b767cb62c8c0ea020e94a230093be63dbd6af4f21b044960517a5842d
|
||||
healthcheck:
|
||||
test: redis-cli ping || exit 1
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"@playwright/test": "^1.44.1",
|
||||
"@socket.io/component-emitter": "^3.1.2",
|
||||
"@types/luxon": "^3.4.2",
|
||||
"@types/node": "^24.10.13",
|
||||
"@types/node": "^24.10.14",
|
||||
"@types/pg": "^8.15.1",
|
||||
"@types/pngjs": "^6.0.4",
|
||||
"@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');
|
||||
});
|
||||
});
|
||||
167
e2e/src/ui/mock-network/broken-asset-network.ts
Normal file
167
e2e/src/ui/mock-network/broken-asset-network.ts
Normal file
@@ -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,
|
||||
});
|
||||
});
|
||||
};
|
||||
127
e2e/src/ui/mock-network/face-editor-network.ts
Normal file
127
e2e/src/ui/mock-network/face-editor-network.ts
Normal file
@@ -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,
|
||||
} from 'src/ui/generators/timeline';
|
||||
import { sleep } from 'src/ui/specs/timeline/utils';
|
||||
import { MINIMAL_MP4_BUFFER } from './face-editor-network';
|
||||
|
||||
export class TimelineTestContext {
|
||||
slowBucket = false;
|
||||
@@ -135,6 +136,14 @@ export const setupTimelineMockApiRoutes = async (
|
||||
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) => {
|
||||
const albumsMatch = request.url().match(/\/api\/albums\/(?<albumId>[^/?]+)/);
|
||||
if (albumsMatch) {
|
||||
|
||||
84
e2e/src/ui/specs/asset-viewer/broken-asset.e2e-spec.ts
Normal file
84
e2e/src/ui/specs/asset-viewer/broken-asset.e2e-spec.ts
Normal file
@@ -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/);
|
||||
});
|
||||
});
|
||||
285
e2e/src/ui/specs/asset-viewer/face-editor.e2e-spec.ts
Normal file
285
e2e/src/ui/specs/asset-viewer/face-editor.e2e-spec.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
84
e2e/src/ui/specs/asset-viewer/stack.e2e-spec.ts
Normal file
84
e2e/src/ui/specs/asset-viewer/stack.e2e-spec.ts
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
116
e2e/src/ui/specs/asset-viewer/utils.ts
Normal file
116
e2e/src/ui/specs/asset-viewer/utils.ts
Normal file
@@ -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 },
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -871,8 +871,8 @@
|
||||
"current_pin_code": "Current PIN code",
|
||||
"current_server_address": "Current server address",
|
||||
"custom_date": "Custom date",
|
||||
"custom_locale": "Custom Locale",
|
||||
"custom_locale_description": "Format dates and numbers based on the language and the region",
|
||||
"custom_locale": "Custom locale",
|
||||
"custom_locale_description": "Format dates, times, and numbers based on the selected language and region",
|
||||
"custom_url": "Custom URL",
|
||||
"cutoff_date_description": "Keep photos from the last…",
|
||||
"cutoff_day": "{count, plural, one {day} other {days}}",
|
||||
@@ -895,8 +895,6 @@
|
||||
"deduplication_criteria_2": "Count of EXIF data",
|
||||
"deduplication_info": "Deduplication Info",
|
||||
"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_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",
|
||||
@@ -2338,6 +2336,8 @@
|
||||
"url": "URL",
|
||||
"usage": "Usage",
|
||||
"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_custom_date_range": "Use custom date range instead",
|
||||
"user": "User",
|
||||
|
||||
@@ -16,9 +16,9 @@ config_roots = [
|
||||
[tools]
|
||||
node = "24.13.1"
|
||||
flutter = "3.35.7"
|
||||
pnpm = "10.30.0"
|
||||
terragrunt = "0.98.0"
|
||||
opentofu = "1.11.4"
|
||||
pnpm = "10.30.3"
|
||||
terragrunt = "0.99.4"
|
||||
opentofu = "1.11.5"
|
||||
java = "21.0.2"
|
||||
|
||||
[tools."github:CQLabs/homebrew-dcm"]
|
||||
|
||||
@@ -7,6 +7,7 @@ import android.database.Cursor
|
||||
import androidx.exifinterface.media.ExifInterface
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.ext.SdkExtensions
|
||||
import android.provider.MediaStore
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
@@ -78,15 +79,22 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
add(MediaStore.MediaColumns.IS_FAVORITE)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
if (hasSpecialFormatColumn()) {
|
||||
add(SPECIAL_FORMAT_COLUMN)
|
||||
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
// Fallback: read XMP from MediaStore to detect Motion Photos
|
||||
// only needed if SPECIAL_FORMAT column isn't available
|
||||
add(MediaStore.MediaColumns.XMP)
|
||||
}
|
||||
}.toTypedArray()
|
||||
|
||||
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(
|
||||
|
||||
1
mobile/drift_schemas/main/drift_schema_v21.json
generated
Normal file
1
mobile/drift_schemas/main/drift_schema_v21.json
generated
Normal file
File diff suppressed because one or more lines are too long
@@ -11,6 +11,10 @@ enum AssetType {
|
||||
|
||||
enum AssetState { local, remote, merged }
|
||||
|
||||
// do not change!
|
||||
// keep in sync with PlatformAssetPlaybackStyle
|
||||
enum AssetPlaybackStyle { unknown, image, video, imageAnimated, livePhoto, videoLooping }
|
||||
|
||||
sealed class BaseAsset {
|
||||
final String name;
|
||||
final String? checksum;
|
||||
@@ -43,6 +47,14 @@ sealed class BaseAsset {
|
||||
|
||||
bool get isMotionPhoto => livePhotoVideoId != null;
|
||||
|
||||
AssetPlaybackStyle get playbackStyle {
|
||||
if (isVideo) return AssetPlaybackStyle.video;
|
||||
if (isMotionPhoto) return AssetPlaybackStyle.livePhoto;
|
||||
if (isImage && durationInSeconds != null && durationInSeconds! > 0) return AssetPlaybackStyle.imageAnimated;
|
||||
if (isImage) return AssetPlaybackStyle.image;
|
||||
return AssetPlaybackStyle.unknown;
|
||||
}
|
||||
|
||||
Duration get duration {
|
||||
final durationInSeconds = this.durationInSeconds;
|
||||
if (durationInSeconds != null) {
|
||||
|
||||
@@ -5,6 +5,8 @@ class LocalAsset extends BaseAsset {
|
||||
final String? remoteAssetId;
|
||||
final String? cloudId;
|
||||
final int orientation;
|
||||
@override
|
||||
final AssetPlaybackStyle playbackStyle;
|
||||
|
||||
final DateTime? adjustmentTime;
|
||||
final double? latitude;
|
||||
@@ -25,6 +27,7 @@ class LocalAsset extends BaseAsset {
|
||||
super.isFavorite = false,
|
||||
super.livePhotoVideoId,
|
||||
this.orientation = 0,
|
||||
required this.playbackStyle,
|
||||
this.adjustmentTime,
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
@@ -56,6 +59,7 @@ class LocalAsset extends BaseAsset {
|
||||
width: ${width ?? "<NA>"},
|
||||
height: ${height ?? "<NA>"},
|
||||
durationInSeconds: ${durationInSeconds ?? "<NA>"},
|
||||
playbackStyle: $playbackStyle,
|
||||
remoteId: ${remoteId ?? "<NA>"},
|
||||
cloudId: ${cloudId ?? "<NA>"},
|
||||
checksum: ${checksum ?? "<NA>"},
|
||||
@@ -76,6 +80,7 @@ class LocalAsset extends BaseAsset {
|
||||
id == other.id &&
|
||||
cloudId == other.cloudId &&
|
||||
orientation == other.orientation &&
|
||||
playbackStyle == other.playbackStyle &&
|
||||
adjustmentTime == other.adjustmentTime &&
|
||||
latitude == other.latitude &&
|
||||
longitude == other.longitude;
|
||||
@@ -87,6 +92,7 @@ class LocalAsset extends BaseAsset {
|
||||
id.hashCode ^
|
||||
remoteId.hashCode ^
|
||||
orientation.hashCode ^
|
||||
playbackStyle.hashCode ^
|
||||
adjustmentTime.hashCode ^
|
||||
latitude.hashCode ^
|
||||
longitude.hashCode;
|
||||
@@ -105,6 +111,7 @@ class LocalAsset extends BaseAsset {
|
||||
int? durationInSeconds,
|
||||
bool? isFavorite,
|
||||
int? orientation,
|
||||
AssetPlaybackStyle? playbackStyle,
|
||||
DateTime? adjustmentTime,
|
||||
double? latitude,
|
||||
double? longitude,
|
||||
@@ -124,6 +131,7 @@ class LocalAsset extends BaseAsset {
|
||||
durationInSeconds: durationInSeconds ?? this.durationInSeconds,
|
||||
isFavorite: isFavorite ?? this.isFavorite,
|
||||
orientation: orientation ?? this.orientation,
|
||||
playbackStyle: playbackStyle ?? this.playbackStyle,
|
||||
adjustmentTime: adjustmentTime ?? this.adjustmentTime,
|
||||
latitude: latitude ?? this.latitude,
|
||||
longitude: longitude ?? this.longitude,
|
||||
|
||||
@@ -435,9 +435,19 @@ extension PlatformToLocalAsset on PlatformAsset {
|
||||
durationInSeconds: durationInSeconds,
|
||||
isFavorite: isFavorite,
|
||||
orientation: orientation,
|
||||
playbackStyle: _toPlaybackStyle(playbackStyle),
|
||||
adjustmentTime: tryFromSecondsSinceEpoch(adjustmentTime, isUtc: true),
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
isEdited: false,
|
||||
);
|
||||
}
|
||||
|
||||
AssetPlaybackStyle _toPlaybackStyle(PlatformAssetPlaybackStyle style) => switch (style) {
|
||||
PlatformAssetPlaybackStyle.unknown => AssetPlaybackStyle.unknown,
|
||||
PlatformAssetPlaybackStyle.image => AssetPlaybackStyle.image,
|
||||
PlatformAssetPlaybackStyle.video => AssetPlaybackStyle.video,
|
||||
PlatformAssetPlaybackStyle.imageAnimated => AssetPlaybackStyle.imageAnimated,
|
||||
PlatformAssetPlaybackStyle.livePhoto => AssetPlaybackStyle.livePhoto,
|
||||
PlatformAssetPlaybackStyle.videoLooping => AssetPlaybackStyle.videoLooping,
|
||||
};
|
||||
|
||||
@@ -25,6 +25,8 @@ class LocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin {
|
||||
|
||||
RealColumn get longitude => real().nullable()();
|
||||
|
||||
IntColumn get playbackStyle => intEnum<AssetPlaybackStyle>().withDefault(const Constant(0))();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
@@ -43,6 +45,7 @@ extension LocalAssetEntityDataDomainExtension on LocalAssetEntityData {
|
||||
width: width,
|
||||
remoteId: remoteId,
|
||||
orientation: orientation,
|
||||
playbackStyle: playbackStyle,
|
||||
adjustmentTime: adjustmentTime,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
|
||||
@@ -25,6 +25,7 @@ typedef $$LocalAssetEntityTableCreateCompanionBuilder =
|
||||
i0.Value<DateTime?> adjustmentTime,
|
||||
i0.Value<double?> latitude,
|
||||
i0.Value<double?> longitude,
|
||||
i0.Value<i2.AssetPlaybackStyle> playbackStyle,
|
||||
});
|
||||
typedef $$LocalAssetEntityTableUpdateCompanionBuilder =
|
||||
i1.LocalAssetEntityCompanion Function({
|
||||
@@ -43,6 +44,7 @@ typedef $$LocalAssetEntityTableUpdateCompanionBuilder =
|
||||
i0.Value<DateTime?> adjustmentTime,
|
||||
i0.Value<double?> latitude,
|
||||
i0.Value<double?> longitude,
|
||||
i0.Value<i2.AssetPlaybackStyle> playbackStyle,
|
||||
});
|
||||
|
||||
class $$LocalAssetEntityTableFilterComposer
|
||||
@@ -129,6 +131,16 @@ class $$LocalAssetEntityTableFilterComposer
|
||||
column: $table.longitude,
|
||||
builder: (column) => i0.ColumnFilters(column),
|
||||
);
|
||||
|
||||
i0.ColumnWithTypeConverterFilters<
|
||||
i2.AssetPlaybackStyle,
|
||||
i2.AssetPlaybackStyle,
|
||||
int
|
||||
>
|
||||
get playbackStyle => $composableBuilder(
|
||||
column: $table.playbackStyle,
|
||||
builder: (column) => i0.ColumnWithTypeConverterFilters(column),
|
||||
);
|
||||
}
|
||||
|
||||
class $$LocalAssetEntityTableOrderingComposer
|
||||
@@ -214,6 +226,11 @@ class $$LocalAssetEntityTableOrderingComposer
|
||||
column: $table.longitude,
|
||||
builder: (column) => i0.ColumnOrderings(column),
|
||||
);
|
||||
|
||||
i0.ColumnOrderings<int> get playbackStyle => $composableBuilder(
|
||||
column: $table.playbackStyle,
|
||||
builder: (column) => i0.ColumnOrderings(column),
|
||||
);
|
||||
}
|
||||
|
||||
class $$LocalAssetEntityTableAnnotationComposer
|
||||
@@ -277,6 +294,12 @@ class $$LocalAssetEntityTableAnnotationComposer
|
||||
|
||||
i0.GeneratedColumn<double> get longitude =>
|
||||
$composableBuilder(column: $table.longitude, builder: (column) => column);
|
||||
|
||||
i0.GeneratedColumnWithTypeConverter<i2.AssetPlaybackStyle, int>
|
||||
get playbackStyle => $composableBuilder(
|
||||
column: $table.playbackStyle,
|
||||
builder: (column) => column,
|
||||
);
|
||||
}
|
||||
|
||||
class $$LocalAssetEntityTableTableManager
|
||||
@@ -334,6 +357,8 @@ class $$LocalAssetEntityTableTableManager
|
||||
i0.Value<DateTime?> adjustmentTime = const i0.Value.absent(),
|
||||
i0.Value<double?> latitude = const i0.Value.absent(),
|
||||
i0.Value<double?> longitude = const i0.Value.absent(),
|
||||
i0.Value<i2.AssetPlaybackStyle> playbackStyle =
|
||||
const i0.Value.absent(),
|
||||
}) => i1.LocalAssetEntityCompanion(
|
||||
name: name,
|
||||
type: type,
|
||||
@@ -350,6 +375,7 @@ class $$LocalAssetEntityTableTableManager
|
||||
adjustmentTime: adjustmentTime,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
playbackStyle: playbackStyle,
|
||||
),
|
||||
createCompanionCallback:
|
||||
({
|
||||
@@ -368,6 +394,8 @@ class $$LocalAssetEntityTableTableManager
|
||||
i0.Value<DateTime?> adjustmentTime = const i0.Value.absent(),
|
||||
i0.Value<double?> latitude = const i0.Value.absent(),
|
||||
i0.Value<double?> longitude = const i0.Value.absent(),
|
||||
i0.Value<i2.AssetPlaybackStyle> playbackStyle =
|
||||
const i0.Value.absent(),
|
||||
}) => i1.LocalAssetEntityCompanion.insert(
|
||||
name: name,
|
||||
type: type,
|
||||
@@ -384,6 +412,7 @@ class $$LocalAssetEntityTableTableManager
|
||||
adjustmentTime: adjustmentTime,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
playbackStyle: playbackStyle,
|
||||
),
|
||||
withReferenceMapper: (p0) => p0
|
||||
.map((e) => (e.readTable(table), i0.BaseReferences(db, table, e)))
|
||||
@@ -596,6 +625,19 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
|
||||
requiredDuringInsert: false,
|
||||
);
|
||||
@override
|
||||
late final i0.GeneratedColumnWithTypeConverter<i2.AssetPlaybackStyle, int>
|
||||
playbackStyle =
|
||||
i0.GeneratedColumn<int>(
|
||||
'playback_style',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i0.DriftSqlType.int,
|
||||
requiredDuringInsert: false,
|
||||
defaultValue: const i4.Constant(0),
|
||||
).withConverter<i2.AssetPlaybackStyle>(
|
||||
i1.$LocalAssetEntityTable.$converterplaybackStyle,
|
||||
);
|
||||
@override
|
||||
List<i0.GeneratedColumn> get $columns => [
|
||||
name,
|
||||
type,
|
||||
@@ -612,6 +654,7 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
|
||||
adjustmentTime,
|
||||
latitude,
|
||||
longitude,
|
||||
playbackStyle,
|
||||
];
|
||||
@override
|
||||
String get aliasedName => _alias ?? actualTableName;
|
||||
@@ -793,6 +836,12 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
|
||||
i0.DriftSqlType.double,
|
||||
data['${effectivePrefix}longitude'],
|
||||
),
|
||||
playbackStyle: i1.$LocalAssetEntityTable.$converterplaybackStyle.fromSql(
|
||||
attachedDatabase.typeMapping.read(
|
||||
i0.DriftSqlType.int,
|
||||
data['${effectivePrefix}playback_style'],
|
||||
)!,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -803,6 +852,10 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
|
||||
|
||||
static i0.JsonTypeConverter2<i2.AssetType, int, int> $convertertype =
|
||||
const i0.EnumIndexConverter<i2.AssetType>(i2.AssetType.values);
|
||||
static i0.JsonTypeConverter2<i2.AssetPlaybackStyle, int, int>
|
||||
$converterplaybackStyle = const i0.EnumIndexConverter<i2.AssetPlaybackStyle>(
|
||||
i2.AssetPlaybackStyle.values,
|
||||
);
|
||||
@override
|
||||
bool get withoutRowId => true;
|
||||
@override
|
||||
@@ -826,6 +879,7 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
final DateTime? adjustmentTime;
|
||||
final double? latitude;
|
||||
final double? longitude;
|
||||
final i2.AssetPlaybackStyle playbackStyle;
|
||||
const LocalAssetEntityData({
|
||||
required this.name,
|
||||
required this.type,
|
||||
@@ -842,6 +896,7 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
this.adjustmentTime,
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
required this.playbackStyle,
|
||||
});
|
||||
@override
|
||||
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
|
||||
@@ -881,6 +936,11 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
if (!nullToAbsent || longitude != null) {
|
||||
map['longitude'] = i0.Variable<double>(longitude);
|
||||
}
|
||||
{
|
||||
map['playback_style'] = i0.Variable<int>(
|
||||
i1.$LocalAssetEntityTable.$converterplaybackStyle.toSql(playbackStyle),
|
||||
);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
@@ -907,6 +967,9 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
adjustmentTime: serializer.fromJson<DateTime?>(json['adjustmentTime']),
|
||||
latitude: serializer.fromJson<double?>(json['latitude']),
|
||||
longitude: serializer.fromJson<double?>(json['longitude']),
|
||||
playbackStyle: i1.$LocalAssetEntityTable.$converterplaybackStyle.fromJson(
|
||||
serializer.fromJson<int>(json['playbackStyle']),
|
||||
),
|
||||
);
|
||||
}
|
||||
@override
|
||||
@@ -930,6 +993,9 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
'adjustmentTime': serializer.toJson<DateTime?>(adjustmentTime),
|
||||
'latitude': serializer.toJson<double?>(latitude),
|
||||
'longitude': serializer.toJson<double?>(longitude),
|
||||
'playbackStyle': serializer.toJson<int>(
|
||||
i1.$LocalAssetEntityTable.$converterplaybackStyle.toJson(playbackStyle),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -949,6 +1015,7 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
i0.Value<DateTime?> adjustmentTime = const i0.Value.absent(),
|
||||
i0.Value<double?> latitude = const i0.Value.absent(),
|
||||
i0.Value<double?> longitude = const i0.Value.absent(),
|
||||
i2.AssetPlaybackStyle? playbackStyle,
|
||||
}) => i1.LocalAssetEntityData(
|
||||
name: name ?? this.name,
|
||||
type: type ?? this.type,
|
||||
@@ -969,6 +1036,7 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
: this.adjustmentTime,
|
||||
latitude: latitude.present ? latitude.value : this.latitude,
|
||||
longitude: longitude.present ? longitude.value : this.longitude,
|
||||
playbackStyle: playbackStyle ?? this.playbackStyle,
|
||||
);
|
||||
LocalAssetEntityData copyWithCompanion(i1.LocalAssetEntityCompanion data) {
|
||||
return LocalAssetEntityData(
|
||||
@@ -995,6 +1063,9 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
: this.adjustmentTime,
|
||||
latitude: data.latitude.present ? data.latitude.value : this.latitude,
|
||||
longitude: data.longitude.present ? data.longitude.value : this.longitude,
|
||||
playbackStyle: data.playbackStyle.present
|
||||
? data.playbackStyle.value
|
||||
: this.playbackStyle,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1015,7 +1086,8 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
..write('iCloudId: $iCloudId, ')
|
||||
..write('adjustmentTime: $adjustmentTime, ')
|
||||
..write('latitude: $latitude, ')
|
||||
..write('longitude: $longitude')
|
||||
..write('longitude: $longitude, ')
|
||||
..write('playbackStyle: $playbackStyle')
|
||||
..write(')'))
|
||||
.toString();
|
||||
}
|
||||
@@ -1037,6 +1109,7 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
adjustmentTime,
|
||||
latitude,
|
||||
longitude,
|
||||
playbackStyle,
|
||||
);
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
@@ -1056,7 +1129,8 @@ class LocalAssetEntityData extends i0.DataClass
|
||||
other.iCloudId == this.iCloudId &&
|
||||
other.adjustmentTime == this.adjustmentTime &&
|
||||
other.latitude == this.latitude &&
|
||||
other.longitude == this.longitude);
|
||||
other.longitude == this.longitude &&
|
||||
other.playbackStyle == this.playbackStyle);
|
||||
}
|
||||
|
||||
class LocalAssetEntityCompanion
|
||||
@@ -1076,6 +1150,7 @@ class LocalAssetEntityCompanion
|
||||
final i0.Value<DateTime?> adjustmentTime;
|
||||
final i0.Value<double?> latitude;
|
||||
final i0.Value<double?> longitude;
|
||||
final i0.Value<i2.AssetPlaybackStyle> playbackStyle;
|
||||
const LocalAssetEntityCompanion({
|
||||
this.name = const i0.Value.absent(),
|
||||
this.type = const i0.Value.absent(),
|
||||
@@ -1092,6 +1167,7 @@ class LocalAssetEntityCompanion
|
||||
this.adjustmentTime = const i0.Value.absent(),
|
||||
this.latitude = const i0.Value.absent(),
|
||||
this.longitude = const i0.Value.absent(),
|
||||
this.playbackStyle = const i0.Value.absent(),
|
||||
});
|
||||
LocalAssetEntityCompanion.insert({
|
||||
required String name,
|
||||
@@ -1109,6 +1185,7 @@ class LocalAssetEntityCompanion
|
||||
this.adjustmentTime = const i0.Value.absent(),
|
||||
this.latitude = const i0.Value.absent(),
|
||||
this.longitude = const i0.Value.absent(),
|
||||
this.playbackStyle = const i0.Value.absent(),
|
||||
}) : name = i0.Value(name),
|
||||
type = i0.Value(type),
|
||||
id = i0.Value(id);
|
||||
@@ -1128,6 +1205,7 @@ class LocalAssetEntityCompanion
|
||||
i0.Expression<DateTime>? adjustmentTime,
|
||||
i0.Expression<double>? latitude,
|
||||
i0.Expression<double>? longitude,
|
||||
i0.Expression<int>? playbackStyle,
|
||||
}) {
|
||||
return i0.RawValuesInsertable({
|
||||
if (name != null) 'name': name,
|
||||
@@ -1145,6 +1223,7 @@ class LocalAssetEntityCompanion
|
||||
if (adjustmentTime != null) 'adjustment_time': adjustmentTime,
|
||||
if (latitude != null) 'latitude': latitude,
|
||||
if (longitude != null) 'longitude': longitude,
|
||||
if (playbackStyle != null) 'playback_style': playbackStyle,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1164,6 +1243,7 @@ class LocalAssetEntityCompanion
|
||||
i0.Value<DateTime?>? adjustmentTime,
|
||||
i0.Value<double?>? latitude,
|
||||
i0.Value<double?>? longitude,
|
||||
i0.Value<i2.AssetPlaybackStyle>? playbackStyle,
|
||||
}) {
|
||||
return i1.LocalAssetEntityCompanion(
|
||||
name: name ?? this.name,
|
||||
@@ -1181,6 +1261,7 @@ class LocalAssetEntityCompanion
|
||||
adjustmentTime: adjustmentTime ?? this.adjustmentTime,
|
||||
latitude: latitude ?? this.latitude,
|
||||
longitude: longitude ?? this.longitude,
|
||||
playbackStyle: playbackStyle ?? this.playbackStyle,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1234,6 +1315,13 @@ class LocalAssetEntityCompanion
|
||||
if (longitude.present) {
|
||||
map['longitude'] = i0.Variable<double>(longitude.value);
|
||||
}
|
||||
if (playbackStyle.present) {
|
||||
map['playback_style'] = i0.Variable<int>(
|
||||
i1.$LocalAssetEntityTable.$converterplaybackStyle.toSql(
|
||||
playbackStyle.value,
|
||||
),
|
||||
);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
@@ -1254,7 +1342,8 @@ class LocalAssetEntityCompanion
|
||||
..write('iCloudId: $iCloudId, ')
|
||||
..write('adjustmentTime: $adjustmentTime, ')
|
||||
..write('latitude: $latitude, ')
|
||||
..write('longitude: $longitude')
|
||||
..write('longitude: $longitude, ')
|
||||
..write('playbackStyle: $playbackStyle')
|
||||
..write(')'))
|
||||
.toString();
|
||||
}
|
||||
|
||||
@@ -26,7 +26,8 @@ SELECT
|
||||
NULL as latitude,
|
||||
NULL as longitude,
|
||||
NULL as adjustmentTime,
|
||||
rae.is_edited
|
||||
rae.is_edited,
|
||||
0 as playback_style
|
||||
FROM
|
||||
remote_asset_entity rae
|
||||
LEFT JOIN
|
||||
@@ -63,7 +64,8 @@ SELECT
|
||||
lae.latitude,
|
||||
lae.longitude,
|
||||
lae.adjustment_time,
|
||||
0 as is_edited
|
||||
0 as is_edited,
|
||||
lae.playback_style
|
||||
FROM
|
||||
local_asset_entity lae
|
||||
WHERE NOT EXISTS (
|
||||
|
||||
@@ -29,7 +29,7 @@ class MergedAssetDrift extends i1.ModularAccessor {
|
||||
);
|
||||
$arrayStartIndex += generatedlimit.amountOfVariables;
|
||||
return customSelect(
|
||||
'SELECT rae.id AS remote_id, (SELECT lae.id FROM local_asset_entity AS lae WHERE lae.checksum = rae.checksum LIMIT 1) AS local_id, rae.name, rae.type, rae.created_at AS created_at, rae.updated_at, rae.width, rae.height, rae.duration_in_seconds, rae.is_favorite, rae.thumb_hash, rae.checksum, rae.owner_id, rae.live_photo_video_id, 0 AS orientation, rae.stack_id, NULL AS i_cloud_id, NULL AS latitude, NULL AS longitude, NULL AS adjustmentTime, rae.is_edited FROM remote_asset_entity AS rae LEFT JOIN stack_entity AS se ON rae.stack_id = se.id WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandeduserIds) AND(rae.stack_id IS NULL OR rae.id = se.primary_asset_id)UNION ALL SELECT NULL AS remote_id, lae.id AS local_id, lae.name, lae.type, lae.created_at AS created_at, lae.updated_at, lae.width, lae.height, lae.duration_in_seconds, lae.is_favorite, NULL AS thumb_hash, lae.checksum, NULL AS owner_id, NULL AS live_photo_video_id, lae.orientation, NULL AS stack_id, lae.i_cloud_id, lae.latitude, lae.longitude, lae.adjustment_time, 0 AS is_edited FROM local_asset_entity AS lae WHERE NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.checksum = lae.checksum AND rae.owner_id IN ($expandeduserIds)) AND EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 0) AND NOT EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 2) ORDER BY created_at DESC ${generatedlimit.sql}',
|
||||
'SELECT rae.id AS remote_id, (SELECT lae.id FROM local_asset_entity AS lae WHERE lae.checksum = rae.checksum LIMIT 1) AS local_id, rae.name, rae.type, rae.created_at AS created_at, rae.updated_at, rae.width, rae.height, rae.duration_in_seconds, rae.is_favorite, rae.thumb_hash, rae.checksum, rae.owner_id, rae.live_photo_video_id, 0 AS orientation, rae.stack_id, NULL AS i_cloud_id, NULL AS latitude, NULL AS longitude, NULL AS adjustmentTime, rae.is_edited, 0 AS playback_style FROM remote_asset_entity AS rae LEFT JOIN stack_entity AS se ON rae.stack_id = se.id WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandeduserIds) AND(rae.stack_id IS NULL OR rae.id = se.primary_asset_id)UNION ALL SELECT NULL AS remote_id, lae.id AS local_id, lae.name, lae.type, lae.created_at AS created_at, lae.updated_at, lae.width, lae.height, lae.duration_in_seconds, lae.is_favorite, NULL AS thumb_hash, lae.checksum, NULL AS owner_id, NULL AS live_photo_video_id, lae.orientation, NULL AS stack_id, lae.i_cloud_id, lae.latitude, lae.longitude, lae.adjustment_time, 0 AS is_edited, lae.playback_style FROM local_asset_entity AS lae WHERE NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.checksum = lae.checksum AND rae.owner_id IN ($expandeduserIds)) AND EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 0) AND NOT EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 2) ORDER BY created_at DESC ${generatedlimit.sql}',
|
||||
variables: [
|
||||
for (var $ in userIds) i0.Variable<String>($),
|
||||
...generatedlimit.introducedVariables,
|
||||
@@ -67,6 +67,7 @@ class MergedAssetDrift extends i1.ModularAccessor {
|
||||
longitude: row.readNullable<double>('longitude'),
|
||||
adjustmentTime: row.readNullable<DateTime>('adjustmentTime'),
|
||||
isEdited: row.read<bool>('is_edited'),
|
||||
playbackStyle: row.read<int>('playback_style'),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -139,6 +140,7 @@ class MergedAssetResult {
|
||||
final double? longitude;
|
||||
final DateTime? adjustmentTime;
|
||||
final bool isEdited;
|
||||
final int playbackStyle;
|
||||
MergedAssetResult({
|
||||
this.remoteId,
|
||||
this.localId,
|
||||
@@ -161,6 +163,7 @@ class MergedAssetResult {
|
||||
this.longitude,
|
||||
this.adjustmentTime,
|
||||
required this.isEdited,
|
||||
required this.playbackStyle,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,8 @@ class TrashedLocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntity
|
||||
|
||||
IntColumn get source => intEnum<TrashOrigin>()();
|
||||
|
||||
IntColumn get playbackStyle => intEnum<AssetPlaybackStyle>().withDefault(const Constant(0))();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id, albumId};
|
||||
}
|
||||
@@ -45,6 +47,7 @@ extension TrashedLocalAssetEntityDataDomainExtension on TrashedLocalAssetEntityD
|
||||
height: height,
|
||||
width: width,
|
||||
orientation: orientation,
|
||||
playbackStyle: playbackStyle,
|
||||
isEdited: false,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ typedef $$TrashedLocalAssetEntityTableCreateCompanionBuilder =
|
||||
i0.Value<bool> isFavorite,
|
||||
i0.Value<int> orientation,
|
||||
required i3.TrashOrigin source,
|
||||
i0.Value<i2.AssetPlaybackStyle> playbackStyle,
|
||||
});
|
||||
typedef $$TrashedLocalAssetEntityTableUpdateCompanionBuilder =
|
||||
i1.TrashedLocalAssetEntityCompanion Function({
|
||||
@@ -39,6 +40,7 @@ typedef $$TrashedLocalAssetEntityTableUpdateCompanionBuilder =
|
||||
i0.Value<bool> isFavorite,
|
||||
i0.Value<int> orientation,
|
||||
i0.Value<i3.TrashOrigin> source,
|
||||
i0.Value<i2.AssetPlaybackStyle> playbackStyle,
|
||||
});
|
||||
|
||||
class $$TrashedLocalAssetEntityTableFilterComposer
|
||||
@@ -117,6 +119,16 @@ class $$TrashedLocalAssetEntityTableFilterComposer
|
||||
column: $table.source,
|
||||
builder: (column) => i0.ColumnWithTypeConverterFilters(column),
|
||||
);
|
||||
|
||||
i0.ColumnWithTypeConverterFilters<
|
||||
i2.AssetPlaybackStyle,
|
||||
i2.AssetPlaybackStyle,
|
||||
int
|
||||
>
|
||||
get playbackStyle => $composableBuilder(
|
||||
column: $table.playbackStyle,
|
||||
builder: (column) => i0.ColumnWithTypeConverterFilters(column),
|
||||
);
|
||||
}
|
||||
|
||||
class $$TrashedLocalAssetEntityTableOrderingComposer
|
||||
@@ -193,6 +205,11 @@ class $$TrashedLocalAssetEntityTableOrderingComposer
|
||||
column: $table.source,
|
||||
builder: (column) => i0.ColumnOrderings(column),
|
||||
);
|
||||
|
||||
i0.ColumnOrderings<int> get playbackStyle => $composableBuilder(
|
||||
column: $table.playbackStyle,
|
||||
builder: (column) => i0.ColumnOrderings(column),
|
||||
);
|
||||
}
|
||||
|
||||
class $$TrashedLocalAssetEntityTableAnnotationComposer
|
||||
@@ -249,6 +266,12 @@ class $$TrashedLocalAssetEntityTableAnnotationComposer
|
||||
|
||||
i0.GeneratedColumnWithTypeConverter<i3.TrashOrigin, int> get source =>
|
||||
$composableBuilder(column: $table.source, builder: (column) => column);
|
||||
|
||||
i0.GeneratedColumnWithTypeConverter<i2.AssetPlaybackStyle, int>
|
||||
get playbackStyle => $composableBuilder(
|
||||
column: $table.playbackStyle,
|
||||
builder: (column) => column,
|
||||
);
|
||||
}
|
||||
|
||||
class $$TrashedLocalAssetEntityTableTableManager
|
||||
@@ -310,6 +333,8 @@ class $$TrashedLocalAssetEntityTableTableManager
|
||||
i0.Value<bool> isFavorite = const i0.Value.absent(),
|
||||
i0.Value<int> orientation = const i0.Value.absent(),
|
||||
i0.Value<i3.TrashOrigin> source = const i0.Value.absent(),
|
||||
i0.Value<i2.AssetPlaybackStyle> playbackStyle =
|
||||
const i0.Value.absent(),
|
||||
}) => i1.TrashedLocalAssetEntityCompanion(
|
||||
name: name,
|
||||
type: type,
|
||||
@@ -324,6 +349,7 @@ class $$TrashedLocalAssetEntityTableTableManager
|
||||
isFavorite: isFavorite,
|
||||
orientation: orientation,
|
||||
source: source,
|
||||
playbackStyle: playbackStyle,
|
||||
),
|
||||
createCompanionCallback:
|
||||
({
|
||||
@@ -340,6 +366,8 @@ class $$TrashedLocalAssetEntityTableTableManager
|
||||
i0.Value<bool> isFavorite = const i0.Value.absent(),
|
||||
i0.Value<int> orientation = const i0.Value.absent(),
|
||||
required i3.TrashOrigin source,
|
||||
i0.Value<i2.AssetPlaybackStyle> playbackStyle =
|
||||
const i0.Value.absent(),
|
||||
}) => i1.TrashedLocalAssetEntityCompanion.insert(
|
||||
name: name,
|
||||
type: type,
|
||||
@@ -354,6 +382,7 @@ class $$TrashedLocalAssetEntityTableTableManager
|
||||
isFavorite: isFavorite,
|
||||
orientation: orientation,
|
||||
source: source,
|
||||
playbackStyle: playbackStyle,
|
||||
),
|
||||
withReferenceMapper: (p0) => p0
|
||||
.map((e) => (e.readTable(table), i0.BaseReferences(db, table, e)))
|
||||
@@ -550,6 +579,19 @@ class $TrashedLocalAssetEntityTable extends i3.TrashedLocalAssetEntity
|
||||
i1.$TrashedLocalAssetEntityTable.$convertersource,
|
||||
);
|
||||
@override
|
||||
late final i0.GeneratedColumnWithTypeConverter<i2.AssetPlaybackStyle, int>
|
||||
playbackStyle =
|
||||
i0.GeneratedColumn<int>(
|
||||
'playback_style',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i0.DriftSqlType.int,
|
||||
requiredDuringInsert: false,
|
||||
defaultValue: const i4.Constant(0),
|
||||
).withConverter<i2.AssetPlaybackStyle>(
|
||||
i1.$TrashedLocalAssetEntityTable.$converterplaybackStyle,
|
||||
);
|
||||
@override
|
||||
List<i0.GeneratedColumn> get $columns => [
|
||||
name,
|
||||
type,
|
||||
@@ -564,6 +606,7 @@ class $TrashedLocalAssetEntityTable extends i3.TrashedLocalAssetEntity
|
||||
isFavorite,
|
||||
orientation,
|
||||
source,
|
||||
playbackStyle,
|
||||
];
|
||||
@override
|
||||
String get aliasedName => _alias ?? actualTableName;
|
||||
@@ -720,6 +763,13 @@ class $TrashedLocalAssetEntityTable extends i3.TrashedLocalAssetEntity
|
||||
data['${effectivePrefix}source'],
|
||||
)!,
|
||||
),
|
||||
playbackStyle: i1.$TrashedLocalAssetEntityTable.$converterplaybackStyle
|
||||
.fromSql(
|
||||
attachedDatabase.typeMapping.read(
|
||||
i0.DriftSqlType.int,
|
||||
data['${effectivePrefix}playback_style'],
|
||||
)!,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -732,6 +782,10 @@ class $TrashedLocalAssetEntityTable extends i3.TrashedLocalAssetEntity
|
||||
const i0.EnumIndexConverter<i2.AssetType>(i2.AssetType.values);
|
||||
static i0.JsonTypeConverter2<i3.TrashOrigin, int, int> $convertersource =
|
||||
const i0.EnumIndexConverter<i3.TrashOrigin>(i3.TrashOrigin.values);
|
||||
static i0.JsonTypeConverter2<i2.AssetPlaybackStyle, int, int>
|
||||
$converterplaybackStyle = const i0.EnumIndexConverter<i2.AssetPlaybackStyle>(
|
||||
i2.AssetPlaybackStyle.values,
|
||||
);
|
||||
@override
|
||||
bool get withoutRowId => true;
|
||||
@override
|
||||
@@ -753,6 +807,7 @@ class TrashedLocalAssetEntityData extends i0.DataClass
|
||||
final bool isFavorite;
|
||||
final int orientation;
|
||||
final i3.TrashOrigin source;
|
||||
final i2.AssetPlaybackStyle playbackStyle;
|
||||
const TrashedLocalAssetEntityData({
|
||||
required this.name,
|
||||
required this.type,
|
||||
@@ -767,6 +822,7 @@ class TrashedLocalAssetEntityData extends i0.DataClass
|
||||
required this.isFavorite,
|
||||
required this.orientation,
|
||||
required this.source,
|
||||
required this.playbackStyle,
|
||||
});
|
||||
@override
|
||||
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
|
||||
@@ -800,6 +856,13 @@ class TrashedLocalAssetEntityData extends i0.DataClass
|
||||
i1.$TrashedLocalAssetEntityTable.$convertersource.toSql(source),
|
||||
);
|
||||
}
|
||||
{
|
||||
map['playback_style'] = i0.Variable<int>(
|
||||
i1.$TrashedLocalAssetEntityTable.$converterplaybackStyle.toSql(
|
||||
playbackStyle,
|
||||
),
|
||||
);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
@@ -826,6 +889,8 @@ class TrashedLocalAssetEntityData extends i0.DataClass
|
||||
source: i1.$TrashedLocalAssetEntityTable.$convertersource.fromJson(
|
||||
serializer.fromJson<int>(json['source']),
|
||||
),
|
||||
playbackStyle: i1.$TrashedLocalAssetEntityTable.$converterplaybackStyle
|
||||
.fromJson(serializer.fromJson<int>(json['playbackStyle'])),
|
||||
);
|
||||
}
|
||||
@override
|
||||
@@ -849,6 +914,11 @@ class TrashedLocalAssetEntityData extends i0.DataClass
|
||||
'source': serializer.toJson<int>(
|
||||
i1.$TrashedLocalAssetEntityTable.$convertersource.toJson(source),
|
||||
),
|
||||
'playbackStyle': serializer.toJson<int>(
|
||||
i1.$TrashedLocalAssetEntityTable.$converterplaybackStyle.toJson(
|
||||
playbackStyle,
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -866,6 +936,7 @@ class TrashedLocalAssetEntityData extends i0.DataClass
|
||||
bool? isFavorite,
|
||||
int? orientation,
|
||||
i3.TrashOrigin? source,
|
||||
i2.AssetPlaybackStyle? playbackStyle,
|
||||
}) => i1.TrashedLocalAssetEntityData(
|
||||
name: name ?? this.name,
|
||||
type: type ?? this.type,
|
||||
@@ -882,6 +953,7 @@ class TrashedLocalAssetEntityData extends i0.DataClass
|
||||
isFavorite: isFavorite ?? this.isFavorite,
|
||||
orientation: orientation ?? this.orientation,
|
||||
source: source ?? this.source,
|
||||
playbackStyle: playbackStyle ?? this.playbackStyle,
|
||||
);
|
||||
TrashedLocalAssetEntityData copyWithCompanion(
|
||||
i1.TrashedLocalAssetEntityCompanion data,
|
||||
@@ -906,6 +978,9 @@ class TrashedLocalAssetEntityData extends i0.DataClass
|
||||
? data.orientation.value
|
||||
: this.orientation,
|
||||
source: data.source.present ? data.source.value : this.source,
|
||||
playbackStyle: data.playbackStyle.present
|
||||
? data.playbackStyle.value
|
||||
: this.playbackStyle,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -924,7 +999,8 @@ class TrashedLocalAssetEntityData extends i0.DataClass
|
||||
..write('checksum: $checksum, ')
|
||||
..write('isFavorite: $isFavorite, ')
|
||||
..write('orientation: $orientation, ')
|
||||
..write('source: $source')
|
||||
..write('source: $source, ')
|
||||
..write('playbackStyle: $playbackStyle')
|
||||
..write(')'))
|
||||
.toString();
|
||||
}
|
||||
@@ -944,6 +1020,7 @@ class TrashedLocalAssetEntityData extends i0.DataClass
|
||||
isFavorite,
|
||||
orientation,
|
||||
source,
|
||||
playbackStyle,
|
||||
);
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
@@ -961,7 +1038,8 @@ class TrashedLocalAssetEntityData extends i0.DataClass
|
||||
other.checksum == this.checksum &&
|
||||
other.isFavorite == this.isFavorite &&
|
||||
other.orientation == this.orientation &&
|
||||
other.source == this.source);
|
||||
other.source == this.source &&
|
||||
other.playbackStyle == this.playbackStyle);
|
||||
}
|
||||
|
||||
class TrashedLocalAssetEntityCompanion
|
||||
@@ -979,6 +1057,7 @@ class TrashedLocalAssetEntityCompanion
|
||||
final i0.Value<bool> isFavorite;
|
||||
final i0.Value<int> orientation;
|
||||
final i0.Value<i3.TrashOrigin> source;
|
||||
final i0.Value<i2.AssetPlaybackStyle> playbackStyle;
|
||||
const TrashedLocalAssetEntityCompanion({
|
||||
this.name = const i0.Value.absent(),
|
||||
this.type = const i0.Value.absent(),
|
||||
@@ -993,6 +1072,7 @@ class TrashedLocalAssetEntityCompanion
|
||||
this.isFavorite = const i0.Value.absent(),
|
||||
this.orientation = const i0.Value.absent(),
|
||||
this.source = const i0.Value.absent(),
|
||||
this.playbackStyle = const i0.Value.absent(),
|
||||
});
|
||||
TrashedLocalAssetEntityCompanion.insert({
|
||||
required String name,
|
||||
@@ -1008,6 +1088,7 @@ class TrashedLocalAssetEntityCompanion
|
||||
this.isFavorite = const i0.Value.absent(),
|
||||
this.orientation = const i0.Value.absent(),
|
||||
required i3.TrashOrigin source,
|
||||
this.playbackStyle = const i0.Value.absent(),
|
||||
}) : name = i0.Value(name),
|
||||
type = i0.Value(type),
|
||||
id = i0.Value(id),
|
||||
@@ -1027,6 +1108,7 @@ class TrashedLocalAssetEntityCompanion
|
||||
i0.Expression<bool>? isFavorite,
|
||||
i0.Expression<int>? orientation,
|
||||
i0.Expression<int>? source,
|
||||
i0.Expression<int>? playbackStyle,
|
||||
}) {
|
||||
return i0.RawValuesInsertable({
|
||||
if (name != null) 'name': name,
|
||||
@@ -1042,6 +1124,7 @@ class TrashedLocalAssetEntityCompanion
|
||||
if (isFavorite != null) 'is_favorite': isFavorite,
|
||||
if (orientation != null) 'orientation': orientation,
|
||||
if (source != null) 'source': source,
|
||||
if (playbackStyle != null) 'playback_style': playbackStyle,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1059,6 +1142,7 @@ class TrashedLocalAssetEntityCompanion
|
||||
i0.Value<bool>? isFavorite,
|
||||
i0.Value<int>? orientation,
|
||||
i0.Value<i3.TrashOrigin>? source,
|
||||
i0.Value<i2.AssetPlaybackStyle>? playbackStyle,
|
||||
}) {
|
||||
return i1.TrashedLocalAssetEntityCompanion(
|
||||
name: name ?? this.name,
|
||||
@@ -1074,6 +1158,7 @@ class TrashedLocalAssetEntityCompanion
|
||||
isFavorite: isFavorite ?? this.isFavorite,
|
||||
orientation: orientation ?? this.orientation,
|
||||
source: source ?? this.source,
|
||||
playbackStyle: playbackStyle ?? this.playbackStyle,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1123,6 +1208,13 @@ class TrashedLocalAssetEntityCompanion
|
||||
i1.$TrashedLocalAssetEntityTable.$convertersource.toSql(source.value),
|
||||
);
|
||||
}
|
||||
if (playbackStyle.present) {
|
||||
map['playback_style'] = i0.Variable<int>(
|
||||
i1.$TrashedLocalAssetEntityTable.$converterplaybackStyle.toSql(
|
||||
playbackStyle.value,
|
||||
),
|
||||
);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
@@ -1141,7 +1233,8 @@ class TrashedLocalAssetEntityCompanion
|
||||
..write('checksum: $checksum, ')
|
||||
..write('isFavorite: $isFavorite, ')
|
||||
..write('orientation: $orientation, ')
|
||||
..write('source: $source')
|
||||
..write('source: $source, ')
|
||||
..write('playbackStyle: $playbackStyle')
|
||||
..write(')'))
|
||||
.toString();
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ class Drift extends $Drift implements IDatabaseRepository {
|
||||
}
|
||||
|
||||
@override
|
||||
int get schemaVersion => 20;
|
||||
int get schemaVersion => 21;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration => MigrationStrategy(
|
||||
@@ -230,6 +230,10 @@ class Drift extends $Drift implements IDatabaseRepository {
|
||||
await m.addColumn(v20.assetFaceEntity, v20.assetFaceEntity.isVisible);
|
||||
await m.addColumn(v20.assetFaceEntity, v20.assetFaceEntity.deletedAt);
|
||||
},
|
||||
from20To21: (m, v21) async {
|
||||
await m.addColumn(v21.localAssetEntity, v21.localAssetEntity.playbackStyle);
|
||||
await m.addColumn(v21.trashedLocalAssetEntity, v21.trashedLocalAssetEntity.playbackStyle);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -8904,6 +8904,591 @@ i1.GeneratedColumn<bool> _column_102(String aliasedName) =>
|
||||
),
|
||||
defaultValue: const CustomExpression('1'),
|
||||
);
|
||||
|
||||
final class Schema21 extends i0.VersionedSchema {
|
||||
Schema21({required super.database}) : super(version: 21);
|
||||
@override
|
||||
late final List<i1.DatabaseSchemaEntity> entities = [
|
||||
userEntity,
|
||||
remoteAssetEntity,
|
||||
stackEntity,
|
||||
localAssetEntity,
|
||||
remoteAlbumEntity,
|
||||
localAlbumEntity,
|
||||
localAlbumAssetEntity,
|
||||
idxLocalAlbumAssetAlbumAsset,
|
||||
idxRemoteAlbumOwnerId,
|
||||
idxLocalAssetChecksum,
|
||||
idxLocalAssetCloudId,
|
||||
idxStackPrimaryAssetId,
|
||||
idxRemoteAssetOwnerChecksum,
|
||||
uQRemoteAssetsOwnerChecksum,
|
||||
uQRemoteAssetsOwnerLibraryChecksum,
|
||||
idxRemoteAssetChecksum,
|
||||
idxRemoteAssetStackId,
|
||||
idxRemoteAssetLocalDateTimeDay,
|
||||
idxRemoteAssetLocalDateTimeMonth,
|
||||
authUserEntity,
|
||||
userMetadataEntity,
|
||||
partnerEntity,
|
||||
remoteExifEntity,
|
||||
remoteAlbumAssetEntity,
|
||||
remoteAlbumUserEntity,
|
||||
remoteAssetCloudIdEntity,
|
||||
memoryEntity,
|
||||
memoryAssetEntity,
|
||||
personEntity,
|
||||
assetFaceEntity,
|
||||
storeEntity,
|
||||
trashedLocalAssetEntity,
|
||||
idxPartnerSharedWithId,
|
||||
idxLatLng,
|
||||
idxRemoteAlbumAssetAlbumAsset,
|
||||
idxRemoteAssetCloudId,
|
||||
idxPersonOwnerId,
|
||||
idxAssetFacePersonId,
|
||||
idxAssetFaceAssetId,
|
||||
idxTrashedLocalAssetChecksum,
|
||||
idxTrashedLocalAssetAlbum,
|
||||
];
|
||||
late final Shape20 userEntity = Shape20(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'user_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_0,
|
||||
_column_1,
|
||||
_column_3,
|
||||
_column_84,
|
||||
_column_85,
|
||||
_column_91,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape28 remoteAssetEntity = Shape28(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_1,
|
||||
_column_8,
|
||||
_column_9,
|
||||
_column_5,
|
||||
_column_10,
|
||||
_column_11,
|
||||
_column_12,
|
||||
_column_0,
|
||||
_column_13,
|
||||
_column_14,
|
||||
_column_15,
|
||||
_column_16,
|
||||
_column_17,
|
||||
_column_18,
|
||||
_column_19,
|
||||
_column_20,
|
||||
_column_21,
|
||||
_column_86,
|
||||
_column_101,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape3 stackEntity = Shape3(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'stack_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [_column_0, _column_9, _column_5, _column_15, _column_75],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape30 localAssetEntity = Shape30(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'local_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_1,
|
||||
_column_8,
|
||||
_column_9,
|
||||
_column_5,
|
||||
_column_10,
|
||||
_column_11,
|
||||
_column_12,
|
||||
_column_0,
|
||||
_column_22,
|
||||
_column_14,
|
||||
_column_23,
|
||||
_column_98,
|
||||
_column_96,
|
||||
_column_46,
|
||||
_column_47,
|
||||
_column_103,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape9 remoteAlbumEntity = Shape9(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_album_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_0,
|
||||
_column_1,
|
||||
_column_56,
|
||||
_column_9,
|
||||
_column_5,
|
||||
_column_15,
|
||||
_column_57,
|
||||
_column_58,
|
||||
_column_59,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape19 localAlbumEntity = Shape19(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'local_album_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_0,
|
||||
_column_1,
|
||||
_column_5,
|
||||
_column_31,
|
||||
_column_32,
|
||||
_column_90,
|
||||
_column_33,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape22 localAlbumAssetEntity = Shape22(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'local_album_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
|
||||
columns: [_column_34, _column_35, _column_33],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
final i1.Index idxLocalAlbumAssetAlbumAsset = i1.Index(
|
||||
'idx_local_album_asset_album_asset',
|
||||
'CREATE INDEX IF NOT EXISTS idx_local_album_asset_album_asset ON local_album_asset_entity (album_id, asset_id)',
|
||||
);
|
||||
final i1.Index idxRemoteAlbumOwnerId = i1.Index(
|
||||
'idx_remote_album_owner_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_album_owner_id ON remote_album_entity (owner_id)',
|
||||
);
|
||||
final i1.Index idxLocalAssetChecksum = i1.Index(
|
||||
'idx_local_asset_checksum',
|
||||
'CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)',
|
||||
);
|
||||
final i1.Index idxLocalAssetCloudId = i1.Index(
|
||||
'idx_local_asset_cloud_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)',
|
||||
);
|
||||
final i1.Index idxStackPrimaryAssetId = i1.Index(
|
||||
'idx_stack_primary_asset_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_stack_primary_asset_id ON stack_entity (primary_asset_id)',
|
||||
);
|
||||
final i1.Index idxRemoteAssetOwnerChecksum = i1.Index(
|
||||
'idx_remote_asset_owner_checksum',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_checksum ON remote_asset_entity (owner_id, checksum)',
|
||||
);
|
||||
final i1.Index uQRemoteAssetsOwnerChecksum = i1.Index(
|
||||
'UQ_remote_assets_owner_checksum',
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum ON remote_asset_entity (owner_id, checksum) WHERE(library_id IS NULL)',
|
||||
);
|
||||
final i1.Index uQRemoteAssetsOwnerLibraryChecksum = i1.Index(
|
||||
'UQ_remote_assets_owner_library_checksum',
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_library_checksum ON remote_asset_entity (owner_id, library_id, checksum) WHERE(library_id IS NOT NULL)',
|
||||
);
|
||||
final i1.Index idxRemoteAssetChecksum = i1.Index(
|
||||
'idx_remote_asset_checksum',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)',
|
||||
);
|
||||
final i1.Index idxRemoteAssetStackId = i1.Index(
|
||||
'idx_remote_asset_stack_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_stack_id ON remote_asset_entity (stack_id)',
|
||||
);
|
||||
final i1.Index idxRemoteAssetLocalDateTimeDay = i1.Index(
|
||||
'idx_remote_asset_local_date_time_day',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_day ON remote_asset_entity (STRFTIME(\'%Y-%m-%d\', local_date_time))',
|
||||
);
|
||||
final i1.Index idxRemoteAssetLocalDateTimeMonth = i1.Index(
|
||||
'idx_remote_asset_local_date_time_month',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_month ON remote_asset_entity (STRFTIME(\'%Y-%m\', local_date_time))',
|
||||
);
|
||||
late final Shape21 authUserEntity = Shape21(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'auth_user_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_0,
|
||||
_column_1,
|
||||
_column_3,
|
||||
_column_2,
|
||||
_column_84,
|
||||
_column_85,
|
||||
_column_92,
|
||||
_column_93,
|
||||
_column_7,
|
||||
_column_94,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape4 userMetadataEntity = Shape4(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'user_metadata_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(user_id, "key")'],
|
||||
columns: [_column_25, _column_26, _column_27],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape5 partnerEntity = Shape5(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'partner_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(shared_by_id, shared_with_id)'],
|
||||
columns: [_column_28, _column_29, _column_30],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape8 remoteExifEntity = Shape8(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_exif_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(asset_id)'],
|
||||
columns: [
|
||||
_column_36,
|
||||
_column_37,
|
||||
_column_38,
|
||||
_column_39,
|
||||
_column_40,
|
||||
_column_41,
|
||||
_column_11,
|
||||
_column_10,
|
||||
_column_42,
|
||||
_column_43,
|
||||
_column_44,
|
||||
_column_45,
|
||||
_column_46,
|
||||
_column_47,
|
||||
_column_48,
|
||||
_column_49,
|
||||
_column_50,
|
||||
_column_51,
|
||||
_column_52,
|
||||
_column_53,
|
||||
_column_54,
|
||||
_column_55,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape7 remoteAlbumAssetEntity = Shape7(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_album_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
|
||||
columns: [_column_36, _column_60],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape10 remoteAlbumUserEntity = Shape10(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_album_user_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(album_id, user_id)'],
|
||||
columns: [_column_60, _column_25, _column_61],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape27 remoteAssetCloudIdEntity = Shape27(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'remote_asset_cloud_id_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(asset_id)'],
|
||||
columns: [
|
||||
_column_36,
|
||||
_column_99,
|
||||
_column_100,
|
||||
_column_96,
|
||||
_column_46,
|
||||
_column_47,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape11 memoryEntity = Shape11(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'memory_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_0,
|
||||
_column_9,
|
||||
_column_5,
|
||||
_column_18,
|
||||
_column_15,
|
||||
_column_8,
|
||||
_column_62,
|
||||
_column_63,
|
||||
_column_64,
|
||||
_column_65,
|
||||
_column_66,
|
||||
_column_67,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape12 memoryAssetEntity = Shape12(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'memory_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(asset_id, memory_id)'],
|
||||
columns: [_column_36, _column_68],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape14 personEntity = Shape14(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'person_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_0,
|
||||
_column_9,
|
||||
_column_5,
|
||||
_column_15,
|
||||
_column_1,
|
||||
_column_69,
|
||||
_column_71,
|
||||
_column_72,
|
||||
_column_73,
|
||||
_column_74,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape29 assetFaceEntity = Shape29(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'asset_face_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [
|
||||
_column_0,
|
||||
_column_36,
|
||||
_column_76,
|
||||
_column_77,
|
||||
_column_78,
|
||||
_column_79,
|
||||
_column_80,
|
||||
_column_81,
|
||||
_column_82,
|
||||
_column_83,
|
||||
_column_102,
|
||||
_column_18,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape18 storeEntity = Shape18(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'store_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id)'],
|
||||
columns: [_column_87, _column_88, _column_89],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape31 trashedLocalAssetEntity = Shape31(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'trashed_local_asset_entity',
|
||||
withoutRowId: true,
|
||||
isStrict: true,
|
||||
tableConstraints: ['PRIMARY KEY(id, album_id)'],
|
||||
columns: [
|
||||
_column_1,
|
||||
_column_8,
|
||||
_column_9,
|
||||
_column_5,
|
||||
_column_10,
|
||||
_column_11,
|
||||
_column_12,
|
||||
_column_0,
|
||||
_column_95,
|
||||
_column_22,
|
||||
_column_14,
|
||||
_column_23,
|
||||
_column_97,
|
||||
_column_103,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
final i1.Index idxPartnerSharedWithId = i1.Index(
|
||||
'idx_partner_shared_with_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_partner_shared_with_id ON partner_entity (shared_with_id)',
|
||||
);
|
||||
final i1.Index idxLatLng = i1.Index(
|
||||
'idx_lat_lng',
|
||||
'CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)',
|
||||
);
|
||||
final i1.Index idxRemoteAlbumAssetAlbumAsset = i1.Index(
|
||||
'idx_remote_album_asset_album_asset',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_album_asset_album_asset ON remote_album_asset_entity (album_id, asset_id)',
|
||||
);
|
||||
final i1.Index idxRemoteAssetCloudId = i1.Index(
|
||||
'idx_remote_asset_cloud_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_remote_asset_cloud_id ON remote_asset_cloud_id_entity (cloud_id)',
|
||||
);
|
||||
final i1.Index idxPersonOwnerId = i1.Index(
|
||||
'idx_person_owner_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_person_owner_id ON person_entity (owner_id)',
|
||||
);
|
||||
final i1.Index idxAssetFacePersonId = i1.Index(
|
||||
'idx_asset_face_person_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_asset_face_person_id ON asset_face_entity (person_id)',
|
||||
);
|
||||
final i1.Index idxAssetFaceAssetId = i1.Index(
|
||||
'idx_asset_face_asset_id',
|
||||
'CREATE INDEX IF NOT EXISTS idx_asset_face_asset_id ON asset_face_entity (asset_id)',
|
||||
);
|
||||
final i1.Index idxTrashedLocalAssetChecksum = i1.Index(
|
||||
'idx_trashed_local_asset_checksum',
|
||||
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)',
|
||||
);
|
||||
final i1.Index idxTrashedLocalAssetAlbum = i1.Index(
|
||||
'idx_trashed_local_asset_album',
|
||||
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)',
|
||||
);
|
||||
}
|
||||
|
||||
class Shape30 extends i0.VersionedTable {
|
||||
Shape30({required super.source, required super.alias}) : super.aliased();
|
||||
i1.GeneratedColumn<String> get name =>
|
||||
columnsByName['name']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<int> get type =>
|
||||
columnsByName['type']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<DateTime> get createdAt =>
|
||||
columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
|
||||
i1.GeneratedColumn<DateTime> get updatedAt =>
|
||||
columnsByName['updated_at']! as i1.GeneratedColumn<DateTime>;
|
||||
i1.GeneratedColumn<int> get width =>
|
||||
columnsByName['width']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<int> get height =>
|
||||
columnsByName['height']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<int> get durationInSeconds =>
|
||||
columnsByName['duration_in_seconds']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<String> get id =>
|
||||
columnsByName['id']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get checksum =>
|
||||
columnsByName['checksum']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<bool> get isFavorite =>
|
||||
columnsByName['is_favorite']! as i1.GeneratedColumn<bool>;
|
||||
i1.GeneratedColumn<int> get orientation =>
|
||||
columnsByName['orientation']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<String> get iCloudId =>
|
||||
columnsByName['i_cloud_id']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<DateTime> get adjustmentTime =>
|
||||
columnsByName['adjustment_time']! as i1.GeneratedColumn<DateTime>;
|
||||
i1.GeneratedColumn<double> get latitude =>
|
||||
columnsByName['latitude']! as i1.GeneratedColumn<double>;
|
||||
i1.GeneratedColumn<double> get longitude =>
|
||||
columnsByName['longitude']! as i1.GeneratedColumn<double>;
|
||||
i1.GeneratedColumn<int> get playbackStyle =>
|
||||
columnsByName['playback_style']! as i1.GeneratedColumn<int>;
|
||||
}
|
||||
|
||||
i1.GeneratedColumn<int> _column_103(String aliasedName) =>
|
||||
i1.GeneratedColumn<int>(
|
||||
'playback_style',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i1.DriftSqlType.int,
|
||||
defaultValue: const CustomExpression('0'),
|
||||
);
|
||||
|
||||
class Shape31 extends i0.VersionedTable {
|
||||
Shape31({required super.source, required super.alias}) : super.aliased();
|
||||
i1.GeneratedColumn<String> get name =>
|
||||
columnsByName['name']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<int> get type =>
|
||||
columnsByName['type']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<DateTime> get createdAt =>
|
||||
columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
|
||||
i1.GeneratedColumn<DateTime> get updatedAt =>
|
||||
columnsByName['updated_at']! as i1.GeneratedColumn<DateTime>;
|
||||
i1.GeneratedColumn<int> get width =>
|
||||
columnsByName['width']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<int> get height =>
|
||||
columnsByName['height']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<int> get durationInSeconds =>
|
||||
columnsByName['duration_in_seconds']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<String> get id =>
|
||||
columnsByName['id']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get albumId =>
|
||||
columnsByName['album_id']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get checksum =>
|
||||
columnsByName['checksum']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<bool> get isFavorite =>
|
||||
columnsByName['is_favorite']! as i1.GeneratedColumn<bool>;
|
||||
i1.GeneratedColumn<int> get orientation =>
|
||||
columnsByName['orientation']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<int> get source =>
|
||||
columnsByName['source']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<int> get playbackStyle =>
|
||||
columnsByName['playback_style']! as i1.GeneratedColumn<int>;
|
||||
}
|
||||
|
||||
i0.MigrationStepWithVersion migrationSteps({
|
||||
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
|
||||
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
|
||||
@@ -8924,6 +9509,7 @@ i0.MigrationStepWithVersion migrationSteps({
|
||||
required Future<void> Function(i1.Migrator m, Schema18 schema) from17To18,
|
||||
required Future<void> Function(i1.Migrator m, Schema19 schema) from18To19,
|
||||
required Future<void> Function(i1.Migrator m, Schema20 schema) from19To20,
|
||||
required Future<void> Function(i1.Migrator m, Schema21 schema) from20To21,
|
||||
}) {
|
||||
return (currentVersion, database) async {
|
||||
switch (currentVersion) {
|
||||
@@ -9022,6 +9608,11 @@ i0.MigrationStepWithVersion migrationSteps({
|
||||
final migrator = i1.Migrator(database, schema);
|
||||
await from19To20(migrator, schema);
|
||||
return 20;
|
||||
case 20:
|
||||
final schema = Schema21(database: database);
|
||||
final migrator = i1.Migrator(database, schema);
|
||||
await from20To21(migrator, schema);
|
||||
return 21;
|
||||
default:
|
||||
throw ArgumentError.value('Unknown migration from $currentVersion');
|
||||
}
|
||||
@@ -9048,6 +9639,7 @@ i1.OnUpgrade stepByStep({
|
||||
required Future<void> Function(i1.Migrator m, Schema18 schema) from17To18,
|
||||
required Future<void> Function(i1.Migrator m, Schema19 schema) from18To19,
|
||||
required Future<void> Function(i1.Migrator m, Schema20 schema) from19To20,
|
||||
required Future<void> Function(i1.Migrator m, Schema21 schema) from20To21,
|
||||
}) => i0.VersionedSchema.stepByStepHelper(
|
||||
step: migrationSteps(
|
||||
from1To2: from1To2,
|
||||
@@ -9069,5 +9661,6 @@ i1.OnUpgrade stepByStep({
|
||||
from17To18: from17To18,
|
||||
from18To19: from18To19,
|
||||
from19To20: from19To20,
|
||||
from20To21: from20To21,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -301,6 +301,7 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
|
||||
id: asset.id,
|
||||
orientation: Value(asset.orientation),
|
||||
isFavorite: Value(asset.isFavorite),
|
||||
playbackStyle: Value(asset.playbackStyle),
|
||||
latitude: Value(asset.latitude),
|
||||
longitude: Value(asset.longitude),
|
||||
adjustmentTime: Value(asset.adjustmentTime),
|
||||
@@ -333,6 +334,7 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
|
||||
checksum: const Value(null),
|
||||
orientation: Value(asset.orientation),
|
||||
isFavorite: Value(asset.isFavorite),
|
||||
playbackStyle: Value(asset.playbackStyle),
|
||||
);
|
||||
batch.insert<$LocalAssetEntityTable, LocalAssetEntityData>(
|
||||
_db.localAssetEntity,
|
||||
|
||||
@@ -101,6 +101,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
||||
isFavorite: row.isFavorite,
|
||||
durationInSeconds: row.durationInSeconds,
|
||||
orientation: row.orientation,
|
||||
playbackStyle: AssetPlaybackStyle.values[row.playbackStyle],
|
||||
cloudId: row.iCloudId,
|
||||
latitude: row.latitude,
|
||||
longitude: row.longitude,
|
||||
|
||||
@@ -85,6 +85,7 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository {
|
||||
durationInSeconds: Value(item.asset.durationInSeconds),
|
||||
isFavorite: Value(item.asset.isFavorite),
|
||||
orientation: Value(item.asset.orientation),
|
||||
playbackStyle: Value(item.asset.playbackStyle),
|
||||
source: TrashOrigin.localSync,
|
||||
);
|
||||
|
||||
@@ -147,6 +148,7 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository {
|
||||
durationInSeconds: Value(asset.durationInSeconds),
|
||||
isFavorite: Value(asset.isFavorite),
|
||||
orientation: Value(asset.orientation),
|
||||
playbackStyle: Value(asset.playbackStyle),
|
||||
createdAt: Value(asset.createdAt),
|
||||
updatedAt: Value(asset.updatedAt),
|
||||
source: const Value(TrashOrigin.remoteSync),
|
||||
@@ -195,6 +197,7 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository {
|
||||
checksum: Value(e.checksum),
|
||||
isFavorite: Value(e.isFavorite),
|
||||
orientation: Value(e.orientation),
|
||||
playbackStyle: Value(e.playbackStyle),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -245,6 +248,7 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository {
|
||||
checksum: Value(e.asset.checksum),
|
||||
isFavorite: Value(e.asset.isFavorite),
|
||||
orientation: Value(e.asset.orientation),
|
||||
playbackStyle: Value(e.asset.playbackStyle),
|
||||
source: TrashOrigin.localUser,
|
||||
albumId: e.albumId,
|
||||
);
|
||||
|
||||
@@ -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/is_motion_video_playing.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/haptic_feedback.provider.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
@@ -367,9 +366,6 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
stackIndex.value = 0;
|
||||
|
||||
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
|
||||
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/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/video_player_controls_provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
|
||||
import 'package:immich_mobile/providers/cast.provider.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/services/app_settings.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:logging/logging.dart';
|
||||
import 'package:native_video_player/native_video_player.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
|
||||
@RoutePage()
|
||||
class NativeVideoViewerPage extends HookConsumerWidget {
|
||||
@@ -42,18 +38,10 @@ class NativeVideoViewerPage extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final videoId = asset.id.toString();
|
||||
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);
|
||||
|
||||
// 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 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 {
|
||||
final videoController = controller.value;
|
||||
if (videoController == null || !isCurrent || !context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
final videoPlayback = VideoPlaybackValue.fromNativeController(videoController);
|
||||
ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback;
|
||||
final notifier = ref.read(videoPlayerProvider(videoId).notifier);
|
||||
notifier.onNativePlaybackReady();
|
||||
|
||||
isVideoReady.value = true;
|
||||
|
||||
try {
|
||||
final autoPlayVideo = ref.read(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.autoPlayVideo);
|
||||
if (autoPlayVideo) {
|
||||
await videoController.play();
|
||||
await notifier.play();
|
||||
}
|
||||
await videoController.setVolume(0.9);
|
||||
await notifier.setVolume(1);
|
||||
} catch (error) {
|
||||
log.severe('Error playing video: $error');
|
||||
}
|
||||
}
|
||||
|
||||
void onPlaybackStatusChanged() {
|
||||
final videoController = controller.value;
|
||||
if (videoController == null || !context.mounted) {
|
||||
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;
|
||||
if (!context.mounted) return;
|
||||
ref.read(videoPlayerProvider(videoId).notifier).onNativeStatusChanged();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
if (!context.mounted) return;
|
||||
ref.read(videoPlayerProvider(videoId).notifier).onNativePositionChanged();
|
||||
}
|
||||
|
||||
void onPlaybackEnded() {
|
||||
final videoController = controller.value;
|
||||
if (videoController == null || !context.mounted) {
|
||||
return;
|
||||
}
|
||||
if (!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(isPlayingMotionVideoProvider.notifier).playing = false;
|
||||
}
|
||||
@@ -254,14 +160,15 @@ class NativeVideoViewerPage extends HookConsumerWidget {
|
||||
if (controller.value != null || !context.mounted) {
|
||||
return;
|
||||
}
|
||||
ref.read(videoPlayerControlsProvider.notifier).reset();
|
||||
ref.read(videoPlaybackValueProvider.notifier).reset();
|
||||
|
||||
final source = await videoSource;
|
||||
if (source == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final notifier = ref.read(videoPlayerProvider(videoId).notifier);
|
||||
notifier.attachController(nc);
|
||||
|
||||
nc.onPlaybackPositionChanged.addListener(onPlaybackPositionChanged);
|
||||
nc.onPlaybackStatusChanged.addListener(onPlaybackStatusChanged);
|
||||
nc.onPlaybackReady.addListener(onPlaybackReady);
|
||||
@@ -273,10 +180,9 @@ class NativeVideoViewerPage extends HookConsumerWidget {
|
||||
}),
|
||||
);
|
||||
final loopVideo = ref.read(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.loopVideo);
|
||||
unawaited(nc.setLoop(loopVideo));
|
||||
await notifier.setLoop(loopVideo);
|
||||
|
||||
controller.value = nc;
|
||||
Timer(const Duration(milliseconds: 200), checkIfBuffering);
|
||||
}
|
||||
|
||||
ref.listen(currentAssetProvider, (_, value) {
|
||||
@@ -300,10 +206,6 @@ class NativeVideoViewerPage extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
// 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)
|
||||
@@ -337,19 +239,18 @@ class NativeVideoViewerPage extends HookConsumerWidget {
|
||||
playerController.stop().catchError((error) {
|
||||
log.fine('Error stopping video: $error');
|
||||
});
|
||||
|
||||
WakelockPlus.disable();
|
||||
};
|
||||
}, const []);
|
||||
|
||||
useOnAppLifecycleStateChange((_, state) async {
|
||||
final notifier = ref.read(videoPlayerProvider(videoId).notifier);
|
||||
if (state == AppLifecycleState.resumed && shouldPlayOnForeground.value) {
|
||||
await controller.value?.play();
|
||||
await notifier.play();
|
||||
} else if (state == AppLifecycleState.paused) {
|
||||
final videoPlaying = await controller.value?.isPlaying();
|
||||
if (videoPlaying ?? true) {
|
||||
shouldPlayOnForeground.value = true;
|
||||
await controller.value?.pause();
|
||||
await notifier.pause();
|
||||
} else {
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
@@ -11,7 +12,8 @@ import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/generated/codegen_loader.g.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:immich_mobile/providers/auth.provider.dart';
|
||||
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/backup.provider.dart';
|
||||
@@ -129,11 +131,16 @@ class _BottomPanelState extends State<_BottomPanel> {
|
||||
return;
|
||||
}
|
||||
|
||||
final db = Drift();
|
||||
try {
|
||||
await db.reset();
|
||||
} finally {
|
||||
await db.close();
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
for (final suffix in ['', '-wal', '-shm']) {
|
||||
final file = File(path.join(dir.path, 'immich.sqlite$suffix'));
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
|
||||
@@ -7,7 +7,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.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/video_player_value_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/memories/memory_bottom_info.dart';
|
||||
@@ -166,9 +165,6 @@ class MemoryPage extends HookConsumerWidget {
|
||||
final asset = currentMemory.value.assets[otherIndex];
|
||||
currentAsset.value = 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
|
||||
|
||||
@@ -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/memory.model.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/memory/memory_bottom_info.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/infrastructure/asset_viewer/asset.provider.dart';
|
||||
import 'package:immich_mobile/widgets/memories/memory_epilogue.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()
|
||||
class DriftMemoryPage extends HookConsumerWidget {
|
||||
final List<DriftMemory> memories;
|
||||
@@ -26,11 +25,7 @@ class DriftMemoryPage extends HookConsumerWidget {
|
||||
|
||||
static void setMemory(WidgetRef ref, DriftMemory memory) {
|
||||
if (memory.assets.isNotEmpty) {
|
||||
ref.read(currentAssetNotifier.notifier).setAsset(memory.assets.first);
|
||||
|
||||
if (memory.assets.first.isVideo) {
|
||||
ref.read(videoPlaybackValueProvider.notifier).reset();
|
||||
}
|
||||
ref.read(assetViewerProvider.notifier).setAsset(memory.assets.first);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,11 +167,7 @@ class DriftMemoryPage extends HookConsumerWidget {
|
||||
|
||||
final asset = currentMemory.value.assets[otherIndex];
|
||||
currentAsset.value = asset;
|
||||
ref.read(currentAssetNotifier.notifier).setAsset(asset);
|
||||
// if (asset.isVideo || asset.isMotionPhoto) {
|
||||
if (asset.isVideo) {
|
||||
ref.read(videoPlaybackValueProvider.notifier).reset();
|
||||
}
|
||||
ref.read(assetViewerProvider.notifier).setAsset(asset);
|
||||
}
|
||||
|
||||
/* Notification listener is used instead of OnPageChanged callback since OnPageChanged is called
|
||||
@@ -273,7 +264,12 @@ class DriftMemoryPage extends HookConsumerWidget {
|
||||
children: [
|
||||
Container(
|
||||
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(
|
||||
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/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/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/providers/infrastructure/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||
@@ -49,7 +49,7 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
|
||||
}
|
||||
|
||||
List<Widget> _buildMenuChildren() {
|
||||
final asset = ref.read(currentAssetNotifier);
|
||||
final asset = ref.read(assetViewerProvider).currentAsset;
|
||||
if (asset == null) return [];
|
||||
|
||||
final user = ref.read(currentUserProvider);
|
||||
@@ -103,7 +103,7 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
|
||||
}
|
||||
|
||||
void _openAlbumSelector() {
|
||||
final currentAsset = ref.read(currentAssetNotifier);
|
||||
final currentAsset = ref.read(assetViewerProvider).currentAsset;
|
||||
if (currentAsset == null) {
|
||||
ImmichToast.show(context: context, msg: "Cannot load asset information.", toastType: ToastType.error);
|
||||
return;
|
||||
@@ -133,7 +133,7 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
|
||||
}
|
||||
|
||||
Future<void> _addCurrentAssetToAlbum(RemoteAlbum album) async {
|
||||
final latest = ref.read(currentAssetNotifier);
|
||||
final latest = ref.read(assetViewerProvider).currentAsset;
|
||||
|
||||
if (latest == null) {
|
||||
ImmichToast.show(context: context, msg: "Cannot load asset information.", toastType: ToastType.error);
|
||||
@@ -169,7 +169,7 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final asset = ref.watch(currentAssetNotifier);
|
||||
final asset = ref.watch(assetViewerProvider.select((s) => s.currentAsset));
|
||||
if (asset == null) {
|
||||
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/presentation/widgets/action_buttons/base_action_button.widget.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';
|
||||
|
||||
class EditImageActionButton extends ConsumerWidget {
|
||||
@@ -12,7 +12,7 @@ class EditImageActionButton extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final currentAsset = ref.watch(currentAssetNotifier);
|
||||
final currentAsset = ref.watch(assetViewerProvider.select((s) => s.currentAsset));
|
||||
|
||||
onPress() {
|
||||
if (currentAsset == null) {
|
||||
|
||||
@@ -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/presentation/widgets/action_buttons/base_action_button.widget.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/user.provider.dart';
|
||||
|
||||
@@ -20,7 +20,7 @@ class LikeActivityActionButton extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
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 activities = ref.watch(albumActivityProvider(album?.id ?? "", asset?.id));
|
||||
|
||||
@@ -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/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/asset_viewer/asset_viewer.state.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
|
||||
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/infrastructure/album.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/user.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
@@ -809,7 +809,7 @@ class CreateAlbumButton extends ConsumerWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
final asset = ref.read(currentAssetNotifier);
|
||||
final asset = ref.read(assetViewerProvider).currentAsset;
|
||||
|
||||
if (asset == null) {
|
||||
ImmichToast.show(context: context, msg: "Cannot load asset information.", toastType: ToastType.error);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/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/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';
|
||||
|
||||
class AssetDetails extends ConsumerWidget {
|
||||
final BaseAsset asset;
|
||||
final double minHeight;
|
||||
|
||||
const AssetDetails({required this.minHeight, super.key});
|
||||
const AssetDetails({super.key, required this.asset, required this.minHeight});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final asset = ref.watch(currentAssetNotifier);
|
||||
if (asset == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final exifInfo = ref.watch(assetExifProvider(asset)).valueOrNull;
|
||||
|
||||
return Container(
|
||||
constraints: BoxConstraints(minHeight: minHeight),
|
||||
decoration: BoxDecoration(
|
||||
@@ -31,12 +31,12 @@ class AssetDetails extends ConsumerWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const DragHandle(),
|
||||
const DateTimeDetails(),
|
||||
const PeopleDetails(),
|
||||
const LocationDetails(),
|
||||
const TechnicalDetails(),
|
||||
const RatingDetails(),
|
||||
const AppearsInDetails(),
|
||||
DateTimeDetails(asset: asset, exifInfo: exifInfo),
|
||||
PeopleDetails(asset: asset),
|
||||
LocationDetails(asset: asset, exifInfo: exifInfo),
|
||||
TechnicalDetails(asset: asset, exifInfo: exifInfo),
|
||||
RatingDetails(exifInfo: exifInfo),
|
||||
AppearsInDetails(asset: asset),
|
||||
SizedBox(height: context.padding.bottom + 48),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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/translate_extensions.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/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/routing/router.dart';
|
||||
|
||||
class AppearsInDetails extends ConsumerWidget {
|
||||
const AppearsInDetails({super.key});
|
||||
final BaseAsset asset;
|
||||
|
||||
const AppearsInDetails({super.key, required this.asset});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final asset = ref.watch(currentAssetNotifier);
|
||||
if (asset == null || !asset.hasRemote) return const SizedBox.shrink();
|
||||
if (!asset.hasRemote) return const SizedBox.shrink();
|
||||
|
||||
String? remoteAssetId;
|
||||
if (asset is RemoteAsset) {
|
||||
remoteAssetId = asset.id;
|
||||
} else if (asset is LocalAsset) {
|
||||
remoteAssetId = asset.remoteAssetId;
|
||||
}
|
||||
final remoteAssetId = switch (asset) {
|
||||
RemoteAsset(:final id) => id,
|
||||
LocalAsset(:final remoteAssetId) => remoteAssetId,
|
||||
};
|
||||
|
||||
if (remoteAssetId == null) return const SizedBox.shrink();
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import 'package:immich_mobile/extensions/duration_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/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/utils/timezone.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 = ' • ';
|
||||
|
||||
class DateTimeDetails extends ConsumerWidget {
|
||||
const DateTimeDetails({super.key});
|
||||
final BaseAsset asset;
|
||||
final ExifInfo? exifInfo;
|
||||
|
||||
const DateTimeDetails({super.key, required this.asset, this.exifInfo});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final asset = ref.watch(currentAssetNotifier);
|
||||
if (asset == null) return const SizedBox.shrink();
|
||||
|
||||
final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
|
||||
final asset = this.asset;
|
||||
final exifInfo = this.exifInfo;
|
||||
final isOwner = ref.watch(currentUserProvider)?.id == (asset is RemoteAsset ? asset.ownerId : null);
|
||||
|
||||
return Column(
|
||||
@@ -106,9 +106,7 @@ class _SheetAssetDescriptionState extends ConsumerState<_SheetAssetDescription>
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final currentExifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
|
||||
|
||||
final currentDescription = currentExifInfo?.description ?? '';
|
||||
final currentDescription = widget.exif.description ?? '';
|
||||
final hintText = (widget.isEditable ? 'exif_bottom_sheet_description' : 'exif_bottom_sheet_no_description').t(
|
||||
context: context,
|
||||
);
|
||||
@@ -134,7 +132,7 @@ class _SheetAssetDescriptionState extends ConsumerState<_SheetAssetDescription>
|
||||
errorBorder: InputBorder.none,
|
||||
focusedErrorBorder: InputBorder.none,
|
||||
),
|
||||
onTapOutside: (_) => saveDescription(currentExifInfo?.description),
|
||||
onTapOutside: (_) => saveDescription(widget.exif.description),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -8,12 +8,14 @@ import 'package:immich_mobile/extensions/theme_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/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:maplibre_gl/maplibre_gl.dart';
|
||||
|
||||
class LocationDetails extends ConsumerStatefulWidget {
|
||||
const LocationDetails({super.key});
|
||||
final BaseAsset asset;
|
||||
final ExifInfo? exifInfo;
|
||||
|
||||
const LocationDetails({super.key, required this.asset, this.exifInfo});
|
||||
|
||||
@override
|
||||
ConsumerState createState() => _LocationDetailsState();
|
||||
@@ -40,17 +42,15 @@ class _LocationDetailsState extends ConsumerState<LocationDetails> {
|
||||
_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
|
||||
void initState() {
|
||||
super.initState();
|
||||
ref.listenManual(currentAssetExifProvider, _onExifChanged, fireImmediately: true);
|
||||
void didUpdateWidget(LocationDetails oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
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 {
|
||||
@@ -59,8 +59,8 @@ class _LocationDetailsState extends ConsumerState<LocationDetails> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final asset = ref.watch(currentAssetNotifier);
|
||||
final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
|
||||
final asset = widget.asset;
|
||||
final exifInfo = widget.exifInfo;
|
||||
final hasCoordinates = exifInfo?.hasCoordinates ?? false;
|
||||
|
||||
// Guard local assets
|
||||
|
||||
@@ -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/translate_extensions.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/routes.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/people.utils.dart';
|
||||
|
||||
class PeopleDetails extends ConsumerStatefulWidget {
|
||||
const PeopleDetails({super.key});
|
||||
class PeopleDetails extends ConsumerWidget {
|
||||
final BaseAsset asset;
|
||||
|
||||
const PeopleDetails({super.key, required this.asset});
|
||||
|
||||
@override
|
||||
ConsumerState createState() => _PeopleDetailsState();
|
||||
}
|
||||
|
||||
class _PeopleDetailsState extends ConsumerState<PeopleDetails> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final asset = ref.watch(currentAssetNotifier);
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final asset = this.asset;
|
||||
if (asset is! RemoteAsset) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.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/theme_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/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';
|
||||
|
||||
class RatingDetails extends ConsumerWidget {
|
||||
const RatingDetails({super.key});
|
||||
final ExifInfo? exifInfo;
|
||||
|
||||
const RatingDetails({super.key, this.exifInfo});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
@@ -20,8 +22,6 @@ class RatingDetails extends ConsumerWidget {
|
||||
|
||||
if (!isRatingEnabled) return const SizedBox.shrink();
|
||||
|
||||
final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 16.0, top: 16.0),
|
||||
child: Column(
|
||||
|
||||
@@ -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/translate_extensions.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/utils/bytes_units.dart';
|
||||
|
||||
const _kSeparator = ' • ';
|
||||
|
||||
class TechnicalDetails extends ConsumerWidget {
|
||||
const TechnicalDetails({super.key});
|
||||
final BaseAsset asset;
|
||||
final ExifInfo? exifInfo;
|
||||
|
||||
const TechnicalDetails({super.key, required this.asset, this.exifInfo});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final asset = ref.watch(currentAssetNotifier);
|
||||
if (asset == null) return const SizedBox.shrink();
|
||||
|
||||
final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
|
||||
final exifInfo = this.exifInfo;
|
||||
final cameraTitle = _getCameraInfoTitle(exifInfo);
|
||||
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/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_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/images/image_provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_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/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/widgets/common/immich_loading_indicator.dart';
|
||||
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
|
||||
@@ -53,8 +52,6 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||
|
||||
final _scrollController = ScrollController();
|
||||
late final _proxyScrollController = ProxyScrollController(scrollController: _scrollController);
|
||||
final ValueNotifier<PhotoViewScaleState> _videoScaleStateNotifier = ValueNotifier(PhotoViewScaleState.initial);
|
||||
|
||||
double _snapOffset = 0.0;
|
||||
|
||||
DragStartDetails? _dragStart;
|
||||
@@ -79,7 +76,6 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||
_proxyScrollController.dispose();
|
||||
_scaleBoundarySub?.cancel();
|
||||
_eventSubscription?.cancel();
|
||||
_videoScaleStateNotifier.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -249,17 +245,16 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||
ref.read(isPlayingMotionVideoProvider.notifier).playing = true;
|
||||
|
||||
void _onScaleStateChanged(PhotoViewScaleState scaleState) {
|
||||
_isZoomed =
|
||||
scaleState == PhotoViewScaleState.zoomedIn ||
|
||||
scaleState == PhotoViewScaleState.covering ||
|
||||
_videoScaleStateNotifier.value == PhotoViewScaleState.zoomedIn ||
|
||||
_videoScaleStateNotifier.value == PhotoViewScaleState.covering;
|
||||
_isZoomed = scaleState == PhotoViewScaleState.zoomedIn || scaleState == PhotoViewScaleState.covering;
|
||||
_viewer.setZoomed(_isZoomed);
|
||||
|
||||
if (scaleState != PhotoViewScaleState.initial) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -294,22 +289,20 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||
_listenForScaleBoundaries(controller);
|
||||
}
|
||||
|
||||
Widget _buildPhotoView(
|
||||
BaseAsset displayAsset,
|
||||
BaseAsset asset, {
|
||||
required bool isCurrentPage,
|
||||
required bool showingDetails,
|
||||
Widget _buildPhotoView({
|
||||
required BaseAsset asset,
|
||||
required PhotoViewHeroAttributes? heroAttributes,
|
||||
required bool isCurrent,
|
||||
required bool isPlayingMotionVideo,
|
||||
required BoxDecoration backgroundDecoration,
|
||||
}) {
|
||||
final heroAttributes = isCurrentPage ? PhotoViewHeroAttributes(tag: '${asset.heroTag}_${widget.heroOffset}') : null;
|
||||
final size = context.sizeData;
|
||||
|
||||
if (displayAsset.isImage && !isPlayingMotionVideo) {
|
||||
final size = context.sizeData;
|
||||
if (asset.isImage && !isPlayingMotionVideo) {
|
||||
return PhotoView(
|
||||
key: Key(displayAsset.heroTag),
|
||||
key: Key(asset.heroTag),
|
||||
index: widget.index,
|
||||
imageProvider: getFullImageProvider(displayAsset, size: size),
|
||||
imageProvider: getFullImageProvider(asset, size: size),
|
||||
heroAttributes: heroAttributes,
|
||||
loadingBuilder: (context, progress, index) => const Center(child: ImmichLoadingIndicator()),
|
||||
backgroundDecoration: backgroundDecoration,
|
||||
@@ -317,7 +310,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||
filterQuality: FilterQuality.high,
|
||||
tightMode: true,
|
||||
enablePanAlways: true,
|
||||
disableScaleGestures: showingDetails,
|
||||
disableScaleGestures: _showingDetails,
|
||||
scaleStateChangedCallback: _onScaleStateChanged,
|
||||
onPageBuild: _onPageBuild,
|
||||
onDragStart: _onDragStart,
|
||||
@@ -325,25 +318,30 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||
onDragEnd: _onDragEnd,
|
||||
onDragCancel: _onDragCancel,
|
||||
onTapUp: _onTapUp,
|
||||
onLongPressStart: displayAsset.isMotionPhoto ? _onLongPress : null,
|
||||
onLongPressStart: asset.isMotionPhoto ? _onLongPress : null,
|
||||
errorBuilder: (_, __, ___) => SizedBox(
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
child: Thumbnail.fromAsset(asset: displayAsset, fit: BoxFit.contain),
|
||||
child: Thumbnail.fromAsset(asset: asset, fit: BoxFit.contain),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
onDragUpdate: _onDragUpdate,
|
||||
onDragEnd: _onDragEnd,
|
||||
onDragCancel: _onDragCancel,
|
||||
onTapUp: _onTapUp,
|
||||
scaleStateChangedCallback: _onScaleStateChanged,
|
||||
heroAttributes: heroAttributes,
|
||||
filterQuality: FilterQuality.high,
|
||||
basePosition: Alignment.center,
|
||||
disableScaleGestures: true,
|
||||
disableScaleGestures: _showingDetails,
|
||||
minScale: PhotoViewComputedScale.contained,
|
||||
initialScale: PhotoViewComputedScale.contained,
|
||||
tightMode: true,
|
||||
@@ -351,14 +349,11 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||
enablePanAlways: true,
|
||||
backgroundDecoration: backgroundDecoration,
|
||||
child: NativeVideoViewer(
|
||||
key: _NativeVideoViewerKey(displayAsset.heroTag),
|
||||
asset: displayAsset,
|
||||
scaleStateNotifier: _videoScaleStateNotifier,
|
||||
disableScaleGestures: showingDetails,
|
||||
key: _NativeVideoViewerKey(asset.heroTag),
|
||||
asset: asset,
|
||||
isCurrent: isCurrent,
|
||||
image: Image(
|
||||
image: getFullImageProvider(displayAsset, size: context.sizeData),
|
||||
height: context.height,
|
||||
width: context.width,
|
||||
image: getFullImageProvider(asset, size: size),
|
||||
fit: BoxFit.contain,
|
||||
alignment: Alignment.center,
|
||||
),
|
||||
@@ -384,6 +379,8 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||
displayAsset = stackChildren.elementAt(stackIndex);
|
||||
}
|
||||
|
||||
final isCurrent = currentHeroTag == displayAsset.heroTag;
|
||||
|
||||
final viewportWidth = MediaQuery.widthOf(context);
|
||||
final viewportHeight = MediaQuery.heightOf(context);
|
||||
final imageHeight = _getImageHeight(viewportWidth, viewportHeight, displayAsset);
|
||||
@@ -397,65 +394,63 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
||||
_proxyScrollController.snapPosition.snapOffset = _snapOffset;
|
||||
}
|
||||
|
||||
return ProviderScope(
|
||||
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: [
|
||||
Offstage(
|
||||
child: SingleChildScrollView(
|
||||
controller: _proxyScrollController,
|
||||
physics: const SnapScrollPhysics(),
|
||||
child: const SizedBox.shrink(),
|
||||
),
|
||||
return Stack(
|
||||
children: [
|
||||
Offstage(
|
||||
child: SingleChildScrollView(
|
||||
controller: _proxyScrollController,
|
||||
physics: const SnapScrollPhysics(),
|
||||
child: const SizedBox.shrink(),
|
||||
),
|
||||
SingleChildScrollView(
|
||||
controller: _scrollController,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
child: Stack(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: viewportWidth,
|
||||
height: viewportHeight,
|
||||
child: _buildPhotoView(
|
||||
displayAsset,
|
||||
asset,
|
||||
isCurrentPage: currentHeroTag == asset.heroTag,
|
||||
showingDetails: _showingDetails,
|
||||
isPlayingMotionVideo: isPlayingMotionVideo,
|
||||
backgroundDecoration: BoxDecoration(color: _showingDetails ? Colors.black : Colors.transparent),
|
||||
),
|
||||
),
|
||||
SingleChildScrollView(
|
||||
controller: _scrollController,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
child: Stack(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: viewportWidth,
|
||||
height: viewportHeight,
|
||||
child: _buildPhotoView(
|
||||
asset: displayAsset,
|
||||
heroAttributes: isCurrent
|
||||
? PhotoViewHeroAttributes(tag: '${asset.heroTag}_${widget.heroOffset}')
|
||||
: null,
|
||||
isCurrent: isCurrent,
|
||||
isPlayingMotionVideo: isPlayingMotionVideo,
|
||||
backgroundDecoration: BoxDecoration(color: _showingDetails ? Colors.black : Colors.transparent),
|
||||
),
|
||||
IgnorePointer(
|
||||
ignoring: !_showingDetails,
|
||||
child: Column(
|
||||
children: [
|
||||
SizedBox(height: detailsOffset),
|
||||
GestureDetector(
|
||||
onVerticalDragStart: _beginDrag,
|
||||
onVerticalDragUpdate: _updateDrag,
|
||||
onVerticalDragEnd: _endDrag,
|
||||
onVerticalDragCancel: _onDragCancel,
|
||||
child: AnimatedOpacity(
|
||||
opacity: _showingDetails ? 1.0 : 0.0,
|
||||
duration: Durations.short2,
|
||||
child: AssetDetails(minHeight: viewportHeight - snapTarget),
|
||||
),
|
||||
),
|
||||
IgnorePointer(
|
||||
ignoring: !_showingDetails,
|
||||
child: Column(
|
||||
children: [
|
||||
SizedBox(height: detailsOffset),
|
||||
GestureDetector(
|
||||
onVerticalDragStart: _beginDrag,
|
||||
onVerticalDragUpdate: _updateDrag,
|
||||
onVerticalDragEnd: _endDrag,
|
||||
onVerticalDragCancel: _onDragCancel,
|
||||
child: AnimatedOpacity(
|
||||
opacity: _showingDetails ? 1.0 : 0.0,
|
||||
duration: Durations.short2,
|
||||
child: AssetDetails(asset: displayAsset, minHeight: viewportHeight - snapTarget),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (stackChildren != null && stackChildren.isNotEmpty)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: context.padding.bottom,
|
||||
child: AssetStackRow(stack: stackChildren),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,53 +1,42 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/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/images/thumbnail.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||
|
||||
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;
|
||||
|
||||
const _StackList({required this.stack});
|
||||
const AssetStackRow({super.key, required this.stack});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Center(
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 10.0, right: 10.0, bottom: 20.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
spacing: 5.0,
|
||||
children: List.generate(stack.length, (i) {
|
||||
final asset = stack[i];
|
||||
return _StackItem(key: ValueKey(asset.heroTag), asset: asset, index: i);
|
||||
}),
|
||||
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(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 10.0, right: 10.0, bottom: 20.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
spacing: 5.0,
|
||||
children: List.generate(stack.length, (i) {
|
||||
final asset = stack[i];
|
||||
return _StackItem(key: ValueKey(asset.heroTag), asset: asset, index: i);
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -67,8 +56,9 @@ class _StackItem extends ConsumerStatefulWidget {
|
||||
|
||||
class _StackItemState extends ConsumerState<_StackItem> {
|
||||
void _onTap() {
|
||||
ref.read(currentAssetNotifier.notifier).setAsset(widget.asset);
|
||||
ref.read(assetViewerProvider.notifier).setStackIndex(widget.index);
|
||||
final notifier = ref.read(assetViewerProvider.notifier);
|
||||
notifier.setAsset(widget.asset);
|
||||
notifier.setStackIndex(widget.index);
|
||||
}
|
||||
|
||||
@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_preloader.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_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/infrastructure/asset_viewer/asset.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/widgets/photo_view/photo_view.dart';
|
||||
@@ -72,15 +69,7 @@ class AssetViewer extends ConsumerStatefulWidget {
|
||||
}
|
||||
|
||||
static void _setAsset(WidgetRef ref, BaseAsset asset) {
|
||||
// Always holds the current asset from the timeline
|
||||
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
|
||||
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 _preloader = AssetPreloader(timelineService: ref.read(timelineServiceProvider), mounted: () => mounted);
|
||||
|
||||
late int _currentPage = widget.initialIndex;
|
||||
|
||||
StreamSubscription? _reloadSubscription;
|
||||
KeepAliveLink? _stackChildrenKeepAlive;
|
||||
|
||||
@@ -102,7 +93,9 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
final target = page + direction;
|
||||
final maxPage = ref.read(timelineServiceProvider).totalAssets - 1;
|
||||
if (target >= 0 && target <= maxPage) {
|
||||
_currentPage = target;
|
||||
_pageController.jumpToPage(target);
|
||||
_onAssetChanged(target);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,7 +103,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
void 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");
|
||||
if (asset != null) _stackChildrenKeepAlive = ref.read(stackChildrenNotifier(asset).notifier).ref.keepAlive();
|
||||
|
||||
@@ -134,6 +127,26 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
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) {
|
||||
_preloader.preload(widget.initialIndex, context.sizeData);
|
||||
_handleCasting();
|
||||
@@ -153,7 +166,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
|
||||
void _handleCasting() {
|
||||
if (!ref.read(castProvider).isCasting) return;
|
||||
final asset = ref.read(currentAssetNotifier);
|
||||
final asset = ref.read(assetViewerProvider).currentAsset;
|
||||
if (asset == null) return;
|
||||
|
||||
if (asset is RemoteAsset) {
|
||||
@@ -195,17 +208,19 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
}
|
||||
|
||||
var index = _pageController.page?.round() ?? 0;
|
||||
final currentAsset = ref.read(currentAssetNotifier);
|
||||
final currentAsset = ref.read(assetViewerProvider).currentAsset;
|
||||
if (currentAsset != null) {
|
||||
final newIndex = timelineService.getIndex(currentAsset.heroTag);
|
||||
if (newIndex != null && newIndex != index) {
|
||||
index = newIndex;
|
||||
_currentPage = index;
|
||||
_pageController.jumpToPage(index);
|
||||
}
|
||||
}
|
||||
|
||||
if (index >= totalAssets) {
|
||||
index = totalAssets - 1;
|
||||
_currentPage = index;
|
||||
_pageController.jumpToPage(index);
|
||||
}
|
||||
|
||||
@@ -221,7 +236,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
final newAsset = await timelineService.getAssetAsync(index);
|
||||
if (newAsset == null) return;
|
||||
|
||||
final currentAsset = ref.read(currentAssetNotifier);
|
||||
final currentAsset = ref.read(assetViewerProvider).currentAsset;
|
||||
|
||||
// Do not reload if the asset has not changed
|
||||
if (newAsset.heroTag == currentAsset?.heroTag) return;
|
||||
@@ -258,25 +273,26 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
_setSystemUIMode(controls, details);
|
||||
});
|
||||
|
||||
return PopScope(
|
||||
onPopInvokedWithResult: (didPop, result) => ref.read(currentAssetNotifier.notifier).dispose(),
|
||||
child: Scaffold(
|
||||
backgroundColor: backgroundColor,
|
||||
appBar: const ViewerTopAppBar(),
|
||||
extendBody: true,
|
||||
extendBodyBehindAppBar: true,
|
||||
floatingActionButton: IgnorePointer(
|
||||
ignoring: !showingControls,
|
||||
child: AnimatedOpacity(
|
||||
opacity: showingControls ? 1.0 : 0.0,
|
||||
duration: Durations.short2,
|
||||
child: const DownloadStatusFloatingButton(),
|
||||
),
|
||||
return Scaffold(
|
||||
backgroundColor: backgroundColor,
|
||||
resizeToAvoidBottomInset: false,
|
||||
appBar: const ViewerTopAppBar(),
|
||||
extendBody: true,
|
||||
extendBodyBehindAppBar: true,
|
||||
floatingActionButton: IgnorePointer(
|
||||
ignoring: !showingControls,
|
||||
child: AnimatedOpacity(
|
||||
opacity: showingControls ? 1.0 : 0.0,
|
||||
duration: Durations.short2,
|
||||
child: const DownloadStatusFloatingButton(),
|
||||
),
|
||||
bottomNavigationBar: const ViewerBottomAppBar(),
|
||||
body: Stack(
|
||||
children: [
|
||||
PhotoViewGestureDetectorScope(
|
||||
),
|
||||
bottomNavigationBar: const ViewerBottomAppBar(),
|
||||
body: Stack(
|
||||
children: [
|
||||
NotificationListener<ScrollEndNotification>(
|
||||
onNotification: _onScrollEnd,
|
||||
child: PhotoViewGestureDetectorScope(
|
||||
axis: Axis.horizontal,
|
||||
child: PageView.builder(
|
||||
controller: _pageController,
|
||||
@@ -286,21 +302,20 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||
? const FastScrollPhysics()
|
||||
: const FastClampingScrollPhysics(),
|
||||
itemCount: ref.read(timelineServiceProvider).totalAssets,
|
||||
onPageChanged: (index) => _onAssetChanged(index),
|
||||
itemBuilder: (context, index) =>
|
||||
AssetPage(index: index, heroOffset: _heroOffset, onTapNavigate: _onTapNavigate),
|
||||
),
|
||||
),
|
||||
if (!CurrentPlatform.isIOS)
|
||||
IgnorePointer(
|
||||
child: AnimatedContainer(
|
||||
duration: Durations.short2,
|
||||
color: Colors.black.withValues(alpha: showingDetails ? 0.6 : 0.0),
|
||||
height: context.padding.top,
|
||||
),
|
||||
),
|
||||
if (!CurrentPlatform.isIOS)
|
||||
IgnorePointer(
|
||||
child: AnimatedContainer(
|
||||
duration: Durations.short2,
|
||||
color: Colors.black.withValues(alpha: showingDetails ? 0.6 : 0.0),
|
||||
height: context.padding.top,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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/upload_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/infrastructure/asset_viewer/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.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/user.provider.dart';
|
||||
@@ -21,7 +20,7 @@ class ViewerBottomBar extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final asset = ref.watch(currentAssetNotifier);
|
||||
final asset = ref.watch(assetViewerProvider.select((s) => s.currentAsset));
|
||||
if (asset == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
@@ -65,9 +64,9 @@ class ViewerBottomBar extends ConsumerWidget {
|
||||
color: Colors.black.withAlpha(125),
|
||||
padding: EdgeInsets.only(bottom: context.padding.bottom, top: 16),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (asset.isVideo) const VideoControls(),
|
||||
if (asset.isVideo) VideoControls(videoPlayerName: asset.heroTag),
|
||||
if (!isReadonlyModeEnabled)
|
||||
Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions),
|
||||
],
|
||||
|
||||
@@ -1,469 +1,233 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
|
||||
import 'package:hooks_riverpod/hooks_riverpod.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/store.model.dart';
|
||||
import 'package:immich_mobile/domain/services/setting.service.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/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/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
|
||||
import 'package:immich_mobile/providers/cast.provider.dart';
|
||||
import 'package:immich_mobile/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/services/api.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:native_video_player/native_video_player.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
|
||||
bool _isCurrentAsset(BaseAsset asset, BaseAsset? currentAsset) {
|
||||
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');
|
||||
class NativeVideoViewer extends ConsumerStatefulWidget {
|
||||
final BaseAsset asset;
|
||||
final bool isCurrent;
|
||||
final bool showControls;
|
||||
final int playbackDelayFactor;
|
||||
final Widget image;
|
||||
final ValueNotifier<PhotoViewScaleState>? scaleStateNotifier;
|
||||
final bool disableScaleGestures;
|
||||
|
||||
const NativeVideoViewer({
|
||||
super.key,
|
||||
required this.asset,
|
||||
required this.image,
|
||||
this.isCurrent = false,
|
||||
this.showControls = true,
|
||||
this.playbackDelayFactor = 1,
|
||||
this.scaleStateNotifier,
|
||||
this.disableScaleGestures = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final controller = useState<NativeVideoPlayerController?>(null);
|
||||
final lastVideoPosition = useRef(-1);
|
||||
final isBuffering = useRef(false);
|
||||
ConsumerState<NativeVideoViewer> createState() => _NativeVideoViewerState();
|
||||
}
|
||||
|
||||
// Used to track whether the video should play when the app
|
||||
// is brought back to the foreground
|
||||
final shouldPlayOnForeground = useRef(true);
|
||||
class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with WidgetsBindingObserver {
|
||||
static final _log = Logger('NativeVideoViewer');
|
||||
|
||||
// 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(currentAssetNotifier));
|
||||
final isCurrent = _isCurrentAsset(asset, currentAsset.value);
|
||||
NativeVideoPlayerController? _controller;
|
||||
late final Future<VideoSource?> _videoSource;
|
||||
Timer? _loadTimer;
|
||||
bool _isVideoReady = false;
|
||||
bool _shouldPlayOnForeground = true;
|
||||
|
||||
// Used to show the placeholder during hero animations for remote videos to avoid a stutter
|
||||
final isVisible = useState(Platform.isIOS && asset.hasLocal);
|
||||
VideoPlayerNotifier get _notifier => ref.read(videoPlayerProvider(widget.asset.heroTag).notifier);
|
||||
|
||||
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
|
||||
|
||||
Future<VideoSource?> createSource() async {
|
||||
if (!context.mounted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final videoAsset = await ref.read(assetServiceProvider).getAsset(asset) ?? asset;
|
||||
if (!context.mounted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
if (videoAsset.hasLocal && videoAsset.livePhotoVideoId == null) {
|
||||
final id = videoAsset is LocalAsset ? videoAsset.id : (videoAsset as RemoteAsset).localId!;
|
||||
final file = await StorageRepository().getFileForAsset(id);
|
||||
if (!context.mounted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (file == null) {
|
||||
throw Exception('No file found for the video');
|
||||
}
|
||||
|
||||
// Pass a file:// URI so Android's Uri.parse doesn't
|
||||
// interpret characters like '#' as fragment identifiers.
|
||||
final source = await VideoSource.init(
|
||||
path: CurrentPlatform.isAndroid ? file.uri.toString() : file.path,
|
||||
type: VideoSourceType.file,
|
||||
);
|
||||
return source;
|
||||
}
|
||||
|
||||
final remoteId = (videoAsset as RemoteAsset).id;
|
||||
|
||||
// Use a network URL for the video player controller
|
||||
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
|
||||
final isOriginalVideo = ref.read(settingsProvider).get<bool>(Setting.loadOriginalVideo);
|
||||
final String postfixUrl = isOriginalVideo ? 'original' : 'video/playback';
|
||||
final String videoUrl = videoAsset.livePhotoVideoId != null
|
||||
? '$serverEndpoint/assets/${videoAsset.livePhotoVideoId}/$postfixUrl'
|
||||
: '$serverEndpoint/assets/$remoteId/$postfixUrl';
|
||||
|
||||
final source = await VideoSource.init(
|
||||
path: videoUrl,
|
||||
type: VideoSourceType.network,
|
||||
headers: ApiService.getRequestHeaders(),
|
||||
);
|
||||
return source;
|
||||
} catch (error) {
|
||||
log.severe('Error creating video source for asset ${videoAsset.name}: $error');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
final videoSource = useMemoized<Future<VideoSource?>>(() => createSource());
|
||||
final aspectRatio = useState<double?>(null);
|
||||
|
||||
useMemoized(() async {
|
||||
if (!context.mounted || aspectRatio.value != null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
aspectRatio.value = await ref.read(assetServiceProvider).getAspectRatio(asset);
|
||||
} catch (error) {
|
||||
log.severe('Error getting aspect ratio for asset ${asset.name}: $error');
|
||||
}
|
||||
}, [asset.heroTag]);
|
||||
|
||||
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 {
|
||||
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);
|
||||
if (autoPlayVideo) {
|
||||
await videoController.play();
|
||||
}
|
||||
await videoController.setVolume(0.9);
|
||||
} catch (error) {
|
||||
log.severe('Error playing video: $error');
|
||||
}
|
||||
}
|
||||
|
||||
void onPlaybackStatusChanged() {
|
||||
final videoController = controller.value;
|
||||
if (videoController == null || !context.mounted) {
|
||||
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() {
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
void removeListeners(NativeVideoPlayerController controller) {
|
||||
controller.onPlaybackPositionChanged.removeListener(onPlaybackPositionChanged);
|
||||
controller.onPlaybackStatusChanged.removeListener(onPlaybackStatusChanged);
|
||||
controller.onPlaybackReady.removeListener(onPlaybackReady);
|
||||
controller.onPlaybackEnded.removeListener(onPlaybackEnded);
|
||||
}
|
||||
|
||||
void initController(NativeVideoPlayerController nc) async {
|
||||
if (controller.value != null || !context.mounted) {
|
||||
return;
|
||||
}
|
||||
ref.read(videoPlayerControlsProvider.notifier).reset();
|
||||
ref.read(videoPlaybackValueProvider.notifier).reset();
|
||||
|
||||
final source = await videoSource;
|
||||
if (source == null || !context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
nc.onPlaybackPositionChanged.addListener(onPlaybackPositionChanged);
|
||||
nc.onPlaybackStatusChanged.addListener(onPlaybackStatusChanged);
|
||||
nc.onPlaybackReady.addListener(onPlaybackReady);
|
||||
nc.onPlaybackEnded.addListener(onPlaybackEnded);
|
||||
|
||||
unawaited(
|
||||
nc.loadVideoSource(source).catchError((error) {
|
||||
log.severe('Error loading video source: $error');
|
||||
}),
|
||||
);
|
||||
final loopVideo = ref.read(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.loopVideo);
|
||||
unawaited(nc.setLoop(!asset.isMotionPhoto && loopVideo));
|
||||
|
||||
controller.value = nc;
|
||||
Timer(const Duration(milliseconds: 200), checkIfBuffering);
|
||||
}
|
||||
|
||||
Size? videoContextSize(double? videoAspectRatio, BuildContext? context) {
|
||||
Size? videoContextSize;
|
||||
if (videoAspectRatio == null || context == null) {
|
||||
return null;
|
||||
}
|
||||
final contextAspectRatio = context.width / context.height;
|
||||
if (videoAspectRatio > contextAspectRatio) {
|
||||
videoContextSize = Size(context.width, context.width / aspectRatio.value!);
|
||||
} else {
|
||||
videoContextSize = Size(context.height * aspectRatio.value!, context.height);
|
||||
}
|
||||
return videoContextSize;
|
||||
}
|
||||
|
||||
ref.listen(currentAssetNotifier, (_, value) {
|
||||
final playerController = controller.value;
|
||||
if (playerController != null && value != asset) {
|
||||
removeListeners(playerController);
|
||||
}
|
||||
|
||||
if (value != null) {
|
||||
isVisible.value = _isCurrentAsset(value, asset);
|
||||
}
|
||||
final curAsset = currentAsset.value;
|
||||
if (curAsset == asset) {
|
||||
return;
|
||||
}
|
||||
|
||||
final imageToVideo = curAsset != null && !curAsset.isVideo;
|
||||
|
||||
// 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: [
|
||||
// Hide thumbnail once video is visible to avoid it showing in background when zooming out on video.
|
||||
if (!isVisible.value || controller.value == null) Center(child: image),
|
||||
if (aspectRatio.value != null && !isCasting && isCurrent)
|
||||
Visibility.maintain(
|
||||
visible: isVisible.value,
|
||||
child: PhotoView.customChild(
|
||||
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 (showControls) const Center(child: VideoViewerControls()),
|
||||
],
|
||||
),
|
||||
);
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_videoSource = _createSource();
|
||||
}
|
||||
|
||||
Future<void> _onPauseChange(
|
||||
BuildContext context,
|
||||
NativeVideoPlayerController controller,
|
||||
Debouncer seekDebouncer,
|
||||
bool isPaused,
|
||||
) async {
|
||||
if (!context.mounted) {
|
||||
@override
|
||||
void didUpdateWidget(NativeVideoViewer oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
|
||||
if (widget.isCurrent == oldWidget.isCurrent || _controller == null) return;
|
||||
|
||||
if (!widget.isCurrent) {
|
||||
_loadTimer?.cancel();
|
||||
_notifier.pause();
|
||||
return;
|
||||
}
|
||||
|
||||
// Make sure the last seek is complete before pausing or playing
|
||||
// Otherwise, `onPlaybackPositionChanged` can receive outdated events
|
||||
if (seekDebouncer.isActive) {
|
||||
await seekDebouncer.drain();
|
||||
}
|
||||
// Prevent unnecessary loading when swiping between assets.
|
||||
_loadTimer = Timer(const Duration(milliseconds: 200), _loadVideo);
|
||||
}
|
||||
|
||||
try {
|
||||
if (isPaused) {
|
||||
await controller.pause();
|
||||
} else {
|
||||
await controller.play();
|
||||
}
|
||||
} catch (error) {
|
||||
log.severe('Error pausing or playing video: $error');
|
||||
@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 {
|
||||
if (videoAsset.hasLocal && videoAsset.livePhotoVideoId == null) {
|
||||
final id = videoAsset is LocalAsset ? videoAsset.id : (videoAsset as RemoteAsset).localId!;
|
||||
final file = await StorageRepository().getFileForAsset(id);
|
||||
if (!mounted) return null;
|
||||
|
||||
if (file == null) {
|
||||
throw Exception('No file found for the video');
|
||||
}
|
||||
|
||||
// Pass a file:// URI so Android's Uri.parse doesn't
|
||||
// interpret characters like '#' as fragment identifiers.
|
||||
return VideoSource.init(
|
||||
path: CurrentPlatform.isAndroid ? file.uri.toString() : file.path,
|
||||
type: VideoSourceType.file,
|
||||
);
|
||||
}
|
||||
|
||||
final remoteId = (videoAsset as RemoteAsset).id;
|
||||
|
||||
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
|
||||
final isOriginalVideo = ref.read(settingsProvider).get<bool>(Setting.loadOriginalVideo);
|
||||
final String postfixUrl = isOriginalVideo ? 'original' : 'video/playback';
|
||||
final String videoUrl = videoAsset.livePhotoVideoId != null
|
||||
? '$serverEndpoint/assets/${videoAsset.livePhotoVideoId}/$postfixUrl'
|
||||
: '$serverEndpoint/assets/$remoteId/$postfixUrl';
|
||||
|
||||
return VideoSource.init(path: videoUrl, type: VideoSourceType.network, headers: ApiService.getRequestHeaders());
|
||||
} catch (error) {
|
||||
_log.severe('Error creating video source for asset ${videoAsset.name}: $error');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
void _onPlaybackReady() async {
|
||||
if (!mounted || !widget.isCurrent) return;
|
||||
|
||||
_notifier.onNativePlaybackReady();
|
||||
|
||||
// onPlaybackReady may be called multiple times, usually when more data
|
||||
// loads. If this is not the first time that the player has become ready, we
|
||||
// should not autoplay.
|
||||
if (_isVideoReady) return;
|
||||
|
||||
setState(() => _isVideoReady = true);
|
||||
|
||||
if (ref.read(assetViewerProvider).showingDetails) return;
|
||||
|
||||
final autoPlayVideo = AppSetting.get(Setting.autoPlayVideo);
|
||||
if (autoPlayVideo) await _notifier.play();
|
||||
}
|
||||
|
||||
void _onPlaybackEnded() {
|
||||
if (!mounted) return;
|
||||
|
||||
_notifier.onNativePlaybackEnded();
|
||||
|
||||
if (_controller?.playbackInfo?.status == PlaybackStatus.stopped) {
|
||||
ref.read(isPlayingMotionVideoProvider.notifier).playing = false;
|
||||
}
|
||||
}
|
||||
|
||||
void _onPlaybackPositionChanged() {
|
||||
if (!mounted) return;
|
||||
_notifier.onNativePositionChanged();
|
||||
}
|
||||
|
||||
void _onPlaybackStatusChanged() {
|
||||
if (!mounted) return;
|
||||
_notifier.onNativeStatusChanged();
|
||||
}
|
||||
|
||||
void _removeListeners() {
|
||||
_controller?.onPlaybackPositionChanged.removeListener(_onPlaybackPositionChanged);
|
||||
_controller?.onPlaybackStatusChanged.removeListener(_onPlaybackStatusChanged);
|
||||
_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(
|
||||
nc.loadVideoSource(source).catchError((error) {
|
||||
_log.severe('Error loading video source: $error');
|
||||
}),
|
||||
);
|
||||
final loopVideo = ref.read(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.loopVideo);
|
||||
await _notifier.setLoop(!widget.asset.isMotionPhoto && loopVideo);
|
||||
await _notifier.setVolume(1);
|
||||
}
|
||||
|
||||
void _initController(NativeVideoPlayerController nc) {
|
||||
if (_controller != null || !mounted) return;
|
||||
|
||||
_notifier.attachController(nc);
|
||||
|
||||
nc.onPlaybackPositionChanged.addListener(_onPlaybackPositionChanged);
|
||||
nc.onPlaybackStatusChanged.addListener(_onPlaybackStatusChanged);
|
||||
nc.onPlaybackReady.addListener(_onPlaybackReady);
|
||||
nc.onPlaybackEnded.addListener(_onPlaybackEnded);
|
||||
|
||||
_controller = nc;
|
||||
|
||||
if (widget.isCurrent) _loadVideo();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Prevent the provider from being disposed whilst the widget is alive.
|
||||
ref.listen(videoPlayerProvider(widget.asset.heroTag), (_, __) {});
|
||||
|
||||
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
Center(child: widget.image),
|
||||
if (!isCasting)
|
||||
Visibility.maintain(
|
||||
visible: _isVideoReady,
|
||||
child: NativeVideoPlayerView(onViewReady: _initController),
|
||||
),
|
||||
if (widget.showControls) Center(child: VideoViewerControls(asset: widget.asset)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +1,26 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/models/cast/cast_manager_state.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.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/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
|
||||
import 'package:immich_mobile/providers/cast.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||
import 'package:immich_mobile/utils/hooks/timer_hook.dart';
|
||||
import 'package:immich_mobile/widgets/asset_viewer/center_play_button.dart';
|
||||
import 'package:immich_mobile/widgets/common/delayed_loading_indicator.dart';
|
||||
|
||||
class VideoViewerControls extends HookConsumerWidget {
|
||||
final BaseAsset asset;
|
||||
final Duration hideTimerDuration;
|
||||
|
||||
const VideoViewerControls({super.key, this.hideTimerDuration = const Duration(seconds: 5)});
|
||||
const VideoViewerControls({super.key, required this.asset, this.hideTimerDuration = const Duration(seconds: 5)});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final assetIsVideo = ref.watch(currentAssetNotifier.select((asset) => asset != null && asset.isVideo));
|
||||
bool showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
|
||||
final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails));
|
||||
if (showingDetails) {
|
||||
showControls = false;
|
||||
}
|
||||
final VideoPlaybackState state = ref.watch(videoPlaybackValueProvider.select((value) => value.state));
|
||||
final videoPlayerName = asset.heroTag;
|
||||
final assetIsVideo = asset.isVideo;
|
||||
final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls && !s.showingDetails));
|
||||
final status = ref.watch(videoPlayerProvider(videoPlayerName).select((value) => value.status));
|
||||
|
||||
final cast = ref.watch(castProvider);
|
||||
|
||||
@@ -32,14 +29,14 @@ class VideoViewerControls extends HookConsumerWidget {
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
final state = ref.read(videoPlaybackValueProvider).state;
|
||||
final status = ref.read(videoPlayerProvider(videoPlayerName)).status;
|
||||
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
final showBuffering = state == VideoPlaybackState.buffering && !cast.isCasting;
|
||||
final showBuffering = status == VideoPlaybackStatus.buffering && !cast.isCasting;
|
||||
|
||||
/// Shows the controls and starts the timer to hide them
|
||||
void showControlsAndStartHideTimer() {
|
||||
@@ -47,9 +44,11 @@ class VideoViewerControls extends HookConsumerWidget {
|
||||
ref.read(assetViewerProvider.notifier).setControls(true);
|
||||
}
|
||||
|
||||
// When we change position, show or hide timer
|
||||
ref.listen(videoPlayerControlsProvider.select((v) => v.position), (previous, next) {
|
||||
showControlsAndStartHideTimer();
|
||||
// When playback starts, reset the hide timer
|
||||
ref.listen(videoPlayerProvider(videoPlayerName).select((v) => v.status), (previous, next) {
|
||||
if (next == VideoPlaybackStatus.playing) {
|
||||
hideTimer.reset();
|
||||
}
|
||||
});
|
||||
|
||||
/// Toggles between playing and pausing depending on the state of the video
|
||||
@@ -57,34 +56,30 @@ class VideoViewerControls extends HookConsumerWidget {
|
||||
showControlsAndStartHideTimer();
|
||||
|
||||
if (cast.isCasting) {
|
||||
if (cast.castState == CastState.playing) {
|
||||
ref.read(castProvider.notifier).pause();
|
||||
} else if (cast.castState == CastState.paused) {
|
||||
ref.read(castProvider.notifier).play();
|
||||
} else if (cast.castState == CastState.idle) {
|
||||
// resend the play command since its finished
|
||||
final asset = ref.read(currentAssetNotifier);
|
||||
if (asset == null) {
|
||||
return;
|
||||
}
|
||||
// ref.read(castProvider.notifier).loadMedia(asset, true);
|
||||
switch (cast.castState) {
|
||||
case CastState.playing:
|
||||
ref.read(castProvider.notifier).pause();
|
||||
case CastState.paused:
|
||||
ref.read(castProvider.notifier).play();
|
||||
default:
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (state == VideoPlaybackState.playing) {
|
||||
ref.read(videoPlayerControlsProvider.notifier).pause();
|
||||
} else if (state == VideoPlaybackState.completed) {
|
||||
ref.read(videoPlayerControlsProvider.notifier).restart();
|
||||
} else {
|
||||
ref.read(videoPlayerControlsProvider.notifier).play();
|
||||
final notifier = ref.read(videoPlayerProvider(videoPlayerName).notifier);
|
||||
switch (status) {
|
||||
case VideoPlaybackStatus.playing:
|
||||
notifier.pause();
|
||||
case VideoPlaybackStatus.completed:
|
||||
notifier.restart();
|
||||
default:
|
||||
notifier.play();
|
||||
}
|
||||
}
|
||||
|
||||
void toggleControlsVisibility() {
|
||||
if (showBuffering) {
|
||||
return;
|
||||
}
|
||||
if (showBuffering) return;
|
||||
|
||||
if (showControls) {
|
||||
ref.read(assetViewerProvider.notifier).setControls(false);
|
||||
} else {
|
||||
@@ -105,9 +100,9 @@ class VideoViewerControls extends HookConsumerWidget {
|
||||
CenterPlayButton(
|
||||
backgroundColor: Colors.black54,
|
||||
iconColor: Colors.white,
|
||||
isFinished: state == VideoPlaybackState.completed,
|
||||
isFinished: status == VideoPlaybackStatus.completed,
|
||||
isPlaying:
|
||||
state == VideoPlaybackState.playing || (cast.isCasting && cast.castState == CastState.playing),
|
||||
status == VideoPlaybackStatus.playing || (cast.isCasting && cast.castState == CastState.playing),
|
||||
show: assetIsVideo && showControls,
|
||||
onPressed: togglePlay,
|
||||
),
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.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/bottom_bar.widget.dart';
|
||||
|
||||
class ViewerBottomAppBar extends ConsumerWidget {
|
||||
@@ -9,24 +8,12 @@ class ViewerBottomAppBar extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
double opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity));
|
||||
final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
|
||||
|
||||
if (!showControls) {
|
||||
opacity = 0.0;
|
||||
}
|
||||
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: const Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [AssetStackRow(), ViewerBottomBar()],
|
||||
),
|
||||
),
|
||||
child: AnimatedOpacity(opacity: opacity, duration: Durations.short2, child: const 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/extensions/build_context_extensions.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/setting.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
@@ -21,7 +21,7 @@ class ViewerKebabMenu extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final asset = ref.watch(currentAssetNotifier);
|
||||
final asset = ref.watch(assetViewerProvider.select((s) => s.currentAsset));
|
||||
if (asset == null) {
|
||||
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/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/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/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/readonly_mode.provider.dart';
|
||||
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||
@@ -22,7 +21,7 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final asset = ref.watch(currentAssetNotifier);
|
||||
final asset = ref.watch(assetViewerProvider.select((s) => s.currentAsset));
|
||||
if (asset == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
@@ -35,16 +34,13 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
||||
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
|
||||
|
||||
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) {
|
||||
ref.watch(albumActivityProvider(album.id, asset.id));
|
||||
}
|
||||
|
||||
if (!showControls) {
|
||||
opacity = 0.0;
|
||||
}
|
||||
final showingControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
|
||||
double opacity = ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity)) * (showingControls ? 1 : 0);
|
||||
|
||||
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/duration_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/timeline/constants.dart';
|
||||
import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart';
|
||||
|
||||
@@ -13,12 +13,14 @@ class DriftMemoryCard extends StatelessWidget {
|
||||
final RemoteAsset asset;
|
||||
final String title;
|
||||
final bool showTitle;
|
||||
final bool isCurrent;
|
||||
final Function()? onVideoEnded;
|
||||
|
||||
const DriftMemoryCard({
|
||||
required this.asset,
|
||||
required this.title,
|
||||
required this.showTitle,
|
||||
this.isCurrent = false,
|
||||
this.onVideoEnded,
|
||||
super.key,
|
||||
});
|
||||
@@ -37,33 +39,35 @@ class DriftMemoryCard extends StatelessWidget {
|
||||
SizedBox.expand(child: _BlurredBackdrop(asset: asset)),
|
||||
LayoutBuilder(
|
||||
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
|
||||
BoxFit fit = BoxFit.contain;
|
||||
if (asset.width != null && asset.height != null) {
|
||||
final aspectRatio = asset.width! / asset.height!;
|
||||
final phoneAspectRatio = constraints.maxWidth / constraints.maxHeight;
|
||||
// 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
|
||||
fit = BoxFit.cover;
|
||||
}
|
||||
}
|
||||
|
||||
if (asset.isImage) {
|
||||
return FullImage(asset, fit: fit, size: const Size(double.infinity, double.infinity));
|
||||
} else {
|
||||
return SizedBox(
|
||||
width: context.width,
|
||||
height: context.height,
|
||||
if (asset.isImage) return FullImage(asset, fit: fit, size: const Size(double.infinity, double.infinity));
|
||||
|
||||
return Center(
|
||||
child: AspectRatio(
|
||||
aspectRatio: r,
|
||||
child: NativeVideoViewer(
|
||||
key: ValueKey(asset.id),
|
||||
asset: asset,
|
||||
isCurrent: isCurrent,
|
||||
showControls: false,
|
||||
playbackDelayFactor: 2,
|
||||
image: FullImage(asset, size: Size(context.width, context.height), fit: BoxFit.contain),
|
||||
image: FullImage(asset, size: context.sizeData, fit: BoxFit.contain),
|
||||
),
|
||||
);
|
||||
}
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
if (showTitle)
|
||||
|
||||
@@ -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/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';
|
||||
|
||||
class AssetViewerState {
|
||||
@@ -68,6 +70,12 @@ class AssetViewerState {
|
||||
class AssetViewerStateNotifier extends Notifier<AssetViewerState> {
|
||||
@override
|
||||
AssetViewerState build() {
|
||||
ref.listen(_watchedCurrentAssetProvider, (_, next) {
|
||||
final updated = next.valueOrNull;
|
||||
if (updated != null) {
|
||||
state = state.copyWith(currentAsset: updated);
|
||||
}
|
||||
});
|
||||
return const AssetViewerState();
|
||||
}
|
||||
|
||||
@@ -75,10 +83,8 @@ class AssetViewerStateNotifier extends Notifier<AssetViewerState> {
|
||||
state = const AssetViewerState();
|
||||
}
|
||||
|
||||
void setAsset(BaseAsset? asset) {
|
||||
if (asset == state.currentAsset) {
|
||||
return;
|
||||
}
|
||||
void setAsset(BaseAsset asset) {
|
||||
if (asset == state.currentAsset) return;
|
||||
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);
|
||||
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 _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);
|
||||
}
|
||||
}
|
||||
200
mobile/lib/providers/asset_viewer/video_player_provider.dart
Normal file
200
mobile/lib/providers/asset_viewer/video_player_provider.dart
Normal file
@@ -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/services/asset.service.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_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/user.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
@@ -123,7 +123,7 @@ class ActionNotifier extends Notifier<void> {
|
||||
Set<BaseAsset> _getAssets(ActionSource source) {
|
||||
return switch (source) {
|
||||
ActionSource.timeline => ref.read(multiSelectProvider).selectedAssets,
|
||||
ActionSource.viewer => switch (ref.read(currentAssetNotifier)) {
|
||||
ActionSource.viewer => switch (ref.read(assetViewerProvider).currentAsset) {
|
||||
BaseAsset asset => {asset},
|
||||
null => const {},
|
||||
},
|
||||
@@ -307,7 +307,10 @@ class ActionNotifier extends Notifier<void> {
|
||||
// does not update the currentAsset which means
|
||||
// the exif provider will not be refreshed automatically
|
||||
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);
|
||||
@@ -409,7 +412,6 @@ class ActionNotifier extends Notifier<void> {
|
||||
if (source == ActionSource.viewer) {
|
||||
final updatedParent = await _assetService.getRemoteAsset(assets.first.id);
|
||||
if (updatedParent != null) {
|
||||
ref.read(currentAssetNotifier.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: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';
|
||||
|
||||
final currentAssetNotifier = AutoDisposeNotifierProvider<CurrentAssetNotifier, BaseAsset?>(CurrentAssetNotifier.new);
|
||||
|
||||
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);
|
||||
final assetExifProvider = FutureProvider.autoDispose.family<ExifInfo?, BaseAsset>((ref, asset) {
|
||||
return ref.watch(assetServiceProvider).getExif(asset);
|
||||
});
|
||||
|
||||
@@ -25,6 +25,7 @@ class FileMediaRepository {
|
||||
type: AssetType.image,
|
||||
createdAt: entity.createDateTime,
|
||||
updatedAt: entity.modifiedDateTime,
|
||||
playbackStyle: AssetPlaybackStyle.image,
|
||||
isEdited: false,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import 'dart:io';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/entities/album.entity.dart';
|
||||
import 'package:immich_mobile/entities/android_device_asset.entity.dart';
|
||||
@@ -17,6 +18,7 @@ import 'package:immich_mobile/infrastructure/entities/device_asset.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/exif.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/store.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
|
||||
@@ -33,7 +35,7 @@ import 'package:isar/isar.dart';
|
||||
// ignore: import_rule_photo_manager
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
|
||||
const int targetVersion = 22;
|
||||
const int targetVersion = 23;
|
||||
|
||||
Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async {
|
||||
final hasVersion = Store.tryGet(StoreKey.version) != null;
|
||||
@@ -99,6 +101,10 @@ Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async {
|
||||
}
|
||||
}
|
||||
|
||||
if (version < 23 && Store.isBetaTimelineEnabled) {
|
||||
await _populateLocalAssetPlaybackStyle(drift);
|
||||
}
|
||||
|
||||
if (version < 22 && !Store.isBetaTimelineEnabled) {
|
||||
await Store.put(StoreKey.needBetaMigration, true);
|
||||
}
|
||||
@@ -392,6 +398,52 @@ Future<void> migrateStoreToIsar(Isar db, Drift drift) async {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _populateLocalAssetPlaybackStyle(Drift db) async {
|
||||
try {
|
||||
final nativeApi = NativeSyncApi();
|
||||
|
||||
final albums = await nativeApi.getAlbums();
|
||||
for (final album in albums) {
|
||||
final assets = await nativeApi.getAssetsForAlbum(album.id);
|
||||
await db.batch((batch) {
|
||||
for (final asset in assets) {
|
||||
batch.update(
|
||||
db.localAssetEntity,
|
||||
LocalAssetEntityCompanion(playbackStyle: Value(_toPlaybackStyle(asset.playbackStyle))),
|
||||
where: (t) => t.id.equals(asset.id),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
final trashedAssetMap = await nativeApi.getTrashedAssets();
|
||||
for (final assets in trashedAssetMap.values) {
|
||||
await db.batch((batch) {
|
||||
for (final asset in assets) {
|
||||
batch.update(
|
||||
db.trashedLocalAssetEntity,
|
||||
TrashedLocalAssetEntityCompanion(playbackStyle: Value(_toPlaybackStyle(asset.playbackStyle))),
|
||||
where: (t) => t.id.equals(asset.id),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
dPrint(() => "[MIGRATION] Successfully populated playbackStyle for local and trashed assets");
|
||||
} catch (error) {
|
||||
dPrint(() => "[MIGRATION] Error while populating playbackStyle: $error");
|
||||
}
|
||||
}
|
||||
|
||||
AssetPlaybackStyle _toPlaybackStyle(PlatformAssetPlaybackStyle style) => switch (style) {
|
||||
PlatformAssetPlaybackStyle.unknown => AssetPlaybackStyle.unknown,
|
||||
PlatformAssetPlaybackStyle.image => AssetPlaybackStyle.image,
|
||||
PlatformAssetPlaybackStyle.video => AssetPlaybackStyle.video,
|
||||
PlatformAssetPlaybackStyle.imageAnimated => AssetPlaybackStyle.imageAnimated,
|
||||
PlatformAssetPlaybackStyle.livePhoto => AssetPlaybackStyle.livePhoto,
|
||||
PlatformAssetPlaybackStyle.videoLooping => AssetPlaybackStyle.videoLooping,
|
||||
};
|
||||
|
||||
class _DeviceAsset {
|
||||
final String assetId;
|
||||
final List<int>? hash;
|
||||
|
||||
@@ -333,7 +333,7 @@ class BottomGalleryBar extends ConsumerWidget {
|
||||
padding: const EdgeInsets.only(top: 40.0),
|
||||
child: Column(
|
||||
children: [
|
||||
if (asset.isVideo) const VideoControls(),
|
||||
if (asset.isVideo) VideoControls(videoPlayerName: asset.id.toString()),
|
||||
BottomNavigationBar(
|
||||
elevation: 0.0,
|
||||
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/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/video_player_controls_provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
|
||||
import 'package:immich_mobile/providers/cast.provider.dart';
|
||||
import 'package:immich_mobile/utils/hooks/timer_hook.dart';
|
||||
import 'package:immich_mobile/widgets/asset_viewer/center_play_button.dart';
|
||||
import 'package:immich_mobile/widgets/common/delayed_loading_indicator.dart';
|
||||
|
||||
class CustomVideoPlayerControls extends HookConsumerWidget {
|
||||
final String videoId;
|
||||
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
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final assetIsVideo = ref.watch(currentAssetProvider.select((asset) => asset != null && asset.isVideo));
|
||||
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);
|
||||
|
||||
@@ -28,14 +32,14 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
final state = ref.read(videoPlaybackValueProvider).state;
|
||||
final s = ref.read(videoPlayerProvider(videoId)).status;
|
||||
|
||||
// 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;
|
||||
}
|
||||
});
|
||||
final showBuffering = state == VideoPlaybackState.buffering && !cast.isCasting;
|
||||
final showBuffering = status == VideoPlaybackStatus.buffering && !cast.isCasting;
|
||||
|
||||
/// Shows the controls and starts the timer to hide them
|
||||
void showControlsAndStartHideTimer() {
|
||||
@@ -43,9 +47,11 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
|
||||
ref.read(showControlsProvider.notifier).show = true;
|
||||
}
|
||||
|
||||
// When we change position, show or hide timer
|
||||
ref.listen(videoPlayerControlsProvider.select((v) => v.position), (previous, next) {
|
||||
showControlsAndStartHideTimer();
|
||||
// When playback starts, reset the hide timer
|
||||
ref.listen(videoPlayerProvider(videoId).select((v) => v.status), (previous, next) {
|
||||
if (next == VideoPlaybackStatus.playing) {
|
||||
hideTimer.reset();
|
||||
}
|
||||
});
|
||||
|
||||
/// Toggles between playing and pausing depending on the state of the video
|
||||
@@ -68,12 +74,13 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
if (state == VideoPlaybackState.playing) {
|
||||
ref.read(videoPlayerControlsProvider.notifier).pause();
|
||||
} else if (state == VideoPlaybackState.completed) {
|
||||
ref.read(videoPlayerControlsProvider.notifier).restart();
|
||||
final notifier = ref.read(videoPlayerProvider(videoId).notifier);
|
||||
if (status == VideoPlaybackStatus.playing) {
|
||||
notifier.pause();
|
||||
} else if (status == VideoPlaybackStatus.completed) {
|
||||
notifier.restart();
|
||||
} else {
|
||||
ref.read(videoPlayerControlsProvider.notifier).play();
|
||||
notifier.play();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,9 +99,9 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
|
||||
child: CenterPlayButton(
|
||||
backgroundColor: Colors.black54,
|
||||
iconColor: Colors.white,
|
||||
isFinished: state == VideoPlaybackState.completed,
|
||||
isFinished: status == VideoPlaybackStatus.completed,
|
||||
isPlaying:
|
||||
state == VideoPlaybackState.playing || (cast.isCasting && cast.castState == CastState.playing),
|
||||
status == VideoPlaybackStatus.playing || (cast.isCasting && cast.castState == CastState.playing),
|
||||
show: assetIsVideo && showControls,
|
||||
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/widgets/asset_viewer/video_position.dart';
|
||||
|
||||
/// The video controls for the [videoPlayerControlsProvider]
|
||||
/// The video controls for the [videoPlayerProvider]
|
||||
class VideoControls extends ConsumerWidget {
|
||||
const VideoControls({super.key});
|
||||
final String videoPlayerName;
|
||||
|
||||
const VideoControls({super.key, required this.videoPlayerName});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isPortrait = context.orientation == Orientation.portrait;
|
||||
return isPortrait
|
||||
? const VideoPosition()
|
||||
: const Padding(padding: EdgeInsets.symmetric(horizontal: 60.0), child: VideoPosition());
|
||||
? VideoPosition(videoPlayerName: videoPlayerName)
|
||||
: 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:hooks_riverpod/hooks_riverpod.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_value_provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
|
||||
import 'package:immich_mobile/providers/cast.provider.dart';
|
||||
import 'package:immich_mobile/widgets/asset_viewer/formatted_duration.dart';
|
||||
|
||||
class VideoPosition extends HookConsumerWidget {
|
||||
const VideoPosition({super.key});
|
||||
final String videoPlayerName;
|
||||
|
||||
const VideoPosition({super.key, required this.videoPlayerName});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
@@ -18,7 +19,7 @@ class VideoPosition extends HookConsumerWidget {
|
||||
|
||||
final (position, duration) = isCasting
|
||||
? 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);
|
||||
return duration == Duration.zero
|
||||
@@ -44,13 +45,13 @@ class VideoPosition extends HookConsumerWidget {
|
||||
activeColor: Colors.white,
|
||||
inactiveColor: whiteOpacity75,
|
||||
onChangeStart: (value) {
|
||||
final state = ref.read(videoPlaybackValueProvider).state;
|
||||
wasPlaying.value = state != VideoPlaybackState.paused;
|
||||
ref.read(videoPlayerControlsProvider.notifier).pause();
|
||||
final status = ref.read(videoPlayerProvider(videoPlayerName)).status;
|
||||
wasPlaying.value = status != VideoPlaybackStatus.paused;
|
||||
ref.read(videoPlayerProvider(videoPlayerName).notifier).pause();
|
||||
},
|
||||
onChangeEnd: (value) {
|
||||
if (wasPlaying.value) {
|
||||
ref.read(videoPlayerControlsProvider.notifier).play();
|
||||
ref.read(videoPlayerProvider(videoPlayerName).notifier).play();
|
||||
}
|
||||
},
|
||||
onChanged: (value) {
|
||||
@@ -61,10 +62,7 @@ class VideoPosition extends HookConsumerWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
ref.read(videoPlayerControlsProvider.notifier).position = seekToDuration;
|
||||
|
||||
// This immediately updates the slider position without waiting for the video to update
|
||||
ref.read(videoPlaybackValueProvider.notifier).position = seekToDuration;
|
||||
ref.read(videoPlayerProvider(videoPlayerName).notifier).seekTo(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/widgets/asset_grid/thumbnail_placeholder.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/routing/router.dart';
|
||||
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
|
||||
@@ -34,9 +33,6 @@ class MemoryLane extends HookConsumerWidget {
|
||||
if (memories[memoryIndex].assets.isNotEmpty) {
|
||||
final asset = memories[memoryIndex].assets[0];
|
||||
ref.read(currentAssetProvider.notifier).set(asset);
|
||||
if (asset.isVideo || asset.isMotionPhoto) {
|
||||
ref.read(videoPlaybackValueProvider.notifier).reset();
|
||||
}
|
||||
}
|
||||
context.pushRoute(MemoryRoute(memories: memories, memoryIndex: memoryIndex));
|
||||
},
|
||||
|
||||
@@ -420,7 +420,11 @@ class PhotoViewCoreState extends State<PhotoViewCore>
|
||||
|
||||
Widget _buildChild() {
|
||||
return widget.hasCustomChild
|
||||
? widget.customChild!
|
||||
? SizedBox(
|
||||
width: scaleBoundaries.childSize.width * scale,
|
||||
height: scaleBoundaries.childSize.height * scale,
|
||||
child: widget.customChild!,
|
||||
)
|
||||
: Image(
|
||||
key: widget.heroAttributes?.tag != null ? ObjectKey(widget.heroAttributes!.tag) : null,
|
||||
image: widget.imageProvider!,
|
||||
@@ -428,7 +432,7 @@ class PhotoViewCoreState extends State<PhotoViewCore>
|
||||
gaplessPlayback: widget.gaplessPlayback ?? false,
|
||||
filterQuality: widget.filterQuality,
|
||||
width: scaleBoundaries.childSize.width * scale,
|
||||
fit: BoxFit.cover,
|
||||
fit: BoxFit.contain,
|
||||
isAntiAlias: widget.filterQuality == FilterQuality.high,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1241,8 +1241,8 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "."
|
||||
ref: e132bc3
|
||||
resolved-ref: e132bc3ecc6a6d8fc2089d96f849c8a13129500e
|
||||
ref: "0cc59ab"
|
||||
resolved-ref: "0cc59abad8216d4bb26d959252c6aca1a8d05ef9"
|
||||
url: "https://github.com/immich-app/native_video_player"
|
||||
source: git
|
||||
version: "1.3.1"
|
||||
|
||||
@@ -57,7 +57,7 @@ dependencies:
|
||||
native_video_player:
|
||||
git:
|
||||
url: https://github.com/immich-app/native_video_player
|
||||
ref: 'e132bc3'
|
||||
ref: '0cc59ab'
|
||||
network_info_plus: ^6.1.3
|
||||
octo_image: ^2.1.0
|
||||
openapi:
|
||||
|
||||
4
mobile/test/drift/main/generated/schema.dart
generated
4
mobile/test/drift/main/generated/schema.dart
generated
@@ -23,6 +23,7 @@ import 'schema_v17.dart' as v17;
|
||||
import 'schema_v18.dart' as v18;
|
||||
import 'schema_v19.dart' as v19;
|
||||
import 'schema_v20.dart' as v20;
|
||||
import 'schema_v21.dart' as v21;
|
||||
|
||||
class GeneratedHelper implements SchemaInstantiationHelper {
|
||||
@override
|
||||
@@ -68,6 +69,8 @@ class GeneratedHelper implements SchemaInstantiationHelper {
|
||||
return v19.DatabaseAtV19(db);
|
||||
case 20:
|
||||
return v20.DatabaseAtV20(db);
|
||||
case 21:
|
||||
return v21.DatabaseAtV21(db);
|
||||
default:
|
||||
throw MissingSchemaException(version, versions);
|
||||
}
|
||||
@@ -94,5 +97,6 @@ class GeneratedHelper implements SchemaInstantiationHelper {
|
||||
18,
|
||||
19,
|
||||
20,
|
||||
21,
|
||||
];
|
||||
}
|
||||
|
||||
8545
mobile/test/drift/main/generated/schema_v21.dart
generated
Normal file
8545
mobile/test/drift/main/generated/schema_v21.dart
generated
Normal file
File diff suppressed because it is too large
Load Diff
2
mobile/test/fixtures/asset.stub.dart
vendored
2
mobile/test/fixtures/asset.stub.dart
vendored
@@ -64,6 +64,7 @@ abstract final class LocalAssetStub {
|
||||
type: AssetType.image,
|
||||
createdAt: DateTime(2025),
|
||||
updatedAt: DateTime(2025, 2),
|
||||
playbackStyle: AssetPlaybackStyle.image,
|
||||
isEdited: false,
|
||||
);
|
||||
|
||||
@@ -73,6 +74,7 @@ abstract final class LocalAssetStub {
|
||||
type: AssetType.image,
|
||||
createdAt: DateTime(2000),
|
||||
updatedAt: DateTime(20021),
|
||||
playbackStyle: AssetPlaybackStyle.image,
|
||||
isEdited: false,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -194,6 +194,7 @@ void main() {
|
||||
latitude: 37.7749,
|
||||
longitude: -122.4194,
|
||||
adjustmentTime: DateTime(2026, 1, 2),
|
||||
playbackStyle: AssetPlaybackStyle.image,
|
||||
isEdited: false,
|
||||
);
|
||||
|
||||
@@ -243,6 +244,7 @@ void main() {
|
||||
cloudId: 'cloud-id-123',
|
||||
latitude: 37.7749,
|
||||
longitude: -122.4194,
|
||||
playbackStyle: AssetPlaybackStyle.image,
|
||||
isEdited: false,
|
||||
);
|
||||
|
||||
@@ -281,6 +283,7 @@ void main() {
|
||||
createdAt: DateTime(2025, 1, 1),
|
||||
updatedAt: DateTime(2025, 1, 2),
|
||||
cloudId: null, // No cloudId
|
||||
playbackStyle: AssetPlaybackStyle.image,
|
||||
isEdited: false,
|
||||
);
|
||||
|
||||
@@ -323,6 +326,7 @@ void main() {
|
||||
cloudId: 'cloud-id-livephoto',
|
||||
latitude: 37.7749,
|
||||
longitude: -122.4194,
|
||||
playbackStyle: AssetPlaybackStyle.image,
|
||||
isEdited: false,
|
||||
);
|
||||
|
||||
|
||||
@@ -155,6 +155,7 @@ abstract final class TestUtils {
|
||||
width: width,
|
||||
height: height,
|
||||
orientation: orientation,
|
||||
playbackStyle: domain.AssetPlaybackStyle.image,
|
||||
isEdited: false,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ class MediumFactory {
|
||||
type: type ?? AssetType.image,
|
||||
createdAt: createdAt ?? DateTime.fromMillisecondsSinceEpoch(random.nextInt(1000000000)),
|
||||
updatedAt: updatedAt ?? DateTime.fromMillisecondsSinceEpoch(random.nextInt(1000000000)),
|
||||
playbackStyle: AssetPlaybackStyle.image,
|
||||
isEdited: false,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ LocalAsset createLocalAsset({
|
||||
createdAt: createdAt ?? DateTime.now(),
|
||||
updatedAt: updatedAt ?? DateTime.now(),
|
||||
isFavorite: isFavorite,
|
||||
playbackStyle: AssetPlaybackStyle.image,
|
||||
isEdited: false,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"@oazapfts/runtime": "^1.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.10.13",
|
||||
"@types/node": "^24.10.14",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"repository": {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "2.5.6",
|
||||
"description": "Monorepo for Immich",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.30.0+sha512.2b5753de015d480eeb88f5b5b61e0051f05b4301808a82ec8b840c9d2adf7748eb352c83f5c1593ca703ff1017295bc3fdd3119abb9686efc96b9fcb18200937",
|
||||
"packageManager": "pnpm@10.30.3+sha512.c961d1e0a2d8e354ecaa5166b822516668b7f44cb5bd95122d590dd81922f606f5473b6d23ec4a5be05e7fcd18e8488d47d978bbe981872f1145d06e9a740017",
|
||||
"engines": {
|
||||
"pnpm": ">=10.0.0"
|
||||
}
|
||||
|
||||
1836
pnpm-lock.yaml
generated
1836
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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 \
|
||||
CI=1 \
|
||||
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 \
|
||||
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
|
||||
ENV NODE_ENV=production \
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# 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 \
|
||||
CI=1 \
|
||||
COREPACK_HOME=/tmp \
|
||||
COREPACK_HOME=/tmp \
|
||||
PNPM_HOME=/buildcache/pnpm-store
|
||||
|
||||
RUN npm install --global corepack@latest && \
|
||||
|
||||
@@ -57,8 +57,7 @@
|
||||
"@opentelemetry/semantic-conventions": "^1.34.0",
|
||||
"@react-email/components": "^0.5.0",
|
||||
"@react-email/render": "^1.1.2",
|
||||
"@socket.io/postgres-adapter": "^0.5.0",
|
||||
"@types/pg": "^8.16.0",
|
||||
"@socket.io/redis-adapter": "^8.3.0",
|
||||
"ajv": "^8.17.1",
|
||||
"archiver": "^7.0.0",
|
||||
"async-lock": "^1.4.0",
|
||||
@@ -110,7 +109,6 @@
|
||||
"sharp": "^0.34.5",
|
||||
"sirv": "^3.0.0",
|
||||
"socket.io": "^4.8.1",
|
||||
"socket.io-adapter": "^2.5.6",
|
||||
"tailwindcss-preset-email": "^1.4.0",
|
||||
"thumbhash": "^0.1.1",
|
||||
"transformation-matrix": "^3.1.0",
|
||||
@@ -138,7 +136,7 @@
|
||||
"@types/luxon": "^3.6.2",
|
||||
"@types/mock-fs": "^4.13.1",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/node": "^24.10.13",
|
||||
"@types/node": "^24.10.14",
|
||||
"@types/nodemailer": "^7.0.0",
|
||||
"@types/picomatch": "^4.0.0",
|
||||
"@types/pngjs": "^6.0.5",
|
||||
|
||||
@@ -5,9 +5,8 @@ import cookieParser from 'cookie-parser';
|
||||
import { existsSync } from 'node:fs';
|
||||
import sirv from 'sirv';
|
||||
import { excludePaths, serverVersion } from 'src/constants';
|
||||
import { SocketIoAdapter } from 'src/enum';
|
||||
import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.service';
|
||||
import { createWebSocketAdapter } from 'src/middleware/websocket.adapter';
|
||||
import { WebSocketAdapter } from 'src/middleware/websocket.adapter';
|
||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { bootstrapTelemetry } from 'src/repositories/telemetry.repository';
|
||||
@@ -26,7 +25,6 @@ export async function configureExpress(
|
||||
{
|
||||
permitSwaggerWrite = true,
|
||||
ssr,
|
||||
socketIoAdapter,
|
||||
}: {
|
||||
/**
|
||||
* Whether to allow swagger module to write to the specs.json
|
||||
@@ -38,10 +36,6 @@ export async function configureExpress(
|
||||
* Service to use for server-side rendering
|
||||
*/
|
||||
ssr: typeof ApiService | typeof MaintenanceWorkerService;
|
||||
/**
|
||||
* Override the Socket.IO adapter. If not specified, uses the adapter from config.
|
||||
*/
|
||||
socketIoAdapter?: SocketIoAdapter;
|
||||
},
|
||||
) {
|
||||
const configRepository = app.get(ConfigRepository);
|
||||
@@ -61,7 +55,7 @@ export async function configureExpress(
|
||||
}
|
||||
|
||||
app.setGlobalPrefix('api', { exclude: excludePaths });
|
||||
app.useWebSocketAdapter(await createWebSocketAdapter(app, socketIoAdapter));
|
||||
app.useWebSocketAdapter(new WebSocketAdapter(app));
|
||||
|
||||
useSwagger(app, { write: configRepository.isDev() && permitSwaggerWrite });
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import { DatabaseBackupController } from 'src/controllers/database-backup.contro
|
||||
import { DownloadController } from 'src/controllers/download.controller';
|
||||
import { DuplicateController } from 'src/controllers/duplicate.controller';
|
||||
import { FaceController } from 'src/controllers/face.controller';
|
||||
import { InternalController } from 'src/controllers/internal.controller';
|
||||
import { JobController } from 'src/controllers/job.controller';
|
||||
import { LibraryController } from 'src/controllers/library.controller';
|
||||
import { MaintenanceController } from 'src/controllers/maintenance.controller';
|
||||
@@ -52,7 +51,6 @@ export const controllers = [
|
||||
DownloadController,
|
||||
DuplicateController,
|
||||
FaceController,
|
||||
InternalController,
|
||||
JobController,
|
||||
LibraryController,
|
||||
MaintenanceController,
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import { Body, Controller, NotFoundException, Post, Req } from '@nestjs/common';
|
||||
import { ApiExcludeController } from '@nestjs/swagger';
|
||||
import { Request } from 'express';
|
||||
import { AppRestartEvent, EventRepository } from 'src/repositories/event.repository';
|
||||
|
||||
const LOCALHOST_ADDRESSES = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
|
||||
|
||||
@ApiExcludeController()
|
||||
@Controller('internal')
|
||||
export class InternalController {
|
||||
constructor(private eventRepository: EventRepository) {}
|
||||
|
||||
@Post('restart')
|
||||
async restart(@Req() req: Request, @Body() dto: AppRestartEvent): Promise<void> {
|
||||
const remoteAddress = req.socket.remoteAddress;
|
||||
if (!remoteAddress || !LOCALHOST_ADDRESSES.has(remoteAddress)) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
await this.eventRepository.emit('AppRestart', dto);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import { IsEnum, IsInt, IsString, Matches } from 'class-validator';
|
||||
import { ImmichEnvironment, LogFormat, LogLevel, SocketIoAdapter } from 'src/enum';
|
||||
import { ImmichEnvironment, LogFormat, LogLevel } from 'src/enum';
|
||||
import { IsIPRange, Optional, ValidateBoolean } from 'src/validation';
|
||||
|
||||
// TODO import from sql-tools once the swagger plugin supports external enums
|
||||
@@ -149,11 +149,6 @@ export class EnvDto {
|
||||
@Optional()
|
||||
IMMICH_WORKERS_EXCLUDE?: string;
|
||||
|
||||
@IsEnum(SocketIoAdapter)
|
||||
@Optional()
|
||||
@Transform(({ value }) => (value ? String(value).toLowerCase().trim() : value))
|
||||
IMMICH_SOCKETIO_ADAPTER?: SocketIoAdapter;
|
||||
|
||||
@IsString()
|
||||
@Optional()
|
||||
DB_DATABASE_NAME?: string;
|
||||
|
||||
@@ -518,11 +518,6 @@ export enum ImmichTelemetry {
|
||||
Job = 'job',
|
||||
}
|
||||
|
||||
export enum SocketIoAdapter {
|
||||
BroadcastChannel = 'broadcastchannel',
|
||||
Postgres = 'postgres',
|
||||
}
|
||||
|
||||
export enum ExifOrientation {
|
||||
Horizontal = 1,
|
||||
MirrorHorizontal = 2,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user