A tool to sync music with your favorite devices
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

*: mfw gtk4/libadwaita ui??

Gee Sawra 645057be 5c69bf02

+1014
+294
Cargo.lock
··· 386 386 checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" 387 387 388 388 [[package]] 389 + name = "cairo-rs" 390 + version = "0.21.5" 391 + source = "registry+https://github.com/rust-lang/crates.io-index" 392 + checksum = "b01fe135c0bd16afe262b6dea349bd5ea30e6de50708cec639aae7c5c14cc7e4" 393 + dependencies = [ 394 + "bitflags 2.10.0", 395 + "cairo-sys-rs", 396 + "glib", 397 + "libc", 398 + ] 399 + 400 + [[package]] 401 + name = "cairo-sys-rs" 402 + version = "0.21.5" 403 + source = "registry+https://github.com/rust-lang/crates.io-index" 404 + checksum = "06c28280c6b12055b5e39e4554271ae4e6630b27c0da9148c4cf6485fc6d245c" 405 + dependencies = [ 406 + "glib-sys", 407 + "libc", 408 + "system-deps", 409 + ] 410 + 411 + [[package]] 389 412 name = "cc" 390 413 version = "1.2.52" 391 414 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 785 808 checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" 786 809 787 810 [[package]] 811 + name = "field-offset" 812 + version = "0.3.6" 813 + source = "registry+https://github.com/rust-lang/crates.io-index" 814 + checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" 815 + dependencies = [ 816 + "memoffset", 817 + "rustc_version", 818 + ] 819 + 820 + [[package]] 788 821 name = "find-msvc-tools" 789 822 version = "0.1.7" 790 823 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 967 1000 ] 968 1001 969 1002 [[package]] 1003 + name = "gdk-pixbuf" 1004 + version = "0.21.5" 1005 + source = "registry+https://github.com/rust-lang/crates.io-index" 1006 + checksum = "debb0d39e3cdd84626edfd54d6e4a6ba2da9a0ef2e796e691c4e9f8646fda00c" 1007 + dependencies = [ 1008 + "gdk-pixbuf-sys", 1009 + "gio", 1010 + "glib", 1011 + "libc", 1012 + ] 1013 + 1014 + [[package]] 1015 + name = "gdk-pixbuf-sys" 1016 + version = "0.21.5" 1017 + source = "registry+https://github.com/rust-lang/crates.io-index" 1018 + checksum = "bd95ad50b9a3d2551e25dd4f6892aff0b772fe5372d84514e9d0583af60a0ce7" 1019 + dependencies = [ 1020 + "gio-sys", 1021 + "glib-sys", 1022 + "gobject-sys", 1023 + "libc", 1024 + "system-deps", 1025 + ] 1026 + 1027 + [[package]] 1028 + name = "gdk4" 1029 + version = "0.10.3" 1030 + source = "registry+https://github.com/rust-lang/crates.io-index" 1031 + checksum = "756564212bbe4a4ce05d88ffbd2582581ac6003832d0d32822d0825cca84bfbf" 1032 + dependencies = [ 1033 + "cairo-rs", 1034 + "gdk-pixbuf", 1035 + "gdk4-sys", 1036 + "gio", 1037 + "glib", 1038 + "libc", 1039 + "pango", 1040 + ] 1041 + 1042 + [[package]] 1043 + name = "gdk4-sys" 1044 + version = "0.10.3" 1045 + source = "registry+https://github.com/rust-lang/crates.io-index" 1046 + checksum = "a6d4e5b3ccf591826a4adcc83f5f57b4e59d1925cb4bf620b0d645f79498b034" 1047 + dependencies = [ 1048 + "cairo-sys-rs", 1049 + "gdk-pixbuf-sys", 1050 + "gio-sys", 1051 + "glib-sys", 1052 + "gobject-sys", 1053 + "libc", 1054 + "pango-sys", 1055 + "pkg-config", 1056 + "system-deps", 1057 + ] 1058 + 1059 + [[package]] 970 1060 name = "generic-array" 971 1061 version = "0.14.7" 972 1062 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 997 1087 "libc", 998 1088 "r-efi", 999 1089 "wasip2", 1090 + ] 1091 + 1092 + [[package]] 1093 + name = "gio" 1094 + version = "0.21.5" 1095 + source = "registry+https://github.com/rust-lang/crates.io-index" 1096 + checksum = "c5ff48bf600c68b476e61dc6b7c762f2f4eb91deef66583ba8bb815c30b5811a" 1097 + dependencies = [ 1098 + "futures-channel", 1099 + "futures-core", 1100 + "futures-io", 1101 + "futures-util", 1102 + "gio-sys", 1103 + "glib", 1104 + "libc", 1105 + "pin-project-lite", 1106 + "smallvec", 1000 1107 ] 1001 1108 1002 1109 [[package]] ··· 1086 1193 ] 1087 1194 1088 1195 [[package]] 1196 + name = "graphene-rs" 1197 + version = "0.21.5" 1198 + source = "registry+https://github.com/rust-lang/crates.io-index" 1199 + checksum = "2730030ac9db663fd8bfe1e7093742c1cafb92db9c315c9417c29032341fe2f9" 1200 + dependencies = [ 1201 + "glib", 1202 + "graphene-sys", 1203 + "libc", 1204 + ] 1205 + 1206 + [[package]] 1207 + name = "graphene-sys" 1208 + version = "0.21.5" 1209 + source = "registry+https://github.com/rust-lang/crates.io-index" 1210 + checksum = "915e32091ea9ad241e4b044af62b7351c2d68aeb24f489a0d7f37a0fc484fd93" 1211 + dependencies = [ 1212 + "glib-sys", 1213 + "libc", 1214 + "pkg-config", 1215 + "system-deps", 1216 + ] 1217 + 1218 + [[package]] 1219 + name = "gsk4" 1220 + version = "0.10.3" 1221 + source = "registry+https://github.com/rust-lang/crates.io-index" 1222 + checksum = "e755de9d8c5896c5beaa028b89e1969d067f1b9bf1511384ede971f5983aa153" 1223 + dependencies = [ 1224 + "cairo-rs", 1225 + "gdk4", 1226 + "glib", 1227 + "graphene-rs", 1228 + "gsk4-sys", 1229 + "libc", 1230 + "pango", 1231 + ] 1232 + 1233 + [[package]] 1234 + name = "gsk4-sys" 1235 + version = "0.10.3" 1236 + source = "registry+https://github.com/rust-lang/crates.io-index" 1237 + checksum = "7ce91472391146f482065f1041876d8f869057b195b95399414caa163d72f4f7" 1238 + dependencies = [ 1239 + "cairo-sys-rs", 1240 + "gdk4-sys", 1241 + "glib-sys", 1242 + "gobject-sys", 1243 + "graphene-sys", 1244 + "libc", 1245 + "pango-sys", 1246 + "system-deps", 1247 + ] 1248 + 1249 + [[package]] 1250 + name = "gtk4" 1251 + version = "0.10.3" 1252 + source = "registry+https://github.com/rust-lang/crates.io-index" 1253 + checksum = "acb21d53cfc6f7bfaf43549731c43b67ca47d87348d81c8cfc4dcdd44828e1a4" 1254 + dependencies = [ 1255 + "cairo-rs", 1256 + "field-offset", 1257 + "futures-channel", 1258 + "gdk-pixbuf", 1259 + "gdk4", 1260 + "gio", 1261 + "glib", 1262 + "graphene-rs", 1263 + "gsk4", 1264 + "gtk4-macros", 1265 + "gtk4-sys", 1266 + "libc", 1267 + "pango", 1268 + ] 1269 + 1270 + [[package]] 1271 + name = "gtk4-macros" 1272 + version = "0.10.3" 1273 + source = "registry+https://github.com/rust-lang/crates.io-index" 1274 + checksum = "3ccfb5a14a3d941244815d5f8101fa12d4577b59cc47245778d8d907b0003e42" 1275 + dependencies = [ 1276 + "proc-macro-crate", 1277 + "proc-macro2", 1278 + "quote", 1279 + "syn 2.0.114", 1280 + ] 1281 + 1282 + [[package]] 1283 + name = "gtk4-sys" 1284 + version = "0.10.3" 1285 + source = "registry+https://github.com/rust-lang/crates.io-index" 1286 + checksum = "842577fe5a1ee15d166cd3afe804ce0cab6173bc789ca32e21308834f20088dd" 1287 + dependencies = [ 1288 + "cairo-sys-rs", 1289 + "gdk-pixbuf-sys", 1290 + "gdk4-sys", 1291 + "gio-sys", 1292 + "glib-sys", 1293 + "gobject-sys", 1294 + "graphene-sys", 1295 + "gsk4-sys", 1296 + "libc", 1297 + "pango-sys", 1298 + "system-deps", 1299 + ] 1300 + 1301 + [[package]] 1089 1302 name = "hashbrown" 1090 1303 version = "0.14.5" 1091 1304 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1409 1622 ] 1410 1623 1411 1624 [[package]] 1625 + name = "libadwaita" 1626 + version = "0.8.1" 1627 + source = "registry+https://github.com/rust-lang/crates.io-index" 1628 + checksum = "fb09e12bf8f73342b3315c839d0a7668cc0ccebd78490c49fec48bab15d5484b" 1629 + dependencies = [ 1630 + "gdk4", 1631 + "gio", 1632 + "glib", 1633 + "gtk4", 1634 + "libadwaita-sys", 1635 + "libc", 1636 + "pango", 1637 + ] 1638 + 1639 + [[package]] 1640 + name = "libadwaita-sys" 1641 + version = "0.8.1" 1642 + source = "registry+https://github.com/rust-lang/crates.io-index" 1643 + checksum = "6d7f94227ba87eb596fecada2491f04e357d507324142f77bf76d9e6be4a3e31" 1644 + dependencies = [ 1645 + "gdk4-sys", 1646 + "gio-sys", 1647 + "glib-sys", 1648 + "gobject-sys", 1649 + "gtk4-sys", 1650 + "libc", 1651 + "pango-sys", 1652 + "system-deps", 1653 + ] 1654 + 1655 + [[package]] 1412 1656 name = "libc" 1413 1657 version = "0.2.180" 1414 1658 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1547 1791 version = "2.7.6" 1548 1792 source = "registry+https://github.com/rust-lang/crates.io-index" 1549 1793 checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" 1794 + 1795 + [[package]] 1796 + name = "memoffset" 1797 + version = "0.9.1" 1798 + source = "registry+https://github.com/rust-lang/crates.io-index" 1799 + checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" 1800 + dependencies = [ 1801 + "autocfg", 1802 + ] 1550 1803 1551 1804 [[package]] 1552 1805 name = "metaflac" ··· 1721 1974 checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" 1722 1975 1723 1976 [[package]] 1977 + name = "pango" 1978 + version = "0.21.5" 1979 + source = "registry+https://github.com/rust-lang/crates.io-index" 1980 + checksum = "52d1d85e2078077a065bb7fc072783d5bcd4e51b379f22d67107d0a16937eb69" 1981 + dependencies = [ 1982 + "gio", 1983 + "glib", 1984 + "libc", 1985 + "pango-sys", 1986 + ] 1987 + 1988 + [[package]] 1989 + name = "pango-sys" 1990 + version = "0.21.5" 1991 + source = "registry+https://github.com/rust-lang/crates.io-index" 1992 + checksum = "b4f06627d36ed5ff303d2df65211fc2e52ba5b17bf18dd80ff3d9628d6e06cfd" 1993 + dependencies = [ 1994 + "glib-sys", 1995 + "gobject-sys", 1996 + "libc", 1997 + "system-deps", 1998 + ] 1999 + 2000 + [[package]] 1724 2001 name = "parking" 1725 2002 version = "2.2.1" 1726 2003 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2131 2408 checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" 2132 2409 2133 2410 [[package]] 2411 + name = "rustc_version" 2412 + version = "0.4.1" 2413 + source = "registry+https://github.com/rust-lang/crates.io-index" 2414 + checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" 2415 + dependencies = [ 2416 + "semver", 2417 + ] 2418 + 2419 + [[package]] 2134 2420 name = "rustix" 2135 2421 version = "0.37.28" 2136 2422 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2190 2476 version = "1.2.0" 2191 2477 source = "registry+https://github.com/rust-lang/crates.io-index" 2192 2478 checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" 2479 + 2480 + [[package]] 2481 + name = "semver" 2482 + version = "1.0.27" 2483 + source = "registry+https://github.com/rust-lang/crates.io-index" 2484 + checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" 2193 2485 2194 2486 [[package]] 2195 2487 name = "serde" ··· 2859 3151 "fs_extra", 2860 3152 "futures", 2861 3153 "glib", 3154 + "gtk4", 2862 3155 "indicatif", 2863 3156 "infer", 3157 + "libadwaita", 2864 3158 "libc", 2865 3159 "lofty", 2866 3160 "log",
+2
Cargo.toml
··· 53 53 infer = "0.19.0" 54 54 sysinfo = "0.37.2" 55 55 lofty = "0.22.4" 56 + gtk4 = { version = "0.10.3", features = ["v4_20"] } 57 + libadwaita = { version = "0.8.1", features = ["v1_6"] } 56 58 57 59 # Re-enable those and compile with "gui" feature when you're ready 58 60 # to tackle GUI again.
+1
src/db/instance.rs
··· 11 11 12 12 static DATABASE_DEFAULT_NAME: &str = "tunz.db"; 13 13 14 + #[derive(Clone)] 14 15 pub struct Instance { 15 16 pool: SqlitePool, 16 17 }
+710
src/gtkgui/gtkgui.rs
··· 1 + use std::cell::RefCell; 2 + use std::rc::Rc; 3 + 4 + use anyhow::Result; 5 + use glib::BoxedAnyObject; 6 + use gtk::{ 7 + ColumnView, ColumnViewColumn, CustomSorter, FileDialog, Label, ListItem, MenuButton, Ordering, 8 + PolicyType, ScrolledWindow, SignalListItemFactory, SortListModel, SortType, StringList, gio, 9 + }; 10 + use gtk4 as gtk; 11 + use libadwaita as adw; 12 + use libadwaita::prelude::*; 13 + 14 + use crate::db::{self, Instance}; 15 + use crate::ffmpeg::{Codec, SamplingRate}; 16 + use crate::model::Track; 17 + 18 + pub async fn run() -> Result<()> { 19 + let app = adw::Application::builder() 20 + .application_id("industries.geesawra.tunz") 21 + .build(); 22 + 23 + app.connect_activate(build_ui); 24 + 25 + app.run(); 26 + 27 + Ok(()) 28 + } 29 + 30 + fn build_ui(app: &adw::Application) { 31 + let manager = adw::StyleManager::default(); 32 + manager.set_color_scheme(adw::ColorScheme::Default); 33 + 34 + let db_state = Rc::new(RefCell::new(None)); 35 + 36 + // Setup Actions 37 + setup_actions(app, db_state.clone()); 38 + 39 + let window = adw::ApplicationWindow::builder() 40 + .application(app) 41 + .title("Tunz Library") 42 + .default_width(800) 43 + .default_height(600) 44 + .build(); 45 + 46 + let store = gtk::gio::ListStore::new::<BoxedAnyObject>(); 47 + 48 + // Initially Create SortListModel without a sorter 49 + let sort_model = SortListModel::new(Some(store.clone()), None::<gtk::Sorter>); 50 + let selection_model = gtk::NoSelection::new(Some(sort_model.clone())); 51 + 52 + let column_view = ColumnView::new(Some(selection_model)); 53 + column_view.set_vexpand(true); 54 + column_view.set_hexpand(true); 55 + 56 + let title_col = add_column( 57 + &column_view, 58 + "Title", 59 + |t: &Track| t.title.clone(), 60 + |a, b| a.title.cmp(&b.title).into(), 61 + Some(200), 62 + true, 63 + ); 64 + add_column( 65 + &column_view, 66 + "Artist", 67 + |t: &Track| t.artist.clone(), 68 + |a, b| a.artist.cmp(&b.artist).into(), 69 + Some(200), 70 + true, 71 + ); 72 + add_column( 73 + &column_view, 74 + "Album", 75 + |t: &Track| t.album.clone(), 76 + |a, b| a.album.cmp(&b.album).into(), 77 + Some(200), 78 + true, 79 + ); 80 + add_column( 81 + &column_view, 82 + "Format", 83 + |t: &Track| t.extension.to_uppercase(), 84 + |a, b| a.extension.cmp(&b.extension).into(), 85 + Some(50), 86 + true, 87 + ); 88 + 89 + // Set the sorter on the model to use the column view's sorter 90 + if let Some(sorter) = column_view.sorter() { 91 + sort_model.set_sorter(Some(&sorter)); 92 + } 93 + 94 + // Default sort: Ascending by Title 95 + column_view.sort_by_column(Some(&title_col), SortType::Ascending); 96 + 97 + let scrolled_window = ScrolledWindow::builder() 98 + .hscrollbar_policy(PolicyType::Automatic) 99 + .min_content_width(360) 100 + .vexpand(true) 101 + .hexpand(true) 102 + .child(&column_view) 103 + .build(); 104 + 105 + let header_bar = adw::HeaderBar::new(); 106 + 107 + // Menu Setup 108 + let menu = gio::Menu::new(); 109 + menu.append(Some("Sources"), Some("app.sources")); 110 + menu.append(Some("Update"), Some("app.update")); 111 + menu.append(Some("Sync"), Some("app.sync")); 112 + menu.append(Some("Quit"), Some("app.quit")); 113 + 114 + let menu_button = MenuButton::builder() 115 + .icon_name("open-menu-symbolic") 116 + .menu_model(&menu) 117 + .build(); 118 + 119 + header_bar.pack_end(&menu_button); 120 + 121 + let toolbar_view = adw::ToolbarView::builder() 122 + .content(&scrolled_window) 123 + .build(); 124 + 125 + toolbar_view.add_top_bar(&header_bar); 126 + 127 + window.set_content(Some(&toolbar_view)); 128 + window.present(); 129 + 130 + let window_weak = window.downgrade(); 131 + glib::MainContext::default().spawn_local(async move { 132 + let db_dir = db::default_database_dir(); 133 + let db_dir_str = db_dir.to_str().expect("Invalid path"); 134 + 135 + match Instance::new(db_dir_str, false).await { 136 + Ok(instance) => { 137 + *db_state.borrow_mut() = Some(instance.clone()); 138 + 139 + match instance.tracks_iter().await { 140 + Ok(rx) => { 141 + let mut buffer = Vec::new(); 142 + let batch_size = 1000; 143 + 144 + while let Ok(res_track) = rx.recv().await { 145 + if let Ok(track) = res_track { 146 + buffer.push(BoxedAnyObject::new(track)); 147 + if buffer.len() >= batch_size { 148 + store.splice(store.n_items(), 0, &buffer); 149 + buffer.clear(); 150 + } 151 + } 152 + } 153 + if !buffer.is_empty() { 154 + store.splice(store.n_items(), 0, &buffer); 155 + } 156 + } 157 + Err(e) => { 158 + if let Some(window) = window_weak.upgrade() { 159 + show_error_dialog(&window, &format!("Failed to fetch tracks: {}", e)); 160 + } 161 + } 162 + } 163 + } 164 + Err(e) => { 165 + if let Some(window) = window_weak.upgrade() { 166 + show_error_dialog(&window, &format!("Failed to open database: {}", e)); 167 + } 168 + } 169 + } 170 + }); 171 + } 172 + 173 + fn setup_actions(app: &adw::Application, db_state: Rc<RefCell<Option<Instance>>>) { 174 + let action_quit = gio::SimpleAction::new("quit", None); 175 + let app_weak = app.downgrade(); 176 + action_quit.connect_activate(move |_, _| { 177 + if let Some(app) = app_weak.upgrade() { 178 + app.quit(); 179 + } 180 + }); 181 + app.add_action(&action_quit); 182 + 183 + let action_sources = gio::SimpleAction::new("sources", None); 184 + let app_weak = app.downgrade(); 185 + let db_state_clone = db_state.clone(); 186 + action_sources.connect_activate(move |_, _| { 187 + if let Some(app) = app_weak.upgrade() { 188 + if let Some(window) = app.active_window() { 189 + show_sources_dialog(&window, db_state_clone.clone()); 190 + } 191 + } 192 + }); 193 + app.add_action(&action_sources); 194 + 195 + let action_update = gio::SimpleAction::new("update", None); 196 + action_update.connect_activate(|_, _| { 197 + println!("Update clicked"); 198 + }); 199 + app.add_action(&action_update); 200 + 201 + let action_sync = gio::SimpleAction::new("sync", None); 202 + let app_weak = app.downgrade(); 203 + let db_state_clone = db_state.clone(); 204 + action_sync.connect_activate(move |_, _| { 205 + if let Some(app) = app_weak.upgrade() { 206 + if let Some(window) = app.active_window() { 207 + show_sync_dialog(&window, db_state_clone.clone()); 208 + } 209 + } 210 + }); 211 + app.add_action(&action_sync); 212 + } 213 + 214 + fn show_sources_dialog(parent: &impl IsA<gtk::Window>, db_state: Rc<RefCell<Option<Instance>>>) { 215 + let dialog = adw::Window::builder() 216 + .transient_for(parent) 217 + .modal(true) 218 + .title("Sources") 219 + .default_width(500) 220 + .default_height(400) 221 + .build(); 222 + 223 + let header_bar = adw::HeaderBar::new(); 224 + 225 + let clamp = adw::Clamp::new(); 226 + clamp.set_maximum_size(600); 227 + clamp.set_margin_top(24); 228 + clamp.set_margin_bottom(24); 229 + clamp.set_margin_start(12); 230 + clamp.set_margin_end(12); 231 + 232 + let scrolled = ScrolledWindow::builder() 233 + .child(&clamp) 234 + .vexpand(true) 235 + .build(); 236 + 237 + let toolbar_view = adw::ToolbarView::builder().content(&scrolled).build(); 238 + 239 + toolbar_view.add_top_bar(&header_bar); 240 + 241 + dialog.set_content(Some(&toolbar_view)); 242 + 243 + // Load sources 244 + let clamp_weak = clamp.downgrade(); 245 + let dialog_weak = dialog.downgrade(); 246 + let db_state_clone = db_state.clone(); 247 + glib::MainContext::default().spawn_local(async move { 248 + if let (Some(clamp), Some(dialog)) = (clamp_weak.upgrade(), dialog_weak.upgrade()) { 249 + refresh_sources_list(&clamp, &dialog, db_state_clone).await; 250 + } 251 + }); 252 + 253 + dialog.present(); 254 + } 255 + 256 + async fn refresh_sources_list( 257 + clamp: &adw::Clamp, 258 + window: &impl IsA<gtk::Window>, 259 + db_state: Rc<RefCell<Option<Instance>>>, 260 + ) { 261 + let group = adw::PreferencesGroup::new(); 262 + group.set_title("Configured Sources"); 263 + 264 + let instance = { db_state.borrow().clone() }; 265 + if let Some(instance) = instance { 266 + match instance.directories().await { 267 + Ok(dirs) => { 268 + for dir in dirs { 269 + let row = adw::ActionRow::builder().title(&dir).build(); 270 + group.add(&row); 271 + } 272 + } 273 + Err(e) => { 274 + show_error_dialog(window, &format!("Failed to load directories: {}", e)); 275 + } 276 + } 277 + } 278 + 279 + // Add "Add Source..." row 280 + let add_row = adw::ActionRow::builder() 281 + .title("Add Source...") 282 + .activatable(true) 283 + .build(); 284 + add_row.add_prefix(&gtk::Image::from_icon_name("list-add-symbolic")); 285 + 286 + // We need to keep window alive for callback, but we can't clone `&impl IsA<gtk::Window>`. 287 + // We can't downgrade `&impl IsA<gtk::Window>` directly without knowing it's an Object. 288 + // IsA<T> implies IsA<Object>. 289 + let window_obj = window.as_ref(); // &Object 290 + let window_weak = window_obj.downgrade(); 291 + let clamp_weak = clamp.downgrade(); 292 + let db_state_clone = db_state.clone(); 293 + 294 + add_row.connect_activated(move |_| { 295 + let window_weak = window_weak.clone(); 296 + let clamp_weak = clamp_weak.clone(); 297 + let db_state_clone = db_state_clone.clone(); 298 + 299 + glib::MainContext::default().spawn_local(async move { 300 + // Upgrade to gtk::Window to use with FileDialog 301 + if let Some(window_obj) = window_weak.upgrade() { 302 + // We need to cast back to Window? No, FileDialog accepts &impl IsA<Window>. 303 + // Upgrade returns T where T is the Object type. Downgrade on &impl IsA<Window> might return WeakRef<Object>. 304 + // We can cast Object to Window. 305 + let dialog = window_obj.downcast::<gtk::Window>().ok(); 306 + if let Some(dialog) = dialog { 307 + let file_dialog = FileDialog::builder() 308 + .title("Select Source Directory") 309 + .modal(true) 310 + .build(); 311 + 312 + if let Ok(file) = file_dialog.select_folder_future(Some(&dialog)).await { 313 + if let Some(path) = file.path() { 314 + let path_str = path.to_string_lossy().to_string(); 315 + // Add to DB 316 + let instance = { db_state_clone.borrow().clone() }; 317 + if let Some(instance) = instance { 318 + if let Err(e) = instance.insert_directory(path_str).await { 319 + show_error_dialog( 320 + &dialog, 321 + &format!("Failed to insert directory: {}", e), 322 + ); 323 + } else { 324 + if let Some(clamp) = clamp_weak.upgrade() { 325 + refresh_sources_list(&clamp, &dialog, db_state_clone).await; 326 + } 327 + } 328 + } 329 + } 330 + } 331 + } 332 + } 333 + }); 334 + }); 335 + 336 + group.add(&add_row); 337 + 338 + clamp.set_child(Some(&group)); 339 + } 340 + 341 + fn show_sync_dialog( 342 + parent: &impl IsA<gtk::Window>, 343 + _source_db_state: Rc<RefCell<Option<Instance>>>, 344 + ) { 345 + let dialog = adw::Window::builder() 346 + .transient_for(parent) 347 + .modal(true) 348 + .title("Sync") 349 + .default_width(500) 350 + .default_height(400) 351 + .build(); 352 + 353 + let header_bar = adw::HeaderBar::new(); 354 + let start_button = gtk::Button::builder() 355 + .label("Start") 356 + .css_classes(["suggested-action"]) 357 + .sensitive(false) 358 + .build(); 359 + header_bar.pack_end(&start_button); 360 + 361 + let page = adw::PreferencesPage::new(); 362 + let group = adw::PreferencesGroup::new(); 363 + group.set_title("Sync Configuration"); 364 + 365 + // Destination Database State 366 + let dest_db_state = Rc::new(RefCell::new(None)); 367 + 368 + // Destination 369 + let dest_row = adw::ActionRow::builder() 370 + .title("Destination") 371 + .subtitle("Select destination folder...") 372 + .activatable(true) 373 + .build(); 374 + 375 + // iPod 376 + let ipod_row = adw::SwitchRow::builder() 377 + .title("iPod Mode") 378 + .subtitle("Transcodes files to high-quality AAC (iTunes Plus equivalent) and updates the iPod database.") 379 + .sensitive(false) 380 + .build(); 381 + 382 + // Transcoding 383 + let transcode_row = adw::ActionRow::builder() 384 + .title("Transcoding Settings") 385 + .activatable(true) 386 + .sensitive(false) 387 + .build(); 388 + transcode_row.add_prefix(&gtk::Image::from_icon_name("emblem-system-symbolic")); 389 + 390 + // Callbacks 391 + let dialog_weak = dialog.downgrade(); 392 + let dest_row_weak = dest_row.downgrade(); 393 + let dest_db_state_clone = dest_db_state.clone(); 394 + let start_button_weak = start_button.downgrade(); 395 + let ipod_row_weak = ipod_row.downgrade(); 396 + let transcode_row_weak = transcode_row.downgrade(); 397 + 398 + dest_row.connect_activated(move |_| { 399 + let dialog_weak = dialog_weak.clone(); 400 + let dest_row_weak = dest_row_weak.clone(); 401 + let dest_db_state_clone = dest_db_state_clone.clone(); 402 + let start_button_weak = start_button_weak.clone(); 403 + let ipod_row_weak = ipod_row_weak.clone(); 404 + let transcode_row_weak = transcode_row_weak.clone(); 405 + 406 + glib::MainContext::default().spawn_local(async move { 407 + if let Some(dialog) = dialog_weak.upgrade() { 408 + let file_dialog = FileDialog::builder() 409 + .title("Select Destination Directory") 410 + .modal(true) 411 + .build(); 412 + if let Ok(file) = file_dialog.select_folder_future(Some(&dialog)).await { 413 + if let Some(path) = file.path() { 414 + let path_str = path.to_string_lossy().to_string(); 415 + if let Some(row) = dest_row_weak.upgrade() { 416 + row.set_subtitle(&path_str); 417 + } 418 + 419 + // Initialize Destination DB 420 + match Instance::new(&path_str, true).await { 421 + Ok(inst) => { 422 + *dest_db_state_clone.borrow_mut() = Some(inst); 423 + if let Some(btn) = start_button_weak.upgrade() { 424 + btn.set_sensitive(true); 425 + } 426 + if let Some(row) = ipod_row_weak.upgrade() { 427 + row.set_sensitive(true); 428 + } 429 + if let Some(row) = transcode_row_weak.upgrade() { 430 + row.set_sensitive(true); 431 + } 432 + } 433 + Err(e) => { 434 + show_error_dialog( 435 + &dialog, 436 + &format!("Failed to open destination DB: {}", e), 437 + ); 438 + } 439 + } 440 + } 441 + } 442 + } 443 + }); 444 + }); 445 + 446 + group.add(&dest_row); 447 + group.add(&ipod_row); 448 + 449 + let dest_db_state_clone = dest_db_state.clone(); 450 + let dialog_weak = dialog.downgrade(); 451 + transcode_row.connect_activated(move |_| { 452 + if let Some(dialog) = dialog_weak.upgrade() { 453 + show_transcoding_dialog(&dialog, dest_db_state_clone.clone()); 454 + } 455 + }); 456 + group.add(&transcode_row); 457 + 458 + page.add(&group); 459 + 460 + let toolbar_view = adw::ToolbarView::builder().content(&page).build(); 461 + 462 + toolbar_view.add_top_bar(&header_bar); 463 + 464 + dialog.set_content(Some(&toolbar_view)); 465 + dialog.present(); 466 + } 467 + 468 + fn show_transcoding_dialog( 469 + parent: &impl IsA<gtk::Window>, 470 + db_state: Rc<RefCell<Option<Instance>>>, 471 + ) { 472 + let dialog = adw::Window::builder() 473 + .transient_for(parent) 474 + .modal(true) 475 + .title("Transcoding Settings") 476 + .default_width(400) 477 + .default_height(350) 478 + .build(); 479 + 480 + let header_bar = adw::HeaderBar::new(); 481 + let save_button = gtk::Button::builder() 482 + .label("Save") 483 + .css_classes(["suggested-action"]) 484 + .build(); 485 + header_bar.pack_end(&save_button); 486 + 487 + let page = adw::PreferencesPage::new(); 488 + let group = adw::PreferencesGroup::new(); 489 + group.set_title("Settings"); 490 + 491 + // Codec 492 + let codec_model = StringList::new(&["AAC", "MP3", "OGG", "Opus"]); 493 + let codec_row = adw::ComboRow::builder() 494 + .title("Codec") 495 + .model(&codec_model) 496 + .build(); 497 + group.add(&codec_row); 498 + 499 + // Bitrate Mode 500 + let mode_model = StringList::new(&["Constant Bitrate (CBR)", "Variable Bitrate (VBR)"]); 501 + let mode_row = adw::ComboRow::builder() 502 + .title("Bitrate Mode") 503 + .model(&mode_model) 504 + .build(); 505 + group.add(&mode_row); 506 + 507 + // Bitrate Value 508 + let value_row = adw::EntryRow::builder().title("Bitrate Value").build(); 509 + group.add(&value_row); 510 + 511 + // Sampling Rate 512 + let rate_model = StringList::new(&["44100", "48000"]); 513 + let rate_row = adw::ComboRow::builder() 514 + .title("Sampling Rate") 515 + .model(&rate_model) 516 + .build(); 517 + group.add(&rate_row); 518 + 519 + page.add(&group); 520 + 521 + let toolbar_view = adw::ToolbarView::builder().content(&page).build(); 522 + 523 + toolbar_view.add_top_bar(&header_bar); 524 + 525 + dialog.set_content(Some(&toolbar_view)); 526 + 527 + // Load settings 528 + let db_state_clone = db_state.clone(); 529 + let codec_row_weak = codec_row.downgrade(); 530 + let mode_row_weak = mode_row.downgrade(); 531 + let value_row_weak = value_row.downgrade(); 532 + let rate_row_weak = rate_row.downgrade(); 533 + 534 + glib::MainContext::default().spawn_local(async move { 535 + let instance = { db_state_clone.borrow().clone() }; 536 + if let Some(instance) = instance { 537 + if let Ok(Some(settings)) = instance.get_transcoding_settings().await { 538 + if let Some(row) = codec_row_weak.upgrade() { 539 + let idx = match settings.codec { 540 + Codec::AAC => 0, 541 + Codec::MP3 => 1, 542 + Codec::OGG => 2, 543 + Codec::Opus => 3, 544 + }; 545 + row.set_selected(idx); 546 + } 547 + 548 + let bitrate_str = settings.bitrate.to_string(); 549 + if let Some(row) = mode_row_weak.upgrade() { 550 + if bitrate_str.starts_with("vbr") { 551 + row.set_selected(1); 552 + } else { 553 + row.set_selected(0); 554 + } 555 + } 556 + if let Some(row) = value_row_weak.upgrade() { 557 + let parts: Vec<&str> = bitrate_str.split(':').collect(); 558 + if parts.len() > 1 { 559 + let val = parts[1].trim_end_matches('k'); 560 + row.set_text(val); 561 + } 562 + } 563 + 564 + if let Some(row) = rate_row_weak.upgrade() { 565 + let idx = match settings.sampling_rate { 566 + Some(SamplingRate::Rate441K) => 0, 567 + Some(SamplingRate::Rate48K) => 1, 568 + _ => 0, 569 + }; 570 + row.set_selected(idx); 571 + } 572 + } 573 + } 574 + }); 575 + 576 + // Save handler 577 + let db_state_clone = db_state.clone(); 578 + let dialog_weak = dialog.downgrade(); 579 + save_button.connect_clicked(move |_| { 580 + let codec_idx = codec_row.selected(); 581 + let mode_idx = mode_row.selected(); 582 + let val = value_row.text().to_string(); 583 + let rate_idx = rate_row.selected(); 584 + let db_state_clone = db_state_clone.clone(); 585 + let dialog_weak = dialog_weak.clone(); 586 + 587 + glib::MainContext::default().spawn_local(async move { 588 + let codec = match codec_idx { 589 + 0 => "aac", 590 + 1 => "mp3", 591 + 2 => "ogg", 592 + 3 => "opus", 593 + _ => "aac", 594 + } 595 + .to_string(); 596 + 597 + let bitrate = if mode_idx == 1 { 598 + format!("vbr:{}", val) 599 + } else { 600 + if val.ends_with('k') { 601 + format!("cbr:{}", val) 602 + } else { 603 + format!("cbr:{}k", val) 604 + } 605 + }; 606 + 607 + let rate = match rate_idx { 608 + 0 => "44100", 609 + 1 => "48000", 610 + _ => "44100", 611 + } 612 + .to_string(); 613 + 614 + let instance = { db_state_clone.borrow().clone() }; 615 + if let Some(instance) = instance { 616 + if let Err(e) = instance 617 + .set_transcoding_settings(Some(codec), Some(bitrate), Some(rate)) 618 + .await 619 + { 620 + if let Some(dialog) = dialog_weak.upgrade() { 621 + show_error_dialog(&dialog, &format!("Failed to save settings: {}", e)); 622 + } 623 + } else { 624 + if let Some(dialog) = dialog_weak.upgrade() { 625 + dialog.close(); 626 + } 627 + } 628 + } 629 + }); 630 + }); 631 + 632 + dialog.present(); 633 + } 634 + 635 + fn show_error_dialog(parent: &impl IsA<gtk::Window>, message: &str) { 636 + let dialog = adw::AlertDialog::builder() 637 + .heading("Error") 638 + .body(message) 639 + .build(); 640 + dialog.add_response("ok", "OK"); 641 + dialog.present(Some(parent.as_ref())); 642 + } 643 + 644 + fn add_column<F, C>( 645 + column_view: &ColumnView, 646 + title: &str, 647 + getter: F, 648 + comparator: C, 649 + fixed_width: Option<i32>, 650 + expand: bool, 651 + ) -> ColumnViewColumn 652 + where 653 + F: Fn(&Track) -> String + 'static + Clone, 654 + C: Fn(&Track, &Track) -> Ordering + 'static + Clone, 655 + { 656 + let factory = SignalListItemFactory::new(); 657 + let getter = Rc::new(getter); 658 + 659 + factory.connect_setup(move |_, list_item| { 660 + let list_item = list_item 661 + .downcast_ref::<ListItem>() 662 + .expect("Needs to be ListItem"); 663 + let label = Label::new(None); 664 + label.set_xalign(0.0); 665 + label.set_hexpand(false); 666 + label.set_ellipsize(gtk::pango::EllipsizeMode::End); 667 + list_item.set_child(Some(&label)); 668 + }); 669 + 670 + let getter_clone = getter.clone(); 671 + factory.connect_bind(move |_, list_item| { 672 + let list_item = list_item 673 + .downcast_ref::<ListItem>() 674 + .expect("Needs to be ListItem"); 675 + let label = list_item 676 + .child() 677 + .and_downcast::<Label>() 678 + .expect("ListItem child is not a Label"); 679 + let item = list_item 680 + .item() 681 + .and_downcast::<BoxedAnyObject>() 682 + .expect("ListItem item is not a BoxedAnyObject"); 683 + let track = item.borrow::<Track>(); 684 + label.set_label(&getter_clone(&track)); 685 + }); 686 + 687 + let column = ColumnViewColumn::builder() 688 + .title(title) 689 + .factory(&factory) 690 + .build(); 691 + 692 + if let Some(width) = fixed_width { 693 + column.set_fixed_width(width); 694 + } 695 + 696 + column.set_expand(expand); 697 + column.set_resizable(true); 698 + 699 + let sorter = CustomSorter::new(move |a, b| { 700 + let a = a.downcast_ref::<BoxedAnyObject>().unwrap(); 701 + let b = b.downcast_ref::<BoxedAnyObject>().unwrap(); 702 + let track_a = a.borrow::<Track>(); 703 + let track_b = b.borrow::<Track>(); 704 + comparator(&track_a, &track_b) 705 + }); 706 + column.set_sorter(Some(&sorter)); 707 + 708 + column_view.append_column(&column); 709 + column 710 + }
+2
src/gtkgui/mod.rs
··· 1 + mod gtkgui; 2 + pub use gtkgui::run;
+5
src/main.rs
··· 6 6 mod filter; 7 7 mod fs; 8 8 mod gpod; 9 + mod gtkgui; 9 10 mod gui; 10 11 mod model; 11 12 ··· 20 21 if std::env::args().count() == 1 { 21 22 return gui::run().await; 22 23 } 24 + } 25 + 26 + if std::env::args().count() == 1 { 27 + return gtkgui::run().await; 23 28 } 24 29 25 30 let c = cli::Cli::parse();