Compare commits

..

14 Commits

Author SHA1 Message Date
mertalev
f039672a2a android improvements 2025-08-13 22:16:43 -04:00
mertalev
3100702e93 better scrolling 2025-08-13 18:04:50 -04:00
mertalev
c988342de1 platform thumbhash 2025-08-13 18:04:50 -04:00
mertalev
84462560e3 cancel in image stream completer 2025-08-13 00:15:27 -04:00
mertalev
f931060670 image provider improvements 2025-08-13 00:10:55 -04:00
Brandon Wees
0d60199514 fix(mobile): newest/oldest album sort (#20743)
* fix(mobile): newest/oldest album sort

* chore: use sqlite to determine album asset timestamps

* Fix missing future

Co-authored-by: Alex <alex.tran1502@gmail.com>

* fix: async handling of sort

* chore: tests

* chore: code review changes

* fix: use created at for newest asset

* fix: use localDateTime for sorting

* chore: cleanup

* chore: use final

* feat: loading indicator

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-08-12 14:46:50 -05:00
Alex
54960157c0 chore: backup info card styling tweak (#20799)
* chore: backup info card styling tweak

* pr feedback
2025-08-12 16:08:31 +00:00
waclaw66
244d097d01 fix(mobile): enable person age pluralization (#20881)
Enable person age pluralization
2025-08-12 14:55:47 +00:00
renovate[bot]
adb55f3726 fix(deps): update machine-learning (#19803)
* fix(deps): update machine-learning

* typing fixes

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com>
2025-08-11 18:07:49 -04:00
Mirek
5d2777a5c6 feat: format date and time in /admin/users/ -> Profile section (#20811)
Matches the format used in the user settings page.

Added a formatting function in utils.
2025-08-11 16:50:34 -05:00
Alex
24db881c14 feat: swipe to delete album (#20765) 2025-08-11 16:49:53 -05:00
Alex
f09bed9ad2 fix: age info cut off (#20872) 2025-08-11 16:42:16 -05:00
Mert
e29cc66361 docs: vectorchord migration instructions, deprecation log on startup (#20867)
* deprecation log, migration docs

* update tests

* fix info boxes
2025-08-11 16:50:48 -04:00
Brandon Wees
669b765662 feat: edit image in beta timeline (#20709)
* feat: edit image in beta timeline

* delete album notifier pull

* feat: sync local after saving image

* feat: queue asset for manual upload after saving

* chore: clarify PlatformException catch
2025-08-11 15:01:31 -05:00
77 changed files with 4262 additions and 2334 deletions

View File

@@ -27,3 +27,102 @@ docker image prune
[watchtower]: https://containrrr.dev/watchtower/
[breaking]: https://github.com/immich-app/immich/discussions?discussions_q=label%3Achangelog%3Abreaking-change+sort%3Adate_created
[releases]: https://github.com/immich-app/immich/releases
## Migrating to VectorChord
:::info
If you deploy Immich using Docker Compose, see `ghcr.io/immich-app/postgres` in the `docker-compose.yml` file and have not explicitly set the `DB_VECTOR_EXTENSION` environmental variable, your Immich database is already using VectorChord and this section does not apply to you.
:::
:::important
If you do not deploy Immich using Docker Compose and see a deprecation warning for pgvecto.rs on server startup, you should refer to the maintainers of the Immich distribution for guidance (if using a turnkey solution) or adapt the instructions for your specific setup.
:::
Immich has migrated off of the deprecated pgvecto.rs database extension to its successor, [VectorChord](https://github.com/tensorchord/VectorChord), which comes with performance improvements in almost every aspect. This section will guide you on how to make this change in a Docker Compose setup.
Before making any changes, please [back up your database](/docs/administration/backup-and-restore). While every effort has been made to make this migration as smooth as possible, theres always a chance that something can go wrong.
After making a backup, please modify your `docker-compose.yml` file with the following information.
```diff
[...]
database:
container_name: immich_postgres
- image: docker.io/tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:739cdd626151ff1f796dc95a6591b55a714f341c737e27f045019ceabf8e8c52
+ image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0
environment:
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_USER: ${DB_USERNAME}
POSTGRES_DB: ${DB_DATABASE_NAME}
POSTGRES_INITDB_ARGS: '--data-checksums'
+ # Uncomment the DB_STORAGE_TYPE: 'HDD' var if your database isn't stored on SSDs
+ # DB_STORAGE_TYPE: 'HDD'
volumes:
# Do not edit the next line. If you want to change the database storage location on your system, edit the value of DB_DATA_LOCATION in the .env file
- ${DB_DATA_LOCATION}:/var/lib/postgresql/data
- healthcheck:
- test: >-
- pg_isready --dbname="$${POSTGRES_DB}" --username="$${POSTGRES_USER}" || exit 1;
- Chksum="$$(psql --dbname="$${POSTGRES_DB}" --username="$${POSTGRES_USER}" --tuples-only --no-align
- --command='SELECT COALESCE(SUM(checksum_failures), 0) FROM pg_stat_database')";
- echo "checksum failure count is $$Chksum";
- [ "$$Chksum" = '0' ] || exit 1
- interval: 5m
- start_interval: 30s
- start_period: 5m
- command: >-
- postgres
- -c shared_preload_libraries=vectors.so
- -c 'search_path="$$user", public, vectors'
- -c logging_collector=on
- -c max_wal_size=2GB
- -c shared_buffers=512MB
- -c wal_compression=on
+ shm_size: 128mb
restart: always
[...]
```
:::important
If you deviated from the defaults of pg14 or pgvectors0.2.0, you must adjust the pg major version and pgvecto.rs version. If you are still using the default `docker.io/tensorchord/pgvecto-rs:pg14-v0.2.0` image, you can just follow the changes above. For example, if the previous image is `docker.io/tensorchord/pgvecto-rs:pg16-v0.3.0`, the new image should be `ghcr.io/immich-app/postgres:16-vectorchord0.3.0-pgvectors0.3.0` instead of the image specified in the diff.
:::
After making these changes, you can start Immich as normal. Immich will make some changes to the DB during startup, which can take seconds to minutes to finish, depending on hardware and library size. In particular, its normal for the server logs to be seemingly stuck at `Reindexing clip_index` and `Reindexing face_index`for some time if you have over 100k assets in Immich and/or Immich is on a relatively weak server. If you see these logs and there are no errors, just give it time.
:::danger
After switching to VectorChord, you should not downgrade Immich below 1.133.0.
:::
Please dont hesitate to contact us on [GitHub](https://github.com/immich-app/immich/discussions) or [Discord](https://discord.immich.app/) if you encounter migration issues.
### VectorChord FAQ
#### I have a separate PostgreSQL instance shared with multiple services. How can I switch to VectorChord?
Please see the [standalone PostgreSQL documentation](/docs/administration/postgres-standalone#migrating-to-vectorchord) for migration instructions. The migration path will be different depending on whether youre currently using pgvecto.rs or pgvector, as well as whether Immich has superuser DB permissions.
#### Why are so many lines removed from the `docker-compose.yml` file? Does this mean the health check is removed?
These lines are now incorporated into the image itself along with some additional tuning.
#### What does this change mean for my existing DB backups?
The new DB image includes pgvector and pgvecto.rs in addition to VectorChord, so you can use this image to restore from existing backups that used either of these extensions. However, backups made after switching to VectorChord require an image containing VectorChord to restore successfully.
#### Do I still need pgvecto.rs installed after migrating to VectorChord?
pgvecto.rs only needs to be available during the migration, or if you need to restore from a backup that used pgvecto.rs. For a leaner DB and a smaller image, you can optionally switch to an image variant that doesnt have pgvecto.rs installed after youve performed the migration and started Immich: `ghcr.io/immich-app/postgres:14-vectorchord0.4.3`, changing the PostgreSQL version as appropriate.
#### Why does it matter whether my database is on an SSD or an HDD?
These storage mediums have different performance characteristics. As a result, the optimal settings for an SSD are not the same as those for an HDD. Either configuration is compatible with SSD and HDD, but using the right configuration will make Immich snappier. As a general tip, we recommend users store the database on an SSD whenever possible.
#### Can I use the new database image as a general PostgreSQL image outside of Immich?
Its a standard PostgreSQL container image that additionally contains the VectorChord, pgvector, and (optionally) pgvecto.rs extensions. If you were using the previous pgvecto.rs image for other purposes, you can similarly do so with this image.
#### If pgvecto.rs and pgvector still work, why should I switch to VectorChord?
VectorChord is faster, more stable, uses less RAM, and (with the settings Immich uses) offers higher-quality results than pgvector and pgvecto.rs. This translates to better search and facial recognition experiences. In addition, pgvecto.rs support will be dropped in the future, so changing it sooner will avoid disruption.

View File

@@ -1457,9 +1457,9 @@
"permission_onboarding_permission_limited": "Permission limited. To let Immich backup and manage your entire gallery collection, grant photo and video permissions in Settings.",
"permission_onboarding_request": "Immich requires permission to view your photos and videos.",
"person": "Person",
"person_age_months": "{months} months old",
"person_age_year_months": "1 year, {months} months old",
"person_age_years": "{years} years old",
"person_age_months": "{months, plural, one {# month} other {# months}} old",
"person_age_year_months": "1 year, {months, plural, one {# month} other {# months}} old",
"person_age_years": "{years, plural, other {# years}} old",
"person_birthdate": "Born on {date}",
"person_hidden": "{name}{hidden, select, true { (hidden)} other {}}",
"photo_shared_all_users": "Looks like you shared your photos with all users or you don't have any user to share with.",
@@ -1856,6 +1856,7 @@
"sort_created": "Date created",
"sort_items": "Number of items",
"sort_modified": "Date modified",
"sort_newest": "Newest photo",
"sort_oldest": "Oldest photo",
"sort_people_by_similarity": "Sort people by similarity",
"sort_recent": "Most recent photo",

View File

@@ -1,6 +1,6 @@
ARG DEVICE=cpu
FROM python:3.11-bookworm@sha256:ce3b954c9285a7a145cba620bae03db836ab890b6b9e0d05a3ca522ea00dfbc9 AS builder-cpu
FROM python:3.11-bookworm@sha256:c1239cb82bf08176c4c90421ab425a1696257b098d9ce21e68de9319c255a47d AS builder-cpu
FROM builder-cpu AS builder-openvino
@@ -59,7 +59,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
RUN apt-get update && apt-get install -y --no-install-recommends g++
COPY --from=ghcr.io/astral-sh/uv:latest@sha256:9653efd4380d5a0e5511e337dcfc3b8ba5bc4e6ea7fa3be7716598261d5503fa /uv /uvx /bin/
COPY --from=ghcr.io/astral-sh/uv:latest@sha256:67b2bcccdc103d608727d1b577e58008ef810f751ed324715eb60b3f0c040d30 /uv /uvx /bin/
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
@@ -68,11 +68,11 @@ RUN if [ "$DEVICE" = "rocm" ]; then \
uv pip install /opt/onnxruntime_rocm-*.whl; \
fi
FROM python:3.11-slim-bookworm@sha256:9e1912aab0a30bbd9488eb79063f68f42a68ab0946cbe98fecf197fe5b085506 AS prod-cpu
FROM python:3.11-slim-bookworm@sha256:0ce77749ac83174a31d5e107ce0cfa6b28a2fd6b0615e029d9d84b39c48976ee AS prod-cpu
ENV LD_PRELOAD=/usr/lib/libmimalloc.so.2
FROM python:3.11-slim-bookworm@sha256:9e1912aab0a30bbd9488eb79063f68f42a68ab0946cbe98fecf197fe5b085506 AS prod-openvino
FROM python:3.11-slim-bookworm@sha256:0ce77749ac83174a31d5e107ce0cfa6b28a2fd6b0615e029d9d84b39c48976ee AS prod-openvino
RUN apt-get update && \
apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \

View File

@@ -36,7 +36,7 @@ def to_numpy(img: Image.Image) -> NDArray[np.float32]:
def normalize(
img: NDArray[np.float32], mean: float | NDArray[np.float32], std: float | NDArray[np.float32]
) -> NDArray[np.float32]:
return np.divide(img - mean, std, dtype=np.float32)
return (img - mean) / std
def get_pil_resampling(resample: str) -> Image.Resampling:
@@ -58,11 +58,13 @@ def decode_pil(image_bytes: bytes | IO[bytes] | Image.Image) -> Image.Image:
def decode_cv2(image_bytes: NDArray[np.uint8] | bytes | Image.Image) -> NDArray[np.uint8]:
if isinstance(image_bytes, bytes):
image_bytes = decode_pil(image_bytes) # pillow is much faster than cv2
if isinstance(image_bytes, Image.Image):
return pil_to_cv2(image_bytes)
return image_bytes
match image_bytes:
case bytes() | memoryview() | bytearray():
return pil_to_cv2(decode_pil(image_bytes)) # pillow is much faster than cv2
case Image.Image():
return pil_to_cv2(image_bytes)
case _:
return image_bytes
def clean_text(text: str, canonicalize: bool = False) -> str:

View File

@@ -112,8 +112,4 @@ def has_profiling(obj: Any) -> TypeGuard[HasProfiling]:
return hasattr(obj, "profiling") and isinstance(obj.profiling, dict)
def is_ndarray(obj: Any, dtype: "type[np._DTypeScalar_co]") -> "TypeGuard[npt.NDArray[np._DTypeScalar_co]]":
return isinstance(obj, np.ndarray) and obj.dtype == dtype
T = TypeVar("T")

View File

@@ -12,6 +12,7 @@ dependencies = [
"gunicorn>=21.1.0",
"huggingface-hub>=0.20.1,<1.0",
"insightface>=0.7.3,<1.0",
"numpy<2",
"opencv-python-headless>=4.7.0.72,<5.0",
"orjson>=3.9.5",
"pillow>=9.5.0,<11.0",

3247
machine-learning/uv.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,3 @@
cmake_minimum_required(VERSION 3.10.2)
project("native_buffer")

View File

@@ -10,11 +10,11 @@ import com.bumptech.glide.module.AppGlideModule
@GlideModule
class AppGlideModule : AppGlideModule() {
override fun applyOptions(context: Context, builder: GlideBuilder) {
super.applyOptions(context, builder)
// disable caching as this is already done on the Flutter side
builder.setMemoryCache(MemoryCacheAdapter())
builder.setDiskCache(DiskCacheAdapter.Factory())
builder.setBitmapPool(BitmapPoolAdapter())
}
override fun applyOptions(context: Context, builder: GlideBuilder) {
super.applyOptions(context, builder)
// disable caching as this is already done on the Flutter side
builder.setMemoryCache(MemoryCacheAdapter())
builder.setDiskCache(DiskCacheAdapter.Factory())
builder.setBitmapPool(BitmapPoolAdapter())
}
}

View File

@@ -2,6 +2,7 @@ package app.alextran.immich
import android.os.Build
import android.os.ext.SdkExtensions
import androidx.annotation.NonNull
import app.alextran.immich.images.ThumbnailApi
import app.alextran.immich.images.ThumbnailsImpl
import app.alextran.immich.sync.NativeSyncApi
@@ -11,7 +12,7 @@ import io.flutter.embedding.android.FlutterFragmentActivity
import io.flutter.embedding.engine.FlutterEngine
class MainActivity : FlutterFragmentActivity() {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
flutterEngine.plugins.add(BackgroundServicePlugin())
flutterEngine.plugins.add(HttpSSLOptionsPlugin())
@@ -23,7 +24,7 @@ class MainActivity : FlutterFragmentActivity() {
} else {
NativeSyncApiImpl30(this)
}
NativeSyncApi.setUp(flutterEngine.dartExecutor.binaryMessenger, nativeSyncApiImpl)
ThumbnailApi.setUp(flutterEngine.dartExecutor.binaryMessenger, ThumbnailsImpl(this))
NativeSyncApi.setUp(flutterEngine.dartExecutor.binaryMessenger, nativeSyncApiImpl)
ThumbnailApi.setUp(flutterEngine.dartExecutor.binaryMessenger, ThumbnailsImpl(this))
}
}

View File

@@ -0,0 +1,164 @@
package app.alextran.immich.images;
// Copyright (c) 2023 Evan Wallace
// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
import java.nio.ByteBuffer;
// modified to use native allocations
public final class ThumbHash {
/**
* Decodes a ThumbHash to an RGBA image. RGB is not be premultiplied by A.
*
* @param hash The bytes of the ThumbHash.
* @return The width, height, and pixels of the rendered placeholder image.
*/
public static Image thumbHashToRGBA(byte[] hash) {
// Read the constants
int header24 = (hash[0] & 255) | ((hash[1] & 255) << 8) | ((hash[2] & 255) << 16);
int header16 = (hash[3] & 255) | ((hash[4] & 255) << 8);
float l_dc = (float) (header24 & 63) / 63.0f;
float p_dc = (float) ((header24 >> 6) & 63) / 31.5f - 1.0f;
float q_dc = (float) ((header24 >> 12) & 63) / 31.5f - 1.0f;
float l_scale = (float) ((header24 >> 18) & 31) / 31.0f;
boolean hasAlpha = (header24 >> 23) != 0;
float p_scale = (float) ((header16 >> 3) & 63) / 63.0f;
float q_scale = (float) ((header16 >> 9) & 63) / 63.0f;
boolean isLandscape = (header16 >> 15) != 0;
int lx = Math.max(3, isLandscape ? hasAlpha ? 5 : 7 : header16 & 7);
int ly = Math.max(3, isLandscape ? header16 & 7 : hasAlpha ? 5 : 7);
float a_dc = hasAlpha ? (float) (hash[5] & 15) / 15.0f : 1.0f;
float a_scale = (float) ((hash[5] >> 4) & 15) / 15.0f;
// Read the varying factors (boost saturation by 1.25x to compensate for quantization)
int ac_start = hasAlpha ? 6 : 5;
int ac_index = 0;
Channel l_channel = new Channel(lx, ly);
Channel p_channel = new Channel(3, 3);
Channel q_channel = new Channel(3, 3);
Channel a_channel = null;
ac_index = l_channel.decode(hash, ac_start, ac_index, l_scale);
ac_index = p_channel.decode(hash, ac_start, ac_index, p_scale * 1.25f);
ac_index = q_channel.decode(hash, ac_start, ac_index, q_scale * 1.25f);
if (hasAlpha) {
a_channel = new Channel(5, 5);
a_channel.decode(hash, ac_start, ac_index, a_scale);
}
float[] l_ac = l_channel.ac;
float[] p_ac = p_channel.ac;
float[] q_ac = q_channel.ac;
float[] a_ac = hasAlpha ? a_channel.ac : null;
// Decode using the DCT into RGB
float ratio = thumbHashToApproximateAspectRatio(hash);
int w = Math.round(ratio > 1.0f ? 32.0f : 32.0f * ratio);
int h = Math.round(ratio > 1.0f ? 32.0f / ratio : 32.0f);
int size = w * h * 4;
long pointer = ThumbnailsImpl.allocateNative(size);
ByteBuffer rgba = ThumbnailsImpl.wrapAsBuffer(pointer, size);
int cx_stop = Math.max(lx, hasAlpha ? 5 : 3);
int cy_stop = Math.max(ly, hasAlpha ? 5 : 3);
float[] fx = new float[cx_stop];
float[] fy = new float[cy_stop];
for (int y = 0, i = 0; y < h; y++) {
for (int x = 0; x < w; x++, i += 4) {
float l = l_dc, p = p_dc, q = q_dc, a = a_dc;
// Precompute the coefficients
for (int cx = 0; cx < cx_stop; cx++)
fx[cx] = (float) Math.cos(Math.PI / w * (x + 0.5f) * cx);
for (int cy = 0; cy < cy_stop; cy++)
fy[cy] = (float) Math.cos(Math.PI / h * (y + 0.5f) * cy);
// Decode L
for (int cy = 0, j = 0; cy < ly; cy++) {
float fy2 = fy[cy] * 2.0f;
for (int cx = cy > 0 ? 0 : 1; cx * ly < lx * (ly - cy); cx++, j++)
l += l_ac[j] * fx[cx] * fy2;
}
// Decode P and Q
for (int cy = 0, j = 0; cy < 3; cy++) {
float fy2 = fy[cy] * 2.0f;
for (int cx = cy > 0 ? 0 : 1; cx < 3 - cy; cx++, j++) {
float f = fx[cx] * fy2;
p += p_ac[j] * f;
q += q_ac[j] * f;
}
}
// Decode A
if (hasAlpha)
for (int cy = 0, j = 0; cy < 5; cy++) {
float fy2 = fy[cy] * 2.0f;
for (int cx = cy > 0 ? 0 : 1; cx < 5 - cy; cx++, j++)
a += a_ac[j] * fx[cx] * fy2;
}
// Convert to RGB
float b = l - 2.0f / 3.0f * p;
float r = (3.0f * l - b + q) / 2.0f;
float g = r - q;
rgba.put(i, (byte) Math.max(0, Math.round(255.0f * Math.min(1, r))));
rgba.put(i + 1, (byte) Math.max(0, Math.round(255.0f * Math.min(1, g))));
rgba.put(i + 2, (byte) Math.max(0, Math.round(255.0f * Math.min(1, b))));
rgba.put(i + 3, (byte) Math.max(0, Math.round(255.0f * Math.min(1, a))));
}
}
return new Image(w, h, pointer);
}
/**
* Extracts the approximate aspect ratio of the original image.
*
* @param hash The bytes of the ThumbHash.
* @return The approximate aspect ratio (i.e. width / height).
*/
public static float thumbHashToApproximateAspectRatio(byte[] hash) {
byte header = hash[3];
boolean hasAlpha = (hash[2] & 0x80) != 0;
boolean isLandscape = (hash[4] & 0x80) != 0;
int lx = isLandscape ? hasAlpha ? 5 : 7 : header & 7;
int ly = isLandscape ? header & 7 : hasAlpha ? 5 : 7;
return (float) lx / (float) ly;
}
public static final class Image {
public int width;
public int height;
public long pointer;
public Image(int width, int height, long pointer) {
this.width = width;
this.height = height;
this.pointer = pointer;
}
}
private static final class Channel {
int nx;
int ny;
float[] ac;
Channel(int nx, int ny) {
this.nx = nx;
this.ny = ny;
int n = 0;
for (int cy = 0; cy < ny; cy++)
for (int cx = cy > 0 ? 0 : 1; cx * ny < nx * (ny - cy); cx++)
n++;
ac = new float[n];
}
int decode(byte[] hash, int start, int index, float scale) {
for (int i = 0; i < ac.length; i++) {
int data = hash[start + (index >> 1)] >> ((index & 1) << 2);
ac[i] = ((float) (data & 15) / 7.5f - 1.0f) * scale;
index++;
}
return index;
}
}
}

View File

@@ -59,8 +59,9 @@ private open class ThumbnailsPigeonCodec : StandardMessageCodec() {
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface ThumbnailApi {
fun requestImage(assetId: String, requestId: Long, width: Long, height: Long, callback: (Result<Map<String, Long>>) -> Unit)
fun requestImage(assetId: String, requestId: Long, width: Long, height: Long, isVideo: Boolean, callback: (Result<Map<String, Long>>) -> Unit)
fun cancelImageRequest(requestId: Long)
fun getThumbhash(thumbhash: String, callback: (Result<Map<String, Long>>) -> Unit)
companion object {
/** The codec used by ThumbnailApi. */
@@ -80,7 +81,8 @@ interface ThumbnailApi {
val requestIdArg = args[1] as Long
val widthArg = args[2] as Long
val heightArg = args[3] as Long
api.requestImage(assetIdArg, requestIdArg, widthArg, heightArg) { result: Result<Map<String, Long>> ->
val isVideoArg = args[4] as Boolean
api.requestImage(assetIdArg, requestIdArg, widthArg, heightArg, isVideoArg) { result: Result<Map<String, Long>> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(ThumbnailsPigeonUtils.wrapError(error))
@@ -112,6 +114,26 @@ interface ThumbnailApi {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.ThumbnailApi.getThumbhash$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val thumbhashArg = args[0] as String
api.getThumbhash(thumbhashArg) { result: Result<Map<String, Long>> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(ThumbnailsPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(ThumbnailsPigeonUtils.wrapResult(data))
}
}
}
} else {
channel.setMessageHandler(null)
}
}
}
}
}

View File

@@ -18,8 +18,10 @@ import java.util.concurrent.Executors
import com.bumptech.glide.Glide
import com.bumptech.glide.Priority
import com.bumptech.glide.load.DecodeFormat
import java.util.Base64
import java.util.HashMap
import java.util.concurrent.CancellationException
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Future
data class Request(
@@ -32,9 +34,10 @@ data class Request(
class ThumbnailsImpl(context: Context) : ThumbnailApi {
private val ctx: Context = context.applicationContext
private val resolver: ContentResolver = ctx.contentResolver
private val requestThread = Executors.newSingleThreadExecutor()
private val threadPool =
Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors() / 2 + 1)
private val requestMap = HashMap<Long, Request>()
private val requestMap = ConcurrentHashMap<Long, Request>()
companion object {
val PROJECTION = arrayOf(
@@ -63,17 +66,35 @@ class ThumbnailsImpl(context: Context) : ThumbnailApi {
external fun wrapAsBuffer(address: Long, capacity: Int): ByteBuffer
}
override fun getThumbhash(thumbhash: String, callback: (Result<Map<String, Long>>) -> Unit) {
threadPool.execute {
try {
val bytes = Base64.getDecoder().decode(thumbhash)
val image = ThumbHash.thumbHashToRGBA(bytes)
val res = mapOf(
"pointer" to image.pointer,
"width" to image.width.toLong(),
"height" to image.height.toLong()
)
callback(Result.success(res))
} catch (e: Exception) {
callback(Result.failure(e))
}
}
}
override fun requestImage(
assetId: String,
requestId: Long,
width: Long,
height: Long,
isVideo: Boolean,
callback: (Result<Map<String, Long>>) -> Unit
) {
val signal = CancellationSignal()
val task = threadPool.submit {
try {
getThumbnailBufferInternal(assetId, width, height, callback, signal)
getThumbnailBufferInternal(assetId, width, height, isVideo, callback, signal)
} catch (e: Exception) {
when (e) {
is OperationCanceledException -> callback(CANCELLED)
@@ -84,7 +105,8 @@ class ThumbnailsImpl(context: Context) : ThumbnailApi {
requestMap.remove(requestId)
}
}
requestMap[requestId] = Request(requestId, task, signal, callback)
val request = Request(requestId, task, signal, callback)
requestMap[requestId] = request
}
override fun cancelImageRequest(requestId: Long) {
@@ -92,7 +114,12 @@ class ThumbnailsImpl(context: Context) : ThumbnailApi {
request.taskFuture.cancel(false)
request.cancellationSignal.cancel()
if (request.taskFuture.isCancelled) {
request.callback(CANCELLED)
requestThread.execute {
try {
request.callback(CANCELLED)
} catch (_: Exception) {
}
}
}
}
@@ -100,6 +127,7 @@ class ThumbnailsImpl(context: Context) : ThumbnailApi {
assetId: String,
width: Long,
height: Long,
isVideo: Boolean,
callback: (Result<Map<String, Long>>) -> Unit,
signal: CancellationSignal
) {
@@ -108,24 +136,14 @@ class ThumbnailsImpl(context: Context) : ThumbnailApi {
val targetHeight = height.toInt()
val id = assetId.toLong()
val cursor = resolver.query(URI, PROJECTION, SELECTION, arrayOf(assetId), null)
?: return callback(Result.failure(RuntimeException("Asset not found")))
signal.throwIfCanceled()
cursor.use { c ->
if (!c.moveToNext()) {
return callback(Result.failure(RuntimeException("Asset not found")))
}
val mediaType = c.getInt(1)
val bitmap = when (mediaType) {
MEDIA_TYPE_IMAGE -> decodeImage(id, targetWidth, targetHeight, signal)
MEDIA_TYPE_VIDEO -> decodeVideoThumbnail(id, targetWidth, targetHeight, signal)
else -> return callback(Result.failure(RuntimeException("Unsupported media type")))
}
processBitmap(bitmap, callback, signal)
val bitmap = if (isVideo) {
decodeVideoThumbnail(id, targetWidth, targetHeight, signal)
} else {
decodeImage(id, targetWidth, targetHeight, signal)
}
processBitmap(bitmap, callback, signal)
}
private fun processBitmap(
@@ -144,6 +162,7 @@ class ThumbnailsImpl(context: Context) : ThumbnailApi {
signal.throwIfCanceled()
val buffer = wrapAsBuffer(pointer, size)
bitmap.copyPixelsToBuffer(buffer)
bitmap.recycle()
signal.throwIfCanceled()
val res = mapOf(
"pointer" to pointer,

View File

@@ -3,7 +3,7 @@
archiveVersion = 1;
classes = {
};
objectVersion = 54;
objectVersion = 77;
objects = {
/* Begin PBXBuildFile section */
@@ -24,6 +24,7 @@
FAC6F89B2D287C890078CB2F /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = FAC6F8902D287C890078CB2F /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
FAC6F8B72D287F120078CB2F /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAC6F8B52D287F120078CB2F /* ShareViewController.swift */; };
FAC6F8B92D287F120078CB2F /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = FAC6F8B32D287F120078CB2F /* MainInterface.storyboard */; };
FEAFA8732E4D42F4001E47FE /* Thumbhash.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEAFA8722E4D42F4001E47FE /* Thumbhash.swift */; };
FED3B1962E253E9B0030FD97 /* ThumbnailsImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = FED3B1942E253E9B0030FD97 /* ThumbnailsImpl.swift */; };
FED3B1972E253E9B0030FD97 /* Thumbnails.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = FED3B1932E253E9B0030FD97 /* Thumbnails.g.swift */; };
/* End PBXBuildFile section */
@@ -104,6 +105,7 @@
FAC6F8B42D287F120078CB2F /* ShareExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ShareExtension.entitlements; sourceTree = "<group>"; };
FAC6F8B52D287F120078CB2F /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = "<group>"; };
FAC7416727DB9F5500C668D8 /* RunnerProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = RunnerProfile.entitlements; sourceTree = "<group>"; };
FEAFA8722E4D42F4001E47FE /* Thumbhash.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Thumbhash.swift; sourceTree = "<group>"; };
FED3B1932E253E9B0030FD97 /* Thumbnails.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Thumbnails.g.swift; sourceTree = "<group>"; };
FED3B1942E253E9B0030FD97 /* ThumbnailsImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThumbnailsImpl.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
@@ -121,8 +123,6 @@
/* Begin PBXFileSystemSynchronizedRootGroup section */
B2CF7F8C2DDE4EBB00744BF6 /* Sync */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = Sync;
sourceTree = "<group>";
};
@@ -266,6 +266,7 @@
FED3B1952E253E9B0030FD97 /* Images */ = {
isa = PBXGroup;
children = (
FEAFA8722E4D42F4001E47FE /* Thumbhash.swift */,
FED3B1932E253E9B0030FD97 /* Thumbnails.g.swift */,
FED3B1942E253E9B0030FD97 /* ThumbnailsImpl.swift */,
);
@@ -537,6 +538,7 @@
files = (
65F32F31299BD2F800CE9261 /* BackgroundServicePlugin.swift in Sources */,
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
FEAFA8732E4D42F4001E47FE /* Thumbhash.swift in Sources */,
FED3B1962E253E9B0030FD97 /* ThumbnailsImpl.swift in Sources */,
FED3B1972E253E9B0030FD97 /* Thumbnails.g.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,

View File

@@ -0,0 +1,225 @@
// Copyright (c) 2023 Evan Wallace
// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
import Foundation
// NOTE: Swift has an exponential-time type checker and compiling very simple
// expressions can easily take many seconds, especially when expressions involve
// numeric type constructors.
//
// This file deliberately breaks compound expressions up into separate variables
// to improve compile time even though this comes at the expense of readability.
// This is a known workaround for this deficiency in the Swift compiler.
//
// The following command is helpful when debugging Swift compile time issues:
//
// swiftc ThumbHash.swift -Xfrontend -debug-time-function-bodies
//
// These optimizations brought the compile time for this file from around 2.5
// seconds to around 250ms (10x faster).
// NOTE: Swift's debug-build performance of for-in loops over numeric ranges is
// really awful. Debug builds compile a very generic indexing iterator thing
// that makes many nested calls for every iteration, which makes debug-build
// performance crawl.
//
// This file deliberately avoids for-in loops that loop for more than a few
// times to improve debug-build run time even though this comes at the expense
// of readability. Similarly unsafe pointers are used instead of array getters
// to avoid unnecessary bounds checks, which have extra overhead in debug builds.
//
// These optimizations brought the run time to encode and decode 10 ThumbHashes
// in debug mode from 700ms to 70ms (10x faster).
// changed signature and allocation method to avoid automatic GC
func thumbHashToRGBA(hash: Data) -> (Int, Int, UnsafeMutableRawBufferPointer) {
// Read the constants
let h0 = UInt32(hash[0])
let h1 = UInt32(hash[1])
let h2 = UInt32(hash[2])
let h3 = UInt16(hash[3])
let h4 = UInt16(hash[4])
let header24 = h0 | (h1 << 8) | (h2 << 16)
let header16 = h3 | (h4 << 8)
let il_dc = header24 & 63
let ip_dc = (header24 >> 6) & 63
let iq_dc = (header24 >> 12) & 63
var l_dc = Float32(il_dc)
var p_dc = Float32(ip_dc)
var q_dc = Float32(iq_dc)
l_dc = l_dc / 63
p_dc = p_dc / 31.5 - 1
q_dc = q_dc / 31.5 - 1
let il_scale = (header24 >> 18) & 31
var l_scale = Float32(il_scale)
l_scale = l_scale / 31
let hasAlpha = (header24 >> 23) != 0
let ip_scale = (header16 >> 3) & 63
let iq_scale = (header16 >> 9) & 63
var p_scale = Float32(ip_scale)
var q_scale = Float32(iq_scale)
p_scale = p_scale / 63
q_scale = q_scale / 63
let isLandscape = (header16 >> 15) != 0
let lx16 = max(3, isLandscape ? hasAlpha ? 5 : 7 : header16 & 7)
let ly16 = max(3, isLandscape ? header16 & 7 : hasAlpha ? 5 : 7)
let lx = Int(lx16)
let ly = Int(ly16)
var a_dc = Float32(1)
var a_scale = Float32(1)
if hasAlpha {
let ia_dc = hash[5] & 15
let ia_scale = hash[5] >> 4
a_dc = Float32(ia_dc)
a_scale = Float32(ia_scale)
a_dc /= 15
a_scale /= 15
}
// Read the varying factors (boost saturation by 1.25x to compensate for quantization)
let ac_start = hasAlpha ? 6 : 5
var ac_index = 0
let decodeChannel = { (nx: Int, ny: Int, scale: Float32) -> [Float32] in
var ac: [Float32] = []
for cy in 0 ..< ny {
var cx = cy > 0 ? 0 : 1
while cx * ny < nx * (ny - cy) {
let iac = (hash[ac_start + (ac_index >> 1)] >> ((ac_index & 1) << 2)) & 15;
var fac = Float32(iac)
fac = (fac / 7.5 - 1) * scale
ac.append(fac)
ac_index += 1
cx += 1
}
}
return ac
}
let l_ac = decodeChannel(lx, ly, l_scale)
let p_ac = decodeChannel(3, 3, p_scale * 1.25)
let q_ac = decodeChannel(3, 3, q_scale * 1.25)
let a_ac = hasAlpha ? decodeChannel(5, 5, a_scale) : []
// Decode using the DCT into RGB
let ratio = thumbHashToApproximateAspectRatio(hash: hash)
let fw = round(ratio > 1 ? 32 : 32 * ratio)
let fh = round(ratio > 1 ? 32 / ratio : 32)
let w = Int(fw)
let h = Int(fh)
let pointer = UnsafeMutableRawBufferPointer.allocate(
byteCount: w * h * 4,
alignment: MemoryLayout<UInt8>.alignment
)
var rgba = pointer.baseAddress!.assumingMemoryBound(to: UInt8.self)
let cx_stop = max(lx, hasAlpha ? 5 : 3)
let cy_stop = max(ly, hasAlpha ? 5 : 3)
var fx = [Float32](repeating: 0, count: cx_stop)
var fy = [Float32](repeating: 0, count: cy_stop)
fx.withUnsafeMutableBytes { fx in
let fx = fx.baseAddress!.bindMemory(to: Float32.self, capacity: fx.count)
fy.withUnsafeMutableBytes { fy in
let fy = fy.baseAddress!.bindMemory(to: Float32.self, capacity: fy.count)
var y = 0
while y < h {
var x = 0
while x < w {
var l = l_dc
var p = p_dc
var q = q_dc
var a = a_dc
// Precompute the coefficients
var cx = 0
while cx < cx_stop {
let fw = Float32(w)
let fxx = Float32(x)
let fcx = Float32(cx)
fx[cx] = cos(Float32.pi / fw * (fxx + 0.5) * fcx)
cx += 1
}
var cy = 0
while cy < cy_stop {
let fh = Float32(h)
let fyy = Float32(y)
let fcy = Float32(cy)
fy[cy] = cos(Float32.pi / fh * (fyy + 0.5) * fcy)
cy += 1
}
// Decode L
var j = 0
cy = 0
while cy < ly {
var cx = cy > 0 ? 0 : 1
let fy2 = fy[cy] * 2
while cx * ly < lx * (ly - cy) {
l += l_ac[j] * fx[cx] * fy2
j += 1
cx += 1
}
cy += 1
}
// Decode P and Q
j = 0
cy = 0
while cy < 3 {
var cx = cy > 0 ? 0 : 1
let fy2 = fy[cy] * 2
while cx < 3 - cy {
let f = fx[cx] * fy2
p += p_ac[j] * f
q += q_ac[j] * f
j += 1
cx += 1
}
cy += 1
}
// Decode A
if hasAlpha {
j = 0
cy = 0
while cy < 5 {
var cx = cy > 0 ? 0 : 1
let fy2 = fy[cy] * 2
while cx < 5 - cy {
a += a_ac[j] * fx[cx] * fy2
j += 1
cx += 1
}
cy += 1
}
}
// Convert to RGB
var b = l - 2 / 3 * p
var r = (3 * l - b + q) / 2
var g = r - q
r = max(0, 255 * min(1, r))
g = max(0, 255 * min(1, g))
b = max(0, 255 * min(1, b))
a = max(0, 255 * min(1, a))
rgba[0] = UInt8(r)
rgba[1] = UInt8(g)
rgba[2] = UInt8(b)
rgba[3] = UInt8(a)
rgba = rgba.advanced(by: 4)
x += 1
}
y += 1
}
}
}
return (w, h, pointer)
}
func thumbHashToApproximateAspectRatio(hash: Data) -> Float32 {
let header = hash[3]
let hasAlpha = (hash[2] & 0x80) != 0
let isLandscape = (hash[4] & 0x80) != 0
let lx = isLandscape ? hasAlpha ? 5 : 7 : header & 7
let ly = isLandscape ? header & 7 : hasAlpha ? 5 : 7
return Float32(lx) / Float32(ly)
}

View File

@@ -70,8 +70,9 @@ class ThumbnailsPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable {
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
protocol ThumbnailApi {
func requestImage(assetId: String, requestId: Int64, width: Int64, height: Int64, completion: @escaping (Result<[String: Int64], Error>) -> Void)
func requestImage(assetId: String, requestId: Int64, width: Int64, height: Int64, isVideo: Bool, completion: @escaping (Result<[String: Int64], Error>) -> Void)
func cancelImageRequest(requestId: Int64) throws
func getThumbhash(thumbhash: String, completion: @escaping (Result<[String: Int64], Error>) -> Void)
}
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
@@ -88,7 +89,8 @@ class ThumbnailApiSetup {
let requestIdArg = args[1] as! Int64
let widthArg = args[2] as! Int64
let heightArg = args[3] as! Int64
api.requestImage(assetId: assetIdArg, requestId: requestIdArg, width: widthArg, height: heightArg) { result in
let isVideoArg = args[4] as! Bool
api.requestImage(assetId: assetIdArg, requestId: requestIdArg, width: widthArg, height: heightArg, isVideo: isVideoArg) { result in
switch result {
case .success(let res):
reply(wrapResult(res))
@@ -115,5 +117,22 @@ class ThumbnailApiSetup {
} else {
cancelImageRequestChannel.setMessageHandler(nil)
}
let getThumbhashChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.ThumbnailApi.getThumbhash\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
getThumbhashChannel.setMessageHandler { message, reply in
let args = message as! [Any?]
let thumbhashArg = args[0] as! String
api.getThumbhash(thumbhash: thumbhashArg) { result in
switch result {
case .success(let res):
reply(wrapResult(res))
case .failure(let error):
reply(wrapError(error))
}
}
}
} else {
getThumbhashChannel.setMessageHandler(nil)
}
}
}

View File

@@ -47,6 +47,16 @@ class ThumbnailApiImpl: ThumbnailApi {
return assetCache
}()
func getThumbhash(thumbhash: String, completion: @escaping (Result<[String : Int64], any Error>) -> Void) {
Self.processingQueue.async {
guard let data = Data(base64Encoded: thumbhash)
else { return completion(.failure(PigeonError(code: "", message: "Invalid base64 string: \(thumbhash)", details: nil)))}
let (width, height, pointer) = thumbHashToRGBA(hash: data)
completion(.success(["pointer": Int64(Int(bitPattern: pointer.baseAddress)), "width": Int64(width), "height": Int64(height)]))
}
}
func requestImage(assetId: String, requestId: Int64, width: Int64, height: Int64, completion: @escaping (Result<[String: Int64], any Error>) -> Void) {
let request = Request(callback: completion)
let item = DispatchWorkItem {

View File

@@ -1,35 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>AppGroupId</key>
<string>$(CUSTOM_GROUP_ID)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>IntentsSupported</key>
<array>
<string>INSendMessageIntent</string>
</array>
<key>NSExtensionActivationRule</key>
<string>SUBQUERY ( extensionItems, $extensionItem, SUBQUERY ( $extensionItem.attachments,
<dict>
<key>AppGroupId</key>
<string>$(CUSTOM_GROUP_ID)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>IntentsSupported</key>
<array>
<string>INSendMessageIntent</string>
</array>
<key>NSExtensionActivationRule</key>
<string>SUBQUERY ( extensionItems, $extensionItem, SUBQUERY ( $extensionItem.attachments,
$attachment, ( ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.file-url"
|| ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.image" || ANY
$attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.text" || ANY
$attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.movie" || ANY
$attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.url" ) ).@count &gt; 0
).@count &gt; 0 </string>
<key>PHSupportedMediaTypes</key>
<array>
<string>Video</string>
<string>Image</string>
</array>
</dict>
<key>NSExtensionMainStoryboard</key>
<string>MainInterface</string>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.share-services</string>
</dict>
</dict>
</plist>
<key>PHSupportedMediaTypes</key>
<array>
<string>Video</string>
<string>Image</string>
</array>
</dict>
<key>NSExtensionMainStoryboard</key>
<string>MainInterface</string>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.share-services</string>
</dict>
</dict>
</plist>

View File

@@ -28,7 +28,6 @@ const String kDownloadGroupLivePhoto = 'group_livephoto';
const int kTimelineNoneSegmentSize = 120;
const int kTimelineAssetLoadBatchSize = 1024;
const int kTimelineAssetLoadOppositeSize = 64;
const int kTimelineImageCacheMemory = 200 * 1024 * 1024;
// Widget keys
const String appShareGroupId = "group.app.immich.share";

View File

@@ -1,12 +1,12 @@
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
import 'package:immich_mobile/models/albums/album_search.model.dart';
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
import 'package:immich_mobile/utils/remote_album.utils.dart';
class RemoteAlbumService {
final DriftRemoteAlbumRepository _repository;
@@ -26,8 +26,21 @@ class RemoteAlbumService {
return _repository.get(albumId);
}
List<RemoteAlbum> sortAlbums(List<RemoteAlbum> albums, RemoteAlbumSortMode sortMode, {bool isReverse = false}) {
return sortMode.sortFn(albums, isReverse);
Future<List<RemoteAlbum>> sortAlbums(
List<RemoteAlbum> albums,
RemoteAlbumSortMode sortMode, {
bool isReverse = false,
}) async {
final List<RemoteAlbum> sorted = switch (sortMode) {
RemoteAlbumSortMode.created => albums.sortedBy((album) => album.createdAt),
RemoteAlbumSortMode.title => albums.sortedBy((album) => album.name),
RemoteAlbumSortMode.lastModified => albums.sortedBy((album) => album.updatedAt),
RemoteAlbumSortMode.assetCount => albums.sortedBy((album) => album.assetCount),
RemoteAlbumSortMode.mostRecent => await _sortByNewestAsset(albums),
RemoteAlbumSortMode.mostOldest => await _sortByOldestAsset(albums),
};
return (isReverse ? sorted.reversed : sorted).toList();
}
List<RemoteAlbum> searchAlbums(
@@ -143,4 +156,60 @@ class RemoteAlbumService {
Future<int> getCount() {
return _repository.getCount();
}
Future<List<RemoteAlbum>> _sortByNewestAsset(List<RemoteAlbum> albums) async {
// map album IDs to their newest asset dates
final Map<String, Future<DateTime?>> assetTimestampFutures = {};
for (final album in albums) {
assetTimestampFutures[album.id] = _repository.getNewestAssetTimestamp(album.id);
}
// await all database queries
final entries = await Future.wait(
assetTimestampFutures.entries.map((entry) async => MapEntry(entry.key, await entry.value)),
);
final assetTimestamps = Map.fromEntries(entries);
final sorted = albums.sorted((a, b) {
final aDate = assetTimestamps[a.id] ?? DateTime.fromMillisecondsSinceEpoch(0);
final bDate = assetTimestamps[b.id] ?? DateTime.fromMillisecondsSinceEpoch(0);
return aDate.compareTo(bDate);
});
return sorted;
}
Future<List<RemoteAlbum>> _sortByOldestAsset(List<RemoteAlbum> albums) async {
// map album IDs to their oldest asset dates
final Map<String, Future<DateTime?>> assetTimestampFutures = {
for (final album in albums) album.id: _repository.getOldestAssetTimestamp(album.id),
};
// await all database queries
final entries = await Future.wait(
assetTimestampFutures.entries.map((entry) async => MapEntry(entry.key, await entry.value)),
);
final assetTimestamps = Map.fromEntries(entries);
final sorted = albums.sorted((a, b) {
final aDate = assetTimestamps[a.id] ?? DateTime.fromMillisecondsSinceEpoch(0);
final bDate = assetTimestamps[b.id] ?? DateTime.fromMillisecondsSinceEpoch(0);
return aDate.compareTo(bDate);
});
return sorted.reversed.toList();
}
}
enum RemoteAlbumSortMode {
title("library_page_sort_title"),
assetCount("library_page_sort_asset_count"),
lastModified("library_page_sort_last_modified"),
created("library_page_sort_created"),
mostRecent("sort_newest"),
mostOldest("sort_oldest");
final String key;
const RemoteAlbumSortMode(this.key);
}

View File

@@ -32,3 +32,31 @@ class FastClampingScrollPhysics extends ClampingScrollPhysics {
damping: 80,
);
}
class ScrollUnawareScrollPhysics extends ScrollPhysics {
const ScrollUnawareScrollPhysics({super.parent});
@override
ScrollUnawareScrollPhysics applyTo(ScrollPhysics? ancestor) {
return ScrollUnawareScrollPhysics(parent: buildParent(ancestor));
}
@override
bool recommendDeferredLoading(double velocity, ScrollMetrics metrics, BuildContext context) {
return false;
}
}
class ScrollUnawareClampingScrollPhysics extends ClampingScrollPhysics {
const ScrollUnawareClampingScrollPhysics({super.parent});
@override
ScrollUnawareClampingScrollPhysics applyTo(ScrollPhysics? ancestor) {
return ScrollUnawareClampingScrollPhysics(parent: buildParent(ancestor));
}
@override
bool recommendDeferredLoading(double velocity, ScrollMetrics metrics, BuildContext context) {
return false;
}
}

View File

@@ -6,6 +6,7 @@ import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter/foundation.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/providers/image/cache/remote_image_cache_manager.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:ffi/ffi.dart';
@@ -29,36 +30,20 @@ abstract class ImageRequest {
return;
}
_isCancelled = true;
if (!kReleaseMode) {
debugPrint('Cancelling image request $requestId');
}
return _onCancelled();
}
void _onCancelled();
}
class LocalImageRequest extends ImageRequest {
final String localId;
final int width;
final int height;
LocalImageRequest({required this.localId, required ui.Size size})
: width = size.width.toInt(),
height = size.height.toInt();
@override
Future<ImageInfo?> load(ImageDecoderCallback decode, {double scale = 1.0}) async {
if (_isCancelled) {
return null;
}
final Map<String, int> info = await thumbnailApi.requestImage(
localId,
requestId: requestId,
width: width,
height: height,
);
Future<ui.FrameInfo?> _fromPlatformImage(Map<String, int> info) async {
final address = info['pointer'];
if (address == null) {
if (!kReleaseMode) {
debugPrint('Platform image request for $requestId was cancelled');
}
return null;
}
@@ -88,12 +73,75 @@ class LocalImageRequest extends ImageRequest {
return null;
}
final frame = await codec.getNextFrame();
return ImageInfo(image: frame.image, scale: scale);
return await codec.getNextFrame();
} finally {
malloc.free(pointer);
}
}
}
class ThumbhashImageRequest extends ImageRequest {
final String thumbhash;
ThumbhashImageRequest({required this.thumbhash});
@override
Future<ImageInfo?> load(ImageDecoderCallback decode, {double scale = 1.0}) async {
if (_isCancelled) {
return null;
}
Stopwatch? stopwatch;
if (!kReleaseMode) {
stopwatch = Stopwatch()..start();
}
final Map<String, int> info = await thumbnailApi.getThumbhash(thumbhash);
if (!kReleaseMode) {
stopwatch!.stop();
debugPrint('Thumbhash request $requestId took ${stopwatch.elapsedMilliseconds} ms');
}
final frame = await _fromPlatformImage(info);
return frame == null ? null : ImageInfo(image: frame.image, scale: scale);
}
@override
void _onCancelled() {}
}
class LocalImageRequest extends ImageRequest {
final String localId;
final int width;
final int height;
final AssetType assetType;
LocalImageRequest({required this.localId, required ui.Size size, required this.assetType})
: width = size.width.toInt(),
height = size.height.toInt();
@override
Future<ImageInfo?> load(ImageDecoderCallback decode, {double scale = 1.0}) async {
if (_isCancelled) {
return null;
}
Stopwatch? stopwatch;
if (!kReleaseMode) {
stopwatch = Stopwatch()..start();
}
final Map<String, int> info = await thumbnailApi.requestImage(
localId,
requestId: requestId,
width: width,
height: height,
isVideo: assetType == AssetType.video,
);
if (!kReleaseMode) {
stopwatch!.stop();
debugPrint('Local image request $requestId took ${stopwatch.elapsedMilliseconds} ms');
}
final frame = await _fromPlatformImage(info);
return frame == null ? null : ImageInfo(image: frame.image, scale: scale);
}
@override
Future<void> _onCancelled() {
@@ -104,7 +152,7 @@ class LocalImageRequest extends ImageRequest {
class RemoteImageRequest extends ImageRequest {
static final log = Logger('RemoteImageRequest');
static final cacheManager = RemoteImageCacheManager();
static final client = HttpClient();
static final client = HttpClient()..maxConnectionsPerHost = 32;
String uri;
Map<String, String> headers;
HttpClientRequest? _request;
@@ -117,29 +165,42 @@ class RemoteImageRequest extends ImageRequest {
return null;
}
try {
// The DB calls made by the cache manager are a *massive* bottleneck (6+ seconds) with high concurrency.
// Since it isn't possible to cancel these operations, we only prefer the cache when they can be avoided.
// The DB hit is left as a fallback for offline use.
final cachedFileBuffer = await _loadCachedFile(uri, inMemoryOnly: true);
if (cachedFileBuffer != null) {
return _decodeBuffer(cachedFileBuffer, decode, scale);
}
// TODO: the cache manager makes everything sequential with its DB calls and its operations cannot be cancelled,
// so it just makes things slower and more memory hungry. Even just saving files to disk
// for offline use adds too much overhead as these calls add up. We only prefer fetching from it when
// it can skip the DB call.
final cachedFileImage = await _loadCachedFile(uri, decode, scale, inMemoryOnly: true);
if (cachedFileImage != null) {
return cachedFileImage;
}
try {
Stopwatch? stopwatch;
if (!kReleaseMode) {
stopwatch = Stopwatch()..start();
}
final buffer = await _downloadImage(uri);
if (buffer == null || _isCancelled) {
if (buffer == null) {
return null;
}
if (!kReleaseMode) {
stopwatch!.stop();
debugPrint('Remote image download request $requestId took ${stopwatch.elapsedMilliseconds} ms');
}
return await _decodeBuffer(buffer, decode, scale);
} catch (e) {
if (e is HttpException && (e.message.endsWith('aborted') || e.message.startsWith('Connection closed'))) {
if (_isCancelled) {
if (!kReleaseMode) {
debugPrint('Remote image download request for $requestId was cancelled');
}
return null;
}
log.severe('Failed to load remote image', e);
final buffer = await _loadCachedFile(uri, inMemoryOnly: false);
if (buffer != null) {
return _decodeBuffer(buffer, decode, scale);
final cachedFileImage = await _loadCachedFile(uri, decode, scale, inMemoryOnly: false);
if (cachedFileImage != null) {
return cachedFileImage;
}
rethrow;
} finally {
_request = null;
@@ -147,45 +208,59 @@ class RemoteImageRequest extends ImageRequest {
}
Future<ImmutableBuffer?> _downloadImage(String url) async {
final request = _request = await client.getUrl(Uri.parse(url));
if (_isCancelled) {
return null;
}
final request = _request = await client.getUrl(Uri.parse(url));
if (_isCancelled) {
request.abort();
return _request = null;
}
final headers = ApiService.getRequestHeaders();
for (final entry in headers.entries) {
request.headers.set(entry.key, entry.value);
}
final response = await request.close();
if (_isCancelled) {
return null;
}
final bytes = await consolidateHttpClientResponseBytes(response);
_cacheFile(url, bytes);
if (_isCancelled) {
return null;
}
return await ImmutableBuffer.fromUint8List(bytes);
}
Future<void> _cacheFile(String url, Uint8List bytes) async {
try {
await cacheManager.putFile(url, bytes);
} catch (e) {
log.severe('Failed to cache image', e);
}
}
Future<ImmutableBuffer?> _loadCachedFile(String url, {required bool inMemoryOnly}) async {
Future<ImageInfo?> _loadCachedFile(
String url,
ImageDecoderCallback decode,
double scale, {
required bool inMemoryOnly,
}) async {
if (_isCancelled) {
return null;
}
final file = await (inMemoryOnly ? cacheManager.getFileFromMemory(url) : cacheManager.getFileFromCache(url));
if (_isCancelled || file == null) {
return null;
}
return await ImmutableBuffer.fromFilePath(file.file.path);
try {
final buffer = await ImmutableBuffer.fromFilePath(file.file.path);
return await _decodeBuffer(buffer, decode, scale);
} catch (e) {
log.severe('Failed to decode cached image', e);
_evictFile(url);
return null;
}
}
Future<void> _evictFile(String url) async {
try {
await cacheManager.removeFile(url);
} catch (e) {
log.severe('Failed to remove cached image', e);
}
}
Future<ImageInfo?> _decodeBuffer(ImmutableBuffer buffer, ImageDecoderCallback decode, scale) async {

View File

@@ -265,6 +265,28 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
}).watchSingleOrNull();
}
Future<DateTime?> getNewestAssetTimestamp(String albumId) {
final query = _db.remoteAlbumAssetEntity.selectOnly()
..where(_db.remoteAlbumAssetEntity.albumId.equals(albumId))
..addColumns([_db.remoteAssetEntity.localDateTime.max()])
..join([
innerJoin(_db.remoteAssetEntity, _db.remoteAssetEntity.id.equalsExp(_db.remoteAlbumAssetEntity.assetId)),
]);
return query.map((row) => row.read(_db.remoteAssetEntity.localDateTime.max())).getSingleOrNull();
}
Future<DateTime?> getOldestAssetTimestamp(String albumId) {
final query = _db.remoteAlbumAssetEntity.selectOnly()
..where(_db.remoteAlbumAssetEntity.albumId.equals(albumId))
..addColumns([_db.remoteAssetEntity.localDateTime.min()])
..join([
innerJoin(_db.remoteAssetEntity, _db.remoteAssetEntity.id.equalsExp(_db.remoteAlbumAssetEntity.assetId)),
]);
return query.map((row) => row.read(_db.remoteAssetEntity.localDateTime.min())).getSingleOrNull();
}
Future<int> getCount() {
return _db.managers.remoteAlbumEntity.count();
}

View File

@@ -71,8 +71,6 @@ Future<void> initApp() async {
}
}
PaintingBinding.instance.imageCache.maximumSizeBytes = kTimelineImageCacheMemory;
await DynamicTheme.fetchSystemPalette();
final log = Logger("ImmichErrorLogger");

View File

@@ -224,7 +224,7 @@ class FileDetailDialog extends ConsumerWidget {
borderRadius: const BorderRadius.all(Radius.circular(12)),
),
child: asset != null
? Thumbnail.fromBaseAsset(asset: asset, size: const Size(512, 512), fit: BoxFit.cover)
? Thumbnail(asset: asset, size: const Size(512, 512), fit: BoxFit.cover)
: null,
),
),

View File

@@ -54,6 +54,7 @@ class ThumbnailApi {
required int requestId,
required int width,
required int height,
required bool isVideo,
}) async {
final String pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.ThumbnailApi.requestImage$pigeonVar_messageChannelSuffix';
@@ -62,7 +63,13 @@ class ThumbnailApi {
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[assetId, requestId, width, height]);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[
assetId,
requestId,
width,
height,
isVideo,
]);
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
if (pigeonVar_replyList == null) {
throw _createConnectionError(pigeonVar_channelName);
@@ -104,4 +111,32 @@ class ThumbnailApi {
return;
}
}
Future<Map<String, int>> getThumbhash(String thumbhash) async {
final String pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.ThumbnailApi.getThumbhash$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[thumbhash]);
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
if (pigeonVar_replyList == null) {
throw _createConnectionError(pigeonVar_channelName);
} else if (pigeonVar_replyList.length > 1) {
throw PlatformException(
code: pigeonVar_replyList[0]! as String,
message: pigeonVar_replyList[1] as String?,
details: pigeonVar_replyList[2],
);
} else if (pigeonVar_replyList[0] == null) {
throw PlatformException(
code: 'null-error',
message: 'Host platform returned null value for non-null return value.',
);
} else {
return (pigeonVar_replyList[0] as Map<Object?, Object?>?)!.cast<String, int>();
}
}
}

View File

@@ -119,7 +119,7 @@ class _DriftCreateAlbumPageState extends ConsumerState<DriftCreateAlbumPage> {
final asset = selectedAssets.elementAt(index);
return GestureDetector(
onTap: onBackgroundTapped,
child: Thumbnail.fromBaseAsset(asset: asset),
child: Thumbnail(asset: asset),
);
}, childCount: selectedAssets.length),
),

View File

@@ -4,7 +4,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/pages/common/large_leading_tile.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/routing/router.dart';
@@ -164,11 +163,7 @@ class _PlaceTile extends StatelessWidget {
title: Text(place.$1, style: context.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w500)),
leading: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(20)),
child: Thumbnail(
imageProvider: RemoteThumbProvider(assetId: place.$2),
size: const Size(80, 80),
fit: BoxFit.cover,
),
child: Thumbnail(size: const Size(80, 80), fit: BoxFit.cover, remoteId: place.$2),
),
);
}

View File

@@ -0,0 +1,174 @@
import 'package:auto_route/auto_route.dart';
import 'package:crop_image/crop_image.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.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/routing/router.dart';
import 'package:immich_mobile/utils/hooks/crop_controller_hook.dart';
/// A widget for cropping an image.
/// This widget uses [HookWidget] to manage its lifecycle and state. It allows
/// users to crop an image and then navigate to the [EditImagePage] with the
/// cropped image.
@RoutePage()
class DriftCropImagePage extends HookWidget {
final Image image;
final BaseAsset asset;
const DriftCropImagePage({super.key, required this.image, required this.asset});
@override
Widget build(BuildContext context) {
final cropController = useCropController();
final aspectRatio = useState<double?>(null);
return Scaffold(
appBar: AppBar(
backgroundColor: context.scaffoldBackgroundColor,
title: Text("crop".tr()),
leading: CloseButton(color: context.primaryColor),
actions: [
IconButton(
icon: Icon(Icons.done_rounded, color: context.primaryColor, size: 24),
onPressed: () async {
final croppedImage = await cropController.croppedImage();
context.pushRoute(DriftEditImageRoute(asset: asset, image: croppedImage, isEdited: true));
},
),
],
),
backgroundColor: context.scaffoldBackgroundColor,
body: SafeArea(
child: LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
return Column(
children: [
Container(
padding: const EdgeInsets.only(top: 20),
width: constraints.maxWidth * 0.9,
height: constraints.maxHeight * 0.6,
child: CropImage(controller: cropController, image: image, gridColor: Colors.white),
),
Expanded(
child: Container(
width: double.infinity,
decoration: BoxDecoration(
color: context.scaffoldBackgroundColor,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(20),
topRight: Radius.circular(20),
),
),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Padding(
padding: const EdgeInsets.only(left: 20, right: 20, bottom: 10),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
IconButton(
icon: Icon(Icons.rotate_left, color: context.themeData.iconTheme.color),
onPressed: () {
cropController.rotateLeft();
},
),
IconButton(
icon: Icon(Icons.rotate_right, color: context.themeData.iconTheme.color),
onPressed: () {
cropController.rotateRight();
},
),
],
),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: <Widget>[
_AspectRatioButton(
cropController: cropController,
aspectRatio: aspectRatio,
ratio: null,
label: 'Free',
),
_AspectRatioButton(
cropController: cropController,
aspectRatio: aspectRatio,
ratio: 1.0,
label: '1:1',
),
_AspectRatioButton(
cropController: cropController,
aspectRatio: aspectRatio,
ratio: 16.0 / 9.0,
label: '16:9',
),
_AspectRatioButton(
cropController: cropController,
aspectRatio: aspectRatio,
ratio: 3.0 / 2.0,
label: '3:2',
),
_AspectRatioButton(
cropController: cropController,
aspectRatio: aspectRatio,
ratio: 7.0 / 5.0,
label: '7:5',
),
],
),
],
),
),
),
),
],
);
},
),
),
);
}
}
class _AspectRatioButton extends StatelessWidget {
final CropController cropController;
final ValueNotifier<double?> aspectRatio;
final double? ratio;
final String label;
const _AspectRatioButton({
required this.cropController,
required this.aspectRatio,
required this.ratio,
required this.label,
});
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: Icon(switch (label) {
'Free' => Icons.crop_free_rounded,
'1:1' => Icons.crop_square_rounded,
'16:9' => Icons.crop_16_9_rounded,
'3:2' => Icons.crop_3_2_rounded,
'7:5' => Icons.crop_7_5_rounded,
_ => Icons.crop_free_rounded,
}, color: aspectRatio.value == ratio ? context.primaryColor : context.themeData.iconTheme.color),
onPressed: () {
cropController.crop = const Rect.fromLTRB(0.1, 0.1, 0.9, 0.9);
aspectRatio.value = ratio;
cropController.aspectRatio = ratio;
},
),
Text(label, style: context.textTheme.displayMedium),
],
);
}
}

View File

@@ -0,0 +1,165 @@
import 'dart:async';
import 'dart:ui';
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/repositories/file_media.repository.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/upload.service.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:logging/logging.dart';
import 'package:path/path.dart' as p;
/// A stateless widget that provides functionality for editing an image.
///
/// This widget allows users to edit an image provided either as an [Asset] or
/// directly as an [Image]. It ensures that exactly one of these is provided.
///
/// It also includes a conversion method to convert an [Image] to a [Uint8List] to save the image on the user's phone
/// They automatically navigate to the [HomePage] with the edited image saved and they eventually get backed up to the server.
@immutable
@RoutePage()
class DriftEditImagePage extends ConsumerWidget {
final BaseAsset asset;
final Image image;
final bool isEdited;
const DriftEditImagePage({super.key, required this.asset, required this.image, required this.isEdited});
Future<Uint8List> _imageToUint8List(Image image) async {
final Completer<Uint8List> completer = Completer();
image.image
.resolve(const ImageConfiguration())
.addListener(
ImageStreamListener((ImageInfo info, bool _) {
info.image.toByteData(format: ImageByteFormat.png).then((byteData) {
if (byteData != null) {
completer.complete(byteData.buffer.asUint8List());
} else {
completer.completeError('Failed to convert image to bytes');
}
});
}, onError: (exception, stackTrace) => completer.completeError(exception)),
);
return completer.future;
}
Future<void> _saveEditedImage(BuildContext context, BaseAsset asset, Image image, WidgetRef ref) async {
try {
final Uint8List imageData = await _imageToUint8List(image);
LocalAsset? localAsset;
try {
localAsset = await ref
.read(fileMediaRepositoryProvider)
.saveLocalAsset(imageData, title: "${p.withoutExtension(asset.name)}_edited.jpg");
} on PlatformException catch (e) {
// OS might not return the saved image back, so we handle that gracefully
// This can happen if app does not have full library access
Logger("SaveEditedImage").warning("Failed to retrieve the saved image back from OS", e);
}
ref.read(backgroundSyncProvider).syncLocal(full: true);
context.navigator.popUntil((route) => route.isFirst);
ImmichToast.show(durationInSecond: 3, context: context, msg: 'Image Saved!');
if (localAsset == null) {
return;
}
await ref.read(uploadServiceProvider).manualBackup([localAsset]);
} catch (e) {
ImmichToast.show(
durationInSecond: 6,
context: context,
msg: "error_saving_image".tr(namedArgs: {'error': e.toString()}),
);
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
appBar: AppBar(
title: Text("edit".tr()),
backgroundColor: context.scaffoldBackgroundColor,
leading: IconButton(
icon: Icon(Icons.close_rounded, color: context.primaryColor, size: 24),
onPressed: () => context.navigator.popUntil((route) => route.isFirst),
),
actions: <Widget>[
TextButton(
onPressed: isEdited ? () => _saveEditedImage(context, asset, image, ref) : null,
child: Text("save_to_gallery".tr(), style: TextStyle(color: isEdited ? context.primaryColor : Colors.grey)),
),
],
),
backgroundColor: context.scaffoldBackgroundColor,
body: Center(
child: ConstrainedBox(
constraints: BoxConstraints(maxHeight: context.height * 0.7, maxWidth: context.width * 0.9),
child: Container(
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(7)),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.2),
spreadRadius: 2,
blurRadius: 10,
offset: const Offset(0, 3),
),
],
),
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(7)),
child: Image(image: image.image, fit: BoxFit.contain),
),
),
),
),
bottomNavigationBar: Container(
height: 70,
margin: const EdgeInsets.only(bottom: 60, right: 10, left: 10, top: 10),
decoration: BoxDecoration(
color: context.scaffoldBackgroundColor,
borderRadius: const BorderRadius.all(Radius.circular(30)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: <Widget>[
Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
IconButton(
icon: Icon(Icons.crop_rotate_rounded, color: context.themeData.iconTheme.color, size: 25),
onPressed: () {
context.pushRoute(DriftCropImageRoute(asset: asset, image: image));
},
),
Text("crop".tr(), style: context.textTheme.displayMedium),
],
),
Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
IconButton(
icon: Icon(Icons.filter, color: context.themeData.iconTheme.color, size: 25),
onPressed: () {
context.pushRoute(DriftFilterImageRoute(asset: asset, image: image));
},
),
Text("filter".tr(), style: context.textTheme.displayMedium),
],
),
],
),
),
);
}
}

View File

@@ -0,0 +1,159 @@
import 'dart:async';
import 'dart:ui' as ui;
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:immich_mobile/constants/filters.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/routing/router.dart';
/// A widget for filtering an image.
/// This widget uses [HookWidget] to manage its lifecycle and state. It allows
/// users to add filters to an image and then navigate to the [EditImagePage] with the
/// final composition.'
@RoutePage()
class DriftFilterImagePage extends HookWidget {
final Image image;
final BaseAsset asset;
const DriftFilterImagePage({super.key, required this.image, required this.asset});
@override
Widget build(BuildContext context) {
final colorFilter = useState<ColorFilter>(filters[0]);
final selectedFilterIndex = useState<int>(0);
Future<ui.Image> createFilteredImage(ui.Image inputImage, ColorFilter filter) {
final completer = Completer<ui.Image>();
final size = Size(inputImage.width.toDouble(), inputImage.height.toDouble());
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
final paint = Paint()..colorFilter = filter;
canvas.drawImage(inputImage, Offset.zero, paint);
recorder.endRecording().toImage(size.width.round(), size.height.round()).then((image) {
completer.complete(image);
});
return completer.future;
}
void applyFilter(ColorFilter filter, int index) {
colorFilter.value = filter;
selectedFilterIndex.value = index;
}
Future<Image> applyFilterAndConvert(ColorFilter filter) async {
final completer = Completer<ui.Image>();
image.image
.resolve(ImageConfiguration.empty)
.addListener(
ImageStreamListener((ImageInfo info, bool _) {
completer.complete(info.image);
}),
);
final uiImage = await completer.future;
final filteredUiImage = await createFilteredImage(uiImage, filter);
final byteData = await filteredUiImage.toByteData(format: ui.ImageByteFormat.png);
final pngBytes = byteData!.buffer.asUint8List();
return Image.memory(pngBytes, fit: BoxFit.contain);
}
return Scaffold(
appBar: AppBar(
backgroundColor: context.scaffoldBackgroundColor,
title: Text("filter".tr()),
leading: CloseButton(color: context.primaryColor),
actions: [
IconButton(
icon: Icon(Icons.done_rounded, color: context.primaryColor, size: 24),
onPressed: () async {
final filteredImage = await applyFilterAndConvert(colorFilter.value);
context.pushRoute(DriftEditImageRoute(asset: asset, image: filteredImage, isEdited: true));
},
),
],
),
backgroundColor: context.scaffoldBackgroundColor,
body: Column(
children: [
SizedBox(
height: context.height * 0.7,
child: Center(
child: ColorFiltered(colorFilter: colorFilter.value, child: image),
),
),
SizedBox(
height: 120,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: filters.length,
itemBuilder: (context, index) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: _FilterButton(
image: image,
label: filterNames[index],
filter: filters[index],
isSelected: selectedFilterIndex.value == index,
onTap: () => applyFilter(filters[index], index),
),
);
},
),
),
],
),
);
}
}
class _FilterButton extends StatelessWidget {
final Image image;
final String label;
final ColorFilter filter;
final bool isSelected;
final VoidCallback onTap;
const _FilterButton({
required this.image,
required this.label,
required this.filter,
required this.isSelected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Column(
children: [
GestureDetector(
onTap: onTap,
child: Container(
width: 80,
height: 80,
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(10)),
border: isSelected ? Border.all(color: context.primaryColor, width: 3) : null,
),
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(10)),
child: ColorFiltered(
colorFilter: filter,
child: FittedBox(fit: BoxFit.cover, child: image),
),
),
),
),
const SizedBox(height: 10),
Text(label, style: context.themeData.textTheme.bodyMedium),
],
);
}
}

View File

@@ -0,0 +1,37 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/pages/editing/drift_edit.page.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/current_asset.provider.dart';
class EditImageActionButton extends ConsumerWidget {
const EditImageActionButton({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final currentAsset = ref.watch(currentAssetNotifier);
onPress() {
if (currentAsset == null) {
return;
}
final image = Image(image: getFullImageProvider(currentAsset));
context.navigator.push(
MaterialPageRoute(
builder: (context) => DriftEditImagePage(asset: currentAsset, image: image, isEdited: false),
),
);
}
return BaseActionButton(
iconData: Icons.tune,
label: "edit".t(context: context),
onPressed: onPress,
);
}
}

View File

@@ -7,19 +7,19 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/services/remote_album.service.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/models/albums/album_search.model.dart';
import 'package:immich_mobile/pages/common/large_leading_tile.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.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';
import 'package:immich_mobile/utils/remote_album.utils.dart';
import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_mobile/widgets/common/search_field.dart';
import 'package:sliver_tools/sliver_tools.dart';
@@ -138,21 +138,28 @@ class _SortButton extends ConsumerStatefulWidget {
class _SortButtonState extends ConsumerState<_SortButton> {
RemoteAlbumSortMode albumSortOption = RemoteAlbumSortMode.lastModified;
bool albumSortIsReverse = true;
bool isSorting = false;
void onMenuTapped(RemoteAlbumSortMode sortMode) {
Future<void> onMenuTapped(RemoteAlbumSortMode sortMode) async {
final selected = albumSortOption == sortMode;
// Switch direction
if (selected) {
setState(() {
albumSortIsReverse = !albumSortIsReverse;
isSorting = true;
});
ref.read(remoteAlbumProvider.notifier).sortFilteredAlbums(sortMode, isReverse: albumSortIsReverse);
await ref.read(remoteAlbumProvider.notifier).sortFilteredAlbums(sortMode, isReverse: albumSortIsReverse);
} else {
setState(() {
albumSortOption = sortMode;
isSorting = true;
});
ref.read(remoteAlbumProvider.notifier).sortFilteredAlbums(sortMode, isReverse: albumSortIsReverse);
await ref.read(remoteAlbumProvider.notifier).sortFilteredAlbums(sortMode, isReverse: albumSortIsReverse);
}
setState(() {
isSorting = false;
});
}
@override
@@ -230,6 +237,16 @@ class _SortButtonState extends ConsumerState<_SortButton> {
color: context.colorScheme.onSurface.withAlpha(225),
),
),
isSorting
? SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(
strokeWidth: 2,
color: context.colorScheme.onSurface.withAlpha(225),
),
)
: const SizedBox.shrink(),
],
),
);
@@ -424,46 +441,72 @@ class _AlbumList extends ConsumerWidget {
sliver: SliverList.builder(
itemBuilder: (_, index) {
final album = albums[index];
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: LargeLeadingTile(
title: Text(
album.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600),
),
subtitle: Text(
'${'items_count'.t(context: context, args: {'count': album.assetCount})} • ${album.ownerId != userId ? 'shared_by_user'.t(context: context, args: {'user': album.ownerName}) : 'owned'.t(context: context)}',
overflow: TextOverflow.ellipsis,
style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
),
onTap: () => onAlbumSelected(album),
leadingPadding: const EdgeInsets.only(right: 16),
leading: album.thumbnailAssetId != null
? ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(15)),
child: SizedBox(
width: 80,
height: 80,
child: Thumbnail(imageProvider: RemoteThumbProvider(assetId: album.thumbnailAssetId!)),
),
)
: SizedBox(
width: 80,
height: 80,
child: Container(
decoration: BoxDecoration(
color: context.colorScheme.surfaceContainer,
borderRadius: const BorderRadius.all(Radius.circular(16)),
border: Border.all(color: context.colorScheme.outline.withAlpha(50), width: 1),
),
child: const Icon(Icons.photo_album_rounded, size: 24, color: Colors.grey),
),
),
final albumTile = LargeLeadingTile(
title: Text(
album.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600),
),
subtitle: Text(
'${'items_count'.t(context: context, args: {'count': album.assetCount})} • ${album.ownerId != userId ? 'shared_by_user'.t(context: context, args: {'user': album.ownerName}) : 'owned'.t(context: context)}',
overflow: TextOverflow.ellipsis,
style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
),
onTap: () => onAlbumSelected(album),
leadingPadding: const EdgeInsets.only(right: 16),
leading: album.thumbnailAssetId != null
? ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(15)),
child: SizedBox(width: 80, height: 80, child: Thumbnail(remoteId: album.thumbnailAssetId)),
)
: SizedBox(
width: 80,
height: 80,
child: Container(
decoration: BoxDecoration(
color: context.colorScheme.surfaceContainer,
borderRadius: const BorderRadius.all(Radius.circular(16)),
border: Border.all(color: context.colorScheme.outline.withAlpha(50), width: 1),
),
child: const Icon(Icons.photo_album_rounded, size: 24, color: Colors.grey),
),
),
);
final isOwner = album.ownerId == userId;
if (isOwner) {
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Dismissible(
key: ValueKey(album.id),
background: Container(
color: context.colorScheme.error,
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 16),
child: Icon(Icons.delete, color: context.colorScheme.onError),
),
direction: DismissDirection.endToStart,
confirmDismiss: (direction) {
return showDialog<bool>(
context: context,
builder: (context) => ConfirmDialog(
onOk: () => true,
title: "delete_album".t(context: context),
content: "album_delete_confirmation".t(context: context, args: {'album': album.name}),
ok: "delete".t(context: context),
),
);
},
onDismissed: (direction) async {
await ref.read(remoteAlbumProvider.notifier).deleteAlbum(album.id);
},
child: albumTile,
),
);
} else {
return Padding(padding: const EdgeInsets.only(bottom: 8.0), child: albumTile);
}
},
itemCount: albums.length,
),
@@ -534,7 +577,7 @@ class _GridAlbumCard extends ConsumerWidget {
child: SizedBox(
width: double.infinity,
child: album.thumbnailAssetId != null
? Thumbnail(imageProvider: RemoteThumbProvider(assetId: album.thumbnailAssetId!))
? Thumbnail(remoteId: album.thumbnailAssetId)
: Container(
color: context.colorScheme.surfaceContainerHighest,
child: const Icon(Icons.photo_album_rounded, size: 40, color: Colors.grey),

View File

@@ -113,10 +113,10 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
super.dispose();
}
bool get showingBottomSheet => ref.read(assetViewerProvider).showingBottomSheet;
bool get showingBottomSheet => ref.read(assetViewerProvider.select((s) => s.showingBottomSheet));
Color get backgroundColor {
final opacity = ref.read(assetViewerProvider).backgroundOpacity;
final opacity = ref.read(assetViewerProvider.select((s) => s.backgroundOpacity));
return Colors.black.withAlpha(opacity);
}
@@ -172,8 +172,9 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
// Check if widget is still mounted before proceeding
if (!mounted) return;
unawaited(_precacheImage(index - 1));
unawaited(_precacheImage(index + 1));
for (final offset in [-1, 1]) {
unawaited(_precacheImage(index + offset));
}
});
_delayedOperations.add(timer);
@@ -472,7 +473,12 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
if (stackChildren != null && stackChildren.isNotEmpty) {
asset = stackChildren.elementAt(ref.read(assetViewerProvider.select((s) => s.stackIndex)));
}
return Thumbnail.fromBaseAsset(asset: asset, fit: BoxFit.contain);
return Container(
width: double.infinity,
height: double.infinity,
color: backgroundColor,
child: Thumbnail(asset: asset, fit: BoxFit.contain),
);
}
void _onScaleStateChanged(PhotoViewScaleState scaleState) {
@@ -490,7 +496,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
BaseAsset asset = ref.read(timelineServiceProvider).getAsset(index);
final stackChildren = ref.read(stackChildrenNotifier(asset)).valueOrNull;
if (stackChildren != null && stackChildren.isNotEmpty) {
asset = stackChildren.elementAt(ref.read(assetViewerProvider).stackIndex);
asset = stackChildren.elementAt(ref.read(assetViewerProvider.select((s) => s.stackIndex)));
}
final isPlayingMotionVideo = ref.read(isPlayingMotionVideoProvider);
@@ -509,8 +515,8 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
heroAttributes: PhotoViewHeroAttributes(tag: '${asset.heroTag}_$heroOffset'),
filterQuality: FilterQuality.high,
tightMode: true,
initialScale: PhotoViewComputedScale.contained,
minScale: PhotoViewComputedScale.contained,
initialScale: PhotoViewComputedScale.contained * 0.999,
minScale: PhotoViewComputedScale.contained * 0.999,
disableScaleGestures: showingBottomSheet,
onDragStart: _onDragStart,
onDragUpdate: _onDragUpdate,
@@ -521,7 +527,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
width: size.width,
height: size.height,
color: backgroundColor,
child: Thumbnail.fromBaseAsset(asset: asset, fit: BoxFit.contain),
child: Thumbnail(asset: asset, fit: BoxFit.contain),
),
);
}
@@ -539,9 +545,9 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
onTapDown: _onTapDown,
heroAttributes: PhotoViewHeroAttributes(tag: '${asset.heroTag}_$heroOffset'),
filterQuality: FilterQuality.high,
initialScale: PhotoViewComputedScale.contained,
initialScale: PhotoViewComputedScale.contained * 0.99,
maxScale: 1.0,
minScale: PhotoViewComputedScale.contained,
minScale: PhotoViewComputedScale.contained * 0.99,
basePosition: Alignment.center,
child: SizedBox(
width: ctx.width,
@@ -570,11 +576,9 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
Widget build(BuildContext context) {
// Rebuild the widget when the asset viewer state changes
// Using multiple selectors to avoid unnecessary rebuilds for other state changes
ref.watch(
assetViewerProvider.select(
(s) => s.showingBottomSheet.hashCode ^ s.backgroundOpacity.hashCode ^ s.stackIndex.hashCode,
),
);
ref.watch(assetViewerProvider.select((s) => s.showingBottomSheet));
ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity));
ref.watch(assetViewerProvider.select((s) => s.stackIndex));
ref.watch(isPlayingMotionVideoProvider);
// Listen for casting changes and send initial asset to the cast provider

View File

@@ -75,34 +75,22 @@ class AssetViewerStateNotifier extends AutoDisposeNotifier<AssetViewerState> {
}
void setAsset(BaseAsset? asset) {
if (asset != state.currentAsset) {
state = state.copyWith(currentAsset: asset, stackIndex: 0);
}
state = state.copyWith(currentAsset: asset, stackIndex: 0);
}
void setOpacity(int opacity) {
if (opacity != state.backgroundOpacity) {
state = state.copyWith(
backgroundOpacity: opacity,
showingControls: opacity == 255 ? true : state.showingControls,
);
}
state = state.copyWith(backgroundOpacity: opacity, showingControls: opacity == 255 ? true : state.showingControls);
}
void setBottomSheet(bool showing) {
if (showing == state.showingBottomSheet) {
return;
}
state = state.copyWith(showingBottomSheet: showing, showingControls: showing || state.showingControls);
state = state.copyWith(showingBottomSheet: showing, showingControls: showing ? true : state.showingControls);
if (showing) {
ref.read(videoPlayerControlsProvider.notifier).pause();
}
}
void setControls(bool isShowing) {
if (isShowing != state.showingControls) {
state = state.copyWith(showingControls: isShowing);
}
state = state.copyWith(showingControls: isShowing);
}
void toggleControls() {
@@ -110,9 +98,7 @@ class AssetViewerStateNotifier extends AutoDisposeNotifier<AssetViewerState> {
}
void setStackIndex(int index) {
if (index != state.stackIndex) {
state = state.copyWith(stackIndex: index);
}
state = state.copyWith(stackIndex: index);
}
}

View File

@@ -6,6 +6,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_image_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
@@ -38,6 +39,7 @@ class ViewerBottomBar extends ConsumerWidget {
final actions = <Widget>[
const ShareActionButton(source: ActionSource.viewer),
if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer),
if (asset.type == AssetType.image) const EditImageActionButton(),
if (asset.hasRemote && isOwner) const ArchiveActionButton(source: ActionSource.viewer),
asset.isLocalOnly
? const DeleteLocalActionButton(source: ActionSource.viewer)

View File

@@ -61,7 +61,7 @@ class _SheetPeopleDetailsState extends ConsumerState<SheetPeopleDetails> {
),
),
SizedBox(
height: 150,
height: 160,
child: ListView(
padding: const EdgeInsets.only(left: 16.0),
scrollDirection: Axis.horizontal,

View File

@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.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/widgets/asset_grid/thumbnail_placeholder.dart';
import 'package:octo_image/octo_image.dart';
class FullImage extends StatelessWidget {
@@ -9,7 +9,7 @@ class FullImage extends StatelessWidget {
this.asset, {
required this.size,
this.fit = BoxFit.cover,
this.placeholder = const Thumbnail(),
this.placeholder = const ThumbnailPlaceholder(),
super.key,
});

View File

@@ -30,7 +30,7 @@ ImageProvider getFullImageProvider(BaseAsset asset, {Size size = const Size(1080
final ImageProvider provider;
if (_shouldUseLocalAsset(asset)) {
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!;
provider = LocalFullImageProvider(id: id, size: size);
provider = LocalFullImageProvider(id: id, size: size, assetType: asset.type);
} else {
final String assetId;
if (asset is LocalAsset && asset.hasRemote) {
@@ -55,7 +55,7 @@ ImageProvider getThumbnailImageProvider({BaseAsset? asset, String? remoteId, Siz
if (_shouldUseLocalAsset(asset!)) {
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!;
return LocalThumbProvider(id: id, size: size);
return LocalThumbProvider(id: id, size: size, assetType: asset.type);
}
final String assetId;

View File

@@ -26,7 +26,7 @@ class LocalAlbumThumbnail extends ConsumerWidget {
return ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(16)),
child: Thumbnail.fromBaseAsset(asset: data),
child: Thumbnail(asset: data),
);
},
error: (error, stack) {

View File

@@ -3,6 +3,7 @@ import 'dart:ui';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/infrastructure/repositories/asset_media.repository.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart';
@@ -11,8 +12,9 @@ import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
class LocalThumbProvider extends ImageProvider<LocalThumbProvider> with CancellableImageProviderMixin {
final String id;
final Size size;
final AssetType assetType;
LocalThumbProvider({required this.id, this.size = kThumbnailResolution});
LocalThumbProvider({required this.id, required this.assetType, this.size = kThumbnailResolution});
@override
Future<LocalThumbProvider> obtainKey(ImageConfiguration configuration) {
@@ -21,17 +23,19 @@ class LocalThumbProvider extends ImageProvider<LocalThumbProvider> with Cancella
@override
ImageStreamCompleter loadImage(LocalThumbProvider key, ImageDecoderCallback decode) {
return OneFramePlaceholderImageStreamCompleter(
final completer = OneFramePlaceholderImageStreamCompleter(
_codec(key, decode),
informationCollector: () => <DiagnosticsNode>[
DiagnosticsProperty<String>('Id', key.id),
DiagnosticsProperty<Size>('Size', key.size),
],
);
completer.addOnLastListenerRemovedCallback(cancel);
return completer;
}
Stream<ImageInfo> _codec(LocalThumbProvider key, ImageDecoderCallback decode) async* {
final request = this.request = LocalImageRequest(localId: key.id, size: size);
final request = this.request = LocalImageRequest(localId: key.id, size: size, assetType: key.assetType);
try {
final image = await request.load(decode);
if (image != null) {
@@ -58,8 +62,9 @@ class LocalThumbProvider extends ImageProvider<LocalThumbProvider> with Cancella
class LocalFullImageProvider extends ImageProvider<LocalFullImageProvider> with CancellableImageProviderMixin {
final String id;
final Size size;
final AssetType assetType;
LocalFullImageProvider({required this.id, required this.size});
LocalFullImageProvider({required this.id, required this.assetType, required this.size});
@override
Future<LocalFullImageProvider> obtainKey(ImageConfiguration configuration) {
@@ -68,15 +73,17 @@ class LocalFullImageProvider extends ImageProvider<LocalFullImageProvider> with
@override
ImageStreamCompleter loadImage(LocalFullImageProvider key, ImageDecoderCallback decode) {
return OneFramePlaceholderImageStreamCompleter(
final completer = OneFramePlaceholderImageStreamCompleter(
_codec(key, decode),
initialImage: getCachedImage(LocalThumbProvider(id: key.id)),
initialImage: getCachedImage(LocalThumbProvider(id: key.id, assetType: key.assetType)),
informationCollector: () => <DiagnosticsNode>[
DiagnosticsProperty<ImageProvider>('Image provider', this),
DiagnosticsProperty<String>('Id', key.id),
DiagnosticsProperty<Size>('Size', key.size),
],
);
completer.addOnLastListenerRemovedCallback(cancel);
return completer;
}
Stream<ImageInfo> _codec(LocalFullImageProvider key, ImageDecoderCallback decode) async* {
@@ -84,6 +91,7 @@ class LocalFullImageProvider extends ImageProvider<LocalFullImageProvider> with
final request = this.request = LocalImageRequest(
localId: key.id,
size: Size(size.width * devicePixelRatio, size.height * devicePixelRatio),
assetType: key.assetType,
);
try {

View File

@@ -24,13 +24,15 @@ class RemoteThumbProvider extends ImageProvider<RemoteThumbProvider> with Cancel
@override
ImageStreamCompleter loadImage(RemoteThumbProvider key, ImageDecoderCallback decode) {
return OneFramePlaceholderImageStreamCompleter(
final completer = OneFramePlaceholderImageStreamCompleter(
_codec(key, decode),
informationCollector: () => <DiagnosticsNode>[
DiagnosticsProperty<ImageProvider>('Image provider', this),
DiagnosticsProperty<String>('Asset Id', key.assetId),
],
);
completer.addOnLastListenerRemovedCallback(cancel);
return completer;
}
Stream<ImageInfo> _codec(RemoteThumbProvider key, ImageDecoderCallback decode) async* {
@@ -73,7 +75,7 @@ class RemoteFullImageProvider extends ImageProvider<RemoteFullImageProvider> wit
@override
ImageStreamCompleter loadImage(RemoteFullImageProvider key, ImageDecoderCallback decode) {
return OneFramePlaceholderImageStreamCompleter(
final completer = OneFramePlaceholderImageStreamCompleter(
_codec(key, decode),
initialImage: getCachedImage(RemoteThumbProvider(assetId: assetId)),
informationCollector: () => <DiagnosticsNode>[
@@ -81,6 +83,8 @@ class RemoteFullImageProvider extends ImageProvider<RemoteFullImageProvider> wit
DiagnosticsProperty<String>('Asset Id', key.assetId),
],
);
completer.addOnLastListenerRemovedCallback(cancel);
return completer;
}
Stream<ImageInfo> _codec(RemoteFullImageProvider key, ImageDecoderCallback decode) async* {

View File

@@ -0,0 +1,47 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/rendering.dart';
import 'package:immich_mobile/infrastructure/repositories/asset_media.repository.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart';
class ThumbHashProvider extends ImageProvider<ThumbHashProvider> with CancellableImageProviderMixin {
final String thumbHash;
ThumbHashProvider({required this.thumbHash});
@override
Future<ThumbHashProvider> obtainKey(ImageConfiguration configuration) {
return SynchronousFuture(this);
}
@override
ImageStreamCompleter loadImage(ThumbHashProvider key, ImageDecoderCallback decode) {
final completer = OneFramePlaceholderImageStreamCompleter(_loadCodec(key, decode));
completer.addOnLastListenerRemovedCallback(cancel);
return completer;
}
Stream<ImageInfo> _loadCodec(ThumbHashProvider key, ImageDecoderCallback decode) async* {
final request = this.request = ThumbhashImageRequest(thumbhash: thumbHash);
try {
final image = await request.load(decode);
if (image != null) {
yield image;
}
} finally {
this.request = null;
}
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
if (other is ThumbHashProvider) {
return thumbHash == other.thumbHash;
}
return false;
}
@override
int get hashCode => thumbHash.hashCode;
}

View File

@@ -1,372 +1,95 @@
import 'dart:async';
import 'dart:convert';
import 'dart:ui' as ui;
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/local_image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/thumb_hash_provider.dart';
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart';
import 'package:immich_mobile/widgets/common/fade_in_placeholder_image.dart';
import 'package:logging/logging.dart';
import 'package:thumbhash/thumbhash.dart' as thumbhash;
final log = Logger('ThumbnailWidget');
enum ThumbhashMode { enabled, disabled, only }
import 'package:octo_image/octo_image.dart';
class Thumbnail extends StatefulWidget {
final ImageProvider? imageProvider;
const Thumbnail({this.asset, this.remoteId, this.size = kTimelineFixedTileExtent, this.fit = BoxFit.cover, super.key})
: assert(asset != null || remoteId != null, 'Either asset or remoteId must be provided');
final BaseAsset? asset;
final String? remoteId;
final Size size;
final BoxFit fit;
final ui.Size size;
final String? blurhash;
final ThumbhashMode thumbhashMode;
const Thumbnail({
this.imageProvider,
this.fit = BoxFit.cover,
this.size = kThumbnailResolution,
this.blurhash,
this.thumbhashMode = ThumbhashMode.enabled,
super.key,
});
Thumbnail.fromAsset({
required Asset asset,
this.fit = BoxFit.cover,
this.size = kThumbnailResolution,
this.thumbhashMode = ThumbhashMode.enabled,
super.key,
}) : blurhash = asset.thumbhash,
imageProvider = _getImageProviderFromAsset(asset, size);
Thumbnail.fromBaseAsset({
required BaseAsset? asset,
this.fit = BoxFit.cover,
this.size = kThumbnailResolution,
this.thumbhashMode = ThumbhashMode.enabled,
super.key,
}) : blurhash = switch (asset) {
RemoteAsset() => asset.thumbHash,
_ => null,
},
imageProvider = _getImageProviderFromBaseAsset(asset, size);
static ImageProvider? _getImageProviderFromAsset(Asset asset, ui.Size size) {
if (asset.localId != null) {
return LocalThumbProvider(id: asset.localId!, size: size);
} else if (asset.remoteId != null) {
return RemoteThumbProvider(assetId: asset.remoteId!);
}
return null;
}
static ImageProvider? _getImageProviderFromBaseAsset(BaseAsset? asset, ui.Size size) {
switch (asset) {
case RemoteAsset():
if (asset.localId != null) {
return LocalThumbProvider(id: asset.localId!, size: size);
} else {
return RemoteThumbProvider(assetId: asset.id);
}
case LocalAsset():
return LocalThumbProvider(id: asset.id, size: size);
case null:
return null;
}
}
@override
State<Thumbnail> createState() => _ThumbnailState();
createState() => _ThumbnailState();
}
class _ThumbnailState extends State<Thumbnail> {
ui.Image? _providerImage;
ImageStream? _imageStream;
ImageStreamListener? _imageStreamListener;
static final _gradientCache = <ColorScheme, Gradient>{};
ImageProvider? provider;
@override
void initState() {
provider = getThumbnailImageProvider(asset: widget.asset, remoteId: widget.remoteId);
super.initState();
_loadImage();
}
@override
void didUpdateWidget(Thumbnail oldWidget) {
void didUpdateWidget(covariant Thumbnail oldWidget) {
if (oldWidget.asset == widget.asset && oldWidget.remoteId == widget.remoteId) {
return;
}
if (provider is CancellableImageProvider) {
(provider as CancellableImageProvider).cancel();
}
provider = getThumbnailImageProvider(asset: widget.asset, remoteId: widget.remoteId);
super.didUpdateWidget(oldWidget);
if (widget.imageProvider != oldWidget.imageProvider) {
return _loadImage();
}
if (_providerImage != null) {
return;
}
if ((oldWidget.thumbhashMode == ThumbhashMode.disabled && widget.thumbhashMode != ThumbhashMode.disabled) ||
(oldWidget.thumbhashMode == ThumbhashMode.only && widget.thumbhashMode != ThumbhashMode.only) ||
(widget.thumbhashMode != ThumbhashMode.disabled && oldWidget.blurhash != widget.blurhash)) {
_loadImage();
}
}
@override
void reassemble() {
super.reassemble();
_loadImage();
}
void _loadImage() {
_stopListeningToStream();
if (widget.thumbhashMode != ThumbhashMode.only && widget.imageProvider != null) {
_loadFromProvider();
}
if (widget.thumbhashMode != ThumbhashMode.disabled && widget.blurhash != null) {
_decodeThumbhash();
}
}
void _loadFromProvider() {
final imageProvider = widget.imageProvider;
if (imageProvider == null) return;
_imageStream = imageProvider.resolve(ImageConfiguration.empty);
_imageStreamListener = ImageStreamListener(
(ImageInfo imageInfo, bool synchronousCall) {
if (!mounted) return;
if (_providerImage != imageInfo.image) {
setState(() {
_providerImage = imageInfo.image;
});
}
},
onError: (exception, stackTrace) {
log.severe('Error loading image: $exception', exception, stackTrace);
},
);
_imageStream?.addListener(_imageStreamListener!);
}
void _stopListeningToStream() {
if (_imageStreamListener != null && _imageStream != null) {
_imageStream!.removeListener(_imageStreamListener!);
}
_imageStream = null;
_imageStreamListener = null;
}
Future<void> _decodeThumbhash() async {
final blurhash = widget.blurhash;
if (blurhash == null || !mounted || _providerImage != null) {
return;
}
try {
final image = thumbhash.thumbHashToRGBA(base64.decode(blurhash));
final buffer = await ImmutableBuffer.fromUint8List(image.rgba);
if (!mounted || _providerImage != null) {
buffer.dispose();
return;
}
final descriptor = ImageDescriptor.raw(
buffer,
width: image.width,
height: image.height,
pixelFormat: PixelFormat.rgba8888,
);
final codec = await descriptor.instantiateCodec();
if (!mounted || _providerImage != null) {
buffer.dispose();
descriptor.dispose();
codec.dispose();
return;
}
final frame = (await codec.getNextFrame()).image;
buffer.dispose();
descriptor.dispose();
codec.dispose();
if (!mounted || _providerImage != null) {
frame.dispose();
return;
}
setState(() {
_providerImage = frame;
});
} catch (e) {
log.severe('Error decoding thumbhash: $e');
}
}
@override
Widget build(BuildContext context) {
final colorScheme = context.colorScheme;
final gradient = _gradientCache[colorScheme] ??= LinearGradient(
colors: [colorScheme.surfaceContainer, colorScheme.surfaceContainer.darken(amount: .1)],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
);
final thumbHash = widget.asset is RemoteAsset ? (widget.asset as RemoteAsset).thumbHash : null;
return _ThumbnailLeaf(image: _providerImage, fit: widget.fit, placeholderGradient: gradient);
return OctoImage.fromSet(
image: provider!,
octoSet: OctoSet(
placeholderBuilder: _blurHashPlaceholderBuilder(thumbHash),
errorBuilder: _blurHashErrorBuilder(thumbHash, provider: provider, asset: widget.asset),
),
fadeOutDuration: const Duration(milliseconds: 100),
fadeInDuration: Duration.zero,
width: widget.size.width,
height: widget.size.height,
fit: widget.fit,
placeholderFadeInDuration: Duration.zero,
);
}
@override
void dispose() {
_stopListeningToStream();
_providerImage?.dispose();
final imageProvider = widget.imageProvider;
if (imageProvider is CancellableImageProvider) {
(imageProvider as CancellableImageProvider).cancel();
if (provider is CancellableImageProvider) {
(provider as CancellableImageProvider).cancel();
}
super.dispose();
}
}
class _ThumbnailLeaf extends LeafRenderObjectWidget {
final ui.Image? image;
final BoxFit fit;
final Gradient placeholderGradient;
const _ThumbnailLeaf({required this.image, required this.fit, required this.placeholderGradient});
@override
RenderObject createRenderObject(BuildContext context) {
return _ThumbnailRenderBox(image: image, fit: fit, placeholderGradient: placeholderGradient);
}
@override
void updateRenderObject(BuildContext context, _ThumbnailRenderBox renderObject) {
renderObject.fit = fit;
renderObject.image = image;
renderObject.placeholderGradient = placeholderGradient;
}
}
class _ThumbnailRenderBox extends RenderBox {
ui.Image? _image;
ui.Image? _previousImage;
BoxFit _fit;
Gradient _placeholderGradient;
DateTime _lastImageRequest;
double _crossFadeProgress = 1.0;
static const _fadeDuration = Duration(milliseconds: 100);
DateTime? _fadeStartTime;
@override
bool isRepaintBoundary = true;
_ThumbnailRenderBox({required ui.Image? image, required BoxFit fit, required Gradient placeholderGradient})
: _image = image,
_fit = fit,
_placeholderGradient = placeholderGradient,
_lastImageRequest = DateTime.now();
@override
void paint(PaintingContext context, Offset offset) {
final rect = offset & size;
final canvas = context.canvas;
if (_fadeStartTime != null) {
final elapsed = DateTime.now().difference(_fadeStartTime!);
_crossFadeProgress = (elapsed.inMilliseconds / _fadeDuration.inMilliseconds).clamp(0.0, 1.0);
if (_crossFadeProgress < 1.0) {
SchedulerBinding.instance.scheduleFrameCallback((_) {
markNeedsPaint();
});
} else {
_previousImage?.dispose();
_previousImage = null;
_fadeStartTime = null;
}
}
if (_previousImage != null && _crossFadeProgress < 1.0) {
paintImage(
canvas: canvas,
rect: rect,
image: _previousImage!,
fit: _fit,
filterQuality: FilterQuality.low,
opacity: 1.0 - _crossFadeProgress,
);
} else if (_image == null) {
final paint = Paint();
paint.shader = _placeholderGradient.createShader(rect);
canvas.drawRect(rect, paint);
}
if (_image != null) {
paintImage(
canvas: canvas,
rect: rect,
image: _image!,
fit: _fit,
filterQuality: FilterQuality.low,
opacity: _crossFadeProgress,
);
}
}
@override
void performLayout() {
size = constraints.biggest;
}
set image(ui.Image? value) {
if (_image == value) {
return;
}
final time = DateTime.now();
if (time.difference(_lastImageRequest).inMilliseconds >= 16) {
_fadeStartTime = time;
_previousImage = _image;
}
_image = value;
_lastImageRequest = time;
markNeedsPaint();
}
set fit(BoxFit value) {
if (_fit == value) {
return;
}
_fit = value;
if (_image != null) {
markNeedsPaint();
}
}
set placeholderGradient(Gradient value) {
if (_placeholderGradient == value) {
return;
}
_placeholderGradient = value;
if (_image == null) {
markNeedsPaint();
}
}
@override
dispose() {
_previousImage?.dispose();
super.dispose();
}
OctoPlaceholderBuilder _blurHashPlaceholderBuilder(String? thumbHash) {
return (context) => thumbHash == null
? const ThumbnailPlaceholder()
: FadeInPlaceholderImage(
placeholder: const ThumbnailPlaceholder(),
image: ThumbHashProvider(thumbHash: thumbHash),
fit: widget.fit,
width: widget.size.width,
height: widget.size.height,
);
}
OctoErrorBuilder _blurHashErrorBuilder(String? blurhash, {BaseAsset? asset, ImageProvider? provider}) =>
(context, e, s) {
Logger("ImThumbnail").warning("Error loading thumbnail for ${asset?.name}", e, s);
return Stack(
alignment: Alignment.center,
children: [
_blurHashPlaceholderBuilder(blurhash)(context),
const Opacity(opacity: 0.75, child: Icon(Icons.error_outline_rounded)),
],
);
};
}

View File

@@ -7,15 +7,13 @@ 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/images/thumbnail.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
class ThumbnailTile extends ConsumerWidget {
const ThumbnailTile(
this.asset, {
this.size = kTimelineFixedTileExtent,
this.size = const Size.square(256),
this.fit = BoxFit.cover,
this.showStorageIndicator,
this.lockSelection = false,
@@ -23,7 +21,7 @@ class ThumbnailTile extends ConsumerWidget {
super.key,
});
final BaseAsset? asset;
final BaseAsset asset;
final Size size;
final BoxFit fit;
final bool? showStorageIndicator;
@@ -32,7 +30,6 @@ class ThumbnailTile extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final asset = this.asset;
final heroIndex = heroOffset ?? TabsRouterScope.of(context)?.controller.activeIndex ?? 0;
final assetContainerColor = context.isDarkTheme
@@ -42,7 +39,6 @@ class ThumbnailTile extends ConsumerWidget {
final isSelected = ref.watch(
multiSelectProvider.select((multiselect) => multiselect.selectedAssets.contains(asset)),
);
final isScrubbing = ref.watch(timelineStateProvider.select((state) => state.isScrubbing));
final borderStyle = lockSelection
? BoxDecoration(
@@ -56,6 +52,8 @@ class ThumbnailTile extends ConsumerWidget {
)
: const BoxDecoration();
final hasStack = asset is RemoteAsset && (asset as RemoteAsset).stackId != null;
final bool storageIndicator =
showStorageIndicator ?? ref.watch(settingsProvider.select((s) => s.get(Setting.showStorageIndicator)));
@@ -73,34 +71,19 @@ class ThumbnailTile extends ConsumerWidget {
children: [
Positioned.fill(
child: Hero(
tag: '${asset?.heroTag ?? ''}_$heroIndex',
child: Thumbnail.fromBaseAsset(
asset: asset,
thumbhashMode: isScrubbing
? ThumbhashMode.only
: asset != null && asset.hasLocal
? ThumbhashMode.disabled
: ThumbhashMode.enabled,
),
tag: '${asset.heroTag}_$heroIndex',
child: Thumbnail(asset: asset, fit: fit, size: size),
),
),
if (asset is RemoteAsset && asset.stackId != null)
asset.isVideo
? const Align(
alignment: Alignment.topRight,
child: Padding(
padding: EdgeInsets.only(right: 10.0, top: 24.0),
child: _TileOverlayIcon(Icons.burst_mode_rounded),
),
)
: const Align(
alignment: Alignment.topRight,
child: Padding(
padding: EdgeInsets.only(right: 10.0, top: 6.0),
child: _TileOverlayIcon(Icons.burst_mode_rounded),
),
),
if (asset != null && asset.isVideo)
if (hasStack)
Align(
alignment: Alignment.topRight,
child: Padding(
padding: EdgeInsets.only(right: 10.0, top: asset.isVideo ? 24.0 : 6.0),
child: const _TileOverlayIcon(Icons.burst_mode_rounded),
),
),
if (asset.isVideo)
Align(
alignment: Alignment.topRight,
child: Padding(
@@ -108,7 +91,7 @@ class ThumbnailTile extends ConsumerWidget {
child: _VideoIndicator(asset.duration),
),
),
if (storageIndicator && asset != null)
if (storageIndicator)
switch (asset.storage) {
AssetState.local => const Align(
alignment: Alignment.bottomRight,
@@ -132,7 +115,7 @@ class ThumbnailTile extends ConsumerWidget {
),
),
},
if (asset != null && asset.isFavorite)
if (asset.isFavorite)
const Align(
alignment: Alignment.bottomLeft,
child: Padding(

View File

@@ -7,7 +7,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer.widget.dart';
import 'package:immich_mobile/presentation/widgets/images/full_image.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/utils/hooks/blurhash_hook.dart';
class DriftMemoryCard extends StatelessWidget {
final RemoteAsset asset;
@@ -88,26 +88,31 @@ class _BlurredBackdrop extends HookWidget {
@override
Widget build(BuildContext context) {
final blurhash = asset.thumbHash;
final blurhash = useDriftBlurHashRef(asset).value;
if (blurhash != null) {
// Use a nice cheap blur hash image decoration
return Thumbnail(blurhash: blurhash);
}
// Fall back to using a more expensive image filtered
// Since the ImmichImage is already precached, we can
// safely use that as the image provider
return ImageFiltered(
imageFilter: ImageFilter.blur(sigmaX: 30, sigmaY: 30),
child: DecoratedBox(
return Container(
decoration: BoxDecoration(
image: DecorationImage(
image: getFullImageProvider(asset, size: Size(context.width, context.height)),
fit: BoxFit.cover,
),
image: DecorationImage(image: MemoryImage(blurhash), fit: BoxFit.cover),
),
child: const ColoredBox(color: Color.fromRGBO(0, 0, 0, 0.2)),
),
);
child: Container(color: Colors.black.withValues(alpha: 0.2)),
);
} else {
// Fall back to using a more expensive image filtered
// Since the ImmichImage is already precached, we can
// safely use that as the image provider
return ImageFiltered(
imageFilter: ImageFilter.blur(sigmaX: 30, sigmaY: 30),
child: Container(
decoration: BoxDecoration(
image: DecorationImage(
image: getFullImageProvider(asset, size: Size(context.width, context.height)),
fit: BoxFit.cover,
),
),
child: Container(color: Colors.black.withValues(alpha: 0.2)),
),
);
}
}
}

View File

@@ -58,7 +58,11 @@ class DriftMemoryCard extends ConsumerWidget {
children: [
ColorFiltered(
colorFilter: ColorFilter.mode(Colors.black.withValues(alpha: 0.2), BlendMode.darken),
child: SizedBox(width: 205, height: 200, child: Thumbnail.fromBaseAsset(asset: memory.assets[0])),
child: SizedBox(
width: 205,
height: 200,
child: Thumbnail(remoteId: memory.assets[0].id, fit: BoxFit.cover),
),
),
Positioned(
bottom: 16,

View File

@@ -1,8 +1,9 @@
import 'dart:ui';
const double kTimelineHeaderExtent = 80.0;
const Size kTimelineFixedTileExtent = Size.square(256);
const Size kThumbnailResolution = Size.square(384);
const double kTimelineFixedTileExtentPixels = 256;
const Size kTimelineFixedTileExtent = Size.square(kTimelineFixedTileExtentPixels);
const Size kThumbnailResolution = Size.square(256);
const double kTimelineSpacing = 2.0;
const int kTimelineColumnCount = 3;

View File

@@ -4,19 +4,16 @@ import 'package:auto_route/auto_route.dart';
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/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail_tile.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/fixed/row.dart';
import 'package:immich_mobile/presentation/widgets/timeline/header.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
import 'package:immich_mobile/presentation/widgets/timeline/segment_builder.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline_drag_region.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/haptic_feedback.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/routing/router.dart';
@@ -79,21 +76,6 @@ class FixedSegment extends Segment {
spacing: spacing,
);
}
const FixedSegment.empty()
: this(
firstIndex: 0,
lastIndex: 0,
startOffset: 0,
endOffset: 0,
firstAssetIndex: 0,
bucket: const Bucket(assetCount: 0),
tileHeight: 1,
columnCount: 0,
headerExtent: 0,
spacing: 0,
header: HeaderType.none,
);
}
class _FixedSegmentRow extends ConsumerWidget {
@@ -111,45 +93,58 @@ class _FixedSegmentRow extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final isScrubbing = ref.watch(timelineStateProvider.select((s) => s.isScrubbing));
final timelineService = ref.read(timelineServiceProvider);
try {
final assets = timelineService.getAssets(assetIndex, assetCount);
return _buildAssetRow(context, assets, timelineService);
} catch (e) {
return FutureBuilder<List<BaseAsset>>(
future: timelineService.loadAssets(assetIndex, assetCount),
builder: (context, snapshot) {
return _buildAssetRow(context, snapshot.data, timelineService);
},
);
if (isScrubbing) {
return _buildPlaceholder(context);
}
if (timelineService.hasRange(assetIndex, assetCount)) {
return _buildAssetRow(context, timelineService.getAssets(assetIndex, assetCount), timelineService);
}
return FutureBuilder<List<BaseAsset>>(
future: timelineService.loadAssets(assetIndex, assetCount),
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return _buildPlaceholder(context);
}
return _buildAssetRow(context, snapshot.requireData, timelineService);
},
);
}
Widget _buildAssetRow(BuildContext context, List<BaseAsset>? assets, TimelineService timelineService) {
final assetIndex = this.assetIndex;
Widget _buildPlaceholder(BuildContext context) {
return SegmentBuilder.buildPlaceholder(context, assetCount, size: Size.square(tileHeight), spacing: spacing);
}
Widget _buildAssetRow(BuildContext context, List<BaseAsset> assets, TimelineService timelineService) {
return FixedTimelineRow(
dimension: tileHeight,
spacing: spacing,
textDirection: Directionality.of(context),
children: List.generate(assetCount, (i) {
final curAssetIndex = assetIndex + i;
return TimelineAssetIndexWrapper(
// this key is intentionally generic to preserve the state of the widget and its subtree
key: ValueKey(i.hashCode ^ timelineService.hashCode),
assetIndex: curAssetIndex,
segmentIndex: 0, // For simplicity, using 0 for now
child: _AssetTileWidget(asset: assets?[i], assetIndex: curAssetIndex),
);
}, growable: false),
children: [
for (int i = 0; i < assets.length; i++)
TimelineAssetIndexWrapper(
assetIndex: assetIndex + i,
segmentIndex: 0, // For simplicity, using 0 for now
child: _AssetTileWidget(
key: ValueKey(Object.hash(assets[i].heroTag, assetIndex + i, timelineService.hashCode)),
asset: assets[i],
assetIndex: assetIndex + i,
),
),
],
);
}
}
class _AssetTileWidget extends ConsumerWidget {
final BaseAsset? asset;
final BaseAsset asset;
final int assetIndex;
const _AssetTileWidget({required this.asset, required this.assetIndex});
const _AssetTileWidget({super.key, required this.asset, required this.assetIndex});
Future _handleOnTap(BuildContext ctx, WidgetRef ref, int assetIndex, BaseAsset asset, int? heroOffset) async {
final multiSelectState = ref.read(multiSelectProvider);
@@ -159,12 +154,6 @@ class _AssetTileWidget extends ConsumerWidget {
} else {
await ref.read(timelineServiceProvider).loadAssets(assetIndex, 1);
ref.read(isPlayingMotionVideoProvider.notifier).playing = false;
ref.read(assetViewerProvider.notifier).setAsset(asset);
ref.read(currentAssetNotifier.notifier).setAsset(asset);
if (asset.isVideo || asset.isMotionPhoto) {
ref.read(videoPlaybackValueProvider.notifier).reset();
ref.read(videoPlayerControlsProvider.notifier).pause();
}
ctx.pushRoute(
AssetViewerRoute(
initialIndex: assetIndex,
@@ -201,16 +190,17 @@ class _AssetTileWidget extends ConsumerWidget {
final lockSelection = _getLockSelectionStatus(ref);
final showStorageIndicator = ref.watch(timelineArgsProvider.select((args) => args.showStorageIndicator));
final asset = this.asset;
return GestureDetector(
onTap: () => lockSelection || asset == null ? null : _handleOnTap(context, ref, assetIndex, asset, heroOffset),
onLongPress: () => lockSelection || asset == null ? null : _handleOnLongPress(ref, asset),
child: ThumbnailTile(
asset,
lockSelection: lockSelection,
showStorageIndicator: showStorageIndicator,
heroOffset: heroOffset,
return RepaintBoundary(
child: GestureDetector(
onTap: () => lockSelection ? null : _handleOnTap(context, ref, assetIndex, asset, heroOffset),
onLongPress: () => lockSelection ? null : _handleOnLongPress(ref, asset),
child: ThumbnailTile(
asset,
lockSelection: lockSelection,
showStorageIndicator: showStorageIndicator,
heroOffset: heroOffset,
),
),
);
}

View File

@@ -1,5 +1,4 @@
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/extensions/datetime_extensions.dart';
import 'package:immich_mobile/presentation/widgets/timeline/fixed/segment.model.dart';
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
import 'package:immich_mobile/presentation/widgets/timeline/segment_builder.dart';
@@ -7,7 +6,6 @@ import 'package:immich_mobile/presentation/widgets/timeline/segment_builder.dart
class FixedSegmentBuilder extends SegmentBuilder {
final double tileHeight;
final int columnCount;
static final DateTime _dummyDate = DateTime.fromMicrosecondsSinceEpoch(0);
const FixedSegmentBuilder({
required super.buckets,
@@ -18,11 +16,12 @@ class FixedSegmentBuilder extends SegmentBuilder {
});
List<Segment> generate() {
final segments = List.filled(buckets.length, const FixedSegment.empty());
final segments = <Segment>[];
int firstIndex = 0;
double startOffset = 0;
int assetIndex = 0;
DateTime previousDate = _dummyDate;
DateTime? previousDate;
for (int i = 0; i < buckets.length; i++) {
final bucket = buckets[i];
@@ -33,10 +32,11 @@ class FixedSegmentBuilder extends SegmentBuilder {
final segmentFirstIndex = firstIndex;
firstIndex += segmentCount;
final segmentLastIndex = firstIndex - 1;
final timelineHeader = switch (groupBy) {
GroupAssetsBy.month => HeaderType.month,
GroupAssetsBy.day || GroupAssetsBy.auto =>
bucket is TimeBucket && !previousDate.isSameMonth(bucket.date) ? HeaderType.monthAndDay : HeaderType.day,
bucket is TimeBucket && bucket.date.month != previousDate?.month ? HeaderType.monthAndDay : HeaderType.day,
GroupAssetsBy.none => HeaderType.none,
};
final headerExtent = SegmentBuilder.headerExtent(timelineHeader);
@@ -45,18 +45,20 @@ class FixedSegmentBuilder extends SegmentBuilder {
startOffset += headerExtent + (tileHeight * numberOfRows) + spacing * (numberOfRows - 1);
final segmentEndOffset = startOffset;
segments[i] = FixedSegment(
firstIndex: segmentFirstIndex,
lastIndex: segmentLastIndex,
startOffset: segmentStartOffset,
endOffset: segmentEndOffset,
firstAssetIndex: assetIndex,
bucket: bucket,
tileHeight: tileHeight,
columnCount: columnCount,
headerExtent: headerExtent,
spacing: spacing,
header: timelineHeader,
segments.add(
FixedSegment(
firstIndex: segmentFirstIndex,
lastIndex: segmentLastIndex,
startOffset: segmentStartOffset,
endOffset: segmentEndOffset,
firstAssetIndex: assetIndex,
bucket: bucket,
tileHeight: tileHeight,
columnCount: columnCount,
headerExtent: headerExtent,
spacing: spacing,
header: timelineHeader,
),
);
assetIndex += assetCount;

View File

@@ -1,5 +1,8 @@
import 'package:flutter/widgets.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
import 'package:immich_mobile/presentation/widgets/timeline/fixed/row.dart';
import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart';
abstract class SegmentBuilder {
final List<Bucket> buckets;
@@ -14,4 +17,18 @@ abstract class SegmentBuilder {
HeaderType.monthAndDay => kTimelineHeaderExtent * 1.6,
HeaderType.none => 0.0,
};
static Widget buildPlaceholder(
BuildContext context,
int count, {
Size size = kTimelineFixedTileExtent,
double spacing = kTimelineSpacing,
}) => RepaintBoundary(
child: FixedTimelineRow(
dimension: size.height,
spacing: spacing,
textDirection: Directionality.of(context),
children: List.filled(count, const ThumbnailPlaceholder()),
),
);
}

View File

@@ -14,6 +14,7 @@ import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/scroll_extensions.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/scrubber.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
@@ -106,7 +107,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
bool _dragging = false;
TimelineAssetIndex? _dragAnchorIndex;
final Set<BaseAsset> _draggedAssets = HashSet();
ScrollPhysics? _scrollPhysics;
ScrollPhysics _scrollPhysics = const ScrollUnawareScrollPhysics();
int _perRow = 4;
double _scaleFactor = 3.0;
@@ -188,7 +189,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
// Drag selection methods
void _setDragStartIndex(TimelineAssetIndex index) {
setState(() {
_scrollPhysics = const ClampingScrollPhysics();
_scrollPhysics = const ScrollUnawareClampingScrollPhysics();
_dragAnchorIndex = index;
_dragging = true;
});
@@ -198,7 +199,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
WidgetsBinding.instance.addPostFrameCallback((_) {
// Update the physics post frame to prevent sudden change in physics on iOS.
setState(() {
_scrollPhysics = null;
_scrollPhysics = const ScrollUnawareScrollPhysics();
});
});
setState(() {

View File

@@ -5,7 +5,6 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/domain/services/remote_album.service.dart';
import 'package:immich_mobile/models/albums/album_search.model.dart';
import 'package:immich_mobile/utils/remote_album.utils.dart';
import 'package:logging/logging.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
@@ -71,8 +70,8 @@ class RemoteAlbumNotifier extends Notifier<RemoteAlbumState> {
state = state.copyWith(filteredAlbums: state.albums);
}
void sortFilteredAlbums(RemoteAlbumSortMode sortMode, {bool isReverse = false}) {
final sortedAlbums = _remoteAlbumService.sortAlbums(state.filteredAlbums, sortMode, isReverse: isReverse);
Future<void> sortFilteredAlbums(RemoteAlbumSortMode sortMode, {bool isReverse = false}) async {
final sortedAlbums = await _remoteAlbumService.sortAlbums(state.filteredAlbums, sortMode, isReverse: isReverse);
state = state.copyWith(filteredAlbums: sortedAlbums);
}

View File

@@ -2,7 +2,8 @@ import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/entities/asset.entity.dart' hide AssetType;
import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:photo_manager/photo_manager.dart' hide AssetType;
@@ -15,6 +16,18 @@ class FileMediaRepository {
return AssetMediaRepository.toAsset(entity);
}
Future<LocalAsset?> saveLocalAsset(Uint8List data, {required String title, String? relativePath}) async {
final entity = await PhotoManager.editor.saveImage(data, filename: title, title: title, relativePath: relativePath);
return LocalAsset(
id: entity.id,
name: title,
type: AssetType.image,
createdAt: entity.createDateTime,
updatedAt: entity.modifiedDateTime,
);
}
Future<Asset?> saveImageWithFile(String filePath, {String? title, String? relativePath}) async {
final entity = await PhotoManager.editor.saveImageWithPath(filePath, title: title, relativePath: relativePath);
return AssetMediaRepository.toAsset(entity);

View File

@@ -101,6 +101,9 @@ import 'package:immich_mobile/presentation/pages/drift_remote_album.page.dart';
import 'package:immich_mobile/presentation/pages/drift_trash.page.dart';
import 'package:immich_mobile/presentation/pages/drift_user_selection.page.dart';
import 'package:immich_mobile/presentation/pages/drift_video.page.dart';
import 'package:immich_mobile/presentation/pages/editing/drift_crop.page.dart';
import 'package:immich_mobile/presentation/pages/editing/drift_edit.page.dart';
import 'package:immich_mobile/presentation/pages/editing/drift_filter.page.dart';
import 'package:immich_mobile/presentation/pages/local_timeline.page.dart';
import 'package:immich_mobile/presentation/pages/search/drift_search.page.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.page.dart';
@@ -333,6 +336,9 @@ class AppRouter extends RootStackRouter {
AutoRoute(page: DriftBackupOptionsRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DriftAlbumOptionsRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DriftMapRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DriftEditImageRoute.page),
AutoRoute(page: DriftCropImageRoute.page),
AutoRoute(page: DriftFilterImageRoute.page),
// required to handle all deeplinks in deep_link.service.dart
// auto_route_library#1722
RedirectRoute(path: '*', redirectTo: '/'),

View File

@@ -828,6 +828,112 @@ class DriftCreateAlbumRoute extends PageRouteInfo<void> {
);
}
/// generated route for
/// [DriftCropImagePage]
class DriftCropImageRoute extends PageRouteInfo<DriftCropImageRouteArgs> {
DriftCropImageRoute({
Key? key,
required Image image,
required BaseAsset asset,
List<PageRouteInfo>? children,
}) : super(
DriftCropImageRoute.name,
args: DriftCropImageRouteArgs(key: key, image: image, asset: asset),
initialChildren: children,
);
static const String name = 'DriftCropImageRoute';
static PageInfo page = PageInfo(
name,
builder: (data) {
final args = data.argsAs<DriftCropImageRouteArgs>();
return DriftCropImagePage(
key: args.key,
image: args.image,
asset: args.asset,
);
},
);
}
class DriftCropImageRouteArgs {
const DriftCropImageRouteArgs({
this.key,
required this.image,
required this.asset,
});
final Key? key;
final Image image;
final BaseAsset asset;
@override
String toString() {
return 'DriftCropImageRouteArgs{key: $key, image: $image, asset: $asset}';
}
}
/// generated route for
/// [DriftEditImagePage]
class DriftEditImageRoute extends PageRouteInfo<DriftEditImageRouteArgs> {
DriftEditImageRoute({
Key? key,
required BaseAsset asset,
required Image image,
required bool isEdited,
List<PageRouteInfo>? children,
}) : super(
DriftEditImageRoute.name,
args: DriftEditImageRouteArgs(
key: key,
asset: asset,
image: image,
isEdited: isEdited,
),
initialChildren: children,
);
static const String name = 'DriftEditImageRoute';
static PageInfo page = PageInfo(
name,
builder: (data) {
final args = data.argsAs<DriftEditImageRouteArgs>();
return DriftEditImagePage(
key: args.key,
asset: args.asset,
image: args.image,
isEdited: args.isEdited,
);
},
);
}
class DriftEditImageRouteArgs {
const DriftEditImageRouteArgs({
this.key,
required this.asset,
required this.image,
required this.isEdited,
});
final Key? key;
final BaseAsset asset;
final Image image;
final bool isEdited;
@override
String toString() {
return 'DriftEditImageRouteArgs{key: $key, asset: $asset, image: $image, isEdited: $isEdited}';
}
}
/// generated route for
/// [DriftFavoritePage]
class DriftFavoriteRoute extends PageRouteInfo<void> {
@@ -844,6 +950,54 @@ class DriftFavoriteRoute extends PageRouteInfo<void> {
);
}
/// generated route for
/// [DriftFilterImagePage]
class DriftFilterImageRoute extends PageRouteInfo<DriftFilterImageRouteArgs> {
DriftFilterImageRoute({
Key? key,
required Image image,
required BaseAsset asset,
List<PageRouteInfo>? children,
}) : super(
DriftFilterImageRoute.name,
args: DriftFilterImageRouteArgs(key: key, image: image, asset: asset),
initialChildren: children,
);
static const String name = 'DriftFilterImageRoute';
static PageInfo page = PageInfo(
name,
builder: (data) {
final args = data.argsAs<DriftFilterImageRouteArgs>();
return DriftFilterImagePage(
key: args.key,
image: args.image,
asset: args.asset,
);
},
);
}
class DriftFilterImageRouteArgs {
const DriftFilterImageRouteArgs({
this.key,
required this.image,
required this.asset,
});
final Key? key;
final Image image;
final BaseAsset asset;
@override
String toString() {
return 'DriftFilterImageRouteArgs{key: $key, image: $image, asset: $asset}';
}
}
/// generated route for
/// [DriftLibraryPage]
class DriftLibraryRoute extends PageRouteInfo<void> {

View File

@@ -1,6 +1,7 @@
import 'package:flutter/painting.dart';
import 'package:immich_mobile/presentation/widgets/images/local_image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/thumb_hash_provider.dart';
import 'package:immich_mobile/providers/image/immich_local_image_provider.dart';
import 'package:immich_mobile/providers/image/immich_local_thumbnail_provider.dart';
import 'package:immich_mobile/providers/image/immich_remote_image_provider.dart';
@@ -9,6 +10,7 @@ import 'package:immich_mobile/providers/image/immich_remote_thumbnail_provider.d
/// [ImageCache] that uses two caches for small and large images
/// so that a single large image does not evict all small images
final class CustomImageCache implements ImageCache {
final _thumbhash = ImageCache()..maximumSize = 0;
final _small = ImageCache();
final _large = ImageCache()..maximumSize = 5; // Maximum 5 images
@@ -39,13 +41,16 @@ final class CustomImageCache implements ImageCache {
/// Gets the cache for the given key
/// [_large] is used for [ImmichLocalImageProvider] and [ImmichRemoteImageProvider]
/// [_small] is used for [ImmichLocalThumbnailProvider] and [ImmichRemoteThumbnailProvider]
ImageCache _cacheForKey(Object key) =>
(key is ImmichLocalImageProvider ||
key is ImmichRemoteImageProvider ||
key is LocalFullImageProvider ||
key is RemoteFullImageProvider)
? _large
: _small;
ImageCache _cacheForKey(Object key) {
return switch (key) {
ImmichLocalImageProvider() ||
ImmichRemoteImageProvider() ||
LocalFullImageProvider() ||
RemoteFullImageProvider() => _large,
ThumbHashProvider() => _thumbhash,
_ => _small,
};
}
@override
bool containsKey(Object key) {

View File

@@ -0,0 +1,26 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:thumbhash/thumbhash.dart' as thumbhash;
ObjectRef<Uint8List?> useBlurHashRef(Asset? asset) {
if (asset?.thumbhash == null) {
return useRef(null);
}
final rbga = thumbhash.thumbHashToRGBA(base64Decode(asset!.thumbhash!));
return useRef(thumbhash.rgbaToBmp(rbga));
}
ObjectRef<Uint8List?> useDriftBlurHashRef(RemoteAsset? asset) {
if (asset?.thumbHash == null) {
return useRef(null);
}
final rbga = thumbhash.thumbHashToRGBA(base64Decode(asset!.thumbHash!));
return useRef(thumbhash.rgbaToBmp(rbga));
}

View File

@@ -1,64 +0,0 @@
import 'package:collection/collection.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
typedef AlbumSortFn = List<RemoteAlbum> Function(List<RemoteAlbum> albums, bool isReverse);
class _RemoteAlbumSortHandlers {
const _RemoteAlbumSortHandlers._();
static const AlbumSortFn created = _sortByCreated;
static List<RemoteAlbum> _sortByCreated(List<RemoteAlbum> albums, bool isReverse) {
final sorted = albums.sortedBy((album) => album.createdAt);
return (isReverse ? sorted.reversed : sorted).toList();
}
static const AlbumSortFn title = _sortByTitle;
static List<RemoteAlbum> _sortByTitle(List<RemoteAlbum> albums, bool isReverse) {
final sorted = albums.sortedBy((album) => album.name);
return (isReverse ? sorted.reversed : sorted).toList();
}
static const AlbumSortFn lastModified = _sortByLastModified;
static List<RemoteAlbum> _sortByLastModified(List<RemoteAlbum> albums, bool isReverse) {
final sorted = albums.sortedBy((album) => album.updatedAt);
return (isReverse ? sorted.reversed : sorted).toList();
}
static const AlbumSortFn assetCount = _sortByAssetCount;
static List<RemoteAlbum> _sortByAssetCount(List<RemoteAlbum> albums, bool isReverse) {
final sorted = albums.sorted((a, b) => a.assetCount.compareTo(b.assetCount));
return (isReverse ? sorted.reversed : sorted).toList();
}
static const AlbumSortFn mostRecent = _sortByMostRecent;
static List<RemoteAlbum> _sortByMostRecent(List<RemoteAlbum> albums, bool isReverse) {
final sorted = albums.sorted((a, b) {
// For most recent, we sort by updatedAt in descending order
return b.updatedAt.compareTo(a.updatedAt);
});
return (isReverse ? sorted.reversed : sorted).toList();
}
static const AlbumSortFn mostOldest = _sortByMostOldest;
static List<RemoteAlbum> _sortByMostOldest(List<RemoteAlbum> albums, bool isReverse) {
final sorted = albums.sorted((a, b) {
// For oldest, we sort by createdAt in ascending order
return a.createdAt.compareTo(b.createdAt);
});
return (isReverse ? sorted.reversed : sorted).toList();
}
}
enum RemoteAlbumSortMode {
title("library_page_sort_title", _RemoteAlbumSortHandlers.title),
assetCount("library_page_sort_asset_count", _RemoteAlbumSortHandlers.assetCount),
lastModified("library_page_sort_last_modified", _RemoteAlbumSortHandlers.lastModified),
created("library_page_sort_created", _RemoteAlbumSortHandlers.created),
mostRecent("sort_recent", _RemoteAlbumSortHandlers.mostRecent),
mostOldest("sort_oldest", _RemoteAlbumSortHandlers.mostOldest);
final String key;
final AlbumSortFn sortFn;
const RemoteAlbumSortMode(this.key, this.sortFn);
}

View File

@@ -1,13 +1,19 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
class ThumbnailPlaceholder extends StatelessWidget {
final EdgeInsets margin;
final double width;
final double height;
const ThumbnailPlaceholder({super.key, this.margin = EdgeInsets.zero, this.width = 250, this.height = 250});
const ThumbnailPlaceholder({
super.key,
this.margin = EdgeInsets.zero,
this.width = kTimelineFixedTileExtentPixels,
this.height = kTimelineFixedTileExtentPixels,
});
@override
Widget build(BuildContext context) {

View File

@@ -0,0 +1,43 @@
import 'package:flutter/material.dart';
class FadeInPlaceholderImage extends StatelessWidget {
final Widget placeholder;
final ImageProvider image;
final Duration duration;
final BoxFit fit;
final double width;
final double height;
const FadeInPlaceholderImage({
super.key,
required this.placeholder,
required this.image,
required this.width,
required this.height,
this.duration = const Duration(milliseconds: 100),
this.fit = BoxFit.cover,
});
@override
Widget build(BuildContext context) {
final stopwatch = Stopwatch()..start();
return Image(
image: image,
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
if (frame == null) {
return AnimatedSwitcher(duration: duration, child: placeholder);
}
stopwatch.stop();
if (stopwatch.elapsedMilliseconds < 32) {
return child;
}
return AnimatedSwitcher(duration: duration, child: child);
},
filterQuality: FilterQuality.low,
fit: fit,
width: width,
height: height,
);
}
}

View File

@@ -40,12 +40,9 @@ class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget {
showDialog(context: context, useRootNavigator: false, builder: (ctx) => const ImmichAppBarDialog()),
borderRadius: const BorderRadius.all(Radius.circular(12)),
child: Badge(
label: const DecoratedBox(
decoration: BoxDecoration(
color: Colors.black,
borderRadius: BorderRadius.all(Radius.circular(widgetSize / 2)),
),
child: Icon(Icons.info, color: Color.fromARGB(255, 243, 188, 106), size: widgetSize / 2),
label: Container(
decoration: BoxDecoration(color: Colors.black, borderRadius: BorderRadius.circular(widgetSize / 2)),
child: const Icon(Icons.info, color: Color.fromARGB(255, 243, 188, 106), size: widgetSize / 2),
),
backgroundColor: Colors.transparent,
alignment: Alignment.bottomRight,

View File

@@ -3,9 +3,9 @@ import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/providers/image/immich_local_image_provider.dart';
import 'package:immich_mobile/providers/image/immich_remote_image_provider.dart';
import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart';
import 'package:octo_image/octo_image.dart';
class ImmichImage extends StatelessWidget {
@@ -14,7 +14,7 @@ class ImmichImage extends StatelessWidget {
this.width,
this.height,
this.fit = BoxFit.cover,
this.placeholder = const Thumbnail(),
this.placeholder = const ThumbnailPlaceholder(),
super.key,
});

View File

@@ -1,8 +1,11 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/providers/image/immich_local_thumbnail_provider.dart';
import 'package:immich_mobile/providers/image/immich_remote_thumbnail_provider.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/utils/hooks/blurhash_hook.dart';
import 'package:immich_mobile/utils/thumbnail_utils.dart';
import 'package:immich_mobile/widgets/common/immich_image.dart';
import 'package:immich_mobile/widgets/common/thumbhash_placeholder.dart';
@@ -39,6 +42,7 @@ class ImmichThumbnail extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
Uint8List? blurhash = useBlurHashRef(asset).value;
final userId = ref.watch(currentUserProvider)?.id;
if (asset == null) {
@@ -50,14 +54,14 @@ class ImmichThumbnail extends HookConsumerWidget {
);
}
final assetAltText = getAltText(asset!.exifInfo, asset!.fileCreatedAt, asset!.type, const []);
final assetAltText = getAltText(asset!.exifInfo, asset!.fileCreatedAt, asset!.type, []);
final thumbnailProviderInstance = ImmichThumbnail.imageProvider(asset: asset, userId: userId);
customErrorBuilder(BuildContext ctx, Object error, StackTrace? stackTrace) {
thumbnailProviderInstance.evict();
final originalErrorWidgetBuilder = blurHashErrorBuilder(asset?.thumbhash, fit: fit);
final originalErrorWidgetBuilder = blurHashErrorBuilder(blurhash, width: width, height: height, fit: fit);
return originalErrorWidgetBuilder(ctx, error, stackTrace);
}
@@ -68,7 +72,7 @@ class ImmichThumbnail extends HookConsumerWidget {
fadeInDuration: Duration.zero,
fadeOutDuration: const Duration(milliseconds: 100),
octoSet: OctoSet(
placeholderBuilder: blurHashPlaceholderBuilder(asset?.thumbhash, fit: fit),
placeholderBuilder: blurHashPlaceholderBuilder(blurhash, width: width, height: height, fit: fit),
errorBuilder: customErrorBuilder,
),
image: thumbnailProviderInstance,

View File

@@ -1,21 +1,53 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart';
import 'package:immich_mobile/widgets/common/fade_in_placeholder_image.dart';
import 'package:octo_image/octo_image.dart';
OctoPlaceholderBuilder blurHashPlaceholderBuilder(String? blurhash, {required BoxFit fit}) {
return (context) => Thumbnail(blurhash: blurhash, fit: fit);
/// Simple set to show [OctoPlaceholder.circularProgressIndicator] as
/// placeholder and [OctoError.icon] as error.
OctoSet blurHashOrPlaceholder(
Uint8List? blurhash, {
required double width,
required double height,
BoxFit? fit,
Text? errorMessage,
}) {
return OctoSet(
placeholderBuilder: blurHashPlaceholderBuilder(blurhash, width: width, height: height, fit: fit),
errorBuilder: blurHashErrorBuilder(blurhash, width: width, height: height, fit: fit, message: errorMessage),
);
}
OctoPlaceholderBuilder blurHashPlaceholderBuilder(
Uint8List? blurhash, {
required double width,
required double height,
BoxFit? fit,
}) {
return (context) => blurhash == null
? const ThumbnailPlaceholder()
: FadeInPlaceholderImage(
placeholder: const ThumbnailPlaceholder(),
image: MemoryImage(blurhash),
fit: fit ?? BoxFit.cover,
width: width,
height: height,
);
}
OctoErrorBuilder blurHashErrorBuilder(
String? blurhash, {
BoxFit fit = BoxFit.cover,
Uint8List? blurhash, {
required double width,
required double height,
BoxFit? fit,
Text? message,
IconData? icon,
Color? iconColor,
double? iconSize,
}) {
return OctoError.placeholderWithErrorIcon(
blurHashPlaceholderBuilder(blurhash, fit: fit),
blurHashPlaceholderBuilder(blurhash, width: width, height: width, fit: fit),
message: message,
icon: icon,
iconColor: iconColor,

View File

@@ -5,7 +5,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/pages/common/native_video_viewer.page.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/utils/hooks/blurhash_hook.dart';
import 'package:immich_mobile/widgets/common/immich_image.dart';
class MemoryCard extends StatelessWidget {
@@ -87,26 +87,31 @@ class _BlurredBackdrop extends HookWidget {
@override
Widget build(BuildContext context) {
final blurhash = asset.thumbhash;
final blurhash = useBlurHashRef(asset).value;
if (blurhash != null) {
// Use a nice cheap blur hash image decoration
return Thumbnail(blurhash: blurhash, fit: BoxFit.cover);
}
// Fall back to using a more expensive image filtered
// Since the ImmichImage is already precached, we can
// safely use that as the image provider
return ImageFiltered(
imageFilter: ImageFilter.blur(sigmaX: 30, sigmaY: 30),
child: DecoratedBox(
return Container(
decoration: BoxDecoration(
image: DecorationImage(
image: ImmichImage.imageProvider(asset: asset, height: context.height, width: context.width),
fit: BoxFit.cover,
),
image: DecorationImage(image: MemoryImage(blurhash), fit: BoxFit.cover),
),
child: const ColoredBox(color: Color.fromRGBO(0, 0, 0, 0.2)),
),
);
child: Container(color: Colors.black.withValues(alpha: 0.2)),
);
} else {
// Fall back to using a more expensive image filtered
// Since the ImmichImage is already precached, we can
// safely use that as the image provider
return ImageFiltered(
imageFilter: ImageFilter.blur(sigmaX: 30, sigmaY: 30),
child: Container(
decoration: BoxDecoration(
image: DecorationImage(
image: ImmichImage.imageProvider(asset: asset, height: context.height, width: context.width),
fit: BoxFit.cover,
),
),
child: Container(color: Colors.black.withValues(alpha: 0.2)),
),
);
}
}
}

View File

@@ -15,7 +15,16 @@ import 'package:pigeon/pigeon.dart';
@HostApi()
abstract class ThumbnailApi {
@async
Map<String, int> requestImage(String assetId, {required int requestId, required int width, required int height});
Map<String, int> requestImage(
String assetId, {
required int requestId,
required int width,
required int height,
required bool isVideo,
});
void cancelImageRequest(int requestId);
@async
Map<String, int> getThumbhash(String thumbhash);
}

View File

@@ -0,0 +1,116 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/services/remote_album.service.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
import 'package:mocktail/mocktail.dart';
import '../../infrastructure/repository.mock.dart';
void main() {
late RemoteAlbumService sut;
late DriftRemoteAlbumRepository mockRemoteAlbumRepo;
late DriftAlbumApiRepository mockAlbumApiRepo;
setUp(() {
mockRemoteAlbumRepo = MockRemoteAlbumRepository();
mockAlbumApiRepo = MockDriftAlbumApiRepository();
sut = RemoteAlbumService(mockRemoteAlbumRepo, mockAlbumApiRepo);
when(() => mockRemoteAlbumRepo.getNewestAssetTimestamp(any())).thenAnswer((invocation) {
// Simulate a timestamp for the newest asset in the album
final albumID = invocation.positionalArguments[0] as String;
if (albumID == '1') {
return Future.value(DateTime(2023, 1, 1));
} else if (albumID == '2') {
return Future.value(DateTime(2023, 2, 1));
}
return Future.value(DateTime.fromMillisecondsSinceEpoch(0));
});
when(() => mockRemoteAlbumRepo.getOldestAssetTimestamp(any())).thenAnswer((invocation) {
// Simulate a timestamp for the oldest asset in the album
final albumID = invocation.positionalArguments[0] as String;
if (albumID == '1') {
return Future.value(DateTime(2019, 1, 1));
} else if (albumID == '2') {
return Future.value(DateTime(2019, 2, 1));
}
return Future.value(DateTime.fromMillisecondsSinceEpoch(0));
});
});
final albumA = RemoteAlbum(
id: '1',
name: 'Album A',
description: "",
isActivityEnabled: false,
order: AlbumAssetOrder.asc,
assetCount: 1,
createdAt: DateTime(2023, 1, 1),
updatedAt: DateTime(2023, 1, 2),
ownerId: 'owner1',
ownerName: "Test User",
);
final albumB = RemoteAlbum(
id: '2',
name: 'Album B',
description: "",
isActivityEnabled: false,
order: AlbumAssetOrder.desc,
assetCount: 2,
createdAt: DateTime(2023, 2, 1),
updatedAt: DateTime(2023, 2, 2),
ownerId: 'owner2',
ownerName: "Test User",
);
group('sortAlbums', () {
test('should sort correctly based on name', () async {
final albums = [albumB, albumA];
final result = await sut.sortAlbums(albums, RemoteAlbumSortMode.title);
expect(result, [albumA, albumB]);
});
test('should sort correctly based on createdAt', () async {
final albums = [albumB, albumA];
final result = await sut.sortAlbums(albums, RemoteAlbumSortMode.created);
expect(result, [albumA, albumB]);
});
test('should sort correctly based on updatedAt', () async {
final albums = [albumB, albumA];
final result = await sut.sortAlbums(albums, RemoteAlbumSortMode.lastModified);
expect(result, [albumA, albumB]);
});
test('should sort correctly based on assetCount', () async {
final albums = [albumB, albumA];
final result = await sut.sortAlbums(albums, RemoteAlbumSortMode.assetCount);
expect(result, [albumA, albumB]);
});
test('should sort correctly based on newestAssetTimestamp', () async {
final albums = [albumB, albumA];
final result = await sut.sortAlbums(albums, RemoteAlbumSortMode.mostRecent);
expect(result, [albumA, albumB]);
});
test('should sort correctly based on oldestAssetTimestamp', () async {
final albums = [albumB, albumA];
final result = await sut.sortAlbums(albums, RemoteAlbumSortMode.mostOldest);
expect(result, [albumB, albumA]);
});
});
}

View File

@@ -2,12 +2,14 @@ import 'package:immich_mobile/infrastructure/repositories/device_asset.repositor
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/log.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/user.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/user_api.repository.dart';
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
import 'package:mocktail/mocktail.dart';
class MockStoreRepository extends Mock implements IsarStoreRepository {}
@@ -22,6 +24,8 @@ class MockSyncStreamRepository extends Mock implements SyncStreamRepository {}
class MockLocalAlbumRepository extends Mock implements DriftLocalAlbumRepository {}
class MockRemoteAlbumRepository extends Mock implements DriftRemoteAlbumRepository {}
class MockLocalAssetRepository extends Mock implements DriftLocalAssetRepository {}
class MockStorageRepository extends Mock implements StorageRepository {}
@@ -30,3 +34,5 @@ class MockStorageRepository extends Mock implements StorageRepository {}
class MockUserApiRepository extends Mock implements UserApiRepository {}
class MockSyncApiRepository extends Mock implements SyncApiRepository {}
class MockDriftAlbumApiRepository extends Mock implements DriftAlbumApiRepository {}

View File

@@ -261,8 +261,12 @@ describe(DatabaseService.name, () => {
await expect(sut.onBootstrap()).rejects.toThrow('Failed to update extension');
expect(mocks.logger.warn.mock.calls[0][0]).toContain(
`The ${extensionName} extension can be updated to ${updateInRange}.`,
expect(mocks.logger.warn.mock.calls).toEqual(
expect.arrayContaining([
expect.arrayContaining([
expect.stringContaining(`The ${extensionName} extension can be updated to ${updateInRange}.`),
]),
]),
);
expect(mocks.logger.fatal).not.toHaveBeenCalled();
expect(mocks.database.updateVectorExtension).toHaveBeenCalledWith(extension, updateInRange);
@@ -281,8 +285,10 @@ describe(DatabaseService.name, () => {
await expect(sut.onBootstrap()).resolves.toBeUndefined();
expect(mocks.logger.warn).toHaveBeenCalledTimes(1);
expect(mocks.logger.warn.mock.calls[0][0]).toContain(extensionName);
expect(mocks.logger.warn.mock.calls).toEqual(
expect.arrayContaining([expect.arrayContaining([expect.stringContaining(extensionName)])]),
);
expect(mocks.database.updateVectorExtension).toHaveBeenCalledWith(extension, updateInRange);
expect(mocks.database.runMigrations).toHaveBeenCalledTimes(1);
expect(mocks.logger.fatal).not.toHaveBeenCalled();
@@ -415,5 +421,21 @@ describe(DatabaseService.name, () => {
expect(mocks.database.dropExtension).not.toHaveBeenCalled();
});
it(`should warn if using pgvecto.rs`, async () => {
mocks.database.getExtensionVersions.mockResolvedValue([
{
name: DatabaseExtension.Vectors,
installedVersion: minVersionInRange,
availableVersion: minVersionInRange,
},
]);
mocks.database.getVectorExtension.mockResolvedValue(DatabaseExtension.Vectors);
await expect(sut.onBootstrap()).resolves.toBeUndefined();
expect(mocks.logger.warn).toHaveBeenCalledTimes(1);
expect(mocks.logger.warn.mock.calls[0][0]).toContain('DEPRECATION WARNING');
});
});
});

View File

@@ -53,6 +53,9 @@ const messages = {
`The database currently has ${name} ${installedVersion} activated, but the Postgres instance only has ${availableVersion} available.
This most likely means the extension was downgraded.
If ${name} ${installedVersion} is compatible with Immich, please ensure the Postgres instance has this available.`,
deprecatedExtension: (name: string) =>
`DEPRECATION WARNING: The ${name} extension is deprecated and support for it will be removed very soon.
See https://immich.app/docs/install/upgrading#migrating-to-vectorchord in order to switch to the VectorChord extension instead.`,
};
@Injectable()
@@ -71,6 +74,9 @@ export class DatabaseService extends BaseService {
await this.databaseRepository.withLock(DatabaseLock.Migrations, async () => {
const extension = await this.databaseRepository.getVectorExtension();
const name = EXTENSION_NAMES[extension];
if (extension === DatabaseExtension.Vectors) {
this.logger.warn(messages.deprecatedExtension(name));
}
const extensionRange = this.databaseRepository.getExtensionVersionRange(extension);
const extensionVersions = await this.databaseRepository.getExtensionVersions(VECTOR_EXTENSIONS);

View File

@@ -1,16 +1,22 @@
<script lang="ts">
import Icon from '$lib/components/elements/icon.svelte';
import FormatBoldMessage from '$lib/components/i18n/format-bold-message.svelte';
import FormatMessage from '$lib/components/i18n/format-message.svelte';
import { Heading, HStack, Stack } from '@immich/ui';
import { mdiAlert } from '@mdi/js';
import { Stack } from '@immich/ui';
import { mdiAlertCircleOutline } from '@mdi/js';
import type { Translations } from 'svelte-i18n';
const messageKeys = [
'admin.backup_onboarding_3_description',
'admin.backup_onboarding_2_description',
'admin.backup_onboarding_1_description',
];
</script>
<div class="flex flex-col">
<Stack gap={2}>
<HStack gap={4}>
<Icon path={mdiAlert} size="96" class="text-warning" />
<p class="mb-2">
<div class="flex items-start gap-4 p-6 my-10 bg-gray-100 dark:bg-gray-800/40 rounded-xl border border-gray-700/50">
<Icon path={mdiAlertCircleOutline} size="36" class="text-warning flex-shrink-0 mt-0.5" />
<div class="text-gray-800 dark:text-gray-300 leading-relaxed">
<FormatMessage key="admin.backup_onboarding_description">
{#snippet children({ message })}
<a
@@ -23,40 +29,41 @@
</a>
{/snippet}
</FormatMessage>
</div>
</div>
<div class="space-y-1">
<h2 class="mb-6"><FormatMessage key="admin.backup_onboarding_parts_title" /></h2>
<div class="space-y-6">
{#each messageKeys as keyString, index (index)}
<div class="flex items-start gap-6">
<div class="flex-shrink-0 w-12 h-12 rounded-full bg-primary/90 flex items-center justify-center">
<span class="text-light text-xl font-semibold">{3 - index}</span>
</div>
<div class="leading-relaxed pt-2">
<FormatMessage key={keyString as Translations} />
</div>
</div>
{/each}
</div>
</div>
<div class="text-sm text-gray-600 dark:text-gray-400 leading-relaxed mt-4">
<p>
<FormatMessage key="admin.backup_onboarding_footer">
{#snippet children({ message })}
<a
href="https://immich.app/docs/administration/backup-and-restore/"
class="underline"
target="_blank"
rel="noreferrer"
>
{message}
</a>
{/snippet}
</FormatMessage>
</p>
</HStack>
<p class="text-lg font-semibold">
<FormatBoldMessage key="admin.backup_onboarding_parts_title"></FormatBoldMessage>
</p>
<Stack class="bg-gray-100 dark:bg-gray-800 rounded-xl p-4" gap={4}>
<HStack gap={6}>
<Heading tag="h1" size="title" color="primary">3</Heading>
<FormatMessage key="admin.backup_onboarding_3_description" />
</HStack>
<HStack gap={6}>
<Heading tag="h1" size="title" color="primary">2</Heading>
<FormatMessage key="admin.backup_onboarding_2_description" />
</HStack>
<HStack gap={6} class="ml-2">
<Heading tag="h1" size="title" color="primary">1</Heading>
<FormatMessage key="admin.backup_onboarding_1_description" />
</HStack>
</Stack>
<p>
<FormatMessage key="admin.backup_onboarding_footer">
{#snippet children({ message })}
<a
href="https://immich.app/docs/administration/backup-and-restore/"
class="underline"
target="_blank"
rel="noreferrer"
>
{message}
</a>
{/snippet}
</FormatMessage>
</p>
</div>
</Stack>
</div>

View File

@@ -31,7 +31,7 @@
<div
id="onboarding-card"
class="flex w-full max-w-4xl flex-col gap-4 rounded-3xl border-2 border-gray-500 px-8 py-8 dark:border-immich-dark-gray dark:bg-immich-dark-gray text-black dark:text-immich-dark-fg bg-gray-50"
class="flex w-full max-w-4xl flex-col gap-4 rounded-3xl border-2 border-gray-500 px-8 py-8 dark:border-gray-700 dark:bg-immich-dark-gray text-black dark:text-immich-dark-fg bg-gray-50"
in:fade={{ duration: 250 }}
>
{#if title || icon}

View File

@@ -12,7 +12,7 @@
playVideoThumbnailOnHover,
showDeleteModal,
} from '$lib/stores/preferences.store';
import { findLocale } from '$lib/utils';
import { createDateFormatter, findLocale } from '$lib/utils';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
@@ -48,21 +48,7 @@
}
};
let editedLocale = $derived(findLocale($locale).code);
let formattedDate = $derived(
time.toLocaleString(editedLocale, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
}),
);
let timePortion = $derived(
time.toLocaleString(editedLocale, {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
}),
);
let selectedDate = $derived(`${formattedDate} ${timePortion}`);
let selectedDate: string = $derived(createDateFormatter(editedLocale).formatDateTime(time));
let selectedOption = $derived({
value: findLocale(editedLocale).code || fallbackLocale.code,
label: findLocale(editedLocale).name || fallbackLocale.name,

View File

@@ -36,6 +36,12 @@ interface DownloadRequestOptions<T = unknown> {
onDownloadProgress?: (event: ProgressEvent<XMLHttpRequestEventTarget>) => void;
}
interface DateFormatter {
formatDate: (date: Date) => string;
formatTime: (date: Date) => string;
formatDateTime: (date: Date) => string;
}
export const initLanguage = async () => {
const preferenceLang = get(lang);
for (const { code, loader } of langs) {
@@ -343,3 +349,35 @@ export const withError = async <T>(fn: () => Promise<T>): Promise<[undefined, T]
// eslint-disable-next-line unicorn/prefer-code-point
export const decodeBase64 = (data: string) => Uint8Array.from(atob(data), (c) => c.charCodeAt(0));
export function createDateFormatter(localeCode: string | undefined): DateFormatter {
return {
formatDate: (date: Date): string =>
date.toLocaleString(localeCode, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
}),
formatTime: (date: Date): string =>
date.toLocaleString(localeCode, {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
}),
formatDateTime: (date: Date): string => {
const formattedDate = date.toLocaleString(localeCode, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
const formattedTime = date.toLocaleString(localeCode, {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
return `${formattedDate} ${formattedTime}`;
},
};
}

View File

@@ -13,6 +13,7 @@
import UserRestoreConfirmModal from '$lib/modals/UserRestoreConfirmModal.svelte';
import { locale } from '$lib/stores/preferences.store';
import { user as authUser } from '$lib/stores/user.store';
import { createDateFormatter, findLocale } from '$lib/utils';
import { getBytesWithUnit } from '$lib/utils/byte-units';
import { handleError } from '$lib/utils/handle-error';
import { updateUserAdmin } from '@immich/sdk';
@@ -70,6 +71,12 @@
let canResetPassword = $derived($authUser.id !== user.id);
let newPassword = $state<string>('');
let editedLocale = $derived(findLocale($locale).code);
let createAtDate: Date = $derived(new Date(user.createdAt));
let updatedAtDate: Date = $derived(new Date(user.updatedAt));
let userCreatedAtDateAndTime: string = $derived(createDateFormatter(editedLocale).formatDateTime(createAtDate));
let userUpdatedAtDateAndTime: string = $derived(createDateFormatter(editedLocale).formatDateTime(updatedAtDate));
const handleEdit = async () => {
const result = await modalManager.show(UserEditModal, { user: { ...user } });
if (result) {
@@ -266,11 +273,11 @@
</div>
<div>
<Heading tag="h3" size="tiny">{$t('created_at')}</Heading>
<Text>{user.createdAt}</Text>
<Text>{userCreatedAtDateAndTime}</Text>
</div>
<div>
<Heading tag="h3" size="tiny">{$t('updated_at')}</Heading>
<Text>{user.updatedAt}</Text>
<Text>{userUpdatedAtDateAndTime}</Text>
</div>
<div>
<Heading tag="h3" size="tiny">{$t('id')}</Heading>

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import OnboardingBackup from '$lib/components/onboarding-page/onboarding-backup.svelte';
import OnboardingCard from '$lib/components/onboarding-page/onboarding-card.svelte';
import OnboardingHello from '$lib/components/onboarding-page/onboarding-hello.svelte';
import OnboardingLocale from '$lib/components/onboarding-page/onboarding-language.svelte';
@@ -8,13 +9,12 @@
import OnboardingStorageTemplate from '$lib/components/onboarding-page/onboarding-storage-template.svelte';
import OnboardingTheme from '$lib/components/onboarding-page/onboarding-theme.svelte';
import OnboardingUserPrivacy from '$lib/components/onboarding-page/onboarding-user-privacy.svelte';
import OnboardingBackup from '$lib/components/onboarding-page/onboarding-backup.svelte';
import { AppRoute, QueryParameter } from '$lib/constants';
import { OnboardingRole } from '$lib/models/onboarding-role';
import { retrieveServerConfig, retrieveSystemConfig, serverConfig } from '$lib/stores/server-config.store';
import { user } from '$lib/stores/user.store';
import { setUserOnboarding, updateAdminOnboarding } from '@immich/sdk';
import { mdiCloudUpload, mdiHarddisk, mdiIncognito, mdiThemeLightDark, mdiTranslate } from '@mdi/js';
import { mdiCloudCheckOutline, mdiHarddisk, mdiIncognito, mdiThemeLightDark, mdiTranslate } from '@mdi/js';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
@@ -74,7 +74,7 @@
component: OnboardingBackup,
role: OnboardingRole.SERVER,
title: $t('admin.backup_onboarding_title'),
icon: mdiCloudUpload,
icon: mdiCloudCheckOutline,
},
]);