From fc721ba8171df3f17d8d168eba6e230c8e72d8c1 Mon Sep 17 00:00:00 2001 From: Joakim Hulthe Date: Sun, 24 May 2026 13:53:30 +0200 Subject: [PATCH] Download images when clicking on them --- Cargo.lock | 1 + src/api.rs | 55 +++++++++++++++++++++++++++++++++++++++---- src/cachemap.rs | 2 +- src/main.rs | 38 ++++++++++++++++++++++++++---- ui/app-window.slint | 4 ++-- ui/global.slint | 3 ++- ui/image-viewer.slint | 4 +++- ui/timeline.slint | 41 ++++++++++++++++---------------- ui/types.slint | 5 ++-- 9 files changed, 118 insertions(+), 35 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 397c4d5..812f3bc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2487,6 +2487,7 @@ dependencies = [ "bytes", "chrono", "image", + "mime", "reqwest", "serde", "serde_json", diff --git a/src/api.rs b/src/api.rs index 6c92ffb..11b6020 100644 --- a/src/api.rs +++ b/src/api.rs @@ -24,6 +24,7 @@ pub struct Api { client: immich_sdk::Client, buckets: Mutex>>, thumbnails: Mutex>>, + assets: Mutex>>, } fn serialize_thumbnail(_id: &AssetId, thumbnail: &Arc) -> Vec { @@ -61,12 +62,50 @@ fn deserialize_thumbnail(&id: &AssetId, bytes: &[u8]) -> anyhow::Result Arc { + 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::::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, 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 response = client + let asset = client .assets() .thumbnail(asset_id) .size(immich_sdk::AssetMediaSize::Thumbnail) @@ -74,7 +113,7 @@ impl Api { .await .context(anyhow!("Failed to get asset thumbnail for {asset_id}"))?; - let thumbnail = response + let thumbnail = asset .decode() .context(anyhow!("Failed to decode asset thumbnail for {asset_id}"))? .into_rgba8(); @@ -91,7 +130,7 @@ impl Api { })) }) }); - let cache_map = CacheMap::new( + let thumbnail_map = CacheMap::new( Arc::new(thumbnail_fetcher) as Arc, Key = AssetId>>, "thumbnails", serialize_thumbnail, @@ -102,7 +141,8 @@ impl Api { Arc::new(Self { client, buckets: Default::default(), - thumbnails: Mutex::new(cache_map), + thumbnails: Mutex::new(thumbnail_map), + assets: Mutex::new(asset_map), }) } } @@ -220,4 +260,11 @@ impl Api { .await .context(anyhow!("Failed to fetch thumbnail for {asset_id:?}")) } + + pub async fn get_asset(&self, asset_id: AssetId) -> anyhow::Result> { + let fetch = self.assets.lock().unwrap().get(asset_id); + fetch + .await + .context(anyhow!("Failed to fetch asset {asset_id:?}")) + } } diff --git a/src/cachemap.rs b/src/cachemap.rs index d1bf8bd..0754475 100644 --- a/src/cachemap.rs +++ b/src/cachemap.rs @@ -89,7 +89,7 @@ where deserialize: fn(&K, &[u8]) -> anyhow::Result, ) -> anyhow::Result { let data_dir = BASE_DIRECTORIES - .create_cache_directory("thumbnails") + .create_cache_directory(cache_name) .context(anyhow!( "Failed to create XDG data folder for {cache_name:?}" ))?; diff --git a/src/main.rs b/src/main.rs index 73046ca..164121c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -139,7 +139,7 @@ fn start_api(app: &AppWindow, immich: &config::ImmichLogin) { asset_id: SharedString::new(), image: preview_image.clone(), ratio: 1.0, - kind: ui::PreviewKind::None, + kind: ui::ImageKind::None, }) .collect::>(), ), @@ -170,6 +170,36 @@ fn start_api(app: &AppWindow, immich: &config::ImmichLogin) { calculate_timeline_visibility(&app, api, scroll); }); }); + + 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}"); + + let _ = app_weak.upgrade_in_event_loop(move |app| { + let global = app.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, + }); + }); + }); + }); } fn calculate_timeline_visibility(app: &AppWindow, api: Arc, scroll: f32) { @@ -299,7 +329,7 @@ fn unload_bucket(time_bucket: &TimeBucketKey, app: &AppWindow) { let Some(mut preview) = bucket.previews.row_data(j) else { break; }; - preview.kind = ui::PreviewKind::None; + preview.kind = ui::ImageKind::None; preview.image = placeholder_preview.clone(); bucket.previews.set_row_data(j, preview); } @@ -326,7 +356,7 @@ fn load_bucket(time_bucket: TimeBucketKey, app_weak: Weak, api: Arc logged-in: false; in-out property min-image-size: 160px; in-out property image-margin: 2px; - in-out property previewed-image; + in-out property viewed-image; 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 }, @@ -16,4 +16,5 @@ export global Global { callback login-api-key(url: string, api_key: string); callback set-timeline-width(length); callback timeline-scrolled(length); + callback view-image(string); } diff --git a/ui/image-viewer.slint b/ui/image-viewer.slint index da2332a..c07ad0c 100644 --- a/ui/image-viewer.slint +++ b/ui/image-viewer.slint @@ -21,7 +21,7 @@ export component ImageViewer inherits Rectangle { interval: 0.2s; triggered => { self.running = false; - Global.previewed-image.asset-id = ""; + Global.viewed-image.asset-id = ""; } } @@ -64,6 +64,8 @@ export component ImageViewer inherits Rectangle { 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 diff --git a/ui/timeline.slint b/ui/timeline.slint index 43be860..281fd99 100644 --- a/ui/timeline.slint +++ b/ui/timeline.slint @@ -17,7 +17,8 @@ component ImagePreview inherits Rectangle { touch := TouchArea { clicked => { - Global.previewed-image = root.preview; + Global.viewed-image = root.preview; + Global.view-image(root.preview.asset-id); } } } @@ -75,26 +76,26 @@ component TimelineBlock inherits VerticalLayout { } export component Timeline inherits ScrollView { - mouse-drag-pan-enabled: true; - viewport-height: rect.height; + mouse-drag-pan-enabled: true; + viewport-height: rect.height; - changed viewport-y => { - Global.timeline-scrolled(-self.viewport-y); - } - - rect := Rectangle { - y: 0; - x: 0; + 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; - 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; - } - } + index: i; + bucket: bucket; } + } + } } diff --git a/ui/types.slint b/ui/types.slint index c97cc2c..2717c45 100644 --- a/ui/types.slint +++ b/ui/types.slint @@ -1,7 +1,8 @@ -export enum PreviewKind { +export enum ImageKind { None, Thumbhash, Thumbnail, + Original, } export struct ImagePreview { @@ -13,7 +14,7 @@ export struct ImagePreview { // Image aspect ratio. (width/height) ratio: float, - kind: PreviewKind, + kind: ImageKind, } export enum Visibility {