fix(cluster): artist Play All/top tracks and member-scoped browse UX

Load artist album tracks via getAlbumForServer when clusterSeedServerId is set
so Play All, shuffle, and top-track clicks resolve on the correct member server.
Also improve connection indicator tooltips, Song Info server labels, artist browse
covers/detail fallbacks, and faster merged artist list SQL.
This commit is contained in:
Maxim Isaev
2026-06-05 18:38:10 +03:00
parent 884951fbc2
commit 1d65a8d339
86 changed files with 1194 additions and 212 deletions
+4 -1
View File
@@ -77,7 +77,10 @@ function AlbumCard({
return meta.trackIds.every(tid => !!s.tracks[`${serverId}:${tid}`]);
});
const psyDrag = useDragDrop();
const coverRef = useAlbumCoverRef(album.id, album.coverArt, undefined, { libraryResolve });
const coverRef = useAlbumCoverRef(album.id, album.coverArt, undefined, {
libraryResolve,
clusterSeedServerId: album.clusterSeedServerId,
});
const dragCoverKey = useMemo(() => {
if (!coverRef) return '';
const tier = resolveCoverDisplayTier(displayCssPx, { surface: 'dense' });
+5 -1
View File
@@ -5,6 +5,7 @@ import { useNavigate } from 'react-router-dom';
import { Play, Heart, ExternalLink, X, ChevronLeft, Download, ListPlus, HardDriveDownload, Share2, Highlighter, Loader2, Shuffle } from 'lucide-react';
import { CoverArtImage } from '../cover/CoverArtImage';
import { useAlbumCoverRef } from '../cover/useLibraryCoverRef';
import { COVER_SCOPE_ACTIVE, type CoverServerScope } from '../cover/types';
import { useCoverLightboxSrc } from '../cover/lightbox';
import { useTranslation } from 'react-i18next';
import { useIsMobile } from '../hooks/useIsMobile';
@@ -71,6 +72,8 @@ interface AlbumHeaderProps {
headerArtistRefs: SubsonicOpenArtistRef[];
songs: SubsonicSong[];
coverArtId?: string;
/** Cluster / multi-server album detail — fetch cover from the seed member, not representative. */
coverServerScope?: CoverServerScope;
resolvedCoverUrl: string | null;
isStarred: boolean;
downloadProgress: number | null;
@@ -98,6 +101,7 @@ export default function AlbumHeader({
headerArtistRefs,
songs,
coverArtId,
coverServerScope = COVER_SCOPE_ACTIVE,
resolvedCoverUrl,
isStarred,
downloadProgress,
@@ -124,7 +128,7 @@ export default function AlbumHeader({
const isMobile = useIsMobile();
const enableCoverArtBackground = useThemeStore(s => s.enableCoverArtBackground);
const coverRef = useAlbumCoverRef(info.id, coverArtId, undefined, { libraryResolve: true });
const coverRef = useAlbumCoverRef(info.id, coverArtId, coverServerScope, { libraryResolve: true });
const { open: openLightbox, lightbox } = useCoverLightboxSrc(coverRef, {
alt: `${info.name} Cover`,
});
+4 -1
View File
@@ -18,7 +18,10 @@ interface Props {
export default function ArtistCardLocal({ artist, linkQuery, libraryResolve = false }: Props) {
const { t } = useTranslation();
const navigateToArtist = useNavigateToArtist();
const coverRef = useArtistCoverRef(artist.id, artist.coverArt, undefined, { libraryResolve });
const coverRef = useArtistCoverRef(artist.id, artist.coverArt, undefined, {
libraryResolve,
clusterSeedServerId: artist.clusterSeedServerId,
});
return (
<div
+7 -2
View File
@@ -5,6 +5,7 @@ import { songToTrack } from '../utils/playback/songToTrack';
import { shuffleArray } from '../utils/playback/shuffleArray';
import React, { memo, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigateToAlbum } from '../hooks/useNavigateToAlbum';
import { useTranslation } from 'react-i18next';
import { Play, ListPlus, Music } from 'lucide-react';
import { useAlbumCoverRef } from '../cover/useLibraryCoverRef';
@@ -584,15 +585,19 @@ const BecauseCard = memo(function BecauseCard({ album, anchor, disableArtwork, e
onLongPress: () => playAlbumShuffled(album.id),
});
const navigate = useNavigate();
const navigateToAlbum = useNavigateToAlbum();
const enqueue = usePlayerStore(s => s.enqueue);
const coverRef = useAlbumCoverRef(album.id, album.coverArt, undefined, { libraryResolve: false });
const coverRef = useAlbumCoverRef(album.id, album.coverArt, undefined, {
libraryResolve: false,
clusterSeedServerId: album.clusterSeedServerId,
});
const coverHandle = useCoverArt(coverRef, BECAUSE_CARD_COVER_CSS_PX, {
surface: 'dense',
ensurePriority: 'high',
});
const imgSrc = coverImgSrc(coverHandle.src);
const bgResolved = coverHandle.src;
const handleOpen = () => navigate(`/album/${album.id}`);
const handleOpen = () => navigateToAlbum(album.id, { seedServerId: album.clusterSeedServerId });
const handleEnqueue = async (e: React.MouseEvent) => {
e.stopPropagation();
try {
+26 -11
View File
@@ -5,12 +5,12 @@ import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { Check, ChevronDown, Layers } from 'lucide-react';
import { ConnectionStatus } from '../hooks/useConnectionStatus';
import { useClusterConnectionLed } from '../hooks/useClusterConnectionLed';
import { useAuthStore } from '../store/authStore';
import { switchActiveCluster, switchActiveServer } from '../utils/server/switchActiveServer';
import { showToast } from '../utils/ui/toast';
import { serverListDisplayLabel } from '../utils/server/serverDisplayName';
import type { ServerCluster } from '../utils/serverCluster/types';
import ClusterMergeBanner from './ClusterMergeBanner';
interface Props {
status: ConnectionStatus;
@@ -35,6 +35,9 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
const activeCluster = activeClusterId
? clusters.find(cluster => cluster.id === activeClusterId) ?? null
: null;
const clusterLed = useClusterConnectionLed(activeCluster);
const effectiveStatus =
activeCluster && clusterLed.ledStatus !== null ? clusterLed.ledStatus : status;
const updateMenuPosition = useCallback(() => {
const el = hostRef.current;
@@ -120,13 +123,19 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
};
const label = isLan ? 'LAN' : t('connection.extern');
const tooltip = multi
? t('connection.switchScopeHint')
: status === 'connected'
const metaTooltip = multi ? t('connection.switchScopeHint') : undefined;
const statusTooltip =
effectiveStatus === 'connected'
? t('connection.connectedTo', { server: serverName })
: status === 'disconnected'
: effectiveStatus === 'disconnected'
? t('connection.disconnectedFrom', { server: serverName })
: t('connection.checking');
: effectiveStatus === 'degraded'
? t('connection.connectedTo', { server: serverName })
: t('connection.checking');
const clusterActive = Boolean(activeCluster);
const ledTooltip = clusterActive
? (clusterLed.ledTooltip ?? t('connection.checking'))
: statusTooltip;
const renderMenuItem = (id: string, labelText: string, active: boolean, onClick: () => void, icon?: React.ReactNode) => (
<button
@@ -157,14 +166,21 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
className="connection-indicator"
style={{ cursor: 'pointer' }}
onClick={onTriggerClick}
data-tooltip={tooltip}
data-tooltip-pos="bottom"
role={multi ? 'button' : undefined}
aria-haspopup={multi ? 'menu' : undefined}
aria-expanded={multi ? menuOpen : undefined}
>
<div className={`connection-led connection-led--${status}`} />
<div className="connection-meta">
<div
className={`connection-led connection-led--${effectiveStatus}`}
data-tooltip={ledTooltip}
data-tooltip-pos="bottom"
{...(clusterActive ? { 'data-tooltip-wrap': true } : {})}
/>
<div
className="connection-meta"
data-tooltip={metaTooltip}
data-tooltip-pos="bottom"
>
<span className="connection-type">{label}</span>
<span className="connection-server" style={{ display: 'flex', alignItems: 'center', gap: 4, maxWidth: 120 }}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{serverName}</span>
@@ -174,7 +190,6 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props
</span>
</div>
</div>
{activeCluster && <ClusterMergeBanner cluster={activeCluster} />}
{multi &&
menuOpen &&
typeof document !== 'undefined' &&
+4 -2
View File
@@ -267,7 +267,9 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
});
}, [album?.id]);
const heroCoverRef = useAlbumCoverRef(album?.id, album?.coverArt);
const heroCoverRef = useAlbumCoverRef(album?.id, album?.coverArt, undefined, {
clusterSeedServerId: album?.clusterSeedServerId,
});
const bgHandle = useCoverArt(heroCoverRef, HERO_BG_CSS_PX, {
surface: 'dense',
ensurePriority: 'high',
@@ -293,7 +295,7 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
className="hero"
role="banner"
aria-label={t('hero.eyebrow')}
onClick={() => navigateToAlbum(album.id)}
onClick={() => navigateToAlbum(album.id, { seedServerId: album.clusterSeedServerId })}
style={{ cursor: 'pointer' }}
>
{enableCoverArtBackground && !perfFlags.disableMainstageHeroBackdrop && heroInView && <HeroBg url={stableBgUrl.current} />}
+35 -17
View File
@@ -23,6 +23,7 @@ import {
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useNavigateToAlbum } from '../hooks/useNavigateToAlbum';
import { useNavigateToArtist } from '../hooks/useNavigateToArtist';
import { Search, Disc3, Users, Music, TextSearch, Database, Globe } from 'lucide-react';
import { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
@@ -34,7 +35,7 @@ import { AlbumCoverArtImage } from '../cover/AlbumCoverArtImage';
import { ArtistCoverArtImage } from '../cover/ArtistCoverArtImage';
import { CoverArtImage } from '../cover/CoverArtImage';
import { COVER_DENSE_SEARCH_CSS_PX } from '../cover/layoutSizes';
import { albumCoverRef } from '../cover/ref';
import { albumCoverRef, coverScopeForServerProfileId } from '../cover/ref';
import { showToast } from '../utils/ui/toast';
import { useShareSearch } from '../hooks/useShareSearch';
import ShareSearchResults from './search/ShareSearchResults';
@@ -55,11 +56,20 @@ import { resolveIndexKey } from '../utils/server/serverIndexKey';
type LiveSearchSource = 'local' | 'network';
function LiveSearchAlbumThumb({ albumId, coverArt }: { albumId: string; coverArt: string }) {
function LiveSearchAlbumThumb({
albumId,
coverArt,
clusterSeedServerId,
}: {
albumId: string;
coverArt: string;
clusterSeedServerId?: string;
}) {
return (
<AlbumCoverArtImage
albumId={albumId}
coverArt={coverArt}
clusterSeedServerId={clusterSeedServerId}
libraryResolve={false}
displayCssPx={COVER_DENSE_SEARCH_CSS_PX}
surface="dense"
@@ -70,17 +80,19 @@ function LiveSearchAlbumThumb({ albumId, coverArt }: { albumId: string; coverArt
);
}
function LiveSearchSongThumb({ song }: { song: Pick<SubsonicSong, 'id' | 'albumId' | 'coverArt' | 'discNumber'> }) {
// Search results carry the per-track `mf-…` coverArt id, which the cover
// pipeline fails to resolve and the thumbnail goes blank. The album-scoped
// `al-<albumId>_0` id is what actually loads (verified in the RC1 blank-thumb
// investigation), and a song's search thumbnail is its album cover anyway —
// so fetch the album cover from the albumId. Falls back to a music glyph when
// there is no album to key on.
function LiveSearchSongThumb({
song,
}: {
song: Pick<SubsonicSong, 'id' | 'albumId' | 'coverArt' | 'discNumber' | 'clusterBrowseServerId'>;
}) {
const albumId = song.albumId?.trim();
const scope = React.useMemo(
() => coverScopeForServerProfileId(song.clusterBrowseServerId),
[song.clusterBrowseServerId],
);
const coverRef = React.useMemo(
() => (albumId ? albumCoverRef(albumId, `al-${albumId}_0`) : undefined),
[albumId],
() => (albumId ? albumCoverRef(albumId, `al-${albumId}_0`, scope) : undefined),
[albumId, scope],
);
if (!coverRef) return <div className="search-result-icon"><Music size={14} /></div>;
return (
@@ -95,7 +107,11 @@ function LiveSearchSongThumb({ song }: { song: Pick<SubsonicSong, 'id' | 'albumI
);
}
function LiveSearchArtistThumb({ artist }: { artist: Pick<SubsonicArtist, 'id' | 'coverArt'> }) {
function LiveSearchArtistThumb({
artist,
}: {
artist: Pick<SubsonicArtist, 'id' | 'coverArt' | 'clusterSeedServerId'>;
}) {
const [failed, setFailed] = useState(false);
useEffect(() => { setFailed(false); }, [artist.id, artist.coverArt]);
if (failed) return <div className="search-result-icon"><Users size={14} /></div>;
@@ -103,6 +119,7 @@ function LiveSearchArtistThumb({ artist }: { artist: Pick<SubsonicArtist, 'id' |
<ArtistCoverArtImage
artistId={artist.id}
coverArt={artist.coverArt}
clusterSeedServerId={artist.clusterSeedServerId}
libraryResolve={false}
displayCssPx={COVER_DENSE_SEARCH_CSS_PX}
surface="dense"
@@ -138,6 +155,7 @@ export default function LiveSearch() {
const liveSearchGenRef = useRef(0);
const navigate = useNavigate();
const navigateToAlbum = useNavigateToAlbum();
const navigateToArtist = useNavigateToArtist();
const enqueue = usePlayerStore(state => state.enqueue);
const openContextMenu = usePlayerStore(state => state.openContextMenu);
const ctxIsOpen = usePlayerStore(state => state.contextMenu.isOpen);
@@ -527,8 +545,8 @@ export default function LiveSearch() {
},
},
] : results ? [
...(results.artists.map(a => ({ id: a.id, action: () => { navigate(`/artist/${a.id}`); setOpen(false); setQuery(''); } }))),
...(results.albums.map(a => ({ id: a.id, action: () => { navigateToAlbum(a.id); setOpen(false); setQuery(''); } }))),
...(results.artists.map(a => ({ id: a.id, action: () => { navigateToArtist(a.id, { seedServerId: a.clusterSeedServerId }); setOpen(false); setQuery(''); } }))),
...(results.albums.map(a => ({ id: a.id, action: () => { navigateToAlbum(a.id, { seedServerId: a.clusterSeedServerId }); setOpen(false); setQuery(''); } }))),
...(results.songs.map(s => ({ id: s.id, action: () => {
const track = songToTrack(s);
enqueue([track]);
@@ -738,7 +756,7 @@ export default function LiveSearch() {
const isCtxActive = ctxIsOpen && ctxType === 'artist' && ctxItemId === a.id;
return (
<button key={a.id} className={`search-result-item${activeIndex === i ? ' active' : ''}${isCtxActive ? ' context-active' : ''}`}
onClick={() => { navigate(`/artist/${a.id}`); setOpen(false); setQuery(''); }}
onClick={() => { navigateToArtist(a.id, { seedServerId: a.clusterSeedServerId }); setOpen(false); setQuery(''); }}
onContextMenu={(e) => {
e.preventDefault();
openContextMenu(e.clientX, e.clientY, a, 'artist');
@@ -762,14 +780,14 @@ export default function LiveSearch() {
const isCtxActive = ctxIsOpen && ctxType === 'album' && ctxItemId === a.id;
return (
<button key={a.id} className={`search-result-item${activeIndex === i ? ' active' : ''}${isCtxActive ? ' context-active' : ''}`}
onClick={() => { navigateToAlbum(a.id); setOpen(false); setQuery(''); }}
onClick={() => { navigateToAlbum(a.id, { seedServerId: a.clusterSeedServerId }); setOpen(false); setQuery(''); }}
onContextMenu={(e) => {
e.preventDefault();
openContextMenu(e.clientX, e.clientY, a, 'album');
}}
role="option" aria-selected={activeIndex === i}>
{a.coverArt ? (
<LiveSearchAlbumThumb albumId={a.id} coverArt={a.coverArt} />
<LiveSearchAlbumThumb albumId={a.id} coverArt={a.coverArt} clusterSeedServerId={a.clusterSeedServerId} />
) : (
<div className="search-result-icon"><Disc3 size={14} /></div>
)}
+7
View File
@@ -8,6 +8,7 @@ import { open as shellOpen } from '@tauri-apps/plugin-shell';
import { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
import { usePlaybackServerId } from '../hooks/usePlaybackServerId';
import { useClusterMemberDisplayLabel } from '../hooks/useClusterMemberDisplayLabel';
import { fetchBandsintownEvents, type BandsintownEvent } from '../api/bandsintown';
import CachedImage from './CachedImage';
import OverlayScrollArea from './OverlayScrollArea';
@@ -88,6 +89,7 @@ export default function NowPlayingInfo() {
const setEnableBandsintown = useAuthStore(s => s.setEnableBandsintown);
const subsonicServerId = usePlaybackServerId();
const subsonicReady = Boolean(subsonicServerId);
const clusterServerLabel = useClusterMemberDisplayLabel(subsonicServerId);
const primaryArtist = currentTrack ? primaryTrackArtistRef(currentTrack) : null;
const artistName = primaryArtist?.name ?? currentTrack?.artist ?? '';
@@ -230,6 +232,11 @@ export default function NowPlayingInfo() {
<div className="np-info-artist-body">
<div className="np-info-section-title">{t('nowPlayingInfo.artist', 'Artist')}</div>
<div className="np-info-artist-name">{artistName || t('common.unknownArtist', 'Unknown artist')}</div>
{clusterServerLabel && (
<div className="np-info-cluster-server">
{t('nowPlayingInfo.clusterServer')}: {clusterServerLabel}
</div>
)}
{bioClean && (
<>
<p
+1 -1
View File
@@ -65,7 +65,7 @@ function SongCard({
const handleAlbumClick = (e: React.MouseEvent) => {
if (!song.albumId) return;
e.stopPropagation();
navigateToAlbum(song.albumId);
navigateToAlbum(song.albumId, { seedServerId: song.clusterBrowseServerId });
};
return (
+13 -7
View File
@@ -1,4 +1,4 @@
import { getSong } from '../api/subsonicLibrary';
import { getSongForServer } from '../api/subsonicLibrary';
import { libraryGetFacts } from '../api/library';
import type { SubsonicSong } from '../api/subsonicTypes';
import React, { useEffect, useState } from 'react';
@@ -22,6 +22,7 @@ import {
type ParsedTrackEnrichment,
} from '../utils/library/trackEnrichment';
import i18n from '../i18n';
import { useClusterMemberDisplayLabel } from '../hooks/useClusterMemberDisplayLabel';
function formatSize(bytes?: number): string | null {
if (!bytes) return null;
@@ -90,8 +91,9 @@ export default function SongInfoModal() {
setEnrichment(null);
setAbsolutePath(null);
const songId = songInfoModal.songId;
const streamServerId = songInfoModal.serverId ?? useAuthStore.getState().activeServerId ?? '';
void (async () => {
const s = await getSong(songId);
const s = streamServerId ? await getSongForServer(streamServerId, songId) : null;
if (cancelled) return;
setSong(s);
setLoading(false);
@@ -99,8 +101,7 @@ export default function SongInfoModal() {
setEnrichment(null);
return;
}
const auth = useAuthStore.getState();
const sid = auth.activeServerId;
const sid = streamServerId;
const indexEnabled = sid ? useLibraryIndexStore.getState().isIndexEnabled(sid) : false;
if (sid && indexEnabled && await libraryIsReady(sid)) {
try {
@@ -115,11 +116,11 @@ export default function SongInfoModal() {
setEnrichment(null);
}
})();
// Try the native API in parallel; only when the active server is Navidrome
// Try the native API in parallel; only when the stream server is Navidrome
// and we have credentials. Failures are silent — modal falls back to
// whatever the Subsonic `path` field carried (typically nothing).
const sid = streamServerId;
const auth = useAuthStore.getState();
const sid = auth.activeServerId;
const profile = sid ? auth.servers.find(p => p.id === sid) : null;
const identity = sid ? auth.subsonicServerIdentityByServer[sid] : undefined;
const isNavidrome = identity?.type?.trim().toLowerCase() === 'navidrome';
@@ -130,7 +131,9 @@ export default function SongInfoModal() {
});
}
return () => { cancelled = true; };
}, [songInfoModal.isOpen, songInfoModal.songId]);
}, [songInfoModal.isOpen, songInfoModal.songId, songInfoModal.serverId]);
const clusterServerLabel = useClusterMemberDisplayLabel(songInfoModal.serverId);
useEffect(() => {
if (!songInfoModal.isOpen) return;
@@ -189,6 +192,9 @@ export default function SongInfoModal() {
<CopyableFieldRow label={t('songInfo.songTitle')} text={song.title} />
<CopyableFieldRow label={t('songInfo.artist')} text={song.artist} />
<CopyableFieldRow label={t('songInfo.album')} text={song.album} />
{clusterServerLabel && (
<Row label={t('songInfo.clusterServer')} value={clusterServerLabel} />
)}
{song.albumArtist && song.albumArtist !== song.artist && (
<Row label={t('songInfo.albumArtist')} value={song.albumArtist} />
)}
+1 -1
View File
@@ -117,7 +117,7 @@ function SongRow({ song, showBpm }: Props) {
<span
className="track-artist-link"
style={{ cursor: 'pointer' }}
onClick={(e) => { e.stopPropagation(); navigateToAlbum(song.albumId!); }}
onClick={(e) => { e.stopPropagation(); navigateToAlbum(song.albumId!, { seedServerId: song.clusterBrowseServerId }); }}
title={song.album}
>{song.album}</span>
) : <span title={song.album}>{song.album}</span>}
@@ -16,10 +16,12 @@ interface Props {
marginTop: string;
playTopSongWithContinuation: (startIndex: number) => Promise<void>;
losslessOnly?: boolean;
clusterSeedServerId?: string;
}
export default function ArtistDetailTopTracks({
topSongs, albums, marginTop, playTopSongWithContinuation, losslessOnly = false,
clusterSeedServerId,
}: Props) {
const { t } = useTranslation();
const currentTrack = usePlayerStore(s => s.currentTrack);
@@ -96,7 +98,9 @@ export default function ArtistDetailTopTracks({
</button>
{(() => {
const albumForCover = topSongAlbumForCover(song, albums);
return albumForCover ? <ArtistTopTrackCover album={albumForCover} /> : null;
return albumForCover ? (
<ArtistTopTrackCover album={albumForCover} clusterSeedServerId={song.clusterBrowseServerId ?? clusterSeedServerId} />
) : null;
})()}
<div style={{ display: 'flex', flexDirection: 'column', minWidth: 0 }}>
<div className="track-title">{song.title}</div>
@@ -5,8 +5,17 @@ import { COVER_ARTIST_TOP_TRACK_CSS_PX } from '../../cover/layoutSizes';
import type { TopSongAlbumCoverSource } from './topSongAlbumForCover';
/** 32px album thumb — same cover ref path as {@link AlbumCard} on artist pages. */
export default function ArtistTopTrackCover({ album }: { album: TopSongAlbumCoverSource }) {
const coverRef = useAlbumCoverRef(album.id, album.coverArt, undefined, { libraryResolve: false });
export default function ArtistTopTrackCover({
album,
clusterSeedServerId,
}: {
album: TopSongAlbumCoverSource;
clusterSeedServerId?: string;
}) {
const coverRef = useAlbumCoverRef(album.id, album.coverArt, undefined, {
libraryResolve: false,
clusterSeedServerId,
});
if (!coverRef) return null;
return (
+2
View File
@@ -26,6 +26,7 @@ export function ArtistCardAvatar({ artist, showImages }: AvatarProps) {
<ArtistCoverArtImage
artistId={artist.id}
coverArt={artist.coverArt}
clusterSeedServerId={artist.clusterSeedServerId}
displayCssPx={COVER_DENSE_GRID_MIN_CELL_CSS_PX}
surface="dense"
alt={artist.name}
@@ -53,6 +54,7 @@ export function ArtistRowAvatar({ artist, showImages }: AvatarProps) {
<ArtistCoverArtImage
artistId={artist.id}
coverArt={artist.coverArt}
clusterSeedServerId={artist.clusterSeedServerId}
displayCssPx={COVER_DENSE_ARTIST_LIST_CSS_PX}
surface="dense"
alt={artist.name}
+3 -3
View File
@@ -14,7 +14,7 @@ interface TileProps {
selectedArtists: SubsonicArtist[];
showArtistImages: boolean;
toggleSelect: (id: string) => void;
onOpenArtist: (id: string) => void;
onOpenArtist: (artistId: string, opts?: { seedServerId?: string }) => void;
openContextMenu: PlayerState['openContextMenu'];
t: TFunction;
}
@@ -29,7 +29,7 @@ function ArtistGridTile({ artist, ...rest }: TileProps) {
if (rest.selectionMode) {
rest.toggleSelect(artist.id);
} else {
rest.onOpenArtist(artist.id);
rest.onOpenArtist(artist.id, { seedServerId: artist.clusterSeedServerId });
}
}}
onContextMenu={(e) => {
@@ -68,7 +68,7 @@ interface Props {
selectedArtists: SubsonicArtist[];
showArtistImages: boolean;
toggleSelect: (id: string) => void;
onOpenArtist: (id: string) => void;
onOpenArtist: (artistId: string, opts?: { seedServerId?: string }) => void;
openContextMenu: PlayerState['openContextMenu'];
t: TFunction;
}
+3 -3
View File
@@ -13,7 +13,7 @@ interface RowProps {
selectedArtists: SubsonicArtist[];
showArtistImages: boolean;
toggleSelect: (id: string) => void;
onOpenArtist: (id: string) => void;
onOpenArtist: (artistId: string, opts?: { seedServerId?: string }) => void;
openContextMenu: PlayerState['openContextMenu'];
t: TFunction;
}
@@ -37,7 +37,7 @@ function ArtistListRow({
if (selectionMode) {
toggleSelect(artist.id);
} else {
onOpenArtist(artist.id);
onOpenArtist(artist.id, { seedServerId: artist.clusterSeedServerId });
}
}}
onContextMenu={(e) => {
@@ -78,7 +78,7 @@ interface Props {
selectedArtists: SubsonicArtist[];
showArtistImages: boolean;
toggleSelect: (id: string) => void;
onOpenArtist: (id: string) => void;
onOpenArtist: (artistId: string, opts?: { seedServerId?: string }) => void;
openContextMenu: PlayerState['openContextMenu'];
t: TFunction;
}
@@ -111,7 +111,10 @@ export default function QueueItemContextItems(props: ContextMenuItemsProps) {
<div className="context-menu-item" onClick={() => handleAction(() => copyShareLink('track', song.id))}>
<Share2 size={14} /> {t('contextMenu.shareLink')}
</div>
<div className="context-menu-item" onClick={() => handleAction(() => openSongInfo(song.id))}>
<div className="context-menu-item" onClick={() => handleAction(() => openSongInfo(
song.id,
song.clusterBrowseServerId ?? queue[queueIndex ?? -1]?.serverId,
))}>
<Info size={14} /> {t('contextMenu.songInfo')}
</div>
</>
@@ -103,7 +103,7 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
)}
<div className="context-menu-divider" />
{song.albumId && (
<div className="context-menu-item" onClick={() => handleAction(() => navigateToAlbum(song.albumId!))}>
<div className="context-menu-item" onClick={() => handleAction(() => navigateToAlbum(song.albumId!, { seedServerId: song.clusterBrowseServerId }))}>
<Disc3 size={14} /> {t('contextMenu.openAlbum')}
</div>
)}
@@ -161,7 +161,7 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
<div className="context-menu-item" onClick={() => handleAction(() => copyShareLink('track', song.id))}>
<Share2 size={14} /> {t('contextMenu.shareLink')}
</div>
<div className="context-menu-item" onClick={() => handleAction(() => openSongInfo(song.id))}>
<div className="context-menu-item" onClick={() => handleAction(() => openSongInfo(song.id, song.clusterBrowseServerId))}>
<Info size={14} /> {t('contextMenu.songInfo')}
</div>
{playlistId && playlistSongIndex !== undefined && (
@@ -246,7 +246,7 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
</div>
<div className="context-menu-divider" />
{song.albumId && (
<div className="context-menu-item" onClick={() => handleAction(() => navigateToAlbum(song.albumId!))}>
<div className="context-menu-item" onClick={() => handleAction(() => navigateToAlbum(song.albumId!, { seedServerId: song.clusterBrowseServerId }))}>
<Disc3 size={14} /> {t('contextMenu.openAlbum')}
</div>
)}
@@ -298,7 +298,7 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
<div className="context-menu-item" onClick={() => handleAction(() => copyShareLink('track', song.id))}>
<Share2 size={14} /> {t('contextMenu.shareLink')}
</div>
<div className="context-menu-item" onClick={() => handleAction(() => openSongInfo(song.id))}>
<div className="context-menu-item" onClick={() => handleAction(() => openSongInfo(song.id, song.clusterBrowseServerId))}>
<Info size={14} /> {t('contextMenu.songInfo')}
</div>
<div className="context-menu-divider" />
@@ -31,7 +31,7 @@ export interface ContextMenuItemsProps {
setStarredOverride: (id: string, starred: boolean) => void;
lastfmLovedCache: Record<string, boolean>;
setLastfmLovedForSong: (title: string, artist: string, loved: boolean) => void;
openSongInfo: (id: string) => void;
openSongInfo: (id: string, serverId?: string | null) => void;
userRatingOverrides: Record<string, number>;
setKeyboardRating: React.Dispatch<React.SetStateAction<KeyboardRating | null>>;
keyboardRating: KeyboardRating | null;
+3 -1
View File
@@ -14,6 +14,7 @@ import MarqueeText from '../MarqueeText';
import { OpenArtistRefInline } from '../OpenArtistRefInline';
import StarRating from '../StarRating';
import { PlaybackBufferingOverlay } from '../playback/PlaybackBufferingOverlay';
import { useNavigateToAlbum } from '../../hooks/useNavigateToAlbum';
import { usePlayerStore } from '../../store/playerStore';
import {
usePlayerBarLayoutStore,
@@ -54,6 +55,7 @@ export function PlayerTrackInfo({
navigate, openContextMenu, t,
}: Props) {
const showBufferingOverlay = usePlayerStore(s => s.isPlaybackBuffering);
const navigateToAlbum = useNavigateToAlbum();
const playbackCoverRef = usePlaybackTrackCoverRef(
showPreviewMeta ? null : currentTrack ?? undefined,
);
@@ -125,7 +127,7 @@ export function PlayerTrackInfo({
: displayTitle}
className="player-track-name"
style={{ cursor: !isRadio && !showPreviewMeta && currentTrack?.albumId ? 'pointer' : 'default' }}
onClick={() => !isRadio && !showPreviewMeta && currentTrack?.albumId && navigate(`/album/${currentTrack.albumId}`)}
onClick={() => !isRadio && !showPreviewMeta && currentTrack?.albumId && navigateToAlbum(currentTrack.albumId, { seedServerId: currentTrack.clusterBrowseServerId })}
onContextMenu={!isRadio && !showPreviewMeta && currentTrack?.albumId
? (e) => {
e.preventDefault();
@@ -17,6 +17,7 @@ import { PlaybackBufferingOverlay } from '../playback/PlaybackBufferingOverlay';
import { CoverArtImage } from '../../cover/CoverArtImage';
import { OpenArtistRefInline } from '../OpenArtistRefInline';
import { usePlaybackTrackCoverRef } from '../../cover/useLibraryCoverRef';
import { useNavigateToAlbum } from '../../hooks/useNavigateToAlbum';
import { usePlayerStore } from '../../store/playerStore';
import { resolveTrackArtistRefs } from '../../utils/playback/trackArtistRefs';
@@ -53,6 +54,7 @@ export function QueueCurrentTrack({
lufsTgtBtnRef, lufsTgtMenuRef, lufsTgtPopStyle, t,
}: Props) {
const showBufferingOverlay = usePlayerStore(s => s.isPlaybackBuffering);
const navigateToAlbum = useNavigateToAlbum();
const coverRef = usePlaybackTrackCoverRef(currentTrack);
const artistRefs = resolveTrackArtistRefs(currentTrack);
const enrichment = useQueueTrackEnrichment(currentTrack.id);
@@ -234,7 +236,7 @@ export function QueueCurrentTrack({
</div>
<div
className={`queue-current-sub truncate${currentTrack.albumId ? ' is-link' : ''}`}
onClick={() => currentTrack.albumId && navigate(`/album/${currentTrack.albumId}`)}
onClick={() => currentTrack.albumId && navigateToAlbum(currentTrack.albumId, { seedServerId: currentTrack.clusterBrowseServerId })}
>{currentTrack.album}</div>
{currentTrack.year && (
<div className="queue-current-sub">{currentTrack.year}</div>
+1 -1
View File
@@ -164,7 +164,7 @@ export default function TracksPageChrome({
<span
className={hero.albumId ? 'track-artist-link' : ''}
style={{ cursor: hero.albumId ? 'pointer' : 'default' }}
onClick={() => hero.albumId && navigateToAlbum(hero.albumId)}
onClick={() => hero.albumId && navigateToAlbum(hero.albumId, { seedServerId: hero.clusterBrowseServerId })}
>{hero.album}</span>
</>
)}
+3 -1
View File
@@ -6,6 +6,7 @@ export type AlbumCoverArtImageProps = Omit<CoverArtImageProps, 'coverRef'> & {
albumId: string;
coverArt?: string | null;
serverScope?: CoverServerScope;
clusterSeedServerId?: string | null;
/** Live search: use API `coverArt` ids only (avoids library IPC per row). */
libraryResolve?: boolean;
};
@@ -14,6 +15,7 @@ export function AlbumCoverArtImage({
albumId,
coverArt,
serverScope,
clusterSeedServerId,
libraryResolve = false,
...rest
}: AlbumCoverArtImageProps) {
@@ -21,7 +23,7 @@ export function AlbumCoverArtImage({
albumId,
coverArt,
serverScope ?? COVER_SCOPE_ACTIVE,
{ libraryResolve },
{ libraryResolve, clusterSeedServerId },
);
if (!coverRef) return null;
return <CoverArtImage coverRef={coverRef} {...rest} />;
+3 -1
View File
@@ -6,6 +6,7 @@ export type ArtistCoverArtImageProps = Omit<CoverArtImageProps, 'coverRef'> & {
artistId: string;
coverArt?: string | null;
serverScope?: CoverServerScope;
clusterSeedServerId?: string | null;
libraryResolve?: boolean;
};
@@ -13,6 +14,7 @@ export function ArtistCoverArtImage({
artistId,
coverArt,
serverScope,
clusterSeedServerId,
libraryResolve = false,
...rest
}: ArtistCoverArtImageProps) {
@@ -20,7 +22,7 @@ export function ArtistCoverArtImage({
artistId,
coverArt,
serverScope ?? COVER_SCOPE_ACTIVE,
{ libraryResolve },
{ libraryResolve, clusterSeedServerId },
);
if (!coverRef) return null;
return <CoverArtImage coverRef={coverRef} {...rest} />;
+1 -1
View File
@@ -4,7 +4,7 @@ import { useTrackCoverRef } from './useLibraryCoverRef';
import { COVER_SCOPE_ACTIVE, type CoverServerScope } from './types';
export type TrackCoverArtImageProps = Omit<CoverArtImageProps, 'coverRef'> & {
song: Pick<SubsonicSong, 'id' | 'albumId' | 'coverArt' | 'discNumber'>;
song: Pick<SubsonicSong, 'id' | 'albumId' | 'coverArt' | 'discNumber' | 'clusterBrowseServerId'>;
serverScope?: CoverServerScope;
/** Default false for browse rails; true for queue/player rows needing per-disc art. */
libraryResolve?: boolean;
+45 -1
View File
@@ -1,8 +1,11 @@
import { getPlaybackServerId } from '../utils/playback/playbackServer';
import { useAuthStore } from '../store/authStore';
import { findServerByIdOrIndexKey } from '../utils/server/serverLookup';
import { usePlayerStore } from '../store/playerStore';
import { findServerByIdOrIndexKey, resolveServerIdForIndexKey } from '../utils/server/serverLookup';
import type { SubsonicSong } from '../api/subsonicTypes';
import type { Track } from '../store/playerStoreTypes';
import type { CoverArtId, CoverArtRef, CoverCacheKind, CoverServerScope } from './types';
import { COVER_SCOPE_ACTIVE } from './types';
import {
albumHasDistinctDiscCovers,
coverEntryToRef,
@@ -185,6 +188,23 @@ export function coverArtRef(
return albumCoverRef(id, id, serverScope);
}
/** Pin cover HTTP/disk scope to a specific library member (cluster browse rows). */
export function coverScopeForServerProfileId(
serverId: string | undefined | null,
fallback: CoverServerScope = COVER_SCOPE_ACTIVE,
): CoverServerScope {
if (!serverId?.trim()) return fallback;
const server = findServerByIdOrIndexKey(serverId);
if (!server) return fallback;
return {
kind: 'server',
serverId: server.id,
url: server.url,
username: server.username,
password: server.password,
};
}
export function resolvePlaybackCoverScope(): CoverServerScope {
const playbackSid = getPlaybackServerId();
const activeSid = useAuthStore.getState().activeServerId;
@@ -202,3 +222,27 @@ export function resolvePlaybackCoverScope(): CoverServerScope {
}
return { kind: 'playback' };
}
/** Playback / queue cover scope — per-track cluster member when queue spans servers. */
export function resolveCoverScopeForPlaybackTrack(
track: Pick<Track, 'clusterBrowseServerId'> | null | undefined,
queueRefServerId?: string | null,
): CoverServerScope {
const fromTrack = coverScopeForServerProfileId(track?.clusterBrowseServerId);
if (fromTrack.kind === 'server') return fromTrack;
if (queueRefServerId) {
const sid = resolveServerIdForIndexKey(queueRefServerId);
const fromQueue = coverScopeForServerProfileId(sid);
if (fromQueue.kind === 'server') return fromQueue;
}
return resolvePlaybackCoverScope();
}
/** Playback integrations (MPRIS, Discord, prewarm) — current track's cluster member. */
export function resolvePlaybackCoverScopeForCurrentTrack(): CoverServerScope {
const st = usePlayerStore.getState();
return resolveCoverScopeForPlaybackTrack(
st.currentTrack,
st.queueItems[st.queueIndex]?.serverId,
);
}
+56 -19
View File
@@ -7,14 +7,16 @@ import {
albumCoverRefForPlayback,
albumCoverRefForSong,
artistCoverRef,
coverScopeForServerProfileId,
resolveCoverScopeForPlaybackTrack,
resolveDistinctDiscCoversForAlbum,
resolvePlaybackCoverScope,
} from './ref';
import {
resolveAlbumCoverRefFromLibrary,
resolveArtistCoverRefFromLibrary,
resolveTrackCoverRefFromLibrary,
} from './resolveEntryLibrary';
import type { Track } from '../store/playerStoreTypes';
import { COVER_SCOPE_ACTIVE, coverScopeKey, type CoverArtRef, type CoverServerScope } from './types';
function coverRefsEqual(a: CoverArtRef, b: CoverArtRef): boolean {
@@ -43,8 +45,18 @@ export type LibraryCoverRefOptions = {
* detail headers and queue rows that need per-disc slots from SQLite.
*/
libraryResolve?: boolean;
/** Cluster browse row — pin cover HTTP/disk to this library member. */
clusterSeedServerId?: string | null;
};
function coverScopeWithClusterSeed(
serverScope: CoverServerScope,
clusterSeedServerId?: string | null,
): CoverServerScope {
if (!clusterSeedServerId) return serverScope;
return coverScopeForServerProfileId(clusterSeedServerId, serverScope);
}
/** Album grid / card — sync fallback, then local library index when indexed. */
export function useAlbumCoverRef(
albumId: string | null | undefined,
@@ -53,7 +65,11 @@ export function useAlbumCoverRef(
options?: LibraryCoverRefOptions,
): CoverArtRef | null {
const libraryResolve = options?.libraryResolve !== false;
const scopeKey = coverScopeKey(serverScope);
const resolvedScope = useMemo(
() => coverScopeWithClusterSeed(serverScope, options?.clusterSeedServerId),
[serverScope, options?.clusterSeedServerId],
);
const scopeKey = coverScopeKey(resolvedScope);
const distinctDiscCovers = useMemo(
() => resolveDistinctDiscCoversForAlbum(albumId ?? '', fallbackCoverArt),
[albumId, fallbackCoverArt],
@@ -61,8 +77,8 @@ export function useAlbumCoverRef(
const syncRef = useMemo(() => {
const id = albumId?.trim();
if (!id) return null;
return albumCoverRef(id, fallbackCoverArt, { serverScope, distinctDiscCovers });
}, [albumId, fallbackCoverArt, scopeKey, serverScope, distinctDiscCovers]);
return albumCoverRef(id, fallbackCoverArt, { serverScope: resolvedScope, distinctDiscCovers });
}, [albumId, fallbackCoverArt, scopeKey, resolvedScope, distinctDiscCovers]);
const [ref, setRef] = useState<CoverArtRef | null>(syncRef);
@@ -72,7 +88,7 @@ export function useAlbumCoverRef(
const id = albumId?.trim();
if (!id) return;
let cancelled = false;
void resolveAlbumCoverRefFromLibrary(id, fallbackCoverArt, serverScope).then(next => {
void resolveAlbumCoverRefFromLibrary(id, fallbackCoverArt, resolvedScope).then(next => {
if (!cancelled) {
setRef(prev => (prev && coverRefsEqual(prev, next) ? prev : next));
}
@@ -80,7 +96,7 @@ export function useAlbumCoverRef(
return () => {
cancelled = true;
};
}, [albumId, fallbackCoverArt, scopeKey, syncRef, libraryResolve]);
}, [albumId, fallbackCoverArt, scopeKey, syncRef, libraryResolve, resolvedScope]);
return libraryResolve ? ref : syncRef;
}
@@ -93,12 +109,16 @@ export function useArtistCoverRef(
options?: LibraryCoverRefOptions,
): CoverArtRef | null {
const libraryResolve = options?.libraryResolve !== false;
const scopeKey = coverScopeKey(serverScope);
const resolvedScope = useMemo(
() => coverScopeWithClusterSeed(serverScope, options?.clusterSeedServerId),
[serverScope, options?.clusterSeedServerId],
);
const scopeKey = coverScopeKey(resolvedScope);
const syncRef = useMemo(() => {
const id = artistId?.trim();
if (!id) return null;
return artistCoverRef(id, fallbackCoverArt, serverScope);
}, [artistId, fallbackCoverArt, scopeKey, serverScope]);
return artistCoverRef(id, fallbackCoverArt, resolvedScope);
}, [artistId, fallbackCoverArt, scopeKey, resolvedScope]);
const [ref, setRef] = useState<CoverArtRef | null>(syncRef);
@@ -108,7 +128,7 @@ export function useArtistCoverRef(
const id = artistId?.trim();
if (!id) return;
let cancelled = false;
void resolveArtistCoverRefFromLibrary(id, fallbackCoverArt, serverScope).then(next => {
void resolveArtistCoverRefFromLibrary(id, fallbackCoverArt, resolvedScope).then(next => {
if (!cancelled) {
setRef(prev => (prev && coverRefsEqual(prev, next) ? prev : next));
}
@@ -116,19 +136,24 @@ export function useArtistCoverRef(
return () => {
cancelled = true;
};
}, [artistId, fallbackCoverArt, scopeKey, syncRef, libraryResolve]);
}, [artistId, fallbackCoverArt, scopeKey, syncRef, libraryResolve, resolvedScope]);
return libraryResolve ? ref : syncRef;
}
/** Track row / song card — album-scoped; multi-CD from library when indexed. */
export function useTrackCoverRef(
song: Pick<SubsonicSong, 'id' | 'albumId' | 'coverArt' | 'discNumber'> | null | undefined,
song: Pick<SubsonicSong, 'id' | 'albumId' | 'coverArt' | 'discNumber' | 'clusterBrowseServerId'> | null | undefined,
serverScope: CoverServerScope = COVER_SCOPE_ACTIVE,
options?: LibraryCoverRefOptions,
): CoverArtRef | undefined {
const libraryResolve = options?.libraryResolve !== false;
const scopeKey = coverScopeKey(serverScope);
const browseServerId = song?.clusterBrowseServerId;
const resolvedScope = useMemo(
() => (browseServerId ? coverScopeForServerProfileId(browseServerId, serverScope) : serverScope),
[browseServerId, serverScope],
);
const scopeKey = coverScopeKey(resolvedScope);
const songId = song?.id;
const albumId = song?.albumId;
const coverArt = song?.coverArt;
@@ -151,8 +176,9 @@ export function useTrackCoverRef(
return albumCoverRefForSong(
{ id: songId, albumId, coverArt, discNumber },
distinctDiscCovers,
resolvedScope,
);
}, [songId, albumId, coverArt, discNumber, distinctDiscCovers]);
}, [songId, albumId, coverArt, discNumber, distinctDiscCovers, resolvedScope]);
const [ref, setRef] = useState<CoverArtRef | undefined>(syncRef);
@@ -165,7 +191,7 @@ export function useTrackCoverRef(
let cancelled = false;
void resolveTrackCoverRefFromLibrary(
{ ...song, id: trackId, albumId: al },
serverScope,
resolvedScope,
distinctDiscCovers,
).then(next => {
if (!cancelled) {
@@ -190,16 +216,18 @@ export function useTrackCoverRef(
return () => {
cancelled = true;
};
}, [song, songId, albumId, coverArt, discNumber, scopeKey, syncRef, libraryResolve, distinctDiscCovers]);
}, [song, songId, albumId, coverArt, discNumber, scopeKey, syncRef, libraryResolve, distinctDiscCovers, resolvedScope]);
return libraryResolve ? ref : syncRef;
}
/** Now playing / queue — playback server scope + library-backed multi-CD. */
export function usePlaybackTrackCoverRef(
track: Parameters<typeof albumCoverRefForPlayback>[0] | null | undefined,
track: (Parameters<typeof albumCoverRefForPlayback>[0] & Pick<Track, 'clusterBrowseServerId'>) | null | undefined,
): CoverArtRef | undefined {
const queueServerId = usePlayerStore(s => s.queueServerId);
const queueIndex = usePlayerStore(s => s.queueIndex);
const queueRefServerId = usePlayerStore(s => s.queueItems[s.queueIndex]?.serverId ?? null);
const queueLength = usePlayerStore(s => s.queueItems.length);
const activeServerId = useAuthStore(s => s.activeServerId);
const serversFingerprint = useAuthStore(s =>
@@ -209,8 +237,17 @@ export function usePlaybackTrackCoverRef(
);
const scope = useMemo(
() => resolvePlaybackCoverScope(),
[queueServerId, queueLength, activeServerId, serversFingerprint],
() => resolveCoverScopeForPlaybackTrack(track, queueRefServerId),
[
track,
track?.clusterBrowseServerId,
queueRefServerId,
queueServerId,
queueIndex,
queueLength,
activeServerId,
serversFingerprint,
],
);
const scopeKey = coverScopeKey(scope);
+18 -3
View File
@@ -1,16 +1,22 @@
import { useMemo } from 'react';
import { resolvePlaybackCoverScope } from './ref';
import { resolveCoverScopeForPlaybackTrack } from './ref';
import type { CoverArtHandle, CoverArtRef } from './types';
import { useCoverArt } from './useCoverArt';
import { useAuthStore } from '../store/authStore';
import { usePlayerStore } from '../store/playerStore';
import type { Track } from '../store/playerStoreTypes';
/** Cover art for playback queue — uses queue server when it differs from browsed server. */
export function usePlaybackCoverArt(
coverRef: CoverArtRef | undefined,
displayCssPx: number,
track?: Pick<Track, 'clusterBrowseServerId'> | null,
): CoverArtHandle {
const storeTrack = usePlayerStore(s => s.currentTrack);
const resolvedTrack = track ?? storeTrack ?? null;
const queueServerId = usePlayerStore(s => s.queueServerId);
const queueIndex = usePlayerStore(s => s.queueIndex);
const queueRefServerId = usePlayerStore(s => s.queueItems[s.queueIndex]?.serverId ?? null);
const queueLength = usePlayerStore(s => s.queueItems.length);
const activeServerId = useAuthStore(s => s.activeServerId);
const serversFingerprint = useAuthStore(s =>
@@ -20,8 +26,17 @@ export function usePlaybackCoverArt(
);
const scope = useMemo(
() => resolvePlaybackCoverScope(),
[queueServerId, queueLength, activeServerId, serversFingerprint],
() => resolveCoverScopeForPlaybackTrack(resolvedTrack, queueRefServerId),
[
resolvedTrack,
resolvedTrack?.clusterBrowseServerId,
queueRefServerId,
queueServerId,
queueIndex,
queueLength,
activeServerId,
serversFingerprint,
],
);
const refWithScope = useMemo(
() => (coverRef ? { ...coverRef, serverScope: scope } : null),
+10 -1
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { search } from '../api/subsonicSearch';
import { getArtist, getArtistInfo, getTopSongs } from '../api/subsonicArtists';
import { getArtist, getArtistInfo, getArtistForServer, getTopSongs } from '../api/subsonicArtists';
import type {
SubsonicAlbum, SubsonicArtist, SubsonicArtistInfo, SubsonicSong,
} from '../api/subsonicTypes';
@@ -88,6 +88,15 @@ export function useArtistDetailData(
}
let nextAlbums = clusterData.albums;
let nextSongs = clusterData.topSongs;
if (nextAlbums.length === 0 && seedServerId) {
const seeded = await getArtistForServer(seedServerId, id).catch(() => null);
if (seeded?.albums?.length) {
nextAlbums = seeded.albums.map(a => ({
...a,
clusterSeedServerId: a.clusterSeedServerId ?? seedServerId,
}));
}
}
if (losslessOnly) {
({ albums: nextAlbums, songs: nextSongs } = filterNetworkArtistToLossless(nextAlbums, nextSongs));
}
+55
View File
@@ -0,0 +1,55 @@
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { ConnectionStatus } from './useConnectionStatus';
import type { ServerCluster } from '../utils/serverCluster/types';
import {
buildClusterMemberStatusTooltip,
clusterLedStatusFromDiagnostics,
getClusterMergeDiagnostics,
type ClusterMergeDiagnostics,
} from '../utils/serverCluster/clusterMergeStatus';
/** Cluster-mode LED state derived from all member probes (not representative only). */
export function useClusterConnectionLed(activeCluster: ServerCluster | null): {
ledStatus: ConnectionStatus | null;
ledTooltip: string | null;
} {
const { t } = useTranslation();
const [diag, setDiag] = useState<ClusterMergeDiagnostics | null>(null);
const [probing, setProbing] = useState(false);
const refresh = useCallback(async () => {
if (!activeCluster) {
setDiag(null);
return;
}
setProbing(true);
try {
const next = await getClusterMergeDiagnostics(activeCluster, { probeMembers: true });
setDiag(next);
} catch {
setDiag(null);
} finally {
setProbing(false);
}
}, [activeCluster]);
useEffect(() => {
void refresh();
const id = window.setInterval(() => void refresh(), 60_000);
return () => window.clearInterval(id);
}, [refresh]);
if (!activeCluster) {
return { ledStatus: null, ledTooltip: null };
}
if (probing && !diag) {
return { ledStatus: 'checking', ledTooltip: t('connection.checking') };
}
const resolved = diag ?? { members: [], mergeCount: 0, totalCount: 0 };
const ledStatus = clusterLedStatusFromDiagnostics(resolved);
const ledTooltip = buildClusterMemberStatusTooltip(t, resolved) || null;
return { ledStatus, ledTooltip };
}
+14
View File
@@ -0,0 +1,14 @@
import { useMemo } from 'react';
import { useAuthStore } from '../store/authStore';
import { isClusterMode } from '../utils/serverCluster/clusterScope';
import { serverListDisplayLabel } from '../utils/server/serverDisplayName';
/** Cluster member label for UI rows; null when not in cluster mode or server unknown. */
export function useClusterMemberDisplayLabel(serverId: string | undefined | null): string | null {
const servers = useAuthStore(s => s.servers);
return useMemo(() => {
if (!isClusterMode() || !serverId?.trim()) return null;
const server = servers.find(s => s.id === serverId);
return server ? serverListDisplayLabel(server, servers) : serverId;
}, [serverId, servers]);
}
+1 -1
View File
@@ -13,7 +13,7 @@ import { usePerfProbeFlags } from '../utils/perf/perfFlags';
// Backward-compatible re-export for call sites that still import from the hook.
export { isLanUrl };
export type ConnectionStatus = 'connected' | 'disconnected' | 'checking';
export type ConnectionStatus = 'connected' | 'degraded' | 'disconnected' | 'checking';
export function useConnectionStatus() {
const perfFlags = usePerfProbeFlags();
+2 -2
View File
@@ -1,7 +1,7 @@
import { useEffect } from 'react';
import { coverCacheEnsure, coverCachePeekBatch } from '../api/coverCache';
import { albumCoverRef } from '../cover/ref';
import { resolvePlaybackCoverScope } from '../cover/ref';
import { resolvePlaybackCoverScopeForCurrentTrack } from '../cover/ref';
import { resolveTrackCoverRefFromLibrary } from '../cover/resolveEntryLibrary';
import { getDiskSrc, rememberDiskSrc } from '../cover/diskSrcCache';
import { coverStorageKeyFromRef } from '../cover/storageKeys';
@@ -75,7 +75,7 @@ export function useNowPlayingPrewarm(): void {
coverArt: currentTrack.coverArt,
discNumber: (currentTrack as { discNumber?: number }).discNumber,
},
resolvePlaybackCoverScope(),
resolvePlaybackCoverScopeForCurrentTrack(),
).then(ref => {
if (ref) void prewarmCoverRef(ref);
});
+10 -4
View File
@@ -1,18 +1,24 @@
import { useMemo } from 'react';
import { useAuthStore } from '../store/authStore';
import { usePlayerStore } from '../store/playerStore';
import { getPlaybackServerId } from '../utils/playback/playbackServer';
import { getPlaybackServerId, resolveStreamServerIdForTrack } from '../utils/playback/playbackServer';
/**
* Subsonic server that owns the current queue / stream (may differ from the browsed
* server). Use for Now Playing metadata without calling `ensurePlaybackServerActive`.
* server). When a track is playing, resolves the cluster member for that track.
* Use for Now Playing metadata without calling `ensurePlaybackServerActive`.
*/
export function usePlaybackServerId(): string {
const queueServerId = usePlayerStore(s => s.queueServerId);
const queueLength = usePlayerStore(s => s.queueItems.length);
const queueIndex = usePlayerStore(s => s.queueIndex);
const queueRefServerId = usePlayerStore(s => s.queueItems[s.queueIndex]?.serverId);
const currentTrack = usePlayerStore(s => s.currentTrack);
const activeServerId = useAuthStore(s => s.activeServerId);
return useMemo(
() => getPlaybackServerId(),
[queueServerId, queueLength, activeServerId],
() => (currentTrack
? resolveStreamServerIdForTrack(currentTrack, queueRefServerId)
: getPlaybackServerId()),
[currentTrack, queueRefServerId, queueServerId, queueLength, queueIndex, activeServerId],
);
}
+5
View File
@@ -3,6 +3,11 @@ export const cluster = {
fanOutRatingFailed: 'Bewertungs-Synchronisierung fehlgeschlagen auf: {{servers}}.',
fanOutScrobbleFailed: 'Scrobble-Synchronisierung fehlgeschlagen auf: {{servers}}.',
mergeBanner: 'Cluster-Zusammenführung schließt aus: {{excluded}}.',
connectionTooltipPartial: '{{available}}/{{total}} Server erreichbar. {{details}}',
connectionTooltipNone: 'Keine Cluster-Server erreichbar. {{details}}',
memberStatusOffline: '{{name}}: offline',
memberStatusIndexing: '{{name}}: Index läuft',
memberStatusAvailable: '{{name}}: erreichbar',
memberIncluded: 'Eingeschlossen',
memberExcludedOffline: 'Ausgeschlossen (offline)',
memberExcludedIndexing: 'Ausgeschlossen (Index läuft)',
+1
View File
@@ -16,6 +16,7 @@ export const nowPlayingInfo = {
enableBandsintownPromptDesc: 'Optional. Lädt Tourdaten der aktuellen Künstler*in über die öffentliche Bandsintown-API.',
enableBandsintownPrivacy: 'Beim Aktivieren wird der Name der aktuell gespielten Künstler*in an die Bandsintown-API übertragen, um Tourdaten abzurufen. Es werden keine Konto- oder persönlichen Daten gesendet.',
enableBandsintownAction: 'Aktivieren',
clusterServer: 'Server',
role: {
artist: 'Hauptkünstler*in',
albumArtist: 'Album-Künstler*in',
+1
View File
@@ -24,4 +24,5 @@ export const songInfo = {
replayGainPeak: 'RG Track Peak',
mono: 'Mono',
stereo: 'Stereo',
clusterServer: 'Server',
};
+5
View File
@@ -3,6 +3,11 @@ export const cluster = {
fanOutRatingFailed: 'Rating sync failed on: {{servers}}.',
fanOutScrobbleFailed: 'Scrobble sync failed on: {{servers}}.',
mergeBanner: 'Cluster merge excludes: {{excluded}}.',
connectionTooltipPartial: '{{available}}/{{total}} servers available. {{details}}',
connectionTooltipNone: 'No cluster servers available. {{details}}',
memberStatusOffline: '{{name}}: offline',
memberStatusIndexing: '{{name}}: indexing',
memberStatusAvailable: '{{name}}: available',
memberIncluded: 'Included',
memberExcludedOffline: 'Excluded (offline)',
memberExcludedIndexing: 'Excluded (indexing)',
+1
View File
@@ -16,6 +16,7 @@ export const nowPlayingInfo = {
enableBandsintownPromptDesc: 'Optional. Loads concerts for the current artist via the public Bandsintown API.',
enableBandsintownPrivacy: 'When enabled, the name of the currently playing artist is sent to the Bandsintown API to fetch tour dates. No account or personal data leaves your device.',
enableBandsintownAction: 'Enable',
clusterServer: 'Server',
role: {
artist: 'Artist',
albumArtist: 'Album artist',
+1
View File
@@ -24,4 +24,5 @@ export const songInfo = {
replayGainPeak: 'RG Track Peak',
mono: 'Mono',
stereo: 'Stereo',
clusterServer: 'Server',
};
+5
View File
@@ -3,6 +3,11 @@ export const cluster = {
fanOutRatingFailed: 'La sincronización de valoraciones falló en: {{servers}}.',
fanOutScrobbleFailed: 'La sincronización de scrobble falló en: {{servers}}.',
mergeBanner: 'La fusión del clúster excluye: {{excluded}}.',
connectionTooltipPartial: '{{available}}/{{total}} servidores disponibles. {{details}}',
connectionTooltipNone: 'Ningún servidor del clúster disponible. {{details}}',
memberStatusOffline: '{{name}}: sin conexión',
memberStatusIndexing: '{{name}}: indexando',
memberStatusAvailable: '{{name}}: disponible',
memberIncluded: 'Incluido',
memberExcludedOffline: 'Excluido (sin conexión)',
memberExcludedIndexing: 'Excluido (indexando)',
+1
View File
@@ -16,6 +16,7 @@ export const nowPlayingInfo = {
enableBandsintownPromptDesc: 'Opcional. Carga conciertos del artista actual vía la API pública de Bandsintown.',
enableBandsintownPrivacy: 'Al activar, el nombre del artista actual se envía a la API de Bandsintown para obtener fechas de gira. No se envían datos de cuenta ni personales.',
enableBandsintownAction: 'Activar',
clusterServer: 'Servidor',
role: {
artist: 'Artista',
albumArtist: 'Artista del álbum',
+1
View File
@@ -24,4 +24,5 @@ export const songInfo = {
replayGainPeak: 'RG Pico de Pista',
mono: 'Mono',
stereo: 'Estéreo',
clusterServer: 'Servidor',
};
+5
View File
@@ -3,6 +3,11 @@ export const cluster = {
fanOutRatingFailed: 'Échec de synchro des notes sur : {{servers}}.',
fanOutScrobbleFailed: 'Échec de synchro des scrobbles sur : {{servers}}.',
mergeBanner: 'La fusion de cluster exclut : {{excluded}}.',
connectionTooltipPartial: '{{available}}/{{total}} serveurs disponibles. {{details}}',
connectionTooltipNone: 'Aucun serveur du cluster disponible. {{details}}',
memberStatusOffline: '{{name}} : hors ligne',
memberStatusIndexing: '{{name}} : indexation',
memberStatusAvailable: '{{name}} : disponible',
memberIncluded: 'Inclus',
memberExcludedOffline: 'Exclu (hors ligne)',
memberExcludedIndexing: 'Exclu (indexation)',
+1
View File
@@ -16,6 +16,7 @@ export const nowPlayingInfo = {
enableBandsintownPromptDesc: 'Optionnel. Charge les concerts de l\'artiste via l\'API publique Bandsintown.',
enableBandsintownPrivacy: 'Lors de l\'activation, le nom de l\'artiste en cours de lecture est envoyé à l\'API Bandsintown pour récupérer les dates de tournée. Aucun compte ni données personnelles ne quittent votre appareil.',
enableBandsintownAction: 'Activer',
clusterServer: 'Serveur',
role: {
artist: 'Artiste',
albumArtist: 'Artiste de l\'album',
+1
View File
@@ -24,4 +24,5 @@ export const songInfo = {
replayGainPeak: 'RG Crête de piste',
mono: 'Mono',
stereo: 'Stéréo',
clusterServer: 'Serveur',
};
+5
View File
@@ -3,6 +3,11 @@ export const cluster = {
fanOutRatingFailed: 'Vurderings-synk mislyktes på: {{servers}}.',
fanOutScrobbleFailed: 'Scrobble-synk mislyktes på: {{servers}}.',
mergeBanner: 'Cluster-fletting ekskluderer: {{excluded}}.',
connectionTooltipPartial: '{{available}}/{{total}} servere tilgjengelige. {{details}}',
connectionTooltipNone: 'Ingen cluster-servere tilgjengelige. {{details}}',
memberStatusOffline: '{{name}}: offline',
memberStatusIndexing: '{{name}}: indeksering',
memberStatusAvailable: '{{name}}: tilgjengelig',
memberIncluded: 'Inkludert',
memberExcludedOffline: 'Ekskludert (offline)',
memberExcludedIndexing: 'Ekskludert (indeksering)',
+1
View File
@@ -16,6 +16,7 @@ export const nowPlayingInfo = {
enableBandsintownPromptDesc: 'Valgfritt. Henter konserter for gjeldende artist via det offentlige Bandsintown-API-et.',
enableBandsintownPrivacy: 'Ved aktivering sendes navnet på artisten som spilles av til Bandsintown-API-et for å hente turnédatoer. Ingen konto- eller personopplysninger forlater enheten din.',
enableBandsintownAction: 'Aktiver',
clusterServer: 'Server',
role: {
artist: 'Artist',
albumArtist: 'Albumartist',
+1
View File
@@ -24,4 +24,5 @@ export const songInfo = {
replayGainPeak: 'RG-sportopp',
mono: 'Mono',
stereo: 'Stereo',
clusterServer: 'Server',
};
+5
View File
@@ -3,6 +3,11 @@ export const cluster = {
fanOutRatingFailed: 'Beoordelings-sync mislukt op: {{servers}}.',
fanOutScrobbleFailed: 'Scrobble-sync mislukt op: {{servers}}.',
mergeBanner: 'Cluster-samenvoeging sluit uit: {{excluded}}.',
connectionTooltipPartial: '{{available}}/{{total}} servers beschikbaar. {{details}}',
connectionTooltipNone: 'Geen clusterservers beschikbaar. {{details}}',
memberStatusOffline: '{{name}}: offline',
memberStatusIndexing: '{{name}}: indexeren',
memberStatusAvailable: '{{name}}: beschikbaar',
memberIncluded: 'Inbegrepen',
memberExcludedOffline: 'Uitgesloten (offline)',
memberExcludedIndexing: 'Uitgesloten (indexeren)',
+1
View File
@@ -16,6 +16,7 @@ export const nowPlayingInfo = {
enableBandsintownPromptDesc: 'Optioneel. Laadt concerten van de huidige artiest via de openbare Bandsintown-API.',
enableBandsintownPrivacy: 'Bij inschakelen wordt de naam van de huidige artiest naar de Bandsintown-API gestuurd om tourdata op te halen. Er worden geen account- of persoonlijke gegevens verzonden.',
enableBandsintownAction: 'Inschakelen',
clusterServer: 'Server',
role: {
artist: 'Artiest',
albumArtist: 'Albumartiest',
+1
View File
@@ -24,4 +24,5 @@ export const songInfo = {
replayGainPeak: 'RG Track Peak',
mono: 'Mono',
stereo: 'Stereo',
clusterServer: 'Server',
};
+5
View File
@@ -3,6 +3,11 @@ export const cluster = {
fanOutRatingFailed: 'Sincronizarea rating-urilor a eșuat pe: {{servers}}.',
fanOutScrobbleFailed: 'Sincronizarea scrobble a eșuat pe: {{servers}}.',
mergeBanner: 'Îmbinarea clusterului exclude: {{excluded}}.',
connectionTooltipPartial: '{{available}}/{{total}} servere disponibile. {{details}}',
connectionTooltipNone: 'Niciun server din cluster disponibil. {{details}}',
memberStatusOffline: '{{name}}: offline',
memberStatusIndexing: '{{name}}: indexare',
memberStatusAvailable: '{{name}}: disponibil',
memberIncluded: 'Inclus',
memberExcludedOffline: 'Exclus (offline)',
memberExcludedIndexing: 'Exclus (indexare)',
+1
View File
@@ -16,6 +16,7 @@ export const nowPlayingInfo = {
enableBandsintownPromptDesc: 'Opțional. Încarcă concertele pentru artistul curent prin API-ul public Bandsintown.',
enableBandsintownPrivacy: 'Când este pornit, numele artistului piesei curente redate este trimis către API-ul Bandsintown pentru a prelua datele turneelor. Nimic din datele personale sau ale contului nu părăsesc dispozitivul.',
enableBandsintownAction: 'Pornește',
clusterServer: 'Server',
role: {
artist: 'Artist',
albumArtist: 'Artist album',
+1
View File
@@ -24,4 +24,5 @@ export const songInfo = {
replayGainPeak: 'RG Vârf Piesă',
mono: 'Mono',
stereo: 'Stereo',
clusterServer: 'Server',
};
+5
View File
@@ -3,6 +3,11 @@ export const cluster = {
fanOutRatingFailed: 'Не удалось синхронизировать рейтинг на: {{servers}}.',
fanOutScrobbleFailed: 'Не удалось синхронизировать скроббл на: {{servers}}.',
mergeBanner: 'Из объединения кластера исключены: {{excluded}}.',
connectionTooltipPartial: 'Доступно {{available}} из {{total}} серверов. {{details}}',
connectionTooltipNone: 'Нет доступных серверов кластера. {{details}}',
memberStatusOffline: '{{name}}: офлайн',
memberStatusIndexing: '{{name}}: индексация',
memberStatusAvailable: '{{name}}: доступен',
memberIncluded: 'Включён',
memberExcludedOffline: 'Исключён (офлайн)',
memberExcludedIndexing: 'Исключён (индексация)',
+1
View File
@@ -18,6 +18,7 @@ export const nowPlayingInfo = {
enableBandsintownPromptDesc: 'Опционально. Загружает концерты текущего исполнителя через публичный API Bandsintown.',
enableBandsintownPrivacy: 'При включении имя текущего исполнителя отправляется в API Bandsintown для получения дат концертов. Аккаунт и личные данные не передаются.',
enableBandsintownAction: 'Включить',
clusterServer: 'Сервер',
role: {
artist: 'Исполнитель',
albumArtist: 'Исполнитель альбома',
+1
View File
@@ -24,4 +24,5 @@ export const songInfo = {
replayGainPeak: 'Пик RG',
mono: 'Моно',
stereo: 'Стерео',
clusterServer: 'Сервер',
};
+5
View File
@@ -3,6 +3,11 @@ export const cluster = {
fanOutRatingFailed: '以下服务器评分同步失败:{{servers}}。',
fanOutScrobbleFailed: '以下服务器 scrobble 同步失败:{{servers}}。',
mergeBanner: '集群合并已排除:{{excluded}}。',
connectionTooltipPartial: '{{available}}/{{total}} 个服务器可用。{{details}}',
connectionTooltipNone: '没有可用的集群服务器。{{details}}',
memberStatusOffline: '{{name}}:离线',
memberStatusIndexing: '{{name}}:索引中',
memberStatusAvailable: '{{name}}:可用',
memberIncluded: '已纳入',
memberExcludedOffline: '已排除(离线)',
memberExcludedIndexing: '已排除(索引中)',
+1
View File
@@ -16,6 +16,7 @@ export const nowPlayingInfo = {
enableBandsintownPromptDesc: '可选。通过公开的 Bandsintown API 加载当前艺术家的演出。',
enableBandsintownPrivacy: '启用后,当前播放的艺术家名称将发送到 Bandsintown API 以获取巡演日期。不会发送任何账户或个人数据。',
enableBandsintownAction: '启用',
clusterServer: '服务器',
role: {
artist: '艺术家',
albumArtist: '专辑艺术家',
+1
View File
@@ -24,4 +24,5 @@ export const songInfo = {
replayGainPeak: 'RG 曲目峰值',
mono: '单声道',
stereo: '立体声',
clusterServer: '服务器',
};
+21 -2
View File
@@ -6,7 +6,7 @@ import type { SubsonicSong } from '../api/subsonicTypes';
import { songToTrack } from '../utils/playback/songToTrack';
import { shuffleArray } from '../utils/playback/shuffleArray';
import React, { useEffect, useState, useCallback, useMemo } from 'react';
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
import { useParams, useNavigate, useSearchParams, useLocation } from 'react-router-dom';
import { invoke } from '@tauri-apps/api/core';
import { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
@@ -26,8 +26,10 @@ import { useCoverArt } from '../cover/useCoverArt';
import {
forgetAlbumDistinctDiscCovers,
rememberAlbumDistinctDiscCovers,
coverScopeForServerProfileId,
} from '../cover/ref';
import { useAlbumCoverRef } from '../cover/useLibraryCoverRef';
import { readClusterSeedServerId } from '../utils/navigation/albumDetailNavigation';
import { useTranslation } from 'react-i18next';
import { showToast } from '../utils/ui/toast';
import { useSelectionStore } from '../store/selectionStore';
@@ -45,6 +47,7 @@ export default function AlbumDetail() {
const perfFlags = usePerfProbeFlags();
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const location = useLocation();
const [searchParams] = useSearchParams();
const losslessOnly = isLosslessMode(searchParams);
const auth = useAuthStore();
@@ -104,8 +107,10 @@ export default function AlbumDetail() {
const handlePlayAll = () => {
if (!album || !effectiveSongs) return;
const albumGenre = album.album.genre;
const seedSid = album.album.clusterSeedServerId;
const tracks = effectiveSongs.map(s => {
const t = songToTrack(s);
if (!t.clusterBrowseServerId && seedSid) t.clusterBrowseServerId = seedSid;
if (!t.genre && albumGenre) t.genre = albumGenre;
return t;
});
@@ -115,8 +120,10 @@ const handlePlayAll = () => {
const handleEnqueueAll = () => {
if (!album || !effectiveSongs) return;
const albumGenre = album.album.genre;
const seedSid = album.album.clusterSeedServerId;
const tracks = effectiveSongs.map(s => {
const t = songToTrack(s);
if (!t.clusterBrowseServerId && seedSid) t.clusterBrowseServerId = seedSid;
if (!t.genre && albumGenre) t.genre = albumGenre;
return t;
});
@@ -126,8 +133,10 @@ const handleEnqueueAll = () => {
const handleShuffleAll = () => {
if (!album || !effectiveSongs) return;
const albumGenre = album.album.genre;
const seedSid = album.album.clusterSeedServerId;
const tracks = effectiveSongs.map(s => {
const t = songToTrack(s);
if (!t.clusterBrowseServerId && seedSid) t.clusterBrowseServerId = seedSid;
if (!t.genre && albumGenre) t.genre = albumGenre;
return t;
});
@@ -141,12 +150,15 @@ const handleShuffleAll = () => {
if (orbitActive) { queueHint(); return; }
if (!album || !effectiveSongs) return;
const albumGenre = album.album.genre;
const seedSid = album.album.clusterSeedServerId;
const tracks = effectiveSongs.map(s => {
const t = songToTrack(s);
if (!t.clusterBrowseServerId && seedSid) t.clusterBrowseServerId = seedSid;
if (!t.genre && albumGenre) t.genre = albumGenre;
return t;
});
const track = tracks.find(t => t.id === song.id) || songToTrack(song);
if (!track.clusterBrowseServerId && seedSid) track.clusterBrowseServerId = seedSid;
playTrack(track, tracks);
};
@@ -281,11 +293,17 @@ const handleShuffleAll = () => {
userRatingOverrides,
});
const albumSeedServerId = album?.album.clusterSeedServerId ?? readClusterSeedServerId(location.state);
const albumCoverScope = useMemo(
() => coverScopeForServerProfileId(albumSeedServerId),
[albumSeedServerId],
);
const albumCoverRefResolved = useAlbumCoverRef(
album?.album.id,
album?.album.coverArt,
undefined,
{ libraryResolve: true },
{ libraryResolve: true, clusterSeedServerId: albumSeedServerId },
);
const albumCover = useCoverArt(albumCoverRefResolved, 400, { surface: 'sparse' });
const resolvedCoverUrl = albumCover.src || null;
@@ -318,6 +336,7 @@ const handleShuffleAll = () => {
headerArtistRefs={headerArtistRefs}
songs={songs}
coverArtId={info.coverArt}
coverServerScope={albumCoverScope}
resolvedCoverUrl={resolvedCoverUrl}
isStarred={isStarred}
downloadProgress={null}
+8 -23
View File
@@ -2,9 +2,7 @@ import { uploadArtistImage } from '../api/subsonicPlaylists';
import { useCoverArt } from '../cover/useCoverArt';
import { useArtistCoverRef } from '../cover/useLibraryCoverRef';
import { setRating, star, unstar } from '../api/subsonicStarRating';
import { getAlbum } from '../api/subsonicLibrary';
import type { SubsonicArtist, SubsonicAlbum, SubsonicSong, SubsonicArtistInfo } from '../api/subsonicTypes';
import { songToTrack } from '../utils/playback/songToTrack';
import { useEffect, useState, useRef, Fragment, useMemo } from 'react';
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
import AlbumCard from '../components/AlbumCard';
@@ -33,6 +31,8 @@ import {
import { useArtistDetailData } from '../hooks/useArtistDetailData';
import { useArtistSimilarArtists } from '../hooks/useArtistSimilarArtists';
import {
buildArtistTopSongPlayQueue,
fetchArtistCatalogTracks,
runArtistDetailPlayAll, runArtistDetailShuffle, runArtistDetailStartRadio,
} from '../utils/componentHelpers/runArtistDetailPlay';
import {
@@ -137,29 +137,12 @@ export default function ArtistDetail() {
};
const playTopSongWithContinuation = async (startIndex: number) => {
if (!artist || albums.length === 0) return;
if (!artist || topSongs.length === 0) return;
setPlayAllLoading(true);
try {
// Get all artist tracks ordered by album and track number
const results = await Promise.all(albums.map(a => getAlbum(a.id)));
const sorted = [...results].sort((a, b) => (a.album.year ?? 0) - (b.album.year ?? 0));
const allTracks = sorted.flatMap(r => [...r.songs].sort((a, b) => (a.track ?? 0) - (b.track ?? 0))).map(songToTrack);
// Top songs from clicked index onward
const topTracksFromIndex = topSongs.slice(startIndex).map(songToTrack);
// Track IDs for deduplication
const topSongIds = new Set(topSongs.map(s => s.id));
// Filter remaining tracks to exclude top songs (prevent duplicates)
const remainingTracks = allTracks.filter(tr => !topSongIds.has(tr.id));
// Build queue: remaining top songs + rest of artist catalog
const queue = [...topTracksFromIndex, ...remainingTracks];
if (queue.length > 0) {
playTrack(queue[0], queue);
}
const catalogTracks = albums.length > 0 ? await fetchArtistCatalogTracks(albums) : [];
const queue = buildArtistTopSongPlayQueue(topSongs, startIndex, catalogTracks);
if (queue.length > 0) playTrack(queue[0], queue);
} finally {
setPlayAllLoading(false);
}
@@ -173,6 +156,7 @@ export default function ArtistDetail() {
const coverId = artist ? (artist.coverArt || artist.id) : '';
const artistCoverRefResolved = useArtistCoverRef(artist?.id, artist?.coverArt, undefined, {
libraryResolve: true,
clusterSeedServerId: artist?.clusterSeedServerId,
});
const artistCoverFallback = useCoverArt(artistCoverRefResolved, 80, { surface: 'sparse' });
@@ -346,6 +330,7 @@ export default function ArtistDetail() {
marginTop={sectionMt('topTracks')}
playTopSongWithContinuation={playTopSongWithContinuation}
losslessOnly={losslessOnly}
clusterSeedServerId={artist.clusterSeedServerId}
/>
);
@@ -1,5 +1,5 @@
import { invoke } from '@tauri-apps/api/core';
import { resolvePlaybackCoverScope } from '../../cover/ref';
import { resolvePlaybackCoverScopeForCurrentTrack } from '../../cover/ref';
import { resolveTrackCoverRefFromLibrary } from '../../cover/resolveEntryLibrary';
import { coverArtUrlForDiscord } from '../../cover/integrations/discord';
import { useAuthStore } from '../authStore';
@@ -96,7 +96,7 @@ export function setupDiscordPresence(): () => void {
coverArt: currentTrack.coverArt,
discNumber: (currentTrack as { discNumber?: number }).discNumber,
},
resolvePlaybackCoverScope(),
resolvePlaybackCoverScopeForCurrentTrack(),
).then(ref => {
if (!ref) {
sendPresence(null);
+2 -2
View File
@@ -1,5 +1,5 @@
import { invoke } from '@tauri-apps/api/core';
import { resolvePlaybackCoverScope } from '../../cover/ref';
import { resolvePlaybackCoverScopeForCurrentTrack } from '../../cover/ref';
import { resolveTrackCoverRefFromLibrary } from '../../cover/resolveEntryLibrary';
import { coverArtUrlForMpris } from '../../cover/integrations/mpris';
import { usePlayerStore } from '../playerStore';
@@ -35,7 +35,7 @@ export function setupMprisSync(): () => void {
coverArt: currentTrack.coverArt,
discNumber: (currentTrack as { discNumber?: number }).discNumber,
},
resolvePlaybackCoverScope(),
resolvePlaybackCoverScopeForCurrentTrack(),
).then(ref => {
if (!ref) return;
coverArtUrlForMpris(ref)
+9 -2
View File
@@ -266,7 +266,10 @@ export function runPlayTrack(
&& getPlaybackCacheServerKey(),
);
const browseServerId = authState.activeServerId ?? '';
const browseServerId =
track.clusterBrowseServerId
?? authState.activeServerId
?? '';
const browseTrackId = track.id;
void (async () => {
@@ -276,7 +279,11 @@ export function runPlayTrack(
if (isClusterMode() && browseServerId) {
const resolved = await resolveClusterPlaybackForTrack(browseServerId, track.id);
if (resolved) {
playTrackResolved = { ...track, id: resolved.trackId };
playTrackResolved = {
...track,
id: resolved.trackId,
clusterBrowseServerId: resolved.serverId,
};
streamServerProfileId = resolved.serverId;
}
}
+16 -2
View File
@@ -112,11 +112,25 @@ describe('openContextMenu / closeContextMenu', () => {
describe('openSongInfo / closeSongInfo', () => {
it('opens with the song id and clears on close', () => {
useAuthStore.setState({ activeServerId: 'a' });
usePlayerStore.getState().openSongInfo('song-1');
expect(usePlayerStore.getState().songInfoModal).toEqual({ isOpen: true, songId: 'song-1' });
expect(usePlayerStore.getState().songInfoModal).toEqual({
isOpen: true,
songId: 'song-1',
serverId: 'a',
});
usePlayerStore.getState().closeSongInfo();
expect(usePlayerStore.getState().songInfoModal).toEqual({ isOpen: false, songId: null });
expect(usePlayerStore.getState().songInfoModal).toEqual({
isOpen: false,
songId: null,
serverId: null,
});
});
it('stores explicit cluster member server id', () => {
usePlayerStore.getState().openSongInfo('song-2', 'b');
expect(usePlayerStore.getState().songInfoModal.serverId).toBe('b');
});
});
+1 -1
View File
@@ -68,7 +68,7 @@ export const usePlayerStore = create<PlayerState>()(
scheduledResumeStartMs: null,
repeatMode: initialPlayerPrefs.repeatMode,
contextMenu: { isOpen: false, x: 0, y: 0, item: null, type: null },
songInfoModal: { isOpen: false, songId: null },
songInfoModal: { isOpen: false, songId: null, serverId: null },
...createUiStateActions(set),
...createLastfmActions(set, get),
+2 -2
View File
@@ -227,7 +227,7 @@ export interface PlayerState {
) => void;
closeContextMenu: () => void;
songInfoModal: { isOpen: boolean; songId: string | null };
openSongInfo: (songId: string) => void;
songInfoModal: { isOpen: boolean; songId: string | null; serverId: string | null };
openSongInfo: (songId: string, serverId?: string | null) => void;
closeSongInfo: () => void;
}
+22 -2
View File
@@ -5,7 +5,11 @@ import type { PlayerState } from './playerStoreTypes';
import {
ensurePlaybackServerActive,
playbackServerDiffersFromActive,
resolveStreamServerIdForTrack,
} from '../utils/playback/playbackServer';
import { useAuthStore } from './authStore';
import { usePlayerStore } from './playerStore';
import { resolveServerIdForIndexKey } from '../utils/server/serverLookup';
type SetState = (
partial: Partial<PlayerState> | ((state: PlayerState) => Partial<PlayerState>),
@@ -77,8 +81,24 @@ export function createUiStateActions(set: SetState): Pick<
contextMenu: { ...state.contextMenu, isOpen: false },
})),
openSongInfo: (songId) => set({ songInfoModal: { isOpen: true, songId } }),
closeSongInfo: () => set({ songInfoModal: { isOpen: false, songId: null } }),
openSongInfo: (songId, serverId) => {
let sid: string | null = null;
if (serverId?.trim()) {
sid = resolveServerIdForIndexKey(serverId) || serverId;
} else {
const st = usePlayerStore.getState();
if (st.currentTrack?.id === songId) {
sid = resolveStreamServerIdForTrack(
st.currentTrack,
st.queueItems[st.queueIndex]?.serverId,
);
} else {
sid = useAuthStore.getState().activeServerId ?? null;
}
}
set({ songInfoModal: { isOpen: true, songId, serverId: sid } });
},
closeSongInfo: () => set({ songInfoModal: { isOpen: false, songId: null, serverId: null } }),
toggleQueue: () =>
set(state => {
@@ -38,6 +38,11 @@
box-shadow: 0 0 6px 2px rgba(166, 227, 161, 0.55);
}
.connection-led--degraded {
background: #f9e2af;
box-shadow: 0 0 6px 2px rgba(249, 226, 175, 0.55);
}
.connection-led--disconnected {
background: #f38ba8;
box-shadow: 0 0 6px 2px rgba(243, 139, 168, 0.55);
@@ -599,6 +599,12 @@ html[data-perf-disable-home-artwork-clip="true"] .home-lite-artwork .song-card-c
letter-spacing: -0.01em;
text-align: left;
}
.np-info-cluster-server {
font-size: 12px;
line-height: 1.4;
color: var(--text-secondary);
text-align: left;
}
.np-info-artist-bio {
margin: 0;
font-size: 13px;
@@ -0,0 +1,77 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
import type { SubsonicAlbum, SubsonicSong } from '../../api/subsonicTypes';
import {
buildArtistTopSongPlayQueue,
fetchArtistCatalogTracks,
} from './runArtistDetailPlay';
vi.mock('../../api/subsonicLibrary', () => ({
getAlbum: vi.fn(),
getAlbumForServer: vi.fn(),
}));
import { getAlbum, getAlbumForServer } from '../../api/subsonicLibrary';
const mockGetAlbum = vi.mocked(getAlbum);
const mockGetAlbumForServer = vi.mocked(getAlbumForServer);
describe('fetchArtistCatalogTracks', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('uses getAlbumForServer when clusterSeedServerId is set', async () => {
const albums: SubsonicAlbum[] = [
{ id: 'a1', name: 'Album', artist: 'A', artistId: 'ar1', songCount: 1, duration: 100, clusterSeedServerId: 'srv-1' },
];
mockGetAlbumForServer.mockResolvedValue({
album: { id: 'a1', name: 'Album', artist: 'A', artistId: 'ar1', songCount: 1, duration: 100, year: 2020 },
songs: [{ id: 't1', title: 'Track 1', artist: 'A', artistId: 'ar1', album: 'Album', albumId: 'a1', track: 1, duration: 100 }],
});
const tracks = await fetchArtistCatalogTracks(albums);
expect(mockGetAlbumForServer).toHaveBeenCalledWith('srv-1', 'a1');
expect(mockGetAlbum).not.toHaveBeenCalled();
expect(tracks).toHaveLength(1);
expect(tracks[0].id).toBe('t1');
expect(tracks[0].clusterBrowseServerId).toBe('srv-1');
});
it('uses getAlbum when no clusterSeedServerId', async () => {
const albums: SubsonicAlbum[] = [
{ id: 'a2', name: 'Album 2', artist: 'A', artistId: 'ar1', songCount: 1, duration: 100 },
];
mockGetAlbum.mockResolvedValue({
album: { id: 'a2', name: 'Album 2', artist: 'A', artistId: 'ar1', songCount: 1, duration: 100, year: 2019 },
songs: [{ id: 't2', title: 'Track 2', artist: 'A', artistId: 'ar1', album: 'Album 2', albumId: 'a2', track: 1, duration: 100 }],
});
const tracks = await fetchArtistCatalogTracks(albums);
expect(mockGetAlbum).toHaveBeenCalledWith('a2');
expect(mockGetAlbumForServer).not.toHaveBeenCalled();
expect(tracks[0].id).toBe('t2');
});
});
describe('buildArtistTopSongPlayQueue', () => {
const topSongs: SubsonicSong[] = [
{ id: 'top1', title: 'Top 1', artist: 'A', artistId: 'ar1', album: 'X', albumId: 'ax', duration: 100, clusterBrowseServerId: 's1' },
{ id: 'top2', title: 'Top 2', artist: 'A', artistId: 'ar1', album: 'Y', albumId: 'ay', duration: 100, clusterBrowseServerId: 's1' },
];
it('returns top songs from index when catalog is empty', () => {
const queue = buildArtistTopSongPlayQueue(topSongs, 1, []);
expect(queue.map(t => t.id)).toEqual(['top2']);
});
it('appends catalog tracks excluding top song ids', () => {
const catalog = [
{ id: 'top1', title: 'Top 1', artist: 'A', artistId: 'ar1', album: 'X', albumId: 'ax', duration: 100 },
{ id: 'other', title: 'Other', artist: 'A', artistId: 'ar1', album: 'Z', albumId: 'az', duration: 100 },
];
const queue = buildArtistTopSongPlayQueue(topSongs, 0, catalog);
expect(queue.map(t => t.id)).toEqual(['top1', 'top2', 'other']);
});
});
@@ -1,15 +1,49 @@
import type { TFunction } from 'i18next';
import { getAlbum } from '../../api/subsonicLibrary';
import { getAlbum, getAlbumForServer } from '../../api/subsonicLibrary';
import { getSimilarSongs2, getTopSongs } from '../../api/subsonicArtists';
import type { SubsonicAlbum, SubsonicArtist } from '../../api/subsonicTypes';
import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from '../../api/subsonicTypes';
import type { Track } from '../../store/playerStoreTypes';
import { songToTrack } from '../playback/songToTrack';
import { runBulkPlayAll, runBulkShuffle } from '../playback/runBulkPlay';
async function fetchAllTracks(albums: SubsonicAlbum[]): Promise<Track[]> {
const results = await Promise.all(albums.map(a => getAlbum(a.id)));
const sorted = [...results].sort((a, b) => (a.album.year ?? 0) - (b.album.year ?? 0));
return sorted.flatMap(r => [...r.songs].sort((a, b) => (a.track ?? 0) - (b.track ?? 0))).map(songToTrack);
async function fetchAlbumTracks(album: SubsonicAlbum): Promise<{ year: number; tracks: Track[] }> {
const serverId = album.clusterSeedServerId;
const data = serverId
? await getAlbumForServer(serverId, album.id)
: await getAlbum(album.id);
const genre = data.album.genre ?? album.genre;
const tracks = [...data.songs]
.sort((a, b) => (a.track ?? 0) - (b.track ?? 0))
.map(s => {
const track = songToTrack(
serverId ? { ...s, clusterBrowseServerId: s.clusterBrowseServerId ?? serverId } : s,
);
if (!track.genre && genre) track.genre = genre;
return track;
});
return { year: data.album.year ?? album.year ?? 0, tracks };
}
/** All album tracks for artist Play All / shuffle (cluster-aware per album). */
export async function fetchArtistCatalogTracks(albums: SubsonicAlbum[]): Promise<Track[]> {
if (albums.length === 0) return [];
const parts = await Promise.all(albums.map(fetchAlbumTracks));
return [...parts]
.sort((a, b) => a.year - b.year)
.flatMap(p => p.tracks);
}
/** Top-song click queue: from index through top list, then rest of catalog without dupes. */
export function buildArtistTopSongPlayQueue(
topSongs: SubsonicSong[],
startIndex: number,
catalogTracks: Track[],
): Track[] {
const topTracksFromIndex = topSongs.slice(startIndex).map(songToTrack);
if (catalogTracks.length === 0) return topTracksFromIndex;
const topSongIds = new Set(topSongs.map(s => s.id));
const remaining = catalogTracks.filter(tr => !topSongIds.has(tr.id));
return [...topTracksFromIndex, ...remaining];
}
export interface RunArtistDetailPlayDeps {
@@ -21,13 +55,21 @@ export interface RunArtistDetailPlayDeps {
export async function runArtistDetailPlayAll(deps: RunArtistDetailPlayDeps): Promise<void> {
const { albums, setPlayAllLoading, playTrack } = deps;
if (albums.length === 0) return;
await runBulkPlayAll({ fetchTracks: () => fetchAllTracks(albums), setLoading: setPlayAllLoading, playTrack });
await runBulkPlayAll({
fetchTracks: () => fetchArtistCatalogTracks(albums),
setLoading: setPlayAllLoading,
playTrack,
});
}
export async function runArtistDetailShuffle(deps: RunArtistDetailPlayDeps): Promise<void> {
const { albums, setPlayAllLoading, playTrack } = deps;
if (albums.length === 0) return;
await runBulkShuffle({ fetchTracks: () => fetchAllTracks(albums), setLoading: setPlayAllLoading, playTrack });
await runBulkShuffle({
fetchTracks: () => fetchArtistCatalogTracks(albums),
setLoading: setPlayAllLoading,
playTrack,
});
}
export interface RunArtistDetailStartRadioDeps {
+4 -1
View File
@@ -3,6 +3,7 @@ import { listen, emitTo } from '@tauri-apps/api/event';
import { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
import { resolveQueueTrack } from './library/queueTrackView';
import { getCurrentTrackStreamServerId } from './playback/playbackServer';
import type { SubsonicOpenArtistRef } from '../api/subsonicTypes';
export const MINI_WINDOW_LABEL = 'mini';
@@ -266,7 +267,9 @@ export function initMiniPlayerBridgeOnMain(): () => void {
w.unminimize().catch(() => {});
w.show().catch(() => {});
w.setFocus().catch(() => {});
usePlayerStore.getState().openSongInfo(id);
const st = usePlayerStore.getState();
const serverId = st.currentTrack?.id === id ? getCurrentTrackStreamServerId() : undefined;
st.openSongInfo(id, serverId);
});
return () => {
+24
View File
@@ -5,10 +5,12 @@ import {
bindQueueServerForPlayback,
clearQueueServerForPlayback,
ensurePlaybackServerActive,
getCurrentTrackStreamServerId,
getPlaybackServerId,
playbackCoverArtForId,
playbackServerDiffersFromActive,
prepareActiveServerForNewMix,
resolveStreamServerIdForTrack,
shouldBindQueueServerForPlay,
shouldHandoffQueueToActiveServer,
} from './playbackServer';
@@ -48,6 +50,28 @@ describe('playbackServer', () => {
expect(getPlaybackServerId()).toBe('b');
});
it('resolveStreamServerIdForTrack prefers clusterBrowseServerId over queue pin', () => {
usePlayerStore.setState({
queueItems: [{ serverId: 'a', trackId: 't1' }],
queueServerId: 'a',
queueIndex: 0,
currentTrack: {
id: 't1',
title: 'T',
artist: 'A',
album: 'Al',
albumId: 'al1',
duration: 100,
clusterBrowseServerId: 'b',
},
});
expect(resolveStreamServerIdForTrack(
usePlayerStore.getState().currentTrack,
'a',
)).toBe('b');
expect(getCurrentTrackStreamServerId()).toBe('b');
});
it('bindQueueServerForPlayback pins active server as canonical index key', () => {
useAuthStore.setState({ activeServerId: 'b' });
bindQueueServerForPlayback();
+27 -2
View File
@@ -1,5 +1,5 @@
import { buildCoverArtFetchUrl } from '../../cover/fetchUrl';
import { resolvePlaybackCoverScope } from '../../cover/ref';
import { resolvePlaybackCoverScopeForCurrentTrack } from '../../cover/ref';
import { coverEntryToRef, resolveAlbumCoverEntry } from '../../cover/resolveEntry';
import { coverStorageKeyFromRef } from '../../cover/storageKeys';
import { resolveCoverDisplayTier } from '../../cover/tiers';
@@ -15,6 +15,31 @@ import {
serverIndexKeyFromUrl,
} from '../server/serverIndexKey';
/** Per-track streaming server in cluster mixed queues (track ref → queue ref → queue pin). */
export function resolveStreamServerIdForTrack(
track: Pick<Track, 'clusterBrowseServerId'> | null | undefined,
queueRefServerId?: string | null,
): string {
if (track?.clusterBrowseServerId?.trim()) {
const sid = resolveServerIdForIndexKey(track.clusterBrowseServerId);
if (sid) return sid;
}
if (queueRefServerId) {
const sid = resolveServerIdForIndexKey(queueRefServerId);
if (sid) return sid;
}
return getPlaybackServerId();
}
/** Subsonic server that owns the currently playing track (cluster-aware). */
export function getCurrentTrackStreamServerId(): string {
const st = usePlayerStore.getState();
return resolveStreamServerIdForTrack(
st.currentTrack,
st.queueItems[st.queueIndex]?.serverId,
);
}
/** Server that owns the current queue / stream URLs (may differ from the browsed server). */
export function getPlaybackServerId(): string {
const { queueServerId, queueItems } = usePlayerStore.getState();
@@ -132,7 +157,7 @@ export function playbackCoverArtForAlbum(
if (!entry) {
return playbackCoverArtForId(coverArt, displayCssPx);
}
const ref = coverEntryToRef(entry, resolvePlaybackCoverScope());
const ref = coverEntryToRef(entry, resolvePlaybackCoverScopeForCurrentTrack());
const tier = resolveCoverDisplayTier(displayCssPx, { surface: 'sparse' });
return {
src: buildCoverArtFetchUrl(ref, tier),
+5
View File
@@ -60,6 +60,11 @@ describe('songToTrack', () => {
expect(t.coverArt).toBe('s2');
});
it('preserves clusterBrowseServerId for cluster-mode playback and covers', () => {
const song = makeSubsonicSong({ id: 's3', clusterBrowseServerId: 'server-b' });
expect(songToTrack(song).clusterBrowseServerId).toBe('server-b');
});
it('flattens replayGain into replayGainTrackDb / AlbumDb / Peak', () => {
const song = makeSubsonicSong({
replayGain: {
+1
View File
@@ -25,5 +25,6 @@ export function songToTrack(song: SubsonicSong): Track {
samplingRate: song.samplingRate,
bitDepth: song.bitDepth,
size: song.size,
clusterBrowseServerId: song.clusterBrowseServerId,
};
}
@@ -0,0 +1,109 @@
import { describe, expect, it, vi } from 'vitest';
import type { TFunction } from 'i18next';
import {
buildClusterConnectionTooltip,
buildClusterMemberStatusTooltip,
clusterLedStatusFromDiagnostics,
type ClusterMergeDiagnostics,
} from './clusterMergeStatus';
describe('clusterLedStatusFromDiagnostics', () => {
it('returns disconnected when no members merge', () => {
const diag: ClusterMergeDiagnostics = {
members: [{ serverId: 'a', label: 'A', included: false, reason: 'unreachable' }],
mergeCount: 0,
totalCount: 1,
};
expect(clusterLedStatusFromDiagnostics(diag)).toBe('disconnected');
});
it('returns degraded when some members merge', () => {
const diag: ClusterMergeDiagnostics = {
members: [
{ serverId: 'a', label: 'A', included: true },
{ serverId: 'b', label: 'B', included: false, reason: 'unreachable' },
],
mergeCount: 1,
totalCount: 2,
};
expect(clusterLedStatusFromDiagnostics(diag)).toBe('degraded');
});
it('returns connected when all members merge', () => {
const diag: ClusterMergeDiagnostics = {
members: [{ serverId: 'a', label: 'A', included: true }],
mergeCount: 1,
totalCount: 1,
};
expect(clusterLedStatusFromDiagnostics(diag)).toBe('connected');
});
});
describe('buildClusterMemberStatusTooltip', () => {
const t = vi.fn((key: string, opts?: Record<string, unknown>) => {
if (key === 'cluster.memberStatusAvailable') return `${opts?.name}: ok`;
if (key === 'cluster.memberStatusOffline') return `${opts?.name}: offline`;
if (key === 'cluster.memberStatusIndexing') return `${opts?.name}: indexing`;
return key;
}) as unknown as TFunction;
it('lists every member on separate lines', () => {
const diag: ClusterMergeDiagnostics = {
members: [
{ serverId: 'a', label: 'A', included: true },
{ serverId: 'b', label: 'B', included: false, reason: 'unreachable' },
{ serverId: 'c', label: 'C', included: false, reason: 'indexing' },
],
mergeCount: 1,
totalCount: 3,
};
expect(buildClusterMemberStatusTooltip(t, diag)).toBe(
'A: ok\nB: offline\nC: indexing',
);
});
it('returns empty when there are no members', () => {
expect(buildClusterMemberStatusTooltip(t, { members: [], mergeCount: 0, totalCount: 0 })).toBe('');
});
});
describe('buildClusterConnectionTooltip', () => {
const t = vi.fn((key: string, opts?: Record<string, unknown>) => {
if (key === 'cluster.memberStatusOffline') return `${opts?.name}: offline`;
if (key === 'cluster.connectionTooltipNone') return `None: ${opts?.details}`;
if (key === 'cluster.connectionTooltipPartial') {
return `${opts?.available}/${opts?.total}: ${opts?.details}`;
}
return key;
}) as unknown as TFunction;
it('returns empty when all included', () => {
const diag: ClusterMergeDiagnostics = {
members: [{ serverId: 'a', label: 'A', included: true }],
mergeCount: 1,
totalCount: 1,
};
expect(buildClusterConnectionTooltip(t, diag)).toBe('');
});
it('builds partial tooltip with member details', () => {
const diag: ClusterMergeDiagnostics = {
members: [
{ serverId: 'a', label: 'A', included: true },
{ serverId: 'b', label: 'B', included: false, reason: 'unreachable' },
],
mergeCount: 1,
totalCount: 2,
};
expect(buildClusterConnectionTooltip(t, diag)).toBe('1/2: B: offline');
});
it('builds none tooltip when mergeCount is zero', () => {
const diag: ClusterMergeDiagnostics = {
members: [{ serverId: 'a', label: 'A', included: false, reason: 'unreachable' }],
mergeCount: 0,
totalCount: 1,
};
expect(buildClusterConnectionTooltip(t, diag)).toBe('None: A: offline');
});
});
@@ -1,8 +1,11 @@
import { libraryIsReady } from '../library/libraryReady';
import { ensureConnectUrlResolved } from '../server/serverEndpoint';
import { serverListDisplayLabel } from '../server/serverDisplayName';
import { getClusterMemberProfiles } from './clusterScope';
import { isServerLikelyReachable } from './representative';
import type { ServerCluster } from './types';
import type { TFunction } from 'i18next';
import type { ConnectionStatus } from '../../hooks/useConnectionStatus';
export type ClusterMemberExcludeReason = 'unreachable' | 'indexing';
@@ -22,11 +25,15 @@ export interface ClusterMergeDiagnostics {
/** Per-member merge eligibility (spec §4 exclusion rules). */
export async function getClusterMergeDiagnostics(
cluster: ServerCluster,
options?: { probeMembers?: boolean },
): Promise<ClusterMergeDiagnostics> {
const profiles = getClusterMemberProfiles(cluster);
const members: ClusterMemberStatus[] = [];
let mergeCount = 0;
for (const p of profiles) {
if (options?.probeMembers) {
await ensureConnectUrlResolved(p);
}
const label = serverListDisplayLabel(p, profiles);
if (!isServerLikelyReachable(p.id)) {
members.push({ serverId: p.id, label, included: false, reason: 'unreachable' });
@@ -53,3 +60,47 @@ export function formatExcludedMemberLabels(
})
.join(', ');
}
/** LED color for cluster scope: green = all members merge-ready, yellow = partial, red = none. */
export function clusterLedStatusFromDiagnostics(diag: ClusterMergeDiagnostics): ConnectionStatus {
if (diag.totalCount === 0 || diag.mergeCount === 0) return 'disconnected';
if (diag.mergeCount < diag.totalCount) return 'degraded';
return 'connected';
}
function memberStatusLine(t: TFunction, member: ClusterMemberStatus): string {
if (member.included) {
return t('cluster.memberStatusAvailable', { name: member.label });
}
if (member.reason === 'indexing') {
return t('cluster.memberStatusIndexing', { name: member.label });
}
return t('cluster.memberStatusOffline', { name: member.label });
}
/** Hover tooltip for the cluster connection LED — one line per member. */
export function buildClusterMemberStatusTooltip(
t: TFunction,
diag: ClusterMergeDiagnostics,
): string {
if (diag.members.length === 0) return '';
return diag.members.map(m => memberStatusLine(t, m)).join('\n');
}
/** @deprecated Prefer {@link buildClusterMemberStatusTooltip} for LED hover. */
export function buildClusterConnectionTooltip(
t: TFunction,
diag: ClusterMergeDiagnostics,
): string {
const excluded = diag.members.filter(m => !m.included);
if (excluded.length === 0) return '';
const details = excluded.map(m => memberStatusLine(t, m)).join(' · ');
if (diag.mergeCount === 0) {
return t('cluster.connectionTooltipNone', { details });
}
return t('cluster.connectionTooltipPartial', {
available: diag.mergeCount,
total: diag.totalCount,
details,
});
}
@@ -30,14 +30,14 @@ export function useClusterPlaybackMonitor(): void {
const streamSid = resolveServerIdForIndexKey(ref.serverId);
if (isServerLikelyReachable(streamSid)) return;
const browseId = useAuthStore.getState().activeServerId ?? streamSid;
const track = st.currentTrack;
if (!track) return;
const browseId = track.clusterBrowseServerId ?? useAuthStore.getState().activeServerId ?? streamSid;
void cascadeClusterPlayback(browseId, ref.trackId, streamSid).then(next => {
if (!next) {
usePlayerStore.getState().next(false);
return;
}
const track = st.currentTrack;
if (!track) return;
usePlayerStore.getState().playTrack(
{ ...track, id: next.trackId, clusterBrowseServerId: next.serverId },
undefined,
+9 -7
View File
@@ -47,11 +47,13 @@ export async function getClusterMergeMemberIds(clusterId: string): Promise<strin
const { useAuthStore } = await import('../../store/authStore');
const cluster = useAuthStore.getState().clusters.find(c => c.id === clusterId);
if (!cluster) return [];
const out: string[] = [];
for (const server of getClusterMemberProfiles(cluster)) {
if (!isServerLikelyReachable(server.id)) continue;
if (!(await libraryIsReady(server.id))) continue;
out.push(server.id);
}
return out;
const members = getClusterMemberProfiles(cluster);
const ready = await Promise.all(
members.map(async server => {
if (!isServerLikelyReachable(server.id)) return null;
if (!(await libraryIsReady(server.id))) return null;
return server.id;
}),
);
return ready.filter((id): id is string => id != null);
}