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:
2 files changed
+146
-78
No files matched your search
@@ -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 */
|
||||
|
||||
Reference in new issue
Block a user