diff --git a/furumi-node-player/client/src/FurumiPlayer.tsx b/furumi-node-player/client/src/FurumiPlayer.tsx index 3d614df..96c41d2 100644 --- a/furumi-node-player/client/src/FurumiPlayer.tsx +++ b/furumi-node-player/client/src/FurumiPlayer.tsx @@ -2,26 +2,33 @@ import { useEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from import './furumi-player.css' import { API_ROOT, - getArtists, - getArtistAlbums, - getAlbumTracks, - getArtistTracks, searchTracks, - getTrackInfo, preloadStream, } from './furumiApi' +import { useAppDispatch, useAppSelector } from './store' +import { fetchArtists } from './store/slices/artistsSlice' +import { fetchArtistAlbums } from './store/slices/albumsSlice' +import { fetchArtistTracks } from './store/slices/artistTracksSlice' +import { fetchAlbumTracks } from './store/slices/albumTracksSlice' +import { fetchTrackDetail } from './store/slices/trackDetailSlice' import { fmt } from './utils' import { Header } from './components/Header' import { MainPanel, type Crumb } from './components/MainPanel' import { PlayerBar } from './components/PlayerBar' import type { QueueItem } from './components/QueueList' +import type { Track } from './types' export function FurumiPlayer() { + const dispatch = useAppDispatch() + const artistsLoading = useAppSelector((s) => s.artists.loading) + const artistsError = useAppSelector((s) => s.artists.error) + const albumsLoading = useAppSelector((s) => s.albums.loading) + const albumsError = useAppSelector((s) => s.albums.error) + const albumTracksLoading = useAppSelector((s) => s.albumTracks.loading) + const albumTracksError = useAppSelector((s) => s.albumTracks.error) const [breadcrumbs, setBreadcrumbs] = useState( [], ) - const [libraryLoading, setLibraryLoading] = useState(false) - const [libraryError, setLibraryError] = useState(null) const [libraryItems, setLibraryItems] = useState< Array<{ key: string @@ -115,25 +122,21 @@ export function FurumiPlayer() { // --- Library navigation --- async function showArtists() { setBreadcrumb([{ label: 'Artists', action: showArtists }]) - setLibraryLoading(true) - setLibraryError(null) - const artists = await getArtists() - if (!artists) { - setLibraryLoading(false) - setLibraryError('Error') - return + try { + const artists = await dispatch(fetchArtists()).unwrap() + setLibraryItems( + artists.map((a) => ({ + key: `artist:${a.slug}`, + className: 'file-item dir', + icon: '👤', + name: a.name, + detail: `${a.album_count} albums`, + onClick: () => void showArtistAlbums(a.slug, a.name), + })), + ) + } catch { + // Error is stored in artists.error } - setLibraryLoading(false) - setLibraryItems( - (artists as any[]).map((a) => ({ - key: `artist:${a.slug}`, - className: 'file-item dir', - icon: '👤', - name: a.name, - detail: `${a.album_count} albums`, - onClick: () => void showArtistAlbums(a.slug, a.name), - })), - ) } async function showArtistAlbums(artistSlug: string, artistName: string) { @@ -141,42 +144,38 @@ export function FurumiPlayer() { { label: 'Artists', action: showArtists }, { label: artistName, action: () => showArtistAlbums(artistSlug, artistName) }, ]) - setLibraryLoading(true) - setLibraryError(null) - const albums = await getArtistAlbums(artistSlug) - if (!albums) { - setLibraryLoading(false) - setLibraryError('Error') - return - } - setLibraryLoading(false) - const allTracksItem = { - key: `artist-all:${artistSlug}`, - className: 'file-item', - icon: '▶', - name: 'Play all tracks', - nameClassName: 'name', - onClick: () => void playAllArtistTracks(artistSlug), - } - const albumItems = (albums as any[]).map((a) => { - const year = a.year ? ` (${a.year})` : '' - return { - key: `album:${a.slug}`, - className: 'file-item dir', - icon: '💿', - name: `${a.name}${year}`, - detail: `${a.track_count} tracks`, - onClick: () => void showAlbumTracks(a.slug, a.name, artistSlug, artistName), - button: { - title: 'Add album to queue', - onClick: (ev: ReactMouseEvent) => { - ev.stopPropagation() - void addAlbumToQueue(a.slug) - }, - }, + try { + const { albums } = await dispatch(fetchArtistAlbums(artistSlug)).unwrap() + const allTracksItem = { + key: `artist-all:${artistSlug}`, + className: 'file-item', + icon: '▶', + name: 'Play all tracks', + nameClassName: 'name', + onClick: () => void playAllArtistTracks(artistSlug), } - }) - setLibraryItems([allTracksItem, ...albumItems]) + const albumItems = albums.map((a) => { + const year = a.year ? ` (${a.year})` : '' + return { + key: `album:${a.slug}`, + className: 'file-item dir', + icon: '💿', + name: `${a.name}${year}`, + detail: `${a.track_count} tracks`, + onClick: () => void showAlbumTracks(a.slug, a.name, artistSlug, artistName), + button: { + title: 'Add album to queue', + onClick: (ev: ReactMouseEvent) => { + ev.stopPropagation() + void addAlbumToQueue(a.slug) + }, + }, + } + }) + setLibraryItems([allTracksItem, ...albumItems]) + } catch { + // Error is stored in albums.error + } } async function showAlbumTracks( @@ -190,15 +189,9 @@ export function FurumiPlayer() { { label: artistName, action: () => showArtistAlbums(artistSlug, artistName) }, { label: albumName }, ]) - setLibraryLoading(true) - setLibraryError(null) - const tracks = await getAlbumTracks(albumSlug) - if (!tracks) { - setLibraryLoading(false) - setLibraryError('Error') - return - } - setLibraryLoading(false) + const result = await dispatch(fetchAlbumTracks(albumSlug)) + if (result.meta.requestStatus === 'rejected') return + const { tracks } = result.payload as { albumSlug: string; tracks: Track[] } const playAlbumItem = { key: `album-play:${albumSlug}`, className: 'file-item', @@ -208,7 +201,7 @@ export function FurumiPlayer() { void addAlbumToQueue(albumSlug, true) }, } - const trackItems = (tracks as any[]).map((t) => { + const trackItems = tracks.map((t) => { const num = t.track_number ? `${t.track_number}. ` : '' const dur = t.duration_secs ? fmt(t.duration_secs) : '' return { @@ -262,13 +255,24 @@ export function FurumiPlayer() { } async function addAlbumToQueue(albumSlug: string, playFirst?: boolean) { - const tracks = await getAlbumTracks(albumSlug) - if (!tracks || !(tracks as any[]).length) return - const list = tracks as any[] + const result = await dispatch(fetchAlbumTracks(albumSlug)) + if (result.meta.requestStatus === 'rejected') return + const { tracks } = result.payload as { albumSlug: string; tracks: Track[] } + if (!tracks || !tracks.length) return + const list = tracks let firstIdx = queue.length list.forEach((t) => { if (queue.find((q) => q.slug === t.slug)) return - setQueue((q) => [...q, t]) + setQueue((q) => [ + ...q, + { + slug: t.slug, + title: t.title, + artist: t.artist_name, + album_slug: t.album_slug, + duration: t.duration_secs, + }, + ]) }) updateQueueModel() if (playFirst || queueIndex === -1) playIndex(firstIdx) @@ -276,19 +280,19 @@ export function FurumiPlayer() { } async function playAllArtistTracks(artistSlug: string) { - const tracks = await getArtistTracks(artistSlug) - if (!tracks || !(tracks as any[]).length) return - const list = tracks as any[] + const result = await dispatch(fetchArtistTracks(artistSlug)) + if (result.meta.requestStatus === 'rejected') return + const { tracks } = result.payload as { artistSlug: string; tracks: Track[] } + if (!tracks || !tracks.length) return + const list = tracks clearQueue() - list.forEach((t) => { - queue.push({ - slug: t.slug, - title: t.title, - artist: t.artist_name, - album_slug: t.album_slug, - duration: t.duration_secs, - }) - }) + setQueue(list.map((t) => ({ + slug: t.slug, + title: t.title, + artist: t.artist_name, + album_slug: t.album_slug, + duration: t.duration_secs, + }))) updateQueueModel() playIndex(0) showToast(`Added ${list.length} tracks`) @@ -609,18 +613,20 @@ export function FurumiPlayer() { const url = new URL(window.location.href) const urlSlug = url.searchParams.get('t') if (urlSlug) { - const info = await getTrackInfo(urlSlug) - if (info) { + try { + const { detail } = await dispatch(fetchTrackDetail(urlSlug)).unwrap() addTrackToQueue( { - slug: (info as any).slug, - title: (info as any).title, - artist: (info as any).artist_name, - album_slug: (info as any).album_slug, - duration: (info as any).duration_secs, + slug: detail.slug, + title: detail.title, + artist: detail.artist_name, + album_slug: detail.album_slug, + duration: detail.duration_secs, }, true, ) + } catch { + // fetchTrackDetail rejected — track not found or error } } void showArtists() @@ -633,6 +639,20 @@ export function FurumiPlayer() { } }, []) + const libraryLoading = + breadcrumbs.length === 1 + ? artistsLoading + : breadcrumbs.length === 2 + ? albumsLoading + : albumTracksLoading + + const libraryError = + breadcrumbs.length === 1 + ? artistsError + : breadcrumbs.length === 2 + ? albumsError + : albumTracksError + return (
{ + const data = await getArtistTracks(artistSlug) + if (data === null) return rejectWithValue('Failed to fetch artist tracks') + return { artistSlug, tracks: data } + }, +) + +interface ArtistTracksState { + byArtist: Record + loading: boolean + error: string | null +} + +const initialState: ArtistTracksState = { + byArtist: {}, + loading: false, + error: null, +} + +const artistTracksSlice = createSlice({ + name: 'artistTracks', + initialState, + reducers: { + clearArtistTracks(state) { + state.byArtist = {} + state.error = null + }, + }, + extraReducers: (builder) => { + builder + .addCase(fetchArtistTracks.pending, (state) => { + state.loading = true + state.error = null + }) + .addCase(fetchArtistTracks.fulfilled, (state, action) => { + state.loading = false + state.byArtist[action.payload.artistSlug] = action.payload.tracks + state.error = null + }) + .addCase(fetchArtistTracks.rejected, (state, action) => { + state.loading = false + state.error = action.payload as string ?? 'Unknown error' + }) + }, +}) + +export const { clearArtistTracks } = artistTracksSlice.actions +export default artistTracksSlice.reducer