From 493a689d75b99afd37401b74808f92749e45b7a7 Mon Sep 17 00:00:00 2001 From: Joakim Hulthe Date: Fri, 5 Jun 2026 17:31:18 +0200 Subject: [PATCH] Add basic album list --- src/api.rs | 19 +++++++- src/main.rs | 107 +++++++++++++++++++++++++++++++++----------- ui/albums.slint | 71 +++++++++++++++++++++++++++++ ui/app-window.slint | 7 ++- ui/footer.slint | 6 +-- ui/global.slint | 4 +- ui/timeline.slint | 22 ++++----- ui/types.slint | 8 ++++ 8 files changed, 201 insertions(+), 43 deletions(-) create mode 100644 ui/albums.slint diff --git a/src/api.rs b/src/api.rs index 11b6020..812d6b8 100644 --- a/src/api.rs +++ b/src/api.rs @@ -10,7 +10,7 @@ use image::{ DynamicImage, EncodableLayout, codecs::webp::{WebPDecoder, WebPEncoder}, }; -use immich_sdk::{AssetId, AssetVisibility}; +use immich_sdk::{AlbumResponse, AssetId, AssetVisibility}; use slint::{Rgba8Pixel, SharedPixelBuffer}; use crate::{ @@ -180,7 +180,7 @@ impl Api { .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:?}"); })?; @@ -267,4 +267,19 @@ impl Api { .await .context(anyhow!("Failed to fetch asset {asset_id:?}")) } + + pub async fn get_album_list(&self) -> anyhow::Result> { + let albums = self + .client + .albums() + .list() + .execute() + .await + .context(anyhow!("Failed to fetch album list")) + .inspect_err(|e| { + tracing::error!("{e:?}"); + })?; + + Ok(albums) + } } diff --git a/src/main.rs b/src/main.rs index 164121c..735edf7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,7 +6,7 @@ use std::{mem, ops::Deref, sync::Arc}; use clap::Parser; use immich_sdk::AssetId; use slint::{ - ComponentHandle as _, Model, ModelRc, SharedPixelBuffer, SharedString, ToSharedString, + ComponentHandle as _, Image, Model, ModelRc, SharedPixelBuffer, SharedString, ToSharedString, VecModel, Weak, }; use tracing::Level; @@ -14,7 +14,7 @@ use tracing::Level; use crate::{ api::{Api, TimeBucketKey}, config::Config, - ui::{AppWindow, ImageBucket}, + ui::AppWindow, }; /// Use jemalloc to reduce memory fragmentation. @@ -36,27 +36,6 @@ mod ui { #[derive(clap::Parser)] struct Opt {} -// enum ApiReq -// where -// Api: Message, -// { -// AskRequest( -// AskRequest< -// 'static, -// Api, -// M, -// kameo::request::WithoutRequestTimeout, -// kameo::request::WithoutRequestTimeout, -// >, -// ), -// PendingReply(PendingReply>::Reply>), -// } - -// enum ApiReqs { -// GetBuckets(ApiReq), -// GetBucket(ApiReq), -// } - fn main() -> anyhow::Result<()> { let _opt = Opt::parse(); @@ -200,6 +179,82 @@ fn start_api(app: &AppWindow, immich: &config::ImmichLogin) { }); }); }); + + 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::>(); + let albums = ModelRc::new(albums); + + let global = app.global::(); + global.set_albums(albums); + }); + }); + }); } fn calculate_timeline_visibility(app: &AppWindow, api: Arc, scroll: f32) { @@ -287,11 +342,11 @@ fn calculate_timeline_layout(app: &AppWindow, api: Arc, timeline_width: f32 } struct AppImageBuckets { - buckets: ModelRc, + buckets: ModelRc, } impl Deref for AppImageBuckets { - type Target = VecModel; + type Target = VecModel; #[track_caller] fn deref(&self) -> &Self::Target { @@ -302,7 +357,7 @@ impl Deref for AppImageBuckets { } } -fn get_image_buckets(app: &AppWindow) -> impl Deref> { +fn get_image_buckets(app: &AppWindow) -> impl Deref> { let global = app.global::(); AppImageBuckets { buckets: global.get_image_buckets(), diff --git a/ui/albums.slint b/ui/albums.slint new file mode 100644 index 0000000..0165fed --- /dev/null +++ b/ui/albums.slint @@ -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 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 min-image-size: Global.min-image-size; + property 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; + } + } + } + } +} + diff --git a/ui/app-window.slint b/ui/app-window.slint index 40db3a0..777d236 100644 --- a/ui/app-window.slint +++ b/ui/app-window.slint @@ -6,6 +6,7 @@ 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 { @@ -32,6 +33,7 @@ export component AppWindow inherits Window { if !Global.logged-in: LoginView {} if Global.logged-in && view == View.Timeline: Timeline {} + if Global.logged-in && view == View.Albums: Albums {} Footer { FooterButton { @@ -46,7 +48,10 @@ export component AppWindow inherits Window { FooterButton { title: "Album"; icon: @image-url("../assets/album.svg"); - clicked => { view = View.Albums} + clicked => { + view = View.Albums; + Global.load-albums(); + } } FooterButton { title: "Library"; diff --git a/ui/footer.slint b/ui/footer.slint index 96d2834..cbad50b 100644 --- a/ui/footer.slint +++ b/ui/footer.slint @@ -7,10 +7,10 @@ export component FooterButton inherits Rectangle { states [ pressed when touch.pressed: { - background: #0000ff30; + background: #0000ff30; // TODO: palette } hovered when touch.has-hover: { - background: #0000ff15; + background: #0000ff15; // TODO: palette } default: { background: #0000; @@ -30,7 +30,7 @@ export component FooterButton inherits Rectangle { padding-right: 20px; Image { source: icon; - colorize: #accbfa; + colorize: Palette.accent-background; } Text { text: title; diff --git a/ui/global.slint b/ui/global.slint index 499e294..d20bbfc 100644 --- a/ui/global.slint +++ b/ui/global.slint @@ -1,10 +1,11 @@ -import { ImageBucket, ImagePreview } from "types.slint"; +import { ImageBucket, ImagePreview, AlbumCover } from "types.slint"; export global Global { in-out property logged-in: false; in-out property min-image-size: 88px; in-out property image-margin: 2px; in-out property 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 }, @@ -17,4 +18,5 @@ export global Global { callback set-timeline-width(length); callback timeline-scrolled(length); callback view-image(string); + callback load-albums(); } diff --git a/ui/timeline.slint b/ui/timeline.slint index 86f831a..66fe1c7 100644 --- a/ui/timeline.slint +++ b/ui/timeline.slint @@ -1,10 +1,12 @@ -import { ScrollView } from "std-widgets.slint"; +import { ScrollView, Palette } from "std-widgets.slint"; import { Global } from "global.slint"; import { ImageBucket, Visibility, ImagePreview } from "types.slint"; -component ImagePreview inherits Rectangle { +export component ImagePreview inherits Rectangle { in property preview; in property size: 32px; + callback clicked <=> touch.clicked; + width: size; height: size; clip: true; @@ -15,12 +17,7 @@ component ImagePreview inherits Rectangle { source: preview.image; } - touch := TouchArea { - clicked => { - Global.viewed-image = root.preview; - Global.view-image(root.preview.asset-id); - } - } + touch := TouchArea {} } component TimelineBlock inherits VerticalLayout { @@ -62,14 +59,15 @@ component TimelineBlock inherits VerticalLayout { if !checked : Image { source: @image-url("../assets/unchecked.svg"); - colorize: #aaa; + colorize: Palette.foreground; + opacity: 0.8; height: title.height; width: self.height; } if checked : Image { source: @image-url("../assets/checked.svg"); - colorize: #accbfa; + colorize: Palette.accent-background; height: title.height; width: self.height; } @@ -91,6 +89,10 @@ component TimelineBlock inherits VerticalLayout { 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); + } } } } diff --git a/ui/types.slint b/ui/types.slint index 2717c45..82c56a9 100644 --- a/ui/types.slint +++ b/ui/types.slint @@ -33,3 +33,11 @@ export struct ImageBucket { visibility: Visibility, } +export struct AlbumCover { + id: string, + name: string, + description: string, + thumbnail: ImagePreview, + asset_count: int, +} +