1use std::cell::{Cell, RefCell};
9use std::rc::Rc;
10use std::sync::Arc;
11
12use glib::clone;
13use gtk::prelude::*;
14use libadwaita::prelude::*;
15
16use crate::api_client::{LibraryAsset, MetadataSearchFilters};
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
74pub(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
82pub(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
92struct 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
129pub 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 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 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(>k::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 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(>k::ShortcutTrigger::parse_string("F9").unwrap())
478 .action(>k::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
557fn 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
574fn 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 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
626fn connect_lightweight_filters(ui: Rc<LibraryWindowUi>) {
628 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 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
649fn connect_search_view(ui: Rc<LibraryWindowUi>) {
651 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 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
688fn 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(); 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 ui.content_stack.set_visible_child_name("loading");
705 load_source_page(ui.clone(), request, false);
706}
707
708fn 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 0 => {
722 if query.is_empty() {
723 Some(LibrarySource::AdvancedSearch {
725 filters: Box::new(filters),
726 })
727 } else {
728 Some(LibrarySource::SmartSearch {
729 query: query.to_string(),
730 })
731 }
732 }
733 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 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
757fn 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 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 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: >k::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 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
915fn 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
928fn 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
943fn 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
952fn 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
993fn 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
1026fn 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
1039fn 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 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 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
1101fn update_timeline_banner_if_active(ui: &Rc<LibraryWindowUi>, adj: >k::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
1120fn 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
1146fn 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 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
1174fn 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
1193fn 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 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) = §ions_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
1318fn 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 let prev_source = ui.ctx.library_state.lock().source.clone();
1337
1338 {
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(ui.clone(), 0);
1350
1351 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 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
1372fn 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 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 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 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: >k::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 setup_album_drop_target(ui, &row, album.id.clone(), album.album_name.clone());
1607
1608 row
1609}
1610
1611fn 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
1661fn select_fixed_row(list: >k::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
1676fn 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
1699fn 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
1731fn 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 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
1794fn 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
1867pub(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
1874fn 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
1890async 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
1926fn 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
1933async 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
1972fn 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}