feat: refactoring data fetching
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user