Merge branch 'feature/artist-enrichment' into pre-release
This commit is contained in:
commit
596f8ab524
10 files changed
+610
-12
No files matched your search
@@ -402,16 +402,18 @@ fn extract_relationships(artist: &ArtistFullResponse) -> ArtistRelationships {
|
||||
begin: relation.begin.clone(),
|
||||
end: relation.end.clone(),
|
||||
}),
|
||||
ended: relation.ended.unwrap_or(false),
|
||||
};
|
||||
|
||||
match relation.relation_type.as_str() {
|
||||
"member of band" => {
|
||||
if relation.direction.as_deref() == Some("backward") {
|
||||
groups.push(related);
|
||||
} else if relation.ended == Some(true) {
|
||||
past_members.push(related);
|
||||
} else {
|
||||
// We're viewing a BAND, the related artist is a MEMBER
|
||||
// All members go to the same list - don't separate by ended status
|
||||
members.push(related);
|
||||
} else {
|
||||
// We're viewing a PERSON, the related artist is a BAND/GROUP
|
||||
groups.push(related);
|
||||
}
|
||||
}
|
||||
"collaboration" => {
|
||||
|
||||
@@ -328,6 +328,7 @@ pub struct RelatedArtist {
|
||||
pub name: String,
|
||||
pub role: Option<String>,
|
||||
pub period: Option<Period>,
|
||||
pub ended: bool,
|
||||
}
|
||||
|
||||
/// Time period for a relationship
|
||||
|
||||
@@ -392,6 +392,33 @@ function categorizeAlbum(album: QobuzAlbum, mainArtistId: number): 'tributes' |
|
||||
return 'albums';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract unique labels from albums
|
||||
* Only considers albums categorized as 'albums' (main Discography)
|
||||
* to avoid pollution from tributes, compilations, etc.
|
||||
*/
|
||||
function extractLabelsFromAlbums(albums: QobuzAlbum[], artistId: number): { id: number; name: string }[] {
|
||||
const labelMap = new Map<number, string>();
|
||||
|
||||
for (const album of albums) {
|
||||
// Only extract labels from main discography albums
|
||||
const category = categorizeAlbum(album, artistId);
|
||||
if (category !== 'albums') continue;
|
||||
|
||||
if (album.label?.id && album.label?.name) {
|
||||
// Only add if we haven't seen this label ID before
|
||||
if (!labelMap.has(album.label.id)) {
|
||||
labelMap.set(album.label.id, album.label.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to array and sort by name
|
||||
return Array.from(labelMap.entries())
|
||||
.map(([id, name]) => ({ id, name }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Qobuz API artist response to UI ArtistDetail model
|
||||
*/
|
||||
@@ -443,6 +470,7 @@ export function convertQobuzArtist(artist: QobuzArtist): ArtistDetail {
|
||||
tributes,
|
||||
others,
|
||||
playlists: toArtistPlaylists(artist.playlists),
|
||||
labels: extractLabelsFromAlbums(albumItems, artist.id),
|
||||
totalAlbums: artist.albums?.total || artist.albums_count || 0,
|
||||
albumsFetched
|
||||
};
|
||||
@@ -507,6 +535,13 @@ export function appendArtistAlbums(
|
||||
}
|
||||
}
|
||||
|
||||
// Merge new labels from new albums (only from main discography)
|
||||
const existingLabelIds = new Set(artist.labels.map(l => l.id));
|
||||
const newLabels = extractLabelsFromAlbums(newAlbums, artist.id)
|
||||
.filter(l => !existingLabelIds.has(l.id));
|
||||
const labels = [...artist.labels, ...newLabels]
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
return {
|
||||
...artist,
|
||||
albums,
|
||||
@@ -514,6 +549,7 @@ export function appendArtistAlbums(
|
||||
liveAlbums,
|
||||
tributes,
|
||||
others,
|
||||
labels,
|
||||
totalAlbums: totalAlbums ?? artist.totalAlbums,
|
||||
albumsFetched: albumsFetched ?? artist.albumsFetched + newAlbums.length
|
||||
};
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { ArrowLeft, User, ChevronDown, ChevronUp, Play, Music, Heart, Search, X, ChevronLeft, ChevronRight, Radio, MoreHorizontal, Info } from 'lucide-svelte';
|
||||
import { ArrowLeft, User, ChevronDown, ChevronUp, Play, Music, Heart, Search, X, ChevronLeft, ChevronRight, Radio, MoreHorizontal, Info, Disc } from 'lucide-svelte';
|
||||
import type { ArtistDetail, QobuzArtist } from '$lib/types';
|
||||
import AlbumCard from '../AlbumCard.svelte';
|
||||
import TrackMenu from '../TrackMenu.svelte';
|
||||
import { consumeContextTrackFocus, setPlaybackContext, getPlaybackContext } from '$lib/stores/playbackContextStore';
|
||||
import { togglePlay } from '$lib/stores/playerStore';
|
||||
import { getQueue, syncQueueState, playQueueIndex } from '$lib/stores/queueStore';
|
||||
import { tick } from 'svelte';
|
||||
import { subscribeContentSidebar, toggleContentSidebar, type ContentSidebarType } from '$lib/stores/sidebarStore';
|
||||
import { tick, onMount, onDestroy } from 'svelte';
|
||||
|
||||
interface Track {
|
||||
id: number;
|
||||
@@ -73,6 +74,7 @@
|
||||
onTrackShareSonglink?: (track: Track) => void;
|
||||
onTrackGoToAlbum?: (albumId: string) => void;
|
||||
onTrackGoToArtist?: (artistId: number) => void;
|
||||
onLabelClick?: (labelId: number, labelName?: string) => void;
|
||||
activeTrackId?: number | null;
|
||||
isPlaybackActive?: boolean;
|
||||
}
|
||||
@@ -104,6 +106,7 @@
|
||||
onTrackShareSonglink,
|
||||
onTrackGoToAlbum,
|
||||
onTrackGoToArtist,
|
||||
onLabelClick,
|
||||
activeTrackId = null,
|
||||
isPlaybackActive = false
|
||||
}: Props = $props();
|
||||
@@ -117,6 +120,18 @@
|
||||
let isRadioLoading = $state(false);
|
||||
let radioLoadingMessage = $state('');
|
||||
let radioJustCreated = $state(false);
|
||||
let showNetworkSidebar = $state(false);
|
||||
let unsubscribeSidebar: (() => void) | null = null;
|
||||
|
||||
onMount(() => {
|
||||
unsubscribeSidebar = subscribeContentSidebar((active: ContentSidebarType) => {
|
||||
showNetworkSidebar = active === 'network';
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
unsubscribeSidebar?.();
|
||||
});
|
||||
let similarArtists = $state<QobuzArtist[]>([]);
|
||||
let similarArtistsLoading = $state(false);
|
||||
let similarArtistImageErrors = $state<Set<number>>(new Set());
|
||||
@@ -127,6 +142,7 @@
|
||||
name: string;
|
||||
role?: string;
|
||||
period?: { begin?: string; end?: string };
|
||||
ended: boolean;
|
||||
}
|
||||
interface ArtistRelationships {
|
||||
members: RelatedArtist[];
|
||||
@@ -134,9 +150,17 @@
|
||||
groups: RelatedArtist[];
|
||||
collaborators: RelatedArtist[];
|
||||
}
|
||||
interface GroupedMember {
|
||||
mbid: string;
|
||||
name: string;
|
||||
roles: string[];
|
||||
period?: { begin?: string; end?: string };
|
||||
ended: boolean;
|
||||
}
|
||||
let mbRelationships = $state<ArtistRelationships | null>(null);
|
||||
let mbRelationshipsLoading = $state(false);
|
||||
let mbArtistMbid = $state<string | null>(null);
|
||||
let mbAvailable = $state(true); // Assume available until proven otherwise
|
||||
let artistDetailEl = $state<HTMLDivElement | null>(null);
|
||||
let aboutSection = $state<HTMLDivElement | null>(null);
|
||||
let topTracksSection = $state<HTMLDivElement | null>(null);
|
||||
@@ -480,11 +504,13 @@
|
||||
mbRelationshipsLoading = true;
|
||||
mbRelationships = null;
|
||||
mbArtistMbid = null;
|
||||
mbAvailable = true; // Reset on each load attempt
|
||||
|
||||
try {
|
||||
// Check if MusicBrainz is enabled
|
||||
const enabled = await invoke<boolean>('musicbrainz_is_enabled');
|
||||
if (!enabled) {
|
||||
mbAvailable = false;
|
||||
mbRelationshipsLoading = false;
|
||||
return;
|
||||
}
|
||||
@@ -519,6 +545,7 @@
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('Failed to load MusicBrainz relationships:', err);
|
||||
mbAvailable = false;
|
||||
mbRelationships = null;
|
||||
} finally {
|
||||
mbRelationshipsLoading = false;
|
||||
@@ -555,8 +582,44 @@
|
||||
let hasRelationships = $derived(
|
||||
mbRelationships &&
|
||||
(mbRelationships.members.length > 0 ||
|
||||
mbRelationships.pastMembers.length > 0 ||
|
||||
mbRelationships.groups.length > 0)
|
||||
mbRelationships.groups.length > 0 ||
|
||||
mbRelationships.collaborators.length > 0)
|
||||
);
|
||||
|
||||
// Group members by MBID, combining their roles
|
||||
function groupMembersByMbid(members: RelatedArtist[]): GroupedMember[] {
|
||||
const grouped = new Map<string, GroupedMember>();
|
||||
for (const member of members) {
|
||||
const existing = grouped.get(member.mbid);
|
||||
if (existing) {
|
||||
if (member.role && !existing.roles.includes(member.role)) {
|
||||
existing.roles.push(member.role);
|
||||
}
|
||||
// If any entry is ended, mark as ended
|
||||
if (member.ended) {
|
||||
existing.ended = true;
|
||||
}
|
||||
} else {
|
||||
grouped.set(member.mbid, {
|
||||
mbid: member.mbid,
|
||||
name: member.name,
|
||||
roles: member.role ? [member.role] : [],
|
||||
period: member.period,
|
||||
ended: member.ended
|
||||
});
|
||||
}
|
||||
}
|
||||
return Array.from(grouped.values());
|
||||
}
|
||||
|
||||
let groupedMembers = $derived(
|
||||
mbRelationships ? groupMembersByMbid(mbRelationships.members) : []
|
||||
);
|
||||
let groupedGroups = $derived(
|
||||
mbRelationships ? groupMembersByMbid(mbRelationships.groups) : []
|
||||
);
|
||||
let groupedCollaborators = $derived(
|
||||
mbRelationships ? groupMembersByMbid(mbRelationships.collaborators) : []
|
||||
);
|
||||
|
||||
function getSimilarArtistImage(similar: QobuzArtist): string {
|
||||
@@ -1032,6 +1095,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- TEMPORARILY HIDDEN FOR SIDEBAR EXPERIMENTS
|
||||
{#if similarArtistsLoading}
|
||||
<div class="similar-loading">Loading similar artists...</div>
|
||||
{:else if similarArtists.length > 0}
|
||||
@@ -1054,7 +1118,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- MusicBrainz Relationships -->
|
||||
MusicBrainz Relationships
|
||||
{#if mbRelationshipsLoading}
|
||||
<div class="mb-relationships-loading">Loading artist relationships...</div>
|
||||
{:else if hasRelationships}
|
||||
@@ -1120,6 +1184,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
END TEMPORARILY HIDDEN -->
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="artist-actions">
|
||||
@@ -1153,6 +1218,14 @@
|
||||
{/key}
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
class="network-btn"
|
||||
class:active={showNetworkSidebar}
|
||||
onclick={() => toggleContentSidebar('network')}
|
||||
title="Artist Network"
|
||||
>
|
||||
<img src="/element-connect.svg" alt="Network" class="network-icon" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1841,6 +1914,137 @@
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Artist Network Sidebar -->
|
||||
{#if showNetworkSidebar}
|
||||
<aside class="network-sidebar">
|
||||
<div class="sidebar-header">
|
||||
<div class="sidebar-header-icon">
|
||||
<img src="/element-connect.svg" alt="" />
|
||||
</div>
|
||||
<div class="sidebar-header-text">
|
||||
<h3 class="sidebar-title">Network</h3>
|
||||
<div class="sidebar-subtitle">{artist.name}</div>
|
||||
</div>
|
||||
<button class="sidebar-close" onclick={() => toggleContentSidebar('network')} title="Close">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-content">
|
||||
<!-- Labels Section -->
|
||||
<section class="sidebar-section">
|
||||
<h4 class="section-label">LABELS</h4>
|
||||
<div class="section-items">
|
||||
{#if artist.labels.length > 0}
|
||||
{#each artist.labels as label}
|
||||
<button
|
||||
class="sidebar-artist-link"
|
||||
onclick={() => onLabelClick?.(label.id, label.name)}
|
||||
title={label.name}
|
||||
>
|
||||
<Disc size={12} />
|
||||
{label.name}
|
||||
</button>
|
||||
{/each}
|
||||
{:else}
|
||||
<span class="placeholder-text">No label info</span>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Similar Artists Section -->
|
||||
<section class="sidebar-section">
|
||||
<h4 class="section-label">SIMILAR ARTISTS</h4>
|
||||
<div class="section-items">
|
||||
{#if similarArtistsLoading}
|
||||
<span class="placeholder-text">Loading...</span>
|
||||
{:else if similarArtists.length > 0}
|
||||
{#each similarArtists as similar}
|
||||
<button
|
||||
class="sidebar-artist-link"
|
||||
onclick={() => onTrackGoToArtist?.(similar.id)}
|
||||
title={similar.name}
|
||||
>
|
||||
<User size={12} />
|
||||
{similar.name}
|
||||
</button>
|
||||
{/each}
|
||||
{:else}
|
||||
<span class="placeholder-text">No similar artists found</span>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- MusicBrainz Relationships (only shown if MB is enabled and available) -->
|
||||
{#if mbAvailable}
|
||||
<section class="sidebar-section">
|
||||
<h4 class="section-label">RELATIONSHIPS</h4>
|
||||
<div class="section-items">
|
||||
{#if mbRelationshipsLoading}
|
||||
<span class="placeholder-text">Loading...</span>
|
||||
{:else if hasRelationships}
|
||||
{#if groupedMembers.length > 0}
|
||||
<div class="relationship-group">
|
||||
<span class="relationship-label">Members & Former</span>
|
||||
{#each groupedMembers as member}
|
||||
{@const periodStr = member.period?.begin || member.period?.end
|
||||
? `${member.period.begin || '?'} - ${member.period.end || 'present'}`
|
||||
: ''}
|
||||
{@const tooltipParts = [...member.roles]}
|
||||
{@const tooltip = tooltipParts.length > 0
|
||||
? (periodStr ? `${tooltipParts.join(', ')} (${periodStr})` : tooltipParts.join(', '))
|
||||
: (periodStr || member.name)}
|
||||
<button
|
||||
class="sidebar-artist-link"
|
||||
onclick={() => navigateToRelatedArtist(member.name)}
|
||||
title={tooltip}
|
||||
>
|
||||
<User size={12} />
|
||||
{member.name}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if groupedGroups.length > 0}
|
||||
<div class="relationship-group">
|
||||
<span class="relationship-label">Member Of</span>
|
||||
{#each groupedGroups as group}
|
||||
<button
|
||||
class="sidebar-artist-link"
|
||||
onclick={() => navigateToRelatedArtist(group.name)}
|
||||
title={group.roles.length > 0 ? group.roles.join(', ') : group.name}
|
||||
>
|
||||
<Music size={12} />
|
||||
{group.name}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if groupedCollaborators.length > 0}
|
||||
<div class="relationship-group">
|
||||
<span class="relationship-label">Collaborators</span>
|
||||
{#each groupedCollaborators as collab}
|
||||
<button
|
||||
class="sidebar-artist-link"
|
||||
onclick={() => navigateToRelatedArtist(collab.name)}
|
||||
title={collab.roles.length > 0 ? collab.roles.join(', ') : collab.name}
|
||||
>
|
||||
<User size={12} />
|
||||
{collab.name}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<span class="placeholder-text">No relationships found</span>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
</aside>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@@ -1853,6 +2057,175 @@
|
||||
padding-right: 8px;
|
||||
padding-bottom: 100px;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Network Sidebar - matches LyricsSidebar dimensions */
|
||||
.network-sidebar {
|
||||
position: fixed;
|
||||
top: 32px;
|
||||
right: 0;
|
||||
width: 340px;
|
||||
height: calc(100vh - 32px - 72px);
|
||||
background: var(--bg-secondary);
|
||||
border-left: 1px solid var(--bg-tertiary);
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: slideIn 200ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--bg-tertiary);
|
||||
background: var(--bg-primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-header-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 8px;
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.sidebar-header-icon img {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
filter: brightness(0) saturate(100%) invert(56%) sepia(63%) saturate(4848%) hue-rotate(230deg) brightness(102%) contrast(101%);
|
||||
}
|
||||
|
||||
.sidebar-header-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sidebar-subtitle {
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.sidebar-close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
transition: background 150ms ease, color 150ms ease;
|
||||
}
|
||||
|
||||
.sidebar-close:hover {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.sidebar-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.sidebar-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.sidebar-section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.5px;
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
|
||||
.section-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.placeholder-text {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.sidebar-artist-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 8px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
transition: background 150ms ease, color 150ms ease;
|
||||
}
|
||||
|
||||
.sidebar-artist-link:hover {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
|
||||
.relationship-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.relationship-group:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.relationship-label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
@@ -2056,6 +2429,42 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.network-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
background: var(--bg-tertiary);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
transition: background 200ms ease, color 200ms ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.network-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.network-btn.active {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.network-btn .network-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
filter: brightness(0) saturate(100%) invert(70%) sepia(0%) saturate(0%) hue-rotate(0deg) brightness(90%) contrast(90%);
|
||||
transition: filter 150ms ease;
|
||||
}
|
||||
|
||||
.network-btn:hover .network-icon,
|
||||
.network-btn.active .network-icon {
|
||||
filter: brightness(0) saturate(100%) invert(56%) sepia(63%) saturate(4848%) hue-rotate(230deg) brightness(102%) contrast(101%);
|
||||
}
|
||||
|
||||
.floating-message {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
@@ -2108,9 +2517,9 @@
|
||||
line-height: 1.7;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 300;
|
||||
/* Smart 3-line clamp */
|
||||
/* Smart 2-line clamp */
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -106,6 +106,8 @@
|
||||
// Lyrics cache state
|
||||
let isClearingLyrics = $state(false);
|
||||
let lyricsCacheStats = $state<{ entries: number; sizeBytes: number } | null>(null);
|
||||
let isClearingMusicBrainz = $state(false);
|
||||
let musicBrainzCacheStats = $state<{ recordings: number; artists: number; releases: number; relations: number } | null>(null);
|
||||
|
||||
// Migration state
|
||||
let showMigrationModal = $state(false);
|
||||
@@ -464,6 +466,9 @@
|
||||
// Load lyrics cache stats
|
||||
loadLyricsCacheStats();
|
||||
|
||||
// Load MusicBrainz cache stats
|
||||
loadMusicBrainzCacheStats();
|
||||
|
||||
// Load audio devices first (includes PipeWire sinks), then settings
|
||||
// Also load backends and ALSA plugins
|
||||
Promise.all([
|
||||
@@ -1492,6 +1497,29 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClearMusicBrainzCache() {
|
||||
if (isClearingMusicBrainz) return;
|
||||
isClearingMusicBrainz = true;
|
||||
try {
|
||||
await invoke('musicbrainz_clear_cache');
|
||||
console.log('MusicBrainz cache cleared');
|
||||
await loadMusicBrainzCacheStats();
|
||||
} catch (err) {
|
||||
console.error('Failed to clear MusicBrainz cache:', err);
|
||||
} finally {
|
||||
isClearingMusicBrainz = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMusicBrainzCacheStats() {
|
||||
try {
|
||||
musicBrainzCacheStats = await invoke('musicbrainz_get_cache_stats');
|
||||
} catch (err) {
|
||||
console.error('Failed to load MusicBrainz cache stats:', err);
|
||||
musicBrainzCacheStats = null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
@@ -2162,7 +2190,7 @@
|
||||
{isClearing ? $t('settings.storage.clearing') : $t('actions.clear')}
|
||||
</button>
|
||||
</div>
|
||||
<div class="setting-row last">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">{$t('settings.lyrics.clearLyrics')}</span>
|
||||
<small class="setting-note">
|
||||
@@ -2186,6 +2214,25 @@
|
||||
{isClearingLyrics ? $t('settings.storage.clearing') : $t('actions.clear')}
|
||||
</button>
|
||||
</div>
|
||||
<div class="setting-row last">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">MusicBrainz Cache</span>
|
||||
<small class="setting-note">
|
||||
{#if musicBrainzCacheStats}
|
||||
{musicBrainzCacheStats.artists} artists, {musicBrainzCacheStats.relations} relations, {musicBrainzCacheStats.recordings} recordings
|
||||
{:else}
|
||||
-
|
||||
{/if}
|
||||
</small>
|
||||
</div>
|
||||
<button
|
||||
class="clear-btn"
|
||||
onclick={handleClearMusicBrainzCache}
|
||||
disabled={isClearingMusicBrainz || !musicBrainzCacheStats || (musicBrainzCacheStats.artists === 0 && musicBrainzCacheStats.relations === 0 && musicBrainzCacheStats.recordings === 0)}
|
||||
>
|
||||
{isClearingMusicBrainz ? $t('settings.storage.clearing') : $t('actions.clear')}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Flatpak Section (only shown when running in Flatpak) -->
|
||||
|
||||
@@ -81,3 +81,65 @@ export function collapseSidebar(): void {
|
||||
export function toggleSidebar(): void {
|
||||
setExpanded(!isExpanded);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Content Sidebars (Lyrics, Queue, Network)
|
||||
// Only one can be open at a time
|
||||
// ============================================
|
||||
|
||||
export type ContentSidebarType = 'lyrics' | 'queue' | 'network' | null;
|
||||
|
||||
let activeContentSidebar: ContentSidebarType = null;
|
||||
const contentSidebarListeners = new Set<(type: ContentSidebarType) => void>();
|
||||
|
||||
function notifyContentSidebarListeners(): void {
|
||||
for (const listener of contentSidebarListeners) {
|
||||
listener(activeContentSidebar);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to content sidebar changes
|
||||
*/
|
||||
export function subscribeContentSidebar(listener: (type: ContentSidebarType) => void): () => void {
|
||||
contentSidebarListeners.add(listener);
|
||||
listener(activeContentSidebar);
|
||||
return () => contentSidebarListeners.delete(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active content sidebar
|
||||
*/
|
||||
export function getActiveContentSidebar(): ContentSidebarType {
|
||||
return activeContentSidebar;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a content sidebar (closes others)
|
||||
*/
|
||||
export function openContentSidebar(type: ContentSidebarType): void {
|
||||
activeContentSidebar = type;
|
||||
notifyContentSidebarListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Close a content sidebar
|
||||
*/
|
||||
export function closeContentSidebar(type: ContentSidebarType): void {
|
||||
if (activeContentSidebar === type) {
|
||||
activeContentSidebar = null;
|
||||
notifyContentSidebarListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle a content sidebar
|
||||
*/
|
||||
export function toggleContentSidebar(type: ContentSidebarType): void {
|
||||
if (activeContentSidebar === type) {
|
||||
activeContentSidebar = null;
|
||||
} else {
|
||||
activeContentSidebar = type;
|
||||
}
|
||||
notifyContentSidebarListeners();
|
||||
}
|
||||
@@ -238,6 +238,10 @@ export interface ArtistDetail {
|
||||
trackCount?: number;
|
||||
owner?: string;
|
||||
}[];
|
||||
labels: {
|
||||
id: number;
|
||||
name: string;
|
||||
}[];
|
||||
totalAlbums: number;
|
||||
albumsFetched: number;
|
||||
}
|
||||
|
||||
@@ -242,10 +242,14 @@
|
||||
// MiniPlayer
|
||||
import { enterMiniplayerMode } from '$lib/services/miniplayerService';
|
||||
|
||||
// Sidebar mutual exclusion
|
||||
import { closeContentSidebar, subscribeContentSidebar, type ContentSidebarType } from '$lib/stores/sidebarStore';
|
||||
|
||||
// Lyrics state management
|
||||
import {
|
||||
subscribe as subscribeLyrics,
|
||||
toggleSidebar as toggleLyricsSidebar,
|
||||
hideSidebar as hideLyricsSidebar,
|
||||
startWatching as startLyricsWatching,
|
||||
stopWatching as stopLyricsWatching,
|
||||
startActiveLineUpdates,
|
||||
@@ -2078,6 +2082,10 @@
|
||||
// Subscribe to UI state changes
|
||||
const unsubscribeUI = subscribeUI(() => {
|
||||
const uiState = getUIState();
|
||||
// Close network sidebar when queue opens
|
||||
if (uiState.isQueueOpen && !isQueueOpen) {
|
||||
closeContentSidebar('network');
|
||||
}
|
||||
isQueueOpen = uiState.isQueueOpen;
|
||||
isFullScreenOpen = uiState.isFullScreenOpen;
|
||||
isFocusModeOpen = uiState.isFocusModeOpen;
|
||||
@@ -2179,6 +2187,18 @@
|
||||
lyricsActiveIndex = state.activeIndex;
|
||||
lyricsActiveProgress = state.activeProgress;
|
||||
lyricsSidebarVisible = state.sidebarVisible;
|
||||
// Close network sidebar when lyrics opens
|
||||
if (state.sidebarVisible) {
|
||||
closeContentSidebar('network');
|
||||
}
|
||||
});
|
||||
|
||||
// Subscribe to content sidebar for mutual exclusion (network closes lyrics/queue)
|
||||
const unsubscribeContentSidebar = subscribeContentSidebar((active: ContentSidebarType) => {
|
||||
if (active === 'network') {
|
||||
hideLyricsSidebar();
|
||||
closeQueue();
|
||||
}
|
||||
});
|
||||
|
||||
// Subscribe to cast state changes
|
||||
@@ -2307,6 +2327,7 @@
|
||||
unsubscribePlayer();
|
||||
unsubscribeQueue();
|
||||
unsubscribeLyrics();
|
||||
unsubscribeContentSidebar();
|
||||
unsubscribeCast();
|
||||
stopLyricsWatching();
|
||||
stopActiveLineUpdates();
|
||||
@@ -2575,6 +2596,7 @@
|
||||
onTrackGoToAlbum={handleAlbumClick}
|
||||
onTrackGoToArtist={handleArtistClick}
|
||||
onPlaylistClick={selectPlaylist}
|
||||
onLabelClick={handleLabelClick}
|
||||
activeTrackId={currentTrack?.id ?? null}
|
||||
isPlaybackActive={isPlaying}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" ?>
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 512 512" id="svg2793" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;" version="1.1" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:serif="http://www.serif.com/" xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs id="defs2797"/>
|
||||
<g id="_24-Connect" style="display:inline" transform="translate(-3072,-4096)">
|
||||
<g id="g2211" transform="translate(3339.32,4267.43)">
|
||||
<path d="m 0,169.14 c -44.818,0 -81.28,-36.462 -81.28,-81.28 0,-44.818 36.462,-81.28 81.28,-81.28 44.817,0 81.279,36.462 81.279,81.28 0,44.818 -36.462,81.28 -81.279,81.28 m -149.2,141.43 c -24.587,0 -44.59,-20.003 -44.59,-44.59 0,-24.587 20.003,-44.59 44.59,-44.59 24.587,0 44.59,20.003 44.59,44.59 0,24.587 -20.003,44.59 -44.59,44.59 m -83.91,-398.5 c 0,-29.5 24,-53.5 53.5,-53.5 29.494,0 53.49,24 53.49,53.5 0,29.494 -23.996,53.49 -53.49,53.49 -29.5,0 -53.5,-23.996 -53.5,-53.49 m 412.099,37 c 17.364,0 31.49,14.126 31.49,31.49 0,17.369 -14.126,31.5 -31.49,31.5 -17.369,0 -31.5,-14.131 -31.5,-31.5 0,-17.364 14.131,-31.49 31.5,-31.49 m -44.46,73.915 c 11.207,11.739 26.989,19.075 44.46,19.075 33.907,0 61.49,-27.589 61.49,-61.5 0,-33.906 -27.583,-61.49 -61.49,-61.49 -33.911,0 -61.5,27.584 -61.5,61.49 0,5.628 0.776,11.076 2.198,16.259 L 87.091,18.672 C 66.687,-6.959 35.231,-23.42 0,-23.42 c -26.243,0 -50.388,9.137 -69.436,24.388 l -44.024,-38.046 c 10.859,-14.093 17.34,-31.725 17.34,-50.852 0,-46.042 -37.453,-83.5 -83.49,-83.5 -46.042,0 -83.5,37.458 -83.5,83.5 0,46.036 37.458,83.49 83.5,83.49 16.012,0 30.98,-4.54 43.702,-12.388 l 45.751,39.538 c -13.281,18.326 -21.123,40.838 -21.123,65.15 0,27.971 10.377,53.562 27.478,73.133 l -30.6,39.04 c -10.397,-5.508 -22.236,-8.643 -34.798,-8.643 -41.129,0 -74.59,33.461 -74.59,74.59 0,41.129 33.461,74.59 74.59,74.59 41.129,0 74.59,-33.461 74.59,-74.59 0,-17.745 -6.24,-34.055 -16.625,-46.873 l 30.054,-38.343 c 17.567,11.607 38.596,18.376 61.181,18.376 61.359,0 111.279,-49.92 111.279,-111.28 0,-15.382 -3.138,-30.045 -8.806,-43.383 z" id="path2209" style="fill-rule:nonzero"/>
|
||||
</g>
|
||||
<g id="g2215" transform="translate(3521.08,4502.59)">
|
||||
<path d="M 0,57.45 C -15.842,57.45 -28.73,44.562 -28.73,28.72 -28.73,12.884 -15.842,0 0,0 15.836,0 28.72,12.884 28.72,28.72 28.72,44.562 15.836,57.45 0,57.45 M 0,-30 c -32.384,0 -58.73,26.342 -58.73,58.72 0,32.384 26.346,58.73 58.73,58.73 32.378,0 58.72,-26.346 58.72,-58.73 C 58.72,-3.658 32.378,-30 0,-30" id="path2213" style="fill-rule:nonzero"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M20.5 9a3.49 3.49 0 0 0-3.45 3h-1.1a2.49 2.49 0 0 0-4.396-1.052L8.878 9.731l3.143-4.225a2.458 2.458 0 0 0 2.98-.019L17.339 8H16v1h3V6h-1v1.243l-2.336-2.512A2.473 2.473 0 0 0 16 3.5a2.5 2.5 0 0 0-5 0 2.474 2.474 0 0 0 .343 1.243L7.947 9.308 4.955 7.947a2.404 2.404 0 0 0-.161-1.438l3.704-1.385-.44 1.371.942.333L10 4 7.172 3l-.334.943 1.01.357-3.659 1.368a2.498 2.498 0 1 0-.682 4.117l2.085 2.688-2.053 2.76a2.5 2.5 0 1 0 .87 3.864l3.484 1.587-1.055.373.334.943L10 21l-1-2.828-.943.333.435 1.354-3.608-1.645A2.471 2.471 0 0 0 5 17.5a2.5 2.5 0 0 0-.058-.527l3.053-1.405 3.476 4.48a2.498 2.498 0 1 0 4.113.075L18 17.707V19h1v-3h-3v1h1.293l-2.416 2.416a2.466 2.466 0 0 0-2.667-.047l-3.283-4.23 2.554-1.176A2.494 2.494 0 0 0 15.95 13h1.1a3.493 3.493 0 1 0 3.45-4zm-7-7A1.5 1.5 0 1 1 12 3.5 1.502 1.502 0 0 1 13.5 2zm0 18a1.5 1.5 0 1 1-1.5 1.5 1.502 1.502 0 0 1 1.5-1.5zM1 7.5a1.5 1.5 0 1 1 2.457 1.145l-.144.112A1.496 1.496 0 0 1 1 7.5zm3.32 1.703a2.507 2.507 0 0 0 .264-.326l2.752 1.251-1.124 1.512zM2.5 19A1.5 1.5 0 1 1 4 17.5 1.502 1.502 0 0 1 2.5 19zm2.037-2.941a2.518 2.518 0 0 0-.193-.234l1.885-2.532 1.136 1.464zm3.76-1.731L6.849 12.46l1.42-1.908L11.1 11.84a2.29 2.29 0 0 0-.033 1.213zM13.5 14a1.5 1.5 0 1 1 1.5-1.5 1.502 1.502 0 0 1-1.5 1.5zm7 1a2.5 2.5 0 1 1 2.5-2.5 2.502 2.502 0 0 1-2.5 2.5zm1.5-2.5a1.5 1.5 0 1 1-1.5-1.5 1.502 1.502 0 0 1 1.5 1.5z"/><path fill="none" d="M0 0h24v24H0z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
Reference in new issue
Block a user