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
**/*.rs.bk
# Flatpak stuff
/repo
*.flatpak

52
Cargo.lock generated
View File

@@ -2425,7 +2425,6 @@ dependencies = [
"ravif",
"rayon",
"rgb",
"serde",
"tiff",
"zune-core",
"zune-jpeg",
@@ -2461,9 +2460,6 @@ dependencies = [
"base64",
"blurhash",
"clap",
"either",
"futures",
"image",
"immich-sdk",
"kameo",
"serde",
@@ -2471,24 +2467,19 @@ dependencies = [
"slint",
"slint-build",
"thumbhash",
"tikv-jemallocator",
"tokio",
"toml 1.1.2+spec-1.1.0",
"tracing",
"tracing-subscriber",
"xdg",
]
[[package]]
name = "immich-sdk"
version = "1.137.0"
source = "git+https://git.nubo.sh/hulthe/immich-sdk.git?rev=c0bde4f8bd50d2861548666297f40bed3b85b865#c0bde4f8bd50d2861548666297f40bed3b85b865"
dependencies = [
"async-trait",
"bytes",
"chrono",
"image",
"mime",
"reqwest",
"serde",
"serde_json",
@@ -4801,7 +4792,7 @@ dependencies = [
"regex",
"serde_json",
"tar",
"toml 0.9.12+spec-1.1.0",
"toml",
]
[[package]]
@@ -5293,26 +5284,6 @@ dependencies = [
"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]]
name = "tiny-skia"
version = "0.11.4"
@@ -5457,21 +5428,6 @@ dependencies = [
"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]]
name = "toml_datetime"
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"
checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b"
[[package]]
name = "xdg"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2fb433233f2df9344722454bc7e96465c9d03bff9d77c248f9e7523fe79585b5"
[[package]]
name = "xkbcommon"
version = "0.9.0"

View File

@@ -8,9 +8,7 @@ anyhow = "1.0.102"
base64 = "0.22.1"
blurhash = "0.2.3"
clap = { version = "4.6.0", features = ["derive", "env"] }
either = "1.15.0"
futures = "0.3.32"
image = { version = "0.25.10", default-features = false, features = ["serde", "webp"] }
immich-sdk.path = "../immich-sdk/"
kameo = "0.19.2"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149"
@@ -18,14 +16,6 @@ thumbhash = "0.1.0"
tokio = { version = "1.51.0", features = ["full"] }
tracing = "0.1.44"
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]
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::{
collections::HashMap,
io::Cursor,
iter::repeat,
sync::{Arc, Mutex},
};
use std::{collections::HashMap, iter::repeat, sync::Arc};
use anyhow::{Context as _, anyhow};
use image::{
DynamicImage, EncodableLayout,
codecs::webp::{WebPDecoder, WebPEncoder},
use immich_sdk::{AssetId, AssetVisibility};
use kameo::{
Actor,
prelude::{Context, Message},
};
use immich_sdk::{AlbumResponse, AssetId, AssetVisibility};
use slint::{Rgba8Pixel, SharedPixelBuffer};
use crate::{
cachemap::{AsyncFnFetcher, CacheMap, Fetcher},
thumbhash::thumbhashes_to_pixels,
};
use crate::thumbhash::thumbhashes_to_pixels;
pub type TimeBucketKey = String;
#[derive(Actor)]
pub struct Api {
client: immich_sdk::Client,
buckets: Mutex<HashMap<TimeBucketKey, Arc<TimeBucket>>>,
thumbnails: Mutex<CacheMap<AssetId, Arc<AssetThumbnail>>>,
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,
}))
buckets: HashMap<TimeBucketKey, Arc<TimeBucket>>,
thumbnails: HashMap<AssetId, Arc<AssetThumbnail>>, // TODO
}
impl Api {
pub fn new(client: immich_sdk::Client) -> Arc<Self> {
let client_ = client.clone();
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 {
pub fn new(client: immich_sdk::Client) -> Self {
Self {
client,
buckets: Default::default(),
thumbnails: Mutex::new(thumbnail_map),
assets: Mutex::new(asset_map),
})
thumbnails: Default::default(),
}
}
}
pub struct GetTimeBuckets;
pub struct TimeBucketRef {
pub key: TimeBucketKey,
pub count: usize,
}
pub struct GetTimeBucket {
pub time_bucket: TimeBucketKey,
}
pub struct TimeBucket {
pub key: TimeBucketKey,
pub entries: Arc<[TimeBucketEntry]>,
@@ -167,20 +54,30 @@ pub struct TimeBucketEntry {
pub visibility: AssetVisibility,
}
pub struct GetAssetThumbnail {
pub id: AssetId,
}
pub struct AssetThumbnail {
pub id: AssetId,
pub thumbnail: SharedPixelBuffer<Rgba8Pixel>,
}
impl Api {
pub async fn get_time_buckets(&self) -> anyhow::Result<Vec<TimeBucketRef>> {
impl Message<GetTimeBuckets> for Api {
type Reply = anyhow::Result<Arc<[TimeBucketRef]>>;
async fn handle(
&mut self,
_msg: GetTimeBuckets,
_ctx: &mut Context<Self, Self::Reply>,
) -> Self::Reply {
let buckets = self
.client
.timeline()
.buckets()
.execute()
.await
.context(anyhow!("Failed to fetch list of time buckets"))
.context(anyhow!("Failed to fetch list of time buckets",))
.inspect_err(|e| {
tracing::error!("{e:?}");
})?;
@@ -193,22 +90,30 @@ impl Api {
})
.collect())
}
}
pub async fn get_time_bucket(
&self,
time_bucket: TimeBucketKey,
) -> anyhow::Result<Arc<TimeBucket>> {
if let Some(time_bucket) = self.buckets.lock().unwrap().get(&time_bucket).cloned() {
impl Message<GetTimeBucket> for Api {
type Reply = anyhow::Result<Arc<TimeBucket>>;
async fn handle(
&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);
}
let bucket = self
.client
.timeline()
.bucket(&time_bucket)
.bucket(&msg.time_bucket)
.execute()
.await
.context(anyhow!("Failed to fetch time bucket {:?}", &time_bucket))
.context(anyhow!(
"Failed to fetch time bucket {:?}",
&msg.time_bucket
))
.inspect_err(|e| {
tracing::error!("{e:?}");
})?;
@@ -239,47 +144,55 @@ impl Api {
.collect();
let bucket = Arc::new(TimeBucket {
key: time_bucket,
key: msg.time_bucket,
entries,
});
self.buckets
.lock()
.unwrap()
.insert(bucket.key.clone(), bucket.clone());
self.buckets.insert(bucket.key.clone(), bucket.clone());
Ok(bucket)
}
}
pub async fn get_asset_thumbnail(
&self,
asset_id: AssetId,
) -> anyhow::Result<Arc<AssetThumbnail>> {
let fetch = self.thumbnails.lock().unwrap().get(asset_id);
fetch
.await
.context(anyhow!("Failed to fetch thumbnail for {asset_id:?}"))
}
impl Message<GetAssetThumbnail> for Api {
type Reply = anyhow::Result<Arc<AssetThumbnail>>;
pub async fn get_asset(&self, asset_id: AssetId) -> anyhow::Result<Arc<AssetThumbnail>> {
let fetch = self.assets.lock().unwrap().get(asset_id);
fetch
.await
.context(anyhow!("Failed to fetch asset {asset_id:?}"))
}
async fn handle(
&mut self,
msg: GetAssetThumbnail,
_ctx: &mut Context<Self, Self::Reply>,
) -> Self::Reply {
if let Some(thumbnail) = self.thumbnails.get(&msg.id).cloned() {
return Ok(thumbnail);
}
pub async fn get_album_list(&self) -> anyhow::Result<Vec<AlbumResponse>> {
let albums = self
let response = self
.client
.albums()
.list()
.assets()
.thumbnail(msg.id)
.size(immich_sdk::AssetMediaSize::Thumbnail)
.execute()
.await
.context(anyhow!("Failed to fetch album list"))
.inspect_err(|e| {
tracing::error!("{e:?}");
})?;
.context(anyhow!("Failed to get asset thumbnail for {}", msg.id))?;
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.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use std::{mem, ops::Deref, sync::Arc};
use std::{mem, ops::Deref};
use clap::Parser;
use immich_sdk::AssetId;
use kameo::actor::{ActorRef, Spawn};
use slint::{
ComponentHandle as _, Image, Model, ModelRc, SharedPixelBuffer, SharedString, ToSharedString,
ComponentHandle as _, Model, ModelRc, SharedPixelBuffer, SharedString, ToSharedString,
VecModel, Weak,
};
use tracing::Level;
use crate::{
api::{Api, TimeBucketKey},
config::Config,
ui::AppWindow,
api::{Api, GetAssetThumbnail, GetTimeBucket, GetTimeBuckets, TimeBucketKey},
ui::{AppWindow, ImageBucket},
};
/// Use jemalloc to reduce memory fragmentation.
#[global_allocator]
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 api;
mod thumbhash;
mod ui {
slint::include_modules!();
}
#[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<()> {
let _opt = Opt::parse();
let opt = Opt::parse();
tracing_subscriber::fmt()
.with_max_level(Level::DEBUG)
@@ -46,59 +64,18 @@ fn main() -> anyhow::Result<()> {
let runtime = tokio::runtime::Runtime::new().unwrap();
let _rt_guard = runtime.enter();
let config_ = runtime
.block_on(Config::load())
.inspect_err(|e| tracing::debug!("{e}"))
.map(Arc::new)
.unwrap_or_default();
let immich_config =
immich_sdk::Config::new(opt.immich_base_url).with_api_key(opt.immich_api_key);
let api = Api::new(immich_sdk::Client::new(immich_config).unwrap());
let api_ = Api::spawn(api);
let app = ui::AppWindow::new()?;
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 api = api_.clone();
tokio::spawn(async move {
let Ok(buckets) = api.get_time_buckets().await else {
let Ok(buckets) = api.ask(GetTimeBuckets).await else {
return;
};
@@ -117,8 +94,7 @@ fn start_api(app: &AppWindow, immich: &config::ImmichLogin) {
.map(|_| ui::ImagePreview {
asset_id: SharedString::new(),
image: preview_image.clone(),
ratio: 1.0,
kind: ui::ImageKind::None,
kind: ui::PreviewKind::None,
})
.collect::<VecModel<_>>(),
),
@@ -150,120 +126,18 @@ fn start_api(app: &AppWindow, immich: &config::ImmichLogin) {
});
});
let app_weak = app.as_weak();
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}");
app.run()?;
let _ = app_weak.upgrade_in_event_loop(move |app| {
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);
});
});
});
Ok(())
}
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>();
global.set_timeline_scroll(scroll);
let window_height = app.get_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() {
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 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 {
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 min_image_size = global.get_min_image_size();
let image_margin = global.get_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 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 {
buckets: ModelRc<ui::ImageBucket>,
buckets: ModelRc<ImageBucket>,
}
impl Deref for AppImageBuckets {
type Target = VecModel<ui::ImageBucket>;
type Target = VecModel<ImageBucket>;
#[track_caller]
fn deref(&self) -> &Self::Target {
self.buckets
.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>();
AppImageBuckets {
buckets: global.get_image_buckets(),
@@ -374,7 +247,7 @@ fn placeholder_preview() -> slint::Image {
fn unload_bucket(time_bucket: &TimeBucketKey, app: &AppWindow) {
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;
};
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 {
break;
};
preview.kind = ui::ImageKind::None;
preview.kind = ui::PreviewKind::None;
preview.image = placeholder_preview.clone();
bucket.previews.set_row_data(j, preview);
}
@@ -392,16 +265,21 @@ fn unload_bucket(time_bucket: &TimeBucketKey, app: &AppWindow) {
// 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 {
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;
};
let _ = app_weak.upgrade_in_event_loop(move |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;
};
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()
.map(|entry| ui::ImagePreview {
asset_id: entry.id.to_shared_string(), // slint doesn't have a uuid type
kind: ui::ImageKind::Thumbhash,
ratio: entry.ratio as f32,
kind: ui::PreviewKind::Thumbhash,
// TODO: don't unwrap
image: entry
.thumbhash
@@ -433,13 +310,13 @@ fn load_bucket(time_bucket: TimeBucketKey, app_weak: Weak<AppWindow>, api: Arc<A
fn load_thumbnail(
time_bucket: TimeBucketKey,
asset_id: AssetId,
id: AssetId,
app_weak: Weak<AppWindow>,
api: Arc<Api>,
api: ActorRef<Api>,
) {
tokio::spawn(async move {
tracing::debug!("Fetching thumbnail for {asset_id}");
let thumbnail = match api.get_asset_thumbnail(asset_id).await {
tracing::debug!("Fetching thumbnail for {id}");
let thumbnail = match api.ask(GetAssetThumbnail { id }).await {
Ok(thumbnail) => thumbnail,
Err(e) => {
tracing::error!("{e:?}");
@@ -449,20 +326,18 @@ fn load_thumbnail(
let _ = app_weak.upgrade_in_event_loop(move |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;
};
let bucket = buckets.row_data(i).expect("i is in the list");
let id_str = asset_id.to_string();
let Some(i) = bucket.previews.iter().position(|p| p.asset_id == id_str) else {
let id_str = id.to_string();
let Some(i) = bucket.previews.iter().position(|p| &p.asset_id == &id_str) else {
return;
};
let mut preview = bucket.previews.row_data(i).expect("i is in the list");
preview.image = slint::Image::from_rgba8(thumbnail.thumbnail.clone());
preview.kind = ui::ImageKind::Thumbnail;
preview.ratio =
thumbnail.thumbnail.width() as f32 / thumbnail.thumbnail.height() as f32;
preview.kind = ui::PreviewKind::Thumbnail;
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 { 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";
import { Albums } from "albums.slint";
export { Global }
enum View {
Timeline,
Albums
enum PreviewKind {
None,
Thumbhash,
Thumbnail,
}
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 {
out property <length> window-height: self.height;
out property <View> view: View.Timeline;
// Do not base preferred-width on children
preferred-width: 480px;
@@ -31,37 +151,30 @@ export component AppWindow inherits Window {
Header {}
if !Global.logged-in: LoginView {}
if Global.logged-in && view == View.Timeline: Timeline {}
if Global.logged-in && view == View.Albums: Albums {}
ScrollView {
mouse-drag-pan-enabled: true;
viewport-height: rect.height;
Footer {
FooterButton {
title: "Photos";
icon: @image-url("../assets/photos.svg");
clicked => { view = View.Timeline }
changed viewport-y => {
Global.timeline-scrolled(-self.viewport-y);
}
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();
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;
}
}
}
FooterButton {
title: "Library";
icon: @image-url("../assets/album.svg"); // TODO
}
}
}
if Global.viewed-image.asset-id != "" : ImageViewer {
image: Global.viewed-image.image;
}
}

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,
}