Compare commits

...

10 Commits

26 changed files with 874 additions and 117 deletions

4
.gitignore vendored
View File

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

20
Cargo.lock generated
View File

@@ -2473,6 +2473,7 @@ dependencies = [
"thumbhash", "thumbhash",
"tikv-jemallocator", "tikv-jemallocator",
"tokio", "tokio",
"toml 1.1.2+spec-1.1.0",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"xdg", "xdg",
@@ -2481,11 +2482,13 @@ dependencies = [
[[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",
@@ -4798,7 +4801,7 @@ dependencies = [
"regex", "regex",
"serde_json", "serde_json",
"tar", "tar",
"toml", "toml 0.9.12+spec-1.1.0",
] ]
[[package]] [[package]]
@@ -5454,6 +5457,21 @@ 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"

View File

@@ -11,7 +11,6 @@ clap = { version = "4.6.0", features = ["derive", "env"] }
either = "1.15.0" either = "1.15.0"
futures = "0.3.32" futures = "0.3.32"
image = { version = "0.25.10", default-features = false, features = ["serde", "webp"] } image = { version = "0.25.10", default-features = false, features = ["serde", "webp"] }
immich-sdk.path = "../immich-sdk/"
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"
@@ -21,6 +20,12 @@ 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" xdg = "3.0.0"
tikv-jemallocator = "0.6" 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"

29
README.md Normal file
View File

@@ -0,0 +1,29 @@
# 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
```

1
assets/album.svg Normal file
View File

@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 374 B

44
assets/checked.svg Normal file
View File

@@ -0,0 +1,44 @@
<?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>

After

Width:  |  Height:  |  Size: 1.3 KiB

18
assets/immich-logo.svg Normal file
View File

@@ -0,0 +1,18 @@
<?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>

After

Width:  |  Height:  |  Size: 2.0 KiB

7
assets/immich-rs.desktop Normal file
View File

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

1
assets/photos.svg Normal file
View File

@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 356 B

1
assets/search.svg Normal file
View File

@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 473 B

47
assets/unchecked.svg Normal file
View File

@@ -0,0 +1,47 @@
<?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>

After

Width:  |  Height:  |  Size: 1.3 KiB

41
sh.nubo.immich-rs.yml Normal file
View File

@@ -0,0 +1,41 @@
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

@@ -10,7 +10,7 @@ use image::{
DynamicImage, EncodableLayout, DynamicImage, EncodableLayout,
codecs::webp::{WebPDecoder, WebPEncoder}, codecs::webp::{WebPDecoder, WebPEncoder},
}; };
use immich_sdk::{AssetId, AssetVisibility}; use immich_sdk::{AlbumResponse, AssetId, AssetVisibility};
use slint::{Rgba8Pixel, SharedPixelBuffer}; use slint::{Rgba8Pixel, SharedPixelBuffer};
use crate::{ use crate::{
@@ -24,6 +24,7 @@ pub struct Api {
client: immich_sdk::Client, client: immich_sdk::Client,
buckets: Mutex<HashMap<TimeBucketKey, Arc<TimeBucket>>>, buckets: Mutex<HashMap<TimeBucketKey, Arc<TimeBucket>>>,
thumbnails: Mutex<CacheMap<AssetId, Arc<AssetThumbnail>>>, thumbnails: Mutex<CacheMap<AssetId, Arc<AssetThumbnail>>>,
assets: Mutex<CacheMap<AssetId, Arc<AssetThumbnail>>>,
} }
fn serialize_thumbnail(_id: &AssetId, thumbnail: &Arc<AssetThumbnail>) -> Vec<u8> { fn serialize_thumbnail(_id: &AssetId, thumbnail: &Arc<AssetThumbnail>) -> Vec<u8> {
@@ -61,12 +62,50 @@ fn deserialize_thumbnail(&id: &AssetId, bytes: &[u8]) -> anyhow::Result<Arc<Asse
impl Api { impl Api {
pub fn new(client: immich_sdk::Client) -> Arc<Self> { 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 client_ = client.clone();
let thumbnail_fetcher = AsyncFnFetcher::new(move |asset_id: &AssetId| { let thumbnail_fetcher = AsyncFnFetcher::new(move |asset_id: &AssetId| {
let client = client_.clone(); let client = client_.clone();
let asset_id = asset_id.clone(); let asset_id = asset_id.clone();
tokio::spawn(async move { tokio::spawn(async move {
let response = client let asset = client
.assets() .assets()
.thumbnail(asset_id) .thumbnail(asset_id)
.size(immich_sdk::AssetMediaSize::Thumbnail) .size(immich_sdk::AssetMediaSize::Thumbnail)
@@ -74,7 +113,7 @@ impl Api {
.await .await
.context(anyhow!("Failed to get asset thumbnail for {asset_id}"))?; .context(anyhow!("Failed to get asset thumbnail for {asset_id}"))?;
let thumbnail = response let thumbnail = asset
.decode() .decode()
.context(anyhow!("Failed to decode asset thumbnail for {asset_id}"))? .context(anyhow!("Failed to decode asset thumbnail for {asset_id}"))?
.into_rgba8(); .into_rgba8();
@@ -91,7 +130,7 @@ impl Api {
})) }))
}) })
}); });
let cache_map = CacheMap::new( let thumbnail_map = CacheMap::new(
Arc::new(thumbnail_fetcher) as Arc<dyn Fetcher<Arc<AssetThumbnail>, Key = AssetId>>, Arc::new(thumbnail_fetcher) as Arc<dyn Fetcher<Arc<AssetThumbnail>, Key = AssetId>>,
"thumbnails", "thumbnails",
serialize_thumbnail, serialize_thumbnail,
@@ -102,7 +141,8 @@ impl Api {
Arc::new(Self { Arc::new(Self {
client, client,
buckets: Default::default(), buckets: Default::default(),
thumbnails: Mutex::new(cache_map), thumbnails: Mutex::new(thumbnail_map),
assets: Mutex::new(asset_map),
}) })
} }
} }
@@ -140,7 +180,7 @@ impl Api {
.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:?}");
})?; })?;
@@ -220,4 +260,26 @@ impl Api {
.await .await
.context(anyhow!("Failed to fetch thumbnail for {asset_id:?}")) .context(anyhow!("Failed to fetch thumbnail for {asset_id:?}"))
} }
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:?}"))
}
pub async fn get_album_list(&self) -> anyhow::Result<Vec<AlbumResponse>> {
let albums = self
.client
.albums()
.list()
.execute()
.await
.context(anyhow!("Failed to fetch album list"))
.inspect_err(|e| {
tracing::error!("{e:?}");
})?;
Ok(albums)
}
} }

View File

@@ -15,6 +15,8 @@ use either::Either;
use futures::{FutureExt, future::WeakShared}; use futures::{FutureExt, future::WeakShared};
use tokio::{fs, task::JoinHandle}; use tokio::{fs, task::JoinHandle};
use crate::xdg::BASE_DIRECTORIES;
pub trait Fetcher<V>: Send + Sync + 'static { pub trait Fetcher<V>: Send + Sync + 'static {
type Key: ToString + FromStr; type Key: ToString + FromStr;
@@ -86,9 +88,8 @@ where
serialize: fn(&K, &V) -> Vec<u8>, serialize: fn(&K, &V) -> Vec<u8>,
deserialize: fn(&K, &[u8]) -> anyhow::Result<V>, deserialize: fn(&K, &[u8]) -> anyhow::Result<V>,
) -> anyhow::Result<Self> { ) -> anyhow::Result<Self> {
let crate_name = env!("CARGO_CRATE_NAME"); let data_dir = BASE_DIRECTORIES
let data_dir = xdg::BaseDirectories::new() .create_cache_directory(cache_name)
.create_cache_directory(format!("{crate_name}/thumbnails"))
.context(anyhow!( .context(anyhow!(
"Failed to create XDG data folder for {cache_name:?}" "Failed to create XDG data folder for {cache_name:?}"
))?; ))?;

46
src/config.rs Normal file
View File

@@ -0,0 +1,46 @@
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

@@ -6,60 +6,38 @@ use std::{mem, ops::Deref, sync::Arc};
use clap::Parser; use clap::Parser;
use immich_sdk::AssetId; use immich_sdk::AssetId;
use slint::{ use slint::{
ComponentHandle as _, Model, ModelRc, SharedPixelBuffer, SharedString, ToSharedString, ComponentHandle as _, Image, Model, ModelRc, SharedPixelBuffer, SharedString, ToSharedString,
VecModel, Weak, VecModel, Weak,
}; };
use tracing::Level; use tracing::Level;
use crate::{ use crate::{
api::{Api, TimeBucketKey}, api::{Api, TimeBucketKey},
ui::{AppWindow, ImageBucket}, config::Config,
ui::AppWindow,
}; };
/// Use jemalloc to reduce memory fragmentation. /// Use jemalloc to reduce memory fragmentation.
#[global_allocator] #[global_allocator]
static ALLOCATOR: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc; static ALLOCATOR: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc;
mod api; pub mod api;
pub mod cachemap; pub mod cachemap;
mod thumbhash; 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)
@@ -68,12 +46,53 @@ 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 immich_config = let config_ = runtime
immich_sdk::Config::new(opt.immich_base_url).with_api_key(opt.immich_api_key); .block_on(Config::load())
let api_ = Api::new(immich_sdk::Client::new(immich_config).unwrap()); .inspect_err(|e| tracing::debug!("{e}"))
.map(Arc::new)
.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())); global.set_image_buckets(ModelRc::new(VecModel::default()));
let app_weak = app.as_weak(); let app_weak = app.as_weak();
@@ -98,7 +117,8 @@ fn main() -> anyhow::Result<()> {
.map(|_| ui::ImagePreview { .map(|_| ui::ImagePreview {
asset_id: SharedString::new(), asset_id: SharedString::new(),
image: preview_image.clone(), image: preview_image.clone(),
kind: ui::PreviewKind::None, ratio: 1.0,
kind: ui::ImageKind::None,
}) })
.collect::<VecModel<_>>(), .collect::<VecModel<_>>(),
), ),
@@ -130,9 +150,111 @@ fn main() -> anyhow::Result<()> {
}); });
}); });
app.run()?; 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}");
Ok(()) 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);
});
});
});
} }
fn calculate_timeline_visibility(app: &AppWindow, api: Arc<Api>, scroll: f32) { fn calculate_timeline_visibility(app: &AppWindow, api: Arc<Api>, scroll: f32) {
@@ -220,11 +342,11 @@ fn calculate_timeline_layout(app: &AppWindow, api: Arc<Api>, timeline_width: f32
} }
struct AppImageBuckets { struct AppImageBuckets {
buckets: ModelRc<ImageBucket>, buckets: ModelRc<ui::ImageBucket>,
} }
impl Deref for AppImageBuckets { impl Deref for AppImageBuckets {
type Target = VecModel<ImageBucket>; type Target = VecModel<ui::ImageBucket>;
#[track_caller] #[track_caller]
fn deref(&self) -> &Self::Target { fn deref(&self) -> &Self::Target {
@@ -235,7 +357,7 @@ impl Deref for AppImageBuckets {
} }
} }
fn get_image_buckets(app: &AppWindow) -> impl Deref<Target = VecModel<ImageBucket>> { fn get_image_buckets(app: &AppWindow) -> impl Deref<Target = VecModel<ui::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(),
@@ -262,7 +384,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::PreviewKind::None; preview.kind = ui::ImageKind::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);
} }
@@ -289,7 +411,8 @@ 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::PreviewKind::Thumbhash, kind: ui::ImageKind::Thumbhash,
ratio: entry.ratio as f32,
// TODO: don't unwrap // TODO: don't unwrap
image: entry image: entry
.thumbhash .thumbhash
@@ -337,7 +460,9 @@ fn load_thumbnail(
}; };
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::PreviewKind::Thumbnail; preview.kind = ui::ImageKind::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);
}); });
}); });

8
src/xdg.rs Normal file
View File

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

71
ui/albums.slint Normal file
View File

@@ -0,0 +1,71 @@
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,25 +1,22 @@
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 { Timeline } from "timeline.slint";
import { ImageViewer } from "image-viewer.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 { Global } from "global.slint";
import { Albums } from "albums.slint";
export { Global } export { Global }
component Header inherits Rectangle { enum View {
width: 100%; Timeline,
height: 48px; Albums
background: Palette.alternate-background;
HorizontalBox {
height: parent.height;
Text {
text: "immich";
}
}
} }
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;
@@ -34,11 +31,37 @@ export component AppWindow inherits Window {
Header {} Header {}
Timeline {} if !Global.logged-in: LoginView {}
if Global.logged-in && view == View.Timeline: Timeline {}
if Global.logged-in && view == View.Albums: Albums {}
Footer {
FooterButton {
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.previewed-image.asset-id != "" : ImageViewer { if Global.viewed-image.asset-id != "" : ImageViewer {
image: Global.previewed-image.image; image: Global.viewed-image.image;
} }
} }

59
ui/footer.slint Normal file
View File

@@ -0,0 +1,59 @@
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,9 +1,11 @@
import { ImageBucket, ImagePreview } from "types.slint"; import { ImageBucket, ImagePreview, AlbumCover } from "types.slint";
export global Global { export global Global {
in-out property <length> min-image-size: 160px; 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 <length> image-margin: 2px;
in-out property <ImagePreview> previewed-image; in-out property <ImagePreview> viewed-image;
in-out property <[AlbumCover]> albums;
in-out property <[ImageBucket]> image-buckets: [ in-out property <[ImageBucket]> image-buckets: [
{ key: "2026-02-01", title: "Feb 1, 2026", count: 12 }, { key: "2026-02-01", title: "Feb 1, 2026", count: 12 },
{ key: "2026-02-02", title: "Feb 2, 2026", count: 12 }, { key: "2026-02-02", title: "Feb 2, 2026", count: 12 },
@@ -12,6 +14,9 @@ export global Global {
in property <length> timeline-height; in property <length> timeline-height;
in property <length> timeline-width; in property <length> timeline-width;
in property <length> timeline-scroll; in property <length> timeline-scroll;
callback login-api-key(url: string, api_key: string);
callback set-timeline-width(length); callback set-timeline-width(length);
callback timeline-scrolled(length); callback timeline-scrolled(length);
callback view-image(string);
callback load-albums();
} }

14
ui/header.slint Normal file
View File

@@ -0,0 +1,14 @@
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

@@ -21,7 +21,7 @@ export component ImageViewer inherits Rectangle {
interval: 0.2s; interval: 0.2s;
triggered => { triggered => {
self.running = false; self.running = false;
Global.previewed-image.asset-id = ""; Global.viewed-image.asset-id = "";
} }
} }
@@ -64,6 +64,8 @@ export component ImageViewer inherits Rectangle {
Image { Image {
source: image; source: image;
image-fit: ImageFit.contain; image-fit: ImageFit.contain;
width: root.width;
height: root.height;
function calc-y() -> length { function calc-y() -> length {
parent.y + parent.height / 2 - self.height / 2 parent.y + parent.height / 2 - self.height / 2

35
ui/login.slint Normal file
View File

@@ -0,0 +1,35 @@
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,24 +1,23 @@
import { ScrollView } from "std-widgets.slint"; import { ScrollView, Palette } from "std-widgets.slint";
import { Global } from "global.slint"; import { Global } from "global.slint";
import { ImageBucket, Visibility, ImagePreview } from "types.slint"; import { ImageBucket, Visibility, ImagePreview } from "types.slint";
component ImagePreview inherits Rectangle { export component ImagePreview inherits Rectangle {
in property <ImagePreview> preview; in property <ImagePreview> preview;
in property <length> size: 32px; in property <length> size: 32px;
callback clicked <=> touch.clicked;
width: size; width: size;
height: size; height: size;
clip: true;
Image { Image {
width: 100%; width: preview.ratio < 1.0 ? size : size * preview.ratio;
height: 100%; height: preview.ratio > 1.0 ? size : size / preview.ratio;
source: preview.image; source: preview.image;
} }
touch := TouchArea { touch := TouchArea {}
clicked => {
Global.previewed-image = root.preview;
}
}
} }
component TimelineBlock inherits VerticalLayout { component TimelineBlock inherits VerticalLayout {
@@ -38,25 +37,46 @@ component TimelineBlock inherits VerticalLayout {
property <length> image-size: calc-image-size(); property <length> image-size: calc-image-size();
property <length> image-size-with-margin: image-size + Global.image-margin; property <length> image-size-with-margin: image-size + Global.image-margin;
property <length> title-box-height: 36px; property <length> title-box-height: 44px;
height: title-box.height + count-y * image-size-with-margin; height: title-box-height + count-y * image-size-with-margin;
y: bucket.y; y: bucket.y;
min-width: min-image-size; min-width: min-image-size;
alignment: start; alignment: start;
title-box := HorizontalLayout { title-box := Rectangle {
alignment: space-between; property <bool> checked: false;
height: title-box-height;
padding: 8px;
Text { HorizontalLayout {
text: bucket.title; 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;
}
} }
// TODO: checkbox thingy title-touch := TouchArea {
Text { clicked => {
text: "O"; parent.checked = !parent.checked;
}
} }
} }
@@ -69,31 +89,86 @@ component TimelineBlock inherits VerticalLayout {
size: image-size; size: image-size;
x: Global.image-margin / 2 + Math.mod(i, count-x) * (Global.image-margin + 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); y: Math.floor(i / count-x) * (image-size + Global.image-margin);
clicked => {
Global.viewed-image = preview;
Global.view-image(preview.asset-id);
}
} }
} }
} }
export component Timeline inherits ScrollView { export component ScrollHandle {
mouse-drag-pan-enabled: true; out property<float> maximum: 1;
viewport-height: rect.height; out property<float> minimum: 0;
in-out property<float> value;
callback dragged(float);
changed viewport-y => { width: handle.width * 0.66;
Global.timeline-scrolled(-self.viewport-y); horizontal-stretch: 0;
} vertical-stretch: 1;
height: 100%;
rect := Rectangle { handle := Rectangle {
y: 0; x: 0;
x: 0; width: 64px;
width: root.width; height: self.width;
height: Global.timeline-height; border-width: 3px;
preferred-width: self.width; border-radius: self.height / 2;
preferred-height: self.height; background: touch.pressed ? Palette.accent-background : Palette.alternate-background;
for bucket[i] in Global.image-buckets : Rectangle { border-color: Palette.accent-foreground;
if bucket.visibility == Visibility.InView : TimelineBlock { y: (root.height - handle.height) * (root.value - root.minimum)/(root.maximum - root.minimum);
width: root.width;
index: i; touch := TouchArea {
bucket: bucket; 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,13 +1,20 @@
export enum PreviewKind { export enum ImageKind {
None, None,
Thumbhash, Thumbhash,
Thumbnail, Thumbnail,
Original,
} }
export struct ImagePreview { export struct ImagePreview {
asset_id: string, asset_id: string,
// Thumbnail/thumbhash/etc
image: image, image: image,
kind: PreviewKind,
// Image aspect ratio. (width/height)
ratio: float,
kind: ImageKind,
} }
export enum Visibility { export enum Visibility {
@@ -26,3 +33,11 @@ export struct ImageBucket {
visibility: Visibility, visibility: Visibility,
} }
export struct AlbumCover {
id: string,
name: string,
description: string,
thumbnail: ImagePreview,
asset_count: int,
}