Compare commits

..

1 Commits

Author SHA1 Message Date
99a436571b Optimize timeline visibility
Due to "Recursion detected" bugs in slint, we need to calculate the
layout of the timeline in Rust. This is ugly, but works.
2026-04-26 12:02:38 +02:00
26 changed files with 308 additions and 1413 deletions

4
.gitignore vendored
View File

@@ -2,7 +2,3 @@
# These are backup files generated by rustfmt # These are backup files generated by rustfmt
**/*.rs.bk **/*.rs.bk
# Flatpak stuff
/repo
*.flatpak

52
Cargo.lock generated
View File

@@ -2425,7 +2425,6 @@ dependencies = [
"ravif", "ravif",
"rayon", "rayon",
"rgb", "rgb",
"serde",
"tiff", "tiff",
"zune-core", "zune-core",
"zune-jpeg", "zune-jpeg",
@@ -2461,9 +2460,6 @@ dependencies = [
"base64", "base64",
"blurhash", "blurhash",
"clap", "clap",
"either",
"futures",
"image",
"immich-sdk", "immich-sdk",
"kameo", "kameo",
"serde", "serde",
@@ -2471,24 +2467,19 @@ dependencies = [
"slint", "slint",
"slint-build", "slint-build",
"thumbhash", "thumbhash",
"tikv-jemallocator",
"tokio", "tokio",
"toml 1.1.2+spec-1.1.0",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"xdg",
] ]
[[package]] [[package]]
name = "immich-sdk" name = "immich-sdk"
version = "1.137.0" version = "1.137.0"
source = "git+https://git.nubo.sh/hulthe/immich-sdk.git?rev=c0bde4f8bd50d2861548666297f40bed3b85b865#c0bde4f8bd50d2861548666297f40bed3b85b865"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"bytes", "bytes",
"chrono", "chrono",
"image", "image",
"mime",
"reqwest", "reqwest",
"serde", "serde",
"serde_json", "serde_json",
@@ -4801,7 +4792,7 @@ dependencies = [
"regex", "regex",
"serde_json", "serde_json",
"tar", "tar",
"toml 0.9.12+spec-1.1.0", "toml",
] ]
[[package]] [[package]]
@@ -5293,26 +5284,6 @@ dependencies = [
"zune-jpeg", "zune-jpeg",
] ]
[[package]]
name = "tikv-jemalloc-sys"
version = "0.6.1+5.3.0-1-ge13ca993e8ccb9ba9847cc330696e02839f328f7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd8aa5b2ab86a2cefa406d889139c162cbb230092f7d1d7cbc1716405d852a3b"
dependencies = [
"cc",
"libc",
]
[[package]]
name = "tikv-jemallocator"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0359b4327f954e0567e69fb191cf1436617748813819c94b8cd4a431422d053a"
dependencies = [
"libc",
"tikv-jemalloc-sys",
]
[[package]] [[package]]
name = "tiny-skia" name = "tiny-skia"
version = "0.11.4" version = "0.11.4"
@@ -5457,21 +5428,6 @@ dependencies = [
"winnow 0.7.15", "winnow 0.7.15",
] ]
[[package]]
name = "toml"
version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee"
dependencies = [
"indexmap",
"serde_core",
"serde_spanned",
"toml_datetime 1.1.1+spec-1.1.0",
"toml_parser",
"toml_writer",
"winnow 1.0.2",
]
[[package]] [[package]]
name = "toml_datetime" name = "toml_datetime"
version = "0.7.5+spec-1.1.0" version = "0.7.5+spec-1.1.0"
@@ -6842,12 +6798,6 @@ version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b" checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b"
[[package]]
name = "xdg"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2fb433233f2df9344722454bc7e96465c9d03bff9d77c248f9e7523fe79585b5"
[[package]] [[package]]
name = "xkbcommon" name = "xkbcommon"
version = "0.9.0" version = "0.9.0"

View File

@@ -8,9 +8,7 @@ anyhow = "1.0.102"
base64 = "0.22.1" base64 = "0.22.1"
blurhash = "0.2.3" blurhash = "0.2.3"
clap = { version = "4.6.0", features = ["derive", "env"] } clap = { version = "4.6.0", features = ["derive", "env"] }
either = "1.15.0" immich-sdk.path = "../immich-sdk/"
futures = "0.3.32"
image = { version = "0.25.10", default-features = false, features = ["serde", "webp"] }
kameo = "0.19.2" kameo = "0.19.2"
serde = { version = "1.0.228", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149" serde_json = "1.0.149"
@@ -18,14 +16,6 @@ thumbhash = "0.1.0"
tokio = { version = "1.51.0", features = ["full"] } tokio = { version = "1.51.0", features = ["full"] }
tracing = "0.1.44" tracing = "0.1.44"
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
xdg = "3.0.0"
tikv-jemallocator = "0.6"
toml = "1.1.2"
[dependencies.immich-sdk]
#path = "../immich-sdk"
git = "https://git.nubo.sh/hulthe/immich-sdk.git"
rev = "c0bde4f8bd50d2861548666297f40bed3b85b865"
[dependencies.slint] [dependencies.slint]
version = "1.16.1" version = "1.16.1"

View File

@@ -1,29 +0,0 @@
# immich-rs
Desktop client for https://immich.app/
## Building (Flatpak)
1) Install flatpak
2) Install runtime and SDK
```
flatpak --user install flathub \
org.freedesktop.Platform//25.08 \
org.freedesktop.Sdk//25.08 \
org.freedesktop.Sdk.Extension.rust-stable//25.08
```
3) Build flatpak
```
flatpak-builder --user --force-clean --repo=repo build-dir sh.nubo.immich-rs.yml
```
Add `--disable-rofiles-fuse` if running in an environment without FUSE.
4) Export flatpak bundle
```
flatpak build-bundle repo sh.nubo.immich-rs.flatpak sh.nubo.immich-rs
```

View File

@@ -1 +0,0 @@
<svg width="1.375em" height="1.375em" viewBox="0 0 24 24" class="shrink-0 -scale-x-100 svelte-ztbs85" stroke="transparent" stroke-width="2" role="img" aria-hidden="true"><!----><!----><path d="M6,19L9,15.14L11.14,17.72L14.14,13.86L18,19H6M6,4H11V12L8.5,10.5L6,12M18,2H6A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V4A2,2 0 0,0 18,2Z" fill="currentColor"></path></svg>

Before

Width:  |  Height:  |  Size: 374 B

View File

@@ -1,44 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
class="text-primary svelte-ztbs85"
stroke="transparent"
stroke-width="2"
role="img"
version="1.1"
id="svg1"
sodipodi:docname="checked.svg"
inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs1" />
<sodipodi:namedview
id="namedview1"
pagecolor="#505050"
bordercolor="#eeeeee"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#505050"
inkscape:zoom="13.470055"
inkscape:cx="12.583468"
inkscape:cy="5.8277418"
inkscape:window-width="1555"
inkscape:window-height="1000"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg1" />
<!---->
<!---->
<path
d="M12 2C6.5 2 2 6.5 2 12S6.5 22 12 22 22 17.5 22 12 17.5 2 12 2M10 17L5 12L6.41 10.59L10 14.17L17.59 6.58L19 8L10 17Z"
fill="currentColor"
id="path1" />
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -1,18 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: Adobe Illustrator 28.3.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Flower" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 792 792" style="enable-background:new 0 0 792 792;" xml:space="preserve">
<style type="text/css">
.st0{fill:#FA2921;}
.st1{fill:#ED79B5;}
.st2{fill:#FFB400;}
.st3{fill:#1E83F7;}
.st4{fill:#18C249;}
</style>
<g id="Flower_00000077325900055813483940000000694823054982625702_">
<path class="st0" d="M375.48,267.63c38.64,34.21,69.78,70.87,89.82,105.42c34.42-61.56,57.42-134.71,57.71-181.3 c0-0.33,0-0.63,0-0.91c0-68.94-68.77-95.77-128.01-95.77s-128.01,26.83-128.01,95.77c0,0.94,0,2.2,0,3.72 C300.01,209.24,339.15,235.47,375.48,267.63z"/>
<path class="st1" d="M164.7,455.63c24.15-26.87,61.2-55.99,103.01-80.61c44.48-26.18,88.97-44.47,128.02-52.84 c-47.91-51.76-110.37-96.24-154.6-110.91c-0.31-0.1-0.6-0.19-0.86-0.28c-65.57-21.3-112.34,35.81-130.64,92.15 c-18.3,56.34-14.04,130.04,51.53,151.34C162.05,454.77,163.25,455.16,164.7,455.63z"/>
<path class="st2" d="M681.07,302.19c-18.3-56.34-65.07-113.45-130.64-92.15c-0.9,0.29-2.1,0.68-3.54,1.15 c-3.75,35.93-16.6,81.27-35.96,125.76c-20.59,47.32-45.84,88.27-72.51,118c69.18,13.72,145.86,12.98,190.26-1.14 c0.31-0.1,0.6-0.2,0.86-0.28C695.11,432.22,699.37,358.52,681.07,302.19z"/>
<path class="st3" d="M336.54,510.71c-11.15-50.39-14.8-98.36-10.7-138.08c-64.03,29.57-125.63,75.23-153.26,112.76 c-0.19,0.26-0.37,0.51-0.53,0.73c-40.52,55.78-0.66,117.91,47.27,152.72c47.92,34.82,119.33,53.54,159.86-2.24 c0.56-0.76,1.3-1.78,2.19-3.01C363.28,602.32,347.02,558.08,336.54,510.71z"/>
<path class="st4" d="M617.57,482.52c-35.33,7.54-82.42,9.33-130.72,4.66c-51.37-4.96-98.11-16.32-134.63-32.5 c8.33,70.03,32.73,142.73,59.88,180.6c0.19,0.26,0.37,0.51,0.53,0.73c40.52,55.78,111.93,37.06,159.86,2.24 c47.92-34.82,87.79-96.95,47.27-152.72C619.2,484.77,618.46,483.75,617.57,482.52z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -1,7 +0,0 @@
[Desktop Entry]
Name=immich-rs
Comment=Immich desktop client
Exec=immich-rs
Icon=sh.nubo.immich-rs
Type=Application
Categories=Network;Photography;AudioVideo;

View File

@@ -1 +0,0 @@
<svg width="1.375em" height="1.375em" viewBox="0 0 24 24" class="shrink-0 svelte-ztbs85" stroke="transparent" stroke-width="2" role="img" aria-hidden="true"><!----><!----><path d="M22,16V4A2,2 0 0,0 20,2H8A2,2 0 0,0 6,4V16A2,2 0 0,0 8,18H20A2,2 0 0,0 22,16M11,12L13.03,14.71L16,11L20,16H8M2,6V20A2,2 0 0,0 4,22H18V20H4V6" fill="currentColor"></path></svg>

Before

Width:  |  Height:  |  Size: 356 B

View File

@@ -1 +0,0 @@
<svg width="1.375em" height="1.375em" viewBox="0 0 24 24" class="shrink-0 svelte-ztbs85" stroke="transparent" stroke-width="2" role="img" aria-hidden="true"><!----><!----><path d="M9.5,3A6.5,6.5 0 0,1 16,9.5C16,11.11 15.41,12.59 14.44,13.73L14.71,14H15.5L20.5,19L19,20.5L14,15.5V14.71L13.73,14.44C12.59,15.41 11.11,16 9.5,16A6.5,6.5 0 0,1 3,9.5A6.5,6.5 0 0,1 9.5,3M9.5,5C7,5 5,7 5,9.5C5,12 7,14 9.5,14C12,14 14,12 14,9.5C14,7 12,5 9.5,5Z" fill="currentColor"></path></svg>

Before

Width:  |  Height:  |  Size: 473 B

View File

@@ -1,47 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
class="text-primary svelte-ztbs85"
stroke="transparent"
stroke-width="2"
role="img"
version="1.1"
id="svg1"
sodipodi:docname="unchecked.svg"
inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs1" />
<sodipodi:namedview
id="namedview1"
pagecolor="#505050"
bordercolor="#eeeeee"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#505050"
inkscape:zoom="11.92186"
inkscape:cx="32.922716"
inkscape:cy="21.682859"
inkscape:window-width="1555"
inkscape:window-height="1000"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg1" />
<!---->
<!---->
<ellipse
style="fill:none;stroke:#000000;stroke-width:1.91177;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
id="path2"
cx="12"
cy="11.999998"
rx="9.0441151"
ry="9.0441122" />
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -1,41 +0,0 @@
app-id: sh.nubo.immich-rs
runtime: org.freedesktop.Platform
runtime-version: '25.08'
sdk: org.freedesktop.Sdk
sdk-extensions:
- org.freedesktop.Sdk.Extension.rust-stable
build-options:
append-path: /usr/lib/sdk/rust-stable/bin
build-args:
- --share=network
command: immich-rs
finish-args:
# Core Wayland access
- --socket=wayland
# GPU acceleration (DRI)
- --device=dri
# Access to XDG dirs
- --filesystem=xdg-config/immich-rs:create
- --filesystem=xdg-data/immich-rs:create
- --filesystem=xdg-cache/immich-rs:create
# Talk to portal for file dialogs, etc.
- --talk-name=org.freedesktop.portal.Desktop
# Network access
- --share=network
modules:
- name: immich-rs
buildsystem: simple
build-commands:
- cargo build --release --locked
- install -Dm755 ./target/release/immich-rs -t /app/bin/
- install -Dm644 ./assets/immich-rs.desktop /app/share/applications/sh.nubo.immich-rs.desktop
- install -Dm644 ./assets/immich-logo.svg /app/share/icons/hicolor/scalable/apps/sh.nubo.immich-rs.svg
# - install -Dm644 ./assets/sh.nubo.immich-rs.metainfo.xml -t /app/share/metainfo/
sources:
- type: dir
path: .

View File

@@ -1,157 +1,44 @@
use std::{ use std::{collections::HashMap, iter::repeat, sync::Arc};
collections::HashMap,
io::Cursor,
iter::repeat,
sync::{Arc, Mutex},
};
use anyhow::{Context as _, anyhow}; use anyhow::{Context as _, anyhow};
use image::{ use immich_sdk::{AssetId, AssetVisibility};
DynamicImage, EncodableLayout, use kameo::{
codecs::webp::{WebPDecoder, WebPEncoder}, Actor,
prelude::{Context, Message},
}; };
use immich_sdk::{AlbumResponse, AssetId, AssetVisibility};
use slint::{Rgba8Pixel, SharedPixelBuffer}; use slint::{Rgba8Pixel, SharedPixelBuffer};
use crate::{ use crate::thumbhash::thumbhashes_to_pixels;
cachemap::{AsyncFnFetcher, CacheMap, Fetcher},
thumbhash::thumbhashes_to_pixels,
};
pub type TimeBucketKey = String; pub type TimeBucketKey = String;
#[derive(Actor)]
pub struct Api { pub struct Api {
client: immich_sdk::Client, client: immich_sdk::Client,
buckets: Mutex<HashMap<TimeBucketKey, Arc<TimeBucket>>>, buckets: HashMap<TimeBucketKey, Arc<TimeBucket>>,
thumbnails: Mutex<CacheMap<AssetId, Arc<AssetThumbnail>>>, thumbnails: HashMap<AssetId, Arc<AssetThumbnail>>, // TODO
assets: Mutex<CacheMap<AssetId, Arc<AssetThumbnail>>>,
}
fn serialize_thumbnail(_id: &AssetId, thumbnail: &Arc<AssetThumbnail>) -> Vec<u8> {
let image: &SharedPixelBuffer<Rgba8Pixel> = &thumbnail.thumbnail;
let mut webp = vec![];
WebPEncoder::new_lossless(&mut webp)
.encode(
image.as_bytes(),
image.width(),
image.height(),
image::ExtendedColorType::Rgba8,
)
.expect("width and height matches image.as_bytes().len()");
webp
}
fn deserialize_thumbnail(&id: &AssetId, bytes: &[u8]) -> anyhow::Result<Arc<AssetThumbnail>> {
let image = WebPDecoder::new(Cursor::new(bytes))
.and_then(DynamicImage::from_decoder)
.context("Failed to decode image")?
.to_rgba8();
let image = SharedPixelBuffer::<Rgba8Pixel>::clone_from_slice(
image.as_bytes(),
image.width(),
image.height(),
);
Ok(Arc::new(AssetThumbnail {
id,
thumbnail: image,
}))
} }
impl Api { impl Api {
pub fn new(client: immich_sdk::Client) -> Arc<Self> { pub fn new(client: immich_sdk::Client) -> Self {
let client_ = client.clone(); Self {
let asset_fetcher = AsyncFnFetcher::new(move |asset_id: &AssetId| {
let client = client_.clone();
let asset_id = asset_id.clone();
tokio::spawn(async move {
let asset = client
.assets()
.download(asset_id)
.edited()
.execute()
.await
.context(anyhow!("Failed to get asset {asset_id}"))?;
let asset = asset
.decode()
.context(anyhow!("Failed to decode asset {asset_id}"))?
.into_rgba8();
let pixel_buffer = SharedPixelBuffer::<Rgba8Pixel>::clone_from_slice(
&asset,
asset.width(),
asset.height(),
);
Ok(Arc::new(AssetThumbnail {
id: asset_id,
thumbnail: pixel_buffer,
}))
})
});
let asset_map = CacheMap::new(
Arc::new(asset_fetcher) as Arc<dyn Fetcher<Arc<AssetThumbnail>, Key = AssetId>>,
"assets",
serialize_thumbnail,
deserialize_thumbnail,
)
.unwrap();
let client_ = client.clone();
let thumbnail_fetcher = AsyncFnFetcher::new(move |asset_id: &AssetId| {
let client = client_.clone();
let asset_id = asset_id.clone();
tokio::spawn(async move {
let asset = client
.assets()
.thumbnail(asset_id)
.size(immich_sdk::AssetMediaSize::Thumbnail)
.execute()
.await
.context(anyhow!("Failed to get asset thumbnail for {asset_id}"))?;
let thumbnail = asset
.decode()
.context(anyhow!("Failed to decode asset thumbnail for {asset_id}"))?
.into_rgba8();
let pixel_buffer = SharedPixelBuffer::<Rgba8Pixel>::clone_from_slice(
&thumbnail,
thumbnail.width(),
thumbnail.height(),
);
Ok(Arc::new(AssetThumbnail {
id: asset_id,
thumbnail: pixel_buffer,
}))
})
});
let thumbnail_map = CacheMap::new(
Arc::new(thumbnail_fetcher) as Arc<dyn Fetcher<Arc<AssetThumbnail>, Key = AssetId>>,
"thumbnails",
serialize_thumbnail,
deserialize_thumbnail,
)
.unwrap();
Arc::new(Self {
client, client,
buckets: Default::default(), buckets: Default::default(),
thumbnails: Mutex::new(thumbnail_map), thumbnails: Default::default(),
assets: Mutex::new(asset_map), }
})
} }
} }
pub struct GetTimeBuckets;
pub struct TimeBucketRef { pub struct TimeBucketRef {
pub key: TimeBucketKey, pub key: TimeBucketKey,
pub count: usize, pub count: usize,
} }
pub struct GetTimeBucket {
pub time_bucket: TimeBucketKey,
}
pub struct TimeBucket { pub struct TimeBucket {
pub key: TimeBucketKey, pub key: TimeBucketKey,
pub entries: Arc<[TimeBucketEntry]>, pub entries: Arc<[TimeBucketEntry]>,
@@ -167,20 +54,30 @@ pub struct TimeBucketEntry {
pub visibility: AssetVisibility, pub visibility: AssetVisibility,
} }
pub struct GetAssetThumbnail {
pub id: AssetId,
}
pub struct AssetThumbnail { pub struct AssetThumbnail {
pub id: AssetId, pub id: AssetId,
pub thumbnail: SharedPixelBuffer<Rgba8Pixel>, pub thumbnail: SharedPixelBuffer<Rgba8Pixel>,
} }
impl Api { impl Message<GetTimeBuckets> for Api {
pub async fn get_time_buckets(&self) -> anyhow::Result<Vec<TimeBucketRef>> { type Reply = anyhow::Result<Arc<[TimeBucketRef]>>;
async fn handle(
&mut self,
_msg: GetTimeBuckets,
_ctx: &mut Context<Self, Self::Reply>,
) -> Self::Reply {
let buckets = self let buckets = self
.client .client
.timeline() .timeline()
.buckets() .buckets()
.execute() .execute()
.await .await
.context(anyhow!("Failed to fetch list of time buckets")) .context(anyhow!("Failed to fetch list of time buckets",))
.inspect_err(|e| { .inspect_err(|e| {
tracing::error!("{e:?}"); tracing::error!("{e:?}");
})?; })?;
@@ -193,22 +90,30 @@ impl Api {
}) })
.collect()) .collect())
} }
}
pub async fn get_time_bucket( impl Message<GetTimeBucket> for Api {
&self, type Reply = anyhow::Result<Arc<TimeBucket>>;
time_bucket: TimeBucketKey,
) -> anyhow::Result<Arc<TimeBucket>> { async fn handle(
if let Some(time_bucket) = self.buckets.lock().unwrap().get(&time_bucket).cloned() { &mut self,
msg: GetTimeBucket,
_ctx: &mut Context<Self, Self::Reply>,
) -> Self::Reply {
if let Some(time_bucket) = self.buckets.get(&msg.time_bucket).cloned() {
return Ok(time_bucket); return Ok(time_bucket);
} }
let bucket = self let bucket = self
.client .client
.timeline() .timeline()
.bucket(&time_bucket) .bucket(&msg.time_bucket)
.execute() .execute()
.await .await
.context(anyhow!("Failed to fetch time bucket {:?}", &time_bucket)) .context(anyhow!(
"Failed to fetch time bucket {:?}",
&msg.time_bucket
))
.inspect_err(|e| { .inspect_err(|e| {
tracing::error!("{e:?}"); tracing::error!("{e:?}");
})?; })?;
@@ -239,47 +144,55 @@ impl Api {
.collect(); .collect();
let bucket = Arc::new(TimeBucket { let bucket = Arc::new(TimeBucket {
key: time_bucket, key: msg.time_bucket,
entries, entries,
}); });
self.buckets self.buckets.insert(bucket.key.clone(), bucket.clone());
.lock()
.unwrap()
.insert(bucket.key.clone(), bucket.clone());
Ok(bucket) Ok(bucket)
} }
}
pub async fn get_asset_thumbnail( impl Message<GetAssetThumbnail> for Api {
&self, type Reply = anyhow::Result<Arc<AssetThumbnail>>;
asset_id: AssetId,
) -> anyhow::Result<Arc<AssetThumbnail>> { async fn handle(
let fetch = self.thumbnails.lock().unwrap().get(asset_id); &mut self,
fetch msg: GetAssetThumbnail,
.await _ctx: &mut Context<Self, Self::Reply>,
.context(anyhow!("Failed to fetch thumbnail for {asset_id:?}")) ) -> Self::Reply {
if let Some(thumbnail) = self.thumbnails.get(&msg.id).cloned() {
return Ok(thumbnail);
} }
pub async fn get_asset(&self, asset_id: AssetId) -> anyhow::Result<Arc<AssetThumbnail>> { let response = self
let fetch = self.assets.lock().unwrap().get(asset_id);
fetch
.await
.context(anyhow!("Failed to fetch asset {asset_id:?}"))
}
pub async fn get_album_list(&self) -> anyhow::Result<Vec<AlbumResponse>> {
let albums = self
.client .client
.albums() .assets()
.list() .thumbnail(msg.id)
.size(immich_sdk::AssetMediaSize::Thumbnail)
.execute() .execute()
.await .await
.context(anyhow!("Failed to fetch album list")) .context(anyhow!("Failed to get asset thumbnail for {}", msg.id))?;
.inspect_err(|e| {
tracing::error!("{e:?}");
})?;
Ok(albums) let thumbnail = response
.decode()
.context(anyhow!("Failed to decode asset thumbnail for {}", msg.id))?
.into_rgba8();
let pixel_buffer = SharedPixelBuffer::<Rgba8Pixel>::clone_from_slice(
&thumbnail,
thumbnail.width(),
thumbnail.height(),
);
let thumbnail = Arc::new(AssetThumbnail {
id: msg.id,
thumbnail: pixel_buffer,
});
self.thumbnails.insert(msg.id, Arc::clone(&thumbnail));
Ok(thumbnail)
} }
} }

View File

@@ -1,191 +0,0 @@
use std::{
collections::{HashMap, hash_map::Entry},
fmt::Debug,
future::ready,
hash::Hash,
marker::PhantomData,
path::PathBuf,
pin::Pin,
str::FromStr,
sync::Arc,
};
use anyhow::{Context, anyhow};
use either::Either;
use futures::{FutureExt, future::WeakShared};
use tokio::{fs, task::JoinHandle};
use crate::xdg::BASE_DIRECTORIES;
pub trait Fetcher<V>: Send + Sync + 'static {
type Key: ToString + FromStr;
fn fetch(
&self,
key: &Self::Key,
) -> Pin<Box<dyn Future<Output = anyhow::Result<V>> + Send + Sync>>;
}
pub struct AsyncFnFetcher<K, V, F> {
f: F,
_phantom: PhantomData<(K, V)>,
}
impl<K, V, F> AsyncFnFetcher<K, V, F>
where
K: ToString + FromStr + Send + 'static,
F: Fn(&K) -> JoinHandle<anyhow::Result<V>> + Send + 'static,
V: Send + 'static,
{
pub fn new(f: F) -> Self {
Self {
f,
_phantom: PhantomData,
}
}
}
impl<K, V, F> Fetcher<V> for AsyncFnFetcher<K, V, F>
where
K: ToString + FromStr + Send + Sync + 'static,
F: Fn(&K) -> JoinHandle<anyhow::Result<V>> + Send + Sync + 'static,
V: Send + Sync + 'static,
{
type Key = K;
fn fetch(
&self,
key: &Self::Key,
) -> Pin<Box<dyn Future<Output = anyhow::Result<V>> + Send + Sync>> {
let handle = (self.f)(key);
Box::pin(async move { handle.await.context("Fetch task panicked")? })
}
}
type FetchJob<V> = Pin<Box<dyn Future<Output = Option<V>> + Send + Sync>>;
pub struct CacheMap<K, V> {
fetcher: Arc<dyn Fetcher<V, Key = K>>,
cache_dir: PathBuf,
serialize: fn(&K, &V) -> Vec<u8>,
deserialize: fn(&K, &[u8]) -> anyhow::Result<V>,
/// Cache of ongoing [`FetchJob`]s.
///
/// If [`CacheMap::get`] is called while a fetch job is ongoing, [`WeakShared::upgrade`]
/// will succeed, and the [`FetchJob`] can be cloned.
fetch_jobs: HashMap<K, WeakShared<FetchJob<V>>>,
}
impl<K, V> CacheMap<K, V>
where
K: Debug + FromStr + ToString + Eq + Hash + Clone + Send + Sync + 'static,
V: Clone + Send + Sync + 'static,
{
pub fn new(
fetcher: Arc<dyn Fetcher<V, Key = K> + 'static>,
cache_name: &str,
serialize: fn(&K, &V) -> Vec<u8>,
deserialize: fn(&K, &[u8]) -> anyhow::Result<V>,
) -> anyhow::Result<Self> {
let data_dir = BASE_DIRECTORIES
.create_cache_directory(cache_name)
.context(anyhow!(
"Failed to create XDG data folder for {cache_name:?}"
))?;
Ok(Self {
fetcher,
cache_dir: data_dir,
serialize,
deserialize,
fetch_jobs: HashMap::new(),
})
}
fn fetch_from_cache(
&self,
key: &K,
) -> impl Future<Output = anyhow::Result<V>> + Send + Sync + use<K, V> {
let key = key.clone();
let key_str = key.to_string();
let path = self.cache_dir.join(key_str);
let deserialize = self.deserialize;
Box::pin(async move {
let bytes = fs::read(&path)
.await
.context(anyhow!("Failed to read {path:?}"))?;
deserialize(&key, &bytes).context(anyhow!("Failed to deserialize value at {path:?}"))
})
}
pub fn get(&mut self, key: K) -> impl Future<Output = Option<V>> + use<K, V> {
// FIXME: creating this future here because lifetimes.
let fetch_from_cache = self.fetch_from_cache(&key);
let entry = match self.fetch_jobs.entry(key.clone()) {
Entry::Vacant(entry) => entry,
Entry::Occupied(entry) => {
if let Some(fetching) = entry.get().upgrade() {
return match fetching.clone().now_or_never() {
// Value fetched
Some(Some(value)) => {
entry.remove();
Either::Left(ready(Some(value)))
}
// Failed to fetch
Some(None) => {
entry.remove();
Either::Left(ready(None))
}
// Still pending
None => Either::Right(fetching.clone()),
};
}
entry.remove();
let Entry::Vacant(entry) = self.fetch_jobs.entry(key.clone()) else {
unreachable!()
};
entry
}
};
let fetcher = self.fetcher.clone();
let serialize = self.serialize;
let file_path = self.cache_dir.join(key.to_string());
let fetching = Box::pin(async move {
if let Ok(value) = fetch_from_cache.await.inspect_err(|e| {
tracing::debug!("Failed to fetch {key:?} from cache: {e}");
}) {
return Some(value);
}
let value = match fetcher.fetch(&key).await {
Ok(value) => value,
Err(e) => {
tracing::warn!("Couldn't fetch {key:?}: {e}");
return None;
}
};
let data = serialize(&key, &value);
if let Err(e) = fs::write(file_path, data).await {
tracing::error!("Failed to cahce value for {key:?}: {e}");
}
Some(value)
});
let fetching = fetching as Pin<Box<dyn Future<Output = Option<V>> + Send + Sync>>;
let fetching = fetching.shared();
if let Some(fetching) = fetching.downgrade() {
entry.insert(fetching);
}
Either::Right(fetching)
}
}

View File

@@ -1,46 +0,0 @@
use anyhow::{Context, anyhow, bail};
use serde::{Deserialize, Serialize};
use tokio::fs;
use crate::xdg::BASE_DIRECTORIES;
const CONFIG_FILE: &str = "config.toml";
#[derive(Default, Debug, Clone, Serialize, Deserialize)]
pub struct Config {
pub immich: Option<ImmichLogin>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImmichLogin {
pub url: String,
pub api_key: String,
}
impl Config {
pub async fn load() -> anyhow::Result<Self> {
let Some(config_path) = BASE_DIRECTORIES.get_config_file(CONFIG_FILE) else {
bail!("No config file exists")
};
let config = fs::read_to_string(&config_path)
.await
.context(anyhow!("Failed to read config file at {config_path:?}"))?;
toml::from_str(&config).context(anyhow!(
"Failed to deserialize config file at {config_path:?}"
))
}
pub async fn save(&self) -> anyhow::Result<()> {
let config_path = BASE_DIRECTORIES
.place_config_file(CONFIG_FILE)
.context(anyhow!("Failed to create config folder"))?;
let config = toml::to_string_pretty(self)?;
fs::write(&config_path, config)
.await
.context(anyhow!("Failed to write config file to {config_path:?}"))
}
}

View File

@@ -1,43 +1,61 @@
// Prevent console window in addition to Slint window in Windows release builds when, e.g., starting the app via file manager. Ignored on other platforms. // Prevent console window in addition to Slint window in Windows release builds when, e.g., starting the app via file manager. Ignored on other platforms.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use std::{mem, ops::Deref, sync::Arc}; use std::{mem, ops::Deref};
use clap::Parser; use clap::Parser;
use immich_sdk::AssetId; use immich_sdk::AssetId;
use kameo::actor::{ActorRef, Spawn};
use slint::{ use slint::{
ComponentHandle as _, Image, Model, ModelRc, SharedPixelBuffer, SharedString, ToSharedString, ComponentHandle as _, Model, ModelRc, SharedPixelBuffer, SharedString, ToSharedString,
VecModel, Weak, VecModel, Weak,
}; };
use tracing::Level; use tracing::Level;
use crate::{ use crate::{
api::{Api, TimeBucketKey}, api::{Api, GetAssetThumbnail, GetTimeBucket, GetTimeBuckets, TimeBucketKey},
config::Config, ui::{AppWindow, ImageBucket},
ui::AppWindow,
}; };
/// Use jemalloc to reduce memory fragmentation. mod api;
#[global_allocator] mod thumbhash;
static ALLOCATOR: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc;
pub mod api;
pub mod cachemap;
pub mod config;
pub mod thumbhash;
pub mod xdg;
pub const CRATE_NAME: &str = env!("CARGO_CRATE_NAME");
mod ui { mod ui {
slint::include_modules!(); slint::include_modules!();
} }
#[derive(clap::Parser)] #[derive(clap::Parser)]
struct Opt {} struct Opt {
#[clap(long, env = "IMMICH_BASE_URL")]
pub immich_base_url: String,
#[clap(long, env = "IMMICH_API_KEY")]
pub immich_api_key: String,
}
// enum ApiReq<M: Send + 'static>
// where
// Api: Message<M>,
// {
// AskRequest(
// AskRequest<
// 'static,
// Api,
// M,
// kameo::request::WithoutRequestTimeout,
// kameo::request::WithoutRequestTimeout,
// >,
// ),
// PendingReply(PendingReply<M, <Api as Message<M>>::Reply>),
// }
// enum ApiReqs {
// GetBuckets(ApiReq<GetTimeBuckets>),
// GetBucket(ApiReq<GetTimeBucket>),
// }
fn main() -> anyhow::Result<()> { fn main() -> anyhow::Result<()> {
let _opt = Opt::parse(); let opt = Opt::parse();
tracing_subscriber::fmt() tracing_subscriber::fmt()
.with_max_level(Level::DEBUG) .with_max_level(Level::DEBUG)
@@ -46,59 +64,18 @@ fn main() -> anyhow::Result<()> {
let runtime = tokio::runtime::Runtime::new().unwrap(); let runtime = tokio::runtime::Runtime::new().unwrap();
let _rt_guard = runtime.enter(); let _rt_guard = runtime.enter();
let config_ = runtime let immich_config =
.block_on(Config::load()) immich_sdk::Config::new(opt.immich_base_url).with_api_key(opt.immich_api_key);
.inspect_err(|e| tracing::debug!("{e}")) let api = Api::new(immich_sdk::Client::new(immich_config).unwrap());
.map(Arc::new) let api_ = Api::spawn(api);
.unwrap_or_default();
let app = ui::AppWindow::new()?; let app = ui::AppWindow::new()?;
let global = app.global::<ui::Global>(); let global = app.global::<ui::Global>();
let config = Arc::clone(&config_);
let app_weak = app.as_weak();
global.on_login_api_key(move |url, api_key| {
tracing::debug!("url: {url}, api_key: {api_key}");
let mut config = config.as_ref().clone();
let immich_config = config::ImmichLogin {
url: url.to_string(),
api_key: api_key.to_string(),
};
config.immich = Some(immich_config.clone());
tokio::spawn(async move {
if let Err(e) = config.save().await {
tracing::error!("{e}");
}
});
let _ = app_weak.upgrade_in_event_loop(move |app| {
start_api(&app, &immich_config);
});
});
if let Some(immich) = &config_.immich {
start_api(&app, immich);
}
app.run()?;
Ok(())
}
fn start_api(app: &AppWindow, immich: &config::ImmichLogin) {
let immich_config = immich_sdk::Config::new(&immich.url).with_api_key(&immich.api_key);
let api_ = Api::new(immich_sdk::Client::new(immich_config).unwrap());
let global = app.global::<ui::Global>();
global.set_logged_in(true);
global.set_image_buckets(ModelRc::new(VecModel::default()));
let app_weak = app.as_weak(); let app_weak = app.as_weak();
let api = api_.clone(); let api = api_.clone();
tokio::spawn(async move { tokio::spawn(async move {
let Ok(buckets) = api.get_time_buckets().await else { let Ok(buckets) = api.ask(GetTimeBuckets).await else {
return; return;
}; };
@@ -117,8 +94,7 @@ fn start_api(app: &AppWindow, immich: &config::ImmichLogin) {
.map(|_| ui::ImagePreview { .map(|_| ui::ImagePreview {
asset_id: SharedString::new(), asset_id: SharedString::new(),
image: preview_image.clone(), image: preview_image.clone(),
ratio: 1.0, kind: ui::PreviewKind::None,
kind: ui::ImageKind::None,
}) })
.collect::<VecModel<_>>(), .collect::<VecModel<_>>(),
), ),
@@ -150,120 +126,18 @@ fn start_api(app: &AppWindow, immich: &config::ImmichLogin) {
}); });
}); });
let app_weak = app.as_weak(); app.run()?;
let api = api_.clone();
global.on_view_image(move |asset_id_str| {
tracing::info!("on_view_image({asset_id_str})");
let api = api.clone();
let app_weak = app_weak.clone();
tokio::spawn(async move {
let asset_id: AssetId = asset_id_str.parse().unwrap();
let asset = api.get_asset(asset_id).await.unwrap();
let image = asset.thumbnail.clone();
tracing::info!("got image for {asset_id_str}");
let _ = app_weak.upgrade_in_event_loop(move |app| { Ok(())
let global = app.global::<ui::Global>();
let existing = global.get_viewed_image();
if existing.asset_id != asset_id_str {
return;
}
global.set_viewed_image(ui::ImagePreview {
asset_id: asset_id_str,
ratio: image.size().width as f32 / image.size().height as f32,
image: slint::Image::from_rgba8(image),
kind: ui::ImageKind::Original,
});
});
});
});
let app_weak = app.as_weak();
let api = api_.clone();
global.on_load_albums(move || {
tracing::info!("on_load_albums()");
let api = api.clone();
let app_weak = app_weak.clone();
tokio::spawn(async move {
let Ok(albums) = api
.get_album_list()
.await
.inspect_err(|e| tracing::error!("Failed to load albums: {e}"))
else {
return;
};
tracing::info!("loaded {} album covers", albums.len());
let mut thumbnail_tasks = vec![];
for album in &albums {
let Some(asset_id) = album.album_thumbnail_asset_id else {
thumbnail_tasks.push(None);
continue;
};
let api = api.clone();
let task = tokio::spawn(async move {
api.get_asset_thumbnail(asset_id)
.await
.inspect_err(|e| tracing::error!("Failed to get album thumbnail: {e}"))
});
thumbnail_tasks.push(Some(task));
}
let mut thumbnails = vec![];
for task in thumbnail_tasks {
if let Some(task) = task
&& let Ok(Ok(thumbnail)) = task.await
{
thumbnails.push(Some(thumbnail.thumbnail.clone()));
} else {
thumbnails.push(None);
};
}
let _ = app_weak.upgrade_in_event_loop(move |app| {
let albums = albums
.into_iter()
.zip(thumbnails)
.map(|(album, thumbnail)| {
ui::AlbumCover {
asset_count: album.asset_count as i32,
description: album.description.into(),
id: album.id.to_shared_string(),
name: album.album_name.into(),
thumbnail: thumbnail
.map(|t| ui::ImagePreview {
asset_id: album
.album_thumbnail_asset_id
.unwrap()
.to_shared_string(),
kind: ui::ImageKind::Thumbnail,
ratio: t.width() as f32 / t.height() as f32,
image: Image::from_rgba8(t),
})
.unwrap_or_default(), // TODO
}
})
.collect::<VecModel<_>>();
let albums = ModelRc::new(albums);
let global = app.global::<ui::Global>();
global.set_albums(albums);
});
});
});
} }
fn calculate_timeline_visibility(app: &AppWindow, api: Arc<Api>, scroll: f32) { fn calculate_timeline_visibility(app: &AppWindow, api: ActorRef<Api>, scroll: f32) {
let global = app.global::<ui::Global>(); let global = app.global::<ui::Global>();
global.set_timeline_scroll(scroll); global.set_timeline_scroll(scroll);
let window_height = app.get_window_height(); let window_height = app.get_window_height();
let visible_range = scroll..=(scroll + window_height); let visible_range = scroll..=(scroll + window_height);
let buckets = get_image_buckets(app); let buckets = get_image_buckets(&app);
for i in 0..buckets.row_count() { for i in 0..buckets.row_count() {
let Some(mut bucket) = buckets.row_data(i) else { let Some(mut bucket) = buckets.row_data(i) else {
@@ -272,7 +146,7 @@ fn calculate_timeline_visibility(app: &AppWindow, api: Arc<Api>, scroll: f32) {
let top_y = bucket.y; let top_y = bucket.y;
let bottom_y = bucket.y + bucket.height; let bottom_y = bucket.y + bucket.height;
let is_visible = &top_y <= visible_range.end() && visible_range.start() <= &bottom_y; let is_visible = &top_y <= &visible_range.end() && visible_range.start() <= &bottom_y;
let visibility = if is_visible { let visibility = if is_visible {
ui::Visibility::InView ui::Visibility::InView
@@ -303,12 +177,12 @@ fn calculate_timeline_visibility(app: &AppWindow, api: Arc<Api>, scroll: f32) {
} }
} }
fn calculate_timeline_layout(app: &AppWindow, api: Arc<Api>, timeline_width: f32) { fn calculate_timeline_layout(app: &AppWindow, api: ActorRef<Api>, timeline_width: f32) {
let global = app.global::<ui::Global>(); let global = app.global::<ui::Global>();
let min_image_size = global.get_min_image_size(); let min_image_size = global.get_min_image_size();
let image_margin = global.get_image_margin(); let image_margin = global.get_image_margin();
let min_size_with_margin = min_image_size + image_margin; let min_size_with_margin = min_image_size + image_margin;
let buckets = get_image_buckets(app); let buckets = get_image_buckets(&app);
let count_x = (timeline_width / min_size_with_margin).floor() as usize; let count_x = (timeline_width / min_size_with_margin).floor() as usize;
let remaining_length = timeline_width.rem_euclid(min_size_with_margin); let remaining_length = timeline_width.rem_euclid(min_size_with_margin);
@@ -342,13 +216,12 @@ fn calculate_timeline_layout(app: &AppWindow, api: Arc<Api>, timeline_width: f32
} }
struct AppImageBuckets { struct AppImageBuckets {
buckets: ModelRc<ui::ImageBucket>, buckets: ModelRc<ImageBucket>,
} }
impl Deref for AppImageBuckets { impl Deref for AppImageBuckets {
type Target = VecModel<ui::ImageBucket>; type Target = VecModel<ImageBucket>;
#[track_caller]
fn deref(&self) -> &Self::Target { fn deref(&self) -> &Self::Target {
self.buckets self.buckets
.as_any() .as_any()
@@ -357,7 +230,7 @@ impl Deref for AppImageBuckets {
} }
} }
fn get_image_buckets(app: &AppWindow) -> impl Deref<Target = VecModel<ui::ImageBucket>> { fn get_image_buckets(app: &AppWindow) -> impl Deref<Target = VecModel<ImageBucket>> {
let global = app.global::<ui::Global>(); let global = app.global::<ui::Global>();
AppImageBuckets { AppImageBuckets {
buckets: global.get_image_buckets(), buckets: global.get_image_buckets(),
@@ -374,7 +247,7 @@ fn placeholder_preview() -> slint::Image {
fn unload_bucket(time_bucket: &TimeBucketKey, app: &AppWindow) { fn unload_bucket(time_bucket: &TimeBucketKey, app: &AppWindow) {
let buckets = get_image_buckets(app); let buckets = get_image_buckets(app);
let Some(i) = buckets.iter().position(|b| b.key == time_bucket) else { let Some(i) = buckets.iter().position(|b| &b.key == &time_bucket) else {
return; return;
}; };
let bucket = buckets.row_data(i).expect("i is in the list"); let bucket = buckets.row_data(i).expect("i is in the list");
@@ -384,7 +257,7 @@ fn unload_bucket(time_bucket: &TimeBucketKey, app: &AppWindow) {
let Some(mut preview) = bucket.previews.row_data(j) else { let Some(mut preview) = bucket.previews.row_data(j) else {
break; break;
}; };
preview.kind = ui::ImageKind::None; preview.kind = ui::PreviewKind::None;
preview.image = placeholder_preview.clone(); preview.image = placeholder_preview.clone();
bucket.previews.set_row_data(j, preview); bucket.previews.set_row_data(j, preview);
} }
@@ -392,16 +265,21 @@ fn unload_bucket(time_bucket: &TimeBucketKey, app: &AppWindow) {
// TODO: write `bucket` into `buckets?` // TODO: write `bucket` into `buckets?`
} }
fn load_bucket(time_bucket: TimeBucketKey, app_weak: Weak<AppWindow>, api: Arc<Api>) { fn load_bucket(time_bucket: TimeBucketKey, app_weak: Weak<AppWindow>, api: ActorRef<Api>) {
tokio::spawn(async move { tokio::spawn(async move {
let Ok(api_bucket) = api.get_time_bucket(time_bucket.clone()).await else { let Ok(api_bucket) = api
.ask(GetTimeBucket {
time_bucket: time_bucket.clone(),
})
.await
else {
return; return;
}; };
let _ = app_weak.upgrade_in_event_loop(move |app| { let _ = app_weak.upgrade_in_event_loop(move |app| {
let buckets = get_image_buckets(&app); let buckets = get_image_buckets(&app);
let Some(i) = buckets.iter().position(|b| b.key == time_bucket) else { let Some(i) = buckets.iter().position(|b| &b.key == &time_bucket) else {
return; return;
}; };
let mut bucket = buckets.row_data(i).expect("i is in the list"); let mut bucket = buckets.row_data(i).expect("i is in the list");
@@ -411,8 +289,7 @@ fn load_bucket(time_bucket: TimeBucketKey, app_weak: Weak<AppWindow>, api: Arc<A
.iter() .iter()
.map(|entry| ui::ImagePreview { .map(|entry| ui::ImagePreview {
asset_id: entry.id.to_shared_string(), // slint doesn't have a uuid type asset_id: entry.id.to_shared_string(), // slint doesn't have a uuid type
kind: ui::ImageKind::Thumbhash, kind: ui::PreviewKind::Thumbhash,
ratio: entry.ratio as f32,
// TODO: don't unwrap // TODO: don't unwrap
image: entry image: entry
.thumbhash .thumbhash
@@ -433,13 +310,13 @@ fn load_bucket(time_bucket: TimeBucketKey, app_weak: Weak<AppWindow>, api: Arc<A
fn load_thumbnail( fn load_thumbnail(
time_bucket: TimeBucketKey, time_bucket: TimeBucketKey,
asset_id: AssetId, id: AssetId,
app_weak: Weak<AppWindow>, app_weak: Weak<AppWindow>,
api: Arc<Api>, api: ActorRef<Api>,
) { ) {
tokio::spawn(async move { tokio::spawn(async move {
tracing::debug!("Fetching thumbnail for {asset_id}"); tracing::debug!("Fetching thumbnail for {id}");
let thumbnail = match api.get_asset_thumbnail(asset_id).await { let thumbnail = match api.ask(GetAssetThumbnail { id }).await {
Ok(thumbnail) => thumbnail, Ok(thumbnail) => thumbnail,
Err(e) => { Err(e) => {
tracing::error!("{e:?}"); tracing::error!("{e:?}");
@@ -449,20 +326,18 @@ fn load_thumbnail(
let _ = app_weak.upgrade_in_event_loop(move |app| { let _ = app_weak.upgrade_in_event_loop(move |app| {
let buckets = get_image_buckets(&app); let buckets = get_image_buckets(&app);
let Some(i) = buckets.iter().position(|b| b.key == time_bucket) else { let Some(i) = buckets.iter().position(|b| &b.key == &time_bucket) else {
return; return;
}; };
let bucket = buckets.row_data(i).expect("i is in the list"); let bucket = buckets.row_data(i).expect("i is in the list");
let id_str = asset_id.to_string(); let id_str = id.to_string();
let Some(i) = bucket.previews.iter().position(|p| p.asset_id == id_str) else { let Some(i) = bucket.previews.iter().position(|p| &p.asset_id == &id_str) else {
return; return;
}; };
let mut preview = bucket.previews.row_data(i).expect("i is in the list"); let mut preview = bucket.previews.row_data(i).expect("i is in the list");
preview.image = slint::Image::from_rgba8(thumbnail.thumbnail.clone()); preview.image = slint::Image::from_rgba8(thumbnail.thumbnail.clone());
preview.kind = ui::ImageKind::Thumbnail; preview.kind = ui::PreviewKind::Thumbnail;
preview.ratio =
thumbnail.thumbnail.width() as f32 / thumbnail.thumbnail.height() as f32;
bucket.previews.set_row_data(i, preview); bucket.previews.set_row_data(i, preview);
}); });
}); });

View File

@@ -1,8 +0,0 @@
use std::sync::LazyLock;
use xdg::BaseDirectories;
use crate::CRATE_NAME;
pub static BASE_DIRECTORIES: LazyLock<BaseDirectories> =
LazyLock::new(|| BaseDirectories::with_prefix(CRATE_NAME));

View File

@@ -1,71 +0,0 @@
import { AlbumCover } from "types.slint";
import { Global } from "global.slint";
import { ScrollView, HorizontalBox, Palette } from "std-widgets.slint";
import { ImagePreview } from "timeline.slint";
component AlbumCover {
in property <AlbumCover> album;
states [
pressed when touch.pressed: {
click-effect.opacity: 0.2;
}
default: {
click-effect.opacity: 0;
}
]
Rectangle {
background: Palette.alternate-background;
border-radius: 24px;
clip: true;
HorizontalLayout {
spacing: 20px;
ImagePreview {
preview: album.thumbnail;
size: 100px;
}
Text {
text: album.name;
horizontal-alignment: left;
vertical-alignment: center;
font-size: 20px;
}
}
click_effect := Rectangle {
background: Palette.accent-foreground;
opacity: 0;
}
touch := TouchArea {}
}
}
export component Albums {
private property <[AlbumCover]> albums: Global.albums;
property <length> min-image-size: Global.min-image-size;
property <length> min-size-with-margin: min-image-size + Global.image-margin;
ScrollView {
mouse-drag-pan-enabled: true;
HorizontalLayout {
alignment: center;
VerticalLayout {
min-width: min-image-size;
alignment: start;
spacing: 10px;
for album[i] in albums : AlbumCover {
album: album;
}
}
}
}
}

View File

@@ -1,22 +1,142 @@
import { AboutSlint, Button, Palette, HorizontalBox, ScrollView } from "std-widgets.slint"; import { AboutSlint, Button, Palette, HorizontalBox, ScrollView } from "std-widgets.slint";
import { Timeline } from "timeline.slint";
import { ImageViewer } from "image-viewer.slint";
import { LoginView } from "login.slint";
import { Header } from "header.slint";
import { Footer, FooterButton } from "footer.slint";
import { Global } from "global.slint"; enum PreviewKind {
import { Albums } from "albums.slint"; None,
export { Global } Thumbhash,
Thumbnail,
enum View {
Timeline,
Albums
} }
struct ImagePreview {
asset_id: string,
image: image,
kind: PreviewKind,
}
enum Visibility {
Hidden,
NearView,
InView,
}
struct ImageBucket {
key: string,
title: string,
count: int,
previews: [ImagePreview],
y: length,
height: length,
visibility: Visibility,
}
export global Global {
in-out property <length> min-image-size: 100px;
in-out property <length> image-margin: 2px;
in-out property <[ImageBucket]> image-buckets: [
{ key: "2026-02-01", title: "Feb 1, 2026", count: 12 },
{ key: "2026-02-02", title: "Feb 2, 2026", count: 12 },
{ key: "2026-02-03", title: "Feb 3, 2026", count: 12 },
];
in property <length> timeline-height;
in property <length> timeline-width;
in property <length> timeline-scroll;
callback set-timeline-width(length);
callback timeline-scrolled(length);
}
component Header inherits Rectangle {
width: 100%;
height: 48px;
background: Palette.alternate-background;
HorizontalBox {
height: parent.height;
Text {
text: "immich";
}
}
}
component ImagePreview inherits Rectangle {
in property <image> preview;
in property <length> size: 32px;
width: size;
height: size;
Image {
width: 100%;
height: 100%;
source: preview;
}
touch := TouchArea {
clicked => {
}
}
}
component TimelineBlock inherits VerticalLayout {
in property <int> index: -1;
in-out property <ImageBucket> bucket;
property <length> min-image-size: Global.min-image-size;
property <length> min-size-with-margin: min-image-size + Global.image-margin;
property <int> count-x: Math.floor(self.width / min-size-with-margin); // TODO: or is it ceil?
property <int> count-y: Math.ceil(bucket.count / count-x);
function calc-image-size() -> length {
let remaining-length = Math.mod(self.width, min-size-with-margin);
min-image-size + remaining-length / count-x
}
property <length> image-size: calc-image-size();
property <length> image-size-with-margin: image-size + Global.image-margin;
property <length> title-box-height: 36px;
height: title-box.height + count-y * image-size-with-margin;
y: bucket.y;
min-width: min-image-size;
alignment: start;
title-box := HorizontalBox {
alignment: space-between;
height: title-box-height;
Text {
text: bucket.title;
}
// TODO: checkbox thingy
Text {
text: "O";
}
}
image-box := Rectangle {
width: 100%;
height: count-y * image-size-with-margin;
for preview[i] in bucket.previews : ImagePreview {
preview: preview.image;
size: image-size;
x: Global.image-margin / 2 + Math.mod(i, count-x) * (Global.image-margin + image-size);
y: Math.floor(i / count-x) * (image-size + Global.image-margin);
}
}
}
// component ImageViewer inherits Rectangle {
// in property <image> image;
// width: 100%;
// height: 100%;
// background: black;
// }
export component AppWindow inherits Window { export component AppWindow inherits Window {
out property <length> window-height: self.height; out property <length> window-height: self.height;
out property <View> view: View.Timeline;
// Do not base preferred-width on children // Do not base preferred-width on children
preferred-width: 480px; preferred-width: 480px;
@@ -31,37 +151,30 @@ export component AppWindow inherits Window {
Header {} Header {}
if !Global.logged-in: LoginView {} ScrollView {
if Global.logged-in && view == View.Timeline: Timeline {} mouse-drag-pan-enabled: true;
if Global.logged-in && view == View.Albums: Albums {} viewport-height: rect.height;
Footer { changed viewport-y => {
FooterButton { Global.timeline-scrolled(-self.viewport-y);
title: "Photos";
icon: @image-url("../assets/photos.svg");
clicked => { view = View.Timeline }
}
FooterButton {
title: "Search";
icon: @image-url("../assets/search.svg");
}
FooterButton {
title: "Album";
icon: @image-url("../assets/album.svg");
clicked => {
view = View.Albums;
Global.load-albums();
}
}
FooterButton {
title: "Library";
icon: @image-url("../assets/album.svg"); // TODO
}
}
} }
if Global.viewed-image.asset-id != "" : ImageViewer { rect := Rectangle {
image: Global.viewed-image.image; y: 0;
x: 0;
width: root.width;
height: Global.timeline-height;
preferred-width: self.width;
preferred-height: self.height;
for bucket[i] in Global.image-buckets : Rectangle {
if bucket.visibility != Visibility.InView : TimelineBlock {
width: root.width;
index: i;
bucket: bucket;
}
}
}
}
} }
} }

View File

@@ -1,59 +0,0 @@
import { Palette } from "std-widgets.slint";
export component FooterButton inherits Rectangle {
in property <string> title: "Button";
in property <image> icon;
callback clicked <=> touch.clicked;
states [
pressed when touch.pressed: {
background: #0000ff30; // TODO: palette
}
hovered when touch.has-hover: {
background: #0000ff15; // TODO: palette
}
default: {
background: #0000;
}
]
animate background {
duration: 0.1s;
easing: ease-in-out;
}
border-radius: 20px;
VerticalLayout {
padding: 10px;
padding-left: 20px;
padding-right: 20px;
Image {
source: icon;
colorize: Palette.accent-background;
}
Text {
text: title;
horizontal-alignment: center;
}
}
touch := TouchArea {}
}
export component Footer inherits Rectangle {
width: 100%;
height: 88px;
background: Palette.alternate-background;
HorizontalLayout {
height: parent.height;
spacing: 16px;
padding-top: 8px;
padding-bottom: 16px;
padding-left: 16px;
padding-right: 16px;
@children
}
}

View File

@@ -1,22 +0,0 @@
import { ImageBucket, ImagePreview, AlbumCover } from "types.slint";
export global Global {
in-out property <bool> logged-in: false;
in-out property <length> min-image-size: 88px;
in-out property <length> image-margin: 2px;
in-out property <ImagePreview> viewed-image;
in-out property <[AlbumCover]> albums;
in-out property <[ImageBucket]> image-buckets: [
{ key: "2026-02-01", title: "Feb 1, 2026", count: 12 },
{ key: "2026-02-02", title: "Feb 2, 2026", count: 12 },
{ key: "2026-02-03", title: "Feb 3, 2026", count: 12 },
];
in property <length> timeline-height;
in property <length> timeline-width;
in property <length> timeline-scroll;
callback login-api-key(url: string, api_key: string);
callback set-timeline-width(length);
callback timeline-scrolled(length);
callback view-image(string);
callback load-albums();
}

View File

@@ -1,14 +0,0 @@
import { HorizontalBox, Palette } from "std-widgets.slint";
export component Header inherits Rectangle {
width: 100%;
height: 48px;
background: Palette.alternate-background;
HorizontalBox {
height: parent.height;
Text {
text: "immich";
}
}
}

View File

@@ -1,90 +0,0 @@
import { Global } from "global.slint";
export component ImageViewer inherits Rectangle {
in property <image> image;
width: 100%;
height: 100%;
background: black;
enter-animation := Timer {
running: true;
interval: 1ms;
triggered => {
self.running = false;
}
}
exit-animation := Timer {
running: false;
interval: 0.2s;
triggered => {
self.running = false;
Global.viewed-image.asset-id = "";
}
}
states [
entering when enter-animation.running: {
root.opacity: 0.0;
out {
animate root.opacity {
duration: 0.15s;
}
}
}
exiting when exit-animation.running: {
root.opacity: 0.0;
in {
animate root.opacity {
duration: exit-animation.interval;
}
}
}
entered: {
root.opacity: 1.0;
}
]
sgh := SwipeGestureHandler {
enabled: !enter-animation.running && !exit-animation.running;
handle-swipe-down: true;
handle-swipe-up: true;
swiped() => {
if self.current-position.y < self.pressed-position.y {
debug ("todo: handle swiped up")
} else {
exit-animation.running = true;
}
}
Image {
source: image;
image-fit: ImageFit.contain;
width: root.width;
height: root.height;
function calc-y() -> length {
parent.y + parent.height / 2 - self.height / 2
}
y: calc-y();
states [
swiping when sgh.swiping: {
y: calc-y() + sgh.current-position.y - sgh.pressed-position.y;
}
exiting when exit-animation.running: {
y: parent.height;
in {
animate y {
duration: exit-animation.interval;
}
}
}
]
}
}
}

View File

@@ -1,35 +0,0 @@
import { TextEdit, LineEdit, Button } from "std-widgets.slint";
import { Global } from "global.slint";
export component LoginView inherits VerticalLayout {
padding: 16px;
alignment: center;
spacing: 8px;
HorizontalLayout {
alignment: center;
Image {
width: 128px;
height: self.width;
source: @image-url("../assets/immich-logo.svg");
}
}
url := LineEdit {
placeholder-text: "immich url";
height: 40px;
}
api-key := LineEdit {
placeholder-text: "immich api key";
height: 40px;
}
Button {
text: "Login";
height: 40px;
clicked => {
Global.login-api-key(url.text, api-key.text);
}
}
}

View File

@@ -1,174 +0,0 @@
import { ScrollView, Palette } from "std-widgets.slint";
import { Global } from "global.slint";
import { ImageBucket, Visibility, ImagePreview } from "types.slint";
export component ImagePreview inherits Rectangle {
in property <ImagePreview> preview;
in property <length> size: 32px;
callback clicked <=> touch.clicked;
width: size;
height: size;
clip: true;
Image {
width: preview.ratio < 1.0 ? size : size * preview.ratio;
height: preview.ratio > 1.0 ? size : size / preview.ratio;
source: preview.image;
}
touch := TouchArea {}
}
component TimelineBlock inherits VerticalLayout {
in property <int> index: -1;
in-out property <ImageBucket> bucket;
property <length> min-image-size: Global.min-image-size;
property <length> min-size-with-margin: min-image-size + Global.image-margin;
property <int> count-x: Math.floor(self.width / min-size-with-margin); // TODO: or is it ceil?
property <int> count-y: Math.ceil(bucket.count / count-x);
function calc-image-size() -> length {
let remaining-length = Math.mod(self.width, min-size-with-margin);
min-image-size + remaining-length / count-x
}
property <length> image-size: calc-image-size();
property <length> image-size-with-margin: image-size + Global.image-margin;
property <length> title-box-height: 44px;
height: title-box-height + count-y * image-size-with-margin;
y: bucket.y;
min-width: min-image-size;
alignment: start;
title-box := Rectangle {
property <bool> checked: false;
HorizontalLayout {
alignment: space-between;
height: title-box-height;
padding: 8px;
title := Text {
text: bucket.title;
font-size: 20px;
}
if !checked : Image {
source: @image-url("../assets/unchecked.svg");
colorize: Palette.foreground;
opacity: 0.8;
height: title.height;
width: self.height;
}
if checked : Image {
source: @image-url("../assets/checked.svg");
colorize: Palette.accent-background;
height: title.height;
width: self.height;
}
}
title-touch := TouchArea {
clicked => {
parent.checked = !parent.checked;
}
}
}
image-box := Rectangle {
width: 100%;
height: count-y * image-size-with-margin;
for preview[i] in bucket.previews : ImagePreview {
preview: preview;
size: image-size;
x: Global.image-margin / 2 + Math.mod(i, count-x) * (Global.image-margin + image-size);
y: Math.floor(i / count-x) * (image-size + Global.image-margin);
clicked => {
Global.viewed-image = preview;
Global.view-image(preview.asset-id);
}
}
}
}
export component ScrollHandle {
out property<float> maximum: 1;
out property<float> minimum: 0;
in-out property<float> value;
callback dragged(float);
width: handle.width * 0.66;
horizontal-stretch: 0;
vertical-stretch: 1;
height: 100%;
handle := Rectangle {
x: 0;
width: 64px;
height: self.width;
border-width: 3px;
border-radius: self.height / 2;
background: touch.pressed ? Palette.accent-background : Palette.alternate-background;
border-color: Palette.accent-foreground;
y: (root.height - handle.height) * (root.value - root.minimum)/(root.maximum - root.minimum);
touch := TouchArea {
moved => {
if (self.enabled && self.pressed) {
root.value = max(root.minimum, min(root.maximum,
root.value + (self.mouse-y - self.pressed-y) * (root.maximum - root.minimum) / root.height));
dragged(root.value)
}
}
}
}
}
export component Timeline {
scroll-view := ScrollView {
mouse-drag-pan-enabled: true;
viewport-height: rect.height;
vertical-scrollbar-policy: always-off;
horizontal-scrollbar-policy: always-off;
scrolled => {
// sync ScrollHandle with ScrollView
scroll-handle.value = (-scroll-view.viewport-y) / scroll-view.viewport-height;
}
changed viewport-y => {
Global.timeline-scrolled(-self.viewport-y);
}
rect := Rectangle {
y: 0;
x: 0;
width: root.width;
height: Global.timeline-height;
preferred-width: self.width;
preferred-height: self.height;
for bucket[i] in Global.image-buckets : Rectangle {
if bucket.visibility == Visibility.InView : TimelineBlock {
width: root.width;
index: i;
bucket: bucket;
}
}
}
}
scroll-handle := ScrollHandle {
x: parent.x + parent.width - self.width;
height: root.height;
dragged(value) => {
// sync ScrollView with ScrollHandle
scroll-view.viewport-y = -(value * scroll-view.viewport-height);
}
}
}

View File

@@ -1,43 +0,0 @@
export enum ImageKind {
None,
Thumbhash,
Thumbnail,
Original,
}
export struct ImagePreview {
asset_id: string,
// Thumbnail/thumbhash/etc
image: image,
// Image aspect ratio. (width/height)
ratio: float,
kind: ImageKind,
}
export enum Visibility {
Hidden,
NearView,
InView,
}
export struct ImageBucket {
key: string,
title: string,
count: int,
previews: [ImagePreview],
y: length,
height: length,
visibility: Visibility,
}
export struct AlbumCover {
id: string,
name: string,
description: string,
thumbnail: ImagePreview,
asset_count: int,
}