diff --git a/crates/qbz-slint/src/genre_filter.rs b/crates/qbz-slint/src/genre_filter.rs new file mode 100644 index 000000000..b3bf50fec --- /dev/null +++ b/crates/qbz-slint/src/genre_filter.rs @@ -0,0 +1,195 @@ +//! Filter-by-genre controller. +//! +//! Loads the parent genres for the Discover popup's simple grid and +//! owns the shared genre selection (one set for all three Discover +//! tabs, matching Tauri's single "home" genre context). The selection +//! persists to `/qbz/genre_filter.json` when "Remember +//! selection" is on, and feeds `genre_ids` into the discover-index +//! fetch. + +use std::path::PathBuf; +use std::sync::Mutex; + +use qbz_app::shell::AppRuntime; +use qbz_core::FrontendAdapter; +use serde::{Deserialize, Serialize}; +use slint::{ComponentHandle, ModelRc, VecModel}; + +use crate::{AppWindow, GenreChip, GenreFilterState}; + +#[derive(Clone)] +struct GenreItem { + id: u64, + name: String, +} + +#[derive(Default, Serialize, Deserialize)] +struct Persisted { + #[serde(default)] + selected: Vec, + #[serde(default = "default_true")] + remember: bool, +} + +fn default_true() -> bool { + true +} + +struct State { + parents: Vec, + selected: Vec, + remember: bool, +} + +static STATE: Mutex = Mutex::new(State { + parents: Vec::new(), + selected: Vec::new(), + remember: true, +}); + +fn store_path() -> Option { + Some(dirs::data_dir()?.join("qbz").join("genre_filter.json")) +} + +fn load_persisted() -> Persisted { + let Some(path) = store_path() else { + return Persisted::default(); + }; + match std::fs::read(&path) { + Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_default(), + Err(_) => Persisted::default(), + } +} + +fn save_persisted(selected: &[u64], remember: bool) { + let Some(path) = store_path() else { + return; + }; + if !remember { + // Remember off — drop any persisted selection. + let _ = std::fs::remove_file(&path); + return; + } + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let data = Persisted { + selected: selected.to_vec(), + remember, + }; + if let Ok(json) = serde_json::to_vec_pretty(&data) { + let _ = std::fs::write(&path, json); + } +} + +/// The current shared genre-id selection. Read by navigate_home to +/// pass into the discover-index fetch. +pub fn selected_ids() -> Vec { + STATE.lock().map(|s| s.selected.clone()).unwrap_or_default() +} + +/// Fetch the parent genres (if not already loaded) and seed the +/// persisted selection. Runs on a worker; call apply_state afterwards +/// on the UI thread. +pub async fn load_parents(runtime: &AppRuntime) +where + A: FrontendAdapter + Send + Sync + 'static, +{ + { + let already = STATE.lock().map(|s| !s.parents.is_empty()).unwrap_or(false); + if already { + return; + } + } + let persisted = load_persisted(); + let mut parents: Vec = match runtime.core().get_genres(None).await { + Ok(list) => list + .into_iter() + .map(|g| GenreItem { + id: g.id, + name: g.name, + }) + .collect(), + Err(e) => { + log::warn!("[qbz-slint] genre filter: get_genres failed: {e}"); + Vec::new() + } + }; + parents.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); + + // Drop any persisted ids that no longer exist. + let valid: Vec = persisted + .selected + .into_iter() + .filter(|id| parents.iter().any(|p| p.id == *id)) + .collect(); + + if let Ok(mut s) = STATE.lock() { + s.parents = parents; + s.selected = valid; + s.remember = persisted.remember; + } +} + +/// Push the current parents + selection into GenreFilterState. UI thread. +pub fn apply_state(window: &AppWindow) { + let (chips, count, remember) = { + let Ok(s) = STATE.lock() else { + return; + }; + let chips: Vec = s + .parents + .iter() + .map(|g| GenreChip { + id: g.id.to_string().into(), + name: g.name.clone().into(), + selected: s.selected.contains(&g.id), + }) + .collect(); + (chips, s.selected.len() as i32, s.remember) + }; + let state = window.global::(); + state.set_genres(ModelRc::new(VecModel::from(chips))); + state.set_selected_count(count); + state.set_remember(remember); +} + +/// Toggle a genre id in the selection. Returns true if the selection +/// changed (so the caller can re-fetch). +pub fn toggle(id_str: &str) -> bool { + let Ok(id) = id_str.parse::() else { + return false; + }; + let Ok(mut s) = STATE.lock() else { + return false; + }; + if let Some(pos) = s.selected.iter().position(|x| *x == id) { + s.selected.remove(pos); + } else { + s.selected.push(id); + } + let (sel, rem) = (s.selected.clone(), s.remember); + drop(s); + save_persisted(&sel, rem); + true +} + +pub fn clear() { + let Ok(mut s) = STATE.lock() else { + return; + }; + s.selected.clear(); + let rem = s.remember; + drop(s); + save_persisted(&[], rem); +} + +pub fn set_remember(remember: bool) { + let Ok(mut s) = STATE.lock() else { + return; + }; + s.remember = remember; + let sel = s.selected.clone(); + drop(s); + save_persisted(&sel, remember); +} diff --git a/crates/qbz-slint/src/home.rs b/crates/qbz-slint/src/home.rs index 5388bcbce..0d053b62c 100644 --- a/crates/qbz-slint/src/home.rs +++ b/crates/qbz-slint/src/home.rs @@ -75,14 +75,18 @@ pub struct SlimData { pub artwork_url: String, } -/// Fetch the discover index and map it into Home sections. -pub async fn load_home(runtime: &Arc>) -> Result +/// Fetch the discover index (optionally genre-filtered) and map it +/// into the Home / Editor's Picks / For You section sets. +pub async fn load_home( + runtime: &Arc>, + genre_ids: Option>, +) -> Result where A: FrontendAdapter + Send + Sync + 'static, { let response = runtime .core() - .get_discover_index(None) + .get_discover_index(genre_ids) .await .map_err(|e| e.to_string())?; let containers = response.containers; diff --git a/crates/qbz-slint/src/main.rs b/crates/qbz-slint/src/main.rs index 12ba47efa..ab77c91f1 100644 --- a/crates/qbz-slint/src/main.rs +++ b/crates/qbz-slint/src/main.rs @@ -23,6 +23,7 @@ mod commands; mod custom_artwork; mod discovery_dismiss; mod favorites; +mod genre_filter; mod home; mod label; mod location_view; @@ -92,74 +93,71 @@ async fn enter_shell( }); } - match home::load_home(&runtime).await { + // Load the genre-filter parents + persisted selection, then seed + // the popup state. Done before the discover load so the first + // fetch honors a remembered genre selection. + genre_filter::load_parents(&runtime).await; + let _ = weak.upgrade_in_event_loop(|w| { + genre_filter::apply_state(&w); + }); + + reload_home(&runtime, &weak, &image_cache, "home".to_string()).await; +} + +/// Fetch the discover index (honoring the shared genre selection), +/// apply all three tab section sets, show the requested tab, and fan +/// out artwork. Shared by the initial shell load and genre-filter / +/// tab re-fetches. +async fn reload_home( + runtime: &Arc>, + weak: &slint::Weak, + image_cache: &artwork::ImageCache, + active_tab: String, +) { + let genre_ids = genre_filter::selected_ids(); + let genre_ids = (!genre_ids.is_empty()).then_some(genre_ids); + + match home::load_home(runtime, genre_ids).await { Ok(data) => { - // Collect artwork jobs before the data is consumed by apply_home. - let mut jobs: Vec = data - .sections - .iter() - .enumerate() - .flat_map(|(section_idx, section)| { - section - .albums - .iter() - .enumerate() - .filter_map(move |(album_idx, card)| { - if card.artwork_url.is_empty() { - None - } else { - Some(artwork::ArtworkJob { - target: artwork::ArtworkTarget::Section { - section_idx, - album_idx, - }, - url: card.artwork_url.clone(), - }) - } - }) - }) - .collect(); + // Artwork for the active tab's section set (Section-targeted, + // so it lands in HomeState.sections once select_tab swaps it + // in below). Built before `data` is moved. + let active_set = match active_tab.as_str() { + "editorPicks" => &data.editor_sections, + "forYou" => &data.foryou_sections, + _ => &data.sections, + }; + let mut jobs = home::section_artwork_jobs(active_set); + // Home-only slim grids (their models are populated regardless + // of the visible tab; harmless to prefetch). jobs.extend(data.popular.iter().enumerate().filter_map(|(idx, slim)| { - if slim.artwork_url.is_empty() { - None - } else { - Some(artwork::ArtworkJob { - target: artwork::ArtworkTarget::Popular { idx }, - url: slim.artwork_url.clone(), - }) - } + (!slim.artwork_url.is_empty()).then(|| artwork::ArtworkJob { + target: artwork::ArtworkTarget::Popular { idx }, + url: slim.artwork_url.clone(), + }) })); jobs.extend(data.recent.iter().enumerate().filter_map(|(idx, slim)| { - if slim.artwork_url.is_empty() { - None - } else { - Some(artwork::ArtworkJob { - target: artwork::ArtworkTarget::Recent { idx }, - url: slim.artwork_url.clone(), - }) - } + (!slim.artwork_url.is_empty()).then(|| artwork::ArtworkJob { + target: artwork::ArtworkTarget::Recent { idx }, + url: slim.artwork_url.clone(), + }) })); - jobs.extend( - data.recent_albums - .iter() - .enumerate() - .filter_map(|(idx, card)| { - if card.artwork_url.is_empty() { - None - } else { - Some(artwork::ArtworkJob { - target: artwork::ArtworkTarget::RecentAlbum { idx }, - url: card.artwork_url.clone(), - }) - } - }), - ); + jobs.extend(data.recent_albums.iter().enumerate().filter_map(|(idx, card)| { + (!card.artwork_url.is_empty()).then(|| artwork::ArtworkJob { + target: artwork::ArtworkTarget::RecentAlbum { idx }, + url: card.artwork_url.clone(), + }) + })); + let weak_for_artwork = weak.clone(); let _ = weak.upgrade_in_event_loop(move |w| { home::apply_home(&w, data); + // apply_home shows the home set; swap to the requested + // tab (no-op when it is "home"). + home::select_tab(&w, &active_tab); w.global::().set_loading(false); }); - artwork::spawn_loads(jobs, weak_for_artwork, image_cache); + artwork::spawn_loads(jobs, weak_for_artwork, image_cache.clone()); } Err(e) => { log::error!("[qbz-slint] discover load failed: {e}"); @@ -1777,6 +1775,79 @@ fn main() -> Result<(), Box> { }); } + // Genre filter — shared selection across the three Discover tabs. + // Toggling / clearing re-fetches the discover index with the new + // genre ids and rebuilds the section sets; the active tab stays. + { + let runtime = app_runtime.clone(); + let weak = window.as_weak(); + let handle = tokio_rt.handle().clone(); + let image_cache = image_cache.clone(); + window + .global::() + .on_toggle(move |id| { + if !genre_filter::toggle(id.as_str()) { + return; + } + let Some(w) = weak.upgrade() else { + return; + }; + genre_filter::apply_state(&w); + w.global::().set_loading(true); + let active = w.global::().get_active_tab().to_string(); + let runtime = runtime.clone(); + let weak = weak.clone(); + let image_cache = image_cache.clone(); + handle.spawn(async move { + reload_home(&runtime, &weak, &image_cache, active).await; + }); + }); + } + { + let runtime = app_runtime.clone(); + let weak = window.as_weak(); + let handle = tokio_rt.handle().clone(); + let image_cache = image_cache.clone(); + window + .global::() + .on_clear(move || { + genre_filter::clear(); + let Some(w) = weak.upgrade() else { + return; + }; + genre_filter::apply_state(&w); + w.global::().set_loading(true); + let active = w.global::().get_active_tab().to_string(); + let runtime = runtime.clone(); + let weak = weak.clone(); + let image_cache = image_cache.clone(); + handle.spawn(async move { + reload_home(&runtime, &weak, &image_cache, active).await; + }); + }); + } + { + let weak = window.as_weak(); + window + .global::() + .on_set_remember(move |v| { + genre_filter::set_remember(v); + if let Some(w) = weak.upgrade() { + genre_filter::apply_state(&w); + } + }); + } + { + let weak = window.as_weak(); + window + .global::() + .on_set_advanced(move |v| { + if let Some(w) = weak.upgrade() { + w.global::().set_advanced(v); + } + }); + } + // Header nav-menu navigation — currently routes the Library // dropdown rows into Library > Favorites tabs. { diff --git a/crates/qbz-slint/ui/app.slint b/crates/qbz-slint/ui/app.slint index 3de855f66..bafcb14f4 100644 --- a/crates/qbz-slint/ui/app.slint +++ b/crates/qbz-slint/ui/app.slint @@ -17,7 +17,7 @@ import { LoginScreen } from "login/LoginScreen.slint"; import { AppShell } from "shell/AppShell.slint"; // Re-export the state globals so the Rust layer can populate them. -export { HomeState, HomeActions, AlbumState, ArtistState, NavState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions } from "state.slint"; +export { HomeState, HomeActions, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions } from "state.slint"; // Which top-level screen is shown. The app starts on `splash` while it // restores a saved session, then resolves to `shell` or `login`. diff --git a/crates/qbz-slint/ui/discover/GenreFilterPopup.slint b/crates/qbz-slint/ui/discover/GenreFilterPopup.slint new file mode 100644 index 000000000..ea1cf31b0 --- /dev/null +++ b/crates/qbz-slint/ui/discover/GenreFilterPopup.slint @@ -0,0 +1,232 @@ +// Filter-by-genre popup — ported from Tauri's GenreFilterPopup. +// Simple grid view (parent genres as selectable chips). The selection +// is shared across the three Discover tabs and re-fetches the discover +// index. The Advanced view (search + 3-level tree + counts) is a +// later increment; its toggle is present but its body is a stub. + +import { Theme } from "../foundation/semantic-colors.slint"; +import { Typography } from "../foundation/typography.slint"; +import { Radius } from "../foundation/radius.slint"; +import { GenreFilterState, GenreFilterActions, GenreChip } from "../state.slint"; +import { QbzIcon } from "../primitives/QbzIcon.slint"; +import { QbzToggle } from "../primitives/QbzToggle.slint"; + +component GenreCard inherits Rectangle { + in property chip; + callback clicked(string); + + height: 38px; + border-radius: 6px; + background: root.chip.selected + ? Theme.accent + : (ta.has-hover ? Theme.surface-hover : Theme.surface-elevated); + + HorizontalLayout { + padding-left: 12px; + padding-right: 12px; + spacing: 8px; + Text { + text: root.chip.name; + color: root.chip.selected ? #ffffff : Theme.text-secondary; + font-size: 13px; + font-weight: root.chip.selected ? Typography.medium : Typography.regular; + vertical-alignment: center; + horizontal-stretch: 1; + overflow: elide; + } + // Check circle. + VerticalLayout { + alignment: center; + Rectangle { + width: 16px; + height: 16px; + border-radius: 8px; + border-width: 1.5px; + border-color: root.chip.selected ? #ffffff : Theme.border-muted; + background: root.chip.selected ? #ffffff : transparent; + QbzIcon { + visible: root.chip.selected; + source: @image-url("../assets/icons/check.svg"); + width: 11px; + height: 11px; + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + tint: Theme.accent; + } + } + } + } + + ta := TouchArea { + mouse-cursor: pointer; + clicked => { + root.clicked(root.chip.id); + } + } +} + +export component GenreFilterPopup inherits Rectangle { + property columns: 3; + property grid-gap: 8px; + property chip-height: 38px; + + width: 470px; + background: Theme.surface-main; + border-radius: Radius.md; + border-width: 1px; + border-color: Theme.border-muted; + drop-shadow-blur: 28px; + drop-shadow-color: #00000080; + drop-shadow-offset-y: 8px; + + // Height hugs the content. + VerticalLayout { + padding: 16px; + spacing: 14px; + + // --- Header -------------------------------------------------- + HorizontalLayout { + spacing: 8px; + VerticalLayout { + alignment: center; + QbzIcon { + source: @image-url("../assets/icons/list-filter.svg"); + width: 16px; + height: 16px; + tint: Theme.text-secondary; + } + } + Text { + text: @tr("Filter by genre"); + color: Theme.text-primary; + font-size: Typography.heading; + font-weight: Typography.semibold; + vertical-alignment: center; + horizontal-stretch: 1; + } + Rectangle { + width: 26px; + height: 26px; + border-radius: 6px; + background: close-ta.has-hover ? Theme.surface-hover : transparent; + QbzIcon { + source: @image-url("../assets/icons/x.svg"); + width: 15px; + height: 15px; + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + tint: close-ta.has-hover ? Theme.text-primary : Theme.text-muted; + } + close-ta := TouchArea { + mouse-cursor: pointer; + clicked => { + GenreFilterState.open = false; + } + } + } + } + + // --- Options row: Remember selection + Advanced view --------- + HorizontalLayout { + spacing: 16px; + HorizontalLayout { + spacing: 10px; + horizontal-stretch: 1; + Text { + text: @tr("Remember selection"); + color: Theme.text-secondary; + font-size: 13px; + vertical-alignment: center; + horizontal-stretch: 1; + } + VerticalLayout { + alignment: center; + QbzToggle { + checked: GenreFilterState.remember; + toggled(v) => { + GenreFilterActions.set-remember(v); + } + } + } + } + HorizontalLayout { + spacing: 10px; + horizontal-stretch: 1; + Text { + text: @tr("Advanced view"); + color: Theme.text-secondary; + font-size: 13px; + vertical-alignment: center; + horizontal-stretch: 1; + } + VerticalLayout { + alignment: center; + QbzToggle { + checked: GenreFilterState.advanced; + toggled(v) => { + GenreFilterActions.set-advanced(v); + } + } + } + } + } + + // --- Advanced view stub (tree/search/counts: next increment) - + if GenreFilterState.advanced: Rectangle { + height: 80px; + Text { + text: @tr("Advanced view coming soon."); + color: Theme.text-muted; + font-size: 13px; + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + } + } + + // --- Simple grid: parent genres as chips --------------------- + if !GenreFilterState.advanced: grid := Rectangle { + property count: GenreFilterState.genres.length; + property rows: Math.ceil(self.count / root.columns); + property cell-width: + (self.width - (root.columns - 1) * root.grid-gap) / root.columns; + height: self.rows > 0 + ? self.rows * root.chip-height + Math.max(0, self.rows - 1) * root.grid-gap + : 0px; + + for chip[i] in GenreFilterState.genres: GenreCard { + chip: chip; + width: grid.cell-width; + x: Math.mod(i, root.columns) * (grid.cell-width + root.grid-gap); + y: Math.floor(i / root.columns) * (root.chip-height + root.grid-gap); + clicked(id) => { + GenreFilterActions.toggle(id); + } + } + } + + // --- Footer: Clear filter ------------------------------------ + Rectangle { + height: 34px; + border-radius: 6px; + background: clear-ta.has-hover && GenreFilterState.selected-count > 0 + ? Theme.surface-hover + : Theme.surface-elevated; + opacity: GenreFilterState.selected-count > 0 ? 1.0 : 0.5; + Text { + text: @tr("Clear filter"); + color: Theme.text-secondary; + font-size: 13px; + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + } + clear-ta := TouchArea { + mouse-cursor: GenreFilterState.selected-count > 0 ? pointer : default; + clicked => { + if (GenreFilterState.selected-count > 0) { + GenreFilterActions.clear(); + } + } + } + } + } +} diff --git a/crates/qbz-slint/ui/discover/HomeView.slint b/crates/qbz-slint/ui/discover/HomeView.slint index 47c418206..30d804129 100644 --- a/crates/qbz-slint/ui/discover/HomeView.slint +++ b/crates/qbz-slint/ui/discover/HomeView.slint @@ -8,10 +8,11 @@ import { Theme } from "../foundation/semantic-colors.slint"; import { Typography } from "../foundation/typography.slint"; -import { HomeState, HomeActions, NavState, ContentView } from "../state.slint"; +import { HomeState, HomeActions, GenreFilterState, NavState, ContentView } from "../state.slint"; import { Carousel } from "Carousel.slint"; import { SlimCarousel } from "SlimCarousel.slint"; import { HomeSkeleton } from "HomeSkeleton.slint"; +import { GenreFilterPopup } from "GenreFilterPopup.slint"; import { QbzIcon } from "../primitives/QbzIcon.slint"; import { NavButtons } from "../shell/NavButtons.slint"; @@ -67,11 +68,17 @@ component Tab inherits Rectangle { component GenreButton inherits Rectangle { callback clicked(); + // Accent-filled + "N genres" label when a filter is active, + // matching Tauri. + property active: GenreFilterState.selected-count > 0; + // Hug the content — the button is only as wide as its label. width: genre-row.preferred-width; height: 32px; border-radius: 6px; - background: ta.has-hover ? Theme.surface-hover : Theme.surface-elevated; + background: root.active + ? Theme.accent + : (ta.has-hover ? Theme.surface-hover : Theme.surface-elevated); genre-row := HorizontalLayout { padding-left: 12px; @@ -83,13 +90,18 @@ component GenreButton inherits Rectangle { source: @image-url("../assets/icons/list-filter.svg"); width: 14px; height: 14px; - tint: Theme.text-secondary; + tint: root.active ? #ffffff : Theme.text-secondary; } } Text { - text: @tr("Filter by genre"); - color: Theme.text-secondary; + text: root.active + ? (GenreFilterState.selected-count == 1 + ? @tr("1 genre") + : @tr("{} genres", GenreFilterState.selected-count)) + : @tr("Filter by genre"); + color: root.active ? #ffffff : Theme.text-secondary; font-size: 13px; + font-weight: root.active ? Typography.medium : Typography.regular; vertical-alignment: center; } } @@ -204,7 +216,11 @@ export component HomeView inherits Rectangle { width: self.preferred-width; height: self.preferred-height; spacing: 6px; - GenreButton { } + GenreButton { + clicked => { + GenreFilterState.open = !GenreFilterState.open; + } + } // Per-tab sort / show-hide control for the Discover // sections (not global Settings — that lives in the // header menu). Its menu is a later increment. @@ -294,4 +310,21 @@ export component HomeView inherits Rectangle { } } } + + // --- Genre filter overlay -------------------------------------------- + // Backdrop closes the popup on outside click; the panel anchors to + // the top-right under the Filter-by-genre button. + if GenreFilterState.open: Rectangle { + width: 100%; + height: 100%; + TouchArea { + clicked => { + GenreFilterState.open = false; + } + } + } + if GenreFilterState.open: GenreFilterPopup { + x: root.width - self.width - 32px; + y: 52px; + } } diff --git a/crates/qbz-slint/ui/state.slint b/crates/qbz-slint/ui/state.slint index fecc1737b..a60a47792 100644 --- a/crates/qbz-slint/ui/state.slint +++ b/crates/qbz-slint/ui/state.slint @@ -56,6 +56,33 @@ export global HomeActions { callback select-tab(string); } +// One genre in the Filter-by-genre popup's simple grid. +export struct GenreChip { + id: string, + name: string, + selected: bool, +} + +// Filter-by-genre state. The selection is shared across the three +// Discover tabs (matching Tauri's single "home" genre context) — it +// re-fetches the discover index with the chosen genre ids. Populated +// by crate::genre_filter. +export global GenreFilterState { + in property <[GenreChip]> genres: []; + in property selected-count: 0; + in-out property open: false; + in-out property remember: true; + in-out property advanced: false; + in property loading: false; +} + +export global GenreFilterActions { + callback toggle(string /* genre id */); + callback clear(); + callback set-remember(bool); + callback set-advanced(bool); +} + export struct AlbumTrackItem { id: string, number: string,