feat: refactoring data fetching

This commit is contained in:
Boris Cherepanov
2026-03-23 15:51:50 +03:00
parent f0e1bbc7f8
commit 3491c52793
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 {
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<Crumb[]>(
[],
)
const [libraryLoading, setLibraryLoading] = useState(false)
const [libraryError, setLibraryError] = useState<string | null>(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<HTMLButtonElement>) => {
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<HTMLButtonElement>) => {
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 (
<div className="furumi-root">
<Header

View File

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