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>,
|
bridge: State<'_, CoreBridgeState>,
|
||||||
blacklist_state: State<'_, BlacklistState>,
|
blacklist_state: State<'_, BlacklistState>,
|
||||||
runtime: State<'_, RuntimeManagerState>,
|
runtime: State<'_, RuntimeManagerState>,
|
||||||
|
app: tauri::AppHandle,
|
||||||
) -> Result<crate::musicbrainz::LocationDiscoveryResponse, RuntimeError> {
|
) -> Result<crate::musicbrainz::LocationDiscoveryResponse, RuntimeError> {
|
||||||
use crate::musicbrainz::genre_normalization::{extract_affinity_seeds, genre_summary, is_broad_genre};
|
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};
|
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 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
|
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
|
let search_result = state
|
||||||
.client
|
.client
|
||||||
.search_artists_by_tag_and_area(genre, &search_name, country.as_deref(), per_genre_limit, 0)
|
.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
|
// Step 4: Validate against Qobuz
|
||||||
let bridge_guard = bridge.try_get().await;
|
let bridge_guard = bridge.try_get().await;
|
||||||
let mut validated: Vec<LocationCandidate> = Vec::new();
|
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 {
|
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
|
// Qobuz validation cache — TEMPORARILY DISABLED for tuning
|
||||||
let _name_normalized =
|
let _name_normalized =
|
||||||
crate::musicbrainz::cache::MusicBrainzCache::normalize_name(mb_name);
|
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!(
|
log::info!(
|
||||||
"[V2] Scene discovery complete: {} validated artists from {}",
|
"[V2] Scene discovery complete: {} validated artists from {}",
|
||||||
validated.len(),
|
validated.len(),
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
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 VirtualizedFavoritesArtistGrid from '../VirtualizedFavoritesArtistGrid.svelte';
|
||||||
import VirtualizedFavoritesArtistList from '../VirtualizedFavoritesArtistList.svelte';
|
import VirtualizedFavoritesArtistList from '../VirtualizedFavoritesArtistList.svelte';
|
||||||
import AlbumCard from '../AlbumCard.svelte';
|
import AlbumCard from '../AlbumCard.svelte';
|
||||||
@@ -97,37 +98,56 @@
|
|||||||
let loadingAlbums = $state(false);
|
let loadingAlbums = $state(false);
|
||||||
let albumsError = $state<string | null>(null);
|
let albumsError = $state<string | null>(null);
|
||||||
|
|
||||||
// Dynamic loading state
|
// Loading progress state
|
||||||
let loadingStep = $state(0);
|
let loadingProgress = $state(0);
|
||||||
let loadingStepTimer: ReturnType<typeof setInterval> | null = null;
|
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 = [
|
// Icon cycle: Music → Globe → Share2 (network-like)
|
||||||
() => {
|
const LOADING_ICONS = ['music', 'globe', 'network'] as const;
|
||||||
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'),
|
|
||||||
];
|
|
||||||
|
|
||||||
function startLoadingAnimation() {
|
function startLoadingAnimation() {
|
||||||
loadingStep = 0;
|
loadingIconIndex = 0;
|
||||||
loadingStepTimer = setInterval(() => {
|
iconCycleTimer = setInterval(() => {
|
||||||
if (loadingStep < loadingSteps.length - 1) {
|
loadingIconIndex = (loadingIconIndex + 1) % LOADING_ICONS.length;
|
||||||
loadingStep++;
|
}, 1500);
|
||||||
}
|
|
||||||
}, 3000);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopLoadingAnimation() {
|
function stopLoadingAnimation() {
|
||||||
if (loadingStepTimer) {
|
if (iconCycleTimer) {
|
||||||
clearInterval(loadingStepTimer);
|
clearInterval(iconCycleTimer);
|
||||||
loadingStepTimer = null;
|
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 () => {
|
onMount(async () => {
|
||||||
loading = true;
|
loading = true;
|
||||||
error = null;
|
error = null;
|
||||||
|
loadingProgress = 0;
|
||||||
|
loadingPhase = 'searching';
|
||||||
|
await setupProgressListener();
|
||||||
startLoadingAnimation();
|
startLoadingAnimation();
|
||||||
await discoverArtists();
|
await discoverArtists();
|
||||||
stopLoadingAnimation();
|
stopLoadingAnimation();
|
||||||
|
cleanupProgressListener();
|
||||||
loading = false;
|
loading = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
stopLoadingAnimation();
|
stopLoadingAnimation();
|
||||||
|
cleanupProgressListener();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="scene-view">
|
<div class="scene-view">
|
||||||
|
{#if !loading}
|
||||||
<!-- Top bar with back button -->
|
<!-- Top bar with back button -->
|
||||||
<div class="top-bar">
|
<div class="top-bar">
|
||||||
<button class="back-btn" onclick={onBack}>
|
<button class="back-btn" onclick={onBack}>
|
||||||
@@ -391,6 +417,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Nav bar with search + controls -->
|
<!-- Nav bar with search + controls -->
|
||||||
{#if !loading && !error && allArtists.length > 0}
|
{#if !loading && !error && allArtists.length > 0}
|
||||||
@@ -518,20 +545,27 @@
|
|||||||
<div class="scene-loading">
|
<div class="scene-loading">
|
||||||
<div class="loading-visual">
|
<div class="loading-visual">
|
||||||
<div class="loading-pulse">
|
<div class="loading-pulse">
|
||||||
<Music size={28} />
|
{#key loadingIconIndex}
|
||||||
</div>
|
<span class="icon-fade">
|
||||||
</div>
|
{#if LOADING_ICONS[loadingIconIndex] === 'music'}
|
||||||
<div class="loading-status">
|
<Music size={42} />
|
||||||
{#key loadingStep}
|
{:else if LOADING_ICONS[loadingIconIndex] === 'globe'}
|
||||||
<span class="loading-text fade-in">
|
<Globe size={42} />
|
||||||
{loadingSteps[loadingStep]()}
|
{:else}
|
||||||
|
<Share2 size={42} />
|
||||||
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
{/key}
|
{/key}
|
||||||
</div>
|
</div>
|
||||||
<div class="loading-dots">
|
</div>
|
||||||
<span class="dot"></span>
|
|
||||||
<span class="dot"></span>
|
<div class="loading-progress-bar">
|
||||||
<span class="dot"></span>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
{:else if error}
|
{:else if error}
|
||||||
@@ -1182,8 +1216,8 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 20px;
|
gap: 24px;
|
||||||
padding: 100px 0;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-visual {
|
.loading-visual {
|
||||||
@@ -1196,24 +1230,51 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 64px;
|
width: 96px;
|
||||||
height: 64px;
|
height: 96px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
color: var(--accent-primary);
|
color: var(--accent-primary);
|
||||||
animation: pulse 2s ease-in-out infinite;
|
animation: pulse 2s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes pulse {
|
.icon-fade {
|
||||||
0%, 100% { transform: scale(1); opacity: 0.8; }
|
|
||||||
50% { transform: scale(1.08); opacity: 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-status {
|
|
||||||
min-height: 20px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: 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 {
|
.loading-text {
|
||||||
@@ -1223,34 +1284,10 @@
|
|||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fade-in {
|
.loading-percent {
|
||||||
animation: fadeIn 400ms ease-out;
|
font-size: 12px;
|
||||||
}
|
color: var(--text-muted);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
@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); }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Error and empty states */
|
/* Error and empty states */
|
||||||
|
|||||||
Reference in new issue
Block a user