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
@@ -13,6 +13,7 @@ use crate::search::aliased_track_columns;
use crate::store::LibraryStore;
use super::db::ATTACH_ALIAS;
use super::keys::artist_key_from_display_name;
use super::list_albums::list_merged_albums;
use super::merge::{solo_partition_key, DURATION_TOLERANCE_SEC};
use super::priority::{in_list_sql, priority_case_sql};
@@ -96,8 +97,8 @@ fn member_album_pairs(
}
}
let (in_placeholders, mut in_params) = in_list_sql(servers_ordered);
let (priority_sql, mut priority_params) = priority_case_sql("t.server_id", servers_ordered);
let (in_placeholders, in_params) = in_list_sql(servers_ordered);
let (priority_sql, priority_params) = priority_case_sql("t.server_id", servers_ordered);
let sql = format!(
"SELECT DISTINCT t.server_id, t.album_id, ({priority_sql}) AS priority_rank
FROM track t
@@ -110,8 +111,8 @@ fn member_album_pairs(
ORDER BY priority_rank, t.server_id, t.album_id"
);
let mut params: Vec<SqlValue> = Vec::new();
params.append(&mut priority_params);
params.append(&mut in_params);
params.extend(priority_params);
params.extend(in_params);
params.push(SqlValue::Text(merge_key.to_string()));
store.with_read_conn(|conn| {
@@ -231,7 +232,7 @@ fn merged_tracks_for_album_pairs(
if pairs.is_empty() {
return Ok(Vec::new());
}
let (priority_sql, mut priority_params) = priority_case_sql("t.server_id", servers_ordered);
let (priority_sql, priority_params) = priority_case_sql("t.server_id", servers_ordered);
let pair_clauses: Vec<String> = pairs
.iter()
.map(|_| "(t.server_id = ? AND t.album_id = ?)".to_string())
@@ -289,7 +290,7 @@ fn merged_tracks_for_album_pairs(
);
let mut params: Vec<SqlValue> = Vec::new();
params.append(&mut priority_params);
params.extend(priority_params);
for (sid, aid, _) in pairs {
params.push(SqlValue::Text(sid.clone()));
params.push(SqlValue::Text(aid.clone()));
@@ -406,6 +407,9 @@ fn resolve_artist_seed(
let exists: bool = store.with_read_conn(|conn| {
conn.query_row(
"SELECT EXISTS(
SELECT 1 FROM artist ar
WHERE ar.server_id = ?1 AND ar.id = ?2
) OR EXISTS(
SELECT 1 FROM track
WHERE server_id = ?1 AND deleted = 0
AND (artist_id = ?2 OR artist = ?2)
@@ -526,8 +530,8 @@ fn merged_albums_for_artist_key(
if servers_ordered.is_empty() {
return Ok(Vec::new());
}
let (in_placeholders, mut in_params) = in_list_sql(servers_ordered);
let (priority_sql, mut priority_params) = priority_case_sql("t.server_id", servers_ordered);
let (in_placeholders, in_params) = in_list_sql(servers_ordered);
let (priority_sql, priority_params) = priority_case_sql("t.server_id", servers_ordered);
let sql = format!(
"WITH candidates AS (
SELECT
@@ -585,8 +589,8 @@ fn merged_albums_for_artist_key(
ORDER BY COALESCE(a.name, t.album) COLLATE NOCASE",
);
let mut params: Vec<SqlValue> = Vec::new();
params.append(&mut priority_params);
params.append(&mut in_params);
params.extend(priority_params);
params.extend(in_params);
params.push(SqlValue::Text(artist_key.to_string()));
store.with_read_conn(|conn| {
@@ -625,8 +629,8 @@ fn merged_top_tracks_for_artist_key(
if servers_ordered.is_empty() {
return Ok(Vec::new());
}
let (in_placeholders, mut in_params) = in_list_sql(servers_ordered);
let (priority_sql, mut priority_params) = priority_case_sql("t.server_id", servers_ordered);
let (in_placeholders, in_params) = in_list_sql(servers_ordered);
let (priority_sql, priority_params) = priority_case_sql("t.server_id", servers_ordered);
let cols = aliased_track_columns("t");
let sql = format!(
"WITH candidates AS (
@@ -681,8 +685,8 @@ fn merged_top_tracks_for_artist_key(
tol = DURATION_TOLERANCE_SEC,
);
let mut params: Vec<SqlValue> = Vec::new();
params.append(&mut priority_params);
params.append(&mut in_params);
params.extend(priority_params);
params.extend(in_params);
params.push(SqlValue::Text(artist_key.to_string()));
params.push(SqlValue::Integer(limit as i64));
@@ -696,6 +700,165 @@ fn merged_top_tracks_for_artist_key(
.map_err(|e| e.to_string())
}
/// Album rows for an artist when cluster-key merge yields nothing — match the
/// `album` table (and track-only albums) by artist id or display name.
fn fallback_albums_for_artist_scope(
store: &LibraryStore,
servers_ordered: &[String],
artist_ref: &str,
artist_name: &str,
) -> Result<Vec<LibraryAlbumDto>, String> {
if servers_ordered.is_empty() {
return Ok(Vec::new());
}
let (in_placeholders, in_params) = in_list_sql(servers_ordered);
let album_sql = format!(
"SELECT
a.server_id,
a.id,
a.name,
a.artist,
a.artist_id,
a.song_count,
a.duration_sec,
a.year,
a.genre,
a.cover_art_id,
a.starred_at,
a.synced_at,
a.raw_json
FROM album a
WHERE a.server_id IN ({in_placeholders})
AND (a.artist_id = ? OR a.artist = ? OR a.artist = ?)
ORDER BY a.name COLLATE NOCASE, a.server_id",
);
let mut album_params: Vec<SqlValue> = Vec::new();
album_params.extend(in_params.clone());
album_params.push(SqlValue::Text(artist_ref.to_string()));
album_params.push(SqlValue::Text(artist_ref.to_string()));
album_params.push(SqlValue::Text(artist_name.to_string()));
let from_table: Vec<LibraryAlbumDto> = store.with_read_conn(|conn| {
let mut stmt = conn.prepare(&album_sql)?;
let rows = stmt.query_map(rusqlite::params_from_iter(album_params.iter()), map_album_dto_row)?;
rows.collect::<rusqlite::Result<Vec<_>>>()
})?;
if !from_table.is_empty() {
return Ok(from_table);
}
let (priority_sql, priority_params) = priority_case_sql("t.server_id", servers_ordered);
let track_sql = format!(
"WITH picks AS (
SELECT t.server_id, t.album_id, MIN(t.rowid) AS tid
FROM track t
WHERE t.deleted = 0
AND t.server_id IN ({in_placeholders})
AND t.album_id IS NOT NULL AND t.album_id != ''
AND (t.artist_id = ? OR t.artist = ? OR t.artist_id = ?)
GROUP BY t.server_id, t.album_id
)
SELECT
t.server_id,
t.album_id,
COALESCE(a.name, t.album),
COALESCE(a.artist, t.artist),
COALESCE(a.artist_id, t.artist_id),
COALESCE(a.song_count, (
SELECT COUNT(*) FROM track c
WHERE c.server_id = t.server_id AND c.album_id = t.album_id AND c.deleted = 0
)),
COALESCE(a.duration_sec, (
SELECT COALESCE(SUM(c.duration_sec), 0) FROM track c
WHERE c.server_id = t.server_id AND c.album_id = t.album_id AND c.deleted = 0
)),
COALESCE(a.year, t.year),
COALESCE(a.genre, t.genre),
COALESCE(a.cover_art_id, t.cover_art_id),
COALESCE(a.starred_at, t.starred_at),
COALESCE(a.synced_at, t.synced_at),
a.raw_json
FROM picks p
JOIN track t ON t.rowid = p.tid
LEFT JOIN album a ON a.server_id = t.server_id AND a.id = t.album_id
ORDER BY ({priority_sql}), COALESCE(a.name, t.album) COLLATE NOCASE",
);
let mut track_params: Vec<SqlValue> = Vec::new();
track_params.extend(in_params);
track_params.push(SqlValue::Text(artist_ref.to_string()));
track_params.push(SqlValue::Text(artist_name.to_string()));
track_params.push(SqlValue::Text(artist_ref.to_string()));
track_params.extend(priority_params);
store.with_read_conn(|conn| {
let mut stmt = conn.prepare(&track_sql)?;
let rows = stmt.query_map(rusqlite::params_from_iter(track_params.iter()), map_album_dto_row)?;
rows.collect::<rusqlite::Result<Vec<_>>>()
})
.map_err(|e| e.to_string())
}
fn map_album_dto_row(r: &rusqlite::Row<'_>) -> rusqlite::Result<LibraryAlbumDto> {
let raw: Option<String> = r.get(12)?;
Ok(LibraryAlbumDto {
server_id: r.get(0)?,
id: r.get(1)?,
name: r.get(2)?,
artist: r.get(3)?,
artist_id: r.get(4)?,
song_count: r.get(5)?,
duration_sec: r.get(6)?,
year: r.get(7)?,
genre: r.get(8)?,
cover_art_id: r.get(9)?,
starred_at: r.get(10)?,
synced_at: r.get(11)?,
raw_json: raw
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or(Value::Null),
})
}
fn fallback_top_tracks_for_artist_scope(
store: &LibraryStore,
servers_ordered: &[String],
artist_ref: &str,
artist_name: &str,
limit: u32,
) -> Result<Vec<LibraryTrackDto>, String> {
if servers_ordered.is_empty() {
return Ok(Vec::new());
}
let (in_placeholders, in_params) = in_list_sql(servers_ordered);
let (priority_sql, priority_params) = priority_case_sql("t.server_id", servers_ordered);
let sql = format!(
"SELECT {cols}
FROM track t
WHERE t.deleted = 0
AND t.server_id IN ({in_placeholders})
AND (t.artist_id = ? OR t.artist = ? OR t.artist_id = ?)
ORDER BY ({priority_sql}), COALESCE(t.play_count, 0) DESC, t.title COLLATE NOCASE
LIMIT ?",
cols = aliased_track_columns("t"),
);
let mut params: Vec<SqlValue> = Vec::new();
params.extend(priority_params);
params.extend(in_params);
params.push(SqlValue::Text(artist_ref.to_string()));
params.push(SqlValue::Text(artist_name.to_string()));
params.push(SqlValue::Text(artist_ref.to_string()));
params.push(SqlValue::Integer(limit as i64));
store.with_read_conn(|conn| {
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| {
repos::row_to_track_row(r).map(|row| LibraryTrackDto::from_row(&row))
})?;
rows.collect::<rusqlite::Result<Vec<_>>>()
})
.map_err(|e| e.to_string())
}
pub fn cluster_artist_detail(
store: &LibraryStore,
servers_ordered: &[String],
@@ -708,8 +871,6 @@ pub fn cluster_artist_detail(
return Err("artist not found in cluster scope".to_string());
};
let artist_key = artist_key_for_pair(store, &seed_sid, &seed_aid)?;
let owner_sid = seed_sid.clone();
let owner_aid = seed_aid.clone();
@@ -717,17 +878,39 @@ pub fn cluster_artist_detail(
.or_else(|| fallback_artist_from_tracks(store, &owner_sid, &owner_aid).ok().flatten())
.ok_or_else(|| "artist metadata missing".to_string())?;
let albums = if let Some(ref key) = artist_key {
let mut artist_key = artist_key_for_pair(store, &owner_sid, &owner_aid)?;
if artist_key.is_none() {
artist_key = artist_key_from_display_name(&artist.name);
}
let mut albums = if let Some(ref key) = artist_key {
merged_albums_for_artist_key(store, servers_ordered, key)?
} else {
Vec::new()
};
if albums.is_empty() {
albums = fallback_albums_for_artist_scope(
store,
servers_ordered,
&owner_aid,
&artist.name,
)?;
}
let top_tracks = if let Some(ref key) = artist_key {
let mut top_tracks = if let Some(ref key) = artist_key {
merged_top_tracks_for_artist_key(store, servers_ordered, key, TOP_TRACKS_LIMIT)?
} else {
Vec::new()
};
if top_tracks.is_empty() {
top_tracks = fallback_top_tracks_for_artist_scope(
store,
servers_ordered,
&owner_aid,
&artist.name,
TOP_TRACKS_LIMIT,
)?;
}
Ok(LibraryClusterArtistDetailResponse {
artist,
@@ -73,6 +73,15 @@ pub fn compute_track_cluster_keys(
})
}
/// Stable cross-server artist merge key from display name alone (spec §2.5).
pub fn artist_key_from_display_name(name: &str) -> Option<String> {
let norm = norm_field(name);
if norm.is_empty() {
return None;
}
Some(hash_parts(&[&norm], None))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -110,6 +119,13 @@ mod tests {
assert!(compute_track_cluster_keys(None, None, "Title", "Album").is_none());
}
#[test]
fn artist_key_from_display_name_matches_track_derived_key() {
let from_track = compute_track_cluster_keys(Some("Pink Floyd"), None, "x", "y").unwrap();
let from_name = artist_key_from_display_name("Pink Floyd").unwrap();
assert_eq!(from_track.artist_key, from_name);
}
#[test]
fn punctuation_insensitive_cluster_key() {
let a = compute_track_cluster_keys(Some("Pink Floyd"), None, "Time", "Dark Side").unwrap();
@@ -24,63 +24,95 @@ pub fn list_merged_artists(
}
let limit = limit.clamp(1, PAGE_LIMIT_MAX);
let offset = offset.min(i32::MAX as u32) as i32;
let (in_placeholders, mut in_params) = in_list_sql(servers_ordered);
let (priority_sql, mut priority_params) = priority_case_sql("t.server_id", servers_ordered);
let (in_placeholders, in_params) = in_list_sql(servers_ordered);
let (priority_sql, priority_params) = priority_case_sql("c.server_id", servers_ordered);
// Artist-first catalog: one row per artist (not per track), then merge by
// `artist_key`. The previous track-scan + window over every row was O(tracks)
// with correlated album counts and blocked the Artists browse page on large libs.
let sql = format!(
"WITH candidates AS (
"WITH artist_keys AS (
SELECT
t.rowid AS tid,
t.server_id,
COALESCE(NULLIF(t.artist_id, ''), t.artist) AS artist_ref,
k.artist_key,
({priority_sql}) AS priority_rank
MIN(k.artist_key) AS artist_key
FROM track t
LEFT JOIN {ATTACH_ALIAS}.track_cluster_key k
INNER JOIN {ATTACH_ALIAS}.track_cluster_key k
ON k.server_id = t.server_id AND k.track_id = t.id
WHERE t.deleted = 0
AND t.server_id IN ({in_placeholders})
AND COALESCE(t.artist, '') != ''
AND k.artist_key IS NOT NULL
GROUP BY t.server_id, artist_ref
),
partitioned AS (
SELECT c.tid,
track_artists AS (
SELECT
t.server_id,
COALESCE(NULLIF(t.artist_id, ''), t.artist) AS id,
MAX(t.artist) AS name,
COUNT(DISTINCT CASE
WHEN t.album_id IS NOT NULL AND t.album_id != '' THEN t.album_id
END) AS album_count,
MAX(t.synced_at) AS synced_at,
CAST(NULL AS TEXT) AS raw_json
FROM track t
WHERE t.deleted = 0
AND t.server_id IN ({in_placeholders})
AND COALESCE(t.artist, '') != ''
AND NOT EXISTS (
SELECT 1 FROM artist ar
WHERE ar.server_id = t.server_id
AND ar.id = COALESCE(NULLIF(t.artist_id, ''), t.artist)
)
GROUP BY t.server_id, COALESCE(NULLIF(t.artist_id, ''), t.artist)
),
catalog AS (
SELECT ar.server_id, ar.id, ar.name, ar.album_count, ar.synced_at, ar.raw_json
FROM artist ar
WHERE ar.server_id IN ({in_placeholders})
UNION ALL
SELECT server_id, id, name, album_count, synced_at, raw_json
FROM track_artists
),
candidates AS (
SELECT
c.server_id,
c.id,
c.name,
c.album_count,
c.synced_at,
c.raw_json,
({priority_sql}) AS priority_rank,
CASE
WHEN c.artist_key IS NULL THEN 'solo:' || c.server_id || ':' || c.artist_ref
ELSE c.artist_key
END AS merge_key,
c.priority_rank
FROM candidates c
WHEN ak.artist_key IS NOT NULL THEN ak.artist_key
ELSE 'solo:' || c.server_id || ':' || c.id
END AS merge_key
FROM catalog c
LEFT JOIN artist_keys ak
ON ak.server_id = c.server_id AND ak.artist_ref = c.id
),
winners AS (
SELECT tid,
SELECT
server_id,
id,
name,
album_count,
synced_at,
raw_json,
ROW_NUMBER() OVER (PARTITION BY merge_key ORDER BY priority_rank) AS rn
FROM partitioned
FROM candidates
)
SELECT
t.server_id,
COALESCE(NULLIF(t.artist_id, ''), t.artist),
COALESCE(ar.name, t.artist),
COALESCE(ar.album_count, (
SELECT COUNT(DISTINCT c.album_id) FROM track c
WHERE c.server_id = t.server_id
AND c.deleted = 0
AND c.album_id IS NOT NULL
AND (c.artist_id = t.artist_id OR c.artist = t.artist)
)),
COALESCE(ar.synced_at, t.synced_at),
ar.raw_json
FROM winners w
JOIN track t ON t.rowid = w.tid
LEFT JOIN artist ar ON ar.server_id = t.server_id
AND ar.id = COALESCE(NULLIF(t.artist_id, ''), t.artist)
WHERE w.rn = 1
ORDER BY COALESCE(ar.name, t.artist) COLLATE NOCASE, t.server_id
LIMIT ? OFFSET ?",
SELECT server_id, id, name, album_count, synced_at, raw_json
FROM winners
WHERE rn = 1
ORDER BY name COLLATE NOCASE, server_id
LIMIT ? OFFSET ?",
);
let mut params: Vec<SqlValue> = Vec::new();
params.append(&mut priority_params);
params.append(&mut in_params);
params.extend(in_params.iter().cloned());
params.extend(in_params.iter().cloned());
params.extend(in_params.iter().cloned());
params.extend(priority_params);
params.push(SqlValue::Integer(limit as i64));
params.push(SqlValue::Integer(offset as i64));
@@ -166,4 +198,28 @@ mod tests {
assert_eq!(resp.artists.len(), 1);
assert_eq!(resp.artists[0].server_id, "s1");
}
#[test]
fn prefers_artist_table_album_count_when_present() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[track("s1", "t1", "Band"), track("s2", "t2", "Band")])
.unwrap();
rebuild_all_cluster_keys(&store).unwrap();
store
.with_conn("test", |conn| {
conn.execute(
"INSERT INTO artist (server_id, id, name, album_count, synced_at, raw_json) \
VALUES ('s1', 'art-s1', 'Band', 3, 1, '{}'), \
('s2', 'art-s2', 'Band', 2, 1, '{}')",
[],
)
})
.unwrap();
let resp = list_merged_artists(&store, &["s1".into(), "s2".into()], 50, 0).unwrap();
assert_eq!(resp.artists.len(), 1);
assert_eq!(resp.artists[0].server_id, "s1");
assert_eq!(resp.artists[0].album_count, Some(3));
}
}
+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);
}