Add basic album list

This commit is contained in:
2026-06-05 17:31:18 +02:00
parent e1a5a733c1
commit 493a689d75
8 changed files with 201 additions and 43 deletions

View File

@@ -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<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

@@ -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<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();
@@ -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::<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) {
@@ -287,11 +342,11 @@ fn calculate_timeline_layout(app: &AppWindow, api: Arc<Api>, timeline_width: f32
}
struct AppImageBuckets {
buckets: ModelRc<ImageBucket>,
buckets: ModelRc<ui::ImageBucket>,
}
impl Deref for AppImageBuckets {
type Target = VecModel<ImageBucket>;
type Target = VecModel<ui::ImageBucket>;
#[track_caller]
fn deref(&self) -> &Self::Target {
@@ -302,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>();
AppImageBuckets {
buckets: global.get_image_buckets(),

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

@@ -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";

View File

@@ -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;

View File

@@ -1,10 +1,11 @@
import { ImageBucket, ImagePreview } from "types.slint";
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 },
@@ -17,4 +18,5 @@ export global Global {
callback set-timeline-width(length);
callback timeline-scrolled(length);
callback view-image(string);
callback load-albums();
}

View File

@@ -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 <ImagePreview> preview;
in property <length> 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);
}
}
}
}

View File

@@ -33,3 +33,11 @@ export struct ImageBucket {
visibility: Visibility,
}
export struct AlbumCover {
id: string,
name: string,
description: string,
thumbnail: ImagePreview,
asset_count: int,
}