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 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import { useParams, useNavigate } from 'react-router';
|
import { useParams, useNavigate } from 'react-router';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { ScrollArea, IconButton, Button } from '@olly/modern-sk';
|
import { ScrollArea, IconButton, Button, Callout } from '@olly/modern-sk';
|
||||||
import {
|
import {
|
||||||
useGetAlbumQuery,
|
useGetAlbumQuery,
|
||||||
useGetAlbumTracksQuery,
|
useGetAlbumTracksQuery,
|
||||||
@@ -10,6 +10,11 @@ import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
|
|||||||
import { ErrorState } from '../../components/common/ErrorState';
|
import { ErrorState } from '../../components/common/ErrorState';
|
||||||
import { EmptyState } from '../../components/common/EmptyState';
|
import { EmptyState } from '../../components/common/EmptyState';
|
||||||
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
|
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 { setQueue } from '../../store/slices/queue';
|
||||||
import { formatDuration } from '../../lib/format';
|
import { formatDuration } from '../../lib/format';
|
||||||
import { getCoverUrl, getTrackCoverUrl } from '../../api/endpoints/streaming';
|
import { getCoverUrl, getTrackCoverUrl } from '../../api/endpoints/streaming';
|
||||||
@@ -24,15 +29,36 @@ export function AlbumDetailPage() {
|
|||||||
const albumQuery = useGetAlbumQuery(albumId ?? '', { skip: !albumId });
|
const albumQuery = useGetAlbumQuery(albumId ?? '', { skip: !albumId });
|
||||||
const tracksQuery = useGetAlbumTracksQuery(albumId ?? '', { skip: !albumId });
|
const tracksQuery = useGetAlbumTracksQuery(albumId ?? '', { skip: !albumId });
|
||||||
|
|
||||||
if (albumQuery.isLoading || tracksQuery.isLoading) {
|
// Offline fallback: resolve the album + its tracks from the locally-cached
|
||||||
return (
|
// library when the backend is unreachable (same approach as LibraryPage).
|
||||||
<div style={{ padding: '1.5rem' }}>
|
const offline = useIsOffline();
|
||||||
<LoadingSkeleton rows={10} />
|
const localAlbums = useAppSelector(selectLocalAlbums);
|
||||||
</div>
|
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 (
|
||||||
|
<div style={{ padding: '1.5rem' }}>
|
||||||
|
<LoadingSkeleton rows={10} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (offline) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
icon="💿"
|
||||||
|
title={t('album.offline.title')}
|
||||||
|
description={t('album.offline.description')}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<ErrorState
|
<ErrorState
|
||||||
message={t('album.error')}
|
message={t('album.error')}
|
||||||
@@ -40,9 +66,6 @@ export function AlbumDetailPage() {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const album = albumQuery.data;
|
|
||||||
const tracks = tracksQuery.data ?? [];
|
|
||||||
// The album record itself carries no cover; fall back to a track's cover.
|
// The album record itself carries no cover; fall back to a track's cover.
|
||||||
const coverTrack = tracks.find((t) => t.hasCover);
|
const coverTrack = tracks.find((t) => t.hasCover);
|
||||||
const artUrl =
|
const artUrl =
|
||||||
@@ -72,6 +95,11 @@ export function AlbumDetailPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||||
|
{offline && (
|
||||||
|
<div style={{ padding: '0.75rem 1.5rem 0', flexShrink: 0 }}>
|
||||||
|
<Callout variant="info">{t('common.offlineBanner')}</Callout>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
padding: '1.25rem 1.5rem',
|
padding: '1.25rem 1.5rem',
|
||||||
@@ -168,16 +196,17 @@ export function AlbumDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ScrollArea style={{ flex: 1 }}>
|
<ScrollArea style={{ flex: 1 }}>
|
||||||
{tracksQuery.isLoading && <LoadingSkeleton rows={10} />}
|
{tracks.length === 0 && !offline && tracksQuery.isLoading && (
|
||||||
{tracksQuery.isError && (
|
<LoadingSkeleton rows={10} />
|
||||||
|
)}
|
||||||
|
{tracks.length === 0 && !offline && tracksQuery.isError && (
|
||||||
<ErrorState
|
<ErrorState
|
||||||
message={t('album.tracksError')}
|
message={t('album.tracksError')}
|
||||||
onRetry={() => tracksQuery.refetch()}
|
onRetry={() => tracksQuery.refetch()}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!tracksQuery.isLoading &&
|
{tracks.length === 0 &&
|
||||||
!tracksQuery.isError &&
|
(offline || (!tracksQuery.isLoading && !tracksQuery.isError)) && (
|
||||||
tracks.length === 0 && (
|
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon="♫"
|
icon="♫"
|
||||||
title={t('album.empty.title')}
|
title={t('album.empty.title')}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useParams, useNavigate } from 'react-router';
|
import { useParams, useNavigate } from 'react-router';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { ScrollArea, IconButton, Button, Card } from '@olly/modern-sk';
|
import { ScrollArea, IconButton, Button, Card, Callout } from '@olly/modern-sk';
|
||||||
import {
|
import {
|
||||||
useGetArtistQuery,
|
useGetArtistQuery,
|
||||||
useGetArtistAlbumsQuery,
|
useGetArtistAlbumsQuery,
|
||||||
@@ -11,7 +11,13 @@ import { ArtTile } from '../../components/common/ArtTile';
|
|||||||
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
|
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
|
||||||
import { ErrorState } from '../../components/common/ErrorState';
|
import { ErrorState } from '../../components/common/ErrorState';
|
||||||
import { EmptyState } from '../../components/common/EmptyState';
|
import { EmptyState } from '../../components/common/EmptyState';
|
||||||
import { useAppDispatch } from '../../hooks/useAppDispatch';
|
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
|
||||||
|
import { useIsOffline } from '../../hooks/useConnectionStatus';
|
||||||
|
import {
|
||||||
|
selectLocalArtists,
|
||||||
|
selectLocalAlbums,
|
||||||
|
selectLocalTracks,
|
||||||
|
} from '../../store/selectors/localLibrary';
|
||||||
import { setQueue } from '../../store/slices/queue';
|
import { setQueue } from '../../store/slices/queue';
|
||||||
import { formatDuration } from '../../lib/format';
|
import { formatDuration } from '../../lib/format';
|
||||||
import { getCoverUrl } from '../../api/endpoints/streaming';
|
import { getCoverUrl } from '../../api/endpoints/streaming';
|
||||||
@@ -31,15 +37,40 @@ export function ArtistDetailPage() {
|
|||||||
skip: !artistId,
|
skip: !artistId,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (artistQuery.isLoading) {
|
// Offline fallback: resolve the artist + their albums/tracks from the
|
||||||
return (
|
// locally-cached library when the backend is unreachable.
|
||||||
<div style={{ padding: '1.5rem' }}>
|
const offline = useIsOffline();
|
||||||
<LoadingSkeleton rows={10} />
|
const localArtists = useAppSelector(selectLocalArtists);
|
||||||
</div>
|
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 (
|
||||||
|
<div style={{ padding: '1.5rem' }}>
|
||||||
|
<LoadingSkeleton rows={10} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (offline) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
icon="🎤"
|
||||||
|
title={t('artist.offline.title')}
|
||||||
|
description={t('artist.offline.description')}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<ErrorState
|
<ErrorState
|
||||||
message={t('artist.error')}
|
message={t('artist.error')}
|
||||||
@@ -48,10 +79,6 @@ export function ArtistDetailPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const artist = artistQuery.data;
|
|
||||||
const albums = albumsQuery.data ?? [];
|
|
||||||
const tracks = tracksQuery.data ?? [];
|
|
||||||
|
|
||||||
const handlePlayAll = () => {
|
const handlePlayAll = () => {
|
||||||
if (!tracks.length) return;
|
if (!tracks.length) return;
|
||||||
dispatch(
|
dispatch(
|
||||||
@@ -73,6 +100,11 @@ export function ArtistDetailPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||||
|
{offline && (
|
||||||
|
<div style={{ padding: '0.75rem 1.5rem 0', flexShrink: 0 }}>
|
||||||
|
<Callout variant="info">{t('common.offlineBanner')}</Callout>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
padding: '1.25rem 1.5rem',
|
padding: '1.25rem 1.5rem',
|
||||||
@@ -152,13 +184,14 @@ export function ArtistDetailPage() {
|
|||||||
>
|
>
|
||||||
{t('artist.albums')}
|
{t('artist.albums')}
|
||||||
</h2>
|
</h2>
|
||||||
{albumsQuery.isLoading && <LoadingSkeleton rows={3} height={72} />}
|
{albums.length === 0 && !offline && albumsQuery.isLoading && (
|
||||||
{albumsQuery.isError && (
|
<LoadingSkeleton rows={3} height={72} />
|
||||||
|
)}
|
||||||
|
{albums.length === 0 && !offline && albumsQuery.isError && (
|
||||||
<ErrorState onRetry={() => albumsQuery.refetch()} />
|
<ErrorState onRetry={() => albumsQuery.refetch()} />
|
||||||
)}
|
)}
|
||||||
{!albumsQuery.isLoading &&
|
{albums.length === 0 &&
|
||||||
!albumsQuery.isError &&
|
(offline || (!albumsQuery.isLoading && !albumsQuery.isError)) && (
|
||||||
albums.length === 0 && (
|
|
||||||
<p style={{ color: 'var(--color-text-3)', fontSize: '0.875rem' }}>
|
<p style={{ color: 'var(--color-text-3)', fontSize: '0.875rem' }}>
|
||||||
{t('artist.noAlbums')}
|
{t('artist.noAlbums')}
|
||||||
</p>
|
</p>
|
||||||
@@ -193,13 +226,14 @@ export function ArtistDetailPage() {
|
|||||||
>
|
>
|
||||||
{t('artist.tracks')}
|
{t('artist.tracks')}
|
||||||
</h2>
|
</h2>
|
||||||
{tracksQuery.isLoading && <LoadingSkeleton rows={6} />}
|
{tracks.length === 0 && !offline && tracksQuery.isLoading && (
|
||||||
{tracksQuery.isError && (
|
<LoadingSkeleton rows={6} />
|
||||||
|
)}
|
||||||
|
{tracks.length === 0 && !offline && tracksQuery.isError && (
|
||||||
<ErrorState onRetry={() => tracksQuery.refetch()} />
|
<ErrorState onRetry={() => tracksQuery.refetch()} />
|
||||||
)}
|
)}
|
||||||
{!tracksQuery.isLoading &&
|
{tracks.length === 0 &&
|
||||||
!tracksQuery.isError &&
|
(offline || (!tracksQuery.isLoading && !tracksQuery.isError)) && (
|
||||||
tracks.length === 0 && (
|
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon="♫"
|
icon="♫"
|
||||||
title={t('artist.empty.title')}
|
title={t('artist.empty.title')}
|
||||||
|
|||||||
@@ -114,6 +114,10 @@ const en = {
|
|||||||
title: 'No tracks',
|
title: 'No tracks',
|
||||||
description: 'This album has no tracks.',
|
description: 'This album has no tracks.',
|
||||||
},
|
},
|
||||||
|
offline: {
|
||||||
|
title: 'Album not available offline',
|
||||||
|
description: "You're offline and this album isn't cached on this device.",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
artist: {
|
artist: {
|
||||||
type: 'Artist',
|
type: 'Artist',
|
||||||
@@ -127,6 +131,10 @@ const en = {
|
|||||||
title: 'No tracks',
|
title: 'No tracks',
|
||||||
description: 'This artist has no tracks.',
|
description: 'This artist has no tracks.',
|
||||||
},
|
},
|
||||||
|
offline: {
|
||||||
|
title: 'Artist not available offline',
|
||||||
|
description: "You're offline and this artist isn't cached on this device.",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
playlist: {
|
playlist: {
|
||||||
type: 'Playlist',
|
type: 'Playlist',
|
||||||
@@ -229,6 +237,7 @@ const en = {
|
|||||||
retry: 'Retry',
|
retry: 'Retry',
|
||||||
comingSoon: 'Coming soon',
|
comingSoon: 'Coming soon',
|
||||||
back: 'Back',
|
back: 'Back',
|
||||||
|
offlineBanner: "You're offline — showing locally available data, read-only.",
|
||||||
},
|
},
|
||||||
storage: {
|
storage: {
|
||||||
subtitle: 'Everything this instance has tucked away',
|
subtitle: 'Everything this instance has tucked away',
|
||||||
|
|||||||
@@ -116,6 +116,10 @@ const ru: Translations = {
|
|||||||
title: 'Нет треков',
|
title: 'Нет треков',
|
||||||
description: 'В этом альбоме нет треков.',
|
description: 'В этом альбоме нет треков.',
|
||||||
},
|
},
|
||||||
|
offline: {
|
||||||
|
title: 'Альбом недоступен офлайн',
|
||||||
|
description: 'Нет связи, а этот альбом не сохранён на устройстве.',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
artist: {
|
artist: {
|
||||||
type: 'Исполнитель',
|
type: 'Исполнитель',
|
||||||
@@ -129,6 +133,10 @@ const ru: Translations = {
|
|||||||
title: 'Нет треков',
|
title: 'Нет треков',
|
||||||
description: 'У этого исполнителя нет треков.',
|
description: 'У этого исполнителя нет треков.',
|
||||||
},
|
},
|
||||||
|
offline: {
|
||||||
|
title: 'Исполнитель недоступен офлайн',
|
||||||
|
description: 'Нет связи, а этот исполнитель не сохранён на устройстве.',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
playlist: {
|
playlist: {
|
||||||
type: 'Плейлист',
|
type: 'Плейлист',
|
||||||
@@ -231,6 +239,8 @@ const ru: Translations = {
|
|||||||
retry: 'Повторить',
|
retry: 'Повторить',
|
||||||
comingSoon: 'Скоро',
|
comingSoon: 'Скоро',
|
||||||
back: 'Назад',
|
back: 'Назад',
|
||||||
|
offlineBanner:
|
||||||
|
'Нет связи с сервером — показаны локально доступные данные, только для чтения.',
|
||||||
},
|
},
|
||||||
storage: {
|
storage: {
|
||||||
subtitle: 'Всё, что хранит этот инстанс',
|
subtitle: 'Всё, что хранит этот инстанс',
|
||||||
|
|||||||
Reference in New Issue
Block a user