updated server management when its offline
This commit is contained in:
+9
-5
@@ -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<string | null>(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
|
||||
}
|
||||
|
||||
@@ -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<ServerConfig, 'host' | 'port' | 'apiKey' | 'ssl' | 'forceApiKeyInQuery'>) {
|
||||
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, 'id' | 'lastTest'>) => Server
|
||||
@@ -44,6 +56,7 @@ function saveServers(servers: Server[]) {
|
||||
|
||||
export function ServersProvider({ children }: { children: React.ReactNode }) {
|
||||
const [servers, setServers] = useState<Server[]>(() => loadServers())
|
||||
const [verifiedConnectivitySignatures, setVerifiedConnectivitySignatures] = useState<Record<string, string>>({})
|
||||
const [activeServerId, setActiveServerIdState] = useState<string | null>(() => {
|
||||
try {
|
||||
return localStorage.getItem(ACTIVE_KEY)
|
||||
@@ -51,6 +64,7 @@ export function ServersProvider({ children }: { children: React.ReactNode }) {
|
||||
return null
|
||||
}
|
||||
})
|
||||
const connectivityRequestsRef = useRef<Record<string, Promise<ConnectivityResult>>>({})
|
||||
|
||||
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<Server, 'id' | 'lastTest'>) => {
|
||||
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 (
|
||||
<ServersContext.Provider value={value}>
|
||||
|
||||
+144
-119
@@ -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<Record<string, number[]>>({})
|
||||
const trackCacheRef = React.useRef<Record<string, Track>>({})
|
||||
const albumTracksRef = React.useRef<Record<string, Track[]>>({})
|
||||
const playMetadataAbortRef = useRef<AbortController | null>(null)
|
||||
const persistTimeoutRef = useRef<number | null>(null)
|
||||
const detailsAbortRef = useRef<AbortController | null>(null)
|
||||
const longPressTimerRef = useRef<number | null>(null)
|
||||
const longPressTriggeredRef = useRef(false)
|
||||
const [albumFilter, setAlbumFilter] = useState<string | null>(null)
|
||||
const [artistFilter, setArtistFilter] = useState<string | null>(null)
|
||||
const [detailsTrack, setDetailsTrack] = useState<Track | null>(null)
|
||||
const [detailsData, setDetailsData] = useState<HydrusFileDetails | null>(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<string, unknown> }
|
||||
) => (
|
||||
<TableCell align={options?.align} sx={{ width: options?.width, ...(options?.sx || {}) }}>
|
||||
<TableSortLabel
|
||||
active={sortBy === field}
|
||||
direction={sortBy === field ? sortDirection : 'asc'}
|
||||
onClick={() => handleSortRequest(field)}
|
||||
>
|
||||
{label}
|
||||
</TableSortLabel>
|
||||
</TableCell>
|
||||
)
|
||||
|
||||
const renderEntryHeaderCell = (
|
||||
label: string,
|
||||
field: EntrySortField,
|
||||
options?: { width?: string; align?: 'left' | 'right'; sx?: Record<string, unknown> }
|
||||
) => (
|
||||
<TableCell align={options?.align} sx={{ width: options?.width, ...(options?.sx || {}) }}>
|
||||
<TableSortLabel
|
||||
active={sortBy === field}
|
||||
direction={sortBy === field ? sortDirection : 'asc'}
|
||||
onClick={() => handleSortRequest(field)}
|
||||
>
|
||||
{label}
|
||||
</TableSortLabel>
|
||||
</TableCell>
|
||||
)
|
||||
|
||||
const renderTrackTable = (tracks: Track[], options?: { showAlbum?: boolean; showFileIdFallback?: boolean }) => (
|
||||
isCompactTableLayout ? (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25 }}>
|
||||
@@ -1064,11 +1107,11 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
|
||||
<Table size="small" sx={{ width: '100%', tableLayout: 'fixed' }}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell sx={{ width: '38%' }}>Track</TableCell>
|
||||
<TableCell sx={{ width: '22%', display: { xs: 'none', md: 'table-cell' } }}>Artist</TableCell>
|
||||
<TableCell sx={{ width: '22%', display: { xs: 'none', sm: 'table-cell' } }}>Album</TableCell>
|
||||
<TableCell sx={{ width: '12%', display: { xs: 'none', lg: 'table-cell' } }}>Server</TableCell>
|
||||
<TableCell align="right" sx={{ width: '6%' }}>ID</TableCell>
|
||||
{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' })}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
@@ -1126,8 +1169,8 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
|
||||
<Table size="small" sx={{ width: '100%', tableLayout: 'fixed' }}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell sx={{ width: '70%' }}>{kind === 'album' ? 'Album' : 'Artist'}</TableCell>
|
||||
<TableCell align="right" sx={{ width: '15%' }}>Items</TableCell>
|
||||
{renderEntryHeaderCell(kind === 'album' ? 'Album' : 'Artist', 'name', { width: '70%' })}
|
||||
{renderEntryHeaderCell('Items', 'count', { width: '15%', align: 'right' })}
|
||||
<TableCell align="right" sx={{ width: '15%', display: { xs: 'none', sm: 'table-cell' } }}>Servers</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
@@ -1187,6 +1230,12 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{hasServers && healthChecksComplete && onlineServerIds.length === 0 && (
|
||||
<Alert severity="info" sx={{ mt: 2 }}>
|
||||
No Hydrus servers are currently online. Cached results stay hidden until a server passes a connection test.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mt: 2 }}>
|
||||
{error}
|
||||
@@ -1194,6 +1243,8 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
|
||||
)}
|
||||
|
||||
<Box sx={{ mt: 2, mb: 2, display: 'flex', gap: 1, flexWrap: 'nowrap', alignItems: 'center', overflowX: 'auto', pb: 0.5 }}>
|
||||
{showToolbarSortControls && (
|
||||
<>
|
||||
<FormControl size="small" sx={{ minWidth: 132, flexShrink: 0 }}>
|
||||
<InputLabel id="library-sort-by-label">Sort</InputLabel>
|
||||
<Select labelId="library-sort-by-label" value={sortBy} label="Sort by" onChange={(event) => setSortBy(event.target.value as SortField)}>
|
||||
@@ -1209,11 +1260,13 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
|
||||
<MenuItem value="desc">Desc</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</>
|
||||
)}
|
||||
<Chip
|
||||
size="small"
|
||||
sx={{ flexShrink: 0, ml: 'auto' }}
|
||||
label={isTrackLikeView
|
||||
? `${albumFilter ? sortedFilteredAlbumTracks.length : artistFilter ? sortedFilteredArtistTracks.length : sortedCurrentTrackResults.length} items`
|
||||
? `${sortedCurrentTrackResults.length} items`
|
||||
: `${view === 'albums' ? sortedAlbums.length : sortedArtists.length} items`}
|
||||
/>
|
||||
</Box>
|
||||
@@ -1221,35 +1274,7 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
|
||||
<Box>
|
||||
{(view === 'tracks' || view === 'text' || view === 'data') && (
|
||||
<>
|
||||
{albumFilter ? (
|
||||
<Box>
|
||||
<Box sx={{ mb: 2, display: 'flex', alignItems: { xs: 'flex-start', sm: 'center' }, justifyContent: 'space-between', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Typography variant="h6">Album: {albumFilter}</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Button size="small" onClick={() => setAlbumFilter(null)} sx={{ mr: 1 }}>Clear</Button>
|
||||
{hasAlbumsView && <Button size="small" onClick={() => { setView('albums'); onQueryChange(albumFilter || '') }}>Open in Albums</Button>}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{effectiveDisplayMode === 'table'
|
||||
? renderTrackTable(visibleFilteredAlbumTracks, { showAlbum: true })
|
||||
: renderTrackGrid(visibleFilteredAlbumTracks, { showAlbum: true })}
|
||||
</Box>
|
||||
) : artistFilter ? (
|
||||
<Box>
|
||||
<Box sx={{ mb: 2, display: 'flex', alignItems: { xs: 'flex-start', sm: 'center' }, justifyContent: 'space-between', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Typography variant="h6">Artist: {artistFilter}</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Button size="small" onClick={() => setArtistFilter(null)} sx={{ mr: 1 }}>Clear</Button>
|
||||
{hasArtistsView && <Button size="small" onClick={() => { setView('artists'); onQueryChange(artistFilter || '') }}>Open in Artists</Button>}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{effectiveDisplayMode === 'table'
|
||||
? renderTrackTable(visibleFilteredArtistTracks, { showAlbum: true })
|
||||
: renderTrackGrid(visibleFilteredArtistTracks, { showAlbum: true })}
|
||||
</Box>
|
||||
) : 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
|
||||
<Box sx={{ mb: 1, display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||
{albumNames.map((a) => {
|
||||
const count = g.tracks.filter((t: any) => (t.album || '') === a).length
|
||||
return <Chip key={a} label={`${a} (${count})`} clickable onClick={() => { setArtistFilter(null); onQueryChange(''); setAlbumFilter(a); setView('tracks') }} />
|
||||
return <Chip key={a} label={`${a} (${count})`} clickable onClick={() => openAlbumEntry(a)} />
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
@@ -1342,7 +1367,7 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{loading && (view === 'tracks' || view === 'text' || view === 'data') && currentTrackResults.length === 0 && !albumFilter && !artistFilter && (
|
||||
{loading && (view === 'tracks' || view === 'text' || view === 'data') && currentTrackResults.length === 0 && (
|
||||
<Alert severity="info" sx={{ mt: 2 }}>
|
||||
Loading {sectionConfig.views.find((item) => item.id === view)?.label.toLowerCase() || sectionConfig.label.toLowerCase()}...
|
||||
</Alert>
|
||||
|
||||
Reference in New Issue
Block a user