Skip to main content

mimick/library/
mod.rs

1//! Library view module -- browse, search, and download assets from an Immich server.
2//!
3//! Constructs the main GTK window with a split-pane sidebar, paginated
4//! grid view, timeline scrubber, and search bar. Submodules handle the
5//! explore dashboard, album grids, lightbox, sidebar, and thumbnail
6//! caching independently.
7
8use 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};
17use crate::app_context::AppContext;
18use crate::library::albums_view::{
19    AlbumClick, AlbumsViewParts, build_albums_view, populate_albums,
20};
21use crate::library::explore_view::{ExploreViewParts, build_explore_view};
22use crate::library::local_source::{
23    LocalAsset, enumerate_local, enumerate_local_for_entry, filter_by_filename,
24};
25use crate::library::masonry::{GridViewParts, build_grid_view};
26use crate::library::search_view::{SearchViewParts, build_search_view};
27use crate::library::sidebar::{SidebarParts, build_sidebar};
28use crate::library::state::{LibraryLoadState, LibrarySource};
29use crate::state_manager::TransferDirection;
30
31use self::actions::{connect_bulk_actions, connect_select_mode};
32use self::album_link::{connect_album_link_row, refresh_album_link_row};
33use self::context_menu::show_asset_context_menu;
34use self::controls::{
35    connect_controls, connect_grid_handlers, connect_sidebar_handlers,
36    refresh_library_after_mutation, sidebar_dispatch,
37};
38use self::download::format_rate;
39use self::lightbox::open_lightbox;
40
41pub(super) const LOCAL_ID_PREFIX: &str = "local::";
42
43pub mod album_sync;
44pub mod albums_view;
45pub mod asset_model;
46pub mod asset_object;
47pub mod explore_view;
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 actions;
57mod album_link;
58mod context_menu;
59mod controls;
60mod download;
61mod lightbox;
62pub mod search_filters;
63pub mod search_view;
64mod server_stats_dialog;
65pub mod staging_view;
66mod upload_picker;
67
68const PAGE_SIZE: u32 = 50;
69
70mod texture_decode;
71use texture_decode::{decode_raw_thumbnail_texture, load_texture_blocking};
72pub use texture_decode::{set_raw_cache_enabled, set_raw_full_decode};
73
74/// Register application custom icons with the Gtk icon theme system.
75pub(crate) fn register_app_icons() {
76    if let Some(display) = gtk::gdk::Display::default() {
77        let theme = gtk::IconTheme::for_display(&display);
78        theme.add_resource_path("/dev/nicx/mimick/icons");
79    }
80}
81
82/// Load a local texture, preferring decoders that cover formats outside the
83/// GDK-Pixbuf runtime loader set before falling back to pixbuf and image-rs.
84pub(crate) async fn load_texture_oriented(path: &std::path::Path) -> Option<gdk4::Texture> {
85    let path_buf = path.to_path_buf();
86    tokio::task::spawn_blocking(move || load_texture_blocking(&path_buf))
87        .await
88        .ok()
89        .flatten()
90}
91
92/// The complete UI widgets state wrapper for the library window interface.
93struct LibraryWindowUi {
94    ctx: Arc<AppContext>,
95    app: libadwaita::Application,
96    window: libadwaita::ApplicationWindow,
97    nav: libadwaita::NavigationView,
98    sidebar: SidebarParts,
99    grid: GridViewParts,
100    explore: ExploreViewParts,
101    albums: AlbumsViewParts,
102    content_stack: gtk::Stack,
103    error_label: gtk::Label,
104    transfer_bar: gtk::Box,
105    transfer_progress: gtk::ProgressBar,
106    transfer_icon: gtk::Image,
107    transfer_label: gtk::Label,
108    search_view: SearchViewParts,
109    sort_mode: gtk::DropDown,
110    source_mode: gtk::DropDown,
111    source_revealer: gtk::Revealer,
112    upload_button: gtk::Button,
113    timeline_toggle: gtk::ToggleButton,
114    timeline_banner: gtk::Label,
115    source_mode_suppressed: Cell<bool>,
116    sidebar_suppressed: Cell<bool>,
117    back_button: gtk::Button,
118    select_toggle: gtk::ToggleButton,
119    bulk_bar: gtk::Revealer,
120    bulk_count_label: gtk::Label,
121    album_link_row: libadwaita::ActionRow,
122    album_link_button: gtk::Button,
123    album_sync_button: gtk::Button,
124    last_seen_upload_batch: Cell<u64>,
125    split: libadwaita::OverlaySplitView,
126    drop_overlay: gtk::Revealer,
127}
128
129/// Build and display the main library window application layout.
130pub fn build_library_window(app: &libadwaita::Application, ctx: Arc<AppContext>) {
131    style::ensure_registered();
132    register_app_icons();
133
134    let window = libadwaita::ApplicationWindow::builder()
135        .application(app)
136        .title("Mimick Library")
137        .name("mimick-library-window")
138        .default_width(1480)
139        .default_height(780)
140        .width_request(360)
141        .height_request(480)
142        .build();
143
144    let header = libadwaita::HeaderBar::builder()
145        .show_start_title_buttons(true)
146        .show_end_title_buttons(true)
147        .build();
148    let sidebar_toggle = gtk::ToggleButton::builder()
149        .icon_name("sidebar-show-symbolic")
150        .tooltip_text("Toggle sidebar (F9)")
151        .active(true)
152        .css_classes(["mimick-pressable"])
153        .build();
154    let back_button = gtk::Button::builder()
155        .icon_name("go-previous-symbolic")
156        .tooltip_text("Back (Alt+Left)")
157        .sensitive(false)
158        .css_classes(["mimick-pressable"])
159        .build();
160    let menu = gtk::gio::Menu::new();
161    menu.append(Some("Refresh"), Some("win.refresh"));
162    menu.append(Some("Queue Inspector"), Some("win.queue"));
163    menu.append(Some("Settings"), Some("win.settings"));
164    let menu_button = gtk::MenuButton::builder()
165        .icon_name("open-menu-symbolic")
166        .menu_model(&menu)
167        .tooltip_text("Menu")
168        .css_classes(["mimick-pressable"])
169        .build();
170    header.pack_start(&sidebar_toggle);
171    header.pack_start(&back_button);
172    header.pack_end(&menu_button);
173    let select_toggle = gtk::ToggleButton::builder()
174        .icon_name("checkbox-symbolic")
175        .tooltip_text("Select assets (Esc to exit)")
176        .build();
177
178    let toolbar = libadwaita::ToolbarView::builder().build();
179    toolbar.add_top_bar(&header);
180
181    let narrow_flag = Rc::new(Cell::new(false));
182    let sidebar = build_sidebar();
183    let grid = build_grid_view(ctx.clone(), select_toggle.clone(), narrow_flag.clone());
184    let explore = build_explore_view();
185    let albums = build_albums_view();
186
187    let source_mode_model = gtk::StringList::new(&["Remote", "Local", "Unified"]);
188    let source_mode = gtk::DropDown::builder()
189        .model(&source_mode_model)
190        .selected(0)
191        .tooltip_text("Asset source")
192        .build();
193    let timeline_toggle = gtk::ToggleButton::builder()
194        .label("Timeline")
195        .tooltip_text("Timeline view (all assets only)")
196        .build();
197
198    let search_view = build_search_view();
199
200    let sort_model = gtk::StringList::new(&["Newest", "Filename", "File Type"]);
201    let sort_mode = gtk::DropDown::builder()
202        .model(&sort_model)
203        .selected(0)
204        .build();
205
206    let upload_button = gtk::Button::builder()
207        .icon_name("document-send-symbolic")
208        .tooltip_text("Upload to library")
209        .css_classes(["suggested-action", "mimick-pressable"])
210        .build();
211
212    // Source mode (Remote/Local/Unified) is meaningful only inside a linked
213    // album; the revealer is unhidden by `apply_view_chrome` per source kind.
214    let source_revealer = gtk::Revealer::builder()
215        .transition_type(gtk::RevealerTransitionType::SlideRight)
216        .transition_duration(180)
217        .reveal_child(false)
218        .build();
219    source_revealer.set_child(Some(&source_mode));
220
221    let source_group = gtk::Box::builder()
222        .orientation(gtk::Orientation::Horizontal)
223        .spacing(8)
224        .build();
225    source_group.append(&source_revealer);
226    source_group.append(&timeline_toggle);
227
228    let sort_group = gtk::Box::builder()
229        .orientation(gtk::Orientation::Horizontal)
230        .spacing(8)
231        .build();
232    sort_group.append(&sort_mode);
233    sort_group.append(&upload_button);
234
235    let controls = gtk::Box::builder()
236        .orientation(gtk::Orientation::Vertical)
237        .spacing(12)
238        .margin_top(12)
239        .margin_bottom(12)
240        .margin_start(8)
241        .margin_end(8)
242        .build();
243    controls.append(&source_group);
244    controls.append(&sort_group);
245
246    let timeline_banner = gtk::Label::builder()
247        .xalign(0.0)
248        .css_classes(vec!["mimick-timeline-banner".to_string()])
249        .visible(false)
250        .ellipsize(gtk::pango::EllipsizeMode::End)
251        .max_width_chars(20)
252        .margin_top(4)
253        .margin_bottom(4)
254        .margin_start(12)
255        .build();
256
257    let content_stack = gtk::Stack::builder()
258        .vexpand(true)
259        .hexpand(true)
260        .transition_type(gtk::StackTransitionType::Crossfade)
261        .transition_duration(180)
262        .build();
263    let loading_view = build_loading_view();
264    let empty_view = build_status_view(
265        "image-x-generic-symbolic",
266        "Nothing to show",
267        "No assets match the current view",
268    );
269    let error_view = build_status_view(
270        "dialog-warning-symbolic",
271        "Library data unavailable",
272        "Could not load library assets",
273    );
274    let error_label = error_view
275        .last_child()
276        .and_downcast::<gtk::Label>()
277        .expect("status-view subtitle label");
278    content_stack.add_named(&loading_view, Some("loading"));
279    content_stack.add_named(&empty_view, Some("empty"));
280    content_stack.add_named(&error_view, Some("error"));
281    content_stack.add_named(&grid.scrolled, Some("grid"));
282    content_stack.add_named(&explore.root, Some("explore"));
283    content_stack.add_named(&albums.root, Some("albums"));
284
285    let transfer_progress = gtk::ProgressBar::builder()
286        .hexpand(true)
287        .valign(gtk::Align::Center)
288        .css_classes(vec!["mimick-transfer-progress".to_string()])
289        .build();
290    let transfer_icon = gtk::Image::builder()
291        .icon_size(gtk::IconSize::Normal)
292        .css_classes(vec!["dim-label".to_string()])
293        .visible(false)
294        .build();
295    let transfer_label = gtk::Label::builder()
296        .xalign(0.0)
297        .hexpand(true)
298        .wrap(true)
299        .max_width_chars(24)
300        .ellipsize(gtk::pango::EllipsizeMode::End)
301        .css_classes(vec!["caption".to_string(), "dim-label".to_string()])
302        .build();
303    let transfer_bar = gtk::Box::builder()
304        .orientation(gtk::Orientation::Horizontal)
305        .spacing(12)
306        .margin_top(8)
307        .margin_bottom(16)
308        .margin_start(12)
309        .margin_end(12)
310        .css_classes(vec!["mimick-transfer-shell".to_string()])
311        .build();
312    transfer_bar.append(&transfer_progress);
313    transfer_bar.append(&transfer_icon);
314    transfer_bar.append(&transfer_label);
315
316    let album_link_row = libadwaita::ActionRow::builder()
317        .title("No local folder linked")
318        .subtitle("Drop files in the linked folder to sync this album")
319        .title_lines(1)
320        .subtitle_lines(2)
321        .build();
322    let album_sync_button = gtk::Button::builder()
323        .label("Sync")
324        .valign(gtk::Align::Center)
325        .css_classes(vec!["suggested-action".to_string()])
326        .visible(false)
327        .build();
328    let album_link_button = gtk::Button::builder()
329        .label("Link")
330        .valign(gtk::Align::Center)
331        .build();
332    album_link_row.add_suffix(&album_sync_button);
333    album_link_row.add_suffix(&album_link_button);
334    let album_link_listbox = gtk::ListBox::builder()
335        .selection_mode(gtk::SelectionMode::None)
336        .css_classes(vec!["boxed-list".to_string()])
337        .margin_start(12)
338        .margin_end(12)
339        .margin_top(4)
340        .margin_bottom(4)
341        .visible(false)
342        .build();
343    album_link_listbox.append(&album_link_row);
344
345    let bulk_count_label = gtk::Label::builder().xalign(0.0).hexpand(true).build();
346    let bulk_delete = gtk::Button::builder()
347        .icon_name("user-trash-symbolic")
348        .tooltip_text("Delete selected")
349        .css_classes(vec!["destructive-action".to_string()])
350        .build();
351    let bulk_download = gtk::Button::builder()
352        .icon_name("mimick-download-symbolic")
353        .tooltip_text("Download selected")
354        .build();
355    let bulk_clear = gtk::Button::builder()
356        .icon_name("edit-clear-symbolic")
357        .tooltip_text("Clear selection")
358        .css_classes(vec!["flat".to_string()])
359        .build();
360    let bulk_inner = gtk::Box::builder()
361        .orientation(gtk::Orientation::Horizontal)
362        .spacing(8)
363        .margin_top(8)
364        .margin_bottom(16)
365        .margin_start(12)
366        .margin_end(12)
367        .css_classes(vec!["toolbar".to_string()])
368        .build();
369    bulk_inner.append(&bulk_count_label);
370    bulk_inner.append(&bulk_clear);
371    bulk_inner.append(&bulk_download);
372    bulk_inner.append(&bulk_delete);
373    let bulk_bar = gtk::Revealer::builder()
374        .transition_type(gtk::RevealerTransitionType::SlideUp)
375        .reveal_child(false)
376        .child(&bulk_inner)
377        .build();
378
379    let content = gtk::Box::builder()
380        .orientation(gtk::Orientation::Vertical)
381        .build();
382    content.append(&controls);
383    content.append(&search_view.root);
384    content.append(&album_link_listbox);
385    content.append(&timeline_banner);
386    content.append(&content_stack);
387    content.append(&bulk_bar);
388    content.append(&transfer_bar);
389
390    // Drop overlay: shown when files are dragged over the window.
391    let (content_with_drop, drop_overlay) = build_drop_overlay(content);
392
393    let split = libadwaita::OverlaySplitView::builder()
394        .sidebar(&sidebar.root)
395        .content(&content_with_drop)
396        .show_sidebar(true)
397        .collapsed(true)
398        .enable_show_gesture(true)
399        .enable_hide_gesture(true)
400        .min_sidebar_width(180.0)
401        .max_sidebar_width(260.0)
402        .sidebar_width_fraction(0.3)
403        .build();
404    split
405        .bind_property("show-sidebar", &sidebar_toggle, "active")
406        .sync_create()
407        .bidirectional()
408        .build();
409    toolbar.set_content(Some(&split));
410
411    let nav = libadwaita::NavigationView::new();
412    let root_page = libadwaita::NavigationPage::builder()
413        .child(&toolbar)
414        .title("Library")
415        .can_pop(false)
416        .build();
417    nav.add(&root_page);
418    window.set_content(Some(&nav));
419
420    let breakpoint = libadwaita::Breakpoint::new(
421        libadwaita::BreakpointCondition::parse("max-width: 600px")
422            .expect("valid breakpoint condition"),
423    );
424    breakpoint.add_setter(&transfer_bar, "visible", Some(&false.to_value()));
425    breakpoint.add_setter(&back_button, "visible", Some(&false.to_value()));
426    let narrow_apply = narrow_flag.clone();
427    let canvas_for_apply = grid.canvas.clone();
428    breakpoint.connect_apply(move |_| {
429        log::info!("NARROW breakpoint APPLIED: setting narrow=true");
430        narrow_apply.set(true);
431        canvas_for_apply.set_narrow(true);
432    });
433    let narrow_unapply = narrow_flag.clone();
434    let canvas_for_unapply = grid.canvas.clone();
435    breakpoint.connect_unapply(move |_| {
436        log::info!("NARROW breakpoint UNAPPLIED: setting narrow=false");
437        narrow_unapply.set(false);
438        canvas_for_unapply.set_narrow(false);
439    });
440    window.add_breakpoint(breakpoint);
441
442    let desktop_bp = libadwaita::Breakpoint::new(
443        libadwaita::BreakpointCondition::parse("min-width: 600px")
444            .expect("valid breakpoint condition"),
445    );
446    let window_for_desktop_apply = window.clone();
447    desktop_bp.connect_apply(move |_| {
448        window_for_desktop_apply.add_css_class("mimick-wide");
449    });
450    let window_for_desktop_unapply = window.clone();
451    desktop_bp.connect_unapply(move |_| {
452        window_for_desktop_unapply.remove_css_class("mimick-wide");
453    });
454    desktop_bp.add_setter(
455        &controls,
456        "orientation",
457        Some(&gtk::Orientation::Horizontal.to_value()),
458    );
459    desktop_bp.add_setter(&split, "collapsed", Some(&false.to_value()));
460    desktop_bp.add_setter(&album_sync_button, "label", Some(&"Sync…".to_value()));
461    desktop_bp.add_setter(
462        &album_link_button,
463        "label",
464        Some(&"Link folder…".to_value()),
465    );
466    window.add_breakpoint(desktop_bp);
467
468    // Tablet-width breakpoint: hide transfer bar and tweak chrome when the
469    // window is narrower than a typical desktop width.
470    let tablet_bp = libadwaita::Breakpoint::new(
471        libadwaita::BreakpointCondition::parse("max-width: 1000px")
472            .expect("valid breakpoint condition"),
473    );
474    window.add_breakpoint(tablet_bp);
475
476    let f9 = gtk::Shortcut::builder()
477        .trigger(&gtk::ShortcutTrigger::parse_string("F9").unwrap())
478        .action(&gtk::CallbackAction::new(clone!(
479            #[strong]
480            split,
481            move |_, _| {
482                split.set_show_sidebar(!split.shows_sidebar());
483                glib::Propagation::Stop
484            }
485        )))
486        .build();
487    let shortcut_controller = gtk::ShortcutController::new();
488    shortcut_controller.add_shortcut(f9);
489    window.add_controller(shortcut_controller);
490
491    let ui = Rc::new(LibraryWindowUi {
492        ctx,
493        app: app.clone(),
494        window: window.clone(),
495        nav: nav.clone(),
496        sidebar,
497        grid,
498        explore,
499        albums,
500        content_stack,
501        error_label,
502        transfer_bar,
503        transfer_progress,
504        transfer_icon,
505        transfer_label,
506        search_view,
507        sort_mode,
508        source_mode,
509        source_revealer,
510        upload_button: upload_button.clone(),
511        timeline_toggle,
512        timeline_banner,
513        source_mode_suppressed: Cell::new(false),
514        sidebar_suppressed: Cell::new(false),
515        back_button: back_button.clone(),
516        select_toggle: select_toggle.clone(),
517        bulk_bar: bulk_bar.clone(),
518        bulk_count_label: bulk_count_label.clone(),
519        album_link_row: album_link_row.clone(),
520        album_link_button: album_link_button.clone(),
521        album_sync_button: album_sync_button.clone(),
522        last_seen_upload_batch: Cell::new(0),
523        split: split.clone(),
524        drop_overlay: drop_overlay.clone(),
525    });
526    *ui.grid.context_menu_handler.borrow_mut() = Some(Box::new(clone!(
527        #[strong]
528        ui,
529        move |position, x, y| {
530            show_asset_context_menu(ui.clone(), &ui.grid.canvas, position, x, y);
531        }
532    )));
533
534    connect_album_link_row(ui.clone(), album_link_listbox);
535
536    connect_select_mode(ui.clone(), select_toggle.clone());
537    connect_bulk_actions(ui.clone(), bulk_delete, bulk_download, bulk_clear);
538
539    connect_sidebar_handlers(ui.clone());
540    connect_controls(ui.clone());
541    connect_grid_handlers(ui.clone());
542    connect_search_view(ui.clone());
543    connect_lightweight_filters(ui.clone());
544    connect_drop_target(ui.clone());
545
546    let close_ctx = ui.ctx.clone();
547    window.connect_close_request(move |_| {
548        close_ctx.thumbnail_cache.clear_memory();
549        glib::Propagation::Proceed
550    });
551
552    bootstrap_window(ui);
553
554    window.present();
555}
556
557/// Bootstrap initial data loading and background tasks for the library window.
558fn bootstrap_window(ui: Rc<LibraryWindowUi>) {
559    let initial_request = {
560        let mut state = ui.ctx.library_state.lock();
561        state.load_initial_source()
562    };
563
564    apply_timeline_ui_state(&ui, &initial_request.1);
565    load_albums(ui.clone());
566    load_status(ui.clone());
567    fetch_current_user(ui.clone());
568    connect_albums_create(ui.clone());
569    spawn_server_ping_loop(ui.clone());
570    spawn_transfer_poll_loop(ui.clone());
571    load_source_page(ui, initial_request, false);
572}
573
574/// Build a drop overlay widget and wrap `content` in a `gtk::Overlay`.
575///
576/// Returns the overlay wrapper (to be used as the content widget) and the
577/// revealer handle for toggling visibility from the `DropTarget` handlers.
578fn build_drop_overlay(content: gtk::Box) -> (gtk::Overlay, gtk::Revealer) {
579    let drop_icon = gtk::Image::builder()
580        .icon_name("document-send-symbolic")
581        .pixel_size(48)
582        .halign(gtk::Align::Center)
583        .build();
584    let drop_label = gtk::Label::builder()
585        .label("Drop files to upload")
586        .halign(gtk::Align::Center)
587        .build();
588    // Inner box centers the icon+label; outer box provides the full-window tint.
589    let inner = gtk::Box::builder()
590        .orientation(gtk::Orientation::Vertical)
591        .spacing(12)
592        .halign(gtk::Align::Center)
593        .valign(gtk::Align::Center)
594        .vexpand(true)
595        .build();
596    inner.append(&drop_icon);
597    inner.append(&drop_label);
598
599    let drop_box = gtk::Box::builder()
600        .orientation(gtk::Orientation::Vertical)
601        .halign(gtk::Align::Fill)
602        .valign(gtk::Align::Fill)
603        .vexpand(true)
604        .hexpand(true)
605        .css_classes(["mimick-drop-overlay"])
606        .build();
607    drop_box.append(&inner);
608
609    let revealer = gtk::Revealer::builder()
610        .transition_type(gtk::RevealerTransitionType::Crossfade)
611        .transition_duration(180)
612        .reveal_child(false)
613        .can_target(false)
614        .vexpand(true)
615        .hexpand(true)
616        .child(&drop_box)
617        .build();
618
619    let overlay = gtk::Overlay::builder().build();
620    overlay.set_child(Some(&content));
621    overlay.add_overlay(&revealer);
622
623    (overlay, revealer)
624}
625
626/// Wire the lightweight filter entries embedded in Albums and Explore views.
627fn connect_lightweight_filters(ui: Rc<LibraryWindowUi>) {
628    // Albums: live-filter tiles as the user types.
629    ui.albums.filter_entry.connect_search_changed(clone!(
630        #[strong]
631        ui,
632        move |entry| {
633            let query = entry.text().to_string();
634            albums_view::set_search_filter(&ui.albums, &query);
635        }
636    ));
637
638    // Explore: live-filter people tiles as the user types.
639    ui.explore.filter_entry.connect_search_changed(clone!(
640        #[strong]
641        ui,
642        move |entry| {
643            let query = entry.text().to_string();
644            explore_view::set_people_search(&ui.explore, &query);
645        }
646    ));
647}
648
649/// Wire the dedicated search view's interactive handlers.
650fn connect_search_view(ui: Rc<LibraryWindowUi>) {
651    // Clear button resets all fields.
652    ui.search_view.clear_button.connect_clicked(clone!(
653        #[strong]
654        ui,
655        move |_| {
656            search_view::clear_all_filters(&ui.search_view);
657        }
658    ));
659
660    // Search button (or Enter in the entry) dispatches the search.
661    let do_search = {
662        let ui = ui.clone();
663        move || {
664            dispatch_search_from_view(&ui);
665        }
666    };
667
668    let do_search_btn = do_search.clone();
669    ui.search_view.search_button.connect_clicked(clone!(
670        #[strong]
671        ui,
672        move |_| {
673            let _ = &ui;
674            do_search_btn();
675        }
676    ));
677
678    ui.search_view.search_entry.connect_activate(clone!(
679        #[strong]
680        ui,
681        move |_| {
682            let _ = &ui;
683            do_search();
684        }
685    ));
686}
687
688/// Read the search view widgets and dispatch the appropriate search source.
689fn dispatch_search_from_view(ui: &Rc<LibraryWindowUi>) {
690    let query = ui.search_view.search_entry.text().trim().to_string();
691    let filters = search_view::collect_filters(&ui.search_view);
692    let has_filters = filters != MetadataSearchFilters::default();
693    let mode = ui.search_view.search_mode.selected(); // 0=Smart, 1=Filename, 2=OCR
694
695    let source = build_search_source(mode, &query, has_filters, filters);
696    let Some(source) = source else {
697        return;
698    };
699
700    let request = ui.ctx.library_state.lock().navigate_to(source);
701    apply_timeline_ui_state(ui, &request.1);
702    // The search form revealer stays visible -- results load into the
703    // grid below it so the user can see and edit their query.
704    ui.content_stack.set_visible_child_name("loading");
705    load_source_page(ui.clone(), request, false);
706}
707
708/// Decide which `LibrarySource` variant to use based on mode, query, and filters.
709fn build_search_source(
710    mode: u32,
711    query: &str,
712    has_filters: bool,
713    filters: MetadataSearchFilters,
714) -> Option<LibrarySource> {
715    if query.is_empty() && !has_filters {
716        return None;
717    }
718
719    match mode {
720        // Smart Search
721        0 => {
722            if query.is_empty() {
723                // Filters only -> metadata search
724                Some(LibrarySource::AdvancedSearch {
725                    filters: Box::new(filters),
726                })
727            } else {
728                Some(LibrarySource::SmartSearch {
729                    query: query.to_string(),
730                })
731            }
732        }
733        // Filename
734        1 => {
735            let mut f = filters;
736            if !query.is_empty() {
737                f.original_file_name = Some(query.to_string());
738            }
739            Some(LibrarySource::AdvancedSearch {
740                filters: Box::new(f),
741            })
742        }
743        // OCR
744        2 => {
745            let mut f = filters;
746            if !query.is_empty() {
747                f.ocr = Some(query.to_string());
748            }
749            Some(LibrarySource::AdvancedSearch {
750                filters: Box::new(f),
751            })
752        }
753        _ => None,
754    }
755}
756
757/// Attach a `DropTarget` to the library window for drag-and-drop file uploads.
758fn connect_drop_target(ui: Rc<LibraryWindowUi>) {
759    let drop_target = gtk::DropTarget::new(
760        gtk::gdk::FileList::static_type(),
761        gtk::gdk::DragAction::COPY,
762    );
763
764    let ui_enter = ui.clone();
765    drop_target.connect_enter(move |target, _x, _y| {
766        // Reject internal drags (from our own DragSource).
767        if target.current_drop().is_some_and(|d| d.drag().is_some()) {
768            return gtk::gdk::DragAction::empty();
769        }
770        ui_enter.drop_overlay.set_reveal_child(true);
771        gtk::gdk::DragAction::COPY
772    });
773
774    let ui_leave = ui.clone();
775    drop_target.connect_leave(move |_target| {
776        ui_leave.drop_overlay.set_reveal_child(false);
777    });
778
779    let ui_drop = ui.clone();
780    drop_target.connect_drop(move |target, value, _x, _y| {
781        ui_drop.drop_overlay.set_reveal_child(false);
782
783        // Reject internal drags.
784        if target.current_drop().is_some_and(|d| d.drag().is_some()) {
785            return false;
786        }
787
788        handle_drop(&ui_drop, value)
789    });
790
791    ui.window.add_controller(drop_target);
792}
793
794fn handle_drop(ui: &LibraryWindowUi, value: &gtk::glib::Value) -> bool {
795    let file_list = match value.get::<gtk::gdk::FileList>() {
796        Ok(fl) => fl,
797        Err(_) => return false,
798    };
799
800    let paths: Vec<std::path::PathBuf> = file_list
801        .files()
802        .iter()
803        .filter_map(|f| f.path())
804        .filter(|p| crate::media_kinds::is_supported_path(p))
805        .collect();
806
807    if paths.is_empty() {
808        show_unsupported_drop_toast(ui);
809        return true;
810    }
811
812    let album = match ui.ctx.library_state.lock().source.clone() {
813        LibrarySource::Album { id, name }
814        | LibrarySource::AlbumLocal { id, name }
815        | LibrarySource::AlbumUnified { id, name } => Some((id, name)),
816        _ => None,
817    };
818
819    if let Some(album) = album {
820        handle_album_drop_upload(ui, album, paths);
821    } else {
822        handle_library_drop_upload(ui, paths);
823    }
824
825    true
826}
827
828fn show_unsupported_drop_toast(ui: &LibraryWindowUi) {
829    let toast = libadwaita::Toast::new("No supported media files in drop");
830    if let Some(overlay) = ui
831        .window
832        .content()
833        .and_then(|w| w.first_child())
834        .and_downcast::<libadwaita::ToastOverlay>()
835    {
836        overlay.add_toast(toast);
837    } else {
838        log::info!("No supported media files in drop");
839    }
840}
841
842fn handle_album_drop_upload(
843    ui: &LibraryWindowUi,
844    album: (String, String),
845    paths: Vec<std::path::PathBuf>,
846) {
847    let count = paths.len();
848    upload_picker::spawn_enqueue_with_callback(
849        ui.ctx.clone(),
850        Some(album.clone()),
851        paths,
852        move |queued, _skipped| {
853            log::info!(
854                "Drop upload to album '{}': queued {}/{} file(s)",
855                album.1,
856                queued,
857                count
858            );
859        },
860    );
861}
862
863fn handle_library_drop_upload(ui: &LibraryWindowUi, paths: Vec<std::path::PathBuf>) {
864    let count = paths.len();
865    let ctx_for_album = ui.ctx.clone();
866    let window_for_album = ui.window.clone();
867    let paths_for_album = paths.clone();
868
869    upload_picker::spawn_enqueue_with_callback(
870        ui.ctx.clone(),
871        None,
872        paths,
873        move |queued, _skipped| {
874            log::info!(
875                "Drop upload to library: queued {}/{} file(s)",
876                queued,
877                count
878            );
879            if queued > 0 {
880                staging_view::show_album_picker(window_for_album, ctx_for_album, paths_for_album);
881            }
882        },
883    );
884}
885
886fn spawn_server_ping_loop(ui: Rc<LibraryWindowUi>) {
887    glib::timeout_add_seconds_local(5, move || {
888        let ui_for_tick = ui.clone();
889        glib::MainContext::default().spawn_local(async move {
890            let _ = ui_for_tick.ctx.api_client.check_connection().await;
891            let route = ui_for_tick.ctx.api_client.active_route_label().await;
892
893            // If we are online but missing stats (e.g. from an initial network failure), re-fetch them.
894            if route.is_some() {
895                let missing_stats = {
896                    let state = ui_for_tick.ctx.library_state.lock();
897                    state.status.stats.is_none() || state.status.about.is_none()
898                };
899                if missing_stats {
900                    let stats = ui_for_tick.ctx.api_client.fetch_server_stats().await.ok();
901                    let about = ui_for_tick.ctx.api_client.fetch_server_about().await.ok();
902                    if stats.is_some() || about.is_some() {
903                        let mut state = ui_for_tick.ctx.library_state.lock();
904                        state.set_status(stats, about);
905                    }
906                }
907            }
908
909            update_footer(&ui_for_tick, route);
910        });
911        glib::ControlFlow::Continue
912    });
913}
914
915/// Start a quick periodic polling loop updating the transfer progress bar UI.
916fn spawn_transfer_poll_loop(ui: Rc<LibraryWindowUi>) {
917    glib::timeout_add_local(std::time::Duration::from_millis(250), move || {
918        let completed_batches = ui.ctx.state.lock().completed_upload_batches;
919        if completed_batches != ui.last_seen_upload_batch.get() {
920            ui.last_seen_upload_batch.set(completed_batches);
921            refresh_library_after_mutation(ui.clone(), true);
922        }
923        update_transfer_ui(&ui);
924        glib::ControlFlow::Continue
925    });
926}
927
928/// Fetch current logged-in API user ID and store it in app context.
929fn fetch_current_user(ui: Rc<LibraryWindowUi>) {
930    if ui.ctx.current_user_id.lock().is_some() {
931        return;
932    }
933    glib::MainContext::default().spawn_local(async move {
934        match ui.ctx.api_client.fetch_current_user_id().await {
935            Ok(id) => {
936                *ui.ctx.current_user_id.lock() = Some(id);
937            }
938            Err(err) => log::warn!("Could not fetch current user id: {}", err),
939        }
940    });
941}
942
943/// Connect action handlers to the album creation widgets.
944fn connect_albums_create(ui: Rc<LibraryWindowUi>) {
945    ui.albums.create_button.connect_clicked(clone!(
946        #[strong]
947        ui,
948        move |_| prompt_create_album(ui.clone())
949    ));
950}
951
952/// Show a popup modal dialog prompting the user to name and create a new album.
953fn prompt_create_album(ui: Rc<LibraryWindowUi>) {
954    let dialog = libadwaita::AlertDialog::builder()
955        .heading("Create album")
956        .body("Choose a name for the new album.")
957        .build();
958    let entry = gtk::Entry::builder()
959        .placeholder_text("Album name")
960        .activates_default(true)
961        .build();
962    dialog.set_extra_child(Some(&entry));
963    dialog.add_response("cancel", "Cancel");
964    dialog.add_response("create", "Create");
965    dialog.set_response_appearance("create", libadwaita::ResponseAppearance::Suggested);
966    dialog.set_default_response(Some("create"));
967    dialog.set_close_response("cancel");
968
969    let ui_for_choice = ui.clone();
970    let entry_for_choice = entry.clone();
971    dialog.connect_response(None, move |dlg, response| {
972        if response != "create" {
973            return;
974        }
975        let name = entry_for_choice.text().to_string();
976        if name.trim().is_empty() {
977            return;
978        }
979        let ui = ui_for_choice.clone();
980        glib::MainContext::default().spawn_local(async move {
981            match ui.ctx.api_client.create_album(name.trim()).await {
982                Ok(_) => {
983                    refresh_library_after_mutation(ui.clone(), false);
984                }
985                Err(err) => log::error!("Create album failed: {}", err),
986            }
987        });
988        dlg.close();
989    });
990    dialog.present(Some(&ui.window));
991}
992
993/// Asynchronously fetch the latest albums from the server and redraw the album views.
994///
995/// Late-arriving fetches must not hijack the stack: if the user navigated to
996/// another tab while we were waiting on the network, we still bind the data
997/// (so the grid is current when they come back) but we do *not* force the
998/// stack child to "albums" or "error".
999fn refresh_albums_view(ui: Rc<LibraryWindowUi>) {
1000    glib::MainContext::default().spawn_local(async move {
1001        match ui.ctx.api_client.fetch_library_albums().await {
1002            Ok(albums) => {
1003                let on_click = album_click_handler(ui.clone());
1004                populate_albums(&ui.albums, ui.ctx.clone(), albums, on_click);
1005                if matches!(
1006                    ui.content_stack.visible_child_name().as_deref(),
1007                    Some("albums") | Some("loading")
1008                ) {
1009                    ui.content_stack.set_visible_child_name("albums");
1010                }
1011            }
1012            Err(err) => {
1013                log::warn!("Albums fetch failed: {}", err);
1014                if !ui.albums.populated.get()
1015                    && ui.content_stack.visible_child_name().as_deref() == Some("loading")
1016                {
1017                    ui.error_label
1018                        .set_label(&format!("Could not load albums: {}", err));
1019                    ui.content_stack.set_visible_child_name("error");
1020                }
1021            }
1022        }
1023    });
1024}
1025
1026/// Produce a click callback handler for handling album activation events.
1027fn album_click_handler(ui: Rc<LibraryWindowUi>) -> AlbumClick {
1028    Rc::new(move |id: &str, name: String| {
1029        sidebar_dispatch(
1030            ui.clone(),
1031            LibrarySource::Album {
1032                id: id.to_string(),
1033                name,
1034            },
1035        );
1036    })
1037}
1038
1039/// Apply header control layout adjustments when switching view modes.
1040fn apply_timeline_ui_state(ui: &LibraryWindowUi, source: &LibrarySource) {
1041    let timeline_allowed = matches!(source, LibrarySource::AllAssets | LibrarySource::Timeline);
1042    let timeline_active = matches!(source, LibrarySource::Timeline);
1043    ui.ctx
1044        .library_timeline_active
1045        .store(timeline_active, std::sync::atomic::Ordering::Relaxed);
1046    ui.timeline_toggle.set_sensitive(timeline_allowed);
1047    if ui.timeline_toggle.is_active() != timeline_active {
1048        ui.timeline_toggle.set_active(timeline_active);
1049    }
1050    ui.timeline_banner.set_visible(timeline_active);
1051    if timeline_active {
1052        ui.sort_mode.set_selected(0);
1053    }
1054
1055    let is_local = matches!(
1056        source,
1057        LibrarySource::LocalAll
1058            | LibrarySource::LocalSearch { .. }
1059            | LibrarySource::AlbumLocal { .. }
1060    );
1061    let is_unified = matches!(
1062        source,
1063        LibrarySource::Unified
1064            | LibrarySource::UnifiedSearch { .. }
1065            | LibrarySource::AlbumUnified { .. }
1066    );
1067    let in_album = matches!(
1068        source,
1069        LibrarySource::Album { .. }
1070            | LibrarySource::AlbumLocal { .. }
1071            | LibrarySource::AlbumUnified { .. }
1072    );
1073
1074    // Source-mode (Remote/Local/Unified) only relevant inside an album.
1075    ui.source_revealer.set_reveal_child(in_album);
1076
1077    if in_album {
1078        ui.upload_button.set_tooltip_text(Some("Upload to album"));
1079    } else {
1080        ui.upload_button.set_tooltip_text(Some("Upload to library"));
1081    }
1082
1083    // Keep source dropdown visually consistent with the active source so
1084    // sidebar selections don't leave it showing the wrong tab.
1085    let target = if is_local {
1086        1
1087    } else if is_unified {
1088        2
1089    } else {
1090        0
1091    };
1092    if ui.source_mode.selected() != target {
1093        ui.source_mode_suppressed.set(true);
1094        ui.source_mode.set_selected(target);
1095        ui.source_mode_suppressed.set(false);
1096    }
1097
1098    refresh_album_link_row(ui, source);
1099}
1100
1101/// Compute and refresh the month-year overlay text dynamically while scrubbing the timeline list.
1102fn update_timeline_banner_if_active(ui: &Rc<LibraryWindowUi>, adj: &gtk::Adjustment) {
1103    let state = ui.ctx.library_state.lock();
1104    if !matches!(state.source, LibrarySource::Timeline) {
1105        return;
1106    }
1107    if state.assets.is_empty() {
1108        ui.timeline_banner.set_label("");
1109        return;
1110    }
1111
1112    let max = (adj.upper() - adj.page_size()).max(1.0);
1113    let frac = (adj.value() / max).clamp(0.0, 1.0);
1114    let idx = ((state.assets.len() as f64) * frac) as usize;
1115    let idx = idx.min(state.assets.len() - 1);
1116    let label = month_year_label(&state.assets[idx].created_at);
1117    ui.timeline_banner.set_label(&label);
1118}
1119
1120/// Convert an ISO 8601 string to a human-readable "Month Year" label.
1121fn month_year_label(iso: &str) -> String {
1122    use chrono::{DateTime, Datelike};
1123    if let Ok(dt) = DateTime::parse_from_rfc3339(iso) {
1124        const MONTHS: [&str; 12] = [
1125            "January",
1126            "February",
1127            "March",
1128            "April",
1129            "May",
1130            "June",
1131            "July",
1132            "August",
1133            "September",
1134            "October",
1135            "November",
1136            "December",
1137        ];
1138        let m = dt.month0() as usize;
1139        if let Some(name) = MONTHS.get(m) {
1140            return format!("{} {}", name, dt.year());
1141        }
1142    }
1143    iso.chars().take(7).collect()
1144}
1145
1146/// Retrieve albums from the API and update the side navigation sidebar entries.
1147fn load_albums(ui: Rc<LibraryWindowUi>) {
1148    glib::MainContext::default().spawn_local(clone!(
1149        #[strong]
1150        ui,
1151        async move {
1152            match ui.ctx.api_client.fetch_library_albums().await {
1153                Ok(albums) => {
1154                    ui.ctx.library_state.lock().load_albums(albums);
1155                    reload_sidebar(&ui);
1156                }
1157                Err(err) => {
1158                    // Sidebar-list refresh: never hijack the user's current
1159                    // tab with an error page just because the albums list
1160                    // couldn't reload. Only surface the error view when the
1161                    // user is actively waiting on something.
1162                    log::warn!("Albums sidebar refresh failed: {}", err);
1163                    if ui.content_stack.visible_child_name().as_deref() == Some("loading") {
1164                        ui.error_label
1165                            .set_label(&format!("Could not load albums: {}", err));
1166                        ui.content_stack.set_visible_child_name("error");
1167                    }
1168                }
1169            }
1170        }
1171    ));
1172}
1173
1174/// Fetch and populate active server version details and stats into the UI.
1175fn load_status(ui: Rc<LibraryWindowUi>) {
1176    glib::MainContext::default().spawn_local(clone!(
1177        #[strong]
1178        ui,
1179        async move {
1180            let stats = ui.ctx.api_client.fetch_server_stats().await.ok();
1181            let about = ui.ctx.api_client.fetch_server_about().await.ok();
1182            let route = ui.ctx.api_client.active_route_label().await;
1183
1184            {
1185                let mut state = ui.ctx.library_state.lock();
1186                state.set_status(stats, about);
1187            }
1188            update_footer(&ui, route);
1189        }
1190    ));
1191}
1192
1193/// Load smart/metadata sections onto the Explore dashboard view.
1194///
1195/// Switches to the Explore stack child immediately so the user sees the
1196/// section headers + per-section spinners while data is in flight. Each of
1197/// the three fetches runs as an independent task, so a slow endpoint never
1198/// blocks the others — first contentful paint happens as soon as the
1199/// fastest section returns.
1200fn load_explore_landing(ui: Rc<LibraryWindowUi>) {
1201    if ui.explore.populated.get() {
1202        log::debug!("Explore: populated=true, reusing cached widgets");
1203        ui.content_stack.set_visible_child_name("explore");
1204        return;
1205    }
1206    ui.explore.populated.set(true);
1207    let ctx = ui.ctx.clone();
1208    explore_view::wire_people_filter(&ui.explore, ctx.clone(), || {});
1209    explore_view::show_loading(&ui.explore);
1210    ui.content_stack.set_visible_child_name("explore");
1211
1212    let mctx = glib::MainContext::default();
1213
1214    mctx.spawn_local(clone!(
1215        #[strong]
1216        ui,
1217        #[strong]
1218        ctx,
1219        async move {
1220            let people_res = ctx.api_client.fetch_people(false).await;
1221            if let Err(e) = &people_res
1222                && (e.contains("HTTP 401") || e.contains("HTTP 403"))
1223            {
1224                show_library_permission_error(&ui.window);
1225            }
1226            let people = people_res.unwrap_or_default();
1227            let click_ui = ui.clone();
1228            explore_view::populate_people(&ui.explore, ctx.clone(), people, move |id, _name| {
1229                let filters = MetadataSearchFilters {
1230                    person_ids: Some(vec![id]),
1231                    ..Default::default()
1232                };
1233                let request = click_ui.ctx.library_state.lock().switch_source(
1234                    LibrarySource::AdvancedSearch {
1235                        filters: Box::new(filters),
1236                    },
1237                );
1238                apply_timeline_ui_state(&click_ui, &request.1);
1239                load_source_page(click_ui.clone(), request, false);
1240            });
1241        }
1242    ));
1243    // Fetch places (slow paginated scan) only if not cached.
1244    if !explore_view::has_cached_places(&ui.explore) {
1245        log::debug!("Explore: places cache empty, fetching from server");
1246        mctx.spawn_local(clone!(
1247            #[strong]
1248            ui,
1249            #[strong]
1250            ctx,
1251            async move {
1252                let places_res = ctx.api_client.fetch_all_places().await;
1253                if let Err(e) = &places_res
1254                    && (e.contains("HTTP 401") || e.contains("HTTP 403"))
1255                {
1256                    show_library_permission_error(&ui.window);
1257                }
1258                let places = places_res.unwrap_or_default();
1259                let click_ui = ui.clone();
1260                explore_view::populate_places(
1261                    &ui.explore,
1262                    ctx.clone(),
1263                    places,
1264                    move |_kind, value, _asset_id| {
1265                        let next = LibrarySource::AdvancedSearch {
1266                            filters: Box::new(MetadataSearchFilters {
1267                                city: Some(value.clone()),
1268                                ..Default::default()
1269                            }),
1270                        };
1271                        let request = click_ui.ctx.library_state.lock().switch_source(next);
1272                        apply_timeline_ui_state(&click_ui, &request.1);
1273                        load_source_page(click_ui.clone(), request, false);
1274                    },
1275                );
1276            }
1277        ));
1278    } else {
1279        log::debug!("Explore: rendering places from cache");
1280        explore_view::render_cached_places(&ui.explore, ctx.clone());
1281    }
1282
1283    mctx.spawn_local(clone!(
1284        #[strong]
1285        ui,
1286        #[strong]
1287        ctx,
1288        async move {
1289            let sections_res = ctx.api_client.fetch_explore().await;
1290            if let Err(e) = &sections_res
1291                && (e.contains("HTTP 401") || e.contains("HTTP 403"))
1292            {
1293                show_library_permission_error(&ui.window);
1294            }
1295            let sections = sections_res.unwrap_or_default();
1296            let click_ui = ui.clone();
1297            explore_view::populate_explore(
1298                &ui.explore,
1299                ctx.clone(),
1300                sections,
1301                move |kind, value, asset_id| {
1302                    if kind == "recent" {
1303                        open_asset_in_lightbox(click_ui.clone(), asset_id);
1304                        return;
1305                    }
1306                    let next = LibrarySource::SmartSearch {
1307                        query: value.clone(),
1308                    };
1309                    let request = click_ui.ctx.library_state.lock().switch_source(next);
1310                    apply_timeline_ui_state(&click_ui, &request.1);
1311                    load_source_page(click_ui.clone(), request, false);
1312                },
1313            );
1314        }
1315    ));
1316}
1317
1318/// Fetch a single asset by ID and open it in lightbox without leaving explore.
1319///
1320/// Temporarily loads the asset into the grid model so the lightbox can display
1321/// it, then restores the previous library state when the lightbox page is popped.
1322fn open_asset_in_lightbox(ui: Rc<LibraryWindowUi>, asset_id: String) {
1323    glib::MainContext::default().spawn_local(clone!(
1324        #[strong]
1325        ui,
1326        async move {
1327            let asset = match ui.ctx.api_client.fetch_asset_by_id(&asset_id).await {
1328                Ok(a) => a,
1329                Err(e) => {
1330                    log::warn!("Failed to fetch asset {} for lightbox: {}", asset_id, e);
1331                    return;
1332                }
1333            };
1334
1335            // Snapshot the current state so we can restore it after lightbox.
1336            let prev_source = ui.ctx.library_state.lock().source.clone();
1337
1338            // Temporarily load just this asset so open_lightbox can read it.
1339            {
1340                let mut state = ui.ctx.library_state.lock();
1341                let generation = state.generation;
1342                state.replace_assets_with_more(generation, vec![asset], false);
1343                ui.grid
1344                    .model
1345                    .reset(&ui.ctx, &state.assets, &state.sort_mode);
1346            }
1347
1348            // Open lightbox at position 0 (the single asset).
1349            open_lightbox(ui.clone(), 0);
1350
1351            // After the lightbox page is popped, restore the explore state.
1352            let restore_ui = ui.clone();
1353            let handler_id = Rc::new(RefCell::new(None::<glib::SignalHandlerId>));
1354            let handler_id_clone = handler_id.clone();
1355            let id = ui.nav.connect_popped(move |nav, _page| {
1356                let request = restore_ui
1357                    .ctx
1358                    .library_state
1359                    .lock()
1360                    .switch_source(prev_source.clone());
1361                load_source_page(restore_ui.clone(), request, false);
1362                // Disconnect this handler so it only fires once.
1363                if let Some(id) = handler_id_clone.borrow_mut().take() {
1364                    nav.disconnect(id);
1365                }
1366            });
1367            *handler_id.borrow_mut() = Some(id);
1368        }
1369    ));
1370}
1371
1372/// Load a paginated subset of assets for the current source.
1373fn load_source_page(ui: Rc<LibraryWindowUi>, request: (u64, LibrarySource, u32), append: bool) {
1374    if matches!(request.1, LibrarySource::Explore) {
1375        load_explore_landing(ui);
1376        return;
1377    }
1378    if !append {
1379        ui.content_stack.set_visible_child_name("loading");
1380    }
1381    log::debug!("Loading library source {:?} page={}", request.1, request.2);
1382    glib::MainContext::default().spawn_local(clone!(
1383        #[strong]
1384        ui,
1385        async move {
1386            let (generation, source, page) = request;
1387            let order = ui.ctx.library_state.lock().sort_mode.server_order();
1388            let result: Result<(Vec<LibraryAsset>, bool), String> = match source.clone() {
1389                LibrarySource::AllAssets | LibrarySource::Timeline => {
1390                    ui.ctx
1391                        .api_client
1392                        .search_metadata("", page, PAGE_SIZE, order)
1393                        .await
1394                }
1395                LibrarySource::Explore => unreachable!("intercepted above"),
1396                LibrarySource::Album { id, .. } => {
1397                    ui.ctx
1398                        .api_client
1399                        .fetch_album_assets(&id, page, PAGE_SIZE, order)
1400                        .await
1401                }
1402                LibrarySource::SmartSearch { query } => {
1403                    ui.ctx
1404                        .api_client
1405                        .search_smart(&query, page, PAGE_SIZE)
1406                        .await
1407                }
1408                LibrarySource::OcrSearch { query } => {
1409                    ui.ctx
1410                        .api_client
1411                        .search_ocr(&query, page, PAGE_SIZE, order)
1412                        .await
1413                }
1414                LibrarySource::MetadataSearch { query } => {
1415                    ui.ctx
1416                        .api_client
1417                        .search_metadata(&query, page, PAGE_SIZE, order)
1418                        .await
1419                }
1420                LibrarySource::AdvancedSearch { filters } => {
1421                    let mut filters = (*filters).clone();
1422                    filters.order = order;
1423                    ui.ctx
1424                        .api_client
1425                        .search_metadata_with_filters(&filters, page, PAGE_SIZE)
1426                        .await
1427                }
1428                LibrarySource::LocalAll => {
1429                    // Local enumeration is bounded — single synthetic page.
1430                    if page > 1 {
1431                        Ok((Vec::new(), false))
1432                    } else {
1433                        let locals = enumerate_local(ui.ctx.clone()).await;
1434                        Ok((
1435                            locals.into_iter().map(local_to_library_asset).collect(),
1436                            false,
1437                        ))
1438                    }
1439                }
1440                LibrarySource::LocalSearch { query } => {
1441                    if page > 1 {
1442                        Ok((Vec::new(), false))
1443                    } else {
1444                        let locals = enumerate_local(ui.ctx.clone()).await;
1445                        let filtered = filter_by_filename(locals, &query);
1446                        Ok((
1447                            filtered.into_iter().map(local_to_library_asset).collect(),
1448                            false,
1449                        ))
1450                    }
1451                }
1452                LibrarySource::Unified => {
1453                    let remote = ui
1454                        .ctx
1455                        .api_client
1456                        .search_metadata("", page, PAGE_SIZE, order)
1457                        .await;
1458                    merge_unified_page(remote, page, &ui, None).await
1459                }
1460                LibrarySource::UnifiedSearch { query } => {
1461                    let remote = ui
1462                        .ctx
1463                        .api_client
1464                        .search_metadata(&query, page, PAGE_SIZE, order)
1465                        .await;
1466                    merge_unified_page(remote, page, &ui, Some(&query)).await
1467                }
1468                LibrarySource::AlbumLocal { name, .. } => {
1469                    if page > 1 {
1470                        Ok((Vec::new(), false))
1471                    } else {
1472                        match linked_entry_path_for_album(&ui, &name) {
1473                            Some(path) => {
1474                                let locals = enumerate_local_for_entry(ui.ctx.clone(), path).await;
1475                                Ok((
1476                                    locals.into_iter().map(local_to_library_asset).collect(),
1477                                    false,
1478                                ))
1479                            }
1480                            None => Ok((Vec::new(), false)),
1481                        }
1482                    }
1483                }
1484                LibrarySource::AlbumUnified { id, name } => {
1485                    let remote = ui
1486                        .ctx
1487                        .api_client
1488                        .fetch_album_assets(&id, page, PAGE_SIZE, order)
1489                        .await;
1490                    merge_album_unified_page(remote, page, &ui, &name).await
1491                }
1492            };
1493
1494            match result {
1495                Ok((items, has_more)) => {
1496                    {
1497                        let mut state = ui.ctx.library_state.lock();
1498                        let applied = if append {
1499                            state.append_assets_with_more(generation, items, has_more)
1500                        } else {
1501                            state.replace_assets_with_more(generation, items, has_more)
1502                        };
1503                        if !applied {
1504                            return;
1505                        }
1506                        if append {
1507                            ui.grid
1508                                .model
1509                                .extend(&ui.ctx, &state.assets, &state.sort_mode);
1510                        } else {
1511                            ui.grid
1512                                .model
1513                                .reset(&ui.ctx, &state.assets, &state.sort_mode);
1514                        }
1515                    }
1516                    // Lock is released before touching GTK widgets so that
1517                    // signal handlers triggered by the stack transition
1518                    // can safely re-acquire library_state.
1519                    sync_content_state(&ui);
1520                    reload_sidebar(&ui);
1521                    update_timeline_banner_if_active(&ui, &ui.grid.scrolled.vadjustment());
1522                }
1523                Err(err) => {
1524                    {
1525                        let mut state = ui.ctx.library_state.lock();
1526                        state.mark_error(generation, err.clone());
1527                    }
1528                    // Lock dropped before GTK calls (same pattern as Ok path).
1529                    ui.error_label
1530                        .set_label(&format!("Could not load library assets: {}", err));
1531                    ui.content_stack.set_visible_child_name("error");
1532                }
1533            }
1534        }
1535    ));
1536}
1537
1538fn setup_album_drop_target(
1539    ui: &Rc<LibraryWindowUi>,
1540    row: &gtk::ListBoxRow,
1541    album_id: String,
1542    album_name: String,
1543) {
1544    let row_drop = gtk::DropTarget::new(
1545        gtk::gdk::FileList::static_type(),
1546        gtk::gdk::DragAction::COPY,
1547    );
1548    let row_ref_enter = row.clone();
1549    row_drop.connect_enter(move |target, _x, _y| {
1550        if target.current_drop().is_some_and(|d| d.drag().is_some()) {
1551            return gtk::gdk::DragAction::empty();
1552        }
1553        row_ref_enter.add_css_class("mimick-album-drop-hover");
1554        gtk::gdk::DragAction::COPY
1555    });
1556    let row_ref_leave = row.clone();
1557    row_drop.connect_leave(move |_target| {
1558        row_ref_leave.remove_css_class("mimick-album-drop-hover");
1559    });
1560    let row_ref_drop = row.clone();
1561    let ui_drop = ui.clone();
1562    row_drop.connect_drop(move |target, value, _x, _y| {
1563        row_ref_drop.remove_css_class("mimick-album-drop-hover");
1564
1565        if target.current_drop().is_some_and(|d| d.drag().is_some()) {
1566            return false;
1567        }
1568        let file_list = match value.get::<gtk::gdk::FileList>() {
1569            Ok(fl) => fl,
1570            Err(_) => return false,
1571        };
1572        let paths: Vec<std::path::PathBuf> = file_list
1573            .files()
1574            .iter()
1575            .filter_map(|f| f.path())
1576            .filter(|p| crate::media_kinds::is_supported_path(p))
1577            .collect();
1578        if paths.is_empty() {
1579            show_unsupported_drop_toast(&ui_drop);
1580            return true;
1581        }
1582        handle_album_drop_upload(&ui_drop, (album_id.clone(), album_name.clone()), paths);
1583        true
1584    });
1585    row.add_controller(row_drop);
1586}
1587
1588fn build_album_sidebar_row(
1589    ui: &Rc<LibraryWindowUi>,
1590    album: &crate::api_client::LibraryAlbum,
1591) -> gtk::ListBoxRow {
1592    let subtitle = format!("{} asset(s)", album.asset_count);
1593    let action = libadwaita::ActionRow::builder()
1594        .title(&album.album_name)
1595        .subtitle(&subtitle)
1596        .title_lines(1)
1597        .subtitle_lines(1)
1598        .build();
1599    let row = gtk::ListBoxRow::builder()
1600        .tooltip_text(format!("{}:{}", album.id, album.album_name))
1601        .child(&action)
1602        .build();
1603
1604    // Per-row drop target: dragging files onto an album row uploads
1605    // them directly into that album.
1606    setup_album_drop_target(ui, &row, album.id.clone(), album.album_name.clone());
1607
1608    row
1609}
1610
1611/// Repopulate and select the active album or fixed row in the side navigation sidebar.
1612fn reload_sidebar(ui: &Rc<LibraryWindowUi>) {
1613    while let Some(row) = ui.sidebar.albums_list.first_child() {
1614        ui.sidebar.albums_list.remove(&row);
1615    }
1616
1617    let selected_source = ui.ctx.library_state.lock().source.clone();
1618    let albums = ui.ctx.library_state.lock().albums.clone();
1619    for album in albums {
1620        let row = build_album_sidebar_row(ui, &album);
1621        ui.sidebar.albums_list.append(&row);
1622    }
1623
1624    match selected_source {
1625        LibrarySource::Timeline => {
1626            select_fixed_row(&ui.sidebar.fixed_list, "photos");
1627            ui.sidebar.albums_list.unselect_all();
1628        }
1629        LibrarySource::Explore => {
1630            select_fixed_row(&ui.sidebar.fixed_list, "explore");
1631            ui.sidebar.albums_list.unselect_all();
1632        }
1633        LibrarySource::Album { id, .. }
1634        | LibrarySource::AlbumLocal { id, .. }
1635        | LibrarySource::AlbumUnified { id, .. } => {
1636            ui.sidebar.fixed_list.unselect_all();
1637            ui.sidebar_suppressed.set(true);
1638            let mut child = ui.sidebar.albums_list.first_child();
1639            while let Some(widget) = child {
1640                let next = widget.next_sibling();
1641                if let Ok(row) = widget.downcast::<gtk::ListBoxRow>()
1642                    && row.tooltip_text().as_deref().is_some_and(|tooltip| {
1643                        tooltip.split_once(':').map(|(prefix, _)| prefix) == Some(id.as_str())
1644                    })
1645                {
1646                    ui.sidebar.albums_list.select_row(Some(&row));
1647                    ui.sidebar_suppressed.set(false);
1648                    break;
1649                }
1650                child = next;
1651            }
1652            ui.sidebar_suppressed.set(false);
1653        }
1654        _ => {
1655            ui.sidebar.fixed_list.unselect_all();
1656            ui.sidebar.albums_list.unselect_all();
1657        }
1658    }
1659}
1660
1661/// Helper to select a sidebar row matching a specific string key.
1662fn select_fixed_row(list: &gtk::ListBox, key: &str) {
1663    let mut child = list.first_child();
1664    while let Some(widget) = child {
1665        let next = widget.next_sibling();
1666        if let Ok(row) = widget.downcast::<gtk::ListBoxRow>()
1667            && row.tooltip_text().as_deref() == Some(key)
1668        {
1669            list.select_row(Some(&row));
1670            return;
1671        }
1672        child = next;
1673    }
1674}
1675
1676/// Sync the visibility of the grid, loading, empty, and error page widgets.
1677///
1678/// The lock on `library_state` is released **before** calling
1679/// `set_visible_child_name` because that GTK call triggers widget
1680/// realization, factory binds, and signal handlers that may need to
1681/// re-acquire the same lock.  Holding it across the call caused a
1682/// parking_lot deadlock on first library open.
1683fn sync_content_state(ui: &LibraryWindowUi) {
1684    let (child_name, error_msg) = {
1685        let state = ui.ctx.library_state.lock();
1686        match &state.load_state {
1687            LibraryLoadState::Idle | LibraryLoadState::Loading => ("loading", None),
1688            LibraryLoadState::Loaded => ("grid", None),
1689            LibraryLoadState::Empty => ("empty", None),
1690            LibraryLoadState::Error(msg) => ("error", Some(msg.clone())),
1691        }
1692    };
1693    if let Some(msg) = error_msg {
1694        ui.error_label.set_label(&msg);
1695    }
1696    ui.content_stack.set_visible_child_name(child_name);
1697}
1698
1699/// Update the status sidebar rows with the current server route and statistics.
1700fn update_footer(ui: &LibraryWindowUi, route: Option<String>) {
1701    let (stats_text, about_text) = {
1702        let state = ui.ctx.library_state.lock();
1703        let stats = state
1704            .status
1705            .stats
1706            .as_ref()
1707            .map(|stats| format!("{} photos, {} videos", stats.images, stats.videos))
1708            .unwrap_or_else(|| "Statistics unavailable".to_string());
1709        let about = state
1710            .status
1711            .about
1712            .as_ref()
1713            .map(|about| format!("Immich {}", about.version))
1714            .unwrap_or_else(|| "Version unavailable".to_string());
1715        (stats, about)
1716    };
1717    let route_subtitle = route
1718        .as_deref()
1719        .map(|route| match route {
1720            "LAN" => "Connected through LAN",
1721            "WAN" => "Connected through WAN",
1722            _ => "Connected through configured server",
1723        })
1724        .unwrap_or("Offline");
1725    ui.sidebar.connection_row.set_subtitle(route_subtitle);
1726    ui.sidebar
1727        .server_row
1728        .set_subtitle(&format!("{stats_text} | {about_text}"));
1729}
1730
1731/// Synchronize the ongoing upload/download progress indicator bar and rate information text.
1732fn update_transfer_ui(ui: &LibraryWindowUi) {
1733    let transfer = {
1734        let mut state = ui.ctx.state.lock();
1735        if state.transfer.active
1736            && state.transfer.active_uploads == 0
1737            && state.transfer.active_downloads == 0
1738        {
1739            // Guard against sessions that were opened but never queued.
1740            state.transfer.reset_runtime();
1741        }
1742        state.transfer.clone()
1743    };
1744    if !transfer.active {
1745        ui.transfer_bar.remove_css_class("active");
1746        ui.transfer_progress.set_fraction(0.0);
1747        ui.transfer_icon.set_visible(false);
1748        let idle_summary =
1749            if transfer.last_upload_avg_bps > 0.0 || transfer.last_download_avg_bps > 0.0 {
1750                format!(
1751                    "Idle  Last upload avg {}  Last download avg {}",
1752                    format_rate(transfer.last_upload_avg_bps),
1753                    format_rate(transfer.last_download_avg_bps)
1754                )
1755            } else {
1756                "Idle  No recent transfer session".to_string()
1757            };
1758        ui.transfer_label.set_label(&idle_summary);
1759        return;
1760    }
1761    ui.transfer_bar.add_css_class("active");
1762
1763    let icon_name = match transfer.direction {
1764        TransferDirection::Upload => "mimick-upload-symbolic",
1765        TransferDirection::Download => "mimick-download-symbolic",
1766    };
1767    ui.transfer_icon.set_icon_name(Some(icon_name));
1768    ui.transfer_icon.set_visible(true);
1769
1770    let detail = transfer
1771        .active_item_label
1772        .as_deref()
1773        .unwrap_or(match transfer.direction {
1774            TransferDirection::Upload => "queued asset",
1775            TransferDirection::Download => "selected asset",
1776        });
1777    let live_speed = format_rate(transfer.instant_bps);
1778    let avg_speed = format_rate(transfer.session_avg_bps);
1779    ui.transfer_label
1780        .set_label(&format!("{detail}  {live_speed}  avg {avg_speed}"));
1781
1782    match transfer.total_bytes {
1783        Some(total) if total > 0 => {
1784            ui.transfer_progress.set_show_text(false);
1785            ui.transfer_progress
1786                .set_fraction((transfer.current_bytes as f64 / total as f64).clamp(0.0, 1.0));
1787        }
1788        _ => {
1789            ui.transfer_progress.pulse();
1790        }
1791    }
1792}
1793
1794/// Construct a styled placeholder view containing an icon, header title, and description text.
1795/// Centered Mimick-icon spinner used while library data is fetching. Shares
1796/// the `mimick-loader-icon` animation with the lightbox image-load spinner.
1797fn build_loading_view() -> gtk::Box {
1798    let container = gtk::Box::builder()
1799        .orientation(gtk::Orientation::Vertical)
1800        .spacing(12)
1801        .vexpand(true)
1802        .hexpand(true)
1803        .valign(gtk::Align::Center)
1804        .halign(gtk::Align::Center)
1805        .css_classes(vec!["mimick-empty".to_string()])
1806        .build();
1807    let icon = gtk::Image::builder()
1808        .icon_name("dev.nicx.mimick")
1809        .pixel_size(72)
1810        .css_classes(["mimick-loader-icon"])
1811        .build();
1812    let title = gtk::Label::builder()
1813        .label("Loading…")
1814        .css_classes(vec!["mimick-empty-title".to_string()])
1815        .build();
1816    let subtitle = gtk::Label::builder()
1817        .label("Fetching library data from the Immich server")
1818        .wrap(true)
1819        .wrap_mode(gtk::pango::WrapMode::WordChar)
1820        .justify(gtk::Justification::Center)
1821        .max_width_chars(28)
1822        .css_classes(vec!["mimick-empty-subtitle".to_string()])
1823        .build();
1824    container.append(&icon);
1825    container.append(&title);
1826    container.append(&subtitle);
1827    container
1828}
1829
1830fn build_status_view(icon_name: &str, title: &str, subtitle: &str) -> gtk::Box {
1831    let container = gtk::Box::builder()
1832        .orientation(gtk::Orientation::Vertical)
1833        .spacing(12)
1834        .vexpand(true)
1835        .hexpand(true)
1836        .valign(gtk::Align::Center)
1837        .halign(gtk::Align::Center)
1838        .css_classes(vec!["mimick-empty".to_string()])
1839        .build();
1840    let icon = gtk::Image::builder()
1841        .icon_name(icon_name)
1842        .pixel_size(64)
1843        .build();
1844    icon.add_css_class("dim-label");
1845    let title_label = gtk::Label::builder()
1846        .label(title)
1847        .wrap(true)
1848        .wrap_mode(gtk::pango::WrapMode::WordChar)
1849        .justify(gtk::Justification::Center)
1850        .max_width_chars(28)
1851        .css_classes(vec!["mimick-empty-title".to_string()])
1852        .build();
1853    let subtitle_label = gtk::Label::builder()
1854        .label(subtitle)
1855        .wrap(true)
1856        .wrap_mode(gtk::pango::WrapMode::WordChar)
1857        .justify(gtk::Justification::Center)
1858        .max_width_chars(28)
1859        .css_classes(vec!["mimick-empty-subtitle".to_string()])
1860        .build();
1861    container.append(&icon);
1862    container.append(&title_label);
1863    container.append(&subtitle_label);
1864    container
1865}
1866
1867/// Convert a Base64-encoded Immich checksum string to its standard hexadecimal representation.
1868pub(super) fn immich_checksum_to_hex(b64: &str) -> Option<String> {
1869    use base64::Engine as _;
1870    let bytes = base64::engine::general_purpose::STANDARD.decode(b64).ok()?;
1871    Some(bytes.iter().map(|b| format!("{:02x}", b)).collect())
1872}
1873
1874/// Build a synthetic `LibraryAsset` from a physical local folder file.
1875fn local_to_library_asset(local: LocalAsset) -> LibraryAsset {
1876    LibraryAsset {
1877        id: format!("{}{}", LOCAL_ID_PREFIX, local.path.display()),
1878        filename: local.filename,
1879        mime_type: local.mime,
1880        created_at: local.created_at,
1881        asset_type: local.asset_type.to_string(),
1882        thumbhash: None,
1883        width: None,
1884        height: None,
1885        checksum: None,
1886        exif_info: None,
1887    }
1888}
1889
1890/// Merge a page of remote API assets with local files that haven't been synchronized yet.
1891async fn merge_unified_page(
1892    remote: Result<(Vec<LibraryAsset>, bool), String>,
1893    page: u32,
1894    ui: &Rc<LibraryWindowUi>,
1895    query: Option<&str>,
1896) -> Result<(Vec<LibraryAsset>, bool), String> {
1897    let (mut remote, has_more) = remote?;
1898    if page > 1 {
1899        return Ok((remote, has_more));
1900    }
1901
1902    let mut locals = enumerate_local(ui.ctx.clone()).await;
1903    if let Some(q) = query {
1904        locals = filter_by_filename(locals, q);
1905    }
1906
1907    let synced_paths: std::collections::HashSet<String> = {
1908        remote
1909            .iter()
1910            .filter_map(|a| a.checksum.as_deref())
1911            .filter_map(immich_checksum_to_hex)
1912            .filter_map(|hex| ui.ctx.sync_index.local_path_for_checksum(&hex))
1913            .collect()
1914    };
1915
1916    let mut local_rows: Vec<LibraryAsset> = locals
1917        .into_iter()
1918        .filter(|l| !synced_paths.contains(&l.path.display().to_string()))
1919        .map(local_to_library_asset)
1920        .collect();
1921
1922    local_rows.append(&mut remote);
1923    Ok((local_rows, has_more))
1924}
1925
1926/// Return the path of the watch entry linked to `album_name`, if any.
1927/// Find the local directory watch path mapped to the specified album.
1928fn linked_entry_path_for_album(ui: &Rc<LibraryWindowUi>, album_name: &str) -> Option<String> {
1929    let entries = ui.ctx.live_watch_paths.lock().clone();
1930    crate::config::watch_entry_for_album(album_name, &entries).map(|e| e.path().to_string())
1931}
1932
1933/// Album-scoped variant of `merge_unified_page`: takes the album's asset
1934/// page from the remote API and overlays sync state from the album's
1935/// linked local folder only — never from siblings.
1936/// Merge a page of remote album assets with local folder files linked to the album.
1937async fn merge_album_unified_page(
1938    remote: Result<(Vec<LibraryAsset>, bool), String>,
1939    page: u32,
1940    ui: &Rc<LibraryWindowUi>,
1941    album_name: &str,
1942) -> Result<(Vec<LibraryAsset>, bool), String> {
1943    let (mut remote, has_more) = remote?;
1944    if page > 1 {
1945        return Ok((remote, has_more));
1946    }
1947
1948    let locals = match linked_entry_path_for_album(ui, album_name) {
1949        Some(path) => enumerate_local_for_entry(ui.ctx.clone(), path).await,
1950        None => Vec::new(),
1951    };
1952
1953    let synced_paths: std::collections::HashSet<String> = {
1954        remote
1955            .iter()
1956            .filter_map(|a| a.checksum.as_deref())
1957            .filter_map(immich_checksum_to_hex)
1958            .filter_map(|hex| ui.ctx.sync_index.local_path_for_checksum(&hex))
1959            .collect()
1960    };
1961
1962    let mut local_rows: Vec<LibraryAsset> = locals
1963        .into_iter()
1964        .filter(|l| !synced_paths.contains(&l.path.display().to_string()))
1965        .map(local_to_library_asset)
1966        .collect();
1967
1968    local_rows.append(&mut remote);
1969    Ok((local_rows, has_more))
1970}
1971
1972/// Helper to show a permissions error dialog for library views.
1973fn show_library_permission_error(window: &libadwaita::ApplicationWindow) {
1974    let dialog = libadwaita::AlertDialog::builder()
1975        .heading("Missing API Permissions")
1976        .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.")
1977        .build();
1978    dialog.add_response("close", "Close");
1979    dialog.present(Some(window));
1980}