import { useState } from 'react'; import { useNavigate } from 'react-router'; import { useTranslation } from 'react-i18next'; import { Tabs, TabsList, TabsContent, ScrollArea, Card, TextField, Callout, } from '@olly/modern-sk'; import { useGetTracksQuery, useGetAlbumsQuery, useGetArtistsQuery, } from '../../api/endpoints/library'; import { TrackRow } from '../../components/track/TrackRow'; import { LoadingSkeleton } from '../../components/common/LoadingSkeleton'; import { EmptyState } from '../../components/common/EmptyState'; import { ErrorState } from '../../components/common/ErrorState'; import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch'; import { useIsOffline } from '../../hooks/useConnectionStatus'; import { selectLocalTracks, selectLocalAlbums, selectLocalArtists, } from '../../store/selectors/localLibrary'; import { setQueue } from '../../store/slices/queue'; import type { Track, Album, Artist } from '../../api/types'; import { getCoverUrl } from '../../api/endpoints/streaming'; import { formatDuration } from '../../lib/format'; import { useDebounce } from 'use-debounce'; /** Case-insensitive substring match used for client-side search while offline * (the server can't run the query, so we filter the locally-cached library). */ function matchTrack(tr: Track, q: string): boolean { return ( tr.title.toLowerCase().includes(q) || tr.artistName.toLowerCase().includes(q) || tr.albumTitle.toLowerCase().includes(q) ); } const matchAlbum = (a: Album, q: string): boolean => a.title.toLowerCase().includes(q) || a.artistName.toLowerCase().includes(q); const matchArtist = (a: Artist, q: string): boolean => a.name.toLowerCase().includes(q); export function LibraryPage() { const { t } = useTranslation(); const navigate = useNavigate(); const dispatch = useAppDispatch(); const [tab, setTab] = useState('tracks'); const [search, setSearch] = useState(''); const [debouncedSearch] = useDebounce(search, 300); const tracksQuery = useGetTracksQuery( debouncedSearch ? { search } : undefined, ); const albumsQuery = useGetAlbumsQuery( debouncedSearch ? { search } : undefined, ); const artistsQuery = useGetArtistsQuery( debouncedSearch ? { search } : undefined, ); // Offline fallback: when the backend is unreachable, compose the library from // whatever the RTKQ cache holds locally (rehydrated last-seen + this session), // filtering client-side since the server can't run the search. const offline = useIsOffline(); const localTracks = useAppSelector(selectLocalTracks); const localAlbums = useAppSelector(selectLocalAlbums); const localArtists = useAppSelector(selectLocalArtists); const q = debouncedSearch.trim().toLowerCase(); // Live server data wins; offline we fall back to the locally-composed list. const tracksToShow = tracksQuery.data?.items ?? (offline ? (q ? localTracks.filter((tr) => matchTrack(tr, q)) : localTracks) : undefined); const albumsToShow = albumsQuery.data?.items ?? (offline ? (q ? localAlbums.filter((a) => matchAlbum(a, q)) : localAlbums) : undefined); const artistsToShow = artistsQuery.data?.items ?? (offline ? (q ? localArtists.filter((a) => matchArtist(a, q)) : localArtists) : undefined); const handlePlayAll = (tracks: Track[]) => { dispatch( setQueue({ entries: tracks.map((t) => ({ trackId: t.id, title: t.title, artistName: t.artistName, albumTitle: t.albumTitle, durationMs: t.durationMs, albumArtUrl: t.albumArtUrl, })), source: 'manual', sourceName: t('library.title'), }), ); }; return (