feat: real progress bar and improved loading state for scene discovery

Backend emits 'scene-discovery-progress' events with phase, progress
percentage, and detail. Frontend listens and shows a real progress bar
(0-40% MB search, 40-95% Qobuz validation, 100% done).

Loading state: hides header, cycles icons (Music/Globe/Network) at
1.5s intervals in a 96px pulse circle, shows progress bar with
percentage text.
This commit is contained in:
vicrodh committed 2026-03-09 17:29:14 -06:00
1 parent 3050999d60
commit faa68d1079
2 files changed
+146 -78

No files matched your search

+33 -2
View File
@@ -8819,6 +8819,7 @@ pub async fn v2_discover_artists_by_location(
bridge: State<'_, CoreBridgeState>,
blacklist_state: State<'_, BlacklistState>,
runtime: State<'_, RuntimeManagerState>,
app: tauri::AppHandle,
) -> Result<crate::musicbrainz::LocationDiscoveryResponse, RuntimeError> {
use crate::musicbrainz::genre_normalization::{extract_affinity_seeds, genre_summary, is_broad_genre};
use crate::musicbrainz::location_discovery::{build_scene_cache_key, compute_affinity_score};
@@ -8969,7 +8970,15 @@ pub async fn v2_discover_artists_by_location(
let mut candidate_map: HashMap<String, (String, i32, usize, Vec<String>)> = HashMap::new();
let per_genre_limit = 200; // Get up to 200 per genre query for broader coverage
for genre in &search_genres {
let total_genres = search_genres.len();
for (genre_idx, genre) in search_genres.iter().enumerate() {
// Progress: MB search phase = 0-40%
let progress = ((genre_idx as f64 / total_genres as f64) * 40.0) as u8;
let _ = app.emit("scene-discovery-progress", serde_json::json!({
"phase": "searching",
"progress": progress,
"detail": genre
}));
let search_result = state
.client
.search_artists_by_tag_and_area(genre, &search_name, country.as_deref(), per_genre_limit, 0)
@@ -9096,9 +9105,25 @@ pub async fn v2_discover_artists_by_location(
// Step 4: Validate against Qobuz
let bridge_guard = bridge.try_get().await;
let mut validated: Vec<LocationCandidate> = Vec::new();
let total_to_validate = candidates_to_validate.len();
let _ = app.emit("scene-discovery-progress", serde_json::json!({
"phase": "validating",
"progress": 40,
"detail": format!("{} candidates", total_to_validate)
}));
if let Some(ref core_bridge) = bridge_guard {
for (mbid, mb_name, candidate_genres, score) in &candidates_to_validate {
for (validate_idx, (mbid, mb_name, candidate_genres, score)) in candidates_to_validate.iter().enumerate() {
// Progress: Qobuz validation phase = 40-95%
if validate_idx % 5 == 0 {
let progress = 40 + ((validate_idx as f64 / total_to_validate as f64) * 55.0) as u8;
let _ = app.emit("scene-discovery-progress", serde_json::json!({
"phase": "validating",
"progress": progress,
"detail": format!("{}/{}", validate_idx, total_to_validate)
}));
}
// Qobuz validation cache — TEMPORARILY DISABLED for tuning
let _name_normalized =
crate::musicbrainz::cache::MusicBrainzCache::normalize_name(mb_name);
@@ -9164,6 +9189,12 @@ pub async fn v2_discover_artists_by_location(
}
}
let _ = app.emit("scene-discovery-progress", serde_json::json!({
"phase": "done",
"progress": 100,
"detail": format!("{} artists", validated.len())
}));
log::info!(
"[V2] Scene discovery complete: {} validated artists from {}",
validated.len(),
@@ -2,7 +2,8 @@
import { onMount, onDestroy } from 'svelte';
import { t } from '$lib/i18n';
import { invoke } from '@tauri-apps/api/core';
import { ArrowLeft, Loader2, Music, Search, X, LayoutGrid, PanelLeftClose, Mic2, Disc3, ChevronDown, Filter } from 'lucide-svelte';
import { listen } from '@tauri-apps/api/event';
import { ArrowLeft, Loader2, Music, Search, X, LayoutGrid, PanelLeftClose, Mic2, Disc3, ChevronDown, Filter, Globe, Share2 } from 'lucide-svelte';
import VirtualizedFavoritesArtistGrid from '../VirtualizedFavoritesArtistGrid.svelte';
import VirtualizedFavoritesArtistList from '../VirtualizedFavoritesArtistList.svelte';
import AlbumCard from '../AlbumCard.svelte';
@@ -97,37 +98,56 @@
let loadingAlbums = $state(false);
let albumsError = $state<string | null>(null);
// Dynamic loading state
let loadingStep = $state(0);
let loadingStepTimer: ReturnType<typeof setInterval> | null = null;
// Loading progress state
let loadingProgress = $state(0);
let loadingPhase = $state('searching');
let loadingDetail = $state('');
let loadingIconIndex = $state(0);
let iconCycleTimer: ReturnType<typeof setInterval> | null = null;
let progressUnlisten: (() => void) | null = null;
const loadingSteps = [
() => {
const genre = context.affinitySeeds.genres[0] || context.affinitySeeds.tags[0] || 'music';
return $t('artist.sceneStep1', { values: { genre } });
},
() => {
const genres = context.affinitySeeds.genres.slice(0, 3);
const count = genres.length * 50;
return $t('artist.sceneStep2', { values: { count: String(count) } });
},
() => $t('artist.sceneStep3'),
() => $t('artist.sceneStep4'),
];
// Icon cycle: Music → Globe → Share2 (network-like)
const LOADING_ICONS = ['music', 'globe', 'network'] as const;
function startLoadingAnimation() {
loadingStep = 0;
loadingStepTimer = setInterval(() => {
if (loadingStep < loadingSteps.length - 1) {
loadingStep++;
}
}, 3000);
loadingIconIndex = 0;
iconCycleTimer = setInterval(() => {
loadingIconIndex = (loadingIconIndex + 1) % LOADING_ICONS.length;
}, 1500);
}
function stopLoadingAnimation() {
if (loadingStepTimer) {
clearInterval(loadingStepTimer);
loadingStepTimer = null;
if (iconCycleTimer) {
clearInterval(iconCycleTimer);
iconCycleTimer = null;
}
}
async function setupProgressListener() {
progressUnlisten = await listen<{ phase: string; progress: number; detail: string }>('scene-discovery-progress', (event) => {
loadingProgress = event.payload.progress;
loadingPhase = event.payload.phase;
loadingDetail = event.payload.detail;
});
}
function cleanupProgressListener() {
if (progressUnlisten) {
progressUnlisten();
progressUnlisten = null;
}
}
function getPhaseLabel(): string {
switch (loadingPhase) {
case 'searching':
return $t('artist.sceneStep1', { values: { genre: loadingDetail || 'music' } });
case 'validating':
return $t('artist.sceneStep3');
case 'done':
return $t('artist.sceneStep4');
default:
return $t('artist.sceneStep1', { values: { genre: 'music' } });
}
}
@@ -350,18 +370,24 @@
onMount(async () => {
loading = true;
error = null;
loadingProgress = 0;
loadingPhase = 'searching';
await setupProgressListener();
startLoadingAnimation();
await discoverArtists();
stopLoadingAnimation();
cleanupProgressListener();
loading = false;
});
onDestroy(() => {
stopLoadingAnimation();
cleanupProgressListener();
});
</script>
<div class="scene-view">
{#if !loading}
<!-- Top bar with back button -->
<div class="top-bar">
<button class="back-btn" onclick={onBack}>
@@ -391,6 +417,7 @@
{/if}
</div>
</div>
{/if}
<!-- Nav bar with search + controls -->
{#if !loading && !error && allArtists.length > 0}
@@ -518,20 +545,27 @@
<div class="scene-loading">
<div class="loading-visual">
<div class="loading-pulse">
<Music size={28} />
</div>
</div>
<div class="loading-status">
{#key loadingStep}
<span class="loading-text fade-in">
{loadingSteps[loadingStep]()}
{#key loadingIconIndex}
<span class="icon-fade">
{#if LOADING_ICONS[loadingIconIndex] === 'music'}
<Music size={42} />
{:else if LOADING_ICONS[loadingIconIndex] === 'globe'}
<Globe size={42} />
{:else}
<Share2 size={42} />
{/if}
</span>
{/key}
</div>
<div class="loading-dots">
<span class="dot"></span>
<span class="dot"></span>
<span class="dot"></span>
</div>
<div class="loading-progress-bar">
<div class="loading-progress-fill" style="width: {loadingProgress}%"></div>
</div>
<div class="loading-status">
<span class="loading-text">{getPhaseLabel()}</span>
<span class="loading-percent">{loadingProgress}%</span>
</div>
</div>
{:else if error}
@@ -1182,8 +1216,8 @@
flex-direction: column;
align-items: center;
justify-content: center;
gap: 20px;
padding: 100px 0;
gap: 24px;
flex: 1;
}
.loading-visual {
@@ -1196,24 +1230,51 @@
display: flex;
align-items: center;
justify-content: center;
width: 64px;
height: 64px;
width: 96px;
height: 96px;
border-radius: 50%;
background: var(--bg-secondary);
color: var(--accent-primary);
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { transform: scale(1); opacity: 0.8; }
50% { transform: scale(1.08); opacity: 1; }
}
.loading-status {
min-height: 20px;
.icon-fade {
display: flex;
align-items: center;
justify-content: center;
animation: iconFadeIn 400ms ease-out;
}
@keyframes iconFadeIn {
from { opacity: 0; transform: scale(0.8); }
to { opacity: 1; transform: scale(1); }
}
@keyframes pulse {
0%, 100% { transform: scale(1); opacity: 0.8; }
50% { transform: scale(1.06); opacity: 1; }
}
.loading-progress-bar {
width: 280px;
height: 4px;
border-radius: 2px;
background: var(--bg-tertiary);
overflow: hidden;
}
.loading-progress-fill {
height: 100%;
border-radius: 2px;
background: var(--accent-primary);
transition: width 300ms ease-out;
}
.loading-status {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.loading-text {
@@ -1223,34 +1284,10 @@
line-height: 1.4;
}
.fade-in {
animation: fadeIn 400ms ease-out;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
.loading-dots {
display: flex;
gap: 6px;
}
.dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--text-muted);
animation: dotBounce 1.4s ease-in-out infinite;
}
.dot:nth-child(2) { animation-delay: 0.2s; }
.dot:nth-child(3) { animation-delay: 0.4s; }
@keyframes dotBounce {
0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); }
40% { opacity: 1; transform: scale(1); }
.loading-percent {
font-size: 12px;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
/* Error and empty states */