feat(library): offline fallback for album & artist detail pages
Docker Build & Publish / Prune old image versions (push) Has been cancelled
Docker Build & Publish / build (push) Has been cancelled
Docker Build & Publish / push (push) Has been cancelled

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:
Senko-san
2026-06-14 01:49:39 +03:00
parent 8a0e6782ad
commit 94361899a8
4 changed files with 123 additions and 41 deletions
+46 -17
View File
@@ -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')}
+58 -24
View File
@@ -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')}
+9
View File
@@ -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',
+10
View File
@@ -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: 'Всё, что хранит этот инстанс',