diff --git a/src/App.tsx b/src/App.tsx index ea090bb..0e1694c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -324,9 +324,10 @@ async function embedDownloadMetadata(blob: Blob, track: Track, details?: HydrusF } function StartupLibraryCacheSync() { - const { servers, updateServer } = useServers() + const { servers, onlineServerIds, healthChecksComplete, updateServer } = useServers() const lastSyncSignatureRef = useRef(null) const syncSignature = useMemo(() => servers + .filter((server) => onlineServerIds.includes(server.id)) .map((server) => [ server.id, server.host, @@ -335,15 +336,18 @@ function StartupLibraryCacheSync() { server.ssl, server.forceApiKeyInQuery, ].join('|')) - .join(','), [servers]) + .join(','), [onlineServerIds, servers]) useEffect(() => { - if (!servers.length || !syncSignature || lastSyncSignatureRef.current === syncSignature) return + if (!healthChecksComplete || !servers.length || !syncSignature || lastSyncSignatureRef.current === syncSignature) return lastSyncSignatureRef.current = syncSignature let cancelled = false + const targetServerIds = onlineServerIds.filter((serverId) => servers.some((server) => server.id === serverId)) - void syncLibraryCache(servers) + if (targetServerIds.length === 0) return + + void syncLibraryCache(servers, { targetServerIds }) .then((result) => { if (cancelled) return @@ -358,7 +362,7 @@ function StartupLibraryCacheSync() { return () => { cancelled = true } - }, [servers, syncSignature, updateServer]) + }, [healthChecksComplete, onlineServerIds, servers, syncSignature, updateServer]) return null } diff --git a/src/context/ServersContext.tsx b/src/context/ServersContext.tsx index 49b03b9..b7be7dd 100644 --- a/src/context/ServersContext.tsx +++ b/src/context/ServersContext.tsx @@ -1,4 +1,4 @@ -import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react' +import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' import type { ServerConfig, ConnectivityResult } from '../api/hydrusClient' import { HydrusClient, makeId } from '../api/hydrusClient' import type { ServerSyncSummary } from '../types' @@ -11,8 +11,20 @@ export type Server = ServerConfig & { syncSummary?: ServerSyncSummary | null } +function buildConnectivitySignature(server: Pick) { + return [ + server.host?.trim().toLowerCase() || '', + server.port ?? '', + server.apiKey || '', + server.ssl ? '1' : '0', + server.forceApiKeyInQuery ? '1' : '0', + ].join('|') +} + type ServersContextType = { servers: Server[] + onlineServerIds: string[] + healthChecksComplete: boolean activeServerId: string | null setActiveServerId: (id: string | null) => void addServer: (s: Omit) => Server @@ -44,6 +56,7 @@ function saveServers(servers: Server[]) { export function ServersProvider({ children }: { children: React.ReactNode }) { const [servers, setServers] = useState(() => loadServers()) + const [verifiedConnectivitySignatures, setVerifiedConnectivitySignatures] = useState>({}) const [activeServerId, setActiveServerIdState] = useState(() => { try { return localStorage.getItem(ACTIVE_KEY) @@ -51,6 +64,7 @@ export function ServersProvider({ children }: { children: React.ReactNode }) { return null } }) + const connectivityRequestsRef = useRef>>({}) useEffect(() => saveServers(servers), [servers]) @@ -102,25 +116,91 @@ export function ServersProvider({ children }: { children: React.ReactNode }) { const removeServer = useCallback((id: string) => { setServers((prev) => prev.filter((s) => s.id !== id)) + setVerifiedConnectivitySignatures((prev) => { + if (!(id in prev)) return prev + const next = { ...prev } + delete next[id] + return next + }) if (activeServerId === id) setActiveServerId(null) }, [activeServerId, setActiveServerId]) + const runPersistedConnectivityTest = useCallback((server: Server) => { + const signature = buildConnectivitySignature(server) + const requestKey = `${server.id}|${signature}` + const existingRequest = connectivityRequestsRef.current[requestKey] + if (existingRequest) return existingRequest + + const request = (async () => { + const client = new HydrusClient(server) + const res = await client.testConnectivity() + updateServer(server.id, { lastTest: { ...res, timestamp: Date.now() } }) + setVerifiedConnectivitySignatures((prev) => { + if (prev[server.id] === signature) return prev + return { ...prev, [server.id]: signature } + }) + return res + })() + + connectivityRequestsRef.current[requestKey] = request + + void request.finally(() => { + delete connectivityRequestsRef.current[requestKey] + }) + + return request + }, [updateServer]) + + useEffect(() => { + const activeServerIds = new Set(servers.map((server) => server.id)) + + setVerifiedConnectivitySignatures((prev) => { + const nextEntries = Object.entries(prev).filter(([serverId]) => activeServerIds.has(serverId)) + if (nextEntries.length === Object.keys(prev).length) return prev + return Object.fromEntries(nextEntries) + }) + + Object.keys(connectivityRequestsRef.current).forEach((requestKey) => { + const [serverId] = requestKey.split('|') + if (!activeServerIds.has(serverId)) { + delete connectivityRequestsRef.current[requestKey] + } + }) + + for (const server of servers) { + const signature = buildConnectivitySignature(server) + if (verifiedConnectivitySignatures[server.id] === signature) continue + void runPersistedConnectivityTest(server) + } + }, [servers, runPersistedConnectivityTest, verifiedConnectivitySignatures]) + const testServerById = useCallback(async (id: string) => { const server = servers.find((s) => s.id === id) if (!server) return { ok: false, message: 'Server not found' } as ConnectivityResult - const client = new HydrusClient(server) - const res = await client.testConnectivity() - updateServer(id, { lastTest: { ...res, timestamp: Date.now() } }) - return res - }, [servers, updateServer]) + return runPersistedConnectivityTest(server) + }, [runPersistedConnectivityTest, servers]) const testServerConfig = useCallback(async (cfg: Omit) => { const client = new HydrusClient(cfg as ServerConfig) return client.testConnectivity() }, []) + const healthChecksComplete = useMemo(() => servers.every((server) => { + const signature = buildConnectivitySignature(server) + return verifiedConnectivitySignatures[server.id] === signature + }), [servers, verifiedConnectivitySignatures]) + + const onlineServerIds = useMemo(() => servers + .filter((server) => { + const signature = buildConnectivitySignature(server) + return verifiedConnectivitySignatures[server.id] === signature && server.lastTest?.ok === true + }) + .map((server) => server.id), [servers, verifiedConnectivitySignatures]) + const value = useMemo(() => ({ servers, + onlineServerIds, + healthChecksComplete, activeServerId, setActiveServerId, addServer, @@ -128,7 +208,7 @@ export function ServersProvider({ children }: { children: React.ReactNode }) { removeServer, testServerById, testServerConfig, - }), [servers, activeServerId, setActiveServerId, addServer, updateServer, removeServer, testServerById, testServerConfig]) + }), [servers, onlineServerIds, healthChecksComplete, activeServerId, setActiveServerId, addServer, updateServer, removeServer, testServerById, testServerConfig]) return ( diff --git a/src/pages/Library.tsx b/src/pages/Library.tsx index 5243618..dc55c13 100644 --- a/src/pages/Library.tsx +++ b/src/pages/Library.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useMemo, useRef, useState } from 'react' -import { Alert, Box, Button, Card, CardActionArea, CardContent, CardMedia, Chip, Dialog, DialogActions, DialogContent, DialogTitle, FormControl, Grid, InputLabel, MenuItem, Paper, Select, Tab, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Tabs, Typography, useMediaQuery } from '@mui/material' +import { Alert, Box, Button, Card, CardActionArea, CardContent, CardMedia, Chip, Dialog, DialogActions, DialogContent, DialogTitle, FormControl, Grid, InputLabel, MenuItem, Paper, Select, Tab, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TableSortLabel, Tabs, Typography, useMediaQuery } from '@mui/material' import { useTheme } from '@mui/material/styles' import DownloadIcon from '@mui/icons-material/Download' import { useServers } from '../context/ServersContext' @@ -229,19 +229,17 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr const theme = useTheme() const isCompactTableLayout = useMediaQuery(theme.breakpoints.down('sm')) - const { servers } = useServers() + const { servers, onlineServerIds, healthChecksComplete } = useServers() const hasServers = servers.length > 0 + const onlineServerIdsKey = useMemo(() => [...onlineServerIds].sort().join(','), [onlineServerIds]) const serverCacheKey = buildLibraryCacheKey(servers) const searchCacheRef = React.useRef>({}) const trackCacheRef = React.useRef>({}) - const albumTracksRef = React.useRef>({}) const playMetadataAbortRef = useRef(null) const persistTimeoutRef = useRef(null) const detailsAbortRef = useRef(null) const longPressTimerRef = useRef(null) const longPressTriggeredRef = useRef(false) - const [albumFilter, setAlbumFilter] = useState(null) - const [artistFilter, setArtistFilter] = useState(null) const [detailsTrack, setDetailsTrack] = useState(null) const [detailsData, setDetailsData] = useState(null) const [detailsOpen, setDetailsOpen] = useState(false) @@ -273,10 +271,6 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr return bestValue || null } - function normalizeNamespaceValue(value?: string | null) { - return (value || '').trim().toLocaleLowerCase() - } - function matchesMediaSection(track: Track, section: MediaSection) { if (section === 'all') return true if (track.mediaKind) return track.mediaKind === section @@ -501,6 +495,47 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr }) } + function formatNamespacedSearchValue(value: string) { + const trimmed = value.trim() + if (!trimmed) return '' + + const escaped = trimmed + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + + return /[,"]/.test(trimmed) ? `"${escaped}"` : escaped + } + + function getNamespacedQueryValue(rawQuery: string, namespace: 'album' | 'artist') { + const clauses = splitSearchClauses(rawQuery) + + for (let index = clauses.length - 1; index >= 0; index -= 1) { + const namespaceMatch = clauses[index].match(SEARCH_NAMESPACE_PATTERN) + if (!namespaceMatch) continue + if (namespaceMatch[1].toLowerCase() !== namespace) continue + + const normalized = normalizeSearchPattern(namespaceMatch[2]) + return normalized || null + } + + return null + } + + function applyLibraryQueryFilter(namespace: 'album' | 'artist', value: string) { + const formattedValue = formatNamespacedSearchValue(value) + const clauses = splitSearchClauses(query) + const retainedClauses = clauses.filter((clause) => { + const namespaceMatch = clause.match(SEARCH_NAMESPACE_PATTERN) + if (!namespaceMatch) return true + + const clauseNamespace = namespaceMatch[1].toLowerCase() + return clauseNamespace !== 'album' && clauseNamespace !== 'artist' + }) + + const nextQuery = [...retainedClauses, `${namespace}:${formattedValue}`].join(', ') + onQueryChange(nextQuery) + } + function getTrackExtension(track: Track, details?: HydrusFileDetails | null) { if (details?.extension) return details.extension const match = track.url.match(/\.([a-z0-9]{1,10})(?:[?#]|$)/i) @@ -523,20 +558,6 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr schedulePersistLibraryCache() } - function addTrackToAlbumCache(track: Track) { - if (!track.album) return - const cacheKey = getTrackCacheKey(track.serverId, track.fileId) - if (!cacheKey) return - - const currentTracks = albumTracksRef.current[track.album] || [] - const existingIndex = currentTracks.findIndex((candidate) => getTrackCacheKey(candidate.serverId, candidate.fileId) === cacheKey) - - if (existingIndex >= 0) currentTracks[existingIndex] = { ...currentTracks[existingIndex], ...track } - else currentTracks.push(track) - - albumTracksRef.current[track.album] = currentTracks - } - function schedulePersistLibraryCache() { if (!serverCacheKey || typeof window === 'undefined') return @@ -614,7 +635,24 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr if (!hasServers || !serverCacheKey) { trackCacheRef.current = {} searchCacheRef.current = {} - albumTracksRef.current = {} + setResults([]) + setError(null) + setLoading(false) + return + } + + if (!healthChecksComplete) { + trackCacheRef.current = {} + searchCacheRef.current = {} + setResults([]) + setError(null) + setLoading(true) + return + } + + if (onlineServerIds.length === 0) { + trackCacheRef.current = {} + searchCacheRef.current = {} setResults([]) setError(null) setLoading(false) @@ -626,15 +664,16 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr if (cancelled) return trackCacheRef.current = {} - albumTracksRef.current = {} searchCacheRef.current = snapshot?.searchCache || {} + const onlineServerIdSet = new Set(onlineServerIds) let localCounter = Date.now() - const hydratedTracks = (snapshot?.tracks || []).map((track) => ({ ...track, id: ++localCounter })) + const hydratedTracks = (snapshot?.tracks || []) + .filter((track) => track.serverId && onlineServerIdSet.has(track.serverId)) + .map((track) => ({ ...track, id: ++localCounter })) for (const track of hydratedTracks) { const cacheKey = getTrackCacheKey(track.serverId, track.fileId) if (cacheKey) trackCacheRef.current[cacheKey] = track - addTrackToAlbumCache(track) } if (!cancelled) { @@ -645,7 +684,6 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr if (cancelled) return trackCacheRef.current = {} searchCacheRef.current = {} - albumTracksRef.current = {} setResults([]) } finally { if (!cancelled) { @@ -681,7 +719,7 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr window.removeEventListener(LIBRARY_CACHE_SYNC_EVENT, handleCacheSyncEvent as EventListener) } } - }, [hasServers, serverCacheKey]) + }, [hasServers, healthChecksComplete, onlineServerIdsKey, serverCacheKey]) useEffect(() => { syncingSectionViewRef.current = true @@ -721,23 +759,9 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr }) }, [sortBy, sortDirection]) - useEffect(() => { - if (view !== 'tracks') { - setAlbumFilter(null) - setArtistFilter(null) - } - }, [view]) - - useEffect(() => { - if (query.trim().length > 0) { - setAlbumFilter(null) - setArtistFilter(null) - } - }, [query]) - useEffect(() => { setVisibleCount(RESULTS_PAGE_SIZE) - }, [view, query, albumFilter, artistFilter]) + }, [view, query]) useEffect(() => { return () => { @@ -790,7 +814,6 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr if (nextTrack !== track) { setResults((prev) => prev.map((r) => (r.id === track.id ? { ...r, ...nextTrack } : r))) cacheTracks([nextTrack]) - addTrackToAlbumCache(nextTrack) track = nextTrack } } @@ -819,8 +842,6 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr } const handleDetailsTagSearch = (tag: string) => { - setAlbumFilter(null) - setArtistFilter(null) setView('tracks') onQueryChange(tag) closeDetails() @@ -938,28 +959,17 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr return visibleGroups } - const filteredAlbumTracks = useMemo(() => { - if (!albumFilter) return [] - return filterTracksForSection(albumTracksRef.current[albumFilter] || results.filter((track) => track.album === albumFilter), mediaSection) - }, [albumFilter, mediaSection, results]) - - const filteredArtistTracks = useMemo(() => { - if (!artistFilter) return [] - return filterTracksForSection(Object.values(trackCacheRef.current), mediaSection) - .filter((track) => normalizeNamespaceValue(track.artist) === normalizeNamespaceValue(artistFilter)) - }, [artistFilter, mediaSection, results]) - const sectionTracks = useMemo(() => filterTracksForSection(results, mediaSection), [mediaSection, results]) + const activeAlbumQuery = useMemo(() => getNamespacedQueryValue(query, 'album'), [query]) + const activeArtistQuery = useMemo(() => getNamespacedQueryValue(query, 'artist'), [query]) const queryMatchedSectionTracks = useMemo(() => sectionTracks.filter((track) => matchesTrackSearch(track, query)), [query, sectionTracks]) const currentTrackResults = useMemo(() => filterTracksForView(queryMatchedSectionTracks, view), [queryMatchedSectionTracks, view]) - const albums = useMemo(() => buildNamespaceEntriesFromTracks(queryMatchedSectionTracks, 'album'), [queryMatchedSectionTracks]) - const artists = useMemo(() => buildNamespaceEntriesFromTracks(queryMatchedSectionTracks, 'artist'), [queryMatchedSectionTracks]) + const albums = useMemo(() => buildNamespaceEntriesFromTracks(sectionTracks, 'album'), [sectionTracks]) + const artists = useMemo(() => buildNamespaceEntriesFromTracks(sectionTracks, 'artist'), [sectionTracks]) const baseArtistGroups = useMemo(() => { - if (!hasArtistsView || albumFilter || artistFilter) return [] + if (!hasArtistsView || activeAlbumQuery || activeArtistQuery) return [] return buildArtistGroupsFromTracks(currentTrackResults) - }, [albumFilter, artistFilter, currentTrackResults, hasArtistsView]) - const sortedFilteredAlbumTracks = useMemo(() => sortTracks(filteredAlbumTracks, sortBy as TrackSortField, sortDirection), [filteredAlbumTracks, sortBy, sortDirection]) - const sortedFilteredArtistTracks = useMemo(() => sortTracks(filteredArtistTracks, sortBy as TrackSortField, sortDirection), [filteredArtistTracks, sortBy, sortDirection]) + }, [activeAlbumQuery, activeArtistQuery, currentTrackResults, hasArtistsView]) const sortedCurrentTrackResults = useMemo(() => sortTracks(currentTrackResults, sortBy as TrackSortField, sortDirection), [currentTrackResults, sortBy, sortDirection]) const sortedArtistGroups = useMemo(() => { if (!hasArtistsView) return [] @@ -970,23 +980,18 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr }, [baseArtistGroups, hasArtistsView, sortDirection]) const sortedAlbums = useMemo(() => sortEntries(albums, sortBy as EntrySortField, sortDirection), [albums, sortBy, sortDirection]) const sortedArtists = useMemo(() => sortEntries(artists, sortBy as EntrySortField, sortDirection), [artists, sortBy, sortDirection]) - const shouldShowGroupedArtists = effectiveDisplayMode === 'grid' && !albumFilter && !artistFilter && hasArtistsView && sortedArtistGroups.length > 0 && sortBy === 'artist' - const visibleFilteredAlbumTracks = useMemo(() => sortedFilteredAlbumTracks.slice(0, visibleCount), [sortedFilteredAlbumTracks, visibleCount]) - const visibleFilteredArtistTracks = useMemo(() => sortedFilteredArtistTracks.slice(0, visibleCount), [sortedFilteredArtistTracks, visibleCount]) + const shouldShowGroupedArtists = effectiveDisplayMode === 'grid' && !activeAlbumQuery && !activeArtistQuery && hasArtistsView && sortedArtistGroups.length > 0 && sortBy === 'artist' const totalArtistGroupTracks = useMemo(() => sortedArtistGroups.reduce((count, group) => count + group.tracks.length, 0), [sortedArtistGroups]) const visibleArtistGroups = useMemo(() => getVisibleArtistGroups(sortedArtistGroups, visibleCount), [sortedArtistGroups, visibleCount]) const visibleResults = useMemo(() => sortedCurrentTrackResults.slice(0, visibleCount), [sortedCurrentTrackResults, visibleCount]) const visibleAlbums = useMemo(() => sortedAlbums.slice(0, visibleCount), [sortedAlbums, visibleCount]) const visibleArtists = useMemo(() => sortedArtists.slice(0, visibleCount), [sortedArtists, visibleCount]) + const showToolbarSortControls = effectiveDisplayMode === 'table' && isCompactTableLayout const canLoadMore = isTrackLikeView - ? albumFilter - ? visibleCount < sortedFilteredAlbumTracks.length - : artistFilter - ? visibleCount < sortedFilteredArtistTracks.length - : shouldShowGroupedArtists - ? visibleCount < totalArtistGroupTracks - : visibleCount < sortedCurrentTrackResults.length + ? shouldShowGroupedArtists + ? visibleCount < totalArtistGroupTracks + : visibleCount < sortedCurrentTrackResults.length : view === 'albums' ? visibleCount < sortedAlbums.length : visibleCount < sortedArtists.length @@ -1026,19 +1031,57 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr ) const openAlbumEntry = (name: string) => { - setArtistFilter(null) - onQueryChange('') - setAlbumFilter(name) setView('tracks') + applyLibraryQueryFilter('album', name) } const openArtistEntry = (name: string) => { - setAlbumFilter(null) - onQueryChange('') - setArtistFilter(name) setView('tracks') + applyLibraryQueryFilter('artist', name) } + const handleSortRequest = (field: SortField) => { + if (sortBy === field) { + setSortDirection((prev) => prev === 'asc' ? 'desc' : 'asc') + return + } + + setSortBy(field) + setSortDirection('asc') + } + + const renderTrackHeaderCell = ( + label: string, + field: TrackSortField, + options?: { width?: string; align?: 'left' | 'right'; sx?: Record } + ) => ( + + handleSortRequest(field)} + > + {label} + + + ) + + const renderEntryHeaderCell = ( + label: string, + field: EntrySortField, + options?: { width?: string; align?: 'left' | 'right'; sx?: Record } + ) => ( + + handleSortRequest(field)} + > + {label} + + + ) + const renderTrackTable = (tracks: Track[], options?: { showAlbum?: boolean; showFileIdFallback?: boolean }) => ( isCompactTableLayout ? ( @@ -1064,11 +1107,11 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr - Track - Artist - Album - Server - ID + {renderTrackHeaderCell('Track', 'title', { width: '38%' })} + {renderTrackHeaderCell('Artist', 'artist', { width: '22%', sx: { display: { xs: 'none', md: 'table-cell' } } })} + {renderTrackHeaderCell('Album', 'album', { width: '22%', sx: { display: { xs: 'none', sm: 'table-cell' } } })} + {renderTrackHeaderCell('Server', 'server', { width: '12%', sx: { display: { xs: 'none', lg: 'table-cell' } } })} + {renderTrackHeaderCell('ID', 'fileId', { width: '6%', align: 'right' })} @@ -1126,8 +1169,8 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
- {kind === 'album' ? 'Album' : 'Artist'} - Items + {renderEntryHeaderCell(kind === 'album' ? 'Album' : 'Artist', 'name', { width: '70%' })} + {renderEntryHeaderCell('Items', 'count', { width: '15%', align: 'right' })} Servers @@ -1187,6 +1230,12 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr )} + {hasServers && healthChecksComplete && onlineServerIds.length === 0 && ( + + No Hydrus servers are currently online. Cached results stay hidden until a server passes a connection test. + + )} + {error && ( {error} @@ -1194,6 +1243,8 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr )} + {showToolbarSortControls && ( + <> Sort + + )} @@ -1221,35 +1274,7 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr {(view === 'tracks' || view === 'text' || view === 'data') && ( <> - {albumFilter ? ( - - - Album: {albumFilter} - - - {hasAlbumsView && } - - - - {effectiveDisplayMode === 'table' - ? renderTrackTable(visibleFilteredAlbumTracks, { showAlbum: true }) - : renderTrackGrid(visibleFilteredAlbumTracks, { showAlbum: true })} - - ) : artistFilter ? ( - - - Artist: {artistFilter} - - - {hasArtistsView && } - - - - {effectiveDisplayMode === 'table' - ? renderTrackTable(visibleFilteredArtistTracks, { showAlbum: true }) - : renderTrackGrid(visibleFilteredArtistTracks, { showAlbum: true })} - - ) : shouldShowGroupedArtists ? ( + {shouldShowGroupedArtists ? ( visibleArtistGroups.map((g) => { const albumNames = Array.from(new Set(g.tracks.map((t: any) => (t.album || '').trim()).filter(Boolean))) return ( @@ -1260,7 +1285,7 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr {albumNames.map((a) => { const count = g.tracks.filter((t: any) => (t.album || '') === a).length - return { setArtistFilter(null); onQueryChange(''); setAlbumFilter(a); setView('tracks') }} /> + return openAlbumEntry(a)} /> })} )} @@ -1342,7 +1367,7 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr )} - {loading && (view === 'tracks' || view === 'text' || view === 'data') && currentTrackResults.length === 0 && !albumFilter && !artistFilter && ( + {loading && (view === 'tracks' || view === 'text' || view === 'data') && currentTrackResults.length === 0 && ( Loading {sectionConfig.views.find((item) => item.id === view)?.label.toLowerCase() || sectionConfig.label.toLowerCase()}...