// 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 clap::Parser; use immich_sdk::AssetId; use slint::{ ComponentHandle as _, Model, ModelRc, SharedPixelBuffer, SharedString, ToSharedString, VecModel, Weak, }; use tracing::Level; use crate::{ api::{Api, TimeBucketKey}, ui::{AppWindow, ImageBucket}, }; mod api; pub mod cachemap; mod thumbhash; mod ui { slint::include_modules!(); } #[derive(clap::Parser)] 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 // 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(); tracing_subscriber::fmt() .with_max_level(Level::DEBUG) .init(); let runtime = tokio::runtime::Runtime::new().unwrap(); let _rt_guard = runtime.enter(); 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 app = ui::AppWindow::new()?; let global = app.global::(); 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 { return; }; let api = api.clone(); let _ = app_weak.upgrade_in_event_loop(move |app| { let preview_image = placeholder_preview(); let global = app.global::(); let buckets = buckets .into_iter() .map(|bucket| ui::ImageBucket { key: bucket.key.to_shared_string(), title: bucket.key.to_shared_string(), // TODO: format count: bucket.count as i32, previews: ModelRc::new( (0..bucket.count) .map(|_| ui::ImagePreview { asset_id: SharedString::new(), image: preview_image.clone(), kind: ui::PreviewKind::None, }) .collect::>(), ), y: Default::default(), height: Default::default(), visibility: ui::Visibility::Hidden, }) .collect::>(); global.set_image_buckets(ModelRc::new(buckets)); calculate_timeline_layout(&app, api, global.get_timeline_width()); }); }); let app_weak = app.as_weak(); let api = api_.clone(); global.on_set_timeline_width(move |new_width| { let api = api.clone(); let _ = app_weak.upgrade_in_event_loop(move |app| { calculate_timeline_layout(&app, api, new_width); }); }); let app_weak = app.as_weak(); let api = api_.clone(); global.on_timeline_scrolled(move |scroll| { let api = api.clone(); let _ = app_weak.upgrade_in_event_loop(move |app| { calculate_timeline_visibility(&app, api, scroll); }); }); app.run()?; Ok(()) } fn calculate_timeline_visibility(app: &AppWindow, api: Arc, scroll: f32) { let global = app.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); for i in 0..buckets.row_count() { let Some(mut bucket) = buckets.row_data(i) else { break; }; 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 visibility = if is_visible { ui::Visibility::InView } else { ui::Visibility::Hidden }; let prev_visibility = mem::replace(&mut bucket.visibility, visibility); if prev_visibility != visibility { tracing::debug!("{i} {prev_visibility:?} => {visibility:?}"); } match (prev_visibility, visibility) { (ui::Visibility::Hidden, ui::Visibility::InView) => { load_bucket(bucket.key.to_string(), app.as_weak(), api.clone()); } (ui::Visibility::NearView, ui::Visibility::Hidden) | (ui::Visibility::InView, ui::Visibility::Hidden) => { unload_bucket(&bucket.key.to_string(), app); } (..) => {} } buckets.set_row_data(i, bucket); } } fn calculate_timeline_layout(app: &AppWindow, api: Arc, timeline_width: f32) { let global = app.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 count_x = (timeline_width / min_size_with_margin).floor() as usize; let remaining_length = timeline_width.rem_euclid(min_size_with_margin); let image_size = min_image_size + remaining_length / (count_x as f32); let image_size_with_margin = image_size + image_margin; let header_height = 48.0; // TODO let mut y = 0.0; for i in 0..buckets.row_count() { let Some(mut bucket) = buckets.row_data(i) else { break; }; let count_y = ((bucket.count as f32) / (count_x as f32)).ceil(); if i == 0 { dbg!(count_x); dbg!(count_y); } bucket.y = y; bucket.height = header_height + count_y * image_size_with_margin; y += bucket.height; buckets.set_row_data(i, bucket); } global.set_timeline_width(timeline_width); global.set_timeline_height(y); calculate_timeline_visibility(app, api, global.get_timeline_scroll()); } struct AppImageBuckets { buckets: ModelRc, } impl Deref for AppImageBuckets { type Target = VecModel; #[track_caller] fn deref(&self) -> &Self::Target { self.buckets .as_any() .downcast_ref::>() .expect("Image buckets is a VecModel") } } fn get_image_buckets(app: &AppWindow) -> impl Deref> { let global = app.global::(); AppImageBuckets { buckets: global.get_image_buckets(), } } fn placeholder_preview() -> slint::Image { slint::Image::from_rgb8(SharedPixelBuffer::clone_from_slice( &[0x33, 0x33, 0x33], // a single dark gray pixel 1, 1, )) } 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 { return; }; let bucket = buckets.row_data(i).expect("i is in the list"); let placeholder_preview = placeholder_preview(); for j in 0..bucket.previews.row_count() { let Some(mut preview) = bucket.previews.row_data(j) else { break; }; preview.kind = ui::PreviewKind::None; preview.image = placeholder_preview.clone(); bucket.previews.set_row_data(j, preview); } // TODO: write `bucket` into `buckets?` } fn load_bucket(time_bucket: TimeBucketKey, app_weak: Weak, api: Arc) { tokio::spawn(async move { let Ok(api_bucket) = api.get_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 { return; }; let mut bucket = buckets.row_data(i).expect("i is in the list"); bucket.previews = ModelRc::new( api_bucket .entries .iter() .map(|entry| ui::ImagePreview { asset_id: entry.id.to_shared_string(), // slint doesn't have a uuid type kind: ui::PreviewKind::Thumbhash, // TODO: don't unwrap image: entry .thumbhash .clone() .map(slint::Image::from_rgba8) .unwrap(), }) .collect::>(), ); buckets.set_row_data(i, bucket); for entry in api_bucket.entries.iter() { load_thumbnail(time_bucket.clone(), entry.id, app.as_weak(), api.clone()); } }); }); } fn load_thumbnail( time_bucket: TimeBucketKey, asset_id: AssetId, app_weak: Weak, api: Arc, ) { tokio::spawn(async move { tracing::debug!("Fetching thumbnail for {asset_id}"); let thumbnail = match api.get_asset_thumbnail(asset_id).await { Ok(thumbnail) => thumbnail, Err(e) => { tracing::error!("{e:?}"); 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 { 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 { 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::PreviewKind::Thumbnail; bucket.previews.set_row_data(i, preview); }); }); }