1use std::cell::{Cell, RefCell};
9use std::rc::Rc;
10use std::sync::Arc;
11
12use glib::clone;
13use gtk::prelude::*;
14use libadwaita::prelude::*;
15
16use crate::api_client::{LibraryAsset, MetadataSearchFilters, SharedLinkTarget, SortOrder, Tag};
17use crate::app_context::AppContext;
18use crate::library::albums_view::{AlbumClick, AlbumsViewParts, populate_albums};
19use crate::library::explore_view::ExploreViewParts;
20use crate::library::local_source::{
21 LocalAsset, enumerate_local, enumerate_local_for_entry, filter_by_filename,
22};
23use crate::library::masonry::GridViewParts;
24use crate::library::search_view::SearchViewParts;
25use crate::library::sidebar::SidebarParts;
26use crate::library::state::{LibraryLoadState, LibraryPage, LibrarySource, Location};
27use crate::state_manager::TransferDirection;
28
29use self::actions::{connect_bulk_actions, connect_select_mode};
30use self::album_link::{connect_album_link_row, refresh_album_link_row};
31use self::context_menu::{AssetMenuHooks, show_asset_context_menu};
32use self::controls::{
33 connect_controls, connect_grid_handlers, connect_sidebar_handlers,
34 refresh_library_after_mutation,
35};
36use self::lightbox::open_lightbox;
37
38pub(super) const LOCAL_ID_PREFIX: &str = "local::";
39
40pub mod album_sync;
41pub mod albums_view;
42mod archive;
43pub mod asset_model;
44pub mod asset_object;
45mod bottom_nav;
46pub mod explore_view;
47mod favorites;
48pub mod local_exif;
49pub mod local_source;
50pub mod masonry;
51pub mod sidebar;
52pub mod state;
53pub mod style;
54pub mod thumbnail_cache;
55
56mod about_dialog;
57mod actions;
58mod album_edit;
59mod album_link;
60mod album_share_dialog;
61mod asset_edit;
62mod asset_tags;
63mod context_menu;
64mod controls;
65mod dialogs;
66mod download;
67mod features;
68mod folders_view;
69mod header_format;
70mod lightbox;
71mod nav_history;
72mod navigation;
73mod people_view;
74mod person_merge_dialog;
75pub mod search_filters;
76pub mod search_view;
77mod server_stats_dialog;
78mod shared_link_dialog;
79mod shared_links_view;
80mod sharing_view;
81mod source_header;
82pub mod staging_view;
83mod tags_view;
84mod trash_view;
85mod tree_page;
86mod upload_picker;
87mod window_layout;
88
89const PAGE_SIZE: u32 = 50;
90
91mod texture_decode;
92use texture_decode::{decode_raw_thumbnail_texture, load_texture_blocking};
93pub use texture_decode::{set_raw_cache_enabled, set_raw_full_decode};
94
95pub(crate) fn register_app_icons() {
97 if let Some(display) = gtk::gdk::Display::default() {
98 let theme = gtk::IconTheme::for_display(&display);
99 theme.add_resource_path("/dev/nicx/mimick/icons");
100 }
101}
102
103pub(crate) async fn load_texture_oriented(path: &std::path::Path) -> Option<gdk4::Texture> {
106 let path_buf = path.to_path_buf();
107 tokio::task::spawn_blocking(move || load_texture_blocking(&path_buf))
108 .await
109 .ok()
110 .flatten()
111}
112
113struct LibraryWindowUi {
115 ctx: Arc<AppContext>,
116 app: libadwaita::Application,
117 window: libadwaita::ApplicationWindow,
118 nav: libadwaita::NavigationView,
119 sidebar: SidebarParts,
120 grid: GridViewParts,
121 explore: ExploreViewParts,
122 albums: AlbumsViewParts,
123 shared_links: shared_links_view::SharedLinksView,
124 people: people_view::PeopleView,
125 tags: tags_view::TagsView,
126 folders: folders_view::FoldersView,
127 sharing: sharing_view::SharingView,
128 header: source_header::SourceHeader,
129 bottom_nav: bottom_nav::BottomNav,
130 features: Cell<crate::api_client::LibraryFeatures>,
131 content_stack: gtk::Stack,
132 error_label: gtk::Label,
133 transfer: window_layout::TransferParts,
134 search_view: SearchViewParts,
135 sort_mode: gtk::DropDown,
136 source_mode: gtk::DropDown,
137 source_revealer: gtk::Revealer,
138 upload_button: gtk::Button,
139 timeline_toggle: gtk::ToggleButton,
140 timeline_banner: gtk::Label,
141 source_mode_suppressed: Cell<bool>,
142 sidebar_suppressed: Cell<bool>,
143 back_button: gtk::Button,
144 select_toggle: gtk::ToggleButton,
145 bulk_bar: gtk::Revealer,
146 bulk_archive: gtk::Button,
147 trash: trash_view::TrashControls,
148 bulk_count_label: gtk::Label,
149 album_link_row: libadwaita::ActionRow,
150 album_link_button: gtk::Button,
151 album_sync_button: gtk::Button,
152 last_seen_upload_batch: Cell<u64>,
153 split: libadwaita::OverlaySplitView,
154 drop_overlay: gtk::Revealer,
155}
156
157pub fn build_library_window(app: &libadwaita::Application, ctx: Arc<AppContext>) {
159 style::ensure_registered();
160 register_app_icons();
161
162 let parts = window_layout::WindowParts::build(app, &ctx);
163 let window = parts.window.clone();
164 let album_link_listbox = parts.content.album_link.listbox.clone();
165 let bulk = &parts.content.bulk;
166 let bulk_share_link = bulk.share_link.clone();
167 let (bulk_delete, bulk_download, bulk_clear) = (
168 bulk.delete.clone(),
169 bulk.download.clone(),
170 bulk.clear.clone(),
171 );
172 let ui = Rc::new(LibraryWindowUi::from_parts(ctx, app, parts));
173
174 connect_menus(ui.clone());
175 connect_album_link_row(ui.clone(), album_link_listbox);
176 connect_select_mode(ui.clone(), ui.select_toggle.clone());
177 connect_bulk_actions(ui.clone(), bulk_delete, bulk_download, bulk_clear);
178 actions::connect_bulk_share_link(ui.clone(), &bulk_share_link);
179 actions::connect_bulk_archive(ui.clone());
180 trash_view::connect_trash_controls(ui.clone());
181 people_view::connect_people_view(ui.clone());
182 tags_view::connect_tags_view(ui.clone());
183 sharing_view::connect_sharing_view(ui.clone());
184 bottom_nav::connect_bottom_nav(&ui);
185 connect_sidebar_handlers(ui.clone());
186 connect_controls(ui.clone());
187 connect_grid_handlers(ui.clone());
188 connect_search_view(ui.clone());
189 connect_lightweight_filters(ui.clone());
190 connect_drop_target(ui.clone());
191
192 let close_ctx = ui.ctx.clone();
193 window.connect_close_request(move |_| {
194 close_ctx.thumbnail_cache.clear_memory();
195 glib::Propagation::Proceed
196 });
197
198 bootstrap_window(ui);
199 window.present();
200}
201
202fn connect_menus(ui: Rc<LibraryWindowUi>) {
204 *ui.grid.context_menu_handler.borrow_mut() = Some(Box::new(clone!(
205 #[strong]
206 ui,
207 move |position, x, y| {
208 show_asset_context_menu(
209 ui.clone(),
210 &ui.grid.canvas,
211 position,
212 x,
213 y,
214 AssetMenuHooks::for_grid(&ui),
215 );
216 }
217 )));
218
219 let ui_for_share = ui.clone();
220 albums_view::set_share_handler(
221 &ui.albums,
222 Rc::new(move |album, action| match action {
223 albums_view::AlbumAction::Share => {
224 album_share_dialog::present(ui_for_share.clone(), album)
225 }
226 albums_view::AlbumAction::Rename => album_edit::rename(&ui_for_share, &album),
227 albums_view::AlbumAction::EditDescription => {
228 album_edit::edit_description(&ui_for_share, &album)
229 }
230 albums_view::AlbumAction::CreateLink => shared_link_dialog::present(
231 ui_for_share.clone(),
232 shared_link_dialog::LinkDialogMode::Create(SharedLinkTarget::Album(album.id)),
233 Rc::new(|| {}),
234 ),
235 }),
236 );
237}
238
239fn bootstrap_window(ui: Rc<LibraryWindowUi>) {
241 let initial_request = {
242 let mut state = ui.ctx.library_state.lock();
243 state.load_initial_source()
244 };
245
246 apply_timeline_ui_state(&ui, &initial_request.1);
247 source_header::show(&ui, &initial_request.1);
248 navigation::sync_controls(&ui, &Location::Source(initial_request.1.clone()));
249 load_albums(ui.clone());
250 load_status(ui.clone());
251 features::load(&ui);
252 fetch_current_user(ui.clone());
253 connect_albums_create(ui.clone());
254 spawn_server_ping_loop(ui.clone());
255 spawn_transfer_poll_loop(ui.clone());
256 load_source_page(ui, initial_request, false);
257}
258
259fn build_drop_overlay(content: gtk::Box) -> (gtk::Overlay, gtk::Revealer) {
264 let drop_icon = gtk::Image::builder()
265 .icon_name("document-send-symbolic")
266 .pixel_size(48)
267 .halign(gtk::Align::Center)
268 .build();
269 let drop_label = gtk::Label::builder()
270 .label("Drop files to upload")
271 .halign(gtk::Align::Center)
272 .build();
273 let inner = gtk::Box::builder()
275 .orientation(gtk::Orientation::Vertical)
276 .spacing(12)
277 .halign(gtk::Align::Center)
278 .valign(gtk::Align::Center)
279 .vexpand(true)
280 .build();
281 inner.append(&drop_icon);
282 inner.append(&drop_label);
283
284 let drop_box = gtk::Box::builder()
285 .orientation(gtk::Orientation::Vertical)
286 .halign(gtk::Align::Fill)
287 .valign(gtk::Align::Fill)
288 .vexpand(true)
289 .hexpand(true)
290 .css_classes(["mimick-drop-overlay"])
291 .build();
292 drop_box.append(&inner);
293
294 let revealer = gtk::Revealer::builder()
295 .transition_type(gtk::RevealerTransitionType::Crossfade)
296 .transition_duration(180)
297 .reveal_child(false)
298 .can_target(false)
299 .vexpand(true)
300 .hexpand(true)
301 .child(&drop_box)
302 .build();
303
304 let overlay = gtk::Overlay::builder().build();
305 overlay.set_child(Some(&content));
306 overlay.add_overlay(&revealer);
307
308 (overlay, revealer)
309}
310
311fn connect_lightweight_filters(ui: Rc<LibraryWindowUi>) {
313 ui.albums.filter_entry.connect_search_changed(clone!(
315 #[strong]
316 ui,
317 move |entry| {
318 let query = entry.text().to_string();
319 albums_view::set_search_filter(&ui.albums, &query);
320 }
321 ));
322
323 ui.explore.filter_entry.connect_search_changed(clone!(
325 #[strong]
326 ui,
327 move |entry| {
328 let query = entry.text().to_string();
329 explore_view::set_people_search(&ui.explore, &query);
330 }
331 ));
332}
333
334fn connect_search_view(ui: Rc<LibraryWindowUi>) {
336 ui.search_view.clear_button.connect_clicked(clone!(
338 #[strong]
339 ui,
340 move |_| {
341 search_view::clear_all_filters(&ui.search_view);
342 }
343 ));
344
345 let do_search = {
347 let ui = ui.clone();
348 move || {
349 dispatch_search_from_view(&ui);
350 }
351 };
352
353 let do_search_btn = do_search.clone();
354 ui.search_view.search_button.connect_clicked(clone!(
355 #[strong]
356 ui,
357 move |_| {
358 let _ = &ui;
359 do_search_btn();
360 }
361 ));
362
363 ui.search_view.search_entry.connect_activate(clone!(
364 #[strong]
365 ui,
366 move |_| {
367 let _ = &ui;
368 do_search();
369 }
370 ));
371}
372
373fn dispatch_search_from_view(ui: &Rc<LibraryWindowUi>) {
375 let query = ui.search_view.search_entry.text().trim().to_string();
376 let filters = search_view::collect_filters(&ui.search_view);
377 let has_filters = filters != MetadataSearchFilters::default();
378 let mode = search_view::selected_mode(&ui.search_view); let source = build_search_source(mode, &query, has_filters, filters);
381 let Some(source) = source else {
382 return;
383 };
384
385 navigation::navigate_source(ui, source);
386}
387
388fn build_search_source(
390 mode: u32,
391 query: &str,
392 has_filters: bool,
393 filters: MetadataSearchFilters,
394) -> Option<LibrarySource> {
395 if query.is_empty() && !has_filters {
396 return None;
397 }
398
399 match mode {
400 0 => {
402 if query.is_empty() {
403 Some(LibrarySource::AdvancedSearch {
405 filters: Box::new(filters),
406 })
407 } else {
408 Some(LibrarySource::SmartSearch {
409 query: query.to_string(),
410 })
411 }
412 }
413 1 => {
415 let mut f = filters;
416 if !query.is_empty() {
417 f.original_file_name = Some(query.to_string());
418 }
419 Some(LibrarySource::AdvancedSearch {
420 filters: Box::new(f),
421 })
422 }
423 2 => {
425 let mut f = filters;
426 if !query.is_empty() {
427 f.ocr = Some(query.to_string());
428 }
429 Some(LibrarySource::AdvancedSearch {
430 filters: Box::new(f),
431 })
432 }
433 _ => None,
434 }
435}
436
437fn connect_drop_target(ui: Rc<LibraryWindowUi>) {
439 let drop_target = gtk::DropTarget::new(
440 gtk::gdk::FileList::static_type(),
441 gtk::gdk::DragAction::COPY,
442 );
443
444 let ui_enter = ui.clone();
445 drop_target.connect_enter(move |target, _x, _y| {
446 if target.current_drop().is_some_and(|d| d.drag().is_some()) {
448 return gtk::gdk::DragAction::empty();
449 }
450 ui_enter.drop_overlay.set_reveal_child(true);
451 gtk::gdk::DragAction::COPY
452 });
453
454 let ui_leave = ui.clone();
455 drop_target.connect_leave(move |_target| {
456 ui_leave.drop_overlay.set_reveal_child(false);
457 });
458
459 let ui_drop = ui.clone();
460 drop_target.connect_drop(move |target, value, _x, _y| {
461 ui_drop.drop_overlay.set_reveal_child(false);
462
463 if target.current_drop().is_some_and(|d| d.drag().is_some()) {
465 return false;
466 }
467
468 handle_drop(&ui_drop, value)
469 });
470
471 ui.window.add_controller(drop_target);
472}
473
474fn handle_drop(ui: &LibraryWindowUi, value: >k::glib::Value) -> bool {
475 let file_list = match value.get::<gtk::gdk::FileList>() {
476 Ok(fl) => fl,
477 Err(_) => return false,
478 };
479
480 let paths: Vec<std::path::PathBuf> = file_list
481 .files()
482 .iter()
483 .filter_map(gtk::gio::prelude::FileExt::path)
484 .filter(|p| crate::media_kinds::is_supported_path(p))
485 .collect();
486
487 if paths.is_empty() {
488 show_unsupported_drop_toast(ui);
489 return true;
490 }
491
492 let album = match ui.ctx.library_state.lock().source.clone() {
493 LibrarySource::Album { id, name }
494 | LibrarySource::AlbumLocal { id, name }
495 | LibrarySource::AlbumUnified { id, name } => Some((id, name)),
496 _ => None,
497 };
498
499 if let Some(album) = album {
500 handle_album_drop_upload(ui, album, paths);
501 } else {
502 handle_library_drop_upload(ui, paths);
503 }
504
505 true
506}
507
508fn show_unsupported_drop_toast(ui: &LibraryWindowUi) {
509 let toast = libadwaita::Toast::new("No supported media files in drop");
510 if let Some(overlay) = ui
511 .window
512 .content()
513 .and_then(|w| w.first_child())
514 .and_downcast::<libadwaita::ToastOverlay>()
515 {
516 overlay.add_toast(toast);
517 } else {
518 log::info!("No supported media files in drop");
519 }
520}
521
522fn handle_album_drop_upload(
523 ui: &LibraryWindowUi,
524 album: (String, String),
525 paths: Vec<std::path::PathBuf>,
526) {
527 let count = paths.len();
528 upload_picker::spawn_enqueue_with_callback(
529 ui.ctx.clone(),
530 Some(album.clone()),
531 paths,
532 move |queued, _skipped| {
533 log::info!(
534 "Drop upload to album '{}': queued {}/{} file(s)",
535 album.1,
536 queued,
537 count
538 );
539 },
540 );
541}
542
543fn handle_library_drop_upload(ui: &LibraryWindowUi, paths: Vec<std::path::PathBuf>) {
544 let count = paths.len();
545 let ctx_for_album = ui.ctx.clone();
546 let window_for_album = ui.window.clone();
547 let paths_for_album = paths.clone();
548
549 upload_picker::spawn_enqueue_with_callback(
550 ui.ctx.clone(),
551 None,
552 paths,
553 move |queued, _skipped| {
554 log::info!(
555 "Drop upload to library: queued {}/{} file(s)",
556 queued,
557 count
558 );
559 if queued > 0 {
560 staging_view::show_album_picker(window_for_album, ctx_for_album, paths_for_album);
561 }
562 },
563 );
564}
565
566fn spawn_server_ping_loop(ui: Rc<LibraryWindowUi>) {
567 glib::timeout_add_seconds_local(5, move || {
568 let ui_for_tick = ui.clone();
569 glib::MainContext::default().spawn_local(async move {
570 let _ = ui_for_tick.ctx.api_client.check_connection().await;
571 let route = ui_for_tick.ctx.api_client.active_route_label().await;
572
573 if route.is_some() {
575 let missing_stats = {
576 let state = ui_for_tick.ctx.library_state.lock();
577 state.status.stats.is_none() || state.status.about.is_none()
578 };
579 if missing_stats {
580 let stats = ui_for_tick.ctx.api_client.fetch_server_stats().await.ok();
581 let about = ui_for_tick.ctx.api_client.fetch_server_about().await.ok();
582 if stats.is_some() || about.is_some() {
583 let mut state = ui_for_tick.ctx.library_state.lock();
584 state.set_status(stats, about);
585 }
586 }
587 }
588
589 update_footer(&ui_for_tick, route);
590 });
591 glib::ControlFlow::Continue
592 });
593}
594
595fn spawn_transfer_poll_loop(ui: Rc<LibraryWindowUi>) {
597 glib::timeout_add_local(std::time::Duration::from_millis(250), move || {
598 let completed_batches = ui.ctx.state.lock().completed_upload_batches;
599 if completed_batches != ui.last_seen_upload_batch.get() {
600 ui.last_seen_upload_batch.set(completed_batches);
601 refresh_library_after_mutation(ui.clone(), true);
602 }
603 update_transfer_ui(&ui);
604 glib::ControlFlow::Continue
605 });
606}
607
608fn fetch_current_user(ui: Rc<LibraryWindowUi>) {
610 glib::MainContext::default().spawn_local(async move {
611 ensure_current_user_id(&ui.ctx).await;
612 });
613}
614
615async fn ensure_current_user_id(ctx: &AppContext) -> Option<String> {
617 if let Some(id) = ctx.current_user_id.lock().clone() {
618 return Some(id);
619 }
620 match ctx.api_client.fetch_current_user_id().await {
621 Ok(id) => {
622 *ctx.current_user_id.lock() = Some(id.clone());
623 Some(id)
624 }
625 Err(err) => {
626 log::warn!("Could not fetch current user id: {}", err);
627 None
628 }
629 }
630}
631
632fn connect_albums_create(ui: Rc<LibraryWindowUi>) {
634 ui.albums.create_button.connect_clicked(clone!(
635 #[strong]
636 ui,
637 move |_| prompt_create_album(ui.clone())
638 ));
639}
640
641fn prompt_create_album(ui: Rc<LibraryWindowUi>) {
643 let dialog = libadwaita::AlertDialog::builder()
644 .heading("Create album")
645 .body("Choose a name for the new album.")
646 .build();
647 let entry = gtk::Entry::builder()
648 .placeholder_text("Album name")
649 .activates_default(true)
650 .build();
651 dialog.set_extra_child(Some(&entry));
652 dialog.add_response("cancel", "Cancel");
653 dialog.add_response("create", "Create");
654 dialog.set_response_appearance("create", libadwaita::ResponseAppearance::Suggested);
655 dialog.set_default_response(Some("create"));
656 dialog.set_close_response("cancel");
657
658 let ui_for_choice = ui.clone();
659 let entry_for_choice = entry.clone();
660 dialog.connect_response(None, move |dlg, response| {
661 if response != "create" {
662 return;
663 }
664 let name = entry_for_choice.text().to_string();
665 if name.trim().is_empty() {
666 return;
667 }
668 let ui = ui_for_choice.clone();
669 glib::MainContext::default().spawn_local(async move {
670 match ui.ctx.api_client.create_album(name.trim()).await {
671 Ok(_) => {
672 refresh_library_after_mutation(ui.clone(), false);
673 }
674 Err(err) => log::error!("Create album failed: {}", err),
675 }
676 });
677 dlg.close();
678 });
679 dialog.present(Some(&ui.window));
680}
681
682fn refresh_albums_view(ui: Rc<LibraryWindowUi>) {
689 glib::MainContext::default().spawn_local(async move {
690 ensure_current_user_id(&ui.ctx).await;
692 match ui.ctx.api_client.fetch_library_albums().await {
693 Ok(albums) => {
694 let on_click = album_click_handler(ui.clone());
695 populate_albums(&ui.albums, ui.ctx.clone(), albums, on_click);
696 if on_albums_page(&ui) {
697 ui.content_stack.set_visible_child_name("albums");
698 }
699 }
700 Err(err) => {
701 log::warn!("Albums fetch failed: {}", err);
702 if !ui.albums.populated.get() && on_albums_page(&ui) {
703 ui.error_label
704 .set_label(&format!("Could not load albums: {}", err));
705 ui.content_stack.set_visible_child_name("error");
706 }
707 }
708 }
709 });
710}
711
712fn on_albums_page(ui: &LibraryWindowUi) -> bool {
713 matches!(
714 navigation::current(ui),
715 Location::Page(LibraryPage::Albums { .. })
716 )
717}
718
719fn album_click_handler(ui: Rc<LibraryWindowUi>) -> AlbumClick {
720 Rc::new(move |id: &str, name: String| {
721 navigation::navigate_source(
722 &ui,
723 LibrarySource::Album {
724 id: id.to_string(),
725 name,
726 },
727 );
728 })
729}
730
731fn refresh_search_tags(ui: Rc<LibraryWindowUi>, select: Option<String>) {
734 glib::MainContext::default().spawn_local(async move {
735 match ui.ctx.api_client.fetch_tags().await {
736 Ok(tags) => {
737 search_view::set_available_tags(&ui.search_view, &tags);
738 if let Some(tag_id) = select {
739 search_view::select_tag(&ui.search_view, &tag_id);
740 }
741 }
742 Err(err) => log::debug!("Could not load tags for search: {}", err),
744 }
745 });
746}
747
748fn search_by_tag(ui: Rc<LibraryWindowUi>, tag: Tag) {
749 navigation::navigate_source(
750 &ui,
751 LibrarySource::Tag {
752 id: tag.id,
753 name: tag.value,
754 },
755 );
756}
757
758fn apply_timeline_ui_state(ui: &LibraryWindowUi, source: &LibrarySource) {
759 let timeline_allowed = matches!(source, LibrarySource::AllAssets | LibrarySource::Timeline);
760 let timeline_active = matches!(source, LibrarySource::Timeline);
761 ui.ctx
762 .library_timeline_active
763 .store(timeline_active, std::sync::atomic::Ordering::Relaxed);
764 ui.timeline_toggle.set_sensitive(timeline_allowed);
765 if ui.timeline_toggle.is_active() != timeline_active {
766 ui.timeline_toggle.set_active(timeline_active);
767 }
768 ui.timeline_banner.set_visible(timeline_active);
769 if timeline_active {
770 ui.sort_mode.set_selected(0);
771 }
772
773 let is_local = matches!(
774 source,
775 LibrarySource::LocalAll
776 | LibrarySource::LocalSearch { .. }
777 | LibrarySource::AlbumLocal { .. }
778 );
779 let is_unified = matches!(
780 source,
781 LibrarySource::Unified
782 | LibrarySource::UnifiedSearch { .. }
783 | LibrarySource::AlbumUnified { .. }
784 );
785 let in_album = matches!(
786 source,
787 LibrarySource::Album { .. }
788 | LibrarySource::AlbumLocal { .. }
789 | LibrarySource::AlbumUnified { .. }
790 );
791
792 ui.source_revealer.set_reveal_child(in_album);
794 trash_view::set_trash_mode(ui, matches!(source, LibrarySource::Trash));
795 archive::sync_bulk_button(&ui.bulk_archive, source);
796
797 if in_album {
798 ui.upload_button.set_tooltip_text(Some("Upload to album"));
799 } else {
800 ui.upload_button.set_tooltip_text(Some("Upload to library"));
801 }
802
803 let target = if is_local {
806 1
807 } else if is_unified {
808 2
809 } else {
810 0
811 };
812 if ui.source_mode.selected() != target {
813 ui.source_mode_suppressed.set(true);
814 ui.source_mode.set_selected(target);
815 ui.source_mode_suppressed.set(false);
816 }
817
818 refresh_album_link_row(ui, source);
819}
820
821fn update_timeline_banner_if_active(ui: &Rc<LibraryWindowUi>, adj: >k::Adjustment) {
823 let state = ui.ctx.library_state.lock();
824 if !matches!(state.source, LibrarySource::Timeline) {
825 return;
826 }
827 if state.assets.is_empty() {
828 ui.timeline_banner.set_label("");
829 return;
830 }
831
832 let max = (adj.upper() - adj.page_size()).max(1.0);
833 let frac = (adj.value() / max).clamp(0.0, 1.0);
834 let idx = ((state.assets.len() as f64) * frac) as usize;
835 let idx = idx.min(state.assets.len() - 1);
836 let label = month_year_label(&state.assets[idx].created_at);
837 ui.timeline_banner.set_label(&label);
838}
839
840fn month_year_label(iso: &str) -> String {
842 use chrono::{DateTime, Datelike};
843 if let Ok(dt) = DateTime::parse_from_rfc3339(iso) {
844 const MONTHS: [&str; 12] = [
845 "January",
846 "February",
847 "March",
848 "April",
849 "May",
850 "June",
851 "July",
852 "August",
853 "September",
854 "October",
855 "November",
856 "December",
857 ];
858 let m = dt.month0() as usize;
859 if let Some(name) = MONTHS.get(m) {
860 return format!("{} {}", name, dt.year());
861 }
862 }
863 iso.chars().take(7).collect()
864}
865
866fn load_albums(ui: Rc<LibraryWindowUi>) {
868 glib::MainContext::default().spawn_local(clone!(
869 #[strong]
870 ui,
871 async move {
872 match ui.ctx.api_client.fetch_library_albums().await {
873 Ok(albums) => {
874 ui.ctx.library_state.lock().load_albums(albums);
875 reload_sidebar(&ui);
876 }
877 Err(err) => {
878 log::warn!("Albums sidebar refresh failed: {}", err);
883 if ui.content_stack.visible_child_name().as_deref() == Some("loading") {
884 ui.error_label
885 .set_label(&format!("Could not load albums: {}", err));
886 ui.content_stack.set_visible_child_name("error");
887 }
888 }
889 }
890 }
891 ));
892}
893
894fn load_status(ui: Rc<LibraryWindowUi>) {
896 glib::MainContext::default().spawn_local(clone!(
897 #[strong]
898 ui,
899 async move {
900 let stats = ui.ctx.api_client.fetch_server_stats().await.ok();
901 let about = ui.ctx.api_client.fetch_server_about().await.ok();
902 let route = ui.ctx.api_client.active_route_label().await;
903
904 {
905 let mut state = ui.ctx.library_state.lock();
906 state.set_status(stats, about);
907 }
908 update_footer(&ui, route);
909 if matches!(
910 navigation::current(&ui),
911 Location::Source(
912 LibrarySource::AllAssets | LibrarySource::Timeline | LibrarySource::Unified
913 )
914 ) {
915 source_header::refresh(&ui);
916 }
917 }
918 ));
919}
920
921fn load_explore_landing(ui: Rc<LibraryWindowUi>) {
929 if ui.explore.populated.get() {
930 log::debug!("Explore: populated=true, reusing cached widgets");
931 ui.content_stack.set_visible_child_name("explore");
932 return;
933 }
934 ui.explore.populated.set(true);
935 explore_view::wire_people_filter(&ui.explore, ui.ctx.clone(), || {});
936 explore_view::show_loading(&ui.explore);
937 ui.content_stack.set_visible_child_name("explore");
938
939 let sections = ui.explore.sections.get();
940 if sections.people {
941 load_explore_people(ui.clone());
942 }
943 if sections.places && explore_view::has_cached_places(&ui.explore) {
945 log::debug!("Explore: rendering places from cache");
946 explore_view::render_cached_places(&ui.explore, ui.ctx.clone());
947 } else if sections.places {
948 log::debug!("Explore: places cache empty, fetching from server");
949 load_explore_places(ui.clone());
950 }
951 load_explore_sections(ui);
952}
953
954fn unwrap_or_report_permission<T: Default>(ui: &LibraryWindowUi, result: Result<T, String>) -> T {
956 if let Err(e) = &result
957 && (e.contains("HTTP 401") || e.contains("HTTP 403"))
958 {
959 show_library_permission_error(&ui.window);
960 }
961 result.unwrap_or_default()
962}
963
964fn load_explore_people(ui: Rc<LibraryWindowUi>) {
965 glib::MainContext::default().spawn_local(async move {
966 let people = unwrap_or_report_permission(&ui, ui.ctx.api_client.fetch_people(false).await);
967 let click_ui = ui.clone();
968 explore_view::populate_people(&ui.explore, ui.ctx.clone(), people, move |id, name| {
969 navigation::navigate_source(&click_ui, LibrarySource::Person { id, name });
970 });
971 });
972}
973
974fn load_explore_places(ui: Rc<LibraryWindowUi>) {
975 glib::MainContext::default().spawn_local(async move {
976 let places = unwrap_or_report_permission(&ui, ui.ctx.api_client.fetch_all_places().await);
977 let click_ui = ui.clone();
978 explore_view::populate_places(
979 &ui.explore,
980 ui.ctx.clone(),
981 places,
982 move |_kind, value, _asset_id| {
983 navigation::navigate_source(&click_ui, LibrarySource::Place { city: value });
984 },
985 );
986 });
987}
988
989fn load_explore_sections(ui: Rc<LibraryWindowUi>) {
990 glib::MainContext::default().spawn_local(async move {
991 let sections = unwrap_or_report_permission(&ui, ui.ctx.api_client.fetch_explore().await);
992 let click_ui = ui.clone();
993 explore_view::populate_explore(
994 &ui.explore,
995 ui.ctx.clone(),
996 sections,
997 move |kind, value, asset_id| {
998 if kind == "recent" {
999 open_asset_in_lightbox(click_ui.clone(), asset_id);
1000 return;
1001 }
1002 let source = if kind == "place" {
1003 LibrarySource::Place { city: value }
1004 } else {
1005 LibrarySource::Thing { query: value }
1006 };
1007 navigation::navigate_source(&click_ui, source);
1008 },
1009 );
1010 });
1011}
1012
1013fn open_asset_in_lightbox(ui: Rc<LibraryWindowUi>, asset_id: String) {
1018 glib::MainContext::default().spawn_local(clone!(
1019 #[strong]
1020 ui,
1021 async move {
1022 let asset = match ui.ctx.api_client.fetch_asset_by_id(&asset_id).await {
1023 Ok(a) => a,
1024 Err(e) => {
1025 log::warn!("Failed to fetch asset {} for lightbox: {}", asset_id, e);
1026 return;
1027 }
1028 };
1029
1030 let prev_source = ui.ctx.library_state.lock().source.clone();
1032
1033 {
1035 let mut state = ui.ctx.library_state.lock();
1036 let generation = state.generation;
1037 state.replace_assets_with_more(generation, vec![asset], false);
1038 ui.grid
1039 .model
1040 .reset(&ui.ctx, &state.assets, &state.sort_mode);
1041 }
1042
1043 open_lightbox(ui.clone(), 0);
1045
1046 let restore_ui = ui.clone();
1048 let handler_id = Rc::new(RefCell::new(None::<glib::SignalHandlerId>));
1049 let handler_id_clone = handler_id.clone();
1050 let id = ui.nav.connect_popped(move |nav, _page| {
1051 let request = restore_ui
1052 .ctx
1053 .library_state
1054 .lock()
1055 .switch_source(prev_source.clone());
1056 load_source_page(restore_ui.clone(), request, false);
1057 if let Some(id) = handler_id_clone.borrow_mut().take() {
1059 nav.disconnect(id);
1060 }
1061 });
1062 *handler_id.borrow_mut() = Some(id);
1063 }
1064 ));
1065}
1066
1067fn load_source_page(ui: Rc<LibraryWindowUi>, request: (u64, LibrarySource, u32), append: bool) {
1069 if matches!(request.1, LibrarySource::Explore) {
1070 load_explore_landing(ui);
1071 return;
1072 }
1073 if !append && !navigation::on_page(&ui) {
1074 source_header::set_status(&ui, "loading", None);
1075 ui.content_stack.set_visible_child_name("grid");
1076 }
1077 log::debug!("Loading library source {:?} page={}", request.1, request.2);
1078 glib::MainContext::default().spawn_local(clone!(
1079 #[strong]
1080 ui,
1081 async move {
1082 let (generation, source, page) = request;
1083 let order = ui.ctx.library_state.lock().sort_mode.server_order();
1084 let result = fetch_source_page(&ui, source, page, order).await;
1085 apply_page_result(&ui, generation, append, result);
1086 }
1087 ));
1088}
1089
1090type PageResult = Result<(Vec<LibraryAsset>, bool), String>;
1092
1093async fn fetch_source_page(
1094 ui: &Rc<LibraryWindowUi>,
1095 source: LibrarySource,
1096 page: u32,
1097 order: Option<SortOrder>,
1098) -> PageResult {
1099 match source {
1100 LibrarySource::LocalAll
1101 | LibrarySource::LocalSearch { .. }
1102 | LibrarySource::AlbumLocal { .. } => fetch_local_page(ui, source, page).await,
1103 LibrarySource::Unified
1104 | LibrarySource::UnifiedSearch { .. }
1105 | LibrarySource::AlbumUnified { .. } => fetch_unified_page(ui, source, page, order).await,
1106 remote => fetch_remote_page(&ui.ctx.api_client, remote, page, order).await,
1107 }
1108}
1109
1110async fn fetch_remote_page(
1111 api: &crate::api_client::ImmichApiClient,
1112 source: LibrarySource,
1113 page: u32,
1114 order: Option<SortOrder>,
1115) -> PageResult {
1116 if let Some(filters) = remote_filters(&source, order) {
1117 return api
1118 .search_metadata_with_filters(&filters, page, PAGE_SIZE)
1119 .await;
1120 }
1121 match source {
1122 LibrarySource::Folder { path } => {
1123 if page > 1 {
1124 return Ok((Vec::new(), false));
1125 }
1126 let mut assets = api.fetch_folder_assets(&path).await?;
1127 folders_view::sort_by_date(&mut assets, order);
1128 Ok((assets, false))
1129 }
1130 LibrarySource::Album { id, .. } => {
1131 api.fetch_album_assets(&id, page, PAGE_SIZE, order).await
1132 }
1133 LibrarySource::SmartSearch { query } | LibrarySource::Thing { query } => {
1134 api.search_smart(&query, page, PAGE_SIZE).await
1135 }
1136 LibrarySource::OcrSearch { query } => api.search_ocr(&query, page, PAGE_SIZE, order).await,
1137 LibrarySource::MetadataSearch { query } => {
1138 api.search_metadata(&query, page, PAGE_SIZE, order).await
1139 }
1140 other => unreachable!("{other:?} is not a remote page source"),
1141 }
1142}
1143
1144fn remote_filters(
1145 source: &LibrarySource,
1146 order: Option<SortOrder>,
1147) -> Option<MetadataSearchFilters> {
1148 let base = match source {
1149 LibrarySource::AllAssets | LibrarySource::Timeline => MetadataSearchFilters {
1150 visibility: Some("timeline".to_string()),
1151 ..MetadataSearchFilters::default()
1152 },
1153 LibrarySource::Archive => MetadataSearchFilters {
1154 visibility: Some("archive".to_string()),
1155 ..MetadataSearchFilters::default()
1156 },
1157 LibrarySource::Favorites => MetadataSearchFilters {
1158 is_favorite: Some(true),
1159 ..MetadataSearchFilters::default()
1160 },
1161 LibrarySource::Trash => MetadataSearchFilters::trashed(),
1162 LibrarySource::Person { id, .. } => MetadataSearchFilters {
1163 person_ids: Some(vec![id.clone()]),
1164 ..MetadataSearchFilters::default()
1165 },
1166 LibrarySource::Tag { id, .. } => MetadataSearchFilters {
1167 tag_ids: Some(vec![id.clone()]),
1168 ..MetadataSearchFilters::default()
1169 },
1170 LibrarySource::Place { city } => MetadataSearchFilters {
1171 city: Some(city.clone()),
1172 ..MetadataSearchFilters::default()
1173 },
1174 LibrarySource::AdvancedSearch { filters } => (**filters).clone(),
1175 _ => return None,
1176 };
1177 Some(MetadataSearchFilters { order, ..base })
1178}
1179
1180async fn fetch_local_page(
1182 ui: &Rc<LibraryWindowUi>,
1183 source: LibrarySource,
1184 page: u32,
1185) -> PageResult {
1186 if page > 1 {
1187 return Ok((Vec::new(), false));
1188 }
1189 let locals = match source {
1190 LibrarySource::LocalAll => enumerate_local(ui.ctx.clone()).await,
1191 LibrarySource::LocalSearch { query } => {
1192 filter_by_filename(enumerate_local(ui.ctx.clone()).await, &query)
1193 }
1194 LibrarySource::AlbumLocal { name, .. } => match linked_entry_path_for_album(ui, &name) {
1195 Some(path) => enumerate_local_for_entry(ui.ctx.clone(), path).await,
1196 None => Vec::new(),
1197 },
1198 other => unreachable!("{other:?} is not a local page source"),
1199 };
1200 Ok((
1201 locals.into_iter().map(local_to_library_asset).collect(),
1202 false,
1203 ))
1204}
1205
1206async fn fetch_unified_page(
1208 ui: &Rc<LibraryWindowUi>,
1209 source: LibrarySource,
1210 page: u32,
1211 order: Option<SortOrder>,
1212) -> PageResult {
1213 let api = &ui.ctx.api_client;
1214 match source {
1215 LibrarySource::Unified => {
1216 let remote = api
1217 .search_metadata_with_filters(
1218 &remote_filters(&LibrarySource::Timeline, order).unwrap_or_default(),
1219 page,
1220 PAGE_SIZE,
1221 )
1222 .await;
1223 merge_unified_page(remote, page, ui, None).await
1224 }
1225 LibrarySource::UnifiedSearch { query } => {
1226 let remote = api.search_metadata(&query, page, PAGE_SIZE, order).await;
1227 merge_unified_page(remote, page, ui, Some(&query)).await
1228 }
1229 LibrarySource::AlbumUnified { id, name } => {
1230 let remote = api.fetch_album_assets(&id, page, PAGE_SIZE, order).await;
1231 merge_album_unified_page(remote, page, ui, &name).await
1232 }
1233 other => unreachable!("{other:?} is not a unified page source"),
1234 }
1235}
1236
1237fn apply_page_result(ui: &Rc<LibraryWindowUi>, generation: u64, append: bool, result: PageResult) {
1238 let (items, has_more) = match result {
1239 Ok(page) => page,
1240 Err(err) => {
1241 let current = {
1242 let mut state = ui.ctx.library_state.lock();
1243 state.mark_error(generation, err.clone());
1244 state.generation == generation
1245 };
1246 if current && !append && !navigation::on_page(ui) {
1247 let message = format!("Could not load library assets: {err}");
1248 source_header::set_status(ui, "error", Some(&message));
1249 ui.content_stack.set_visible_child_name("grid");
1250 }
1251 return;
1252 }
1253 };
1254 {
1255 let mut state = ui.ctx.library_state.lock();
1256 let applied = if append {
1257 state.append_assets_with_more(generation, items, has_more)
1258 } else {
1259 state.replace_assets_with_more(generation, items, has_more)
1260 };
1261 if !applied {
1262 return;
1263 }
1264 if append {
1265 ui.grid
1266 .model
1267 .extend(&ui.ctx, &state.assets, &state.sort_mode);
1268 } else {
1269 ui.grid
1270 .model
1271 .reset(&ui.ctx, &state.assets, &state.sort_mode);
1272 }
1273 }
1274 sync_content_state(ui);
1277 reload_sidebar(ui);
1278 update_timeline_banner_if_active(ui, &ui.grid.scrolled.vadjustment());
1279}
1280
1281fn setup_album_drop_target(
1282 ui: &Rc<LibraryWindowUi>,
1283 row: >k::ListBoxRow,
1284 album_id: String,
1285 album_name: String,
1286) {
1287 let row_drop = gtk::DropTarget::new(
1288 gtk::gdk::FileList::static_type(),
1289 gtk::gdk::DragAction::COPY,
1290 );
1291 let row_ref_enter = row.clone();
1292 row_drop.connect_enter(move |target, _x, _y| {
1293 if target.current_drop().is_some_and(|d| d.drag().is_some()) {
1294 return gtk::gdk::DragAction::empty();
1295 }
1296 row_ref_enter.add_css_class("mimick-album-drop-hover");
1297 gtk::gdk::DragAction::COPY
1298 });
1299 let row_ref_leave = row.clone();
1300 row_drop.connect_leave(move |_target| {
1301 row_ref_leave.remove_css_class("mimick-album-drop-hover");
1302 });
1303 let row_ref_drop = row.clone();
1304 let ui_drop = ui.clone();
1305 row_drop.connect_drop(move |target, value, _x, _y| {
1306 row_ref_drop.remove_css_class("mimick-album-drop-hover");
1307
1308 if target.current_drop().is_some_and(|d| d.drag().is_some()) {
1309 return false;
1310 }
1311 let file_list = match value.get::<gtk::gdk::FileList>() {
1312 Ok(fl) => fl,
1313 Err(_) => return false,
1314 };
1315 let paths: Vec<std::path::PathBuf> = file_list
1316 .files()
1317 .iter()
1318 .filter_map(gtk::gio::prelude::FileExt::path)
1319 .filter(|p| crate::media_kinds::is_supported_path(p))
1320 .collect();
1321 if paths.is_empty() {
1322 show_unsupported_drop_toast(&ui_drop);
1323 return true;
1324 }
1325 handle_album_drop_upload(&ui_drop, (album_id.clone(), album_name.clone()), paths);
1326 true
1327 });
1328 row.add_controller(row_drop);
1329}
1330
1331fn build_album_sidebar_row(
1332 ui: &Rc<LibraryWindowUi>,
1333 album: &crate::api_client::LibraryAlbum,
1334) -> gtk::ListBoxRow {
1335 let subtitle = format!("{} asset(s)", album.asset_count);
1336 let row: gtk::ListBoxRow = libadwaita::ActionRow::builder()
1337 .title(&album.album_name)
1338 .subtitle(&subtitle)
1339 .title_lines(1)
1340 .subtitle_lines(1)
1341 .tooltip_text(format!("{}:{}", album.id, album.album_name))
1342 .build()
1343 .upcast();
1344
1345 setup_album_drop_target(ui, &row, album.id.clone(), album.album_name.clone());
1348
1349 row
1350}
1351
1352fn reload_sidebar(ui: &Rc<LibraryWindowUi>) {
1353 let albums = ui.ctx.library_state.lock().albums.clone();
1354 sidebar::set_recent_albums(&ui.sidebar, &albums);
1355 if sidebar::album_list_changed(&ui.sidebar, &albums) {
1356 let had_focus = ui.sidebar.albums_list.focus_child().is_some();
1357 ui.sidebar_suppressed.set(true);
1358 while let Some(row) = ui.sidebar.albums_list.first_child() {
1359 ui.sidebar.albums_list.remove(&row);
1360 }
1361 for album in &albums {
1362 let row = build_album_sidebar_row(ui, album);
1363 ui.sidebar.albums_list.append(&row);
1364 }
1365 ui.sidebar_suppressed.set(false);
1366 navigation::sync_sidebar(ui);
1367 if had_focus {
1368 match ui.sidebar.albums_list.selected_row() {
1369 Some(row) => row.grab_focus(),
1370 None => ui.sidebar.fixed_lists[0].grab_focus(),
1371 };
1372 }
1373 return;
1374 }
1375 navigation::sync_sidebar(ui);
1376}
1377
1378fn sync_content_state(ui: &LibraryWindowUi) {
1386 if navigation::on_page(ui) {
1387 return;
1388 }
1389 let (status, error_msg, single_page_count) = {
1390 let state = ui.ctx.library_state.lock();
1391 let single_page = matches!(
1392 state.source,
1393 LibrarySource::Folder { .. }
1394 | LibrarySource::LocalAll
1395 | LibrarySource::LocalSearch { .. }
1396 );
1397 let count = (single_page && !state.has_more).then_some(state.assets.len());
1398 match &state.load_state {
1399 LibraryLoadState::Idle | LibraryLoadState::Loading => ("loading", None, None),
1400 LibraryLoadState::Loaded => ("none", None, count),
1401 LibraryLoadState::Empty => ("empty", None, count),
1402 LibraryLoadState::Error(msg) => ("error", Some(msg.clone()), None),
1403 }
1404 };
1405 source_header::set_status(ui, status, error_msg.as_deref());
1406 if let Some(count) = single_page_count {
1407 source_header::set_loaded_count(ui, count);
1408 }
1409 ui.content_stack.set_visible_child_name("grid");
1410}
1411
1412fn update_footer(ui: &LibraryWindowUi, route: Option<String>) {
1414 let (stats_text, about_text) = {
1415 let state = ui.ctx.library_state.lock();
1416 let stats = state
1417 .status
1418 .stats
1419 .as_ref()
1420 .map(|stats| format!("{} photos, {} videos", stats.images, stats.videos))
1421 .unwrap_or_else(|| "Statistics unavailable".to_string());
1422 let about = state
1423 .status
1424 .about
1425 .as_ref()
1426 .map(|about| format!("Immich {}", about.version))
1427 .unwrap_or_else(|| "Version unavailable".to_string());
1428 (stats, about)
1429 };
1430 let route_subtitle = route
1431 .as_deref()
1432 .map(|route| match route {
1433 "LAN" => "Connected through LAN",
1434 "WAN" => "Connected through WAN",
1435 _ => "Connected through configured server",
1436 })
1437 .unwrap_or("Offline");
1438 ui.sidebar.connection_row.set_subtitle(route_subtitle);
1439 ui.sidebar
1440 .server_row
1441 .set_subtitle(&format!("{stats_text} | {about_text}"));
1442}
1443
1444fn update_transfer_ui(ui: &LibraryWindowUi) {
1446 let transfer = {
1447 let mut state = ui.ctx.state.lock();
1448 if state.transfer.active
1449 && state.transfer.active_uploads == 0
1450 && state.transfer.active_downloads == 0
1451 {
1452 state.transfer.reset_runtime();
1454 }
1455 state.transfer.clone()
1456 };
1457 ui.transfer.indicator.set_visible(transfer.active);
1458 if !transfer.active {
1459 return;
1460 }
1461 let icon_name = match transfer.direction {
1462 TransferDirection::Upload => "mimick-upload-symbolic",
1463 TransferDirection::Download => "mimick-download-symbolic",
1464 };
1465 ui.transfer.icon.set_icon_name(Some(icon_name));
1466 let detail = transfer
1467 .active_item_label
1468 .as_deref()
1469 .unwrap_or(match transfer.direction {
1470 TransferDirection::Upload => "queued asset",
1471 TransferDirection::Download => "selected asset",
1472 });
1473 ui.transfer.indicator.set_tooltip_text(Some(detail));
1474 match transfer.total_bytes {
1475 Some(total) if total > 0 => {
1476 let fraction = (transfer.current_bytes as f64 / total as f64).clamp(0.0, 1.0);
1477 ui.transfer.progress.set_fraction(fraction);
1478 }
1479 _ => ui.transfer.progress.pulse(),
1480 }
1481}
1482
1483fn build_loading_view() -> gtk::Box {
1487 let container = gtk::Box::builder()
1488 .orientation(gtk::Orientation::Vertical)
1489 .spacing(12)
1490 .vexpand(true)
1491 .hexpand(true)
1492 .valign(gtk::Align::Center)
1493 .halign(gtk::Align::Center)
1494 .css_classes(vec!["mimick-empty".to_string()])
1495 .build();
1496 let icon = gtk::Image::builder()
1497 .icon_name("dev.nicx.mimick")
1498 .pixel_size(72)
1499 .css_classes(["mimick-loader-icon"])
1500 .build();
1501 let title = gtk::Label::builder()
1502 .label("Loading…")
1503 .css_classes(vec!["mimick-empty-title".to_string()])
1504 .build();
1505 let subtitle = gtk::Label::builder()
1506 .label("Fetching library data from the Immich server")
1507 .wrap(true)
1508 .wrap_mode(gtk::pango::WrapMode::WordChar)
1509 .justify(gtk::Justification::Center)
1510 .max_width_chars(28)
1511 .css_classes(vec!["mimick-empty-subtitle".to_string()])
1512 .build();
1513 container.append(&icon);
1514 container.append(&title);
1515 container.append(&subtitle);
1516 container
1517}
1518
1519fn build_status_view(icon_name: &str, title: &str, subtitle: &str) -> gtk::Box {
1520 let container = gtk::Box::builder()
1521 .orientation(gtk::Orientation::Vertical)
1522 .spacing(12)
1523 .vexpand(true)
1524 .hexpand(true)
1525 .valign(gtk::Align::Center)
1526 .halign(gtk::Align::Center)
1527 .css_classes(vec!["mimick-empty".to_string()])
1528 .build();
1529 let icon = gtk::Image::builder()
1530 .icon_name(icon_name)
1531 .pixel_size(64)
1532 .build();
1533 icon.add_css_class("dim-label");
1534 let title_label = gtk::Label::builder()
1535 .label(title)
1536 .wrap(true)
1537 .wrap_mode(gtk::pango::WrapMode::WordChar)
1538 .justify(gtk::Justification::Center)
1539 .max_width_chars(28)
1540 .css_classes(vec!["mimick-empty-title".to_string()])
1541 .build();
1542 let subtitle_label = gtk::Label::builder()
1543 .label(subtitle)
1544 .wrap(true)
1545 .wrap_mode(gtk::pango::WrapMode::WordChar)
1546 .justify(gtk::Justification::Center)
1547 .max_width_chars(28)
1548 .css_classes(vec!["mimick-empty-subtitle".to_string()])
1549 .build();
1550 container.append(&icon);
1551 container.append(&title_label);
1552 container.append(&subtitle_label);
1553 container
1554}
1555
1556pub(super) fn immich_checksum_to_hex(b64: &str) -> Option<String> {
1558 use base64::Engine as _;
1559 let bytes = base64::engine::general_purpose::STANDARD.decode(b64).ok()?;
1560 Some(bytes.iter().map(|b| format!("{:02x}", b)).collect())
1561}
1562
1563fn local_to_library_asset(local: LocalAsset) -> LibraryAsset {
1565 LibraryAsset {
1566 id: format!("{}{}", LOCAL_ID_PREFIX, local.path.display()),
1567 filename: local.filename,
1568 mime_type: local.mime,
1569 created_at: local.created_at,
1570 asset_type: local.asset_type.to_string(),
1571 thumbhash: None,
1572 width: None,
1573 height: None,
1574 checksum: None,
1575 exif_info: None,
1576 is_favorite: false,
1577 visibility: String::new(),
1578 }
1579}
1580
1581async fn merge_unified_page(
1583 remote: Result<(Vec<LibraryAsset>, bool), String>,
1584 page: u32,
1585 ui: &Rc<LibraryWindowUi>,
1586 query: Option<&str>,
1587) -> Result<(Vec<LibraryAsset>, bool), String> {
1588 let (mut remote, has_more) = remote?;
1589 if page > 1 {
1590 return Ok((remote, has_more));
1591 }
1592
1593 let mut locals = enumerate_local(ui.ctx.clone()).await;
1594 if let Some(q) = query {
1595 locals = filter_by_filename(locals, q);
1596 }
1597
1598 let synced_paths: std::collections::HashSet<String> = {
1599 remote
1600 .iter()
1601 .filter_map(|a| a.checksum.as_deref())
1602 .filter_map(immich_checksum_to_hex)
1603 .filter_map(|hex| ui.ctx.sync_index.local_path_for_checksum(&hex))
1604 .collect()
1605 };
1606
1607 let mut local_rows: Vec<LibraryAsset> = locals
1608 .into_iter()
1609 .filter(|l| !synced_paths.contains(&l.path.display().to_string()))
1610 .map(local_to_library_asset)
1611 .collect();
1612
1613 local_rows.append(&mut remote);
1614 Ok((local_rows, has_more))
1615}
1616
1617fn linked_entry_path_for_album(ui: &Rc<LibraryWindowUi>, album_name: &str) -> Option<String> {
1620 let entries = ui.ctx.live_watch_paths.lock().clone();
1621 crate::config::watch_entry_for_album(album_name, &entries).map(|e| e.path().to_string())
1622}
1623
1624async fn merge_album_unified_page(
1629 remote: Result<(Vec<LibraryAsset>, bool), String>,
1630 page: u32,
1631 ui: &Rc<LibraryWindowUi>,
1632 album_name: &str,
1633) -> Result<(Vec<LibraryAsset>, bool), String> {
1634 let (mut remote, has_more) = remote?;
1635 if page > 1 {
1636 return Ok((remote, has_more));
1637 }
1638
1639 let locals = match linked_entry_path_for_album(ui, album_name) {
1640 Some(path) => enumerate_local_for_entry(ui.ctx.clone(), path).await,
1641 None => Vec::new(),
1642 };
1643
1644 let synced_paths: std::collections::HashSet<String> = {
1645 remote
1646 .iter()
1647 .filter_map(|a| a.checksum.as_deref())
1648 .filter_map(immich_checksum_to_hex)
1649 .filter_map(|hex| ui.ctx.sync_index.local_path_for_checksum(&hex))
1650 .collect()
1651 };
1652
1653 let mut local_rows: Vec<LibraryAsset> = locals
1654 .into_iter()
1655 .filter(|l| !synced_paths.contains(&l.path.display().to_string()))
1656 .map(local_to_library_asset)
1657 .collect();
1658
1659 local_rows.append(&mut remote);
1660 Ok((local_rows, has_more))
1661}
1662
1663fn show_library_permission_error(window: &libadwaita::ApplicationWindow) {
1665 let dialog = libadwaita::AlertDialog::builder()
1666 .heading("Missing API Permissions")
1667 .body("Your API key is missing permissions required for the Library view. Please ensure the key has 'asset.read', 'asset.view', 'asset.download', and 'person.read' enabled.")
1668 .build();
1669 dialog.add_response("close", "Close");
1670 dialog.present(Some(window));
1671}