Music, but without the subscription. vleer.app
gpui music openmusic openmetadata rust
1
fork

Configure Feed

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

feat: add artists page

0PandaDEV 2eab3d4a 4e040a2f

+407 -10
+7
src/data/db/models.rs
··· 50 50 } 51 51 52 52 #[derive(Debug, Clone, FromRow)] 53 + pub struct ArtistListRow { 54 + pub id: Cuid, 55 + pub name: String, 56 + pub image_id: Option<String>, 57 + } 58 + 59 + #[derive(Debug, Clone, FromRow)] 53 60 pub struct AlbumRow { 54 61 pub id: Cuid, 55 62 pub title: String,
+48
src/data/db/queries.rs
··· 369 369 .await 370 370 } 371 371 372 + pub async fn get_artists_count_filtered(pool: &SqlitePool, query: &str) -> sqlx::Result<i64> { 373 + let query_lower = query.to_lowercase(); 374 + 375 + let row: (i64,) = sqlx::query_as( 376 + r#" 377 + SELECT COUNT(DISTINCT ar.id) 378 + FROM artists ar 379 + WHERE 380 + ?1 = '' 381 + OR LOWER(ar.name) LIKE '%' || ?1 || '%' 382 + "#, 383 + ) 384 + .bind(&query_lower) 385 + .fetch_one(pool) 386 + .await?; 387 + 388 + Ok(row.0) 389 + } 390 + 391 + pub async fn get_artists_paged_filtered( 392 + pool: &SqlitePool, 393 + query: &str, 394 + limit: i64, 395 + offset: i64, 396 + ) -> sqlx::Result<Vec<ArtistListRow>> { 397 + let query_lower = query.to_lowercase(); 398 + 399 + sqlx::query_as::<_, ArtistListRow>( 400 + r#" 401 + SELECT 402 + ar.id, 403 + ar.name, 404 + ar.image_id 405 + FROM artists ar 406 + WHERE 407 + ?1 = '' 408 + OR LOWER(ar.name) LIKE '%' || ?1 || '%' 409 + ORDER BY LOWER(ar.name) ASC 410 + LIMIT ?2 OFFSET ?3 411 + "#, 412 + ) 413 + .bind(&query_lower) 414 + .bind(limit) 415 + .bind(offset) 416 + .fetch_all(pool) 417 + .await 418 + } 419 + 372 420 pub async fn get_playlist(pool: &SqlitePool, id: &Cuid) -> sqlx::Result<Option<PlaylistRow>> { 373 421 sqlx::query_as::<_, PlaylistRow>("SELECT * FROM playlists WHERE id = ?") 374 422 .bind(id)
+24 -2
src/data/db/repo.rs
··· 1 1 use crate::data::db::models::Toggleable; 2 2 use crate::data::db::queries; 3 3 use crate::data::models::{ 4 - Album, AlbumListItem, Artist, Cuid, Event, EventContext, EventType, Image, PinnedItem, 5 - Playlist, PlaylistTrack, RecentItem, Song, SongListItem, SongSort, 4 + Album, AlbumListItem, Artist, ArtistListItem, Cuid, Event, EventContext, EventType, Image, 5 + PinnedItem, Playlist, PlaylistTrack, RecentItem, Song, SongListItem, SongSort, 6 6 }; 7 7 use gpui::Global; 8 8 use sqlx::SqlitePool; ··· 85 85 Ok(queries::get_artist(&self.pool, id) 86 86 .await? 87 87 .map(|row| row.into())) 88 + } 89 + 90 + pub async fn get_artists_count_filtered(&self, query: &str) -> sqlx::Result<usize> { 91 + let count = queries::get_artists_count_filtered(&self.pool, query).await?; 92 + Ok(count as usize) 93 + } 94 + 95 + pub async fn get_artists_paged_filtered( 96 + &self, 97 + query: &str, 98 + offset: i64, 99 + limit: i64, 100 + ) -> sqlx::Result<Vec<ArtistListItem>> { 101 + let rows = queries::get_artists_paged_filtered(&self.pool, query, limit, offset).await?; 102 + Ok(rows 103 + .into_iter() 104 + .map(|row| ArtistListItem { 105 + id: row.id, 106 + name: row.name, 107 + image_id: row.image_id, 108 + }) 109 + .collect()) 88 110 } 89 111 90 112 pub async fn upsert_artist(&self, name: &str) -> sqlx::Result<Cuid> {
+7
src/data/models.rs
··· 92 92 } 93 93 94 94 #[derive(Debug, Clone, Serialize, Deserialize)] 95 + pub struct ArtistListItem { 96 + pub id: Cuid, 97 + pub name: String, 98 + pub image_id: Option<String>, 99 + } 100 + 101 + #[derive(Debug, Clone, Serialize, Deserialize)] 95 102 pub struct Artist { 96 103 pub id: Cuid, 97 104 pub name: String,
+1 -1
src/ui/global_actions.rs
··· 3 3 4 4 use crate::{ 5 5 data::{config::Config, db::repo::Database, scanner::Scanner}, 6 - media::{playback::Playback, queue::Queue}, 6 + media::playback::Playback, 7 7 }; 8 8 9 9 actions!(vleer, [Quit, ReloadConfig, Scan, ForceScan]);
+320 -7
src/ui/views/artists.rs
··· 1 - use gpui::{Context, IntoElement, Render, *}; 1 + use gpui::{Context, IntoElement, Render, prelude::FluentBuilder, *}; 2 + use rustc_hash::{FxHashMap, FxHashSet}; 3 + 4 + use crate::{ 5 + data::{db::repo::Database, models::ArtistListItem}, 6 + ui::{ 7 + assets::image_cache::app_image_cache, 8 + components::{ 9 + div::{flex_col, flex_row}, 10 + scrollbar::{Scrollbar, ScrollbarAxis}, 11 + }, 12 + layout::library::Search, 13 + variables::Variables, 14 + }, 15 + }; 2 16 3 - use crate::ui::{components::div::flex_col, variables::Variables}; 17 + const MIN_COVER_SIZE: f32 = 180.0; 18 + const MAX_COVER_SIZE: f32 = 400.0; 19 + const GAP_SIZE: f32 = 16.0; 4 20 5 - pub struct ArtistsView {} 21 + pub struct ArtistsView { 22 + page_size: usize, 23 + total_count: usize, 24 + page_cache: FxHashMap<usize, Vec<ArtistListItem>>, 25 + page_pending: FxHashSet<usize>, 26 + last_query: String, 27 + container_width: Option<f32>, 28 + scroll_handle: UniformListScrollHandle, 29 + } 6 30 7 31 impl ArtistsView { 8 - pub fn new(_window: &mut Window, _cx: &mut Context<Self>) -> Self { 9 - let view = Self {}; 32 + pub fn new(_window: &mut Window, cx: &mut Context<Self>) -> Self { 33 + let mut view = Self { 34 + page_size: 60, 35 + total_count: 0, 36 + page_cache: FxHashMap::default(), 37 + page_pending: FxHashSet::default(), 38 + last_query: String::new(), 39 + container_width: None, 40 + scroll_handle: UniformListScrollHandle::default(), 41 + }; 42 + 43 + view.refresh_query(cx); 44 + 45 + cx.observe_global::<Search>(|this, cx| { 46 + let query = cx.global::<Search>().query.to_string(); 47 + if query == this.last_query { 48 + return; 49 + } 50 + this.last_query = query; 51 + this.refresh_query(cx); 52 + }) 53 + .detach(); 54 + 10 55 view 11 56 } 57 + 58 + fn refresh_query(&mut self, cx: &mut Context<Self>) { 59 + self.page_cache.clear(); 60 + self.page_pending.clear(); 61 + self.total_count = 0; 62 + cx.notify(); 63 + 64 + let db = cx.global::<Database>().clone(); 65 + let query = self.last_query.clone(); 66 + 67 + cx.spawn(async move |this, cx: &mut AsyncApp| { 68 + let count = db.get_artists_count_filtered(&query).await.unwrap_or(0); 69 + 70 + cx.update(|cx| { 71 + this.update(cx, |this, cx| { 72 + if this.last_query != query { 73 + return; 74 + } 75 + this.total_count = count; 76 + cx.notify(); 77 + }) 78 + }) 79 + .ok(); 80 + }) 81 + .detach(); 82 + } 83 + 84 + fn ensure_page(&mut self, page: usize, cx: &mut Context<Self>) { 85 + if self.page_cache.contains_key(&page) || self.page_pending.contains(&page) { 86 + return; 87 + } 88 + 89 + self.page_pending.insert(page); 90 + 91 + let db = cx.global::<Database>().clone(); 92 + let query = self.last_query.clone(); 93 + let page_size = self.page_size; 94 + let offset = (page * page_size) as i64; 95 + 96 + cx.spawn(async move |this, cx: &mut AsyncApp| { 97 + let artists = db 98 + .get_artists_paged_filtered(&query, offset, page_size as i64) 99 + .await 100 + .unwrap_or_default(); 101 + 102 + cx.update(|cx| { 103 + this.update(cx, |this, cx| { 104 + if this.last_query != query { 105 + return; 106 + } 107 + this.page_cache.insert(page, artists); 108 + this.page_pending.remove(&page); 109 + cx.notify(); 110 + }) 111 + }) 112 + .ok(); 113 + }) 114 + .detach(); 115 + } 116 + 117 + fn ensure_pages_for_range( 118 + &mut self, 119 + range: std::ops::Range<usize>, 120 + items_per_row: usize, 121 + cx: &mut Context<Self>, 122 + ) { 123 + if self.total_count == 0 { 124 + return; 125 + } 126 + 127 + let start_item = range.start.saturating_mul(items_per_row); 128 + let end_item = (range.end.saturating_mul(items_per_row)).min(self.total_count); 129 + if start_item >= end_item { 130 + return; 131 + } 132 + 133 + let page_start = start_item / self.page_size; 134 + let page_end = (end_item - 1) / self.page_size; 135 + let buffer = 1usize; 136 + 137 + let begin = page_start.saturating_sub(buffer); 138 + let end = page_end.saturating_add(buffer); 139 + 140 + for page in begin..=end { 141 + self.ensure_page(page, cx); 142 + } 143 + } 144 + 145 + fn get_artist_at(&self, index: usize) -> Option<ArtistListItem> { 146 + let page = index / self.page_size; 147 + let offset = index % self.page_size; 148 + self.page_cache 149 + .get(&page) 150 + .and_then(|p| p.get(offset)) 151 + .cloned() 152 + } 153 + 154 + fn calculate_layout(&self) -> (f32, usize) { 155 + let width = self.container_width.unwrap_or(1000.0); 156 + 157 + let num_items = ((width + GAP_SIZE) / (MIN_COVER_SIZE + GAP_SIZE)).floor() as usize; 158 + let num_items = num_items.max(1); 159 + 160 + let cover_size = if num_items > 0 { 161 + ((width - (num_items - 1) as f32 * GAP_SIZE) / num_items as f32) 162 + .clamp(MIN_COVER_SIZE, MAX_COVER_SIZE) 163 + } else { 164 + MIN_COVER_SIZE 165 + }; 166 + 167 + (cover_size, num_items) 168 + } 169 + } 170 + 171 + fn artist_tile( 172 + idx: usize, 173 + artist: &ArtistListItem, 174 + cover_size: f32, 175 + variables: &Variables, 176 + ) -> impl IntoElement { 177 + let cover_element = if let Some(uri) = &artist.image_id { 178 + img(format!("!image://{}", uri)) 179 + .id(ElementId::Name(format!("artist-cover-{}", idx).into())) 180 + .size(px(cover_size)) 181 + .object_fit(ObjectFit::Cover) 182 + .rounded_full() 183 + .into_any_element() 184 + } else { 185 + div() 186 + .id(ElementId::Name( 187 + format!("artist-cover-placeholder-{}", idx).into(), 188 + )) 189 + .size(px(cover_size)) 190 + .bg(variables.border) 191 + .rounded_full() 192 + .into_any_element() 193 + }; 194 + 195 + flex_col() 196 + .id(ElementId::Name(format!("artist-item-{}", idx).into())) 197 + .w(px(cover_size)) 198 + .gap(px(8.0)) 199 + .child(cover_element) 200 + .child( 201 + div() 202 + .id(ElementId::Name(format!("artist-title-{}", idx).into())) 203 + .text_ellipsis() 204 + .font_weight(FontWeight(500.0)) 205 + .overflow_x_hidden() 206 + .max_w(px(cover_size)) 207 + .child(artist.name.clone()), 208 + ) 12 209 } 13 210 14 211 impl Render for ArtistsView { 15 - fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement { 212 + fn render(&mut self, window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement { 16 213 let variables = cx.global::<Variables>(); 17 214 215 + let bounds = window.bounds(); 216 + let window_width: f32 = bounds.size.width.into(); 217 + let estimated_width = window_width - 300.0 - 98.0; 218 + if estimated_width > 0.0 { 219 + self.container_width = Some(estimated_width); 220 + } 221 + 222 + let (cover_size, items_per_row) = self.calculate_layout(); 223 + let items_per_row = items_per_row.max(1); 224 + 225 + let row_count = if self.total_count == 0 { 226 + 0 227 + } else { 228 + (self.total_count + items_per_row - 1) / items_per_row 229 + }; 230 + 231 + let view_handle = cx.entity(); 232 + 233 + let grid_content = if row_count == 0 { 234 + flex_row() 235 + .id("artists-empty") 236 + .w_full() 237 + .child("No Data") 238 + .text_color(variables.text_secondary) 239 + .into_any_element() 240 + } else { 241 + let scroll_handle = self.scroll_handle.clone(); 242 + 243 + div() 244 + .size_full() 245 + .child( 246 + uniform_list( 247 + ElementId::Name("artists-rows".into()), 248 + row_count, 249 + move |range, _, cx| { 250 + view_handle.update(cx, |this, cx| { 251 + this.ensure_pages_for_range(range.clone(), items_per_row, cx); 252 + }); 253 + 254 + range 255 + .map(|row_idx| { 256 + let variables = cx.global::<Variables>(); 257 + let mut row = flex_row() 258 + .id(ElementId::Name( 259 + format!("artists-row-{}", row_idx).into(), 260 + )) 261 + .w_full() 262 + .gap(px(GAP_SIZE)) 263 + .pb(px(GAP_SIZE)); 264 + 265 + for col_idx in 0..items_per_row { 266 + let item_idx = row_idx * items_per_row + col_idx; 267 + if item_idx >= view_handle.read(cx).total_count { 268 + break; 269 + } 270 + 271 + let artist_opt = 272 + view_handle.read(cx).get_artist_at(item_idx); 273 + 274 + if let Some(artist) = artist_opt { 275 + row = row.child(artist_tile( 276 + item_idx, &artist, cover_size, variables, 277 + )); 278 + } else { 279 + row = row.child( 280 + div() 281 + .id(ElementId::Name( 282 + format!("artist-placeholder-{}", item_idx) 283 + .into(), 284 + )) 285 + .w(px(cover_size)) 286 + .h(px(cover_size + 44.0)) 287 + .rounded_full() 288 + .bg(variables.border), 289 + ); 290 + } 291 + } 292 + 293 + row.into_any_element() 294 + }) 295 + .collect() 296 + }, 297 + ) 298 + .track_scroll(&scroll_handle) 299 + .size_full(), 300 + ) 301 + .into_any_element() 302 + }; 303 + 18 304 flex_col() 305 + .image_cache(app_image_cache()) 19 306 .size_full() 20 - .p(px(variables.padding_24)) 307 + .child( 308 + div() 309 + .id("artists-scroll-container") 310 + .flex_1() 311 + .size_full() 312 + .min_h_0() 313 + .relative() 314 + .child( 315 + div() 316 + .id("artists-content") 317 + .size_full() 318 + .p(px(variables.padding_24)) 319 + .child(grid_content), 320 + ), 321 + ) 322 + .when(row_count > 0, |this| { 323 + let scroll_handle = self.scroll_handle.clone(); 324 + this.child( 325 + div() 326 + .absolute() 327 + .top_0() 328 + .right_0() 329 + .bottom_0() 330 + .left_0() 331 + .child(Scrollbar::new(&scroll_handle).axis(ScrollbarAxis::Vertical)), 332 + ) 333 + }) 21 334 } 22 335 }