feat: refactoring data fetching

This commit is contained in:
Boris Cherepanov
2026-03-23 15:51:50 +03:00
parent 0df2df0cff
commit 59ab2ce2c2
3 changed files with 169 additions and 93 deletions

View File

@@ -2,26 +2,33 @@ import { useEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from
import './furumi-player.css' import './furumi-player.css'
import { import {
API_ROOT, API_ROOT,
getArtists,
getArtistAlbums,
getAlbumTracks,
getArtistTracks,
searchTracks, searchTracks,
getTrackInfo,
preloadStream, preloadStream,
} from './furumiApi' } 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 { fmt } from './utils'
import { Header } from './components/Header' import { Header } from './components/Header'
import { MainPanel, type Crumb } from './components/MainPanel' import { MainPanel, type Crumb } from './components/MainPanel'
import { PlayerBar } from './components/PlayerBar' import { PlayerBar } from './components/PlayerBar'
import type { QueueItem } from './components/QueueList' import type { QueueItem } from './components/QueueList'
import type { Track } from './types'
export function FurumiPlayer() { 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<Crumb[]>( const [breadcrumbs, setBreadcrumbs] = useState<Crumb[]>(
[], [],
) )
const [libraryLoading, setLibraryLoading] = useState(false)
const [libraryError, setLibraryError] = useState<string | null>(null)
const [libraryItems, setLibraryItems] = useState< const [libraryItems, setLibraryItems] = useState<
Array<{ Array<{
key: string key: string
@@ -115,25 +122,21 @@ export function FurumiPlayer() {
// --- Library navigation --- // --- Library navigation ---
async function showArtists() { async function showArtists() {
setBreadcrumb([{ label: 'Artists', action: showArtists }]) setBreadcrumb([{ label: 'Artists', action: showArtists }])
setLibraryLoading(true) try {
setLibraryError(null) const artists = await dispatch(fetchArtists()).unwrap()
const artists = await getArtists() setLibraryItems(
if (!artists) { artists.map((a) => ({
setLibraryLoading(false) key: `artist:${a.slug}`,
setLibraryError('Error') className: 'file-item dir',
return 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) { async function showArtistAlbums(artistSlug: string, artistName: string) {
@@ -141,42 +144,38 @@ export function FurumiPlayer() {
{ label: 'Artists', action: showArtists }, { label: 'Artists', action: showArtists },
{ label: artistName, action: () => showArtistAlbums(artistSlug, artistName) }, { label: artistName, action: () => showArtistAlbums(artistSlug, artistName) },
]) ])
setLibraryLoading(true) try {
setLibraryError(null) const { albums } = await dispatch(fetchArtistAlbums(artistSlug)).unwrap()
const albums = await getArtistAlbums(artistSlug) const allTracksItem = {
if (!albums) { key: `artist-all:${artistSlug}`,
setLibraryLoading(false) className: 'file-item',
setLibraryError('Error') icon: '▶',
return name: 'Play all tracks',
} nameClassName: 'name',
setLibraryLoading(false) onClick: () => void playAllArtistTracks(artistSlug),
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<HTMLButtonElement>) => {
ev.stopPropagation()
void addAlbumToQueue(a.slug)
},
},
} }
}) const albumItems = albums.map((a) => {
setLibraryItems([allTracksItem, ...albumItems]) 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<HTMLButtonElement>) => {
ev.stopPropagation()
void addAlbumToQueue(a.slug)
},
},
}
})
setLibraryItems([allTracksItem, ...albumItems])
} catch {
// Error is stored in albums.error
}
} }
async function showAlbumTracks( async function showAlbumTracks(
@@ -190,15 +189,9 @@ export function FurumiPlayer() {
{ label: artistName, action: () => showArtistAlbums(artistSlug, artistName) }, { label: artistName, action: () => showArtistAlbums(artistSlug, artistName) },
{ label: albumName }, { label: albumName },
]) ])
setLibraryLoading(true) const result = await dispatch(fetchAlbumTracks(albumSlug))
setLibraryError(null) if (result.meta.requestStatus === 'rejected') return
const tracks = await getAlbumTracks(albumSlug) const { tracks } = result.payload as { albumSlug: string; tracks: Track[] }
if (!tracks) {
setLibraryLoading(false)
setLibraryError('Error')
return
}
setLibraryLoading(false)
const playAlbumItem = { const playAlbumItem = {
key: `album-play:${albumSlug}`, key: `album-play:${albumSlug}`,
className: 'file-item', className: 'file-item',
@@ -208,7 +201,7 @@ export function FurumiPlayer() {
void addAlbumToQueue(albumSlug, true) 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 num = t.track_number ? `${t.track_number}. ` : ''
const dur = t.duration_secs ? fmt(t.duration_secs) : '' const dur = t.duration_secs ? fmt(t.duration_secs) : ''
return { return {
@@ -262,13 +255,24 @@ export function FurumiPlayer() {
} }
async function addAlbumToQueue(albumSlug: string, playFirst?: boolean) { async function addAlbumToQueue(albumSlug: string, playFirst?: boolean) {
const tracks = await getAlbumTracks(albumSlug) const result = await dispatch(fetchAlbumTracks(albumSlug))
if (!tracks || !(tracks as any[]).length) return if (result.meta.requestStatus === 'rejected') return
const list = tracks as any[] const { tracks } = result.payload as { albumSlug: string; tracks: Track[] }
if (!tracks || !tracks.length) return
const list = tracks
let firstIdx = queue.length let firstIdx = queue.length
list.forEach((t) => { list.forEach((t) => {
if (queue.find((q) => q.slug === t.slug)) return 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() updateQueueModel()
if (playFirst || queueIndex === -1) playIndex(firstIdx) if (playFirst || queueIndex === -1) playIndex(firstIdx)
@@ -276,19 +280,19 @@ export function FurumiPlayer() {
} }
async function playAllArtistTracks(artistSlug: string) { async function playAllArtistTracks(artistSlug: string) {
const tracks = await getArtistTracks(artistSlug) const result = await dispatch(fetchArtistTracks(artistSlug))
if (!tracks || !(tracks as any[]).length) return if (result.meta.requestStatus === 'rejected') return
const list = tracks as any[] const { tracks } = result.payload as { artistSlug: string; tracks: Track[] }
if (!tracks || !tracks.length) return
const list = tracks
clearQueue() clearQueue()
list.forEach((t) => { setQueue(list.map((t) => ({
queue.push({ slug: t.slug,
slug: t.slug, title: t.title,
title: t.title, artist: t.artist_name,
artist: t.artist_name, album_slug: t.album_slug,
album_slug: t.album_slug, duration: t.duration_secs,
duration: t.duration_secs, })))
})
})
updateQueueModel() updateQueueModel()
playIndex(0) playIndex(0)
showToast(`Added ${list.length} tracks`) showToast(`Added ${list.length} tracks`)
@@ -609,18 +613,20 @@ export function FurumiPlayer() {
const url = new URL(window.location.href) const url = new URL(window.location.href)
const urlSlug = url.searchParams.get('t') const urlSlug = url.searchParams.get('t')
if (urlSlug) { if (urlSlug) {
const info = await getTrackInfo(urlSlug) try {
if (info) { const { detail } = await dispatch(fetchTrackDetail(urlSlug)).unwrap()
addTrackToQueue( addTrackToQueue(
{ {
slug: (info as any).slug, slug: detail.slug,
title: (info as any).title, title: detail.title,
artist: (info as any).artist_name, artist: detail.artist_name,
album_slug: (info as any).album_slug, album_slug: detail.album_slug,
duration: (info as any).duration_secs, duration: detail.duration_secs,
}, },
true, true,
) )
} catch {
// fetchTrackDetail rejected — track not found or error
} }
} }
void showArtists() 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 ( return (
<div className="furumi-root"> <div className="furumi-root">
<Header <Header

View File

@@ -3,6 +3,7 @@ import { useDispatch, useSelector, type TypedUseSelectorHook } from 'react-redux
import artistsReducer from './slices/artistsSlice' import artistsReducer from './slices/artistsSlice'
import albumsReducer from './slices/albumsSlice' import albumsReducer from './slices/albumsSlice'
import albumTracksReducer from './slices/albumTracksSlice' import albumTracksReducer from './slices/albumTracksSlice'
import artistTracksReducer from './slices/artistTracksSlice'
import trackDetailReducer from './slices/trackDetailSlice' import trackDetailReducer from './slices/trackDetailSlice'
export const store = configureStore({ export const store = configureStore({
@@ -10,6 +11,7 @@ export const store = configureStore({
artists: artistsReducer, artists: artistsReducer,
albums: albumsReducer, albums: albumsReducer,
albumTracks: albumTracksReducer, albumTracks: albumTracksReducer,
artistTracks: artistTracksReducer,
trackDetail: trackDetailReducer, trackDetail: trackDetailReducer,
}, },
}) })

View File

@@ -0,0 +1,54 @@
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'
import type { Track } from '../../types'
import { getArtistTracks } from '../../furumiApi'
export const fetchArtistTracks = createAsyncThunk(
'artistTracks/fetch',
async (artistSlug: string, { rejectWithValue }) => {
const data = await getArtistTracks(artistSlug)
if (data === null) return rejectWithValue('Failed to fetch artist tracks')
return { artistSlug, tracks: data }
},
)
interface ArtistTracksState {
byArtist: Record<string, Track[]>
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