From 94361899a8cb6c7f89657bca36cdc8dd715e8d1a Mon Sep 17 00:00:00 2001 From: Senko-san Date: Sun, 14 Jun 2026 01:49:39 +0300 Subject: [PATCH] feat(library): offline fallback for album & artist detail pages Extend the offline-library behaviour to the detail screens: with the backend unreachable, both pages resolve their entity + tracks/albums from the locally-cached library (reusing the `selectLocal*` selectors, filtered by id) instead of showing a retry-only error. - album detail: album + tracks from cache; offline banner; "not available offline" state when the album isn't cached; inner track states no longer error over locally-available tracks - artist detail: artist + discography + tracks from cache; same treatment - i18n: `common.offlineBanner`, `album.offline.*`, `artist.offline.*` (en + ru) Co-Authored-By: Claude Opus 4.8 --- src/features/album-detail/AlbumDetailPage.tsx | 63 ++++++++++---- .../artist-detail/ArtistDetailPage.tsx | 82 +++++++++++++------ src/i18n/locales/en.ts | 9 ++ src/i18n/locales/ru.ts | 10 +++ 4 files changed, 123 insertions(+), 41 deletions(-) diff --git a/src/features/album-detail/AlbumDetailPage.tsx b/src/features/album-detail/AlbumDetailPage.tsx index 2044886..bb2390b 100644 --- a/src/features/album-detail/AlbumDetailPage.tsx +++ b/src/features/album-detail/AlbumDetailPage.tsx @@ -1,6 +1,6 @@ import { useParams, useNavigate } from 'react-router'; import { useTranslation } from 'react-i18next'; -import { ScrollArea, IconButton, Button } from '@olly/modern-sk'; +import { ScrollArea, IconButton, Button, Callout } from '@olly/modern-sk'; import { useGetAlbumQuery, useGetAlbumTracksQuery, @@ -10,6 +10,11 @@ import { LoadingSkeleton } from '../../components/common/LoadingSkeleton'; import { ErrorState } from '../../components/common/ErrorState'; import { EmptyState } from '../../components/common/EmptyState'; import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch'; +import { useIsOffline } from '../../hooks/useConnectionStatus'; +import { + selectLocalAlbums, + selectLocalTracks, +} from '../../store/selectors/localLibrary'; import { setQueue } from '../../store/slices/queue'; import { formatDuration } from '../../lib/format'; import { getCoverUrl, getTrackCoverUrl } from '../../api/endpoints/streaming'; @@ -24,15 +29,36 @@ export function AlbumDetailPage() { const albumQuery = useGetAlbumQuery(albumId ?? '', { skip: !albumId }); const tracksQuery = useGetAlbumTracksQuery(albumId ?? '', { skip: !albumId }); - if (albumQuery.isLoading || tracksQuery.isLoading) { - return ( -
- -
- ); - } + // Offline fallback: resolve the album + its tracks from the locally-cached + // library when the backend is unreachable (same approach as LibraryPage). + const offline = useIsOffline(); + const localAlbums = useAppSelector(selectLocalAlbums); + const localTracks = useAppSelector(selectLocalTracks); - if (albumQuery.isError) { + const album = + albumQuery.data ?? + (offline ? localAlbums.find((a) => a.id === albumId) : undefined); + const tracks = + tracksQuery.data ?? + (offline ? localTracks.filter((tr) => tr.albumId === albumId) : []); + + if (!album) { + if (albumQuery.isLoading && !offline) { + return ( +
+ +
+ ); + } + if (offline) { + return ( + + ); + } return ( ); } - - const album = albumQuery.data; - const tracks = tracksQuery.data ?? []; // The album record itself carries no cover; fall back to a track's cover. const coverTrack = tracks.find((t) => t.hasCover); const artUrl = @@ -72,6 +95,11 @@ export function AlbumDetailPage() { return (
+ {offline && ( +
+ {t('common.offlineBanner')} +
+ )}
- {tracksQuery.isLoading && } - {tracksQuery.isError && ( + {tracks.length === 0 && !offline && tracksQuery.isLoading && ( + + )} + {tracks.length === 0 && !offline && tracksQuery.isError && ( tracksQuery.refetch()} /> )} - {!tracksQuery.isLoading && - !tracksQuery.isError && - tracks.length === 0 && ( + {tracks.length === 0 && + (offline || (!tracksQuery.isLoading && !tracksQuery.isError)) && ( - -
- ); - } + // Offline fallback: resolve the artist + their albums/tracks from the + // locally-cached library when the backend is unreachable. + const offline = useIsOffline(); + const localArtists = useAppSelector(selectLocalArtists); + const localAlbums = useAppSelector(selectLocalAlbums); + const localTracks = useAppSelector(selectLocalTracks); - if (artistQuery.isError || !artistQuery.data) { + const artist = + artistQuery.data ?? + (offline ? localArtists.find((a) => a.id === artistId) : undefined); + const albums = + albumsQuery.data ?? + (offline ? localAlbums.filter((a) => a.artistId === artistId) : []); + const tracks = + tracksQuery.data ?? + (offline ? localTracks.filter((tr) => tr.artistId === artistId) : []); + + if (!artist) { + if (artistQuery.isLoading && !offline) { + return ( +
+ +
+ ); + } + if (offline) { + return ( + + ); + } return ( { if (!tracks.length) return; dispatch( @@ -73,6 +100,11 @@ export function ArtistDetailPage() { return (
+ {offline && ( +
+ {t('common.offlineBanner')} +
+ )}
{t('artist.albums')} - {albumsQuery.isLoading && } - {albumsQuery.isError && ( + {albums.length === 0 && !offline && albumsQuery.isLoading && ( + + )} + {albums.length === 0 && !offline && albumsQuery.isError && ( albumsQuery.refetch()} /> )} - {!albumsQuery.isLoading && - !albumsQuery.isError && - albums.length === 0 && ( + {albums.length === 0 && + (offline || (!albumsQuery.isLoading && !albumsQuery.isError)) && (

{t('artist.noAlbums')}

@@ -193,13 +226,14 @@ export function ArtistDetailPage() { > {t('artist.tracks')} - {tracksQuery.isLoading && } - {tracksQuery.isError && ( + {tracks.length === 0 && !offline && tracksQuery.isLoading && ( + + )} + {tracks.length === 0 && !offline && tracksQuery.isError && ( tracksQuery.refetch()} /> )} - {!tracksQuery.isLoading && - !tracksQuery.isError && - tracks.length === 0 && ( + {tracks.length === 0 && + (offline || (!tracksQuery.isLoading && !tracksQuery.isError)) && (