diff --git a/webui/index.html b/webui/index.html index 3d4c6b63..7ff29dc5 100644 --- a/webui/index.html +++ b/webui/index.html @@ -6184,147 +6184,6 @@ - -
-
-
-
- Stats -

Listening Stats

-
-
-
- - - - -
-
- - -
-
-
- - -
-
-
0
-
Total Plays
-
-
-
0h
-
Listening Time
-
-
-
0
-
Artists
-
-
-
0
-
Albums
-
-
-
0
-
Tracks
-
-
- - -
-
-
-
Listening Activity
-
- -
-
-
-
Genre Breakdown
-
- -
-
-
-
-
Recently Played
-
-
-
-
-
-
Top Artists
-
-
-
-
-
Top Albums
-
-
-
-
Top Tracks
-
-
-
-
- - -
-
Library Health
-
-
-
Format Breakdown
-
-
-
-
0
-
Unplayed Tracks
-
-
-
0h
-
Total Duration
-
-
-
0
-
Total Tracks
-
-
-
-
- - -
-
Library Disk Usage
-
-
-
โ€”
-
Run a Deep Scan to populate
-
-
-
-
- - -
-
Database Storage
-
-
- -
-
-
-
-
- - - -
-
-
diff --git a/webui/src/routes/stats/-ui/stats-page.tsx b/webui/src/routes/stats/-ui/stats-page.tsx index 1b162df6..1ab9654a 100644 --- a/webui/src/routes/stats/-ui/stats-page.tsx +++ b/webui/src/routes/stats/-ui/stats-page.tsx @@ -134,14 +134,19 @@ export function StatsPage() { }; return ( -
+
Stats

Listening Stats

-
+
{(['7d', '30d', '12m', 'all'] as const).map((option) => ( - ${_fmt(item.play_count)} plays -
- `); - - // Timeline chart - _renderTimelineChart(data.timeline || []); - - // Genre chart - _renderGenreChart(data.genres || []); - - // Library health - _renderLibraryHealth(data.health || {}); - - // DB storage chart (separate fetch โ€” not part of cached stats) - _loadDbStorageChart(); - // Library disk usage (separate fetch โ€” populated by deep scan) - _loadLibraryDiskUsage(); - - // Recent plays - _renderRecentPlays(data.recent || []); -} - -function _renderTopArtistsVisual(artists) { - const el = document.getElementById('stats-top-artists-visual'); - if (!el || !artists.length) { if (el) el.innerHTML = ''; return; } - - const top5 = artists.slice(0, 5); - const maxPlays = top5[0]?.play_count || 1; - const _fmt = (n) => { - if (!n) return '0'; - if (n >= 1000000) return (n / 1000000).toFixed(1).replace(/\.0$/, '') + 'M'; - if (n >= 1000) return (n / 1000).toFixed(1).replace(/\.0$/, '') + 'K'; - return n.toString(); - }; - - el.innerHTML = `
- ${top5.map((a, i) => { - const pct = Math.round((a.play_count / maxPlays) * 100); - const size = 44 + (4 - i) * 6; // Largest first: 68, 62, 56, 50, 44 - return ` -
- ${!a.image_url ? `${(a.name || '?')[0]}` : ''} -
-
-
-
-
${_esc(a.name)}
-
${_fmt(a.play_count)}
-
`; - }).join('')} -
`; -} - -function _setText(id, text) { - const el = document.getElementById(id); - if (el) el.textContent = text; -} - -function _renderRankedList(containerId, items, template) { - const el = document.getElementById(containerId); - if (!el) return; - el.innerHTML = items.length - ? items.map((item, i) => template(item, i)).join('') - : '
No data yet
'; -} - -function _renderTimelineChart(data) { - const canvas = document.getElementById('stats-timeline-chart'); - if (!canvas || typeof Chart === 'undefined') return; - - if (_statsTimelineChart) _statsTimelineChart.destroy(); - - _statsTimelineChart = new Chart(canvas, { - type: 'bar', - data: { - labels: data.map(d => d.date), - datasets: [{ - label: 'Plays', - data: data.map(d => d.plays), - backgroundColor: `rgba(${getComputedStyle(document.documentElement).getPropertyValue('--accent-rgb').trim() || '29,185,84'}, 0.5)`, - borderColor: `rgba(${getComputedStyle(document.documentElement).getPropertyValue('--accent-rgb').trim() || '29,185,84'}, 0.8)`, - borderWidth: 1, - borderRadius: 4, - }] - }, - options: { - responsive: true, - maintainAspectRatio: false, - plugins: { legend: { display: false } }, - scales: { - x: { grid: { display: false }, ticks: { color: 'rgba(255,255,255,0.3)', font: { size: 10 }, maxTicksLimit: 12 } }, - y: { grid: { color: 'rgba(255,255,255,0.04)' }, ticks: { color: 'rgba(255,255,255,0.3)', font: { size: 10 } }, beginAtZero: true }, - } - } - }); -} - -function _renderGenreChart(data) { - const canvas = document.getElementById('stats-genre-chart'); - const legend = document.getElementById('stats-genre-legend'); - if (!canvas || typeof Chart === 'undefined') return; - - if (_statsGenreChart) _statsGenreChart.destroy(); - - const colors = [ - '#1db954', '#1ed760', '#4ade80', '#7c3aed', '#a855f7', - '#ec4899', '#f43f5e', '#f97316', '#eab308', '#06b6d4', - '#3b82f6', '#6366f1', '#14b8a6', '#84cc16', '#f59e0b', - ]; - - const top = data.slice(0, 10); - - _statsGenreChart = new Chart(canvas, { - type: 'doughnut', - data: { - labels: top.map(g => g.genre), - datasets: [{ - data: top.map(g => g.play_count), - backgroundColor: colors.slice(0, top.length), - borderWidth: 0, - hoverOffset: 6, - }] - }, - options: { - responsive: true, - maintainAspectRatio: true, - cutout: '65%', - plugins: { legend: { display: false } }, - } - }); - - if (legend) { - legend.innerHTML = top.map((g, i) => ` -
- - ${g.genre} - ${g.percentage}% -
- `).join(''); - } -} - -function _renderLibraryHealth(data) { - if (!data || !data.total_tracks) return; - - const _fmt = (n) => { - if (!n) return '0'; - if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M'; - if (n >= 1000) return (n / 1000).toFixed(1) + 'K'; - return n.toLocaleString(); - }; - - _setText('stats-unplayed', `${_fmt(data.unplayed_count)} (${data.unplayed_percentage || 0}%)`); - _setText('stats-total-duration', data.total_duration_ms ? `${Math.floor(data.total_duration_ms / 3600000)}h` : '0h'); - _setText('stats-total-tracks-count', _fmt(data.total_tracks)); - - // Format bar - const bar = document.getElementById('stats-format-bar'); - if (bar && data.format_breakdown) { - const total = Object.values(data.format_breakdown).reduce((s, v) => s + v, 0) || 1; - const fmtColors = { FLAC: '#3b82f6', MP3: '#f97316', Opus: '#a855f7', AAC: '#14b8a6', OGG: '#eab308', WAV: '#ec4899', Other: '#555' }; - - bar.innerHTML = Object.entries(data.format_breakdown).map(([fmt, count]) => { - const pct = (count / total * 100).toFixed(1); - return `
${pct > 8 ? fmt : ''}
`; - }).join(''); - } - - // Enrichment coverage - const enrichEl = document.getElementById('stats-enrichment-coverage'); - if (enrichEl && data.enrichment_coverage) { - const ec = data.enrichment_coverage; - const services = [ - { name: 'Spotify', pct: ec.spotify || 0, color: '#1db954' }, - { name: 'MusicBrainz', pct: ec.musicbrainz || 0, color: '#ba55d3' }, - { name: 'Deezer', pct: ec.deezer || 0, color: '#a238ff' }, - { name: 'Last.fm', pct: ec.lastfm || 0, color: '#d51007' }, - { name: 'iTunes', pct: ec.itunes || 0, color: '#fc3c44' }, - { name: 'AudioDB', pct: ec.audiodb || 0, color: '#1a9fff' }, - { name: 'Genius', pct: ec.genius || 0, color: '#ffff64' }, - { name: 'Tidal', pct: ec.tidal || 0, color: '#00ffff' }, - { name: 'Qobuz', pct: ec.qobuz || 0, color: '#4285f4' }, - ]; - enrichEl.innerHTML = services.map(s => ` -
- ${s.name} -
- ${s.pct}% -
- `).join(''); - } -} - -async function _loadDbStorageChart() { - try { - const resp = await fetch('/api/stats/db-storage'); - const data = await resp.json(); - if (!data.success || !data.tables || !data.tables.length) return; - _renderDbStorageChart(data.tables, data.total_file_size, data.method); - } catch (e) { - console.debug('DB storage chart load failed:', e); - } -} - -async function _loadLibraryDiskUsage() { - try { - const resp = await fetch('/api/stats/library-disk-usage'); - const data = await resp.json(); - if (!data.success) return; - _renderLibraryDiskUsage(data); - } catch (e) { - console.debug('Library disk usage load failed:', e); - } -} - -function _formatBytes(n) { - if (!n || n <= 0) return '0 B'; - const units = ['B', 'KB', 'MB', 'GB', 'TB']; - let i = 0; - let v = n; - while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; } - return `${v.toFixed(v < 10 ? 2 : 1)} ${units[i]}`; -} - -function _renderLibraryDiskUsage(data) { - const totalEl = document.getElementById('stats-disk-total-value'); - const metaEl = document.getElementById('stats-disk-total-meta'); - const formatsEl = document.getElementById('stats-disk-formats'); - if (!totalEl || !metaEl || !formatsEl) return; - - if (!data.has_data || !data.total_bytes) { - totalEl.textContent = 'โ€”'; - metaEl.textContent = data.tracks_without_size > 0 - ? `Run a Deep Scan to populate (${data.tracks_without_size.toLocaleString()} tracks pending)` - : 'No tracks in library yet'; - formatsEl.innerHTML = ''; - return; - } - - totalEl.textContent = _formatBytes(data.total_bytes); - - const withSize = data.tracks_with_size || 0; - const withoutSize = data.tracks_without_size || 0; - const trackBits = `${withSize.toLocaleString()} tracks measured`; - const pendingBits = withoutSize > 0 - ? ` (+${withoutSize.toLocaleString()} pending next Deep Scan)` - : ''; - metaEl.textContent = trackBits + pendingBits; - - // Per-format bars sorted by size descending. Skip if no breakdown. - const formats = Object.entries(data.by_format || {}).sort((a, b) => b[1] - a[1]); - if (!formats.length) { formatsEl.innerHTML = ''; return; } - - const max = formats[0][1] || 1; - formatsEl.innerHTML = formats.map(([ext, bytes]) => { - const pct = Math.max(2, Math.round((bytes / max) * 100)); - return ` -
- ${ext.toUpperCase()} -
-
-
- ${_formatBytes(bytes)} -
- `; - }).join(''); -} - -function _renderDbStorageChart(tables, totalFileSize, method) { - const canvas = document.getElementById('stats-db-storage-chart'); - if (!canvas || typeof Chart === 'undefined') return; - - if (_statsDbStorageChart) _statsDbStorageChart.destroy(); - - // Top 8 tables, group rest as "Other" - const top = tables.slice(0, 8); - const rest = tables.slice(8); - const restSize = rest.reduce((s, t) => s + t.size, 0); - if (restSize > 0) top.push({ name: 'Other', size: restSize }); - - const colors = ['#3b82f6', '#f97316', '#a855f7', '#14b8a6', '#eab308', '#ec4899', '#6366f1', '#22c55e', '#555']; - - _statsDbStorageChart = new Chart(canvas, { - type: 'doughnut', - data: { - labels: top.map(t => t.name), - datasets: [{ - data: top.map(t => t.size), - backgroundColor: colors.slice(0, top.length), - borderWidth: 0, - hoverOffset: 4, - }], - }, - options: { - responsive: false, - cutout: '65%', - plugins: { - legend: { display: false }, - tooltip: { - callbacks: { - label: (ctx) => { - const val = ctx.parsed; - if (method === 'dbstat') { - if (val > 1048576) return ` ${(val / 1048576).toFixed(1)} MB`; - return ` ${(val / 1024).toFixed(0)} KB`; - } - return ` ${val.toLocaleString()} rows`; - } - } - } - }, - }, - }); - - // Center label โ€” total file size - const totalEl = document.getElementById('stats-db-total'); - if (totalEl) { - let sizeStr; - if (totalFileSize > 1073741824) sizeStr = (totalFileSize / 1073741824).toFixed(2) + ' GB'; - else if (totalFileSize > 1048576) sizeStr = (totalFileSize / 1048576).toFixed(1) + ' MB'; - else sizeStr = (totalFileSize / 1024).toFixed(0) + ' KB'; - totalEl.innerHTML = `
${sizeStr}
Total Size
`; - } - - // Legend - const legendEl = document.getElementById('stats-db-legend'); - if (legendEl) { - legendEl.innerHTML = top.map((t, i) => { - let sizeLabel; - if (method === 'dbstat') { - if (t.size > 1048576) sizeLabel = (t.size / 1048576).toFixed(1) + ' MB'; - else sizeLabel = (t.size / 1024).toFixed(0) + ' KB'; - } else { - sizeLabel = t.size.toLocaleString() + ' rows'; - } - return `
- - ${t.name} - ${sizeLabel} -
`; - }).join(''); - } -} - -async function playStatsTrack(title, artist, album) { - // 1. Try the library first โ€” fastest and best quality if owned. - try { - const resp = await fetch('/api/stats/resolve-track', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ title, artist }), - }); - const data = await resp.json(); - if (data.success && data.track) { - const t = data.track; - playLibraryTrack({ - id: t.id, - title: t.title, - file_path: t.file_path, - bitrate: t.bitrate, - artist_id: t.artist_id, - album_id: t.album_id, - _stats_image: t.image_url || null, - }, t.album_title || album || '', t.artist_name || artist || ''); - return; - } - } catch (e) { - console.debug('Library resolve failed, will try streaming fallback:', e); - } - - // 2. Library miss โ€” fall back to streaming via the enhanced-search streamer - // (Soulseek โ†’ YouTube โ†’ other configured sources, same pipeline used by - // the search results' play button). - if (typeof showLoadingOverlay === 'function') { - showLoadingOverlay(`Searching for ${title}...`); - } - try { - const streamResp = await fetch('/api/enhanced-search/stream-track', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - track_name: title, - artist_name: artist, - album_name: album || '', - duration_ms: 0, - }), - }); - const streamData = await streamResp.json(); - if (typeof hideLoadingOverlay === 'function') hideLoadingOverlay(); - - if (streamData.success && streamData.result) { - if (typeof startStream === 'function') { - await startStream(streamData.result); - } else { - showToast('Streaming not available', 'error'); - } - } else { - showToast(streamData.error || 'Track not found in library or any source', 'error'); - } - } catch (e) { - if (typeof hideLoadingOverlay === 'function') hideLoadingOverlay(); - showToast('Failed to play track', 'error'); - console.error('Stream fallback failed:', e); - } -} - -function _renderRecentPlays(tracks) { - const el = document.getElementById('stats-recent-plays'); - if (!el) return; - - if (!tracks.length) { - el.innerHTML = '
No recent plays
'; - return; - } - - const _ago = (dateStr) => { - if (!dateStr) return ''; - const diff = Date.now() - new Date(dateStr).getTime(); - const mins = Math.floor(diff / 60000); - if (mins < 60) return `${mins}m ago`; - const hours = Math.floor(mins / 60); - if (hours < 24) return `${hours}h ago`; - const days = Math.floor(hours / 24); - if (days < 30) return `${days}d ago`; - return `${Math.floor(days / 30)}mo ago`; - }; - - el.innerHTML = tracks.map(t => ` -
- - ${_esc(t.title)} - ${_esc(t.artist || '')} - ${_ago(t.played_at)} -
- `).join(''); -} - // --- Initialization --- function initializeImportPage() { diff --git a/webui/static/style.css b/webui/static/style.css index bbdc9371..a6ef10e9 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -40072,887 +40072,6 @@ div.artist-hero-badge { IMPORT PAGE (full page, replaces modal) ======================================== */ -/* ============================================================================ - STATS PAGE - ============================================================================ */ - -/* Stats page uses dashboard-container pattern for consistency */ -.stats-container { - display: flex; - flex-direction: column; - gap: 20px; - padding: 28px 24px 30px; - overflow: hidden; - background: linear-gradient(135deg, - rgba(20, 20, 20, 0.55) 0%, - rgba(12, 12, 12, 0.62) 100%); - border-radius: 24px; - border: 1px solid rgba(255, 255, 255, 0.08); - border-top: 1px solid rgba(255, 255, 255, 0.12); - margin: 20px; - box-shadow: - 0 8px 32px rgba(0, 0, 0, 0.3), - 0 4px 16px rgba(0, 0, 0, 0.2), - inset 0 1px 0 rgba(255, 255, 255, 0.08); -} - -/* Header uses same pattern as .dashboard-header */ -.stats-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 20px 24px; - margin: -28px -24px 0 -24px; - position: relative; - overflow: hidden; - flex-wrap: wrap; - gap: 16px; - background: linear-gradient(180deg, - rgba(var(--accent-rgb), 0.10) 0%, - rgba(var(--accent-rgb), 0.04) 40%, - transparent 100%); - border-bottom: 1px solid rgba(255, 255, 255, 0.06); - border-top-left-radius: 24px; - border-top-right-radius: 24px; -} - -.stats-header::before { - content: ''; - position: absolute; - top: 0; - left: -100%; - width: 50%; - height: 100%; - background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.03), transparent); - animation: stats-header-sweep 12s ease-in-out infinite; -} - -@keyframes stats-header-sweep { - 0%, 100% { left: -100%; } - 50% { left: 150%; } -} - -.stats-header-title { - display: flex; - align-items: center; - gap: 14px; -} - -.stats-header-title h1 { - font-size: 28px; - font-weight: 700; - color: #fff; - margin: 0; - font-family: 'SF Pro Display', -apple-system, sans-serif; -} - -/* Time range pills */ -.stats-time-range { - display: flex; - gap: 4px; - background: rgba(255, 255, 255, 0.04); - border: 1px solid rgba(255, 255, 255, 0.06); - border-radius: 10px; - padding: 3px; -} - -.stats-range-btn { - padding: 7px 16px; - border: none; - border-radius: 8px; - background: transparent; - color: rgba(255, 255, 255, 0.5); - font-size: 0.82em; - font-weight: 600; - cursor: pointer; - transition: all 0.2s; - font-family: inherit; -} - -.stats-range-btn:hover { - color: rgba(255, 255, 255, 0.8); - background: rgba(255, 255, 255, 0.04); -} - -.stats-range-btn.active { - background: rgb(var(--accent-rgb)); - color: #fff; - box-shadow: 0 2px 8px rgba(var(--accent-rgb), 0.3); -} - -.stats-header-controls { - display: flex; - align-items: center; - gap: 16px; -} - -.stats-sync-controls { - display: flex; - align-items: center; - gap: 8px; -} - -.stats-last-synced { - font-size: 0.72em; - color: rgba(255, 255, 255, 0.3); -} - -.stats-sync-btn { - width: 32px; - height: 32px; - border-radius: 8px; - border: 1px solid rgba(255, 255, 255, 0.08); - background: rgba(255, 255, 255, 0.04); - color: rgba(255, 255, 255, 0.5); - font-size: 16px; - cursor: pointer; - transition: all 0.2s; - font-family: inherit; - display: flex; - align-items: center; - justify-content: center; -} - -.stats-sync-btn:hover { - background: rgba(255, 255, 255, 0.08); - color: #fff; - border-color: rgba(255, 255, 255, 0.15); -} - -.stats-sync-btn.syncing { - pointer-events: none; - color: transparent; - position: relative; -} - -.stats-sync-btn.syncing::after { - content: ''; - position: absolute; - width: 14px; - height: 14px; - border: 2px solid rgba(var(--accent-rgb), 0.2); - border-top-color: rgba(var(--accent-rgb), 0.8); - border-radius: 50%; - animation: stats-spin 0.8s linear infinite; -} - -@keyframes stats-spin { - to { transform: rotate(360deg); } -} - -/* Overview cards */ -.stats-overview { - display: grid; - grid-template-columns: repeat(5, 1fr); - gap: 14px; -} - -.stats-card { - background: linear-gradient(135deg, rgba(20, 20, 20, 0.95) 0%, rgba(12, 12, 12, 0.98) 100%); - border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 14px; - padding: 20px; - text-align: center; - position: relative; - overflow: hidden; - transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.06); -} - -.stats-card::before { - content: ''; - position: absolute; - top: 0; - left: 20%; - right: 20%; - height: 2px; - background: linear-gradient(90deg, transparent, rgba(var(--accent-rgb), 0.5), transparent); - border-radius: 2px; - transition: all 0.3s; -} - -.stats-card:hover { - transform: translateY(-3px); - border-color: rgba(var(--accent-rgb), 0.2); - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4), 0 0 20px rgba(var(--accent-rgb), 0.08); -} - -.stats-card:hover::before { - left: 10%; - right: 10%; - height: 3px; - box-shadow: 0 0 12px rgba(var(--accent-rgb), 0.4); -} - -.stats-card-value { - font-size: 2em; - font-weight: 700; - color: #fff; - line-height: 1.2; - margin-bottom: 6px; - font-family: 'SF Pro Display', -apple-system, sans-serif; -} - -.stats-card-label { - font-size: 0.78em; - color: rgba(255, 255, 255, 0.45); - text-transform: uppercase; - letter-spacing: 0.06em; - font-weight: 600; -} - -/* Main grid */ -.stats-main-grid { - display: grid; - grid-template-columns: 1fr 360px; - gap: 20px; - min-width: 0; -} - -.stats-left-col, .stats-right-col { - display: flex; - flex-direction: column; - gap: 20px; - min-width: 0; -} - -.stats-two-col { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 20px; -} - -/* Section cards */ -.stats-section-card { - background: rgba(255, 255, 255, 0.02); - border: 1px solid rgba(255, 255, 255, 0.06); - border-radius: 14px; - padding: 20px; - transition: border-color 0.2s; - min-width: 0; - overflow: hidden; -} - -.stats-section-card:hover { - border-color: rgba(255, 255, 255, 0.1); -} - -.stats-full-width { - /* No extra margin โ€” container handles it */ -} - -.stats-section-title { - font-size: 0.78em; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.06em; - color: rgba(255, 255, 255, 0.4); - margin-bottom: 16px; - padding-bottom: 10px; - border-bottom: 1px solid rgba(255, 255, 255, 0.05); -} - -/* Genre chart */ -.stats-genre-chart-container { - display: flex; - align-items: center; - gap: 24px; -} - -.stats-genre-chart-container canvas { - width: 180px !important; - height: 180px !important; - flex-shrink: 0; -} - -.stats-genre-legend { - flex: 1; - display: flex; - flex-direction: column; - gap: 6px; -} - -.stats-genre-legend-item { - display: flex; - align-items: center; - gap: 8px; - font-size: 0.82em; - color: rgba(255, 255, 255, 0.7); -} - -.stats-genre-dot { - width: 10px; - height: 10px; - border-radius: 3px; - flex-shrink: 0; -} - -.stats-genre-pct { - margin-left: auto; - color: rgba(255, 255, 255, 0.4); - font-variant-numeric: tabular-nums; -} - -/* Top artists visual bubbles */ -.stats-top-artists-visual { - margin-bottom: 16px; - padding-bottom: 14px; - border-bottom: 1px solid rgba(255, 255, 255, 0.04); -} - -.stats-artist-bubbles { - display: flex; - justify-content: space-around; - align-items: flex-end; - gap: 8px; -} - -.stats-artist-bubble { - display: flex; - flex-direction: column; - align-items: center; - gap: 6px; - min-width: 0; - flex: 1; - transition: transform 0.2s; -} - -.stats-artist-bubble:hover { - transform: translateY(-3px); -} - -.stats-bubble-img { - border-radius: 50%; - background-size: cover; - background-position: center; - background-color: rgba(255, 255, 255, 0.06); - border: 2px solid rgba(var(--accent-rgb), 0.2); - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - transition: border-color 0.2s, box-shadow 0.2s; -} - -.stats-artist-bubble:hover .stats-bubble-img { - border-color: rgba(var(--accent-rgb), 0.5); - box-shadow: 0 4px 20px rgba(var(--accent-rgb), 0.2); -} - -.stats-bubble-img span { - font-size: 1.2em; - font-weight: 700; - color: rgba(255, 255, 255, 0.4); -} - -.stats-bubble-bar-container { - width: 100%; - height: 3px; - background: rgba(255, 255, 255, 0.06); - border-radius: 2px; - overflow: hidden; -} - -.stats-bubble-bar { - height: 100%; - background: linear-gradient(90deg, rgb(var(--accent-rgb)), rgba(var(--accent-rgb), 0.4)); - border-radius: 2px; - transition: width 0.5s ease; -} - -.stats-bubble-name { - font-size: 0.7em; - color: rgba(255, 255, 255, 0.7); - font-weight: 500; - text-align: center; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - max-width: 100%; -} - -.stats-bubble-count { - font-size: 0.65em; - color: rgba(var(--accent-rgb), 0.7); - font-weight: 600; -} - -/* Ranked lists */ -.stats-ranked-list { - display: flex; - flex-direction: column; - gap: 4px; - max-height: 280px; - overflow-y: auto; - scrollbar-width: thin; - scrollbar-color: rgba(255, 255, 255, 0.1) transparent; -} - -.stats-ranked-item { - display: flex; - align-items: center; - gap: 10px; - padding: 8px 10px; - border-radius: 8px; - transition: background 0.15s; - cursor: default; -} - -.stats-ranked-item:hover { - background: rgba(255, 255, 255, 0.04); -} - -.stats-ranked-num { - font-size: 0.75em; - color: rgba(255, 255, 255, 0.25); - font-weight: 700; - width: 18px; - text-align: right; - flex-shrink: 0; -} - -.stats-ranked-img { - width: 36px; - height: 36px; - border-radius: 6px; - object-fit: cover; - flex-shrink: 0; - background: rgba(255, 255, 255, 0.05); -} - -.stats-ranked-info { - flex: 1; - min-width: 0; -} - -.stats-ranked-name { - font-size: 0.88em; - color: rgba(255, 255, 255, 0.85); - font-weight: 500; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.stats-ranked-meta { - font-size: 0.72em; - color: rgba(255, 255, 255, 0.4); -} - -.stats-ranked-count { - font-size: 0.78em; - color: rgba(var(--accent-rgb), 0.8); - font-weight: 600; - flex-shrink: 0; - font-variant-numeric: tabular-nums; -} - -.stats-artist-link { - color: inherit; - text-decoration: none; - cursor: pointer; - transition: color 0.15s; -} - -.stats-artist-link:hover { - color: rgb(var(--accent-rgb)); -} - -/* Play buttons */ -.stats-play-btn { - width: 28px; - height: 28px; - border-radius: 50%; - border: none; - background: rgba(var(--accent-rgb), 0.15); - color: rgb(var(--accent-rgb)); - font-size: 10px; - cursor: pointer; - flex-shrink: 0; - display: flex; - align-items: center; - justify-content: center; - transition: all 0.2s; - opacity: 0; -} - -.stats-ranked-item:hover .stats-play-btn, -.stats-recent-item:hover .stats-play-btn { - opacity: 1; -} - -.stats-play-btn:hover { - background: rgb(var(--accent-rgb)); - color: #fff; - transform: scale(1.1); -} - -.stats-play-btn-sm { - width: 22px; - height: 22px; - font-size: 8px; -} - -/* Library health */ -.stats-health-grid { - display: grid; - grid-template-columns: 2fr 1fr 1fr 1fr; - gap: 16px; - align-items: start; -} - -.stats-health-item { - text-align: center; -} - -.stats-health-item:first-child { - text-align: left; -} - -.stats-health-value { - font-size: 1.6em; - font-weight: 700; - color: #fff; - line-height: 1.2; - margin-bottom: 4px; -} - -.stats-health-label { - font-size: 0.75em; - color: rgba(255, 255, 255, 0.4); - text-transform: uppercase; - letter-spacing: 0.04em; - font-weight: 600; -} - -/* Format breakdown bar */ -.stats-format-bar { - display: flex; - height: 28px; - border-radius: 8px; - overflow: hidden; - margin-top: 8px; - background: rgba(255, 255, 255, 0.04); -} - -.stats-format-segment { - display: flex; - align-items: center; - justify-content: center; - font-size: 0.68em; - font-weight: 600; - color: #fff; - white-space: nowrap; - min-width: 30px; - transition: flex 0.5s ease; -} - -/* Recent plays */ -.stats-recent-list { - display: flex; - flex-direction: column; - gap: 4px; - max-height: 300px; - overflow-y: auto; - scrollbar-width: thin; - scrollbar-color: rgba(255, 255, 255, 0.1) transparent; -} - -.stats-recent-item { - display: flex; - align-items: center; - gap: 10px; - padding: 6px 8px; - border-radius: 6px; -} - -.stats-recent-item:hover { - background: rgba(255, 255, 255, 0.03); -} - -.stats-recent-title { - flex: 1; - font-size: 0.85em; - color: rgba(255, 255, 255, 0.8); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.stats-recent-artist { - font-size: 0.78em; - color: rgba(255, 255, 255, 0.4); - flex-shrink: 0; -} - -.stats-recent-time { - font-size: 0.72em; - color: rgba(255, 255, 255, 0.25); - flex-shrink: 0; - min-width: 65px; - text-align: right; -} - -/* Enrichment coverage */ -.stats-enrichment { - display: flex; - gap: 16px; - margin-top: 16px; - padding-top: 14px; - border-top: 1px solid rgba(255, 255, 255, 0.04); - flex-wrap: wrap; -} - -.stats-enrich-item { - flex: 1; - min-width: 120px; - display: flex; - align-items: center; - gap: 8px; -} - -.stats-enrich-name { - font-size: 0.72em; - color: rgba(255, 255, 255, 0.45); - min-width: 70px; - font-weight: 500; -} - -.stats-enrich-bar { - flex: 1; - height: 4px; - background: rgba(255, 255, 255, 0.06); - border-radius: 2px; - overflow: hidden; -} - -.stats-enrich-fill { - height: 100%; - border-radius: 2px; - transition: width 0.5s ease; -} - -.stats-enrich-pct { - font-size: 0.72em; - color: rgba(255, 255, 255, 0.4); - font-variant-numeric: tabular-nums; - min-width: 30px; - text-align: right; -} - -/* Library Disk Usage */ -.stats-disk-usage-wrap { - display: flex; - flex-direction: column; - gap: 14px; - margin-top: 8px; -} - -.stats-disk-total-row { - display: flex; - align-items: baseline; - gap: 16px; - flex-wrap: wrap; -} - -.stats-disk-total-value { - font-size: 28px; - font-weight: 700; - color: rgb(var(--accent-rgb)); -} - -.stats-disk-total-meta { - font-size: 12px; - color: rgba(255, 255, 255, 0.55); -} - -.stats-disk-formats { - display: flex; - flex-direction: column; - gap: 6px; -} - -.stats-disk-format-row { - display: grid; - grid-template-columns: 60px 1fr 80px; - align-items: center; - gap: 10px; - font-size: 12px; -} - -.stats-disk-format-name { - font-weight: 600; - color: rgba(255, 255, 255, 0.8); -} - -.stats-disk-format-bar { - height: 8px; - background: rgba(255, 255, 255, 0.05); - border-radius: 4px; - overflow: hidden; -} - -.stats-disk-format-fill { - height: 100%; - background: linear-gradient(90deg, - rgb(var(--accent-rgb)) 0%, - rgba(var(--accent-rgb), 0.6) 100%); - border-radius: 4px; -} - -.stats-disk-format-size { - text-align: right; - color: rgba(255, 255, 255, 0.55); - font-variant-numeric: tabular-nums; -} - -/* Database Storage Chart */ -.stats-db-storage-wrap { - display: flex; - align-items: center; - gap: 24px; - margin-top: 8px; -} - -.stats-db-chart-container { - position: relative; - width: 180px; - height: 180px; - flex-shrink: 0; -} - -.stats-db-chart-container canvas { - width: 180px !important; - height: 180px !important; -} - -.stats-db-total { - position: absolute; - inset: 0; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - pointer-events: none; -} - -.stats-db-total-value { - font-size: 20px; - font-weight: 700; - color: rgba(255, 255, 255, 0.85); -} - -.stats-db-total-label { - font-size: 10px; - color: rgba(255, 255, 255, 0.4); - text-transform: uppercase; - letter-spacing: 0.05em; -} - -.stats-db-legend { - flex: 1; - display: flex; - flex-direction: column; - gap: 5px; - min-width: 0; -} - -.stats-db-legend-item { - display: flex; - align-items: center; - gap: 8px; - font-size: 12px; - color: rgba(255, 255, 255, 0.6); -} - -.stats-db-legend-dot { - width: 10px; - height: 10px; - border-radius: 3px; - flex-shrink: 0; -} - -.stats-db-legend-name { - flex: 1; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.stats-db-legend-size { - font-variant-numeric: tabular-nums; - color: rgba(255, 255, 255, 0.4); - font-size: 11px; -} - -/* Stats empty state */ -.stats-empty { - text-align: center; - padding: 80px 20px; - color: rgba(255, 255, 255, 0.5); -} - -.stats-empty-icon { - font-size: 48px; - margin-bottom: 16px; -} - -.stats-empty h3 { - font-size: 1.2em; - color: rgba(255, 255, 255, 0.7); - margin-bottom: 8px; -} - -.stats-empty p { - font-size: 0.88em; - max-width: 400px; - margin: 0 auto; - line-height: 1.5; -} - -/* Mobile responsive */ -@media (max-width: 768px) { - .stats-container { - margin: 10px; - padding: 16px; - } - .stats-overview { - grid-template-columns: repeat(2, 1fr); - } - .stats-main-grid { - grid-template-columns: 1fr; - } - .stats-health-grid { - grid-template-columns: 1fr 1fr; - } - .stats-genre-chart-container { - flex-direction: column; - } - .stats-two-col { - grid-template-columns: 1fr; - } - .stats-genre-chart-container canvas { - width: 150px !important; - height: 150px !important; - } - .stats-header { - flex-direction: column; - align-items: flex-start; - padding: 16px; - } - .stats-header-controls { - flex-direction: column; - align-items: flex-start; - gap: 10px; - width: 100%; - } - .stats-card-value { - font-size: 1.5em; - } -} - /* ============================================================================ IMPORT PAGE ============================================================================ */