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, 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
95/// Register application custom icons with the Gtk icon theme system.
96pub(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
103/// Load a local texture, preferring decoders that cover formats outside the
104/// GDK-Pixbuf runtime loader set before falling back to pixbuf and image-rs.
105pub(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
113/// The complete UI widgets state wrapper for the library window interface.
114struct 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
157/// Build and display the main library window application layout.
158pub 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
202/// Wire the grid right-click menu and the album tile sharing actions.
203fn 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
239/// Bootstrap initial data loading and background tasks for the library window.
240fn 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
259/// Build a drop overlay widget and wrap `content` in a `gtk::Overlay`.
260///
261/// Returns the overlay wrapper (to be used as the content widget) and the
262/// revealer handle for toggling visibility from the `DropTarget` handlers.
263fn 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    // Inner box centers the icon+label; outer box provides the full-window tint.
274    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
311/// Wire the lightweight filter entries embedded in Albums and Explore views.
312fn connect_lightweight_filters(ui: Rc<LibraryWindowUi>) {
313    // Albums: live-filter tiles as the user types.
314    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    // Explore: live-filter people tiles as the user types.
324    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
334/// Wire the dedicated search view's interactive handlers.
335fn connect_search_view(ui: Rc<LibraryWindowUi>) {
336    // Clear button resets all fields.
337    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    // Search button (or Enter in the entry) dispatches the search.
346    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
373/// Read the search view widgets and dispatch the appropriate search source.
374fn 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); // 0=Smart, 1=Filename, 2=OCR
379
380    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
388/// Decide which `LibrarySource` variant to use based on mode, query, and filters.
389fn 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        // Smart Search
401        0 => {
402            if query.is_empty() {
403                // Filters only -> metadata search
404                Some(LibrarySource::AdvancedSearch {
405                    filters: Box::new(filters),
406                })
407            } else {
408                Some(LibrarySource::SmartSearch {
409                    query: query.to_string(),
410                })
411            }
412        }
413        // Filename
414        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        // OCR
424        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
437/// Attach a `DropTarget` to the library window for drag-and-drop file uploads.
438fn 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        // Reject internal drags (from our own DragSource).
447        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        // Reject internal drags.
464        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: &gtk::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 we are online but missing stats (e.g. from an initial network failure), re-fetch them.
574            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
595/// Start a quick periodic polling loop updating the transfer progress bar UI.
596fn 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
608/// Fetch current logged-in API user ID and store it in app context.
609fn 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
615/// The API key's user id, fetched and cached on first use.
616async 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
632/// Connect action handlers to the album creation widgets.
633fn 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
641/// Show a popup modal dialog prompting the user to name and create a new album.
642fn 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
682/// Asynchronously fetch the latest albums from the server and redraw the album views.
683///
684/// Late-arriving fetches must not hijack the stack: if the user navigated to
685/// another tab while we were waiting on the network, we still bind the data
686/// (so the grid is current when they come back) but we do *not* force the
687/// stack child to "albums" or "error".
688fn refresh_albums_view(ui: Rc<LibraryWindowUi>) {
689    glib::MainContext::default().spawn_local(async move {
690        // Albums are split into yours and shared-with-you by owner, so the id must be known.
691        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
731/// Apply header control layout adjustments when switching view modes.
732/// Refresh the search form's Tag dropdown from the server, then select `select` if given.
733fn 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            // Tags are optional (`tag.read`); the dropdown just stays at "Any".
743            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    // Source-mode (Remote/Local/Unified) only relevant inside an album.
793    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    // Keep source dropdown visually consistent with the active source so
804    // sidebar selections don't leave it showing the wrong tab.
805    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
821/// Compute and refresh the month-year overlay text dynamically while scrubbing the timeline list.
822fn update_timeline_banner_if_active(ui: &Rc<LibraryWindowUi>, adj: &gtk::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
840/// Convert an ISO 8601 string to a human-readable "Month Year" label.
841fn 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
866/// Retrieve albums from the API and update the side navigation sidebar entries.
867fn 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                    // Sidebar-list refresh: never hijack the user's current
879                    // tab with an error page just because the albums list
880                    // couldn't reload. Only surface the error view when the
881                    // user is actively waiting on something.
882                    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
894/// Fetch and populate active server version details and stats into the UI.
895fn 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
921/// Load smart/metadata sections onto the Explore dashboard view.
922///
923/// Switches to the Explore stack child immediately so the user sees the
924/// section headers + per-section spinners while data is in flight. Each of
925/// the three fetches runs as an independent task, so a slow endpoint never
926/// blocks the others — first contentful paint happens as soon as the
927/// fastest section returns.
928fn 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    // Fetch places (slow paginated scan) only if not cached.
944    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
954/// The fetched value, or its default after telling the user if the API key lacks permission.
955fn 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
1013/// Fetch a single asset by ID and open it in lightbox without leaving explore.
1014///
1015/// Temporarily loads the asset into the grid model so the lightbox can display
1016/// it, then restores the previous library state when the lightbox page is popped.
1017fn 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            // Snapshot the current state so we can restore it after lightbox.
1031            let prev_source = ui.ctx.library_state.lock().source.clone();
1032
1033            // Temporarily load just this asset so open_lightbox can read it.
1034            {
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 at position 0 (the single asset).
1044            open_lightbox(ui.clone(), 0);
1045
1046            // After the lightbox page is popped, restore the explore state.
1047            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                // Disconnect this handler so it only fires once.
1058                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
1067/// Load a paginated subset of assets for the current source.
1068fn 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
1090/// One page of assets plus whether more pages follow.
1091type 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
1180/// Local enumeration is bounded, so local sources return everything as one page.
1181async 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
1206/// Remote assets overlayed with local sync state.
1207async 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    // Lock is released before touching GTK widgets so that signal handlers
1275    // triggered by the stack transition can safely re-acquire library_state.
1276    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: &gtk::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    // Per-row drop target: dragging files onto an album row uploads
1346    // them directly into that album.
1347    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
1378/// Sync the visibility of the grid, loading, empty, and error page widgets.
1379///
1380/// The lock on `library_state` is released **before** calling
1381/// `set_visible_child_name` because that GTK call triggers widget
1382/// realization, factory binds, and signal handlers that may need to
1383/// re-acquire the same lock.  Holding it across the call caused a
1384/// parking_lot deadlock on first library open.
1385fn 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
1412/// Update the status sidebar rows with the current server route and statistics.
1413fn 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
1444/// Synchronize the ongoing upload/download progress indicator bar and rate information text.
1445fn 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            // Guard against sessions that were opened but never queued.
1453            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
1483/// Construct a styled placeholder view containing an icon, header title, and description text.
1484/// Centered Mimick-icon spinner used while library data is fetching. Shares
1485/// the `mimick-loader-icon` animation with the lightbox image-load spinner.
1486fn 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
1556/// Convert a Base64-encoded Immich checksum string to its standard hexadecimal representation.
1557pub(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
1563/// Build a synthetic `LibraryAsset` from a physical local folder file.
1564fn 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
1581/// Merge a page of remote API assets with local files that haven't been synchronized yet.
1582async 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
1617/// Return the path of the watch entry linked to `album_name`, if any.
1618/// Find the local directory watch path mapped to the specified album.
1619fn 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
1624/// Album-scoped variant of `merge_unified_page`: takes the album's asset
1625/// page from the remote API and overlays sync state from the album's
1626/// linked local folder only — never from siblings.
1627/// Merge a page of remote album assets with local folder files linked to the album.
1628async 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
1663/// Helper to show a permissions error dialog for library views.
1664fn 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}